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

1"""Thin, fail-soft ``gh`` (GitHub CLI) wrappers (argv, no shell). 

2 

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

7 

8from __future__ import annotations 

9 

10import json 

11import random 

12import time 

13from collections.abc import Sequence 

14 

15from .runner import CommandResult, run_argv 

16 

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) 

39 

40 

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 

52 

53 

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. 

65 

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 

81 

82 

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 ) 

91 

92 

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). 

95 

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): 

98 

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*. 

103 

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. 

106 

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() 

150 

151 

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. 

154 

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) 

170 

171 

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. 

174 

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. 

180 

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) 

194 

195 

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()] 

205 

206 

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}, ...]``). 

211 

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

221 

222 

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": ...}, ...]``). 

227 

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

247 

248 

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. 

251 

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 

266 

267 

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). 

270 

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 ) 

281 

282 

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*. 

285 

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 ) 

296 

297 

298def _lines(argv: list[str], *, cwd: str | None, _run, _sleep=None) -> list[str] | None: 

299 """Read newline-separated ``gh`` output, retrying transient failures. 

300 

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. 

309 

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()] 

318 

319 

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 

324 

325 

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). 

337 

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. 

341 

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. 

349 

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. 

353 

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. 

360 

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 ] 

394 

395 

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 

406 

407 

408def _merge_window_fields(result: CommandResult) -> list[str]: 

409 """The four TSV fields of a ``pr_merge_window`` read, trailing blank preserved. 

410 

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

417 

418 

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 

425 

426 

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). 

431 

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. 

435 

436 ``None`` when ``gh`` cannot be asked or the PR is not merged. 

437 

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 } 

487 

488 

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 ) 

502 

503 

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. 

513 

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

523 

524 

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

527 

528 

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 ) 

550 

551 

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 ) 

573 

574 

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. 

584 

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

594 

595 

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``. 

604 

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. 

608 

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 ) 

618 

619 

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 

624 

625 

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 

630 

631 

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

635 

636 

637#: Seconds ``keel doctor`` waits for ``gh auth status`` — one round trip to GitHub. 

638AUTH_STATUS_TIMEOUT_S = 10 

639 

640 

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``. 

643 

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

647 

648 

649def label_create_argv(name: str, repo: str | None = None) -> list[str]: 

650 """The exact ``gh label create`` command for one label. 

651 

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 

658 

659 

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

665 

666 

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``. 

669 

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 

678 

679 

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

690 

691 

692def _kw(_run): 

693 return {"_run": _run} if _run is not None else {} 

694 

695 

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. 

708 

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}"] 

711 

712 

713def graphql_available(*, cwd: str | None = None, _run=None) -> bool: 

714 """Can this host reach GitHub's GraphQL endpoint? 

715 

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. 

720 

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 

727 

728 

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

732 

733 

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 ) 

746 

747 

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 ) 

760 

761 

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 

792 

793 

794def _check_run_rows(payload: object) -> list[dict]: 

795 """The check runs inside a `commits/<sha>/check-runs` body, across every page. 

796 

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*. 

801 

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. 

806 

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 

820 

821 

822def rest_rollup(check_runs: object, statuses: object) -> list[dict]: 

823 """The two REST payloads in the shape ``statusCheckRollup`` has. 

824 

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. 

831 

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 

867 

868 

869def _status_rows(payload: object) -> list[dict]: 

870 """The rows of a `commits/<sha>/statuses` body, across every page. 

871 

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)] 

878 

879 

880def _upper(value: object) -> str | None: 

881 """``value`` upper-cased when it is a string; ``None`` otherwise. 

882 

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 

887 

888 

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. 

898 

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

916 

917 

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 ) 

935 

936 

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? 

939 

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

958 

959 

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. 

964 

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 

1014 

1015 

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. 

1027 

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). 

1038 

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 ]