This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/agent_team/transport/github_adapter.py
Adam Moussa 15a416d31a Add Plane-2 leaf scaffold (pipeline graph, nodes, HITL, transports, CI)
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.
2026-06-17 15:16:12 -04:00

347 lines
13 KiB
Python

"""GitHub issue-comment transport adapter (design §3.3.1, §7.1 P4).
One concrete :class:`~agent_team.transport.base.Transport` implementation: it
delivers a question-set as a **GitHub issue comment** and parses an inbound
answer comment back into the ``(question_id, answer, via)`` tuple the durable
responder feeds into the §3.3.1 first-answer-wins compare-and-set.
Why an HTML-comment marker (and not a native callback id like Slack):
GitHub issue comments carry no per-message callback metadata we control, so
the question-set comment embeds ``<!-- shq:<question_id> -->`` (the
``GITHUB_MARKER_TEMPLATE`` from the foundation contract). The answering
human quotes / replies under that comment, GitHub preserves the marker in
the quoted body, and :meth:`GitHubTransport.parse_answer` recovers the
``question_id`` from it. This is exactly the mapping §3.3.1 specifies for
transports without native callback metadata.
Delivery returns the new comment's numeric id (stringified) as the
``channel_ref`` the ledger stores (§3.3.1 "issue-comment id"), so
reconcile/recovery can act on it.
Design constraints honoured here (pre-deployment scaffolding):
* **No live infrastructure.** Nothing is provisioned or called at import.
The HTTP transport is dependency-injected (``http_post``); the default
is a stdlib-only (``urllib``) poster invoked only on an actual post, so
there is no third-party dependency and the unit tests stay fully
hermetic (they inject an in-memory fake).
* **Secrets never committed.** The GitHub token is read from the
environment (``GITHUB_TOKEN`` by default) at call time, never stored in
source or logged.
"""
from __future__ import annotations
import json
import os
import re
from typing import Any, Callable, Protocol
from urllib import error as _urlerror
from urllib import request as _urlrequest
from agent_team.transport.base import (
GITHUB_MARKER_TEMPLATE,
NormalizedAnswer,
QuestionSet,
Transport,
)
__all__ = [
"GITHUB_API_ROOT",
"GitHubApiError",
"GitHubTransport",
"HttpPost",
"build_marker",
"extract_question_id",
"render_question_comment",
]
# Default GitHub REST API root. Overridable per-instance for GitHub Enterprise.
GITHUB_API_ROOT = "https://api.github.com"
# Compiled matcher for the foundation marker ``<!-- shq:<question_id> -->``.
# ``question_id`` is a uuid4 hex in practice but the pattern stays permissive
# to also accept hyphenated test/synthetic ids. It captures everything up to
# the closing ``-->`` non-greedily, then strips trailing whitespace, so a
# malformed marker surfaces as "no match" rather than a silently wrong capture.
_MARKER_RE = re.compile(r"<!--\s*shq:(\S+?)\s*-->")
class GitHubApiError(RuntimeError):
"""Raised when a GitHub REST call returns a non-success status.
Carries the HTTP ``status`` and the (truncated) response ``body`` so the
reconcile loop can decide whether to retry. The triggering question is left
``open`` with no ``channel_ref`` per §3.3.1's lost-post handling.
"""
def __init__(self, status: int, body: str) -> None:
self.status = status
self.body = body
super().__init__(f"GitHub API error {status}: {body[:200]}")
class HttpPost(Protocol):
"""Injected HTTP POST seam: ``(url, headers, json_body) -> (status, data)``.
Returns the response status code and the parsed JSON body (a dict). Keeping
this a narrow callable means the adapter has no hard dependency on any HTTP
client and the tests pass a pure in-memory fake.
"""
def __call__(
self,
url: str,
*,
headers: dict[str, str],
json_body: dict[str, Any],
) -> tuple[int, dict[str, Any]]: ...
def build_marker(question_id: str) -> str:
"""Render the hidden ``question_id`` marker for an outbound comment.
Thin wrapper over the foundation ``GITHUB_MARKER_TEMPLATE`` so the leaf
never re-defines the template string (the contract owns it).
"""
return GITHUB_MARKER_TEMPLATE.format(question_id=question_id)
def extract_question_id(body: str) -> str | None:
"""Return the ``question_id`` embedded in ``body``, or ``None`` if absent.
Scans for the ``<!-- shq:<question_id> -->`` marker. Works on both the
original question comment and an answer that quotes it (GitHub preserves the
HTML comment in the ``>``-quoted block).
"""
match = _MARKER_RE.search(body or "")
return match.group(1) if match else None
def render_question_comment(
question_set: QuestionSet,
*,
question_id: str,
turn: int,
deadline: str,
) -> str:
"""Render the Markdown body for the outbound question-set comment.
The body embeds the hidden ``question_id`` marker (so the answer can be
mapped back), a human-readable header, any ``context`` the question-set
carries (e.g. ``repo``/``summary``), and the ordered questions. Answer
instructions tell the human to reply *quoting this comment* so the marker
survives into their reply.
"""
lines: list[str] = [build_marker(question_id)]
lines.append(f"### Agent-team needs input (turn {turn})")
lines.append("")
context = question_set.context or {}
repo = context.get("repo")
summary = context.get("summary")
if repo:
lines.append(f"**Repo:** {repo}")
if summary:
lines.append(f"**Summary:** {summary}")
if repo or summary:
lines.append("")
if question_set.questions:
for index, question in enumerate(question_set.questions, start=1):
lines.append(f"{index}. {question}")
else:
lines.append("_(no questions)_")
lines.append("")
lines.append(f"_Please reply **quoting this comment** by {deadline}._")
return "\n".join(lines)
def _default_http_post(
url: str,
*,
headers: dict[str, str],
json_body: dict[str, Any],
) -> tuple[int, dict[str, Any]]:
"""Stdlib-only default POST (no third-party dependency at import time).
Used only when no ``http_post`` is injected and an actual delivery is
attempted. Tests never reach this path — they inject a fake.
"""
payload = json.dumps(json_body).encode("utf-8")
request = _urlrequest.Request(url, data=payload, method="POST")
for key, value in headers.items():
request.add_header(key, value)
try:
with _urlrequest.urlopen(request) as response: # noqa: S310 (trusted api host)
status = response.getcode()
raw = response.read().decode("utf-8")
except _urlerror.HTTPError as exc: # pragma: no cover - network path
raw = exc.read().decode("utf-8", "replace")
raise GitHubApiError(exc.code, raw) from exc
data = json.loads(raw) if raw else {}
return status, data
class GitHubTransport(Transport):
"""Deliver / parse human-in-the-loop questions over GitHub issue comments.
Posts a question-set as a comment on a fixed ``owner/repo#issue_number``
thread and parses answers replied under it. The durable ledger + resume
worker depend only on the :class:`Transport` contract, so this adapter can
be swapped for Slack / Claude-Code without touching the core (§3.3.1).
"""
def __init__(
self,
*,
owner: str,
repo: str,
issue_number: int,
http_post: HttpPost | None = None,
token_env: str = "GITHUB_TOKEN",
api_root: str = GITHUB_API_ROOT,
token_provider: Callable[[], str | None] | None = None,
) -> None:
"""Bind the adapter to one issue thread.
Args:
owner: Repository owner / org login.
repo: Repository name.
issue_number: Issue (or PR) number whose comment thread carries the
question-sets.
http_post: Injected POST seam. Defaults to a stdlib-only poster
that is built lazily and only invoked on a real delivery.
token_env: Environment variable holding the GitHub token. Read at
call time so the secret is never captured in source/state.
api_root: REST API root (override for GitHub Enterprise).
token_provider: Optional explicit token source (takes precedence
over ``token_env``); lets a caller wire in a secrets manager
without an env round-trip. Must never be a literal token in
source.
"""
self.owner = owner
self.repo = repo
self.issue_number = issue_number
self._http_post = http_post or _default_http_post
self._token_env = token_env
self._api_root = api_root.rstrip("/")
self._token_provider = token_provider
# -- delivery -----------------------------------------------------------
@property
def comments_url(self) -> str:
"""REST endpoint for creating a comment on the bound issue."""
return (
f"{self._api_root}/repos/{self.owner}/{self.repo}"
f"/issues/{self.issue_number}/comments"
)
def _resolve_token(self) -> str:
"""Fetch the GitHub token at call time (never stored on the instance)."""
token = (
self._token_provider()
if self._token_provider is not None
else os.environ.get(self._token_env)
)
if not token:
raise GitHubApiError(
401,
f"no GitHub token available (env {self._token_env!r} unset)",
)
return token
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._resolve_token()}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
}
def post_question(
self,
*,
thread_id: str,
question_id: str,
turn: int,
question_set: QuestionSet,
deadline: str,
) -> str:
"""Post the question-set as an issue comment; return its comment id.
The comment body embeds ``<!-- shq:<question_id> -->`` so an inbound
answer maps back (§3.3.1). The returned ``channel_ref`` is the GitHub
comment id as a string, which the ledger stores for reconcile/recovery.
Raises :class:`GitHubApiError` on a non-2xx response (the row stays
``open`` with no ref, and the reconcile loop retries idempotently).
"""
body = render_question_comment(
question_set,
question_id=question_id,
turn=turn,
deadline=deadline,
)
status, data = self._http_post(
self.comments_url,
headers=self._headers(),
json_body={"body": body},
)
if not (200 <= status < 300):
raise GitHubApiError(status, json.dumps(data))
comment_id = data.get("id")
if comment_id is None:
raise GitHubApiError(status, f"response missing comment id: {data!r}")
return str(comment_id)
# -- answer parsing -----------------------------------------------------
def parse_answer(self, raw: Any) -> tuple[str, Any, str]:
"""Normalize an inbound GitHub comment payload to ``(qid, answer, via)``.
``raw`` is the issue-comment webhook payload shape (or an equivalent
dict): ``{"comment": {"body": ..., "user": {"login": ...}}}``. A
flattened ``{"body": ..., "user": {...}}`` is also accepted.
The ``question_id`` is recovered from the embedded marker; the answer is
the comment body with the marker line(s) stripped; ``via`` is
``github:<login>`` for the audit trail (``answered_via``). Raises
:class:`ValueError` if no marker is present (the responder treats an
unmappable comment as not an answer).
"""
comment = raw.get("comment", raw) if isinstance(raw, dict) else {}
body = comment.get("body", "") if isinstance(comment, dict) else ""
question_id = extract_question_id(body)
if question_id is None:
raise ValueError("no shq question marker found in comment body")
user = comment.get("user") or {}
login = user.get("login") if isinstance(user, dict) else None
via = f"github:{login}" if login else "github"
answer = self._strip_marker(body)
normalized = NormalizedAnswer(question_id=question_id, answer=answer, via=via)
return normalized.question_id, normalized.answer, normalized.via
@staticmethod
def _strip_marker(body: str) -> str:
"""Recover the human's answer text from a reply body.
The reply typically quotes the original question comment, which drags
the ``<!-- shq:... -->`` marker and the question text (as ``>``-prefixed
Markdown quote lines) into the body. To isolate the human's *new* text:
* drop quote lines (those starting with ``>``) — that is the echoed
original question, not the answer;
* remove any remaining inline marker token, in case the marker sits on
the same line as a short answer (``<!-- shq:x --> yes``);
* collapse the surrounding blank lines.
"""
kept: list[str] = []
for line in (body or "").splitlines():
if line.lstrip().startswith(">"):
continue
cleaned = _MARKER_RE.sub("", line)
kept.append(cleaned)
return "\n".join(kept).strip()