Split the overloaded `Approved` Plane status: it served BOTH as the human BRD
gate on `analysis` AND as the silent Phase B prod-deploy trigger on `deploy`
(ORCH-036), so a routine approve could launch a self-hosting prod restart.
ORCH-059 introduces a dedicated logical status `confirm_deploy` ("Confirm
Deploy") that triggers ONLY Phase B on `deploy`; `Approved` stays purely a
pipeline gate.
- plane_sync: map "Confirm Deploy" -> "confirm_deploy" in _PLANE_NAME_TO_KEY;
intentionally absent from _DEFAULT_STATES => fail-closed (no UUID -> .get
yields None, no KeyError, no blind deploy).
- webhooks/plane: handle_issue_updated routes "Confirm Deploy" (fail-closed
.get) to new handle_confirm_deploy (guarded to stage=="deploy") ->
_try_advance_stage(confirm_deploy=True).
- stage_engine: advance_stage gains keyword-only confirm_deploy=False; Phase B
block returns early for deploy+finished_agent is None but only initiates the
deploy when confirm_deploy=True; a plain Approved is a deterministic no-op
(returns before check_deploy_status -> no false БАГ-8 rollback).
- Phase A CTA now asks the operator for "Confirm Deploy", not "Approved".
Contracts unchanged: STAGE_TRANSITIONS, QG_CHECKS, check_deploy_status, hook
exit codes, Phases A/C, merge-gate, DB schema. Conditional like ORCH-35/36
(self-hosting only). Docs updated (CLAUDE.md, architecture/README.md, CHANGELOG).
Refs: ORCH-059
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
121 lines
4.6 KiB
Python
121 lines
4.6 KiB
Python
"""ORCH-059 TC-01/02/03: resolver registration of the dedicated "Confirm Deploy"
|
|
status and its fail-closed absence in fallback environments.
|
|
|
|
Contract (AC-1, AC-7):
|
|
* TC-01 — _PLANE_NAME_TO_KEY maps the board name "Confirm Deploy" to the logical
|
|
key "confirm_deploy".
|
|
* TC-02 — get_project_states for an ORCH-like project (Plane API mocked to
|
|
include a "Confirm Deploy" state) returns a NON-empty uuid under
|
|
"confirm_deploy", distinct from "approved".
|
|
* TC-03 — fail-closed: when the status is absent (API fallback to
|
|
_DEFAULT_STATES / unreachable Plane), the key is simply missing and a .get
|
|
access yields None WITHOUT raising — the confirm-deploy branch never activates.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
|
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_plane_states.db")
|
|
|
|
import src.plane_sync as plane_sync # noqa: E402
|
|
from src.plane_sync import ( # noqa: E402
|
|
_PLANE_NAME_TO_KEY,
|
|
_DEFAULT_STATES,
|
|
get_project_states,
|
|
reload_project_states,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fresh_cache():
|
|
reload_project_states()
|
|
yield
|
|
reload_project_states()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-01: name -> key mapping is registered
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc01_confirm_deploy_name_to_key_mapping():
|
|
assert _PLANE_NAME_TO_KEY.get("Confirm Deploy") == "confirm_deploy"
|
|
|
|
|
|
def test_tc01_confirm_deploy_not_in_default_states():
|
|
"""Fail-closed by construction: NO fallback UUID exists for confirm_deploy, so
|
|
enduro / API-fallback environments never resolve a (wrong) deploy trigger."""
|
|
assert "confirm_deploy" not in _DEFAULT_STATES
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-02: live API resolves a real, distinct uuid for an ORCH-like project
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc02_get_project_states_resolves_confirm_deploy(monkeypatch):
|
|
confirm_uuid = "cfd00000-0000-0000-0000-000000000059"
|
|
approved_uuid = "a519a341-dada-4a91-8910-7604f82b79c5"
|
|
|
|
class _Resp:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {
|
|
"results": [
|
|
{"name": "In Progress", "id": "b873d9eb-993c-48cd-97ac-99a9b1623967"},
|
|
{"name": "Approved", "id": approved_uuid},
|
|
{"name": "Confirm Deploy", "id": confirm_uuid},
|
|
]
|
|
}
|
|
|
|
monkeypatch.setattr(plane_sync.httpx, "get", lambda *a, **k: _Resp())
|
|
|
|
states = get_project_states("orch-project-uuid")
|
|
assert states.get("confirm_deploy") == confirm_uuid
|
|
# Distinct gestures: confirm-deploy must NOT alias the human "Approved" gate.
|
|
assert states["confirm_deploy"] != states["approved"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-03: fail-closed when the status is absent (API fallback / unreachable)
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc03_fail_closed_when_api_unreachable(monkeypatch):
|
|
"""A Plane outage -> get_project_states falls back to _DEFAULT_STATES, which
|
|
has no confirm_deploy key. .get must yield None, never raise."""
|
|
|
|
def _boom(*a, **k):
|
|
raise RuntimeError("plane down")
|
|
|
|
monkeypatch.setattr(plane_sync.httpx, "get", _boom)
|
|
|
|
states = get_project_states("any-project-uuid")
|
|
# No KeyError, branch never activates.
|
|
assert states.get("confirm_deploy") is None
|
|
# The human gate "Approved" still resolves (fallback is intact).
|
|
assert states.get("approved") == _DEFAULT_STATES["approved"]
|
|
|
|
|
|
def test_tc03_fail_closed_when_status_not_on_board(monkeypatch):
|
|
"""Project whose board lacks "Confirm Deploy": the key is filled by NEITHER the
|
|
API loop NOR the _DEFAULT_STATES backfill -> absent -> fail-closed."""
|
|
|
|
class _Resp:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {
|
|
"results": [
|
|
{"name": "In Progress", "id": "b873d9eb-993c-48cd-97ac-99a9b1623967"},
|
|
{"name": "Approved", "id": "a519a341-dada-4a91-8910-7604f82b79c5"},
|
|
]
|
|
}
|
|
|
|
monkeypatch.setattr(plane_sync.httpx, "get", lambda *a, **k: _Resp())
|
|
|
|
states = get_project_states("board-without-confirm")
|
|
assert states.get("confirm_deploy") is None
|
|
assert states.get("approved") == "a519a341-dada-4a91-8910-7604f82b79c5"
|