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).
229 lines
9.6 KiB
Python
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)
|