Consolidates the 18 leaf modules from the r720-plane2-scaffold workflow onto the foundation commit. Full suite: 535 passed, 1 skipped; ruff + format clean. Built (pre-deployment scaffold only — nothing provisioned/enabled): - LangGraph pipeline graph.py (INTAKE->CLARIFY->PLAN, interrupt()/resume, checkpointer-injectable) - nodes: clarifier (98% gate), planner, review_loop (GPT-4.1), builders->candidate diff, verifier - §3.3.1 HITL: ledger ops, resume_worker, deadline_timer, recovery sweep, responder - transports: slack / github / claude_code adapters - ci_gate (pure-code pass/fail), operator_cli, run-team.py entry, P1 sim harness - ci/agent-team-apply-verify.yml (split untrusted/privileged jobs) — authored, disabled KNOWN OPEN FINDINGS (verifier/cross-review, not yet fixed — see follow-up): - builders denylist: 4 execution-proven bypasses (delete, mode-change, copy-to, out-of-scope delete) - §3.3.1 CAS: BEGIN IMMEDIATE outside try/except; shared-connection txn nesting unsafe under concurrency - operator_cli: missing re-deliver/force-resume; audit-after-mutate ordering gap - ci yaml: GPT-4.1 cross-review PASS w/ 4 FIX items (symlink path escape, etc.) - P1 sim harness models the ledger layer, not real LangGraph interrupt/resume; P1 exit criteria not yet truly proven Deploy-gated (NOT done): IAM/step-ca/Roles Anywhere/confluence-bot provisioning, /sh-security-review sign-off, live Slack/CI, rsync, live dry-runs, Adam approval.
396 lines
12 KiB
Python
396 lines
12 KiB
Python
"""Unit tests for agent_team.transport.github_adapter (§3.3.1, §7.1 P4).
|
|
|
|
Fully hermetic: the HTTP transport is dependency-injected with an in-memory
|
|
fake, so no network call, token, or live infrastructure is exercised.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from agent_team.transport.base import (
|
|
GITHUB_MARKER_TEMPLATE,
|
|
QuestionSet,
|
|
Transport,
|
|
)
|
|
from agent_team.transport.github_adapter import (
|
|
GITHUB_API_ROOT,
|
|
GitHubApiError,
|
|
GitHubTransport,
|
|
build_marker,
|
|
extract_question_id,
|
|
render_question_comment,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fakes / fixtures
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class FakeHttpPost:
|
|
"""In-memory ``HttpPost`` double recording calls and returning a scripted
|
|
``(status, data)``."""
|
|
|
|
def __init__(self, status: int = 201, data: dict[str, Any] | None = None) -> None:
|
|
self.status = status
|
|
self.data = {"id": 987654321} if data is None else data
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
def __call__(
|
|
self,
|
|
url: str,
|
|
*,
|
|
headers: dict[str, str],
|
|
json_body: dict[str, Any],
|
|
) -> tuple[int, dict[str, Any]]:
|
|
self.calls.append({"url": url, "headers": headers, "json_body": json_body})
|
|
return self.status, self.data
|
|
|
|
|
|
def make_transport(
|
|
http_post: FakeHttpPost | None = None,
|
|
*,
|
|
token: str | None = "ghp_fake",
|
|
) -> GitHubTransport:
|
|
return GitHubTransport(
|
|
owner="Sea-Haven-Industries",
|
|
repo="agent-team",
|
|
issue_number=42,
|
|
http_post=http_post or FakeHttpPost(),
|
|
token_provider=(lambda: token),
|
|
)
|
|
|
|
|
|
def make_question_set() -> QuestionSet:
|
|
return QuestionSet(
|
|
thread_id="thread-1",
|
|
question_id="qid-abc",
|
|
turn=3,
|
|
questions=["Proceed with the migration?", "Which region?"],
|
|
context={"repo": "Sea-Haven-Industries/agent-team", "summary": "DB cutover"},
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Contract / typing
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_is_transport_subclass() -> None:
|
|
assert issubclass(GitHubTransport, Transport)
|
|
|
|
|
|
def test_instantiable_concrete_adapter() -> None:
|
|
# Transport is abstract; the leaf must implement both abstract methods.
|
|
transport = make_transport()
|
|
assert isinstance(transport, Transport)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Marker helpers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_build_marker_uses_foundation_template() -> None:
|
|
assert build_marker("xyz") == GITHUB_MARKER_TEMPLATE.format(question_id="xyz")
|
|
assert build_marker("xyz") == "<!-- shq:xyz -->"
|
|
|
|
|
|
def test_extract_question_id_roundtrips_build_marker() -> None:
|
|
qid = "deadbeef1234"
|
|
assert extract_question_id(build_marker(qid)) == qid
|
|
|
|
|
|
def test_extract_question_id_found_in_quoted_reply() -> None:
|
|
quoted = "> <!-- shq:q-77 -->\n> ### Agent-team needs input\n\nYes, go ahead."
|
|
assert extract_question_id(quoted) == "q-77"
|
|
|
|
|
|
def test_extract_question_id_absent_returns_none() -> None:
|
|
assert extract_question_id("just a normal comment") is None
|
|
assert extract_question_id("") is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Rendering
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_render_embeds_marker_and_questions() -> None:
|
|
qs = make_question_set()
|
|
body = render_question_comment(
|
|
qs, question_id="qid-abc", turn=3, deadline="2026-06-18T00:00:00Z"
|
|
)
|
|
assert "<!-- shq:qid-abc -->" in body
|
|
assert extract_question_id(body) == "qid-abc"
|
|
assert "1. Proceed with the migration?" in body
|
|
assert "2. Which region?" in body
|
|
assert "turn 3" in body
|
|
assert "2026-06-18T00:00:00Z" in body
|
|
# Context surfaced.
|
|
assert "Sea-Haven-Industries/agent-team" in body
|
|
assert "DB cutover" in body
|
|
|
|
|
|
def test_render_handles_empty_questions() -> None:
|
|
qs = QuestionSet(thread_id="t", question_id="q", turn=0, questions=[])
|
|
body = render_question_comment(
|
|
qs, question_id="q", turn=0, deadline="2026-06-18T00:00:00Z"
|
|
)
|
|
assert "(no questions)" in body
|
|
assert extract_question_id(body) == "q"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# post_question
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_post_question_returns_comment_id_as_channel_ref() -> None:
|
|
http = FakeHttpPost(status=201, data={"id": 555})
|
|
transport = make_transport(http)
|
|
ref = transport.post_question(
|
|
thread_id="thread-1",
|
|
question_id="qid-abc",
|
|
turn=3,
|
|
question_set=make_question_set(),
|
|
deadline="2026-06-18T00:00:00Z",
|
|
)
|
|
assert ref == "555"
|
|
assert isinstance(ref, str)
|
|
|
|
|
|
def test_post_question_targets_correct_issue_endpoint() -> None:
|
|
http = FakeHttpPost()
|
|
transport = make_transport(http)
|
|
transport.post_question(
|
|
thread_id="thread-1",
|
|
question_id="qid-abc",
|
|
turn=1,
|
|
question_set=make_question_set(),
|
|
deadline="2026-06-18T00:00:00Z",
|
|
)
|
|
(call,) = http.calls
|
|
assert call["url"] == (
|
|
f"{GITHUB_API_ROOT}/repos/Sea-Haven-Industries/agent-team/issues/42/comments"
|
|
)
|
|
|
|
|
|
def test_post_question_body_embeds_question_id() -> None:
|
|
http = FakeHttpPost()
|
|
transport = make_transport(http)
|
|
transport.post_question(
|
|
thread_id="thread-1",
|
|
question_id="qid-abc",
|
|
turn=1,
|
|
question_set=make_question_set(),
|
|
deadline="2026-06-18T00:00:00Z",
|
|
)
|
|
body = http.calls[0]["json_body"]["body"]
|
|
assert extract_question_id(body) == "qid-abc"
|
|
|
|
|
|
def test_post_question_sends_bearer_auth_header() -> None:
|
|
http = FakeHttpPost()
|
|
transport = make_transport(http, token="ghp_secret_value")
|
|
transport.post_question(
|
|
thread_id="t",
|
|
question_id="q",
|
|
turn=0,
|
|
question_set=make_question_set(),
|
|
deadline="d",
|
|
)
|
|
headers = http.calls[0]["headers"]
|
|
assert headers["Authorization"] == "Bearer ghp_secret_value"
|
|
assert headers["Accept"] == "application/vnd.github+json"
|
|
assert headers["X-GitHub-Api-Version"] == "2022-11-28"
|
|
|
|
|
|
def test_post_question_raises_on_missing_token() -> None:
|
|
transport = make_transport(FakeHttpPost(), token=None)
|
|
with pytest.raises(GitHubApiError) as exc:
|
|
transport.post_question(
|
|
thread_id="t",
|
|
question_id="q",
|
|
turn=0,
|
|
question_set=make_question_set(),
|
|
deadline="d",
|
|
)
|
|
assert exc.value.status == 401
|
|
|
|
|
|
def test_post_question_reads_token_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
http = FakeHttpPost()
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_from_env")
|
|
transport = GitHubTransport(owner="o", repo="r", issue_number=1, http_post=http)
|
|
transport.post_question(
|
|
thread_id="t",
|
|
question_id="q",
|
|
turn=0,
|
|
question_set=make_question_set(),
|
|
deadline="d",
|
|
)
|
|
assert http.calls[0]["headers"]["Authorization"] == "Bearer ghp_from_env"
|
|
|
|
|
|
def test_post_question_raises_on_non_2xx() -> None:
|
|
http = FakeHttpPost(status=422, data={"message": "Validation Failed"})
|
|
transport = make_transport(http)
|
|
with pytest.raises(GitHubApiError) as exc:
|
|
transport.post_question(
|
|
thread_id="t",
|
|
question_id="q",
|
|
turn=0,
|
|
question_set=make_question_set(),
|
|
deadline="d",
|
|
)
|
|
assert exc.value.status == 422
|
|
assert "Validation Failed" in exc.value.body
|
|
|
|
|
|
def test_post_question_raises_when_response_missing_id() -> None:
|
|
http = FakeHttpPost(status=201, data={"no_id": True})
|
|
transport = make_transport(http)
|
|
with pytest.raises(GitHubApiError):
|
|
transport.post_question(
|
|
thread_id="t",
|
|
question_id="q",
|
|
turn=0,
|
|
question_set=make_question_set(),
|
|
deadline="d",
|
|
)
|
|
|
|
|
|
def test_comments_url_respects_custom_api_root() -> None:
|
|
transport = GitHubTransport(
|
|
owner="o",
|
|
repo="r",
|
|
issue_number=7,
|
|
http_post=FakeHttpPost(),
|
|
api_root="https://ghe.example.com/api/v3/",
|
|
token_provider=lambda: "t",
|
|
)
|
|
assert transport.comments_url == (
|
|
"https://ghe.example.com/api/v3/repos/o/r/issues/7/comments"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# parse_answer
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_parse_answer_full_webhook_shape() -> None:
|
|
transport = make_transport()
|
|
raw = {
|
|
"comment": {
|
|
"body": "<!-- shq:qid-abc -->\nYes, proceed with us-east-1.",
|
|
"user": {"login": "amoussa1229"},
|
|
}
|
|
}
|
|
qid, answer, via = transport.parse_answer(raw)
|
|
assert qid == "qid-abc"
|
|
assert answer == "Yes, proceed with us-east-1."
|
|
assert via == "github:amoussa1229"
|
|
|
|
|
|
def test_parse_answer_flattened_shape() -> None:
|
|
transport = make_transport()
|
|
raw = {
|
|
"body": "<!-- shq:q9 --> looks good",
|
|
"user": {"login": "adam"},
|
|
}
|
|
qid, answer, via = transport.parse_answer(raw)
|
|
assert qid == "q9"
|
|
assert answer == "looks good"
|
|
assert via == "github:adam"
|
|
|
|
|
|
def test_parse_answer_strips_quoted_marker_lines() -> None:
|
|
transport = make_transport()
|
|
raw = {
|
|
"comment": {
|
|
"body": (
|
|
"> <!-- shq:q-77 -->\n"
|
|
"> ### Agent-team needs input (turn 1)\n"
|
|
"\n"
|
|
"Approved. Use the staging bucket."
|
|
),
|
|
"user": {"login": "adam"},
|
|
}
|
|
}
|
|
qid, answer, via = transport.parse_answer(raw)
|
|
assert qid == "q-77"
|
|
assert "<!-- shq:" not in answer
|
|
assert answer.endswith("Approved. Use the staging bucket.")
|
|
|
|
|
|
def test_parse_answer_without_marker_raises() -> None:
|
|
transport = make_transport()
|
|
with pytest.raises(ValueError):
|
|
transport.parse_answer(
|
|
{"comment": {"body": "no marker here", "user": {"login": "x"}}}
|
|
)
|
|
|
|
|
|
def test_parse_answer_without_login_falls_back_to_github() -> None:
|
|
transport = make_transport()
|
|
qid, answer, via = transport.parse_answer(
|
|
{"comment": {"body": "<!-- shq:q1 -->\nok"}}
|
|
)
|
|
assert qid == "q1"
|
|
assert via == "github"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Round-trip: post then parse the reply maps to the same question_id
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_post_then_answer_roundtrip_question_id() -> None:
|
|
http = FakeHttpPost()
|
|
transport = make_transport(http)
|
|
transport.post_question(
|
|
thread_id="thread-1",
|
|
question_id="round-trip-qid",
|
|
turn=2,
|
|
question_set=make_question_set(),
|
|
deadline="2026-06-18T00:00:00Z",
|
|
)
|
|
posted_body = http.calls[0]["json_body"]["body"]
|
|
# Simulate a human quoting the posted comment in their reply.
|
|
reply_body = "\n".join(f"> {line}" for line in posted_body.splitlines())
|
|
reply_body += "\n\nYes."
|
|
qid, answer, via = transport.parse_answer(
|
|
{"comment": {"body": reply_body, "user": {"login": "adam"}}}
|
|
)
|
|
assert qid == "round-trip-qid"
|
|
assert answer == "Yes."
|
|
|
|
|
|
def test_default_http_post_is_not_called_in_tests() -> None:
|
|
# Sanity: the adapter never falls back to the network when a poster is
|
|
# injected (guards against an accidental live call in CI).
|
|
http = FakeHttpPost()
|
|
transport = make_transport(http)
|
|
transport.post_question(
|
|
thread_id="t",
|
|
question_id="q",
|
|
turn=0,
|
|
question_set=make_question_set(),
|
|
deadline="d",
|
|
)
|
|
assert len(http.calls) == 1
|
|
|
|
|
|
def test_api_error_str_is_truncated_and_typed() -> None:
|
|
err = GitHubApiError(500, "x" * 1000)
|
|
assert err.status == 500
|
|
assert isinstance(err, RuntimeError)
|
|
# __str__ truncates the body to keep logs bounded.
|
|
assert len(str(err)) < 300
|
|
assert json.dumps({"ok": True}) # keep json import meaningful/no-op
|