Coverage for src/keel/github.py: 100%
111 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""Thin, fail-soft ``gh`` (GitHub CLI) wrappers (argv, no shell).
3Like :mod:`keel.git`, these build the exact ``gh`` command for each backbone
4operation and run it via the injectable ``_run`` seam. Command construction is
5unit-tested offline; live behaviour is opt-in.
6"""
8from __future__ import annotations
10import random
11import time
12from collections.abc import Sequence
14from .runner import CommandResult, run_argv
16_TRANSIENT_PATTERNS = (
17 "rate limit",
18 "secondary rate limit",
19 "too many requests",
20 "connection reset",
21 "connection refused",
22 "could not resolve host",
23 "network is unreachable",
24 "tls handshake",
25 "ssl error",
26 "timed out",
27 "timeout",
28 "500 internal server error",
29 "502 bad gateway",
30 "503 service unavailable",
31 "504 gateway timeout",
32 "http 429",
33 "http 500",
34 "http 502",
35 "http 503",
36 "http 504",
37)
40def is_transient_error(result: CommandResult) -> bool:
41 """Return whether ``result`` failed due to a transient network or rate limit error."""
42 if result.ok:
43 return False
44 if result.timed_out:
45 return True
46 combined = f"{result.stderr} {result.stdout}".lower()
47 for pattern in _TRANSIENT_PATTERNS:
48 if pattern in combined:
49 return True
50 return False
53def run_argv_retry(
54 argv: Sequence[str],
55 *,
56 cwd: str | None = None,
57 max_attempts: int = 3,
58 backoff_factor: float = 1.0,
59 jitter: bool = True,
60 _run=None,
61 _sleep=None,
62) -> CommandResult:
63 """Execute a ``gh`` command with jittered exponential backoff on transient errors.
65 Retries only transient network, 5xx server errors, or secondary rate limit errors.
66 Deterministic and dependency-free, with injectable ``_run`` and ``_sleep`` seams
67 for offline testing at 100% line + branch coverage.
68 """
69 sleep_fn = _sleep or time.sleep
70 attempt = 1
71 while True:
72 result = run_argv(argv, cwd=cwd, **_kw(_run))
73 if result.ok or attempt >= max_attempts or not is_transient_error(result):
74 return result
75 delay = backoff_factor * (2 ** (attempt - 1))
76 if jitter:
77 delay += random.uniform(0.0, 0.5) if _sleep is None else 0.1 # nosec B311
78 sleep_fn(delay)
79 attempt += 1
82def open_pr(
83 title: str, body: str, base: str, head: str, *, cwd: str | None = None, _run=None
84) -> CommandResult:
85 return run_argv(
86 ["gh", "pr", "create", "--title", title, "--body", body, "--base", base, "--head", head],
87 cwd=cwd, **_kw(_run),
88 )
91def ci_conclusion(pr: int | str, *, cwd: str | None = None, _run=None) -> str | None:
92 """Return the PR's check-rollup state (e.g. SUCCESS/FAILURE/PENDING).
94 Three distinct answers, because collapsing them is what let a PR with **no
95 checks at all** read as clear to merge (issue #675):
97 * a conclusion string — checks reported, here is what they said
98 * ``""`` — ``gh`` answered and the rollup is **empty**: nothing ran for this
99 head. A fact about the *PR*.
100 * ``None`` — ``gh`` could not be asked. A fact about the *runner*.
102 Only the caller can weigh those, so this returns the empty string rather than
103 folding it into ``None``. :func:`keel.ship.ci_ran` reads the distinction.
105 ``statusCheckRollup`` retains every historical run of a check, not just the
106 latest — a check that failed once and was later rerun to green still carries
107 its old FAILURE conclusion in the raw list, and a freshly requeued rerun may
108 carry no timestamp at all yet. The ``--jq`` filter dedupes by check identity
109 (``context`` for legacy commit statuses, ``name`` for check runs — an empty
110 string is treated the same as absent, matching the Python-side dedupe used
111 by the merge gate) down to each check's most recent entry before collecting
112 conclusions. "Most recent" prefers an entry genuinely still in flight (no
113 ``conclusion`` yet *and* a recognized pending ``status``) over any
114 concluded one for the same check — a new run cannot be queued before the
115 previous one concluded — and otherwise compares ``completedAt``, falling
116 back to ``startedAt``. Requiring a recognized pending ``status`` (not
117 merely an absent ``conclusion``) means a malformed or unexpected payload
118 shape can never mask a genuine stale failure. This mirrors
119 :func:`keel.cli._rollup_recency`/``_PENDING_CHECK_STATES``, manually
120 verified against a real ``jq`` binary; jq output can't be exercised by
121 this module's offline unit tests (see the module docstring).
122 """
123 pending_states = (
124 "\"EXPECTED\",\"PENDING\",\"QUEUED\",\"REQUESTED\",\"WAITING\",\"IN_PROGRESS\""
125 )
126 jq = (
127 "[.statusCheckRollup[]] "
128 "| group_by("
129 "(.context | select(. != null and . != \"\")) "
130 "// (.name | select(. != null and . != \"\")) "
131 "// \"\""
132 ") "
133 "| map(max_by(["
134 "((.conclusion == null) and "
135 "((.status | if type == \"string\" then ascii_upcase else \"\" end) "
136 "| IN(" + pending_states + "))), "
137 "(.completedAt // .startedAt // \"\")"
138 "])) "
139 "| map(.conclusion // empty) "
140 "| unique | join(\",\")"
141 )
142 result = run_argv(
143 ["gh", "pr", "view", str(pr), "--json", "statusCheckRollup", "--jq", jq],
144 cwd=cwd, **_kw(_run),
145 )
146 if not result.ok:
147 return None
148 return result.stdout.strip()
151def ci_check_names(pr: int | str, *, cwd: str | None = None, _run=None) -> list[str] | None:
152 """The distinct check identities reported for ``pr``, or ``None`` when ``gh`` failed.
154 Used for the **count** an operator sees, so "0 checks" is a visible fact rather
155 than something inferred from a blank word. Identity is ``context`` for legacy
156 commit statuses and ``name`` for check runs — the same identity
157 :func:`ci_conclusion` dedupes on, so the two views agree about what "one check"
158 is. ``[]`` means the rollup is genuinely empty; ``None`` means ``gh`` could not
159 be asked.
160 """
161 jq = (
162 "[.statusCheckRollup[] "
163 "| (.context | select(. != null and . != \"\")) "
164 "// (.name | select(. != null and . != \"\")) "
165 "// empty] "
166 "| unique | .[]"
167 )
168 return _rollup_strings(pr, jq, cwd=cwd, _run=_run)
171def ci_workflow_names(pr: int | str, *, cwd: str | None = None, _run=None) -> list[str] | None:
172 """The distinct **workflow** names that reported for ``pr``, or ``None`` on failure.
174 Deliberately not :func:`ci_check_names`. ``knobs.ci_workflows`` is keyed by the
175 *workflow* name (``CI``, ``CodeQL``), but the rollup reports *job* names — a
176 matrix job appears as ``test (py3.13 / ubuntu-latest)``, never as ``CI``. Asking
177 the presence question against job names would report every declared workflow
178 missing on a repo that uses a matrix, which is most of them.
180 ``workflowName`` is what a check run carries for this; legacy commit statuses have
181 none, so they fall back to ``context``/``name`` — a project that declares a bare
182 status-check name still matches.
183 """
184 jq = (
185 "[.statusCheckRollup[] "
186 "| (.workflowName | select(. != null and . != \"\")) "
187 "// (.context | select(. != null and . != \"\")) "
188 "// (.name | select(. != null and . != \"\")) "
189 "// empty] "
190 "| unique | .[]"
191 )
192 return _rollup_strings(pr, jq, cwd=cwd, _run=_run)
195def _rollup_strings(pr, jq: str, *, cwd: str | None, _run) -> list[str] | None:
196 result = run_argv(
197 ["gh", "pr", "view", str(pr), "--json", "statusCheckRollup", "--jq", jq],
198 cwd=cwd, **_kw(_run),
199 )
200 if not result.ok:
201 return None
202 return [line.strip() for line in result.stdout.splitlines() if line.strip()]
205def merged_prs(
206 *, search: str | None = None, limit: int = 100, cwd: str | None = None, _run=None
207) -> CommandResult:
208 """List recently-merged PR numbers as a JSON array (``[{"number": N}, ...]``).
210 Thin I/O for ``capture-verify`` transport derivation: the authoritative
211 merged-PR set is read from the host instead of trusting the agent's args.
212 ``search`` narrows the set (e.g. ``"merged:>=2026-06-01"``). Fail-soft —
213 the caller inspects ``result.ok`` and degrades gracefully when offline.
214 """
215 argv = ["gh", "pr", "list", "--state", "merged", "--limit", str(limit), "--json", "number"]
216 if search:
217 argv += ["--search", search]
218 return run_argv(argv, cwd=cwd, **_kw(_run))
221def list_prs(
222 *, head: str | None = None, limit: int = 100, cwd: str | None = None, _run=None
223) -> CommandResult:
224 """List PRs (any state) as a JSON array (``[{"number": N, "headRefName": ...}, ...]``).
226 Thin I/O for dry-run integrity verification: the PRs that exist around a
227 rehearsed run are read from the host. ``head`` narrows to a specific head
228 branch. Fail-soft — the caller inspects ``result.ok`` and degrades to "no
229 PRs observed" when offline.
230 """
231 argv = [
232 "gh", "pr", "list", "--state", "all", "--limit", str(limit),
233 "--json", "number,headRefName",
234 ]
235 if head:
236 argv += ["--head", head]
237 return run_argv(argv, cwd=cwd, **_kw(_run))
240def pr_state(pr: int | str, *, cwd: str | None = None, _run=None) -> str | None:
241 """Live PR state as ``open`` / ``merged`` / ``closed``, or ``None`` when unreadable.
243 ``None`` is a fact about the **runner** (``gh`` missing, offline, no auth) and must
244 not be read as a fact about the PR — the caller maps it to ``unknown``, never to
245 ``missing``. A ``gh`` call that succeeds and reports no such PR is the only thing
246 that means the PR is gone.
247 """
248 result = run_argv(
249 ["gh", "pr", "view", str(pr), "--json", "state", "--jq", ".state"],
250 cwd=cwd, **_kw(_run),
251 )
252 if not result.ok:
253 return None
254 raw = result.stdout.strip().lower()
255 return raw if raw in ("open", "merged", "closed") else None
258def pr_files(pr: int | str, *, cwd: str | None = None, _run=None) -> list[str] | None:
259 """Paths the pull request changed — what it *meant* to change (#561).
261 Read from GitHub rather than from a local diff on purpose: after a squash-merge
262 the head branch is usually deleted, so the branch tip may not exist locally at
263 the moment this check runs. ``None`` when ``gh`` could not be asked.
264 """
265 return _lines(
266 ["gh", "pr", "view", str(pr), "--json", "files", "--jq", ".files[].path"],
267 cwd=cwd, _run=_run,
268 )
271def commit_files(sha: str, *, cwd: str | None = None, _run=None) -> list[str] | None:
272 """Paths a commit changed against its first parent — what actually *landed*.
274 For a squash-merge the commit has one parent, so this is precisely the set of
275 files the merge wrote onto the base branch. ``None`` when ``gh`` could not be
276 asked; an empty list means the commit changed nothing, which is itself a fact.
277 """
278 return _lines(
279 ["gh", "api", f"repos/{{owner}}/{{repo}}/commits/{sha}",
280 "--jq", ".files[].filename"],
281 cwd=cwd, _run=_run,
282 )
285def _lines(argv: list[str], *, cwd: str | None, _run) -> list[str] | None:
286 result = run_argv(argv, cwd=cwd, **_kw(_run))
287 if not result.ok:
288 return None
289 return [line.strip() for line in result.stdout.splitlines() if line.strip()]
292def prs_merged_between(
293 base: str, since: str, until: str, *, cwd: str | None = None, _run=None
294) -> list[int] | None:
295 """Pull request numbers merged into ``base`` in the half-open window (#561).
297 ``since``/``until`` are ISO-8601 timestamps. This is the window in which another
298 merge can land work that a branch created before ``since`` will not contain — the
299 precondition for an update-branch squash reverting it.
300 """
301 return _ints(
302 ["gh", "pr", "list", "--base", base, "--state", "merged", "--limit", "100",
303 "--json", "number,mergedAt",
304 "--jq", f'.[] | select(.mergedAt > "{since}" and .mergedAt < "{until}") | .number'],
305 cwd=cwd, _run=_run,
306 )
309def _ints(argv: list[str], *, cwd: str | None, _run) -> list[int] | None:
310 lines = _lines(argv, cwd=cwd, _run=_run)
311 if lines is None:
312 return None
313 out = []
314 for line in lines:
315 try:
316 out.append(int(line))
317 except ValueError:
318 continue
319 return out
322def pr_merge_window(pr: int | str, *, cwd: str | None = None, _run=None) -> dict | None:
323 """When a PR branched and merged, its base, and the SHA it merged as (#561).
325 ``createdAt`` stands in for the branch point. It is the conservative choice: a
326 branch is cut at or before its PR is opened, so the window can only be too wide,
327 never too narrow — a wider window over-reports rather than missing a revert.
329 ``None`` when ``gh`` cannot be asked or the PR is not merged.
330 """
331 # Named rather than written as two adjacent literals inside the argv list: an
332 # implicit concatenation there reads as a possible missing comma (CodeQL flags it),
333 # and an argv list is exactly where that ambiguity is expensive.
334 jq = '[.createdAt, .mergedAt, .baseRefName, (.mergeCommit.oid // "")] | @tsv'
335 result = run_argv(
336 ["gh", "pr", "view", str(pr), "--json",
337 "createdAt,mergedAt,baseRefName,mergeCommit", "--jq", jq],
338 cwd=cwd, **_kw(_run),
339 )
340 if not result.ok:
341 return None
342 parts = result.stdout.strip().split("\t")
343 if len(parts) != 4 or not all(parts[:3]) or not parts[3]:
344 return None
345 return {
346 "branched_at": parts[0],
347 "merged_at": parts[1],
348 "base": parts[2],
349 "merge_commit": parts[3],
350 }
353def pr_merge_snapshot(pr: int | str, *, cwd: str | None = None, _run=None) -> CommandResult:
354 return run_argv(
355 [
356 "gh", "pr", "view", str(pr),
357 "--json", "headRefOid,mergeStateStatus,statusCheckRollup",
358 ],
359 cwd=cwd, **_kw(_run),
360 )
363def merge_pr(
364 pr: int | str, *, method: str = "squash", cwd: str | None = None, _run=None
365) -> CommandResult:
366 return run_argv(["gh", "pr", "merge", str(pr), f"--{method}"], cwd=cwd, **_kw(_run))
369def comment(pr: int | str, body: str, *, cwd: str | None = None, _run=None) -> CommandResult:
370 return run_argv(["gh", "pr", "comment", str(pr), "--body", body], cwd=cwd, **_kw(_run))
373def post_issue_comment(
374 owner_repo: str,
375 issue_or_pr: int | str,
376 body: str,
377 *,
378 cwd: str | None = None,
379 _run=None,
380) -> CommandResult:
381 return run_argv(
382 [
383 "gh",
384 "api",
385 f"repos/{owner_repo}/issues/{issue_or_pr}/comments",
386 "-X",
387 "POST",
388 "-f",
389 f"body={body}",
390 ],
391 cwd=cwd,
392 **_kw(_run),
393 )
396def edit_issue_comment(
397 owner_repo: str,
398 comment_id: int | str,
399 body: str,
400 *,
401 cwd: str | None = None,
402 _run=None,
403) -> CommandResult:
404 return run_argv(
405 [
406 "gh",
407 "api",
408 f"repos/{owner_repo}/issues/comments/{comment_id}",
409 "-X",
410 "PATCH",
411 "-f",
412 f"body={body}",
413 ],
414 cwd=cwd,
415 **_kw(_run),
416 )
419def close_issue(issue: int | str, *, cwd: str | None = None, _run=None) -> CommandResult:
420 return run_argv(["gh", "issue", "close", str(issue)], cwd=cwd, **_kw(_run))
423def issue_facts(issue: int | str, *, cwd: str | None = None, _run=None) -> CommandResult:
424 """Fetch an issue's ``title`` and ``labels`` as JSON for ``keel guard``.
426 Thin I/O for blocker evaluation: the issue facts are read from the host
427 rather than trusting agent-supplied args. Fail-soft — the caller inspects
428 ``result.ok`` and falls back to offline args when offline.
429 """
430 return run_argv(
431 ["gh", "issue", "view", str(issue), "--json", "title,labels"],
432 cwd=cwd, **_kw(_run),
433 )
436def _kw(_run):
437 return {"_run": _run} if _run is not None else {}