Files
orchestrator/tests/test_orch073_conditionality.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

94 lines
4.4 KiB
Python

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