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

1#!/usr/bin/env python3 

2"""Block until the release bump is on the default branch, so one run can tag it. 

3 

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. 

9 

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: 

15 

16 git show refs/remotes/origin/<branch>:CHANGELOG.md -> newest `## [X.Y.Z]` heading 

17 

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. 

24 

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. 

27 

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. 

33 

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] 

39 

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. 

43 

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""" 

50 

51from __future__ import annotations 

52 

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 

64 

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

66from _rhiza_changelog import newest_changelog_version # noqa: E402 

67 

68EXIT_LANDED = 0 

69EXIT_GIT_FAILED = 1 

70EXIT_USAGE = 2 

71EXIT_TIMEOUT = 3 

72 

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" 

80 

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" 

84 

85_SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") 

86 

87 

88def _git(target_dir: Path, args: list[str]) -> tuple[int, str, str]: 

89 """Run git in *target_dir*, returning ``(returncode, stdout, stderr)``. 

90 

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 

105 

106 

107def fetch_branch(target_dir: Path, branch: str) -> str: 

108 """Update ``refs/remotes/origin/<branch>``; return '' on success, else the error. 

109 

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] 

126 

127 

128def landed_version(target_dir: Path, branch: str, changelog: str) -> str | None: 

129 """Return the newest changelog version on ``origin/<branch>``, or None. 

130 

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 

136 

137 

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 "" 

142 

143 

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 ) 

151 

152 

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. 

165 

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 

202 

203 

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") 

215 

216 

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) 

243 

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 

248 

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 

263 

264 

265if __name__ == "__main__": 

266 sys.exit(main())