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

161 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-26 04:21 +0000

1#!/usr/bin/env python3 

2"""Map a **write** operation onto `gh` or `glab` — one home for "the two CLIs disagree". 

3 

4Every rhiza command that touches the forge has to pick between GitHub's `gh` and 

5GitLab's `glab`, and the two differ in **subcommand, flag names, and output shape**: 

6 

7 gh pr create --base B --head H --title T --body-file F 

8 glab mr create --target-branch B --source-branch H --title T --description TEXT 

9 

10 gh issue create --title T --body-file F 

11 glab issue create --title T --description TEXT 

12 

13 gh repo view --json defaultBranchRef,visibility -> {"visibility": "PUBLIC"} 

14 glab repo view -F json -> {"visibility": "public"} 

15 

16That mapping used to live in command prose, where nothing executes it and no test can 

17reach it. It showed: `/update` shipped with **no GitLab branch at all**, detecting 

18GitLab, offering `gitlab-project`, then calling `gh pr create` and failing. 

19 

20Extracting it is not sufficient on its own. The first extraction still passed 

21``--description-file`` to `glab mr create` — a flag **glab has never had** — and the 

22tests did not catch it, because they stubbed the CLI and asserted the argv the code 

23itself produced. Self-consistency is not correctness. The flags below were read off 

24`glab --help` and checked against a real binary; ``tests/test_platform_cli.py`` records 

25what each one is, so a future edit has something to contradict. 

26 

27Actions: 

28 auth-status is the platform CLI installed and logged in? 

29 repo-view default branch + visibility, **normalised** across the two shapes 

30 pr-create open a pull/merge request pr-update edit its body 

31 pr-merge let the request merge itself once its checks pass 

32 issue-create file an issue 

33 issue-comment comment on an issue pr-comment comment on a request 

34 release-create publish a release from an existing tag 

35 

36**Every issue and every comment is stamped.** `issue-create`, `issue-comment` and 

37`pr-comment` put `ISSUE_BADGE` on the first line of the body before it is sent, so a 

38finding rhiza filed in somebody else's tracker says where it came from instead of 

39arriving anonymously. It happens here, not in the command prose, for the reason anything 

40deterministic does: prose is re-read by a model each run, and a provenance mark that is 

41only usually applied is worse than none. A comment is stamped for the same reason: it is 

42posted under a person's account, and a recommendation to close someone's pull request 

43reads very differently once you know a tool drafted it. The caller's body file is left 

44untouched — the stamped text goes to a scratch file, since gh takes a path where glab 

45takes the text. 

46 

47Reading a request's CI state is the same problem one door down, and it lives in 

48``pr_status.py`` rather than here: its two CLIs disagree about *shape* far more than 

49about flags, and folding it in would push this module past the size and complexity bars 

50it is held to. What the two share — deciding which forge `origin` is on at all — is in 

51``_rhiza_forge.py``, so there is still exactly one answer to that question. 

52 

53Three divergences are surfaced rather than papered over: 

54 

55* **glab has no `--body-file`/`--description-file` anywhere.** The body is passed 

56 inline via ``--description``, so this script reads the file and puts its text on the 

57 command line. (`-d -` means "open an editor", which is useless non-interactively.) 

58* **The two disagree about what "auto" defers on.** `gh pr merge --auto` waits for the 

59 *required checks* configured on the branch, and is refused outright — `Auto-merge is 

60 not allowed for this repository` — when the repo has the setting switched off. glab's 

61 `--auto-merge` defers only *while a pipeline is running*: with no pipeline in flight 

62 it merges the MR immediately. Both are "merge this when it is allowed to merge", which 

63 is what `/rhiza:release` asks for, but only the GitHub side is a gate. The `--yes` on 

64 the glab argv is not optional — without it `glab mr merge` prompts, and a prompt hangs 

65 a non-interactive run rather than failing it. 

66* **glab spells a comment `note`, and on a merge request it is a subcommand deeper.** 

67 `gh pr comment N` and `gh issue comment N` are symmetric; glab has `glab issue note N` 

68 but `glab mr note create N` — the bare `glab mr note N --message` still parses in glab 

69 1.112 but warns that `--message` there is deprecated, so it is not used. 

70* **glab has no `--generate-notes`.** `gh release create` can synthesise release notes; 

71 GitLab cannot. Asking for it on GitLab is an error naming the fix — pass 

72 ``--notes-file``, which `/rhiza:release` already has from `git-cliff`. 

73 

74Usage: 

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

76 scripts/platform_cli.py ACTION [--target-dir DIR] [--dry-run] [--json] ... 

77 

78Exit codes: 

79 0 done, or (with --dry-run) rendered 

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

81 2 the platform could not be determined, a required file is missing, or the 

82 action has no equivalent on this platform 

83""" 

84 

85from __future__ import annotations 

86 

87import argparse 

88import contextlib 

89import json 

90import shutil 

91import subprocess # nosec B404 

92import sys 

93import tempfile 

94from collections.abc import Iterator 

95from pathlib import Path 

96from typing import Any 

97 

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

99from _rhiza_forge import PlatformError, detect_platform # noqa: E402 

100 

101EXIT_OK = 0 

102EXIT_CLI_FAILED = 1 

103EXIT_USAGE = 2 

104 

105ACTIONS = ( 

106 "auth-status", 

107 "repo-view", 

108 "pr-create", 

109 "pr-update", 

110 "pr-merge", 

111 "issue-create", 

112 "issue-comment", 

113 "pr-comment", 

114 "release-create", 

115) 

116 

117# The actions that address an existing issue or request by its number. 

118_COMMENT_ACTIONS = ("issue-comment", "pr-comment") 

119 

120# The actions whose body text has to reach the CLI one way or the other. 

121_BODY_ACTIONS = ("pr-create", "pr-update", "issue-create", *_COMMENT_ACTIONS) 

122 

123# The actions whose body is stamped with `ISSUE_BADGE` before it is sent. A request body 

124# is not among them: a request already has a branch, an author and a diff to attribute it. 

125_STAMPED_ACTIONS = ("issue-create", *_COMMENT_ACTIONS) 

126 

127# Stamped at the top of every issue body. Filing is the one thing rhiza does *inside* 

128# somebody else's tracker, where the next reader has no other way to tell an automated 

129# finding from a human one, so the badge says so and links back to what filed it. 

130ISSUE_BADGE = ( 

131 "[![rhiza](https://img.shields.io/badge/filed_by-rhiza-007ec6)]" 

132 "(https://github.com/Jebel-Quant/rhiza-claude)" 

133) 

134 

135 

136def stamp_issue_body(body: str) -> str: 

137 """Return *body* with `ISSUE_BADGE` as its first line. 

138 

139 Idempotent, and deliberately so: a body that already carries the badge is returned 

140 unchanged, so a caller that stamped its own — or a re-run over the same file — never 

141 stacks two. 

142 

143 >>> stamp_issue_body("Coverage is 84%.\\n").splitlines()[0] == ISSUE_BADGE 

144 True 

145 >>> stamp_issue_body(stamp_issue_body("x")) == stamp_issue_body("x") 

146 True 

147 """ 

148 if body.lstrip().startswith(ISSUE_BADGE): 

149 return body 

150 return f"{ISSUE_BADGE}\n\n{body.lstrip()}" 

151 

152 

153class UnsupportedAction(Exception): 

154 """The requested action has no equivalent on this platform.""" 

155 

156 

157def _github_command(action: str, opts: dict[str, Any]) -> list[str]: 

158 """Return the `gh` argv for *action*.""" 

159 if action == "auth-status": 

160 return ["gh", "auth", "status"] 

161 if action == "repo-view": 

162 return ["gh", "repo", "view", "--json", "defaultBranchRef,visibility"] 

163 if action == "pr-update": 

164 return ["gh", "pr", "edit", opts["head"], "--body-file", opts["body_file"]] 

165 if action == "pr-create": 

166 return [ 

167 "gh", "pr", "create", 

168 "--base", opts["base"], "--head", opts["head"], 

169 "--title", opts["title"], "--body-file", opts["body_file"], 

170 ] # fmt: skip 

171 if action == "pr-merge": 

172 # No `--delete-branch`: the branch is the release PR's, and whether a merged 

173 # branch is deleted is a repo policy this has no business overriding. 

174 return ["gh", "pr", "merge", opts["head"], "--squash", "--auto"] 

175 if action == "issue-create": 

176 return ["gh", "issue", "create", "--title", opts["title"], "--body-file", opts["body_file"]] 

177 if action in _COMMENT_ACTIONS: 

178 group = "pr" if action == "pr-comment" else "issue" 

179 return ["gh", group, "comment", str(opts["number"]), "--body-file", opts["body_file"]] 

180 command = ["gh", "release", "create", opts["tag"]] 

181 if opts.get("notes_file"): 

182 return [*command, "--notes-file", opts["notes_file"]] 

183 return [*command, "--generate-notes"] 

184 

185 

186def _gitlab_command(action: str, opts: dict[str, Any]) -> list[str]: 

187 """Return the `glab` argv for *action*. 

188 

189 The body-carrying actions take their text **inline**: glab has no file flag for a 

190 description on `mr create`, `mr update` or `issue create`. Passing 

191 ``--description-file`` there is not a near-miss, it is ``Unknown flag``. 

192 """ 

193 if action == "auth-status": 

194 return ["glab", "auth", "status"] 

195 if action == "repo-view": 

196 # `-F json` returns the Projects API object, which carries `default_branch` 

197 # and `visibility` directly. Parsed here rather than through glab's `--jq`, so 

198 # one piece of code normalises both platforms. 

199 return ["glab", "repo", "view", "-F", "json"] 

200 if action == "pr-update": 

201 return ["glab", "mr", "update", opts["head"], "--description", opts["body"]] 

202 if action == "pr-create": 

203 return [ 

204 "glab", "mr", "create", 

205 "--target-branch", opts["base"], "--source-branch", opts["head"], 

206 "--title", opts["title"], "--description", opts["body"], 

207 ] # fmt: skip 

208 if action == "pr-merge": 

209 return ["glab", "mr", "merge", opts["head"], "--squash", "--auto-merge", "--yes"] 

210 if action == "issue-create": 

211 # Supplying both --title and --description is what stops glab opening an 

212 # editor, which would hang a non-interactive run. 

213 return ["glab", "issue", "create", "--title", opts["title"], "--description", opts["body"]] 

214 if action in _COMMENT_ACTIONS: 

215 verb = ["mr", "note", "create"] if action == "pr-comment" else ["issue", "note"] 

216 return ["glab", *verb, str(opts["number"]), "--message", opts["body"]] 

217 if not opts.get("notes_file"): 

218 raise UnsupportedAction( 

219 "glab has no --generate-notes; pass --notes-file (e.g. the git-cliff output " 

220 "that /rhiza:release already produces)" 

221 ) 

222 return ["glab", "release", "create", opts["tag"], "--notes-file", opts["notes_file"]] 

223 

224 

225def build_command(platform: str, action: str, **opts: Any) -> list[str]: 

226 """Return the argv that performs *action* on *platform*. 

227 

228 Kept separate from execution so a test can assert the exact argv for both 

229 platforms with neither CLI installed. 

230 """ 

231 if platform == "github": 

232 return _github_command(action, opts) 

233 return _gitlab_command(action, opts) 

234 

235 

236def normalize_repo_view(platform: str, payload: dict[str, Any]) -> dict[str, Any]: 

237 """Reduce either CLI's `repo view` JSON to ``{default_branch, visibility}``. 

238 

239 The two disagree on key names *and* on case — gh answers ``"PUBLIC"``, glab 

240 ``"public"`` — so a caller comparing the raw value gets a platform-dependent 

241 answer. Visibility is lower-cased here. 

242 """ 

243 if platform == "github": 

244 branch = (payload.get("defaultBranchRef") or {}).get("name") 

245 else: 

246 branch = payload.get("default_branch") 

247 visibility = payload.get("visibility") 

248 return { 

249 "default_branch": branch or None, 

250 "visibility": visibility.lower() if isinstance(visibility, str) else None, 

251 } 

252 

253 

254def run(target_dir: Path, action: str, *, dry_run: bool = False, **opts: Any) -> dict[str, Any]: 

255 """Perform *action* against *target_dir*'s platform; return a summary dict.""" 

256 platform = detect_platform(target_dir) 

257 command = build_command(platform, action, **opts) 

258 

259 summary: dict[str, Any] = { 

260 "platform": platform, 

261 "action": action, 

262 "command": command, 

263 "dry_run": dry_run, 

264 "url": None, 

265 "data": None, 

266 "notes": [], 

267 "exit_code": EXIT_OK, 

268 } 

269 if dry_run: 

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

271 return summary 

272 

273 # Run the path `which` resolved, not the bare name again. On Windows the two are not 

274 # the same question: `shutil.which` honours PATHEXT and so finds a `gh.cmd`/`glab.cmd` 

275 # shim (how npm- and scoop-installed CLIs commonly arrive), while CreateProcess given 

276 # a bare name only ever appends `.exe` — so the check passed and the call then failed 

277 # with FileNotFoundError. Handing it the resolved path closes that gap, and matches 

278 # what `_rhiza_forge.git_stdout` already does for git. 

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

280 if executable is None: 

281 summary.update( 

282 exit_code=EXIT_CLI_FAILED, 

283 notes=[f"{command[0]} is not installed — do this step manually"], 

284 ) 

285 return summary 

286 

287 result = subprocess.run( # nosec B603 

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

289 ) 

290 if result.returncode != 0: 

291 summary.update( 

292 exit_code=EXIT_CLI_FAILED, 

293 notes=[f"{command[0]} failed: {result.stderr.strip()[:300]}"], 

294 ) 

295 return summary 

296 

297 if action == "repo-view": 

298 try: 

299 payload = json.loads(result.stdout) 

300 except ValueError: 

301 summary.update( 

302 exit_code=EXIT_CLI_FAILED, 

303 notes=[f"{command[0]} returned output that is not JSON"], 

304 ) 

305 return summary 

306 summary["data"] = normalize_repo_view(platform, payload) 

307 return summary 

308 

309 summary["url"] = next( 

310 (line.strip() for line in result.stdout.splitlines() if line.strip().startswith("http")), 

311 None, 

312 ) 

313 return summary 

314 

315 

316def resolve_body(target_dir: Path, body_file: str | None) -> str | None: 

317 """Return the text of *body_file*, or None when it is absent or unreadable. 

318 

319 The text is read even for GitHub, which takes a path: it is the only way to give 

320 the GitLab branch what it needs, and reading it here means a missing file is one 

321 error at the boundary rather than two divergent ones inside the CLIs. 

322 """ 

323 if not body_file: 

324 return None 

325 candidate = target_dir / body_file 

326 if not candidate.is_file(): 

327 candidate = Path(body_file) 

328 return candidate.read_text(encoding="utf-8") if candidate.is_file() else None 

329 

330 

331@contextlib.contextmanager 

332def prepared_body( 

333 action: str, body: str | None, body_file: str | None 

334) -> Iterator[tuple[str | None, str | None]]: 

335 """Yield the ``(body, body_file)`` to send, stamping the badge where one is owed. 

336 

337 Both halves have to move together: glab is given the text and gh a path, so a stamp 

338 applied to only one would file two different issues depending on the forge. The 

339 scratch file lives for the length of the call and no longer — the caller's body file 

340 is never rewritten, since it belongs to the command that wrote it. 

341 """ 

342 if action not in _STAMPED_ACTIONS or body is None: 

343 yield body, body_file 

344 return 

345 with tempfile.TemporaryDirectory() as scratch: 

346 stamped = stamp_issue_body(body) 

347 path = Path(scratch) / "issue-body.md" 

348 path.write_text(stamped, encoding="utf-8") 

349 yield stamped, str(path) 

350 

351 

352def usage_error(action: str, *, body: str | None, number: int) -> str | None: 

353 """Return what is missing for *action* to run, or None when nothing is. 

354 

355 >>> usage_error("pr-comment", body="x", number=0) 

356 '--number is required for pr-comment' 

357 >>> usage_error("auth-status", body=None, number=0) is None 

358 True 

359 """ 

360 if action in _BODY_ACTIONS and body is None: 

361 return f"--body-file is required and must exist for {action}" 

362 if action in _COMMENT_ACTIONS and number <= 0: 

363 return f"--number is required for {action}" 

364 return None 

365 

366 

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

368 """Entry point: perform the requested action and return an exit code.""" 

369 parser = argparse.ArgumentParser(description="Run a forge operation on gh or glab.") 

370 parser.add_argument("action", choices=ACTIONS, help="The operation to perform.") 

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

372 parser.add_argument("--base", default="", help="Branch to merge into (pr-create).") 

373 parser.add_argument( 

374 "--head", default="", help="Branch holding the work (pr-create/update/merge)." 

375 ) 

376 parser.add_argument("--title", default="", help="Title (pr-create, issue-create).") 

377 parser.add_argument( 

378 "--body-file", 

379 default=None, 

380 help="File holding the body (pr-create/update, issue-create, *-comment).", 

381 ) 

382 parser.add_argument( 

383 "--number", type=int, default=0, help="Issue or request number (issue-/pr-comment)." 

384 ) 

385 parser.add_argument("--tag", default="", help="Tag to release (release-create).") 

386 parser.add_argument( 

387 "--notes-file", default=None, help="Release notes file; required on GitLab." 

388 ) 

389 parser.add_argument( 

390 "--dry-run", action="store_true", help="Print the command without running it." 

391 ) 

392 parser.add_argument( 

393 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON." 

394 ) 

395 args = parser.parse_args(argv) 

396 

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

398 body = resolve_body(target_dir, args.body_file) 

399 problem = usage_error(args.action, body=body, number=args.number) 

400 if problem: 

401 print(f"error: {problem}", file=sys.stderr) 

402 return EXIT_USAGE 

403 

404 with prepared_body(args.action, body, args.body_file) as (body, body_file): 

405 try: 

406 summary = run( 

407 target_dir, 

408 args.action, 

409 dry_run=args.dry_run, 

410 base=args.base, 

411 head=args.head, 

412 title=args.title, 

413 number=args.number, 

414 body_file=body_file, 

415 body=body, 

416 tag=args.tag, 

417 notes_file=args.notes_file, 

418 ) 

419 except (PlatformError, UnsupportedAction) as exc: 

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

421 return EXIT_USAGE 

422 

423 if args.json_output: 

424 print(json.dumps(summary, indent=2)) 

425 else: 

426 print(f"platform {summary['platform']}") 

427 print(f"command {' '.join(summary['command'])}") 

428 if summary["url"]: 

429 print(f"url {summary['url']}") 

430 for key, value in (summary["data"] or {}).items(): 

431 print(f"{key:<14} {value}") 

432 for note in summary["notes"]: 

433 stream = sys.stdout if summary["exit_code"] == EXIT_OK else sys.stderr 

434 print(f"note {note}", file=stream) 

435 return int(summary["exit_code"]) 

436 

437 

438if __name__ == "__main__": 

439 raise SystemExit(main())