feat(notifications): direct BRD + Plane links in approve ping (ORCH-017)
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s

notify_approve_requested now embeds two HTML <a> links into the single
notifying approve-gate message: a Gitea branch-view link to 01-brd.md and a
Plane issue browser link. Adds ORCH_PLANE_WEB_URL (external Plane web URL,
fallback to plane_api_url) with a loopback-guard that omits the Plane link
when the resolved base is localhost/empty (no broken localhost URLs in prod).

Each link is built independently and omitted on missing data; the message and
the "flip to Approved" call to action are always sent as exactly one ping. The
shared send_telegram helper is left untouched (min blast radius for the
self-hosting prod container). Dynamic labels are html.escaped; parse_mode=HTML
preserved. QG registry / stages / approve handler unchanged.

Docs updated in-PR: CHANGELOG, .env.example, INFRA env map.
Tests: test_notify_approve_links.py, test_analysis_approve_flow_links.py.

Refs: ORCH-017
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 17:58:00 +00:00
parent c9b1195c0b
commit 69a4aaab99
7 changed files with 512 additions and 1 deletions

View File

@@ -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=

View File

@@ -5,6 +5,7 @@
## [Unreleased]
### Added
- **Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве** (ORCH-017): пингующее сообщение `notify_approve_requested` теперь встраивает две HTML-`<a>`-ссылки — на `docs/work-items/<WI>/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_<AGENT>` / `ORCH_AGENT_EFFORT_<AGENT>`, дефолты `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` для аналитика), `<b>Документы:</b><ul><li><a>…</a></li></ul>`, тех-хвост `<sub>tokens · cost</sub>`. Утилитки: `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 рисками).

View File

@@ -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 |

View File

@@ -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 = ""

View File

@@ -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: '<a>' 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'<a href="{html.escape(url, quote=True)}">'
f"\U0001f4c4 Открыть BRD</a>"
)
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).
"""
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'<a href="{html.escape(url, quote=True)}">'
f"✅ Задача в Plane</a>"
)
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

View File

@@ -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

View File

@@ -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/<WI>/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'<a href="{expected}">' 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'<a href="{expected}">' 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
# <a> 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&amp;17" in text
# Well-formed anchor markup: equal number of opening/closing tags.
assert text.count("<a href=") == text.count("</a>")
assert text.count("<a href=") >= 1
def test_tc08b_send_telegram_keeps_parse_mode_html(monkeypatch):
"""End-to-end through the REAL send_telegram: payload still parse_mode=HTML."""
# Restore the genuine send_telegram (conftest stubbed it to a no-op).
monkeypatch.setattr(N, "send_telegram", _ORIG_SEND_TELEGRAM)
monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None)
_set(monkeypatch, telegram_bot_token="T", telegram_chat_id="C",
gitea_public_url="https://git.example.org", gitea_owner="orchteam",
plane_web_url="https://plane.example.org", plane_workspace_slug="acme")
tid = _mk_task()
with patch("src.notifications.httpx") as hx:
resp = MagicMock()
resp.json.return_value = {"ok": True, "result": {"message_id": 9}}
hx.post.return_value = resp
N.notify_approve_requested(tid)
assert hx.post.call_count == 1
payload = hx.post.call_args.kwargs["json"]
assert payload["parse_mode"] == "HTML"
assert payload["disable_notification"] is False # notifying
assert "<a href=" in payload["text"]