Coverage for scripts/platform_cli.py: 100%

147 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 06:18 +0000

1#!/usr/bin/env python3 

2"""Map an operation onto `gh` or `glab` — the 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 issue-create file an issue 

32 release-create publish a release from an existing tag 

33 

34Two divergences are surfaced rather than papered over: 

35 

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

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

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

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

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

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

42 

43Usage: 

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

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

46 

47Exit codes: 

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

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

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

51 action has no equivalent on this platform 

52""" 

53 

54from __future__ import annotations 

55 

56import argparse 

57import json 

58import os 

59import re 

60import shutil 

61import subprocess # nosec B404 

62import sys 

63from pathlib import Path 

64from typing import Any 

65 

66_KNOWN_HOSTS = {"github.com": "github", "gitlab.com": "gitlab"} 

67 

68EXIT_OK = 0 

69EXIT_CLI_FAILED = 1 

70EXIT_USAGE = 2 

71 

72ACTIONS = ( 

73 "auth-status", 

74 "repo-view", 

75 "pr-create", 

76 "pr-update", 

77 "issue-create", 

78 "release-create", 

79) 

80 

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

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

83 

84 

85class PlatformError(Exception): 

86 """The hosting platform could not be determined.""" 

87 

88 

89class UnsupportedAction(Exception): 

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

91 

92 

93def _git(target_dir: Path, args: list[str]) -> str: 

94 """Run a read-only git command, returning stdout ('' on failure).""" 

95 env = os.environ.copy() 

96 env["GIT_TERMINAL_PROMPT"] = "0" 

97 result = subprocess.run( # nosec B603 

98 [shutil.which("git") or "git", *args], 

99 cwd=str(target_dir), 

100 capture_output=True, 

101 text=True, 

102 env=env, 

103 check=False, 

104 ) 

105 return result.stdout.strip() if result.returncode == 0 else "" 

106 

107 

108def classify_host(host: str) -> str | None: 

109 """Return ``github``/``gitlab`` for *host*, or None when it is neither. 

110 

111 Label-boundary matching, so ``github.com.evil.example`` — which embeds a known 

112 domain without being a subdomain of it — is not taken for GitHub. Self-hosted 

113 GitLab conventionally lives at ``gitlab.<company>.<tld>``. 

114 """ 

115 h = host.lower().rstrip(".") 

116 for domain, platform in _KNOWN_HOSTS.items(): 

117 if h == domain or h.endswith(f".{domain}"): 

118 return platform 

119 if domain in h: 

120 return None 

121 return "gitlab" if h.split(".", 1)[0] == "gitlab" else None 

122 

123 

124def detect_platform(target_dir: Path) -> str: 

125 """Return the hosting platform for *target_dir*'s `origin` remote.""" 

126 url = _git(target_dir, ["remote", "get-url", "origin"]) 

127 if not url: 

128 raise PlatformError("no `origin` remote — cannot tell which platform to use") 

129 match = re.match(r"git@([^:]+):", url) or re.match(r"[a-zA-Z]+://(?:[^@]+@)?([^/]+)/", url) 

130 if match is None: 

131 raise PlatformError(f"could not parse a host from origin: {url}") 

132 platform = classify_host(match.group(1)) 

133 if platform is None: 

134 raise PlatformError( 

135 f"unsupported host {match.group(1)!r} — only GitHub and GitLab are handled" 

136 ) 

137 return platform 

138 

139 

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

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

142 if action == "auth-status": 

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

144 if action == "repo-view": 

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

146 if action == "pr-update": 

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

148 if action == "pr-create": 

149 return [ 

150 "gh", "pr", "create", 

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

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

153 ] # fmt: skip 

154 if action == "issue-create": 

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

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

157 if opts.get("notes_file"): 

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

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

160 

161 

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

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

164 

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

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

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

168 """ 

169 if action == "auth-status": 

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

171 if action == "repo-view": 

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

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

174 # one piece of code normalises both platforms. 

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

176 if action == "pr-update": 

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

178 if action == "pr-create": 

179 return [ 

180 "glab", "mr", "create", 

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

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

183 ] # fmt: skip 

184 if action == "issue-create": 

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

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

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

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

189 raise UnsupportedAction( 

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

191 "that /rhiza:release already produces)" 

192 ) 

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

194 

195 

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

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

198 

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

200 platforms with neither CLI installed. 

201 """ 

202 if platform == "github": 

203 return _github_command(action, opts) 

204 return _gitlab_command(action, opts) 

205 

206 

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

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

209 

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

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

212 answer. Visibility is lower-cased here. 

213 """ 

214 if platform == "github": 

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

216 else: 

217 branch = payload.get("default_branch") 

218 visibility = payload.get("visibility") 

219 return { 

220 "default_branch": branch or None, 

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

222 } 

223 

224 

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

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

227 platform = detect_platform(target_dir) 

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

229 

230 summary: dict[str, Any] = { 

231 "platform": platform, 

232 "action": action, 

233 "command": command, 

234 "dry_run": dry_run, 

235 "url": None, 

236 "data": None, 

237 "notes": [], 

238 "exit_code": EXIT_OK, 

239 } 

240 if dry_run: 

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

242 return summary 

243 

244 if shutil.which(command[0]) is None: 

245 summary.update( 

246 exit_code=EXIT_CLI_FAILED, 

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

248 ) 

249 return summary 

250 

251 result = subprocess.run( # nosec B603 

252 command, cwd=str(target_dir), capture_output=True, text=True, check=False 

253 ) 

254 if result.returncode != 0: 

255 summary.update( 

256 exit_code=EXIT_CLI_FAILED, 

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

258 ) 

259 return summary 

260 

261 if action == "repo-view": 

262 try: 

263 payload = json.loads(result.stdout) 

264 except ValueError: 

265 summary.update( 

266 exit_code=EXIT_CLI_FAILED, 

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

268 ) 

269 return summary 

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

271 return summary 

272 

273 summary["url"] = next( 

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

275 None, 

276 ) 

277 return summary 

278 

279 

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

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

282 

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

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

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

286 """ 

287 if not body_file: 

288 return None 

289 candidate = target_dir / body_file 

290 if not candidate.is_file(): 

291 candidate = Path(body_file) 

292 return candidate.read_text() if candidate.is_file() else None 

293 

294 

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

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

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

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

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

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

301 parser.add_argument("--head", default="", help="Branch holding the work (pr-create/update).") 

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

303 parser.add_argument( 

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

305 ) 

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

307 parser.add_argument( 

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

309 ) 

310 parser.add_argument( 

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

312 ) 

313 parser.add_argument( 

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

315 ) 

316 args = parser.parse_args(argv) 

317 

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

319 body = resolve_body(target_dir, args.body_file) 

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

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

322 return EXIT_USAGE 

323 

324 try: 

325 summary = run( 

326 target_dir, 

327 args.action, 

328 dry_run=args.dry_run, 

329 base=args.base, 

330 head=args.head, 

331 title=args.title, 

332 body_file=args.body_file, 

333 body=body, 

334 tag=args.tag, 

335 notes_file=args.notes_file, 

336 ) 

337 except (PlatformError, UnsupportedAction) as exc: 

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

339 return EXIT_USAGE 

340 

341 if args.json_output: 

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

343 else: 

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

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

346 if summary["url"]: 

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

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

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

350 for note in summary["notes"]: 

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

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

353 return int(summary["exit_code"]) 

354 

355 

356if __name__ == "__main__": 

357 raise SystemExit(main())