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

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 random 

11import time 

12from collections.abc import Sequence 

13 

14from .runner import CommandResult, run_argv 

15 

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) 

38 

39 

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 

51 

52 

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. 

64 

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 

80 

81 

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 ) 

89 

90 

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

93 

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

96 

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

101 

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. 

104 

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

149 

150 

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. 

153 

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) 

169 

170 

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. 

173 

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. 

179 

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) 

193 

194 

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

203 

204 

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

209 

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

219 

220 

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

225 

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

238 

239 

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. 

242 

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 

256 

257 

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

260 

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 ) 

269 

270 

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

273 

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 ) 

283 

284 

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

290 

291 

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

296 

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 ) 

307 

308 

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 

320 

321 

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

324 

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. 

328 

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 } 

351 

352 

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 ) 

361 

362 

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

367 

368 

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

371 

372 

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 ) 

394 

395 

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 ) 

417 

418 

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

421 

422 

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

425 

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 ) 

434 

435 

436def _kw(_run): 

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