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_live.py
Adam Moussa 0842ff778b feat(agent-team): P4 live github/claude_code transports + GitHub-issue intake
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).
2026-06-18 13:23:02 -04:00

229 lines
9.6 KiB
Python

"""Live ``requests``-backed GitHub poster (design §3.3.1, §7.1 P4 — GitHub).
The :mod:`agent_team.transport.github_adapter` module ships the §3.3.1 transport
contract with a dependency-injected ``http_post`` seam: the adapter renders the
question-set into a Markdown comment body (embedding the
``<!-- shq:<question_id> -->`` marker so an inbound answer maps back) and hands
the REST POST to an
``HttpPost = (url, *, headers, json_body) -> (status, data)`` whose job is to
perform the real ``POST /repos/{owner}/{repo}/issues/{n}/comments`` and return
the GitHub comment payload carrying the new comment ``id``. The foundation's
default ``http_post`` is a stdlib-only (``urllib``) poster invoked only on an
actual delivery, so nothing ships provisioned; this module supplies the
**production** poster, backed by a thin ``requests`` session, that the P4 (GitHub)
live wiring injects.
Why a thin ``requests`` shim (and not PyGithub):
The adapter already speaks the GitHub REST API directly — it builds the
comments URL, the ``Authorization: Bearer`` / ``X-GitHub-Api-Version``
headers, and the ``{"body": ...}`` JSON itself, then hands a plain
``(url, headers, json_body)`` POST to the seam. The live poster therefore
only needs a minimal HTTP client, not the full PyGithub object model. A
``requests`` session keeps the shim small and fully mirrors the seam the
adapter already accepts.
Deferred import (mirrors :func:`agent_team.graph.build_sqlite_checkpointer` and
:func:`agent_team.transport.slack_live.build_slack_poster`):
``requests`` is an optional dependency that may be absent in pre-deploy / test
environments, so this module imports cleanly without it. The import is deferred
to the moment a live client is actually constructed, and a missing package
raises a clear :class:`RuntimeError` so a misconfigured deploy fails loudly
rather than silently. The token is likewise resolved at build time (falling back
to ``GITHUB_TOKEN``); a missing token raises a clear :class:`RuntimeError`.
The marker round-trip is owned by the adapter, not the poster: the poster is the
pure network seam. :meth:`GitHubTransport.post_question` embeds the
``question_id`` marker in the comment body it hands to this poster, and
:meth:`GitHubTransport.parse_answer` recovers it from an inbound reply, so the
``question_id`` survives end-to-end without the poster needing to know about it.
Production-default safety (P2 stays clarify->plan->review): this module is live
**transport I/O only** for the human gate. It performs exactly one operation —
post an issue/PR comment — and contains NO CI calls, NO OIDC, NO git/patch
apply, and NO GitHub Actions network. The P3 builders/verifier live apply/verify
workflow is held for a separate review gate and is deliberately absent here.
"""
from __future__ import annotations
import os
from typing import Any
from agent_team.transport.github_adapter import (
GITHUB_API_ROOT,
GitHubApiError,
GitHubTransport,
HttpPost,
)
__all__ = [
"build_github_poster",
"build_live_github_transport",
]
def build_github_poster(token: str | None = None, *, client: Any = None) -> HttpPost:
"""Build a live ``requests``-backed :data:`HttpPost` seam (§3.3.1, P4).
The returned callable implements the adapter's
``(url, *, headers, json_body) -> (status, data)`` seam: it performs the
real ``POST`` against the GitHub REST API and returns the response status
plus parsed JSON body so :meth:`GitHubTransport.post_question` can read the
new comment ``id`` as the ``channel_ref`` the ledger stores. A non-2xx
response is surfaced as :class:`GitHubApiError` (the adapter also guards the
status, but the poster raises early so a failed POST never looks like a
success with an empty body).
``client`` (optional) injects a pre-built HTTP client for testability; any
object exposing ``post(url, *, headers, json)`` and returning a response
with ``status_code`` plus a ``json()`` method works (the ``requests``
``Session`` shape). When omitted, a ``requests.Session`` is constructed
lazily from ``token`` (falling back to the ``GITHUB_TOKEN`` environment
variable). The ``requests`` import is deferred so this module imports
cleanly without the optional package; a missing package or a missing token
raises a clear :class:`RuntimeError`.
The poster is the pure network seam: the ``question_id`` marker is embedded
by the adapter in the comment body it passes through ``json_body``, so the
posted comment carries the marker without the poster handling it. See the
module docstring for the full rationale.
"""
if client is None:
client = _build_session(token)
def _poster(
url: str,
*,
headers: dict[str, str],
json_body: dict[str, Any],
) -> tuple[int, dict[str, Any]]:
response = client.post(url, headers=headers, json=json_body)
status = _status_of(response)
data = _json_of(response)
if not (200 <= status < 300):
raise GitHubApiError(status, _body_repr(data))
return status, data
return _poster
def build_live_github_transport(
*,
owner: str,
repo: str,
issue_number: int,
token: str | None = None,
client: Any = None,
api_root: str = GITHUB_API_ROOT,
) -> GitHubTransport:
"""Build a :class:`GitHubTransport` wired to a live ``requests`` poster.
Convenience constructor for the P4 live coordinator: builds the live poster
and binds it to the issue (or PR) thread where the human gate posts its
question-set comment and reads the reply. See :func:`build_github_poster`
for the token / client / deferred-import semantics.
The transport builds the ``Authorization`` header itself (from a
``token_provider``), so the resolved token is threaded in once and shared
with the poster's session: a single token source backs the whole live path.
When neither ``token`` nor ``client`` is given, the token still resolves from
``GITHUB_TOKEN`` at call time so the deploy fails loudly if it is unset.
``api_root`` is forwarded to the transport so a GitHub Enterprise host can be
targeted; the poster itself is endpoint-agnostic (the adapter builds the URL).
"""
resolved = _resolve_token(token)
return GitHubTransport(
owner=owner,
repo=repo,
issue_number=issue_number,
http_post=build_github_poster(resolved, client=client),
api_root=api_root,
token_provider=(lambda: resolved) if resolved is not None else None,
)
def _build_session(token: str | None) -> Any:
"""Lazily construct a ``requests.Session`` (deferred optional import).
Raises a clear :class:`RuntimeError` if ``requests`` is not installed or no
token is resolvable (neither ``token`` nor ``GITHUB_TOKEN``), so a
misconfigured deploy fails loudly rather than silently. The token is held on
the session purely so the same authenticated client can be reused; the
adapter still sets the ``Authorization`` header per call.
"""
try:
import requests
except ImportError as exc: # pragma: no cover - depends on optional dep
raise RuntimeError(
"requests is unavailable; install the 'requests' package to build "
"a live GitHub poster (P4), or inject a 'client' for testing."
) from exc
resolved = _resolve_token(token)
if not resolved:
raise RuntimeError(
"No GitHub token available; pass 'token' or set the GITHUB_TOKEN "
"environment variable to build a live GitHub poster."
)
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {resolved}"})
return session
def _resolve_token(token: str | None) -> str | None:
"""Resolve the GitHub token, falling back to ``GITHUB_TOKEN`` (read at call time).
Returns ``None`` when no token is available so callers can decide whether a
missing token is fatal (the poster path) or deferrable (an injected-client
test path that never needs one).
"""
return token or os.environ.get("GITHUB_TOKEN")
def _status_of(response: Any) -> int:
"""Read the HTTP status code from a ``requests``-like response.
``requests.Response`` exposes ``status_code``; a test double may instead
expose ``status``. Either is accepted so the poster stays usable with a
minimal fake.
"""
for attr in ("status_code", "status"):
value = getattr(response, attr, None)
if value is not None:
return int(value)
raise TypeError(
"GitHub response exposes no 'status_code'/'status'; expected a "
f"requests-like response, got {type(response)!r}"
)
def _json_of(response: Any) -> dict[str, Any]:
"""Parse the JSON body of a ``requests``-like response to a dict.
A successful create returns the new comment object (carrying ``id``); an
empty body coerces to ``{}`` so the adapter's missing-id guard fires with a
clear message rather than an attribute error.
"""
parser = getattr(response, "json", None)
if not callable(parser):
raise TypeError(
"GitHub response exposes no callable 'json()'; expected a "
f"requests-like response, got {type(response)!r}"
)
data = parser()
return data if isinstance(data, dict) else {}
def _body_repr(data: dict[str, Any]) -> str:
"""Render a response body for a :class:`GitHubApiError` message.
Best-effort JSON; falls back to ``repr`` so the error is always constructible
even for an exotic body.
"""
try:
import json
return json.dumps(data)
except (TypeError, ValueError): # pragma: no cover - exotic body
return repr(data)