Files
rune/tests/test_merge_robot.py
T
Nick ab2c567ea4
CI Pipeline / canary (push) Successful in 30s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / unit-tests (push) Successful in 54s
CI Pipeline / lint (push) Successful in 30s
CI Pipeline / type-check (push) Successful in 24s
CI Pipeline / required-checks (push) Successful in 1s
Auto-label / priority-gate (pull_request) Skipped
CI Pipeline / best-practices (pull_request) Successful in 3s
Auto-label / auto-label (pull_request) Successful in 3s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / canary (pull_request) Successful in 26s
CI Pipeline / lint (pull_request) Successful in 30s
CI Pipeline / type-check (pull_request) Successful in 26s
Spec gate (headless) / spec-gate (pull_request) Successful in 6s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / unit-tests (pull_request) Successful in 47s
CI Pipeline / required-checks (pull_request) Successful in 1s
Auto-version and Release / release (pull_request) Failing after 10s
fix(robot): classify gate on BOTH update paths (conflict-first + behind-base) — code-path touch always waits for fresh CI; docs-only carries. Closes the #208 slip (merge on stale required-checks)
2026-09-08 05:54:37 +01:00

434 lines
18 KiB
Python

"""Unit tests for scripts/merge_robot.py — the fail-loud merge-on-green logic.
Bug-fix contract red-first: the original inline robot swallowed merge refusals
(print-and-continue, exit 0) — the silent no-op defect. These tests freeze the
fail-loud behavior: refusals fail the job, identity is verified first, every
scan produces a decision line.
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SPEC = importlib.util.spec_from_file_location("merge_robot", ROOT / "scripts" / "merge_robot.py")
mr = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(mr)
class FakeForge:
"""Simulates the SERVER contract (not the client's logic — no circularity)."""
def __init__(self, prs, statuses, merge_results):
self.prs = prs
self.statuses = statuses # sha -> state
self.merge_results = merge_results # pr_number -> (status, body)
self.mergeable: dict[int, bool] = {} # pr_number -> server-reported mergeable
self.calls: list[tuple[str, str]] = []
def get(self, path):
self.calls.append(("GET", path))
if path == "/user":
return 200, {"login": "gitea-actions-robot"}
if path.startswith("/pulls?state=open"):
return 200, self.prs
if path.startswith("/commits/"):
sha = path.split("/")[2]
return 200, {"state": self.statuses.get(sha, "pending")}
if path.startswith("/pulls/") and not path.endswith("/merge"):
n = int(path.split("/")[2])
for pr in self.prs:
if pr["number"] == n:
# server truth: GET /pulls/{n} includes mergeable (default True)
return 200, {**pr, "mergeable": self.mergeable.get(n, True)}
return 404, {}
raise AssertionError(f"unexpected GET {path}")
def post(self, path, data):
self.calls.append(("POST", path))
if path.endswith("/merge"):
n = int(path.split("/")[2])
return self.merge_results.get(n, (200, {}))
if path.endswith("/update"):
return 409, {"message": "There are conflicting files"} # fixture default: genuine conflict
raise AssertionError(f"unexpected POST {path}")
@pytest.fixture
def forge(monkeypatch):
f = FakeForge([], {}, {})
def fake_api(base, token, path, method="GET", data=None, ok=(200,)):
f.calls.append((method, path))
if method == "GET":
status, body = f.get(path)
else:
status, body = f.post(path, data)
return status, body
monkeypatch.setattr(mr, "_api", fake_api)
return f
def _pr(n, sha, state="success", mergeable=True):
return {"number": n, "head": {"sha": sha}}, {sha: state}, mergeable
def test_identity_verified_before_any_merge(forge, capsys):
forge.prs = [{"number": 1, "head": {"sha": "a"}}]
forge.statuses = {"a": "pending"}
mr.main_with(["merge_robot.py", "tok", "http://x/api/v1/repos/o/r"])
assert ("GET", "/user") == forge.calls[0], "identity check must be the first call"
def test_merge_refusal_403_fails_job(forge, capsys):
forge.prs = [{"number": 7, "head": {"sha": "s7"}}]
forge.statuses = {"s7": "success"}
forge.merge_results = {7: (403, {"message": "token does not have required scope"})}
with pytest.raises(mr.MergeRefused, match="403"):
mr.decide("base", "tok", forge.prs[0])
def test_merge_refusal_405_unexpected_fails_job(forge):
forge.prs = [{"number": 8, "head": {"sha": "s8"}}]
forge.statuses = {"s8": "success"}
forge.merge_results = {8: (405, {"message": "protected branch rejects"})}
with pytest.raises(mr.MergeRefused, match="405"):
mr.decide("base", "tok", forge.prs[0])
def test_merge_success_returns_merged(forge):
forge.prs = [{"number": 9, "head": {"sha": "s9"}}]
forge.statuses = {"s9": "success"}
forge.merge_results = {9: (200, {})}
assert "MERGED" in mr.decide("base", "tok", forge.prs[0])
def test_pending_pr_skipped_not_merged(forge):
forge.prs = [{"number": 10, "head": {"sha": "s10"}}]
forge.statuses = {"s10": "pending"}
assert "skipped" in mr.decide("base", "tok", forge.prs[0])
assert not any(m == "POST" for m, _ in forge.calls), "must not attempt merge on pending"
def test_conflict_pr_skipped(forge):
forge.prs = [{"number": 11, "head": {"sha": "s11"}}]
forge.statuses = {"s11": "success"}
forge.mergeable = {11: False} # server reports conflict
# conflict-first: the robot attempts the API update; FakeForge 409s it
# (genuine conflict) -> skip with reason
result = mr.decide("base", "tok", forge.prs[0])
assert "skipped" in result and "conflict" in result.lower()
assert not any(m == "POST" and p.endswith("/merge") for m, p in forge.calls), (
"must never attempt a merge on a conflicted PR"
)
def test_behind_base_405_self_heals_then_merges(forge, monkeypatch):
"""405 'behind the base branch' = robot updates the branch and retries
(inefficiency fix). The old skip behavior forced a human worktree dance
every time any merge staled the other PRs."""
forge.prs = [{"number": 12, "head": {"sha": "s12"}}]
def scripted_api(base, token, path, method="GET", data=None, ok=(200,)):
if method == "GET" and path.startswith("/commits/"):
return 200, {"state": "success"}
if method == "GET" and path == "/pulls/12":
return 200, {**forge.prs[0], "mergeable": True}
if method == "GET" and path.startswith("/compare/"):
return 200, {"files": [{"filename": "docs/notes.md"}]} # docs-only → carry
if method == "POST" and path.endswith("/merge"):
return 405, {"message": "The head branch is behind the base branch"}
if method == "POST" and path.endswith("/update"):
return 200, {}
raise AssertionError(f"unexpected {method} {path}")
monkeypatch.setattr(mr, "_api", scripted_api)
line = mr.decide("base", "tok", forge.prs[0])
assert "MERGED" in line or "skipped" in line, "self-heal must resolve the 405, not crash"
def test_main_exits_1_on_refusal(forge, monkeypatch):
forge.prs = [{"number": 13, "head": {"sha": "s13"}}]
forge.statuses = {"s13": "success"}
forge.merge_results = {13: (403, {"message": "no scope"})}
def fake_scan(base, token):
return forge.prs
monkeypatch.setattr(mr, "scan", fake_scan)
rc = mr.main_with(["merge_robot.py", "tok", "base"])
assert rc == 1, "refusal must fail the job (exit 1) — the silent no-op defect"
def test_main_exits_0_all_merged(forge, monkeypatch):
forge.prs = [{"number": 14, "head": {"sha": "s14"}}]
forge.statuses = {"s14": "success"}
forge.merge_results = {14: (200, {})}
def fake_scan(base, token):
return forge.prs
monkeypatch.setattr(mr, "scan", fake_scan)
assert mr.main_with(["merge_robot.py", "tok", "base"]) == 0
def test_verify_identity_uses_instance_base_not_repo_scoped(forge, monkeypatch):
"""RED (#214 follow-up): verify_identity received the REPO-scoped base and
built .../repos/o/r/user — a 404 with a plain-text body that crashed
json.loads before MergeRefused could fire. Identity must be verified at the
INSTANCE root: .../api/v1/user."""
recorded = []
def spy_api(base, token, path, method="GET", data=None, ok=(200,)):
recorded.append((base, path))
return 200, {"login": "conductor"}
monkeypatch.setattr(mr, "_api", spy_api)
mr.verify_identity("http://x/api/v1/repos/o/r", "tok")
assert recorded == [("http://x/api/v1", "/user")], (
"identity check must hit the instance root /user, not the repo-scoped path"
)
def test_api_handles_non_json_error_body(monkeypatch):
# NOTE: no forge fixture — it replaces _api entirely, which would make the
# urlopen patch unreachable. This test exercises the REAL _api.
"""A 404 plain-text body ('404 page not found') must raise MergeRefused,
not an unhandled JSONDecodeError."""
import urllib.error
class FakeHTTPError(urllib.error.HTTPError):
def __init__(self):
super().__init__("url", 404, "not found", hdrs=None, fp=None)
def real_url(req, timeout):
raise FakeHTTPError()
monkeypatch.setattr(mr.urllib.request, "urlopen", real_url)
with pytest.raises(mr.MergeRefused):
mr._api("http://x/api/v1", "tok", "/user")
def test_behind_base_pr_is_updated_then_rechecked(forge, monkeypatch):
"""THE INEFFICIENCY FIX: a behind-base PR must be UPDATED via the forge
API (POST /pulls/{n}/update) and re-checked — the robot heals staleness
itself instead of skipping and forcing a human worktree dance. Skips only
on genuine conflicts (409)."""
forge.prs = [{"number": 20, "head": {"sha": "s20"}}]
forge.statuses = {"s20": "success"}
forge.mergeable = {20: True}
forge.merge_results = {20: (405, {"message": "The head branch is behind the base branch"})}
updates = []
def fake_api(base, token, path, method="GET", data=None, ok=(200,)):
if method == "POST" and path.endswith("/update"):
updates.append(int(path.split("/")[2]))
return 200, {}
return None # fall through to forge fixture's own patching
# Simpler: patch decide's internals via _api directly
calls = {"merge": 0}
real_decide_source = mr.decide
def scripted_api(base, token, path, method="GET", data=None, ok=(200,)):
if method == "GET" and path.startswith("/commits/"):
return 200, {"state": "success"}
if method == "GET" and path == "/pulls/20":
return 200, {**forge.prs[0], "mergeable": True}
if method == "POST" and path.endswith("/merge"):
calls["merge"] += 1
if calls["merge"] == 1:
return 405, {"message": "The head branch is behind the base branch"}
return 200, {}
if method == "POST" and path.endswith("/update"):
updates.append(20)
return 200, {}
raise AssertionError(f"unexpected {method} {path}")
monkeypatch.setattr(mr, "_api", scripted_api)
line = mr.decide("base", "tok", forge.prs[0])
assert updates == [20], "behind-base must trigger an API update"
assert "MERGED" in line, "after update, merge must succeed"
def test_update_conflict_409_skips_not_fails(forge, monkeypatch):
"""409 from update = genuine content conflict -> skip with reason,
never fail the job (conflicts need judgment, not automation)."""
forge.prs = [{"number": 21, "head": {"sha": "s21"}}]
def scripted_api(base, token, path, method="GET", data=None, ok=(200,)):
if method == "GET" and path.startswith("/commits/"):
return 200, {"state": "success"}
if method == "GET" and path == "/pulls/21":
return 200, {**forge.prs[0], "mergeable": True}
if method == "POST" and path.endswith("/merge"):
return 405, {"message": "The head branch is behind the base branch"}
if method == "POST" and path.endswith("/update"):
return 409, {"message": "There are conflicting files, please resolve manually"}
raise AssertionError(f"unexpected {method} {path}")
monkeypatch.setattr(mr, "_api", scripted_api)
line = mr.decide("base", "tok", forge.prs[0])
assert "skipped" in line and "conflict" in line.lower()
def test_carry_forward_policy_code_path_classification():
"""Nick's policy (2026-09-08): checks re-run only when contributed code
changes. A behind-base update whose tree-diff touches NO code path
carries the prior green; any code-path touch demands a full re-run."""
CODE_PATHS = (
"src/",
"tests/",
"scripts/",
"shared/",
"config/",
".gitea/",
"henxels.yaml",
"henxels_checks.py",
"pyproject.toml",
"requirements-ci.txt",
"lint-baseline.json",
)
docs_only = ["docs/pipeline-registry.md", "README.md"]
assert mr.touches_code_path(docs_only, CODE_PATHS) is False
assert mr.touches_code_path(["src/rune/foo.py"], CODE_PATHS) is True
assert mr.touches_code_path([], CODE_PATHS) is False
assert mr.touches_code_path(["henxels.yaml"], CODE_PATHS) is True
def test_workflow_carry_forward_script_classifies_docs_only_update():
"""The companion classifier script (used by the workflow) must treat a
docs-only tree diff as carry-forward-eligible and any code-path touch
as re-run-required."""
import importlib.util
script = ROOT / "scripts" / "carry_forward_check.py"
assert script.exists(), "carry_forward_check.py must exist"
spec2 = importlib.util.spec_from_file_location("cfc", script)
cfc = importlib.util.module_from_spec(spec2)
spec2.loader.exec_module(cfc)
assert cfc.classify(["docs/x.md"], tested_head="a", new_head="b") == "CARRY_FORWARD"
assert cfc.classify(["src/rune/x.py"], tested_head="a", new_head="b") == "RERUN_REQUIRED"
assert cfc.classify([], tested_head="a", new_head="b") == "CARRY_FORWARD"
def test_transient_try_again_later_is_retried_then_merged(forge, monkeypatch):
"""Gitea returns 405 'Please try again later' while recomputing
mergeability — TRANSIENT, not a refusal. The robot must back off and
retry (3 attempts), then merge. Failing the job on this was the
merge-blocking false alarm."""
forge.prs = [{"number": 30, "head": {"sha": "s30"}}]
calls = {"merge": 0}
def scripted_api(base, token, path, method="GET", data=None, ok=(200,)):
if method == "GET" and path.startswith("/commits/"):
return 200, {"state": "success"}
if method == "GET" and path == "/pulls/30":
return 200, {**forge.prs[0], "mergeable": True}
if method == "POST" and path.endswith("/merge"):
calls["merge"] += 1
if calls["merge"] < 3: # transient twice, then recovers
return 405, {"message": "Please try again later"}
return 200, {}
raise AssertionError(f"unexpected {method} {path}")
monkeypatch.setattr(mr, "_api", scripted_api)
monkeypatch.setattr(mr.time, "sleep", lambda s: None) # no real backoff in tests
line = mr.decide("base", "tok", forge.prs[0])
assert "MERGED" in line and "transient retry" in line
assert calls["merge"] == 3, "must retry twice before succeeding"
def test_transient_persisting_raises_loud(forge, monkeypatch):
"""Continuously transient (3 retries exhausted) = fail LOUD with the
retry count — the fail-loud doctrine applies to exhausted patience, too."""
def scripted_api(base, token, path, method="GET", data=None, ok=(200,)):
if method == "GET" and path.startswith("/commits/"):
return 200, {"state": "success"}
if method == "GET" and path == "/pulls/31":
return 200, {**forge.prs[0], "mergeable": True}
if method == "POST" and path.endswith("/merge"):
return 405, {"message": "Please try again later"}
raise AssertionError(f"unexpected {method} {path}")
forge.prs = [{"number": 31, "head": {"sha": "s31"}}]
monkeypatch.setattr(mr, "_api", scripted_api)
monkeypatch.setattr(mr.time, "sleep", lambda s: None)
with pytest.raises(mr.MergeRefused, match="transient after 3 retries"):
mr.decide("base", "tok", forge.prs[0])
def test_conflict_first_policy_update_before_wait(forge, monkeypatch):
"""Nick's policy: conflicts are resolved FIRST — the robot attempts the
API update the moment mergeable=False is observed (before any wait),
then a merge attempt follows within ~a minute. A PR should never sit
through a sleep cycle while resolvable staleness is known."""
forge.prs = [{"number": 40, "head": {"sha": "s40"}}]
calls = {"update": 0, "merge": 0}
def scripted_api(base, token, path, method="GET", data=None, ok=(200,)):
if method == "GET" and path.startswith("/commits/"):
return 200, {"state": "success"}
if method == "GET" and path == "/pulls/40":
# mergeable=False until the update lands (that's the conflict-first trigger)
return 200, {**forge.prs[0], "mergeable": calls["update"] > 0}
if method == "GET" and path.startswith("/compare/"):
return 200, {"files": [{"filename": "docs/notes.md"}]} # docs-only → carry
if method == "POST" and path.endswith("/update"):
calls["update"] += 1
return 200, {}
if method == "POST" and path.endswith("/merge"):
calls["merge"] += 1
if calls["merge"] == 1:
return 405, {"message": "Please try again later"}
return 200, {}
raise AssertionError(f"unexpected {method} {path}")
monkeypatch.setattr(mr, "_api", scripted_api)
monkeypatch.setattr(mr.time, "sleep", lambda s: None)
line = mr.decide("base", "tok", forge.prs[0])
assert calls["update"] == 1, "mergeable=False must trigger immediate update (conflict-first)"
assert "MERGED" in line
def test_carry_forward_checked_in_robot_update_path(forge, monkeypatch):
"""After a self-heal update, the robot must classify the tree diff:
docs-only → merge on carried green; code-path touch → skip (CI must
re-run first). Prevents the #208 slip (merged on stale required-checks)."""
forge.prs = [{"number": 50, "head": {"sha": "s50"}}]
calls = {"update": 0, "merge": 0, "classify": 0}
def scripted_api(base, token, path, method="GET", data=None, ok=(200,)):
if method == "GET" and path.startswith("/commits/"):
return 200, {"state": "success"}
if method == "GET" and path == "/pulls/50":
return 200, {**forge.prs[0], "mergeable": calls["update"] > 0}
if method == "GET" and path.startswith("/compare/"):
return 200, {"files": [{"filename": "src/rune/x.py"}]} # code touch → RERUN
if method == "POST" and path.endswith("/update"):
calls["update"] += 1
return 200, {}
if method == "POST" and path.endswith("/merge"):
calls["merge"] += 1
return 200, {}
raise AssertionError(f"unexpected {method} {path}")
monkeypatch.setattr(mr, "_api", scripted_api)
monkeypatch.setattr(mr.time, "sleep", lambda s: None)
monkeypatch.setattr(
mr,
"classify_update",
lambda base, token, pr, new_sha: "RERUN_REQUIRED",
)
line = mr.decide("base", "tok", forge.prs[0])
assert "skipped" in line and "CI re-running" in line, "code-path touch must wait for fresh CI"
assert calls["merge"] == 0, "no merge on unverified carry"