Coverage for plugin/scripts/pr_status.py: 100%

155 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""Report open pull/merge requests and what CI on the origin said about them. 

3 

4`/rhiza:quality` files issues, those issues become branches, and the branches become 

5requests that were green on the author's machine. The gap this closes is what happens 

6next: **local green and origin green are different claims**, and only the second one 

7merges. A `make test` that passes on macOS with a warm cache says nothing about a 

8matrix job on Windows, a lockfile the runner resolves differently, or a gate the repo 

9runs only in CI. 

10 

11Asking the forge is the deterministic half of answering that, and it is deterministic 

12in the way that hurts — the two CLIs disagree about **shape**, not just about flags: 

13 

14 gh pr list --json statusCheckRollup -> per-check objects, two different 

15 __typenames, SCREAMING_CASE conclusions 

16 glab mr list -F json -> the MRs, with no pipeline in sight; 

17 ci get --merge-request <iid> the pipeline is a second call, and its 

18 status is lower-case 

19 

20So this normalises both into one vocabulary — ``pass``/``fail``/``pending``/``skipped``/ 

21``cancelled``/``unknown`` — and reports, per request, a rollup plus the individual 

22checks. **GitHub's answer is per job; GitLab's is per pipeline**, because a pipeline's 

23job list is not a shape this has been able to verify against a real GitLab, and 

24inventing one is how ``glab mr create --description-file`` shipped. That asymmetry is 

25reported rather than smoothed over: every check carries the exact drill-down command 

26for its platform, and on GitLab that command is the one that lists the failing jobs. 

27 

28**It reads; it never retries, cancels or pushes.** Deciding what a red job means, and 

29fixing it, is `/rhiza:remote`'s job and needs judgement this cannot have. 

30 

31Usage: 

32 uv run --python 3.12 --no-project python \ 

33 scripts/pr_status.py [--target-dir DIR] [--branch B | --all] [--limit N] 

34 [--json] [--dry-run] 

35 

36With neither ``--branch`` nor ``--all``, the branch currently checked out is used; on a 

37detached HEAD that falls back to every open request, which is what ``--all`` asks for 

38explicitly. 

39 

40Exit codes: 

41 0 the forge answered (whatever it said about CI), or --dry-run rendered 

42 1 the platform CLI failed, is absent, or is not authenticated 

43 2 the platform could not be determined 

44""" 

45 

46from __future__ import annotations 

47 

48import argparse 

49import json 

50import shutil 

51import subprocess # nosec B404 

52import sys 

53from pathlib import Path 

54from typing import Any 

55 

56sys.path.insert(0, str(Path(__file__).resolve().parent)) 

57from _rhiza_forge import PlatformError, current_branch, detect_platform # noqa: E402 

58 

59EXIT_OK = 0 

60EXIT_CLI_FAILED = 1 

61EXIT_USAGE = 2 

62 

63SUCCESS = "pass" 

64FAILURE = "fail" 

65PENDING = "pending" 

66SKIPPED = "skipped" 

67CANCELLED = "cancelled" 

68UNKNOWN = "unknown" 

69 

70# Worst-first. The rollup is the first state present, so a request with one failing job 

71# reads `fail` however many green ones surround it, and a still-running one is never 

72# reported as green just because everything finished so far has passed. 

73_PRECEDENCE = (FAILURE, PENDING, CANCELLED, UNKNOWN, SUCCESS, SKIPPED) 

74 

75# GitHub answers a completed CheckRun with a `conclusion`; anything not listed is a 

76# state this has not seen, which is `unknown` rather than a guessed pass. 

77_GITHUB_CONCLUSIONS = { 

78 "SUCCESS": SUCCESS, 

79 "NEUTRAL": SUCCESS, 

80 "SKIPPED": SKIPPED, 

81 "CANCELLED": CANCELLED, 

82 "FAILURE": FAILURE, 

83 "TIMED_OUT": FAILURE, 

84 "STARTUP_FAILURE": FAILURE, 

85 "ACTION_REQUIRED": FAILURE, 

86 "STALE": UNKNOWN, 

87} 

88# A commit status (an external CI posting to the statuses API) has no conclusion — just 

89# a state, and a different vocabulary for it. 

90_GITHUB_STATES = { 

91 "SUCCESS": SUCCESS, 

92 "EXPECTED": PENDING, 

93 "PENDING": PENDING, 

94 "ERROR": FAILURE, 

95 "FAILURE": FAILURE, 

96} 

97# GitLab pipeline statuses, from the pipelines API. `manual` is a pipeline waiting for 

98# somebody to press a button, which is pending on a human rather than on a runner — but 

99# pending either way, and not something to report as green. 

100_GITLAB_STATUSES = { 

101 "success": SUCCESS, 

102 "failed": FAILURE, 

103 "canceled": CANCELLED, 

104 "canceling": CANCELLED, 

105 "skipped": SKIPPED, 

106 "created": PENDING, 

107 "waiting_for_resource": PENDING, 

108 "preparing": PENDING, 

109 "pending": PENDING, 

110 "running": PENDING, 

111 "manual": PENDING, 

112 "scheduled": PENDING, 

113} 

114 

115# The fields `gh pr list --json` is asked for. Spelled out rather than globbed: gh errors 

116# on an unknown field, so this list is itself a contract with the CLI. 

117_GH_FIELDS = "number,title,headRefName,url,isDraft,statusCheckRollup" 

118 

119 

120class ForgeQueryError(Exception): 

121 """A platform CLI was absent, unauthenticated, or answered something unreadable.""" 

122 

123 

124def build_list_command(platform: str, *, branch: str | None, limit: int) -> list[str]: 

125 """Return the argv listing open requests, optionally narrowed to one *branch*.""" 

126 if platform == "github": 

127 command = ["gh", "pr", "list", "--state", "open", "--json", _GH_FIELDS] 

128 command += ["--limit", str(limit)] 

129 return [*command, "--head", branch] if branch else command 

130 command = ["glab", "mr", "list", "--output", "json", "--per-page", str(limit)] 

131 return [*command, "--source-branch", branch] if branch else command 

132 

133 

134def build_pipeline_command(iid: int | str) -> list[str]: 

135 """Return the argv for the head pipeline of GitLab merge request *iid*. 

136 

137 ``--merge-request`` rather than ``--branch`` deliberately: GitLab's detached 

138 merge-request pipelines run against ``refs/merge-requests/<iid>/head``, so a 

139 request whose head pipeline has diverged from its source branch would otherwise be 

140 reported against a pipeline that is not the one gating the merge. 

141 """ 

142 return ["glab", "ci", "get", "--merge-request", str(iid), "--output", "json"] 

143 

144 

145def _run_id(details_url: str) -> str | None: 

146 """Extract the workflow-run id from a GitHub check's details URL. 

147 

148 The URL is ``…/actions/runs/<run>/job/<job>``. A commit status posted by an external 

149 CI has no such URL, and there is no `gh` command to fetch its log — so None here 

150 means "the log lives somewhere gh cannot reach", not "no log". 

151 """ 

152 parts = details_url.split("/") 

153 if "runs" not in parts: 

154 return None 

155 index = parts.index("runs") + 1 

156 return parts[index] if index < len(parts) else None 

157 

158 

159def build_logs_command(platform: str, check: dict[str, Any]) -> list[str] | None: 

160 """Return the argv that drills into a failing *check*, or None when there is none. 

161 

162 Emitted rather than executed. A failed job's log is routinely megabytes, and which 

163 part of it matters is exactly the judgement this script does not make — so the 

164 command is handed to the caller, whose job is to read the answer. 

165 """ 

166 if platform == "github": 

167 run = _run_id(check.get("url") or "") 

168 return ["gh", "run", "view", run, "--log-failed"] if run else None 

169 pipeline = check.get("pipeline_id") 

170 if pipeline is None: 

171 return None 

172 return [ 

173 "glab", "ci", "get", "--pipeline-id", str(pipeline), 

174 "--status", "failed", "--with-job-details", 

175 ] # fmt: skip 

176 

177 

178def normalize_github_check(entry: dict[str, Any]) -> dict[str, Any]: 

179 """Reduce one ``statusCheckRollup`` entry to ``{name, state, url, raw}``. 

180 

181 Two shapes arrive in the same array. A ``CheckRun`` is a GitHub Actions job and 

182 carries ``status``/``conclusion``; a ``StatusContext`` is an external CI posting to 

183 the statuses API and carries ``context``/``state``. Reading the second with the 

184 first's keys yields a nameless check in an unknown state, which is how an external 

185 required check comes to be invisible in a report that claims to be complete. 

186 """ 

187 if entry.get("__typename") == "StatusContext": 

188 return _status_context(entry) 

189 return _check_run(entry) 

190 

191 

192def _status_context(entry: dict[str, Any]) -> dict[str, Any]: 

193 """Normalise a commit status — an external CI posting to the statuses API.""" 

194 raw = str(entry.get("state") or "") 

195 return { 

196 "name": entry.get("context") or "(unnamed status)", 

197 "state": _GITHUB_STATES.get(raw.upper(), UNKNOWN), 

198 "url": entry.get("targetUrl") or "", 

199 "raw": raw, 

200 } 

201 

202 

203def _check_run(entry: dict[str, Any]) -> dict[str, Any]: 

204 """Normalise a GitHub Actions job. 

205 

206 An unfinished job has no ``conclusion`` at all, so its ``status`` is what gets 

207 reported — reading the missing conclusion instead would file every in-flight job 

208 under ``unknown`` and bury the ones that genuinely are. 

209 """ 

210 workflow = entry.get("workflowName") or "" 

211 name = entry.get("name") or "(unnamed check)" 

212 completed = str(entry.get("status") or "").upper() == "COMPLETED" 

213 raw = str(entry.get("conclusion" if completed else "status") or "") 

214 return { 

215 "name": f"{name} ({workflow})" if workflow else name, 

216 "state": _GITHUB_CONCLUSIONS.get(raw.upper(), UNKNOWN) if completed else PENDING, 

217 "url": entry.get("detailsUrl") or "", 

218 "raw": raw, 

219 } 

220 

221 

222def normalize_gitlab_pipeline(payload: dict[str, Any]) -> dict[str, Any]: 

223 """Reduce a GitLab pipeline object to a single check. 

224 

225 One check per *pipeline*, not per job. `glab ci get` can be asked for job details, 

226 but the shape it prints them in is not something this has been able to check 

227 against a live GitLab — and a normaliser written from a guess is the exact bug that 

228 shipped a `glab` flag which did not exist. The rollup below is honest at pipeline 

229 granularity, and ``build_logs_command`` hands back the command that opens the jobs. 

230 """ 

231 raw = str(payload.get("status") or "") 

232 pipeline_id = payload.get("id") 

233 return { 

234 "name": f"pipeline #{pipeline_id}" if pipeline_id else "pipeline", 

235 "state": _GITLAB_STATUSES.get(raw.lower(), UNKNOWN), 

236 "url": payload.get("web_url") or "", 

237 "raw": raw, 

238 "pipeline_id": pipeline_id, 

239 } 

240 

241 

242def rollup(checks: list[dict[str, Any]]) -> str: 

243 """Reduce a request's checks to one state; ``unknown`` when it has none. 

244 

245 No checks at all is *not* a pass. A request whose workflow never triggered — a 

246 misspelled path filter, a workflow file that fails to parse — shows a clean check 

247 list, and reporting that as green is how it gets merged. 

248 """ 

249 present = {check["state"] for check in checks} 

250 return next((state for state in _PRECEDENCE if state in present), UNKNOWN) 

251 

252 

253def _cli_json(target_dir: Path, command: list[str]) -> Any: 

254 """Run *command* and parse its stdout as JSON, or raise ``ForgeQueryError``.""" 

255 # The resolved path, not the bare name — see the same call in `platform_cli.run` for 

256 # why the two differ on Windows. 

257 executable = shutil.which(command[0]) 

258 if executable is None: 

259 raise ForgeQueryError(f"{command[0]} is not installed — cannot read CI state") 

260 result = subprocess.run( # nosec B603 

261 [executable, *command[1:]], cwd=str(target_dir), capture_output=True, text=True, check=False 

262 ) 

263 if result.returncode != 0: 

264 raise ForgeQueryError(f"{' '.join(command)} failed: {result.stderr.strip()[:300]}") 

265 try: 

266 return json.loads(result.stdout or "null") 

267 except ValueError as exc: 

268 raise ForgeQueryError(f"{command[0]} returned output that is not JSON") from exc 

269 

270 

271def _github_requests(target_dir: Path, command: list[str]) -> list[dict[str, Any]]: 

272 """Every open PR the *command* lists, each with its normalised checks.""" 

273 requests = [] 

274 for entry in _cli_json(target_dir, command) or []: 

275 checks = [normalize_github_check(c) for c in entry.get("statusCheckRollup") or []] 

276 requests.append( 

277 { 

278 "id": entry.get("number"), 

279 "title": entry.get("title") or "", 

280 "branch": entry.get("headRefName") or "", 

281 "url": entry.get("url") or "", 

282 "draft": bool(entry.get("isDraft")), 

283 "state": rollup(checks), 

284 "checks": checks, 

285 } 

286 ) 

287 return requests 

288 

289 

290def _gitlab_requests(target_dir: Path, command: list[str]) -> list[dict[str, Any]]: 

291 """Every open MR the *command* lists, each with its head pipeline as one check. 

292 

293 The pipeline is a second call per request, so a request whose pipeline cannot be 

294 read reports ``unknown`` and keeps going: one MR with no pipeline yet must not cost 

295 the caller the report on all the others. 

296 """ 

297 requests = [] 

298 for entry in _cli_json(target_dir, command) or []: 

299 iid = entry.get("iid") 

300 try: 

301 pipeline = _cli_json(target_dir, build_pipeline_command(iid)) or {} 

302 except ForgeQueryError: 

303 pipeline = {} 

304 checks = [normalize_gitlab_pipeline(pipeline)] if pipeline else [] 

305 requests.append( 

306 { 

307 "id": iid, 

308 "title": entry.get("title") or "", 

309 "branch": entry.get("source_branch") or "", 

310 "url": entry.get("web_url") or "", 

311 "draft": bool(entry.get("draft")), 

312 "state": rollup(checks), 

313 "checks": checks, 

314 } 

315 ) 

316 return requests 

317 

318 

319def collect( 

320 target_dir: Path, 

321 *, 

322 branch: str | None, 

323 limit: int, 

324 dry_run: bool = False, 

325) -> dict[str, Any]: 

326 """Query the forge for *target_dir* and return the normalised report.""" 

327 platform = detect_platform(target_dir) 

328 command = build_list_command(platform, branch=branch, limit=limit) 

329 summary: dict[str, Any] = { 

330 "platform": platform, 

331 "branch": branch, 

332 "command": command, 

333 "dry_run": dry_run, 

334 "requests": [], 

335 "notes": [], 

336 } 

337 if dry_run: 

338 summary["notes"].append("dry run — nothing was executed") 

339 return summary 

340 

341 reader = _github_requests if platform == "github" else _gitlab_requests 

342 summary["requests"] = reader(target_dir, command) 

343 for request in summary["requests"]: 

344 for check in request["checks"]: 

345 check["logs_command"] = build_logs_command(platform, check) 

346 return summary 

347 

348 

349_MARK = { 

350 SUCCESS: "✓", 

351 FAILURE: "✗", 

352 PENDING: "•", 

353 SKIPPED: "–", 

354 CANCELLED: "⊘", 

355 UNKNOWN: "?", 

356} 

357 

358 

359def render(summary: dict[str, Any]) -> str: 

360 """Render *summary* as the human report.""" 

361 lines = [f"platform {summary['platform']}", f"command {' '.join(summary['command'])}"] 

362 if not summary["requests"] and not summary["dry_run"]: 

363 lines.append("") 

364 lines.append("no open requests matched — nothing is waiting on CI") 

365 for request in summary["requests"]: 

366 draft = " [draft]" if request["draft"] else "" 

367 lines.append("") 

368 lines.append( 

369 f"#{request['id']} {request['state'].upper()}{draft} {request['title']}" 

370 f"\n {request['branch']} {request['url']}" 

371 ) 

372 lines += [f" {line}" for line in _render_checks(request["checks"])] 

373 lines += [f"note {note}" for note in summary["notes"]] 

374 return "\n".join(lines) 

375 

376 

377def _render_checks(checks: list[dict[str, Any]]) -> list[str]: 

378 """Render one request's checks, with a drill-down line under each failing one.""" 

379 if not checks: 

380 return ["(no checks reported — did a workflow ever trigger?)"] 

381 lines = [] 

382 for check in checks: 

383 mark = _MARK.get(check["state"], "?") 

384 lines.append(f"{mark} {check['name']} {check['url']}".rstrip()) 

385 command = check.get("logs_command") 

386 if check["state"] == FAILURE and command: 

387 lines.append(f" logs: {' '.join(command)}") 

388 return lines 

389 

390 

391def main(argv: list[str] | None = None) -> int: 

392 """Entry point: report the open requests' CI state and return an exit code.""" 

393 parser = argparse.ArgumentParser(description="Report open requests and their CI state.") 

394 parser.add_argument("--target-dir", default=".", help="Repository root (default: cwd).") 

395 scope = parser.add_mutually_exclusive_group() 

396 scope.add_argument("--branch", default=None, help="Only the request for this branch.") 

397 scope.add_argument( 

398 "--all", action="store_true", help="Every open request, not just this branch's." 

399 ) 

400 parser.add_argument("--limit", type=int, default=20, help="Maximum requests to fetch.") 

401 parser.add_argument( 

402 "--dry-run", action="store_true", help="Print the query without running it." 

403 ) 

404 parser.add_argument( 

405 "--json", dest="json_output", action="store_true", help="Emit the report as JSON." 

406 ) 

407 args = parser.parse_args(argv) 

408 

409 target_dir = Path(args.target_dir).resolve() 

410 branch = None if args.all else (args.branch or current_branch(target_dir)) 

411 try: 

412 summary = collect(target_dir, branch=branch, limit=args.limit, dry_run=args.dry_run) 

413 except PlatformError as exc: 

414 print(f"error: {exc}", file=sys.stderr) 

415 return EXIT_USAGE 

416 except ForgeQueryError as exc: 

417 print(f"error: {exc}", file=sys.stderr) 

418 return EXIT_CLI_FAILED 

419 

420 print(json.dumps(summary, indent=2) if args.json_output else render(summary)) 

421 return EXIT_OK 

422 

423 

424if __name__ == "__main__": 

425 raise SystemExit(main())