Coverage for plugin/scripts/wait_for_merge.py: 100%
94 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"""Block until the release bump is on the default branch, so one run can tag it.
4`/rhiza:release` cannot tag what it has just built. A squash-merge replaces the release
5branch's commits with a new one, so the commit worth tagging does not exist until the
6request lands. That used to end the run: the user came back later and invoked the command
7a second time, and the second invocation's whole job was to notice the merge had happened.
8This is that same wait, held inside the one invocation.
10**What it waits for is the bump landing, not a request merging.** Those are different
11claims, and only the first one is the precondition for cutting a tag — a request can be
12closed and re-landed by hand, merged with a title nobody recognises, or squashed into a
13SHA no one predicted, and none of that changes what has to be true before the tag. So the
14probe reads content off the remote branch itself:
16 git show refs/remotes/origin/<branch>:CHANGELOG.md -> newest `## [X.Y.Z]` heading
18`CHANGELOG.md` rather than the declared version location, because the changelog section
19is the one artifact **every** release commit carries: in every language, and on a
20`dynamic = ["version"]` project whose manifest holds no version at all. It is also the
21same fact `check_version_bump.py` reads to tell a merged-but-untagged release from a
22fresh one — through the same parser, `_rhiza_changelog` — so the wait and the phase
23check agree by construction rather than by luck.
25No forge, no CLI, no auth: `git fetch` against `origin` is the entire mechanism. A repo
26whose `gh`/`glab` is missing or logged out still gets the wait.
28**The timeout is a hand-off, not a failure.** Review takes as long as it takes, and a
29session does not outlive a weekend, so running out of time is an expected outcome with
30its own exit code — the caller reports the open request and stops, and the release is
31finished by re-running the command, which finds the merged bump and tags it. Nothing is
32half-done at that point: the wait creates nothing.
34Usage:
35 uv run --python 3.12 --no-project python \
36 scripts/wait_for_merge.py --branch B --expect-version X.Y.Z [--target-dir DIR]
37 [--changelog FILE] [--timeout SECONDS]
38 [--interval SECONDS] [--json]
40``--timeout 0`` polls exactly once, which is how to ask "has it landed?" without waiting.
41The default waits nine minutes, which is what fits in one tool call; waiting longer is the
42caller running this again, not a bigger number.
44Exit codes:
45 0 the bump is on the branch — the SHA to tag is in the summary
46 1 git failed (no `origin`, no network, no such branch) — nothing was waited for
47 2 usage: `--expect-version` is not X.Y.Z
48 3 the timeout elapsed with the bump still not landed — hand back to the caller
49"""
51from __future__ import annotations
53import argparse
54import json
55import os
56import re
57import shutil
58import subprocess # nosec B404
59import sys
60import time
61from collections.abc import Callable
62from pathlib import Path
63from typing import Any
65sys.path.insert(0, str(Path(__file__).resolve().parent))
66from _rhiza_changelog import newest_changelog_version # noqa: E402
68EXIT_LANDED = 0
69EXIT_GIT_FAILED = 1
70EXIT_USAGE = 2
71EXIT_TIMEOUT = 3
73# Nine minutes, because one call has to fit inside one tool call: an agent's Bash
74# timeout caps out at ten. A caller who wants longer than this runs it again rather than
75# raising it — see `/rhiza:release` step 11, which decides between the two by asking the
76# forge whether the request's checks are still running.
77DEFAULT_TIMEOUT = 540.0
78DEFAULT_INTERVAL = 20.0
79DEFAULT_CHANGELOG = "CHANGELOG.md"
81# `origin` is not configurable here for the same reason it is not in `_rhiza_forge`:
82# every command in this plugin pushes to, and reads from, that one remote.
83REMOTE = "origin"
85_SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$")
88def _git(target_dir: Path, args: list[str]) -> tuple[int, str, str]:
89 """Run git in *target_dir*, returning ``(returncode, stdout, stderr)``.
91 ``GIT_TERMINAL_PROMPT=0`` so a remote that wants credentials fails fast instead of
92 blocking the poll on a password prompt nobody is there to answer.
93 """
94 env = os.environ.copy()
95 env["GIT_TERMINAL_PROMPT"] = "0"
96 result = subprocess.run( # nosec B603
97 [shutil.which("git") or "git", *args],
98 cwd=str(target_dir),
99 capture_output=True,
100 text=True,
101 env=env,
102 check=False,
103 )
104 return result.returncode, result.stdout, result.stderr
107def fetch_branch(target_dir: Path, branch: str) -> str:
108 """Update ``refs/remotes/origin/<branch>``; return '' on success, else the error.
110 The refspec is spelled out because a bare `git fetch origin <branch>` updates the
111 remote-tracking ref only *opportunistically*, and everything below reads that ref by
112 name.
113 """
114 code, _, err = _git(
115 target_dir,
116 ["fetch", "--quiet", REMOTE, f"+refs/heads/{branch}:refs/remotes/{REMOTE}/{branch}"],
117 )
118 if code == 0:
119 return ""
120 # git prints the useful line first and then two lines of general advice, so the last
121 # line of a failed fetch is "and the repository exists." — true of every failure and
122 # a diagnosis of none.
123 lines = [line.strip() for line in err.splitlines() if line.strip()]
124 fatal = next((line for line in lines if line.startswith(("fatal:", "error:"))), None)
125 return (fatal or (lines[0] if lines else f"git fetch exited {code}"))[:300]
128def landed_version(target_dir: Path, branch: str, changelog: str) -> str | None:
129 """Return the newest changelog version on ``origin/<branch>``, or None.
131 None covers both "no such file on that branch" and "no release heading in it", which
132 the caller treats identically: neither is the version it is waiting for.
133 """
134 code, out, _ = _git(target_dir, ["show", f"refs/remotes/{REMOTE}/{branch}:{changelog}"])
135 return newest_changelog_version(out) if code == 0 else None
138def branch_sha(target_dir: Path, branch: str) -> str:
139 """Return the commit SHA ``origin/<branch>`` points at, or '' if it won't resolve."""
140 code, out, _ = _git(target_dir, ["rev-parse", f"refs/remotes/{REMOTE}/{branch}"])
141 return out.strip() if code == 0 else ""
144def _timeout_note(branch: str, expect: str, found: str | None, timeout: float) -> str:
145 """Phrase the timeout in terms of what the branch actually carries."""
146 carries = f"carries {found}" if found else "carries no release heading"
147 return (
148 f"{REMOTE}/{branch} still {carries} after {timeout:.0f}s, not {expect} — "
149 "the request has not landed yet"
150 )
153def wait_for_landing(
154 target_dir: Path,
155 *,
156 branch: str,
157 expect: str,
158 changelog: str = DEFAULT_CHANGELOG,
159 timeout: float = DEFAULT_TIMEOUT,
160 interval: float = DEFAULT_INTERVAL,
161 sleep: Callable[[float], None] = time.sleep,
162 clock: Callable[[], float] = time.monotonic,
163) -> dict[str, Any]:
164 """Poll ``origin/<branch>`` until its changelog names *expect*; summarise the wait.
166 *sleep* and *clock* are injected because the shape of the loop — that it polls again
167 after an interval, that it never sleeps past the deadline, that a git failure stops
168 it at once — is the part worth testing, and it is unobservable in real time.
169 """
170 started = clock()
171 summary: dict[str, Any] = {
172 "branch": branch,
173 "expect": expect,
174 "status": "timeout",
175 "found": None,
176 "sha": None,
177 "polls": 0,
178 "elapsed": 0.0,
179 "notes": [],
180 "exit_code": EXIT_TIMEOUT,
181 }
182 while True:
183 summary["polls"] += 1
184 error = fetch_branch(target_dir, branch)
185 if error:
186 summary.update(status="error", exit_code=EXIT_GIT_FAILED, notes=[error])
187 break
188 found = landed_version(target_dir, branch, changelog)
189 summary["found"] = found
190 if found == expect:
191 summary.update(
192 status="landed", exit_code=EXIT_LANDED, sha=branch_sha(target_dir, branch)
193 )
194 break
195 remaining = timeout - (clock() - started)
196 if remaining <= 0:
197 summary["notes"] = [_timeout_note(branch, expect, found, timeout)]
198 break
199 sleep(min(interval, remaining))
200 summary["elapsed"] = round(clock() - started, 1)
201 return summary
204def _print_summary(summary: dict[str, Any]) -> None:
205 """Render *summary* as the two or three lines a caller needs to read."""
206 status = summary["status"]
207 if status == "landed":
208 print(f"landed {REMOTE}/{summary['branch']} carries {summary['expect']}")
209 print(f"commit {summary['sha']}")
210 elif status == "error":
211 print(f"error {summary['notes'][0]}", file=sys.stderr)
212 else:
213 print(f"waiting {summary['notes'][0]}", file=sys.stderr)
214 print(f"polled {summary['polls']}x over {summary['elapsed']}s")
217def main(argv: list[str] | None = None) -> int:
218 """Entry point: wait for the bump to land and return an exit code."""
219 parser = argparse.ArgumentParser(
220 description="Wait for a release bump to land on the default branch."
221 )
222 parser.add_argument("--target-dir", default=".", help="Repository root (default: cwd).")
223 parser.add_argument("--branch", required=True, help="The branch the release merges into.")
224 parser.add_argument(
225 "--expect-version", required=True, help="The version the merged changelog must name."
226 )
227 parser.add_argument(
228 "--changelog", default=DEFAULT_CHANGELOG, help="Changelog path on that branch."
229 )
230 parser.add_argument(
231 "--timeout",
232 type=float,
233 default=DEFAULT_TIMEOUT,
234 help="Seconds to wait before handing back (0 polls once).",
235 )
236 parser.add_argument(
237 "--interval", type=float, default=DEFAULT_INTERVAL, help="Seconds between polls."
238 )
239 parser.add_argument(
240 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
241 )
242 args = parser.parse_args(argv)
244 expect = args.expect_version.strip().lstrip("v")
245 if not _SEMVER.match(expect):
246 print(f"error: {args.expect_version!r} is not a version (expected X.Y.Z)", file=sys.stderr)
247 return EXIT_USAGE
249 summary = wait_for_landing(
250 Path(args.target_dir).resolve(),
251 branch=args.branch,
252 expect=expect,
253 changelog=args.changelog,
254 timeout=max(args.timeout, 0.0),
255 interval=max(args.interval, 1.0),
256 )
257 if args.json_output:
258 print(json.dumps(summary, indent=2))
259 else:
260 _print_summary(summary)
261 exit_code: int = summary["exit_code"]
262 return exit_code
265if __name__ == "__main__":
266 sys.exit(main())