Coverage for src/ai_jury/github.py: 99%

260 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-30 06:29 +0000

1"""Thin GitHub helpers built on the `gh` CLI. 

2 

3Used to pull a PR diff in and to post the jury verdict back as a comment. 

4Kept dependency-free; if `gh` is unavailable these raise a clear error. 

5""" 

6 

7from __future__ import annotations 

8 

9import hashlib 

10import json 

11import re 

12import shutil 

13import subprocess 

14import threading 

15 

16from .findings import strip_html_comments 

17from .redaction import redact 

18 

19# Every `gh` invocation is bounded (#246): a stalled network call or an 

20# interactive auth/2FA prompt would otherwise block `subprocess.run` forever and 

21# hang the whole jury run with no per-call ceiling. On timeout we fail soft with 

22# a clear, actionable error like any other gh failure. 

23_GH_TIMEOUT_S = 90 

24 

25# Ceiling on `gh` stdout. A hostile/huge PR diff pulled via `--pr`/`--issue` 

26# would otherwise be buffered whole by `subprocess.run`, OOMing the process 

27# before the diff budget engages (security audit 2026-06-13 r3). We stream the 

28# output and stop at the cap; stdout/stderr are drained on separate threads so a 

29# full stderr pipe can't deadlock the stdout read. 

30_GH_MAX_OUTPUT_BYTES = 64 * 1024 * 1024 # 64 MiB 

31 

32# Spawning `gh` can fail before the process exists, and the `shutil.which` guard that opens 

33# each of the two callers below narrows that without closing it: the binary can be removed 

34# or unmounted between the check and the spawn, and a machine already short of memory or 

35# process slots refuses the fork outright (`ENOMEM`, `EAGAIN`) however present `gh` is. 

36# 

37# Every other failure in this module — missing CLI, timeout, non-zero exit, output over the 

38# cap — leaves as a `RuntimeError`, and on the path that reads the diff `cli.py` catches 

39# exactly that and prints `error: …` with exit 2 (the handler #836 added around 

40# `_read_diff`). An `OSError` is not a `RuntimeError`, so it escaped that handler and 

41# reached the user as a traceback. `TimeoutExpired` is the only `SubprocessError` these 

42# calls raise and it is caught where it happens, so `OSError` is the whole remaining gap. 

43# 

44# The handler is specific to reading the diff: the post-review block that comments, posts 

45# inline findings and applies labels has no such guard, so a `gh` failure there still 

46# surfaces as a traceback — for every failure kind, not only this one. That is a separate 

47# defect, tracked on its own rather than widened into this change. 

48_GH_SPAWN_FAILED = "gh {label} could not be started: {detail}" 

49 

50 

51def _gh(*args: str) -> str: 

52 if shutil.which("gh") is None: 

53 raise RuntimeError("the GitHub CLI `gh` is not installed or not on PATH") 

54 label = redact(" ".join(args))[0] 

55 try: 

56 proc = subprocess.Popen(["gh", *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 

57 except OSError as exc: 

58 raise RuntimeError( 

59 _GH_SPAWN_FAILED.format(label=label, detail=redact(str(exc))[0]) 

60 ) from None 

61 holder: dict[str, bytes] = {} 

62 

63 def _drain(stream, key: str) -> None: 

64 # read(N+1) is bounded: at most N+1 bytes, never the whole stream if it 

65 # is larger. 

66 holder[key] = stream.read(_GH_MAX_OUTPUT_BYTES + 1) 

67 

68 t_out = threading.Thread(target=_drain, args=(proc.stdout, "out"), daemon=True) 

69 t_err = threading.Thread(target=_drain, args=(proc.stderr, "err"), daemon=True) 

70 t_out.start() 

71 t_err.start() 

72 t_out.join(_GH_TIMEOUT_S) 

73 if t_out.is_alive(): 

74 proc.kill() 

75 raise RuntimeError(f"gh {label} timed out after {_GH_TIMEOUT_S}s") 

76 out = holder.get("out", b"") 

77 if len(out) > _GH_MAX_OUTPUT_BYTES: 

78 proc.kill() 

79 raise RuntimeError(f"gh {label} output exceeds the {_GH_MAX_OUTPUT_BYTES}-byte limit") 

80 try: 

81 proc.wait(timeout=_GH_TIMEOUT_S) 

82 except subprocess.TimeoutExpired: 

83 proc.kill() 

84 raise RuntimeError(f"gh {label} timed out after {_GH_TIMEOUT_S}s") from None 

85 t_err.join(_GH_TIMEOUT_S) 

86 if proc.returncode != 0: 

87 err = holder.get("err", b"").decode("utf-8", "replace").strip() 

88 out_err = holder.get("out", b"").decode("utf-8", "replace").strip() 

89 safe_err = redact(err or out_err)[0] 

90 raise RuntimeError(f"gh {label} failed: {safe_err}") 

91 return out.decode("utf-8", "replace") 

92 

93 

94def pr_diff(pr: str, repo: str | None = None) -> str: 

95 args = ["pr", "diff"] 

96 if repo: 

97 args += ["--repo", repo] 

98 args += ["--", str(pr)] 

99 return _gh(*args) 

100 

101 

102def pr_context(pr: str, repo: str | None = None) -> str: 

103 """Return 'title\\n\\nbody' for a PR, best-effort.""" 

104 args = ["pr", "view", "--json", "title,body", "--jq", '.title + "\\n\\n" + (.body // "")'] 

105 if repo: 

106 args += ["--repo", repo] 

107 args += ["--", str(pr)] 

108 try: 

109 return _gh(*args).strip() 

110 except RuntimeError: 

111 return "" 

112 

113 

114def post_pr_comment(pr: str, body: str, repo: str | None = None) -> None: 

115 args = ["pr", "comment", "--body", body] 

116 if repo: 

117 args += ["--repo", repo] 

118 args += ["--", str(pr)] 

119 _gh(*args) 

120 

121 

122def issue_body(number: str, repo: str | None = None) -> str: 

123 """Return a reviewable text rendering of a GitHub issue, best-effort. 

124 

125 Formats the issue as ``"# <title>\\n\\n_labels: a, b_\\n\\n<body>"`` so the 

126 reviewer sees the title, labels, and description as one prose block. Mirrors 

127 :func:`pr_context`'s error handling: any ``gh`` failure degrades to a minimal 

128 string (the bare number) rather than crashing the run. 

129 """ 

130 jq_expr = ( 

131 '"# " + .title + "\\n\\n_labels: " ' 

132 '+ ((.labels | map(.name)) | join(", ")) + "_\\n\\n" + (.body // "")' 

133 ) 

134 args = [ 

135 "issue", 

136 "view", 

137 "--json", 

138 "title,body,labels", 

139 "--jq", 

140 jq_expr, 

141 ] 

142 if repo: 

143 args += ["--repo", repo] 

144 args += ["--", str(number)] 

145 try: 

146 return _gh(*args).strip() 

147 except RuntimeError: 

148 return f"# issue #{number}" 

149 

150 

151def post_issue_comment(number: str, body: str, repo: str | None = None) -> None: 

152 """Post a comment on a plain GitHub issue. 

153 

154 A separate function from :func:`post_pr_comment` because ``gh pr comment`` 

155 only works for pull requests; ``gh issue comment`` is the issue-side command. 

156 """ 

157 args = ["issue", "comment", "--body", body] 

158 if repo: 

159 args += ["--repo", repo] 

160 args += ["--", str(number)] 

161 _gh(*args) 

162 

163 

164def pr_head_sha(pr: str, repo: str | None = None) -> str: 

165 """Return the current head commit SHA of a PR (best-effort, '' on failure).""" 

166 args = ["pr", "view", "--json", "headRefOid", "--jq", ".headRefOid"] 

167 if repo: 

168 args += ["--repo", repo] 

169 args += ["--", str(pr)] 

170 try: 

171 return _gh(*args).strip() 

172 except RuntimeError: 

173 return "" 

174 

175 

176def pr_comment_bodies(pr: str, repo: str | None = None) -> list[str]: 

177 """Return bodies of a PR's issue comments from TRUSTED authors only. 

178 

179 Used by incremental mode (issue #9) to find the jury's prior reviewed-SHA 

180 marker. The marker is security-sensitive: a forged ``arc-reviewed-sha`` 

181 marker would let an attacker narrow the reviewed range and skip malicious 

182 commits (audit 2026-06-13 r4/M-1). So we only return comments authored by a 

183 repo OWNER/MEMBER/COLLABORATOR — an external fork-PR author (CONTRIBUTOR / 

184 FIRST_TIME_CONTRIBUTOR / NONE) cannot inject a trusted marker. (Run the jury 

185 under such an identity for incremental mode; otherwise it safely falls back 

186 to a full review.) Network errors degrade to an empty list. 

187 """ 

188 jq = ( 

189 '.comments[] | select(.authorAssociation=="OWNER" or ' 

190 '.authorAssociation=="MEMBER" or .authorAssociation=="COLLABORATOR") | .body' 

191 ) 

192 args = ["pr", "view", "--json", "comments", "--jq", jq] 

193 if repo: 

194 args += ["--repo", repo] 

195 args += ["--", str(pr)] 

196 try: 

197 out = _gh(*args) 

198 except RuntimeError: 

199 return [] 

200 return out.splitlines() 

201 

202 

203def compare_diff(base: str, head: str, repo: str | None = None) -> str: 

204 """Return the unified diff between two SHAs via the compare API (issue #9). 

205 

206 Uses the ``application/vnd.github.v3.diff`` media type so the response is a 

207 ready-to-review unified diff. Returns '' on failure so callers can fall back. 

208 """ 

209 resolved = _resolve_repo(repo) 

210 if not resolved: 

211 return "" 

212 try: 

213 return _gh( 

214 "api", 

215 "-H", 

216 "Accept: application/vnd.github.v3.diff", 

217 "--", 

218 f"repos/{resolved}/compare/{base}...{head}", 

219 ) 

220 except RuntimeError: 

221 return "" 

222 

223 

224def build_label_args(pr: str, labels, repo: str | None = None) -> list[str]: 

225 """Build the ``gh pr edit`` arg vector for applying labels (pure). 

226 

227 Returns ``[]`` when there are no labels (nothing to do). Kept pure and 

228 network-free so the arg construction can be unit-tested without invoking 

229 ``gh`` or hitting GitHub. 

230 """ 

231 clean = [str(label) for label in (labels or []) if str(label).strip()] 

232 if not clean: 

233 return [] 

234 args = ["pr", "edit"] 

235 for label in clean: 

236 args += ["--add-label", label] 

237 if repo: 

238 args += ["--repo", repo] 

239 args += ["--", str(pr)] 

240 return args 

241 

242 

243def apply_labels(pr: str, labels, repo: str | None = None) -> list[str]: 

244 """Best-effort: apply ``labels`` to ``pr`` via ``gh pr edit --add-label``. 

245 

246 Only called when labeling is explicitly enabled (CLI ``--label``); it never 

247 runs by default. No-op (returns ``[]``) when there are no labels. Returns the 

248 ``gh`` arg vector that was invoked so callers can log it. 

249 """ 

250 args = build_label_args(pr, labels, repo) 

251 if not args: 

252 return args 

253 _gh(*args) 

254 return args 

255 

256 

257# Marker prefix identifying comments authored by the jury (enables dedup). 

258INLINE_MARKER = "<!-- arc-inline -->" 

259 

260# Hidden per-finding signature marker, embedded in the comment body so that 

261# re-runs can match an existing comment back to the finding that produced it. 

262# Two distinct findings on the same (path, line) get distinct signatures and 

263# therefore do NOT collapse into one another during dedup. 

264_SIG_MARKER_RE = re.compile(r"<!-- arc-sig:([0-9a-f]+) -->") 

265 

266 

267def _finding_signature(finding) -> str: 

268 """Return a short, stable hash identifying a finding. 

269 

270 Derived from the normalized ``severity`` and ``claim`` (lowercased and 

271 stripped) so the same finding yields the same signature across runs, while 

272 a different severity or claim yields a different one. 

273 """ 

274 sev = (getattr(finding, "severity", "") or "").strip().lower() 

275 claim = (getattr(finding, "claim", "") or "").strip().lower() 

276 raw = f"{sev}|{claim}" 

277 # A deduplication key, not a security boundary — said so, for FIPS-mode Pythons and for 

278 # the linters that cannot tell the two apart (#813). The digest is the same either way. 

279 return hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()[:12] 

280 

281 

282def _sig_marker(signature: str) -> str: 

283 return f"<!-- arc-sig:{signature} -->" 

284 

285 

286def _sig_from_body(body: str | None) -> str: 

287 """Extract the embedded finding signature from a comment body ('' if none).""" 

288 if not body: 

289 return "" 

290 match = _SIG_MARKER_RE.search(body) 

291 return match.group(1) if match else "" 

292 

293 

294def _comment_body(finding) -> str: 

295 sev = getattr(finding, "severity", "info") 

296 # Strip HTML comments so a finding can't forge the hidden inline markers 

297 # (``<!-- arc-inline -->`` / ``<!-- arc-sig:… -->``) and perturb dedup 

298 # (audit 2026-06-13 r3/N-3). 

299 claim = strip_html_comments(getattr(finding, "claim", "") or "") 

300 fix = strip_html_comments(getattr(finding, "suggested_fix", "") or "") 

301 text = f"[{sev}] {claim}" 

302 if fix: 

303 text += f" — {fix}" 

304 # The signature marker is hidden (HTML comment); the visible body for humans 

305 # is unchanged. 

306 sig = _sig_marker(_finding_signature(finding)) 

307 return f"{INLINE_MARKER}{sig}\n{text}" 

308 

309 

310def _review_body(n: int) -> str: 

311 """Top-level review body. GitHub's create-review API requires a non-empty 

312 ``body`` when ``event`` is COMMENT (omitting it can 422) — issue #122.""" 

313 return f"{INLINE_MARKER}\n🏛️ AI Jury — {n} inline finding(s)." 

314 

315 

316def build_inline_payload(findings) -> list[dict]: 

317 """Build the inline review-comment array for the GitHub reviews API. 

318 

319 Pure: one comment per finding that has BOTH a file and a line. Findings 

320 without a file or line are skipped (they cannot be anchored inline). 

321 """ 

322 payload: list[dict] = [] 

323 for f in findings or []: 

324 path = getattr(f, "file", None) 

325 line = getattr(f, "line", None) 

326 if not path or line is None: 

327 continue 

328 payload.append( 

329 { 

330 "path": str(path), 

331 "line": int(line), 

332 "side": "RIGHT", 

333 "body": _comment_body(f), 

334 } 

335 ) 

336 return payload 

337 

338 

339def _resolve_repo(repo: str | None) -> str: 

340 if repo: 

341 return repo 

342 try: 

343 out = _gh("repo", "view", "--json", "nameWithOwner") 

344 return json.loads(out).get("nameWithOwner", "") 

345 except (RuntimeError, json.JSONDecodeError, RecursionError): 

346 return "" 

347 

348 

349def _existing_inline_keys(pr: str, repo: str) -> set: 

350 """Return ``(path, line, signature)`` keys for existing jury comments. 

351 

352 Best-effort. ``line`` falls back to ``original_line`` when GitHub reports a 

353 null ``line`` (e.g. for outdated comments). ``signature`` is parsed back out 

354 of the comment body so distinct findings on the same line are tracked 

355 independently. 

356 """ 

357 keys: set = set() 

358 try: 

359 out = _gh("api", "--paginate", "--", f"repos/{repo}/pulls/{pr}/comments") 

360 data = json.loads(out) 

361 except (RuntimeError, json.JSONDecodeError, RecursionError): 

362 return keys 

363 if not isinstance(data, list): 

364 return keys 

365 for c in data: 

366 if not isinstance(c, dict): 

367 continue 

368 body = c.get("body", "") or "" 

369 if INLINE_MARKER not in body: 

370 continue 

371 line = c.get("line") 

372 if line is None: 

373 line = c.get("original_line") 

374 keys.add((c.get("path"), line, _sig_from_body(body))) 

375 return keys 

376 

377 

378def _gh_with_input(args: list[str], stdin_data: str) -> str: 

379 if shutil.which("gh") is None: 

380 raise RuntimeError("the GitHub CLI `gh` is not installed or not on PATH") 

381 try: 

382 proc = subprocess.run( 

383 ["gh", *args], 

384 input=stdin_data, 

385 capture_output=True, 

386 text=True, 

387 timeout=_GH_TIMEOUT_S, 

388 ) 

389 except subprocess.TimeoutExpired: 

390 raise RuntimeError( 

391 f"gh {redact(' '.join(args))[0]} timed out after {_GH_TIMEOUT_S}s" 

392 ) from None 

393 except OSError as exc: 

394 raise RuntimeError( 

395 _GH_SPAWN_FAILED.format(label=redact(" ".join(args))[0], detail=redact(str(exc))[0]) 

396 ) from None 

397 if proc.returncode != 0: 

398 err = proc.stderr.strip() 

399 out_err = proc.stdout.strip() 

400 safe_err = redact(err or out_err)[0] 

401 raise RuntimeError(f"gh {redact(' '.join(args))[0]} failed: {safe_err}") 

402 return proc.stdout 

403 

404 

405def post_inline_comments( 

406 pr: str, 

407 findings, 

408 repo: str | None = None, 

409 dry_run: bool = False, 

410) -> dict: 

411 """Post inline review comments as a single PR review. 

412 

413 Best-effort dedup: skips comments whose ``(path, line, finding-signature)`` 

414 already has a jury inline comment. Keying on the signature means two 

415 distinct findings on the same line are both posted. When ``dry_run`` is True 

416 the payload is printed and returned without any network call. Returns the 

417 review payload (would-be) posted. 

418 """ 

419 comments = build_inline_payload(findings) 

420 

421 if dry_run: 

422 payload = {"event": "COMMENT", "body": _review_body(len(comments)), "comments": comments} 

423 print(redact(json.dumps(payload, indent=2))[0]) 

424 return payload 

425 

426 resolved = _resolve_repo(repo) 

427 existing = _existing_inline_keys(pr, resolved) if resolved else set() 

428 deduped = [ 

429 c for c in comments if (c["path"], c["line"], _sig_from_body(c["body"])) not in existing 

430 ] 

431 

432 payload = {"event": "COMMENT", "body": _review_body(len(deduped)), "comments": deduped} 

433 if not deduped: 

434 return payload 

435 

436 _gh_with_input( 

437 ["api", "--method", "POST", "--input", "-", "--", f"repos/{resolved}/pulls/{pr}/reviews"], 

438 json.dumps(payload), 

439 ) 

440 return payload 

441 

442 

443# Hidden marker identifying the jury's single sticky progress comment (issue #125). 

444PROGRESS_MARKER = "<!-- arc-progress -->" 

445 

446 

447def render_progress_body(stages: list[str], *, done: bool = False, final: str | None = None) -> str: 

448 """Render the sticky progress-comment body (pure, issue #125). 

449 

450 ``stages`` is the ordered list of milestones reached. When ``done`` and a 

451 ``final`` report is given, the comment becomes the verdict (with the marker 

452 kept so the same comment is reused on a re-run). 

453 """ 

454 if done and final is not None: 

455 return f"{PROGRESS_MARKER}\n{final}" 

456 header = "🏛️ **AI Jury** — review complete." if done else "🏛️ **AI Jury** — review in progress…" 

457 lines = [PROGRESS_MARKER, header, ""] 

458 for s in stages: 

459 lines.append(f"- {s}") 

460 if not done: 

461 lines.append("\n_Updating live; the verdict will replace this when done._") 

462 return "\n".join(lines) 

463 

464 

465def _create_issue_comment(pr: str, body: str, repo: str) -> int | None: 

466 """Create a PR/issue comment, returning its numeric id (or None).""" 

467 try: 

468 out = _gh_with_input( 

469 ["api", "--method", "POST", "--input", "-", "--", f"repos/{repo}/issues/{pr}/comments"], 

470 json.dumps({"body": body}), 

471 ) 

472 return json.loads(out).get("id") 

473 except (RuntimeError, json.JSONDecodeError, RecursionError): 

474 return None 

475 

476 

477def _edit_issue_comment(comment_id: int, body: str, repo: str) -> bool: 

478 try: 

479 _gh_with_input( 

480 [ 

481 "api", 

482 "--method", 

483 "PATCH", 

484 "--input", 

485 "-", 

486 "--", 

487 f"repos/{repo}/issues/comments/{comment_id}", 

488 ], 

489 json.dumps({"body": body}), 

490 ) 

491 return True 

492 except RuntimeError: 

493 return False 

494 

495 

496class ProgressReporter: 

497 """Maintains ONE sticky PR comment, updated as the run advances (issue #125). 

498 

499 Best-effort and resilient: a resolve/create/edit failure is swallowed so a 

500 GitHub hiccup never crashes the review. The first ``update`` creates the 

501 comment; subsequent updates edit it in place; ``finish`` turns it into the 

502 final verdict. 

503 """ 

504 

505 def __init__(self, pr: str, repo: str | None = None): 

506 self.pr = str(pr) 

507 self.repo = _resolve_repo(repo) 

508 self.comment_id: int | None = None 

509 self.stages: list[str] = [] 

510 

511 def _push(self, body: str) -> None: 

512 if not self.repo: 

513 return 

514 if self.comment_id is None: 

515 self.comment_id = _create_issue_comment(self.pr, body, self.repo) 

516 else: 

517 _edit_issue_comment(self.comment_id, body, self.repo) 

518 

519 def update(self, milestone: str) -> None: 

520 self.stages.append(milestone) 

521 self._push(render_progress_body(self.stages, done=False)) 

522 

523 def finish(self, final_report: str) -> None: 

524 self._push(render_progress_body(self.stages, done=True, final=final_report))