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>
107 lines
4.3 KiB
Python
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"
|