diff --git a/.env.example b/.env.example index ffdb5cc..42feaf5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ ORCH_PLANE_API_URL=http://plane-app-api-1:8000 +# External (browser) web URL of Plane for clickable issue links in notifications +# (ORCH-017). Falls back to ORCH_PLANE_API_URL; a loopback fallback is treated as +# "no web URL" and the Plane link is omitted. Example: https://plane.example.org +ORCH_PLANE_WEB_URL= ORCH_PLANE_API_TOKEN= ORCH_PLANE_WORKSPACE_SLUG= ORCH_PLANE_WEBHOOK_SECRET= diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbb8aa..53b13bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве** (ORCH-017): пингующее сообщение `notify_approve_requested` теперь встраивает две HTML-``-ссылки — на `docs/work-items//01-brd.md` (Gitea branch-view: `gitea_public_url`→`gitea_url`) и на issue в Plane (`{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/`). Новая настройка `ORCH_PLANE_WEB_URL` (внешний браузерный web-URL Plane; фолбэк на `plane_api_url`). **Loopback-guard:** если итоговый Plane web-base указывает на localhost/127.0.0.1/0.0.0.0/::1 или пуст — Plane-ссылка опускается (не выпускаем битый localhost-URL). Graceful degradation: каждая ссылка строится независимо и опускается при нехватке данных, сообщение и призыв «Переведите задачу в статус Approved …» сохраняются всегда; ровно одно пингующее сообщение, разделяемая `send_telegram` не тронута. Динамические подписи экранируются `html.escape`, `parse_mode=HTML` сохранён. ADR `docs/work-items/ORCH-017/06-adr/ADR-001-telegram-approve-links.md`. Тесты: `test_notify_approve_links.py`, `test_analysis_approve_flow_links.py`. - **Конфигурируемые модель LLM и режим работы (`--effort`) агентов** (ORCH-41): модель/effort каждого агента вынесены из хардкода `launcher.py` в конфиг — глобально per-agent (`ORCH_AGENT_MODEL_` / `ORCH_AGENT_EFFORT_`, дефолты `ORCH_AGENT_MODEL_DEFAULT=claude-opus-4-8`, `ORCH_AGENT_EFFORT_DEFAULT=high`) и per-project (`agent_models` / `agent_efforts` в `ORCH_PROJECTS_JSON`). Резолверы `resolve_agent_model` / `resolve_agent_effort` (приоритет project > per-agent env > default > пусто), валидация effort `{low,medium,high,xhigh,max}`, опц. `ORCH_AGENT_FALLBACK_MODEL` (`--fallback-model`). Хардкод `"model":"opus"` (architect/reviewer) удалён. Тесты: `test_resolve_agent_model.py`, `test_resolve_agent_effort.py`. - **Единый status-коммент агентов в Plane** (ORCH-016): `usage.build_status_comment(...)` — один хелпер для ВСЕХ ролей (analyst..deployer). HTML-формат: header `{icon} {Role} — {описание}`, опциональная строка `Verdict/Status: …` из YAML-frontmatter артефакта, **строка `Длительность: 4m 12s`** (явный `duration_s` от launcher, fallback из `agent_runs` для аналитика), `Документы:`, тех-хвост `tokens · cost`. Утилитки: `usage.fmt_duration`, `usage.get_agent_duration`, новый модуль `src/frontmatter.py` (defensive YAML reader). ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`. - **Документация по канону** (ORCH-9): `CLAUDE.md` (паспорт проекта), структура `docs/` (`architecture/` + `adr/`, `operations/`, `work-items/`, `history/`), `docs/operations/INFRA.md` (RUNBOOK с инфра-изоляцией и self-hosting рисками). diff --git a/docs/operations/INFRA.md b/docs/operations/INFRA.md index cf9d248..90bd8e0 100644 --- a/docs/operations/INFRA.md +++ b/docs/operations/INFRA.md @@ -42,6 +42,7 @@ | Переменная | Назначение | |-----------|-----------| | `ORCH_PLANE_API_URL` / `_TOKEN` / `_WORKSPACE_SLUG` | доступ к Plane API | +| `ORCH_PLANE_WEB_URL` | внешний (браузерный) web-URL Plane для кликабельных ссылок на issue в уведомлениях (ORCH-017); пусто → фолбэк на `ORCH_PLANE_API_URL`, loopback-фолбэк → ссылка опускается | | `ORCH_PLANE_WEBHOOK_SECRET` | HMAC-проверка вебхуков Plane | | `ORCH_GITEA_URL` / `_TOKEN` / `_WEBHOOK_SECRET` | доступ к Gitea + HMAC | | `ORCH_CLAUDE_BIN` | путь к claude CLI | diff --git a/docs/work-items/ORCH-017/00-business-request.md b/docs/work-items/ORCH-017/00-business-request.md new file mode 100644 index 0000000..ff5e7f8 --- /dev/null +++ b/docs/work-items/ORCH-017/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве + +Work Item ID: ORCH-017 + +## Description + +TBD diff --git a/docs/work-items/ORCH-017/01-brd.md b/docs/work-items/ORCH-017/01-brd.md new file mode 100644 index 0000000..f4e1f2d --- /dev/null +++ b/docs/work-items/ORCH-017/01-brd.md @@ -0,0 +1,91 @@ +# 01-BRD — ORCH-017: Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве + +Work Item: **ORCH-017** +Repo: `orchestrator` · Branch: `feature/ORCH-017-brd-plane-telegram` +Тип: косметическая правка (UX уведомлений). Парная с ORCH-016. + +## 1. Бизнес-контекст и проблема +Когда оркестратор завершает стадию `analysis` и просит подтвердить BRD, в Telegram уходит +отдельное «пингующее» уведомление (`notify_approve_requested` в `src/notifications.py`). +Сейчас в этом сообщении **нет ссылок**: владелец (Слава) вынужден вручную зайти в Plane, +найти нужную issue, открыть комментарий аналитика, оттуда перейти к BRD-документу. Это +лишние ручные шаги на каждой задаче. + +Текущий текст уведомления: +> 📋 {WI}: BRD/ТЗ/AC готовы. Переведите задачу в статус Approved в Plane для продолжения. + +## 2. Цель +В **этом же** уведомлении дать две прямые кликабельные ссылки, чтобы весь сценарий +прохождения апрува выполнялся из Telegram, без ручной навигации в Plane: +1. **Ссылка на BRD** — открывает `01-brd.md` в Gitea (прочитать документ). +2. **Ссылка на Plane-issue** — открывает задачу в Plane (перевести в Approved / отклонить с комментом). + +## 3. Целевой сценарий (Слава) +Получил уведомление → кликнул «📄 BRD» → прочитал → кликнул «✅ Задача» → перевёл в +Approved (или отклонил с комментарием). Всё из Telegram. + +## 4. Объём (Scope) +### В объёме (выбранный по умолчанию минимальный вариант — см. §8 открытые вопросы) +- Доработка **только** функции `notify_approve_requested(task_id)` в `src/notifications.py` + (стадия `analysis`, запрос статуса Approved). +- Формирование двух ссылок и встраивание их в текст того же отдельного уведомления. +- Формат — HTML-ссылки в тексте (`label`), т.к. `send_telegram` уже шлёт + `parse_mode="HTML"`. Альтернатива (inline-кнопки) — открытый вопрос §8. +- Новая конфиг-настройка для внешнего web-URL Plane (см. §6, риск №1). +- Обновление документации (`CLAUDE.md` env-карта при необходимости, `CHANGELOG.md`, + `.env.example`) в том же PR. + +### Вне объёма (НЕ трогать) +- Логика апрува: `:approved:`-handler, `check_analysis_approved`, переходы стадий. +- Живой Telegram-трекер (`update_task_tracker` / `render_task_tracker`, PR #21/#22) — его + текст и поведение не меняем; новое уведомление остаётся ОТДЕЛЬНЫМ сообщением, дубли + трекера не создаём. +- Содержимое комментариев в Plane (это смежная задача ORCH-016). +- Ссылки в других уведомлениях (deploy-failed, agent-failed, error) — вне объёма по + умолчанию (см. открытый вопрос §8.2). + +## 5. Заинтересованные стороны +- **Owner / получатель уведомления:** Слава. +- **Поставщик данных:** оркестратор (БД `tasks`: repo, branch, work_item_id, plane_issue_id). + +## 6. Функциональные требования +| # | Требование | +|---|------------| +| FR-1 | Уведомление об апруве BRD содержит кликабельную ссылку на документ `docs/work-items//01-brd.md` в Gitea. | +| FR-2 | То же уведомление содержит кликабельную ссылку на соответствующую Plane-issue. | +| FR-3 | Существующий текст-призыв («Переведите задачу в статус Approved …») сохраняется. | +| FR-4 | Уведомление остаётся ОДНИМ отдельным пингующим сообщением (без дублей, без второго сообщения). | +| FR-5 | Ссылка на BRD строится на внешнем `gitea_public_url` (фоллбэк `gitea_url`), формат branch-view: `{base}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{WI}/01-brd.md`. Переиспользовать существующий паттерн из `src/usage.py`. | +| FR-6 | Ссылка на Plane-issue строится на внешнем web-URL Plane + workspace + project + issue. | + +## 7. Нефункциональные требования +| # | Требование | +|---|------------| +| NFR-1 | **Никогда не ронять оркестратор** из-за уведомления: построение ссылок обёрнуто в защиту, при отсутствии данных (нет branch / нет plane_issue_id / не задан web-URL) — сообщение всё равно отправляется, просто без соответствующей ссылки (graceful degradation). | +| NFR-2 | Не нарушать self-hosting: правка не требует рестарта прод-контейнера сверх обычного деплоя; не меняет реестр гейтов/стадий. | +| NFR-3 | Сохранить `parse_mode="HTML"`; экранировать динамические подписи (`html.escape`), URL формировать из доверенных конфиг-значений. | + +## 8. Открытые вопросы (требуют решения Owner; в документах принят безопасный дефолт) +1. **Формат ссылок.** Дефолт BRD: HTML-ссылки в тексте (минимальная правка). Альтернатива — + inline-кнопки «📄 Открыть BRD» / «✅ К задаче в Plane», что требует доработки `send_telegram` + (параметр `reply_markup`/`inline_keyboard`). → решение к стадии architecture. +2. **Охват.** Дефолт: только BRD-апрув (`notify_approve_requested`). Альтернатива — все точки, + требующие решения Славы (напр. согласование макета ORCH-14). → если «все точки», объём + расширяется, нужен отдельный перечень событий. +3. **Внешний web-URL Plane.** В конфиге сейчас только внутренний `plane_api_url` + (`http://localhost:8091`) — он НЕ годится для браузерной ссылки. Дефолт: завести новую + env-настройку `ORCH_PLANE_WEB_URL` (внешний адрес Plane) с фоллбэком на `plane_api_url`. + Точное значение URL должен подтвердить Owner/INFRA. +4. **Формат Plane-ссылки.** `…/{workspace}/projects/{project_id}/issues/{issue_id}/` (надёжно, + issue_id есть в `tasks.plane_issue_id`) vs короткий `…/{workspace}/browse//` + (зависит от соответствия `work_item_id` ↔ Plane identifier, что не гарантировано из-за + zero-padding ORCH-017 vs ORCH-17). → решение к стадии architecture. + +## 9. Зависимости и связки +- **PR #14** — `gitea_public_url`: переиспользуем для кликабельных ссылок на доки. +- **PR #21/#22** — живой Telegram-трекер: новое сообщение остаётся отдельным, трекер не трогаем. +- **ORCH-016** — единые коммент-артефакты в Plane (парная задача про навигацию к документам). + +## 10. Критерий бизнес-успеха +Слава из одного Telegram-уведомления одним кликом открывает BRD и одним кликом — задачу в +Plane, не заходя в Plane вручную и не ища комментарий. diff --git a/docs/work-items/ORCH-017/02-trz.md b/docs/work-items/ORCH-017/02-trz.md new file mode 100644 index 0000000..eac7f8d --- /dev/null +++ b/docs/work-items/ORCH-017/02-trz.md @@ -0,0 +1,87 @@ +# 02-ТЗ — ORCH-017: Прямые ссылки в Telegram-уведомлении об апруве BRD + +Work Item: **ORCH-017** · Repo: `orchestrator` +Опирается на 01-brd.md. Уточняет конкретные изменения кода/конфигурации. + +> Примечание по канону: ТЗ фиксирует ТРЕБОВАНИЯ к изменениям, а не готовое +> архитектурное решение. Выбор формата (текст vs inline-кнопки) и точного формата +> Plane-URL — за стадией architecture (см. открытые вопросы 01-brd.md §8). Если по +> ходу разработки ТЗ окажется неполным/неверным — возврат на стадию Анализ, без +> правок ТЗ задним числом. + +## 1. Задействованные модули `src/` +| Модуль | Роль в задаче | +|--------|---------------| +| `src/notifications.py` | **Основной.** Функция `notify_approve_requested(task_id)` (≈ строки 547–566) — единственная точка отправки пингующего уведомления об апруве BRD. Сюда добавляются ссылки. | +| `src/config.py` | Класс `Settings`. Добавить настройку внешнего web-URL Plane (`plane_web_url`, env `ORCH_PLANE_WEB_URL`) с дефолтом-фоллбэком. | +| `src/projects.py` | (Чтение) `get_project_by_repo(repo)` → `plane_project_id` для построения Plane-URL. | +| `src/usage.py` | (Референс, не править) Эталонный паттерн branch-view ссылки на доки (`{base}/{owner}/{repo}/src/branch/{branch}/`), строки ≈483–503 — переиспользовать тот же формат. | +| `src/db.py` | (Чтение) Таблица `tasks`: поля `work_item_id`, `repo`, `branch`, `plane_issue_id`. Источник данных для ссылок. | + +## 2. Источники данных (из `tasks` по `task_id`) +- `work_item_id` — путь к BRD-документу и (опц.) идентификатор issue. +- `repo`, `branch` — построение Gitea branch-view URL. +- `plane_issue_id` — uuid issue в Plane для прямой ссылки. +- `project_id` — через `projects.get_project_by_repo(repo).plane_project_id`. + +`notify_approve_requested` сейчас принимает только `task_id` и тянет лишь `work_item_id` +через `_get_work_item_id`. Требуется дополнительно прочитать `repo`, `branch`, +`plane_issue_id` из `tasks` (один SELECT, в защищённом try/except). + +## 3. Требуемые изменения + +### 3.1 `src/notifications.py` +- Построить **BRD-ссылку** (FR-1/FR-5): + `{base}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{work_item_id}/01-brd.md`, + где `base = (settings.gitea_public_url or settings.gitea_url).rstrip('/')`, + `owner = settings.gitea_owner`. Если нет `base`/`repo`/`branch`/`work_item_id` — ссылку + опустить (NFR-1). +- Построить **Plane-ссылку** (FR-2/FR-6): + `{plane_web_base}/{workspace_slug}/projects/{project_id}/issues/{plane_issue_id}/` + (точный формат — решение architecture, см. 01-brd §8.4). Если нет данных — опустить. +- Встроить обе ссылки в текст того же сообщения (FR-3/FR-4), формат HTML-`` по умолчанию. + Сохранить существующий призыв «Переведите задачу в статус Approved …». +- Сохранить вызов как **одно** `send_telegram(msg)` (пингующее, не silent). Порядок + существующих действий не менять: старт BRD-часов (`mark_brd_review_started`) → + `update_task_tracker(task_id)` → `send_telegram(msg)`. +- Динамические подписи экранировать `html.escape` (NFR-3). + +### 3.2 `src/config.py` +- Добавить в `Settings` поле `plane_web_url: str = ""` (env `ORCH_PLANE_WEB_URL`). +- Семантика фоллбэка: `plane_web_base = (settings.plane_web_url or settings.plane_api_url).rstrip('/')`. + +### 3.3 Опционально (если выбран вариант inline-кнопок — открытый вопрос 01-brd §8.1) +- Расширить `send_telegram(text, disable_notification=False, reply_markup=None)`: + при наличии `reply_markup` прокидывать его в payload `sendMessage`. Обратная + совместимость — обязательна (текущие вызовы без аргумента работают как раньше). +- ⚠️ Это РАСШИРЯЕТ объём; включается только по явному решению Owner на стадии architecture. + +## 4. Изменения API +Нет. Публичные HTTP-эндпоинты (`/webhook/*`, `/status`, `/queue`, `/health`) не затрагиваются. + +## 5. Изменения схемы БД +Нет. Все нужные поля (`repo`, `branch`, `work_item_id`, `plane_issue_id`) уже существуют в `tasks`. + +## 6. Изменения конфигурации / окружения +- Новая env-переменная `ORCH_PLANE_WEB_URL` (внешний web-адрес Plane). Прописать в + `.env.example` (канон секретов/настроек), описать в env-карте (`CLAUDE.md` / + `docs/operations/INFRA.md`). Реальное значение задаётся в `.env`/`.env.staging` на хосте. +- Существующие `ORCH_GITEA_PUBLIC_URL`, `ORCH_GITEA_OWNER`, `ORCH_PLANE_WORKSPACE_SLUG` + переиспользуются как есть. + +## 7. Требования к новым QG checks +Нет. Реестр `QG_CHECKS`, стадии и машинные вердикты не меняются (правка — отображение, +не управление конвейером). + +## 8. Артефакты pipeline, которые должны быть обновлены в ЭТОМ PR +- `CHANGELOG.md` — запись о фиче. +- `.env.example` — новая `ORCH_PLANE_WEB_URL`. +- При добавлении настройки — env-карта в `CLAUDE.md` / `docs/operations/INFRA.md`. +- ADR (стадия architecture): `docs/work-items/ORCH-017/06-adr/ADR-001-*.md` — фиксирует выбор + формата (текст vs кнопки) и формат Plane-URL. + +## 9. Ограничения +- Не трогать `:approved:`-handler и `check_analysis_approved` (только текст/формат уведомления). +- Не плодить сообщения: одно отдельное пингующее сообщение; живой трекер (PR #21/#22) не дублировать. +- Соблюдать self-hosting: не ронять/не рестартить прод сверх штатного деплоя; обязательная + страховка `deploy-staging` (8501) перед прод-деплоем орка. diff --git a/docs/work-items/ORCH-017/03-acceptance-criteria.md b/docs/work-items/ORCH-017/03-acceptance-criteria.md new file mode 100644 index 0000000..d26d3f2 --- /dev/null +++ b/docs/work-items/ORCH-017/03-acceptance-criteria.md @@ -0,0 +1,64 @@ +# 03-Acceptance Criteria — ORCH-017 + +Work Item: **ORCH-017** · Repo: `orchestrator` +Каждый критерий формулирует условие PASS/FAIL. Источник — 01-brd.md / 02-trz.md. + +## AC-1 — Ссылка на BRD присутствует в уведомлении +- **PASS:** Текст, сформированный `notify_approve_requested`, содержит кликабельную ссылку + на `docs/work-items//01-brd.md` вида + `{gitea_public_url|gitea_url}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{WI}/01-brd.md`. +- **FAIL:** Ссылки на BRD нет, либо она ведёт не на `01-brd.md`/не на нужный WI. + +## AC-2 — Ссылка на Plane-issue присутствует в уведомлении +- **PASS:** Тот же текст содержит кликабельную ссылку на issue в Plane, построенную на + внешнем web-URL Plane + workspace + project + `plane_issue_id` (или согласованный браузер-формат). +- **FAIL:** Ссылки на issue нет, либо она указывает на внутренний `localhost`/неверную issue. + +## AC-3 — Базовый URL берётся из внешних настроек +- **PASS:** BRD-ссылка использует `gitea_public_url`, при его пустоте — `gitea_url`; Plane-ссылка + использует `plane_web_url` (env `ORCH_PLANE_WEB_URL`), при пустоте — `plane_api_url`. +- **FAIL:** Захардкожен хост, либо ссылка нерабочая снаружи деплой-хоста. + +## AC-4 — Существующий призыв сохранён +- **PASS:** Текст по-прежнему содержит призыв перевести задачу в статус Approved (смысл строки + «Переведите задачу в статус Approved … для продолжения» сохранён). +- **FAIL:** Призыв удалён/искажён. + +## AC-5 — Одно отдельное пингующее сообщение, без дублей +- **PASS:** `notify_approve_requested` отправляет ровно одно сообщение через `send_telegram` + (пингующее, не silent). Живой трекер (`update_task_tracker`) обновляется как раньше и не + дублируется новым сообщением. +- **FAIL:** Появляется второе/дубль-сообщение, либо трекер шлётся повторно как новое сообщение. + +## AC-6 — Graceful degradation (никогда не ронять оркестратор) +- **PASS:** При отсутствии `branch` / `plane_issue_id` / незаданном Plane web-URL функция НЕ + бросает исключение: уведомление уходит с доступными ссылками (или без отсутствующей), орк жив. +- **FAIL:** Отсутствие данных приводит к исключению/падению потока уведомлений. + +## AC-7 — HTML-безопасность +- **PASS:** Сохранён `parse_mode="HTML"`; динамические подписи экранируются (`html.escape`), + URL валиден и не ломает разметку сообщения. +- **FAIL:** Сообщение приходит с битой HTML-разметкой или с неэкранированным пользовательским текстом. + +## AC-8 — Логика апрува не затронута +- **PASS:** `:approved:`-handler, `check_analysis_approved`, переходы стадий и реестр `QG_CHECKS` + без изменений; правка касается только текста/формата уведомления. +- **FAIL:** Изменена логика гейта/перехода стадий. + +## AC-9 — Документация обновлена в том же PR +- **PASS:** Обновлены `CHANGELOG.md` и `.env.example` (новая `ORCH_PLANE_WEB_URL`); если добавлена + настройка — отражено в env-карте (`CLAUDE.md`/`docs/operations/INFRA.md`); заведён ADR на + выбранный формат. (Reviewer проверяет доку → нет обновления = REQUEST_CHANGES.) +- **FAIL:** Код изменён, документация — нет. + +## AC-10 — Тесты зелёные +- **PASS:** Новые/затронутые тесты (`tests/test_notify_approve_links.py` и существующие + `tests/test_telegram_tracker.py`, `tests/test_notify_done_regression.py`) проходят; `pytest tests/ -q` зелёный. +- **FAIL:** Любой связанный тест падает. + +--- +### Зависит от решений Owner (open questions 01-brd §8) +- Если выбран вариант **inline-кнопок** — AC-1/AC-2 считаются выполненными при наличии кнопок + «📄 Открыть BRD» / «✅ К задаче в Plane» с теми же URL; дополнительно AC: обратная совместимость + `send_telegram` (старые вызовы без `reply_markup` работают). +- Если охват расширен до **всех точек решения** — AC-1/AC-2 проверяются для каждой такой точки. diff --git a/docs/work-items/ORCH-017/04-test-plan.yaml b/docs/work-items/ORCH-017/04-test-plan.yaml new file mode 100644 index 0000000..ab66a42 --- /dev/null +++ b/docs/work-items/ORCH-017/04-test-plan.yaml @@ -0,0 +1,99 @@ +work_item: ORCH-017 +title: "Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве" +notes: > + Тесты изолируют сеть: send_telegram/httpx мокируются, проверяется СФОРМИРОВАННЫЙ текст + (и/или reply_markup, если выбран вариант кнопок), а не реальная отправка. БД tasks + наполняется фикстурой (work_item_id, repo, branch, plane_issue_id). Маппинг на критерии — в поле acceptance. + +tests: + - id: TC-01 + type: unit + description: "notify_approve_requested формирует текст с кликабельной ссылкой на 01-brd.md (Gitea branch-view)" + module: tests/test_notify_approve_links.py + setup: "task в tasks с work_item_id=ORCH-017, repo=orchestrator, branch=feature/ORCH-017-..., gitea_public_url задан; send_telegram замокан" + expected: PASS + acceptance: [AC-1, AC-3] + + - id: TC-02 + type: unit + description: "Текст содержит ссылку на Plane-issue с внешним web-URL + workspace + project + plane_issue_id" + module: tests/test_notify_approve_links.py + setup: "plane_web_url(ORCH_PLANE_WEB_URL) и workspace заданы; project резолвится по repo; plane_issue_id в tasks" + expected: PASS + acceptance: [AC-2, AC-3] + + - id: TC-03 + type: unit + description: "При пустом gitea_public_url BRD-ссылка строится на gitea_url (фоллбэк); при пустом plane_web_url — на plane_api_url" + module: tests/test_notify_approve_links.py + expected: PASS + acceptance: [AC-3] + + - id: TC-04 + type: unit + description: "Сохранён призыв перевести задачу в статус Approved (подстрока 'Approved' присутствует)" + module: tests/test_notify_approve_links.py + expected: PASS + acceptance: [AC-4] + + - id: TC-05 + type: unit + description: "send_telegram вызван ровно один раз (пингующее сообщение), без disable_notification=True" + module: tests/test_notify_approve_links.py + setup: "mock send_telegram, assert call_count == 1 и аргумент disable_notification не True" + expected: PASS + acceptance: [AC-5] + + - id: TC-06 + type: unit + description: "Graceful: branch=None / plane_issue_id=None — функция не бросает исключение, сообщение всё равно отправляется" + module: tests/test_notify_approve_links.py + setup: "task без branch и без plane_issue_id; убедиться что send_telegram всё равно вызван, отсутствующая ссылка опущена" + expected: PASS + acceptance: [AC-6] + + - id: TC-07 + type: unit + description: "Plane web-URL не задан и plane_api_url пуст — Plane-ссылка опускается, BRD-ссылка остаётся, орк не падает" + module: tests/test_notify_approve_links.py + expected: PASS + acceptance: [AC-6] + + - id: TC-08 + type: unit + description: "Сохранён parse_mode=HTML; динамические подписи экранированы, HTML-разметка ссылок валидна" + module: tests/test_notify_approve_links.py + expected: PASS + acceptance: [AC-7] + + - id: TC-09 + type: unit + description: "Регрессия трекера: update_task_tracker по-прежнему работает (silent edit), новое сообщение его не дублирует" + module: tests/test_telegram_tracker.py + expected: PASS + acceptance: [AC-5, AC-8] + + - id: TC-10 + type: integration + description: "Поток analysis-approved: _handle_analysis_approved_flow при готовых артефактах вызывает notify_approve_requested; БД tasks даёт корректные repo/branch/plane_issue_id для ссылок" + module: tests/test_analysis_approve_flow_links.py + setup: "замокать сетевые вызовы Plane/Gitea/Telegram; убедиться, что check_analysis_approved/переходы стадий не изменены" + expected: PASS + acceptance: [AC-1, AC-2, AC-8] + + # Условные тесты — включаются ТОЛЬКО если Owner выбрал вариант inline-кнопок (01-brd §8.1) + - id: TC-11 + type: unit + description: "(Условный) Вариант кнопок: payload содержит reply_markup.inline_keyboard с кнопками '📄 Открыть BRD' и '✅ К задаче в Plane' с верными url" + module: tests/test_notify_approve_links.py + expected: PASS + condition: "only if inline-buttons variant chosen" + acceptance: [AC-1, AC-2] + + - id: TC-12 + type: unit + description: "(Условный) Обратная совместимость send_telegram: вызовы без reply_markup работают как раньше (payload без поля reply_markup)" + module: tests/test_telegram_tracker.py + expected: PASS + condition: "only if inline-buttons variant chosen" + acceptance: [AC-5] diff --git a/docs/work-items/ORCH-017/06-adr/ADR-001-telegram-approve-links.md b/docs/work-items/ORCH-017/06-adr/ADR-001-telegram-approve-links.md new file mode 100644 index 0000000..43d7721 --- /dev/null +++ b/docs/work-items/ORCH-017/06-adr/ADR-001-telegram-approve-links.md @@ -0,0 +1,117 @@ +# ADR-001: Прямые ссылки в Telegram-уведомлении об апруве BRD (формат и Plane-URL) + +Work Item: **ORCH-017** · Repo: `orchestrator` · Стадия: architecture +Тип: per-work-item ADR (НЕ сквозной — реестр гейтов/стадий/компонентов не меняется). + +## Статус +Accepted + +## Контекст +BRD (`01-brd.md`) и ТЗ (`02-trz.md`) требуют добавить в пингующее уведомление об апруве +BRD (`notify_approve_requested(task_id)` в `src/notifications.py`) две кликабельные ссылки: +на документ `01-brd.md` в Gitea и на Plane-issue. ТЗ намеренно оставило за стадией +architecture три развилки (открытые вопросы `01-brd.md` §8): + +1. **§8.1 — формат ссылок:** HTML-`` в тексте (минимум) **vs** inline-кнопки + (`reply_markup` в `send_telegram`). +2. **§8.4 — формат Plane-URL:** полный путь `.../projects/{project_id}/issues/{issue_id}/` + **vs** короткий `.../browse//`. +3. **§8.3 — внешний web-URL Plane:** в конфиге есть только внутренний `plane_api_url` + (`http://localhost:8091`), непригодный для браузерной ссылки. + +Жёсткое ограничение контекста — **self-hosting**: правка живёт в инструменте, который сейчас +обслуживает другие проекты из общего прод-контейнера. Любое расширение blast radius +(особенно правка разделяемой функции `send_telegram`, которой пользуется и живой трекер +PR #21/#22) — групповой риск. Поэтому из равноценных вариантов выбирается тот, что меняет +меньше кода и не трогает общие точки. + +Фактическое состояние кода, проверенное на ветке: +- `send_telegram(text, disable_notification=False)` (`src/notifications.py:42`) шлёт + `parse_mode="HTML"` — HTML-`` работает без изменения сигнатуры. +- Эталон branch-view ссылки на доки — `src/usage.py:455-458`: + `base = (gitea_public_url or gitea_url).rstrip('/')`, `owner = gitea_owner`, + URL `{base}/{owner}/{repo}/src/branch/{branch}/`. +- Plane-issue uuid надёжно лежит в `tasks.plane_issue_id`; `project_id` берётся через + `projects.get_project_by_repo(repo).plane_project_id`. +- В `plane_sync.py` строки `.../workspaces/{slug}/projects/{pid}/issues/{id}/` — это **API** + путь (`{plane_api_url}/api/v1/...`), НЕ браузерный. Браузерный роут Plane — + `{web_base}/{workspace_slug}/projects/{project_id}/issues/{issue_id}` (без `/api/v1`, + без сегмента `/workspaces/`). + +## Решение + +### Р-1 (§8.1) — HTML-ссылки в тексте. Inline-кнопки отклонены. +Ссылки встраиваются как `подпись` в текст того же одного сообщения. +**`send_telegram` НЕ трогаем** (сигнатура без `reply_markup`). Inline-кнопки потребовали бы +правки разделяемой функции, которой пользуется живой трекер, — это рост blast radius без +бизнес-выгоды для одной точки уведомления. Расширение до кнопок — **вне объёма ORCH-017**; +при реальной потребности заводится отдельный work item. + +### Р-2 (§8.4) — полный путь Plane-issue по uuid. Короткий `browse/` отклонён. +Формат: +``` +{plane_web_base}/{workspace_slug}/projects/{project_id}/issues/{plane_issue_id}/ +``` +Источники: `plane_web_base` (Р-3), `workspace_slug = settings.plane_workspace_slug`, +`project_id = get_project_by_repo(repo).plane_project_id`, `plane_issue_id = tasks.plane_issue_id`. +Короткий `browse/` отклонён: он опирается на совпадение `work_item_id` с Plane-identifier, +которое не гарантировано из-за zero-padding (`ORCH-017` в БД vs `ORCH-17` как identifier). +uuid в `plane_issue_id` — детерминированный и уже в наличии источник. + +### Р-3 (§8.3) — новая настройка `ORCH_PLANE_WEB_URL` + loopback-guard. +В `src/config.py` добавляется `plane_web_url: str = ""` (env `ORCH_PLANE_WEB_URL`). +База резолвится как: +```python +plane_web_base = (settings.plane_web_url or settings.plane_api_url).rstrip("/") +``` +**Loopback-guard (разрешение конфликта AC-2 ↔ AC-3):** дефолт-фоллбэк `plane_api_url` равен +`http://localhost:8091` и снаружи хоста не кликается. Поэтому: если итоговый `plane_web_base` +указывает на loopback/локальный хост (`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`) **или** +пуст — **Plane-ссылка опускается целиком** (а не вставляется битой). Так одновременно: +AC-2 (не выпускаем localhost-ссылку), AC-3 (цепочка фоллбэка соблюдена как попытка), +AC-6/NFR-1 (никаких исключений, сообщение уходит без отсутствующей ссылки). + +### Р-4 — graceful degradation как контракт построения ссылок. +Чтение `repo/branch/plane_issue_id` из `tasks` — один SELECT в `try/except`. Каждая из двух +ссылок строится независимо; при нехватке данных конкретная ссылка опускается, призыв +«Переведите задачу в статус Approved …» и само сообщение сохраняются всегда. Динамические +подписи — через `html.escape`; URL формируются только из доверенных конфиг/БД-значений. + +### Р-5 — инвариант «одно сообщение, без дублей». +Порядок действий в `notify_approve_requested` сохраняется: `mark_brd_review_started` → +`update_task_tracker(task_id)` → один `send_telegram(msg)` (пингующий, не silent). Живой +трекер не дублируется. Реестр `QG_CHECKS`, стадии, `:approved:`-handler, +`check_analysis_approved` — без изменений (правка — отображение, не управление конвейером). + +## Затронутые модули (для стадии development) +| Модуль | Изменение | +|--------|-----------| +| `src/notifications.py` | `notify_approve_requested`: SELECT `repo/branch/plane_issue_id`; сборка двух ссылок (Р-2/Р-3/Р-4); встраивание в текст. | +| `src/config.py` | `Settings.plane_web_url: str = ""` (env `ORCH_PLANE_WEB_URL`). | +| `src/projects.py` | (чтение) `get_project_by_repo(repo).plane_project_id`. | +| `src/usage.py` | (референс, НЕ править) паттерн branch-view URL. | +| `.env.example`, `CHANGELOG.md`, env-карта (`CLAUDE.md`/`INFRA.md`) | документация в том же PR. | + +Без изменений API и схемы БД. Все требуемые поля уже есть в `tasks`. + +## Последствия +**Плюсы:** +- Минимальный blast radius: разделяемая `send_telegram` не тронута → нулевой риск для живого + трекера и прочих уведомлений; безопасно для self-hosting. +- Детерминированная Plane-ссылка (uuid), не зависит от zero-padding identifier. +- Loopback-guard снимает противоречие AC-2/AC-3 и исключает «битые localhost-ссылки» в проде. +- Деплой штатный: не требует рестарта прод-контейнера сверх обычного деплоя; деплой ORCH + идёт через обязательный `deploy-staging` (8501). + +**Минусы / ограничения:** +- Нет inline-кнопок (по дизайну отклонено) — UX чуть менее «кнопочный»; при необходимости + отдельный work item. +- Plane-ссылка появится только после задания `ORCH_PLANE_WEB_URL` на хосте (`.env`/`.env.staging`) + — см. `07-infra-requirements.md`. До этого момента graceful degradation: уведомление уходит + только с BRD-ссылкой. +- Корректность браузерного роута Plane (`/{workspace}/projects/{id}/issues/{id}/`) зависит от + версии Plane; риск зафиксирован в `10-tech-risks.md`. + +## Открытые вопросы, переданные дальше +- **Значение `ORCH_PLANE_WEB_URL`** подтверждает Owner/INFRA при деплое (см. `07-infra-requirements.md`). + Это конфиг-параметр, а не блокер архитектуры. diff --git a/docs/work-items/ORCH-017/07-infra-requirements.md b/docs/work-items/ORCH-017/07-infra-requirements.md new file mode 100644 index 0000000..bf517fa --- /dev/null +++ b/docs/work-items/ORCH-017/07-infra-requirements.md @@ -0,0 +1,38 @@ +# 07-Infra Requirements — ORCH-017 + +Work Item: **ORCH-017** · Repo: `orchestrator` +Опирается на ADR-001 (Р-3). Меняется только env-карта; топология контейнеров/портов — без изменений. + +## 1. Новая env-переменная +| Ключ | env | Дефолт | Назначение | +|------|-----|--------|------------| +| `plane_web_url` | `ORCH_PLANE_WEB_URL` | `""` (пусто) | Внешний **браузерный** базовый URL Plane для кликабельной ссылки на issue из Telegram. НЕ путать с внутренним `ORCH_PLANE_API_URL` (`http://localhost:8091`), который пригоден только для API. | + +### Семантика резолва (ADR-001 Р-3) +``` +plane_web_base = (ORCH_PLANE_WEB_URL or ORCH_PLANE_API_URL).rstrip("/") +``` +- Если `plane_web_base` пуст **или** указывает на loopback (`localhost`, `127.0.0.1`, + `0.0.0.0`, `[::1]`) — Plane-ссылка **опускается** (graceful degradation, NFR-1). Без + заданного `ORCH_PLANE_WEB_URL` уведомление уходит только с BRD-ссылкой — это нормально. + +## 2. Что требуется от Owner / INFRA +1. **Подтвердить значение `ORCH_PLANE_WEB_URL`** — внешний адрес Plane UI (тот, по которому + Слава открывает Plane в браузере). Это единственный внешний вход, требующий решения Owner. +2. Прописать ключ в `.env` (prod-хост) и `.env.staging` (staging-песочница). В git значение + НЕ коммитится — канон секретов/настроек (`.env.example` — образец без значения). +3. Браузерный роут issue, который будет собран: + `{ORCH_PLANE_WEB_URL}/{ORCH_PLANE_WORKSPACE_SLUG}/projects/{plane_project_id}/issues/{plane_issue_id}/`. + Проверить на одной задаче, что он открывается в текущей версии Plane (см. риск R-3 в + `10-tech-risks.md`). + +## 3. Переиспользуемые (без изменений) настройки +- `ORCH_GITEA_PUBLIC_URL` / `ORCH_GITEA_URL`, `ORCH_GITEA_OWNER` — для BRD-ссылки. +- `ORCH_PLANE_WORKSPACE_SLUG` — workspace в Plane-URL. + +## 4. Топология / деплой +- Контейнеры, порты, сети — **без изменений**. Новый ключ читается из `.env` при старте + (`pydantic Settings`, `env_prefix=ORCH_`). +- Деплой self (ORCH) — штатный, через обязательный `deploy-staging` (8501) перед прод-деплоем + (`orchestrator`, 8500). Рестарт прода сверх обычного деплоя НЕ требуется. +- Документировать ключ в env-карте: `CLAUDE.md` и/или `docs/operations/INFRA.md` (в том же PR). diff --git a/docs/work-items/ORCH-017/10-tech-risks.md b/docs/work-items/ORCH-017/10-tech-risks.md new file mode 100644 index 0000000..1da6cc8 --- /dev/null +++ b/docs/work-items/ORCH-017/10-tech-risks.md @@ -0,0 +1,19 @@ +# 10-Tech Risks — ORCH-017 + +Work Item: **ORCH-017** · Repo: `orchestrator` +Опирается на ADR-001. Шкала: вероятность × влияние. + +| ID | Риск | Вер. | Влияние | Митигация | +|----|------|------|---------|-----------| +| R-1 | **Self-hosting: уведомление роняет поток.** Исключение при построении ссылок (нет данных в `tasks`, неконсистентный реестр проектов) прерывает `notify_approve_requested` и тормозит конвейер всех проектов. | Низк. | Выс. | NFR-1/ADR Р-4: один SELECT в `try/except`, каждая ссылка строится независимо и опускается при нехватке данных; сообщение и призыв отправляются всегда. Тест на ветви degradation (`tests/test_notify_approve_links.py`). | +| R-2 | **Битый/непубличный Plane-URL.** Фоллбэк на `plane_api_url=localhost:8091` дал бы некликабельную ссылку снаружи хоста (нарушение AC-2). | Сред. | Сред. | ADR Р-3 loopback-guard: при пустом/loopback базовом URL Plane-ссылка опускается, а не вставляется битой. Значение `ORCH_PLANE_WEB_URL` подтверждает Owner/INFRA (`07-infra-requirements.md`). | +| R-3 | **Несовпадение браузерного роута Plane.** Формат `/{workspace}/projects/{id}/issues/{id}/` зависит от версии Plane; иной роут → ссылка ведёт в никуда (открывается, но не на ту issue). | Низк. | Сред. | Проверить роут на одной реальной задаче после задания `ORCH_PLANE_WEB_URL` (acceptance в staging). uuid `plane_issue_id` детерминирован — ошибка может быть только в шаблоне пути, не в идентификаторе. | +| R-4 | **Поломка HTML-разметки сообщения.** Неэкранированная динамическая подпись (напр. символы `<`/`&` в `work_item_id`/title) ломает `parse_mode="HTML"` → Telegram отвергает сообщение. | Низк. | Сред. | NFR-3/ADR Р-4: `html.escape` на всех подписях; URL только из доверенных конфиг/БД-значений. Тест на спецсимволы. | +| R-5 | **Регрессия «дубль-сообщения».** Случайное добавление второго `send_telegram` или повторная отправка трекера как нового сообщения. | Низк. | Низк. | ADR Р-5: инвариант «один `send_telegram`», порядок действий зафиксирован; регресс-тесты `tests/test_telegram_tracker.py`, `tests/test_notify_done_regression.py`. | +| R-6 | **Zero-padding identifier.** Короткий `browse/` промахнулся бы по issue (`ORCH-017` vs `ORCH-17`). | — | — | Снят на корню: ADR Р-2 использует uuid `plane_issue_id`, короткий формат отклонён. | + +## Сводно +Изменение косметическое и изолированное: нет правок реестра гейтов/стадий, схемы БД, API и +разделяемой `send_telegram`. Главный класс риска — self-hosting-устойчивость (R-1) — закрыт +graceful-degradation контрактом ADR Р-4. Внешний незакрытый вход — значение `ORCH_PLANE_WEB_URL` +(R-2/R-3), проверяется в staging до прод-деплоя. diff --git a/docs/work-items/ORCH-017/12-review.md b/docs/work-items/ORCH-017/12-review.md new file mode 100644 index 0000000..fb10cc7 --- /dev/null +++ b/docs/work-items/ORCH-017/12-review.md @@ -0,0 +1,83 @@ +--- +type: review +work_item_id: ORCH-017 +verdict: REQUEST_CHANGES +version: 4 +--- + +# Review ORCH-017 + +## Summary +Основная фича (прямые BRD-/Plane-ссылки в `notify_approve_requested`) реализована +качественно и соответствует ТЗ, ADR-001 и всем критериям приёмки (подтверждено в +review v2: изменения по фиче — только `src/config.py` и `src/notifications.py`). + +P0 из review v3 (правка разделяемого гейта `check_tests_passed` коммитом `e62d51a`, +нарушавшая ADR-001 Р-5 и ТЗ §7) **снят**: коммит `d615747` откатил изменение +`src/qg/checks.py` (вынесено в отдельный work item ORCH-47 со своим ADR). Код гейта +теперь идентичен `main` (читает только `verdict:`/`status:`); ADR-001 Р-5 и ТЗ §7 +снова консистентны с кодом. ✔ + +Однако откат кода **не сопровождён откатом документации**: `CHANGELOG.md` и +`docs/architecture/README.md` всё ещё описывают откаченную правку гейта и ссылаются +на не существующие в этом PR тесты `tests/test_qg.py`. Это новый doc↔code конфликт +(golden source). → REQUEST_CHANGES (P1). + +## Соответствие ТЗ +- §3.1–§3.2, §4–§6 (фича уведомления) — выполнено. `_build_brd_link` / + `_build_plane_issue_link` строят ссылки независимо, встроены в текст одного + сообщения; призыв «Переведите задачу в статус Approved …» сохранён; + `html.escape` на динамике; порядок `mark_brd_review_started → update_task_tracker + → send_telegram(msg)` соблюдён; `Settings.plane_web_url` + фолбэк добавлены. ✔ +- §7 — соблюдено. Реестр `QG_CHECKS`, стадии и машинные вердикты в коде не меняются + (правка гейта откачена в `d615747`). ✔ + +## Соответствие ADR +- ADR-001 (Р-1…Р-5) — соблюдён. Ссылки HTML-`` в тексте, `send_telegram` не + тронута; полный Plane-URL по uuid; `ORCH_PLANE_WEB_URL` + loopback-guard + (`_is_loopback_base`); graceful degradation; «одно сообщение, без дублей». ✔ +- ADR-001 Р-5 vs код — конфликт снят откатом гейта. ✔ + +## Качество кода +Фича `notifications.py`/`config.py` — без замечаний. Чтение полей задачи +(`_get_task_link_fields`) и обе сборки ссылок защищены try/except и никогда не +роняют alert (AC-6); loopback-guard корректно опускает неклика­бельный Plane-URL +(AC-2/AC-3); `html.escape(..., quote=True)` на href и `html.escape(work_item_id)` +на подписи (AC-7). Тесты `tests/test_notify_approve_links.py`, +`tests/test_analysis_approve_flow_links.py` присутствуют и содержательны. + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix +- [ ] **Документация описывает откаченный код (doc↔code конфликт).** После + revert-коммита `d615747` код `src/qg/checks.py` НЕ читает `result:` (только + `verdict:`/`status:`), но документация осталась от состояния `e62d51a`: + - `docs/architecture/README.md:61` утверждает, что `check_tests_passed` + читает `verdict:`/`status:`/`result:` — это ложно для текущего кода и + вводит в заблуждение по поведению разделяемого прод-гейта (self-hosting: + tester, написавший только `result: PASS`, реально провалит гейт). + - `CHANGELOG.md:24` (секция Fixed) содержит запись о правке гейта + `check_tests_passed` под тегом ORCH-017 и ссылается на отсутствующие в PR + тесты `tests/test_qg.py::TestCheckTestsPassed::test_result_pass_only_passes` + / `…::test_result_fail_only_fails`. + **Резолюция:** убрать из ORCH-017 PR обе записи (откатить README:61 к + формулировке `main` и удалить CHANGELOG-entry про гейт) — правка гейта + принадлежит ORCH-47 и должна документироваться там вместе с её кодом. + +### P2 — Should fix +- [ ] `13-test-report.md` (`result: PASS`) относится к прогону, включавшему + откаченную правку гейта; после устранения P1 канонический ре-тест — на + стадии testing (отчёт не должен ссылаться на снятые из PR изменения). + +## Документация +Правило «изменён `src/` → обновлена документация в том же PR» по фиче уведомления — +выполнено: `CHANGELOG.md` (Added), `.env.example` (`ORCH_PLANE_WEB_URL`), +`docs/operations/INFRA.md` (env-карта), ADR-001. ✔ + +Неконсистентность (P1): документация про откаченную правку гейта `check_tests_passed` +осталась в `CHANGELOG.md` (Fixed) и `docs/architecture/README.md`, хотя +соответствующий код отозван (`d615747`) и перенесён в ORCH-47. Доку нужно привести в +соответствие с кодом этого PR. diff --git a/docs/work-items/ORCH-017/13-test-report.md b/docs/work-items/ORCH-017/13-test-report.md new file mode 100644 index 0000000..913eb50 --- /dev/null +++ b/docs/work-items/ORCH-017/13-test-report.md @@ -0,0 +1,91 @@ +--- +type: test-report +work_item_id: ORCH-017 +result: PASS +--- + +# Test Report — ORCH-017 + +Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве BRD. +Вердикт review (`12-review.md`): **APPROVED** ✔ — прогон регресса допущен. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (pytest-asyncio 0.23.8, anyio 4.13.0) +- Дата: 2026-06-05 +- Ветка: `feature/ORCH-017-brd-plane-telegram` +- Прод-контейнер `orchestrator` (8500) НЕ перезапускался; smoke — только read-only GET. + +## Smoke test API (prod, read-only) +| Endpoint | HTTP | Результат | +|----------|------|-----------| +| `GET /health` | 200 | `{"status":"ok","service":"orchestrator"}` — PASS | +| `GET /status` | 200 | active_tasks содержит task #35 ORCH-017 (stage=testing) — PASS | +| `GET /queue` | 200 | counts running=1, failed=0, breaker=closed, preflight ok — PASS | + +> `curl` в окружении отсутствует — smoke выполнен через `urllib.request` (GET, без побочных эффектов). + +## Результаты по test-plan (04-test-plan.yaml) + +| TC ID | Описание | Тест | Результат | +|-------|----------|------|-----------| +| TC-01 | BRD-ссылка на `01-brd.md` (Gitea branch-view) | `test_notify_approve_links::test_tc01_brd_link_present` | PASS | +| TC-02 | Plane-ссылка (web-URL+workspace+project+issue_id) | `…::test_tc02_plane_link_present` | PASS | +| TC-03 | Фоллбэки URL (gitea_public_url→gitea_url, plane_web_url→plane_api_url) | `…::test_tc03_url_fallbacks` | PASS | +| TC-04 | Сохранён призыв «Approved» | `…::test_tc04_keeps_approved_call_to_action` | PASS | +| TC-05 | Ровно одно пингующее сообщение (не silent) | `…::test_tc05_single_notifying_message` | PASS | +| TC-06 | Graceful: branch/issue=None — без исключения | `…::test_tc06_graceful_missing_branch_and_issue` | PASS | +| TC-07 | Пустой Plane-base → Plane-ссылка опущена, BRD остаётся | `…::test_tc07_plane_base_empty_drops_plane_link_keeps_brd` | PASS | +| TC-07b | Loopback Plane-base отбрасывается (доп.) | `…::test_tc07b_loopback_plane_base_dropped` | PASS | +| TC-08 | parse_mode=HTML, html.escape, валидная разметка | `…::test_tc08_html_escaped_and_valid_markup` | PASS | +| TC-08b | send_telegram сохраняет parse_mode=HTML (доп.) | `…::test_tc08b_send_telegram_keeps_parse_mode_html` | PASS | +| TC-09 | Регрессия трекера (silent edit, без дублей) | `test_telegram_tracker.py` (полный набор) | PASS | +| TC-10 | Поток analysis-approved строит ссылки из БД | `test_analysis_approve_flow_links::test_tc10_approved_flow_builds_links_from_db` | PASS | +| TC-11 | (Условный) inline-кнопки | — | N/A — вариант кнопок отклонён (ADR-001 Р-1) | +| TC-12 | (Условный) обратная совместимость send_telegram c reply_markup | — | N/A — вариант кнопок отклонён (ADR-001 Р-1) | + +Все запланированные тесты (TC-01…TC-10) — PASS. Условные TC-11/TC-12 не применимы: +ADR-001 (Р-1) зафиксировал HTML-ссылки в тексте без изменения сигнатуры `send_telegram`. + +## Покрытие критериев приёмки (03-acceptance-criteria.md) +| AC | Покрывающие TC | Статус | +|----|----------------|--------| +| AC-1 | TC-01, TC-10 | PASS | +| AC-2 | TC-02, TC-10 | PASS | +| AC-3 | TC-01, TC-02, TC-03 | PASS | +| AC-4 | TC-04 | PASS | +| AC-5 | TC-05, TC-09 | PASS | +| AC-6 | TC-06, TC-07, TC-07b | PASS | +| AC-7 | TC-08, TC-08b | PASS | +| AC-8 | TC-09, TC-10 | PASS | +| AC-9 | проверено review (CHANGELOG/.env.example/INFRA.md/ADR) | PASS | +| AC-10 | полный регресс `pytest tests/` | PASS | + +## Вывод pytest + +### Целевые тесты ORCH-017 +``` +tests/test_notify_approve_links.py::test_tc01_brd_link_present PASSED +tests/test_notify_approve_links.py::test_tc02_plane_link_present PASSED +tests/test_notify_approve_links.py::test_tc03_url_fallbacks PASSED +tests/test_notify_approve_links.py::test_tc04_keeps_approved_call_to_action PASSED +tests/test_notify_approve_links.py::test_tc05_single_notifying_message PASSED +tests/test_notify_approve_links.py::test_tc06_graceful_missing_branch_and_issue PASSED +tests/test_notify_approve_links.py::test_tc07_plane_base_empty_drops_plane_link_keeps_brd PASSED +tests/test_notify_approve_links.py::test_tc07b_loopback_plane_base_dropped PASSED +tests/test_notify_approve_links.py::test_tc08_html_escaped_and_valid_markup PASSED +tests/test_notify_approve_links.py::test_tc08b_send_telegram_keeps_parse_mode_html PASSED +tests/test_analysis_approve_flow_links.py::test_tc10_approved_flow_builds_links_from_db PASSED +11 passed in 0.53s +``` + +### Полный регресс +``` +======================== 434 passed, 1 warning in 7.99s ======================== +``` +Единственное предупреждение — PydanticDeprecatedSince20 (`src/config.py:4`, class-based config), +предсуществующее, к ORCH-017 не относится, на результат не влияет. + +## Итог +**PASS** — 434/434 теста зелёные, целевые TC-01…TC-10 пройдены, все 10 критериев приёмки +покрыты, smoke API прод-инстанса OK. Задача готова к стадии **deploy-staging**. diff --git a/src/config.py b/src/config.py index d8869c1..6da4e98 100644 --- a/src/config.py +++ b/src/config.py @@ -4,6 +4,11 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): # Plane plane_api_url: str = "http://localhost:8091" + # ORCH-017: external (browser) web URL of Plane for clickable issue links in + # notifications, e.g. https://plane.example.org. Falls back to plane_api_url, + # but a loopback fallback (localhost/127.0.0.1) is treated as "no web URL" and + # the Plane link is omitted (see notifications._build_plane_issue_link). + plane_web_url: str = "" plane_api_token: str = "" plane_workspace_slug: str = "" plane_webhook_secret: str = "" diff --git a/src/notifications.py b/src/notifications.py index c445677..0d1876f 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -544,6 +544,105 @@ def notify_qg_failure(task_id: int, stage: str, check: str, reason: str): logger.warning(f"\u26a0\ufe0f {work_item_id}: QG {check} \u2014 failed: {reason}") +# ORCH-017: hosts that are not clickable off the deploy box. A Plane web-base +# resolving to one of these (the plane_api_url loopback default) means "no usable +# browser URL" -> the Plane link is omitted rather than emitted broken (ADR-001 Р-3). +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1"}) + + +def _is_loopback_base(url: str) -> bool: + """True if the URL's host is a loopback/local address (not clickable off-host). + + Empty/garbage URLs count as loopback (i.e. unusable) so callers omit the link. + """ + if not url: + return True + try: + from urllib.parse import urlparse + host = (urlparse(url).hostname or "").lower() + return (not host) or host in _LOOPBACK_HOSTS + except Exception: + return True + + +def _get_task_link_fields(task_id: int): + """ORCH-017: read (repo, branch, plane_issue_id) for a task. Never raises. + + Returns (None, None, None) on any error / missing row so link building can + degrade gracefully (AC-6). + """ + try: + from .db import get_db + conn = get_db() + row = conn.execute( + "SELECT repo, branch, plane_issue_id FROM tasks WHERE id=?", (task_id,) + ).fetchone() + conn.close() + if not row: + return None, None, None + return row["repo"], row["branch"], row["plane_issue_id"] + except Exception as e: + logger.warning(f"_get_task_link_fields({task_id}) failed: {e}") + return None, None, None + + +def _build_brd_link(repo, branch, work_item_id) -> str | None: + """ORCH-017: '' to 01-brd.md in Gitea branch-view, or None if data missing. + + Mirrors the canonical branch-view pattern in src/usage.py: base = + gitea_public_url or gitea_url, owner = gitea_owner (AC-1/AC-3). The href is + html.escaped as defence-in-depth even though parts come from trusted + config/DB (AC-7). + """ + s = _get_settings() + base = ( + getattr(s, "gitea_public_url", "") or getattr(s, "gitea_url", "") + ).rstrip("/") + owner = getattr(s, "gitea_owner", "") + if not (base and owner and repo and branch and work_item_id): + return None + url = ( + f"{base}/{owner}/{repo}/src/branch/{branch}" + f"/docs/work-items/{work_item_id}/01-brd.md" + ) + return ( + f'' + f"\U0001f4c4 Открыть BRD" + ) + + +def _build_plane_issue_link(repo, plane_issue_id) -> str | None: + """ORCH-017: '' 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). + """ + 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 + 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'' + f"✅ Задача в Plane" + ) + + def notify_approve_requested(task_id: int): """ALERT (separate, notifying): BRD/TZ/AC ready -> flip Plane to Approved. @@ -557,10 +656,27 @@ 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 {work_item_id}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. " + f"\U0001f4cb {html.escape(work_item_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." ) + # ORCH-017: embed direct links to the BRD doc (Gitea) and the Plane issue so + # the reviewer can open both straight from the ping. Each link is built + # independently and omitted if its data is missing; building is defensive so + # it can NEVER break the alert (AC-1/AC-2/AC-6). Still exactly one notifying + # message (AC-5); the call to action above is always preserved (AC-4). + try: + repo, branch, plane_issue_id = _get_task_link_fields(task_id) + links = [ + link for link in ( + _build_brd_link(repo, branch, work_item_id), + _build_plane_issue_link(repo, plane_issue_id), + ) if link + ] + if links: + msg = msg + "\n\n" + "\n".join(links) + except Exception as e: + logger.warning(f"notify_approve_requested({task_id}): link build failed: {e}") logger.info(msg) update_task_tracker(task_id) send_telegram(msg) # separate, notifying diff --git a/tests/test_analysis_approve_flow_links.py b/tests/test_analysis_approve_flow_links.py new file mode 100644 index 0000000..296c259 --- /dev/null +++ b/tests/test_analysis_approve_flow_links.py @@ -0,0 +1,100 @@ +"""ORCH-017 / TC-10: analysis-approved flow wires DB fields into the approve ping. + +When the analyst's artifacts are ready, `_handle_analysis_approved_flow` sets the +issue In Review, posts the analyst comment, and calls `notify_approve_requested`. +This test drives that flow with all network side-effects mocked and asserts the +resulting Telegram ping carries the BRD + Plane links built from the task's DB +row (repo / branch / plane_issue_id), while the approval gate name and the +no-self-advance contract are unchanged (AC-1 / AC-2 / AC-8). +""" + +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_approve_flow.db") +os.environ["ORCH_DB_PATH"] = _test_db + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import notifications as N # noqa: E402 +from src import stage_engine as SE # 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() + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _mk_task(monkeypatch): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, " + "plane_issue_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ("p1", "ORCH-017", "orchestrator", + "feature/ORCH-017-brd-plane-telegram", "analysis", + "Approve flow", "issue-uuid-7"), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def test_tc10_approved_flow_builds_links_from_db(monkeypatch): + tid = _mk_task(monkeypatch) + + # Settings that make both links resolvable. + s = N._get_settings() + monkeypatch.setattr(s, "gitea_public_url", "https://git.example.org", raising=False) + monkeypatch.setattr(s, "gitea_owner", "orchteam", raising=False) + monkeypatch.setattr(s, "plane_web_url", "https://plane.example.org", raising=False) + monkeypatch.setattr(s, "plane_workspace_slug", "acme", raising=False) + + # Isolate every network/fs side-effect of the flow. + monkeypatch.setitem(SE.QG_CHECKS, "check_analysis_complete", + lambda repo, wid, branch: (True, "ok")) + monkeypatch.setattr(SE, "set_issue_in_review", lambda wid: None) + monkeypatch.setattr(SE, "plane_add_comment", lambda *a, **k: None) + monkeypatch.setattr(SE, "_build_analyst_ready_comment", lambda *a, **k: "c") + + # Capture the approve ping; stub the tracker refresh. + calls = [] + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: calls.append(text) or 1) + monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None) + + result = SE.AdvanceResult() + SE._handle_analysis_approved_flow( + tid, "analysis", "orchestrator", "ORCH-017", + "feature/ORCH-017-brd-plane-telegram", "analyst", result, + ) + + # Gate name + no-self-advance contract unchanged (AC-8). + assert result.qg_name == "check_analysis_approved" + assert result.note == "analysis-in-review" + assert result.advanced is False + + # Exactly one ping carrying both links built from the DB row (AC-1 / AC-2). + assert len(calls) == 1 + text = calls[0] + assert ( + "https://git.example.org/orchteam/orchestrator/src/branch/" + "feature/ORCH-017-brd-plane-telegram/docs/work-items/ORCH-017/01-brd.md" + ) in text + assert ( + f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}" + f"/issues/issue-uuid-7/" + ) in text diff --git a/tests/test_notify_approve_links.py b/tests/test_notify_approve_links.py new file mode 100644 index 0000000..1190870 --- /dev/null +++ b/tests/test_notify_approve_links.py @@ -0,0 +1,284 @@ +"""ORCH-017: tests for the direct BRD + Plane links in the approve-gate ping. + +`notify_approve_requested` builds ONE notifying Telegram message that embeds: + * a Gitea branch-view link to docs/work-items//01-brd.md (AC-1) + * a Plane issue browser link (AC-2) + +Both links use external base URLs with documented fallbacks (AC-3), degrade +gracefully when data is missing / the Plane base is loopback (AC-6), keep the +'flip to Approved' call to action (AC-4), send exactly one notifying message +(AC-5) and stay HTML-safe (AC-7). + +Network is isolated: send_telegram is replaced with an in-test recorder, the DB +is a temp SQLite seeded by a fixture. Mapping to acceptance criteria is in each +test's docstring (test ids TC-01..TC-08 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_approve_links.db") +os.environ["ORCH_DB_PATH"] = _test_db + +from unittest.mock import MagicMock, patch # noqa: E402 + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import notifications as N # noqa: E402 + +# Captured at import time, BEFORE the conftest autouse fixture stubs it to a +# no-op, so TC-08 can exercise the REAL send_telegram (parse_mode=HTML) end-to-end. +_ORIG_SEND_TELEGRAM = N.send_telegram + +# 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() + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _mk_task(wid="ORCH-017", repo="orchestrator", + branch="feature/ORCH-017-brd-plane-telegram", + plane_issue_id="11112222-3333-4444-5555-666677778888", + title="Links in approve ping", stage="analysis"): + 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, branch, stage, title, plane_issue_id), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _set(monkeypatch, **kw): + """Set settings attrs on the singleton notifications actually reads.""" + s = N._get_settings() + for k, v in kw.items(): + monkeypatch.setattr(s, k, v, raising=False) + + +def _record_send(monkeypatch): + """Replace send_telegram with a recorder; returns the calls list.""" + calls = [] + + def _fake(text, disable_notification=False): + calls.append({"text": text, "silent": disable_notification}) + return 1 + + monkeypatch.setattr(N, "send_telegram", _fake) + # Tracker refresh is irrelevant here and would hit send_telegram too -> stub. + monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None) + return calls + + +# --------------------------------------------------------------------------- # +# TC-01 — BRD link (Gitea branch-view), AC-1 / AC-3 +# --------------------------------------------------------------------------- # +def test_tc01_brd_link_present(monkeypatch): + tid = _mk_task() + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_url="http://localhost:3000", gitea_owner="orchteam") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + assert len(calls) == 1 + text = calls[0]["text"] + expected = ( + 'https://git.example.org/orchteam/orchestrator/src/branch/' + 'feature/ORCH-017-brd-plane-telegram/docs/work-items/ORCH-017/01-brd.md' + ) + assert expected in text + assert f'' in text # clickable, points at 01-brd.md + + +# --------------------------------------------------------------------------- # +# TC-02 — Plane issue link (external web URL + workspace + project + issue id) +# AC-2 / AC-3 +# --------------------------------------------------------------------------- # +def test_tc02_plane_link_present(monkeypatch): + tid = _mk_task(plane_issue_id="abcd-issue-uuid") + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_api_url="http://localhost:8091", plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + text = calls[0]["text"] + expected = ( + f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}" + f"/issues/abcd-issue-uuid/" + ) + assert expected in text + assert f'' in text + + +# --------------------------------------------------------------------------- # +# TC-03 — fallback chain: gitea_public_url -> gitea_url, plane_web_url -> plane_api_url +# AC-3 +# --------------------------------------------------------------------------- # +def test_tc03_url_fallbacks(monkeypatch): + tid = _mk_task(plane_issue_id="iss-1") + _set(monkeypatch, + gitea_public_url="", gitea_url="https://git-fallback.example.org", + gitea_owner="orchteam", + plane_web_url="", plane_api_url="https://plane-fallback.example.org", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + text = calls[0]["text"] + # BRD link uses gitea_url fallback. + assert "https://git-fallback.example.org/orchteam/orchestrator/" in text + # Plane link uses plane_api_url fallback (non-loopback -> allowed). + assert ( + f"https://plane-fallback.example.org/acme/projects/{_ORCH_PROJECT_ID}" + f"/issues/iss-1/" + ) in text + + +# --------------------------------------------------------------------------- # +# TC-04 — the 'flip to Approved' call to action is preserved. AC-4 +# --------------------------------------------------------------------------- # +def test_tc04_keeps_approved_call_to_action(monkeypatch): + tid = _mk_task() + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + assert "Approved" in calls[0]["text"] + + +# --------------------------------------------------------------------------- # +# TC-05 — exactly one notifying (non-silent) message. AC-5 +# --------------------------------------------------------------------------- # +def test_tc05_single_notifying_message(monkeypatch): + tid = _mk_task() + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + assert len(calls) == 1 + assert calls[0]["silent"] is not True # notifying ping, not silent + + +# --------------------------------------------------------------------------- # +# TC-06 — graceful: no branch / no plane_issue_id -> still one message, missing +# links omitted, no exception. AC-6 +# --------------------------------------------------------------------------- # +def test_tc06_graceful_missing_branch_and_issue(monkeypatch): + tid = _mk_task(branch=None, plane_issue_id=None) + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) # must not raise + + assert len(calls) == 1 + text = calls[0]["text"] + assert "Approved" in text # message still sent + assert "01-brd.md" not in text # BRD link omitted (no branch) + assert "/issues/" not in text # Plane link omitted (no issue id) + + +# --------------------------------------------------------------------------- # +# TC-07 — Plane base unusable (web url empty + api url empty) -> Plane link +# dropped, BRD link stays, orchestrator survives. AC-6 +# --------------------------------------------------------------------------- # +def test_tc07_plane_base_empty_drops_plane_link_keeps_brd(monkeypatch): + tid = _mk_task() + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", + plane_web_url="", plane_api_url="", plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + text = calls[0]["text"] + assert "01-brd.md" in text # BRD link survives + assert "/issues/" not in text # Plane link dropped + + +def test_tc07b_loopback_plane_base_dropped(monkeypatch): + """Loopback fallback (plane_api_url=localhost) must NOT emit a broken link.""" + tid = _mk_task() + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", + plane_web_url="", plane_api_url="http://localhost:8091", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + text = calls[0]["text"] + assert "localhost" not in text # no loopback URL leaks into the ping + assert "/issues/" not in text # Plane link dropped by loopback-guard + assert "01-brd.md" in text + + +# --------------------------------------------------------------------------- # +# TC-08 — HTML safety: parse_mode=HTML preserved + dynamic parts escaped + valid +# markup. AC-7 +# --------------------------------------------------------------------------- # +def test_tc08_html_escaped_and_valid_markup(monkeypatch): + # work_item_id with an ampersand exercises html.escape on the dynamic label. + tid = _mk_task(wid="ORCH&17") + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + text = calls[0]["text"] + + # Dynamic work_item_id escaped in the header (no raw '&' before a word). + assert "ORCH&17" in text + # Well-formed anchor markup: equal number of opening/closing tags. + assert text.count("") + assert text.count("