"""ORCH-071 — Phase C finalizer x merge-verify under-gate (integration). Covers TC-05 (FR-3/G2/AC-1: deploy SUCCESS but PR open -> NOT done + alert), TC-06 (AC-4: deploy SUCCESS + merge confirmed -> done) and TC-14 (AC-11: Phase B runs only on confirm_deploy; merge/verify never introduce an auto-deploy). Mirrors tests/test_deploy_terminal_sync.py: the finalizer drives advance_stage, the deploy gate is forced green, and the merge-actor/verifier are mocked so the test stays deterministic (no real Gitea/git). """ import os import tempfile import pytest _test_db = os.path.join(tempfile.gettempdir(), "test_orch_merge_verify.db") os.environ["ORCH_DB_PATH"] = _test_db os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") from unittest.mock import MagicMock # noqa: E402 import src.db as _db # noqa: E402 from src.db import init_db, get_db # noqa: E402 from src import stage_engine # noqa: E402 from src import self_deploy # noqa: E402 @pytest.fixture(autouse=True) def fresh_db(monkeypatch, tmp_path): monkeypatch.setattr(_db.settings, "db_path", _test_db) if os.path.exists(_test_db): os.unlink(_test_db) init_db() monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path)) monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path)) monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True)) # The under-gate is disabled by conftest default; these tests target it. monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_enabled", True) monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_repos", "") # The merged_to_main stamp is an observability side effect (no log file here). monkeypatch.setattr( stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True) ) # ORCH-021 post-deploy monitor is orthogonal; keep it off for these tests. monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", False) yield @pytest.fixture(autouse=True) def silence_side_effects(monkeypatch): for name in ( "notify_stage_change", "notify_qg_failure", "notify_approve_requested", "send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment", "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", "set_issue_blocked", "set_issue_done", "set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_monitoring", ): monkeypatch.setattr(stage_engine, name, MagicMock()) monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock()) def _pass(*a, **k): return (True, "ok") def _make_task(stage, repo="orchestrator", branch="feature/ORCH-071-x", wi="ORCH-071"): conn = get_db() cur = conn.execute( "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) VALUES (?, ?, ?, ?, ?)", (f"plane-{wi}", wi, repo, branch, stage), ) task_id = cur.lastrowid conn.commit() conn.close() return task_id def _stage(task_id): conn = get_db() row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone() conn.close() return row[0] def _force_deploy_gate_green(monkeypatch): monkeypatch.setattr( stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_deploy_status": _pass}, ) # --------------------------------------------------------------------------- # TC-05 (AC-1): deploy_status=SUCCESS but PR open -> task is HELD (not done) + alert. # --------------------------------------------------------------------------- def test_tc05_success_but_not_merged_holds_and_alerts(monkeypatch): self_deploy.write_marker("orchestrator", "ORCH-071", self_deploy.RESULT, "0") _force_deploy_gate_green(monkeypatch) # The merge-actor finds no merge and the verifier confirms NOT merged. monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", MagicMock(return_value=(False, "no open PR"))) monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", MagicMock(return_value=False)) task_id = _make_task("deploy") stage_engine.run_deploy_finalizer( {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"} ) # AC-1 PASS: the task did NOT reach done and was Blocked for manual handling. assert _stage(task_id) == "deploy" assert stage_engine.set_issue_blocked.called assert not stage_engine.set_issue_done.called assert not stage_engine.set_issue_monitoring.called # An alert was sent ("deploy succeeded but not merged"). assert stage_engine.send_telegram.called # --------------------------------------------------------------------------- # TC-06 (AC-4): deploy_status=SUCCESS + merge confirmed -> done (happy-path). # --------------------------------------------------------------------------- def test_tc06_success_and_merged_reaches_done(monkeypatch): self_deploy.write_marker("orchestrator", "ORCH-071", self_deploy.RESULT, "0") _force_deploy_gate_green(monkeypatch) merge_pr = MagicMock(return_value=(True, "merged PR #1")) verify = MagicMock(return_value=True) monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge_pr) monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", verify) task_id = _make_task("deploy") stage_engine.run_deploy_finalizer( {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"} ) assert _stage(task_id) == "done" # The deterministic merge-actor + verifier both ran on the deploy->done edge. assert merge_pr.called assert verify.called # Self-hosting: terminal status -> Monitoring (post_deploy off here -> Done set). assert not stage_engine.set_issue_blocked.called # --------------------------------------------------------------------------- # TC-14 (AC-11): a plain Approved on `deploy` (confirm_deploy=False) is a no-op — # Phase B (prod deploy) requires "Confirm Deploy", and merge/verify do NOT run # (the under-gate never introduces an auto-deploy). # --------------------------------------------------------------------------- def test_tc14_plain_approved_on_deploy_is_noop_no_merge(monkeypatch): monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_enabled", True) monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_repos", "") monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True) merge_pr = MagicMock() verify = MagicMock() initiate = MagicMock(return_value=(True, "ok")) monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge_pr) monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", verify) monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) task_id = _make_task("deploy") # finished_agent=None + confirm_deploy=False == a plain Approved on `deploy`. result = stage_engine.advance_stage( task_id, "deploy", "orchestrator", "ORCH-071", "feature/ORCH-071-x", finished_agent=None, confirm_deploy=False, ) assert result.note == "approved-on-deploy-noop" assert _stage(task_id) == "deploy" # No prod deploy initiated and the merge-verify under-gate never fired. assert not initiate.called assert not merge_pr.called assert not verify.called def test_tc14_confirm_deploy_initiates_phase_b(monkeypatch): monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_enabled", True) monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_repos", "") monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True) initiate = MagicMock(return_value=(True, "ok")) monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) task_id = _make_task("deploy") stage_engine.advance_stage( task_id, "deploy", "orchestrator", "ORCH-071", "feature/ORCH-071-x", finished_agent=None, confirm_deploy=True, ) # Only the dedicated "Confirm Deploy" signal initiates the prod deploy. assert initiate.called