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>
This commit is contained in:
@@ -48,6 +48,9 @@ def silence_side_effects(monkeypatch):
|
||||
"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",
|
||||
# ORCH-066 status setters.
|
||||
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
|
||||
"set_issue_monitoring",
|
||||
):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
|
||||
@@ -127,6 +130,9 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
|
||||
assert _jobs() == []
|
||||
# The restart-safe approve-requested marker was written.
|
||||
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED)
|
||||
# ORCH-066 AC-6/AC-13: Phase A indicates `Awaiting Deploy`, NOT `In Review`.
|
||||
stage_engine.set_issue_awaiting_deploy.assert_called_once_with("ORCH-036")
|
||||
stage_engine.set_issue_in_review.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -151,6 +157,8 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
|
||||
# The finalizer was enqueued.
|
||||
assert any(j["agent"] == "deploy-finalizer" for j in _jobs())
|
||||
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED)
|
||||
# ORCH-066 AC-7: Phase B indicates `Deploying` on a successful initiate.
|
||||
stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036")
|
||||
|
||||
# 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again.
|
||||
res2 = advance_stage(
|
||||
|
||||
@@ -45,6 +45,9 @@ def silence_side_effects(monkeypatch):
|
||||
"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",
|
||||
# ORCH-066 status setters.
|
||||
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
|
||||
"set_issue_monitoring",
|
||||
):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
|
||||
@@ -106,3 +109,56 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch):
|
||||
release.assert_called_once_with("orchestrator", "feature/ORCH-036-x")
|
||||
# No agent is launched leaving deploy (terminal).
|
||||
assert _jobs() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-08 (AC-8): self-hosting deploy->done -> Monitoring after Deploy,
|
||||
# NOT terminal Done. The post-deploy monitor finalises.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08_self_deploy_done_sets_monitoring_not_done(monkeypatch):
|
||||
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "0")
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
|
||||
# post_deploy applies for the self-hosting repo with the monitor enabled.
|
||||
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "")
|
||||
# arm_monitor is orthogonal; stub it so this test stays on the status contract.
|
||||
monkeypatch.setattr(stage_engine.post_deploy, "arm_monitor", MagicMock(return_value=True))
|
||||
|
||||
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"
|
||||
# Self-hosting: the issue enters the Monitoring window, NOT terminal Done yet.
|
||||
stage_engine.set_issue_monitoring.assert_called_once_with("ORCH-036")
|
||||
stage_engine.set_issue_done.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-09 (AC-9): non-self repo deploy->done -> terminal Done (no regress).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc09_non_self_deploy_done_sets_done(monkeypatch):
|
||||
self_deploy.write_marker("enduro-trails", "ET-042", self_deploy.RESULT, "0")
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
|
||||
# Monitor enabled, but the empty CSV means it applies ONLY to the self repo;
|
||||
# a non-self repo therefore takes the unchanged terminal-Done path.
|
||||
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "")
|
||||
|
||||
task_id = _make_task("deploy", repo="enduro-trails", branch="feature/ET-042-x", wi="ET-042")
|
||||
stage_engine.run_deploy_finalizer(
|
||||
{"task_id": task_id, "repo": "enduro-trails", "id": 1, "agent": "deploy-finalizer"}
|
||||
)
|
||||
|
||||
assert _stage(task_id) == "done"
|
||||
stage_engine.set_issue_done.assert_called_once_with("ET-042")
|
||||
stage_engine.set_issue_monitoring.assert_not_called()
|
||||
|
||||
@@ -40,11 +40,15 @@ ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||
_PROJECT_STATES = {
|
||||
ENDURO_PLANE_ID: {
|
||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
# ORCH-066: To Analyse is the start trigger; with the status absent it
|
||||
# aliases to in_progress (the real get_project_states fallback).
|
||||
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
},
|
||||
ORCH_PLANE_ID: {
|
||||
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||
"to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
|
||||
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
|
||||
},
|
||||
|
||||
@@ -460,3 +460,59 @@ def test_default_states_et_values():
|
||||
assert ps._DEFAULT_STATES[key] == expected, (
|
||||
f"_DEFAULT_STATES['{key}']: expected {expected}, got {ps._DEFAULT_STATES.get(key)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-19 (AC-18): resolve-by-name — when a project DEFINES one of the
|
||||
# new statuses, get_project_states must use its OWN UUID, not the default alias.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_orch066_tc19_name_resolution_beats_alias():
|
||||
"""A project that created 'Analysis' / 'Code-Review' / 'Awaiting Deploy' /
|
||||
'Deploying' / 'Monitoring after Deploy' resolves each to its own project
|
||||
UUID (via _PLANE_NAME_TO_KEY), NOT the aliased base-key UUID."""
|
||||
import src.plane_sync as ps
|
||||
|
||||
new_uuids = {
|
||||
"Analysis": "11111111-0000-0000-0000-000000000001",
|
||||
"Code-Review": "11111111-0000-0000-0000-000000000002",
|
||||
"Awaiting Deploy": "11111111-0000-0000-0000-000000000003",
|
||||
"Deploying": "11111111-0000-0000-0000-000000000004",
|
||||
"Monitoring after Deploy": "11111111-0000-0000-0000-000000000005",
|
||||
"To Analyse": "11111111-0000-0000-0000-000000000006",
|
||||
}
|
||||
# Start from the full ORCH base set, then add the dedicated new statuses.
|
||||
results = _make_states_response(ORCH_STATES)["results"]
|
||||
results += [{"id": uid, "name": name} for name, uid in new_uuids.items()]
|
||||
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response({"results": results})
|
||||
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||
|
||||
# Each new key resolved to the project's OWN UUID, not the base-key alias.
|
||||
assert states["analysis"] == new_uuids["Analysis"]
|
||||
assert states["code_review"] == new_uuids["Code-Review"]
|
||||
assert states["awaiting_deploy"] == new_uuids["Awaiting Deploy"]
|
||||
assert states["deploying"] == new_uuids["Deploying"]
|
||||
assert states["monitoring"] == new_uuids["Monitoring after Deploy"]
|
||||
assert states["to_analyse"] == new_uuids["To Analyse"]
|
||||
# Sanity: they are NOT the aliased base UUIDs.
|
||||
assert states["analysis"] != states["in_progress"]
|
||||
assert states["code_review"] != states["review"]
|
||||
assert states["awaiting_deploy"] != states["in_review"]
|
||||
|
||||
|
||||
def test_orch066_tc19_missing_new_status_aliases_to_project_base():
|
||||
"""BR-12: a project WITHOUT the new statuses degrades each new key to its OWN
|
||||
base UUID (not a foreign enduro UUID) — keeping the PATCH state valid."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
|
||||
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||
|
||||
# No dedicated new statuses -> alias to THIS project's base UUIDs.
|
||||
assert states["analysis"] == ORCH_STATES["in_progress"]
|
||||
assert states["to_analyse"] == ORCH_STATES["in_progress"]
|
||||
assert states["code_review"] == ORCH_STATES["review"]
|
||||
assert states["awaiting_deploy"] == ORCH_STATES["in_review"]
|
||||
assert states["deploying"] == ORCH_STATES["in_progress"]
|
||||
assert states["monitoring"] == ORCH_STATES["done"]
|
||||
|
||||
131
tests/test_plane_status_failclosed.py
Normal file
131
tests/test_plane_status_failclosed.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""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()
|
||||
151
tests/test_plane_status_model.py
Normal file
151
tests/test_plane_status_model.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""ORCH-066: the meaningful Plane status model (layer B) — unit coverage.
|
||||
|
||||
These tests pin the layer-B behaviour WITHOUT touching layer A (the stage
|
||||
machine). httpx is mocked; no network.
|
||||
|
||||
* TC-03 (AC-3) — the analyst start/resume indicates `Analysis`, not In Progress.
|
||||
* TC-05 (AC-5) — entering the `review` stage indicates `Code-Review`.
|
||||
* TC-14 (AC-14) — set_issue_needs_input is unchanged (still PATCHes Needs Input).
|
||||
* TC-22 (AC-21) — STAGE_TRANSITIONS (layer A) is byte-identical (explicit pin).
|
||||
* TC-23 (AC-22) — QG_CHECKS registry + check_deploy_status contract unchanged.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
from unittest.mock import patch, MagicMock # noqa: E402
|
||||
|
||||
from src import plane_sync as PS # noqa: E402
|
||||
|
||||
|
||||
# A per-project state map that DEFINES the new ORCH-066 statuses with distinct
|
||||
# UUIDs, so we can prove the dedicated status (not the base alias) is used.
|
||||
_STATES_WITH_NEW = {
|
||||
"in_progress": "ip-uuid",
|
||||
"review": "review-uuid",
|
||||
"in_review": "inrev-uuid",
|
||||
"needs_input": "ni-uuid",
|
||||
"done": "done-uuid",
|
||||
"analysis": "analysis-uuid",
|
||||
"code_review": "codereview-uuid",
|
||||
"awaiting_deploy": "awaiting-uuid",
|
||||
"deploying": "deploying-uuid",
|
||||
"monitoring": "monitoring-uuid",
|
||||
}
|
||||
|
||||
|
||||
def _patch_resolve(states):
|
||||
"""Patch find_issue_id + _resolve_project_id + get_project_states so a
|
||||
set_issue_* helper reaches the PATCH with a known per-project state map."""
|
||||
return (
|
||||
patch("src.plane_sync.httpx.patch"),
|
||||
patch("src.plane_sync.find_issue_id", return_value="issue-uuid"),
|
||||
patch("src.plane_sync._resolve_project_id", return_value="proj-1"),
|
||||
patch("src.plane_sync.get_project_states", return_value=states),
|
||||
)
|
||||
|
||||
|
||||
def _run_setter(setter, states):
|
||||
p_patch, p_find, p_res, p_states = _patch_resolve(states)
|
||||
with p_patch as mock_patch, p_find, p_res, p_states:
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.return_value = None
|
||||
mock_patch.return_value = resp
|
||||
setter("ET-1")
|
||||
return mock_patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03 (AC-3): analyst start/resume indicates Analysis.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc03_set_issue_analysis_patches_analysis_uuid():
|
||||
mock_patch = _run_setter(PS.set_issue_analysis, _STATES_WITH_NEW)
|
||||
# The dedicated Analysis UUID is used (NOT the in_progress base alias).
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == "analysis-uuid"
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] != _STATES_WITH_NEW["in_progress"]
|
||||
|
||||
|
||||
def test_tc03_analysis_aliases_in_progress_when_absent():
|
||||
# A project without the Analysis status -> get_project_states already aliased
|
||||
# 'analysis' onto its in_progress UUID, so the PATCH degrades gracefully.
|
||||
aliased = dict(_STATES_WITH_NEW)
|
||||
aliased["analysis"] = aliased["in_progress"]
|
||||
mock_patch = _run_setter(PS.set_issue_analysis, aliased)
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == aliased["in_progress"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05 (AC-5): the review stage indicates Code-Review.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc05_review_stage_maps_to_code_review():
|
||||
# Both the stage->state-key map and the stage-visibility map point review at
|
||||
# the new code_review logical key (layer B only).
|
||||
assert PS._STAGE_TO_STATE_KEY["review"] == "code_review"
|
||||
assert PS.STAGE_VISIBILITY_STATE["review"] == "code_review"
|
||||
|
||||
|
||||
def test_tc05_set_issue_stage_state_review_patches_code_review_uuid():
|
||||
p_patch, p_find, p_res, p_states = _patch_resolve(_STATES_WITH_NEW)
|
||||
with p_patch as mock_patch, p_find, p_res, p_states:
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.return_value = None
|
||||
mock_patch.return_value = resp
|
||||
PS.set_issue_stage_state("ET-1", "review")
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid"
|
||||
|
||||
|
||||
def test_tc05_set_issue_code_review_helper_patches_code_review_uuid():
|
||||
mock_patch = _run_setter(PS.set_issue_code_review, _STATES_WITH_NEW)
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14 (AC-14): Needs Input behaviour unchanged.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_needs_input_unchanged():
|
||||
mock_patch = _run_setter(PS.set_issue_needs_input, _STATES_WITH_NEW)
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == "ni-uuid"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-22 (AC-21): STAGE_TRANSITIONS (layer A) is byte-identical. ORCH-066 changes
|
||||
# ONLY layer B — the machine must not move.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc22_stage_transitions_unchanged():
|
||||
from src.stages import STAGE_TRANSITIONS
|
||||
assert STAGE_TRANSITIONS == {
|
||||
"created": {"next": "analysis", "agent": "analyst", "qg": None},
|
||||
"analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"},
|
||||
"architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"},
|
||||
"development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"},
|
||||
"review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"},
|
||||
"testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"},
|
||||
"deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
|
||||
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
|
||||
"done": {"next": None, "agent": None, "qg": None},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-23 (AC-22): QG_CHECKS registry + check_deploy_status contract unchanged.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc23_qg_checks_registry_unchanged():
|
||||
from src.qg.checks import QG_CHECKS
|
||||
assert set(QG_CHECKS.keys()) == {
|
||||
"check_analysis_approved", "check_analysis_complete", "check_architecture_done",
|
||||
"check_ci_green", "check_review_approved", "check_tests_passed",
|
||||
"check_reviewer_verdict", "check_tests_local", "check_deploy_status",
|
||||
"check_staging_status", "check_branch_mergeable", "check_staging_image_fresh",
|
||||
}
|
||||
|
||||
|
||||
def test_tc23_check_deploy_status_signature_unchanged():
|
||||
import inspect
|
||||
from src.qg.checks import check_deploy_status, QG_CHECKS
|
||||
# Registry still points at the same callable.
|
||||
assert QG_CHECKS["check_deploy_status"] is check_deploy_status
|
||||
# (repo, work_item_id, branch=None) -> tuple[bool, str] contract intact.
|
||||
params = list(inspect.signature(check_deploy_status).parameters)
|
||||
assert params == ["repo", "work_item_id", "branch"]
|
||||
114
tests/test_plane_to_analyse_resume.py
Normal file
114
tests/test_plane_to_analyse_resume.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""ORCH-066: To Analyse resume semantics (F-1 status-only model).
|
||||
|
||||
`handle_status_start` forks on (existing task?) + (active job?):
|
||||
|
||||
* TC-02 (AC-2, BR-11) — an EXISTING task with NO active job + To Analyse ->
|
||||
RELAUNCH the current stage's agent (the analyst resumes from Needs Input);
|
||||
NO second task is created; the issue is re-indicated `Analysis`.
|
||||
* TC-04 (AC-4) — an EXISTING task WITH an active job + To Analyse ->
|
||||
busy-guard: NO relaunch (no double launch).
|
||||
|
||||
handle_status_start is exercised directly; enqueue_job + Plane side-effects are
|
||||
mocked. A real isolated sqlite DB backs get_task_by_plane_id / the job guard.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch066_to_analyse_resume.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 patch, AsyncMock, MagicMock # noqa: E402
|
||||
|
||||
import src.db as _db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src.webhooks.plane import handle_status_start # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
def _make_task(plane_id="resume-1", stage="analysis", repo="enduro-trails",
|
||||
branch="feature/ET-001-x", wi="ET-001"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(plane_id, wi, repo, branch, stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _count(plane_id):
|
||||
conn = get_db()
|
||||
n = conn.execute("SELECT COUNT(*) FROM tasks WHERE plane_id=?", (plane_id,)).fetchone()[0]
|
||||
conn.close()
|
||||
return n
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02 (AC-2 / BR-11): existing task, no active job -> RELAUNCH (resume), no dup.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_tc02_to_analyse_resume_relaunches_analyst_no_duplicate():
|
||||
_make_task("resume-1", stage="analysis")
|
||||
data = {"id": "resume-1", "state": {"id": "ip-uuid", "name": "To Analyse"}}
|
||||
|
||||
with patch("src.webhooks.plane.enqueue_job", return_value=7) as mock_enqueue, \
|
||||
patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \
|
||||
patch("src.plane_sync.add_comment", MagicMock()), \
|
||||
patch("src.plane_sync.set_issue_analysis") as mock_analysis:
|
||||
await handle_status_start(data, "proj-1")
|
||||
|
||||
# No new pipeline start (it is a resume, not a fresh task).
|
||||
mock_start.assert_not_called()
|
||||
assert _count("resume-1") == 1 # NO duplicate task
|
||||
# The current stage's agent (analyst) was relaunched exactly once.
|
||||
assert mock_enqueue.call_count == 1
|
||||
assert mock_enqueue.call_args.args[0] == "analyst"
|
||||
# AC-3: the resumed analysis stage is re-indicated as Analysis.
|
||||
mock_analysis.assert_called_once_with("ET-001")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 (AC-4): existing task WITH active job -> busy-guard, NO relaunch.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_tc04_to_analyse_with_active_job_does_not_relaunch():
|
||||
tid = _make_task("resume-2", stage="analysis")
|
||||
# Seed an active (queued) job so has_active_job_for_task reports busy.
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (agent, repo, task_id, status) VALUES (?, ?, ?, 'queued')",
|
||||
("analyst", "enduro-trails", tid),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
data = {"id": "resume-2", "state": {"id": "ip-uuid", "name": "To Analyse"}}
|
||||
with patch("src.webhooks.plane.enqueue_job", return_value=9) as mock_enqueue, \
|
||||
patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \
|
||||
patch("src.plane_sync.add_comment", MagicMock()), \
|
||||
patch("src.plane_sync.set_issue_analysis") as mock_analysis:
|
||||
await handle_status_start(data, "proj-1")
|
||||
|
||||
mock_start.assert_not_called()
|
||||
mock_enqueue.assert_not_called() # busy-guard held: NO double launch
|
||||
mock_analysis.assert_not_called()
|
||||
assert _count("resume-2") == 1
|
||||
@@ -47,13 +47,18 @@ UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000"
|
||||
_PROJECT_STATES = {
|
||||
ENDURO_PLANE_ID: {
|
||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
# ORCH-066: To Analyse is the start trigger; absent -> aliases in_progress.
|
||||
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
|
||||
},
|
||||
ORCH_PLANE_ID: {
|
||||
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||
"to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
|
||||
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
|
||||
"cancelled": "59d1d210-8e3a-4a83-930a-cbc5dbf6ad85",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -219,3 +224,38 @@ def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher)
|
||||
assert rows["o1"] == "ORCH-001"
|
||||
assert rows["o2"] == "ORCH-002"
|
||||
assert rows["e1"] == "ET-001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-15 (AC-15): Cancelled is a valid human exit — the orchestrator
|
||||
# performs NO advance/rollback (indication, not control).
|
||||
# ---------------------------------------------------------------------------
|
||||
@patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane.launcher")
|
||||
def test_cancelled_state_does_no_pipeline_action(mock_launcher, mock_start, mock_verdict):
|
||||
cancelled = _PROJECT_STATES[ORCH_PLANE_ID]["cancelled"]
|
||||
resp = client.post(
|
||||
"/webhook/plane",
|
||||
json={
|
||||
"event": "issue",
|
||||
"action": "updated",
|
||||
"data": {
|
||||
"id": "cancel-1",
|
||||
"name": "A cancelled work item",
|
||||
"description_stripped": "This is a sufficiently long description.",
|
||||
"project": ORCH_PLANE_ID,
|
||||
"state": {"id": cancelled, "name": "Cancelled", "group": "cancelled"},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Neither the start nor the verdict (advance/rollback) handler ran.
|
||||
mock_start.assert_not_called()
|
||||
mock_verdict.assert_not_called()
|
||||
mock_launcher.launch.assert_not_called()
|
||||
# No task created off a Cancelled transition.
|
||||
conn = get_db()
|
||||
task = conn.execute("SELECT * FROM tasks WHERE plane_id='cancel-1'").fetchone()
|
||||
conn.close()
|
||||
assert task is None
|
||||
|
||||
@@ -47,6 +47,9 @@ def silence_side_effects(monkeypatch):
|
||||
"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",
|
||||
# ORCH-066 status setters.
|
||||
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
|
||||
"set_issue_monitoring",
|
||||
):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
|
||||
@@ -242,6 +245,81 @@ def test_finished_window_tick_is_noop(monkeypatch):
|
||||
probe.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-10 (AC-10): HEALTHY + window exhausted -> Plane state Done.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_orch066_tc10_clean_window_close_sets_done(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=1
|
||||
monkeypatch.setattr(
|
||||
post_deploy, "probe_signals",
|
||||
lambda url: post_deploy.ProbeResult(True, 2, 0, "ok"),
|
||||
)
|
||||
task_id = _make_task("done")
|
||||
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
|
||||
stage_engine.run_post_deploy_monitor(
|
||||
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
|
||||
)
|
||||
# Clean window close -> terminal Done indicated on Plane; window marked done.
|
||||
stage_engine.set_issue_done.assert_called_once_with("ORCH-021")
|
||||
stage_engine.set_issue_blocked.assert_not_called()
|
||||
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE)
|
||||
# No follow-up tick once the window closed.
|
||||
assert _jobs("post-deploy-monitor") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-11 (AC-11): DEGRADED -> Plane state Blocked (self-hosting alert).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_orch066_tc11_degraded_sets_blocked(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30)
|
||||
monkeypatch.setattr(
|
||||
post_deploy, "probe_signals",
|
||||
lambda url: post_deploy.ProbeResult(False, 2, 2, "down"),
|
||||
)
|
||||
monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock())
|
||||
task_id = _make_task("done")
|
||||
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
|
||||
stage_engine.run_post_deploy_monitor(
|
||||
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
|
||||
)
|
||||
# DEGRADED -> Blocked indication (NOT Done); window finalised.
|
||||
stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021")
|
||||
stage_engine.set_issue_done.assert_not_called()
|
||||
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-12 (AC-12): a self-hosting tick NEVER restarts/rolls back prod —
|
||||
# the Blocked indication is the ONLY mutation (ORCH-021 BR-5 preserved).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_orch066_tc12_self_tick_never_restarts_prod(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30)
|
||||
monkeypatch.setattr(
|
||||
post_deploy, "probe_signals",
|
||||
lambda url: post_deploy.ProbeResult(False, 2, 2, "down"),
|
||||
)
|
||||
monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock())
|
||||
# The rollback hook (the only restart-capable path) MUST stay untouched for self.
|
||||
rollback = MagicMock(return_value=(0, "ok"))
|
||||
monkeypatch.setattr(post_deploy, "run_rollback", rollback)
|
||||
task_id = _make_task("done")
|
||||
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
|
||||
stage_engine.run_post_deploy_monitor(
|
||||
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
|
||||
)
|
||||
rollback.assert_not_called() # never restarts/rolls back the prod self-container
|
||||
stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021") # indication only
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-20 — /queue observability block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -572,7 +572,7 @@ def test_tc060_08_no_gate_call_on_escalated(monkeypatch):
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_09_f2_does_not_replay_blocked(monkeypatch):
|
||||
states = {
|
||||
"in_progress": "IP", "approved": "AP", "rejected": "RJ",
|
||||
"in_progress": "IP", "to_analyse": "IP", "approved": "AP", "rejected": "RJ",
|
||||
"blocked": "BL", "needs_input": "NI",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
@@ -680,3 +680,67 @@ def test_tc060_subflag_disables_only_guard2(monkeypatch):
|
||||
|
||||
assert _stage_of(blocked) == "review" # Guard 2 muted
|
||||
assert _stage_of(escalated) == "development" # Guard 1 still skips
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-21 (AC-20 / BR-13): Guard 2 skips the active orchestrator waits
|
||||
# (Awaiting Deploy / Deploying / Monitoring after Deploy) ONLY when they are
|
||||
# DISTINCT statuses — an aliased (enduro) project must NOT widen the skip-set.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _guard2(monkeypatch, states, cur_state):
|
||||
"""Drive _is_blocked_or_needs_input with a chosen project state map + the
|
||||
issue's current Plane state uuid."""
|
||||
monkeypatch.setattr(reconciler_mod, "get_project_states",
|
||||
MagicMock(return_value=states))
|
||||
monkeypatch.setattr(reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(return_value=cur_state))
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod.projects, "get_project_by_repo",
|
||||
MagicMock(return_value=MagicMock(plane_project_id="proj-test")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod.settings, "reconcile_skip_blocked_enabled", True
|
||||
)
|
||||
task = {"id": 1, "repo": "orchestrator", "plane_id": "iss-1"}
|
||||
return Reconciler()._is_blocked_or_needs_input(task)
|
||||
|
||||
|
||||
# orchestrator has the three new statuses as DISTINCT UUIDs.
|
||||
_DISTINCT_STATES = {
|
||||
"backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u",
|
||||
"review": "rev-u", "architecture": "arch-u", "development": "dev-u",
|
||||
"testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u",
|
||||
"blocked": "blocked-u", "needs_input": "ni-u",
|
||||
"awaiting_deploy": "await-u", "deploying": "deploying-u", "monitoring": "monitor-u",
|
||||
}
|
||||
|
||||
|
||||
def test_tc21_guard2_skips_distinct_active_waits(monkeypatch):
|
||||
# Each active-wait status (distinct UUID) -> skipped (not revived).
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "await-u") is True
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "deploying-u") is True
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "monitor-u") is True
|
||||
# Explicit human gates still skip.
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "blocked-u") is True
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "ni-u") is True
|
||||
# A normal working state is NOT skipped (gets reconciled).
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "ip-u") is False
|
||||
|
||||
|
||||
def test_tc21_guard2_aliased_waits_do_not_widen_skipset(monkeypatch):
|
||||
# enduro: the new keys alias onto base working statuses -> they must NOT make
|
||||
# F-1 skip a genuinely In Progress / In Review / Done task (anti-regress).
|
||||
aliased = {
|
||||
"backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u",
|
||||
"review": "rev-u", "architecture": "arch-u", "development": "dev-u",
|
||||
"testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u",
|
||||
"blocked": "blocked-u", "needs_input": "ni-u",
|
||||
# aliased onto base UUIDs (project did not create dedicated statuses).
|
||||
"awaiting_deploy": "inrev-u", "deploying": "ip-u", "monitoring": "done-u",
|
||||
}
|
||||
# In Progress / In Review / Done are base working states -> NOT skipped.
|
||||
assert _guard2(monkeypatch, aliased, "ip-u") is False
|
||||
assert _guard2(monkeypatch, aliased, "inrev-u") is False
|
||||
assert _guard2(monkeypatch, aliased, "done-u") is False
|
||||
# The explicit human gates still skip.
|
||||
assert _guard2(monkeypatch, aliased, "blocked-u") is True
|
||||
|
||||
@@ -59,6 +59,9 @@ def single_project(monkeypatch):
|
||||
reconciler_mod, "get_project_states",
|
||||
lambda pid: {
|
||||
"in_progress": _IN_PROGRESS,
|
||||
# ORCH-066: To Analyse is the F-2 start/resume trigger; absent in this
|
||||
# project -> aliases in_progress (real get_project_states fallback).
|
||||
"to_analyse": _IN_PROGRESS,
|
||||
"approved": _APPROVED,
|
||||
"rejected": _REJECTED,
|
||||
},
|
||||
@@ -114,6 +117,46 @@ def test_tc11_in_progress_without_task_starts_pipeline(monkeypatch, single_proje
|
||||
verdict.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-20 (AC-19): F-2 polls the DISTINCT To Analyse status and routes it
|
||||
# to handle_status_start (a lost start/resume webhook is recovered).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc20_distinct_to_analyse_polled_and_routed(monkeypatch):
|
||||
_TO_ANALYSE = "uuid-to-analyse" # distinct from in_progress
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "get_project_states",
|
||||
lambda pid: {
|
||||
"in_progress": _IN_PROGRESS,
|
||||
"to_analyse": _TO_ANALYSE, # dedicated status created
|
||||
"approved": _APPROVED,
|
||||
"rejected": _REJECTED,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod.projects, "PROJECTS",
|
||||
[SimpleNamespace(plane_project_id="proj-1", repo="enduro-trails",
|
||||
work_item_prefix="ET")],
|
||||
)
|
||||
start, verdict = _patch_handlers(monkeypatch)
|
||||
|
||||
polled = {}
|
||||
|
||||
def fake_list(pid, states):
|
||||
polled["states"] = list(states)
|
||||
return [{"id": "iss-ta", "state": {"id": _TO_ANALYSE}, "updated_at": _OLD_TS,
|
||||
"name": "Lost start"}]
|
||||
|
||||
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_list)
|
||||
|
||||
Reconciler().reconcile_plane_once()
|
||||
|
||||
# The To Analyse UUID is in the polled set and routed to start (not verdict).
|
||||
assert _TO_ANALYSE in polled["states"]
|
||||
assert start.call_count == 1
|
||||
assert start.call_args.args[0]["id"] == "iss-ta"
|
||||
verdict.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-12: Approved with an existing task, no active job -> handle_verdict(True).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -279,7 +322,10 @@ def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch):
|
||||
|
||||
def fake_states(pid):
|
||||
states_calls.append(pid)
|
||||
return {"in_progress": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED}
|
||||
return {
|
||||
"in_progress": _IN_PROGRESS, "to_analyse": _IN_PROGRESS,
|
||||
"approved": _APPROVED, "rejected": _REJECTED,
|
||||
}
|
||||
|
||||
def fake_issues(pid, states):
|
||||
issues_calls.append((pid, tuple(states)))
|
||||
|
||||
@@ -68,10 +68,18 @@ def test_set_issue_stage_state_patches_correct_uuid(mock_proj, mock_find, mock_p
|
||||
@patch("src.plane_sync.httpx.patch")
|
||||
@patch("src.plane_sync.find_issue_id", return_value="issue-uuid")
|
||||
@patch("src.plane_sync._resolve_project_id", return_value="proj-1")
|
||||
def test_set_issue_stage_state_noop_for_analysis(mock_proj, mock_find, mock_patch):
|
||||
# analysis has no dedicated board status -> no PATCH at all.
|
||||
def test_set_issue_stage_state_noop_for_deploy(mock_proj, mock_find, mock_patch):
|
||||
# ORCH-066: analysis now HAS a dedicated status (Analysis) -> it PATCHes.
|
||||
# deploy still has no board status here (driven by Phase A/B/C) -> no-op.
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.return_value = None
|
||||
mock_patch.return_value = resp
|
||||
|
||||
PS.set_issue_stage_state("ET-1", "analysis")
|
||||
mock_patch.assert_not_called()
|
||||
# analysis aliases in_progress when the Analysis status is absent.
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == PS.PLANE_STATES["analysis"]
|
||||
|
||||
mock_patch.reset_mock()
|
||||
PS.set_issue_stage_state("ET-1", "deploy")
|
||||
mock_patch.assert_not_called()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user