257 lines
12 KiB
Python
257 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""merge_robot.py — deterministic merge-on-green for this repo (#203).
|
|
|
|
Extracted from the inline workflow script so its logic is TESTABLE
|
|
(bug-fix contract, docs/bugfix-contract.md: the silent no-op defect).
|
|
|
|
Behavior:
|
|
- GET /user first: verify the token's identity and log it. Scoped-token
|
|
failures are diagnosable from the job log instead of mysterious.
|
|
- Scan open PRs; for each: combined status must be success AND mergeable.
|
|
- Merge refusals (403/405/anything non-(200,405-already-merged)) FAIL THE JOB
|
|
with the full error body — never print-and-continue. A robot that cannot
|
|
merge must be visibly broken, not silently idle.
|
|
- 405 with "behind the base branch" is retried once after a self-update of
|
|
the head (informational), then fails loudly if still refused.
|
|
- Every scanned PR produces a decision line: merged / skipped(reason).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any
|
|
|
|
|
|
class MergeRefused(Exception):
|
|
"""A merge the robot expected to perform was refused by the forge."""
|
|
|
|
|
|
def _api(
|
|
base: str, token: str, path: str, method: str = "GET", data: Any = None, ok: tuple = (200,)
|
|
) -> tuple[int, Any]:
|
|
req = urllib.request.Request(
|
|
base + path,
|
|
data=json.dumps(data).encode() if data is not None else None,
|
|
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
|
|
method=method,
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
body = r.read()
|
|
status = r.status
|
|
except urllib.error.HTTPError as e:
|
|
status = e.code
|
|
body = e.read()
|
|
except urllib.error.URLError as e:
|
|
raise MergeRefused(f"forge unreachable at {path}: {e.reason}") from e
|
|
try:
|
|
parsed: Any = json.loads(body) if body else {}
|
|
except json.JSONDecodeError:
|
|
if status not in ok:
|
|
raise MergeRefused(f"{method} {path} -> {status} (non-JSON body): {body[:200]}") from None
|
|
parsed = body # 2xx but not JSON — pass through raw
|
|
if method == "GET" and status not in ok:
|
|
raise MergeRefused(f"GET {path} -> {status}: {str(parsed)[:300]}")
|
|
return status, parsed
|
|
|
|
|
|
def verify_identity(base: str, token: str) -> str:
|
|
# Defense in depth: whatever scope the caller hands us, identity is an
|
|
# INSTANCE-scoped endpoint (#214 follow-up: repo-scoped base produced
|
|
# .../repos/o/r/user -> 404 -> unhandled JSONDecodeError).
|
|
stripped = base.rstrip("/")
|
|
instance = stripped.split("/repos/")[0] if "/repos/" in stripped else base
|
|
status, user = _api(instance, token, "/user")
|
|
login = user.get("login", "?")
|
|
print(f"merge-robot: authenticated as {login} (status {status})")
|
|
return login
|
|
|
|
|
|
def scan(base: str, token: str) -> list[dict]:
|
|
status, prs = _api(base, token, "/pulls?state=open&limit=50")
|
|
print(f"merge-robot: {len(prs)} open PR(s)")
|
|
return prs
|
|
|
|
|
|
def decide(base: str, token: str, pr: dict) -> str:
|
|
"""Return a decision line for one PR; raises MergeRefused on unexpected refusals."""
|
|
n = pr["number"]
|
|
sha = pr["head"]["sha"]
|
|
status, cs = _api(base, token, f"/commits/{sha}/status")
|
|
state = cs.get("state")
|
|
if state != "success":
|
|
return f"#{n}: skipped (status={state})"
|
|
# SPEC 0.4 (auto-merge-zero-approvals defect): repository protection
|
|
# requires review for protected classes. A green status alone never
|
|
# justified merging code-path changes — zero approvals on a protected
|
|
# PR is refused BEFORE any self-heal or merge POST.
|
|
if touches_code_path(pr_changed_files(base, token, n)):
|
|
approvals = count_approvals(base, token, n)
|
|
if approvals < 1:
|
|
return f"#{n}: skipped (awaiting review — {approvals} approvals; protected class requires review)"
|
|
status, detail = _api(base, token, f"/pulls/{n}")
|
|
if not detail.get("mergeable", False):
|
|
# CONFLICT-FIRST (Nick policy): resolve staleness immediately —
|
|
# attempt the API update NOW rather than waiting a sleep cycle.
|
|
# 409 (genuine conflict) still skips with the reason named.
|
|
ustatus, uresp = _api(base, token, f"/pulls/{n}/update", "POST", {"style": "merge"}, ok=(200, 409))
|
|
if ustatus == 200:
|
|
print(f"UPDATED {n} (conflict-first: mergeable=False resolved pre-merge)", flush=True)
|
|
# classify the update: docs-only → carried green stands; code-path
|
|
# touch → fresh CI required before merge (#208 slip prevention)
|
|
try:
|
|
_, fresh = _api(base, token, f"/pulls/{n}")
|
|
new_sha = fresh.get("head", {}).get("sha", sha)
|
|
if classify_update(base, token, pr, new_sha) == "RERUN_REQUIRED":
|
|
return f"#{n}: skipped (updated; CI re-running on new head)"
|
|
except MergeRefused:
|
|
return f"#{n}: skipped (updated; CI re-running on new head)"
|
|
status, detail = _api(base, token, f"/pulls/{n}")
|
|
if not detail.get("mergeable", False):
|
|
return f"#{n}: skipped (updated; CI re-running on new head)"
|
|
elif ustatus == 409:
|
|
cmsg = str(uresp.get("message", "")) if isinstance(uresp, dict) else str(uresp)
|
|
return f"#{n}: skipped (genuine conflict — update 409: {cmsg[:120]})"
|
|
else:
|
|
return f"#{n}: skipped (not mergeable; update returned {ustatus})"
|
|
mstatus, mresp = _api(base, token, f"/pulls/{n}/merge", "POST", {"Do": "merge"})
|
|
if mstatus == 200:
|
|
return f"#{n}: MERGED"
|
|
if mstatus == 405 and "try again later" in str(mresp.get("message", "")).lower():
|
|
# TRANSIENT: the forge is recomputing mergeability — back off and retry.
|
|
# (Fail-loud doctrine applies to refusals; this is "not ready".)
|
|
for attempt in range(3):
|
|
time.sleep(5 * (attempt + 1))
|
|
mstatus, mresp = _api(base, token, f"/pulls/{n}/merge", "POST", {"Do": "merge"})
|
|
if mstatus == 200:
|
|
return f"#{n}: MERGED (after transient retry)"
|
|
if mstatus != 405 or "try again later" not in str(mresp.get("message", "")).lower():
|
|
break
|
|
raise MergeRefused(f"#{n} merge still transient after 3 retries: {str(mresp)[:200]}")
|
|
if mstatus == 405:
|
|
msg = str(mresp.get("message", ""))
|
|
if "behind" in msg.lower():
|
|
# SELF-HEAL (inefficiency fix): the forge exposes a merge-style
|
|
# update; perform it and retry the merge once. Skipping here is
|
|
# what caused the nightly behind-base worktree dance.
|
|
ustatus, uresp = _api(base, token, f"/pulls/{n}/update", "POST", {"style": "merge"}, ok=(200, 409))
|
|
if ustatus == 409:
|
|
cmsg = str(uresp.get("message", "")) if isinstance(uresp, dict) else str(uresp)
|
|
return f"#{n}: skipped (genuine conflict — update 409: {cmsg[:120]})"
|
|
mstatus, mresp = _api(base, token, f"/pulls/{n}/merge", "POST", {"Do": "merge"})
|
|
if mstatus == 200:
|
|
return f"#{n}: MERGED (after behind-base self-update)"
|
|
if mstatus == 405 and "behind" in str(mresp.get("message", "")).lower():
|
|
# CARRY-FORWARD PROTOCOL: classify the tree diff between the
|
|
# tested head and the new head. Docs-only movement carries the
|
|
# prior green (no redundant re-run — Nick's policy); any
|
|
# code-path touch requires fresh CI before merge (#208 slip:
|
|
# required-checks satisfied by a stale green).
|
|
try:
|
|
_, fresh = _api(base, token, f"/pulls/{n}")
|
|
new_sha = fresh.get("head", {}).get("sha", sha)
|
|
verdict = classify_update(base, token, pr, new_sha)
|
|
except MergeRefused:
|
|
verdict = "RERUN_REQUIRED" # can't verify → safest default
|
|
if verdict == "RERUN_REQUIRED":
|
|
print(f"UPDATED {n} awaiting-ci (code-path touch)", flush=True)
|
|
return f"#{n}: skipped (updated; CI re-running on new head)"
|
|
print(f"UPDATED {n} carry-forward (docs-only diff)", flush=True)
|
|
mstatus, mresp = _api(base, token, f"/pulls/{n}/merge", "POST", {"Do": "merge"})
|
|
if mstatus == 200:
|
|
return f"#{n}: MERGED (carry-forward: docs-only update)"
|
|
return f"#{n}: skipped (carry-forward merge returned {mstatus})"
|
|
raise MergeRefused(f"#{n} merge refused {mstatus} after update: {str(mresp)[:200]}")
|
|
raise MergeRefused(f"#{n} merge refused 405: {msg}")
|
|
if mstatus == 403:
|
|
raise MergeRefused(f"#{n} merge FORBIDDEN 403 — token lacks pull-request write scope: {str(mresp)[:300]}")
|
|
raise MergeRefused(f"#{n} merge failed {mstatus}: {str(mresp)[:300]}")
|
|
|
|
|
|
CODE_PATHS = (
|
|
"src/",
|
|
"tests/",
|
|
"scripts/",
|
|
"shared/",
|
|
"config/",
|
|
".gitea/",
|
|
"henxels.yaml",
|
|
"henxels_checks.py",
|
|
"pyproject.toml",
|
|
"requirements-ci.txt",
|
|
"lint-baseline.json",
|
|
)
|
|
|
|
|
|
def classify_update(base: str, token: str, pr: dict, new_sha: str) -> str:
|
|
"""Classify a self-heal update: CARRY_FORWARD (docs-only tree diff — the
|
|
tested code is byte-identical, prior green stands) or RERUN_REQUIRED
|
|
(any code-path touch — semantic interactions are real, #208 slip)."""
|
|
old_sha = pr["head"]["sha"]
|
|
status, cmp_data = _api(base, token, f"/compare/{old_sha}...{new_sha}")
|
|
files = [f.get("filename", "") for f in cmp_data.get("files", []) if isinstance(f, dict)]
|
|
return "CARRY_FORWARD" if not touches_code_path(files) else "RERUN_REQUIRED"
|
|
|
|
|
|
def touches_code_path(changed_files: list[str], code_paths=CODE_PATHS) -> bool:
|
|
"""Carry-forward policy: True iff any changed file is a code path.
|
|
Docs-only base movement must NOT trigger a redundant full re-run."""
|
|
for f in changed_files:
|
|
for cp in code_paths:
|
|
if f == cp or f.startswith(cp):
|
|
return True
|
|
return False
|
|
|
|
|
|
def pr_changed_files(base: str, token: str, n: int) -> list[str]:
|
|
"""The PR's changed files (spec 0.4 review gate: protected classes are
|
|
the code paths, mirroring the carry-forward classification)."""
|
|
_, files = _api(base, token, f"/pulls/{n}/files?limit=300")
|
|
if not isinstance(files, list):
|
|
raise MergeRefused(f"#{n} files response malformed — cannot classify change class: {str(files)[:200]}")
|
|
return [f.get("filename", "") for f in files if isinstance(f, dict)]
|
|
|
|
|
|
def count_approvals(base: str, token: str, n: int) -> int:
|
|
"""Official APPROVED reviews on the PR. Malformed review data counts as
|
|
zero — the safe direction for a gate (blocks the merge, never waves it)."""
|
|
_, reviews = _api(base, token, f"/pulls/{n}/reviews")
|
|
if not isinstance(reviews, list):
|
|
return 0
|
|
return sum(1 for r in reviews if isinstance(r, dict) and r.get("state") == "APPROVED")
|
|
|
|
|
|
def main_with(argv: list[str]) -> int:
|
|
token = argv[1] if len(argv) > 1 else ""
|
|
base = argv[2] if len(argv) > 2 else ""
|
|
if not token or not base:
|
|
print("usage: merge_robot.py <token> <api-base>")
|
|
return 2
|
|
try:
|
|
verify_identity(base, token)
|
|
prs = scan(base, token)
|
|
merged = 0
|
|
for pr in prs:
|
|
line = decide(base, token, pr)
|
|
print("merge-robot:", line)
|
|
if "MERGED" in line:
|
|
merged += 1
|
|
time.sleep(1)
|
|
print(f"merge-robot: done — {merged} merged of {len(prs)} scanned")
|
|
return 0
|
|
except MergeRefused as exc:
|
|
print(f"merge-robot: FAILING JOB — {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
def main() -> int:
|
|
return main_with(sys.argv)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|