developer(ET): auto-commit from developer run_id=363
This commit is contained in:
@@ -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<max`→`queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`).
|
||||
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
|
||||
- **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). Все алерты, упоминающие `work_item_id`, делают номер кликабельным. Контракт всего компонента — never raises; карточка всегда silent. Детали — [internals.md](internals.md) §7.
|
||||
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
|
||||
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость).
|
||||
|
||||
|
||||
@@ -111,12 +111,12 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash
|
||||
|
||||
Вместо ~15 отдельных сообщений на задачу оркестратор держит **ОДНУ** live-карточку на задачу (`update_task_tracker`), которая обновляется на каждом переходе стадии. Текст рендерится статически из БД (`render_task_tracker`: стадии, токены, стоимость, BRD-подтверждение, итоги). Карточка всегда тихая (`disable_notification=True`); отдельные пинги шлют только `notify_approve_requested` / `notify_error`. `message_id` хранится в `tasks.tracker_message_id`; helpers `get_tracker_message_id` / `set_tracker_message_id`. Контракт всего компонента — **never raises**.
|
||||
|
||||
**Режимы (ORCH-042, `ORCH_TRACKER_MODE` → `Settings.tracker_mode`).** Резолвится в `update_task_tracker` (case-insensitive, trim); всё, что ≠ `"bump"` (включая пустое/мусор/None), трактуется как `edit` → нулевая регрессия и безопасный фолбэк. Инвариант «одна карточка на задачу» сохраняется в обоих режимах.
|
||||
**Режимы (ORCH-042, `ORCH_TRACKER_MODE` → `Settings.tracker_mode`; дефолт переключён `edit → bump` в ORCH-067).** Резолвится в `update_task_tracker` (case-insensitive, trim); всё, что ≠ `"bump"` (включая пустое/мусор/None), трактуется как `edit` → безопасный фолбэк. Инвариант «одна карточка на задачу» сохраняется в обоих режимах.
|
||||
|
||||
| Режим | Поведение при обновлении |
|
||||
|-------|--------------------------|
|
||||
| `edit` (дефолт) | первый вызов → `send_telegram` (тихо) + сохранение `message_id`; далее → `edit_telegram` на сохранённый id. Новое сообщение шлётся ТОЛЬКО при `EDIT_GONE` (удалено/старше 48ч/невалидный id). `EDIT_NOT_MODIFIED` / `EDIT_FAILED` → нового сообщения нет (анти-дубль). |
|
||||
| `bump` | карточка пересоздаётся внизу чата: best-effort `delete_telegram(старый_id)` → `send_telegram(text, disable_notification=True)` → `set_tracker_message_id(new_id)` **только** при успешном send (`new_mid is not None`). За один вызов — не более одного нового сообщения. |
|
||||
| `bump` (дефолт, ORCH-067) | карточка пересоздаётся внизу чата: best-effort `delete_telegram(старый_id)` → `send_telegram(text, disable_notification=True)` → `set_tracker_message_id(new_id)` **только** при успешном send (`new_mid is not None`). За один вызов — не более одного нового сообщения. Живая карточка всегда «догоняет» переписку. |
|
||||
| `edit` | первый вызов → `send_telegram` (тихо) + сохранение `message_id`; далее → `edit_telegram` на сохранённый id. Новое сообщение шлётся ТОЛЬКО при `EDIT_GONE` (удалено/старше 48ч/невалидный id). `EDIT_NOT_MODIFIED` / `EDIT_FAILED` → нового сообщения нет (анти-дубль). |
|
||||
|
||||
**`delete_telegram(message_id) -> 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 — индикация).** Под заголовком карточка несёт строку `📍 <Plane-статус>` по модели 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)` без `<a>`; динамические части экранируются, `<a>`-разметка валидна под `parse_mode=HTML`. Алерты `stage_engine`/`launcher`/`security_gate`/`reconciler` переведены на `link_for` (резолвит `repo`+`plane_issue_id` из БД по `task_id` или `work_item_id`).
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -400,12 +400,27 @@ 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
|
||||
|
||||
class Config:
|
||||
env_prefix = "ORCH_"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (секреты проверены оффлайн).")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
206
tests/test_notify_issue_links.py
Normal file
206
tests/test_notify_issue_links.py
Normal file
@@ -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"
|
||||
101
tests/test_plane_issue_link.py
Normal file
101
tests/test_plane_issue_link.py
Normal file
@@ -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
|
||||
@@ -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():
|
||||
|
||||
159
tests/test_tracker_bump_default.py
Normal file
159
tests/test_tracker_bump_default.py
Normal file
@@ -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
|
||||
158
tests/test_tracker_issue_link.py
Normal file
158
tests/test_tracker_issue_link.py
Normal file
@@ -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)
|
||||
216
tests/test_tracker_status_line.py
Normal file
216
tests/test_tracker_status_line.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user