Приводит статусы доски 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>
132 lines
5.7 KiB
Python
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()
|