Coverage for src/keel/github.py: 100%
246 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +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 json
11import random
12import time
13from collections.abc import Sequence
15from .runner import CommandResult, run_argv
17_TRANSIENT_PATTERNS = (
18 "rate limit",
19 "secondary rate limit",
20 "too many requests",
21 "connection reset",
22 "connection refused",
23 "could not resolve host",
24 "network is unreachable",
25 "tls handshake",
26 "ssl error",
27 "timed out",
28 "timeout",
29 "500 internal server error",
30 "502 bad gateway",
31 "503 service unavailable",
32 "504 gateway timeout",
33 "http 429",
34 "http 500",
35 "http 502",
36 "http 503",
37 "http 504",
38)
41def is_transient_error(result: CommandResult) -> bool:
42 """Return whether ``result`` failed due to a transient network or rate limit error."""
43 if result.ok:
44 return False
45 if result.timed_out:
46 return True
47 combined = f"{result.stderr} {result.stdout}".lower()
48 for pattern in _TRANSIENT_PATTERNS:
49 if pattern in combined:
50 return True
51 return False
54def run_argv_retry(
55 argv: Sequence[str],
56 *,
57 cwd: str | None = None,
58 max_attempts: int = 3,
59 backoff_factor: float = 1.0,
60 jitter: bool = True,
61 _run=None,
62 _sleep=None,
63) -> CommandResult:
64 """Execute a ``gh`` command with jittered exponential backoff on transient errors.
66 Retries only transient network, 5xx server errors, or secondary rate limit errors.
67 Deterministic and dependency-free, with injectable ``_run`` and ``_sleep`` seams
68 for offline testing at 100% line + branch coverage.
69 """
70 sleep_fn = _sleep or time.sleep
71 attempt = 1
72 while True:
73 result = run_argv(argv, cwd=cwd, **_kw(_run))
74 if result.ok or attempt >= max_attempts or not is_transient_error(result):
75 return result
76 delay = backoff_factor * (2 ** (attempt - 1))
77 if jitter:
78 delay += random.uniform(0.0, 0.5) if _sleep is None else 0.1 # nosec B311
79 sleep_fn(delay)
80 attempt += 1
83def open_pr(
84 title: str, body: str, base: str, head: str, *, cwd: str | None = None, _run=None
85) -> CommandResult:
86 return run_argv(
87 ["gh", "pr", "create", "--title", title, "--body", body, "--base", base, "--head", head],
88 cwd=cwd,
89 **_kw(_run),
90 )
93def ci_conclusion(pr: int | str, *, cwd: str | None = None, _run=None) -> str | None:
94 """Return the PR's check-rollup state (e.g. SUCCESS/FAILURE/PENDING).
96 Three distinct answers, because collapsing them is what let a PR with **no
97 checks at all** read as clear to merge (issue #675):
99 * a conclusion string — checks reported, here is what they said
100 * ``""`` — ``gh`` answered and the rollup is **empty**: nothing ran for this
101 head. A fact about the *PR*.
102 * ``None`` — ``gh`` could not be asked. A fact about the *runner*.
104 Only the caller can weigh those, so this returns the empty string rather than
105 folding it into ``None``. :func:`keel.ship.ci_ran` reads the distinction.
107 ``statusCheckRollup`` retains every historical run of a check, not just the
108 latest — a check that failed once and was later rerun to green still carries
109 its old FAILURE conclusion in the raw list, and a freshly requeued rerun may
110 carry no timestamp at all yet. The ``--jq`` filter dedupes by check identity
111 (``context`` for legacy commit statuses, ``name`` for check runs — an empty
112 string is treated the same as absent, matching the Python-side dedupe used
113 by the merge gate) down to each check's most recent entry before collecting
114 conclusions. "Most recent" prefers an entry genuinely still in flight (no
115 ``conclusion`` yet *and* a recognized pending ``status``) over any
116 concluded one for the same check — a new run cannot be queued before the
117 previous one concluded — and otherwise compares ``completedAt``, falling
118 back to ``startedAt``. Requiring a recognized pending ``status`` (not
119 merely an absent ``conclusion``) means a malformed or unexpected payload
120 shape can never mask a genuine stale failure. This mirrors
121 :func:`keel.cli._rollup_recency`/``_PENDING_CHECK_STATES``, manually
122 verified against a real ``jq`` binary; jq output can't be exercised by
123 this module's offline unit tests (see the module docstring).
124 """
125 pending_states = '"EXPECTED","PENDING","QUEUED","REQUESTED","WAITING","IN_PROGRESS"'
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,
145 **_kw(_run),
146 )
147 if not result.ok:
148 return None
149 return result.stdout.strip()
152def ci_check_names(pr: int | str, *, cwd: str | None = None, _run=None) -> list[str] | None:
153 """The distinct check identities reported for ``pr``, or ``None`` when ``gh`` failed.
155 Used for the **count** an operator sees, so "0 checks" is a visible fact rather
156 than something inferred from a blank word. Identity is ``context`` for legacy
157 commit statuses and ``name`` for check runs — the same identity
158 :func:`ci_conclusion` dedupes on, so the two views agree about what "one check"
159 is. ``[]`` means the rollup is genuinely empty; ``None`` means ``gh`` could not
160 be asked.
161 """
162 jq = (
163 "[.statusCheckRollup[] "
164 '| (.context | select(. != null and . != "")) '
165 '// (.name | select(. != null and . != "")) '
166 "// empty] "
167 "| unique | .[]"
168 )
169 return _rollup_strings(pr, jq, cwd=cwd, _run=_run)
172def ci_workflow_names(pr: int | str, *, cwd: str | None = None, _run=None) -> list[str] | None:
173 """The distinct **workflow** names that reported for ``pr``, or ``None`` on failure.
175 Deliberately not :func:`ci_check_names`. ``knobs.ci_workflows`` is keyed by the
176 *workflow* name (``CI``, ``CodeQL``), but the rollup reports *job* names — a
177 matrix job appears as ``test (py3.13 / ubuntu-latest)``, never as ``CI``. Asking
178 the presence question against job names would report every declared workflow
179 missing on a repo that uses a matrix, which is most of them.
181 ``workflowName`` is what a check run carries for this; legacy commit statuses have
182 none, so they fall back to ``context``/``name`` — a project that declares a bare
183 status-check name still matches.
184 """
185 jq = (
186 "[.statusCheckRollup[] "
187 '| (.workflowName | select(. != null and . != "")) '
188 '// (.context | select(. != null and . != "")) '
189 '// (.name | select(. != null and . != "")) '
190 "// empty] "
191 "| unique | .[]"
192 )
193 return _rollup_strings(pr, jq, cwd=cwd, _run=_run)
196def _rollup_strings(pr, jq: str, *, cwd: str | None, _run) -> list[str] | None:
197 result = run_argv(
198 ["gh", "pr", "view", str(pr), "--json", "statusCheckRollup", "--jq", jq],
199 cwd=cwd,
200 **_kw(_run),
201 )
202 if not result.ok:
203 return None
204 return [line.strip() for line in result.stdout.splitlines() if line.strip()]
207def merged_prs(
208 *, search: str | None = None, limit: int = 100, cwd: str | None = None, _run=None
209) -> CommandResult:
210 """List recently-merged PR numbers as a JSON array (``[{"number": N}, ...]``).
212 Thin I/O for ``capture-verify`` transport derivation: the authoritative
213 merged-PR set is read from the host instead of trusting the agent's args.
214 ``search`` narrows the set (e.g. ``"merged:>=2026-06-01"``). Fail-soft —
215 the caller inspects ``result.ok`` and degrades gracefully when offline.
216 """
217 argv = ["gh", "pr", "list", "--state", "merged", "--limit", str(limit), "--json", "number"]
218 if search:
219 argv += ["--search", search]
220 return run_argv(argv, cwd=cwd, **_kw(_run))
223def list_prs(
224 *, head: str | None = None, limit: int = 100, cwd: str | None = None, _run=None
225) -> CommandResult:
226 """List PRs (any state) as a JSON array (``[{"number": N, "headRefName": ...}, ...]``).
228 Thin I/O for dry-run integrity verification: the PRs that exist around a
229 rehearsed run are read from the host. ``head`` narrows to a specific head
230 branch. Fail-soft — the caller inspects ``result.ok`` and degrades to "no
231 PRs observed" when offline.
232 """
233 argv = [
234 "gh",
235 "pr",
236 "list",
237 "--state",
238 "all",
239 "--limit",
240 str(limit),
241 "--json",
242 "number,headRefName",
243 ]
244 if head:
245 argv += ["--head", head]
246 return run_argv(argv, cwd=cwd, **_kw(_run))
249def pr_state(pr: int | str, *, cwd: str | None = None, _run=None) -> str | None:
250 """Live PR state as ``open`` / ``merged`` / ``closed``, or ``None`` when unreadable.
252 ``None`` is a fact about the **runner** (``gh`` missing, offline, no auth) and must
253 not be read as a fact about the PR — the caller maps it to ``unknown``, never to
254 ``missing``. A ``gh`` call that succeeds and reports no such PR is the only thing
255 that means the PR is gone.
256 """
257 result = run_argv(
258 ["gh", "pr", "view", str(pr), "--json", "state", "--jq", ".state"],
259 cwd=cwd,
260 **_kw(_run),
261 )
262 if not result.ok:
263 return None
264 raw = result.stdout.strip().lower()
265 return raw if raw in ("open", "merged", "closed") else None
268def pr_files(pr: int | str, *, cwd: str | None = None, _run=None, _sleep=None) -> list[str] | None:
269 """Paths the pull request changed — what it *meant* to change (#561).
271 Read from GitHub rather than from a local diff on purpose: after a squash-merge
272 the head branch is usually deleted, so the branch tip may not exist locally at
273 the moment this check runs. ``None`` when ``gh`` could not be asked.
274 """
275 return _lines(
276 ["gh", "pr", "view", str(pr), "--json", "files", "--jq", ".files[].path"],
277 cwd=cwd,
278 _run=_run,
279 _sleep=_sleep,
280 )
283def commit_files(sha: str, *, cwd: str | None = None, _run=None, _sleep=None) -> list[str] | None:
284 """Paths a commit changed against its first parent — what actually *landed*.
286 For a squash-merge the commit has one parent, so this is precisely the set of
287 files the merge wrote onto the base branch. ``None`` when ``gh`` could not be
288 asked; an empty list means the commit changed nothing, which is itself a fact.
289 """
290 return _lines(
291 ["gh", "api", f"repos/{{owner}}/{{repo}}/commits/{sha}", "--jq", ".files[].filename"],
292 cwd=cwd,
293 _run=_run,
294 _sleep=_sleep,
295 )
298def _lines(argv: list[str], *, cwd: str | None, _run, _sleep=None) -> list[str] | None:
299 """Read newline-separated ``gh`` output, retrying transient failures.
301 Routed through :func:`run_argv_retry` rather than :func:`run_argv`: the retry
302 was written, tested, and called by nothing (#938). These are the reads it was
303 written for — ``pr_files`` and ``commit_files`` — and one
304 ``keel verify-merge`` run makes ``4 + N`` of
305 them, N being the pull requests merged in the window (5–25 in this repo). Since
306 #936 made an unreadable input exit 2 instead of quietly passing, a single blip
307 on the busiest day produces a loud wrong answer, and a gate that cries wolf is
308 a gate that gets bypassed.
310 Retries only transient errors, so a persistent failure still returns ``None``
311 and still becomes ``unknown``. The retry must not become a slower way to
312 fail open.
313 """
314 result = run_argv_retry(argv, cwd=cwd, _run=_run, _sleep=_sleep)
315 if not result.ok:
316 return None
317 return [line.strip() for line in result.stdout.splitlines() if line.strip()]
320#: How far back one `gh pr list` page reaches. A window older than this many
321#: merges is reported as unreadable rather than empty — see #937 in the docstring
322#: below. Named so the ceiling is one place to read and one place to change.
323MERGED_PAGE_LIMIT = 100
326def prs_merged_between(
327 base: str,
328 since: str,
329 until: str,
330 *,
331 cwd: str | None = None,
332 _run=None,
333 _sleep=None,
334 limit: int = MERGED_PAGE_LIMIT,
335) -> list[int] | None:
336 """Pull request numbers merged into ``base`` in the half-open window (#561).
338 ``since``/``until`` are ISO-8601 timestamps. This is the window in which another
339 merge can land work that a branch created before ``since`` will not contain — the
340 precondition for an update-branch squash reverting it.
342 ``None`` when the answer could not be read — including when the page came back
343 **truncated**, which is #933's rule reached by a different mechanism (#937).
344 ``gh pr list`` returns the *newest* N merges, and the window filter used to run
345 inside ``--jq``, after that cut. On a repository where more than ``limit``
346 pull requests merged since the window closed, the window's merges are not in
347 the page at all, the filter matches nothing, and an empty list reads as
348 "nothing overtook this merge" — a successful read that saw none of the answer.
350 Live here, not latent: this repo has ~500 merged pull requests and
351 ``docs/keel/cli.md`` documents a retrospective ``verify-merge --pr 543`` on a
352 pull request from a previous month, which is exactly the shape that trips it.
354 Truncation is detectable because the page is newest-first: if it came back
355 **full** and its oldest entry still merged at or after ``since``, the page
356 never reached back far enough to contain the window. Paginating would also
357 work, but on a repo this size a retrospective check would walk hundreds of
358 pull requests to answer one question; saying "I could not see that far" is
359 honest and cheap. Raising ``limit`` is a separate tuning decision.
361 The filter therefore runs in Python rather than ``--jq``: the truncation
362 check needs the raw ``mergedAt`` values, which ``--jq`` had already discarded.
363 """
364 rows = _json_rows(
365 [
366 "gh",
367 "pr",
368 "list",
369 "--base",
370 base,
371 "--state",
372 "merged",
373 "--limit",
374 str(limit),
375 "--json",
376 "number,mergedAt",
377 ],
378 cwd=cwd,
379 _run=_run,
380 _sleep=_sleep,
381 )
382 if rows is None:
383 return None
384 merged_at = [str(row.get("mergedAt") or "") for row in rows]
385 if len(rows) >= limit and merged_at and min(merged_at) >= since:
386 # Full page whose oldest entry is still inside/after the window: the read
387 # succeeded and saw only part of the answer, which must not read as clean.
388 return None
389 return [
390 int(row["number"])
391 for row in rows
392 if isinstance(row.get("number"), int) and since < str(row.get("mergedAt") or "") < until
393 ]
396def _json_rows(argv: list[str], *, cwd: str | None, _run, _sleep=None) -> list[dict] | None:
397 """Parse ``gh --json`` output into rows, or ``None`` when it could not be read."""
398 result = run_argv_retry(argv, cwd=cwd, _run=_run, _sleep=_sleep)
399 if not result.ok:
400 return None
401 try:
402 data = json.loads(result.stdout or "[]")
403 except ValueError:
404 return None
405 return data if isinstance(data, list) else None
408def _merge_window_fields(result: CommandResult) -> list[str]:
409 """The four TSV fields of a ``pr_merge_window`` read, trailing blank preserved.
411 Trims newlines only. ``str.strip()`` also removes the trailing tab, so a merged
412 PR whose ``mergeCommit.oid`` has not appeared yet arrives as three fields and
413 reads as malformed rather than as "not settled yet" — which is precisely the
414 state the poll above exists to recognise (#938).
415 """
416 return result.stdout.strip("\r\n").split("\t")
419#: Reads of a just-merged PR before GitHub has populated ``mergeCommit.oid``.
420#: Three attempts a second apart: the field settles in well under a second in
421#: practice, and the cap matters more than the ceiling — this runs immediately
422#: after an irreversible merge, so it must give up rather than hang (#938).
423MERGE_COMMIT_POLL_ATTEMPTS = 3
424MERGE_COMMIT_POLL_DELAY_S = 1.0
427def pr_merge_window(
428 pr: int | str, *, cwd: str | None = None, _run=None, _sleep=None
429) -> dict | None:
430 """When a PR branched and merged, its base, and the SHA it merged as (#561).
432 ``createdAt`` stands in for the branch point. It is the conservative choice: a
433 branch is cut at or before its PR is opened, so the window can only be too wide,
434 never too narrow — a wider window over-reports rather than missing a revert.
436 ``None`` when ``gh`` cannot be asked or the PR is not merged.
438 A merged PR whose ``mergeCommit.oid`` has not appeared yet is **polled** for,
439 briefly, rather than read as absent. ``ship.md`` tells the operator to run the
440 drift check "immediately after a successful merge" — the one moment the field
441 is least likely to be populated — and since #936 an unreadable input exits 2.
442 Without the poll the runbook's own timing is the most frequent trigger of the
443 loud path, and the shipped remedy is a sentence of prose asking an agent to
444 retry (#938). Bounded, and giving up still yields ``None``: the poll must not
445 become a way to eventually pass.
446 """
447 # Named rather than written as two adjacent literals inside the argv list: an
448 # implicit concatenation there reads as a possible missing comma (CodeQL flags it),
449 # and an argv list is exactly where that ambiguity is expensive.
450 jq = '[.createdAt, .mergedAt, .baseRefName, (.mergeCommit.oid // "")] | @tsv'
451 argv = [
452 "gh",
453 "pr",
454 "view",
455 str(pr),
456 "--json",
457 "createdAt,mergedAt,baseRefName,mergeCommit",
458 "--jq",
459 jq,
460 ]
461 sleep_fn = _sleep or time.sleep
462 for attempt in range(1, MERGE_COMMIT_POLL_ATTEMPTS + 1):
463 result = run_argv_retry(argv, cwd=cwd, _run=_run, _sleep=_sleep)
464 # Only newlines are trimmed. `.strip()` also eats the trailing tab, which is
465 # the very field being waited on — an empty SHA would arrive as three fields
466 # and read as malformed rather than as "not settled yet".
467 parts = _merge_window_fields(result) if result.ok else []
468 # Poll only the settling case: merged (first three fields present) with the
469 # SHA still empty. An unmerged PR or an unreadable `gh` is a real answer, and
470 # waiting on either would just make every such call three seconds slower.
471 settling = len(parts) == 4 and all(parts[:3]) and not parts[3]
472 if not settling:
473 break
474 if attempt < MERGE_COMMIT_POLL_ATTEMPTS:
475 sleep_fn(MERGE_COMMIT_POLL_DELAY_S)
476 if not result.ok:
477 return None
478 parts = _merge_window_fields(result)
479 if len(parts) != 4 or not all(parts[:3]) or not parts[3]:
480 return None
481 return {
482 "branched_at": parts[0],
483 "merged_at": parts[1],
484 "base": parts[2],
485 "merge_commit": parts[3],
486 }
489def pr_merge_snapshot(pr: int | str, *, cwd: str | None = None, _run=None) -> CommandResult:
490 return run_argv(
491 [
492 "gh",
493 "pr",
494 "view",
495 str(pr),
496 "--json",
497 "headRefOid,mergeStateStatus,statusCheckRollup",
498 ],
499 cwd=cwd,
500 **_kw(_run),
501 )
504def merge_pr(
505 pr: int | str,
506 *,
507 method: str = "squash",
508 head_sha: str | None = None,
509 cwd: str | None = None,
510 _run=None,
511) -> CommandResult:
512 """Merge the pull request over GraphQL, pinned to ``head_sha`` when one is known.
514 ``--match-head-commit`` is this transport's spelling of the pin :func:`rest_merge_pr`
515 sends as ``sha``: GitHub refuses the merge if the head is not that commit. Without it
516 the head could move between the checks `keel merge` ran and this call, and the merge
517 would land whatever it moved to.
518 """
519 argv = ["gh", "pr", "merge", str(pr), f"--{method}"]
520 if head_sha:
521 argv += ["--match-head-commit", head_sha]
522 return run_argv(argv, cwd=cwd, **_kw(_run))
525def comment(pr: int | str, body: str, *, cwd: str | None = None, _run=None) -> CommandResult:
526 return run_argv(["gh", "pr", "comment", str(pr), "--body", body], cwd=cwd, **_kw(_run))
529def post_issue_comment(
530 owner_repo: str,
531 issue_or_pr: int | str,
532 body: str,
533 *,
534 cwd: str | None = None,
535 _run=None,
536) -> CommandResult:
537 return run_argv(
538 [
539 "gh",
540 "api",
541 f"repos/{owner_repo}/issues/{issue_or_pr}/comments",
542 "-X",
543 "POST",
544 "-F",
545 f"body={body}",
546 ],
547 cwd=cwd,
548 **_kw(_run),
549 )
552def edit_issue_comment(
553 owner_repo: str,
554 comment_id: int | str,
555 body: str,
556 *,
557 cwd: str | None = None,
558 _run=None,
559) -> CommandResult:
560 return run_argv(
561 [
562 "gh",
563 "api",
564 f"repos/{owner_repo}/issues/comments/{comment_id}",
565 "-X",
566 "PATCH",
567 "-F",
568 f"body={body}",
569 ],
570 cwd=cwd,
571 **_kw(_run),
572 )
575def close_issue(
576 issue: int | str,
577 *,
578 cwd: str | None = None,
579 repo: str | None = None,
580 reason: str | None = None,
581 _run=None,
582) -> CommandResult:
583 """``gh issue close``; ``repo`` names the repository, ``reason`` the state reason.
585 ``swarm-land`` closes a landed cluster's issues as ``completed`` in the configured
586 ``owner/repo`` (#1422) rather than whatever repository the checkout's remote names.
587 """
588 argv = ["gh", "issue", "close", str(issue)]
589 if repo:
590 argv += ["--repo", repo]
591 if reason:
592 argv += ["--reason", reason]
593 return run_argv(argv, cwd=cwd, **_kw(_run))
596def issue_facts(
597 issue: int | str,
598 *,
599 cwd: str | None = None,
600 fields: str = "title,labels",
601 _run=None,
602) -> CommandResult:
603 """Fetch an issue's ``title`` and ``labels`` as JSON for ``keel guard``.
605 Thin I/O for blocker evaluation: the issue facts are read from the host
606 rather than trusting agent-supplied args. Fail-soft — the caller inspects
607 ``result.ok`` and falls back to offline args when offline.
609 ``fields`` widens the same call for the capture sink, which needs the body
610 too. It stays a parameter rather than a second function so both readers make
611 the identical request and a change to one is a change to both.
612 """
613 return run_argv(
614 ["gh", "issue", "view", str(issue), "--json", fields],
615 cwd=cwd,
616 **_kw(_run),
617 )
620#: Labels read in one page. GitHub's default is 30; a policy pack plus keel's
621#: attribution vocabulary exceeds that on any real repository, and a truncated
622#: listing would report labels as missing that exist.
623LABEL_PAGE_LIMIT = 500
626def label_list_argv(repo: str | None = None) -> list[str]:
627 """The exact ``gh label list`` command ``keel doctor`` runs (pure, for tests)."""
628 argv = ["gh", "label", "list", "--limit", str(LABEL_PAGE_LIMIT), "--json", "name"]
629 return argv + ["--repo", repo] if repo else argv
632def list_labels(*, repo: str | None = None, cwd: str | None = None, _run=None) -> CommandResult:
633 """List a repository's labels as JSON. Fail-soft: the caller reads ``result.ok``."""
634 return run_argv(label_list_argv(repo), cwd=cwd, **_kw(_run))
637#: Seconds ``keel doctor`` waits for ``gh auth status`` — one round trip to GitHub.
638AUTH_STATUS_TIMEOUT_S = 10
641def auth_status(*, cwd: str | None = None, _run=None) -> CommandResult:
642 """``gh auth status``: is ``gh`` logged in? Fail-soft; the caller reads ``result.ok``.
644 Read-only (no ``--show-token``), so its output names the account, never the token.
645 """
646 return run_argv(["gh", "auth", "status"], cwd=cwd, timeout=AUTH_STATUS_TIMEOUT_S, **_kw(_run))
649def label_create_argv(name: str, repo: str | None = None) -> list[str]:
650 """The exact ``gh label create`` command for one label.
652 Built here, in the one module that owns keel's ``gh`` command shapes, so the command
653 ``keel doctor`` *prints* for an operator to paste and the command ``--fix`` *runs*
654 are the same string and cannot drift apart.
655 """
656 argv = ["gh", "label", "create", name]
657 return argv + ["--repo", repo] if repo else argv
660def create_label(
661 name: str, *, repo: str | None = None, cwd: str | None = None, _run=None
662) -> CommandResult:
663 """Create one repository label. Mutating — callers gate it on operator consent."""
664 return run_argv(label_create_argv(name, repo), cwd=cwd, **_kw(_run))
667def issue_labels_add_argv(owner_repo: str, number: int, labels: Sequence[str]) -> list[str]:
668 """The exact REST call that adds ``labels`` to issue or pull request ``number``.
670 ``-f``, never ``-F``: ``-F`` reads a value starting with ``@`` as a file path, and a
671 label is a name, not a file. Over REST, like :func:`post_issue_comment`, so a host that
672 blocks GraphQL can still label the pull request it opened.
673 """
674 argv = ["gh", "api", f"repos/{owner_repo}/issues/{number}/labels", "-X", "POST"]
675 for label in labels:
676 argv += ["-f", f"labels[]={label}"]
677 return argv
680def add_issue_labels(
681 owner_repo: str,
682 number: int,
683 labels: Sequence[str],
684 *,
685 cwd: str | None = None,
686 _run=None,
687) -> CommandResult:
688 """Add ``labels`` to issue or pull request ``number``. Mutating, and fail-soft."""
689 return run_argv(issue_labels_add_argv(owner_repo, number, labels), cwd=cwd, **_kw(_run))
692def _kw(_run):
693 return {"_run": _run} if _run is not None else {}
696# --- REST transport (#1175) -------------------------------------------------
697#
698# `gh pr view --json` and `gh pr merge` both go over GitHub's **GraphQL** endpoint.
699# On a host whose egress proxy allows the REST API and blocks GraphQL — the remote
700# environment #1169/#1170/#1171 were shipped from — every one of them fails before
701# `keel merge` reaches its claim, its window re-check, its CI rollup or its evidence
702# verification, and the operator is pushed off the only sanctioned merge path onto a
703# hand-driven squash. These are the same reads and the same write, asked over REST.
704#
705# `gh api` expands `{owner}` and `{repo}` from the checkout's own remote, so none of
706# this needs the project config — which matters for `keel verify-merge`, whose whole
707# contract is that it reads GitHub and nothing else.
709#: A query that costs nothing and proves only that the endpoint answers at all.
710_GRAPHQL_PROBE = ["gh", "api", "graphql", "-f", "query=query{__typename}"]
713def graphql_available(*, cwd: str | None = None, _run=None) -> bool:
714 """Can this host reach GitHub's GraphQL endpoint?
716 Probed once per run and reused, because the answer is a property of the *host*
717 (its proxy) rather than of any pull request. A blocked endpoint fails the probe
718 the same way it fails a real query — a non-zero exit, usually carrying a proxy's
719 403 or 405 — so the probe needs no special-casing of the reason.
721 Deliberately **not** a fallback after a failed call. Reads could retry safely;
722 `gh pr merge` cannot. A merge that failed for an unknown reason may or may not
723 have landed, and re-driving it over a second transport is how a pull request gets
724 merged twice. The transport is chosen before anything is attempted.
725 """
726 return run_argv(_GRAPHQL_PROBE, cwd=cwd, **_kw(_run)).ok
729def rest_pull(pr: int | str, *, cwd: str | None = None, _run=None) -> CommandResult:
730 """The pull request as REST returns it (``head.sha``, ``mergeable_state``, …)."""
731 return run_argv(["gh", "api", f"repos/{{owner}}/{{repo}}/pulls/{pr}"], cwd=cwd, **_kw(_run))
734def rest_check_runs(sha: str, *, cwd: str | None = None, _run=None) -> CommandResult:
735 """Check runs for one commit — the Actions half of a status-check rollup."""
736 return run_argv(
737 [
738 "gh",
739 "api",
740 "--paginate",
741 f"repos/{{owner}}/{{repo}}/commits/{sha}/check-runs?per_page=100",
742 ],
743 cwd=cwd,
744 **_kw(_run),
745 )
748def rest_commit_statuses(sha: str, *, cwd: str | None = None, _run=None) -> CommandResult:
749 """Commit statuses for one commit — the other half, for non-Actions CI."""
750 return run_argv(
751 [
752 "gh",
753 "api",
754 "--paginate",
755 f"repos/{{owner}}/{{repo}}/commits/{sha}/statuses?per_page=100",
756 ],
757 cwd=cwd,
758 **_kw(_run),
759 )
762def rest_json(result: CommandResult) -> object | None:
763 """Parse a `gh api` body, tolerating `--paginate`'s concatenated pages."""
764 if not result.ok:
765 return None
766 text = (result.stdout or "").strip()
767 if not text:
768 return None
769 try:
770 return json.loads(text)
771 except json.JSONDecodeError:
772 pass
773 # `gh api --paginate` concatenates one JSON document per page rather than merging
774 # them, so a two-page read is `[…][…]` and is not a document at all. Decoding them
775 # one at a time is what the flag actually returns, and a reader that only tried
776 # `json.loads` saw the second page as a syntax error and reported "no checks".
777 decoder, index, pages = json.JSONDecoder(), 0, []
778 while index < len(text):
779 try:
780 page, index = decoder.raw_decode(text, index)
781 except json.JSONDecodeError:
782 return None
783 pages.append(page)
784 while index < len(text) and text[index] in " \t\r\n":
785 index += 1
786 # `pages` cannot be empty here: `text` is non-empty and stripped, so the loop ran at
787 # least once and either appended a page or returned. A guard for it would be a branch
788 # no input can take, which is a claim about the code that the tests cannot check.
789 if all(isinstance(page, list) for page in pages):
790 return [item for page in pages for item in page]
791 return pages[0] if len(pages) == 1 else pages
794def _check_run_rows(payload: object) -> list[dict]:
795 """The check runs inside a `commits/<sha>/check-runs` body, across every page.
797 That endpoint answers with an **object** — ``{"total_count": N, "check_runs": [...]}``
798 — not with a bare array, and `gh api --paginate` concatenates one such object per
799 page. So a head with more than a hundred checks arrives as ``{…}{…}``, which
800 :func:`rest_json` correctly reports as *a list of two page objects*.
802 Read as a list of check runs, those two objects became two entries with no ``name``,
803 no ``status`` and no ``conclusion`` — neither a failure nor pending, so the reducer
804 counted them as checks that had reported and returned **pass**. A merge gate handed
805 an all-green rollup for a head whose hundred-odd real checks were never looked at.
807 Only the documented shape contributes. A payload that is neither the object nor
808 pages of it yields nothing, and the caller treats an empty rollup as *no checks have
809 reported*, which refuses a non-docs merge rather than passing it.
810 """
811 pages = payload if isinstance(payload, list) else [payload]
812 rows: list[dict] = []
813 for page in pages:
814 if not isinstance(page, dict):
815 continue
816 runs = page.get("check_runs")
817 if isinstance(runs, list):
818 rows.extend(run for run in runs if isinstance(run, dict))
819 return rows
822def rest_rollup(check_runs: object, statuses: object) -> list[dict]:
823 """The two REST payloads in the shape ``statusCheckRollup`` has.
825 Pure, and a translation rather than a judgement: the rollup reducer reads
826 ``name``/``context``, ``status``, ``conclusion`` and the two timestamps, and
827 GraphQL spells those in SCREAMING_CASE where REST spells them in lower. Keeping
828 the translation here means the reducer, the dedupe and the recency ordering are
829 one implementation asked the same question over either transport, instead of two
830 that agree until they do not.
832 Commit statuses are carried through with their ``context`` and ``state``, which is
833 what the GraphQL rollup returns for a ``StatusContext`` and what the reducer reads
834 from one (#1202). They are included rather than dropped for two reasons: a failing
835 status has to be able to *fail* a merge, and even a passing one makes the rollup
836 non-empty, which is the difference between ``pass`` and ``no-checks``.
837 """
838 entries: list[dict] = []
839 # `_check_run_rows` has already dropped every non-object, so nothing is re-checked
840 # here: a guard no input can trip is a claim about the data the tests cannot make.
841 for run in _check_run_rows(check_runs):
842 entries.append(
843 {
844 "name": run.get("name"),
845 "status": _upper(run.get("status")),
846 "conclusion": _upper(run.get("conclusion")),
847 "startedAt": run.get("started_at"),
848 "completedAt": run.get("completed_at"),
849 }
850 )
851 for row in _status_rows(statuses):
852 # **Carried through, not translated.** A commit status keeps its verdict in
853 # `state`, exactly as the GraphQL rollup returns one, because the reducer reads
854 # that field now (#1202). #1175 translated it here instead, which worked and left
855 # the two wires speaking different shapes — and the GraphQL one, which is the
856 # default nearly every run takes, still counted a *failing* status as a check
857 # that had reported. The timestamps come along because dedupe orders by them.
858 entries.append(
859 {
860 "context": row.get("context"),
861 "state": _upper(row.get("state")),
862 "completedAt": row.get("updated_at"),
863 "startedAt": row.get("created_at"),
864 }
865 )
866 return entries
869def _status_rows(payload: object) -> list[dict]:
870 """The rows of a `commits/<sha>/statuses` body, across every page.
872 That endpoint *does* answer with an array, so `--paginate` yields a list of pages —
873 which :func:`rest_json` flattens — or a single page's list. Both arrive here as a
874 list of row objects; anything else contributes nothing.
875 """
876 rows = payload if isinstance(payload, list) else []
877 return [row for row in rows if isinstance(row, dict)]
880def _upper(value: object) -> str | None:
881 """``value`` upper-cased when it is a string; ``None`` otherwise.
883 REST says ``in_progress`` and ``success`` where GraphQL says ``IN_PROGRESS`` and
884 ``SUCCESS``; the two differ in case alone, so upper-casing *is* the translation.
885 """
886 return value.upper() if isinstance(value, str) else None
889def rest_merge_pr(
890 pr: int | str,
891 *,
892 method: str = "squash",
893 head_sha: str | None = None,
894 cwd: str | None = None,
895 _run=None,
896) -> CommandResult:
897 """Merge the pull request over REST, pinned to ``head_sha`` when one is known.
899 ``sha`` is REST's own head-pin: the merge is refused if the pull request has moved
900 since it was read. `gh pr merge` applies none by default; :func:`merge_pr` passes
901 ``--match-head-commit`` for the same pin, so the two transports are equally strict —
902 deliberately, because a merge is the one operation here that cannot be taken back.
903 """
904 argv = [
905 "gh",
906 "api",
907 "-X",
908 "PUT",
909 f"repos/{{owner}}/{{repo}}/pulls/{pr}/merge",
910 "-f",
911 f"merge_method={method}",
912 ]
913 if head_sha:
914 argv += ["-f", f"sha={head_sha}"]
915 return run_argv(argv, cwd=cwd, **_kw(_run))
918def rest_pr_files(
919 pr: int | str, *, cwd: str | None = None, _run=None, _sleep=None
920) -> list[str] | None:
921 """Paths the pull request changed, over REST. ``None`` when unreadable."""
922 return _lines(
923 [
924 "gh",
925 "api",
926 "--paginate",
927 f"repos/{{owner}}/{{repo}}/pulls/{pr}/files?per_page=100",
928 "--jq",
929 ".[].filename",
930 ],
931 cwd=cwd,
932 _run=_run,
933 _sleep=_sleep,
934 )
937def _rest_commit_on_branch(sha: str, base: str, *, cwd: str | None, _run) -> bool:
938 """Is ``sha`` an ancestor of ``base`` — that is, did this commit actually land?
940 ``compare/<base>...<sha>`` answers ``identical`` when they are the same commit and
941 ``behind`` when ``sha`` is reachable from ``base``; ``ahead`` and ``diverged`` are
942 the speculative test merge, which exists as an object and is on no branch.
943 """
944 if not sha or not base:
945 return False
946 result = run_argv(
947 [
948 "gh",
949 "api",
950 f"repos/{{owner}}/{{repo}}/compare/{base}...{sha}",
951 "--jq",
952 ".status",
953 ],
954 cwd=cwd,
955 **_kw(_run),
956 )
957 return result.ok and result.stdout.strip() in ("identical", "behind")
960def rest_pr_merge_window(
961 pr: int | str, *, cwd: str | None = None, _run=None, _sleep=None
962) -> dict | None:
963 """``branched_at`` / ``merged_at`` / ``base`` / ``merge_commit`` over REST.
965 The same four fields :func:`pr_merge_window` returns, and the same settling poll:
966 a pull request reports ``merged_at`` before ``merge_commit_sha`` is populated, and
967 reading it in that instant is how the drift check comes back "no merge commit yet"
968 for a merge that had just happened.
969 """
970 sleep_fn = _sleep or time.sleep
971 for attempt in range(1, MERGE_COMMIT_POLL_ATTEMPTS + 1):
972 payload = rest_json(
973 run_argv_retry(
974 ["gh", "api", f"repos/{{owner}}/{{repo}}/pulls/{pr}"],
975 cwd=cwd,
976 _run=_run,
977 _sleep=_sleep,
978 )
979 )
980 if not isinstance(payload, dict):
981 return None
982 window = {
983 "branched_at": str(payload.get("created_at") or ""),
984 "merged_at": str(payload.get("merged_at") or ""),
985 "base": str((payload.get("base") or {}).get("ref") or ""),
986 "merge_commit": str(payload.get("merge_commit_sha") or ""),
987 }
988 # **Reachability, not presence.** REST's `merge_commit_sha` is not empty while a
989 # pull request is open: GitHub documents it as the *speculative test-merge* SHA
990 # (`refs/pull/<n>/merge`) until the merge lands, when it becomes the commit that
991 # actually landed. So "merged and the field is filled" is true on the first
992 # post-merge read even while the cached test SHA is still being served — and the
993 # drift check would then judge the test merge instead of the squash. GraphQL's
994 # `mergeCommit.oid` is null until the real commit exists, which is why the same
995 # poll is correct there and not here.
996 settling = window["merged_at"] and not _rest_commit_on_branch(
997 window["merge_commit"], window["base"], cwd=cwd, _run=_run
998 )
999 if settling:
1000 if attempt < MERGE_COMMIT_POLL_ATTEMPTS:
1001 sleep_fn(MERGE_COMMIT_POLL_DELAY_S)
1002 continue
1003 # Still unsettled when the budget runs out: the SHA on offer is one this
1004 # check could not find on the base branch, so it is not an answer. Returning
1005 # it anyway is how the drift read would judge the speculative test merge.
1006 return None
1007 # **All four, exactly as the GraphQL reader requires.** REST answers an
1008 # *unmerged* pull request with `created_at` and `base.ref` and a null
1009 # `merged_at`, so "any field present" reported a window for one — and a caller
1010 # that supplied `--merge-sha` then went on to judge drift on a merge that had
1011 # not happened, where the same call over GraphQL says `unknown`.
1012 return window if all(window.values()) else None
1013 return None # pragma: no cover - the loop always returns on its last attempt
1016def rest_prs_merged_between(
1017 base: str,
1018 since: str,
1019 until: str,
1020 *,
1021 cwd: str | None = None,
1022 _run=None,
1023 _sleep=None,
1024 limit: int = MERGED_PAGE_LIMIT,
1025) -> list[int] | None:
1026 """:func:`prs_merged_between` over REST, with the same truncation honesty.
1028 REST cannot sort by merge time — ``sort`` takes ``created``, ``updated``,
1029 ``popularity`` and ``long-running``, and nothing else — so the page is ordered by
1030 ``updated`` descending and the truncation rule is re-derived on that key rather
1031 than transplanted. It still holds, and for a reason worth writing down: merging a
1032 pull request *updates* it, so ``merged_at <= updated_at`` for every row. If the
1033 oldest row this page reached was updated before ``since``, every row it did not
1034 reach was updated earlier still, so it was merged earlier still, so it is outside
1035 the window and the window was seen whole. If it was not, the page may have stopped
1036 short of the answer — and a partial read must not render as "nothing overtook this
1037 merge" (#933, #937).
1039 ``state=closed`` is the narrowest REST offers; a closed-unmerged row has no
1040 ``merged_at``, is filtered out, and costs only a slot in the page.
1041 """
1042 rows = rest_json(
1043 run_argv_retry(
1044 [
1045 "gh",
1046 "api",
1047 f"repos/{{owner}}/{{repo}}/pulls"
1048 f"?base={base}&state=closed&sort=updated&direction=desc&per_page={limit}",
1049 ],
1050 cwd=cwd,
1051 _run=_run,
1052 _sleep=_sleep,
1053 )
1054 )
1055 if not isinstance(rows, list):
1056 return None
1057 updated = [str(row.get("updated_at") or "") for row in rows if isinstance(row, dict)]
1058 if len(rows) >= limit and updated and min(updated) >= since:
1059 return None
1060 return [
1061 int(row["number"])
1062 for row in rows
1063 if isinstance(row, dict)
1064 and isinstance(row.get("number"), int)
1065 and since < str(row.get("merged_at") or "") < until
1066 ]