Files
orchestrator/tests/test_orch073_regression_guard.py
claude-bot aff334e82b 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>
2026-06-08 16:30:46 +03:00

115 lines
4.9 KiB
Python

"""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 "")