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