fix(merge-gate): SHA-in-main as sole merge-verify criterion + main regression guard
Root-cause fix for main erosion (phantom merge): code of ORCH-067/069 reached `done` while absent from origin/main (only their auto docs-PRs landed). - FR-1: verify_merged_to_main confirms merge ONLY by `git merge-base --is-ancestor <validated_sha> origin/main`; the OR-branch pr_already_merged is removed (a merged PR no longer confirms). Empty SHA / git error -> False. - FR-2: pr_already_merged demoted to merge_pr idempotency-guard; counts a PR only when merged & head.ref==<branch> & base.ref=="main" (explicit in-loop filter). - FR-3: merge_pr selects the open code-PR by head==<branch> AND base==main. - FR-5: new deterministic check_main_regression in _handle_merge_verify (after confirmed SHA-in-main, before done) verifies MAIN_REGRESSION_MARKERS still in origin/main; deterministic count==0 -> alert "main regressed" + HOLD (NOT done, no rollback); git error of the grep -> fail-open. Kill-switch ORCH_REGRESSION_GUARD_ENABLED; non-self -> no-op. - FR-4: root .gitattributes `CHANGELOG.md merge=union` so Unreleased edits auto-merge on rebase without conflict (branch not rolled back). Invariants unchanged (STAGE_TRANSITIONS, QG_CHECKS, deploy-status, merge-gate, image-freshness, DB schema, external HTTP API); non-self repos no-op (INV-5); never-raise (INV-1); merge only via Gitea PR-API (INV-2). Docs: CHANGELOG, .env.example (README/ADR updated by architect). Tests: tests/test_orch073_*.py (TC-01..18); existing merge-gate tests updated for the new code-PR filter. Refs: ORCH-073 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -94,4 +94,8 @@ def _disable_merge_verify(monkeypatch):
|
||||
"""
|
||||
from src import config as _cfg
|
||||
monkeypatch.setattr(_cfg.settings, "merge_verify_enabled", False, raising=False)
|
||||
# ORCH-073: the regression guard (check_main_regression) runs real git in
|
||||
# _handle_merge_verify's confirmed branch. Default it OFF too so unrelated
|
||||
# deploy->done tests stay 1:1; the dedicated ORCH-073 tests re-enable it.
|
||||
monkeypatch.setattr(_cfg.settings, "regression_guard_enabled", False, raising=False)
|
||||
yield
|
||||
|
||||
@@ -42,7 +42,7 @@ def test_tc07_merge_actor_calls_gitea_merge(monkeypatch):
|
||||
|
||||
def fake_get(url, params=None, headers=None, timeout=None):
|
||||
get_calls.append((url, params))
|
||||
return _Resp(200, [{"head": {"ref": branch}, "number": 7}])
|
||||
return _Resp(200, [{"head": {"ref": branch}, "base": {"ref": "main"}, "number": 7}])
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
post_calls.append((url, json))
|
||||
@@ -104,7 +104,7 @@ def test_tc09_never_raise_on_http_error(monkeypatch):
|
||||
def test_tc09_merge_endpoint_non_2xx_is_false(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 3}])
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "base": {"ref": "main"}, "number": 3}])
|
||||
)
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(409, text="conflict"))
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
|
||||
@@ -119,7 +119,7 @@ def test_tc09_merge_endpoint_non_2xx_is_false(monkeypatch):
|
||||
def test_tc13_no_shell_out_no_force_push(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 9}])
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "base": {"ref": "main"}, "number": 9}])
|
||||
)
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(200))
|
||||
|
||||
|
||||
@@ -315,10 +315,17 @@ class _FakeResp:
|
||||
|
||||
|
||||
def test_tc16_pr_already_merged_true(monkeypatch):
|
||||
"""A merged PR -> True so a re-driven/reaped task is a no-op (no second merge)."""
|
||||
"""A merged code-PR -> True so a re-driven/reaped task is a no-op (no second merge).
|
||||
|
||||
ORCH-073 FR-2: the guard now counts a PR only when it carries THIS branch's code
|
||||
into main (merged & head.ref==branch & base.ref=="main").
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
httpx, "get",
|
||||
lambda *a, **k: _FakeResp(200, [{"number": 7, "merged": True}]),
|
||||
lambda *a, **k: _FakeResp(
|
||||
200,
|
||||
[{"number": 7, "merged": True, "head": {"ref": "feature/x"}, "base": {"ref": "main"}}],
|
||||
),
|
||||
)
|
||||
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is True
|
||||
|
||||
|
||||
@@ -204,7 +204,9 @@ def test_tc17_pr_already_merged_makes_redrive_a_noop(race_repo, monkeypatch):
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return [{"merged": True}]
|
||||
# ORCH-073 FR-2: the guard counts a PR only when it carries THIS branch's
|
||||
# code into main (merged & head.ref==branch & base.ref=="main").
|
||||
return [{"merged": True, "head": {"ref": "feature/B"}, "base": {"ref": "main"}}]
|
||||
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _R())
|
||||
assert merge_gate.pr_already_merged(repo, "feature/B") is True
|
||||
|
||||
@@ -49,17 +49,22 @@ def test_tc01_verify_true_when_sha_is_ancestor(monkeypatch):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02: PR.merged==true short-circuits to True even if git is unavailable.
|
||||
# TC-02 (ORCH-073 FR-1): PR.merged==true NO LONGER confirms a merge. The former
|
||||
# OR-branch was the phantom-merge root cause (a merged docs-PR turned verify green).
|
||||
# SHA-in-main is now the SINGLE criterion; an empty SHA -> inconclusive -> False.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_verify_true_when_pr_merged_even_without_git(monkeypatch):
|
||||
def test_tc02_pr_merged_does_not_confirm_without_sha_in_main(monkeypatch):
|
||||
# Even if a (docs-)PR is reported merged, that must NOT short-circuit to True.
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("git must NOT be consulted when PR is already merged")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "") is True
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
# SHA not an ancestor of origin/main (rc=1) -> not confirmed despite merged PR.
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda cmd, *a, **k: _R(1) if "merge-base" in cmd else _R(0),
|
||||
)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
# And an empty SHA is inconclusive -> False (cannot prove SHA-in-main).
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -93,11 +98,13 @@ def test_tc04_verify_never_raises_on_git_error(monkeypatch):
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
|
||||
|
||||
def test_tc04_verify_never_raises_on_http_error(monkeypatch):
|
||||
def boom(r, b):
|
||||
raise RuntimeError("gitea down")
|
||||
def test_tc04_verify_never_raises_on_worktree_error(monkeypatch):
|
||||
# ORCH-073: verify no longer consults pr_already_merged; a worktree/git error
|
||||
# on the SHA-in-main path is the failure to swallow -> conservative False.
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("worktree exploded")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", boom)
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
|
||||
|
||||
|
||||
93
tests/test_orch073_conditionality.py
Normal file
93
tests/test_orch073_conditionality.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""ORCH-073 — conditionality / backward-compat (INV-5).
|
||||
|
||||
Covers TC-17/18 / AC-6. The whole under-gate and the regression guard are no-ops for
|
||||
non-self repos and when their kill-switches are off, so enduro-trails and a disabled
|
||||
self-host behave exactly as before.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch073_cond.db"))
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
from src import merge_gate, stage_engine, image_freshness # noqa: E402
|
||||
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
|
||||
|
||||
REPO = "orchestrator"
|
||||
WI = "ORCH-073"
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-17 (AC-6/INV-5): non-self repo / kill-switch off -> under-gate is a no-op.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_merge_verify_applies_scope(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_repos", "")
|
||||
# Empty CSV -> only the self-hosting repo.
|
||||
assert merge_gate.merge_verify_applies("orchestrator") is True
|
||||
assert merge_gate.merge_verify_applies("enduro-trails") is False
|
||||
# Kill-switch off -> no-op for everyone.
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", False)
|
||||
assert merge_gate.merge_verify_applies("orchestrator") is False
|
||||
|
||||
|
||||
def test_tc17_under_gate_noop_for_non_self(monkeypatch):
|
||||
# When the under-gate does not apply, _handle_merge_verify advances (False) and
|
||||
# never touches the merge-actor / verifier / guard.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: False)
|
||||
|
||||
def must_not_call(*a, **k):
|
||||
raise AssertionError("under-gate must be a no-op for non-self repos")
|
||||
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", must_not_call)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", must_not_call)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "check_main_regression", must_not_call)
|
||||
|
||||
res = AdvanceResult()
|
||||
assert _handle_merge_verify(1, "enduro-trails", WI, BRANCH, res) is False
|
||||
assert res.alerted is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-18 (INV-5): regression guard respects its kill-switch -> no-op; SHA-in-main
|
||||
# alone still advances the task.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc18_guard_kill_switch_skips_guard(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
|
||||
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", False)
|
||||
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #1"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (_ for _ in ()).throw(AssertionError("guard must not run when disabled")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
|
||||
)
|
||||
for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
|
||||
res = AdvanceResult()
|
||||
# Guard disabled -> confirmed SHA-in-main advances straight to done (return False).
|
||||
assert _handle_merge_verify(1, REPO, WI, BRANCH, res) is False
|
||||
assert res.alerted is False
|
||||
assert not stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
def test_tc18_guard_noop_for_non_self_repo(monkeypatch):
|
||||
# check_main_regression is only invoked inside the confirmed branch which itself
|
||||
# only runs when merge_verify_applies is True (self-hosting / CSV). For a non-self
|
||||
# repo the guard is never reached.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: False)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (_ for _ in ()).throw(AssertionError("guard must not run for non-self")),
|
||||
)
|
||||
res = AdvanceResult()
|
||||
assert _handle_merge_verify(1, "enduro-trails", WI, BRANCH, res) is False
|
||||
85
tests/test_orch073_gitattributes.py
Normal file
85
tests/test_orch073_gitattributes.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""ORCH-073 FR-4 — .gitattributes: CHANGELOG.md merge=union.
|
||||
|
||||
Covers TC-11/TC-12 / AC-4. TC-11 asserts the repo-root .gitattributes declares the
|
||||
union driver (git check-attr). TC-12 proves, in a throwaway git repo, that two
|
||||
branches both editing '## [Unreleased]' merge WITHOUT a conflict and BOTH entries
|
||||
survive — exactly what stops auto_rebase_onto_main from rolling a branch back.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _git(cwd, *args, env=None):
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=str(cwd), capture_output=True, text=True, env=env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11 (AC-4): the repo-root .gitattributes declares CHANGELOG.md merge=union.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc11_gitattributes_declares_union():
|
||||
ga = REPO_ROOT / ".gitattributes"
|
||||
assert ga.is_file(), ".gitattributes must exist at the repo root"
|
||||
assert "CHANGELOG.md merge=union" in ga.read_text(encoding="utf-8")
|
||||
|
||||
r = _git(REPO_ROOT, "check-attr", "merge", "CHANGELOG.md")
|
||||
assert r.returncode == 0, r.stderr
|
||||
# Output form: 'CHANGELOG.md: merge: union'
|
||||
assert "merge: union" in r.stdout, r.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-12 (AC-4): two Unreleased edits merge with no conflict; both kept.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _init_repo(tmp_path):
|
||||
env = {
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
"GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null",
|
||||
"PATH": __import__("os").environ.get("PATH", ""),
|
||||
"HOME": str(tmp_path),
|
||||
}
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
assert _git(repo, "init", "-b", "main", env=env).returncode == 0
|
||||
(repo / ".gitattributes").write_text("CHANGELOG.md merge=union\n", encoding="utf-8")
|
||||
base = (
|
||||
"# Changelog\n\n## [Unreleased]\n\n### Common\n\n## [0.1.0]\n- initial\n"
|
||||
)
|
||||
(repo / "CHANGELOG.md").write_text(base, encoding="utf-8")
|
||||
_git(repo, "add", ".", env=env)
|
||||
assert _git(repo, "commit", "-m", "base", env=env).returncode == 0
|
||||
return repo, env
|
||||
|
||||
|
||||
def test_tc12_union_merge_keeps_both_entries(tmp_path):
|
||||
repo, env = _init_repo(tmp_path)
|
||||
|
||||
# Branch A adds its Unreleased line.
|
||||
_git(repo, "checkout", "-b", "task-a", env=env)
|
||||
txt = (repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
(repo / "CHANGELOG.md").write_text(
|
||||
txt.replace("### Common\n", "### Common\n- ORCH-A: feature A\n"), encoding="utf-8"
|
||||
)
|
||||
_git(repo, "commit", "-am", "task A changelog", env=env)
|
||||
|
||||
# Branch B (from main) adds a DIFFERENT Unreleased line at the same spot.
|
||||
_git(repo, "checkout", "main", env=env)
|
||||
_git(repo, "checkout", "-b", "task-b", env=env)
|
||||
txt = (repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
(repo / "CHANGELOG.md").write_text(
|
||||
txt.replace("### Common\n", "### Common\n- ORCH-B: feature B\n"), encoding="utf-8"
|
||||
)
|
||||
_git(repo, "commit", "-am", "task B changelog", env=env)
|
||||
|
||||
# Merge A into B — union must avoid a conflict and keep BOTH lines.
|
||||
m = _git(repo, "merge", "--no-edit", "task-a", env=env)
|
||||
result = (repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert m.returncode == 0, f"union merge must not conflict: {m.stdout}\n{m.stderr}"
|
||||
assert "<<<<<<<" not in result and ">>>>>>>" not in result
|
||||
assert "ORCH-A: feature A" in result
|
||||
assert "ORCH-B: feature B" in result
|
||||
106
tests/test_orch073_merge_pr.py
Normal file
106
tests/test_orch073_merge_pr.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""ORCH-073 FR-3 — merge_pr merges exactly the feature code-PR (base==main).
|
||||
|
||||
Covers TC-08..10 / AC-7 / INV-2/INV-4. The actor selects the open PR with
|
||||
head==branch AND base==main (never an auto docs-PR / foreign base), merges via the
|
||||
Gitea PR-merge API only (no push/force-push), and is idempotent on an already-merged
|
||||
code-PR. Gitea HTTP is mocked; never-raise -> (False, reason).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload if payload is not None else []
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08: open code-PR (head==branch, base==main) -> POST /pulls/{n}/merge.
|
||||
# A concurrently-open docs-PR (head=docs/*) must be skipped.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08_merges_code_pr_not_docs_pr(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
post_calls = []
|
||||
|
||||
def fake_get(url, params=None, headers=None, timeout=None):
|
||||
return _Resp(200, [
|
||||
{"head": {"ref": "docs/ORCH-073-log"}, "base": {"ref": "main"}, "number": 4},
|
||||
{"head": {"ref": BRANCH}, "base": {"ref": "main"}, "number": 7},
|
||||
])
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
post_calls.append((url, json))
|
||||
return _Resp(200)
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
monkeypatch.setattr(httpx, "post", fake_post)
|
||||
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is True and "PR #7" in msg
|
||||
assert len(post_calls) == 1
|
||||
url, body = post_calls[0]
|
||||
assert url.endswith("/repos/admin/orchestrator/pulls/7/merge")
|
||||
assert body == {"Do": "merge"}
|
||||
|
||||
|
||||
def test_tc08_skips_pr_onto_non_main_base(monkeypatch):
|
||||
# Right head but base != main -> not a merge-to-main code-PR -> no open PR.
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(
|
||||
httpx, "get",
|
||||
lambda *a, **k: _Resp(200, [{"head": {"ref": BRANCH}, "base": {"ref": "develop"}, "number": 9}]),
|
||||
)
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("must not POST merge for a non-main base PR")))
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is False and msg == "no open PR"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-09 (INV-2): no open code-PR -> (False, "no open PR"); never shells out.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc09_no_open_pr_no_shell_out(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, []))
|
||||
subprocess_calls = []
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda cmd, *a, **k: subprocess_calls.append(cmd),
|
||||
)
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is False and msg == "no open PR"
|
||||
# No git push/force-push (or any subprocess) for the merge-actor.
|
||||
assert subprocess_calls == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-10 (AC-7/INV-4): already-merged code-PR -> no-op, no second POST merge.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc10_idempotent_already_merged(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True)
|
||||
|
||||
def must_not_call(*a, **k):
|
||||
raise AssertionError("no Gitea call when the code-PR is already merged")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", must_not_call)
|
||||
monkeypatch.setattr(httpx, "post", must_not_call)
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is True and msg == "already-merged"
|
||||
99
tests/test_orch073_merge_verify.py
Normal file
99
tests/test_orch073_merge_verify.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""ORCH-073 FR-1 — verify_merged_to_main: SHA-in-main is the SINGLE criterion.
|
||||
|
||||
Covers TC-01..04 / AC-2 / AC-6. The former OR-branch `pr_already_merged` was the
|
||||
phantom-merge root cause and is removed: a merged docs-PR must NOT confirm a merge.
|
||||
git/HTTP are mocked; the verifier honours the never-raise contract (INV-1).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
|
||||
class _R:
|
||||
"""Minimal completed-subprocess stand-in (returncode only)."""
|
||||
|
||||
def __init__(self, rc):
|
||||
self.returncode = rc
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_timeout_s", 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01 (AC-6): sha is an ancestor of origin/main (merge-base rc=0) -> True.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_true_when_sha_is_ancestor(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, *a, **k):
|
||||
calls.append(cmd)
|
||||
return _R(0) # fetch ok; merge-base --is-ancestor -> 0 (ancestor)
|
||||
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", fake_run)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is True
|
||||
assert any(
|
||||
"merge-base" in c and "--is-ancestor" in c and "origin/main" in c for c in calls
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02 (AC-2): sha NOT in main AND a merged docs-PR exists -> False.
|
||||
# This is the exact ORCH-067/069 bug: a merged docs-PR must not confirm.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_false_when_sha_not_in_main_even_with_merged_docs_pr(monkeypatch):
|
||||
# A merged docs-PR is present (mock returns True), but it must be IGNORED.
|
||||
called = {"pr": False}
|
||||
|
||||
def fake_pr_already_merged(r, b):
|
||||
called["pr"] = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", fake_pr_already_merged)
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda cmd, *a, **k: _R(1) if "merge-base" in cmd else _R(0),
|
||||
)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is False
|
||||
# The merged-PR signal is no longer consulted by the verifier at all.
|
||||
assert called["pr"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03: empty sha -> inconclusive -> False (fail-closed), no git consulted.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc03_empty_sha_is_false(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("git must NOT run for an empty SHA")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 (INV-1): a git/OS error -> False, exception never propagated.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc04_never_raises_on_git_error(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
|
||||
def boom(*a, **k):
|
||||
raise OSError("git exploded")
|
||||
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is False
|
||||
|
||||
|
||||
def test_tc04_never_raises_on_worktree_error(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("worktree down")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is False
|
||||
78
tests/test_orch073_pr_classify.py
Normal file
78
tests/test_orch073_pr_classify.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""ORCH-073 FR-2 — pr_already_merged distinguishes code-PR from docs-PR.
|
||||
|
||||
Covers TC-05..07. pr_already_merged is now an idempotency-guard: it counts a PR as
|
||||
"merged" ONLY when it carries the code of THIS feature-branch into main
|
||||
(merged & head.ref==branch & base.ref=="main"), excluding auto docs-PRs. Gitea HTTP
|
||||
is mocked; never-raise -> False (INV-1).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload if payload is not None else []
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05: a merged docs-PR (head=docs/*, base=main) is NOT counted as code-merge.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc05_merged_docs_pr_not_counted(monkeypatch):
|
||||
payload = [
|
||||
{"merged": True, "head": {"ref": "docs/ORCH-073-staging-log"}, "base": {"ref": "main"}},
|
||||
{"merged": False, "head": {"ref": BRANCH}, "base": {"ref": "main"}},
|
||||
]
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, payload))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06: a merged code-PR (head==branch, base==main) IS recognised.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc06_merged_code_pr_recognised(monkeypatch):
|
||||
payload = [
|
||||
{"merged": True, "head": {"ref": BRANCH}, "base": {"ref": "main"}},
|
||||
]
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, payload))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is True
|
||||
|
||||
|
||||
def test_tc06_merged_code_pr_onto_non_main_base_not_counted(monkeypatch):
|
||||
# Right head but a foreign base (not main) must NOT count.
|
||||
payload = [
|
||||
{"merged": True, "head": {"ref": BRANCH}, "base": {"ref": "develop"}},
|
||||
]
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, payload))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07: HTTP error / non-200 -> False (never-raise, conservative).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc07_non_200_is_false(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(500, []))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
|
||||
|
||||
def test_tc07_http_exception_is_false(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise httpx.ConnectError("gitea unreachable")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", boom)
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
114
tests/test_orch073_regression_guard.py
Normal file
114
tests/test_orch073_regression_guard.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""ORCH-073 FR-5 — main-integrity regression guard wired into _handle_merge_verify.
|
||||
|
||||
Covers TC-13..16 / AC-3 / AC-5 / AC-6 / INV-1. Calls the under-gate handler directly
|
||||
with mocked merge_gate primitives + side effects (Plane/Telegram). Asserts the
|
||||
return contract: False == advance to `done`, True == HOLD (alert, NOT done).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch073_rg.db"))
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import stage_engine, image_freshness # noqa: E402
|
||||
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
|
||||
|
||||
REPO = "orchestrator"
|
||||
WI = "ORCH-073"
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _wire(monkeypatch):
|
||||
# Under-gate is in scope for the self-hosting repo; guard enabled.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
|
||||
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", True)
|
||||
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #1"))
|
||||
# Silence Plane/Telegram side effects (assert on .called where relevant).
|
||||
for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
monkeypatch.setattr(
|
||||
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-13 (AC-6): SHA in main AND markers intact -> advance (return False), no alert.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc13_confirmed_and_intact_advances(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "check_main_regression", lambda r, b: (True, "markers intact (4)"))
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is False # advance to done
|
||||
assert res.alerted is False
|
||||
assert not stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14 (AC-3): SHA NOT in main (docs-only merge) -> HOLD + alert + Blocked.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_sha_not_in_main_holds(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
|
||||
# Guard must never even run when SHA is not confirmed.
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (_ for _ in ()).throw(AssertionError("guard must not run when not confirmed")),
|
||||
)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD
|
||||
assert res.advanced is False
|
||||
assert res.note == "merge-not-verified-hold"
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
assert stage_engine.send_telegram.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-15 (AC-5): SHA in main BUT a marker missing -> HOLD + 'main regressed' alert.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc15_marker_missing_holds(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (False, "main regressed: ORCH-067 code missing (plane_issue_link @ src/notifications.py)"),
|
||||
)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD, NOT done
|
||||
assert res.advanced is False
|
||||
assert res.note == "main-regressed-hold"
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
assert stage_engine.send_telegram.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-16 (INV-1): an internal verifier error -> HOLD + alert, no exception escapes.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc16_internal_error_holds_never_raises(monkeypatch):
|
||||
def boom(r, b, s):
|
||||
raise RuntimeError("verifier exploded")
|
||||
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", boom)
|
||||
|
||||
res = AdvanceResult()
|
||||
# Must NOT raise.
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD
|
||||
assert res.advanced is False
|
||||
assert res.alerted is True
|
||||
assert "merge-verify-error" in (res.note or "")
|
||||
Reference in New Issue
Block a user