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

253 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 23:18 +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 

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

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

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

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

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

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

39 

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

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

42 # is larger. 

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

44 

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

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

47 t_out.start() 

48 t_err.start() 

49 t_out.join(_GH_TIMEOUT_S) 

50 if t_out.is_alive(): 

51 proc.kill() 

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

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

54 if len(out) > _GH_MAX_OUTPUT_BYTES: 

55 proc.kill() 

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

57 try: 

58 proc.wait(timeout=_GH_TIMEOUT_S) 

59 except subprocess.TimeoutExpired: 

60 proc.kill() 

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

62 t_err.join(_GH_TIMEOUT_S) 

63 if proc.returncode != 0: 

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

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

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

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

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

69 

70 

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

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

73 if repo: 

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

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

76 return _gh(*args) 

77 

78 

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

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

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

82 if repo: 

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

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

85 try: 

86 return _gh(*args).strip() 

87 except RuntimeError: 

88 return "" 

89 

90 

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

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

93 if repo: 

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

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

96 _gh(*args) 

97 

98 

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

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

101 

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

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

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

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

106 """ 

107 args = [ 

108 "issue", 

109 "view", 

110 "--json", 

111 "title,body,labels", 

112 "--jq", 

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

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

115 ] 

116 if repo: 

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

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

119 try: 

120 return _gh(*args).strip() 

121 except RuntimeError: 

122 return f"# issue #{number}" 

123 

124 

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

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

127 

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

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

130 """ 

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

132 if repo: 

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

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

135 _gh(*args) 

136 

137 

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

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

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

141 if repo: 

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

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

144 try: 

145 return _gh(*args).strip() 

146 except RuntimeError: 

147 return "" 

148 

149 

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

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

152 

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

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

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

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

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

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

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

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

161 """ 

162 jq = ( 

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

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

165 ) 

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

167 if repo: 

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

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

170 try: 

171 out = _gh(*args) 

172 except RuntimeError: 

173 return [] 

174 return out.splitlines() 

175 

176 

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

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

179 

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

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

182 """ 

183 resolved = _resolve_repo(repo) 

184 if not resolved: 

185 return "" 

186 try: 

187 return _gh( 

188 "api", 

189 "-H", 

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

191 "--", 

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

193 ) 

194 except RuntimeError: 

195 return "" 

196 

197 

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

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

200 

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

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

203 ``gh`` or hitting GitHub. 

204 """ 

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

206 if not clean: 

207 return [] 

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

209 for label in clean: 

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

211 if repo: 

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

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

214 return args 

215 

216 

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

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

219 

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

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

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

223 """ 

224 args = build_label_args(pr, labels, repo) 

225 if not args: 

226 return args 

227 _gh(*args) 

228 return args 

229 

230 

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

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

233 

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

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

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

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

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

239 

240 

241def _finding_signature(finding) -> str: 

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

243 

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

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

246 a different severity or claim yields a different one. 

247 """ 

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

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

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

251 return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:12] 

252 

253 

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

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

256 

257 

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

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

260 if not body: 

261 return "" 

262 match = _SIG_MARKER_RE.search(body) 

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

264 

265 

266def _comment_body(finding) -> str: 

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

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

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

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

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

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

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

274 if fix: 

275 text += f"{fix}" 

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

277 # is unchanged. 

278 sig = _sig_marker(_finding_signature(finding)) 

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

280 

281 

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

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

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

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

286 

287 

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

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

290 

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

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

293 """ 

294 payload: list[dict] = [] 

295 for f in findings or []: 

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

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

298 if not path or line is None: 

299 continue 

300 payload.append( 

301 { 

302 "path": str(path), 

303 "line": int(line), 

304 "side": "RIGHT", 

305 "body": _comment_body(f), 

306 } 

307 ) 

308 return payload 

309 

310 

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

312 if repo: 

313 return repo 

314 try: 

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

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

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

318 return "" 

319 

320 

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

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

323 

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

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

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

327 independently. 

328 """ 

329 keys: set = set() 

330 try: 

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

332 data = json.loads(out) 

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

334 return keys 

335 if not isinstance(data, list): 

336 return keys 

337 for c in data: 

338 if not isinstance(c, dict): 

339 continue 

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

341 if INLINE_MARKER not in body: 

342 continue 

343 line = c.get("line") 

344 if line is None: 

345 line = c.get("original_line") 

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

347 return keys 

348 

349 

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

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

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

353 try: 

354 proc = subprocess.run( 

355 ["gh", *args], 

356 input=stdin_data, 

357 capture_output=True, 

358 text=True, 

359 timeout=_GH_TIMEOUT_S, 

360 ) 

361 except subprocess.TimeoutExpired: 

362 raise RuntimeError(f"gh {redact(' '.join(args))[0]} timed out after {_GH_TIMEOUT_S}s") from None 

363 if proc.returncode != 0: 

364 err = proc.stderr.strip() 

365 out_err = proc.stdout.strip() 

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

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

368 return proc.stdout 

369 

370 

371def post_inline_comments( 

372 pr: str, 

373 findings, 

374 repo: str | None = None, 

375 dry_run: bool = False, 

376) -> dict: 

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

378 

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

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

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

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

383 review payload (would-be) posted. 

384 """ 

385 comments = build_inline_payload(findings) 

386 

387 if dry_run: 

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

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

390 return payload 

391 

392 resolved = _resolve_repo(repo) 

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

394 deduped = [ 

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

396 ] 

397 

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

399 if not deduped: 

400 return payload 

401 

402 _gh_with_input( 

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

404 json.dumps(payload), 

405 ) 

406 return payload 

407 

408 

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

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

411 

412 

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

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

415 

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

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

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

419 """ 

420 if done and final is not None: 

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

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

423 lines = [PROGRESS_MARKER, header, ""] 

424 for s in stages: 

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

426 if not done: 

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

428 return "\n".join(lines) 

429 

430 

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

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

433 try: 

434 out = _gh_with_input( 

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

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

437 ) 

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

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

440 return None 

441 

442 

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

444 try: 

445 _gh_with_input( 

446 [ 

447 "api", 

448 "--method", 

449 "PATCH", 

450 "--input", 

451 "-", 

452 "--", 

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

454 ], 

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

456 ) 

457 return True 

458 except RuntimeError: 

459 return False 

460 

461 

462class ProgressReporter: 

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

464 

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

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

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

468 final verdict. 

469 """ 

470 

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

472 self.pr = str(pr) 

473 self.repo = _resolve_repo(repo) 

474 self.comment_id: int | None = None 

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

476 

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

478 if not self.repo: 

479 return 

480 if self.comment_id is None: 

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

482 else: 

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

484 

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

486 self.stages.append(milestone) 

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

488 

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

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