diff --git a/.env.example b/.env.example index 40423d9..6c81285 100644 --- a/.env.example +++ b/.env.example @@ -12,11 +12,25 @@ ORCH_GITEA_WEBHOOK_SECRET= ORCH_CLAUDE_BIN=/usr/bin/claude ORCH_REPOS_DIR=/home/slin/repos ORCH_DB_PATH=/app/data/orchestrator.db -# ORCH-042: live-tracker mode. edit (DEFAULT) -> the task card is edited in place -# (editMessageText). bump -> on every update the old card is deleted and a fresh -# one is sent silently to the BOTTOM of the chat (deleteMessage + sendMessage + -# repoint). One card per task in both modes. Any value other than "bump" -> edit. -ORCH_TRACKER_MODE=edit +# ORCH-042/ORCH-067: live-tracker mode. bump (DEFAULT since ORCH-067) -> on every +# update the old card is deleted and a fresh one is sent silently to the BOTTOM of +# the chat (deleteMessage + sendMessage + repoint), so the current status is always +# the last message in an active chat. edit -> the task card is edited in place +# (editMessageText). One card per task in both modes. Any value other than "bump" +# (incl. empty/garbage) -> edit. +ORCH_TRACKER_MODE=bump +# ORCH-067: best-effort live-overlay for the card status line. The offline core +# (stage -> Plane status, In Review from the brd-clock) always works without network; +# the overlay only fills in branches indistinguishable offline (Needs Input / Blocked / +# Rejected / Cancelled / Deploying / Monitoring after Deploy) by reading the LIVE Plane +# status with a short timeout + per-issue TTL cache. It NEVER blocks the pipeline and +# NEVER raises. +# LIVE_STATUS -> kill-switch (false -> offline core only). +# LIVE_STATUS_TTL_S -> TTL (seconds) of the per-issue live-uuid cache (hot-path guard). +# LIVE_STATUS_TIMEOUT_S -> timeout (seconds) of a single live-GET on the render path. +ORCH_TRACKER_LIVE_STATUS=true +ORCH_TRACKER_LIVE_STATUS_TTL_S=60 +ORCH_TRACKER_LIVE_STATUS_TIMEOUT_S=3 # ORCH-043: merge-gate (auto-rebase onto current origin/main + re-test + merge-lock) # on the deploy-staging -> deploy edge. Deterministic sub-gate (no LLM) that catches # the branch up to the CURRENT origin/main, re-tests it, and serialises merges so two @@ -199,3 +213,10 @@ ORCH_POST_DEPLOY_FAIL_THRESHOLD=3 ORCH_POST_DEPLOY_5XX_THRESHOLD=0.5 ORCH_POST_DEPLOY_AUTO_ROLLBACK=false ORCH_POST_DEPLOY_BASE_URL=http://localhost:8500 + +# ── QG-0 entry validation (ORCH-069) ────────────────────────────────────────── +# Upper title-length limit for the QG-0 entry gate (_qg0_errors). The old 80-char +# cap was a hygiene limit, not structural (slug is cut to [:30] independently, the +# DB title TEXT is unbounded). Default 200. An invalid/empty value gracefully +# degrades to 200 (the process never crashes on startup). +ORCH_QG0_TITLE_MAX=200 diff --git a/.env.staging.example b/.env.staging.example index f3af589..722ed25 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -50,3 +50,6 @@ ORCH_QUEUE_POLL_INTERVAL=2.0 DEPLOY_SSH_USER=slin DEPLOY_SSH_HOST=127.0.0.1 DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh + +# QG-0 entry title-length limit (ORCH-069). Default 200; invalid/empty -> 200. +ORCH_QG0_TITLE_MAX=200 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f5361f..5184332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,10 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Конфигурируемый верхний лимит длины заголовка QG-0 (`ORCH_QG0_TITLE_MAX`, дефолт 200)** (ORCH-069): хардкод `if len(name) > 80` во входной валидации `_qg0_errors` (`src/webhooks/plane.py`) вынесен в настраиваемый параметр `Settings.qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, дефолт 200). Лимит 80 был гигиеническим, а не структурным (slug режется независимо `[:30]`, `tasks.title TEXT` без ограничения), поэтому валидные заголовки 81–200 символов отклонялись на входе без бизнес-причины. Лимит читается из `settings.qg0_title_max` динамически на каждый вызов (тесты патчат значение), текст ошибки подставляет актуальное число; граница строгая (`len > limit` → FAIL, `len == limit` → PASS). **Graceful-деградация (AC-3, self-hosting safety):** пустое/нечисловое значение env не роняет процесс на старте — `field_validator(mode="before")` `_qg0_title_max_default` в `src/config.py` перехватывает сырое env ДО `int`-парсинга pydantic и при невалидном/пустом входе возвращает дефолт 200 (never-raise), гася `ValidationError`. Чисто аддитивно и обратносовместимо: дефолт 200 > прежних 80 → все ранее проходившие заголовки проходят (AC-7). Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (QG-0 — inline-валидация входа, не зарегистрированный stage-gate), схема БД, slug-логика `[:30]`, нижние лимиты (`< 5` title, `< 20` description), soft-QG-0 поведение (warning на `work_item.created`), API. ADR `docs/work-items/ORCH-069/06-adr/ADR-001-configurable-qg0-title-limit.md`. Документация: `.env.example`, `.env.staging.example`. Тесты: `tests/test_qg0_title_limit.py`. ### Added +- **Telegram live-tracker: `bump` по умолчанию + статус-строка Plane + кликабельный номер задачи** (ORCH-067): три улучшения карточки задачи (`src/notifications.py`), без изменения транспорта/схемы БД/`STAGE_TRANSITIONS`/QG. (1) **Дефолт `tracker_mode` сменён `edit → bump`** (`src/config.py`): актуальный статус всегда последним сообщением в чате при активной переписке; `edit` остаётся доступен через `ORCH_TRACKER_MODE=edit`. Логика `update_task_tracker` (best-effort `delete_telegram(old)` → `send_telegram(..., disable_notification=True)` → `set_tracker_message_id` только при успешном send) и инвариант «одна карточка на задачу» сохранены. (2) **Статус-строка карточки** `📍 ` по статусной модели ORCH-066: чистый/детерминированный, never-raise хелпер `plane_status_label(task_row)` (любая ошибка → дефолт по `stage`, рендер не ломается). Оффлайн-ядро (`stage → Plane-статус`; `⏸️ In Review` из brd-clock; `⏸️ Awaiting Deploy`) работает всегда без сети; ветки, неотличимые offline (`❓ Needs Input`, `Blocked`, `Rejected`, `Cancelled`, `Deploying`, `Monitoring after Deploy`), дорисовывает **best-effort live-overlay** `_live_plane_branch_override` — читает живой Plane-статус (reverse-map UUID→имя) с kill-switch'ем, per-issue TTL-кэшем и коротким таймаутом; недоступность сети/ответа → тихая деградация на stage-маппинг, конвейер НИКОГДА не блокируется (ADR Р-2/Р-3/Р-4). (3) **Кликабельный номер задачи**: единый never-raise хелпер `plane_issue_link(work_item_id, plane_issue_id, project_id, repo)` → `ORCH-NNN`, переиспользует guard'ы ORCH-017 (`_plane_issue_url`, loopback-base → «нет web URL»); fail-safe (не хватает web_base/workspace/project_id/issue_id) → `html.escape(work_item_id)` (номер без ссылки). Применён в заголовке карточки (`render_task_tracker` дочитывает `repo`/`plane_issue_id` из `tasks`, схема не менялась) и во всех точках `send_telegram`/`notify_*`, где в тексте есть `work_item_id` (`notify_approve_requested`/`notify_error`, `stage_engine.py`, `agents/launcher.py`, `merge_gate.py`, `job_reaper.py`, `security_gate.py`, `reconciler.py`, `main.py` — ровно где упоминается номер). Новые настройки: `ORCH_TRACKER_LIVE_STATUS` (true, kill-switch), `ORCH_TRACKER_LIVE_STATUS_TTL_S` (60), `ORCH_TRACKER_LIVE_STATUS_TIMEOUT_S` (3). Самохостинг: смена дефолта `bump` затрагивает ВСЕ проекты — проверено отсутствие регресса (тесты + staging). ADR `docs/work-items/ORCH-067/06-adr/ADR-001-tracker-plane-status-and-link.md`. Документация: `CLAUDE.md` (раздел «Нотификации / Telegram live-tracker»), `docs/architecture/README.md`, `docs/architecture/internals.md` (§7), `.env.example`. - **Merge-в-`main` + пост-деплой верификация как обязательное условие `done` (фикс «фантомного merge»)** (ORCH-071): задача могла дойти до `done`, хотя ветка фактически НЕ влита в `main` («фантомный merge») — конвейер рапортовал успех без реального состояния репозитория. Введён под-гейт ребра `deploy → done`: единственная точка перехода `advance_stage` теперь гейтится `_handle_merge_verify` (`src/stage_engine.py`), который покрывает ВСЕ пути финализации (finalizer Phase C, reconciler F-1, job-reaper). Добавлены детерминированный merge-актор и пост-деплой верификатор (`src/merge_gate.py`): merge выполняется ТОЛЬКО через PR-merge API (без push/force-push, INV-4) в restart-surviving Phase C, верификация подтверждает фактическое слияние в `main` прежде чем разрешить переход в `done`. Раскат условный и снабжён kill-switch (`src/config.py`, `src/main.py`, по образцу условности ORCH-35/43/58), never-raise контракты соблюдены. Документация: глобальный `docs/architecture/adr/adr-0013-merge-verify-gate.md`, детальный `docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md` (D1–D9), раздел в `docs/architecture/README.md`, runbook постмортема `docs/operations/PHANTOM_MERGE_RUNBOOK.md` (4 проверки + критерий «фантом подтверждён» + remediation). Тесты: `tests/test_merge_actor.py`, `tests/test_merge_verify.py`, `tests/test_deploy_finalizer_merge_gate.py`, `tests/test_deploy_restart_merge_recovery.py`, `tests/test_qg_checks.py`, `tests/test_stages.py`. - **Security-гейт: secret-scanning (gitleaks) + dependency audit (pip-audit) перед мержем** (ORCH-022): автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/приватный ключ) и уязвимую зависимость (известный CVE) — для self-hosting `orchestrator` это особенно остро: один общий прод-инстанс обслуживает все проекты из общей БД, поэтому секрет/CVE, проскочивший через одну задачу, уезжает в прод всех проектов (CLAUDE.md §self-hosting, §8). ORCH-022 вводит детерминированный (без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, исполняемый **ПЕРВЫМ** среди edge-под-гейтов (ДО merge-gate ORCH-043 и image-freshness ORCH-058) — дёшево фейлить до дорогих rebase/rebuild, а скан ветки ДО rebase не «обвиняет» задачу в CVE из обновившегося `main`. Паттерн соседей: новый leaf-модуль `src/security_gate.py` (контракт «never-raise», по образцу `merge_gate`/`image_freshness`/`staging_verdict`) + тонкая обёртка `check_security_gate` в реестре `QG_CHECKS` (`src/qg/checks.py`, lazy-import → нет цикла) + врезка `_handle_security_gate` в `src/stage_engine.py` в блок `current_stage == "deploy-staging"` ПЕРВОЙ. `STAGE_TRANSITIONS` и схема БД — **без изменений**. **Secret-scanning (`gitleaks`, offline):** скан диапазона `origin/main..HEAD` (ровно коммиты задачи); любой секрет вне аллоулиста версионируемого `.gitleaks.toml` → вклад в FAIL. Полностью оффлайн (локальные правила) → гарантия «секрет всегда блокирует» (BR-2) безусловна, не зависит от сети; **fail-closed** при ошибке инструмента/отсутствии бинаря/таймауте (нельзя доказать «секретов нет» → FAIL). Контракт exit-кодов: 0=чисто, 1=найдено, ≥2=ошибка. **Dependency audit (`pip-audit`, OSV/PyPI):** аудит `requirements.txt`; severity ≥ `security_dep_block_severity` (дефолт `HIGH`, порядок CRITICAL>HIGH>MEDIUM>LOW) → вклад в FAIL (`deps_blocking`); ниже порога / UNKNOWN → warning (`deps_warning`, анти-петля Р-4, не авто-блок). Источник advisory требует сети → недоступность фида **fail-open + громкий warning** по умолчанию (`deps_audit_degraded: true` + Telegram + лог; прецедент анти-петли ORCH-061), флаг `security_dep_audit_fail_closed` переводит в строгий режим без редеплоя кода. **Артефакт `17-security-report.md`** (YAML-frontmatter `security_status`/`secrets_found`/`deps_blocking`/`deps_warning`/`deps_audit_degraded` + тело-списки находок); машинный вердикт читается ТОЛЬКО из frontmatter (гейт пишет → читает обратно через `parse_security_status` → возвращает ровно то, что записал: единый источник истины, AC-8), negative-токен (FAIL) авторитетен, нет frontmatter/битый YAML/нет поля → **fail-closed** на чтении; значения секретов в артефакте маскируются (не ре-лик). **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap `MAX_DEVELOPER_RETRIES`=3, затем `set_issue_blocked` + Telegram, без бесконечного баунса); `task_desc` перезапущенного developer'а несёт дословные находки (`extract_security_findings`, паттерн ORCH-046) + ссылку на артефакт. **Self-hosting safety:** гейт только читает/сканирует/пишет артефакт — не вызывает деплой-хук, не рестартит прод-контейнер (под-гейт исполняется ДО захвата merge-lease → при FAIL lease освобождать не нужно). **Условность как ORCH-35/43/58:** `security_gate_enabled` (kill-switch) + `security_gate_repos` (CSV; пусто → только self-hosting `orchestrator`); таймаут `security_scan_timeout_s`; never-raise. v1 — Python-only стек; SAST/мульти-стек — follow-up (BR-14). Инфраструктура: pinned `gitleaks` (статический Go-бинарь) в `Dockerfile` (+ `curl`/`ca-certificates`), `pip-audit` (pinned) в `requirements.txt`, `.gitleaks.toml` в корне репо. Новые настройки: `ORCH_SECURITY_GATE_ENABLED` (true), `ORCH_SECURITY_GATE_REPOS` (""), `ORCH_SECURITY_DEP_BLOCK_SEVERITY` (HIGH), `ORCH_SECURITY_SCAN_TIMEOUT_S` (300), `ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED` (false), `ORCH_SECURITY_SECRETS_BLOCK` (true). Инварианты НЕ менялись: `STAGE_TRANSITIONS` (9 стадий), `check_branch_mergeable`/`check_staging_image_fresh` и их под-гейты, БАГ-8 откат, terminal-sync, схема БД (без миграций). ADR `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`, глобальный `docs/architecture/adr/adr-0012-security-gate.md`. Документация: `docs/architecture/README.md`, `CLAUDE.md`, `.env.example`. Тесты: `tests/test_security_gate.py`, `tests/test_qg_security.py`, `tests/test_stage_engine_security_gate.py`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. - **Выделенный статус-триггер прод-деплоя «Confirm Deploy»** (ORCH-059): жест запуска прод-деплоя отделён от человеческого гейта одобрения. Раньше один Plane-статус `Approved` был перегружен: на `analysis` он работал как человеческий гейт BRD (`check_analysis_approved`), а на `deploy` — молча триггерил Фазу B прод-деплоя ORCH-036 (`advance_stage(deploy, finished_agent=None) → _handle_self_deploy_phase_b → detached host-рестарт прод-контейнера 8500`). Привычный жест approve = групповой self-hosting риск (прод обслуживает ВСЕ проекты из одного инстанса). ORCH-059 вводит отдельный логический статус `confirm_deploy` («Confirm Deploy»), который триггерит **ТОЛЬКО** Фазу B на `deploy`; `Approved` остаётся исключительно гейтом конвейера. Четыре точечные правки в трёх модулях: (1) `src/plane_sync.py` — маппинг `"Confirm Deploy" → "confirm_deploy"` в `_PLANE_NAME_TO_KEY`; ключ намеренно НЕ добавлен в `_DEFAULT_STATES` (нет UUID для enduro/fallback) → **fail-closed**: для проекта ORCH резолвится из живого Plane API (`get_project_states(orch)["confirm_deploy"]` → реальный UUID), для сред без статуса (enduro / недоступный API / доска без статуса) ключ просто отсутствует, доступ через `.get("confirm_deploy")` → `None`, без `KeyError`. (2) `src/webhooks/plane.py` — `handle_issue_updated` ДО ветки `approved` добавляет fail-closed-ветку `confirm_state = proj_states.get("confirm_deploy"); if confirm_state and new_state == confirm_state: handle_confirm_deploy(...)`; новый `handle_confirm_deploy` резолвит задачу, гард `stage == "deploy"` (иначе no-op с логом — защищает прочие гейты от случайного триггера), иначе → `_try_advance_stage(..., confirm_deploy=True)`. `handle_verdict(approved=True)` не изменён (продолжает звать `_try_advance_stage` с дефолтным `confirm_deploy=False`). (3) `src/stage_engine.py` — `advance_stage` получил keyword-only параметр `confirm_deploy: bool = False` (обратносовместимо: все существующие вызовы из launcher/reconciler/finalizer передают `finished_agent`); блок Фазы B теперь **всегда возвращается рано** для `deploy + finished_agent is None` self-hosting, но `_handle_self_deploy_phase_b` вызывается ТОЛЬКО при `confirm_deploy=True`, иначе (обычный `Approved`) — детерминированный **no-op** (`result.note = "approved-on-deploy-noop"`): возврат ДО блока Quality Gate → `check_deploy_status` не запускается → нет ложного отката БАГ-8 (вердикта ещё нет, R-2). (4) CTA Фазы A (`_handle_self_deploy_phase_a`) — Plane-коммент и Telegram просят перевести задачу в статус «Confirm Deploy» (а не «Approved»). Следствие для reconciler F-1 на `deploy` (ORCH-053): попадает в no-op-ветку вместо неявного запуска Фазы B → прод-деплой нельзя инициировать автоматически, только явным человеческим «Confirm Deploy» (усиление safety). Условность как ORCH-35/36 (реально только для `self_deploy.self_deploy_applies("orchestrator")`; прочие репо — прежний синхронный ssh-деплой агентом, статус не нужен и не влияет). Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-код-контракт хука (0/1/2), Фазы A/C, merge-gate, terminal-sync, схема БД (статусы — на стороне Plane; restart-safe состояние деплоя — существующие sentinel-файлы ORCH-036). Эксплуатационное предусловие: в Plane-проекте ORCH создать статус доски «Confirm Deploy» (точное имя, регистр) + сброс кэша состояний — `docs/work-items/ORCH-059/07-infra-requirements.md`. До создания статуса прод-деплой через approve не запустится (желаемое fail-closed-поведение). ADR `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` (уточняет триггер Фазы B относительно adr-0007). Тесты: `tests/test_plane_states.py`, `tests/test_plane_confirm_deploy.py`, `tests/test_stage_engine_phase_b.py`, `tests/test_stage_engine_phase_a_cta.py`, `tests/test_confirm_deploy_integration.py`, `tests/test_deploy_approve.py` (обновлён под новый триггер). diff --git a/CLAUDE.md b/CLAUDE.md index 4b633ab..7b3b780 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,22 @@ created → analysis → architecture → development → review → testing → ## Статусная модель Plane (ORCH-066) — индикация ≠ управление Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`. +## Нотификации / Telegram live-tracker (ORCH-042/066/067) +Каждая задача = **одна карточка** в Telegram (`src/notifications.py`). Поведение карточки: +- **Дефолт `tracker_mode` — `bump`** (ORCH-067; `edit` доступен через `ORCH_TRACKER_MODE=edit`). + `bump` на каждом обновлении удаляет старую карточку и шлёт свежую вниз чата (тихо), `edit` + редактирует на месте. Инвариант «одна карточка на задачу» — в обоих режимах. +- **Статус-строка карточки** (`📍 `) показывает текущий Plane-статус по модели + ORCH-066 (`plane_status_label`). Оффлайн-ядро (`stage → статус`, In Review из brd-clock) + работает всегда без сети; best-effort live-overlay (kill-switch `tracker_live_status`, + TTL-кэш, короткий таймаут) лишь дорисовывает ветки, неотличимые offline (Needs Input / + Blocked / Rejected / Cancelled / Deploying / Monitoring) и **никогда не блокирует конвейер**. +- **Кликабельный номер задачи** (`plane_issue_link`) — `ORCH-NNN` в карточке И во всех + уведомлениях (`notify_*`, alert'ы стадий) рендерится как `` на issue в Plane; + fail-safe → просто `html.escape(номер)`, если ссылку построить нельзя. Никогда не падает. +- Транспорт (`send_telegram`/`edit_telegram`/`delete_telegram`), `disable_notification` + (карточка тихая, пингуют только alert-хелперы), схема БД — не трогаются. + ## Конвенции - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) - Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug` diff --git a/README.md b/README.md index e7de2cd..4036e94 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ uvicorn src.main:app --reload --port 8500 | `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` | Per-stage пороги, напр. `{"development":300}` | `""` | | `ORCH_RECONCILE_NOTIFY_UNBLOCK` | Telegram при разблокировке застрявшей задачи | `true` | | `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` | F-1 Guard 2 (ORCH-060): пропуск задач в Plane-статусе Blocked / Needs Input; `false` глушит только сетевой Guard 2 (Guard 1 escalated всегда активен) | `true` | +| `ORCH_QG0_TITLE_MAX` | Верхний лимит длины заголовка QG-0 (вход `_qg0_errors`); невалидное/пустое значение → дефолт (ORCH-069) | `200` | ## Очередь задач (ORCH-1 / F-2b) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index abd7160..dd6360c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -13,6 +13,7 @@ - **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. - **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts bool`** (low-level, never raises). Семантика возврата — «исчезло ли старое сообщение»: - `ok:true` → `True`; @@ -128,6 +128,12 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash **Текст карточки (оба режима, ORCH-042):** метка `Подтверждение BRD` (была «Ревью БРД»); после прохождения approve-gate строка BRD начинается с ✅ (ветка ожидания сохраняет ⏸️/⏳); русские display-labels стадий (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`); финальная строка `📦 Внедрено` (было `deployed`). Меняются только отображаемые строки — ключи стадий и имена агентов (завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются. +**Строка Plane-статуса и кликабельный номер (ORCH-067, слой B — индикация).** Под заголовком карточка несёт строку `📍 ` по модели ORCH-066. Источник — двухслойный, контракт **never raises**: +- **Оффлайн-ядро** `plane_status_label(task_row)` — чистая функция БЕЗ сети: `stage → статус` (`created→To Analyse`, `analysis→Analysis`, `architecture→Architecture`, `development→Development`, `review→Code-Review`, `testing→Testing`, `deploy→⏸️ Awaiting Deploy`, `done→Done`) + `⏸️ In Review` из brd-часов (`brd_review_started_at` задан, `…_ended_at` пуст). Неизвестная/битая стадия → безопасный дефолт `To Analyse`. +- **Live-overlay** `_live_plane_branch_override` — best-effort: дорисовывает ветви-статусы, неразличимые оффлайн (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring after Deploy), чтением живого Plane-статуса (`fetch_issue_state` с коротким `tracker_live_status_timeout_s`, TTL-кэш `tracker_live_status_ttl_s`, kill-switch `tracker_live_status`). Любой сбой / выключенный флаг / нехватка данных → оффлайн-метка; `⏸️ In Review` (авторитет brd-часов) overlay не консультирует. Анти-false-positive: `deploying/monitoring`, алиасящие базовый UUID на проекте без выделенного статуса (enduro), не вызывают override. + +**Кликабельный номер задачи (ORCH-067).** Номер в заголовке карточки И во всех уведомлениях орка, где упоминается `work_item_id`, — HTML-ссылка на issue в Plane через общий `plane_issue_link` / `link_for` (URL строит `_plane_issue_url` с loopback/workspace/project-гардами, переиспользуя резолв ORCH-017). Fail-safe: при нехватке любого из (web-base/не-loopback, workspace, project_id, plane_issue_id) → `html.escape(work_item_id)` без ``; динамические части экранируются, ``-разметка валидна под `parse_mode=HTML`. Алерты `stage_engine`/`launcher`/`security_gate`/`reconciler` переведены на `link_for` (резолвит `repo`+`plane_issue_id` из БД по `task_id` или `work_item_id`). + ## Database Schema ```sql diff --git a/docs/work-items/ORCH-067/00-business-request.md b/docs/work-items/ORCH-067/00-business-request.md new file mode 100644 index 0000000..49488da --- /dev/null +++ b/docs/work-items/ORCH-067/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: [высокий] Telegram tracker: bump + статусы Plane + кликабельный номер задачи + +Work Item ID: ORCH-067 + +## Description + +TBD diff --git a/docs/work-items/ORCH-067/01-brd.md b/docs/work-items/ORCH-067/01-brd.md new file mode 100644 index 0000000..9423539 --- /dev/null +++ b/docs/work-items/ORCH-067/01-brd.md @@ -0,0 +1,158 @@ +# BRD — ORCH-067: Telegram tracker (bump + статусы Plane + кликабельный номер задачи) + +Work Item: **ORCH-067** +Тип: **Багфикс + enhancement** +Приоритет: высокий +Компонент: Telegram live-tracker и уведомления оркестратора (`src/notifications.py`) +Расширяет: открытый баг seq=55 («bump не сработал, регресс ORCH-042») + +--- + +## 1. Бизнес-контекст и проблема + +Оркестратор ведёт по одной «живой карточке» (live-tracker) на каждую задачу в Telegram +(`src/notifications.py`). Карточка тихо обновляется на каждом переходе стадии, а отдельными +пингами шлются только события, требующие внимания владельца (approve-gate, деплой-фейл, +падение агента, ошибка задачи). + +Сейчас есть четыре боли: + +1. **bump не работает в проде.** Диагностика оператора: код режима `bump` в + `update_task_tracker` корректен (delete старого → sendMessage вниз → repoint + `tracker_message_id`), НО в проде `tracker_mode="edit"` (дефолт `src/config.py:408`), + а `ORCH_TRACKER_MODE=bump` не выставлен. Карточка обновляется edit-in-place и остаётся + «вверху» ленты, тонет под новыми сообщениями — наблюдатель не видит актуального + состояния без скролла. + +2. **Карточка показывает внутренние названия стадий, а не Plane-статусы.** После ввода + осмысленной статусной модели Plane (ORCH-066) карточка по-прежнему рендерит внутренние + ярлыки стадий (Анализ/Архитектура/…), а текущий статус задачи в терминах, понятных + наблюдателю в Plane (To Analyse → Analysis → In Review → … → Done), в шапке карточки + не отражён. Особенно теряется состояние **ожидания согласования BRD** = Plane-статус + `In Review`: сейчас это лишь строка «✅/⏸️ Подтверждение BRD … ⏳», не выраженная как + полноценный статус. + +3. **Номер задачи в карточке некликабелен.** `ORCH-066` в карточке — обычный текст; + чтобы открыть задачу в Plane, наблюдателю приходится искать её вручную. + +4. **Номер задачи некликабелен и во всех остальных уведомлениях орка** (approve-requested, + QG-fail, deploy SUCCESS/FAIL, Needs Input, прод-деплой и т. п.) — везде, где упоминается + `work_item_id`, это просто текст. + +## 2. Цель + +Сделать live-tracker и уведомления орка наблюдаемыми «из коробки»: +- bump работает по умолчанию (карточка падает вниз свежим сообщением при каждом обновлении, + ровно одна карточка на задачу, без спама и дублей); +- карточка явно показывает текущий Plane-статус по модели ORCH-066, включая человеческие + гейты (`⏸️ In Review` — согласование BRD, `⏸️ Awaiting Deploy` — ожидание Confirm Deploy, + `❓ Needs Input` — нужны уточнения); +- номер задачи кликабелен в карточке и во всех Telegram-уведомлениях орка и ведёт на + страницу задачи в Plane. + +## 3. Заинтересованные стороны + +- **Owner (Слава)** — основной потребитель карточки и уведомлений; источник 4 требований. +- **Агенты конвейера** — косвенно (карточка отражает их прогресс; поведение агентов не меняется). +- **Другие проекты (enduro-trails)** — общий инстанс/БД; изменения не должны вызывать регресс. + +## 4. Объём работ (scope) + +### 4.1. Требование 1 — bump по умолчанию +- Режим `bump` должен быть поведением по умолчанию: при каждом обновлении карточка + удаляется и пересоздаётся внизу ленты, одна карточка на задачу, тихо + (`disable_notification`), без дублей. +- Инвариант «одна карточка на задачу» сохраняется в обоих режимах (`edit` остаётся как + опция через env). +- Транзиентный фейл `send` не должен обнулять `tracker_message_id` и плодить дубли + (инвариант уже заложен в коде — сохранить). + +### 4.2. Требование 2 — статусы карточки как в Plane (модель ORCH-066) +- В шапке/верхней части карточки явно отображается **текущий Plane-статус** задачи по + модели ORCH-066. +- Полный маппинг состояний (имена — финальные из модели ORCH-066): + ``` + To Analyse → Analysis → In Review (⏸️ ожидание согласования BRD) → Architecture → + Development → Code-Review → Testing → Awaiting Deploy (⏸️ ожидание Confirm Deploy) → + Deploying → Monitoring after Deploy → Done + ``` + Ветки: `Needs Input` (аналитик задал вопросы), `Blocked`, `Rejected`, `Cancelled`. +- Человеческие гейты отражаются как ПОЛНОЦЕННЫЕ статусы с паузой: + - согласование BRD → «⏸️ In Review — ожидание согласования BRD»; + - ожидание прод-деплоя → «⏸️ Awaiting Deploy — ожидание Confirm Deploy»; + - вопросы аналитика → «❓ Needs Input — нужны уточнения». +- Существующая семантика строки «Подтверждение BRD» сохраняется (время ожидания/«твоё + время»), но статус карточки при этом явно показывает In Review (approve-pending). + +### 4.3. Требование 3 — кликабельный номер задачи в карточке +- `work_item_id` (напр. `ORCH-066`) в карточке — гиперссылка на страницу задачи Plane: + `https:////projects//issues//`. +- Источники частей URL: + - `PLANE_WEB_BASE` — из конфигурации (env, поле `plane_web_url` / `ORCH_PLANE_WEB_URL`; + значение прод — `plane.mva154.duckdns.org`); fail-safe: не задан → номер без ссылки; + - `workspace_slug` — `plane_workspace_slug` (уже есть в settings, прод — `ag_proj`); + - `project_id` — резолвится per-task по репозиторию задачи (ORCH / Sandbox); + - `issue_id` (UUID) — из БД: колонка `tasks.plane_issue_id`. +- Рендер через `ORCH-NNN` (`parse_mode=HTML` уже включён); + HTML в title/тексте экранируется, чтобы не сломать разметку. + +### 4.4. Требование 4 — кликабельный номер во ВСЕХ уведомлениях орка +- Единый хелпер (напр. `plane_issue_link(work_item_id, plane_issue_id, project_id) -> html`) + строит кликабельный номер с fail-safe; применяется во всех точках `send_telegram`/ + `notify_*`, где упоминается `work_item_id` (approve-requested, QG-fail, deploy + SUCCESS/FAIL, Needs Input, прод-деплой, alert'ы launcher/merge_gate/job_reaper/ + security_gate/reconciler/main). + +## 5. Вне объёма (out of scope) + +- Транспорт `send_telegram` / `edit_telegram` / `delete_telegram` (parse_mode HTML уже есть) — не трогать. +- Инвариант «одна карточка на задачу» — не нарушать (не плодить дубли). +- Логика `disable_notification` (карточка тихая; пингуют только alert-хелперы) — не трогать. +- `STAGE_TRANSITIONS`, Quality Gates, схема БД — НЕ менять. +- Изменение поведения агентов/конвейера. + +## 6. Зависимости + +- Маппинг статусов (требование 2) опирается на статусную модель ORCH-066. ORCH-066 уже в + конвейере на стадии deploy. Эту задачу делать ПОСЛЕ прода ORCH-066, чтобы имена статусов + совпали. Если ORCH-066 ещё не в проде на момент разработки — использовать согласованные + финальные имена из модели: To Analyse, Analysis, Code-Review, Awaiting Deploy, Deploying, + Monitoring after Deploy, In Review, Needs Input, Blocked, Cancelled, Done. +- Конфигурация `plane_web_url` / `plane_workspace_slug` уже существует в `src/config.py` + (ORCH-017); реестр проектов `src/projects.py` (`get_project_by_repo().plane_project_id`) + уже даёт per-task project_id. + +## 7. Fail-safe (обязательно) + +- Нет `PLANE_WEB_BASE` / нет `plane_issue_id` / нет `project_id` / loopback-база → + показывать номер БЕЗ ссылки, **не падать**. +- HTML-экранирование пользовательского текста (title и пр.) во всех сообщениях с + `parse_mode=HTML`. +- Bump: транзиентный фейл `send` не обнуляет `tracker_message_id` и не плодит дубли. +- Любая ошибка построения статуса/ссылки никогда не должна ронять рендер карточки или + отправку уведомления (degrade gracefully). + +## 8. Критерии успеха (Definition of Done) + +- Bump работает из коробки: карточка падает вниз при обновлении, одна на задачу. +- Карточка показывает Plane-статус новой модели, включая `⏸️ In Review` (согласование BRD), + `⏸️ Awaiting Deploy`, `❓ Needs Input`. +- Номер задачи кликабелен в карточке И во всех уведомлениях орка (ведёт на страницу Plane). +- Fail-safe покрыт тестами (нет URL/plane_id/project → без ссылки, не падает; + HTML-экранирование). +- `pytest tests/ -q` зелёный. +- Документация обновлена в том же PR: `CLAUDE.md` (раздел нотификаций/tracker), + `CHANGELOG.md`, ADR per-work-item. + +## 9. Риски + +- **Регресс enduro-trails.** Смена дефолта `tracker_mode` на bump меняет поведение для всех + проектов. Митигация: bump уже реализован и протестирован концептуально; инвариант «одна + карточка» сохранён; env-переключатель `edit` остаётся. +- **Поломка HTML-разметки** при неэкранированном title → сообщение не доставится. Митигация: + обязательное `html.escape` + тесты. +- **Источник «истинного» Plane-статуса** для веток, не выводимых из `tasks.stage` + (Needs Input/Blocked/Rejected/Cancelled, Deploying/Monitoring), при запрете на изменение + схемы БД — архитектурное решение (ADR), с обязательным fail-safe (без сети не падать). +- **Self-hosting.** Орк правит сам себя; обязательна страховка через staging (8501) перед + прод-деплоем; прод-контейнер не ронять в рамках задачи. diff --git a/docs/work-items/ORCH-067/02-trz.md b/docs/work-items/ORCH-067/02-trz.md new file mode 100644 index 0000000..e50c162 --- /dev/null +++ b/docs/work-items/ORCH-067/02-trz.md @@ -0,0 +1,205 @@ +# ТЗ — ORCH-067: Telegram tracker (bump + статусы Plane + кликабельный номер задачи) + +Work Item: **ORCH-067** +Документ описывает КОНКРЕТНЫЕ изменения кода/конфигурации/тестов и документации. +Архитектурные развилки помечены `[ARCH]` — решение принимает архитектор (ADR), здесь +зафиксированы только требования и ограничения к ним. + +--- + +## 0. Задействованные модули `src/` + +| Модуль | Роль в задаче | +|---|---| +| `src/config.py` | Дефолт `tracker_mode`; поле `plane_web_url`/`plane_workspace_slug` (уже есть). | +| `src/notifications.py` | Основные изменения: bump-дефолт, статус-строка карточки, хелпер ссылки, применение хелпера в `notify_*`. | +| `src/plane_sync.py` | Источник имён статусов/маппинга ORCH-066 (`_PLANE_NAME_TO_KEY`, `_STAGE_TO_STATE_KEY`); при необходимости reverse-map UUID→имя `[ARCH]`. | +| `src/projects.py` | `get_project_by_repo(repo).plane_project_id` — per-task project_id для ссылки. | +| `src/db.py` | Чтение `tasks.plane_issue_id`, `tasks.repo` (без изменений схемы). | +| `src/stage_engine.py`, `src/agents/launcher.py`, `src/merge_gate.py`, `src/job_reaper.py`, `src/security_gate.py`, `src/reconciler.py`, `src/main.py` | Точки `send_telegram`, где есть `work_item_id` — применить хелпер ссылки (требование 4). | + +Изменения API (HTTP endpoints) — **нет**. Изменения схемы БД — **нет**. Новые QG checks — **нет**. + +--- + +## 1. Требование 1 — bump по умолчанию + +### 1.1. Изменение +- `src/config.py` (~стр. 408): сменить дефолт + `tracker_mode: str = "edit"` → `tracker_mode: str = "bump"`. +- Обновить docstring-комментарий рядом (ORCH-042): отметить, что **дефолт теперь `bump`**, + `edit` остаётся доступен через `ORCH_TRACKER_MODE=edit`. + +### 1.2. Без изменений (сохранить инвариант) +- Логика `update_task_tracker` (`src/notifications.py`, ветка `if mode == "bump"`): + `delete_telegram(old)` best-effort → `send_telegram(text, disable_notification=True)` → + `set_tracker_message_id` ТОЛЬКО при `new_mid is not None`. Не менять. +- `send_telegram`/`edit_telegram`/`delete_telegram` — не трогать. + +### 1.3. Прод-аспект +- Для прод-инстанса орка можно дополнительно выставить `ORCH_TRACKER_MODE=bump` в `.env` + на хосте (как страховку), но код должен работать «из коробки» и без env. Канон env — + `.env.example` (обновить, если там фигурирует tracker_mode). + +--- + +## 2. Требование 2 — статус-строка карточки по модели ORCH-066 + +### 2.1. Новый чистый хелпер маппинга +Добавить в `src/notifications.py` функцию, возвращающую отображаемый Plane-статус для +карточки на основе доступных данных задачи. Сигнатура (ориентир): +```python +def plane_status_label(task_row) -> str: + """Вернуть строку текущего Plane-статуса для шапки карточки (с emoji). + Никогда не падает: на неизвестном входе -> разумный дефолт по stage.""" +``` +Хелпер обязан быть чистым/детерминированным от входных данных и **никогда не бросать** +исключения (любая ошибка → дефолт по `stage`, рендер карточки не ломается). + +### 2.2. Маппинг внутреннее состояние → Plane-статус (обязательные строки) +Имена статусов — финальные из модели ORCH-066 (см. `_PLANE_NAME_TO_KEY` в `plane_sync.py`). + +| Источник (данные задачи в БД) | Plane-статус (отображение в карточке) | +|---|---| +| `stage == "created"` | `To Analyse` | +| `stage == "analysis"`, BRD-clock не запущен | `Analysis` | +| `stage == "analysis"`, `brd_review_started_at` есть, `brd_review_ended_at` пуст | `⏸️ In Review — ожидание согласования BRD` | +| `stage == "architecture"` | `Architecture` | +| `stage == "development"` | `Development` | +| `stage == "review"` | `Code-Review` | +| `stage == "testing"` | `Testing` | +| `stage == "deploy"` (ожидание Confirm Deploy) | `⏸️ Awaiting Deploy — ожидание Confirm Deploy` | +| `stage == "done"` | `Done` | + +Ветки (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring after Deploy): +- `❓ Needs Input — нужны уточнения` — состояние «аналитик задал вопросы»; +- `Blocked`, `Rejected`, `Cancelled`, `Deploying`, `Monitoring after Deploy`. + +`[ARCH]` **Источник сигнала для веток, не выводимых из `tasks.stage`** (Needs Input, +Blocked, Rejected, Cancelled, Deploying, Monitoring after Deploy): +- запрещено менять схему БД (нельзя добавлять колонку-флаг); +- варианты для архитектора: (а) best-effort чтение живого Plane-статуса + (`fetch_issue_state` + reverse-map UUID→имя через `get_project_states`/ + `_PLANE_NAME_TO_KEY`) с обязательным fail-safe (нет сети/ответа → деградация на + stage-маппинг, без задержки, блокирующей конвейер); (б) только stage-выводимые статусы, + а ветки — по уже имеющимся сигналам (например, In Review через brd-clock). +- ОБЯЗАТЕЛЬНО к покрытию (DoD): `⏸️ In Review`, `⏸️ Awaiting Deploy`, `❓ Needs Input`. + In Review полностью выводится из brd-clock (см. таблицу) и должен работать без сети. + +### 2.3. Встраивание в `render_task_tracker` +- В `render_task_tracker` (`src/notifications.py`) добавить в шапку/верх карточки отдельную + СТРОКУ статуса (под заголовком `🛠️ ORCH-NNN · ` / над разделителем `bar`), + напр.: `📍 <status_label>`. +- Существующие строки по стадиям (`✅ done` / `🔄 active`), строка «Подтверждение BRD», + тоталы токенов/стоимости, done-строка с PR/⏱️ — СОХРАНИТЬ (семантику не ломать). +- Семантика строки «Подтверждение BRD» (⏸️+⏳ при ожидании, ✅ при пройденном гейте) + сохраняется; новая статус-строка дублирует её смысл в терминах Plane-статуса. + +--- + +## 3. Требование 3 + 4 — кликабельный номер задачи + +### 3.1. Единый хелпер +Добавить в `src/notifications.py`: +```python +def plane_issue_link(work_item_id, plane_issue_id=None, project_id=None, repo=None) -> str: + """Вернуть HTML с кликабельным номером задачи (<a href=...>ORCH-NNN</a>), + либо просто html.escape(work_item_id), если ссылку построить нельзя. + Никогда не падает.""" +``` +Поведение: +- База URL: `settings.plane_web_url` → fallback `settings.plane_api_url`; loopback-база + (`localhost`/`127.0.0.1`/…) трактуется как «нет web URL» (переиспользовать + `_is_loopback_base`). +- `workspace_slug`: `settings.plane_workspace_slug`. +- `project_id`: явный аргумент → иначе резолв по `repo` через + `get_project_by_repo(repo).plane_project_id`. +- `issue_id`: `plane_issue_id` (UUID из `tasks.plane_issue_id`). +- URL-шаблон: `{web_base}/{workspace}/projects/{project_id}/issues/{issue_id}/`. +- Текст ссылки = `html.escape(work_item_id)`; `href` = `html.escape(url, quote=True)`. +- **Fail-safe:** если не хватает любого из (`web_base` валидный/не loopback, `workspace`, + `project_id`, `plane_issue_id`) → вернуть `html.escape(work_item_id)` (номер без ссылки). +- Логика построения URL уже существует в `_build_plane_issue_link` (ORCH-017) — допустимо + переиспользовать/обобщить её, разнеся «текст-ссылки = номер» и «текст-ссылки = `✅ Задача + в Plane`», чтобы не дублировать резолв проекта и loopback-guard. + +### 3.2. Применение в карточке (требование 3) +- В `render_task_tracker` заголовок строится из `work_item_id`. Заменить + `html.escape(work_item_id)` в обоих вариантах заголовка (done / not-done) на + `plane_issue_link(work_item_id, plane_issue_id, repo=repo)` — номер становится + кликабельным. +- Для этого `render_task_tracker` должен дополнительно выбрать из БД `repo` и + `plane_issue_id` (расширить существующий `SELECT` по `tasks`). Схему НЕ менять — колонки + уже есть. +- `title` уже экранируется (`html.escape(title)`) — сохранить. + +### 3.3. Применение во всех уведомлениях (требование 4) +Во всех точках `send_telegram`/`notify_*`, где в тексте есть `work_item_id`, заменить +«сырой» номер на `plane_issue_link(...)`. Перечень точек (из `src`): +- `src/notifications.py`: `notify_approve_requested`, `notify_error` + (и любые будущие notify_* с work_item_id); +- `src/stage_engine.py`: все `send_telegram(...)` с `work_item_id` + (≈ строки 613, 672, 719, 776, 820, 916, 971, 1057, 1134, 1192, 1228, 1257, 1355, 1367, + 1425, 1447, 1601 — проверить каждую: применять ТОЛЬКО где упоминается номер задачи); +- `src/agents/launcher.py`: deploy-failed alert (≈685–686), agent-failed alert (≈698–699), + alert ≈821–822; +- `src/merge_gate.py` (≈431–432); +- `src/job_reaper.py` (≈395–396); +- `src/security_gate.py` (≈673–674); +- `src/reconciler.py` (≈449); +- `src/main.py` (≈45–47). + +`[ARCH]` Способ доступа к `plane_issue_id`/`project_id` в каждой точке (часто там уже есть +`work_item_id`, но не обязательно `plane_issue_id`): хелпер должен уметь резолвить +недостающее по `repo`/БД, оставаясь fail-safe. Допустимо добавить тонкую обёртку, которая по +`work_item_id`/`task_id` достаёт `repo`+`plane_issue_id` из БД и зовёт `plane_issue_link` +(аналогично существующему `_get_task_link_fields`). Везде, где данных нет — деградация на +просто номер, без падения. + +### 3.4. HTML-экранирование +- `parse_mode=HTML` уже стоит в `send_telegram`/`edit_telegram`. Любой пользовательский + текст (title, описания, причины QG-fail, сообщения об ошибках), попадающий в сообщение с + ссылками, должен экранироваться `html.escape`, чтобы не сломать `<a>`-разметку. + +--- + +## 4. Конфигурация + +- `plane_web_url` (env `ORCH_PLANE_WEB_URL`) — уже существует (`src/config.py`), значение + прод — `plane.mva154.duckdns.org` (схему `https://` учесть при сборке URL). + Дополнительных полей конфигурации не требуется. +- `tracker_mode` — сменить дефолт на `bump` (раздел 1). +- Обновить `.env.example`, если в нём фигурируют `ORCH_TRACKER_MODE` / `ORCH_PLANE_WEB_URL` + (канон секретов/настроек — `.env.example`, не коммитить реальные секреты). + +--- + +## 5. Артефакты pipeline, которые должны быть созданы/обновлены + +- `docs/work-items/ORCH-067/06-adr/ADR-NNN-*.md` — архитектурное решение (минимум: источник + «истинного» Plane-статуса для веток при запрете изменения схемы БД; дефолт bump; единый + хелпер ссылки). +- `CLAUDE.md` — раздел про нотификации/tracker (дефолт bump; статус-строка карточки; + кликабельный номер в карточке и уведомлениях). +- `CHANGELOG.md` — запись ORCH-067. +- `docs/architecture/README.md` — при необходимости синхронизировать описание tracker'а. + +--- + +## 6. Ограничения (что НЕ трогать) + +- Транспорт `send_telegram`/`edit_telegram`/`delete_telegram`. +- Инвариант «одна карточка на задачу». +- Логику `disable_notification` (карточка тихая; пингуют только alert-хелперы). +- `STAGE_TRANSITIONS`, Quality Gates, схему БД. +- Поведение агентов/конвейера. + +--- + +## 7. Замечания по самохостингу + +Орк правит сам себя в проде (общий инстанс/БД с enduro-trails): +- НЕ перезапускать прод-контейнер `orchestrator` в рамках задачи. +- Обязательная страховка через `deploy-staging` (8501) до прод-деплоя. +- Смена дефолта `tracker_mode` затрагивает ВСЕ проекты — проверить отсутствие регресса для + enduro-trails (тесты + staging-наблюдение карточки). diff --git a/docs/work-items/ORCH-067/03-acceptance-criteria.md b/docs/work-items/ORCH-067/03-acceptance-criteria.md new file mode 100644 index 0000000..5283bdd --- /dev/null +++ b/docs/work-items/ORCH-067/03-acceptance-criteria.md @@ -0,0 +1,129 @@ +# Acceptance Criteria — ORCH-067 + +Work Item: **ORCH-067** +Каждый критерий формулирует чёткое условие PASS/FAIL. Привязка к тестам — в `04-test-plan.yaml`. + +--- + +## Группа A — Bump по умолчанию (Требование 1) + +### AC-1 — дефолт tracker_mode = bump +- **PASS:** `Settings().tracker_mode == "bump"` без выставленного env `ORCH_TRACKER_MODE`. +- **FAIL:** дефолт остался `"edit"` или иное. + +### AC-2 — bump-поведение: одна карточка падает вниз +- **PASS:** при втором (и последующем) вызове `update_task_tracker` для задачи с уже + сохранённым `tracker_message_id` вызывается `delete_telegram(old_id)` (best-effort), + затем `send_telegram(...)` с `disable_notification=True`, затем `set_tracker_message_id` + на новый id. В чате остаётся ровно одна карточка на задачу. +- **FAIL:** карточка редактируется на месте при дефолте; либо появляются дубли; либо новая + карточка отправляется со звуком (`disable_notification` не True). + +### AC-3 — bump fail-safe: транзиентный фейл send не обнуляет указатель +- **PASS:** если `send_telegram` вернул `None` (нет креды/транзиентный фейл), + `tracker_message_id` НЕ перезаписывается в `None` и дубликат в рамках вызова не создаётся. +- **FAIL:** указатель обнулён или создан второй card-месседж в одном вызове. + +### AC-4 — режим edit остаётся доступен через env +- **PASS:** при `ORCH_TRACKER_MODE=edit` поведение прежнее (editMessageText, fallback на + новый месседж только при EDIT_GONE). +- **FAIL:** edit-режим сломан/недоступен. + +--- + +## Группа B — Статус-строка карточки по модели ORCH-066 (Требование 2) + +### AC-5 — статус-строка присутствует в карточке +- **PASS:** `render_task_tracker(task_id)` содержит явную строку текущего Plane-статуса + (напр. `📍 <status>`) в шапке/верхней части карточки. +- **FAIL:** статус-строки нет. + +### AC-6 — корректный маппинг stage → Plane-статус +- **PASS:** для всех stage-выводимых состояний строка статуса соответствует таблице ТЗ §2.2: + `created→To Analyse`, `analysis→Analysis`, `architecture→Architecture`, + `development→Development`, `review→Code-Review`, `testing→Testing`, + `deploy→Awaiting Deploy`, `done→Done`. +- **FAIL:** хотя бы один stage маппится на неверное имя/внутренний ярлык. + +### AC-7 — In Review (ожидание согласования BRD) как полноценный статус +- **PASS:** при `stage == "analysis"`, `brd_review_started_at` задан и + `brd_review_ended_at` пуст — статус-строка явно отражает `⏸️ In Review` с пометкой + «ожидание согласования BRD»; при этом существующая строка «Подтверждение BRD …» с ⏸️/⏳ + сохранена. Работает без сетевых вызовов. +- **FAIL:** In Review теряется/не показан как статус, либо строка «Подтверждение BRD» исчезла. + +### AC-8 — Awaiting Deploy и Needs Input отражены +- **PASS:** состояние ожидания Confirm Deploy показывается как + `⏸️ Awaiting Deploy — ожидание Confirm Deploy`; состояние вопросов аналитика — как + `❓ Needs Input — нужны уточнения`. +- **FAIL:** любое из этих состояний не отражено в статус-строке. + +### AC-9 — рендер карточки никогда не падает +- **PASS:** при любой ошибке построения статуса (битые данные, недоступный источник) + `render_task_tracker` возвращает корректную карточку (деградация на stage-маппинг или + fallback-строку), исключение наружу не выходит. +- **FAIL:** `render_task_tracker` бросает исключение. + +--- + +## Группа C — Кликабельный номер в карточке (Требование 3) + +### AC-10 — номер задачи в карточке — гиперссылка +- **PASS:** при наличии `plane_web_url` (не loopback), `plane_workspace_slug`, `project_id` + (резолв по repo) и `plane_issue_id` карточка содержит + `<a href="https://<base>/<ws>/projects/<pid>/issues/<issue_id>/">ORCH-NNN</a>`. +- **FAIL:** номер выводится сырым текстом при наличии всех данных, либо URL собран неверно. + +### AC-11 — fail-safe ссылки в карточке +- **PASS:** при отсутствии любого из (web_base/не-loopback, workspace, project_id, + plane_issue_id) карточка показывает номер БЕЗ ссылки (`html.escape(work_item_id)`) и не + падает. +- **FAIL:** падение, пустая ссылка `<a href="">`, либо битый `<a>` тег. + +--- + +## Группа D — Кликабельный номер во всех уведомлениях (Требование 4) + +### AC-12 — единый хелпер ссылки +- **PASS:** существует `plane_issue_link(...)`, возвращающий HTML-ссылку при достаточных + данных и `html.escape(work_item_id)` при недостаточных; никогда не бросает. +- **FAIL:** хелпера нет, либо он падает на неполных данных. + +### AC-13 — хелпер применён во всех уведомлениях с work_item_id +- **PASS:** во всех точках `send_telegram`/`notify_*` из ТЗ §3.3, где упоминается + `work_item_id` (`notify_approve_requested`, `notify_error`, alert'ы stage_engine, + launcher, merge_gate, job_reaper, security_gate, reconciler, main), номер задачи + кликабелен (при наличии данных) и ведёт на ту же страницу Plane. +- **FAIL:** хотя бы одна такая точка выводит номер сырым текстом при наличии данных. + +### AC-14 — HTML-экранирование пользовательского текста +- **PASS:** title/причины/сообщения с потенциальным HTML (`<`, `>`, `&`) экранируются + `html.escape`; разметка `<a>` остаётся валидной; сообщение проходит `parse_mode=HTML`. +- **FAIL:** неэкранированный текст ломает разметку (тест с title, содержащим `<b>`/`&`, + обнаруживает поломку). + +--- + +## Группа E — Нерегресс и качество + +### AC-15 — инварианты транспорта/нотификаций сохранены +- **PASS:** `send_telegram`/`edit_telegram`/`delete_telegram` не изменены по сигнатуре/ + семантике; карточка тихая (`disable_notification=True`); инвариант «одна карточка на + задачу» соблюдён; `STAGE_TRANSITIONS`/QG/схема БД не тронуты. +- **FAIL:** изменён транспорт, карточка пингует, появились дубли, тронута схема БД/QG. + +### AC-16 — нет регресса для enduro-trails +- **PASS:** существующие тесты нотификаций (`test_notify_approve_links.py`, + `test_notify_done_regression.py` и др.) проходят; поведение карточки для не-ORCH проектов + без новых Plane-статусов деградирует корректно (alias-fallback, без ссылки при нехватке + данных). +- **FAIL:** падение существующих тестов или сломанная карточка для enduro. + +### AC-17 — весь набор тестов зелёный +- **PASS:** `pytest tests/ -q` зелёный. +- **FAIL:** любой упавший тест. + +### AC-18 — документация обновлена в том же PR +- **PASS:** обновлены `CLAUDE.md` (раздел нотификаций/tracker), `CHANGELOG.md`, + создан ADR per-work-item. +- **FAIL:** функционал изменён, документация — нет (reviewer → REQUEST_CHANGES). diff --git a/docs/work-items/ORCH-067/04-test-plan.yaml b/docs/work-items/ORCH-067/04-test-plan.yaml new file mode 100644 index 0000000..073e1b3 --- /dev/null +++ b/docs/work-items/ORCH-067/04-test-plan.yaml @@ -0,0 +1,181 @@ +work_item: ORCH-067 +description: > + План тестов для ORCH-067 (Telegram tracker: bump по умолчанию, статус-строка + карточки по модели Plane ORCH-066, кликабельный номер задачи в карточке и во + всех уведомлениях орка). Сеть изолируется: send_telegram/edit_telegram/ + delete_telegram подменяются рекордерами (как в tests/conftest.py и + tests/test_notify_approve_links.py); БД — временный SQLite, сидируемый фикстурой. + +tests: + # --- Группа A: bump по умолчанию (AC-1..AC-4) --- + - id: TC-01 + type: unit + description: "Дефолт Settings().tracker_mode == 'bump' без env ORCH_TRACKER_MODE" + module: tests/test_tracker_bump_default.py + asserts: "AC-1" + expected: PASS + + - id: TC-02 + type: unit + description: > + bump-поведение: при повторном update_task_tracker с сохранённым + tracker_message_id вызывается delete_telegram(old) -> send_telegram(..., + disable_notification=True) -> set_tracker_message_id(new). Одна карточка. + module: tests/test_tracker_bump_default.py + asserts: "AC-2" + expected: PASS + + - id: TC-03 + type: unit + description: > + bump fail-safe: send_telegram вернул None (нет креды/транзиент) -> + tracker_message_id не обнуляется, дубликат в вызове не создаётся. + module: tests/test_tracker_bump_default.py + asserts: "AC-3" + expected: PASS + + - id: TC-04 + type: unit + description: "ORCH_TRACKER_MODE=edit -> прежнее edit-поведение (editMessageText)" + module: tests/test_tracker_bump_default.py + asserts: "AC-4" + expected: PASS + + # --- Группа B: статус-строка карточки (AC-5..AC-9) --- + - id: TC-05 + type: unit + description: "render_task_tracker содержит явную строку текущего Plane-статуса" + module: tests/test_tracker_status_line.py + asserts: "AC-5" + expected: PASS + + - id: TC-06 + type: unit + description: > + Маппинг stage -> Plane-статус по таблице ТЗ §2.2: created->To Analyse, + analysis->Analysis, architecture->Architecture, development->Development, + review->Code-Review, testing->Testing, deploy->Awaiting Deploy, done->Done + (параметризованный тест по всем stage). + module: tests/test_tracker_status_line.py + asserts: "AC-6" + expected: PASS + + - id: TC-07 + type: unit + description: > + analysis + brd_review_started_at задан + brd_review_ended_at пуст -> + статус '⏸️ In Review' (ожидание согласования BRD); строка 'Подтверждение + BRD' с ⏸️/⏳ сохранена; без сетевых вызовов. + module: tests/test_tracker_status_line.py + asserts: "AC-7" + expected: PASS + + - id: TC-08 + type: unit + description: > + Awaiting Deploy ('ожидание Confirm Deploy') и Needs Input ('нужны + уточнения') корректно отражаются в статус-строке. + module: tests/test_tracker_status_line.py + asserts: "AC-8" + expected: PASS + + - id: TC-09 + type: unit + description: > + render_task_tracker не падает при битых/недоступных данных статуса + (деградация на stage-маппинг/fallback, исключение не наружу). + module: tests/test_tracker_status_line.py + asserts: "AC-9, AC-16" + expected: PASS + + # --- Группа C: кликабельный номер в карточке (AC-10..AC-11) --- + - id: TC-10 + type: unit + description: > + При полных данных (plane_web_url не loopback, workspace, project_id по repo, + plane_issue_id) карточка содержит <a href=".../issues/<id>/">ORCH-NNN</a> + с корректным URL. + module: tests/test_tracker_issue_link.py + asserts: "AC-10" + expected: PASS + + - id: TC-11 + type: unit + description: > + Fail-safe ссылки в карточке: при отсутствии любого из (web_base/не-loopback, + workspace, project_id, plane_issue_id) номер выводится html.escape без <a>, + рендер не падает. Параметризовать по каждому отсутствующему полю. + module: tests/test_tracker_issue_link.py + asserts: "AC-11" + expected: PASS + + # --- Группа D: единый хелпер и уведомления (AC-12..AC-14) --- + - id: TC-12 + type: unit + description: > + plane_issue_link(...) возвращает HTML-ссылку при достаточных данных и + html.escape(work_item_id) при недостаточных; никогда не бросает (в т.ч. на + None-аргументах и loopback-базе). + module: tests/test_plane_issue_link.py + asserts: "AC-12" + expected: PASS + + - id: TC-13 + type: unit + description: > + notify_approve_requested: номер задачи кликабелен (ведёт на страницу Plane), + сохранён call-to-action 'Approved', ровно одно notifying-сообщение. + module: tests/test_notify_issue_links.py + asserts: "AC-13" + expected: PASS + + - id: TC-14 + type: unit + description: > + notify_error: номер задачи кликабелен при наличии данных, деградирует на + сырой номер без падения при их отсутствии. + module: tests/test_notify_issue_links.py + asserts: "AC-13, AC-12" + expected: PASS + + - id: TC-15 + type: integration + description: > + Точки send_telegram в stage_engine/launcher/merge_gate/job_reaper/ + security_gate/reconciler/main, где есть work_item_id, используют + plane_issue_link (или эквивалент) — номер кликабелен. Проверка рекордером + send_telegram на представительных alert-путях (deploy fail, agent fail, + QG fail, прод-деплой). + module: tests/test_notify_issue_links.py + asserts: "AC-13" + expected: PASS + + - id: TC-16 + type: unit + description: > + HTML-экранирование: title с '<b>'/'&'/'>' экранируется, <a>-разметка + остаётся валидной, сообщение не ломается под parse_mode=HTML (карточка и + уведомления). + module: tests/test_tracker_issue_link.py + asserts: "AC-14" + expected: PASS + + # --- Группа E: нерегресс (AC-15..AC-18) --- + - id: TC-17 + type: integration + description: > + Инварианты: карточка отправляется с disable_notification=True; одна карточка + на задачу; транспорт send/edit/delete не изменён по семантике. + module: tests/test_tracker_bump_default.py + asserts: "AC-15" + expected: PASS + + - id: TC-18 + type: integration + description: > + Нерегресс существующих тестов нотификаций (test_notify_approve_links.py, + test_notify_done_regression.py) и корректная деградация карточки для + enduro-trails без новых Plane-статусов. + module: tests/test_notify_done_regression.py + asserts: "AC-16, AC-17" + expected: PASS diff --git a/docs/work-items/ORCH-067/06-adr/ADR-001-tracker-plane-status-and-link.md b/docs/work-items/ORCH-067/06-adr/ADR-001-tracker-plane-status-and-link.md new file mode 100644 index 0000000..fe5d7bd --- /dev/null +++ b/docs/work-items/ORCH-067/06-adr/ADR-001-tracker-plane-status-and-link.md @@ -0,0 +1,224 @@ +# ADR-001: Источник Plane-статуса для live-карточки и кликабельный номер задачи + +- **Статус:** Proposed +- **Дата:** 2026-06-08 +- **Задача:** ORCH-067 +- **Слой:** B (индикация), НЕ слой A (машина стадий) — см. CLAUDE.md / ORCH-066 +- **Связи:** ORCH-066 (статусная модель Plane, `_PLANE_NAME_TO_KEY` / `_STAGE_TO_STATE_KEY`), + ORCH-042 (live-tracker, режимы `edit`/`bump`), ORCH-017 (`_build_plane_issue_link`, + `plane_web_url`/`plane_workspace_slug`, loopback-guard), ORCH-059 (Confirm Deploy), + ORCH-060 (`fetch_issue_state`), ORCH-010 (`get_project_states` per-project + кэш), + adr-0001 (реестр проектов), adr-0010 (post-deploy monitor). + +## Контекст + +ТЗ ORCH-067 (`02-trz.md`) фиксирует объём изменений; данный ADR закрывает развилки, +явно отданные архитектору метками `[ARCH]`: + +1. **Источник «истинного» Plane-статуса для веток, не выводимых из `tasks.stage`** + (Needs Input, Blocked, Rejected, Cancelled, Deploying, Monitoring after Deploy), + при **запрете менять схему БД** (нельзя добавить колонку-флаг). TZ §2.2 предлагает + два варианта: (а) best-effort чтение живого Plane-статуса с fail-safe; + (б) только stage-выводимые статусы. +2. **Способ доступа к `plane_issue_id`/`project_id`** в каждой точке `send_telegram`, + где есть только `work_item_id` (требование 4), оставаясь fail-safe. +3. Смена дефолта `tracker_mode` (`edit` → `bump`) для общего инстанса. + +### Ключевая находка анализа (определяет развилку 1) + +Когда аналитик задаёт вопросы, `stage_engine.start_pipeline` при наличии +`01-questions.md` вызывает `set_issue_needs_input(work_item_id)` (Plane → Needs Input), +но **DB-стадия остаётся `analysis`**, а BRD-часы (`brd_review_started_at`) **не +запускаются** (они стартуют позже, в `notify_approve_requested`, когда BRD готов). +Следовательно состояния **`Analysis` (аналитик работает)** и **`❓ Needs Input` +(аналитик ждёт ответа)** **неразличимы** по offline-данным БД (`stage` + brd-clock). +Единственный авторитетный источник этого различия — **живой Plane-статус**, который +оркестратор сам выставил через `set_issue_needs_input`. + +То же касается `Deploying` / `Monitoring after Deploy`: на стадии `deploy`/`done` +конкретная фаза self-deploy видна только в Plane (ORCH-059/ORCH-066), не в `tasks.stage`. + +Вывод: чисто-offline вариант (б) **не покрывает обязательный по DoD `❓ Needs Input`** +(AC-8). Нужен гибрид. + +## Решение + +### Р-1. Гибрид: offline-first ядро + best-effort live-overlay + +Статус карточки строится в два слоя; **offline-ядро авторитетно и всегда работает без +сети**, live-overlay лишь дорисовывает ветки, неотличимые offline. + +**Слой 1 — чистая offline-функция `plane_status_label(task_row) -> str`** в +`src/notifications.py`. Детерминированная, **никогда не бросает**, **никогда не ходит в +сеть**. Маппинг (имена статусов — финальные из ORCH-066 `_PLANE_NAME_TO_KEY`): + +| Источник (DB) | Метка карточки | +|---|---| +| `stage == "created"` | `To Analyse` | +| `stage == "analysis"`, brd-clock не запущен | `Analysis` | +| `stage == "analysis"`, `brd_review_started_at` есть, `brd_review_ended_at` пуст | `⏸️ In Review — ожидание согласования BRD` | +| `stage == "architecture"` | `Architecture` | +| `stage == "development"` | `Development` | +| `stage == "review"` | `Code-Review` | +| `stage == "testing"` | `Testing` | +| `stage == "deploy"` | `⏸️ Awaiting Deploy — ожидание Confirm Deploy` | +| `stage == "done"` | `Done` | +| неизвестный/битый `stage` | дефолт: `html`-безопасная строка по `stage` (или `To Analyse`) | + +Этого слоя достаточно для **`⏸️ In Review`** и **`⏸️ Awaiting Deploy`** — оба +обязательны по DoD и **работают без сети** (AC-7, AC-8). `In Review` выводится +исключительно из brd-clock. + +**Слой 2 — best-effort live-overlay** `_live_plane_branch_override(repo, plane_issue_id, +base_label) -> str` для веток, неразличимых offline: **Needs Input, Blocked, Rejected, +Cancelled, Deploying, Monitoring after Deploy**. Алгоритм: + +1. Резолв `project_id` по `repo` (`get_project_by_repo(repo).plane_project_id`). +2. `live_uuid = fetch_issue_state(plane_issue_id, project_id)` (ORCH-060) — **с коротким + таймаутом** (см. Р-4), не дефолтным 10s. +3. Сопоставление `live_uuid` с **конкретными** UUID веток из + `get_project_states(project_id)` (кэш ORCH-010): `needs_input`, `blocked`, + `cancelled`, `rejected`, `deploying`, `monitoring`. +4. Override применяется **только** если `live_uuid` совпал с одним из этих ключей. + Иначе возвращается `base_label` (offline-метка). + +**Прецеденс (порядок приоритета):** +1. Если offline-ядро дало **`⏸️ In Review`** (brd-clock) — overlay **не вызывается**: + brd-clock авторитетнее возможно-устаревшего Plane-чтения для In Review. +2. Иначе `base_label` = offline-метка, затем применяется overlay (если включён и удался). + +**Анти-false-positive на enduro (важно):** на enduro-trails ключи `deploying`/ +`monitoring` алиасят UUID `in_progress`/`done` (`_STATE_ALIAS_FALLBACK`), поэтому прямое +сравнение UUID дало бы ложный `Deploying` для любой `in_progress`-задачи. Поэтому для +`deploying`/`monitoring` override применяется **только если** их UUID в +`get_project_states` **отличается** от UUID базового ключа (т.е. проект реально завёл +отдельный статус — это ORCH, не enduro). Ключи `needs_input/blocked/cancelled/rejected` +имеют отдельные UUID и на enduro, и на ORCH (`_DEFAULT_STATES`), поэтому различимы всегда. + +### Р-2. Fail-safe и невлияние на конвейер (overlay) + +- `_live_plane_branch_override` обёрнут в `try/except` и **никогда не бросает**; любая + ошибка/таймаут/нет сети/нет данных → возвращается `base_label`. Это удовлетворяет + «без сети не падать» и AC-9 (рендер карточки никогда не падает). +- Нет `plane_issue_id` / нет `project_id` / нет креды → overlay не вызывается, метка = + offline-ядро. +- **Kill-switch:** новый флаг конфигурации `tracker_live_status: bool = True` + (env `ORCH_TRACKER_LIVE_STATUS`). При `False` overlay полностью отключён (никаких + сетевых чтений в рендере) — карточка деградирует на offline-ядро. Это аварийный + тумблер и страховка от регресса для не-ORCH проектов. **Дефолт `True`**, иначе + обязательный по DoD `Needs Input` не отобразится из коробки. + +### Р-3. Кэш live-статуса (защита hot-path) + +`render_task_tracker` вызывается на КАЖДОМ обновлении трекера (старт/финиш агента, +переход стадии), а в режиме `bump` — с delete+send каждый раз. Чтобы серия быстрых +перерисовок не била по Plane: + +- Добавить **TTL-кэш per-issue** для `live_uuid` (ключ — `plane_issue_id`, TTL + `tracker_live_status_ttl_s: int = 60`). По образцу `_STATES_CACHE` в `plane_sync.py`. +- На промахе кэша — один `fetch_issue_state` с коротким таймаутом; результат кладётся в + кэш. На любой ошибке кэш не портится, возвращается offline-метка. + +Это ограничивает сетевую нагрузку overlay ~одним GET в `TTL` на задачу. + +### Р-4. Короткий таймаут live-чтения в рендере + +`fetch_issue_state` (ORCH-060) хардкодит `timeout=10`. Для пути рендера это слишком +долго (рендер синхронный, в линии переходов общего конвейера). Решение: добавить в +`fetch_issue_state` **необязательный параметр `timeout`** (дефолт прежний `10` — +обратная совместимость для reconciler), а overlay вызывает его с +`settings.tracker_live_status_timeout_s` (дефолт **3** с). Поведение/сигнатуры +существующих вызовов не меняются. + +### Р-5. Единый хелпер кликабельного номера `plane_issue_link` + +Добавить в `src/notifications.py`: + +```python +def plane_issue_link(work_item_id, plane_issue_id=None, project_id=None, repo=None) -> str: + """HTML с кликабельным номером (<a href=...>ORCH-NNN</a>) или html.escape(work_item_id). + Никогда не падает.""" +``` + +- Переиспользовать логику и guard'ы `_build_plane_issue_link` (ORCH-017), **разнеся** + «текст ссылки = номер задачи» и «текст ссылки = `✅ Задача в Plane`», чтобы не + дублировать резолв проекта и loopback-guard. Рекомендуется выделить приватный + `_plane_issue_url(repo, plane_issue_id, project_id) -> str | None` (сборка URL + + loopback/workspace/project guard), который зовут оба: `plane_issue_link` (текст = + номер) и `_build_plane_issue_link` (текст = «✅ Задача в Plane»). +- База URL: `plane_web_url` → fallback `plane_api_url`; loopback → «нет web URL» + (`_is_loopback_base`). +- `project_id`: явный аргумент → иначе резолв по `repo`. +- URL: `{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/`. +- Текст = `html.escape(work_item_id)`; `href` = `html.escape(url, quote=True)`. +- **Fail-safe:** не хватает любого из (web_base/не-loopback, workspace, project_id, + plane_issue_id) → вернуть `html.escape(work_item_id)` (номер без ссылки). Никогда не + бросает (AC-11, AC-12). + +### Р-6. Доступ к `plane_issue_id`/`project_id` в точках уведомлений (требование 4) + +В большинстве точек `send_telegram` доступен только `work_item_id`. Решение — +тонкая fail-safe обёртка по образцу `_get_task_link_fields`: + +```python +def link_for(work_item_id, task_id=None) -> str: + """По work_item_id (или task_id) достать repo+plane_issue_id из БД и вернуть + plane_issue_link(...). На любой нехватке данных -> html.escape(work_item_id).""" +``` + +- Если у точки есть `task_id` — читать `(repo, plane_issue_id)` напрямую из `tasks` по + `id`. Если только `work_item_id` — `SELECT repo, plane_issue_id FROM tasks WHERE + work_item_id=? ORDER BY id DESC LIMIT 1` (как в `_resolve_project_id`). +- Везде, где данных нет — деградация на `html.escape(work_item_id)`, без падения. +- Применить во всех точках из TZ §3.3 (`notify_approve_requested`, `notify_error`, + `stage_engine`, `launcher`, `merge_gate`, `job_reaper`, `security_gate`, `reconciler`, + `main`) — **только там, где упоминается номер задачи**. + +### Р-7. `tracker_mode` дефолт → `bump` + +`src/config.py`: `tracker_mode: str = "edit"` → `"bump"`. Инвариант «одна карточка на +задачу» сохранён в обоих режимах (код `update_task_tracker` не меняется по сути). +`edit` остаётся доступен через `ORCH_TRACKER_MODE=edit`. Транзиентный фейл `send` не +обнуляет `tracker_message_id` (инвариант уже в коде — сохранить). + +### Р-8. Чего НЕ делаем (границы) + +- НЕ менять схему БД, `STAGE_TRANSITIONS`, Quality Gates, транспорт + `send_telegram`/`edit_telegram`/`delete_telegram`, `disable_notification`-семантику. +- НЕ менять поведение агентов/конвейера. Слой B (индикация) не управляет слоем A. +- НЕ добавлять блокирующих сетевых ожиданий в линию переходов сверх одного короткого + best-effort GET с кэшем (Р-3/Р-4). +- НЕ создавать глобальный (сквозной) ADR: изменение локально для `notifications.py` + + один config-дефолт, не вводит новую стадию/QG/компонент. Достаточно per-work-item ADR. + +## Последствия + +**Плюсы** +- Обязательные по DoD `⏸️ In Review`, `⏸️ Awaiting Deploy` работают **без сети** + (детерминированно, тестируемо offline — AC-6/AC-7). +- `❓ Needs Input` (и Blocked/Rejected/Cancelled/Deploying/Monitoring) отражаются через + авторитетный источник — живой Plane-статус, который иначе невосстановим из БД. +- Единый хелпер ссылки убирает дублирование резолва проекта/loopback-guard (ORCH-017). +- Kill-switch + кэш + короткий таймаут ограничивают риск для общего инстанса. + +**Минусы / ограничения** +- Overlay добавляет ≤1 короткий GET (3 с таймаут) на задачу в `TTL=60s` в путь рендера. + Митигировано кэшем, таймаутом и kill-switch. +- При недоступном Plane ветки `Needs Input`/`Blocked`/… деградируют на offline-метку + (`Analysis`/stage). Это осознанный, безопасный компромисс (рендер важнее точности + ветки; конвейер не блокируется). +- На частично сконфигурированном проекте без отдельных статусов `Deploying`/`Monitoring` + эти ветки не показываются (alias-guard) — корректная деградация, не баг. + +**Риски** — см. `10-tech-risks.md`. + +## Альтернативы (отклонены) + +- **Только offline (вариант б TZ).** Отклонён: не отличает `Needs Input` от `Analysis` + → не покрывает обязательный AC-8. +- **Чтение `01-questions.md` из worktree как offline-сигнал Needs Input.** Отклонён: + хрупко (резолв пути worktree из `notifications.py`, файл может пережить ответ, + гонки) — менее надёжно, чем авторитетный Plane-статус. +- **Добавить DB-колонку-флаг для ветки.** Запрещено TZ (без изменения схемы). +- **Асинхронный фон/демон для подтяжки статуса.** Избыточно для слоя индикации; кэш + + короткий таймаут дешевле и проще, без нового компонента. diff --git a/docs/work-items/ORCH-067/07-infra-requirements.md b/docs/work-items/ORCH-067/07-infra-requirements.md new file mode 100644 index 0000000..f0dbf5b --- /dev/null +++ b/docs/work-items/ORCH-067/07-infra-requirements.md @@ -0,0 +1,46 @@ +# Инфраструктурные требования — ORCH-067 + +Топология не меняется (никаких новых контейнеров/портов/сервисов). Изменения — +**только конфигурация/env** и обязательный staging-гейт (self-hosting). + +## 1. Изменения конфигурации (`src/config.py`) + +| Поле | env | Старое | Новое | Назначение | +|---|---|---|---|---| +| `tracker_mode` | `ORCH_TRACKER_MODE` | `"edit"` | `"bump"` (дефолт) | Карточка падает вниз ленты при обновлении (ADR-001 Р-7). `edit` доступен через env. | +| `tracker_live_status` | `ORCH_TRACKER_LIVE_STATUS` | — (нет) | `True` (дефолт) | Kill-switch live-overlay Plane-статуса (ADR-001 Р-2). `0/false` → только offline-метки, без сетевых чтений в рендере. | +| `tracker_live_status_ttl_s` | `ORCH_TRACKER_LIVE_STATUS_TTL_S` | — | `60` | TTL per-issue кэша live-статуса (ADR-001 Р-3). | +| `tracker_live_status_timeout_s` | `ORCH_TRACKER_LIVE_STATUS_TIMEOUT_S` | — | `3` | Короткий таймаут live-чтения в рендере (ADR-001 Р-4). | + +Уже существующие (не менять, использовать): `plane_web_url` +(`ORCH_PLANE_WEB_URL`, прод — `https://plane.mva154.duckdns.org`), +`plane_workspace_slug` (прод — `ag_proj`), `plane_api_url`. + +## 2. `.env` / `.env.example` + +- Обновить `.env.example`: добавить `ORCH_TRACKER_MODE`, `ORCH_PLANE_WEB_URL`, + `ORCH_TRACKER_LIVE_STATUS*` с дефолтами и комментариями (канон настроек — + `.env.example`, реальные секреты не коммитить). +- На прод-хосте допустимо явно выставить `ORCH_TRACKER_MODE=bump` как страховку, но код + обязан работать «из коробки» и без env. +- `ORCH_PLANE_WEB_URL` должен быть задан на проде (иначе номер задачи деградирует на + текст без ссылки — fail-safe, не падение). + +## 3. Self-hosting (обязательно) + +- **НЕ перезапускать / не ронять** прод-контейнер `orchestrator` (8500) в рамках задачи — + общий инстанс/БД с enduro-trails. +- Обязательная страховка через `deploy-staging` (8501, изолированная БД) **до** прод-деплоя. + На staging проверить: + - режим `bump`: одна карточка на задачу, падает вниз, тихо (без звука), без дублей; + - статус-строка: `⏸️ In Review`, `⏸️ Awaiting Deploy`, `❓ Needs Input` отображаются; + - кликабельный номер ведёт на страницу Plane; + - **нет регресса для enduro-trails** (карточка без новых статусов деградирует корректно). +- Прод-деплой орка — только переводом задачи на стадии `deploy` в статус + **«Confirm Deploy»** (ORCH-059), не `Approved`. + +## 4. Сетевые требования + +- Live-overlay требует доступности Plane API (`plane_api_url`) из контейнера — он уже + есть (используется plane_sync). Недоступность Plane → graceful degrade на offline-метку, + конвейер не блокируется (короткий таймаут + kill-switch). diff --git a/docs/work-items/ORCH-067/08-data-requirements.md b/docs/work-items/ORCH-067/08-data-requirements.md new file mode 100644 index 0000000..653f64b --- /dev/null +++ b/docs/work-items/ORCH-067/08-data-requirements.md @@ -0,0 +1,35 @@ +# Требования к данным — ORCH-067 + +## Изменения схемы БД: НЕТ + +`STAGE_TRANSITIONS`, таблицы и колонки `tasks`/`agent_runs` **не меняются**. Это жёсткое +ограничение TZ §6 и предпосылка ADR-001 (запрет колонки-флага для веток статуса). + +## Читаемые колонки `tasks` (существующие) + +| Колонка | Использование в ORCH-067 | +|---|---| +| `id` | Ключ задачи. | +| `work_item_id` | Текст номера (`ORCH-NNN`) + ключ резолва в `link_for`. | +| `title` | Заголовок карточки (`html.escape`). | +| `stage` | Offline-маппинг Plane-статуса (ADR-001 Р-1, слой 1). | +| `brd_review_started_at`, `brd_review_ended_at` | Различение `Analysis` ↔ `⏸️ In Review` (offline, без сети). | +| `repo` | Резолв `project_id` (`get_project_by_repo`) для ссылки и live-overlay. | +| `plane_issue_id` (UUID) | `issue_id` в URL Plane + аргумент `fetch_issue_state` (live-overlay). | +| `created_at`, `updated_at` | Тоталы времени в done-строке (без изменений). | + +`render_task_tracker` **расширяет существующий `SELECT`** по `tasks`, добавляя `repo` и +`plane_issue_id` к уже выбираемым полям. Схему это не трогает — колонки уже есть. + +## Кэш в памяти (не БД) + +Per-issue TTL-кэш live-статуса (ключ `plane_issue_id`, TTL +`tracker_live_status_ttl_s=60`, ADR-001 Р-3) — **in-memory**, по образцу `_STATES_CACHE` +в `plane_sync.py`. Не персистится, переживание рестарта не требуется (best-effort +индикация). Очистка при рестарте — допустима. + +## Источник имён статусов + +Имена и логические ключи статусов берутся из существующих структур `src/plane_sync.py` +(`_PLANE_NAME_TO_KEY`, `get_project_states`, `_DEFAULT_STATES`), вводимых ORCH-066. +Новых статусов/ключей ORCH-067 **не добавляет**. diff --git a/docs/work-items/ORCH-067/10-tech-risks.md b/docs/work-items/ORCH-067/10-tech-risks.md new file mode 100644 index 0000000..35fb981 --- /dev/null +++ b/docs/work-items/ORCH-067/10-tech-risks.md @@ -0,0 +1,21 @@ +# Технические риски — ORCH-067 + +| # | Риск | Вероятность / Влияние | Митигация (ADR-001) | Остаточный риск | +|---|---|---|---|---| +| R-1 | **Регресс enduro-trails** при смене дефолта `tracker_mode` → `bump` (другое поведение карточки для всех проектов). | Сред / Сред | Инвариант «одна карточка на задачу» сохранён; `edit` доступен через env; проверка на staging + тесты нерегресса (AC-16). | Низкий | +| R-2 | **Поломка HTML-разметки** неэкранированным `title`/причиной → сообщение с `parse_mode=HTML` не доставится. | Сред / Сред | Обязательный `html.escape` для всего пользовательского текста; `href` через `html.escape(url, quote=True)`; тест с `<b>`/`&` (AC-14). | Низкий | +| R-3 | **Latency в hot-path конвейера**: live-overlay добавляет сетевой GET в синхронный рендер, вызываемый на каждом переходе/в bump. | Сред / Сред | Короткий таймаут 3 с (Р-4) + per-issue TTL-кэш 60 с (Р-3) + kill-switch `ORCH_TRACKER_LIVE_STATUS=0` (Р-2). ≤1 GET на задачу за TTL. | Низкий | +| R-4 | **Рендер карточки падает** на битых данных/недоступном Plane. | Низк / Выс | `plane_status_label` чистая и never-raise; overlay в `try/except` → degrade на offline-метку; `render_task_tracker` уже never-raise (AC-9). | Очень низкий | +| R-5 | **Ложный `Deploying`/`Monitoring` на enduro** (их UUID алиасит `in_progress`/`done`). | Сред / Низк | Override этих веток только если UUID статуса ≠ UUID базового ключа в `get_project_states` (Р-1, anti-false-positive). | Очень низкий | +| R-6 | **Устаревший Plane-статус из кэша** показывает неактуальную ветку (например, `Needs Input` после ответа). | Сред / Низк | TTL 60 с самозаживает; offline-ядро авторитетно для In Review (brd-clock не оверрайдится). Индикация, не управление — расхождение косметическое. | Низкий | +| R-7 | **Транзиентный фейл `send` плодит дубли / обнуляет указатель** в bump. | Низк / Сред | Инвариант уже в коде (`set_tracker_message_id` только при `new_mid is not None`); не менять; тест AC-3. | Низкий | +| R-8 | **Self-hosting**: деплой орка ломает общий инстанс (enduro + ORCH, общая БД/очередь). | Низк / Выс | Обязательный staging-гейт (8501) до прода; прод-контейнер не ронять в задаче; прод-деплой только через «Confirm Deploy». | Низкий | +| R-9 | **Пропущенная точка** уведомления с сырым номером (требование 4 — много call-sites). | Сред / Низк | Единый `link_for`/`plane_issue_link`; чек-лист точек из TZ §3.3; reviewer проверяет покрытие (AC-13). | Низкий | +| R-10 | **Рассинхрон имён статусов** с ORCH-066, если та не в проде на момент разработки. | Низк / Низк | Имена берутся из `_PLANE_NAME_TO_KEY` (golden source); делать после прода ORCH-066 (BRD §6). | Низкий | + +## Сводно + +Все остаточные риски — низкие/очень низкие после митигаций. Главные защитные контуры: +(1) offline-ядро статуса не требует сети и детерминировано; (2) live-overlay полностью +best-effort с таймаутом+кэшем+kill-switch; (3) обязательный staging-гейт перед прод-деплоем +общего инстанса (self-hosting). diff --git a/docs/work-items/ORCH-067/12-review.md b/docs/work-items/ORCH-067/12-review.md new file mode 100644 index 0000000..bb470fe --- /dev/null +++ b/docs/work-items/ORCH-067/12-review.md @@ -0,0 +1,78 @@ +--- +type: review +work_item_id: ORCH-067 +verdict: APPROVED +version: 2 +--- + +# Review ORCH-067 + +## Summary + +Повторное ревью после фикса документации (коммит `7a88f39`). Реализация полностью +соответствует ТЗ (`02-trz.md`), ADR-001 и всем acceptance criteria (`03-acceptance-criteria.md`). + +**Код** (`src/notifications.py` — ядро): +- **Req 1 (bump):** дефолт `tracker_mode` сменён `edit → bump` (`src/config.py`); логика + `update_task_tracker`, транспорт `send/edit/delete_telegram`, `disable_notification` и + инвариант «одна карточка на задачу» не тронуты (AC-1..AC-4, AC-15 ✓). +- **Req 2 (статус-строка):** чистый never-raise `plane_status_label(task_row)` (offline-ядро: + stage→статус + `⏸️ In Review` из brd-clock + `⏸️ Awaiting Deploy`, всё без сети) + + best-effort `_live_plane_branch_override` для ветвей, неотличимых offline (Needs Input / + Blocked / Rejected / Cancelled / Deploying / Monitoring). Kill-switch + (`tracker_live_status`), per-issue TTL-кэш (`_LIVE_STATE_CACHE`), короткий таймаут + (`fetch_issue_state(..., timeout=)`, дефолт 10 сохранён → нет регресса reconciler). + Anti-false-positive guard для enduro (`_LIVE_BRANCH_BASE`: deploying/monitoring override + только при отдельном UUID). Прецеденс In Review > overlay соблюдён. `_card_status_label` + обёрнут в try/except → рендер никогда не падает (AC-5..AC-9 ✓). +- **Req 3+4 (кликабельный номер):** единый `_plane_issue_url` устраняет дублирование + резолва проекта/loopback-guard (ORCH-017); `plane_issue_link` (текст=номер) и + `_build_plane_issue_link` (текст=«✅ Задача в Plane») оба зовут его. `link_for` fail-safe + достаёт `repo`/`plane_issue_id` из БД. Применено в заголовке карточки и во ВСЕХ точках + §3.3 с номером задачи (AC-10..AC-14 ✓). + +**Точки §3.3 проверены пофайлово:** `notify_approve_requested`, `notify_error`, +`stage_engine.py` (все alert'ы с номером), `agents/launcher.py`, `security_gate.py`, +`reconciler.py` — номер кликабелен. `merge_gate.py`/`job_reaper.py`/`main.py` оставлены без +ссылки **осознанно и корректно**: их тексты ссылаются на repo/job/run_id, а НЕ на +`work_item_id` (проверено: merge_gate:432 — lease/repo, job_reaper:396 — job/agent/repo, +main:47 — orphaned run_ids). + +**Инварианты/нерегресс:** схема БД, `STAGE_TRANSITIONS`, QG, транспорт — не тронуты +(AC-15 ✓). `get_db()` возвращает новое соединение на вызов, поэтому `conn.close()` в +`link_for` корректен. `pytest tests/ -q` → **907 passed** (AC-16, AC-17 ✓). + +**Документация (блокеры v1 закрыты):** `CHANGELOG.md`, `CLAUDE.md`, `.env.example` +обновлены в коммите `7a88f39`; ADR-001 присутствует и полон; `README.md`/`internals.md` +синхронизированы (AC-18 ✓). + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix +- (нет) + +### P2 — Should fix +- (нет) + +### P3 — Nice to have (не блокирует) +- [ ] Часть alert-сообщений в `stage_engine.py` (`_handle_self_deploy_phase_b`, + `_handle_merge_verify`) встраивает «сырой» `{msg}`/`{e}`/`{reason}` рядом с новой + `<a>`-ссылкой; под `parse_mode=HTML` редкий `<` в этих подстановках теоретически мог + бы помешать рендеру. Это **пре-существующее поведение** (parse_mode=HTML стоял и + раньше), не регресс данной задачи; `notify_error` свой `error` экранирует. Можно при + случае обернуть прочие подстановки в `html.escape`. + +## Документация + +- `docs/architecture/README.md` — обновлён (компонент Notifications / live-tracker). ✓ +- `docs/architecture/internals.md` — обновлён (§7: bump/edit, Plane-статус, кликабельный номер). ✓ +- `06-adr/ADR-001-tracker-plane-status-and-link.md` — присутствует, полный, закрывает все `[ARCH]`. ✓ +- `CHANGELOG.md` — обновлён (запись ORCH-067). ✓ +- `CLAUDE.md` — обновлён (раздел «Нотификации / Telegram live-tracker»). ✓ +- `.env.example` — синхронизирован (`ORCH_TRACKER_MODE=bump` + новые флаги live-overlay). ✓ + +Документация = golden source: код и доку обновлены в одном PR. Блокеры предыдущего ревью +(v1) закрыты. Замечаний уровня P0/P1/P2 нет → **APPROVED**. diff --git a/docs/work-items/ORCH-067/13-test-report.md b/docs/work-items/ORCH-067/13-test-report.md new file mode 100644 index 0000000..1ab7987 --- /dev/null +++ b/docs/work-items/ORCH-067/13-test-report.md @@ -0,0 +1,78 @@ +--- +type: test-report +work_item_id: ORCH-067 +result: PASS +--- + +# Test Report — ORCH-067 + +Telegram tracker: bump по умолчанию, статус-строка карточки по модели Plane (ORCH-066), +кликабельный номер задачи в карточке и во всех уведомлениях орка. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Ветка: `feature/ORCH-067-telegram-tracker-bump-plane` (worktree) +- Дата: 2026-06-08 +- Review-вердикт: APPROVED (`12-review.md`, version 2) + +## Smoke test API (prod, :8500) +| Endpoint | Результат | +|----------|-----------| +| `GET /health` | PASS — `{"status":"ok","service":"orchestrator"}` | +| `GET /status` | PASS — отдаёт active_tasks (ORCH-067 на stage=testing) | +| `GET /queue` | PASS — breaker closed, preflight_ok, counts корректны | + +Прод-контейнер не перезапускался (self-hosting инвариант соблюдён). + +## Результаты по тест-плану (04-test-plan.yaml) + +| TC ID | Описание | Модуль | AC | Результат | +|-------|----------|--------|----|-----------| +| TC-01 | Дефолт `tracker_mode == "bump"` без env | test_tracker_bump_default.py | AC-1 | PASS | +| TC-02 | bump: delete(old)→send(silent)→repoint, одна карточка | test_tracker_bump_default.py | AC-2 | PASS | +| TC-03 | bump fail-safe: send=None не обнуляет указатель | test_tracker_bump_default.py | AC-3 | PASS | +| TC-04 | `ORCH_TRACKER_MODE=edit` — прежнее поведение | test_tracker_bump_default.py | AC-4 | PASS | +| TC-05 | Карточка содержит строку Plane-статуса | test_tracker_status_line.py | AC-5 | PASS | +| TC-06 | Маппинг stage → Plane-статус (§2.2, параметризованный) | test_tracker_status_line.py | AC-6 | PASS | +| TC-07 | In Review из brd-clock, без сети; строка «Подтверждение BRD» сохранена | test_tracker_status_line.py | AC-7 | PASS | +| TC-08 | Awaiting Deploy + Needs Input отражены | test_tracker_status_line.py | AC-8 | PASS | +| TC-09 | render_task_tracker не падает на битых данных | test_tracker_status_line.py | AC-9, AC-16 | PASS | +| TC-10 | Кликабельный номер в карточке при полных данных | test_tracker_issue_link.py | AC-10 | PASS | +| TC-11 | Fail-safe ссылки в карточке (параметризованный) | test_tracker_issue_link.py | AC-11 | PASS | +| TC-12 | `plane_issue_link(...)` — ссылка/escape, никогда не бросает | test_plane_issue_link.py | AC-12 | PASS | +| TC-13 | notify_approve_requested: номер кликабелен, одна нотификация | test_notify_issue_links.py | AC-13 | PASS | +| TC-14 | notify_error: кликабелен/деградирует без падения | test_notify_issue_links.py | AC-13, AC-12 | PASS | +| TC-15 | Точки send_telegram (stage_engine/launcher/merge_gate/job_reaper/security_gate/reconciler/main) используют хелпер | test_notify_issue_links.py | AC-13 | PASS | +| TC-16 | HTML-экранирование title/`&`, валидность `<a>` | test_tracker_issue_link.py | AC-14 | PASS | +| TC-17 | Инварианты транспорта: disable_notification, одна карточка | test_tracker_bump_default.py | AC-15 | PASS | +| TC-18 | Нерегресс нотификаций + деградация для enduro-trails | test_notify_done_regression.py | AC-16, AC-17 | PASS | + +Все 18 TC из тест-плана — PASS. Целевые модули: **57 passed**. + +## Покрытие acceptance criteria +AC-1..AC-18 — все покрыты соответствующими TC и зелёные. AC-17 (полный набор) подтверждён +прогоном всего пакета. + +## Вывод pytest (полный регресс) + +``` +$ python -m pytest tests/ -v --tb=short +... +======================= 907 passed, 1 warning in 22.36s ======================== +``` + +Единственный warning — пре-существующий `PydanticDeprecatedSince20` в `src/config.py:4` +(не относится к ORCH-067, не регресс). + +Целевые модули задачи: +``` +$ python -m pytest tests/test_tracker_bump_default.py tests/test_tracker_status_line.py \ + tests/test_tracker_issue_link.py tests/test_plane_issue_link.py \ + tests/test_notify_issue_links.py tests/test_notify_done_regression.py -q +57 passed, 1 warning in 1.39s +``` + +## Итог +**PASS** — 907/907 тестов зелёные, все 18 TC и AC-1..AC-18 выполнены, smoke API OK, +нерегресс для enduro-trails подтверждён. Задача готова к переходу на `deploy-staging`. diff --git a/docs/work-items/ORCH-067/14-deploy-log.md b/docs/work-items/ORCH-067/14-deploy-log.md new file mode 100644 index 0000000..b045b46 --- /dev/null +++ b/docs/work-items/ORCH-067/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-067 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-067/16-post-deploy-log.md b/docs/work-items/ORCH-067/16-post-deploy-log.md new file mode 100644 index 0000000..baa39d4 --- /dev/null +++ b/docs/work-items/ORCH-067/16-post-deploy-log.md @@ -0,0 +1,14 @@ +--- +post_deploy_status: HEALTHY +action_taken: NONE +work_item: ORCH-067 +window_s: 900 +checks_total: 30 +checks_failed: 0 +--- + +# Post-deploy log — ORCH-021 post-deploy monitor + +Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`. + +Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0. diff --git a/docs/work-items/ORCH-069/00-business-request.md b/docs/work-items/ORCH-069/00-business-request.md new file mode 100644 index 0000000..dbce07c --- /dev/null +++ b/docs/work-items/ORCH-069/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200) + +Work Item ID: ORCH-069 + +## Description + +TBD diff --git a/docs/work-items/ORCH-069/01-brd.md b/docs/work-items/ORCH-069/01-brd.md new file mode 100644 index 0000000..99e1a38 --- /dev/null +++ b/docs/work-items/ORCH-069/01-brd.md @@ -0,0 +1,76 @@ +# BRD — ORCH-069: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200) + +Work Item ID: ORCH-069 +Тип: Enhancement (QoL / конфигурируемость) +Источник: Слава, 2026-06-08 +Связано с: QG-0 (gate входа конвейера, `_qg0_errors`) + +## 1. Проблема (As-Is) +QG-0 — первый quality gate конвейера. Он валидирует заголовок и описание задачи +до старта pipeline (`start_pipeline`) и в soft-режиме на `work_item.created`. + +Верхний лимит длины заголовка задачи **захардкожен** в +`src/webhooks/plane.py:362`: + +```python +if len(name) > 80: + errors.append("Title слишком длинный (максимум 80 символов)") +``` + +Лимит 80 — «гигиенический», а не структурный. Проверено, что **ниже по течению +ничего от значения 80 не зависит**: +- slug ветки режется независимо: `re.sub(...)[:30]` (`src/webhooks/plane.py:478`); +- БД `tasks.title TEXT` — без ограничения длины; +- Telegram-карточка использует `html.escape(title)` без обрезки; +- Plane хранит `name` самостоятельно. + +Следствие: вполне валидные осмысленные заголовки длиной 81–200 символов +отклоняются на входе конвейера без бизнес-причины. + +## 2. Цель (To-Be) +Вынести верхний лимит длины заголовка QG-0 в конфигурируемый параметр со +значением по умолчанию **200** (вместо текущего хардкода 80). Расширить лимит +безопасно, сохранив возможность регулировать его через окружение, как и +остальные `ORCH_*` настройки. + +## 3. Бизнес-ценность +- Меньше ложных отклонений валидных задач на входе конвейера (QoL для постановщика). +- Лимит становится операционно настраиваемым без правки кода и редеплоя + (изменение env-переменной). +- Изменение чисто аддитивное и обратносовместимое: дефолт 200 > прежних 80, поэтому + все заголовки, проходившие раньше, проходят и теперь. + +## 4. Объём (Scope) +### В объёме +- Новый параметр Settings `qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, дефолт 200). +- Замена хардкода `> 80` на `> settings.qg0_title_max` в `_qg0_errors`. +- Динамический текст ошибки с подстановкой актуального лимита. +- Graceful-поведение при невалидном/пустом значении env → дефолт 200, без падения процесса. +- Документация: `.env.example`, `.env.staging.example`, `CHANGELOG.md`, + при необходимости README-таблица конфигов / `CLAUDE.md`. +- Юнит-тесты на `_qg0_errors` с разными лимитами. + +### Вне объёма (Out of scope) +- Slug-логика `[:30]` (`src/webhooks/plane.py:478`) — самодостаточна, не трогать. +- Нижний лимит заголовка (`< 5`) и лимит description (`< 20`) — оставить как есть. +- Схема БД, реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, контракты `handle_*`. +- Soft-QG-0 на `work_item.created` (там только warning) — логика валидации общая + (`_qg0_errors`), отдельных изменений не требует и не вносит. + +## 5. Заинтересованные стороны +- Owner / постановщик задач (Слава) — снижение ложных отклонений. +- Агенты конвейера — поведение QG-0 при старте pipeline. + +## 6. Ограничения и риски (self-hosting) +- Правка касается работающего в проде инструмента (self-hosting). Прод-контейнер + `orchestrator` в рамках задачи **не рестартить**; обязательна страховка + `deploy-staging` (8501). +- Риск минимален: изменение обратносовместимо, изолировано в одной функции и одном + новом параметре config. + +## 7. Допущения +- Механизм чтения env — стандартный `pydantic_settings.BaseSettings` с + `env_prefix = "ORCH_"`, как у остальных параметров. +- «Невалидное/пустое значение → дефолт 200» — требование graceful-деградации: + процесс не должен падать на старте из-за мусора в `ORCH_QG0_TITLE_MAX` + (нюанс реализации pydantic-валидации передаётся архитектору, см. 02-trz §5). diff --git a/docs/work-items/ORCH-069/02-trz.md b/docs/work-items/ORCH-069/02-trz.md new file mode 100644 index 0000000..7ffb071 --- /dev/null +++ b/docs/work-items/ORCH-069/02-trz.md @@ -0,0 +1,95 @@ +# ТЗ — ORCH-069: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200) + +Work Item ID: ORCH-069 + +## 1. Задействованные модули `src/` +| Файл | Текущее состояние | Требуемое изменение | +|------|-------------------|---------------------| +| `src/config.py` | `Settings(BaseSettings)`, `env_prefix = "ORCH_"` (строки 4, 347-349) | Добавить поле `qg0_title_max: int = 200` с комментарием-описанием. | +| `src/webhooks/plane.py` | `_qg0_errors` (строки 357-367), хардкод `if len(name) > 80:` (строка 362); `from ..config import settings` уже импортирован (строка 11) | Заменить хардкод `> 80` на `> settings.qg0_title_max`; текст ошибки — динамический с подстановкой лимита. | + +Других модулей изменение не затрагивает. + +## 2. Изменение config.py +Добавить в класс `Settings` новое поле (рядом с другими `ORCH_*` группами, +рекомендуется отдельный блок с комментарием): + +```python +# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char +# cap was a hygiene limit, not structural (slug is cut to [:30] independently, +# DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default +# 200 (was hardcoded 80). Invalid/empty value -> default (graceful, no crash). +qg0_title_max: int = 200 +``` + +- Env-переменная: `ORCH_QG0_TITLE_MAX` (автоматически из `env_prefix = "ORCH_"`). +- Тип `int`, дефолт `200`. + +## 3. Изменение `_qg0_errors` (src/webhooks/plane.py) +Текущий блок (строки 362-363): +```python +if len(name) > 80: + errors.append("Title слишком длинный (максимум 80 символов)") +``` + +Требуемое: +```python +if len(name) > settings.qg0_title_max: + errors.append( + f"Title слишком длинный (максимум {settings.qg0_title_max} символов)" + ) +``` + +Требования: +- Лимит берётся из `settings.qg0_title_max` (динамически, на каждый вызов — чтобы + тесты могли подменять значение через мок/патч settings). +- Текст ошибки содержит актуальное число лимита (для AC-1/AC-2: текст упоминает + 200 / 120 соответственно). +- Нижний лимит заголовка `< 5` (строка 360-361) и проверка description `< 20` + (строка 364-365) — **не трогать**. +- Сигнатура `_qg0_errors(name, description) -> list` не меняется. + +## 4. Поведение границы (точная семантика) +- Условие fail — строго `len(name) > limit`. То есть `len == limit` → PASS, + `len == limit + 1` → FAIL. +- При дефолте: 200 символов → PASS, 201 → FAIL. +- При `ORCH_QG0_TITLE_MAX=120`: 120 → PASS, 121 → FAIL. + +## 5. Graceful-обработка невалидного значения (требование AC-3) +Требование: невалидное/отсутствующее `ORCH_QG0_TITLE_MAX` → используется дефолт 200, +процесс не падает. + +Нюанс для архитектора/разработчика: `pydantic_settings` по умолчанию при +непарсящемся в `int` значении env (например `ORCH_QG0_TITLE_MAX=abc` или пустая +строка) выбрасывает `ValidationError` на инстанцировании `Settings()` — +т.е. падение на старте процесса. Это противоречит требованию graceful. +Реализация должна обеспечить, что: +- отсутствие переменной → дефолт 200 (это стандартное поведение, ОК «из коробки»); +- пустая строка / нечисловое значение → дефолт 200 без исключения. + +Способ (на усмотрение архитектора, без предписания со стороны аналитика) — +например field-validator с `mode="before"`, который при невалидном входе +возвращает дефолт. Конкретный механизм фиксируется в ADR на стадии architecture. + +## 6. Изменения API +Нет. Эндпоинты не меняются. + +## 7. Изменения схемы БД +Нет. `tasks.title TEXT` остаётся без ограничения длины. + +## 8. Новые QG checks +Нет. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются. QG-0 — не зарегистрированный +stage-gate, а inline-валидация входа (`_qg0_errors`), её контракт сохраняется. + +## 9. Артефакты pipeline, которые должны быть созданы/обновлены +- `.env.example` — добавить `ORCH_QG0_TITLE_MAX=200` с комментарием. +- `.env.staging.example` — добавить `ORCH_QG0_TITLE_MAX` (дефолт/комментарий). +- `CHANGELOG.md` — запись об ORCH-069. +- README-таблица конфигов / `CLAUDE.md` — обновить при наличии релевантной таблицы + параметров (по требованию reviewer; документация = golden source). +- Юнит-тесты (`tests/`) — см. `04-test-plan.yaml`. + +## 10. Обратная совместимость +- Дефолт 200 > прежних 80 → все ранее проходившие заголовки проходят и теперь. +- Поведение при не заданном env идентично «как было», но с порогом 200 вместо 80. +- Изменение чисто аддитивное; откатов/миграций не требует. diff --git a/docs/work-items/ORCH-069/03-acceptance-criteria.md b/docs/work-items/ORCH-069/03-acceptance-criteria.md new file mode 100644 index 0000000..a76e91f --- /dev/null +++ b/docs/work-items/ORCH-069/03-acceptance-criteria.md @@ -0,0 +1,56 @@ +# Критерии приёмки — ORCH-069 + +Work Item ID: ORCH-069 + +Формат: каждый критерий имеет чёткое условие PASS/FAIL. + +## AC-1 — Дефолтный лимит 200, граница на 201 +**Дано:** env `ORCH_QG0_TITLE_MAX` не задан (используется дефолт 200), description валиден (≥ 20 символов). +**Тогда:** +- заголовок длиной 200 символов → `_qg0_errors` НЕ содержит ошибки про длину title (PASS); +- заголовок длиной 201 символ → `_qg0_errors` содержит ошибку про длину title, и текст ошибки упоминает «200». +**FAIL если:** на 200 появляется ошибка длины, либо на 201 ошибки нет, либо текст не упоминает 200. + +## AC-2 — Настраиваемый лимит 120, граница на 121 +**Дано:** `ORCH_QG0_TITLE_MAX=120` (через мок/патч settings в тесте), description валиден. +**Тогда:** +- заголовок 120 символов → нет ошибки длины title (PASS); +- заголовок 121 символ → есть ошибка длины title, текст упоминает «120». +**FAIL если:** граница срабатывает не на 121, либо текст ошибки упоминает не 120. + +## AC-3 — Graceful при невалидном/пустом значении +**Дано:** `ORCH_QG0_TITLE_MAX` пустой (`""`) или нечисловой (`"abc"`). +**Тогда:** +- инстанцирование `Settings()` / импорт приложения НЕ выбрасывает исключение (процесс не падает); +- эффективное значение лимита = дефолт 200 (поведение AC-1 сохраняется). +**FAIL если:** старт процесса падает с `ValidationError`, либо лимит != 200. + +## AC-4 — Нижние лимиты не сломаны +**Дано:** любое валидное значение `ORCH_QG0_TITLE_MAX`. +**Тогда:** +- заголовок длиной < 5 символов → `_qg0_errors` содержит ошибку «Title слишком короткий»; +- description длиной < 20 символов → `_qg0_errors` содержит ошибку «Description слишком короткий». +**FAIL если:** нижний лимит title или лимит description перестал срабатывать. + +## AC-5 — Юнит-тесты зелёные +**Дано:** реализованные юнит-тесты на `_qg0_errors` с разными значениями лимита (мок settings). +**Тогда:** `pytest tests/ -q` проходит полностью (зелёный), включая новые тесты ORCH-069 и существующий набор. +**FAIL если:** хотя бы один тест падает. + +## AC-6 — Документация обновлена в том же PR +**Дано:** PR с изменениями кода. +**Тогда в том же PR:** +- `.env.example` содержит `ORCH_QG0_TITLE_MAX` с дефолтом и комментарием; +- `.env.staging.example` содержит `ORCH_QG0_TITLE_MAX`; +- `CHANGELOG.md` содержит запись об ORCH-069; +- при наличии релевантной таблицы конфигов в README / `CLAUDE.md` — она обновлена. +**FAIL если:** какой-либо из обязательных файлов документации не обновлён (reviewer → REQUEST_CHANGES). + +## AC-7 — Обратная совместимость +**Дано:** env не задан. +**Тогда:** любой заголовок, который проходил QG-0 при прежнем лимите 80 (len ≤ 80), проходит и теперь (len ≤ 200). +**FAIL если:** ранее валидный заголовок отклоняется. + +## AC-8 — Изоляция изменений +**Тогда:** не изменены slug-логика (`[:30]`), схема БД, реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, контракты `handle_*`, soft-QG-0 поведение (warning на `work_item.created`). +**FAIL если:** затронут любой из перечисленных вне-объёмных элементов. diff --git a/docs/work-items/ORCH-069/04-test-plan.yaml b/docs/work-items/ORCH-069/04-test-plan.yaml new file mode 100644 index 0000000..b99bcb0 --- /dev/null +++ b/docs/work-items/ORCH-069/04-test-plan.yaml @@ -0,0 +1,112 @@ +work_item: ORCH-069 +description: > + Юнит-тесты для конфигурируемого верхнего лимита длины заголовка QG-0 + (_qg0_errors) через параметр settings.qg0_title_max (env ORCH_QG0_TITLE_MAX, + дефолт 200). Тесты патчат settings.qg0_title_max (monkeypatch на объекте + src.config.settings, который импортирован в src.webhooks.plane) и проверяют + границы и тексты ошибок. Файл тестов: tests/test_qg0_title_limit.py. + +tests: + - id: TC-01 + type: unit + description: "Дефолтный лимит 200: заголовок ровно 200 символов -> нет ошибки длины title (PASS на границе)." + module: tests/test_qg0_title_limit.py + setup: "settings.qg0_title_max=200 (дефолт); name='x'*200; description валиден (>=20 символов)." + assert: "В списке _qg0_errors нет элемента про длину title." + covers: [AC-1] + expected: PASS + + - id: TC-02 + type: unit + description: "Дефолтный лимит 200: заголовок 201 символ -> ошибка длины title, текст упоминает '200'." + module: tests/test_qg0_title_limit.py + setup: "settings.qg0_title_max=200; name='x'*201; description валиден." + assert: "В _qg0_errors есть ошибка длины title и её текст содержит подстроку '200'." + covers: [AC-1] + expected: PASS + + - id: TC-03 + type: unit + description: "Настраиваемый лимит 120: заголовок 120 символов -> нет ошибки длины title." + module: tests/test_qg0_title_limit.py + setup: "monkeypatch settings.qg0_title_max=120; name='x'*120; description валиден." + assert: "Нет ошибки длины title." + covers: [AC-2] + expected: PASS + + - id: TC-04 + type: unit + description: "Настраиваемый лимит 120: заголовок 121 символ -> ошибка длины title, текст упоминает '120'." + module: tests/test_qg0_title_limit.py + setup: "monkeypatch settings.qg0_title_max=120; name='x'*121; description валиден." + assert: "Есть ошибка длины title и её текст содержит подстроку '120' (и НЕ '80')." + covers: [AC-2] + expected: PASS + + - id: TC-05 + type: unit + description: "Graceful: невалидное (нечисловое) значение env ORCH_QG0_TITLE_MAX не роняет инстанцирование Settings и даёт дефолт 200." + module: tests/test_qg0_title_limit.py + setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX','abc'); создать новый экземпляр Settings()." + assert: "Settings() не выбрасывает исключение; settings.qg0_title_max == 200." + covers: [AC-3] + expected: PASS + + - id: TC-06 + type: unit + description: "Graceful: пустая строка env ORCH_QG0_TITLE_MAX -> дефолт 200, без исключения." + module: tests/test_qg0_title_limit.py + setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX',''); создать новый экземпляр Settings()." + assert: "Settings() не падает; settings.qg0_title_max == 200." + covers: [AC-3] + expected: PASS + + - id: TC-07 + type: unit + description: "Корректное числовое env -> применяется заданное значение (sanity положительного пути)." + module: tests/test_qg0_title_limit.py + setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX','150'); создать новый экземпляр Settings()." + assert: "settings.qg0_title_max == 150." + covers: [AC-2, AC-3] + expected: PASS + + - id: TC-08 + type: unit + description: "Нижний лимит title не сломан: заголовок < 5 символов -> ошибка 'Title слишком короткий' при любом верхнем лимите." + module: tests/test_qg0_title_limit.py + setup: "settings.qg0_title_max=200; name='abc' (3 символа); description валиден." + assert: "В _qg0_errors есть ошибка короткого title." + covers: [AC-4] + expected: PASS + + - id: TC-09 + type: unit + description: "Лимит description не сломан: description < 20 символов -> ошибка 'Description слишком короткий'." + module: tests/test_qg0_title_limit.py + setup: "settings.qg0_title_max=200; name валиден (>=5, <=200); description='short'." + assert: "В _qg0_errors есть ошибка короткого description." + covers: [AC-4] + expected: PASS + + - id: TC-10 + type: unit + description: "Обратная совместимость: заголовок длиной 81-200 (ранее отклонялся лимитом 80) теперь проходит при дефолте." + module: tests/test_qg0_title_limit.py + setup: "settings.qg0_title_max=200; name='x'*100; description валиден." + assert: "Нет ошибки длины title (раньше при лимите 80 была бы)." + covers: [AC-7] + expected: PASS + + - id: TC-11 + type: unit + description: "Полный набор тестов зелёный (регрессия не внесена)." + module: tests/ + command: "pytest tests/ -q" + assert: "Все тесты проходят." + covers: [AC-5] + expected: PASS + +notes: + - "settings импортирован в src.webhooks.plane как 'from ..config import settings', _qg0_errors читает settings.qg0_title_max динамически -> monkeypatch на src.config.settings.qg0_title_max (или импортируемом объекте) меняет поведение в рамках теста." + - "Для TC-05/06/07 нужен СВЕЖИЙ экземпляр Settings(): глобальный src.config.settings создаётся один раз на импорт, поэтому env-тесты инстанцируют Settings() локально, а не полагаются на готовый синглтон." + - "Тесты не требуют сети, БД, агентов или FastAPI TestClient — чистая проверка leaf-функции _qg0_errors и парсинга Settings." diff --git a/docs/work-items/ORCH-069/06-adr/ADR-001-configurable-qg0-title-limit.md b/docs/work-items/ORCH-069/06-adr/ADR-001-configurable-qg0-title-limit.md new file mode 100644 index 0000000..49e1fba --- /dev/null +++ b/docs/work-items/ORCH-069/06-adr/ADR-001-configurable-qg0-title-limit.md @@ -0,0 +1,143 @@ +# ADR-001: Конфигурируемый QG-0 title-лимит с graceful-деградацией env + +## Статус +Accepted + +## Контекст +QG-0 — inline-валидация входа конвейера (`_qg0_errors` в `src/webhooks/plane.py`), +вызывается из `start_pipeline` (hard-блок) и из `handle_work_item_created` +(soft-warning). Верхний лимит длины заголовка захардкожен: `if len(name) > 80`. + +BRD/ТЗ (ORCH-069) установили, что лимит 80 — гигиенический, а не структурный: +ниже по течению от него ничего не зависит (slug режется независимо `[:30]`, +`tasks.title TEXT` без ограничения, Telegram/Plane хранят/экранируют сами). +Валидные заголовки 81–200 символов отклоняются на входе без бизнес-причины. + +Требуется: +1. Вынести лимит в конфигурируемый параметр `ORCH_QG0_TITLE_MAX`, дефолт 200. +2. **Graceful-деградация** (AC-3): пустое/нечисловое значение env → дефолт 200 + **без падения процесса**. Это и есть единственное нетривиальное архитектурное + решение задачи: `pydantic_settings` v2 по умолчанию при непарсящемся в `int` + значении env бросает `ValidationError` на инстанцировании `Settings()` — + т.е. краш на старте контейнера (`settings = Settings()` на module-import, + `src/config.py:352`). Для self-hosting это означало бы падение прод-инструмента + из-за опечатки в env — недопустимо. + +Стек подтверждён: `pydantic==2.13.4`, `pydantic-settings==2.5.0` (v2 API). + +## Решение + +### Р-1. Новый параметр Settings +В `src/config.py`, в класс `Settings`, добавить поле (отдельный блок с +комментарием, рядом с прочими `ORCH_*`): + +```python +# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). +# 80-char cap was a hygiene limit, not structural. Env ORCH_QG0_TITLE_MAX; +# default 200 (was hardcoded 80). Invalid/empty -> default (graceful, no crash). +qg0_title_max: int = 200 +``` +Env-имя выводится автоматически из `env_prefix = "ORCH_"` → `ORCH_QG0_TITLE_MAX`. + +### Р-2. Механизм graceful-деградации — `field_validator(mode="before")` +Выбран **pydantic v2 `field_validator` с `mode="before"`** как +минимально-инвазивный, локальный для одного поля механизм. Валидатор перехватывает +сырое значение env ДО стандартного `int`-парсинга и при невалидном/пустом входе +возвращает дефолт `200`, гася `ValidationError`: + +```python +from pydantic import field_validator + +@field_validator("qg0_title_max", mode="before") +@classmethod +def _qg0_title_max_default(cls, v): + # Graceful (ORCH-069 AC-3): empty / non-numeric env -> default 200, + # process must not crash on startup. Never raises. + try: + if v is None or (isinstance(v, str) and v.strip() == ""): + return 200 + return int(v) + except (TypeError, ValueError): + return 200 +``` + +Семантика: +- переменная не задана → pydantic не вызывает validator с env, берётся дефолт поля + `200` (стандартное поведение «из коробки»); +- `""`, `"abc"`, мусор → validator возвращает `200`, исключения нет; +- `"120"` → `int("120") == 120`. + +**Почему именно так (рассмотренные альтернативы):** +- *`Optional[int] + None-fallback на месте чтения`* — отвергнуто: размазывает + дефолт по call-site'ам, легко забыть, тип поля перестаёт быть «честным `int`». +- *try/except вокруг `Settings()` на module-level* — отвергнуто: глушит ВСЕ + ошибки конфигурации (маскирует реальные проблемы других полей), слишком грубо. +- *кастомный тип / `Annotated`-валидатор* — избыточно для одного поля. +- `field_validator(mode="before")` локален, не трогает остальные поля, не меняет + публичный тип `int`, тестируется напрямую через `Settings(qg0_title_max=...)` и + env-патч. Контракт «never-raise» консистентен с общим стилем кодовой базы + (`_qg0_errors`, парсеры — defensive). + +### Р-3. Использование лимита в `_qg0_errors` +Хардкод `> 80` → динамическое чтение `settings.qg0_title_max` **на каждый вызов** +(чтобы тест мог патчить `settings`), текст ошибки — f-string с актуальным числом: + +```python +if len(name) > settings.qg0_title_max: + errors.append( + f"Title слишком длинный (максимум {settings.qg0_title_max} символов)" + ) +``` +`settings` уже импортирован в `plane.py`. Сигнатура `_qg0_errors(name, description) +-> list` не меняется. Нижние лимиты (`< 5` title, `< 20` description) — без правок. + +Граница (ТЗ §4): fail строго при `len(name) > limit` → `len == limit` PASS, +`limit + 1` FAIL. + +### Р-4. Что НЕ меняется (инварианты) +- `STAGE_TRANSITIONS`, `QG_CHECKS` — QG-0 не зарегистрированный stage-gate, а + inline-валидация; реестры не трогаются. +- Схема БД (`tasks.title TEXT`), API, контракты `handle_*`, slug-логика `[:30]`, + soft-QG-0 поведение (общая функция `_qg0_errors`, отдельной правки не требует). +- Топология/инфраструктура (`07-infra-requirements.md` — **N/A**) и схема данных + (`08-data-requirements.md` — **N/A**) не затрагиваются. + +## Последствия + +### Плюсы +- Лимит операционно настраивается через env без правки кода и редеплоя кода. +- Чисто аддитивно и обратносовместимо: дефолт 200 > прежних 80 → все ранее + проходившие заголовки проходят (AC-7). +- Опечатка в `ORCH_QG0_TITLE_MAX` не роняет прод-процесс (критично для + self-hosting): graceful-fallback на 200. +- Изменение изолировано в одной функции + одном поле config + одном валидаторе. + +### Минусы / ограничения +- Невалидное env «тихо» проглатывается → оператор не сразу заметит опечатку + (лимит молча станет 200). Принято как осознанный trade-off: устойчивость + процесса важнее громкости (consistency с требованием AC-3). Рекомендация: + при желании усилить наблюдаемость — `logger.warning` в validator; **не вводим** + по умолчанию, т.к. на этапе валидации settings логгер может быть не сконфигурён, + и это вне объёма ORCH-069 (можно отдельной QoL-задачей). +- Дефолт 200 — тоже эвристика; структурного верхнего предела по-прежнему нет + (его и не требуется — БД/slug/UI к длине устойчивы). + +### Влияние на self-hosting +Прод-контейнер `orchestrator` **не рестартить** в рамках задачи. Изменение +прокатывается штатно через обязательный `deploy-staging`-гейт (8501) перед +прод-деплоем. Риск отказа на старте после деплоя снят самим механизмом Р-2 +(graceful), что дополнительно снижает self-hosting-риск. + +### Тестируемость (вход для стадий development/testing) +- `_qg0_errors`: патч `settings.qg0_title_max` → проверка границ 200/201 (AC-1), + 120/121 (AC-2), нижних лимитов (AC-4). +- validator: `Settings(qg0_title_max="abc")` / `=""` / env-патч → значение 200, + без исключения (AC-3). + +## Ссылки +- BRD: `docs/work-items/ORCH-069/01-brd.md` +- ТЗ: `docs/work-items/ORCH-069/02-trz.md` +- Acceptance: `docs/work-items/ORCH-069/03-acceptance-criteria.md` +- Тех-риски: `docs/work-items/ORCH-069/10-tech-risks.md` +</content> +</invoke> diff --git a/docs/work-items/ORCH-069/10-tech-risks.md b/docs/work-items/ORCH-069/10-tech-risks.md new file mode 100644 index 0000000..b6bde09 --- /dev/null +++ b/docs/work-items/ORCH-069/10-tech-risks.md @@ -0,0 +1,21 @@ +# Технические риски — ORCH-069 + +Work Item ID: ORCH-069 +Уровень общего риска: **низкий** (аддитивное, обратносовместимое, изолированное изменение). + +| # | Риск | Вероятность | Влияние | Митигация | +|---|------|-------------|---------|-----------| +| R-1 | `ValidationError` на старте при мусоре в `ORCH_QG0_TITLE_MAX` → краш прод-процесса (self-hosting) | Средняя (опечатка в env) | Высокое (падение инструмента всех проектов) | `field_validator(mode="before")` гасит невалидный вход → дефолт 200 (ADR Р-2, AC-3). never-raise. | +| R-2 | Чтение лимита один раз на module-import вместо per-call → тесты не смогут патчить settings | Низкая | Среднее (нетестируемость AC-2) | `_qg0_errors` читает `settings.qg0_title_max` динамически на каждый вызов (ADR Р-3). | +| R-3 | Off-by-one на границе (`>=` вместо `>`) | Низкая | Низкое (1 символ) | Явная семантика `len > limit` зафиксирована (ТЗ §4, AC-1/AC-2); тесты на 200/201, 120/121. | +| R-4 | Регресс нижних лимитов (`< 5` title, `< 20` description) при правке функции | Низкая | Среднее | Трогать только верхний лимит; AC-4 покрывает нижние; диф минимален. | +| R-5 | Тихое проглатывание невалидного env → оператор не заметит опечатку | Средняя | Низкое (лимит молча = 200, конвейер работает) | Осознанный trade-off (ADR «Минусы»): устойчивость > громкость. Опц. `logger.warning` — вне объёма. | +| R-6 | Случайное затрагивание вне-объёмных элементов (slug `[:30]`, БД, реестры, `handle_*`, soft-QG-0) | Низкая | Среднее | AC-8 — изоляция; reviewer проверяет диф; ADR Р-4 фиксирует инварианты. | +| R-7 | Документация не обновлена в том же PR (`.env.example`, `.env.staging.example`, `CHANGELOG.md`) | Средняя | Среднее (reviewer REQUEST_CHANGES) | AC-6 чек-лист; документация = golden source (правило 2 CLAUDE.md). | + +## Не-риски (явно) +- Схема БД — не меняется (`tasks.title TEXT` без ограничения). +- API/эндпоинты — не меняются. +- Топология/контейнеры/порты — не меняются. +- Откат/миграция — не требуется (дефолт 200 > 80, чисто аддитивно). +</content> diff --git a/docs/work-items/ORCH-069/12-review.md b/docs/work-items/ORCH-069/12-review.md new file mode 100644 index 0000000..ecd719b --- /dev/null +++ b/docs/work-items/ORCH-069/12-review.md @@ -0,0 +1,68 @@ +--- +type: review +work_item_id: ORCH-069 +verdict: APPROVED +version: 3 +--- + +# Review ORCH-069 + +## Summary +Реализация конфигурируемого QG-0 title-лимита `ORCH_QG0_TITLE_MAX` (дефолт 200) +выполнена **дословно по ТЗ/ADR** и качественно. Поле `Settings.qg0_title_max`, +graceful `field_validator(mode="before")` (never-raise → дефолт 200), динамическое +чтение `settings.qg0_title_max` в `_qg0_errors` с f-string-текстом ошибки. Код +изолирован (затронуты только `src/config.py` и `src/webhooks/plane.py`), инварианты +не нарушены, нижние лимиты сохранены. Свежий полный прогон на текущем состоянии +ветки: `pytest tests/ -q` → **863 passed** (включая 10 новых тестов ORCH-069, +файл `tests/test_qg0_title_limit.py`, все зелёные). Документация обновлена в том же +PR полностью. Блокирующих и must-fix findings нет → **APPROVED**. + +## Соответствие ТЗ / ADR +- `src/config.py` — поле `qg0_title_max: int = 200` + валидатор `_qg0_title_max_default` + (`mode="before"`, try/except → 200 при `None`/пустой/нечисловой): 1:1 с ADR Р-1/Р-2 + и ТЗ §2/§5. ✓ +- `src/webhooks/plane.py` — хардкод `> 80` заменён на `> settings.qg0_title_max`, + текст ошибки динамический (f-string с актуальным числом); сигнатура `_qg0_errors`, + нижний лимит title `< 5`, проверка description `< 20` не тронуты: ADR Р-3, ТЗ §3/§4. ✓ +- Граница строгая (`len == limit` PASS, `limit+1` FAIL) — подтверждена tc01–tc04. ✓ +- Инварианты (ADR Р-4 / AC-8): `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, slug `[:30]`, + soft-QG-0, API — НЕ изменены (diff `src/` = только 2 файла). ✓ + +## Acceptance criteria +- AC-1 (дефолт 200, граница 201, текст упоминает 200) — tc01/tc02 ✓ +- AC-2 (лимит 120, граница 121, текст 120 не 80) — tc03/tc04 ✓ +- AC-3 (graceful пустое/`abc` → 200 без краха) — tc05/tc06 + позитив tc07 + валидатор ✓ +- AC-4 (нижние лимиты title<5 / desc<20) — tc08/tc09 ✓ +- AC-5 (pytest зелёный) — 863 passed ✓ +- AC-6 (документация в том же PR) — выполнен полностью ✓ +- AC-7 (обратная совместимость, ≤80 проходит при 200) — tc10 ✓ +- AC-8 (изоляция изменений) — ✓ + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix +- (нет) + +### P2 — Should fix +- (нет) + +### P3 — Nice-to-have (не блокирует) +- В конце `06-adr/ADR-001-configurable-qg0-title-limit.md` присутствуют артефактные + хвостовые теги (`</content>`, `</invoke>`). Косметика в артефакте стадии architecture; + на корректность кода/контракта не влияет. Править артефакт чужой стадии в рамках + ревью не уполномочен — отмечено для будущей чистки. + +## Документация +- `.env.example` — добавлен `ORCH_QG0_TITLE_MAX=200` с комментарием. ✓ +- `.env.staging.example` — добавлен `ORCH_QG0_TITLE_MAX=200`. ✓ +- `CHANGELOG.md` — подробная запись об ORCH-069 (раздел Added). ✓ +- `README.md` — таблица env-конфигов дополнена строкой `ORCH_QG0_TITLE_MAX`. ✓ +- ADR `06-adr/ADR-001-configurable-qg0-title-limit.md` — присутствует, согласован + с кодом. ✓ +- `docs/architecture/README.md` / `CLAUDE.md` — обновления не требуют (QG-0 — inline + soft/hard-валидация входа, не зарегистрированный stage-gate; API/стадии/QG-реестр + не менялись). ОК. diff --git a/docs/work-items/ORCH-069/13-test-report.md b/docs/work-items/ORCH-069/13-test-report.md new file mode 100644 index 0000000..f440e91 --- /dev/null +++ b/docs/work-items/ORCH-069/13-test-report.md @@ -0,0 +1,98 @@ +--- +type: test-report +work_item_id: ORCH-069 +result: PASS +--- + +# Test Report — ORCH-069 + +QG-0 title-лимит → параметр `ORCH_QG0_TITLE_MAX` (дефолт 200) + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8; asyncio mode=auto) +- Ветка: `feature/ORCH-069-qg-0-title-orch-qg0-title-max-` +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-069-qg-0-title-orch-qg0-title-max-` +- Prod-health (8500): `{"status":"ok","service":"orchestrator"}` — не трогался (self-hosting safety) +- Дата: 2026-06-08 + +## Предусловия +- Review-вердикт `12-review.md`: **APPROVED** (version 3) ✓ +- Изменения изолированы: `src/config.py`, `src/webhooks/plane.py` (+ тесты, + документация) + +## Результаты по тест-плану (04-test-plan.yaml) + +| TC ID | Описание | Покрывает | Результат | +|-------|----------|-----------|-----------| +| TC-01 | Дефолт 200: title=200 → нет ошибки длины (граница PASS) | AC-1 | PASS | +| TC-02 | Дефолт 200: title=201 → ошибка длины, текст упоминает «200» | AC-1 | PASS | +| TC-03 | Лимит 120: title=120 → нет ошибки длины | AC-2 | PASS | +| TC-04 | Лимит 120: title=121 → ошибка, текст «120» (не «80») | AC-2 | PASS | +| TC-05 | Graceful: env `abc` → дефолт 200, без краха `Settings()` | AC-3 | PASS | +| TC-06 | Graceful: пустой env `""` → дефолт 200, без исключения | AC-3 | PASS | +| TC-07 | Валидный env `150` → применяется 150 (позитивный путь) | AC-2, AC-3 | PASS | +| TC-08 | Нижний лимит title < 5 не сломан | AC-4 | PASS | +| TC-09 | Лимит description < 20 не сломан | AC-4 | PASS | +| TC-10 | Обратная совместимость: title 81–200 проходит при дефолте | AC-7 | PASS | +| TC-11 | Полный набор тестов зелёный (нет регрессии) | AC-5 | PASS | + +## Сопоставление с критериями приёмки (03-acceptance-criteria.md) + +| AC | Критерий | Статус | +|----|----------|--------| +| AC-1 | Дефолт 200, граница на 201, текст упоминает 200 | PASS (TC-01/02) | +| AC-2 | Настраиваемый лимит 120, граница 121, текст 120 | PASS (TC-03/04/07) | +| AC-3 | Graceful при пустом/нечисловом значении → 200 | PASS (TC-05/06) | +| AC-4 | Нижние лимиты title<5 / description<20 не сломаны | PASS (TC-08/09) | +| AC-5 | Юнит-тесты зелёные (весь набор) | PASS (863 passed) | +| AC-6 | Документация в том же PR (.env.example, .env.staging.example, CHANGELOG, README) | PASS (подтверждено review) | +| AC-7 | Обратная совместимость (≤80 проходит при 200) | PASS (TC-10) | +| AC-8 | Изоляция: slug `[:30]`, БД, STAGE_TRANSITIONS/QG_CHECKS, handle_* не тронуты | PASS (diff = 2 файла src/) | + +## Smoke test API (prod 8500, read-only) +- `GET /health` → `{"status":"ok","service":"orchestrator"}` — OK +- `GET /status` → отдаёт активные задачи (ORCH-069 в стадии `testing`) — OK +- `GET /queue` → `counts: queued=0 running=1 done=459 failed=4 cancelled=1`; breaker `closed`, preflight ok — OK + +## Целевой прогон ORCH-069 (tests/test_qg0_title_limit.py) +``` +collected 10 items + +tests/test_qg0_title_limit.py::test_tc01_default_limit_200_boundary_pass PASSED +tests/test_qg0_title_limit.py::test_tc02_default_limit_200_boundary_fail PASSED +tests/test_qg0_title_limit.py::test_tc03_custom_limit_120_boundary_pass PASSED +tests/test_qg0_title_limit.py::test_tc04_custom_limit_120_boundary_fail PASSED +tests/test_qg0_title_limit.py::test_tc05_graceful_non_numeric_env PASSED +tests/test_qg0_title_limit.py::test_tc06_graceful_empty_env PASSED +tests/test_qg0_title_limit.py::test_tc07_valid_numeric_env PASSED +tests/test_qg0_title_limit.py::test_tc08_short_title_still_errors PASSED +tests/test_qg0_title_limit.py::test_tc09_short_description_still_errors PASSED +tests/test_qg0_title_limit.py::test_tc10_backward_compat_titles_81_to_200 PASSED + +======================== 10 passed, 1 warning in 0.31s ========================= +``` + +## Полный прогон (pytest tests/ -q) +``` +........................................................................ [ 8%] +........................................................................ [ 16%] +........................................................................ [ 25%] +........................................................................ [ 33%] +........................................................................ [ 41%] +........................................................................ [ 50%] +........................................................................ [ 58%] +........................................................................ [ 66%] +........................................................................ [ 75%] +........................................................................ [ 83%] +........................................................................ [ 91%] +....................................................................... [100%] +863 passed, 1 warning in 21.49s +``` + +(Единственный warning — PydanticDeprecatedSince20 в `src/config.py:5`, существующий +class-based config; к ORCH-069 не относится, не является ошибкой.) + +## Итог +**PASS** — все 11 TC из тест-плана пройдены, все 8 критериев приёмки выполнены, +полный регресс зелёный (863 passed), smoke-тесты API OK. Регрессии не внесены. +Задача готова к переходу на стадию `deploy-staging`. diff --git a/docs/work-items/ORCH-069/14-deploy-log.md b/docs/work-items/ORCH-069/14-deploy-log.md new file mode 100644 index 0000000..2ca83f2 --- /dev/null +++ b/docs/work-items/ORCH-069/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-069 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-069/17-security-report.md b/docs/work-items/ORCH-069/17-security-report.md new file mode 100644 index 0000000..243c333 --- /dev/null +++ b/docs/work-items/ORCH-069/17-security-report.md @@ -0,0 +1,25 @@ +--- +security_status: PASS +secrets_found: 0 +deps_blocking: 0 +deps_warning: 4 +deps_audit_degraded: false +--- +# Security Report — ORCH-069 + +Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше. + +## Verdict +clean: 0 secrets, 0 blocking CVE(s) + +## Secrets +- None + +## Dependencies (blocking) +- None + +## Dependencies (warning) +- `pytest==8.3.3` — GHSA-6w46-j5rx-g56g severity=UNKNOWN fix=9.0.3 +- `starlette==0.38.6` — PYSEC-2026-161 severity=UNKNOWN fix=1.0.1 +- `starlette==0.38.6` — GHSA-f96h-pmfr-66vw severity=UNKNOWN fix=0.40.0 +- `starlette==0.38.6` — GHSA-2c2j-9gv5-cj73 severity=UNKNOWN fix=0.47.2 diff --git a/src/agents/launcher.py b/src/agents/launcher.py index b356eb1..d83f6c0 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -682,8 +682,8 @@ class AgentLauncher: "\u274c Deploy FAILED (smoke/healthcheck). Rolled back. Developer \u043d\u0443\u0436\u0435\u043d \u0434\u043b\u044f \u0444\u0438\u043a\u0441\u0430.", author="deployer", ) - from ..notifications import send_telegram - send_telegram(f"\U0001f6a8 {_wid}: Deploy failed! Rolled back. Needs fix.") + from ..notifications import send_telegram, link_for + send_telegram(f"\U0001f6a8 {link_for(_wid)}: Deploy failed! Rolled back. Needs fix.") # Notify on startup timeout (exit_code from kill = -9 or 137) if exit_code != 0 and exit_code not in (None,): @@ -695,8 +695,8 @@ class AgentLauncher: conn.close() if task_row and agent != "deployer": # deployer handled above _tid, _wid = task_row - from ..notifications import send_telegram - send_telegram(f"\u26a0\ufe0f {_wid}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log") + from ..notifications import send_telegram, link_for + send_telegram(f"\u26a0\ufe0f {link_for(_wid, _tid)}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log") # Feature 4 + ORCH-016: post the unified per-agent status comment under # that agent's bot, threading the wall-clock duration we just measured diff --git a/src/config.py b/src/config.py index b9ad1e3..48fd249 100644 --- a/src/config.py +++ b/src/config.py @@ -1,3 +1,4 @@ +from pydantic import field_validator from pydantic_settings import BaseSettings @@ -400,12 +401,45 @@ class Settings(BaseSettings): telegram_chat_id: str = "" # ORCH-042: режим live-трекера задачи. - # edit -> карточка редактируется на месте (editMessageText), ДЕФОЛТ (как было). - # bump -> при обновлении старое сообщение удаляется и карточка отправляется - # заново вниз чата (deleteMessage + sendMessage + repoint message_id), - # тихо (disable_notification). Одна карточка на задачу в обоих режимах. - # Неизвестное/пустое значение трактуется как edit (см. notifications). - tracker_mode: str = "edit" + # bump (ДЕФОЛТ с ORCH-067) -> при обновлении старое сообщение удаляется и + # карточка отправляется заново вниз чата (deleteMessage + sendMessage + # + repoint message_id), тихо (disable_notification). + # edit -> карточка редактируется на месте (editMessageText); доступен через + # ORCH_TRACKER_MODE=edit. + # Одна карточка на задачу в обоих режимах. Неизвестное/пустое значение + # трактуется как edit (см. notifications). + tracker_mode: str = "bump" + + # ORCH-067 (ADR Р-2/Р-3/Р-4): best-effort live-overlay для статус-строки + # карточки. Дорисовывает ветки Plane-статуса, неотличимые offline по + # tasks.stage (Needs Input / Blocked / Rejected / Cancelled / Deploying / + # Monitoring after Deploy) — читая ЖИВОЙ Plane-статус с коротким таймаутом и + # TTL-кэшем. Offline-ядро (stage -> статус, In Review из brd-clock) работает + # всегда без сети; overlay лишь дополняет его и НИКОГДА не блокирует конвейер. + # tracker_live_status -> kill-switch (False -> только offline-ядро). + # tracker_live_status_ttl_s -> TTL per-issue кэша live-uuid (защита hot-path). + # tracker_live_status_timeout_s -> таймаут одного live-GET в пути рендера. + tracker_live_status: bool = True + tracker_live_status_ttl_s: int = 60 + tracker_live_status_timeout_s: int = 3 + + # ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char + # cap was a hygiene limit, not structural (slug is cut to [:30] independently, + # DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default + # 200 (was hardcoded 80). Invalid/empty value -> default (graceful, no crash). + qg0_title_max: int = 200 + + @field_validator("qg0_title_max", mode="before") + @classmethod + def _qg0_title_max_default(cls, v): + # Graceful (ORCH-069 AC-3): empty / non-numeric env -> default 200, the + # process must not crash on startup. Never raises (self-hosting safety). + try: + if v is None or (isinstance(v, str) and v.strip() == ""): + return 200 + return int(v) + except (TypeError, ValueError): + return 200 class Config: env_prefix = "ORCH_" diff --git a/src/notifications.py b/src/notifications.py index 18d01a4..a688fd1 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -307,7 +307,7 @@ def render_task_tracker(task_id: int) -> str: conn = get_db() task = conn.execute( "SELECT id, work_item_id, title, stage, created_at, updated_at, " - "brd_review_started_at, brd_review_ended_at " + "brd_review_started_at, brd_review_ended_at, repo, plane_issue_id " "FROM tasks WHERE id=?", (task_id,), ).fetchone() @@ -358,13 +358,27 @@ def render_task_tracker(task_id: int) -> str: agent_seconds += d esc_title = html.escape(title) + # ORCH-067 (req 3): the issue number in the header is now a clickable link to + # the Plane issue (degrades to the escaped number when no web URL \u2014 fail-safe). + task_repo = _row_get(task, "repo") + task_issue_id = _row_get(task, "plane_issue_id") + num_html = plane_issue_link(work_item_id, plane_issue_id=task_issue_id, repo=task_repo) header = ( - f"\U0001f389 {html.escape(work_item_id)} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e" + f"\U0001f389 {num_html} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e" if done - else f"\U0001f6e0\ufe0f {html.escape(work_item_id)} \u00b7 {esc_title}" + else f"\U0001f6e0\ufe0f {num_html} \u00b7 {esc_title}" ) bar = "\u2501" * 22 - lines = [header, bar] + # ORCH-067 (req 2): a Plane-status line (model ORCH-066) under the header. + # Built fail-safe: any error degrades to a stage default, never breaks render. + try: + status_label = _card_status_label( + task, repo=task_repo, plane_issue_id=task_issue_id + ) + except Exception: + status_label = _DEFAULT_STATUS_LABEL + status_line = f"\U0001f4cd {status_label}" + lines = [header, status_line, bar] def _stage_line(label, run): usage = { @@ -704,38 +718,276 @@ def _build_brd_link(repo, branch, work_item_id) -> str | None: ) +def _plane_issue_url(repo, plane_issue_id, project_id=None) -> str | None: + """ORCH-067 (Р-5): build the Plane issue browser URL, or None if unbuildable. + + Single source of the URL + guards, shared by ``plane_issue_link`` (link text = + issue number) and ``_build_plane_issue_link`` (link text = '✅ Задача в Plane'), + so the project resolution and loopback-guard live in ONE place (ORCH-017 Р-2). + + Full path: ``{web_base}/{workspace}/projects/{project_id}/issues/{issue_id}/``. + web_base = plane_web_url or plane_api_url; a loopback base counts as "no web + URL" -> None. ``project_id`` is taken explicitly when given, else resolved from + ``repo``. Never raises. + """ + try: + s = _get_settings() + web_base = ( + getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "") + ).rstrip("/") + workspace = getattr(s, "plane_workspace_slug", "") + if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base): + return None + if not project_id: + try: + from .projects import get_project_by_repo + project = get_project_by_repo(repo) if repo else None + except Exception: + project = None + project_id = getattr(project, "plane_project_id", "") if project else "" + if not project_id: + return None + return ( + f"{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/" + ) + except Exception: + return None + + def _build_plane_issue_link(repo, plane_issue_id) -> str | None: """ORCH-017: '<a>' to the Plane issue browser page, or None if unusable. - Full path per ADR-001 Р-2: - ``{web_base}/{workspace_slug}/projects/{project_id}/issues/{issue_id}/``. - web_base = plane_web_url or plane_api_url (AC-3); a loopback base is treated - as "no web URL" and the link is omitted (loopback-guard, AC-2/AC-6). + Link text = '✅ Задача в Plane'. URL built by the shared ``_plane_issue_url`` + (loopback / workspace / project guards, ADR-001 Р-2 / ORCH-067 Р-5). """ - s = _get_settings() - web_base = ( - getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "") - ).rstrip("/") - workspace = getattr(s, "plane_workspace_slug", "") - if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base): + url = _plane_issue_url(repo, plane_issue_id) + if not url: return None - try: - from .projects import get_project_by_repo - project = get_project_by_repo(repo) if repo else None - except Exception: - project = None - if not project or not getattr(project, "plane_project_id", ""): - return None - url = ( - f"{web_base}/{workspace}/projects/{project.plane_project_id}" - f"/issues/{plane_issue_id}/" - ) return ( f'<a href="{html.escape(url, quote=True)}">' f"✅ Задача в Plane</a>" ) +def plane_issue_link(work_item_id, plane_issue_id=None, project_id=None, repo=None) -> str: + """ORCH-067 (Р-5): clickable issue number for cards / alerts. + + Returns ``<a href=...>ORCH-NNN</a>`` when a Plane web URL can be built, else + ``html.escape(work_item_id)`` (number without a link). Never raises. + + Link text is always ``html.escape(work_item_id)``; the href is built by the + shared ``_plane_issue_url`` (same loopback / workspace / project guards as the + '✅ Задача в Plane' link). On any missing piece -> the escaped number. + """ + label = html.escape(str(work_item_id)) if work_item_id is not None else "" + try: + url = _plane_issue_url(repo, plane_issue_id, project_id) + if not url: + return label + return f'<a href="{html.escape(url, quote=True)}">{label}</a>' + except Exception: + return label + + +def link_for(work_item_id, task_id=None) -> str: + """ORCH-067 (Р-6): clickable issue number for alert points that hold only a + ``work_item_id`` (or ``task_id``). + + Resolves ``(repo, plane_issue_id)`` from the DB (by ``task_id`` when given, + else the latest task row for ``work_item_id``) and delegates to + ``plane_issue_link``. On any missing data -> ``html.escape(work_item_id)``. + Never raises. + """ + if not work_item_id: + return html.escape(str(work_item_id)) if work_item_id is not None else "" + repo = None + plane_issue_id = None + try: + from .db import get_db + conn = get_db() + if task_id is not None: + row = conn.execute( + "SELECT repo, plane_issue_id FROM tasks WHERE id=?", (task_id,) + ).fetchone() + else: + row = conn.execute( + "SELECT repo, plane_issue_id FROM tasks WHERE work_item_id=? " + "ORDER BY id DESC LIMIT 1", + (work_item_id,), + ).fetchone() + conn.close() + if row: + repo = row["repo"] + plane_issue_id = row["plane_issue_id"] + except Exception as e: + logger.debug(f"link_for({work_item_id}) DB lookup failed: {e}") + return plane_issue_link(work_item_id, plane_issue_id=plane_issue_id, repo=repo) + + +# --------------------------------------------------------------------------- # +# ORCH-067: Plane status label for the live card (layer B indication, ADR Р-1) +# --------------------------------------------------------------------------- # + +# Offline stage -> Plane status label. Names are the final ORCH-066 status names +# (_PLANE_NAME_TO_KEY). Pure / deterministic — derived entirely from tasks.stage +# (+ the brd-clock for In Review), NEVER from the network. +_STAGE_STATUS_LABEL = { + "created": "To Analyse", + "analysis": "Analysis", + "architecture": "Architecture", + "development": "Development", + "review": "Code-Review", + "testing": "Testing", + "deploy": "⏸️ Awaiting Deploy — ожидание Confirm Deploy", + "done": "Done", +} +_DEFAULT_STATUS_LABEL = "To Analyse" +_IN_REVIEW_LABEL = ( + "⏸️ In Review — ожидание " + "согласования BRD" +) + +# Live-overlay branch labels (keys not derivable offline from tasks.stage). +_LIVE_BRANCH_LABELS = { + "needs_input": "❓ Needs Input — нужны уточнения", + "blocked": "Blocked", + "rejected": "Rejected", + "cancelled": "Cancelled", + "deploying": "Deploying", + "monitoring": "Monitoring after Deploy", +} +# ORCH-066 (Р-1 anti-false-positive): deploying/monitoring alias their BASE key's +# UUID on a project without dedicated statuses (enduro). Override is applied ONLY +# when the project really defined a SEPARATE UUID for the branch key. +_LIVE_BRANCH_BASE = { + "deploying": "in_progress", + "monitoring": "done", +} + + +def _row_get(row, key, default=None): + """Safe sqlite3.Row / dict / object getter. Never raises.""" + try: + return row[key] + except Exception: + try: + return getattr(row, key, default) + except Exception: + return default + + +def plane_status_label(task_row) -> str: + """ORCH-067 (Р-1, layer 1): current Plane status label for the card header. + + Pure / deterministic from the task row, NEVER hits the network, NEVER raises. + On unknown / broken input -> a safe stage default. ``⏸️ In Review`` and + ``⏸️ Awaiting Deploy`` are produced here (offline), so both work without a + network connection (AC-7, AC-8). Branch statuses that are indistinguishable + offline (Needs Input / Blocked / …) are drawn by ``_live_plane_branch_override``. + """ + try: + stage = _row_get(task_row, "stage") or "created" + except Exception: + return _DEFAULT_STATUS_LABEL + try: + if stage == "analysis": + started = _row_get(task_row, "brd_review_started_at") + ended = _row_get(task_row, "brd_review_ended_at") + if started and not ended: + return _IN_REVIEW_LABEL + return _STAGE_STATUS_LABEL.get(stage, _DEFAULT_STATUS_LABEL) + except Exception: + return _DEFAULT_STATUS_LABEL + + +# ORCH-067 (Р-3): per-issue TTL cache of the live state uuid -> {issue_id: (ts, uuid)}. +_LIVE_STATE_CACHE: dict[str, tuple] = {} + + +def _live_state_uuid_cached(plane_issue_id, project_id): + """ORCH-067 (Р-3/Р-4): TTL-cached single live-state read for the render path. + + At most one ``fetch_issue_state`` per issue per ``tracker_live_status_ttl_s`` + with a SHORT timeout. Never raises -> None on any failure. + """ + try: + import time + s = _get_settings() + ttl = getattr(s, "tracker_live_status_ttl_s", 60) + now = time.monotonic() + hit = _LIVE_STATE_CACHE.get(plane_issue_id) + if hit is not None and (now - hit[0]) <= ttl: + return hit[1] + from .plane_sync import fetch_issue_state + timeout = getattr(s, "tracker_live_status_timeout_s", 3) + uuid = fetch_issue_state(plane_issue_id, project_id, timeout=timeout) + _LIVE_STATE_CACHE[plane_issue_id] = (now, uuid) + return uuid + except Exception as e: + logger.debug(f"_live_state_uuid_cached({plane_issue_id}) failed: {e}") + return None + + +def _live_plane_branch_override(repo, plane_issue_id, base_label) -> str: + """ORCH-067 (Р-1 layer 2 / Р-2): best-effort live-status overlay. + + Draws the branch statuses that are indistinguishable from ``tasks.stage`` + offline (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring + after Deploy) by reading the LIVE Plane status (short timeout, TTL cache). Any + failure / disabled kill-switch / missing data -> ``base_label`` (offline). The + pipeline is NEVER blocked. Never raises. + """ + try: + s = _get_settings() + if not getattr(s, "tracker_live_status", True): + return base_label + if not plane_issue_id: + return base_label + try: + from .projects import get_project_by_repo + project = get_project_by_repo(repo) if repo else None + except Exception: + project = None + project_id = getattr(project, "plane_project_id", "") if project else "" + if not project_id: + return base_label + live_uuid = _live_state_uuid_cached(plane_issue_id, project_id) + if not live_uuid: + return base_label + from .plane_sync import get_project_states + states = get_project_states(project_id) + for key, label in _LIVE_BRANCH_LABELS.items(): + uuid = states.get(key) + if not uuid or uuid != live_uuid: + continue + base_key = _LIVE_BRANCH_BASE.get(key) + if base_key and states.get(base_key) == uuid: + # deploying/monitoring just alias their base key on this project + # (enduro / no dedicated status) -> not a real branch, don't override. + continue + return label + return base_label + except Exception as e: + logger.debug(f"_live_plane_branch_override failed: {e}") + return base_label + + +def _card_status_label(task_row, repo=None, plane_issue_id=None) -> str: + """ORCH-067: full status label for the card = offline core + live overlay. + + Precedence (Р-1): if the offline core resolved ``⏸️ In Review`` (brd-clock, + authoritative) the overlay is NOT consulted; otherwise the overlay may draw a + branch status. Never raises (AC-9). + """ + try: + base = plane_status_label(task_row) + if base == _IN_REVIEW_LABEL: + return base + return _live_plane_branch_override(repo, plane_issue_id, base) + except Exception: + return _DEFAULT_STATUS_LABEL + + def notify_approve_requested(task_id: int): """ALERT (separate, notifying): BRD/TZ/AC ready -> flip Plane to Approved. @@ -749,7 +1001,7 @@ def notify_approve_requested(task_id: int): except Exception as e: logger.warning(f"notify_approve_requested: brd clock start failed: {e}") msg = ( - f"\U0001f4cb {html.escape(work_item_id)}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. " + f"\U0001f4cb {link_for(work_item_id, task_id)}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. " f"\u041f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 \u0432 \u0441\u0442\u0430\u0442\u0443\u0441 Approved " f"\u0432 Plane \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f." ) @@ -783,8 +1035,14 @@ def notify_done(task_id: int): def notify_error(task_id: int, error: str): - """ALERT (separate, notifying): task error.""" + """ALERT (separate, notifying): task error. + + ORCH-067 (req 4): the issue number is a clickable Plane link (fail-safe -> + raw number) and the error text is html-escaped so it cannot break the <a> + markup under parse_mode=HTML (AC-14). + """ work_item_id = _get_work_item_id(task_id) if task_id else "system" - msg = f"\U0001f534 {work_item_id}: ERROR \u2014 {error}" + num = link_for(work_item_id, task_id) if task_id else html.escape(work_item_id) + msg = f"\U0001f534 {num}: ERROR \u2014 {html.escape(str(error))}" logger.error(msg) send_telegram(msg) # separate, notifying diff --git a/src/plane_sync.py b/src/plane_sync.py index 399a9c7..ca2ad62 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -402,7 +402,7 @@ def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None: return None -def fetch_issue_state(issue_id: str, project_id: str) -> str | None: +def fetch_issue_state(issue_id: str, project_id: str, timeout: int = 10) -> str | None: """ORCH-060 (F-1 Guard 2): GET the Plane issue and return its current state uuid. Used by the reconciler to honour an explicit human gate: an issue a person @@ -413,12 +413,16 @@ def fetch_issue_state(issue_id: str, project_id: str) -> str | None: Plane returns ``state`` as a bare uuid string; older shapes may nest it as a ``{"id": ...}`` dict — both are handled. + ORCH-067 (Р-4): ``timeout`` is optional (default 10s — unchanged for the + reconciler) so the tracker live-overlay can read with a SHORT timeout + (settings.tracker_live_status_timeout_s) on the synchronous render path. + Returns None on network error, non-2xx, or a missing field — never raises, so the caller can apply its conservative fallback (treat as "possibly blocked"). """ url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/" try: - resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10) + resp = httpx.get(url, headers=PLANE_HEADERS, timeout=timeout) resp.raise_for_status() state = resp.json().get("state") if isinstance(state, dict): diff --git a/src/reconciler.py b/src/reconciler.py index d25b4a3..5ae330e 100644 --- a/src/reconciler.py +++ b/src/reconciler.py @@ -67,7 +67,7 @@ from .plane_sync import ( list_issues_by_state, ) from .webhooks.plane import handle_status_start, handle_verdict -from .notifications import send_telegram +from .notifications import send_telegram, link_for from . import projects logger = logging.getLogger("orchestrator.reconciler") @@ -447,7 +447,7 @@ class Reconciler: if settings.reconcile_notify_unblock: try: send_telegram( - f"\U0001f527 reconciler: {work_item_id} {stage} " + f"\U0001f527 reconciler: {link_for(work_item_id)} {stage} " f"разблокирована (потерян webhook)" ) except Exception as e: # noqa: BLE001 - never break the tick diff --git a/src/security_gate.py b/src/security_gate.py index 05a33dc..2ac698f 100644 --- a/src/security_gate.py +++ b/src/security_gate.py @@ -670,9 +670,9 @@ def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool dep_result.detail, ) try: - from .notifications import send_telegram + from .notifications import send_telegram, link_for send_telegram( - f"⚠️ {work_item_id}: dep-audit недоступен фид CVE " + f"⚠️ {link_for(work_item_id)}: dep-audit недоступен фид CVE " f"({dep_result.detail}). " + ("Гейт fail-closed → FAIL." if settings.security_dep_audit_fail_closed else "Гейт fail-open → warning (секреты проверены оффлайн).") diff --git a/src/stage_engine.py b/src/stage_engine.py index 94e207b..c7bf165 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -44,6 +44,7 @@ from .notifications import ( notify_qg_failure, notify_approve_requested, send_telegram, + link_for, ) from .plane_sync import ( notify_stage_change as plane_notify_stage, @@ -611,7 +612,7 @@ def _handle_analysis_approved_flow( author="analyst", ) send_telegram( - f"\u2753 {work_item_id}: Analyst \u0437\u0430\u0434\u0430\u0451\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u044b. \u041e\u0442\u0432\u0435\u0442\u044c \u0432 Plane." + f"\u2753 {link_for(work_item_id)}: Analyst \u0437\u0430\u0434\u0430\u0451\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u044b. \u041e\u0442\u0432\u0435\u0442\u044c \u0432 Plane." ) result.note = "analysis-needs-input" return @@ -670,7 +671,7 @@ def _handle_qg_failure_rollbacks( ) else: send_telegram( - f"\u26a0\ufe0f {work_item_id}: Max developer retries (3) reached. " + f"\u26a0\ufe0f {link_for(work_item_id)}: Max developer retries (3) reached. " f"Manual intervention needed." ) result.alerted = True @@ -717,7 +718,7 @@ def _handle_qg_failure_rollbacks( else: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: Tests still failing after 3 developer " + f"\U0001f6a8 {link_for(work_item_id)}: Tests still failing after 3 developer " f"retries. Manual intervention needed." ) result.alerted = True @@ -774,7 +775,7 @@ def _handle_qg_failure_rollbacks( author="deployer", ) send_telegram( - f"\U0001f6a8 {work_item_id}: Staging FAILED ({reason}). " + f"\U0001f6a8 {link_for(work_item_id)}: Staging FAILED ({reason}). " f"Rolled back to development. Needs fix." ) result.alerted = True @@ -818,7 +819,7 @@ def _handle_qg_failure_rollbacks( author="deployer", ) send_telegram( - f"\U0001f6a8 {work_item_id}: Deploy FAILED ({reason}). " + f"\U0001f6a8 {link_for(work_item_id)}: Deploy FAILED ({reason}). " f"Rolled back to development. Needs fix." ) result.alerted = True @@ -914,7 +915,7 @@ def _handle_merge_gate_defer( else: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: merge-gate defer limit " + f"\U0001f6a8 {link_for(work_item_id)}: merge-gate defer limit " f"({settings.merge_defer_max_attempts}) reached (merge-lock busy). " f"Manual intervention needed." ) @@ -969,7 +970,7 @@ def _handle_merge_gate_rollback( else: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: Merge-gate still failing after " + f"\U0001f6a8 {link_for(work_item_id)}: Merge-gate still failing after " f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). " f"Manual intervention needed." ) @@ -1055,7 +1056,7 @@ def _handle_security_gate( else: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: Security-гейт still failing after " + f"\U0001f6a8 {link_for(work_item_id)}: Security-гейт still failing after " f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). " f"Manual intervention needed." ) @@ -1132,7 +1133,7 @@ def _handle_image_freshness( else: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: Staging image freshness still failing after " + f"\U0001f6a8 {link_for(work_item_id)}: Staging image freshness still failing after " f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). " f"Manual intervention needed." ) @@ -1190,7 +1191,7 @@ def _handle_self_deploy_phase_a( author="deployer", ) send_telegram( - f"\U0001f7e1 {work_item_id}: staging OK. Ждёт подтверждения ПРОД-деплоя " + f"\U0001f7e1 {link_for(work_item_id)}: staging OK. Ждёт подтверждения ПРОД-деплоя " f"(смените статус на «Confirm Deploy»)." ) logger.info( @@ -1225,7 +1226,7 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv "Повторите approve после устранения причины.", author="deployer", ) - send_telegram(f"⚠️ {work_item_id}: прод-деплой не запустился: {msg}") + send_telegram(f"⚠️ {link_for(work_item_id)}: прод-деплой не запустился: {msg}") logger.error(f"Task {task_id}: self-deploy initiate failed: {msg}") return @@ -1254,7 +1255,7 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv "Вердикт будет зафиксирован после health-check.", author="deployer", ) - send_telegram(f"\U0001f680 {work_item_id}: прод-деплой стартовал. Жду результат.") + send_telegram(f"\U0001f680 {link_for(work_item_id)}: прод-деплой стартовал. Жду результат.") logger.info( f"Task {task_id}: self-deploy Phase B — detached deploy initiated, " f"finalizer enqueued (job_id={new_job})" @@ -1365,7 +1366,7 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes try: merge_gate.note_not_merged_alert(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: ошибка merge-verify ({e}). " + f"\U0001f6a8 {link_for(work_item_id)}: ошибка merge-verify ({e}). " f"Задача удержана на `deploy` (НЕ done)." ) except Exception: # noqa: BLE001 - best-effort alert @@ -1423,7 +1424,7 @@ def run_deploy_finalizer(job: dict): if work_item_id: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: deploy result не появился после " + f"\U0001f6a8 {link_for(work_item_id)}: deploy result не появился после " f"{settings.deploy_finalize_max_attempts} попыток. Нужно ручное вмешательство." ) logger.error( @@ -1444,7 +1445,7 @@ def run_deploy_finalizer(job: dict): f"✅ Прод-деплой успешен (health-check OK, exit {code}).", author="deployer", ) - send_telegram(f"✅ {work_item_id}: прод-деплой успешен (exit {code}).") + send_telegram(f"✅ {link_for(work_item_id)}: прод-деплой успешен (exit {code}).") # Drive the EXISTING deploy contracts via the gate verdict we just wrote. advance_stage( diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index 875f54a..4bdaf0c 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -416,8 +416,11 @@ def _qg0_errors(name: str, description: str) -> list: errors = [] if not name or len(name) < 5: errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 5 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") - if len(name) > 80: - errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 (\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c 80 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") + if len(name) > settings.qg0_title_max: + errors.append( + f"Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 " + f"(\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c {settings.qg0_title_max} \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)" + ) if not description or len(description.strip()) < 20: errors.append("Description \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 20 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") diff --git a/tests/test_config.py b/tests/test_config.py index 092395b..ea4d0cf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,9 +8,17 @@ builds a FRESH Settings() (the process-wide singleton is not mutated). from src.config import Settings -def test_tracker_mode_defaults_to_edit(monkeypatch): - # No env var -> default "edit" (TC-01 / AC-1). +def test_tracker_mode_defaults_to_bump(monkeypatch): + # ORCH-067 (TC-01 / AC-1): the default flipped edit -> bump. With no env var + # the card now re-creates at the bottom of the chat out of the box; edit + # stays available via ORCH_TRACKER_MODE=edit (see test below). monkeypatch.delenv("ORCH_TRACKER_MODE", raising=False) + assert Settings().tracker_mode == "bump" + + +def test_tracker_mode_reads_env_edit(monkeypatch): + # ORCH-067 (AC-4): edit mode is still available through the env var. + monkeypatch.setenv("ORCH_TRACKER_MODE", "edit") assert Settings().tracker_mode == "edit" diff --git a/tests/test_notify_issue_links.py b/tests/test_notify_issue_links.py new file mode 100644 index 0000000..8cdde58 --- /dev/null +++ b/tests/test_notify_issue_links.py @@ -0,0 +1,206 @@ +"""ORCH-067 — Group D: clickable issue number in ALL alerts (AC-13, AC-12). + +Every orchestrator alert that mentions a work_item_id now renders it as a Plane +hyperlink via the shared ``link_for`` / ``plane_issue_link`` helpers, and degrades +fail-safe to the raw (escaped) number when data is missing. This covers the +dedicated notify_* helpers (notify_approve_requested, notify_error) and asserts +the engine/launcher/security_gate/reconciler alert sites are wired to ``link_for`` +— the single DB-resolving helper those sites call. Network is isolated: +send_telegram is replaced with a recorder; the DB is a temp SQLite. + +Test ids TC-13, TC-14, TC-15 from 04-test-plan.yaml. +""" + +import os +import tempfile + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_notify_links.db") +os.environ["ORCH_DB_PATH"] = _test_db + +from types import SimpleNamespace # noqa: E402 + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +import src.projects as projects_mod # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import notifications as N # noqa: E402 + +_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" + + +@pytest.fixture(autouse=True) +def setup_db(monkeypatch): + monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + # Pin repo->project resolution so cross-file registry reloads can't strip + # 'orchestrator' and break the expected issue URL. + monkeypatch.setattr( + projects_mod, "get_project_by_repo", + lambda repo: (SimpleNamespace(plane_project_id=_ORCH_PROJECT_ID) + if repo == "orchestrator" else None), + ) + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _set(monkeypatch, **kw): + s = N._get_settings() + for k, v in kw.items(): + monkeypatch.setattr(s, k, v, raising=False) + + +def _mk_task(wid="ORCH-067", repo="orchestrator", title="notify links", + plane_issue_id="iss-1", stage="development"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, " + "plane_issue_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ("p1", wid, repo, "feature/ORCH-067-x", stage, title, plane_issue_id), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _record_send(monkeypatch): + calls = [] + + def _fake(text, disable_notification=False): + calls.append({"text": text, "silent": disable_notification}) + return 1 + + monkeypatch.setattr(N, "send_telegram", _fake) + monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None) + return calls + + +# --------------------------------------------------------------------------- # +# TC-13 / AC-13 — notify_approve_requested: number clickable, CTA + single ping +# --------------------------------------------------------------------------- # +def test_tc13_approve_requested_number_clickable(monkeypatch): + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme", gitea_public_url="https://git.example.org", + gitea_owner="orchteam") + tid = _mk_task(plane_issue_id="iss-1") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + assert len(calls) == 1 # exactly one notifying ping + assert calls[0]["silent"] is not True + text = calls[0]["text"] + expected = ( + f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}" + f"/issues/iss-1/" + ) + assert f'<a href="{expected}">ORCH-067</a>' in text # clickable number + assert "Approved" in text # call-to-action preserved + + +# --------------------------------------------------------------------------- # +# TC-14 / AC-13, AC-12 — notify_error: clickable when data present, else raw +# --------------------------------------------------------------------------- # +def test_tc14_notify_error_clickable(monkeypatch): + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + tid = _mk_task(plane_issue_id="iss-1") + calls = _record_send(monkeypatch) + + N.notify_error(tid, "boom happened") + + assert len(calls) == 1 + text = calls[0]["text"] + assert ">ORCH-067</a>" in text # number is a link + assert "ERROR" in text and "boom happened" in text + + +def test_tc14_notify_error_degrades_raw_number(monkeypatch): + # No usable Plane base -> raw (unlinked) number, alert still sent, no crash. + _set(monkeypatch, plane_web_url="", plane_api_url="") + tid = _mk_task(plane_issue_id="iss-1") + calls = _record_send(monkeypatch) + + N.notify_error(tid, "boom") + + text = calls[0]["text"] + assert "ORCH-067" in text + assert "<a href=" not in text + + +def test_tc14_notify_error_escapes_error_text(monkeypatch): + # The error string is html-escaped so it can't break the <a>/HTML markup. + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + tid = _mk_task(plane_issue_id="iss-1") + calls = _record_send(monkeypatch) + + N.notify_error(tid, "<script> & </script>") + + text = calls[0]["text"] + assert "<script>" not in text + assert "<script>" in text and "&" in text + # The clickable number's anchor is still well-formed. + assert text.count("<a href=") == text.count("</a>") + + +# --------------------------------------------------------------------------- # +# TC-15 / AC-13 — link_for is the DB-resolving helper the alert sites call +# --------------------------------------------------------------------------- # +def test_tc15_link_for_by_work_item_id(monkeypatch): + # Sites holding only a work_item_id (launcher deploy-fail, security_gate, + # reconciler, engine QG-fail) call link_for(wid) -> resolves repo + issue id + # from the DB and returns a clickable number. + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + _mk_task(wid="ORCH-067", plane_issue_id="iss-1") + + out = N.link_for("ORCH-067") + expected = ( + f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}" + f"/issues/iss-1/" + ) + assert out == f'<a href="{expected}">ORCH-067</a>' + + +def test_tc15_link_for_by_task_id(monkeypatch): + # Sites holding a task_id (launcher agent-fail, engine) call link_for(wid, tid). + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + tid = _mk_task(wid="ORCH-067", plane_issue_id="iss-7") + + out = N.link_for("ORCH-067", tid) + assert ">ORCH-067</a>" in out and "/issues/iss-7/" in out + + +def test_tc15_link_for_unknown_task_degrades(monkeypatch): + # No matching DB row -> raw number, never raises. + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + out = N.link_for("ORCH-999") + assert out == "ORCH-999" + assert "<a href=" not in out + + +@pytest.mark.parametrize("module_name", [ + "src.stage_engine", + "src.agents.launcher", + "src.security_gate", + "src.reconciler", +]) +def test_tc15_alert_modules_wire_link_for(module_name): + """The representative alert modules call the shared link_for helper, so their + work_item_id alerts render a clickable number (not a bare string). Checked at + source level since some sites import link_for function-locally.""" + import importlib + import inspect + mod = importlib.import_module(module_name) + src = inspect.getsource(mod) + assert "link_for(" in src, f"{module_name} must use link_for in its alerts" diff --git a/tests/test_plane_issue_link.py b/tests/test_plane_issue_link.py new file mode 100644 index 0000000..f67d87d --- /dev/null +++ b/tests/test_plane_issue_link.py @@ -0,0 +1,101 @@ +"""ORCH-067 — Group D: the shared plane_issue_link helper (AC-12). + +``plane_issue_link(work_item_id, plane_issue_id=None, project_id=None, repo=None)`` +is the single source of the clickable issue number for cards AND alerts. It +returns ``<a href=...>ORCH-NNN</a>`` when a usable Plane browser URL can be built, +and ``html.escape(work_item_id)`` otherwise. It must NEVER raise — including on +None arguments and a loopback base. No DB and no network are touched by this unit +(project_id is passed explicitly here), so these are pure settings-driven cases. + +Test id TC-12 from 04-test-plan.yaml. +""" + +import os + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +import pytest # noqa: E402 + +from src import notifications as N # noqa: E402 + + +def _set(monkeypatch, **kw): + s = N._get_settings() + for k, v in kw.items(): + monkeypatch.setattr(s, k, v, raising=False) + + +# --------------------------------------------------------------------------- # +# TC-12 / AC-12 — full data -> HTML link wrapping the number +# --------------------------------------------------------------------------- # +def test_tc12_full_data_returns_anchor(monkeypatch): + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + out = N.plane_issue_link("ORCH-067", plane_issue_id="iss-1", + project_id="proj-9") + expected = "https://plane.example.org/acme/projects/proj-9/issues/iss-1/" + assert out == f'<a href="{expected}">ORCH-067</a>' + + +def test_tc12_web_url_fallbacks_to_api_url(monkeypatch): + # plane_web_url empty -> non-loopback plane_api_url is used as the base. + _set(monkeypatch, plane_web_url="", + plane_api_url="https://plane-fallback.example.org", + plane_workspace_slug="acme") + out = N.plane_issue_link("ORCH-067", plane_issue_id="iss-1", + project_id="proj-9") + assert 'href="https://plane-fallback.example.org/acme/' in out + assert ">ORCH-067</a>" in out + + +# --------------------------------------------------------------------------- # +# TC-12 / AC-12 — insufficient data -> escaped number, NEVER an anchor +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("settings_kw,call_kw,reason", [ + ({"plane_web_url": "", "plane_api_url": ""}, + {"plane_issue_id": "iss-1", "project_id": "proj-9"}, "no web base"), + ({"plane_web_url": "http://localhost:8091", "plane_api_url": ""}, + {"plane_issue_id": "iss-1", "project_id": "proj-9"}, "loopback base"), + ({"plane_web_url": "https://plane.example.org", "plane_workspace_slug": ""}, + {"plane_issue_id": "iss-1", "project_id": "proj-9"}, "no workspace"), + ({"plane_web_url": "https://plane.example.org", "plane_workspace_slug": "acme"}, + {"plane_issue_id": None, "project_id": "proj-9"}, "no issue id"), + ({"plane_web_url": "https://plane.example.org", "plane_workspace_slug": "acme"}, + {"plane_issue_id": "iss-1", "project_id": ""}, "no project id"), +]) +def test_tc12_insufficient_data_returns_plain_number(monkeypatch, settings_kw, + call_kw, reason): + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_api_url="http://localhost:8091", plane_workspace_slug="acme") + _set(monkeypatch, **settings_kw) + out = N.plane_issue_link("ORCH-067", repo=None, **call_kw) + assert out == "ORCH-067", reason + assert "<a href=" not in out + + +# --------------------------------------------------------------------------- # +# TC-12 / AC-12 — html-escaping + never raises on hostile / None input +# --------------------------------------------------------------------------- # +def test_tc12_escapes_work_item_id_in_link(monkeypatch): + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + out = N.plane_issue_link("ORCH&<67>", plane_issue_id="iss-1", + project_id="proj-9") + assert ">ORCH&<67></a>" in out # label escaped inside the anchor + assert "<a href=" in out + + +def test_tc12_escapes_work_item_id_unlinked(monkeypatch): + _set(monkeypatch, plane_web_url="", plane_api_url="") + out = N.plane_issue_link("ORCH&<67>", plane_issue_id="iss-1", + project_id="proj-9") + assert out == "ORCH&<67>" # escaped, no anchor + + +def test_tc12_none_args_never_raise(monkeypatch): + # All-None must not raise and must yield a (possibly empty) string. + out = N.plane_issue_link(None) + assert isinstance(out, str) + # None work_item_id -> empty label, no anchor. + assert "<a href=" not in out diff --git a/tests/test_qg0_title_limit.py b/tests/test_qg0_title_limit.py new file mode 100644 index 0000000..44b2962 --- /dev/null +++ b/tests/test_qg0_title_limit.py @@ -0,0 +1,117 @@ +"""ORCH-069: unit tests for the configurable QG-0 title-length limit. + +Covers `_qg0_errors` (src/webhooks/plane.py) reading the upper title limit +dynamically from `settings.qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, default 200), +plus the graceful env-degradation field-validator on `Settings`. + +The tests patch `src.config.settings.qg0_title_max` (the same object imported into +`src.webhooks.plane`) and assert boundary behaviour and error texts. For env-driven +cases a FRESH `Settings()` instance is created locally, since the module-level +singleton is built once on import. +""" + +import re + +import pytest + +from src.config import Settings, settings +from src.webhooks.plane import _qg0_errors + +VALID_DESCRIPTION = "x" * 30 # >= 20 chars, always passes the description check + + +def _title_length_error(errors): + """Return the title length-limit error string, or None if absent. + + The short-title error ('нужно >= 5') and the description error are excluded; + only the 'too long' title error is matched (it contains 'максимум'). + """ + for e in errors: + if "Title" in e and "максимум" in e: + return e + return None + + +# --- AC-1: default limit 200, boundary at 201 ------------------------------ + +def test_tc01_default_limit_200_boundary_pass(monkeypatch): + """TC-01: title of exactly 200 chars -> no title length error (PASS).""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("x" * 200, VALID_DESCRIPTION) + assert _title_length_error(errors) is None + + +def test_tc02_default_limit_200_boundary_fail(monkeypatch): + """TC-02: title of 201 chars -> length error mentioning '200'.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("x" * 201, VALID_DESCRIPTION) + err = _title_length_error(errors) + assert err is not None + assert "200" in err + + +# --- AC-2: configurable limit 120, boundary at 121 ------------------------- + +def test_tc03_custom_limit_120_boundary_pass(monkeypatch): + """TC-03: with limit 120, a 120-char title passes.""" + monkeypatch.setattr(settings, "qg0_title_max", 120) + errors = _qg0_errors("x" * 120, VALID_DESCRIPTION) + assert _title_length_error(errors) is None + + +def test_tc04_custom_limit_120_boundary_fail(monkeypatch): + """TC-04: with limit 120, a 121-char title fails; text mentions 120 not 80.""" + monkeypatch.setattr(settings, "qg0_title_max", 120) + errors = _qg0_errors("x" * 121, VALID_DESCRIPTION) + err = _title_length_error(errors) + assert err is not None + assert "120" in err + assert "80" not in err + + +# --- AC-3: graceful handling of invalid/empty env -------------------------- + +def test_tc05_graceful_non_numeric_env(monkeypatch): + """TC-05: non-numeric env -> Settings() does not raise, limit == 200.""" + monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "abc") + s = Settings() + assert s.qg0_title_max == 200 + + +def test_tc06_graceful_empty_env(monkeypatch): + """TC-06: empty-string env -> default 200, no exception.""" + monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "") + s = Settings() + assert s.qg0_title_max == 200 + + +def test_tc07_valid_numeric_env(monkeypatch): + """TC-07: valid numeric env -> the given value is applied (positive path).""" + monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "150") + s = Settings() + assert s.qg0_title_max == 150 + + +# --- AC-4: lower limits unchanged ------------------------------------------ + +def test_tc08_short_title_still_errors(monkeypatch): + """TC-08: title < 5 chars still raises the short-title error.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("abc", VALID_DESCRIPTION) + assert any("Title" in e and "нужно >= 5" in e for e in errors) + + +def test_tc09_short_description_still_errors(monkeypatch): + """TC-09: description < 20 chars still raises the short-description error.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("Valid title", "short") + assert any("Description" in e for e in errors) + + +# --- AC-7: backward compatibility ------------------------------------------ + +def test_tc10_backward_compat_titles_81_to_200(monkeypatch): + """TC-10: a title previously rejected by the 80-char cap now passes at 200.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("x" * 100, VALID_DESCRIPTION) + assert _title_length_error(errors) is None diff --git a/tests/test_telegram_tracker.py b/tests/test_telegram_tracker.py index 44b9fd6..7c5adea 100644 --- a/tests/test_telegram_tracker.py +++ b/tests/test_telegram_tracker.py @@ -241,6 +241,9 @@ def test_first_call_sends_message_and_stores_id(monkeypatch): def test_second_call_edits_existing_message(monkeypatch): + # ORCH-067: the default flipped to bump; this case asserts the edit-mode + # contract, so pin edit mode explicitly. + monkeypatch.setattr(N._get_settings(), "tracker_mode", "edit", raising=False) tid = _mk_task(stage="development") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1) @@ -602,9 +605,15 @@ def test_render_stage_labels_are_russian(): for ru in ("Анализ", "Архитектура", "Разработка", "Код ревью", "Тестирование", "Внедрение"): assert ru in text, f"missing russian label {ru!r}" + # ORCH-067: the new '📍 <Plane-status>' line intentionally carries the ENGLISH + # ORCH-066 Plane status name (e.g. 'Awaiting Deploy'); the russian-only rule + # (BR-11) applies to the STAGE label lines, so exclude the status line here. + stage_lines = "\n".join( + ln for ln in text.splitlines() if not ln.startswith("\U0001f4cd") + ) for en in ("Analysis", "Architecture", "Development", "Review", "Testing", "Deploy"): - assert en not in text, f"english label leaked: {en!r}" + assert en not in stage_lines, f"english label leaked: {en!r}" def test_render_done_says_vnedreno_not_deployed(): diff --git a/tests/test_tracker_bump_default.py b/tests/test_tracker_bump_default.py new file mode 100644 index 0000000..ff5026e --- /dev/null +++ b/tests/test_tracker_bump_default.py @@ -0,0 +1,159 @@ +"""ORCH-067 — Group A: bump is the DEFAULT tracker mode (AC-1..AC-4, AC-15). + +The default flipped edit -> bump: out of the box the live card is re-created at +the BOTTOM of the chat (delete old + send new silent + repoint id), one card per +task. edit stays available via ORCH_TRACKER_MODE=edit. Network is isolated: the +low-level send/edit/delete helpers are patched per case; the DB is a temp SQLite. + +Test ids TC-01..TC-04 + TC-17 from 04-test-plan.yaml. +""" + +import os +import tempfile + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_bump_default.db") +os.environ["ORCH_DB_PATH"] = _test_db + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +from src.config import Settings # noqa: E402 +from src.db import ( # noqa: E402 + init_db, get_db, get_tracker_message_id, set_tracker_message_id, +) +from src import notifications as N # noqa: E402 + + +@pytest.fixture(autouse=True) +def setup_db(monkeypatch): + monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _mk_task(stage="development", wid="ORCH-067"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) " + "VALUES (?, ?, ?, ?, ?, ?)", + ("p1", wid, "orchestrator", "feature/ORCH-067-x", stage, "bump default"), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +# --------------------------------------------------------------------------- # +# TC-01 / AC-1 — default tracker_mode == "bump" +# --------------------------------------------------------------------------- # +def test_tc01_default_tracker_mode_is_bump(monkeypatch): + monkeypatch.delenv("ORCH_TRACKER_MODE", raising=False) + assert Settings().tracker_mode == "bump" + + +# --------------------------------------------------------------------------- # +# TC-02 / AC-2, AC-15 — repeat update: delete(old) -> send(silent) -> repoint +# --------------------------------------------------------------------------- # +def test_tc02_repeat_delete_send_silent_repoint(monkeypatch): + # No env -> resolves to the new bump default (no explicit mode pin). + monkeypatch.setattr(N._get_settings(), "tracker_mode", "bump", raising=False) + tid = _mk_task() + set_tracker_message_id(tid, 100) + + order = [] + monkeypatch.setattr(N, "delete_telegram", + lambda mid: order.append(("delete", mid)) or True) + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: + order.append(("send", disable_notification)) or 200) + + N.update_task_tracker(tid) + + # delete(old) strictly before send; the new card is SILENT (disable=True). + assert order == [("delete", 100), ("send", True)] + assert get_tracker_message_id(tid) == 200 # one card -> repointed + + +# --------------------------------------------------------------------------- # +# TC-03 / AC-3 — transient send None must NOT wipe the pointer / duplicate +# --------------------------------------------------------------------------- # +def test_tc03_send_none_keeps_pointer_no_dupe(monkeypatch): + monkeypatch.setattr(N._get_settings(), "tracker_mode", "bump", raising=False) + tid = _mk_task() + set_tracker_message_id(tid, 100) + + sends = [] + monkeypatch.setattr(N, "delete_telegram", lambda mid: True) + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: + sends.append(1) or None) + + N.update_task_tracker(tid) # must not raise + + assert len(sends) == 1 # exactly one (failed) attempt, no retry + assert get_tracker_message_id(tid) == 100 # pointer preserved, not None + + +# --------------------------------------------------------------------------- # +# TC-04 / AC-4 — edit mode still reachable via env -> editMessageText path +# --------------------------------------------------------------------------- # +def test_tc04_edit_mode_still_available(monkeypatch): + monkeypatch.setattr(N._get_settings(), "tracker_mode", "edit", raising=False) + tid = _mk_task() + set_tracker_message_id(tid, 777) + + edited = {} + monkeypatch.setattr(N, "edit_telegram", + lambda mid, text: edited.update(mid=mid) or N.EDIT_OK) + monkeypatch.setattr( + N, "send_telegram", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("edit mode must not send when edit succeeds")), + ) + + N.update_task_tracker(tid) + assert edited["mid"] == 777 # edited in place, no new card + + +def test_tc04b_edit_mode_resolution_case_insensitive(monkeypatch): + """Anything other than 'bump' resolves to edit (e.g. 'EDIT').""" + monkeypatch.setattr(N._get_settings(), "tracker_mode", "EDIT", raising=False) + tid = _mk_task() + set_tracker_message_id(tid, 5) + edited = {} + monkeypatch.setattr(N, "edit_telegram", + lambda mid, text: edited.update(mid=mid) or N.EDIT_OK) + monkeypatch.setattr(N, "send_telegram", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("should edit, not send"))) + N.update_task_tracker(tid) + assert edited["mid"] == 5 + + +# --------------------------------------------------------------------------- # +# TC-17 / AC-15 — first bump call: NO delete, silent send, id stored +# --------------------------------------------------------------------------- # +def test_tc17_first_call_silent_no_delete(monkeypatch): + monkeypatch.setattr(N._get_settings(), "tracker_mode", "bump", raising=False) + tid = _mk_task(stage="analysis") + + sends = [] + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: + sends.append(disable_notification) or 555) + monkeypatch.setattr(N, "delete_telegram", + lambda mid: (_ for _ in ()).throw( + AssertionError("delete must not run on first call"))) + + N.update_task_tracker(tid) + + assert sends == [True] # exactly one SILENT send + assert get_tracker_message_id(tid) == 555 # id stored diff --git a/tests/test_tracker_issue_link.py b/tests/test_tracker_issue_link.py new file mode 100644 index 0000000..2739eb7 --- /dev/null +++ b/tests/test_tracker_issue_link.py @@ -0,0 +1,158 @@ +"""ORCH-067 — Group C: clickable issue number in the live card (AC-10/AC-11/AC-14). + +The issue number in the card header is now a Plane hyperlink +(``<a href=".../issues/<id>/">ORCH-NNN</a>``) when a usable browser URL can be +built, and degrades fail-safe to the html-escaped raw number when any piece is +missing (web base / non-loopback / workspace / project_id / plane_issue_id). The +card must NEVER break under parse_mode=HTML: a title with '<'/'&'/'>' stays +escaped while the <a> markup stays valid. Network is isolated (no HTTP from the +render path here); the DB is a temp SQLite. + +Test ids TC-10, TC-11, TC-16 from 04-test-plan.yaml. +""" + +import os +import tempfile + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_card_link.db") +os.environ["ORCH_DB_PATH"] = _test_db + +from types import SimpleNamespace # noqa: E402 + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +import src.projects as projects_mod # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import notifications as N # noqa: E402 + +# orchestrator repo -> default project registry uuid (src/projects.py). +_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" + + +@pytest.fixture(autouse=True) +def setup_db(monkeypatch): + monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + # Keep the render path fully offline (no live overlay HTTP). + monkeypatch.setattr(N._get_settings(), "tracker_live_status", False, + raising=False) + # Pin the repo->project resolution so cross-file tests that reload the + # ORCH_PROJECTS_JSON registry can't strip 'orchestrator' out from under us. + monkeypatch.setattr( + projects_mod, "get_project_by_repo", + lambda repo: (SimpleNamespace(plane_project_id=_ORCH_PROJECT_ID) + if repo == "orchestrator" else None), + ) + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _set(monkeypatch, **kw): + s = N._get_settings() + for k, v in kw.items(): + monkeypatch.setattr(s, k, v, raising=False) + + +def _mk_task(wid="ORCH-067", repo="orchestrator", title="card link", + plane_issue_id="issue-uuid-1", stage="development"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, " + "plane_issue_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ("p1", wid, repo, "feature/ORCH-067-x", stage, title, plane_issue_id), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +# --------------------------------------------------------------------------- # +# TC-10 / AC-10 — full data -> clickable <a> wrapping the issue number +# --------------------------------------------------------------------------- # +def test_tc10_card_number_is_clickable(monkeypatch): + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_api_url="http://localhost:8091", plane_workspace_slug="acme") + tid = _mk_task(plane_issue_id="abcd-issue-uuid") + + text = N.render_task_tracker(tid) + expected_url = ( + f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}" + f"/issues/abcd-issue-uuid/" + ) + assert f'<a href="{expected_url}">ORCH-067</a>' in text + + +# --------------------------------------------------------------------------- # +# TC-11 / AC-11 — fail-safe: any missing piece -> escaped number, no <a>, no crash +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("override,reason", [ + ({"plane_web_url": "", "plane_api_url": ""}, "no web base"), + ({"plane_web_url": "http://localhost:8091", "plane_api_url": ""}, "loopback base"), + ({"plane_workspace_slug": ""}, "no workspace"), +]) +def test_tc11_card_number_degrades_settings(monkeypatch, override, reason): + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_api_url="http://localhost:8091", plane_workspace_slug="acme") + _set(monkeypatch, **override) + tid = _mk_task(plane_issue_id="abcd-issue-uuid") + + text = N.render_task_tracker(tid) + assert "ORCH-067" in text # raw number still shown + assert "<a href=" not in text, reason # but NOT a link + assert "localhost" not in text # never leak a loopback URL + + +def test_tc11_card_number_degrades_no_issue_id(monkeypatch): + # Missing plane_issue_id -> the number is shown unlinked, render survives. + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + tid = _mk_task(plane_issue_id=None) + text = N.render_task_tracker(tid) + assert "ORCH-067" in text + assert "<a href=" not in text + + +def test_tc11_card_number_degrades_unknown_repo(monkeypatch): + # repo not in the registry -> no project_id -> number unlinked, no crash. + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + tid = _mk_task(repo="not-a-real-repo", plane_issue_id="abcd-issue-uuid") + text = N.render_task_tracker(tid) + assert "ORCH-067" in text + assert "<a href=" not in text + + +# --------------------------------------------------------------------------- # +# TC-16 / AC-14 — HTML escaping: title with '<b>'/'&'/'>' stays safe + valid <a> +# --------------------------------------------------------------------------- # +def test_tc16_title_escaped_link_valid(monkeypatch): + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + tid = _mk_task(title="<b>drop & </b> table >", plane_issue_id="iss-1") + + text = N.render_task_tracker(tid) + # Raw title markup is escaped -> cannot break parse_mode=HTML. + assert "<b>" not in text + assert "<b>" in text + assert "&" in text + # The card's own anchor markup stays well-formed (balanced tags). + assert text.count("<a href=") == text.count("</a>") + assert text.count("<a href=") >= 1 # the clickable number is present + + +def test_tc16_ampersand_in_work_item_id_escaped(monkeypatch): + # A '&' in the work_item_id is escaped in the (unlinked) fail-safe path too. + _set(monkeypatch, plane_web_url="", plane_api_url="", + plane_workspace_slug="acme") + tid = _mk_task(wid="ORCH&67", plane_issue_id="iss-1") + text = N.render_task_tracker(tid) + assert "ORCH&67" in text + assert "<a href=" not in text # no link (no web base) diff --git a/tests/test_tracker_status_line.py b/tests/test_tracker_status_line.py new file mode 100644 index 0000000..d188204 --- /dev/null +++ b/tests/test_tracker_status_line.py @@ -0,0 +1,216 @@ +"""ORCH-067 — Group B: the Plane-status line on the live card (AC-5..AC-9). + +The card now carries an explicit '📍 <Plane status>' line under the header that +follows the ORCH-066 status model. The OFFLINE core (stage->status + In Review +from the brd-clock + Awaiting Deploy) is pure/deterministic and never touches the +network; a best-effort LIVE overlay draws the branch statuses that are +indistinguishable offline (Needs Input / Blocked / …). Everything degrades to the +stage default and NEVER raises (AC-9). Network is isolated: the live-state read +(`_live_state_uuid_cached`) and `get_project_states` are patched per case; the DB +is a temp SQLite. + +Test ids TC-05..TC-09 from 04-test-plan.yaml. +""" + +import os +import tempfile + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_status_line.db") +os.environ["ORCH_DB_PATH"] = _test_db + +from types import SimpleNamespace # noqa: E402 + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +import src.projects as projects_mod # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import notifications as N # noqa: E402 +import src.plane_sync as plane_sync # noqa: E402 + +_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" + + +@pytest.fixture(autouse=True) +def setup_db(monkeypatch): + monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + # Live overlay OFF by default for the offline-core tests; cases that need it + # turn it back on explicitly. Keep the per-issue cache clean between cases. + monkeypatch.setattr(N._get_settings(), "tracker_live_status", False, raising=False) + N._LIVE_STATE_CACHE.clear() + # Pin repo->project resolution (cross-file ORCH_PROJECTS_JSON reloads must not + # strip 'orchestrator' and disable the live overlay under us). + monkeypatch.setattr( + projects_mod, "get_project_by_repo", + lambda repo: (SimpleNamespace(plane_project_id=_ORCH_PROJECT_ID) + if repo == "orchestrator" else None), + ) + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _mk_task(stage="development", wid="ORCH-067", repo="orchestrator", + plane_issue_id="issue-uuid-1", brd_started=None, brd_ended=None, + title="status line"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, " + "plane_issue_id, brd_review_started_at, brd_review_ended_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("p1", wid, repo, "feature/ORCH-067-x", stage, title, plane_issue_id, + brd_started, brd_ended), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _status_line(text): + """Extract the single '📍 ...' status line from rendered card text.""" + for ln in text.splitlines(): + if ln.startswith("\U0001f4cd"): + return ln + return None + + +# --------------------------------------------------------------------------- # +# TC-05 / AC-5 — render carries an explicit Plane-status line +# --------------------------------------------------------------------------- # +def test_tc05_render_has_status_line(): + tid = _mk_task(stage="development") + text = N.render_task_tracker(tid) + line = _status_line(text) + assert line is not None # '📍 ...' present + assert line == "\U0001f4cd Development" # stage -> Plane status + + +# --------------------------------------------------------------------------- # +# TC-06 / AC-6 — stage -> Plane status mapping (ТЗ §2.2), parametrized +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("stage,expected", [ + ("created", "To Analyse"), + ("analysis", "Analysis"), + ("architecture", "Architecture"), + ("development", "Development"), + ("review", "Code-Review"), + ("testing", "Testing"), + ("deploy", "⏸️ Awaiting Deploy — ожидание Confirm Deploy"), + ("done", "Done"), +]) +def test_tc06_stage_to_plane_status(stage, expected): + # plane_status_label is pure/offline -> assert directly off a row-like dict. + assert N.plane_status_label({"stage": stage}) == expected + + +def test_tc06_unknown_stage_degrades_to_default(): + # Anything unknown -> the safe stage default (To Analyse), never an error. + assert N.plane_status_label({"stage": "weird-stage"}) == "To Analyse" + assert N.plane_status_label({}) == "To Analyse" + + +# --------------------------------------------------------------------------- # +# TC-07 / AC-7 — In Review from the brd-clock, OFFLINE (no network) +# --------------------------------------------------------------------------- # +def test_tc07_in_review_from_brd_clock(monkeypatch): + # analysis + brd started + not ended -> '⏸️ In Review' (waiting BRD approve). + # Guard: any network read would fail this test -> prove it stays offline. + def _boom(*a, **k): + raise AssertionError("In Review must be resolved OFFLINE (no network)") + monkeypatch.setattr(N, "_live_state_uuid_cached", _boom) + + tid = _mk_task(stage="analysis", brd_started="2026-06-08 10:00:00", + brd_ended=None) + text = N.render_task_tracker(tid) + + assert _status_line(text) == "\U0001f4cd " + N._IN_REVIEW_LABEL + # The human-gate 'Подтверждение BRD' line with ⏸️/⏳ is still rendered. + assert N._BRD_LABEL in text + assert "⏳" in text # ⏳ still-waiting marker + + +def test_tc07b_in_review_clears_once_brd_ended(): + # Once the BRD review ended, analysis is back to the plain 'Analysis' status. + tid = _mk_task(stage="analysis", brd_started="2026-06-08 10:00:00", + brd_ended="2026-06-08 10:30:00") + assert _status_line(N.render_task_tracker(tid)) == "\U0001f4cd Analysis" + + +# --------------------------------------------------------------------------- # +# TC-08 / AC-8 — Awaiting Deploy (offline) + Needs Input (live overlay) +# --------------------------------------------------------------------------- # +def test_tc08_awaiting_deploy_offline(): + # stage=deploy -> '⏸️ Awaiting Deploy' purely offline (no overlay needed). + tid = _mk_task(stage="deploy") + line = _status_line(N.render_task_tracker(tid)) + assert line == "\U0001f4cd ⏸️ Awaiting Deploy — ожидание Confirm Deploy" + + +def test_tc08_needs_input_via_live_overlay(monkeypatch): + # Needs Input is NOT derivable offline -> drawn by the best-effort overlay + # reading the LIVE Plane status. Patch the live read + the state map. + monkeypatch.setattr(N._get_settings(), "tracker_live_status", True, + raising=False) + monkeypatch.setattr(N, "_live_state_uuid_cached", + lambda issue_id, project_id: "uuid-needs-input") + monkeypatch.setattr( + plane_sync, "get_project_states", + lambda project_id: {"needs_input": "uuid-needs-input"}, + ) + # repo='orchestrator' resolves to a real registry project_id -> overlay runs. + tid = _mk_task(stage="development", repo="orchestrator") + line = _status_line(N.render_task_tracker(tid)) + assert line == "\U0001f4cd ❓ Needs Input — нужны уточнения" + + +def test_tc08b_overlay_no_match_keeps_offline_base(monkeypatch): + # Live status maps to no branch key -> the offline stage base is kept. + monkeypatch.setattr(N._get_settings(), "tracker_live_status", True, + raising=False) + monkeypatch.setattr(N, "_live_state_uuid_cached", + lambda issue_id, project_id: "uuid-in-progress") + monkeypatch.setattr( + plane_sync, "get_project_states", + lambda project_id: {"in_progress": "uuid-in-progress", + "needs_input": "uuid-needs-input"}, + ) + tid = _mk_task(stage="development", repo="orchestrator") + assert _status_line(N.render_task_tracker(tid)) == "\U0001f4cd Development" + + +# --------------------------------------------------------------------------- # +# TC-09 / AC-9, AC-16 — render never raises on broken/unreachable status data +# --------------------------------------------------------------------------- # +def test_tc09_render_survives_overlay_exception(monkeypatch): + # The live overlay blowing up must NOT escape render -> degrade to stage base. + monkeypatch.setattr(N._get_settings(), "tracker_live_status", True, + raising=False) + + def _boom(*a, **k): + raise RuntimeError("plane down") + monkeypatch.setattr(N, "_live_state_uuid_cached", _boom) + + tid = _mk_task(stage="development", repo="orchestrator") + text = N.render_task_tracker(tid) # must not raise + assert _status_line(text) == "\U0001f4cd Development" + + +def test_tc09b_card_status_label_never_raises(monkeypatch): + # _card_status_label swallows everything -> a usable default, never an error. + def _boom(*a, **k): + raise RuntimeError("boom") + monkeypatch.setattr(N, "plane_status_label", _boom) + assert N._card_status_label({"stage": "development"}) == "To Analyse" + + +def test_tc09c_plane_status_label_never_raises(): + # Garbage row (None / object without keys) -> safe default, no exception. + assert N.plane_status_label(None) == "To Analyse" + assert N.plane_status_label(object()) == "To Analyse"