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

126 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +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 release-create publish a release from an existing tag 

34 

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

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

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

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

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

40 

41Three divergences are surfaced rather than papered over: 

42 

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

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

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

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

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

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

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

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

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

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

53 a non-interactive run rather than failing it. 

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

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

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

57 

58Usage: 

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

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

61 

62Exit codes: 

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

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

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

66 action has no equivalent on this platform 

67""" 

68 

69from __future__ import annotations 

70 

71import argparse 

72import json 

73import shutil 

74import subprocess # nosec B404 

75import sys 

76from pathlib import Path 

77from typing import Any 

78 

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

80from _rhiza_forge import PlatformError, detect_platform # noqa: E402 

81 

82EXIT_OK = 0 

83EXIT_CLI_FAILED = 1 

84EXIT_USAGE = 2 

85 

86ACTIONS = ( 

87 "auth-status", 

88 "repo-view", 

89 "pr-create", 

90 "pr-update", 

91 "pr-merge", 

92 "issue-create", 

93 "release-create", 

94) 

95 

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

97_BODY_ACTIONS = ("pr-create", "pr-update", "issue-create") 

98 

99 

100class UnsupportedAction(Exception): 

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

102 

103 

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

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

106 if action == "auth-status": 

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

108 if action == "repo-view": 

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

110 if action == "pr-update": 

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

112 if action == "pr-create": 

113 return [ 

114 "gh", "pr", "create", 

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

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

117 ] # fmt: skip 

118 if action == "pr-merge": 

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

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

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

122 if action == "issue-create": 

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

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

125 if opts.get("notes_file"): 

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

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

128 

129 

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

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

132 

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

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

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

136 """ 

137 if action == "auth-status": 

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

139 if action == "repo-view": 

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

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

142 # one piece of code normalises both platforms. 

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

144 if action == "pr-update": 

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

146 if action == "pr-create": 

147 return [ 

148 "glab", "mr", "create", 

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

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

151 ] # fmt: skip 

152 if action == "pr-merge": 

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

154 if action == "issue-create": 

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

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

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

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

159 raise UnsupportedAction( 

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

161 "that /rhiza:release already produces)" 

162 ) 

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

164 

165 

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

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

168 

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

170 platforms with neither CLI installed. 

171 """ 

172 if platform == "github": 

173 return _github_command(action, opts) 

174 return _gitlab_command(action, opts) 

175 

176 

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

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

179 

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

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

182 answer. Visibility is lower-cased here. 

183 """ 

184 if platform == "github": 

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

186 else: 

187 branch = payload.get("default_branch") 

188 visibility = payload.get("visibility") 

189 return { 

190 "default_branch": branch or None, 

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

192 } 

193 

194 

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

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

197 platform = detect_platform(target_dir) 

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

199 

200 summary: dict[str, Any] = { 

201 "platform": platform, 

202 "action": action, 

203 "command": command, 

204 "dry_run": dry_run, 

205 "url": None, 

206 "data": None, 

207 "notes": [], 

208 "exit_code": EXIT_OK, 

209 } 

210 if dry_run: 

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

212 return summary 

213 

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

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

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

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

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

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

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

221 if executable is None: 

222 summary.update( 

223 exit_code=EXIT_CLI_FAILED, 

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

225 ) 

226 return summary 

227 

228 result = subprocess.run( # nosec B603 

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

230 ) 

231 if result.returncode != 0: 

232 summary.update( 

233 exit_code=EXIT_CLI_FAILED, 

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

235 ) 

236 return summary 

237 

238 if action == "repo-view": 

239 try: 

240 payload = json.loads(result.stdout) 

241 except ValueError: 

242 summary.update( 

243 exit_code=EXIT_CLI_FAILED, 

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

245 ) 

246 return summary 

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

248 return summary 

249 

250 summary["url"] = next( 

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

252 None, 

253 ) 

254 return summary 

255 

256 

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

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

259 

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

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

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

263 """ 

264 if not body_file: 

265 return None 

266 candidate = target_dir / body_file 

267 if not candidate.is_file(): 

268 candidate = Path(body_file) 

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

270 

271 

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

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

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

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

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

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

278 parser.add_argument( 

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

280 ) 

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

282 parser.add_argument( 

283 "--body-file", default=None, help="File holding the body (pr-create/update, issue-create)." 

284 ) 

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

286 parser.add_argument( 

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

288 ) 

289 parser.add_argument( 

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

291 ) 

292 parser.add_argument( 

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

294 ) 

295 args = parser.parse_args(argv) 

296 

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

298 body = resolve_body(target_dir, args.body_file) 

299 if args.action in _BODY_ACTIONS and body is None: 

300 print(f"error: --body-file is required and must exist for {args.action}", file=sys.stderr) 

301 return EXIT_USAGE 

302 

303 try: 

304 summary = run( 

305 target_dir, 

306 args.action, 

307 dry_run=args.dry_run, 

308 base=args.base, 

309 head=args.head, 

310 title=args.title, 

311 body_file=args.body_file, 

312 body=body, 

313 tag=args.tag, 

314 notes_file=args.notes_file, 

315 ) 

316 except (PlatformError, UnsupportedAction) as exc: 

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

318 return EXIT_USAGE 

319 

320 if args.json_output: 

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

322 else: 

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

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

325 if summary["url"]: 

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

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

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

329 for note in summary["notes"]: 

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

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

332 return int(summary["exit_code"]) 

333 

334 

335if __name__ == "__main__": 

336 raise SystemExit(main())