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
« 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.
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"""
7from __future__ import annotations
9import hashlib
10import json
11import re
12import shutil
13import subprocess
14import threading
16from .findings import strip_html_comments
17from .redaction import redact
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
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
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] = {}
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)
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")
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)
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 ""
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)
99def issue_body(number: str, repo: str | None = None) -> str:
100 """Return a reviewable text rendering of a GitHub issue, best-effort.
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}"
125def post_issue_comment(number: str, body: str, repo: str | None = None) -> None:
126 """Post a comment on a plain GitHub issue.
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)
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 ""
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.
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()
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).
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 ""
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).
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
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``.
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
231# Marker prefix identifying comments authored by the jury (enables dedup).
232INLINE_MARKER = "<!-- arc-inline -->"
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]+) -->")
241def _finding_signature(finding) -> str:
242 """Return a short, stable hash identifying a finding.
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]
254def _sig_marker(signature: str) -> str:
255 return f"<!-- arc-sig:{signature} -->"
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 ""
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}"
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)."
288def build_inline_payload(findings) -> list[dict]:
289 """Build the inline review-comment array for the GitHub reviews API.
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
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 ""
321def _existing_inline_keys(pr: str, repo: str) -> set:
322 """Return ``(path, line, signature)`` keys for existing jury comments.
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
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
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.
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)
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
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 ]
398 payload = {"event": "COMMENT", "body": _review_body(len(deduped)), "comments": deduped}
399 if not deduped:
400 return payload
402 _gh_with_input(
403 ["api", "--method", "POST", "--input", "-", "--", f"repos/{resolved}/pulls/{pr}/reviews"],
404 json.dumps(payload),
405 )
406 return payload
409# Hidden marker identifying the jury's single sticky progress comment (issue #125).
410PROGRESS_MARKER = "<!-- arc-progress -->"
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).
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)
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
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
462class ProgressReporter:
463 """Maintains ONE sticky PR comment, updated as the run advances (issue #125).
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 """
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] = []
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)
485 def update(self, milestone: str) -> None:
486 self.stages.append(milestone)
487 self._push(render_progress_body(self.stages, done=False))
489 def finish(self, final_report: str) -> None:
490 self._push(render_progress_body(self.stages, done=True, final=final_report))