diff --git a/docs/architecture/README.md b/docs/architecture/README.md index abd7160..dd6360c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -13,6 +13,7 @@ - **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. - **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts bool`** (low-level, never raises). Семантика возврата — «исчезло ли старое сообщение»: - `ok:true` → `True`; @@ -128,6 +128,12 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash **Текст карточки (оба режима, ORCH-042):** метка `Подтверждение BRD` (была «Ревью БРД»); после прохождения approve-gate строка BRD начинается с ✅ (ветка ожидания сохраняет ⏸️/⏳); русские display-labels стадий (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`); финальная строка `📦 Внедрено` (было `deployed`). Меняются только отображаемые строки — ключи стадий и имена агентов (завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются. +**Строка Plane-статуса и кликабельный номер (ORCH-067, слой B — индикация).** Под заголовком карточка несёт строку `📍 ` по модели ORCH-066. Источник — двухслойный, контракт **never raises**: +- **Оффлайн-ядро** `plane_status_label(task_row)` — чистая функция БЕЗ сети: `stage → статус` (`created→To Analyse`, `analysis→Analysis`, `architecture→Architecture`, `development→Development`, `review→Code-Review`, `testing→Testing`, `deploy→⏸️ Awaiting Deploy`, `done→Done`) + `⏸️ In Review` из brd-часов (`brd_review_started_at` задан, `…_ended_at` пуст). Неизвестная/битая стадия → безопасный дефолт `To Analyse`. +- **Live-overlay** `_live_plane_branch_override` — best-effort: дорисовывает ветви-статусы, неразличимые оффлайн (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring after Deploy), чтением живого Plane-статуса (`fetch_issue_state` с коротким `tracker_live_status_timeout_s`, TTL-кэш `tracker_live_status_ttl_s`, kill-switch `tracker_live_status`). Любой сбой / выключенный флаг / нехватка данных → оффлайн-метка; `⏸️ In Review` (авторитет brd-часов) overlay не консультирует. Анти-false-positive: `deploying/monitoring`, алиасящие базовый UUID на проекте без выделенного статуса (enduro), не вызывают override. + +**Кликабельный номер задачи (ORCH-067).** Номер в заголовке карточки И во всех уведомлениях орка, где упоминается `work_item_id`, — HTML-ссылка на issue в Plane через общий `plane_issue_link` / `link_for` (URL строит `_plane_issue_url` с loopback/workspace/project-гардами, переиспользуя резолв ORCH-017). Fail-safe: при нехватке любого из (web-base/не-loopback, workspace, project_id, plane_issue_id) → `html.escape(work_item_id)` без ``; динамические части экранируются, ``-разметка валидна под `parse_mode=HTML`. Алерты `stage_engine`/`launcher`/`security_gate`/`reconciler` переведены на `link_for` (резолвит `repo`+`plane_issue_id` из БД по `task_id` или `work_item_id`). + ## Database Schema ```sql diff --git a/src/agents/launcher.py b/src/agents/launcher.py index b356eb1..d83f6c0 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -682,8 +682,8 @@ class AgentLauncher: "\u274c Deploy FAILED (smoke/healthcheck). Rolled back. Developer \u043d\u0443\u0436\u0435\u043d \u0434\u043b\u044f \u0444\u0438\u043a\u0441\u0430.", author="deployer", ) - from ..notifications import send_telegram - send_telegram(f"\U0001f6a8 {_wid}: Deploy failed! Rolled back. Needs fix.") + from ..notifications import send_telegram, link_for + send_telegram(f"\U0001f6a8 {link_for(_wid)}: Deploy failed! Rolled back. Needs fix.") # Notify on startup timeout (exit_code from kill = -9 or 137) if exit_code != 0 and exit_code not in (None,): @@ -695,8 +695,8 @@ class AgentLauncher: conn.close() if task_row and agent != "deployer": # deployer handled above _tid, _wid = task_row - from ..notifications import send_telegram - send_telegram(f"\u26a0\ufe0f {_wid}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log") + from ..notifications import send_telegram, link_for + send_telegram(f"\u26a0\ufe0f {link_for(_wid, _tid)}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log") # Feature 4 + ORCH-016: post the unified per-agent status comment under # that agent's bot, threading the wall-clock duration we just measured diff --git a/src/config.py b/src/config.py index b9ad1e3..2866265 100644 --- a/src/config.py +++ b/src/config.py @@ -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_" diff --git a/src/notifications.py b/src/notifications.py index 18d01a4..a688fd1 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -307,7 +307,7 @@ def render_task_tracker(task_id: int) -> str: conn = get_db() task = conn.execute( "SELECT id, work_item_id, title, stage, created_at, updated_at, " - "brd_review_started_at, brd_review_ended_at " + "brd_review_started_at, brd_review_ended_at, repo, plane_issue_id " "FROM tasks WHERE id=?", (task_id,), ).fetchone() @@ -358,13 +358,27 @@ def render_task_tracker(task_id: int) -> str: agent_seconds += d esc_title = html.escape(title) + # ORCH-067 (req 3): the issue number in the header is now a clickable link to + # the Plane issue (degrades to the escaped number when no web URL \u2014 fail-safe). + task_repo = _row_get(task, "repo") + task_issue_id = _row_get(task, "plane_issue_id") + num_html = plane_issue_link(work_item_id, plane_issue_id=task_issue_id, repo=task_repo) header = ( - f"\U0001f389 {html.escape(work_item_id)} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e" + f"\U0001f389 {num_html} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e" if done - else f"\U0001f6e0\ufe0f {html.escape(work_item_id)} \u00b7 {esc_title}" + else f"\U0001f6e0\ufe0f {num_html} \u00b7 {esc_title}" ) bar = "\u2501" * 22 - lines = [header, bar] + # ORCH-067 (req 2): a Plane-status line (model ORCH-066) under the header. + # Built fail-safe: any error degrades to a stage default, never breaks render. + try: + status_label = _card_status_label( + task, repo=task_repo, plane_issue_id=task_issue_id + ) + except Exception: + status_label = _DEFAULT_STATUS_LABEL + status_line = f"\U0001f4cd {status_label}" + lines = [header, status_line, bar] def _stage_line(label, run): usage = { @@ -704,38 +718,276 @@ def _build_brd_link(repo, branch, work_item_id) -> str | None: ) +def _plane_issue_url(repo, plane_issue_id, project_id=None) -> str | None: + """ORCH-067 (Р-5): build the Plane issue browser URL, or None if unbuildable. + + Single source of the URL + guards, shared by ``plane_issue_link`` (link text = + issue number) and ``_build_plane_issue_link`` (link text = '✅ Задача в Plane'), + so the project resolution and loopback-guard live in ONE place (ORCH-017 Р-2). + + Full path: ``{web_base}/{workspace}/projects/{project_id}/issues/{issue_id}/``. + web_base = plane_web_url or plane_api_url; a loopback base counts as "no web + URL" -> None. ``project_id`` is taken explicitly when given, else resolved from + ``repo``. Never raises. + """ + try: + s = _get_settings() + web_base = ( + getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "") + ).rstrip("/") + workspace = getattr(s, "plane_workspace_slug", "") + if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base): + return None + if not project_id: + try: + from .projects import get_project_by_repo + project = get_project_by_repo(repo) if repo else None + except Exception: + project = None + project_id = getattr(project, "plane_project_id", "") if project else "" + if not project_id: + return None + return ( + f"{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/" + ) + except Exception: + return None + + def _build_plane_issue_link(repo, plane_issue_id) -> str | None: """ORCH-017: '' 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'' f"✅ Задача в Plane" ) +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 ``ORCH-NNN`` 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'{label}' + 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 + markup under parse_mode=HTML (AC-14). + """ work_item_id = _get_work_item_id(task_id) if task_id else "system" - msg = f"\U0001f534 {work_item_id}: ERROR \u2014 {error}" + num = link_for(work_item_id, task_id) if task_id else html.escape(work_item_id) + msg = f"\U0001f534 {num}: ERROR \u2014 {html.escape(str(error))}" logger.error(msg) send_telegram(msg) # separate, notifying diff --git a/src/plane_sync.py b/src/plane_sync.py index 399a9c7..ca2ad62 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -402,7 +402,7 @@ def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None: return None -def fetch_issue_state(issue_id: str, project_id: str) -> str | None: +def fetch_issue_state(issue_id: str, project_id: str, timeout: int = 10) -> str | None: """ORCH-060 (F-1 Guard 2): GET the Plane issue and return its current state uuid. Used by the reconciler to honour an explicit human gate: an issue a person @@ -413,12 +413,16 @@ def fetch_issue_state(issue_id: str, project_id: str) -> str | None: Plane returns ``state`` as a bare uuid string; older shapes may nest it as a ``{"id": ...}`` dict — both are handled. + ORCH-067 (Р-4): ``timeout`` is optional (default 10s — unchanged for the + reconciler) so the tracker live-overlay can read with a SHORT timeout + (settings.tracker_live_status_timeout_s) on the synchronous render path. + Returns None on network error, non-2xx, or a missing field — never raises, so the caller can apply its conservative fallback (treat as "possibly blocked"). """ url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/" try: - resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10) + resp = httpx.get(url, headers=PLANE_HEADERS, timeout=timeout) resp.raise_for_status() state = resp.json().get("state") if isinstance(state, dict): diff --git a/src/reconciler.py b/src/reconciler.py index d25b4a3..5ae330e 100644 --- a/src/reconciler.py +++ b/src/reconciler.py @@ -67,7 +67,7 @@ from .plane_sync import ( list_issues_by_state, ) from .webhooks.plane import handle_status_start, handle_verdict -from .notifications import send_telegram +from .notifications import send_telegram, link_for from . import projects logger = logging.getLogger("orchestrator.reconciler") @@ -447,7 +447,7 @@ class Reconciler: if settings.reconcile_notify_unblock: try: send_telegram( - f"\U0001f527 reconciler: {work_item_id} {stage} " + f"\U0001f527 reconciler: {link_for(work_item_id)} {stage} " f"разблокирована (потерян webhook)" ) except Exception as e: # noqa: BLE001 - never break the tick diff --git a/src/security_gate.py b/src/security_gate.py index 05a33dc..2ac698f 100644 --- a/src/security_gate.py +++ b/src/security_gate.py @@ -670,9 +670,9 @@ def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool dep_result.detail, ) try: - from .notifications import send_telegram + from .notifications import send_telegram, link_for send_telegram( - f"⚠️ {work_item_id}: dep-audit недоступен фид CVE " + f"⚠️ {link_for(work_item_id)}: dep-audit недоступен фид CVE " f"({dep_result.detail}). " + ("Гейт fail-closed → FAIL." if settings.security_dep_audit_fail_closed else "Гейт fail-open → warning (секреты проверены оффлайн).") diff --git a/src/stage_engine.py b/src/stage_engine.py index 94e207b..c7bf165 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -44,6 +44,7 @@ from .notifications import ( notify_qg_failure, notify_approve_requested, send_telegram, + link_for, ) from .plane_sync import ( notify_stage_change as plane_notify_stage, @@ -611,7 +612,7 @@ def _handle_analysis_approved_flow( author="analyst", ) send_telegram( - f"\u2753 {work_item_id}: Analyst \u0437\u0430\u0434\u0430\u0451\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u044b. \u041e\u0442\u0432\u0435\u0442\u044c \u0432 Plane." + f"\u2753 {link_for(work_item_id)}: Analyst \u0437\u0430\u0434\u0430\u0451\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u044b. \u041e\u0442\u0432\u0435\u0442\u044c \u0432 Plane." ) result.note = "analysis-needs-input" return @@ -670,7 +671,7 @@ def _handle_qg_failure_rollbacks( ) else: send_telegram( - f"\u26a0\ufe0f {work_item_id}: Max developer retries (3) reached. " + f"\u26a0\ufe0f {link_for(work_item_id)}: Max developer retries (3) reached. " f"Manual intervention needed." ) result.alerted = True @@ -717,7 +718,7 @@ def _handle_qg_failure_rollbacks( else: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: Tests still failing after 3 developer " + f"\U0001f6a8 {link_for(work_item_id)}: Tests still failing after 3 developer " f"retries. Manual intervention needed." ) result.alerted = True @@ -774,7 +775,7 @@ def _handle_qg_failure_rollbacks( author="deployer", ) send_telegram( - f"\U0001f6a8 {work_item_id}: Staging FAILED ({reason}). " + f"\U0001f6a8 {link_for(work_item_id)}: Staging FAILED ({reason}). " f"Rolled back to development. Needs fix." ) result.alerted = True @@ -818,7 +819,7 @@ def _handle_qg_failure_rollbacks( author="deployer", ) send_telegram( - f"\U0001f6a8 {work_item_id}: Deploy FAILED ({reason}). " + f"\U0001f6a8 {link_for(work_item_id)}: Deploy FAILED ({reason}). " f"Rolled back to development. Needs fix." ) result.alerted = True @@ -914,7 +915,7 @@ def _handle_merge_gate_defer( else: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: merge-gate defer limit " + f"\U0001f6a8 {link_for(work_item_id)}: merge-gate defer limit " f"({settings.merge_defer_max_attempts}) reached (merge-lock busy). " f"Manual intervention needed." ) @@ -969,7 +970,7 @@ def _handle_merge_gate_rollback( else: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: Merge-gate still failing after " + f"\U0001f6a8 {link_for(work_item_id)}: Merge-gate still failing after " f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). " f"Manual intervention needed." ) @@ -1055,7 +1056,7 @@ def _handle_security_gate( else: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: Security-гейт still failing after " + f"\U0001f6a8 {link_for(work_item_id)}: Security-гейт still failing after " f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). " f"Manual intervention needed." ) @@ -1132,7 +1133,7 @@ def _handle_image_freshness( else: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: Staging image freshness still failing after " + f"\U0001f6a8 {link_for(work_item_id)}: Staging image freshness still failing after " f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). " f"Manual intervention needed." ) @@ -1190,7 +1191,7 @@ def _handle_self_deploy_phase_a( author="deployer", ) send_telegram( - f"\U0001f7e1 {work_item_id}: staging OK. Ждёт подтверждения ПРОД-деплоя " + f"\U0001f7e1 {link_for(work_item_id)}: staging OK. Ждёт подтверждения ПРОД-деплоя " f"(смените статус на «Confirm Deploy»)." ) logger.info( @@ -1225,7 +1226,7 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv "Повторите approve после устранения причины.", author="deployer", ) - send_telegram(f"⚠️ {work_item_id}: прод-деплой не запустился: {msg}") + send_telegram(f"⚠️ {link_for(work_item_id)}: прод-деплой не запустился: {msg}") logger.error(f"Task {task_id}: self-deploy initiate failed: {msg}") return @@ -1254,7 +1255,7 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv "Вердикт будет зафиксирован после health-check.", author="deployer", ) - send_telegram(f"\U0001f680 {work_item_id}: прод-деплой стартовал. Жду результат.") + send_telegram(f"\U0001f680 {link_for(work_item_id)}: прод-деплой стартовал. Жду результат.") logger.info( f"Task {task_id}: self-deploy Phase B — detached deploy initiated, " f"finalizer enqueued (job_id={new_job})" @@ -1365,7 +1366,7 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes try: merge_gate.note_not_merged_alert(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: ошибка merge-verify ({e}). " + f"\U0001f6a8 {link_for(work_item_id)}: ошибка merge-verify ({e}). " f"Задача удержана на `deploy` (НЕ done)." ) except Exception: # noqa: BLE001 - best-effort alert @@ -1423,7 +1424,7 @@ def run_deploy_finalizer(job: dict): if work_item_id: set_issue_blocked(work_item_id) send_telegram( - f"\U0001f6a8 {work_item_id}: deploy result не появился после " + f"\U0001f6a8 {link_for(work_item_id)}: deploy result не появился после " f"{settings.deploy_finalize_max_attempts} попыток. Нужно ручное вмешательство." ) logger.error( @@ -1444,7 +1445,7 @@ def run_deploy_finalizer(job: dict): f"✅ Прод-деплой успешен (health-check OK, exit {code}).", author="deployer", ) - send_telegram(f"✅ {work_item_id}: прод-деплой успешен (exit {code}).") + send_telegram(f"✅ {link_for(work_item_id)}: прод-деплой успешен (exit {code}).") # Drive the EXISTING deploy contracts via the gate verdict we just wrote. advance_stage( diff --git a/tests/test_config.py b/tests/test_config.py index 092395b..ea4d0cf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,9 +8,17 @@ builds a FRESH Settings() (the process-wide singleton is not mutated). from src.config import Settings -def test_tracker_mode_defaults_to_edit(monkeypatch): - # No env var -> default "edit" (TC-01 / AC-1). +def test_tracker_mode_defaults_to_bump(monkeypatch): + # ORCH-067 (TC-01 / AC-1): the default flipped edit -> bump. With no env var + # the card now re-creates at the bottom of the chat out of the box; edit + # stays available via ORCH_TRACKER_MODE=edit (see test below). monkeypatch.delenv("ORCH_TRACKER_MODE", raising=False) + assert Settings().tracker_mode == "bump" + + +def test_tracker_mode_reads_env_edit(monkeypatch): + # ORCH-067 (AC-4): edit mode is still available through the env var. + monkeypatch.setenv("ORCH_TRACKER_MODE", "edit") assert Settings().tracker_mode == "edit" diff --git a/tests/test_notify_issue_links.py b/tests/test_notify_issue_links.py new file mode 100644 index 0000000..8cdde58 --- /dev/null +++ b/tests/test_notify_issue_links.py @@ -0,0 +1,206 @@ +"""ORCH-067 — Group D: clickable issue number in ALL alerts (AC-13, AC-12). + +Every orchestrator alert that mentions a work_item_id now renders it as a Plane +hyperlink via the shared ``link_for`` / ``plane_issue_link`` helpers, and degrades +fail-safe to the raw (escaped) number when data is missing. This covers the +dedicated notify_* helpers (notify_approve_requested, notify_error) and asserts +the engine/launcher/security_gate/reconciler alert sites are wired to ``link_for`` +— the single DB-resolving helper those sites call. Network is isolated: +send_telegram is replaced with a recorder; the DB is a temp SQLite. + +Test ids TC-13, TC-14, TC-15 from 04-test-plan.yaml. +""" + +import os +import tempfile + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_notify_links.db") +os.environ["ORCH_DB_PATH"] = _test_db + +from types import SimpleNamespace # noqa: E402 + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +import src.projects as projects_mod # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import notifications as N # noqa: E402 + +_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" + + +@pytest.fixture(autouse=True) +def setup_db(monkeypatch): + monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + # Pin repo->project resolution so cross-file registry reloads can't strip + # 'orchestrator' and break the expected issue URL. + monkeypatch.setattr( + projects_mod, "get_project_by_repo", + lambda repo: (SimpleNamespace(plane_project_id=_ORCH_PROJECT_ID) + if repo == "orchestrator" else None), + ) + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _set(monkeypatch, **kw): + s = N._get_settings() + for k, v in kw.items(): + monkeypatch.setattr(s, k, v, raising=False) + + +def _mk_task(wid="ORCH-067", repo="orchestrator", title="notify links", + plane_issue_id="iss-1", stage="development"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, " + "plane_issue_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ("p1", wid, repo, "feature/ORCH-067-x", stage, title, plane_issue_id), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _record_send(monkeypatch): + calls = [] + + def _fake(text, disable_notification=False): + calls.append({"text": text, "silent": disable_notification}) + return 1 + + monkeypatch.setattr(N, "send_telegram", _fake) + monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None) + return calls + + +# --------------------------------------------------------------------------- # +# TC-13 / AC-13 — notify_approve_requested: number clickable, CTA + single ping +# --------------------------------------------------------------------------- # +def test_tc13_approve_requested_number_clickable(monkeypatch): + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_workspace_slug="acme", gitea_public_url="https://git.example.org", + gitea_owner="orchteam") + tid = _mk_task(plane_issue_id="iss-1") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + assert len(calls) == 1 # exactly one notifying ping + assert calls[0]["silent"] is not True + text = calls[0]["text"] + expected = ( + f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}" + f"/issues/iss-1/" + ) + assert f'ORCH-067' 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" 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 " & ") + + text = calls[0]["text"] + assert "