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
« 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".
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 issue-create file an issue
32 release-create publish a release from an existing tag
34Two divergences are surfaced rather than papered over:
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`.
43Usage:
44 uv run --python 3.12 --no-project python \
45 scripts/platform_cli.py ACTION [--target-dir DIR] [--dry-run] [--json] ...
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"""
54from __future__ import annotations
56import argparse
57import json
58import os
59import re
60import shutil
61import subprocess # nosec B404
62import sys
63from pathlib import Path
64from typing import Any
66_KNOWN_HOSTS = {"github.com": "github", "gitlab.com": "gitlab"}
68EXIT_OK = 0
69EXIT_CLI_FAILED = 1
70EXIT_USAGE = 2
72ACTIONS = (
73 "auth-status",
74 "repo-view",
75 "pr-create",
76 "pr-update",
77 "issue-create",
78 "release-create",
79)
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")
85class PlatformError(Exception):
86 """The hosting platform could not be determined."""
89class UnsupportedAction(Exception):
90 """The requested action has no equivalent on this platform."""
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 ""
108def classify_host(host: str) -> str | None:
109 """Return ``github``/``gitlab`` for *host*, or None when it is neither.
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
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
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"]
162def _gitlab_command(action: str, opts: dict[str, Any]) -> list[str]:
163 """Return the `glab` argv for *action*.
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"]]
196def build_command(platform: str, action: str, **opts: Any) -> list[str]:
197 """Return the argv that performs *action* on *platform*.
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)
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}``.
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 }
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)
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
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
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
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
273 summary["url"] = next(
274 (line.strip() for line in result.stdout.splitlines() if line.strip().startswith("http")),
275 None,
276 )
277 return summary
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.
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
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)
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
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
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"])
356if __name__ == "__main__":
357 raise SystemExit(main())