pr_land.py python
521 lines 16.4 KB
Raw
sha256:0e9549ec7b463911bc08b7d586dc320b1ac9b1f5c943ee7e3865dcc6cb0f6f83 chore(governance): sync handover+roadmap to 84db8c8 (drift:… Human 9 hours ago
1 """Authorized wait-for-green PR land (Tier-3 operator-delegated).
2
3 ``ok land-check`` only verifies paths vs main — it never merges.
4
5 ``ok pr-land`` is the complementary close-ritual command: after the operator
6 authorizes a specific land (``--authorized "<reason>"``), poll GitHub checks
7 until they settle, refuse merge on failure (exit 2 for babysit/fix), and
8 merge only when green.
9
10 Why not ``gh pr merge --auto`` alone?
11 Auto-merge waits only for **required** status checks. Repos without branch
12 protection merge immediately. This module always polls locally first.
13 """
14
15 from __future__ import annotations
16
17 import json
18 import subprocess
19 import time
20 from dataclasses import asdict, dataclass, field
21 from datetime import datetime, timezone
22 from pathlib import Path
23 from typing import TYPE_CHECKING, Any, Callable
24
25 from tools.close_ritual.post_land_sync import (
26 STATUS_FAILED,
27 GitRunner,
28 disabled_report,
29 not_applicable_report,
30 run_post_land_sync,
31 )
32
33 if TYPE_CHECKING:
34 from adapters.config import OverseerConfig
35
36 EXIT_OK = 0
37 EXIT_USAGE = 1
38 EXIT_CHECKS_FAILED = 2
39 EXIT_UNAUTHORIZED = 3
40 EXIT_TIMEOUT = 4
41 EXIT_GH_ERROR = 5
42 # §PLS.6.2 — merge succeeded but post-land sync hard-failed. Confined to
43 # ``ok pr-land``. Never reuse exit 6 (K4 INTEGRITY on status/sync).
44 EXIT_POST_LAND_SYNC = 36
45
46 _FAIL_STATES = frozenset(
47 {"fail", "failure", "cancelled", "timed_out", "action_required", "stale", "error"}
48 )
49 _PASS_STATES = frozenset({"pass", "success", "neutral", "skipped"})
50 _PENDING_STATES = frozenset(
51 {"pending", "queued", "in_progress", "waiting", "requested", "pending_deployment"}
52 )
53
54
55 def _iso_now() -> str:
56 return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
57
58
59 @dataclass
60 class CheckRow:
61 """One row from ``gh pr checks``."""
62
63 name: str
64 state: str
65 elapsed: str = ""
66 url: str = ""
67 description: str = ""
68
69 @property
70 def normalized_state(self) -> str:
71 return (self.state or "").strip().lower()
72
73 @property
74 def is_pending(self) -> bool:
75 return self.normalized_state in _PENDING_STATES or self.normalized_state == ""
76
77 @property
78 def is_fail(self) -> bool:
79 return self.normalized_state in _FAIL_STATES
80
81 @property
82 def is_pass(self) -> bool:
83 return self.normalized_state in _PASS_STATES
84
85
86 @dataclass
87 class PrLandResult:
88 """Outcome of ``ok pr-land``."""
89
90 exit_code: int
91 pr: str
92 authorized: bool
93 authorization: str
94 merged: bool
95 already_merged: bool
96 checks: list[dict[str, str]] = field(default_factory=list)
97 failing: list[str] = field(default_factory=list)
98 pending: list[str] = field(default_factory=list)
99 messages: list[str] = field(default_factory=list)
100 merge_method: str = "squash"
101 recorded_at: str = field(default_factory=_iso_now)
102 auto_merge: bool = False # always False — we poll then merge; never blind auto
103 # §PLS.6.3 — always present; never omit / never bare null. Without config
104 # (unit helpers) sync is treated as disabled.
105 post_land_sync: dict[str, Any] = field(default_factory=lambda: disabled_report().to_dict())
106
107 def to_dict(self) -> dict[str, Any]:
108 return asdict(self)
109
110
111 Runner = Callable[[list[str]], subprocess.CompletedProcess[str]]
112
113
114 def _default_runner(cmd: list[str]) -> subprocess.CompletedProcess[str]:
115 return subprocess.run(cmd, capture_output=True, text=True)
116
117
118 def parse_gh_checks_table(text: str) -> list[CheckRow]:
119 """Parse ``gh pr checks`` output into CheckRow list."""
120 rows: list[CheckRow] = []
121 for line in (text or "").splitlines():
122 if not line.strip():
123 continue
124 parts = line.split("\t") if "\t" in line else line.split()
125 if len(parts) < 2:
126 continue
127 rows.append(
128 CheckRow(
129 name=parts[0].strip(),
130 state=parts[1].strip(),
131 elapsed=parts[2].strip() if len(parts) > 2 else "",
132 url=parts[3].strip() if len(parts) > 3 else "",
133 description="\t".join(parts[4:]).strip() if len(parts) > 4 else "",
134 )
135 )
136 return rows
137
138
139 def classify_checks(rows: list[CheckRow]) -> tuple[list[CheckRow], list[CheckRow], list[CheckRow]]:
140 """Return (passing, failing, pending). Unknown states → pending (fail closed)."""
141 passing = [r for r in rows if r.is_pass]
142 failing = [r for r in rows if r.is_fail]
143 pending = [r for r in rows if not r.is_pass and not r.is_fail]
144 return passing, failing, pending
145
146
147 def fetch_pr_state(pr: str, *, runner: Runner = _default_runner) -> dict[str, Any]:
148 completed = runner(
149 [
150 "gh",
151 "pr",
152 "view",
153 str(pr),
154 "--json",
155 "number,state,url,mergeable,mergeStateStatus",
156 ]
157 )
158 if completed.returncode != 0:
159 raise RuntimeError((completed.stderr or completed.stdout or "gh pr view failed").strip())
160 return json.loads(completed.stdout)
161
162
163 def fetch_checks(pr: str, *, runner: Runner = _default_runner) -> list[CheckRow]:
164 completed = runner(["gh", "pr", "checks", str(pr)])
165 rows = parse_gh_checks_table(completed.stdout or "")
166 if completed.returncode != 0 and not rows and (completed.stderr or "").strip():
167 err = (completed.stderr or completed.stdout or "gh pr checks failed").strip()
168 if "no checks" not in err.lower():
169 raise RuntimeError(err)
170 return rows
171
172
173 def merge_pr(pr: str, *, method: str = "squash", runner: Runner = _default_runner) -> None:
174 flag = {"squash": "--squash", "merge": "--merge", "rebase": "--rebase"}.get(method)
175 if flag is None:
176 raise ValueError(f"unsupported merge method: {method}")
177 completed = runner(["gh", "pr", "merge", str(pr), flag])
178 if completed.returncode != 0:
179 raise RuntimeError((completed.stderr or completed.stdout or "gh pr merge failed").strip())
180
181
182 def wait_for_checks(
183 pr: str,
184 *,
185 poll_seconds: float = 20.0,
186 timeout_seconds: float = 1800.0,
187 allow_empty_checks: bool = False,
188 runner: Runner = _default_runner,
189 sleep_fn: Callable[[float], None] = time.sleep,
190 now_fn: Callable[[], float] = time.monotonic,
191 emit: Callable[[str], None] | None = None,
192 ) -> PrLandResult:
193 """Poll until checks settle. Does not merge."""
194 messages: list[str] = []
195
196 def _emit(line: str) -> None:
197 messages.append(line)
198 if emit:
199 emit(line)
200
201 deadline = now_fn() + timeout_seconds
202 last_rows: list[CheckRow] = []
203
204 while True:
205 try:
206 rows = fetch_checks(pr, runner=runner)
207 except RuntimeError as exc:
208 _emit(f"gh error: {exc}")
209 return PrLandResult(
210 exit_code=EXIT_GH_ERROR,
211 pr=str(pr),
212 authorized=True,
213 authorization="",
214 merged=False,
215 already_merged=False,
216 messages=messages,
217 )
218
219 last_rows = rows
220 passing, failing, pending = classify_checks(rows)
221
222 if not rows:
223 if allow_empty_checks:
224 _emit("no checks reported — allow_empty_checks=true; treating as pass")
225 return PrLandResult(
226 exit_code=EXIT_OK,
227 pr=str(pr),
228 authorized=True,
229 authorization="",
230 merged=False,
231 already_merged=False,
232 messages=messages,
233 )
234 _emit("no checks reported yet — waiting")
235 elif failing:
236 names = [r.name for r in failing]
237 _emit(f"checks FAILED: {', '.join(names)}")
238 return PrLandResult(
239 exit_code=EXIT_CHECKS_FAILED,
240 pr=str(pr),
241 authorized=True,
242 authorization="",
243 merged=False,
244 already_merged=False,
245 checks=[asdict(r) for r in rows],
246 failing=names,
247 pending=[r.name for r in pending],
248 messages=messages,
249 )
250 elif pending or not rows:
251 _emit(f"waiting: {len(passing)} pass, {len(pending) if rows else '?'} pending")
252 else:
253 _emit(f"all checks passed ({len(passing)})")
254 return PrLandResult(
255 exit_code=EXIT_OK,
256 pr=str(pr),
257 authorized=True,
258 authorization="",
259 merged=False,
260 already_merged=False,
261 checks=[asdict(r) for r in rows],
262 messages=messages,
263 )
264
265 if now_fn() >= deadline:
266 _emit(f"timeout after {timeout_seconds:.0f}s waiting for checks")
267 return PrLandResult(
268 exit_code=EXIT_TIMEOUT,
269 pr=str(pr),
270 authorized=True,
271 authorization="",
272 merged=False,
273 already_merged=False,
274 checks=[asdict(r) for r in last_rows],
275 pending=[r.name for r in classify_checks(last_rows)[2]],
276 messages=messages,
277 )
278 sleep_fn(poll_seconds)
279
280
281 def _run_pr_land_inner(
282 pr: str,
283 *,
284 authorization: str,
285 merge_method: str = "squash",
286 poll_seconds: float = 20.0,
287 timeout_seconds: float = 1800.0,
288 allow_empty_checks: bool = False,
289 dry_run: bool = False,
290 runner: Runner = _default_runner,
291 sleep_fn: Callable[[float], None] = time.sleep,
292 now_fn: Callable[[], float] = time.monotonic,
293 emit: Callable[[str], None] | None = None,
294 ) -> PrLandResult:
295 """Authorize → wait for green → merge. Fail closed without authorization."""
296 reason = (authorization or "").strip()
297 if not reason:
298 msg = (
299 "Tier 3: refused — pass --authorized \"<operator reason>\" "
300 "to land after green checks"
301 )
302 if emit:
303 emit(msg)
304 return PrLandResult(
305 exit_code=EXIT_UNAUTHORIZED,
306 pr=str(pr),
307 authorized=False,
308 authorization="",
309 merged=False,
310 already_merged=False,
311 messages=[msg],
312 )
313
314 messages: list[str] = [f"authorized: {reason}"]
315
316 def _emit(line: str) -> None:
317 messages.append(line)
318 if emit:
319 emit(line)
320
321 _emit(f"pr-land start pr={pr} method={merge_method} dry_run={dry_run}")
322
323 try:
324 state = fetch_pr_state(pr, runner=runner)
325 except RuntimeError as exc:
326 _emit(f"gh error: {exc}")
327 return PrLandResult(
328 exit_code=EXIT_GH_ERROR,
329 pr=str(pr),
330 authorized=True,
331 authorization=reason,
332 merged=False,
333 already_merged=False,
334 messages=messages,
335 merge_method=merge_method,
336 )
337
338 pr_state = str(state.get("state") or "").upper()
339 if pr_state == "MERGED":
340 wait = wait_for_checks(
341 pr,
342 poll_seconds=poll_seconds,
343 timeout_seconds=min(timeout_seconds, 120.0),
344 allow_empty_checks=True,
345 runner=runner,
346 sleep_fn=sleep_fn,
347 now_fn=now_fn,
348 emit=_emit,
349 )
350 if wait.exit_code == EXIT_CHECKS_FAILED:
351 _emit("PR already MERGED but checks report failure — investigate")
352 return PrLandResult(
353 exit_code=EXIT_CHECKS_FAILED,
354 pr=str(pr),
355 authorized=True,
356 authorization=reason,
357 merged=True,
358 already_merged=True,
359 checks=wait.checks,
360 failing=wait.failing,
361 messages=messages,
362 merge_method=merge_method,
363 )
364 _emit("PR already MERGED")
365 return PrLandResult(
366 exit_code=EXIT_OK,
367 pr=str(pr),
368 authorized=True,
369 authorization=reason,
370 merged=True,
371 already_merged=True,
372 checks=wait.checks,
373 messages=messages,
374 merge_method=merge_method,
375 )
376
377 if pr_state not in {"OPEN", ""}:
378 _emit(f"refused: PR state is {pr_state or 'unknown'}")
379 return PrLandResult(
380 exit_code=EXIT_USAGE,
381 pr=str(pr),
382 authorized=True,
383 authorization=reason,
384 merged=False,
385 already_merged=False,
386 messages=messages,
387 merge_method=merge_method,
388 )
389
390 wait = wait_for_checks(
391 pr,
392 poll_seconds=poll_seconds,
393 timeout_seconds=timeout_seconds,
394 allow_empty_checks=allow_empty_checks,
395 runner=runner,
396 sleep_fn=sleep_fn,
397 now_fn=now_fn,
398 emit=_emit,
399 )
400 if wait.exit_code != EXIT_OK:
401 return PrLandResult(
402 exit_code=wait.exit_code,
403 pr=str(pr),
404 authorized=True,
405 authorization=reason,
406 merged=False,
407 already_merged=False,
408 checks=wait.checks,
409 failing=wait.failing,
410 pending=wait.pending,
411 messages=messages,
412 merge_method=merge_method,
413 )
414
415 if dry_run:
416 _emit("dry_run: checks green — skip merge")
417 return PrLandResult(
418 exit_code=EXIT_OK,
419 pr=str(pr),
420 authorized=True,
421 authorization=reason,
422 merged=False,
423 already_merged=False,
424 checks=wait.checks,
425 messages=messages,
426 merge_method=merge_method,
427 )
428
429 try:
430 merge_pr(pr, method=merge_method, runner=runner)
431 except (RuntimeError, ValueError) as exc:
432 _emit(f"merge failed: {exc}")
433 return PrLandResult(
434 exit_code=EXIT_GH_ERROR,
435 pr=str(pr),
436 authorized=True,
437 authorization=reason,
438 merged=False,
439 already_merged=False,
440 checks=wait.checks,
441 messages=messages,
442 merge_method=merge_method,
443 )
444
445 _emit(f"merged PR {pr} via {merge_method}")
446 return PrLandResult(
447 exit_code=EXIT_OK,
448 pr=str(pr),
449 authorized=True,
450 authorization=reason,
451 merged=True,
452 already_merged=False,
453 checks=wait.checks,
454 messages=messages,
455 merge_method=merge_method,
456 )
457
458
459 def run_pr_land(
460 pr: str,
461 *,
462 authorization: str,
463 merge_method: str = "squash",
464 poll_seconds: float = 20.0,
465 timeout_seconds: float = 1800.0,
466 allow_empty_checks: bool = False,
467 dry_run: bool = False,
468 runner: Runner = _default_runner,
469 sleep_fn: Callable[[float], None] = time.sleep,
470 now_fn: Callable[[], float] = time.monotonic,
471 emit: Callable[[str], None] | None = None,
472 repo_root: Path | None = None, # required for the sync path when enabled (§PLS.4.3)
473 config: "OverseerConfig | None" = None,
474 git_runner: GitRunner | None = None,
475 ) -> PrLandResult:
476 """Authorized wait-for-green land plus optional §PLS post-land sync.
477
478 The additive sync post-step (§PLS.4.1) runs if and only if
479 ``close_ritual.post_land_sync.enabled`` is true, the merge outcome is
480 successful (``merged: true`` with pre-sync exit ``0``), and ``dry_run`` is
481 false. Every result carries an always-present ``post_land_sync`` object;
482 hard sync failure after a real merge maps to exit ``36`` (never ``6``).
483 """
484 result = _run_pr_land_inner(
485 pr,
486 authorization=authorization,
487 merge_method=merge_method,
488 poll_seconds=poll_seconds,
489 timeout_seconds=timeout_seconds,
490 allow_empty_checks=allow_empty_checks,
491 dry_run=dry_run,
492 runner=runner,
493 sleep_fn=sleep_fn,
494 now_fn=now_fn,
495 emit=emit,
496 )
497
498 sync_cfg = config.close_ritual.post_land_sync if config is not None else None
499 if sync_cfg is None or not sync_cfg.enabled:
500 result.post_land_sync = disabled_report().to_dict()
501 return result
502
503 triggered = result.merged and result.exit_code == EXIT_OK and not dry_run
504 if not triggered:
505 result.post_land_sync = not_applicable_report().to_dict()
506 return result
507
508 report = run_post_land_sync(
509 repo_root=repo_root,
510 regime=config.vcs.regime,
511 remote=config.vcs.git.remote,
512 main_branch=config.vcs.git.main_branch,
513 require_clean_worktree=sync_cfg.require_clean_worktree,
514 git_runner=git_runner,
515 emit=emit,
516 )
517 result.post_land_sync = report.to_dict()
518 result.messages.extend(report.messages)
519 if report.status == STATUS_FAILED:
520 result.exit_code = EXIT_POST_LAND_SYNC
521 return result
File History 1 commit
sha256:6abcf1fa82a7a621ccbc945f19acdba5bc0db54569599404a1452fb4a096a199 fix(ISR): default require_independent_second_reviewer to require Human minor 8 hours ago