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:
File diff suppressed because one or more lines are too long
@@ -38,6 +38,9 @@ created → analysis → architecture → development → review → testing →
|
||||
└──── REQUEST_CHANGES ──────┘ (откат на development, max 3)
|
||||
```
|
||||
|
||||
## Статусная модель Plane (ORCH-066) — индикация ≠ управление
|
||||
Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`.
|
||||
|
||||
## Конвенции
|
||||
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
|
||||
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
|
||||
|
||||
@@ -249,7 +249,7 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц
|
||||
Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md), детально —
|
||||
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`.
|
||||
|
||||
### Осмысленная статусная модель Plane (ORCH-066 — design)
|
||||
### Осмысленная статусная модель Plane (ORCH-066 — реализовано)
|
||||
Plane-доска была семантически перегружена: `In Progress` означал «человек запускает
|
||||
конвейер», «идёт анализ», «идёт прод-деплой» и «возврат из Needs Input» одновременно.
|
||||
ORCH-066 наводит порядок по утверждённой Owner модели, меняя **только слой B**
|
||||
@@ -346,4 +346,4 @@ Monitoring after Deploy → Done
|
||||
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
|
||||
|
||||
---
|
||||
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — design, ветка feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.md`; обновлять при изменении этих мест).*
|
||||
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — реализовано в ветке feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.md`; обновлять при изменении этих мест).*
|
||||
|
||||
@@ -107,6 +107,19 @@ _DEFAULT_STATES = {
|
||||
# Feature 2 (verdict statuses) — Approved / Rejected.
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
# ORCH-066 (meaningful Plane status model, layer B): six new logical keys.
|
||||
# Their _DEFAULT_STATES values alias the enduro-trails UUID of their BASE key
|
||||
# (see _STATE_ALIAS_FALLBACK) so a project without these statuses created
|
||||
# (enduro / Plane down / partial config) degrades to the current behaviour
|
||||
# instead of producing an invalid PATCH state. The project-relative
|
||||
# alias-fallback in get_project_states() overrides these with the *project's
|
||||
# own* base UUID on the success path; these defaults are the last resort.
|
||||
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
|
||||
"analysis": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
|
||||
"code_review": "ba0d802c-5218-41d4-ab43-978b0ea123ed", # = review
|
||||
"awaiting_deploy": "38fb1f64-aa1e-48a3-92e0-0b109679046b", # = in_review
|
||||
"deploying": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
|
||||
"monitoring": "381a2833-3c4e-4be5-bd0f-be84cb946ad8", # = done
|
||||
}
|
||||
|
||||
# Backward-compat alias — do NOT remove (tests + webhooks/plane.py import it).
|
||||
@@ -128,6 +141,29 @@ _PLANE_NAME_TO_KEY: dict[str, str] = {
|
||||
"Needs Input": "needs_input",
|
||||
"In Review": "in_review",
|
||||
"Blocked": "blocked",
|
||||
# ORCH-066: meaningful per-stage / human-input statuses (layer B).
|
||||
"To Analyse": "to_analyse",
|
||||
"Analysis": "analysis",
|
||||
"Code-Review": "code_review",
|
||||
"Awaiting Deploy": "awaiting_deploy",
|
||||
"Deploying": "deploying",
|
||||
"Monitoring after Deploy": "monitoring",
|
||||
}
|
||||
|
||||
# ORCH-066 (BR-12): project-relative alias-fallback for the new logical keys.
|
||||
# After resolving states by name from the Plane API, any NEW key the project did
|
||||
# not define degrades to the UUID of its BASE key **from the same project** — so
|
||||
# the indication falls back to the current status and the PATCH stays valid even
|
||||
# for a partially-configured project. Enduro (none of the new statuses created)
|
||||
# collapses every new key onto its base, i.e. strictly the pre-ORCH-066
|
||||
# behaviour. Strengthened ORCH-059 AC-7 pattern.
|
||||
_STATE_ALIAS_FALLBACK: dict[str, str] = {
|
||||
"to_analyse": "in_progress",
|
||||
"analysis": "in_progress",
|
||||
"code_review": "review",
|
||||
"awaiting_deploy": "in_review",
|
||||
"deploying": "in_progress",
|
||||
"monitoring": "done",
|
||||
}
|
||||
|
||||
# Per-project state cache: {project_id: {logical_key: state_uuid}}
|
||||
@@ -175,6 +211,16 @@ def get_project_states(project_id: str) -> dict[str, str]:
|
||||
if not resolved:
|
||||
raise ValueError("no recognisable states in API response")
|
||||
|
||||
# ORCH-066 (BR-12): project-relative alias-fallback. For each NEW key the
|
||||
# project did not define, reuse the UUID of its BASE key FROM THIS SAME
|
||||
# PROJECT (never a foreign/enduro UUID — that would yield an invalid PATCH
|
||||
# state on a partially-configured orchestrator project). Runs BEFORE the
|
||||
# _DEFAULT_STATES.setdefault below so a project's own base UUID wins over
|
||||
# the static enduro default.
|
||||
for new_key, base_key in _STATE_ALIAS_FALLBACK.items():
|
||||
if new_key not in resolved and resolved.get(base_key):
|
||||
resolved[new_key] = resolved[base_key]
|
||||
|
||||
# Fill any missing keys from _DEFAULT_STATES so callers always get a
|
||||
# complete mapping (defensive against partial Plane configs).
|
||||
for k, v in _DEFAULT_STATES.items():
|
||||
@@ -210,14 +256,16 @@ def reload_project_states(project_id: str = None) -> None:
|
||||
|
||||
|
||||
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
|
||||
# when the pipeline ENTERS that stage. analysis stays driven by the existing
|
||||
# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps
|
||||
# in_progress until done. Needs Input / In Review / Blocked remain higher
|
||||
# priority and are set explicitly elsewhere — do NOT override them from here.
|
||||
# when the pipeline ENTERS that stage. ORCH-066: analysis -> Analysis and
|
||||
# review -> Code-Review now have dedicated statuses. deploy keeps in_progress
|
||||
# until its own Phase A/B/C statuses drive it. Needs Input / In Review / Blocked
|
||||
# remain higher priority and are set explicitly elsewhere — do NOT override them
|
||||
# from here.
|
||||
STAGE_VISIBILITY_STATE = {
|
||||
"analysis": "analysis", # ORCH-066: analysis stage -> Analysis status
|
||||
"architecture": "architecture",
|
||||
"development": "development",
|
||||
"review": "review",
|
||||
"review": "code_review", # ORCH-066: review stage -> Code-Review status
|
||||
"testing": "testing",
|
||||
}
|
||||
|
||||
@@ -225,22 +273,27 @@ STAGE_VISIBILITY_STATE = {
|
||||
# update_issue_state now calls stage_to_state() instead of looking up here.
|
||||
STAGE_TO_STATE = {
|
||||
"created": _DEFAULT_STATES["todo"],
|
||||
"analysis": _DEFAULT_STATES["in_progress"],
|
||||
# ORCH-066: analysis -> Analysis, review -> Code-Review. The new keys alias
|
||||
# the same in_progress / review UUIDs in _DEFAULT_STATES, so legacy callers /
|
||||
# tests that compare against concrete UUIDs see byte-identical values.
|
||||
"analysis": _DEFAULT_STATES["analysis"],
|
||||
"architecture": _DEFAULT_STATES["architecture"],
|
||||
"development": _DEFAULT_STATES["development"],
|
||||
"review": _DEFAULT_STATES["review"],
|
||||
"review": _DEFAULT_STATES["code_review"],
|
||||
"testing": _DEFAULT_STATES["testing"],
|
||||
"deploy": _DEFAULT_STATES["in_progress"],
|
||||
"done": _DEFAULT_STATES["done"],
|
||||
}
|
||||
|
||||
# Map orchestrator stage -> logical state key (project-independent).
|
||||
# ORCH-066: analysis -> analysis, review -> code_review (was in_progress/review).
|
||||
# deploy stays in_progress (Phase A/B/C drive it directly, not update_issue_state).
|
||||
_STAGE_TO_STATE_KEY = {
|
||||
"created": "todo",
|
||||
"analysis": "in_progress",
|
||||
"analysis": "analysis",
|
||||
"architecture": "architecture",
|
||||
"development": "development",
|
||||
"review": "review",
|
||||
"review": "code_review",
|
||||
"testing": "testing",
|
||||
"deploy": "in_progress",
|
||||
"done": "done",
|
||||
@@ -575,6 +628,58 @@ def set_issue_in_progress(work_item_id: str, project_id: str = None):
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_analysis(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-066: set issue to 'Analysis' — analyst is working (start / resume).
|
||||
|
||||
Degrades to the project's In Progress UUID when the 'Analysis' status is not
|
||||
created (alias-fallback). never-raise (via _set_issue_state_direct).
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["analysis"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_code_review(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-066: set issue to 'Code-Review' — review stage indication.
|
||||
|
||||
Degrades to the project's Review UUID when 'Code-Review' is not created.
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["code_review"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_awaiting_deploy(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-066: set issue to 'Awaiting Deploy' — self-deploy Phase A approval-pending.
|
||||
|
||||
Degrades to the project's In Review UUID when 'Awaiting Deploy' is not created.
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["awaiting_deploy"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_deploying(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-066: set issue to 'Deploying' — self-deploy Phase B prod deploy in flight.
|
||||
|
||||
Degrades to the project's In Progress UUID when 'Deploying' is not created.
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["deploying"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_monitoring(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-066: set issue to 'Monitoring after Deploy' — post-deploy window open.
|
||||
|
||||
Degrades to the project's Done UUID when 'Monitoring after Deploy' is not
|
||||
created (so the board shows Done, exactly as before ORCH-066).
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["monitoring"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None):
|
||||
"""Feature 3: move the issue to the board status for a pipeline stage.
|
||||
|
||||
|
||||
@@ -193,12 +193,22 @@ class Reconciler:
|
||||
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
|
||||
|
||||
def _is_blocked_or_needs_input(self, task: dict) -> bool:
|
||||
"""ORCH-060 Guard 2: is this issue in an explicit human Plane gate?
|
||||
"""Guard 2 (ORCH-060 + ORCH-066): is this issue waiting for a human OR in
|
||||
an active orchestrator wait that F-1 must not "revive"?
|
||||
|
||||
Variant A (no schema migration): resolve the task's Plane project, fetch
|
||||
the issue's current state uuid and compare against the project's
|
||||
``blocked`` / ``needs_input`` states. ``tasks`` has no status column, so
|
||||
the live Plane state is the source of truth.
|
||||
the issue's current state uuid and compare against a skip-set. ``tasks``
|
||||
has no status column, so the live Plane state is the source of truth.
|
||||
|
||||
Skip-set = explicit human gates (``blocked`` / ``needs_input``) PLUS the
|
||||
ORCH-066 active waits (``awaiting_deploy`` / ``deploying`` / ``monitoring``,
|
||||
BR-13). **Anti-regress (CRITICAL):** the active-wait keys alias onto
|
||||
``in_review`` / ``in_progress`` / ``done`` on a project that did not create
|
||||
them. Adding them verbatim would make F-1 wrongly skip enduro
|
||||
In Progress / Done tasks (regression of ORCH-053/060). So they are
|
||||
included ONLY when DISTINCT from the project's base working statuses
|
||||
(i.e. actually created as separate statuses): enduro collapses them to {}
|
||||
-> zero regress; orchestrator keeps three real statuses -> BR-13.
|
||||
|
||||
**Never-raise, conservative fallback.** Any error / unresolved project /
|
||||
missing state -> return ``True`` (treat as "possibly blocked" -> skip):
|
||||
@@ -219,7 +229,22 @@ class Reconciler:
|
||||
cur = fetch_issue_state(issue_id, pid)
|
||||
if cur is None:
|
||||
return True # Plane unreachable / no state -> conservative skip
|
||||
return cur in {states.get("blocked"), states.get("needs_input")}
|
||||
# ORCH-066 BR-13: active orchestrator waits, minus base working
|
||||
# statuses so aliased (enduro) keys never widen the skip-set.
|
||||
base_working = {
|
||||
states.get(k) for k in (
|
||||
"backlog", "todo", "in_progress", "in_review", "review",
|
||||
"architecture", "development", "testing",
|
||||
"approved", "rejected", "done",
|
||||
)
|
||||
}
|
||||
extra_waits = {
|
||||
states.get("awaiting_deploy"),
|
||||
states.get("deploying"),
|
||||
states.get("monitoring"),
|
||||
} - base_working - {None}
|
||||
skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits
|
||||
return cur in skip_set
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(
|
||||
f"reconciler Guard 2: blocked-check failed for task "
|
||||
@@ -241,15 +266,19 @@ class Reconciler:
|
||||
def _reconcile_plane_project(self, proj) -> None:
|
||||
pid = proj.plane_project_id
|
||||
# Resolve the actionable state uuids per-project (never hardcode).
|
||||
# ORCH-066 (AC-19): the start/resume trigger is `To Analyse` (was
|
||||
# In Progress). On a project without that status, `to_analyse` aliases to
|
||||
# the project's own `in_progress` UUID, so enduro behaviour is identical
|
||||
# (and `list_issues_by_state` deduplicates the uuid via its internal set).
|
||||
states = get_project_states(pid)
|
||||
in_progress = states["in_progress"]
|
||||
to_analyse = states["to_analyse"]
|
||||
approved = states["approved"]
|
||||
rejected = states["rejected"]
|
||||
issues = list_issues_by_state(pid, [in_progress, approved, rejected])
|
||||
issues = list_issues_by_state(pid, [to_analyse, approved, rejected])
|
||||
for issue in issues:
|
||||
try:
|
||||
self._reconcile_plane_issue(
|
||||
issue, pid, in_progress, approved, rejected
|
||||
issue, pid, to_analyse, approved, rejected
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - isolate one issue's failure
|
||||
logger.error(
|
||||
@@ -258,7 +287,7 @@ class Reconciler:
|
||||
|
||||
def _reconcile_plane_issue(
|
||||
self, issue: dict, project_id: str,
|
||||
in_progress: str, approved: str, rejected: str,
|
||||
to_analyse: str, approved: str, rejected: str,
|
||||
) -> None:
|
||||
issue_id = str(issue.get("id") or "")
|
||||
if not issue_id:
|
||||
@@ -288,10 +317,16 @@ class Reconciler:
|
||||
"description_stripped": issue.get("description_stripped", ""),
|
||||
}
|
||||
|
||||
if new_state == in_progress and task is None:
|
||||
# In Progress without a task -> start the pipeline (lost start webhook).
|
||||
if new_state == to_analyse and task is None:
|
||||
# To Analyse without a task -> start the pipeline (lost start webhook).
|
||||
self._dispatch(handle_status_start, issue_data, project_id)
|
||||
self._note_unblock(issue_id, "analysis")
|
||||
elif new_state == to_analyse and task is not None:
|
||||
# To Analyse with an existing (idle) task -> resume the analyst from
|
||||
# Needs Input (lost resume webhook). handle_status_start applies its
|
||||
# own busy-guard / start-vs-resume fork.
|
||||
self._dispatch(handle_status_start, issue_data, project_id)
|
||||
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
|
||||
elif new_state == approved and task is not None:
|
||||
# Approved but the stage never advanced -> replay the verdict.
|
||||
self._dispatch(handle_verdict, issue_data, project_id, approved=True)
|
||||
|
||||
@@ -53,6 +53,10 @@ from .plane_sync import (
|
||||
set_issue_in_progress,
|
||||
set_issue_blocked,
|
||||
set_issue_done,
|
||||
set_issue_analysis,
|
||||
set_issue_awaiting_deploy,
|
||||
set_issue_deploying,
|
||||
set_issue_monitoring,
|
||||
)
|
||||
from .config import settings
|
||||
|
||||
@@ -335,14 +339,28 @@ def advance_stage(
|
||||
# here, so explicitly drive the Plane issue into the terminal Done state
|
||||
# (PLANE_STATES['done'] — mapping unchanged) in addition to the
|
||||
# stage-change comment above.
|
||||
# ORCH-066 (AC-8/AC-9): split terminal-sync by whether post-deploy
|
||||
# monitoring applies. For self-hosting (post_deploy_applies==True) the
|
||||
# task enters a `Monitoring after Deploy` window, NOT terminal Done yet —
|
||||
# the monitor finalises Done/Blocked (run_post_deploy_monitor). For
|
||||
# non-self repos the behaviour is unchanged: terminal Done immediately.
|
||||
# Where the `Monitoring after Deploy` status is absent, set_issue_monitoring
|
||||
# degrades to the project's Done UUID -> identical to today.
|
||||
if next_stage == "done" and work_item_id:
|
||||
try:
|
||||
set_issue_done(work_item_id)
|
||||
logger.info(
|
||||
f"Task {task_id}: deploy->done, Plane state forced to Done"
|
||||
)
|
||||
if post_deploy.post_deploy_applies(repo):
|
||||
set_issue_monitoring(work_item_id)
|
||||
logger.info(
|
||||
f"Task {task_id}: deploy->done (self), Plane state -> "
|
||||
f"Monitoring after Deploy (post-deploy window)"
|
||||
)
|
||||
else:
|
||||
set_issue_done(work_item_id)
|
||||
logger.info(
|
||||
f"Task {task_id}: deploy->done, Plane state forced to Done"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Task {task_id}: failed to set Plane Done: {e}")
|
||||
logger.error(f"Task {task_id}: failed to set Plane terminal state: {e}")
|
||||
|
||||
# ORCH-043: the merge has landed (deploy->done). Release the merge lease as
|
||||
# a backstop in case the PR-merged webhook was lost (holder-aware no-op if a
|
||||
@@ -666,7 +684,9 @@ def _handle_qg_failure_rollbacks(
|
||||
notify_stage_change(task_id, current_stage, "analysis")
|
||||
plane_notify_stage(work_item_id, current_stage, "analysis")
|
||||
result.rolled_back_to = "analysis"
|
||||
set_issue_in_progress(work_item_id)
|
||||
# ORCH-066 (AC-3): rolled back to analysis -> indicate `Analysis`
|
||||
# (degrades to In Progress where the status is not created).
|
||||
set_issue_analysis(work_item_id)
|
||||
with open(conflict_path, "r") as cf:
|
||||
conflict_text = cf.read()[:500]
|
||||
plane_add_comment(
|
||||
@@ -1009,7 +1029,11 @@ def _handle_self_deploy_phase_a(
|
||||
result.note = "self-deploy-approval-pending"
|
||||
|
||||
if work_item_id:
|
||||
set_issue_in_review(work_item_id)
|
||||
# ORCH-066 (AC-6/AC-13): Phase A approval-pending is now `Awaiting Deploy`,
|
||||
# which discharges `In Review` of the deploy-approval meaning (In Review
|
||||
# stays for analyst BRD/review approve-pending only). Degrades to In Review
|
||||
# where the status is not created.
|
||||
set_issue_awaiting_deploy(work_item_id)
|
||||
# ORCH-036: belt-and-suspenders — wipe any STALE deploy-state markers before
|
||||
# arming a fresh approve. A prior FAILED pass clears on rollback, but clearing
|
||||
# here too guarantees the entry to every new prod-deploy pass starts clean
|
||||
@@ -1069,6 +1093,10 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv
|
||||
self_deploy.write_marker(
|
||||
repo, work_item_id, self_deploy.INITIATED, content=str(time.time())
|
||||
)
|
||||
# ORCH-066 (AC-7): the prod deploy is now in flight -> indicate `Deploying`
|
||||
# (degrades to In Progress where the status is not created).
|
||||
if work_item_id:
|
||||
set_issue_deploying(work_item_id)
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)."
|
||||
@@ -1263,6 +1291,12 @@ def run_post_deploy_monitor(job: dict):
|
||||
settings.post_deploy_window_s, checks_total, checks_failed,
|
||||
)
|
||||
post_deploy.mark_done(repo, work_item_id)
|
||||
# ORCH-066 (AC-10): the post-deploy window closed clean -> terminal Done.
|
||||
if work_item_id:
|
||||
try:
|
||||
set_issue_done(work_item_id)
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(f"post-deploy: set Done failed for {work_item_id}: {e}")
|
||||
_notify_post_deploy(
|
||||
work_item_id,
|
||||
f"✅ {work_item_id}: пост-деплой окно завершено чисто "
|
||||
@@ -1303,6 +1337,15 @@ def run_post_deploy_monitor(job: dict):
|
||||
f"self-hosting запрещён (BR-5).",
|
||||
)
|
||||
|
||||
# ORCH-066 (AC-11/AC-12): a confirmed degradation -> indicate `Blocked` for
|
||||
# manual intervention. This is INDICATION ONLY — the tick NEVER restarts /
|
||||
# rolls back the prod container (self-hosting stays ALERT_ONLY, BR-5).
|
||||
if work_item_id:
|
||||
try:
|
||||
set_issue_blocked(work_item_id)
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(f"post-deploy: set Blocked failed for {work_item_id}: {e}")
|
||||
|
||||
post_deploy.write_post_deploy_log(
|
||||
repo, work_item_id, branch, post_deploy.DEGRADED, action_taken,
|
||||
settings.post_deploy_window_s, checks_total, checks_failed,
|
||||
|
||||
@@ -147,10 +147,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
|
||||
return
|
||||
|
||||
# ORCH-10: resolve expected state UUIDs per the incoming issue's project so
|
||||
# both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the
|
||||
# both enduro (b873d9eb) and orchestrator (e331bfb3) statuses trigger the
|
||||
# pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker.
|
||||
# ORCH-066: the start/resume trigger is now `To Analyse` (human entry-point),
|
||||
# which discharges `In Progress` of its overloaded "start the pipeline"
|
||||
# meaning. Fail-closed: on a project without the `To Analyse` status,
|
||||
# `to_analyse` aliases to the project's own `in_progress` UUID, so moving an
|
||||
# enduro issue to In Progress still triggers start/resume (AC-17).
|
||||
proj_states = get_project_states(project_id)
|
||||
if new_state == proj_states["in_progress"]:
|
||||
if new_state == proj_states["to_analyse"]:
|
||||
await handle_status_start(data, project_id)
|
||||
elif new_state == proj_states["approved"]:
|
||||
await handle_verdict(data, project_id, approved=True)
|
||||
@@ -235,9 +240,14 @@ async def handle_status_start(data: dict, project_id: str = ""):
|
||||
)
|
||||
job_id = enqueue_job(stage_agent, repo, task_desc, task_id=task_id)
|
||||
logger.info(
|
||||
f"Task {task_id}: returned to In Progress (Needs Input answered), "
|
||||
f"Task {task_id}: returned to To Analyse (Needs Input answered), "
|
||||
f"relaunched {stage_agent} for stage {current_stage} (job_id={job_id})"
|
||||
)
|
||||
# ORCH-066 (AC-3): a resume of the analyst (the only Needs-Input owner) is
|
||||
# re-indicated as `Analysis`; other stages keep their own indication.
|
||||
if current_stage == "analysis":
|
||||
from ..plane_sync import set_issue_analysis as _set_analysis
|
||||
_set_analysis(work_item_id)
|
||||
try:
|
||||
_add_comment(
|
||||
work_item_id,
|
||||
@@ -538,6 +548,10 @@ async def start_pipeline(data: dict, project_id: str = ""):
|
||||
)
|
||||
job_id = enqueue_job("analyst", repo, task_desc, task_id=task_id)
|
||||
logger.info(f"Task {task_id}: enqueued analyst (job_id={job_id})")
|
||||
# ORCH-066 (AC-3): indicate the analysis stage with the dedicated
|
||||
# `Analysis` status (degrades to In Progress where it is not created).
|
||||
from ..plane_sync import set_issue_analysis as _set_analysis
|
||||
_set_analysis(work_item_id, plane_project_id)
|
||||
# Post start comment to Plane
|
||||
from ..plane_sync import add_comment as _add_comment
|
||||
_add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).", author="analyst")
|
||||
@@ -579,9 +593,11 @@ async def _rollback_stage(
|
||||
(via the existing rollback notify + an enqueue of the prev-stage agent).
|
||||
"""
|
||||
if current_stage == "analysis":
|
||||
# Already in analysis — just relaunch analyst with rejection reason
|
||||
from ..plane_sync import set_issue_in_progress
|
||||
set_issue_in_progress(work_item_id)
|
||||
# Already in analysis — just relaunch analyst with rejection reason.
|
||||
# ORCH-066 (AC-3): indicate `Analysis` (degrades to In Progress where the
|
||||
# status is not created).
|
||||
from ..plane_sync import set_issue_analysis
|
||||
set_issue_analysis(work_item_id)
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: analysis\nNote: Stakeholder REJECTED your artifacts. "
|
||||
|
||||
@@ -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