Enumerate draft PRs via the GitHub App, not gh

The R720 box has no gh installed, so the draft-PR monitor's `gh pr list`
shell-out raised FileNotFoundError on every 30s tick and the monitor was
permanently blind to open draft PRs (no runaway/stale detection). The
dispatcher already authenticates with a short-lived App installation
token over REST; reuse that path.

Add app_draft_pr_lister (GET /repos/{owner}/{repo}/pulls, client-side
filter to draft PRs under agent-team/apply/) and prefer it in the monitor
provider when AGENT_TEAM_GH_APP_* are set, falling back to gh otherwise.
Fails soft to an empty snapshot so a sweep never crashes the tick loop.
This commit is contained in:
Adam Moussa 2026-06-25 13:37:39 -04:00
parent dff64161de
commit 6f0fc675ed
3 changed files with 229 additions and 14 deletions

View file

@ -26,19 +26,24 @@ production.
from __future__ import annotations from __future__ import annotations
import base64 import base64
import logging
import re import re
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Protocol from typing import Any, Protocol
from agent_team.state_store import compute_content_hash from agent_team.state_store import compute_content_hash
_LOG = logging.getLogger(__name__)
__all__ = [ __all__ = [
"DispatchInputs", "DispatchInputs",
"DispatchResult", "DispatchResult",
"DispatcherError", "DispatcherError",
"RunLocator", "RunLocator",
"app_branch_pusher", "app_branch_pusher",
"app_draft_pr_lister",
"app_run_locator", "app_run_locator",
"app_workflow_dispatcher", "app_workflow_dispatcher",
"build_dispatch_inputs", "build_dispatch_inputs",
@ -748,6 +753,92 @@ def app_workflow_dispatcher(
return _fire return _fire
def app_draft_pr_lister(
token_provider: Any, *, _http: Any = None
) -> "Callable[..., list[dict[str, Any]]]":
"""App-token draft-PR lister for the A4 monitor — REST, never ``gh``.
Enumerates the currently-open *agent-team* draft PRs via
``GET /repos/{owner}/{repo}/pulls?state=open`` with an installation token from
``token_provider.token()``, replacing the ``gh pr list`` shell-out (the box
has no ``gh`` installed, so that path raised ``FileNotFoundError`` every tick
and the monitor was permanently blind). The rows are filtered client-side to
``draft is True`` and a head ref under ``head_prefix`` (the dispatcher's own
``agent-team/apply/`` namespace) so an unrelated human draft PR is never
counted, and mapped to the three fields the monitor keys off
(``number`` / ``createdAt`` / ``updatedAt``).
Fail-SOFT, mirroring the ``gh`` provider it replaces and the sibling sweeps:
any transport, auth (non-200), or parse error yields an EMPTY list (the
monitor no-ops this pass) rather than raising — a maintenance sweep must never
crash the tick loop. The Bearer token is NEVER logged or surfaced in an error.
``_http`` injects a ``requests``-like client for tests (``.get(url, *,
params=..., headers=..., timeout=...)`` -> response exposing ``.status_code``
and ``.json()``); the default lazily imports ``requests``.
"""
def _list(*, owner: str, repo: str, head_prefix: str) -> list[dict[str, Any]]:
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Authorization": f"Bearer {token_provider.token()}",
}
url = f"{GITHUB_API_ROOT}/repos/{owner}/{repo}/pulls"
params = {"state": "open", "per_page": 100}
try:
if _http is not None:
resp = _http.get(url, params=params, headers=headers, timeout=15.0)
else:
import requests # deferred: optional dependency
resp = requests.get(url, params=params, headers=headers, timeout=15.0)
except Exception as exc: # noqa: BLE001 - never surface a token-bearing error
_LOG.warning(
"draft-pr-monitor: REST pulls enumeration transport error (%s); "
"treating as no open draft PRs this pass",
type(exc).__name__,
)
return []
status = getattr(resp, "status_code", None)
if status != 200:
_LOG.warning(
"draft-pr-monitor: REST pulls enumeration failed (status=%s); "
"treating as no open draft PRs this pass",
status,
)
return []
try:
rows = resp.json()
except Exception: # noqa: BLE001 - malformed body -> fail soft
_LOG.warning(
"draft-pr-monitor: REST pulls returned unparseable JSON; "
"treating as no open draft PRs this pass"
)
return []
out: list[dict[str, Any]] = []
for row in rows or []:
if not isinstance(row, dict) or not row.get("draft"):
continue
head_ref = ((row.get("head") or {}).get("ref")) or ""
if not head_ref.startswith(head_prefix):
continue
number = row.get("number")
if number is None:
continue
out.append(
{
"number": number,
"createdAt": row.get("created_at"),
"updatedAt": row.get("updated_at"),
}
)
return out
return _list
def app_run_locator( def app_run_locator(
token_provider: Any, token_provider: Any,
*, *,

View file

@ -581,28 +581,88 @@ _DRAFT_PR_HEAD_PREFIX = "agent-team/apply/"
_DRAFT_PR_LIST_TIMEOUT_S = 30 _DRAFT_PR_LIST_TIMEOUT_S = 30
def _app_token_provider_from_env() -> "Any | None":
"""Build a :class:`agent_team.github_app.TokenProvider` from env, or ``None``.
Mirrors :func:`agent_team.coordinator._app_dispatch_seams`' resolution: all
three of ``AGENT_TEAM_GH_APP_ID`` / ``_INSTALLATION_ID`` / ``_PRIVATE_KEY``
(a path to the App ``.pem``) must be set and the key file readable, else this
returns ``None`` and the caller falls back to the ``gh`` path. Never raises
and NEVER logs the key path's contents — a half-configured box still serves.
"""
import os # noqa: PLC0415
from pathlib import Path # noqa: PLC0415
app_id = os.environ.get("AGENT_TEAM_GH_APP_ID", "").strip()
inst = os.environ.get("AGENT_TEAM_GH_APP_INSTALLATION_ID", "").strip()
key_path = os.environ.get("AGENT_TEAM_GH_APP_PRIVATE_KEY", "").strip()
if not (app_id and inst and key_path):
return None
try:
pem = Path(key_path).read_text(encoding="utf-8")
except Exception: # noqa: BLE001 - unreadable key -> gh fallback, never crash
_LOG.warning(
"draft-pr-monitor: AGENT_TEAM_GH_APP_PRIVATE_KEY path could not be "
"read; falling back to the gh draft-PR provider."
)
return None
from agent_team.github_app import TokenProvider # noqa: PLC0415
return TokenProvider(app_id=app_id, private_key_pem=pem, installation_id=inst)
def _default_draft_pr_provider(*, owner: str, repo: str) -> "Callable[[], list[Any]]": def _default_draft_pr_provider(*, owner: str, repo: str) -> "Callable[[], list[Any]]":
"""Build the production READ-ONLY draft-PR provider for the A4 monitor. """Build the production READ-ONLY draft-PR provider for the A4 monitor.
Returns a zero-arg callable that enumerates the currently-open *agent-team* Returns a zero-arg callable that enumerates the currently-open *agent-team*
draft PRs via a single read-only ``gh pr list`` (one GET; it NEVER writes, draft PRs and maps each into a :class:`agent_team.draft_pr_monitor.DraftPr`
closes, or dispatches anything) and maps each into a snapshot the monitor consumes. It NEVER writes, closes, or dispatches
:class:`agent_team.draft_pr_monitor.DraftPr` snapshot the monitor consumes. anything.
**Auth path (GitHub App vs gh-default).** When the three ``AGENT_TEAM_GH_APP_*``
env vars resolve a token provider, enumeration goes through the REST
``GET /repos/{owner}/{repo}/pulls`` with a short-lived installation token
(:func:`agent_team.dispatcher.app_draft_pr_lister`) — the SAME App auth the
dispatcher already uses, and the path that does not need ``gh`` on the box.
This is the live R720 path: the box has no ``gh`` installed, so the old
``gh pr list`` shell-out raised ``FileNotFoundError`` every tick and the
monitor was permanently blind. Absent the App env (or an unreadable key) it
falls back to the ``gh`` CLI for parity with environments that have it.
The query is scoped to the ``agent-team/apply/`` head namespace The query is scoped to the ``agent-team/apply/`` head namespace
(:data:`_DRAFT_PR_HEAD_PREFIX`) so it only ever sees the dispatcher's own (:data:`_DRAFT_PR_HEAD_PREFIX`) so it only ever sees the dispatcher's own
apply/verify draft PRs — never an unrelated human draft PR. ``--json`` pulls apply/verify draft PRs — never an unrelated human draft PR. Both paths fail
exactly the three fields the monitor keys off (``number`` / ``createdAt`` -> closed — any transport, auth, or parse error yields an empty snapshot (the
``opened_at`` for the runaway window, ``updatedAt`` -> ``updated_at`` for monitor no-ops this pass) rather than raising, so a transient hiccup never
staleness). breaks the tick loop. (The coordinator's ``_draft_pr_monitor_sweep`` ALSO
swallows provider errors, so this is belt-and-suspenders.)
Mirrors :func:`agent_team.ci_watcher.default_ci_poller`: a thin closure over
``owner`` / ``repo`` that fails closed — a non-zero ``gh`` exit, a timeout, or
unparseable JSON yields an empty snapshot (the monitor then no-ops this pass)
rather than raising, so a transient gh hiccup never breaks the tick loop. (The
coordinator's ``_draft_pr_monitor_sweep`` ALSO swallows provider errors, so
this is belt-and-suspenders.)
""" """
from agent_team.draft_pr_monitor import DraftPr # noqa: PLC0415
token_provider = _app_token_provider_from_env()
if token_provider is not None:
from agent_team.dispatcher import app_draft_pr_lister # noqa: PLC0415
lister = app_draft_pr_lister(token_provider)
def app_provider() -> list[Any]:
rows = lister(owner=owner, repo=repo, head_prefix=_DRAFT_PR_HEAD_PREFIX)
snapshots: list[Any] = []
for row in rows:
number = row.get("number")
if number is None:
continue
snapshots.append(
DraftPr(
number=int(number),
opened_at=row.get("createdAt"),
updated_at=row.get("updatedAt"),
)
)
return snapshots
return app_provider
repo_slug = f"{owner}/{repo}" repo_slug = f"{owner}/{repo}"
def provider() -> list[Any]: def provider() -> list[Any]:

View file

@ -17,6 +17,7 @@ from agent_team.dispatcher import (
DispatchInputs, DispatchInputs,
DispatchResult, DispatchResult,
app_branch_pusher, app_branch_pusher,
app_draft_pr_lister,
app_run_locator, app_run_locator,
app_workflow_dispatcher, app_workflow_dispatcher,
build_dispatch_inputs, build_dispatch_inputs,
@ -595,3 +596,66 @@ def test_app_run_locator_scrubs_token_from_transport_error() -> None:
) )
assert "ghs_TESTTOKEN" not in str(excinfo.value) assert "ghs_TESTTOKEN" not in str(excinfo.value)
assert excinfo.value.__cause__ is None assert excinfo.value.__cause__ is None
# --------------------------------------------------------------------------- #
# app_draft_pr_lister (App-token REST seam; replaces the `gh pr list` shell-out)
# --------------------------------------------------------------------------- #
_PULLS_BODY = [
# In scope + draft -> included.
{
"number": 72,
"draft": True,
"head": {"ref": "agent-team/apply/abc123"},
"created_at": "2026-06-25T16:00:00Z",
"updated_at": "2026-06-25T16:30:00Z",
},
# Draft but NOT in the agent-team/apply namespace -> excluded.
{
"number": 50,
"draft": True,
"head": {"ref": "feature/human-thing"},
"created_at": "2026-06-24T10:00:00Z",
"updated_at": "2026-06-24T11:00:00Z",
},
# In namespace but NOT a draft (a ready PR) -> excluded.
{
"number": 60,
"draft": False,
"head": {"ref": "agent-team/apply/def456"},
"created_at": "2026-06-25T12:00:00Z",
"updated_at": "2026-06-25T12:30:00Z",
},
]
def test_app_draft_pr_lister_filters_to_agent_team_drafts() -> None:
http = _FakeHttp(get_body=_PULLS_BODY, get_status=200)
lister = app_draft_pr_lister(_StubTokenProvider(), _http=http)
rows = lister(owner="owner", repo="repo", head_prefix="agent-team/apply/")
assert [r["number"] for r in rows] == [72]
row = rows[0]
assert row["createdAt"] == "2026-06-25T16:00:00Z"
assert row["updatedAt"] == "2026-06-25T16:30:00Z"
call = http.get_calls[0]
assert call["url"].endswith("/repos/owner/repo/pulls")
assert call["params"]["state"] == "open"
assert call["headers"]["Authorization"] == "Bearer ghs_TESTTOKEN"
def test_app_draft_pr_lister_fails_soft_on_auth_error() -> None:
# A non-200 (e.g. 401/403) yields an EMPTY list (monitor no-ops) — it must
# NOT raise, unlike the dispatch seams, because a sweep cannot crash the tick.
http = _FakeHttp(get_body={}, get_status=403)
lister = app_draft_pr_lister(_StubTokenProvider(), _http=http)
assert lister(owner="o", repo="r", head_prefix="agent-team/apply/") == []
def test_app_draft_pr_lister_fails_soft_and_scrubs_token_on_transport_error() -> None:
lister = app_draft_pr_lister(_StubTokenProvider(), _http=_RaisingHttp())
# Returns [] (fail-soft); the raising transport embedded the token in its
# message, but the lister swallows it and never re-raises it.
assert lister(owner="o", repo="r", head_prefix="agent-team/apply/") == []