Files
orchestrator/tests/test_plane_status_failclosed.py
claude-bot 0dfddf93f0 feat(plane): осмысленная статусная модель Plane (слой B — индикация)
Приводит статусы доски Plane к смыслу стадий конвейера, сохраняя
инвариант «статус — индикация, а не управление». Меняется только слой B
(отображение: src/plane_sync.py + точки выставления статуса в
stage_engine.py/webhooks/plane.py/reconciler.py); слой A — машина стадий
src/stages.py::STAGE_TRANSITIONS — остаётся байт-в-байт неизменным (AC-21).

- 6 новых логических ключей статуса (to_analyse, analysis, code_review,
  awaiting_deploy, deploying, monitoring) + сеттеры и диспетчер
  set_issue_stage_state.
- Project-relative alias-fallback (BR-12): новый ключ деградирует на
  базовый UUID того же проекта → нулевая регрессия для enduro-trails.
- Самодеплой (ORCH-036) индицирует фазы: Awaiting Deploy / Deploying;
  terminal-sync для self-hosting → Monitoring after Deploy, для прочих →
  терминальный Done.
- Post-deploy монитор (ORCH-021): HEALTHY → Done, DEGRADED → Blocked
  (только индикация; self-hosting ALERT_ONLY, прод не трогается, BR-5).
- Reconciler: триггер старта/резюма на To Analyse; Guard 2 учитывает
  новые активные ожидания без расширения skip-set на алиасах.
- never-raise контракт сеттеров и резолвера состояний сохранён.
- Раскатка — созданием статусов в Plane оператором, без kill-switch.

Инварианты не менялись: STAGE_TRANSITIONS, QG_CHECKS (12 чеков),
check_deploy_status, exit-код-контракт хука, merge-gate, схема БД.

ADR: docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md
Тесты: test_plane_status_model, test_plane_to_analyse_resume,
test_plane_status_failclosed + TC в существующих наборах. 774 passed.

Refs: ORCH-066

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 22:02:45 +00:00

132 lines
5.7 KiB
Python

"""ORCH-066 fail-closed (CRITICAL) — the new status model must never wedge the
pipeline when the 6 Plane statuses are absent or Plane is unreachable.
* TC-16 (AC-16, BR-12) — a project WITHOUT the new statuses resolves each new
logical key to its OWN base UUID (to_analyse=in_progress, code_review=review,
awaiting_deploy=in_review, monitoring=done); no exception.
* TC-17 (AC-16) — Plane API down -> get_project_states falls back to
_DEFAULT_STATES; every set_issue_* helper is never-raise.
* TC-18 (AC-17) — enduro In Progress STILL starts the pipeline through
the to_analyse alias (= in_progress UUID).
httpx is mocked; no network.
"""
import os
os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from unittest.mock import patch, MagicMock, AsyncMock # noqa: E402
import pytest # noqa: E402
from src import plane_sync as PS # noqa: E402
ENDURO_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
# An enduro-style states response: the 6 ORCH-066 statuses are NOT created.
_ENDURO_BASE = {
"Backlog": "backlog-u", "Todo": "todo-u", "In Progress": "ip-u",
"Review": "review-u", "In Review": "inrev-u", "Approved": "appr-u",
"Rejected": "rej-u", "Done": "done-u", "Needs Input": "ni-u",
"Blocked": "blk-u",
}
def _states_response(name_to_uuid):
return {"results": [{"id": uid, "name": name} for name, uid in name_to_uuid.items()]}
def _fake_resp(data, status=200):
m = MagicMock()
m.status_code = status
m.json.return_value = data
m.raise_for_status.return_value = None
return m
@pytest.fixture(autouse=True)
def _reset_cache():
PS.reload_project_states()
yield
PS.reload_project_states()
# ---------------------------------------------------------------------------
# TC-16 (AC-16 / BR-12): partial project -> alias to its own base UUIDs, no raise.
# ---------------------------------------------------------------------------
def test_tc16_partial_project_aliases_to_base_uuids():
with patch("src.plane_sync.httpx.get") as mock_get:
mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE))
states = PS.get_project_states(ENDURO_PROJECT_ID)
# The new keys degrade to THIS project's base UUIDs (not foreign defaults).
assert states["to_analyse"] == states["in_progress"] == "ip-u"
assert states["analysis"] == "ip-u"
assert states["code_review"] == states["review"] == "review-u"
assert states["awaiting_deploy"] == states["in_review"] == "inrev-u"
assert states["deploying"] == "ip-u"
assert states["monitoring"] == states["done"] == "done-u"
# ---------------------------------------------------------------------------
# TC-17 (AC-16): Plane API down -> _DEFAULT_STATES; set_issue_* never-raise.
# ---------------------------------------------------------------------------
def test_tc17_api_down_falls_back_to_defaults():
with patch("src.plane_sync.httpx.get", side_effect=Exception("plane down")):
states = PS.get_project_states(ENDURO_PROJECT_ID)
assert states is PS._DEFAULT_STATES
# All new keys exist in the defaults (so callers never KeyError).
for k in ("to_analyse", "analysis", "code_review", "awaiting_deploy",
"deploying", "monitoring"):
assert k in states
def test_tc17_set_issue_helpers_never_raise_when_issue_missing():
# find_issue_id returns None (issue not in Plane) -> helpers log + return,
# they must NOT raise. Covers every ORCH-066 setter.
setters = [
PS.set_issue_analysis, PS.set_issue_code_review,
PS.set_issue_awaiting_deploy, PS.set_issue_deploying,
PS.set_issue_monitoring,
]
with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \
patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \
patch("src.plane_sync.find_issue_id", return_value=None), \
patch("src.plane_sync.httpx.patch") as mock_patch:
for setter in setters:
setter("ET-1") # must not raise
# No PATCH issued because the issue could not be resolved.
mock_patch.assert_not_called()
def test_tc17_set_issue_helpers_never_raise_when_patch_errors():
# The PATCH itself blows up -> _set_issue_state_direct swallows it.
with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \
patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \
patch("src.plane_sync.find_issue_id", return_value="issue-uuid"), \
patch("src.plane_sync.httpx.patch", side_effect=Exception("boom")):
PS.set_issue_monitoring("ET-1") # must not raise
# ---------------------------------------------------------------------------
# TC-18 (AC-17): enduro In Progress still starts the pipeline via to_analyse alias.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tc18_enduro_in_progress_still_starts_via_alias():
from src.webhooks.plane import handle_issue_updated
with patch("src.plane_sync.httpx.get") as mock_get, \
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE))
# enduro never created 'To Analyse' -> to_analyse aliases In Progress (ip-u).
data = {"id": "et-issue", "state": {"id": "ip-u", "name": "In Progress"}}
await handle_issue_updated(data, ENDURO_PROJECT_ID)
mock_start.assert_called_once()
mock_verdict.assert_not_called()