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.
347 lines
13 KiB
Python
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()
|