"""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 ```` 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)