"""ORCH-089 — autoDeploy врезка in _handle_self_deploy_phase_a. Covers (04-test-plan.yaml): TC-15 autoDeploy + Phase A advance on `deploy` -> Phase B (initiate_deploy) is auto-invoked. TC-16 no autoDeploy label -> prior Phase A: Awaiting Deploy, wait for Confirm Deploy. TC-17 idempotent: INITIATED marker already present -> repeat auto-trigger no-op. TC-18 non-self repo / out of scope -> no auto (Phase A/B only for self-hosting). TC-19 autoDeploy logged + Telegram + Plane comment (transparency AC-7). """ import os import tempfile import pytest _test_db = os.path.join(tempfile.gettempdir(), "test_auto_deploy.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 from src import labels # noqa: E402 from src.stage_engine import advance_stage # noqa: E402 def _pass(*a, **k): return (True, "ok") @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.settings, "deploy_require_manual_approve", True) # Pass all edge sub-gates so the deploy-staging -> deploy edge reaches Phase A. monkeypatch.setattr( stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, "check_branch_mergeable": _pass, "check_security_gate": _pass, "check_staging_image_fresh": _pass}, ) # Default auto-mode flags ON (overridden per-test). monkeypatch.setattr(stage_engine.settings, "auto_label_enabled", True, raising=False) monkeypatch.setattr(stage_engine.settings, "auto_deploy_label", "autoDeploy", raising=False) yield @pytest.fixture(autouse=True) def silence(monkeypatch): for name in ("notify_stage_change", "notify_qg_failure", "plane_notify_stage", "plane_notify_qg", "set_issue_in_review", "set_issue_awaiting_deploy", "set_issue_deploying"): monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False) monkeypatch.setattr(stage_engine, "plane_add_comment", MagicMock()) monkeypatch.setattr(stage_engine, "send_telegram", MagicMock()) def _make_task(stage="deploy-staging", repo="orchestrator", branch="feature/ORCH-089-x", wi="ORCH-089"): 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), ) tid = cur.lastrowid conn.commit() conn.close() return tid def _label(monkeypatch, present=True, applies=True): monkeypatch.setattr(labels, "auto_deploy_applies", lambda repo: applies) monkeypatch.setattr(labels, "has_label", lambda w, name, p=None: present) def _advance(tid, repo="orchestrator", wi="ORCH-089"): return advance_stage(tid, "deploy-staging", repo, wi, "feature/ORCH-089-x", finished_agent="deployer") # --- TC-15 ----------------------------------------------------------------- def test_tc15_auto_deploy_initiates_phase_b(monkeypatch): _label(monkeypatch, present=True) initiate = MagicMock(return_value=(True, "ok")) monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) tid = _make_task() res = _advance(tid) # Phase B ran via the same path a human Confirm Deploy takes. initiate.assert_called_once() assert res.note == "self-deploy-initiated" assert self_deploy.has_marker("orchestrator", "ORCH-089", self_deploy.INITIATED) # APPROVE_REQUESTED (the human ask) was SKIPPED on the auto path. assert not self_deploy.has_marker( "orchestrator", "ORCH-089", self_deploy.APPROVE_REQUESTED ) # --- TC-16 ----------------------------------------------------------------- def test_tc16_no_label_waits_for_human(monkeypatch): _label(monkeypatch, present=False) initiate = MagicMock(return_value=(True, "ok")) monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) tid = _make_task() res = _advance(tid) # Prior Phase A behaviour: approval-pending, no deploy initiated. assert res.note == "self-deploy-approval-pending" initiate.assert_not_called() assert self_deploy.has_marker( "orchestrator", "ORCH-089", self_deploy.APPROVE_REQUESTED ) stage_engine.set_issue_awaiting_deploy.assert_called_once() # --- TC-17: idempotency ---------------------------------------------------- def test_tc17_idempotent_initiated_marker(monkeypatch): """autoDeploy delegates prod-deploy to _handle_self_deploy_phase_b, whose INITIATED marker makes a repeat a no-op. Phase A always clears stale state first (ADR D4), so the guard that protects against a double prod deploy is the INITIATED marker WRITTEN by Phase B — verify the auto path initiates exactly once and a subsequent Phase B re-entry (duplicate confirm / reaper re-drive) is a no-op.""" _label(monkeypatch, present=True) initiate = MagicMock(return_value=(True, "ok")) monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) tid = _make_task() res = _advance(tid) assert res.note == "self-deploy-initiated" assert initiate.call_count == 1 assert self_deploy.has_marker("orchestrator", "ORCH-089", self_deploy.INITIATED) # A repeat Phase B (e.g. duplicate Confirm Deploy webhook / reaper re-drive) # with INITIATED already set is a no-op — no second prod deploy. res2 = stage_engine._handle_self_deploy_phase_b( tid, "orchestrator", "ORCH-089", "feature/ORCH-089-x", stage_engine.AdvanceResult(from_stage="deploy"), ) assert initiate.call_count == 1 # still exactly one # --- TC-18: non-self / out of scope ---------------------------------------- def test_tc18_non_self_repo_no_phase_a(monkeypatch): # For a non-self repo Phase A is not reached at all (self_deploy_applies False), # so autoDeploy is a structural no-op. The edge advances normally to `deploy`. _label(monkeypatch, present=True, applies=False) initiate = MagicMock(return_value=(True, "ok")) monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) tid = _make_task(repo="enduro-trails", wi="ET-1") res = advance_stage(tid, "deploy-staging", "enduro-trails", "ET-1", "feature/ORCH-089-x", finished_agent="deployer") initiate.assert_not_called() # No Phase A / Phase B for non-self repo. assert res.note != "self-deploy-initiated" # --- TC-19: transparency --------------------------------------------------- def test_tc19_transparency_channels(monkeypatch, caplog): _label(monkeypatch, present=True) monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", MagicMock(return_value=(True, "ok"))) tid = _make_task() import logging with caplog.at_level(logging.INFO, logger="orchestrator.stage_engine"): _advance(tid) assert any("auto-confirmed" in r.message.lower() or "autoDeploy" in r.message for r in caplog.records) assert stage_engine.send_telegram.called comment_calls = [c for c in stage_engine.plane_add_comment.call_args_list if "авто-подтверждён" in c.args[1]] assert comment_calls, "expected an auto-deploy Plane comment"