# 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`.
