Все агенты (analyst..deployer) теперь пишут финальный коммент через единый
хелпер usage.build_status_comment(...) — заголовок «{icon} {Role} — {описание}»,
опциональная строка Verdict/Status из YAML-frontmatter, строка
«Длительность: 4m 12s» (явный duration_s от launcher, fallback из agent_runs
для аналитика), HTML-блок Документы, тех-хвост <sub>tokens · cost</sub>.
- Новые публичные функции в src/usage.py: build_status_comment, fmt_duration,
get_agent_duration. usage_comment(...) → тонкая deprecated-обёртка (legacy
тесты в tests/test_usage.py продолжают работать). artifact_links(...)
переписан на HTML <li><a>…</a></li> (breaking change для внутреннего API,
но единственный внешний клиент — _post_usage_comments — мигрирован).
- Новый модуль src/frontmatter.py: defensive YAML reader, никогда не raise.
- stage_engine._build_analyst_ready_comment(...) теперь тонкая обёртка над
build_status_comment(agent="analyst", ...); task_id пробрасывается из
_handle_analysis_approved_flow для DB-фоллбэка длительности (AC-14).
- launcher._post_usage_comments(...) принимает duration_s, резолвит stage из
tasks для deployer и worktree_root для AC-8 graceful skipping.
Тесты (16 файлов, 56 новых тестовых функций, покрывают TC-01..TC-25):
fmt_duration table, build_status_comment по всем агентам, DB-фоллбэк,
authorship под per-agent ботами, дедуп-инвариант, regression на
status-only verdict аналитика и финальный notify_done, snapshot
QG_CHECKS + STAGE_TRANSITIONS.
Документация: docs/architecture/README.md (раздел Plane Sync),
CHANGELOG.md (Unreleased Added/Changed).
Refs: ORCH-016
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
355 lines
13 KiB
Python
355 lines
13 KiB
Python
"""ORCH-016 / TC-01..TC-10, TC-12, TC-23: unified status comment format.
|
|
|
|
Unit tests for src.usage.build_status_comment(...) — the single hot path for
|
|
every agent's "I just finished a stage" comment in Plane (ADR-001).
|
|
|
|
Covers:
|
|
* Header per agent (icon + role + description from AC-1..AC-5).
|
|
* Verdict / Status line read from frontmatter (reviewer / tester / deployer).
|
|
* Длительность line shown when duration_s is supplied; suppressed otherwise.
|
|
* <a href="..."> link items per agent.
|
|
* URL base picks gitea_public_url, falls back to gitea_url.
|
|
* Graceful behaviour when files are missing / no frontmatter (AC-8).
|
|
|
|
No DB / no network — only the worktree filesystem (via tmp_path).
|
|
"""
|
|
|
|
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 usage as U # noqa: E402
|
|
|
|
|
|
WID = "ET-016"
|
|
REPO = "enduro-trails"
|
|
BRANCH = "feature/ET-016-status-comments"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _set_urls(monkeypatch):
|
|
"""gitea_public_url is the canonical clickable base (AC-9)."""
|
|
monkeypatch.setattr(U, "logger", U.logger)
|
|
from src.config import settings
|
|
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
|
|
monkeypatch.setattr(
|
|
settings, "gitea_public_url", "https://git.mva154.duckdns.org", raising=False
|
|
)
|
|
monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
|
|
yield
|
|
|
|
|
|
def _wt_with_files(tmp_path, files: dict) -> str:
|
|
"""Create a worktree skeleton with given files. `files` maps rel-path -> body."""
|
|
base = tmp_path / "wt"
|
|
docs = base / "docs" / "work-items" / WID
|
|
docs.mkdir(parents=True)
|
|
for rel, body in files.items():
|
|
p = docs / rel if not rel.startswith("/") else base / rel.lstrip("/")
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(body)
|
|
return str(base)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-01: architect comment
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc01_architect_comment(tmp_path):
|
|
wt = _wt_with_files(tmp_path, {"06-adr/ADR-001-x.md": "x"})
|
|
|
|
html = U.build_status_comment(
|
|
"architect",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
duration_s=312,
|
|
worktree_root=wt,
|
|
)
|
|
# Header
|
|
assert "\U0001f4d0 Architect — " in html, html
|
|
assert "архитектурную" in html
|
|
assert "См. ADR ниже" in html
|
|
# Duration: 312s -> 5m 12s
|
|
assert "Длительность: 5m 12s" in html
|
|
# ADR link via gitea_public_url
|
|
assert ("https://git.mva154.duckdns.org/admin/enduro-trails/src/branch/"
|
|
f"{BRANCH}/docs/work-items/{WID}/06-adr") in html
|
|
# No Verdict for architect
|
|
assert "Verdict" not in html
|
|
assert "Status:" not in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-02: developer comment with PR + branch
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc02_developer_comment_links_branch_and_pr():
|
|
html = U.build_status_comment(
|
|
"developer",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
pr_number=42, duration_s=600,
|
|
)
|
|
assert "\U0001f4bb Developer — " in html
|
|
assert "разработку" in html
|
|
# Both branch and PR links
|
|
assert f"https://git.mva154.duckdns.org/admin/{REPO}/src/branch/{BRANCH}" in html
|
|
assert f"https://git.mva154.duckdns.org/admin/{REPO}/pulls/42" in html
|
|
assert f"PR #42" in html
|
|
assert "Длительность: 10m 00s" in html
|
|
assert "Verdict" not in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-03 / TC-04: reviewer verdict via frontmatter
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc03_reviewer_verdict_approve(tmp_path):
|
|
wt = _wt_with_files(tmp_path, {
|
|
"12-review.md": "---\nverdict: APPROVE\n---\nbody...",
|
|
})
|
|
html = U.build_status_comment(
|
|
"reviewer",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
duration_s=120, worktree_root=wt,
|
|
)
|
|
assert "\U0001f50e Reviewer — " in html
|
|
assert "Verdict: APPROVE" in html
|
|
assert "Длительность: 2m 00s" in html
|
|
assert "12-review.md" in html
|
|
|
|
|
|
def test_tc04_reviewer_verdict_request_changes(tmp_path):
|
|
wt = _wt_with_files(tmp_path, {
|
|
"12-review.md": "---\nverdict: REQUEST_CHANGES\n---\nblockers...",
|
|
})
|
|
html = U.build_status_comment(
|
|
"reviewer",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
duration_s=45, worktree_root=wt,
|
|
)
|
|
assert "Verdict: REQUEST_CHANGES" in html
|
|
assert "Длительность: 45s" in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-05: reviewer with NO 12-review.md -> graceful (no Verdict, no Review link)
|
|
# but Длительность and header still present.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc05_reviewer_missing_artifact_graceful(tmp_path):
|
|
wt = _wt_with_files(tmp_path, {}) # empty docs dir
|
|
html = U.build_status_comment(
|
|
"reviewer",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
duration_s=30, worktree_root=wt,
|
|
)
|
|
assert "\U0001f50e Reviewer — " in html
|
|
assert "Verdict" not in html
|
|
# Link to 12-review.md is dropped (AC-8 graceful).
|
|
assert "12-review.md" not in html
|
|
# Duration still printed when known.
|
|
assert "Длительность: 30s" in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-06 / TC-07: tester verdict via frontmatter (verdict OR status)
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc06_tester_pass(tmp_path):
|
|
wt = _wt_with_files(tmp_path, {
|
|
"13-test-report.md": "---\nverdict: PASS\n---\n",
|
|
})
|
|
html = U.build_status_comment(
|
|
"tester",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
duration_s=240, worktree_root=wt,
|
|
)
|
|
assert "\U0001f9ea Tester — " in html
|
|
assert "Verdict: PASS" in html
|
|
assert "Длительность: 4m 00s" in html
|
|
assert "13-test-report.md" in html
|
|
|
|
|
|
def test_tc07_tester_fail(tmp_path):
|
|
wt = _wt_with_files(tmp_path, {
|
|
"13-test-report.md": "---\nverdict: FAIL\n---\n",
|
|
})
|
|
html = U.build_status_comment(
|
|
"tester",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
duration_s=240, worktree_root=wt,
|
|
)
|
|
assert "Verdict: FAIL" in html
|
|
assert "Длительность: 4m 00s" in html
|
|
|
|
|
|
def test_tc07b_tester_falls_back_to_status_key(tmp_path):
|
|
# Some testers used `status:` instead of `verdict:` (ET-006 / ET-008 pattern).
|
|
wt = _wt_with_files(tmp_path, {
|
|
"13-test-report.md": "---\nstatus: PASSED\n---\n",
|
|
})
|
|
html = U.build_status_comment(
|
|
"tester",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
duration_s=10, worktree_root=wt,
|
|
)
|
|
assert "Verdict: PASSED" in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-08 / TC-09: deployer status via frontmatter
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc08_deployer_deploy_status_success(tmp_path):
|
|
wt = _wt_with_files(tmp_path, {
|
|
"14-deploy-log.md": "---\ndeploy_status: SUCCESS\n---\n",
|
|
})
|
|
html = U.build_status_comment(
|
|
"deployer",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
stage="deploy", duration_s=120, worktree_root=wt,
|
|
)
|
|
assert "\U0001f680 Deployer — " in html
|
|
assert "Status: SUCCESS" in html
|
|
assert "Длительность: 2m 00s" in html
|
|
assert "14-deploy-log.md" in html
|
|
|
|
|
|
def test_tc09_deployer_staging_status_success(tmp_path):
|
|
wt = _wt_with_files(tmp_path, {
|
|
"15-staging-log.md": "---\nstaging_status: SUCCESS\n---\n",
|
|
})
|
|
html = U.build_status_comment(
|
|
"deployer",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
stage="deploy-staging", duration_s=60, worktree_root=wt,
|
|
)
|
|
assert "Status: SUCCESS" in html
|
|
assert "Длительность: 1m 00s" in html
|
|
# The staging-stage helper links 15-staging-log.md, not 14-deploy-log.md.
|
|
assert "15-staging-log.md" in html
|
|
assert "14-deploy-log.md" not in html
|
|
|
|
|
|
def test_deployer_status_failed_drives_status_line(tmp_path):
|
|
wt = _wt_with_files(tmp_path, {
|
|
"14-deploy-log.md": "---\ndeploy_status: FAILED\n---\n",
|
|
})
|
|
html = U.build_status_comment(
|
|
"deployer",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
stage="deploy", duration_s=5, worktree_root=wt,
|
|
)
|
|
assert "Status: FAILED" in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-10: gitea_public_url is preferred; falls back to gitea_url when empty.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc10_url_fallback_to_gitea_url(monkeypatch):
|
|
from src.config import settings
|
|
monkeypatch.setattr(settings, "gitea_public_url", "", raising=False)
|
|
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
|
|
html = U.build_status_comment(
|
|
"developer",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
pr_number=7, duration_s=15,
|
|
)
|
|
assert "http://localhost:3000/admin/enduro-trails/pulls/7" in html
|
|
# And the public URL is not there because it was empty.
|
|
assert "git.mva154.duckdns.org" not in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-12: frontmatter parser is graceful — missing file / empty / bad YAML -> None
|
|
# (the comment still publishes the header + duration, just no Verdict / Status).
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc12_frontmatter_missing_file_no_crash(tmp_path):
|
|
from src.frontmatter import read_frontmatter_value
|
|
assert read_frontmatter_value(str(tmp_path / "nope.md"), "verdict") is None
|
|
|
|
|
|
def test_tc12_frontmatter_empty_no_crash(tmp_path):
|
|
p = tmp_path / "empty.md"
|
|
p.write_text("")
|
|
from src.frontmatter import read_frontmatter_value
|
|
assert read_frontmatter_value(str(p), "verdict") is None
|
|
|
|
|
|
def test_tc12_frontmatter_bad_yaml_no_crash(tmp_path):
|
|
p = tmp_path / "bad.md"
|
|
p.write_text("---\nverdict: [unterminated\n---\nbody")
|
|
from src.frontmatter import read_frontmatter_value
|
|
assert read_frontmatter_value(str(p), "verdict") is None
|
|
|
|
|
|
def test_tc12_frontmatter_missing_key_returns_none(tmp_path):
|
|
p = tmp_path / "ok.md"
|
|
p.write_text("---\nother: value\n---\nbody")
|
|
from src.frontmatter import read_frontmatter_value
|
|
assert read_frontmatter_value(str(p), "verdict") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-23: duration_s=None and no task_id -> the Длительность line is OMITTED.
|
|
# Header / description / artifact links remain.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc23_no_duration_no_line(tmp_path):
|
|
wt = _wt_with_files(tmp_path, {"06-adr/ADR-001-x.md": "x"})
|
|
html_none = U.build_status_comment(
|
|
"architect",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
duration_s=None, worktree_root=wt,
|
|
)
|
|
html_default = U.build_status_comment(
|
|
"architect",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
worktree_root=wt,
|
|
)
|
|
for html in (html_none, html_default):
|
|
assert "Длительность" not in html
|
|
# But the header, description and ADR link are still there.
|
|
assert "\U0001f4d0 Architect — " in html
|
|
assert "архитектурную" in html
|
|
assert "06-adr" in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Extra: usage tail is rendered as <sub> when non-zero, suppressed otherwise.
|
|
# (Backs up ADR-001 §3 and keeps the old usage_comment test contract.)
|
|
# ---------------------------------------------------------------------------
|
|
def test_usage_tail_rendered_when_non_zero():
|
|
html = U.build_status_comment(
|
|
"developer",
|
|
repo=REPO, branch=BRANCH, work_item_id=WID,
|
|
usage={"input_tokens": 45231, "output_tokens": 12100, "cost_usd": 0.21},
|
|
)
|
|
assert "<sub>" in html and "</sub>" in html
|
|
assert "45.2k in" in html
|
|
assert "12.1k out" in html
|
|
assert "$0.21" in html
|
|
|
|
|
|
def test_usage_tail_suppressed_when_all_zero():
|
|
html = U.build_status_comment("developer", repo=REPO, branch=BRANCH)
|
|
assert "<sub>" not in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC-1 / AC-5 literal strings — fixed wording per role.
|
|
# ---------------------------------------------------------------------------
|
|
def test_ac1_architect_header_literal():
|
|
html = U.build_status_comment("architect", repo=REPO, branch=BRANCH,
|
|
work_item_id=WID, duration_s=10)
|
|
assert "\U0001f4d0 Architect — " in html
|
|
|
|
|
|
def test_ac5_deployer_deploy_description():
|
|
html = U.build_status_comment(
|
|
"deployer", repo=REPO, branch=BRANCH, work_item_id=WID, stage="deploy",
|
|
)
|
|
assert "прод-деплой" in html
|
|
|
|
|
|
def test_ac5_deployer_staging_description():
|
|
html = U.build_status_comment(
|
|
"deployer", repo=REPO, branch=BRANCH, work_item_id=WID, stage="deploy-staging",
|
|
)
|
|
assert "staging-деплой" in html
|