Live github (issue-comment poster) and claude_code (file-drop) transports, plus GithubIntake (labeled issue -> coordinator.start_task, de-duped). run-team _build_transport now wires github/claude_code live (was SystemExit) + adds the intake-github subcommand. claude_code drop-path also neutralizes backslash (defense-in-depth).
333 lines
11 KiB
Python
333 lines
11 KiB
Python
"""Unit tests for agent_team.transport.github_live (§3.3.1, §7.1 P4).
|
|
|
|
The live poster is the production ``requests`` backing for the §3.3.1 injected
|
|
``HttpPost`` seam. These tests prove the contract entirely with mocks (no
|
|
network, and ``requests`` itself is never required): the poster performs the
|
|
REST POST and returns ``(status, data)``; the posted comment carries the
|
|
``<!-- shq:<question_id> -->`` marker; the new comment ``id`` round-trips as the
|
|
``channel_ref`` through a real ``GitHubTransport``; a missing package / token
|
|
fails loudly; and a non-2xx response surfaces as ``GitHubApiError``.
|
|
|
|
No CI, OIDC, git-apply, or GitHub Actions surface is touched — this is human-gate
|
|
transport I/O only (production default stays P2: clarify->plan->review).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from agent_team.transport.base import GITHUB_MARKER_TEMPLATE, QuestionSet, Transport
|
|
from agent_team.transport.github_adapter import GitHubApiError, GitHubTransport
|
|
from agent_team.transport.github_live import (
|
|
build_github_poster,
|
|
build_live_github_transport,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Test doubles #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class _FakeResponse:
|
|
"""A ``requests.Response``-like object: ``status_code`` + ``json()``."""
|
|
|
|
def __init__(self, status_code: int, data: dict[str, Any]) -> None:
|
|
self.status_code = status_code
|
|
self._data = data
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
return self._data
|
|
|
|
|
|
class _FakeSession:
|
|
"""A fake ``requests.Session`` recording ``post`` kwargs and scripting a reply."""
|
|
|
|
def __init__(
|
|
self, status_code: int = 201, data: dict[str, Any] | None = None
|
|
) -> None:
|
|
self.response = _FakeResponse(
|
|
status_code, {"id": 987654321} if data is None else data
|
|
)
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
def post(
|
|
self, url: str, *, headers: dict[str, str], json: dict[str, Any]
|
|
) -> _FakeResponse:
|
|
self.calls.append({"url": url, "headers": headers, "json": json})
|
|
return self.response
|
|
|
|
|
|
def _question_set() -> QuestionSet:
|
|
return QuestionSet(
|
|
thread_id="task-7",
|
|
question_id="q-42",
|
|
turn=1,
|
|
questions=["Ship it?"],
|
|
context={"repo": "agent-team"},
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Clean import without requests #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_module_imports_without_requests(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The module imports cleanly even when ``requests`` cannot be imported."""
|
|
import builtins
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def _blocked_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
|
if name == "requests" or name.startswith("requests."):
|
|
raise ImportError("requests is blocked for this test")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", _blocked_import)
|
|
|
|
module = importlib.reload(
|
|
importlib.import_module("agent_team.transport.github_live")
|
|
)
|
|
assert hasattr(module, "build_github_poster")
|
|
assert hasattr(module, "build_live_github_transport")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Happy path: injected fake client #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_poster_returns_status_and_data() -> None:
|
|
"""The poster forwards to the client and returns ``(status, data)``."""
|
|
session = _FakeSession()
|
|
poster = build_github_poster(client=session)
|
|
|
|
status, data = poster(
|
|
"https://api.github.com/repos/o/r/issues/1/comments",
|
|
headers={"Authorization": "Bearer x"},
|
|
json_body={"body": "hi"},
|
|
)
|
|
|
|
assert status == 201
|
|
assert data["id"] == 987654321
|
|
assert len(session.calls) == 1
|
|
assert session.calls[0]["json"] == {"body": "hi"}
|
|
|
|
|
|
def test_post_question_round_trips_comment_id_as_channel_ref() -> None:
|
|
"""Wired through a real ``GitHubTransport``, the comment id is the channel_ref."""
|
|
session = _FakeSession(data={"id": 555})
|
|
transport = GitHubTransport(
|
|
owner="Sea-Haven-Industries",
|
|
repo="agent-team",
|
|
issue_number=1,
|
|
http_post=build_github_poster(client=session),
|
|
token_provider=lambda: "ghp_fake",
|
|
)
|
|
|
|
channel_ref = transport.post_question(
|
|
thread_id="task-7",
|
|
question_id="q-42",
|
|
turn=1,
|
|
question_set=_question_set(),
|
|
deadline="2026-06-18T00:00:00Z",
|
|
)
|
|
|
|
assert channel_ref == "555"
|
|
|
|
|
|
def test_posted_comment_carries_question_id_marker() -> None:
|
|
"""The posted comment body embeds ``<!-- shq:<question_id> -->``."""
|
|
session = _FakeSession()
|
|
transport = GitHubTransport(
|
|
owner="Sea-Haven-Industries",
|
|
repo="agent-team",
|
|
issue_number=1,
|
|
http_post=build_github_poster(client=session),
|
|
token_provider=lambda: "ghp_fake",
|
|
)
|
|
|
|
transport.post_question(
|
|
thread_id="task-7",
|
|
question_id="q-42",
|
|
turn=1,
|
|
question_set=_question_set(),
|
|
deadline="2026-06-18T00:00:00Z",
|
|
)
|
|
|
|
assert len(session.calls) == 1
|
|
body = session.calls[0]["json"]["body"]
|
|
assert GITHUB_MARKER_TEMPLATE.format(question_id="q-42") in body
|
|
|
|
|
|
def test_convenience_transport_factory_round_trips_and_parses() -> None:
|
|
"""``build_live_github_transport`` wires the poster and the marker round-trips."""
|
|
session = _FakeSession(data={"id": 777})
|
|
transport = build_live_github_transport(
|
|
owner="Sea-Haven-Industries",
|
|
repo="agent-team",
|
|
issue_number=1,
|
|
token="ghp_fake",
|
|
client=session,
|
|
)
|
|
assert isinstance(transport, Transport)
|
|
|
|
channel_ref = transport.post_question(
|
|
thread_id="task-7",
|
|
question_id="q-42",
|
|
turn=1,
|
|
question_set=_question_set(),
|
|
deadline="2026-06-18T00:00:00Z",
|
|
)
|
|
assert channel_ref == "777"
|
|
|
|
# The factory threads one token into the transport's auth header.
|
|
assert session.calls[0]["headers"]["Authorization"] == "Bearer ghp_fake"
|
|
|
|
# The marker the poster shipped round-trips back through parse_answer: a
|
|
# human reply quoting the question comment recovers the same question_id.
|
|
posted_body = session.calls[0]["json"]["body"]
|
|
reply = "> " + posted_body.replace("\n", "\n> ") + "\nLooks good, ship it."
|
|
question_id, answer, via = transport.parse_answer(
|
|
{"comment": {"body": reply, "user": {"login": "adam"}}}
|
|
)
|
|
assert question_id == "q-42"
|
|
assert answer == "Looks good, ship it."
|
|
assert via == "github:adam"
|
|
|
|
|
|
def test_response_with_status_attr_accepted() -> None:
|
|
"""A response exposing ``status`` (not ``status_code``) is also accepted."""
|
|
|
|
class _StatusOnly:
|
|
status = 200
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
return {"id": 1}
|
|
|
|
class _Session:
|
|
def post(self, url: str, **kwargs: Any) -> _StatusOnly:
|
|
return _StatusOnly()
|
|
|
|
poster = build_github_poster(client=_Session())
|
|
status, data = poster("u", headers={}, json_body={})
|
|
assert status == 200
|
|
assert data["id"] == 1
|
|
|
|
|
|
def test_empty_body_coerces_to_dict() -> None:
|
|
"""A non-dict JSON body coerces to ``{}`` (adapter's missing-id guard fires)."""
|
|
|
|
class _NullJson:
|
|
status_code = 201
|
|
|
|
def json(self) -> Any:
|
|
return None
|
|
|
|
class _Session:
|
|
def post(self, url: str, **kwargs: Any) -> _NullJson:
|
|
return _NullJson()
|
|
|
|
poster = build_github_poster(client=_Session())
|
|
status, data = poster("u", headers={}, json_body={})
|
|
assert status == 201
|
|
assert data == {}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Failure modes #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_non_2xx_response_raises_github_api_error() -> None:
|
|
"""A non-2xx response surfaces as ``GitHubApiError`` carrying the status."""
|
|
session = _FakeSession(status_code=403, data={"message": "Forbidden"})
|
|
poster = build_github_poster(client=session)
|
|
|
|
with pytest.raises(GitHubApiError) as excinfo:
|
|
poster("u", headers={}, json_body={"body": "x"})
|
|
assert excinfo.value.status == 403
|
|
|
|
|
|
def test_unsupported_response_raises_type_error() -> None:
|
|
"""A response with neither status nor json() is fatal (not a silent success)."""
|
|
|
|
class _Session:
|
|
def post(self, url: str, **kwargs: Any) -> object:
|
|
return object()
|
|
|
|
poster = build_github_poster(client=_Session())
|
|
with pytest.raises(TypeError, match="status_code"):
|
|
poster("u", headers={}, json_body={})
|
|
|
|
|
|
def test_missing_token_raises_runtime_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""No token and no GITHUB_TOKEN raises a clear RuntimeError.
|
|
|
|
Stub ``requests`` into ``sys.modules`` so the deferred import SUCCEEDS and
|
|
the no-token branch is what's under test (avoids local-vs-CI drift where a
|
|
missing package would otherwise mask the token check).
|
|
"""
|
|
import sys
|
|
from types import ModuleType
|
|
|
|
fake = ModuleType("requests")
|
|
fake.Session = lambda: type(
|
|
"S", (), {"headers": {}, "post": lambda self, *a, **k: None}
|
|
)() # type: ignore[attr-defined]
|
|
monkeypatch.setitem(sys.modules, "requests", fake)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
|
|
with pytest.raises(RuntimeError, match="GitHub token"):
|
|
build_github_poster()
|
|
|
|
|
|
def test_missing_package_raises_runtime_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A missing ``requests`` package raises a clear RuntimeError."""
|
|
import builtins
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def _blocked_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
|
if name == "requests" or name.startswith("requests."):
|
|
raise ImportError("requests is blocked for this test")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", _blocked_import)
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_present")
|
|
|
|
with pytest.raises(RuntimeError, match="requests is unavailable"):
|
|
build_github_poster()
|
|
|
|
|
|
def test_token_falls_back_to_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""With ``requests`` stubbed, a GITHUB_TOKEN env var builds a session cleanly."""
|
|
import sys
|
|
from types import ModuleType
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _Session:
|
|
def __init__(self) -> None:
|
|
self.headers: dict[str, str] = {}
|
|
|
|
def post(self, *a: Any, **k: Any) -> None: # pragma: no cover - unused
|
|
return None
|
|
|
|
def _make_session() -> _Session:
|
|
session = _Session()
|
|
captured["session"] = session
|
|
return session
|
|
|
|
fake = ModuleType("requests")
|
|
fake.Session = _make_session # type: ignore[attr-defined]
|
|
monkeypatch.setitem(sys.modules, "requests", fake)
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_from_env")
|
|
|
|
poster = build_github_poster()
|
|
assert callable(poster)
|
|
assert captured["session"].headers["Authorization"] == "Bearer ghp_from_env"
|