"""ORCH-082 FR-2/FR-3/FR-4 — ensure_open_pr врезка in _handle_merge_verify. Covers TC-06..12 / AC-3 / AC-4 / AC-5 / AC-7 / AC-8 / AC-9 / FR-5. Calls the ``deploy -> done`` 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). The ORCH-073 SHA-in-main proof stays authoritative — auto-creating a PR must NEVER mask un-merged code. """ 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_orch082.db")) import logging # noqa: E402 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-082" BRANCH = "feature/ORCH-082-x" @pytest.fixture(autouse=True) def _wire(monkeypatch): # Under-gate in scope; autocreate ON; regression guard OFF (its own tests cover it). monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True) monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", True) monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", False) monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef") # 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-06 (AC-3): PR absent -> ensure_open_pr creates -> merge_pr -> verify True -> # deploy->done with NO false HOLD. # --------------------------------------------------------------------------- def test_tc06_autocreate_then_merge_then_done(monkeypatch): ensure = MagicMock(return_value=("created", "5")) merge = MagicMock(return_value=(True, "merged PR #5")) monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure) monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge) monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True) res = AdvanceResult() intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res) assert intervened is False # advance to done assert res.alerted is False ensure.assert_called_once_with(REPO, BRANCH) assert merge.called assert not stage_engine.set_issue_blocked.called # --------------------------------------------------------------------------- # TC-07 (AC-4 / FR-3): PR created/merged but verify_merged_to_main=False (code not # in main) -> HOLD + set_issue_blocked, NOT done, no rollback. ORCH-073 protection # is untouched by auto-create. # --------------------------------------------------------------------------- def test_tc07_verify_false_still_holds(monkeypatch): monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("created", "5")) monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #5")) monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False) 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 # --------------------------------------------------------------------------- # TC-08 (AC-7 / AC-5): ensure_open_pr -> failed -> honest HOLD with distinguishable # text/note; merge_pr is NOT reached; advance_stage does not raise. # --------------------------------------------------------------------------- def test_tc08_ensure_failed_holds_distinct(monkeypatch): monkeypatch.setattr( stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("failed", "gitea down") ) merge = MagicMock() monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge) res = AdvanceResult() intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res) assert intervened is True # HOLD assert res.advanced is False assert res.note == "pr-create-failed-hold" # distinct from "merge-not-verified-hold" assert not merge.called # merge_pr never reached assert stage_engine.set_issue_blocked.called # --------------------------------------------------------------------------- # TC-09 (AC-8): kill-switch OFF -> ensure_open_pr NOT called; "no open PR" -> prior # HOLD 1:1 (ORCH-074 behaviour reproduced). # --------------------------------------------------------------------------- def test_tc09_killswitch_off_no_autocreate(monkeypatch): monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", False) ensure = MagicMock() monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure) # merge_pr finds no open PR -> verify False -> prior not-merged HOLD. monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (False, "no open PR")) monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False) res = AdvanceResult() intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res) assert intervened is True assert res.note == "merge-not-verified-hold" # exactly the prior HOLD assert not ensure.called # auto-create skipped entirely # --------------------------------------------------------------------------- # TC-10 (AC-9): non-self repo (merge_verify_applies=False) -> врезка no-op, neither # ensure_open_pr nor merge_pr called. # --------------------------------------------------------------------------- def test_tc10_non_self_repo_noop(monkeypatch): monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: False) ensure = MagicMock() merge = MagicMock() monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure) monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge) res = AdvanceResult() intervened = _handle_merge_verify(1, "enduro-trails", "ET-1", "feature/x", res) assert intervened is False # advance unchanged assert not ensure.called assert not merge.called # --------------------------------------------------------------------------- # TC-11 (AC-2 / FR-5): idempotent re-drive (reaper/reconciler) -> ensure existed, # merge_pr already-merged -> verify True -> done, no duplicate PR. # --------------------------------------------------------------------------- def test_tc11_idempotent_redrive(monkeypatch): ensure = MagicMock(return_value=("existed", "5")) monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure) monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "already-merged")) monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True) res = AdvanceResult() intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res) assert intervened is False # advance to done assert ensure.return_value[0] == "existed" assert not stage_engine.set_issue_blocked.called # --------------------------------------------------------------------------- # TC-12 (AC-5): logs distinguish created/existed/failed; the create-failed HOLD text # differs from the not-merged HOLD text. # --------------------------------------------------------------------------- def test_tc12_logs_distinguish_outcomes(monkeypatch, caplog): monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("created", "5")) monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #5")) monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True) with caplog.at_level(logging.INFO, logger="orchestrator"): _handle_merge_verify(1, REPO, WI, BRANCH, AdvanceResult()) assert any("ensure_open_pr -> created" in r.message for r in caplog.records) # create-failed note differs from not-merged note (text-distinguishable HOLD). monkeypatch.setattr( stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("failed", "gitea down") ) res = AdvanceResult() _handle_merge_verify(1, REPO, WI, BRANCH, res) assert res.note == "pr-create-failed-hold" assert res.note != "merge-not-verified-hold"