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
« 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".
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**:
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
10 gh issue create --title T --body-file F
11 glab issue create --title T --description TEXT
13 gh repo view --json defaultBranchRef,visibility -> {"visibility": "PUBLIC"}
14 glab repo view -F json -> {"visibility": "public"}
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.
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.
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
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.
41Three divergences are surfaced rather than papered over:
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`.
58Usage:
59 uv run --python 3.12 --no-project python \
60 scripts/platform_cli.py ACTION [--target-dir DIR] [--dry-run] [--json] ...
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"""
69from __future__ import annotations
71import argparse
72import json
73import shutil
74import subprocess # nosec B404
75import sys
76from pathlib import Path
77from typing import Any
79sys.path.insert(0, str(Path(__file__).resolve().parent))
80from _rhiza_forge import PlatformError, detect_platform # noqa: E402
82EXIT_OK = 0
83EXIT_CLI_FAILED = 1
84EXIT_USAGE = 2
86ACTIONS = (
87 "auth-status",
88 "repo-view",
89 "pr-create",
90 "pr-update",
91 "pr-merge",
92 "issue-create",
93 "release-create",
94)
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")
100class UnsupportedAction(Exception):
101 """The requested action has no equivalent on this platform."""
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"]
130def _gitlab_command(action: str, opts: dict[str, Any]) -> list[str]:
131 """Return the `glab` argv for *action*.
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"]]
166def build_command(platform: str, action: str, **opts: Any) -> list[str]:
167 """Return the argv that performs *action* on *platform*.
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)
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}``.
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 }
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)
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
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
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
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
250 summary["url"] = next(
251 (line.strip() for line in result.stdout.splitlines() if line.strip().startswith("http")),
252 None,
253 )
254 return summary
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.
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
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)
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
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
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"])
335if __name__ == "__main__":
336 raise SystemExit(main())