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
434 lines
18 KiB
Python
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"
|