merge_pr now wraps ONLY the mutating POST /pulls/{n}/merge in a bounded
exponential-backoff retry-loop on TRANSIENT outcomes (405 "try again later",
408, any 5xx, network/timeout, and 409|422 while the PR is still mergeable);
TERMINAL outcomes (403/404/real conflict via mergeable==False) -> fast honest
False, so the ORCH-071/081 not-merged HOLD backstop is unchanged. Fixes the
ORCH-063 false HOLD + manual re-merge on Gitea's post-push mergeability hiccup.
ensure_open_pr gains an "already fully in main" guard (_branch_fully_in_main,
git merge-base --is-ancestor HEAD origin/main) BEFORE creating a PR -> new
"already-in-main" outcome avoids the garbage empty PR on a re-driven finalizer;
_handle_merge_verify skips merge_pr on that outcome and lets the authoritative
SHA-in-main check confirm -> done (not a HOLD). git error of the guard fails
OPEN to the create path.
New ORCH_MERGE_RETRY_* settings (kill-switch merge_retry_enabled -> one-shot,
max_attempts=3, backoff base=2/max=5). INV-4 (merge only via Gitea PR-merge API,
never push/force-push main), never-raise, STAGE_TRANSITIONS/QG_CHECKS/DB schema
unchanged. Docs (README merge-verify section, CLAUDE.md, CHANGELOG, .env.example)
updated in the same PR. Tests: test_merge_gate.py TC-01..12, test_config.py
TC-13, test_merge_verify.py TC-14..16; full suite green (1389).
Refs: ORCH-093
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
223 lines
10 KiB
Python
223 lines
10 KiB
Python
"""ORCH-071 — post-deploy merge verification + rollout conditionality.
|
|
|
|
Covers TC-01..04 (FR-2/G1/AC-1/AC-7: verify_merged_to_main), TC-11 (AC-4b: non-self
|
|
repo no-op) and TC-12 (AC-10: kill-switch). All deterministic: git/HTTP are mocked,
|
|
the verifier honours the never-raise contract.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from src import merge_gate
|
|
|
|
|
|
class _R:
|
|
"""Minimal stand-in for a completed subprocess result (returncode only)."""
|
|
|
|
def __init__(self, rc):
|
|
self.returncode = rc
|
|
self.stdout = ""
|
|
self.stderr = ""
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _enable(monkeypatch):
|
|
# The conftest disables the under-gate by default; these tests target it, so
|
|
# re-enable the feature and pin the scope to self-hosting only (empty CSV).
|
|
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True)
|
|
monkeypatch.setattr(merge_gate.settings, "merge_verify_repos", "")
|
|
monkeypatch.setattr(merge_gate.settings, "merge_verify_timeout_s", 5)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-01: validated SHA is an ancestor of origin/main (merge-base rc=0) -> True.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc01_verify_true_when_sha_is_ancestor(monkeypatch):
|
|
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
|
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
|
|
|
calls = []
|
|
|
|
def fake_run(cmd, *a, **k):
|
|
calls.append(cmd)
|
|
# fetch -> rc 0; merge-base --is-ancestor -> rc 0 (is ancestor).
|
|
return _R(0)
|
|
|
|
monkeypatch.setattr(merge_gate.subprocess, "run", fake_run)
|
|
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is True
|
|
# The verifier consulted git merge-base --is-ancestor on origin/main.
|
|
assert any("merge-base" in c and "--is-ancestor" in c and "origin/main" in c for c in calls)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_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)
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-03: not an ancestor (rc=1) AND PR not merged -> False (phantom merge).
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc03_verify_false_when_phantom(monkeypatch):
|
|
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
|
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
|
|
|
def fake_run(cmd, *a, **k):
|
|
if "merge-base" in cmd:
|
|
return _R(1) # NOT an ancestor.
|
|
return _R(0) # fetch ok.
|
|
|
|
monkeypatch.setattr(merge_gate.subprocess, "run", fake_run)
|
|
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-04 (AC-7): never-raise — a git/OS error -> False, exception not propagated.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc04_verify_never_raises_on_git_error(monkeypatch):
|
|
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
|
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)
|
|
# No exception escapes; the conservative verdict is "not confirmed".
|
|
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
|
|
|
|
|
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, "ensure_worktree", boom)
|
|
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-11 (AC-4b): non-self repo -> under-gate is a no-op (merge stays with deployer).
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc11_non_self_repo_does_not_apply(monkeypatch):
|
|
# Empty CSV -> only the self-hosting repo is in scope.
|
|
assert merge_gate.merge_verify_applies("orchestrator") is True
|
|
assert merge_gate.merge_verify_applies("enduro-trails") is False
|
|
|
|
|
|
def test_tc11_csv_scopes_to_listed_repos(monkeypatch):
|
|
monkeypatch.setattr(merge_gate.settings, "merge_verify_repos", "enduro-trails")
|
|
assert merge_gate.merge_verify_applies("enduro-trails") is True
|
|
# When the CSV is set, the self repo is NOT auto-included.
|
|
assert merge_gate.merge_verify_applies("orchestrator") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-12 (AC-10): kill-switch off -> applies False for everyone (1:1 prior behaviour).
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc12_kill_switch_disables_under_gate(monkeypatch):
|
|
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", False)
|
|
assert merge_gate.merge_verify_applies("orchestrator") is False
|
|
assert merge_gate.merge_verify_applies("enduro-trails") is False
|
|
|
|
|
|
# ===========================================================================
|
|
# ORCH-093 / TC-14..16: _handle_merge_verify integration (deploy->done under-gate).
|
|
# already-in-main skips merge_pr; transient-retry success -> done; exhausted -> HOLD.
|
|
# ===========================================================================
|
|
import os # noqa: E402
|
|
import tempfile # noqa: E402
|
|
|
|
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_orch093.db"))
|
|
|
|
from unittest.mock import MagicMock # noqa: E402
|
|
|
|
from src import stage_engine, image_freshness # noqa: E402
|
|
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
|
|
|
|
_O93_REPO = "orchestrator"
|
|
_O93_WI = "ORCH-093"
|
|
_O93_BRANCH = "feature/ORCH-093-x"
|
|
|
|
|
|
@pytest.fixture
|
|
def _o93_wire(monkeypatch):
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
|
|
monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", True)
|
|
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", False)
|
|
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
|
|
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-14: ensure_open_pr -> already-in-main -> skip merge_pr; SHA-in-main -> done (AC-4) ---
|
|
def test_tc14_already_in_main_skips_merge_pr_then_done(_o93_wire, monkeypatch):
|
|
monkeypatch.setattr(
|
|
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("already-in-main", "x")
|
|
)
|
|
merge = MagicMock()
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res)
|
|
|
|
assert intervened is False # advance to done
|
|
assert res.alerted is False
|
|
assert not merge.called # merge_pr SKIPPED (nothing to merge)
|
|
assert not stage_engine.set_issue_blocked.called
|
|
|
|
|
|
# --- TC-15: merge_pr exhausted (False) + SHA not in main -> HOLD + alert (ORCH-071/081) (AC-3) ---
|
|
def test_tc15_merge_failed_and_not_in_main_holds(_o93_wire, monkeypatch):
|
|
monkeypatch.setattr(
|
|
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("existed", "9")
|
|
)
|
|
monkeypatch.setattr(
|
|
stage_engine.merge_gate, "merge_pr",
|
|
lambda r, b: (False, "merge failed after 3 attempts: HTTP 405"),
|
|
)
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res)
|
|
|
|
assert intervened is True # HOLD, NOT done
|
|
assert res.advanced is False
|
|
assert res.note == "merge-not-verified-hold"
|
|
assert stage_engine.set_issue_blocked.called
|
|
|
|
|
|
# --- TC-16: happy path — transient retry success in merge_pr -> SHA-in-main -> done (AC-1) ---
|
|
def test_tc16_transient_retry_success_then_done(_o93_wire, monkeypatch):
|
|
monkeypatch.setattr(
|
|
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("existed", "9")
|
|
)
|
|
# merge_pr already rode out the 405x2->200 transient internally -> (True, ...).
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #9"))
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res)
|
|
|
|
assert intervened is False # done, no false HOLD
|
|
assert res.alerted is False
|
|
assert not stage_engine.set_issue_blocked.called
|