diff --git a/CHANGELOG.md b/CHANGELOG.md index f4cc4c1..18554b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/CLAUDE.md b/CLAUDE.md index f366544..5ab30f8 100644 --- a/CLAUDE.md +++ b/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` diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 501c293..3d591a0 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -130,7 +130,7 @@ 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 — design) +### Авто-режим по лейблам: autoApprove + autoDeploy (ORCH-089 — реализовано) Конвейер имеет два **человеческих** гейта, тормозящих пакетный автономный прогон (эпик ORCH-088): гейт BRD (`analysis`: ждёт ручного `Approved`) и гейт прод-деплоя (`deploy`: Phase A ждёт ручного `Confirm Deploy`, ORCH-059). ORCH-089 снимает **только эти два diff --git a/src/config.py b/src/config.py index f0eb05e..3045b8d 100644 --- a/src/config.py +++ b/src/config.py @@ -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 = "" diff --git a/src/labels.py b/src/labels.py new file mode 100644 index 0000000..fd3a9df --- /dev/null +++ b/src/labels.py @@ -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": ""} diff --git a/src/main.py b/src/main.py index fa7cf8c..ccb3734 100644 --- a/src/main.py +++ b/src/main.py @@ -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), } diff --git a/src/plane_sync.py b/src/plane_sync.py index f2e31fb..0bd2fd2 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -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//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 diff --git a/src/stage_engine.py b/src/stage_engine.py index a48ae72..c088604 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -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()) ) diff --git a/tests/test_auto_approve_brd.py b/tests/test_auto_approve_brd.py new file mode 100644 index 0000000..1f97a0c --- /dev/null +++ b/tests/test_auto_approve_brd.py @@ -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" diff --git a/tests/test_auto_deploy.py b/tests/test_auto_deploy.py new file mode 100644 index 0000000..c7f2eed --- /dev/null +++ b/tests/test_auto_deploy.py @@ -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" diff --git a/tests/test_auto_label_combinations.py b/tests/test_auto_label_combinations.py new file mode 100644 index 0000000..2106b82 --- /dev/null +++ b/tests/test_auto_label_combinations.py @@ -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 diff --git a/tests/test_auto_labels_integration.py b/tests/test_auto_labels_integration.py new file mode 100644 index 0000000..9daec16 --- /dev/null +++ b/tests/test_auto_labels_integration.py @@ -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 diff --git a/tests/test_auto_labels_invariants.py b/tests/test_auto_labels_invariants.py new file mode 100644 index 0000000..11d0c11 --- /dev/null +++ b/tests/test_auto_labels_invariants.py @@ -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 diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..48caaf4 --- /dev/null +++ b/tests/test_labels.py @@ -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"} diff --git a/tests/test_plane_sync_labels.py b/tests/test_plane_sync_labels.py new file mode 100644 index 0000000..365e8d5 --- /dev/null +++ b/tests/test_plane_sync_labels.py @@ -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")