diff --git a/.env.example b/.env.example index ffdb5cc..42feaf5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ ORCH_PLANE_API_URL=http://plane-app-api-1:8000 +# External (browser) web URL of Plane for clickable issue links in notifications +# (ORCH-017). Falls back to ORCH_PLANE_API_URL; a loopback fallback is treated as +# "no web URL" and the Plane link is omitted. Example: https://plane.example.org +ORCH_PLANE_WEB_URL= ORCH_PLANE_API_TOKEN= ORCH_PLANE_WORKSPACE_SLUG= ORCH_PLANE_WEBHOOK_SECRET= diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbb8aa..53b13bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве** (ORCH-017): пингующее сообщение `notify_approve_requested` теперь встраивает две HTML-``-ссылки — на `docs/work-items//01-brd.md` (Gitea branch-view: `gitea_public_url`→`gitea_url`) и на issue в Plane (`{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/`). Новая настройка `ORCH_PLANE_WEB_URL` (внешний браузерный web-URL Plane; фолбэк на `plane_api_url`). **Loopback-guard:** если итоговый Plane web-base указывает на localhost/127.0.0.1/0.0.0.0/::1 или пуст — Plane-ссылка опускается (не выпускаем битый localhost-URL). Graceful degradation: каждая ссылка строится независимо и опускается при нехватке данных, сообщение и призыв «Переведите задачу в статус Approved …» сохраняются всегда; ровно одно пингующее сообщение, разделяемая `send_telegram` не тронута. Динамические подписи экранируются `html.escape`, `parse_mode=HTML` сохранён. ADR `docs/work-items/ORCH-017/06-adr/ADR-001-telegram-approve-links.md`. Тесты: `test_notify_approve_links.py`, `test_analysis_approve_flow_links.py`. - **Конфигурируемые модель LLM и режим работы (`--effort`) агентов** (ORCH-41): модель/effort каждого агента вынесены из хардкода `launcher.py` в конфиг — глобально per-agent (`ORCH_AGENT_MODEL_` / `ORCH_AGENT_EFFORT_`, дефолты `ORCH_AGENT_MODEL_DEFAULT=claude-opus-4-8`, `ORCH_AGENT_EFFORT_DEFAULT=high`) и per-project (`agent_models` / `agent_efforts` в `ORCH_PROJECTS_JSON`). Резолверы `resolve_agent_model` / `resolve_agent_effort` (приоритет project > per-agent env > default > пусто), валидация effort `{low,medium,high,xhigh,max}`, опц. `ORCH_AGENT_FALLBACK_MODEL` (`--fallback-model`). Хардкод `"model":"opus"` (architect/reviewer) удалён. Тесты: `test_resolve_agent_model.py`, `test_resolve_agent_effort.py`. - **Единый status-коммент агентов в Plane** (ORCH-016): `usage.build_status_comment(...)` — один хелпер для ВСЕХ ролей (analyst..deployer). HTML-формат: header `{icon} {Role} — {описание}`, опциональная строка `Verdict/Status: …` из YAML-frontmatter артефакта, **строка `Длительность: 4m 12s`** (явный `duration_s` от launcher, fallback из `agent_runs` для аналитика), `Документы:`, тех-хвост `tokens · cost`. Утилитки: `usage.fmt_duration`, `usage.get_agent_duration`, новый модуль `src/frontmatter.py` (defensive YAML reader). ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`. - **Документация по канону** (ORCH-9): `CLAUDE.md` (паспорт проекта), структура `docs/` (`architecture/` + `adr/`, `operations/`, `work-items/`, `history/`), `docs/operations/INFRA.md` (RUNBOOK с инфра-изоляцией и self-hosting рисками). diff --git a/docs/operations/INFRA.md b/docs/operations/INFRA.md index cf9d248..90bd8e0 100644 --- a/docs/operations/INFRA.md +++ b/docs/operations/INFRA.md @@ -42,6 +42,7 @@ | Переменная | Назначение | |-----------|-----------| | `ORCH_PLANE_API_URL` / `_TOKEN` / `_WORKSPACE_SLUG` | доступ к Plane API | +| `ORCH_PLANE_WEB_URL` | внешний (браузерный) web-URL Plane для кликабельных ссылок на issue в уведомлениях (ORCH-017); пусто → фолбэк на `ORCH_PLANE_API_URL`, loopback-фолбэк → ссылка опускается | | `ORCH_PLANE_WEBHOOK_SECRET` | HMAC-проверка вебхуков Plane | | `ORCH_GITEA_URL` / `_TOKEN` / `_WEBHOOK_SECRET` | доступ к Gitea + HMAC | | `ORCH_CLAUDE_BIN` | путь к claude CLI | diff --git a/src/config.py b/src/config.py index d8869c1..6da4e98 100644 --- a/src/config.py +++ b/src/config.py @@ -4,6 +4,11 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): # Plane plane_api_url: str = "http://localhost:8091" + # ORCH-017: external (browser) web URL of Plane for clickable issue links in + # notifications, e.g. https://plane.example.org. Falls back to plane_api_url, + # but a loopback fallback (localhost/127.0.0.1) is treated as "no web URL" and + # the Plane link is omitted (see notifications._build_plane_issue_link). + plane_web_url: str = "" plane_api_token: str = "" plane_workspace_slug: str = "" plane_webhook_secret: str = "" diff --git a/src/notifications.py b/src/notifications.py index c445677..0d1876f 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -544,6 +544,105 @@ def notify_qg_failure(task_id: int, stage: str, check: str, reason: str): logger.warning(f"\u26a0\ufe0f {work_item_id}: QG {check} \u2014 failed: {reason}") +# ORCH-017: hosts that are not clickable off the deploy box. A Plane web-base +# resolving to one of these (the plane_api_url loopback default) means "no usable +# browser URL" -> the Plane link is omitted rather than emitted broken (ADR-001 Р-3). +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1"}) + + +def _is_loopback_base(url: str) -> bool: + """True if the URL's host is a loopback/local address (not clickable off-host). + + Empty/garbage URLs count as loopback (i.e. unusable) so callers omit the link. + """ + if not url: + return True + try: + from urllib.parse import urlparse + host = (urlparse(url).hostname or "").lower() + return (not host) or host in _LOOPBACK_HOSTS + except Exception: + return True + + +def _get_task_link_fields(task_id: int): + """ORCH-017: read (repo, branch, plane_issue_id) for a task. Never raises. + + Returns (None, None, None) on any error / missing row so link building can + degrade gracefully (AC-6). + """ + try: + from .db import get_db + conn = get_db() + row = conn.execute( + "SELECT repo, branch, plane_issue_id FROM tasks WHERE id=?", (task_id,) + ).fetchone() + conn.close() + if not row: + return None, None, None + return row["repo"], row["branch"], row["plane_issue_id"] + except Exception as e: + logger.warning(f"_get_task_link_fields({task_id}) failed: {e}") + return None, None, None + + +def _build_brd_link(repo, branch, work_item_id) -> str | None: + """ORCH-017: '' to 01-brd.md in Gitea branch-view, or None if data missing. + + Mirrors the canonical branch-view pattern in src/usage.py: base = + gitea_public_url or gitea_url, owner = gitea_owner (AC-1/AC-3). The href is + html.escaped as defence-in-depth even though parts come from trusted + config/DB (AC-7). + """ + s = _get_settings() + base = ( + getattr(s, "gitea_public_url", "") or getattr(s, "gitea_url", "") + ).rstrip("/") + owner = getattr(s, "gitea_owner", "") + if not (base and owner and repo and branch and work_item_id): + return None + url = ( + f"{base}/{owner}/{repo}/src/branch/{branch}" + f"/docs/work-items/{work_item_id}/01-brd.md" + ) + return ( + f'' + f"\U0001f4c4 Открыть BRD" + ) + + +def _build_plane_issue_link(repo, plane_issue_id) -> str | None: + """ORCH-017: '' to the Plane issue browser page, or None if unusable. + + Full path per ADR-001 Р-2: + ``{web_base}/{workspace_slug}/projects/{project_id}/issues/{issue_id}/``. + web_base = plane_web_url or plane_api_url (AC-3); a loopback base is treated + as "no web URL" and the link is omitted (loopback-guard, AC-2/AC-6). + """ + s = _get_settings() + web_base = ( + getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "") + ).rstrip("/") + workspace = getattr(s, "plane_workspace_slug", "") + if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base): + return None + try: + from .projects import get_project_by_repo + project = get_project_by_repo(repo) if repo else None + except Exception: + project = None + if not project or not getattr(project, "plane_project_id", ""): + return None + url = ( + f"{web_base}/{workspace}/projects/{project.plane_project_id}" + f"/issues/{plane_issue_id}/" + ) + return ( + f'' + f"✅ Задача в Plane" + ) + + def notify_approve_requested(task_id: int): """ALERT (separate, notifying): BRD/TZ/AC ready -> flip Plane to Approved. @@ -557,10 +656,27 @@ def notify_approve_requested(task_id: int): except Exception as e: logger.warning(f"notify_approve_requested: brd clock start failed: {e}") msg = ( - f"\U0001f4cb {work_item_id}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. " + f"\U0001f4cb {html.escape(work_item_id)}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. " f"\u041f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 \u0432 \u0441\u0442\u0430\u0442\u0443\u0441 Approved " f"\u0432 Plane \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f." ) + # ORCH-017: embed direct links to the BRD doc (Gitea) and the Plane issue so + # the reviewer can open both straight from the ping. Each link is built + # independently and omitted if its data is missing; building is defensive so + # it can NEVER break the alert (AC-1/AC-2/AC-6). Still exactly one notifying + # message (AC-5); the call to action above is always preserved (AC-4). + try: + repo, branch, plane_issue_id = _get_task_link_fields(task_id) + links = [ + link for link in ( + _build_brd_link(repo, branch, work_item_id), + _build_plane_issue_link(repo, plane_issue_id), + ) if link + ] + if links: + msg = msg + "\n\n" + "\n".join(links) + except Exception as e: + logger.warning(f"notify_approve_requested({task_id}): link build failed: {e}") logger.info(msg) update_task_tracker(task_id) send_telegram(msg) # separate, notifying diff --git a/tests/test_analysis_approve_flow_links.py b/tests/test_analysis_approve_flow_links.py new file mode 100644 index 0000000..296c259 --- /dev/null +++ b/tests/test_analysis_approve_flow_links.py @@ -0,0 +1,100 @@ +"""ORCH-017 / TC-10: analysis-approved flow wires DB fields into the approve ping. + +When the analyst's artifacts are ready, `_handle_analysis_approved_flow` sets the +issue In Review, posts the analyst comment, and calls `notify_approve_requested`. +This test drives that flow with all network side-effects mocked and asserts the +resulting Telegram ping carries the BRD + Plane links built from the task's DB +row (repo / branch / plane_issue_id), while the approval gate name and the +no-self-advance contract are unchanged (AC-1 / AC-2 / AC-8). +""" + +import os +import tempfile + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_approve_flow.db") +os.environ["ORCH_DB_PATH"] = _test_db + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import notifications as N # noqa: E402 +from src import stage_engine as SE # noqa: E402 + +_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" + + +@pytest.fixture(autouse=True) +def setup_db(monkeypatch): + monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _mk_task(monkeypatch): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, " + "plane_issue_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ("p1", "ORCH-017", "orchestrator", + "feature/ORCH-017-brd-plane-telegram", "analysis", + "Approve flow", "issue-uuid-7"), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def test_tc10_approved_flow_builds_links_from_db(monkeypatch): + tid = _mk_task(monkeypatch) + + # Settings that make both links resolvable. + s = N._get_settings() + monkeypatch.setattr(s, "gitea_public_url", "https://git.example.org", raising=False) + monkeypatch.setattr(s, "gitea_owner", "orchteam", raising=False) + monkeypatch.setattr(s, "plane_web_url", "https://plane.example.org", raising=False) + monkeypatch.setattr(s, "plane_workspace_slug", "acme", raising=False) + + # Isolate every network/fs side-effect of the flow. + monkeypatch.setitem(SE.QG_CHECKS, "check_analysis_complete", + lambda repo, wid, branch: (True, "ok")) + monkeypatch.setattr(SE, "set_issue_in_review", lambda wid: None) + monkeypatch.setattr(SE, "plane_add_comment", lambda *a, **k: None) + monkeypatch.setattr(SE, "_build_analyst_ready_comment", lambda *a, **k: "c") + + # Capture the approve ping; stub the tracker refresh. + calls = [] + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: calls.append(text) or 1) + monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None) + + result = SE.AdvanceResult() + SE._handle_analysis_approved_flow( + tid, "analysis", "orchestrator", "ORCH-017", + "feature/ORCH-017-brd-plane-telegram", "analyst", result, + ) + + # Gate name + no-self-advance contract unchanged (AC-8). + assert result.qg_name == "check_analysis_approved" + assert result.note == "analysis-in-review" + assert result.advanced is False + + # Exactly one ping carrying both links built from the DB row (AC-1 / AC-2). + assert len(calls) == 1 + text = calls[0] + assert ( + "https://git.example.org/orchteam/orchestrator/src/branch/" + "feature/ORCH-017-brd-plane-telegram/docs/work-items/ORCH-017/01-brd.md" + ) in text + assert ( + f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}" + f"/issues/issue-uuid-7/" + ) in text diff --git a/tests/test_notify_approve_links.py b/tests/test_notify_approve_links.py new file mode 100644 index 0000000..1190870 --- /dev/null +++ b/tests/test_notify_approve_links.py @@ -0,0 +1,284 @@ +"""ORCH-017: tests for the direct BRD + Plane links in the approve-gate ping. + +`notify_approve_requested` builds ONE notifying Telegram message that embeds: + * a Gitea branch-view link to docs/work-items//01-brd.md (AC-1) + * a Plane issue browser link (AC-2) + +Both links use external base URLs with documented fallbacks (AC-3), degrade +gracefully when data is missing / the Plane base is loopback (AC-6), keep the +'flip to Approved' call to action (AC-4), send exactly one notifying message +(AC-5) and stay HTML-safe (AC-7). + +Network is isolated: send_telegram is replaced with an in-test recorder, the DB +is a temp SQLite seeded by a fixture. Mapping to acceptance criteria is in each +test's docstring (test ids TC-01..TC-08 from 04-test-plan.yaml). +""" + +import os +import tempfile + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_approve_links.db") +os.environ["ORCH_DB_PATH"] = _test_db + +from unittest.mock import MagicMock, patch # noqa: E402 + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import notifications as N # noqa: E402 + +# Captured at import time, BEFORE the conftest autouse fixture stubs it to a +# no-op, so TC-08 can exercise the REAL send_telegram (parse_mode=HTML) end-to-end. +_ORIG_SEND_TELEGRAM = N.send_telegram + +# orchestrator repo -> default project registry uuid (src/projects.py). +_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" + + +@pytest.fixture(autouse=True) +def setup_db(monkeypatch): + monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _mk_task(wid="ORCH-017", repo="orchestrator", + branch="feature/ORCH-017-brd-plane-telegram", + plane_issue_id="11112222-3333-4444-5555-666677778888", + title="Links in approve ping", stage="analysis"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, " + "plane_issue_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ("p1", wid, repo, branch, stage, title, plane_issue_id), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _set(monkeypatch, **kw): + """Set settings attrs on the singleton notifications actually reads.""" + s = N._get_settings() + for k, v in kw.items(): + monkeypatch.setattr(s, k, v, raising=False) + + +def _record_send(monkeypatch): + """Replace send_telegram with a recorder; returns the calls list.""" + calls = [] + + def _fake(text, disable_notification=False): + calls.append({"text": text, "silent": disable_notification}) + return 1 + + monkeypatch.setattr(N, "send_telegram", _fake) + # Tracker refresh is irrelevant here and would hit send_telegram too -> stub. + monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None) + return calls + + +# --------------------------------------------------------------------------- # +# TC-01 — BRD link (Gitea branch-view), AC-1 / AC-3 +# --------------------------------------------------------------------------- # +def test_tc01_brd_link_present(monkeypatch): + tid = _mk_task() + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_url="http://localhost:3000", gitea_owner="orchteam") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + assert len(calls) == 1 + text = calls[0]["text"] + expected = ( + 'https://git.example.org/orchteam/orchestrator/src/branch/' + 'feature/ORCH-017-brd-plane-telegram/docs/work-items/ORCH-017/01-brd.md' + ) + assert expected in text + assert f'' in text # clickable, points at 01-brd.md + + +# --------------------------------------------------------------------------- # +# TC-02 — Plane issue link (external web URL + workspace + project + issue id) +# AC-2 / AC-3 +# --------------------------------------------------------------------------- # +def test_tc02_plane_link_present(monkeypatch): + tid = _mk_task(plane_issue_id="abcd-issue-uuid") + _set(monkeypatch, plane_web_url="https://plane.example.org", + plane_api_url="http://localhost:8091", plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + text = calls[0]["text"] + expected = ( + f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}" + f"/issues/abcd-issue-uuid/" + ) + assert expected in text + assert f'' in text + + +# --------------------------------------------------------------------------- # +# TC-03 — fallback chain: gitea_public_url -> gitea_url, plane_web_url -> plane_api_url +# AC-3 +# --------------------------------------------------------------------------- # +def test_tc03_url_fallbacks(monkeypatch): + tid = _mk_task(plane_issue_id="iss-1") + _set(monkeypatch, + gitea_public_url="", gitea_url="https://git-fallback.example.org", + gitea_owner="orchteam", + plane_web_url="", plane_api_url="https://plane-fallback.example.org", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + text = calls[0]["text"] + # BRD link uses gitea_url fallback. + assert "https://git-fallback.example.org/orchteam/orchestrator/" in text + # Plane link uses plane_api_url fallback (non-loopback -> allowed). + assert ( + f"https://plane-fallback.example.org/acme/projects/{_ORCH_PROJECT_ID}" + f"/issues/iss-1/" + ) in text + + +# --------------------------------------------------------------------------- # +# TC-04 — the 'flip to Approved' call to action is preserved. AC-4 +# --------------------------------------------------------------------------- # +def test_tc04_keeps_approved_call_to_action(monkeypatch): + tid = _mk_task() + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + assert "Approved" in calls[0]["text"] + + +# --------------------------------------------------------------------------- # +# TC-05 — exactly one notifying (non-silent) message. AC-5 +# --------------------------------------------------------------------------- # +def test_tc05_single_notifying_message(monkeypatch): + tid = _mk_task() + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + assert len(calls) == 1 + assert calls[0]["silent"] is not True # notifying ping, not silent + + +# --------------------------------------------------------------------------- # +# TC-06 — graceful: no branch / no plane_issue_id -> still one message, missing +# links omitted, no exception. AC-6 +# --------------------------------------------------------------------------- # +def test_tc06_graceful_missing_branch_and_issue(monkeypatch): + tid = _mk_task(branch=None, plane_issue_id=None) + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) # must not raise + + assert len(calls) == 1 + text = calls[0]["text"] + assert "Approved" in text # message still sent + assert "01-brd.md" not in text # BRD link omitted (no branch) + assert "/issues/" not in text # Plane link omitted (no issue id) + + +# --------------------------------------------------------------------------- # +# TC-07 — Plane base unusable (web url empty + api url empty) -> Plane link +# dropped, BRD link stays, orchestrator survives. AC-6 +# --------------------------------------------------------------------------- # +def test_tc07_plane_base_empty_drops_plane_link_keeps_brd(monkeypatch): + tid = _mk_task() + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", + plane_web_url="", plane_api_url="", plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + text = calls[0]["text"] + assert "01-brd.md" in text # BRD link survives + assert "/issues/" not in text # Plane link dropped + + +def test_tc07b_loopback_plane_base_dropped(monkeypatch): + """Loopback fallback (plane_api_url=localhost) must NOT emit a broken link.""" + tid = _mk_task() + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", + plane_web_url="", plane_api_url="http://localhost:8091", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + + text = calls[0]["text"] + assert "localhost" not in text # no loopback URL leaks into the ping + assert "/issues/" not in text # Plane link dropped by loopback-guard + assert "01-brd.md" in text + + +# --------------------------------------------------------------------------- # +# TC-08 — HTML safety: parse_mode=HTML preserved + dynamic parts escaped + valid +# markup. AC-7 +# --------------------------------------------------------------------------- # +def test_tc08_html_escaped_and_valid_markup(monkeypatch): + # work_item_id with an ampersand exercises html.escape on the dynamic label. + tid = _mk_task(wid="ORCH&17") + _set(monkeypatch, gitea_public_url="https://git.example.org", + gitea_owner="orchteam", plane_web_url="https://plane.example.org", + plane_workspace_slug="acme") + calls = _record_send(monkeypatch) + + N.notify_approve_requested(tid) + text = calls[0]["text"] + + # Dynamic work_item_id escaped in the header (no raw '&' before a word). + assert "ORCH&17" in text + # Well-formed anchor markup: equal number of opening/closing tags. + assert text.count("") + assert text.count("