"""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 ```` (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 ````. # ``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"") 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 ```` 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 ```` 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:`` 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 ```` 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 (`` 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()