feat(labels): auto-mode by Plane labels — autoApprove + autoDeploy (ORCH-089) #89
@@ -3,6 +3,14 @@
|
||||
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
|
||||
|
||||
## [Unreleased]
|
||||
- **Авто-режим по лейблам: autoApprove (авто-BRD) + autoDeploy (авто-деплой)** (ORCH-089, `feat`): сняты **два** человеческих гейта конвейера, тормозящих пакетный автономный прогон (эпик ORCH-088) — гейт BRD (`analysis`: ручной `Approved`) и гейт прод-деплоя (`deploy` Phase A: ручной `Confirm Deploy`, ORCH-059). Решение выборочно (лейбл Plane на задаче), декларативно, обратимо и **не трогает ни одной технической проверки**. Аддитивно по образцу условных под-гейтов (ORCH-035/043/058/088): leaf `src/labels.py` (never-raise) + две точечные врезки + флаги; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД — **без изменений**.
|
||||
- **`autoApprove`** → врезка в `stage_engine._handle_analysis_approved_flow` (ветка `files_ok`) ПОСЛЕ `In Review`+коммента: `set_issue_approved` (индикация) + лог/Telegram/Plane-коммент + `advance_stage(..., finished_agent=None)` — **тот же путь, что человеческий Approved** (`approved-via-status` → `analysis → architecture` + `mark_brd_review_ended`). Без дублирования переходной логики; re-entrancy безопасна (вложенный вызов идёт с `finished_agent=None`, не входит в analyst-ветку).
|
||||
- **`autoDeploy`** → врезка в `stage_engine._handle_self_deploy_phase_a` сразу после advance на `deploy`+`clear_state` (ДО «ask-human»): лог/Telegram/Plane-коммент + `_handle_self_deploy_phase_b(...)` (idempotency-маркер `INITIATED`, статус `Deploying`, finalizer). Пропускаются лишь индикативно-человеческие шаги (`APPROVE_REQUESTED`+`Awaiting Deploy`+«смените на Confirm Deploy»). **BR-5 структурно:** Phase A достигается только после зелёных под-гейтов ребра `deploy-staging → deploy` (security → merge-gate → image-freshness → staging) → autoDeploy физически не деплоит сломанное.
|
||||
- **Чтение лейблов** — `plane_sync.fetch_issue_labels` (поле `labels` issue; `None` при ошибке ≠ `[]`) + `get_project_labels` (`{normalized_name→uuid}`, TTL-кэш `auto_label_states_ttl_s` по образцу `get_project_states`); сопоставление по нормализованному имени (`strip().casefold()`), неоднозначность (две метки → одно нормализованное имя) → сентинел `__AMBIGUOUS__` → «нет лейбла». Новый сеттер `set_issue_approved` (ключ `approved` уже в `_DEFAULT_STATES`). Источник истины — Plane API, не payload вебхука.
|
||||
- **Флаги** (`config.py`): `auto_label_enabled` (kill-switch), `auto_approve_label`/`auto_deploy_label`, `auto_label_repos` (CSV; **пусто → self-hosting only**), `auto_label_states_ttl_s`. `applies(repo)` (локальный) проверяется ПЕРВЫМ; `has_label` (сеть) — только при `applies==True` → при выключенном флаге нулевой сетевой оверхед, нулевая регрессия для enduro (AC-8).
|
||||
- **Fail-safe (never auto):** любая ошибка/недоступность Plane/неоднозначность → «нет авто» → ручной гейт (never-raise, AC-6). **Прозрачность (AC-7):** лог + Telegram + Plane-коммент + live-карточка через штатный advance. Read-only блок `auto_labels` в `GET /queue`.
|
||||
- **Инфра-предусловие:** создать лейблы `autoApprove`/`autoDeploy` в Plane-проекте ORCH (labels API); их отсутствие = `has_label` False = ручной режим (fail-safe). Детали — `docs/work-items/ORCH-089/07-infra-requirements.md`.
|
||||
- Тесты: `tests/test_labels.py`, `test_plane_sync_labels.py`, `test_auto_approve_brd.py`, `test_auto_deploy.py`, `test_auto_label_combinations.py`, `test_auto_labels_integration.py`, `test_auto_labels_invariants.py` (TC-01…TC-26). ADR: `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`, global `docs/architecture/adr/adr-0018-auto-label-gates.md`.
|
||||
- **Per-repo serial gate: пакетный автономный режим (Этап 1, serial e2e)** (ORCH-088, `feat`): закрыт **логический** stale-анализ — ветка задачи N+1 срезалась на входе в анализ (`start_pipeline._create_gitea_branch`) от `main`, ещё не содержащего код предшественника N (физическое затирание уже закрыто ORCH-026). Новая задача репо не входит в `analysis` (не режет ветку, не запускает analyst), пока в репо есть незавершённая задача или репо заморожен. Аддитивно, под kill-switch, область репо, never-raise, restart-safe; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*` — **без изменений**.
|
||||
- **Gate-в-claim** (`db.claim_next_job`): analyst-job (`jobs.agent='analyst'`) применимого репо не выбирается, если `EXISTS` **более ранняя** незавершённая задача репо (`t2.id < jobs.task_id`) ИЛИ активна строка `repo_freeze`. Фрагмент строится в leaf `src/serial_gate.py::build_claim_clause` (санитизация repo-токенов `^[A-Za-z0-9._-]+$`, **fail-OPEN** на любой ошибке построения — не заклинить очередь всех проектов, AC-8); только локальная БД (offline hot-path, NFR-2). Job'ы уже активной задачи проходят свободно. **FIFO-уточнение реализации (FR-2):** ADR-001 D1 фиксировал псевдо-SQL `t2.id != jobs.task_id`; при `!=` пакет одновременно созданных свежих задач (все в `analysis`) взаимно блокировался бы → дедлок всей serial-очереди (воспроизведено). `<` допускает ровно самую раннюю задачу и сериализует остальные за ней (строго по одной, FIFO по `jobs.id`), сохраняя AC-1 и не блокируя rework-analyst собственной задачи (R-7).
|
||||
- **Отложенный срез ветки (анти-stale-base, AC-6):** для применимого репо `start_pipeline` создаёт task-row + enqueue analyst, но **не** создаёт Gitea-ветку/docs; срез релоцирован в `launcher._spawn` (новый `_materialize_deferred_branch`, sync через `asyncio.run` в worker-потоке, R-4) на момент claim analyst-job, когда `origin/main` уже содержит предшественника (`done` ⇔ SHA-в-main, ORCH-071/073). `ensure_worktree` режет от свежего `origin/main` ⇒ AC-6 структурно. Идемпотентно (`_create_gitea_branch` 409 / `_create_initial_docs` 422 = no-op) → безопасно при реклейме/рестарте. Ожидающая задача = `queued` analyst-job без ветки; `tasks.branch` хранится как имя (R-5).
|
||||
|
||||
33
CLAUDE.md
33
CLAUDE.md
@@ -78,6 +78,39 @@ created → analysis → architecture → development → review → testing →
|
||||
- Транспорт (`send_telegram`/`edit_telegram`/`delete_telegram`), `disable_notification`
|
||||
(карточка тихая, пингуют только alert-хелперы), схема БД — не трогаются.
|
||||
|
||||
## Авто-режим по лейблам: autoApprove + autoDeploy (ORCH-089)
|
||||
Конвейер имеет два **человеческих** гейта, тормозящих пакетный автономный прогон
|
||||
(эпик ORCH-088): гейт BRD (`analysis`: ручной `Approved`) и гейт прод-деплоя
|
||||
(`deploy` Phase A: ручной `Confirm Deploy`, ORCH-059). ORCH-089 снимает **только эти
|
||||
два человеческих решения** — выборочно (лейбл Plane на задаче), декларативно,
|
||||
обратимо, **не трогая ни одной технической проверки**. Инвариант: авто-режим снимает
|
||||
лишь ожидание человеческого сигнала; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД
|
||||
— **не трогаются**. Аддитивно: leaf `src/labels.py` (never-raise) + две точечные врезки.
|
||||
- **`autoApprove`** → врезка в `stage_engine._handle_analysis_approved_flow` (ветка
|
||||
`files_ok`): `set_issue_approved` (индикация) + лог/Telegram/Plane-коммент +
|
||||
`advance_stage(..., finished_agent=None)` — **тот же путь, что человеческий Approved**
|
||||
(`approved-via-status` → `analysis → architecture` + `mark_brd_review_ended`).
|
||||
- **`autoDeploy`** → врезка в `stage_engine._handle_self_deploy_phase_a` после advance
|
||||
на `deploy` + `clear_state`: лог/Telegram/Plane-коммент + `_handle_self_deploy_phase_b`
|
||||
(маркер `INITIATED`, статус `Deploying`, finalizer). Пропускаются лишь
|
||||
индикативно-человеческие шаги. **BR-5 структурно:** Phase A достигается только после
|
||||
зелёных под-гейтов ребра `deploy-staging → deploy` (security → merge-gate →
|
||||
image-freshness → staging) → autoDeploy физически не деплоит сломанное.
|
||||
- **Чтение лейблов** — `plane_sync.fetch_issue_labels` (`None` при ошибке ≠ `[]`) +
|
||||
`get_project_labels` (`{normalized_name→uuid}`, TTL-кэш); сопоставление по
|
||||
нормализованному имени (`strip().casefold()`), неоднозначность → «нет лейбла».
|
||||
Источник истины — Plane API, не payload вебхука. Новый сеттер `set_issue_approved`.
|
||||
- **Флаги** (`config.py`): `auto_label_enabled` (kill-switch), `auto_approve_label`/
|
||||
`auto_deploy_label`, `auto_label_repos` (CSV; **пусто → self-hosting only**),
|
||||
`auto_label_states_ttl_s`. `applies(repo)` (локальный) проверяется ПЕРВЫМ; `has_label`
|
||||
(сеть) — только при `applies==True` → при выключенном флаге нулевой сетевой оверхед.
|
||||
- **Fail-safe (never auto):** любая ошибка/недоступность Plane/неоднозначность →
|
||||
«нет авто» → ручной гейт (never-raise). Прозрачность: лог + Telegram + Plane-коммент +
|
||||
live-карточка; блок `auto_labels` в `GET /queue`. **Инфра-предусловие:** создать лейблы
|
||||
`autoApprove`/`autoDeploy` в Plane-проекте ORCH (их отсутствие = ручной режим, fail-safe).
|
||||
Детали — `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`,
|
||||
`docs/architecture/adr/adr-0018-auto-label-gates.md`.
|
||||
|
||||
## Конвенции
|
||||
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
|
||||
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
|
||||
|
||||
@@ -130,6 +130,46 @@ Self-hosting зацикливался на `deploy-staging`: `scripts/staging_ch
|
||||
`docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`,
|
||||
`docs/work-items/ORCH-088/08-data-requirements.md`.
|
||||
|
||||
### Авто-режим по лейблам: autoApprove + autoDeploy (ORCH-089 — реализовано)
|
||||
Конвейер имеет два **человеческих** гейта, тормозящих пакетный автономный прогон (эпик
|
||||
ORCH-088): гейт BRD (`analysis`: ждёт ручного `Approved`) и гейт прод-деплоя (`deploy`:
|
||||
Phase A ждёт ручного `Confirm Deploy`, ORCH-059). ORCH-089 снимает **только эти два
|
||||
человеческих решения** — выборочно (лейбл Plane на задаче), декларативно, обратимо, **не
|
||||
трогая ни одной технической проверки**. Аддитивно, по образцу условных под-гейтов
|
||||
(ORCH-035/043/058/059/088): leaf `src/labels.py` (never-raise) + точечные врезки + флаги;
|
||||
`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / схема БД — **не трогаются**.
|
||||
- **`autoApprove`** → врезка в `stage_engine._handle_analysis_approved_flow` (ветка
|
||||
`files_ok`) после `In Review`+коммента: `set_issue_approved` (индикация) +
|
||||
лог/Telegram/Plane-коммент + `advance_stage(..., finished_agent=None)` — **тот же путь, что
|
||||
человеческий Approved** (`approved-via-status` → `analysis → architecture` +
|
||||
`mark_brd_review_ended`). Без дублирования переходной логики.
|
||||
- **`autoDeploy`** → врезка в `stage_engine._handle_self_deploy_phase_a` сразу после advance
|
||||
на `deploy` + `clear_state`: лог/Telegram/Plane-коммент + `_handle_self_deploy_phase_b(...)`
|
||||
(idempotency-маркер `INITIATED`, статус `Deploying`, finalizer). Пропускаются лишь
|
||||
индикативно-человеческие шаги (`Awaiting Deploy` + «ask-human»). **BR-5 структурно:** Phase A
|
||||
достигается только после зелёных под-гейтов ребра `deploy-staging → deploy` (security →
|
||||
merge-gate → image-freshness → staging) → autoDeploy физически не деплоит сломанное.
|
||||
- **Чтение лейблов** — `plane_sync.fetch_issue_labels` (поле `labels` issue, `None` при
|
||||
ошибке ≠ `[]`) + `get_project_labels` (`{normalized_name→uuid}`, TTL-кэш по образцу
|
||||
`get_project_states`); сопоставление по нормализованному имени (`strip().casefold()`),
|
||||
неоднозначность → «нет лейбла». Источник истины — Plane API, не payload вебхука. Новый
|
||||
сеттер `set_issue_approved` (ключ `approved` уже в `_DEFAULT_STATES`).
|
||||
- **Флаги** (`config.py`): `auto_label_enabled` (kill-switch), `auto_approve_label`/
|
||||
`auto_deploy_label`, `auto_label_repos` (CSV; **пусто → self-hosting only**),
|
||||
`auto_label_states_ttl_s`. `applies(repo)` (локальный) проверяется ПЕРВЫМ; `has_label`
|
||||
(сеть) — только при `applies==True` → при выключенном флаге нулевой сетевой оверхед,
|
||||
нулевая регрессия для enduro.
|
||||
- **Fail-safe (never auto):** любая ошибка/недоступность Plane/неоднозначность →
|
||||
«нет авто» → ручной гейт (never-raise). **Идемпотентность:** autoApprove — advance один раз
|
||||
(поздний Approved/F-2 видят `architecture`); autoDeploy — маркер `INITIATED`. **Прозрачность
|
||||
(AC-7):** лог + Telegram + Plane-коммент + live-карточка; блок `auto_labels` в `GET /queue`.
|
||||
- **Инфра-предусловие:** создать лейблы `autoApprove`/`autoDeploy` в Plane-проекте ORCH
|
||||
(labels API); их отсутствие = `has_label` False = ручной режим (fail-safe).
|
||||
|
||||
Подробнее: [adr-0018](adr/adr-0018-auto-label-gates.md), детально —
|
||||
`docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`,
|
||||
`docs/work-items/ORCH-089/07-infra-requirements.md`.
|
||||
|
||||
### Исполняемый самодеплой стадии `deploy` (ORCH-36)
|
||||
`deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия
|
||||
РЕАЛЬНО деплоит прод (8500) через хост-хук `scripts/orchestrator-deploy-hook.sh`,
|
||||
|
||||
59
docs/architecture/adr/adr-0018-auto-label-gates.md
Normal file
59
docs/architecture/adr/adr-0018-auto-label-gates.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# ADR-0018: Авто-режим по лейблам — autoApprove / autoDeploy (ORCH-089)
|
||||
|
||||
## Статус
|
||||
Accepted (реализация — ORCH-089)
|
||||
|
||||
## Контекст
|
||||
Конвейер имеет два **человеческих** гейта, тормозящих пакетный автономный прогон
|
||||
(эпик ORCH-088, «10–20 задач за ночь»):
|
||||
1. **BRD** (`analysis`): ждёт ручного Plane-статуса `Approved` → advance на `architecture`.
|
||||
2. **Прод-деплой** (`deploy`): Phase A ставит `Awaiting Deploy` и ждёт ручного
|
||||
`Confirm Deploy` (ORCH-059) → Phase B (`initiate_deploy`).
|
||||
|
||||
Для доверенных задач оба клика избыточны. Нужно снять **только эти два человеческих
|
||||
решения**, выборочно/декларативно (лейбл Plane на задаче), не ослабляя ни одной
|
||||
технической проверки.
|
||||
|
||||
## Решение
|
||||
Аддитивно, по образцу условных под-гейтов (ORCH-035/043/058/059/088): leaf-модуль чистой
|
||||
логики `src/labels.py` (never-raise) + точечные врезки + флаги. `STAGE_TRANSITIONS`, реестр
|
||||
`QG_CHECKS`, все `check_*`, схема БД — **не трогаются**.
|
||||
|
||||
- **`autoApprove`** (лейбл задачи) → в `_handle_analysis_approved_flow` (ветка `files_ok`)
|
||||
после `In Review`+коммента: `set_issue_approved` (индикация) + лог/Telegram/Plane-коммент +
|
||||
`advance_stage(..., finished_agent=None)` — тот же путь, что человеческий Approved
|
||||
(`approved-via-status` → `analysis → architecture` + `mark_brd_review_ended`). Без
|
||||
дублирования переходной логики.
|
||||
- **`autoDeploy`** (лейбл задачи) → в `_handle_self_deploy_phase_a` сразу после advance на
|
||||
`deploy` + `clear_state`: лог/Telegram/Plane-коммент + `_handle_self_deploy_phase_b(...)`
|
||||
(idempotency-маркер `INITIATED`, `Deploying`, finalizer). Пропускаются лишь
|
||||
индикативно-человеческие шаги (`Awaiting Deploy` + «ask-human»).
|
||||
- **Чтение лейблов** — `plane_sync.fetch_issue_labels` + `get_project_labels` (TTL-кэш,
|
||||
образец `get_project_states`); сопоставление по нормализованному имени; источник истины —
|
||||
Plane API (не payload). Новый сеттер `set_issue_approved` (ключ `approved` уже в states).
|
||||
- **Флаги:** `auto_label_enabled` (kill-switch), `auto_approve_label`/`auto_deploy_label`
|
||||
(имена), `auto_label_repos` (CSV; **пусто → self-hosting only**), `auto_label_states_ttl_s`.
|
||||
`applies(repo)` (локальный) проверяется ПЕРВЫМ; `has_label` (сеть) — только если
|
||||
`applies==True` → при выключенном флаге нулевой сетевой оверхед.
|
||||
|
||||
## Критические инварианты
|
||||
- **Авто-режим снимает ТОЛЬКО человеческое решение**, не ослабляя ни один тех-гейт
|
||||
(CI / staging / security / merge-gate / image-freshness / merge-verify / regression-guard /
|
||||
post-deploy). autoDeploy живёт в точке, где все под-гейты ребра `deploy-staging → deploy`
|
||||
уже зелёные → структурно «никогда не деплоит сломанное».
|
||||
- **Fail-safe (never auto):** любая ошибка/недоступность Plane/неоднозначность имени →
|
||||
«нет авто» → ручной гейт (согласовано с fail-closed-практикой ORCH-059). never-raise.
|
||||
- **Нулевая регрессия:** без лейблов / `auto_label_enabled=False` / репо вне scope →
|
||||
поведение 1:1 как до ORCH-089 (enduro не затронут).
|
||||
- **Идемпотентность:** autoApprove — advance применяется один раз (поздний Approved/F-2
|
||||
видят уже `architecture`); autoDeploy — маркер `INITIATED`.
|
||||
|
||||
## Последствия
|
||||
**+** минимальная поверхность, единый источник истины перехода, декларативно/обратимо,
|
||||
независимые лейблы, безопасный дефолт. **−** Approved-статус транзиентен (durable-аудит —
|
||||
лог/Telegram/коммент); 1–2 GET к Plane на гейт применимого репо (TTL-кэш карты лейблов);
|
||||
требуется однократно создать лейблы в Plane-проекте ORCH (инфра-предусловие; их отсутствие =
|
||||
fail-safe ручной режим).
|
||||
|
||||
Детально: `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`,
|
||||
`07-infra-requirements.md`, `10-tech-risks.md`.
|
||||
7
docs/work-items/ORCH-089/00-business-request.md
Normal file
7
docs/work-items/ORCH-089/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Авто-режим по лейблам: autoApprove (орк сам подтверждает BRD) + autoDeploy (орк сам деплоит)
|
||||
|
||||
Work Item ID: ORCH-089
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
123
docs/work-items/ORCH-089/01-brd.md
Normal file
123
docs/work-items/ORCH-089/01-brd.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 01 — BRD: Авто-режим по лейблам (autoApprove + autoDeploy)
|
||||
|
||||
**Work Item:** ORCH-089
|
||||
**Repo:** orchestrator (self-hosting)
|
||||
**Стадия:** analysis
|
||||
**Приоритет:** Бэклог (запуск по решению Славы, serial e2e после ORCH-88)
|
||||
|
||||
> ⚠️ Прошлый подход (09.06) ОТМЕНЁН: «Стрим ревьюит и апрувит BRD» — НЕ реализовывать.
|
||||
> Актуальная модель: автономность управляется **лейблами Plane на задаче**, без участия людей.
|
||||
|
||||
## 1. Проблема / зачем
|
||||
|
||||
В конвейере два **человеческих** гейта — точки, где конвейер останавливается и ждёт
|
||||
ручного клика человека (Слава/Стрим):
|
||||
|
||||
1. **Гейт BRD** (стадия `analysis`): после завершения analyst задача переводится в
|
||||
`In Review` и ждёт, пока человек вручную выставит Plane-статус **Approved**, чтобы
|
||||
уйти на `architecture`.
|
||||
2. **Гейт деплоя** (стадия `deploy`): после зелёного staging задача переводится в
|
||||
`Awaiting Deploy` (Phase A, ORCH-036/059) и ждёт, пока человек вручную выставит
|
||||
статус **Confirm Deploy**, чтобы запустить прод-деплой (Phase B).
|
||||
|
||||
Для задач, которым **доверяем**, оба ручных решения избыточны и тормозят пакетный
|
||||
автономный прогон («10–20 задач за ночь», эпик ORCH-088). Нужно снять эти два
|
||||
человеческих решения **выборочно и декларативно** — через лейблы на конкретной задаче.
|
||||
|
||||
## 2. Бизнес-цель
|
||||
|
||||
Дать оператору возможность пометить задачу лейблом и тем самым **разрешить орку
|
||||
самому пройти соответствующий человеческий гейт**, не трогая ни одну техническую
|
||||
проверку. Доверие выражается лейблом — на уровне отдельной задачи, обратимо, прозрачно.
|
||||
|
||||
## 3. Модель (решение Славы, 09.06)
|
||||
|
||||
| Лейбл на задаче | Эффект |
|
||||
|-----------------|--------|
|
||||
| `autoApprove` | Орк САМ подтверждает BRD (гейт 1: `In Review → Approved`), без человека. Конвейер идёт на `architecture`. |
|
||||
| `autoDeploy` | Орк САМ подтверждает прод-деплой (гейт 2: `Confirm Deploy`) и деплоит в прод после зелёного staging + всех тех-гейтов, без человека. |
|
||||
|
||||
**Лейблы независимы:**
|
||||
- только `autoApprove` → BRD авто, деплой вручную;
|
||||
- только `autoDeploy` → BRD вручную, деплой авто;
|
||||
- оба → полная автономность (анализ → деплой без единого ручного клика);
|
||||
- без лейблов → **текущее поведение** (оба гейта ручные, нулевая регрессия).
|
||||
|
||||
## 4. Критический инвариант — авто-режим снимает ТОЛЬКО человеческое решение
|
||||
|
||||
Авто-режим **не отключает и не ослабляет ни одну техническую проверку**. Все
|
||||
тех-гейты остаются на месте и блокируют при провале ровно как сейчас:
|
||||
|
||||
- `check_ci_green` (CI зелёный);
|
||||
- `check_staging_status` (staging healthy, ORCH-035);
|
||||
- security-гейт (gitleaks + pip-audit, ORCH-022);
|
||||
- merge-gate / re-test / merge-lease (ORCH-043);
|
||||
- image-freshness / provenance guard (ORCH-058);
|
||||
- merge-verify + regression-guard (ORCH-071/073);
|
||||
- post-deploy monitor (ORCH-021).
|
||||
|
||||
`autoDeploy` **никогда не деплоит сломанное** — он лишь заменяет ручной клик
|
||||
«Confirm Deploy» на авто-проход, и только когда все тех-гейты на ребре
|
||||
`deploy-staging → deploy` уже зелёные. `autoApprove` заменяет ручной клик «Approved»,
|
||||
но артефакты анализа (BRD/TRZ/AC/test-plan) должны существовать (`check_analysis_complete`).
|
||||
|
||||
## 5. Fail-safe (безопасность по умолчанию)
|
||||
|
||||
При любой неоднозначности — **откат к ручному гейту** (never auto):
|
||||
|
||||
- лейбл не распознан / Plane API недоступен / ошибка чтения лейблов;
|
||||
- неоднозначность сопоставления имени лейбла;
|
||||
- любое исключение в логике определения авто-режима.
|
||||
|
||||
Лучше подождать человека, чем авто-пропустить гейт по ошибке. Это согласуется с
|
||||
fail-closed-практикой проекта (ORCH-059 «нет статуса → нет деплоя»).
|
||||
|
||||
## 6. Прозрачность (обязательно)
|
||||
|
||||
Каждый авто-проход гейта **логируется и виден** оператору:
|
||||
|
||||
- запись в лог (кто/почему: `label autoApprove → auto-approved BRD` /
|
||||
`label autoDeploy → auto-confirmed prod deploy`);
|
||||
- Telegram-уведомление + строка/обновление в live-карточке задачи (ORCH-042/087);
|
||||
- Plane-коммент в задаче (как при ручном проходе гейта).
|
||||
|
||||
Слава должен по карточке/Telegram видеть, что задача прошла гейт автоматически (а не
|
||||
руками), и какой именно лейбл это разрешил.
|
||||
|
||||
## 7. Бизнес-требования (BR)
|
||||
|
||||
- **BR-1.** Лейбл `autoApprove` на задаче → BRD подтверждается автоматически
|
||||
(`In Review → Approved`) сразу после успешного analyst (артефакты готовы),
|
||||
конвейер идёт на `architecture`. Закрывается клок `brd_review_ended_at`.
|
||||
- **BR-2.** Лейбл `autoDeploy` на задаче → после зелёного staging и всех тех-гейтов
|
||||
прод-деплой (Phase B) триггерится автоматически, без ручного `Confirm Deploy`.
|
||||
- **BR-3.** Лейблы независимы; комбинация обоих даёт полную автономность анализ→деплой.
|
||||
- **BR-4.** Без лейблов поведение конвейера **не меняется** (оба гейта ручные).
|
||||
- **BR-5.** Тех-гейт красный → авто-режим НЕ проходит гейт; задача встаёт/заворачивается
|
||||
ровно как сейчас (авто-режим не маскирует провал тех-проверки).
|
||||
- **BR-6.** Нераспознанный/спорный лейбл / ошибка чтения → fail-safe к ручному гейту.
|
||||
- **BR-7.** Каждый авто-проход гейта логируется и виден в карточке/Telegram + Plane.
|
||||
- **BR-8.** Лейблы `autoApprove` и `autoDeploy` должны существовать в Plane-проекте ORCH
|
||||
(сейчас их нет — создать через labels API; инфра-предусловие).
|
||||
- **BR-9.** Раскат под kill-switch (как ORCH-035/043/059/088); выключенный флаг →
|
||||
строго прежнее поведение (нулевая регрессия для enduro-trails и для самого ORCH).
|
||||
- **BR-10.** Авто-проходы — только для self-hosting/applicable репо по тому же
|
||||
условному принципу, что и self-deploy (Phase A/B существуют только для self-hosting).
|
||||
Гейт BRD логически применим к любому репо, но раскат гейтится флагом/scope.
|
||||
|
||||
## 8. Вне scope (НЕ делаем в этой задаче)
|
||||
|
||||
- Любая логика «Стрим/человек ревьюит BRD» (отменённый подход).
|
||||
- Управление лейблами из UI оркестратора.
|
||||
- Авто-режим для REQUEST_CHANGES / откатов reviewer/tester (это не человеческие гейты —
|
||||
это технические вердикты, они и так автоматические).
|
||||
- Снятие/ослабление любого технического гейта.
|
||||
- Авто-снятие per-repo freeze (ORCH-088) — freeze остаётся ручным.
|
||||
|
||||
## 9. Допущения и зависимости
|
||||
|
||||
- Plane labels API v1 работает (`POST /labels/` подтверждён в бизнес-запросе; GET
|
||||
лейблов проекта и поле `labels` issue — проверить на этапе архитектуры/разработки).
|
||||
- Идёт поверх ORCH-088 (serial gate) — авто-режим совместим с serial e2e: serial-gate
|
||||
сериализует задачи, авто-режим убирает человеческие паузы внутри прохода одной задачи.
|
||||
- Self-deploy Phase A/B/C (ORCH-036/059/071) — точки врезки авто-деплоя.
|
||||
210
docs/work-items/ORCH-089/02-trz.md
Normal file
210
docs/work-items/ORCH-089/02-trz.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 02 — ТЗ: Авто-режим по лейблам (autoApprove + autoDeploy)
|
||||
|
||||
**Work Item:** ORCH-089
|
||||
**Базируется на BRD:** `01-brd.md`
|
||||
|
||||
> ТЗ фиксирует **что** должно измениться (модули, API, БД, гейты, артефакты,
|
||||
> флаги) и предметные требования к поведению. Архитектурное **как** (структура
|
||||
> leaf-модуля, стратегия кэша лейблов, точная сигнатура хелперов) — за архитектором
|
||||
> (ADR `06-adr/`). ТЗ задаёт границы, которые архитектура обязана соблюсти.
|
||||
|
||||
---
|
||||
|
||||
## 1. Обзор изменения
|
||||
|
||||
Ввести два независимых авто-прохода человеческих гейтов, управляемых лейблами Plane
|
||||
на конкретной задаче:
|
||||
|
||||
- **autoApprove** — авто-проход гейта BRD (`analysis`: `In Review → Approved`).
|
||||
- **autoDeploy** — авто-проход гейта прод-деплоя (`deploy`: `Confirm Deploy` → Phase B).
|
||||
|
||||
Принцип врезки — аддитивно, по образцу условных под-гейтов (ORCH-035/043/058/088):
|
||||
leaf-модуль чистой логики (never-raise) + точечные врезки в существующие точки
|
||||
принятия решений + флаги в `config.py`. **`STAGE_TRANSITIONS` и реестр `QG_CHECKS`
|
||||
НЕ трогаются** — авто-режим переиспользует уже существующие переходы и гейты, лишь
|
||||
устраняя ожидание человеческого сигнала.
|
||||
|
||||
---
|
||||
|
||||
## 2. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль изменения |
|
||||
|--------|----------------|
|
||||
| `src/labels.py` (**новый, leaf**) | Чистая логика авто-режима: `auto_approve_applies(repo)`, `auto_deploy_applies(repo)`, `has_label(work_item_id, label, project_id) -> bool/None`, нормализация имён лейблов, fail-safe. never-raise. |
|
||||
| `src/plane_sync.py` | Новая функция чтения лейблов issue из Plane API (`fetch_issue_labels`) + резолв карты лейблов проекта (имя↔uuid, с кэшем по образцу `get_project_states`). Новый сеттер статуса `set_issue_approved` (PATCH в Approved-UUID) для индикации авто-аппрува. |
|
||||
| `src/stage_engine.py` | Врезка autoApprove в `_handle_analysis_approved_flow` (ветка `files_ok`, после `set_issue_in_review`). Врезка autoDeploy в `_handle_self_deploy_phase_a` (после advance на `deploy`, перед возвратом). |
|
||||
| `src/config.py` | Новые флаги `auto_label_enabled`, `auto_approve_label`, `auto_deploy_label`, `auto_label_repos` (+ при необходимости TTL кэша лейблов). |
|
||||
| `src/main.py` (`GET /queue`) | Аддитивный блок наблюдаемости `auto_labels` (опционально: счётчики авто-проходов). |
|
||||
| `src/webhooks/plane.py` | (Опц.) если payload вебхука несёт `labels` — использовать как быстрый путь; иначе чтение через `fetch_issue_labels`. Источник истины лейблов — Plane API (надёжнее payload). |
|
||||
|
||||
> Точные имена функций/флагов — ориентир; финальные сигнатуры закрепляет ADR.
|
||||
> Обязательное требование: вся логика определения авто-режима — **never-raise** и
|
||||
> при ошибке возвращает «нет авто» (fail-safe к ручному гейту, BR-6).
|
||||
|
||||
---
|
||||
|
||||
## 3. Точки врезки (insertion points) — предметные требования
|
||||
|
||||
### 3.1 Гейт BRD (autoApprove)
|
||||
|
||||
**Текущее поведение** (`src/stage_engine.py::_handle_analysis_approved_flow`, ветка
|
||||
`files_ok`, ~стр. 584–599):
|
||||
1. `set_issue_in_review(work_item_id)`;
|
||||
2. Plane-коммент «артефакты готовы»;
|
||||
3. `notify_approve_requested(task_id)`;
|
||||
4. `return` — **без advance**; ждёт ручного Approved через webhook
|
||||
(`handle_verdict(approved=True)` → `_try_advance_stage` → advance на `architecture`).
|
||||
|
||||
**Требуемое поведение при `autoApprove`:**
|
||||
- ПОСЛЕ установки `In Review` и коммента (для прозрачности и клока) проверить лейбл
|
||||
`autoApprove` на задаче;
|
||||
- если лейбл есть И `auto_approve_applies(repo)` И `auto_label_enabled`:
|
||||
- выставить Plane-статус **Approved** (индикация; `set_issue_approved`);
|
||||
- залогировать авто-проход (`label autoApprove → BRD auto-approved`);
|
||||
- отправить Telegram + Plane-коммент о факте авто-аппрува (BR-7, прозрачность);
|
||||
- инициировать тот же advance, что делает ручной Approved, т.е. переход
|
||||
`analysis → architecture` через штатный путь (`advance_stage(..., finished_agent=None)`
|
||||
с `qg_passed`/`approved-via-status`-семантикой), чтобы:
|
||||
- закрылся клок `brd_review_ended_at` (`mark_brd_review_ended`),
|
||||
- выполнились все стандартные пост-переходные эффекты (карточка, plane-sync);
|
||||
- если лейбла нет / ошибка чтения → **прежнее поведение** (return, ждём человека).
|
||||
|
||||
> Требование к реализации advance: НЕ дублировать переходную логику. Авто-аппрув
|
||||
> обязан идти через тот же advance-путь, что и человеческий Approved (единый источник
|
||||
> истины перехода). Защита от двойного advance/гонки с реальным webhook — идемпотентность
|
||||
> (advance применяется один раз; повторный сигнал — no-op).
|
||||
|
||||
### 3.2 Гейт прод-деплоя (autoDeploy)
|
||||
|
||||
**Текущее поведение** (`src/stage_engine.py::_handle_self_deploy_phase_a`, ~стр. 1151):
|
||||
вызывается на ребре `deploy-staging → deploy` ПОСЛЕ зелёных под-гейтов (security →
|
||||
merge-gate → image-freshness → staging). Делает:
|
||||
1. `update_task_stage(task_id, "deploy")` + `notify_stage_change`;
|
||||
2. `set_issue_awaiting_deploy`;
|
||||
3. `write_marker(APPROVE_REQUESTED)`;
|
||||
4. Plane-коммент + Telegram «смените статус на Confirm Deploy»;
|
||||
5. `return` — ждёт ручного `Confirm Deploy` → `handle_confirm_deploy` →
|
||||
`advance_stage(confirm_deploy=True)` → `_handle_self_deploy_phase_b` (initiate_deploy).
|
||||
|
||||
**Требуемое поведение при `autoDeploy`:**
|
||||
- Все тех-гейты ребра `deploy-staging → deploy` уже зелёные к моменту Phase A
|
||||
(иначе сюда не дошли бы) — это структурно гарантирует BR-5 (авто не деплоит сломанное);
|
||||
- ПОСЛЕ advance на `deploy` (шаг 1) проверить лейбл `autoDeploy`;
|
||||
- если лейбл есть И `auto_deploy_applies(repo)` И `auto_label_enabled`:
|
||||
- залогировать авто-проход (`label autoDeploy → prod deploy auto-confirmed`);
|
||||
- Telegram + Plane-коммент о факте авто-деплоя (BR-7);
|
||||
- инициировать Phase B тем же путём, что ручной Confirm Deploy
|
||||
(`_handle_self_deploy_phase_b(...)`), сохранив идемпотентность (маркер `INITIATED`);
|
||||
- индикация статуса — `Deploying` (ставит уже сам Phase B);
|
||||
- если лейбла нет / ошибка → **прежнее поведение** (Phase A ждёт человека).
|
||||
|
||||
> Требование: НЕ обходить и НЕ дублировать тех-гейты. autoDeploy запускается строго
|
||||
> в точке, где Phase A уже прошёл все под-гейты. Phase C (finalizer) + merge-verify +
|
||||
> regression-guard + post-deploy monitor остаются неизменны и продолжают верифицировать
|
||||
> результат деплоя.
|
||||
|
||||
---
|
||||
|
||||
## 4. Изменения Plane API
|
||||
|
||||
Новых endpoint оркестратора (FastAPI) — **нет**. Изменяется только клиентское
|
||||
взаимодействие с Plane API v1:
|
||||
|
||||
| Действие | Endpoint Plane | Назначение |
|
||||
|----------|----------------|------------|
|
||||
| Чтение лейблов issue | `GET /workspaces/{slug}/projects/{pid}/issues/{issue_id}/` → поле `labels` (список uuid) | Узнать, какие лейблы навешены на задачу. |
|
||||
| Карта лейблов проекта | `GET /workspaces/{slug}/projects/{pid}/labels/` → `[{id,name}]` | Сопоставить uuid лейбла ↔ имя (`autoApprove`/`autoDeploy`). Кэшировать (TTL, образец `get_project_states`/`plane_states_ttl_s`). |
|
||||
| Установка Approved | `PATCH /…/issues/{issue_id}/` `{"state": <approved_uuid>}` | Индикация авто-аппрува BRD (`set_issue_approved`, через `get_project_states(...)["approved"]`). |
|
||||
| (Инфра) создание лейблов | `POST /…/labels/` | Однократно создать `autoApprove` и `autoDeploy` в проекте ORCH (см. `07-infra-requirements.md`). |
|
||||
|
||||
**Требования:**
|
||||
- Все GET/PATCH к Plane — через существующие `PLANE_HEADERS`/`_resolve_project_id`,
|
||||
таймаут как у соседей (10с), never-raise.
|
||||
- Сопоставление лейбла — по **имени** (нормализованному: регистр/пробелы), резолвенному
|
||||
из карты лейблов проекта; неоднозначность/нет совпадения → «нет лейбла» (fail-safe).
|
||||
- Чтение лейблов НЕ должно блокировать конвейер при недоступности Plane: ошибка →
|
||||
«нет авто» → ручной гейт.
|
||||
|
||||
---
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
|
||||
**Не требуются.** Авто-режим — stateless относительно БД:
|
||||
- источник истины лейблов — Plane (читается на гейте);
|
||||
- идемпотентность авто-деплоя обеспечена существующими sentinel-маркерами
|
||||
(`APPROVE_REQUESTED`/`INITIATED`, ORCH-036), а не новой колонкой;
|
||||
- клок `brd_review_*` уже существует (ORCH-087).
|
||||
|
||||
Если архитектура решит кэшировать факт авто-прохода для наблюдаемости — допускается
|
||||
**аддитивная** идемпотентная миграция (`_ensure_column`, образец ORCH-065 `jobs.pid`),
|
||||
но это не требование ТЗ (предпочтительно без миграции, restart-safe через Plane/маркеры).
|
||||
|
||||
---
|
||||
|
||||
## 6. Новые QG checks
|
||||
|
||||
**Не вводятся.** Авто-режим не добавляет проверок качества — он устраняет ожидание
|
||||
человеческого сигнала на существующих гейтах. Реестр `QG_CHECKS` и
|
||||
`check_analysis_approved` / `check_deploy_status` / `check_staging_status` —
|
||||
**без изменений**. (Это сознательно: добавление QG-чека усложнило бы матрицу и нарушило
|
||||
инвариант «STAGE_TRANSITIONS/QG_CHECKS не трогаются», характерный для соседних под-гейтов.)
|
||||
|
||||
---
|
||||
|
||||
## 7. Конфигурация (флаги `src/config.py`)
|
||||
|
||||
По образцу ORCH-035/043/059/088 (kill-switch + CSV scope):
|
||||
|
||||
| Флаг | Тип / дефолт | Назначение |
|
||||
|------|--------------|------------|
|
||||
| `auto_label_enabled` | `bool = True` (env `ORCH_AUTO_LABEL_ENABLED`) | Глобальный kill-switch обоих авто-режимов. `False` → строго прежнее поведение (оба гейта ручные). |
|
||||
| `auto_approve_label` | `str = "autoApprove"` | Имя лейбла гейта BRD. |
|
||||
| `auto_deploy_label` | `str = "autoDeploy"` | Имя лейбла гейта деплоя. |
|
||||
| `auto_label_repos` | `str = ""` (CSV) | Scope. Пусто → self-hosting only (как ORCH-035/043), либо «все репо» — выбор фиксирует ADR; дефолт безопасный (self-hosting). |
|
||||
| `auto_label_states_ttl_s` | `int` (опц.) | TTL кэша карты лейблов проекта (образец `plane_states_ttl_s`). |
|
||||
|
||||
**Требование:** при `auto_label_enabled=False` — нулевая регрессия (ни одного нового
|
||||
сетевого вызова на гейтах, поведение 1:1 как до ORCH-089).
|
||||
|
||||
---
|
||||
|
||||
## 8. Наблюдаемость
|
||||
|
||||
- Каждый авто-проход → `logger.info` с причиной (label X → действие).
|
||||
- Telegram-уведомление + обновление live-карточки (ORCH-042/087, never-raise).
|
||||
- Plane-коммент в задаче (автор — `analyst` для BRD, `deployer` для деплоя — по образцу
|
||||
существующих комментов гейтов).
|
||||
- (Опц.) аддитивный блок `auto_labels` в `GET /queue` (enabled, label-имена, scope,
|
||||
счётчики `auto_approved_total`/`auto_deployed_total`) — образец блоков
|
||||
`reconcile`/`serial_gate`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Артефакты pipeline
|
||||
|
||||
Новых обязательных артефактов задачи **нет**. Авто-проходы отражаются в:
|
||||
- Plane-комментах и Telegram/карточке (прозрачность, BR-7);
|
||||
- существующих логах деплоя (`14-deploy-log.md` для autoDeploy — пишется Phase C как сейчас).
|
||||
|
||||
Документация golden-source (обязательно в этом же PR):
|
||||
- `CLAUDE.md` — раздел про авто-режим по лейблам (флаги, инвариант «снимает только
|
||||
человеческое решение»);
|
||||
- `docs/architecture/README.md` — описание врезок autoApprove/autoDeploy + флаги;
|
||||
- `06-adr/ADR-001-*.md` — архитектурное решение (точки врезки, fail-safe, чтение лейблов);
|
||||
- `07-infra-requirements.md` — создание лейблов `autoApprove`/`autoDeploy` в Plane ORCH;
|
||||
- `CHANGELOG.md` — `## [Unreleased]`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Инварианты (что НЕ должно измениться)
|
||||
|
||||
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_analysis_approved`,
|
||||
`check_deploy_status`, `check_staging_status` — без изменений.
|
||||
- Все технические под-гейты (security/merge-gate/image-freshness/merge-verify/
|
||||
regression-guard/post-deploy) — без изменений; авто-режим их не обходит.
|
||||
- Ручной путь (без лейблов) — 1:1 как сейчас.
|
||||
- Схема БД, exit-коды deploy-хука, merge-lease, sentinel-маркеры self-deploy — без изменений.
|
||||
- never-raise: ни одна ошибка авто-режима не роняет конвейер и не пропускает гейт
|
||||
ошибочно (fail-safe к ручному).
|
||||
- Self-hosting: авто-режим НЕ рестартит/не роняет прод вне штатного Phase B (который
|
||||
и так есть); autoDeploy лишь авто-инициирует существующий путь деплоя.
|
||||
153
docs/work-items/ORCH-089/03-acceptance-criteria.md
Normal file
153
docs/work-items/ORCH-089/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 03 — Критерии приёмки: Авто-режим по лейблам (ORCH-089)
|
||||
|
||||
Каждый критерий — чёткое условие PASS/FAIL. Маппинг на BR (`01-brd.md`) и AC
|
||||
бизнес-запроса указан в скобках.
|
||||
|
||||
---
|
||||
|
||||
## AC-1 — autoApprove проходит гейт BRD (BR-1 / BizAC-1)
|
||||
|
||||
**Дано:** задача с лейблом `autoApprove`, analyst успешно завершился (артефакты
|
||||
BRD/TRZ/AC/test-plan на месте), `auto_label_enabled=True`, репо в scope.
|
||||
**Когда:** срабатывает `_handle_analysis_approved_flow` (ветка `files_ok`).
|
||||
**Тогда:**
|
||||
- задача автоматически переходит `analysis → architecture` без человеческого Approved;
|
||||
- Plane-статус выставлен в `Approved` (индикация);
|
||||
- клок `brd_review_ended_at` закрыт (`mark_brd_review_ended`);
|
||||
- авто-проход залогирован + Telegram/карточка/Plane-коммент уведомляют о факте.
|
||||
|
||||
**PASS:** стадия задачи стала `architecture` без внешнего webhook Approved; клок закрыт.
|
||||
**FAIL:** задача осталась в `In Review`/`analysis` ИЛИ advance прошёл без индикации/лога.
|
||||
|
||||
---
|
||||
|
||||
## AC-2 — autoDeploy триггерит прод-деплой (BR-2 / BizAC-2)
|
||||
|
||||
**Дано:** задача с лейблом `autoDeploy` дошла до ребра `deploy-staging → deploy`,
|
||||
все тех-гейты (security, merge-gate, image-freshness, staging) зелёные, Phase A advance
|
||||
на `deploy` выполнен, `auto_label_enabled=True`, репо в scope (self-hosting).
|
||||
**Когда:** срабатывает `_handle_self_deploy_phase_a`.
|
||||
**Тогда:**
|
||||
- Phase B (`_handle_self_deploy_phase_b`) инициируется автоматически, без ручного
|
||||
`Confirm Deploy`;
|
||||
- маркер `INITIATED` выставлен (идемпотентность), finalizer-job (Phase C) поставлен;
|
||||
- Plane-статус → `Deploying`; авто-проход залогирован + Telegram/Plane-коммент.
|
||||
|
||||
**PASS:** прод-деплой инициирован без статуса Confirm Deploy от человека; Phase C армлен.
|
||||
**FAIL:** задача застряла в `Awaiting Deploy`, ожидая ручного Confirm Deploy.
|
||||
|
||||
---
|
||||
|
||||
## AC-3 — оба лейбла → полная автономность (BR-3 / BizAC-3)
|
||||
|
||||
**Дано:** задача с лейблами `autoApprove` И `autoDeploy`, все тех-гейты по пути зелёные.
|
||||
**Когда:** задача проходит конвейер `analysis → … → deploy`.
|
||||
**Тогда:** задача проходит от анализа до прод-деплоя без единого ручного клика
|
||||
(ни Approved, ни Confirm Deploy).
|
||||
|
||||
**PASS:** ноль человеческих гейт-кликов; задача достигла `deploy`/`done` автономно.
|
||||
**FAIL:** конвейер остановился хотя бы на одном из двух человеческих гейтов.
|
||||
|
||||
---
|
||||
|
||||
## AC-4 — без лейблов поведение НЕ меняется (BR-4 / BizAC-4)
|
||||
|
||||
**Дано:** задача без лейблов `autoApprove`/`autoDeploy`.
|
||||
**Когда:** проходит гейты BRD и деплоя.
|
||||
**Тогда:** оба гейта остаются ручными — задача ждёт `In Review → Approved` (человек) и
|
||||
`Awaiting Deploy → Confirm Deploy` (человек), ровно как до ORCH-089.
|
||||
|
||||
**PASS:** на гейте BRD задача в `In Review` ждёт человека; на гейте деплоя — в
|
||||
`Awaiting Deploy` ждёт человека. Нулевая регрессия.
|
||||
**FAIL:** задача без лейблов авто-прошла любой гейт.
|
||||
|
||||
---
|
||||
|
||||
## AC-5 — красный тех-гейт блокирует авто-режим (BR-5 / BizAC-5)
|
||||
|
||||
**Дано:** задача с лейблом `autoDeploy`, но один из тех-гейтов на ребре
|
||||
`deploy-staging → deploy` красный (например, staging unhealthy / merge-gate конфликт /
|
||||
security FAIL / image-freshness mismatch).
|
||||
**Когда:** конвейер достигает ребра деплоя.
|
||||
**Тогда:** Phase A НЕ достигается (под-гейт завернул задачу) → autoDeploy НЕ
|
||||
инициирует Phase B; задача откатывается/встаёт ровно как при ручном режиме.
|
||||
|
||||
**PASS:** при красном тех-гейте прод-деплой НЕ инициирован, несмотря на лейбл; поведение
|
||||
тождественно ручному режиму при том же провале.
|
||||
**FAIL:** autoDeploy инициировал прод-деплой при красном тех-гейте.
|
||||
|
||||
> Аналогично для autoApprove: при отсутствии артефактов (`check_analysis_complete` FAIL)
|
||||
> авто-аппрув не срабатывает (нет advance), задача не уходит на architecture.
|
||||
|
||||
---
|
||||
|
||||
## AC-6 — fail-safe к ручному гейту (BR-6 / BizAC-6)
|
||||
|
||||
**Дано:** одно из: лейбл не распознан; Plane API недоступен при чтении лейблов;
|
||||
неоднозначное сопоставление имени; исключение в логике авто-режима.
|
||||
**Когда:** гейт BRD или деплоя.
|
||||
**Тогда:** авто-режим НЕ срабатывает → откат к ручному гейту (задача ждёт человека);
|
||||
конвейер НЕ падает.
|
||||
|
||||
**PASS:** при ошибке/неоднозначности задача переходит в ручное ожидание (никогда не
|
||||
авто-проход по ошибке); ни одно исключение не всплывает наружу (never-raise).
|
||||
**FAIL:** ошибка чтения лейблов привела к авто-проходу ИЛИ к падению/застреванию конвейера.
|
||||
|
||||
---
|
||||
|
||||
## AC-7 — прозрачность каждого авто-прохода (BR-7 / BizAC-7)
|
||||
|
||||
**Дано:** любой сработавший авто-проход (autoApprove или autoDeploy).
|
||||
**Когда:** гейт пройден автоматически.
|
||||
**Тогда:** факт виден в: (а) логе с причиной (label X → действие); (б) Telegram +
|
||||
live-карточке задачи; (в) Plane-комменте.
|
||||
|
||||
**PASS:** все три канала несут отметку об авто-проходе и о том, какой лейбл его разрешил.
|
||||
**FAIL:** авто-проход произошёл «молча» (нет отметки хотя бы в одном из обязательных
|
||||
каналов: лог + Telegram/карточка + Plane).
|
||||
|
||||
---
|
||||
|
||||
## AC-8 — kill-switch и scope (BR-9 / BR-10)
|
||||
|
||||
**Дано:** `auto_label_enabled=False` (или репо вне `auto_label_repos`).
|
||||
**Когда:** задача с лейблами проходит гейты.
|
||||
**Тогда:** авто-режим полностью отключён — оба гейта ручные, никаких новых сетевых
|
||||
вызовов на гейтах; поведение 1:1 как до ORCH-089 (включая нулевую регрессию для enduro).
|
||||
|
||||
**PASS:** при выключенном флаге лейблы игнорируются, поведение прежнее.
|
||||
**FAIL:** при `False` авто-режим сработал ИЛИ появилась регрессия для не-scope репо.
|
||||
|
||||
---
|
||||
|
||||
## AC-9 — независимость лейблов (BR-3)
|
||||
|
||||
**Дано:** задача только с `autoApprove` (без `autoDeploy`) — и симметрично наоборот.
|
||||
**Тогда:**
|
||||
- только `autoApprove`: BRD авто-проходит, деплой ждёт ручного Confirm Deploy;
|
||||
- только `autoDeploy`: BRD ждёт ручного Approved, деплой авто-проходит.
|
||||
|
||||
**PASS:** каждый лейбл влияет строго на свой гейт, второй гейт остаётся ручным.
|
||||
**FAIL:** один лейбл повлиял на оба гейта.
|
||||
|
||||
---
|
||||
|
||||
## AC-10 — инварианты неизменны (TRZ §10)
|
||||
|
||||
**Тогда:** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_analysis_approved`,
|
||||
`check_deploy_status`, `check_staging_status`, схема БД, все технические под-гейты,
|
||||
sentinel-маркеры self-deploy, exit-коды deploy-хука — **не изменены**.
|
||||
|
||||
**PASS:** diff не затрагивает перечисленные контракты; существующие тесты этих
|
||||
компонентов зелёные.
|
||||
**FAIL:** любой из инвариантных контрактов изменён.
|
||||
|
||||
---
|
||||
|
||||
## AC-11 — документация обновлена (CLAUDE.md §правила 2/6)
|
||||
|
||||
**Тогда:** в том же PR обновлены `CLAUDE.md`, `docs/architecture/README.md`,
|
||||
заведён `06-adr/ADR-001-*`, `07-infra-requirements.md` (создание лейблов), `CHANGELOG.md`.
|
||||
|
||||
**PASS:** документация-golden-source синхронна с кодом.
|
||||
**FAIL:** функционал изменён, документация — нет (reviewer → REQUEST_CHANGES).
|
||||
172
docs/work-items/ORCH-089/04-test-plan.yaml
Normal file
172
docs/work-items/ORCH-089/04-test-plan.yaml
Normal file
@@ -0,0 +1,172 @@
|
||||
work_item: ORCH-089
|
||||
title: "Авто-режим по лейблам: autoApprove (авто-BRD) + autoDeploy (авто-деплой)"
|
||||
description: >
|
||||
План тестов авто-прохода двух человеческих гейтов по лейблам Plane.
|
||||
Фокус юнит-тестов — чистая логика src/labels.py (never-raise, fail-safe) и
|
||||
врезки в stage_engine (autoApprove в _handle_analysis_approved_flow,
|
||||
autoDeploy в _handle_self_deploy_phase_a). Сеть Plane — мокается.
|
||||
Инвариант: STAGE_TRANSITIONS/QG_CHECKS/тех-гейты не трогаются.
|
||||
|
||||
tests:
|
||||
# --- src/labels.py: чистая логика авто-режима (never-raise, fail-safe) -----
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "has_label возвращает True, когда лейбл присутствует на issue (мок Plane labels)"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "has_label возвращает False, когда лейбла нет на issue"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "has_label при ошибке Plane API / таймауте → fail-safe (нет авто, never-raise)"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "Сопоставление имени лейбла нормализовано (регистр/пробелы); неоднозначность → нет авто"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "auto_approve_applies/auto_deploy_applies: scope CSV + self-hosting; пустой scope по дефолту"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "auto_label_enabled=False → has_label/applies дают 'нет авто' без сетевых вызовов"
|
||||
module: tests/test_labels.py
|
||||
expected: PASS
|
||||
|
||||
# --- plane_sync: чтение лейблов + сеттер Approved ---------------------------
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "fetch_issue_labels парсит поле labels issue и резолвит uuid→имя по карте проекта (мок httpx)"
|
||||
module: tests/test_plane_sync_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "Карта лейблов проекта кэшируется с TTL (повтор в окне TTL не делает второй GET)"
|
||||
module: tests/test_plane_sync_labels.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "set_issue_approved PATCHит issue в Approved-UUID (get_project_states['approved']); never-raise при ошибке"
|
||||
module: tests/test_plane_sync_labels.py
|
||||
expected: PASS
|
||||
|
||||
# --- autoApprove: врезка в _handle_analysis_approved_flow ------------------
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "autoApprove + артефакты готовы → авто-advance analysis→architecture, Approved выставлен, клок brd_review_ended закрыт"
|
||||
module: tests/test_auto_approve_brd.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "Без лейбла autoApprove → прежнее поведение: In Review, return без advance (ждёт человека)"
|
||||
module: tests/test_auto_approve_brd.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "autoApprove, но артефактов нет (check_analysis_complete FAIL) → НЕ advance (AC-5 для BRD)"
|
||||
module: tests/test_auto_approve_brd.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: "autoApprove идёт через тот же advance-путь, что ручной Approved (нет дублирования логики; идемпотентно при повторе)"
|
||||
module: tests/test_auto_approve_brd.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: "autoApprove: авто-проход логируется + Telegram/карточка/Plane-коммент вызваны (прозрачность AC-7)"
|
||||
module: tests/test_auto_approve_brd.py
|
||||
expected: PASS
|
||||
|
||||
# --- autoDeploy: врезка в _handle_self_deploy_phase_a ----------------------
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "autoDeploy + Phase A advance на deploy → автоматически вызывается _handle_self_deploy_phase_b (initiate_deploy)"
|
||||
module: tests/test_auto_deploy.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-16
|
||||
type: unit
|
||||
description: "Без лейбла autoDeploy → прежнее поведение: Awaiting Deploy, ждёт ручного Confirm Deploy"
|
||||
module: tests/test_auto_deploy.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-17
|
||||
type: unit
|
||||
description: "autoDeploy идемпотентен: маркер INITIATED уже стоит → повторный авто-триггер = no-op"
|
||||
module: tests/test_auto_deploy.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-18
|
||||
type: unit
|
||||
description: "autoDeploy не-self репо / вне scope → no-op (Phase A/B существуют только для self-hosting)"
|
||||
module: tests/test_auto_deploy.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-19
|
||||
type: unit
|
||||
description: "autoDeploy: авто-проход логируется + Telegram + Plane-коммент (прозрачность AC-7)"
|
||||
module: tests/test_auto_deploy.py
|
||||
expected: PASS
|
||||
|
||||
# --- независимость лейблов + kill-switch -----------------------------------
|
||||
- id: TC-20
|
||||
type: unit
|
||||
description: "Только autoApprove (без autoDeploy): BRD авто, деплой ждёт человека (AC-9)"
|
||||
module: tests/test_auto_label_combinations.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-21
|
||||
type: unit
|
||||
description: "Только autoDeploy (без autoApprove): BRD ждёт человека, деплой авто (AC-9)"
|
||||
module: tests/test_auto_label_combinations.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-22
|
||||
type: unit
|
||||
description: "auto_label_enabled=False → оба гейта ручные при наличии обоих лейблов (kill-switch AC-8)"
|
||||
module: tests/test_auto_label_combinations.py
|
||||
expected: PASS
|
||||
|
||||
# --- интеграция: сквозной авто-проход на ребрах конвейера ------------------
|
||||
- id: TC-23
|
||||
type: integration
|
||||
description: "Оба лейбла + все тех-гейты зелёные → задача проходит analysis→deploy без ручных кликов (AC-3)"
|
||||
module: tests/test_auto_labels_integration.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-24
|
||||
type: integration
|
||||
description: "autoDeploy + красный staging/merge-gate → Phase A не достигнут, Phase B не инициирован (AC-5)"
|
||||
module: tests/test_auto_labels_integration.py
|
||||
expected: PASS
|
||||
|
||||
# --- инварианты / регрессия ------------------------------------------------
|
||||
- id: TC-25
|
||||
type: integration
|
||||
description: "Регресс: задача без лейблов проходит оба гейта ровно как до ORCH-089 (AC-4)"
|
||||
module: tests/test_auto_labels_integration.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-26
|
||||
type: unit
|
||||
description: "Инвариант: STAGE_TRANSITIONS и реестр QG_CHECKS не изменены ORCH-089 (snapshot-сверка)"
|
||||
module: tests/test_auto_labels_invariants.py
|
||||
expected: PASS
|
||||
220
docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md
Normal file
220
docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# ADR-001: Авто-режим по лейблам — autoApprove (гейт BRD) + autoDeploy (гейт прод-деплоя)
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
В конвейере два **человеческих** гейта (точки, где конвейер останавливается и ждёт
|
||||
ручного клика человека):
|
||||
|
||||
1. **Гейт BRD** (`analysis`): после успешного analyst задача переводится в `In Review`
|
||||
(`_handle_analysis_approved_flow`, ветка `files_ok`) и ждёт ручного Plane-статуса
|
||||
**Approved**. Approved прилетает вебхуком → `handle_verdict(approved=True)` →
|
||||
`_try_advance_stage` → `advance_stage(..., finished_agent=None)` (ветка
|
||||
`check_analysis_approved` / `approved-via-status`) → advance `analysis → architecture`
|
||||
+ `mark_brd_review_ended`.
|
||||
2. **Гейт прод-деплоя** (`deploy`): на ребре `deploy-staging → deploy` после зелёных
|
||||
под-гейтов (security → merge-gate → image-freshness → staging) выполняется Phase A
|
||||
(`_handle_self_deploy_phase_a`): advance на `deploy` + `Awaiting Deploy` + маркер
|
||||
`APPROVE_REQUESTED` + просьба сменить статус на **Confirm Deploy**. Confirm Deploy
|
||||
прилетает вебхуком → `handle_confirm_deploy` → `advance_stage(..., confirm_deploy=True)`
|
||||
→ `_handle_self_deploy_phase_b` (`initiate_deploy` + маркер `INITIATED` + finalizer).
|
||||
|
||||
Для задач, которым **доверяем** (пакетный автономный прогон, эпик ORCH-088), оба ручных
|
||||
клика избыточны и тормозят прогон «10–20 задач за ночь». Нужно снять **только эти два
|
||||
человеческих решения** — выборочно (на уровне отдельной задачи), декларативно (лейблом
|
||||
Plane), обратимо, прозрачно и **не трогая ни одной технической проверки** (BRD §4).
|
||||
|
||||
Прошлый подход «Стрим ревьюит и апрувит BRD» (09.06) ОТМЕНЁН. Актуальная модель —
|
||||
лейблы на задаче (`autoApprove`, `autoDeploy`), независимые, без участия людей.
|
||||
|
||||
## Решение
|
||||
|
||||
Аддитивная врезка по образцу условных под-гейтов проекта (ORCH-035/043/058/059/088):
|
||||
**leaf-модуль чистой логики (never-raise) + точечные врезки в существующие точки принятия
|
||||
решений + флаги в `config.py`**. `STAGE_TRANSITIONS`, реестр `QG_CHECKS` и все `check_*`
|
||||
**НЕ трогаются** — авто-режим переиспользует уже существующие переходы и гейты, лишь
|
||||
устраняя ожидание человеческого сигнала.
|
||||
|
||||
### D1. Новый leaf-модуль `src/labels.py` (чистая логика, never-raise)
|
||||
|
||||
Контракт «никогда не падает; при любой ошибке/неоднозначности → "нет авто"»
|
||||
(fail-safe к ручному гейту, BR-6/AC-6). Публичная поверхность:
|
||||
|
||||
| Функция | Контракт |
|
||||
|---------|----------|
|
||||
| `auto_approve_applies(repo) -> bool` | scope autoApprove (см. D5). False при kill-switch/ошибке. |
|
||||
| `auto_deploy_applies(repo) -> bool` | scope autoDeploy (см. D5). False при kill-switch/ошибке. |
|
||||
| `has_label(work_item_id, label_name, project_id=None) -> bool` | True ⇔ на issue навешен лейбл с именем `label_name` (нормализованным). **Любая** ошибка/неоднозначность/недоступность Plane → **False**. |
|
||||
| `snapshot() -> dict` | read-only для `GET /queue` (enabled, имена лейблов, scope). never-raise. |
|
||||
|
||||
`has_label` резолвит так (всё внутри одного `try/except → False`):
|
||||
1. `labels = plane_sync.fetch_issue_labels(work_item_id, project_id)` — список uuid
|
||||
лейблов issue (None при ошибке → `has_label=False`);
|
||||
2. `name_map = plane_sync.get_project_labels(project_id)` — `{normalized_name → uuid}`
|
||||
карта лейблов проекта (кэш с TTL, см. D4);
|
||||
3. нормализация искомого имени (`_normalize`: `strip().casefold()`);
|
||||
4. `target_uuid = name_map.get(normalized)`; если нет совпадения **или** имя
|
||||
неоднозначно (две записи проекта свелись к одному нормализованному имени) →
|
||||
**False** (fail-safe);
|
||||
5. `return target_uuid in set(labels)`.
|
||||
|
||||
> Источник истины лейблов — **Plane API**, не payload вебхука: обе точки врезки —
|
||||
> launcher-path (analyst-finished / staging-deployer-finished), где payload недоступен;
|
||||
> API надёжнее и единообразен. (Подтверждено: `src/webhooks/plane.py` не несёт `labels`.)
|
||||
|
||||
### D2. Чтение лейблов в `src/plane_sync.py`
|
||||
|
||||
- `fetch_issue_labels(work_item_id, project_id=None) -> list[str] | None` —
|
||||
`GET …/issues/{issue_id}/` → поле `labels` (список uuid). Через
|
||||
`_resolve_project_id` + `find_issue_id` + `PLANE_HEADERS`, таймаут 10с (как соседи).
|
||||
Ошибка/issue-not-found → `None` (отличимо от пустого списка `[]` = «лейблов нет»).
|
||||
- `get_project_labels(project_id) -> dict[str,str]` —
|
||||
`GET …/projects/{pid}/labels/` → `{normalized_name → uuid}`. **Кэш по образцу
|
||||
`get_project_states`** (`_LABELS_CACHE` per-project + TTL `_cache_record_fresh`),
|
||||
чтобы не бить API на каждом гейте. Стейл-кэш при сетевой ошибке отдаётся как у
|
||||
`get_project_states` (safer-than-empty). Пустой результат / ошибка без кэша → `{}`
|
||||
→ `has_label=False`.
|
||||
- `set_issue_approved(work_item_id, project_id=None)` — новый сеттер, 1:1 калька
|
||||
`set_issue_in_review`: `state_id = get_project_states(pid)["approved"]` →
|
||||
`_set_issue_state_direct`. Ключ `approved` уже существует в `_DEFAULT_STATES`
|
||||
и `_PLANE_NAME_TO_KEY` (`"Approved" → "approved"`), отдельная инфра-настройка не нужна.
|
||||
|
||||
### D3. Врезка autoApprove — `_handle_analysis_approved_flow`, ветка `files_ok`
|
||||
|
||||
После существующих шагов (`set_issue_in_review` + analyst-коммент + `notify_approve_requested`
|
||||
— оставлены ради клока/прозрачности/симметрии с ручным путём), ДО `return`:
|
||||
|
||||
```
|
||||
if labels.auto_approve_applies(repo) and labels.has_label(work_item_id, settings.auto_approve_label):
|
||||
plane_sync.set_issue_approved(work_item_id) # индикация (AC-1), транзиентна*
|
||||
logger.info("Task …: label autoApprove → BRD auto-approved")
|
||||
plane_add_comment(work_item_id, "<auto-approve via label autoApprove>", author="analyst")
|
||||
send_telegram("✅ <ORC-NNN>: BRD авто-подтверждён (лейбл autoApprove)")
|
||||
auto = advance_stage(task_id, current_stage, repo, work_item_id, branch, finished_agent=None)
|
||||
result.advanced = auto.advanced; result.to_stage = auto.to_stage
|
||||
result.note = "auto-approved-via-label"
|
||||
return
|
||||
# (нет лейбла / fail-safe) → прежнее поведение: return, ждём человека.
|
||||
```
|
||||
|
||||
**Ключевое требование — НЕ дублировать переходную логику.** Авто-аппрув идёт через тот
|
||||
же `advance_stage(..., finished_agent=None)`, что и человеческий Approved-вебхук: ветка
|
||||
`check_analysis_approved` с `agent is None` → `qg_passed=True` (`approved-via-status`) →
|
||||
advance `analysis → architecture` → `mark_brd_review_ended` (клок) → штатные
|
||||
post-эффекты (карточка, plane-sync, enqueue architect). Единый источник истины перехода.
|
||||
|
||||
> *Транзиентность Approved-статуса:* сразу после advance `plane_notify_stage` выставит
|
||||
> статус `Architecture`, перекрыв `Approved`. Это ожидаемо — `set_issue_approved` даёт
|
||||
> мгновенную индикацию/симметрию, а **durable**-прозрачность несут лог + Telegram + Plane-коммент
|
||||
> (AC-7). Re-entrancy безопасна: вложенный `advance_stage` не возвращается в
|
||||
> `_handle_analysis_approved_flow` (та ветка требует `agent=='analyst'`; вложенный вызов
|
||||
> идёт с `finished_agent=None`) — рекурсии нет.
|
||||
|
||||
### D4. Врезка autoDeploy — `_handle_self_deploy_phase_a`, ранняя ветка
|
||||
|
||||
Сразу после `update_task_stage(task_id, "deploy")` + `notify_stage_change` +
|
||||
`self_deploy.clear_state(repo, work_item_id)` (всегда — wipe стейл-маркеров), ДО
|
||||
«ask-human» блока:
|
||||
|
||||
```
|
||||
if labels.auto_deploy_applies(repo) and labels.has_label(work_item_id, settings.auto_deploy_label):
|
||||
logger.info("Task …: label autoDeploy → prod deploy auto-confirmed")
|
||||
plane_add_comment(work_item_id, "<auto-confirm prod deploy via label autoDeploy>", author="deployer")
|
||||
send_telegram("🚀 <ORC-NNN>: прод-деплой авто-подтверждён (лейбл autoDeploy)")
|
||||
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result) # INITIATED + Deploying + finalizer
|
||||
return
|
||||
# (нет лейбла / fail-safe) → прежний Phase A: set_issue_awaiting_deploy + APPROVE_REQUESTED + «смените на Confirm Deploy».
|
||||
```
|
||||
|
||||
При autoDeploy пропускаются ТОЛЬКО индикативно-человеческие шаги (`set_issue_awaiting_deploy`
|
||||
+ `APPROVE_REQUESTED` + «ask-human» коммент/Telegram) — статус `Deploying` выставит сам
|
||||
Phase B. Идемпотентность прод-деплоя обеспечена существующим маркером `INITIATED` внутри
|
||||
`_handle_self_deploy_phase_b` (повторный заход — no-op). Phase B/C, merge-verify,
|
||||
regression-guard, post-deploy monitor — **неизменны**.
|
||||
|
||||
**Почему BR-5/AC-5 выполнены структурно:** Phase A достигается ТОЛЬКО после зелёных
|
||||
под-гейтов ребра `deploy-staging → deploy` (security → merge-gate → image-freshness →
|
||||
staging — они исполняются ВЫШЕ в `advance_stage` и при FAIL откатывают/возвращают БЕЗ
|
||||
выхода на Phase A). autoDeploy лишь заменяет ручной клик в точке, где все тех-проверки
|
||||
уже зелёные — он физически не может задеплоить сломанное.
|
||||
|
||||
### D5. Scope и kill-switch (флаги `src/config.py`)
|
||||
|
||||
| Флаг | Тип / дефолт | Назначение |
|
||||
|------|--------------|------------|
|
||||
| `auto_label_enabled` | `bool=True` (`ORCH_AUTO_LABEL_ENABLED`) | Глобальный kill-switch обоих авто-режимов. `False` → строго прежнее поведение, **ни одного нового сетевого вызова на гейтах** (AC-8). |
|
||||
| `auto_approve_label` | `str="autoApprove"` | Имя лейбла гейта BRD. |
|
||||
| `auto_deploy_label` | `str="autoDeploy"` | Имя лейбла гейта деплоя. |
|
||||
| `auto_label_repos` | `str=""` (CSV) | Scope. **Пусто → self-hosting only** (`orchestrator`). |
|
||||
| `auto_label_states_ttl_s` | `int=300` | TTL кэша карты лейблов проекта (образец `plane_states_ttl_s`). |
|
||||
|
||||
`auto_approve_applies`/`auto_deploy_applies` — калька `self_deploy_applies`:
|
||||
`auto_label_enabled=False` → всегда False; непустой `auto_label_repos` → только
|
||||
перечисленные репо; пустой → **self-hosting only** (`is_self_hosting_repo`). Решение
|
||||
по дефолту scope (BRD оставил выбор): **self-hosting only** — безопасный дефолт (BR-10),
|
||||
к тому же autoDeploy-врезка живёт в Phase A, которая существует только для self-hosting.
|
||||
Единый scope-флаг на оба лейбла (минимальная матрица); раздельные репо-скоупы — follow-up
|
||||
при необходимости.
|
||||
|
||||
**Порядок проверки на гейте (важно для AC-8):** `applies(repo)` проверяется ПЕРВЫМ
|
||||
(локальный, без сети). Только если `applies==True` вызывается `has_label` (сеть). При
|
||||
выключенном флаге `applies` сразу False → `has_label` не вызывается → нулевой сетевой
|
||||
оверхед, нулевая регрессия для enduro.
|
||||
|
||||
### D6. Идемпотентность и взаимодействие с reconciler/serial-gate
|
||||
|
||||
- **autoApprove vs реальный Approved-вебхук / reconciler F-2:** после авто-advance стадия
|
||||
= `architecture`. Поздний человеческий Approved или F-2 (plane-side) увидят уже
|
||||
`architecture` → не повторят analysis-advance (тот же эффект, что и человеческий
|
||||
double-click сегодня). Advance применяется один раз.
|
||||
- **autoDeploy:** идемпотентность — существующий маркер `INITIATED` (Phase B).
|
||||
- **serial-gate (ORCH-088):** сериализует claim analyst-job на уровне FIFO — авто-режим
|
||||
ортогонален (убирает паузы ВНУТРИ прохода одной задачи), не конфликтует.
|
||||
- **reconciler F-1** analysis не трогает (человеческий гейт) — авто-аппрув идёт через
|
||||
launcher-path, не через F-1.
|
||||
|
||||
### D7. Наблюдаемость (AC-7)
|
||||
|
||||
Каждый авто-проход → `logger.info` (label X → действие) + Telegram + Plane-коммент
|
||||
(автор `analyst` для BRD, `deployer` для деплоя — образец существующих гейт-комментов) +
|
||||
обновление live-карточки через штатный advance/notify. Аддитивный read-only блок
|
||||
`auto_labels` в `GET /queue` (`labels.snapshot()`: enabled, имена лейблов, scope) — образец
|
||||
блоков `reconcile`/`serial_gate`. Счётчики авто-проходов — best-effort/опционально (v1
|
||||
можно in-memory или опустить; БД не трогаем).
|
||||
|
||||
### D8. Схема БД — без изменений
|
||||
|
||||
Авто-режим stateless относительно БД: источник истины лейблов — Plane (читается на гейте);
|
||||
идемпотентность autoDeploy — существующие sentinel-маркеры (`APPROVE_REQUESTED`/`INITIATED`);
|
||||
клок `brd_review_*` уже существует (ORCH-087). Миграции нет (restart-safe через Plane/маркеры).
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы:**
|
||||
- Минимальная, аддитивная поверхность изменения; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`
|
||||
неприкосновенны (AC-10). Единый источник истины перехода (переиспользование advance/Phase B).
|
||||
- Все тех-гейты на месте; autoDeploy структурно не может задеплоить сломанное (BR-5/AC-5).
|
||||
- Декларативно и обратимо (снял лейбл → ручной режим). Независимые лейблы (AC-9).
|
||||
- Fail-safe by default (never auto при любой неоднозначности, AC-6); kill-switch + scope
|
||||
→ нулевая регрессия для enduro (AC-8).
|
||||
|
||||
**Минусы / ограничения:**
|
||||
- Approved-статус при autoApprove транзиентен (перекрывается `Architecture`) — durable-аудит
|
||||
несут лог/Telegram/коммент, не Plane-статус.
|
||||
- Чтение лейблов добавляет 1–2 GET к Plane на каждом из двух гейтов применимого репо (с TTL-кэшем
|
||||
карты лейблов; вызывается только когда `applies==True`). При недоступности Plane → fail-safe
|
||||
к ручному гейту (не блок конвейера).
|
||||
- Доверие выражается лейблом — оператор отвечает за то, что autoDeploy навешан осознанно
|
||||
(тех-гейты страхуют от поломки, но не от «не той фичи»).
|
||||
|
||||
**Инфра-предусловие:** лейблы `autoApprove`/`autoDeploy` должны существовать в Plane-проекте
|
||||
ORCH (создать однократно через labels API) — см. `07-infra-requirements.md`. Нет лейбла в
|
||||
проекте → `has_label` всегда False → ручной режим (fail-safe), без ошибок.
|
||||
|
||||
## Связанные
|
||||
- BRD/ТЗ/AC: `docs/work-items/ORCH-089/{01-brd,02-trz,03-acceptance-criteria}.md`
|
||||
- Образцы условной врезки: ADR-0003 (staging), 0006 (merge-gate), 0007 (self-deploy),
|
||||
0017 (serial-gate); ORCH-059 (Confirm Deploy status).
|
||||
- Глобальный ADR: `docs/architecture/adr/adr-0018-auto-label-gates.md`.
|
||||
63
docs/work-items/ORCH-089/07-infra-requirements.md
Normal file
63
docs/work-items/ORCH-089/07-infra-requirements.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 07 — Инфра-требования: ORCH-089 (авто-режим по лейблам)
|
||||
|
||||
## I-1. Создать лейблы в Plane-проекте ORCH (однократно, обязательно)
|
||||
|
||||
Авто-режим управляется лейблами на задаче. В Plane-проекте ORCH сейчас лейблов
|
||||
`autoApprove`/`autoDeploy` **нет** — их нужно создать один раз через labels API:
|
||||
|
||||
```
|
||||
POST {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/labels/
|
||||
Headers: PLANE_HEADERS
|
||||
Body: {"name": "autoApprove"}
|
||||
|
||||
POST {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/labels/
|
||||
Body: {"name": "autoDeploy"}
|
||||
```
|
||||
|
||||
Имена должны **точно** соответствовать `auto_approve_label` / `auto_deploy_label`
|
||||
(дефолты `autoApprove` / `autoDeploy`). Сопоставление в коде — по нормализованному имени
|
||||
(`strip().casefold()`), т.е. регистр/пробелы не критичны, но рекомендуется создать ровно
|
||||
как в дефолте.
|
||||
|
||||
**Fail-safe при отсутствии:** если лейбл в проекте не создан, `labels.has_label` всегда
|
||||
вернёт `False` → задача идёт ручным путём (нулевой риск, без ошибок). То есть создание
|
||||
лейблов — предусловие активации фичи, а не условие стабильности конвейера.
|
||||
|
||||
## I-2. Сброс кэша состояний/лейблов после создания (рекомендуется)
|
||||
|
||||
`get_project_labels` кэширует карту лейблов проекта с TTL `auto_label_states_ttl_s`
|
||||
(дефолт 300с). После создания новых лейблов карта подтянется автоматически в течение TTL;
|
||||
для немедленного эффекта — рестарт не требуется, достаточно дождаться TTL или (если будет
|
||||
добавлен) вызвать reload-хелпер кэша лейблов по образцу `reload_project_states`.
|
||||
|
||||
## I-3. Конфигурация (env, хост mva154)
|
||||
|
||||
По умолчанию фича включена (`auto_label_enabled=True`) и применима только к self-hosting
|
||||
репо (`auto_label_repos=""` → `orchestrator`). Управляющие env (опционально, в `.env`):
|
||||
|
||||
| Env | Дефолт | Эффект |
|
||||
|-----|--------|--------|
|
||||
| `ORCH_AUTO_LABEL_ENABLED` | `true` | Глобальный kill-switch. `false` → оба гейта ручные, нулевой сетевой оверхед. |
|
||||
| `ORCH_AUTO_APPROVE_LABEL` | `autoApprove` | Имя лейбла гейта BRD. |
|
||||
| `ORCH_AUTO_DEPLOY_LABEL` | `autoDeploy` | Имя лейбла гейта деплоя. |
|
||||
| `ORCH_AUTO_LABEL_REPOS` | `` (пусто) | CSV scope. Пусто → self-hosting only. |
|
||||
| `ORCH_AUTO_LABEL_STATES_TTL_S` | `300` | TTL кэша карты лейблов проекта. |
|
||||
|
||||
## I-4. Сетевые/доступ
|
||||
|
||||
Новых endpoint оркестратора нет. Дополнительные **исходящие** вызовы к Plane API v1
|
||||
(те же креды `PLANE_HEADERS`, таймаут 10с):
|
||||
- `GET …/issues/{issue_id}/` (поле `labels`) — чтение лейблов задачи на гейте;
|
||||
- `GET …/projects/{pid}/labels/` — карта лейблов проекта (кэш с TTL);
|
||||
- `PATCH …/issues/{issue_id}/` `{"state": <approved_uuid>}` — индикация авто-аппрува.
|
||||
|
||||
Вызовы — только когда `applies(repo)==True` (kill-switch off / репо вне scope → нет
|
||||
вызовов). Недоступность Plane → fail-safe к ручному гейту (конвейер не блокируется).
|
||||
|
||||
## I-5. Топология / прод-риск
|
||||
|
||||
Self-hosting не меняется: autoDeploy лишь авто-инициирует **существующий** Phase B
|
||||
(detached host-деплой через `scripts/orchestrator-deploy-hook.sh`). Никакого нового пути
|
||||
рестарта прод-контейнера не вводится. Phase C / merge-verify / regression-guard /
|
||||
post-deploy monitor продолжают верифицировать результат. Раскат — под kill-switch
|
||||
(`ORCH_AUTO_LABEL_ENABLED`), деплой self — через обязательный staging-гейт (8501), как всегда.
|
||||
20
docs/work-items/ORCH-089/10-tech-risks.md
Normal file
20
docs/work-items/ORCH-089/10-tech-risks.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 10 — Технические риски: ORCH-089 (авто-режим по лейблам)
|
||||
|
||||
| # | Риск | Вероятность / Impact | Митигация |
|
||||
|---|------|----------------------|-----------|
|
||||
| R-1 | **Ложный авто-проход гейта** при ошибке чтения лейблов (Plane вернул мусор/частичный ответ) → задача авто-проходит, хотя лейбла нет. | Низк. / **Критич.** (групповой self-hosting риск). | `has_label` обёрнут в единый `try/except → False`; `fetch_issue_labels` различает `None` (ошибка) и `[]` (нет лейблов); неоднозначность имени → False. Любая неопределённость → ручной гейт (BR-6/AC-6). Дополнительно: тех-гейты страхуют от деплоя сломанного даже при ложном autoDeploy. |
|
||||
| R-2 | **Двойной advance / гонка** автоApprove с реальным Approved-вебхуком или reconciler F-2. | Сред. / Низк. | Advance применяется один раз: после авто-advance стадия = `architecture`; поздний Approved/F-2 видят `architecture` и не повторяют analysis-переход (как человеческий double-click сегодня). |
|
||||
| R-3 | **Двойной прод-деплой** при autoDeploy (повторный заход Phase A / дубль staging-deployer-finished). | Низк. / Высок. | Идемпотентность Phase B по маркеру `INITIATED`. Phase A после первого прохода advance'ит стадию на `deploy` → guard `current_stage=="deploy-staging"` больше не матчится, повторный Phase A не запускается. `clear_state` в Phase A wipe'ит маркеры только при входе в свежий проход. |
|
||||
| R-4 | **Re-entrancy** вложенного `advance_stage` из `_handle_analysis_approved_flow` → рекурсия. | Низк. / Сред. | Вложенный вызов идёт с `finished_agent=None` и попадает в ветку `approved-via-status`, НЕ в `_handle_analysis_approved_flow` (та требует `agent=='analyst'`). Рекурсии нет. |
|
||||
| R-5 | **Регрессия для enduro / при выключенном флаге** (лишние сетевые вызовы, изменение поведения). | Низк. / Высок. | `applies(repo)` (локальный, без сети) проверяется ПЕРВЫМ; `has_label` (сеть) — только при `applies==True`. `auto_label_enabled=False` или репо вне scope → `applies==False` → нулевой сетевой оверхед, поведение 1:1 (AC-8). |
|
||||
| R-6 | **Лейбл не создан в Plane-проекте** → фича «молча не работает». | Сред. / Низк. | `has_label==False` → ручной гейт (fail-safe, не ошибка). Инфра-предусловие задокументировано (`07-infra-requirements.md` I-1). Прозрачность: отсутствие авто-прохода видно по тому, что задача встала на ручном гейте. |
|
||||
| R-7 | **Транзиентность Approved-статуса** (перекрывается `Architecture` сразу после advance) → оператор не увидит, что прошёл именно авто-аппрув. | Сред. / Низк. | Durable-прозрачность — лог + Telegram + Plane-коммент («auto-approved via label autoApprove») + live-карточка (AC-7). Plane-статус Approved — лишь мгновенная индикация. |
|
||||
| R-8 | **Stale-кэш карты лейблов** (`get_project_labels`) → недавно созданный/снятый лейбл не виден. | Низк. / Низк. | TTL `auto_label_states_ttl_s` (300с) — самозалечивание без рестарта (образец `plane_states_ttl_s`/ORCH-068). Окно ≤ TTL. |
|
||||
| R-9 | **Plane API недоступен на гейте** → задержка/блок конвейера. | Низк. / Сред. | Таймаут 10с (как соседи), never-raise → «нет авто» → ручной гейт. Конвейер не блокируется; задача просто ждёт человека (прежнее поведение). |
|
||||
| R-10 | **Доверие выражено лейблом ошибочно** (autoDeploy навешан не на ту задачу). | Сред. / Сред. | Тех-гейты блокируют поломку (не «не ту фичу»). Лейбл обратим (снять → ручной режим). Зона ответственности оператора; прозрачность авто-прохода (AC-7) даёт раннее обнаружение. |
|
||||
|
||||
## Вывод
|
||||
Доминирующий риск — **R-1 (ложный авто-проход)**; закрывается строгим never-raise / fail-safe
|
||||
контрактом leaf-модуля и тем, что тех-гейты остаются последней линией защиты. Все риски
|
||||
укладываются в установленные проектом паттерны (условный под-гейт + kill-switch + scope +
|
||||
fail-safe), новых классов риска фича не вводит.
|
||||
91
docs/work-items/ORCH-089/12-review.md
Normal file
91
docs/work-items/ORCH-089/12-review.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-089
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-089
|
||||
|
||||
## Summary
|
||||
|
||||
Авто-режим по лейблам Plane (`autoApprove` + `autoDeploy`) реализован строго по ТЗ
|
||||
и ADR: аддитивно, по образцу условных под-гейтов (ORCH-035/043/058/088). Снимаются
|
||||
**только два человеческих решения** (гейт BRD `Approved`, гейт прод-деплоя
|
||||
`Confirm Deploy`); ни одна техническая проверка не тронута. Соответствие всем осям
|
||||
(ТЗ, ADR, качество кода, тесты) — полное; документация-golden-source обновлена в том
|
||||
же PR. Блокирующих findings нет. **Вердикт: APPROVED.**
|
||||
|
||||
## Проверка по осям
|
||||
|
||||
### 1. Соответствие ТЗ (`02-trz.md`)
|
||||
- ✅ Leaf `src/labels.py` (never-raise): `auto_approve_applies`/`auto_deploy_applies`
|
||||
(локальный scope-чек ПЕРВЫМ), `has_label` (единственный сетевой вызов, только при
|
||||
`applies==True` → нулевой оверхед при выключенном флаге, §7/AC-8), `snapshot`.
|
||||
- ✅ `src/plane_sync.py`: `fetch_issue_labels` (`None` при ошибке ≠ `[]`),
|
||||
`get_project_labels` (`{normalized→uuid}`, TTL-кэш `auto_label_states_ttl_s` по
|
||||
образцу `get_project_states`, сентинел `__AMBIGUOUS__` при коллизии имён),
|
||||
`set_issue_approved` (1:1 зеркало `set_issue_in_review`).
|
||||
- ✅ Врезка autoApprove — `_handle_analysis_approved_flow` (ветка `files_ok`) ПОСЛЕ
|
||||
`In Review`+коммента; advance идёт через тот же `advance_stage(..., finished_agent=None)`,
|
||||
что человеческий Approved (без дублирования переходной логики, §3.1).
|
||||
- ✅ Врезка autoDeploy — `_handle_self_deploy_phase_a` после advance на `deploy`+
|
||||
`clear_state`, ДО «ask-human»; Phase B запускается тем же `_handle_self_deploy_phase_b`
|
||||
(§3.2).
|
||||
- ✅ Флаги (`config.py`): `auto_label_enabled`, `auto_approve_label`, `auto_deploy_label`,
|
||||
`auto_label_repos` (пусто → self-hosting only), `auto_label_states_ttl_s` (§7).
|
||||
- ✅ Блок наблюдаемости `auto_labels` в `GET /queue` (§8).
|
||||
- ✅ БД-схема и QG-реестр не трогаются (§5/§6) — подтверждено: `stages.py`,
|
||||
`qg/checks.py`, `db.py` отсутствуют в diff feat-коммита.
|
||||
|
||||
### 2. Соответствие ADR (`06-adr/ADR-001`, global `adr-0018`)
|
||||
- ✅ Реализация 1:1 с решениями ADR: D1 (поверхность leaf + порядок резолва `has_label`),
|
||||
D5 (scope: пусто → self-hosting), fail-safe «never auto on doubt», ambiguity-сентинел.
|
||||
- ✅ Глобальный сквозной ADR `adr-0018-auto-label-gates.md` заведён.
|
||||
- ✅ Подтверждена корректность пути advance: `advance_stage` с `agent=None` идёт в
|
||||
ветку `approved-via-status` (qg_passed, без повторного `check_analysis_approved`) →
|
||||
`analysis → architecture` + `mark_brd_review_ended`. Re-entrancy безопасна
|
||||
(вложенный вызов с `finished_agent=None` не входит в analyst-ветку).
|
||||
|
||||
### 3. Качество кода
|
||||
- ✅ never-raise соблюдён во всех публичных функциях (`labels.py`, новые `plane_sync`-хелперы).
|
||||
- ✅ Нет дублирования переходной логики — переиспользованы `advance_stage` и
|
||||
`_handle_self_deploy_phase_b` (включая существующую идемпотентность `INITIATED`).
|
||||
- ✅ Прозрачность (AC-7) во всех трёх каналах: лог + Telegram (`send_telegram`) +
|
||||
Plane-коммент (`plane_add_comment`), плюс live-карточка через штатный advance.
|
||||
- ✅ Docstrings содержательные; кликабельный номер задачи (`link_for`) в уведомлениях.
|
||||
|
||||
### 4. Тесты
|
||||
- ✅ 43 целевых теста (TC-01…TC-26, 7 модулей) — все зелёные.
|
||||
- ✅ Регрессия: 377 релевантных тестов (stage/plane/analysis/deploy/self_deploy/webhook)
|
||||
— все зелёные. AC-10 (инварианты) подтверждён.
|
||||
|
||||
## Документация
|
||||
Обновлена полностью в том же PR (AC-11):
|
||||
- `CLAUDE.md` — раздел «Авто-режим по лейблам» (флаги, инвариант «снимает только
|
||||
человеческое решение»);
|
||||
- `docs/architecture/README.md` — описание врезок autoApprove/autoDeploy + флаги;
|
||||
- `CHANGELOG.md` — запись в `## [Unreleased]`;
|
||||
- `06-adr/ADR-001-auto-label-gates.md` + global `docs/architecture/adr/adr-0018-auto-label-gates.md`;
|
||||
- `07-infra-requirements.md` — предусловие создания лейблов `autoApprove`/`autoDeploy`
|
||||
в Plane-проекте ORCH.
|
||||
|
||||
Статус: документация синхронна с кодом. Требование CLAUDE.md §2/§6 выполнено.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- Нет.
|
||||
|
||||
### P1 — Must fix
|
||||
- Нет.
|
||||
|
||||
### P2 — Should fix
|
||||
- Нет.
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- `set_issue_approved` обращается к `get_project_states(pid)["approved"]` прямым
|
||||
индексом (потенциальный `KeyError`, если ключ отсутствует). На практике защищено:
|
||||
ключ `approved` гарантирован в `_DEFAULT_STATES`, паттерн 1:1 повторяет
|
||||
существующий `set_issue_in_review`, а вызов обёрнут внешним `try/except` в
|
||||
`advance_stage` (деградирует к ручному гейту). Косметика, не блокер.
|
||||
88
docs/work-items/ORCH-089/13-test-report.md
Normal file
88
docs/work-items/ORCH-089/13-test-report.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-089
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-089
|
||||
|
||||
Авто-режим по лейблам: autoApprove (авто-BRD) + autoDeploy (авто-деплой).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Branch: feature/ORCH-089-autoapprove-brd-autodeploy
|
||||
- Дата: 2026-06-09
|
||||
|
||||
## Предусловия
|
||||
- Review verdict: **APPROVED** (`12-review.md`, P0/P1 — нет).
|
||||
- Prod health (8500): `{"status":"ok"}` — конвейер всех проектов жив, деструктивные операции не выполнялись.
|
||||
|
||||
## Результаты (test-plan `04-test-plan.yaml`)
|
||||
|
||||
Все 26 TC из плана покрыты 43 целевыми тестами (7 модулей). Сопоставление с критериями приёмки (`03-acceptance-criteria.md`):
|
||||
|
||||
| TC ID | Описание | AC | Результат |
|
||||
|-------|----------|-----|-----------|
|
||||
| TC-01 | has_label=True когда лейбл присутствует | AC-1 | PASS |
|
||||
| TC-02 | has_label=False когда лейбла нет | AC-4 | PASS |
|
||||
| TC-03 | has_label при ошибке Plane/таймауте → fail-safe, never-raise | AC-6 | PASS |
|
||||
| TC-04 | Нормализация имени лейбла; неоднозначность → нет авто | AC-6 | PASS |
|
||||
| TC-05 | applies: scope CSV + self-hosting; пустой scope по дефолту | AC-8 | PASS |
|
||||
| TC-06 | auto_label_enabled=False → нет авто без сетевых вызовов | AC-8 | PASS |
|
||||
| TC-07 | fetch_issue_labels парсит labels + резолв uuid→имя | AC-1 | PASS |
|
||||
| TC-08 | Карта лейблов проекта кэшируется с TTL | AC-8 | PASS |
|
||||
| TC-09 | set_issue_approved PATCH в Approved-UUID; never-raise | AC-1 | PASS |
|
||||
| TC-10 | autoApprove → авто-advance analysis→architecture, Approved, клок закрыт | AC-1 | PASS |
|
||||
| TC-11 | Без лейбла autoApprove → In Review, return без advance | AC-4 | PASS |
|
||||
| TC-12 | autoApprove без артефактов → НЕ advance | AC-5 | PASS |
|
||||
| TC-13 | autoApprove через тот же advance-путь; идемпотентно | AC-1 | PASS |
|
||||
| TC-14 | autoApprove: лог + Telegram/карточка + Plane-коммент | AC-7 | PASS |
|
||||
| TC-15 | autoDeploy + Phase A → авто _handle_self_deploy_phase_b | AC-2 | PASS |
|
||||
| TC-16 | Без лейбла autoDeploy → Awaiting Deploy, ждёт человека | AC-4 | PASS |
|
||||
| TC-17 | autoDeploy идемпотентен: маркер INITIATED → no-op | AC-2 | PASS |
|
||||
| TC-18 | autoDeploy не-self/вне scope → no-op | AC-8 | PASS |
|
||||
| TC-19 | autoDeploy: лог + Telegram + Plane-коммент | AC-7 | PASS |
|
||||
| TC-20 | Только autoApprove: BRD авто, деплой ждёт человека | AC-9 | PASS |
|
||||
| TC-21 | Только autoDeploy: BRD ждёт человека, деплой авто | AC-9 | PASS |
|
||||
| TC-22 | auto_label_enabled=False → оба гейта ручные | AC-8 | PASS |
|
||||
| TC-23 | Оба лейбла + зелёные тех-гейты → analysis→deploy автономно | AC-3 | PASS |
|
||||
| TC-24 | autoDeploy + красный staging/merge-gate → Phase B НЕ инициирован | AC-5 | PASS |
|
||||
| TC-25 | Регресс: без лейблов оба гейта как до ORCH-089 | AC-4 | PASS |
|
||||
| TC-26 | Инвариант: STAGE_TRANSITIONS и QG_CHECKS не изменены | AC-10 | PASS |
|
||||
|
||||
## Smoke test API (prod 8500)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}` — OK
|
||||
- `GET /status` → 200, активные задачи перечислены (ORCH-089 = `testing`) — OK
|
||||
- `GET /queue` → 200, блоки наблюдаемости присутствуют — OK
|
||||
|
||||
> Примечание: блок `auto_labels` в `GET /queue` на 8500 пока отсутствует — это ожидаемо:
|
||||
> прод-контейнер исполняет код до ORCH-089 (задача ещё в `testing`, не задеплоена).
|
||||
> Блок добавляется кодом ветки и покрыт юнит-тестами (snapshot/observability) выше.
|
||||
|
||||
## Вывод pytest (полный регресс)
|
||||
|
||||
```
|
||||
======================= 1157 passed, 1 warning in 37.99s =======================
|
||||
```
|
||||
|
||||
Целевой набор ORCH-089 (7 модулей):
|
||||
|
||||
```
|
||||
tests/test_labels.py ................ (14)
|
||||
tests/test_plane_sync_labels.py ..... (11)
|
||||
tests/test_auto_approve_brd.py ...... (5)
|
||||
tests/test_auto_deploy.py ........... (5)
|
||||
tests/test_auto_label_combinations.py (3)
|
||||
tests/test_auto_labels_integration.py (3)
|
||||
tests/test_auto_labels_invariants.py . (2)
|
||||
======================== 43 passed, 1 warning in 1.09s =========================
|
||||
```
|
||||
|
||||
Единственный warning — `PydanticDeprecatedSince20` (class-based config в `src/config.py`),
|
||||
не связан с ORCH-089, присутствует в baseline.
|
||||
|
||||
## Итог
|
||||
|
||||
**PASS** — все 26 TC плана зелёные, полный регресс 1157/1157 пройден, smoke-тесты OK,
|
||||
инварианты (STAGE_TRANSITIONS/QG_CHECKS, AC-10) подтверждены. Задача готова к `deploy-staging`.
|
||||
12
docs/work-items/ORCH-089/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-089/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-089
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
@@ -487,6 +487,37 @@ class Settings(BaseSettings):
|
||||
# *_repos, since auto-create is semantically inseparable from merge-verify.
|
||||
merge_verify_autocreate_pr_enabled: bool = True
|
||||
|
||||
# ORCH-089: auto-mode by Plane labels — autoApprove (BRD gate) + autoDeploy
|
||||
# (prod-deploy gate). Two HUMAN gates of the pipeline (analysis: wait for a
|
||||
# manual Approved; deploy Phase A: wait for a manual Confirm Deploy) are the
|
||||
# only blockers of an autonomous batch run (epic ORCH-088). ORCH-089 lifts ONLY
|
||||
# those two human decisions — selectively (a Plane label on the issue),
|
||||
# declaratively, reversibly, WITHOUT touching a single technical check. Additive
|
||||
# leaf (src/labels.py, never-raise) + two point insertions + flags;
|
||||
# STAGE_TRANSITIONS / QG_CHECKS / check_* / DB schema are NOT touched. See
|
||||
# docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md.
|
||||
# auto_label_enabled -> global kill-switch for BOTH auto-modes (env
|
||||
# ORCH_AUTO_LABEL_ENABLED). False -> strictly the prior
|
||||
# behaviour (both gates manual), AND no new network call
|
||||
# on the gates (applies() returns False first, before
|
||||
# has_label is consulted) — zero regression (AC-8).
|
||||
# auto_approve_label -> Plane label name for the BRD gate (env
|
||||
# ORCH_AUTO_APPROVE_LABEL).
|
||||
# auto_deploy_label -> Plane label name for the deploy gate (env
|
||||
# ORCH_AUTO_DEPLOY_LABEL).
|
||||
# auto_label_repos -> CSV scope (env ORCH_AUTO_LABEL_REPOS). Empty ->
|
||||
# self-hosting only (orchestrator), the safe default
|
||||
# (the autoDeploy insertion lives in Phase A, which only
|
||||
# exists for the self-hosting repo). Non-empty -> only
|
||||
# the listed repos.
|
||||
# auto_label_states_ttl_s -> TTL (seconds) of the per-project label-map cache
|
||||
# (mirrors plane_states_ttl_s); 0 -> lifetime cache.
|
||||
auto_label_enabled: bool = True
|
||||
auto_approve_label: str = "autoApprove"
|
||||
auto_deploy_label: str = "autoDeploy"
|
||||
auto_label_repos: str = ""
|
||||
auto_label_states_ttl_s: int = 300
|
||||
|
||||
# Telegram notifications
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
|
||||
133
src/labels.py
Normal file
133
src/labels.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""ORCH-089: auto-mode by Plane labels — autoApprove + autoDeploy (pure logic).
|
||||
|
||||
Leaf module — pure, unit-testable logic over the config flags + the Plane label
|
||||
helpers in ``plane_sync``. Mirrors the leaf pattern of ``src/serial_gate.py`` /
|
||||
``src/self_deploy.py``: imports only ``config`` (and lazily ``plane_sync`` /
|
||||
``qg.checks`` / ``projects``), never ``stage_engine`` / ``launcher``.
|
||||
|
||||
What it decides (ADR-001 D1):
|
||||
* Whether the auto-mode is in scope for a repo (``auto_approve_applies`` /
|
||||
``auto_deploy_applies``) — a LOCAL, network-free check evaluated FIRST.
|
||||
* Whether a given Plane label is present on an issue (``has_label``) — the only
|
||||
network call, made ONLY after ``applies()`` is True, so a disabled kill-switch
|
||||
costs zero network and yields zero regression (AC-8).
|
||||
|
||||
never-raise contract (BR-6/AC-6, fail-safe to the MANUAL gate): every public
|
||||
function degrades to "no auto" on ANY error / ambiguity / Plane unavailability.
|
||||
There is NO fail-open here — the conservative default is always "no auto"
|
||||
(human gate stays), so an error can never auto-pass a gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger("orchestrator.labels")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scope / kill-switch (mirrors self_deploy_applies / serial_gate_applies)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _auto_label_applies(repo: str) -> bool:
|
||||
"""Shared scope check for both auto-modes (ADR-001 D5).
|
||||
|
||||
* ``auto_label_enabled=False`` -> always False (kill-switch; both gates 1:1
|
||||
as before ORCH-089, and — crucially — ``has_label`` is never consulted, so
|
||||
no new network call on the gate, AC-8).
|
||||
* ``auto_label_repos`` (CSV) non-empty -> real only for the listed repos.
|
||||
* empty CSV -> self-hosting only (``orchestrator``) — the safe default
|
||||
(the autoDeploy insertion lives in Phase A, which only exists for the
|
||||
self-hosting repo).
|
||||
Never raises -> False on error (degrade to "no auto" = manual gate).
|
||||
"""
|
||||
try:
|
||||
if not getattr(settings, "auto_label_enabled", False):
|
||||
return False
|
||||
raw = (getattr(settings, "auto_label_repos", "") or "").strip()
|
||||
if raw:
|
||||
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
|
||||
return (repo or "").strip().lower() in allowed
|
||||
# Lazy import keeps this module a leaf (avoids importing qg at load).
|
||||
from .qg.checks import is_self_hosting_repo
|
||||
return is_self_hosting_repo(repo)
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("_auto_label_applies error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
def auto_approve_applies(repo: str) -> bool:
|
||||
"""Whether the autoApprove (BRD gate) auto-mode is in scope for ``repo``."""
|
||||
return _auto_label_applies(repo)
|
||||
|
||||
|
||||
def auto_deploy_applies(repo: str) -> bool:
|
||||
"""Whether the autoDeploy (prod-deploy gate) auto-mode is in scope for ``repo``."""
|
||||
return _auto_label_applies(repo)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Label presence (the ONLY network call; ADR-001 D1)
|
||||
# ---------------------------------------------------------------------------
|
||||
def has_label(work_item_id: str, label_name: str, project_id: str | None = None) -> bool:
|
||||
"""True iff the issue carries a label whose name == ``label_name`` (normalized).
|
||||
|
||||
Resolution (all inside one ``try/except -> False``):
|
||||
1. ``plane_sync.fetch_issue_labels`` — the issue's label uuids (None on error
|
||||
-> False);
|
||||
2. ``plane_sync.get_project_labels`` — {normalized_name -> uuid} project map
|
||||
(TTL-cached);
|
||||
3. normalize the sought name and look it up in the project map;
|
||||
4. no match, OR an ambiguous name (the project map maps it to the
|
||||
``__AMBIGUOUS__`` sentinel) -> False (fail-safe);
|
||||
5. ``return target_uuid in set(labels)``.
|
||||
|
||||
Any error / unavailability / ambiguity -> **False** (never auto on doubt).
|
||||
"""
|
||||
if not label_name:
|
||||
return False
|
||||
try:
|
||||
from . import plane_sync
|
||||
labels = plane_sync.fetch_issue_labels(work_item_id, project_id)
|
||||
if labels is None:
|
||||
# Could not read the issue's labels -> fail-safe to manual.
|
||||
return False
|
||||
if not labels:
|
||||
return False
|
||||
name_map = plane_sync.get_project_labels(
|
||||
plane_sync._resolve_project_id(work_item_id, project_id)
|
||||
)
|
||||
if not name_map:
|
||||
return False
|
||||
normalized = plane_sync._normalize_label(label_name)
|
||||
target_uuid = name_map.get(normalized)
|
||||
if not target_uuid or target_uuid == "__AMBIGUOUS__":
|
||||
return False
|
||||
return target_uuid in set(labels)
|
||||
except Exception as e: # noqa: BLE001 - never-raise -> no auto
|
||||
logger.warning(
|
||||
"has_label error for %s/%s -> fail-safe (no auto): %s",
|
||||
work_item_id, label_name, e,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Observability snapshot for GET /queue (ADR-001 D7)
|
||||
# ---------------------------------------------------------------------------
|
||||
def snapshot() -> dict:
|
||||
"""Read-only auto-label summary for GET /queue (additive block). never-raise."""
|
||||
try:
|
||||
enabled = bool(getattr(settings, "auto_label_enabled", False))
|
||||
except Exception: # noqa: BLE001
|
||||
enabled = False
|
||||
try:
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"approve_label": getattr(settings, "auto_approve_label", ""),
|
||||
"deploy_label": getattr(settings, "auto_deploy_label", ""),
|
||||
"repos": getattr(settings, "auto_label_repos", "") or "",
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 - never-raise -> minimal dict
|
||||
logger.warning("labels snapshot error: %s", e)
|
||||
return {"enabled": enabled, "approve_label": "", "deploy_label": "", "repos": ""}
|
||||
@@ -150,6 +150,7 @@ async def queue():
|
||||
from . import merge_gate
|
||||
from . import task_deps
|
||||
from . import serial_gate
|
||||
from . import labels
|
||||
return {
|
||||
"counts": job_status_counts(),
|
||||
"max_concurrency": worker.max_concurrency,
|
||||
@@ -165,6 +166,9 @@ async def queue():
|
||||
# ORCH-088 (D9 / AC-10): per-repo serial-gate observability (read-only) —
|
||||
# active task, queued/waiting analyst-jobs, freeze state. Additive block.
|
||||
"serial_gate": serial_gate.snapshot(),
|
||||
# ORCH-089 (D7): auto-mode-by-label observability (read-only) — kill-switch,
|
||||
# label names, scope. Additive block.
|
||||
"auto_labels": labels.snapshot(),
|
||||
"recent": recent_jobs(10),
|
||||
}
|
||||
|
||||
|
||||
@@ -326,6 +326,160 @@ def reload_project_states(project_id: str = None) -> None:
|
||||
logger.debug(f"reload_project_states: evicted project {project_id[:8]}...")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-089: label reading (auto-mode by Plane labels) + Approved setter.
|
||||
#
|
||||
# Source of truth for an issue's labels is the Plane API, NOT the webhook payload
|
||||
# (both auto-mode insertion points are launcher-path events where the payload is
|
||||
# absent; src/webhooks/plane.py does not carry `labels`). All three helpers honour
|
||||
# a never-raise contract: a failure degrades to "no label" / "no-op", so the
|
||||
# auto-mode falls back to the manual gate (fail-safe, BR-6/AC-6).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Per-project label-map cache (mirrors _STATES_CACHE / ORCH-068 TTL self-heal).
|
||||
# Each entry: {"map": {normalized_name -> uuid}, "ts": monotonic timestamp}.
|
||||
_LABELS_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _normalize_label(name: str) -> str:
|
||||
"""Normalize a label name for matching (case/whitespace-insensitive)."""
|
||||
return (name or "").strip().casefold()
|
||||
|
||||
|
||||
def _labels_record_fresh(record: dict) -> bool:
|
||||
"""ORCH-089: is a label-map cache record still within its TTL?
|
||||
|
||||
``auto_label_states_ttl_s <= 0`` disables the TTL (lifetime cache, escape
|
||||
hatch mirroring ``_cache_record_fresh`` / ``plane_states_ttl_s``).
|
||||
"""
|
||||
try:
|
||||
ttl = settings.auto_label_states_ttl_s
|
||||
except Exception: # noqa: BLE001
|
||||
ttl = 0
|
||||
if ttl <= 0:
|
||||
return True
|
||||
ts = record.get("ts", 0.0)
|
||||
return (time.monotonic() - ts) <= ttl
|
||||
|
||||
|
||||
def reload_project_labels(project_id: str = None) -> None:
|
||||
"""ORCH-089: clear the per-project label-map cache (tests / config reload)."""
|
||||
global _LABELS_CACHE
|
||||
if project_id is None:
|
||||
_LABELS_CACHE = {}
|
||||
else:
|
||||
_LABELS_CACHE.pop(project_id, None)
|
||||
|
||||
|
||||
def get_project_labels(project_id: str) -> dict[str, str]:
|
||||
"""ORCH-089: resolve {normalized_label_name -> uuid} for a Plane project.
|
||||
|
||||
Source of truth: GET /projects/<pid>/labels/. Cached per project_id with a
|
||||
TTL (``auto_label_states_ttl_s``, default 300s) mirroring
|
||||
``get_project_states`` so we do not hit the API on every gate. On a transient
|
||||
API failure a stale-but-correct cached map is served (safer-than-empty); with
|
||||
nothing cached -> ``{}`` (caller resolves to "no label" -> manual gate).
|
||||
|
||||
Ambiguity guard (D1.4): if two distinct project labels normalise to the SAME
|
||||
name, that name is mapped to a sentinel so ``has_label`` treats it as "no
|
||||
match" (fail-safe) instead of silently picking one uuid. never-raise -> ``{}``.
|
||||
"""
|
||||
if not project_id:
|
||||
return {}
|
||||
|
||||
cached = _LABELS_CACHE.get(project_id)
|
||||
if cached is not None and _labels_record_fresh(cached):
|
||||
return cached["map"]
|
||||
|
||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/labels/"
|
||||
try:
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
items = body.get("results", body) if isinstance(body, dict) else body
|
||||
if not isinstance(items, list):
|
||||
raise ValueError(f"unexpected labels response shape: {type(items)}")
|
||||
|
||||
name_map: dict[str, str] = {}
|
||||
ambiguous: set[str] = set()
|
||||
for item in items:
|
||||
uid = item.get("id", "")
|
||||
norm = _normalize_label(item.get("name", ""))
|
||||
if not (uid and norm):
|
||||
continue
|
||||
if norm in name_map and name_map[norm] != uid:
|
||||
# Two distinct labels collide on the normalized name -> ambiguous.
|
||||
ambiguous.add(norm)
|
||||
name_map[norm] = uid
|
||||
for norm in ambiguous:
|
||||
# AMBIGUOUS sentinel: never equals a real issue-label uuid, so
|
||||
# has_label's membership test is False -> fail-safe to the manual gate.
|
||||
name_map[norm] = "__AMBIGUOUS__"
|
||||
logger.warning(
|
||||
"get_project_labels: ambiguous label name %r in project %s "
|
||||
"-> treated as no-match (fail-safe)", norm, project_id[:8],
|
||||
)
|
||||
|
||||
_LABELS_CACHE[project_id] = {"map": name_map, "ts": time.monotonic()}
|
||||
logger.debug(
|
||||
"get_project_labels: cached %d labels for project %s...",
|
||||
len(name_map), project_id[:8],
|
||||
)
|
||||
return name_map
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
if cached is not None:
|
||||
logger.warning(
|
||||
"get_project_labels: API refresh failed for project %s..., "
|
||||
"serving stale cached map. Error: %s", project_id[:8], e,
|
||||
)
|
||||
return cached["map"]
|
||||
logger.warning(
|
||||
"get_project_labels: API failed for project %s..., no cache -> {}. "
|
||||
"Error: %s", project_id[:8], e,
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def fetch_issue_labels(work_item_id: str, project_id: str = None) -> list[str] | None:
|
||||
"""ORCH-089: GET the issue and return its ``labels`` (a list of label uuids).
|
||||
|
||||
Returns ``None`` on any error / issue-not-found (DISTINCT from ``[]`` = "the
|
||||
issue has no labels") so the caller can distinguish "could not read" (fail-safe
|
||||
to manual) from "definitely no labels". never-raise.
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
issue_id = find_issue_id(work_item_id, project_id)
|
||||
if not issue_id:
|
||||
logger.debug("fetch_issue_labels: issue not found for %s", work_item_id)
|
||||
return None
|
||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/"
|
||||
try:
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
|
||||
resp.raise_for_status()
|
||||
labels = resp.json().get("labels", [])
|
||||
if not isinstance(labels, list):
|
||||
return None
|
||||
return [str(x) for x in labels]
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("fetch_issue_labels failed for %s: %s", work_item_id, e)
|
||||
return None
|
||||
|
||||
|
||||
def set_issue_approved(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-089: set issue to 'Approved' — indication of an auto-approved BRD.
|
||||
|
||||
1:1 mirror of ``set_issue_in_review``: resolve the per-project Approved UUID
|
||||
(``get_project_states(pid)["approved"]`` — the key already exists in
|
||||
``_DEFAULT_STATES`` / ``_PLANE_NAME_TO_KEY``) and PATCH the issue. never-raise
|
||||
(via ``_set_issue_state_direct``). The status is transient — the immediately
|
||||
following advance to ``architecture`` overrides it; durable transparency is
|
||||
carried by the log + Telegram + Plane comment (AC-7).
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["approved"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
|
||||
# when the pipeline ENTERS that stage. ORCH-066: analysis -> Analysis and
|
||||
# review -> Code-Review now have dedicated statuses. deploy keeps in_progress
|
||||
|
||||
@@ -39,6 +39,7 @@ from .qg.checks import QG_CHECKS
|
||||
from . import merge_gate
|
||||
from . import self_deploy
|
||||
from . import post_deploy
|
||||
from . import labels
|
||||
from .notifications import (
|
||||
notify_stage_change,
|
||||
notify_qg_failure,
|
||||
@@ -59,6 +60,7 @@ from .plane_sync import (
|
||||
set_issue_awaiting_deploy,
|
||||
set_issue_deploying,
|
||||
set_issue_monitoring,
|
||||
set_issue_approved,
|
||||
)
|
||||
from .config import settings
|
||||
|
||||
@@ -596,6 +598,47 @@ def _handle_analysis_approved_flow(
|
||||
logger.info(
|
||||
f"Task {task_id}: analyst finished, requested Approved status in Plane"
|
||||
)
|
||||
|
||||
# --- ORCH-089 autoApprove: auto-pass the BRD human gate by label --------
|
||||
# After In Review + the analyst comment + the approve-request (kept for the
|
||||
# BRD-review clock, transparency and symmetry with the manual path), if the
|
||||
# issue carries the autoApprove label AND the repo is in scope, auto-advance
|
||||
# via the SAME path a human Approved takes — never duplicating the
|
||||
# transition logic. applies() (local, network-free) is checked FIRST so a
|
||||
# disabled kill-switch / out-of-scope repo costs zero network (AC-8); any
|
||||
# error / no-label -> fall through to the prior behaviour (return, wait for
|
||||
# a human, AC-4/AC-6).
|
||||
if labels.auto_approve_applies(repo) and labels.has_label(
|
||||
work_item_id, settings.auto_approve_label
|
||||
):
|
||||
set_issue_approved(work_item_id) # indication (AC-1), transient
|
||||
logger.info(
|
||||
f"Task {task_id}: label {settings.auto_approve_label} -> "
|
||||
f"BRD auto-approved (analysis -> architecture)"
|
||||
)
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
f"✅ BRD авто-подтверждён (лейбл {settings.auto_approve_label}). "
|
||||
"Переход на architecture без ручного Approved.",
|
||||
author="analyst",
|
||||
)
|
||||
send_telegram(
|
||||
f"✅ {link_for(work_item_id)}: BRD авто-подтверждён "
|
||||
f"(лейбл {settings.auto_approve_label})."
|
||||
)
|
||||
# Same advance the human Approved webhook uses: finished_agent=None ->
|
||||
# check_analysis_approved approved-via-status -> advance analysis ->
|
||||
# architecture + mark_brd_review_ended (clock) + standard post-effects.
|
||||
# Re-entrancy is safe: the nested call passes finished_agent=None, so it
|
||||
# does NOT re-enter this analyst branch (which requires agent=='analyst').
|
||||
auto = advance_stage(
|
||||
task_id, current_stage, repo, work_item_id, branch, finished_agent=None
|
||||
)
|
||||
result.advanced = auto.advanced
|
||||
result.to_stage = auto.to_stage
|
||||
result.enqueued_agent = auto.enqueued_agent
|
||||
result.enqueued_job_id = auto.enqueued_job_id
|
||||
result.note = "auto-approved-via-label"
|
||||
return
|
||||
|
||||
questions_path = os.path.join(
|
||||
@@ -1179,6 +1222,40 @@ def _handle_self_deploy_phase_a(
|
||||
# (e.g. after a crash/manual intervention), so `initiated`/`result` from an
|
||||
# earlier attempt can never leak into this one.
|
||||
self_deploy.clear_state(repo, work_item_id)
|
||||
|
||||
# --- ORCH-089 autoDeploy: auto-confirm the prod-deploy human gate by label --
|
||||
# After advancing onto `deploy` + wiping stale markers and BEFORE the ask-human
|
||||
# block, if the issue carries the autoDeploy label AND the repo is in scope,
|
||||
# initiate Phase B via the SAME path a human Confirm Deploy takes. Only the
|
||||
# indicative human steps are skipped (APPROVE_REQUESTED marker +
|
||||
# set_issue_awaiting_deploy + the "flip to Confirm Deploy" comment/Telegram) —
|
||||
# status Deploying is set by Phase B itself. BR-5/AC-5 hold STRUCTURALLY: Phase A
|
||||
# is reached ONLY after the green edge sub-gates (security -> merge-gate ->
|
||||
# image-freshness -> staging), so autoDeploy cannot deploy a broken build.
|
||||
# Idempotency is the existing INITIATED marker inside _handle_self_deploy_phase_b.
|
||||
# applies() FIRST (network-free); any error / no-label -> the prior Phase A
|
||||
# ask-human flow (AC-4/AC-6).
|
||||
if labels.auto_deploy_applies(repo) and labels.has_label(
|
||||
work_item_id, settings.auto_deploy_label
|
||||
):
|
||||
logger.info(
|
||||
f"Task {task_id}: label {settings.auto_deploy_label} -> "
|
||||
f"prod deploy auto-confirmed (Phase B without manual Confirm Deploy)"
|
||||
)
|
||||
if work_item_id:
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
f"🚀 Прод-деплой авто-подтверждён (лейбл {settings.auto_deploy_label}). "
|
||||
"Запуск Phase B без ручного «Confirm Deploy».",
|
||||
author="deployer",
|
||||
)
|
||||
send_telegram(
|
||||
f"🚀 {link_for(work_item_id)}: прод-деплой авто-подтверждён "
|
||||
f"(лейбл {settings.auto_deploy_label})."
|
||||
)
|
||||
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result)
|
||||
return
|
||||
|
||||
self_deploy.write_marker(
|
||||
repo, work_item_id, self_deploy.APPROVE_REQUESTED, content=str(time.time())
|
||||
)
|
||||
|
||||
190
tests/test_auto_approve_brd.py
Normal file
190
tests/test_auto_approve_brd.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""ORCH-089 — autoApprove врезка in _handle_analysis_approved_flow.
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-10 autoApprove + artifacts ready -> auto-advance analysis->architecture,
|
||||
Approved set, brd_review_ended clock closed.
|
||||
TC-11 no autoApprove label -> prior behaviour: In Review, return w/o advance.
|
||||
TC-12 autoApprove but artifacts missing (check_analysis_complete FAIL) -> NO
|
||||
advance (AC-5 for BRD).
|
||||
TC-13 autoApprove goes through the SAME advance path as a manual Approved (no
|
||||
duplicated transition logic; idempotent — stage lands on architecture).
|
||||
TC-14 autoApprove logged + Telegram + Plane comment (transparency AC-7).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_auto_approve_brd.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 MagicMock # noqa: E402
|
||||
|
||||
import src.db as _db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import stage_engine # noqa: E402
|
||||
from src import labels # noqa: E402
|
||||
from src.stage_engine import advance_stage # noqa: E402
|
||||
|
||||
|
||||
def _files_ok(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
def _files_fail(*a, **k):
|
||||
return (False, "missing artifacts")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
# Silence Plane/Telegram side effects; capture the transparency channels.
|
||||
for name in ("notify_stage_change", "notify_qg_failure", "plane_notify_stage",
|
||||
"plane_notify_qg", "set_issue_in_review", "set_issue_needs_input",
|
||||
"set_issue_approved", "notify_approve_requested"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||
# Avoid worktree access in the analyst "ready" comment builder.
|
||||
monkeypatch.setattr(stage_engine, "_build_analyst_ready_comment",
|
||||
lambda *a, **k: "ready", raising=False)
|
||||
monkeypatch.setattr(stage_engine, "plane_add_comment", MagicMock())
|
||||
monkeypatch.setattr(stage_engine, "send_telegram", MagicMock())
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(stage="analysis", repo="orchestrator", branch="feature/ORCH-089-x",
|
||||
wi="ORCH-089"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(f"plane-{wi}", wi, repo, branch, stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _stage_of(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
def _brd_ended(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT brd_review_ended_at FROM tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
def _patch_complete_gate(monkeypatch, ok=True):
|
||||
gate = _files_ok if ok else _files_fail
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS, "check_analysis_complete": gate},
|
||||
)
|
||||
|
||||
|
||||
def _label(monkeypatch, present=True, applies=True):
|
||||
monkeypatch.setattr(labels, "auto_approve_applies", lambda repo: applies)
|
||||
monkeypatch.setattr(labels, "has_label", lambda w, name, p=None: present)
|
||||
|
||||
|
||||
# --- TC-10 -----------------------------------------------------------------
|
||||
def test_tc10_auto_approve_advances(monkeypatch):
|
||||
_patch_complete_gate(monkeypatch, ok=True)
|
||||
_label(monkeypatch, present=True)
|
||||
tid = _make_task()
|
||||
# The BRD-review clock was started when the task entered In Review; the
|
||||
# advance closes it (mark_brd_review_ended only stamps when a start exists).
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE tasks SET brd_review_started_at=datetime('now') WHERE id=?", (tid,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
res = advance_stage(tid, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
assert res.note == "auto-approved-via-label"
|
||||
assert res.advanced is True
|
||||
assert _stage_of(tid) == "architecture"
|
||||
assert _brd_ended(tid) is not None # clock closed by mark_brd_review_ended
|
||||
stage_engine.set_issue_approved.assert_called_once() # Approved indication
|
||||
|
||||
|
||||
# --- TC-11 -----------------------------------------------------------------
|
||||
def test_tc11_no_label_waits_for_human(monkeypatch):
|
||||
_patch_complete_gate(monkeypatch, ok=True)
|
||||
_label(monkeypatch, present=False)
|
||||
tid = _make_task()
|
||||
res = advance_stage(tid, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
assert res.note == "analysis-in-review"
|
||||
assert res.advanced is False
|
||||
assert _stage_of(tid) == "analysis" # still waiting for a human
|
||||
stage_engine.set_issue_in_review.assert_called_once()
|
||||
stage_engine.set_issue_approved.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-12 -----------------------------------------------------------------
|
||||
def test_tc12_missing_artifacts_no_auto(monkeypatch):
|
||||
# autoApprove present, but artifacts incomplete -> files_ok False -> the
|
||||
# autoApprove block (inside `if files_ok`) is never reached.
|
||||
_patch_complete_gate(monkeypatch, ok=False)
|
||||
_label(monkeypatch, present=True)
|
||||
tid = _make_task()
|
||||
res = advance_stage(tid, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
assert res.advanced is False
|
||||
assert _stage_of(tid) == "analysis"
|
||||
assert res.note != "auto-approved-via-label"
|
||||
stage_engine.set_issue_approved.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-13: same advance path / idempotent ---------------------------------
|
||||
def test_tc13_same_advance_path_idempotent(monkeypatch):
|
||||
_patch_complete_gate(monkeypatch, ok=True)
|
||||
_label(monkeypatch, present=True)
|
||||
tid = _make_task()
|
||||
res = advance_stage(tid, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
# The advance went through the unified path -> architect enqueued exactly once.
|
||||
assert res.enqueued_agent == "architect"
|
||||
conn = get_db()
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM jobs WHERE task_id=? AND agent='architect'", (tid,)
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
assert n == 1
|
||||
# A later real Approved (webhook path, finished_agent=None) sees architecture,
|
||||
# not analysis -> it cannot re-run the analysis advance (idempotent).
|
||||
assert _stage_of(tid) == "architecture"
|
||||
|
||||
|
||||
# --- TC-14: transparency ---------------------------------------------------
|
||||
def test_tc14_transparency_channels(monkeypatch, caplog):
|
||||
_patch_complete_gate(monkeypatch, ok=True)
|
||||
_label(monkeypatch, present=True)
|
||||
tid = _make_task()
|
||||
import logging
|
||||
with caplog.at_level(logging.INFO, logger="orchestrator.stage_engine"):
|
||||
advance_stage(tid, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
# (a) log mentions the label + auto-approve.
|
||||
assert any("auto-approved" in r.message.lower() or "autoApprove" in r.message
|
||||
for r in caplog.records)
|
||||
# (b) Telegram fired; (c) a Plane comment authored by analyst about the auto-pass.
|
||||
assert stage_engine.send_telegram.called
|
||||
comment_calls = [c for c in stage_engine.plane_add_comment.call_args_list
|
||||
if "авто-подтверждён" in c.args[1]]
|
||||
assert comment_calls, "expected an auto-approve Plane comment"
|
||||
182
tests/test_auto_deploy.py
Normal file
182
tests/test_auto_deploy.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""ORCH-089 — autoDeploy врезка in _handle_self_deploy_phase_a.
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-15 autoDeploy + Phase A advance on `deploy` -> Phase B (initiate_deploy)
|
||||
is auto-invoked.
|
||||
TC-16 no autoDeploy label -> prior Phase A: Awaiting Deploy, wait for Confirm
|
||||
Deploy.
|
||||
TC-17 idempotent: INITIATED marker already present -> repeat auto-trigger no-op.
|
||||
TC-18 non-self repo / out of scope -> no auto (Phase A/B only for self-hosting).
|
||||
TC-19 autoDeploy logged + Telegram + Plane comment (transparency AC-7).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_auto_deploy.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 MagicMock # noqa: E402
|
||||
|
||||
import src.db as _db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import stage_engine # noqa: E402
|
||||
from src import self_deploy # noqa: E402
|
||||
from src import labels # noqa: E402
|
||||
from src.stage_engine import advance_stage # noqa: E402
|
||||
|
||||
|
||||
def _pass(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||
# Pass all edge sub-gates so the deploy-staging -> deploy edge reaches Phase A.
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
# Default auto-mode flags ON (overridden per-test).
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_enabled", True, raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_deploy_label", "autoDeploy", raising=False)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def silence(monkeypatch):
|
||||
for name in ("notify_stage_change", "notify_qg_failure", "plane_notify_stage",
|
||||
"plane_notify_qg", "set_issue_in_review", "set_issue_awaiting_deploy",
|
||||
"set_issue_deploying"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||
monkeypatch.setattr(stage_engine, "plane_add_comment", MagicMock())
|
||||
monkeypatch.setattr(stage_engine, "send_telegram", MagicMock())
|
||||
|
||||
|
||||
def _make_task(stage="deploy-staging", repo="orchestrator",
|
||||
branch="feature/ORCH-089-x", wi="ORCH-089"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(f"plane-{wi}", wi, repo, branch, stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _label(monkeypatch, present=True, applies=True):
|
||||
monkeypatch.setattr(labels, "auto_deploy_applies", lambda repo: applies)
|
||||
monkeypatch.setattr(labels, "has_label", lambda w, name, p=None: present)
|
||||
|
||||
|
||||
def _advance(tid, repo="orchestrator", wi="ORCH-089"):
|
||||
return advance_stage(tid, "deploy-staging", repo, wi,
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
|
||||
|
||||
# --- TC-15 -----------------------------------------------------------------
|
||||
def test_tc15_auto_deploy_initiates_phase_b(monkeypatch):
|
||||
_label(monkeypatch, present=True)
|
||||
initiate = MagicMock(return_value=(True, "ok"))
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||
tid = _make_task()
|
||||
res = _advance(tid)
|
||||
# Phase B ran via the same path a human Confirm Deploy takes.
|
||||
initiate.assert_called_once()
|
||||
assert res.note == "self-deploy-initiated"
|
||||
assert self_deploy.has_marker("orchestrator", "ORCH-089", self_deploy.INITIATED)
|
||||
# APPROVE_REQUESTED (the human ask) was SKIPPED on the auto path.
|
||||
assert not self_deploy.has_marker(
|
||||
"orchestrator", "ORCH-089", self_deploy.APPROVE_REQUESTED
|
||||
)
|
||||
|
||||
|
||||
# --- TC-16 -----------------------------------------------------------------
|
||||
def test_tc16_no_label_waits_for_human(monkeypatch):
|
||||
_label(monkeypatch, present=False)
|
||||
initiate = MagicMock(return_value=(True, "ok"))
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||
tid = _make_task()
|
||||
res = _advance(tid)
|
||||
# Prior Phase A behaviour: approval-pending, no deploy initiated.
|
||||
assert res.note == "self-deploy-approval-pending"
|
||||
initiate.assert_not_called()
|
||||
assert self_deploy.has_marker(
|
||||
"orchestrator", "ORCH-089", self_deploy.APPROVE_REQUESTED
|
||||
)
|
||||
stage_engine.set_issue_awaiting_deploy.assert_called_once()
|
||||
|
||||
|
||||
# --- TC-17: idempotency ----------------------------------------------------
|
||||
def test_tc17_idempotent_initiated_marker(monkeypatch):
|
||||
"""autoDeploy delegates prod-deploy to _handle_self_deploy_phase_b, whose
|
||||
INITIATED marker makes a repeat a no-op. Phase A always clears stale state
|
||||
first (ADR D4), so the guard that protects against a double prod deploy is the
|
||||
INITIATED marker WRITTEN by Phase B — verify the auto path initiates exactly
|
||||
once and a subsequent Phase B re-entry (duplicate confirm / reaper re-drive) is
|
||||
a no-op."""
|
||||
_label(monkeypatch, present=True)
|
||||
initiate = MagicMock(return_value=(True, "ok"))
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||
tid = _make_task()
|
||||
res = _advance(tid)
|
||||
assert res.note == "self-deploy-initiated"
|
||||
assert initiate.call_count == 1
|
||||
assert self_deploy.has_marker("orchestrator", "ORCH-089", self_deploy.INITIATED)
|
||||
# A repeat Phase B (e.g. duplicate Confirm Deploy webhook / reaper re-drive)
|
||||
# with INITIATED already set is a no-op — no second prod deploy.
|
||||
res2 = stage_engine._handle_self_deploy_phase_b(
|
||||
tid, "orchestrator", "ORCH-089", "feature/ORCH-089-x",
|
||||
stage_engine.AdvanceResult(from_stage="deploy"),
|
||||
)
|
||||
assert initiate.call_count == 1 # still exactly one
|
||||
|
||||
|
||||
# --- TC-18: non-self / out of scope ----------------------------------------
|
||||
def test_tc18_non_self_repo_no_phase_a(monkeypatch):
|
||||
# For a non-self repo Phase A is not reached at all (self_deploy_applies False),
|
||||
# so autoDeploy is a structural no-op. The edge advances normally to `deploy`.
|
||||
_label(monkeypatch, present=True, applies=False)
|
||||
initiate = MagicMock(return_value=(True, "ok"))
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||
tid = _make_task(repo="enduro-trails", wi="ET-1")
|
||||
res = advance_stage(tid, "deploy-staging", "enduro-trails", "ET-1",
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
initiate.assert_not_called()
|
||||
# No Phase A / Phase B for non-self repo.
|
||||
assert res.note != "self-deploy-initiated"
|
||||
|
||||
|
||||
# --- TC-19: transparency ---------------------------------------------------
|
||||
def test_tc19_transparency_channels(monkeypatch, caplog):
|
||||
_label(monkeypatch, present=True)
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy",
|
||||
MagicMock(return_value=(True, "ok")))
|
||||
tid = _make_task()
|
||||
import logging
|
||||
with caplog.at_level(logging.INFO, logger="orchestrator.stage_engine"):
|
||||
_advance(tid)
|
||||
assert any("auto-confirmed" in r.message.lower() or "autoDeploy" in r.message
|
||||
for r in caplog.records)
|
||||
assert stage_engine.send_telegram.called
|
||||
comment_calls = [c for c in stage_engine.plane_add_comment.call_args_list
|
||||
if "авто-подтверждён" in c.args[1]]
|
||||
assert comment_calls, "expected an auto-deploy Plane comment"
|
||||
146
tests/test_auto_label_combinations.py
Normal file
146
tests/test_auto_label_combinations.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""ORCH-089 — label independence + kill-switch (AC-8/AC-9).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-20 only autoApprove: BRD auto, deploy waits for a human (AC-9).
|
||||
TC-21 only autoDeploy: BRD waits for a human, deploy auto (AC-9).
|
||||
TC-22 auto_label_enabled=False: both gates manual even with both labels (AC-8).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_auto_combos.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 MagicMock # noqa: E402
|
||||
|
||||
import src.db as _db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import stage_engine # noqa: E402
|
||||
from src import self_deploy # noqa: E402
|
||||
from src import labels # noqa: E402
|
||||
from src.stage_engine import advance_stage # noqa: E402
|
||||
|
||||
|
||||
def _pass(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_analysis_complete": _pass,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine, "_build_analyst_ready_comment",
|
||||
lambda *a, **k: "ready", raising=False)
|
||||
# Real auto-mode scope/flags (kill-switch exercised per-test).
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_enabled", True, raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_repos", "", raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_approve_label", "autoApprove", raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_deploy_label", "autoDeploy", raising=False)
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy",
|
||||
MagicMock(return_value=(True, "ok")))
|
||||
for name in ("notify_stage_change", "notify_qg_failure", "plane_notify_stage",
|
||||
"plane_notify_qg", "set_issue_in_review", "set_issue_needs_input",
|
||||
"set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_approved",
|
||||
"notify_approve_requested"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||
monkeypatch.setattr(stage_engine, "plane_add_comment", MagicMock())
|
||||
monkeypatch.setattr(stage_engine, "send_telegram", MagicMock())
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-089-x", wi="ORCH-089"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, brd_review_started_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, datetime('now'))",
|
||||
(f"plane-{wi}", wi, repo, branch, stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _stage_of(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
def _present_labels(monkeypatch, names):
|
||||
"""has_label True only for the given normalized label names (real applies())."""
|
||||
want = {n.casefold() for n in names}
|
||||
monkeypatch.setattr(labels, "has_label",
|
||||
lambda w, name, p=None: name.casefold() in want)
|
||||
|
||||
|
||||
def _run_brd(wi="ORCH-089"):
|
||||
tid = _make_task("analysis", wi=wi)
|
||||
res = advance_stage(tid, "analysis", "orchestrator", wi,
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
return tid, res
|
||||
|
||||
|
||||
def _run_deploy(wi="ORCH-089"):
|
||||
tid = _make_task("deploy-staging", wi=wi)
|
||||
res = advance_stage(tid, "deploy-staging", "orchestrator", wi,
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
return tid, res
|
||||
|
||||
|
||||
# --- TC-20: only autoApprove -----------------------------------------------
|
||||
def test_tc20_only_auto_approve(monkeypatch):
|
||||
_present_labels(monkeypatch, ["autoApprove"])
|
||||
tid_brd, res_brd = _run_brd()
|
||||
assert res_brd.note == "auto-approved-via-label"
|
||||
assert _stage_of(tid_brd) == "architecture"
|
||||
# Deploy gate still manual (autoDeploy absent).
|
||||
_tid_dep, res_dep = _run_deploy()
|
||||
assert res_dep.note == "self-deploy-approval-pending"
|
||||
|
||||
|
||||
# --- TC-21: only autoDeploy ------------------------------------------------
|
||||
def test_tc21_only_auto_deploy(monkeypatch):
|
||||
_present_labels(monkeypatch, ["autoDeploy"])
|
||||
tid_brd, res_brd = _run_brd()
|
||||
# BRD gate still manual (autoApprove absent).
|
||||
assert res_brd.note == "analysis-in-review"
|
||||
assert _stage_of(tid_brd) == "analysis"
|
||||
# Deploy auto-confirmed.
|
||||
_tid_dep, res_dep = _run_deploy()
|
||||
assert res_dep.note == "self-deploy-initiated"
|
||||
|
||||
|
||||
# --- TC-22: kill-switch -> both manual -------------------------------------
|
||||
def test_tc22_killswitch_both_manual(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_enabled", False, raising=False)
|
||||
# Both labels "present", but the kill-switch makes applies() False FIRST, so
|
||||
# has_label is never consulted -> both gates stay manual.
|
||||
spy = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(labels, "has_label", spy)
|
||||
tid_brd, res_brd = _run_brd()
|
||||
assert res_brd.note == "analysis-in-review"
|
||||
assert _stage_of(tid_brd) == "analysis"
|
||||
_tid_dep, res_dep = _run_deploy()
|
||||
assert res_dep.note == "self-deploy-approval-pending"
|
||||
spy.assert_not_called() # zero network — AC-8
|
||||
147
tests/test_auto_labels_integration.py
Normal file
147
tests/test_auto_labels_integration.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""ORCH-089 — integration: end-to-end auto-pass across pipeline edges.
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-23 both labels + all tech-gates green -> analysis -> deploy with no manual
|
||||
clicks (AC-3).
|
||||
TC-24 autoDeploy + a RED edge sub-gate -> Phase A not reached, Phase B not
|
||||
initiated (AC-5).
|
||||
TC-25 regression: no labels -> both gates manual exactly as before ORCH-089
|
||||
(AC-4).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_auto_integ.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 MagicMock # noqa: E402
|
||||
|
||||
import src.db as _db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import stage_engine # noqa: E402
|
||||
from src import self_deploy # noqa: E402
|
||||
from src import labels # noqa: E402
|
||||
from src.stage_engine import advance_stage # noqa: E402
|
||||
|
||||
|
||||
def _pass(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_enabled", True, raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_label_repos", "", raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_approve_label", "autoApprove", raising=False)
|
||||
monkeypatch.setattr(stage_engine.settings, "auto_deploy_label", "autoDeploy", raising=False)
|
||||
monkeypatch.setattr(stage_engine, "_build_analyst_ready_comment",
|
||||
lambda *a, **k: "ready", raising=False)
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy",
|
||||
MagicMock(return_value=(True, "ok")))
|
||||
for name in ("notify_stage_change", "notify_qg_failure", "plane_notify_stage",
|
||||
"plane_notify_qg", "set_issue_in_review", "set_issue_needs_input",
|
||||
"set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_approved",
|
||||
"set_issue_blocked", "notify_approve_requested"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||
monkeypatch.setattr(stage_engine, "plane_add_comment", MagicMock())
|
||||
monkeypatch.setattr(stage_engine, "send_telegram", MagicMock())
|
||||
yield
|
||||
|
||||
|
||||
def _gates(monkeypatch, **overrides):
|
||||
base = {
|
||||
"check_analysis_complete": _pass,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_staging_image_fresh": _pass,
|
||||
}
|
||||
base.update(overrides)
|
||||
monkeypatch.setattr(stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, **base})
|
||||
|
||||
|
||||
def _make_task(stage, wi="ORCH-089", repo="orchestrator"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, brd_review_started_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, datetime('now'))",
|
||||
(f"plane-{wi}", wi, repo, "feature/ORCH-089-x", stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _stage_of(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
# --- TC-23: both labels, all green -> autonomous ---------------------------
|
||||
def test_tc23_both_labels_autonomous(monkeypatch):
|
||||
_gates(monkeypatch)
|
||||
monkeypatch.setattr(labels, "has_label", lambda w, name, p=None: True)
|
||||
|
||||
# BRD edge: analyst finished -> auto-approve -> architecture.
|
||||
brd = _make_task("analysis")
|
||||
res_brd = advance_stage(brd, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
assert res_brd.note == "auto-approved-via-label"
|
||||
assert _stage_of(brd) == "architecture"
|
||||
|
||||
# Deploy edge: staging-deployer finished -> Phase A advances to deploy -> auto
|
||||
# Phase B initiates the prod deploy. No human Approved nor Confirm Deploy.
|
||||
dep = _make_task("deploy-staging", wi="ORCH-089b")
|
||||
res_dep = advance_stage(dep, "deploy-staging", "orchestrator", "ORCH-089b",
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
assert res_dep.note == "self-deploy-initiated"
|
||||
assert stage_engine.self_deploy.initiate_deploy.called
|
||||
assert _stage_of(dep) == "deploy"
|
||||
|
||||
|
||||
# --- TC-24: red sub-gate blocks autoDeploy ---------------------------------
|
||||
def test_tc24_red_staging_blocks_auto_deploy(monkeypatch):
|
||||
# staging RED -> the edge fails BEFORE Phase A -> autoDeploy cannot fire.
|
||||
_gates(monkeypatch, check_staging_status=lambda *a, **k: (False, "FAILED"))
|
||||
monkeypatch.setattr(labels, "has_label", lambda w, name, p=None: True)
|
||||
|
||||
dep = _make_task("deploy-staging")
|
||||
res = advance_stage(dep, "deploy-staging", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
# Phase B never initiated despite the autoDeploy label.
|
||||
assert not stage_engine.self_deploy.initiate_deploy.called
|
||||
assert res.note != "self-deploy-initiated"
|
||||
assert _stage_of(dep) != "deploy" # did not advance onto deploy
|
||||
|
||||
|
||||
# --- TC-25: regression — no labels -> manual gates -------------------------
|
||||
def test_tc25_no_labels_manual(monkeypatch):
|
||||
_gates(monkeypatch)
|
||||
monkeypatch.setattr(labels, "has_label", lambda w, name, p=None: False)
|
||||
|
||||
brd = _make_task("analysis")
|
||||
res_brd = advance_stage(brd, "analysis", "orchestrator", "ORCH-089",
|
||||
"feature/ORCH-089-x", finished_agent="analyst")
|
||||
assert res_brd.note == "analysis-in-review"
|
||||
assert _stage_of(brd) == "analysis"
|
||||
|
||||
dep = _make_task("deploy-staging", wi="ORCH-089b")
|
||||
res_dep = advance_stage(dep, "deploy-staging", "orchestrator", "ORCH-089b",
|
||||
"feature/ORCH-089-x", finished_agent="deployer")
|
||||
assert res_dep.note == "self-deploy-approval-pending"
|
||||
assert not stage_engine.self_deploy.initiate_deploy.called
|
||||
33
tests/test_auto_labels_invariants.py
Normal file
33
tests/test_auto_labels_invariants.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""ORCH-089 — TC-26: invariant registries are NOT touched by the auto-label work.
|
||||
|
||||
The auto-mode reuses existing transitions/gates and only removes the wait for a
|
||||
human signal; it must not add a stage, a transition, or a QG check (TRZ §10 / AC-10).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_auto_inv.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
|
||||
def test_tc26_stage_transitions_unchanged():
|
||||
from src.stages import STAGE_TRANSITIONS
|
||||
assert set(STAGE_TRANSITIONS) == {
|
||||
"created", "analysis", "architecture", "development", "review",
|
||||
"testing", "deploy-staging", "deploy", "done",
|
||||
}
|
||||
# The two human gates still use their existing QG names (unchanged).
|
||||
assert STAGE_TRANSITIONS["analysis"]["qg"] == "check_analysis_approved"
|
||||
|
||||
|
||||
def test_tc26_no_new_qg_check():
|
||||
from src.qg.checks import QG_CHECKS
|
||||
# No auto-label / auto-approve / auto-deploy QG check was introduced — the
|
||||
# auto-mode is a decision врезка, not a registered quality gate.
|
||||
assert not any(
|
||||
tok in k for k in QG_CHECKS for tok in ("auto_label", "autoapprove", "autodeploy")
|
||||
), "ORCH-089 must not register a new QG check"
|
||||
# The gates it reuses are present and untouched.
|
||||
for k in ("check_analysis_approved", "check_deploy_status", "check_staging_status"):
|
||||
assert k in QG_CHECKS
|
||||
150
tests/test_labels.py
Normal file
150
tests/test_labels.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""ORCH-089 — src/labels.py: auto-mode pure logic (never-raise, fail-safe).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-01 has_label True when the label is present on the issue.
|
||||
TC-02 has_label False when the label is absent.
|
||||
TC-03 has_label fail-safe (no auto, never-raise) on Plane API error/timeout.
|
||||
TC-04 label-name matching is normalized (case/space); ambiguity -> no auto.
|
||||
TC-05 auto_approve_applies / auto_deploy_applies: CSV scope + self-hosting.
|
||||
TC-06 auto_label_enabled=False -> applies() False -> has_label never reached
|
||||
(no network call on the gate).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_labels.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import labels # noqa: E402
|
||||
from src import plane_sync # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enabled_self_hosting(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "auto_approve_label", "autoApprove", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "auto_deploy_label", "autoDeploy", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_repos", "", raising=False)
|
||||
# Keep _resolve_project_id offline-deterministic.
|
||||
monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1")
|
||||
yield
|
||||
|
||||
|
||||
# --- TC-01 / TC-02 ---------------------------------------------------------
|
||||
def test_tc01_has_label_present(monkeypatch):
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is True
|
||||
|
||||
|
||||
def test_tc02_has_label_absent(monkeypatch):
|
||||
# Issue carries a different label uuid than the project's autoApprove uuid.
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-OTHER"])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
def test_tc02_has_label_empty_issue_labels(monkeypatch):
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: [])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
# --- TC-03: fail-safe / never-raise ----------------------------------------
|
||||
def test_tc03_fetch_none_failsafe(monkeypatch):
|
||||
# fetch returns None (could-not-read) -> False, no auto.
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: None)
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
def test_tc03_fetch_raises_failsafe(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("plane down")
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", boom)
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
# Never raises; degrades to no auto.
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
def test_tc03_project_map_empty_failsafe(monkeypatch):
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {})
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
# --- TC-04: normalization + ambiguity --------------------------------------
|
||||
def test_tc04_normalized_match(monkeypatch):
|
||||
# Issue label uuid-A; project maps a differently-cased/spaced name to it.
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
# Sought name has different case + surrounding spaces.
|
||||
assert labels.has_label("ORCH-1", " AUTOapprove ") is True
|
||||
|
||||
|
||||
def test_tc04_ambiguous_name_no_auto(monkeypatch):
|
||||
# Two distinct project labels collapse to the same normalized name -> ambiguous
|
||||
# sentinel from get_project_labels -> has_label False.
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
|
||||
monkeypatch.setattr(
|
||||
plane_sync, "get_project_labels",
|
||||
lambda pid: {"autoapprove": "__AMBIGUOUS__"},
|
||||
)
|
||||
assert labels.has_label("ORCH-1", "autoApprove") is False
|
||||
|
||||
|
||||
def test_tc04_empty_label_name(monkeypatch):
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-A"])
|
||||
monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"autoapprove": "uuid-A"})
|
||||
assert labels.has_label("ORCH-1", "") is False
|
||||
|
||||
|
||||
# --- TC-05: scope (CSV + self-hosting) -------------------------------------
|
||||
def test_tc05_empty_csv_self_hosting_only(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_repos", "", raising=False)
|
||||
assert labels.auto_approve_applies("orchestrator") is True
|
||||
assert labels.auto_deploy_applies("orchestrator") is True
|
||||
# Non-self repo with empty CSV -> not in scope.
|
||||
assert labels.auto_approve_applies("enduro-trails") is False
|
||||
assert labels.auto_deploy_applies("enduro-trails") is False
|
||||
|
||||
|
||||
def test_tc05_csv_membership(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_repos", "enduro-trails, foo", raising=False)
|
||||
assert labels.auto_approve_applies("enduro-trails") is True
|
||||
assert labels.auto_deploy_applies("foo") is True
|
||||
# orchestrator is NOT in the explicit CSV -> out of scope.
|
||||
assert labels.auto_approve_applies("orchestrator") is False
|
||||
|
||||
|
||||
# --- TC-06: kill-switch -> applies False, no network -----------------------
|
||||
def test_tc06_killswitch_applies_false(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_enabled", False, raising=False)
|
||||
assert labels.auto_approve_applies("orchestrator") is False
|
||||
assert labels.auto_deploy_applies("orchestrator") is False
|
||||
|
||||
|
||||
def test_tc06_killswitch_gate_makes_no_network(monkeypatch):
|
||||
"""The gate idiom `applies(repo) and has_label(...)` short-circuits before any
|
||||
network call when the kill-switch is off (AC-8)."""
|
||||
monkeypatch.setattr(cfg.settings, "auto_label_enabled", False, raising=False)
|
||||
called = {"fetch": 0}
|
||||
|
||||
def spy(*a, **k):
|
||||
called["fetch"] += 1
|
||||
return ["uuid-A"]
|
||||
monkeypatch.setattr(plane_sync, "fetch_issue_labels", spy)
|
||||
|
||||
repo = "orchestrator"
|
||||
fired = labels.auto_approve_applies(repo) and labels.has_label("ORCH-1", "autoApprove")
|
||||
assert fired is False
|
||||
assert called["fetch"] == 0 # has_label never reached -> zero network
|
||||
|
||||
|
||||
def test_snapshot_never_raises():
|
||||
snap = labels.snapshot()
|
||||
assert set(snap) >= {"enabled", "approve_label", "deploy_label", "repos"}
|
||||
164
tests/test_plane_sync_labels.py
Normal file
164
tests/test_plane_sync_labels.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""ORCH-089 — plane_sync: label reading + Approved setter (offline, httpx mocked).
|
||||
|
||||
Covers (04-test-plan.yaml):
|
||||
TC-07 fetch_issue_labels parses the issue's `labels` field; get_project_labels
|
||||
resolves {normalized_name -> uuid}.
|
||||
TC-08 the project label-map is cached with a TTL (a repeat inside the TTL window
|
||||
makes no second GET).
|
||||
TC-09 set_issue_approved PATCHes the issue to the Approved UUID; never-raise.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_ps_labels.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
from src import plane_sync as ps # noqa: E402
|
||||
|
||||
|
||||
def _resp(json_body):
|
||||
m = MagicMock()
|
||||
m.json.return_value = json_body
|
||||
m.raise_for_status.return_value = None
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_cache(monkeypatch):
|
||||
ps.reload_project_labels()
|
||||
monkeypatch.setattr(ps, "_resolve_project_id", lambda w=None, p=None: "proj-1")
|
||||
monkeypatch.setattr(ps.settings, "auto_label_states_ttl_s", 300, raising=False)
|
||||
yield
|
||||
ps.reload_project_labels()
|
||||
|
||||
|
||||
# --- TC-07: fetch_issue_labels + get_project_labels ------------------------
|
||||
def test_tc07_fetch_issue_labels(monkeypatch):
|
||||
monkeypatch.setattr(ps, "find_issue_id", lambda w, p=None: "issue-uuid")
|
||||
monkeypatch.setattr(
|
||||
ps.httpx, "get",
|
||||
lambda *a, **k: _resp({"labels": ["uuid-A", "uuid-B"]}),
|
||||
)
|
||||
assert ps.fetch_issue_labels("ORCH-1") == ["uuid-A", "uuid-B"]
|
||||
|
||||
|
||||
def test_tc07_fetch_issue_labels_not_found(monkeypatch):
|
||||
# Issue not resolvable -> None (distinct from [] = "no labels").
|
||||
monkeypatch.setattr(ps, "find_issue_id", lambda w, p=None: None)
|
||||
assert ps.fetch_issue_labels("ORCH-404") is None
|
||||
|
||||
|
||||
def test_tc07_fetch_issue_labels_api_error(monkeypatch):
|
||||
monkeypatch.setattr(ps, "find_issue_id", lambda w, p=None: "issue-uuid")
|
||||
monkeypatch.setattr(ps.httpx, "get", MagicMock(side_effect=Exception("boom")))
|
||||
assert ps.fetch_issue_labels("ORCH-1") is None # never-raise
|
||||
|
||||
|
||||
def test_tc07_get_project_labels_normalized(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ps.httpx, "get",
|
||||
lambda *a, **k: _resp({"results": [
|
||||
{"id": "uuid-A", "name": "autoApprove"},
|
||||
{"id": "uuid-B", "name": "Auto Deploy"},
|
||||
]}),
|
||||
)
|
||||
m = ps.get_project_labels("proj-1")
|
||||
assert m["autoapprove"] == "uuid-A"
|
||||
assert m["auto deploy"] == "uuid-B"
|
||||
|
||||
|
||||
def test_tc07_get_project_labels_ambiguous(monkeypatch):
|
||||
# Two distinct labels collapse to the same normalized name -> sentinel.
|
||||
monkeypatch.setattr(
|
||||
ps.httpx, "get",
|
||||
lambda *a, **k: _resp([
|
||||
{"id": "uuid-A", "name": "autoApprove"},
|
||||
{"id": "uuid-B", "name": "AUTOAPPROVE"},
|
||||
]),
|
||||
)
|
||||
m = ps.get_project_labels("proj-1")
|
||||
assert m["autoapprove"] == "__AMBIGUOUS__"
|
||||
|
||||
|
||||
def test_tc07_get_project_labels_api_error_empty(monkeypatch):
|
||||
monkeypatch.setattr(ps.httpx, "get", MagicMock(side_effect=Exception("down")))
|
||||
assert ps.get_project_labels("proj-1") == {} # never-raise, no cache -> {}
|
||||
|
||||
|
||||
# --- TC-08: TTL cache ------------------------------------------------------
|
||||
def test_tc08_label_map_cached_within_ttl(monkeypatch):
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
|
||||
mock_get = MagicMock(side_effect=lambda *a, **k: _resp(
|
||||
{"results": [{"id": "uuid-A", "name": "autoApprove"}]}
|
||||
))
|
||||
monkeypatch.setattr(ps.httpx, "get", mock_get)
|
||||
|
||||
ps.get_project_labels("proj-1")
|
||||
ps.get_project_labels("proj-1") # within TTL -> served from cache
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
# Past the TTL -> refetch.
|
||||
clock["t"] += 301
|
||||
ps.get_project_labels("proj-1")
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
|
||||
def test_tc08_ttl_zero_lifetime_cache(monkeypatch):
|
||||
monkeypatch.setattr(ps.settings, "auto_label_states_ttl_s", 0, raising=False)
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
|
||||
mock_get = MagicMock(side_effect=lambda *a, **k: _resp(
|
||||
[{"id": "uuid-A", "name": "autoApprove"}]
|
||||
))
|
||||
monkeypatch.setattr(ps.httpx, "get", mock_get)
|
||||
ps.get_project_labels("proj-1")
|
||||
clock["t"] += 100000
|
||||
ps.get_project_labels("proj-1")
|
||||
assert mock_get.call_count == 1 # lifetime cache, never expires
|
||||
|
||||
|
||||
def test_tc08_stale_served_on_refresh_failure(monkeypatch):
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
|
||||
responses = iter([
|
||||
_resp({"results": [{"id": "uuid-A", "name": "autoApprove"}]}),
|
||||
Exception("transient"),
|
||||
])
|
||||
|
||||
def flaky(*a, **k):
|
||||
r = next(responses)
|
||||
if isinstance(r, Exception):
|
||||
raise r
|
||||
return r
|
||||
monkeypatch.setattr(ps.httpx, "get", flaky)
|
||||
ps.get_project_labels("proj-1")
|
||||
clock["t"] += 301 # force a refresh that fails -> stale map served
|
||||
m = ps.get_project_labels("proj-1")
|
||||
assert m["autoapprove"] == "uuid-A"
|
||||
|
||||
|
||||
# --- TC-09: set_issue_approved ---------------------------------------------
|
||||
def test_tc09_set_issue_approved_patches_approved_uuid(monkeypatch):
|
||||
monkeypatch.setattr(ps, "get_project_states", lambda pid: {"approved": "approved-uuid"})
|
||||
monkeypatch.setattr(ps, "find_issue_id", lambda w, p=None: "issue-uuid")
|
||||
patch_spy = MagicMock(return_value=_resp({}))
|
||||
monkeypatch.setattr(ps.httpx, "patch", patch_spy)
|
||||
|
||||
ps.set_issue_approved("ORCH-1")
|
||||
|
||||
patch_spy.assert_called_once()
|
||||
assert patch_spy.call_args.kwargs["json"] == {"state": "approved-uuid"}
|
||||
|
||||
|
||||
def test_tc09_set_issue_approved_never_raises(monkeypatch):
|
||||
monkeypatch.setattr(ps, "get_project_states", lambda pid: {"approved": "approved-uuid"})
|
||||
monkeypatch.setattr(ps, "find_issue_id", lambda w, p=None: "issue-uuid")
|
||||
monkeypatch.setattr(ps.httpx, "patch", MagicMock(side_effect=Exception("boom")))
|
||||
# Must not raise.
|
||||
ps.set_issue_approved("ORCH-1")
|
||||
Reference in New Issue
Block a user