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

107 lines
4.3 KiB
Python

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