feat(plane): unified status-comment format with duration line (ORCH-016)
Все агенты (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>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Единый 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 рисками).
|
||||
- **ADR**: adr-0001 (multi-repo registry), adr-0002 (job queue), adr-0003 (условный staging-гейт).
|
||||
- **Стадия `deploy-staging`** (ORCH-35): промежуточный гейт между `testing` и `deploy`. QG `check_staging_status` (условный, только для self-hosting repo). PR #31.
|
||||
@@ -14,6 +15,7 @@
|
||||
- **Реестр проектов** (ORCH-6): `src/projects.py`, фильтрация вебхуков по проекту.
|
||||
|
||||
### Changed
|
||||
- **Status-коммент агентов теперь HTML и единообразен** (ORCH-016): `src/usage.usage_comment(...)` помечен deprecated и стал тонкой обёрткой над `build_status_comment`; `src/usage.artifact_links(...)` теперь возвращает `<li><a>…</a></li>` HTML-фрагменты (раньше — markdown `[label](url)`); `stage_engine._build_analyst_ready_comment(...)` — тонкая обёртка, аналитик идёт через ту же ветку `build_status_comment(agent="analyst", ...)`. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` НЕ изменялись.
|
||||
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -46,6 +46,21 @@ created → analysis → architecture → development → review → testing →
|
||||
- Deploy / deploy-staging FAILED → откат на `development`.
|
||||
- `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`.
|
||||
|
||||
### Plane Sync: единый status-коммент агентов (ORCH-016)
|
||||
Все агенты (analyst / architect / developer / reviewer / tester / deployer) пишут финальный коммент через **один хелпер** `usage.build_status_comment(...)` (ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`). Формат HTML, разделители `<br>`:
|
||||
|
||||
```
|
||||
{ICON} {RoleName} — {описание стадии}
|
||||
[Verdict|Status: VALUE] # reviewer/tester/deployer, из YAML-frontmatter артефакта
|
||||
[Длительность: 4m 12s] # явный duration_s от launcher, либо fallback из agent_runs
|
||||
<b>Документы:</b><ul><li><a href="…">label</a></li>…</ul>
|
||||
[<sub>8.5M in / 45.8k out · $7.29</sub>] # тех-хвост usage; опускается при нулях
|
||||
```
|
||||
|
||||
- **Длительность** считается launcher'ом (`_monitor_agent`) и пробрасывается в `_post_usage_comments`; для analyst (коммент строится в `stage_engine`) используется DB-фоллбэк `usage.get_agent_duration(task_id, agent)`.
|
||||
- **Vердикт-парсер** — `src/frontmatter.read_frontmatter_value(...)` (defensive, никогда не raise). Машинные ключи: `verdict:` (reviewer/tester), `deploy_status:` (14-deploy-log.md), `staging_status:` (15-staging-log.md).
|
||||
- Формат коммента **не** меняет реестр гейтов и стадий; коммент — отображение, не управление.
|
||||
|
||||
## База данных (SQLite)
|
||||
- `events` — входящие вебхуки (дедуп)
|
||||
- `tasks` — задачи и их стадии
|
||||
|
||||
@@ -507,11 +507,15 @@ class AgentLauncher:
|
||||
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")
|
||||
|
||||
# Feature 4: post the per-agent usage comment under that agent's bot, and
|
||||
# — for the deployer finishing the task — the per-task usage summary.
|
||||
# Feature 4 + ORCH-016: post the unified per-agent status comment under
|
||||
# that agent's bot, threading the wall-clock duration we just measured
|
||||
# straight through (ADR-001 §6: explicit param wins over DB fallback).
|
||||
# The deployer finishing the task also posts the per-task usage summary.
|
||||
if exit_code == 0:
|
||||
try:
|
||||
self._post_usage_comments(run_id, agent, repo, branch, _usage)
|
||||
self._post_usage_comments(
|
||||
run_id, agent, repo, branch, _usage, duration_s=_duration_s
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"run_id={run_id}: usage comment failed: {e}")
|
||||
|
||||
@@ -679,42 +683,67 @@ class AgentLauncher:
|
||||
logger.error(f"Auto-advance failed for run_id={run_id}: {e}")
|
||||
|
||||
|
||||
def _post_usage_comments(self, run_id, agent, repo, branch, usage):
|
||||
"""Feature 4: post the per-agent usage comment (and Deployer summary).
|
||||
def _post_usage_comments(self, run_id, agent, repo, branch, usage, duration_s=None):
|
||||
"""Feature 4 + ORCH-016: post the unified per-agent status comment.
|
||||
|
||||
- Always (on success, with a work_item_id): a per-agent finish comment
|
||||
with token/cost, authored by the finishing agent's Plane bot.
|
||||
via ``usage.build_status_comment(...)``, authored by the finishing
|
||||
agent's Plane bot. The comment carries:
|
||||
* single-line header (icon + role + per-stage description),
|
||||
* machine verdict line for reviewer / tester / deployer (when the
|
||||
relevant frontmatter is present in the worktree),
|
||||
* the agent's wall-clock duration (``duration_s`` is the measured
|
||||
value in _monitor_agent; DB fallback is unused on this path),
|
||||
* an HTML <ul> of artifact links scoped per agent,
|
||||
* a ``<sub>`` token/cost tail.
|
||||
- When the deployer finishes: also a per-task summary (SUM over
|
||||
agent_runs GROUP BY agent), authored by the deployer.
|
||||
|
||||
The deployer's `stage=` is resolved from the task row so the helper can
|
||||
pick between 14-deploy-log.md (prod) and 15-staging-log.md (staging).
|
||||
"""
|
||||
from ..usage import usage_comment, task_summary_comment
|
||||
from ..usage import build_status_comment, task_summary_comment
|
||||
from ..git_worktree import get_worktree_path
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT id, work_item_id FROM tasks WHERE repo=? AND branch=?",
|
||||
"SELECT id, work_item_id, stage FROM tasks WHERE repo=? AND branch=?",
|
||||
(repo, branch),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return
|
||||
task_id, work_item_id = row[0], row[1]
|
||||
task_id, work_item_id, stage = row[0], row[1], row[2]
|
||||
if not work_item_id:
|
||||
return
|
||||
# Observability: every agent's finish comment links its artifact(s)
|
||||
# (reviewer->12-review, tester->13-test-report, deployer->14-deploy-log,
|
||||
# (reviewer->12-review, tester->13-test-report, deployer->14- or 15-,
|
||||
# architect->ADR, developer->PR/branch). For the developer we resolve the
|
||||
# open PR number so the link points straight at it.
|
||||
pr_number = None
|
||||
if agent == "developer":
|
||||
pr_number = self._open_pr_number(repo, branch)
|
||||
|
||||
# Best-effort worktree path — drives AC-8 (skip missing artifacts) and
|
||||
# the verdict frontmatter read. Falls back to None on lookup error so
|
||||
# the comment still goes out without the verdict line / file probe.
|
||||
try:
|
||||
worktree_root = get_worktree_path(repo, branch)
|
||||
except Exception:
|
||||
worktree_root = None
|
||||
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
usage_comment(
|
||||
build_status_comment(
|
||||
agent,
|
||||
usage,
|
||||
repo=repo,
|
||||
branch=branch,
|
||||
work_item_id=work_item_id,
|
||||
pr_number=pr_number,
|
||||
stage=stage,
|
||||
usage=usage,
|
||||
duration_s=duration_s,
|
||||
task_id=task_id,
|
||||
worktree_root=worktree_root,
|
||||
),
|
||||
author=agent,
|
||||
)
|
||||
|
||||
75
src/frontmatter.py
Normal file
75
src/frontmatter.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Safe single-key YAML frontmatter reader (ORCH-016 / ADR-001 §5).
|
||||
|
||||
The status-comment builder (build_status_comment) needs to surface verdict /
|
||||
deploy_status / staging_status from the per-stage artifact files (12-review.md,
|
||||
13-test-report.md, 14-deploy-log.md, 15-staging-log.md). Those files share the
|
||||
same leading-YAML-frontmatter convention used by the quality gates — but the
|
||||
comment hot-path must NEVER raise: a missing file, malformed YAML, or absent
|
||||
key should simply suppress the verdict line, not break the run.
|
||||
|
||||
This module is a tiny defensive helper:
|
||||
- `read_frontmatter_value(path, key)` -> str | None
|
||||
- swallows every exception, logs to logger.debug, returns None.
|
||||
|
||||
It intentionally duplicates ~10 lines of YAML-frontmatter logic that already
|
||||
exist in `src/qg/checks.py` (S-5 / БАГ 8 / ET-013 fixes). ADR-001 §5 accepts
|
||||
this duplication to keep the blast radius of ORCH-016 small (no QG refactor in
|
||||
this PR); merging into a single parser is a follow-up task.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("orchestrator.frontmatter")
|
||||
|
||||
|
||||
def read_frontmatter_value(path: str, key: str) -> str | None:
|
||||
"""Return the value of `key` from the leading YAML frontmatter of `path`.
|
||||
|
||||
Format expected (canonical, matching qg/checks.py):
|
||||
---
|
||||
key: value
|
||||
other: ...
|
||||
---
|
||||
<body>
|
||||
|
||||
Never raises. Returns None for any of:
|
||||
- missing/unreadable file,
|
||||
- no leading `---` frontmatter,
|
||||
- malformed/unterminated frontmatter,
|
||||
- YAML parse error,
|
||||
- frontmatter is not a mapping,
|
||||
- key absent (or its value is None/empty).
|
||||
|
||||
The returned value is stringified and stripped (whitespace removed); casing
|
||||
is preserved so the caller decides whether to upper/lower for matching.
|
||||
"""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
logger.debug(f"read_frontmatter_value: cannot open {path}: {e}")
|
||||
return None
|
||||
|
||||
if not content.startswith("---"):
|
||||
return None
|
||||
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
# Unterminated frontmatter.
|
||||
return None
|
||||
|
||||
try:
|
||||
import yaml
|
||||
fm = yaml.safe_load(parts[1]) or {}
|
||||
except Exception as e: # yaml.YAMLError + anything pyyaml may surface
|
||||
logger.debug(f"read_frontmatter_value: yaml parse failed for {path}: {e}")
|
||||
return None
|
||||
|
||||
if not isinstance(fm, dict):
|
||||
return None
|
||||
|
||||
raw = fm.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
value = str(raw).strip()
|
||||
return value or None
|
||||
@@ -295,56 +295,41 @@ def advance_stage(
|
||||
return result
|
||||
|
||||
|
||||
def _build_analyst_ready_comment(repo: str, work_item_id: str, branch: str) -> str:
|
||||
"""BUG C: HTML comment posted when analyst artifacts are ready.
|
||||
def _build_analyst_ready_comment(
|
||||
repo: str, work_item_id: str, branch: str, task_id: int | None = None
|
||||
) -> str:
|
||||
"""ORCH-016: analyst "artifacts ready" comment via the unified status helper.
|
||||
|
||||
Status-only model (PR #12): approval is the **Approved** status, NOT a
|
||||
``:approved:`` comment and NOT moving back to In Progress. The comment asks
|
||||
the stakeholder to flip the status and links the documents the analyst
|
||||
actually produced.
|
||||
Historically this function hand-built the HTML for the analyst's BUG-C
|
||||
status-only verdict comment (PR #12 / #13). After ORCH-016 / ADR-001 \u00a71 every
|
||||
agent goes through the single ``usage.build_status_comment(...)`` hot path,
|
||||
so this is now a thin compatibility wrapper that:
|
||||
|
||||
Links point at the Gitea web view:
|
||||
{gitea_url}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{wid}/<file>
|
||||
Only files that REALLY exist in the worktree are listed (no invented docs).
|
||||
- keeps the same 3-positional signature that ``_handle_analysis_approved_flow``
|
||||
and the regression tests (``tests/test_analyst_comment.py``) already call,
|
||||
- adds an optional ``task_id`` so the duration line for the analyst can be
|
||||
resolved via the DB fallback (AC-14: analyst's ``_duration_s`` isn't in
|
||||
scope of stage_engine, hence the fallback),
|
||||
- locates the worktree so AC-8 graceful skipping of missing analyst
|
||||
artifacts and ``gitea_public_url`` clickability work exactly as before.
|
||||
|
||||
All historical text contracts are preserved by the analyst branch inside
|
||||
``build_status_comment``: \u00abApproved\u00bb, \u00abRejected\u00bb, no \u00ab:approved:\u00bb, no
|
||||
\u00abIn Progress\u00bb \u2014 the existing test_analyst_comment.py assertions still hold.
|
||||
"""
|
||||
text = (
|
||||
"\u2705 BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
|
||||
"\u0414\u043b\u044f \u043f\u0440\u043e\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f "
|
||||
"\u043f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 "
|
||||
"\u0432 \u0441\u0442\u0430\u0442\u0443\u0441 Approved. "
|
||||
"\u0414\u043b\u044f \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f \u2014 "
|
||||
"\u043d\u0430\u043f\u0438\u0448\u0438\u0442\u0435 \u043f\u0440\u0438\u0447\u0438\u043d\u0443 "
|
||||
"\u043a\u043e\u043c\u043c\u0435\u043d\u0442\u043e\u043c \u0438 \u043f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 "
|
||||
"\u0432 Rejected."
|
||||
)
|
||||
|
||||
# Candidate analyst artifacts (label -> filename). Only existing ones linked.
|
||||
candidates = [
|
||||
("Business request", "00-business-request.md"),
|
||||
("BRD", "01-brd.md"),
|
||||
("\u0422\u0417 (TRZ)", "02-trz.md"),
|
||||
("Acceptance Criteria", "03-acceptance-criteria.md"),
|
||||
("Test Plan", "04-test-plan.yaml"),
|
||||
("UI Test Cases", "04b-ui-test-cases.md"),
|
||||
]
|
||||
rel_dir = f"docs/work-items/{work_item_id}"
|
||||
from .usage import build_status_comment
|
||||
try:
|
||||
wt_dir = os.path.join(get_worktree_path(repo, branch), rel_dir)
|
||||
worktree_root = get_worktree_path(repo, branch)
|
||||
except Exception:
|
||||
wt_dir = None
|
||||
|
||||
owner = getattr(settings, "gitea_owner", "admin")
|
||||
base = (getattr(settings, "gitea_public_url", "") or settings.gitea_url).rstrip("/")
|
||||
links = []
|
||||
for label, fname in candidates:
|
||||
if wt_dir and not os.path.isfile(os.path.join(wt_dir, fname)):
|
||||
continue
|
||||
href = f"{base}/{owner}/{repo}/src/branch/{branch}/{rel_dir}/{fname}"
|
||||
links.append(f'<li><a href="{href}">{label}</a></li>')
|
||||
|
||||
if links:
|
||||
text += "<br><b>\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b:</b><ul>" + "".join(links) + "</ul>"
|
||||
return text
|
||||
worktree_root = None
|
||||
return build_status_comment(
|
||||
"analyst",
|
||||
repo=repo,
|
||||
branch=branch,
|
||||
work_item_id=work_item_id,
|
||||
task_id=task_id,
|
||||
worktree_root=worktree_root,
|
||||
)
|
||||
|
||||
|
||||
def _handle_analysis_approved_flow(
|
||||
@@ -373,7 +358,9 @@ def _handle_analysis_approved_flow(
|
||||
set_issue_in_review(work_item_id)
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
_build_analyst_ready_comment(repo, work_item_id, branch),
|
||||
# task_id is threaded through so build_status_comment can resolve the
|
||||
# analyst duration via agent_runs (ORCH-016 AC-14 DB fallback).
|
||||
_build_analyst_ready_comment(repo, work_item_id, branch, task_id=task_id),
|
||||
author="analyst",
|
||||
)
|
||||
notify_approve_requested(task_id)
|
||||
|
||||
543
src/usage.py
543
src/usage.py
@@ -1,4 +1,4 @@
|
||||
"""Feature 4: token / cost accounting for agent runs.
|
||||
"""Feature 4 + ORCH-016: token / cost accounting and unified status comments.
|
||||
|
||||
claude --output-format json emits a single result JSON object at the end of the
|
||||
run log with fields:
|
||||
@@ -8,11 +8,16 @@ run log with fields:
|
||||
modelUsage, num_turns, duration_ms
|
||||
|
||||
This module parses that JSON out of a (text-or-json) run log, records the usage
|
||||
on the agent_runs row, formats a Plane comment for the finishing agent, and
|
||||
builds the per-task summary the Deployer posts on deploy/done.
|
||||
on the agent_runs row, and builds:
|
||||
- per-agent status comments via build_status_comment(...) — the ORCH-016
|
||||
unified format replacing the legacy usage_comment(...) and the analyst-
|
||||
only stage_engine._build_analyst_ready_comment(...). Every agent now flows
|
||||
through the same hot path.
|
||||
- per-task summary the Deployer posts on deploy/done.
|
||||
|
||||
Everything here is defensive: a missing/garbled JSON never raises \u2014 we record
|
||||
NULL/0 and log a warning so a broken agent run can't crash the monitor.
|
||||
Everything here is defensive: a missing/garbled JSON never raises — we record
|
||||
NULL/0 and log a warning so a broken agent run can't crash the monitor. The
|
||||
status-comment hot path likewise NEVER raises (self-hosting risk R-1).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -247,6 +252,88 @@ def fmt_cost(c) -> str:
|
||||
return f"${c:.2f}"
|
||||
|
||||
|
||||
def fmt_duration(seconds) -> str:
|
||||
"""Format an integer second count for the agent-finish status comment (ORCH-016).
|
||||
|
||||
Contract (ADR-001 §8 / AC-13):
|
||||
0..59 -> '{s}s' (e.g. '0s', '12s', '59s')
|
||||
60..3599 -> '{m}m {ss:02d}s' (e.g. '1m 00s', '4m 12s', '59m 59s')
|
||||
>= 3600 -> '{h}h {mm:02d}m' (seconds dropped; e.g. '1h 00m', '2h 47m')
|
||||
|
||||
None / non-int / negative -> '' so the caller drops the 'Длительность:' line.
|
||||
Pure function: no I/O, no DB.
|
||||
"""
|
||||
try:
|
||||
if seconds is None:
|
||||
return ""
|
||||
s = int(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if s < 0:
|
||||
return ""
|
||||
if s < 60:
|
||||
return f"{s}s"
|
||||
if s < 3600:
|
||||
m, ss = divmod(s, 60)
|
||||
return f"{m}m {ss:02d}s"
|
||||
h, rem = divmod(s, 3600)
|
||||
mm = rem // 60
|
||||
return f"{h}h {mm:02d}m"
|
||||
|
||||
|
||||
def get_agent_duration(task_id, agent: str) -> int | None:
|
||||
"""Last finished agent_runs duration (seconds) for (task_id, agent) — DB fallback.
|
||||
|
||||
ORCH-016 / ADR-001 §6: used by build_status_comment when the caller does NOT
|
||||
pass an explicit duration_s (chiefly the analyst path, which builds its
|
||||
comment from stage_engine where _duration_s is not in scope).
|
||||
|
||||
Reads the last finished row for (task_id, agent) via:
|
||||
SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER)
|
||||
FROM agent_runs WHERE task_id=? AND agent=?
|
||||
AND finished_at IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 1
|
||||
|
||||
Returns None on any of:
|
||||
- missing task_id / agent,
|
||||
- no matching row (or finished_at IS NULL),
|
||||
- computed value < 0 (clock skew),
|
||||
- DB error (logged at debug, never re-raised). This is the hot comment
|
||||
path — a locked / stale DB must never crash a finishing agent.
|
||||
"""
|
||||
if not task_id or not agent:
|
||||
return None
|
||||
try:
|
||||
conn = get_db()
|
||||
except Exception as e:
|
||||
logger.debug(f"get_agent_duration: cannot open DB for ({task_id},{agent}): {e}")
|
||||
return None
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER) "
|
||||
"FROM agent_runs WHERE task_id=? AND agent=? AND finished_at IS NOT NULL "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(task_id, agent),
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
logger.debug(f"get_agent_duration: query failed for ({task_id},{agent}): {e}")
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if not row or row[0] is None:
|
||||
return None
|
||||
try:
|
||||
secs = int(row[0])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if secs < 0:
|
||||
return None
|
||||
return secs
|
||||
|
||||
|
||||
# Pretty agent names for comments (mirrors STAGE_AUTHORS roles).
|
||||
AGENT_DISPLAY = {
|
||||
"analyst": "Analyst",
|
||||
@@ -298,30 +385,28 @@ def usage_comment(
|
||||
work_item_id: str | None = None,
|
||||
pr_number=None,
|
||||
) -> str:
|
||||
"""Build the per-agent finish comment, e.g.
|
||||
'\U0001f4bb Developer \u0433\u043e\u0442\u043e\u0432 \u00b7 8.5M in (8.4M cached) / 45.8k out \u00b7 $7.29'.
|
||||
"""DEPRECATED (ORCH-016 / ADR-001 §1): thin wrapper around build_status_comment.
|
||||
|
||||
When repo/branch/work_item_id are supplied, the agent's artifact link(s) are
|
||||
appended (BUG: only analyst used to link its docs). Missing artifacts are
|
||||
silently skipped — link building never raises.
|
||||
The historical one-line "{icon} Role готов · 8.5M in / 45.8k out · $7.29 + links"
|
||||
format has been replaced by the unified status-comment format. This wrapper
|
||||
is kept only so that legacy callers (notably the test suite in
|
||||
``tests/test_usage.py``) keep working; new code MUST call
|
||||
``build_status_comment(...)`` directly. There is no ``duration_s`` parameter
|
||||
here because the old signature did not carry it.
|
||||
"""
|
||||
usage = usage or {}
|
||||
name = AGENT_DISPLAY.get(agent, agent.capitalize())
|
||||
icon = AGENT_ICON.get(agent, "\u2705")
|
||||
line = (
|
||||
f"{icon} {name} \u0433\u043e\u0442\u043e\u0432 \u00b7 "
|
||||
f"{fmt_in(usage)} / "
|
||||
f"{fmt_tokens(usage.get('output_tokens'))} out \u00b7 "
|
||||
f"{fmt_cost(usage.get('cost_usd'))}"
|
||||
return build_status_comment(
|
||||
agent,
|
||||
repo=repo,
|
||||
branch=branch,
|
||||
work_item_id=work_item_id,
|
||||
pr_number=pr_number,
|
||||
usage=usage,
|
||||
)
|
||||
links = artifact_links(agent, repo, branch, work_item_id, pr_number)
|
||||
if links:
|
||||
line += "\n" + "\n".join(links)
|
||||
return line
|
||||
|
||||
|
||||
# Per-agent artifact file under docs/work-items/{wid}/ (architect/developer use
|
||||
# special handling for ADR dirs / PR links, see artifact_links()).
|
||||
# Per-agent artifact file under docs/work-items/{wid}/ (architect/developer/
|
||||
# deployer use special handling for ADR dirs, PR links, or staging logs —
|
||||
# see artifact_links()).
|
||||
AGENT_ARTIFACT = {
|
||||
"reviewer": ("Review", "12-review.md"),
|
||||
"tester": ("Test report", "13-test-report.md"),
|
||||
@@ -335,13 +420,35 @@ def artifact_links(
|
||||
branch: str | None,
|
||||
work_item_id: str | None,
|
||||
pr_number=None,
|
||||
*,
|
||||
stage: str | None = None,
|
||||
worktree_root: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Markdown link(s) to the finishing agent's artifact(s) in Gitea.
|
||||
"""HTML <li><a>...</a></li> link fragments for the finishing agent's artifacts.
|
||||
|
||||
Uses gitea_public_url (falls back to gitea_url) for clickable links, mirroring
|
||||
the analyst doc links. Returns [] (never raises) when there is nothing to
|
||||
link or the required context is missing. analyst is intentionally NOT handled
|
||||
here — its richer doc list lives in stage_engine._build_analyst_ready_comment.
|
||||
ORCH-016 (ADR-001 §7) breaking change: this function now emits HTML anchor
|
||||
fragments to feed straight into the <ul> of build_status_comment(), instead
|
||||
of the legacy markdown ``[label](url)`` strings. The base URL still prefers
|
||||
settings.gitea_public_url (falls back to gitea_url) so links remain clickable
|
||||
from outside the deploy host, exactly like the analyst doc list.
|
||||
|
||||
Returned strings are individual ``<li><a href="...">label</a></li>`` items;
|
||||
the caller wraps them in ``<ul>...</ul>``. Empty list (never raises) when
|
||||
there is nothing to link or context is missing.
|
||||
|
||||
AC-8 graceful behaviour: when ``worktree_root`` is provided, a candidate
|
||||
whose underlying file does NOT exist in the worktree is dropped silently.
|
||||
With no worktree (unit-test / minimal context), every applicable link is
|
||||
emitted without a file-existence probe (matches the legacy artifact_links
|
||||
semantics; that's what existing tests in tests/test_usage.py exercise).
|
||||
|
||||
Per agent (ADR-001 §7, ТЗ §2.4):
|
||||
developer -> Branch + (open) PR
|
||||
architect -> ADR directory
|
||||
reviewer -> 12-review.md
|
||||
tester -> 13-test-report.md
|
||||
deployer -> 14-deploy-log.md (deploy) or 15-staging-log.md (deploy-staging)
|
||||
analyst -> NOT handled here; build_status_comment owns its richer list.
|
||||
"""
|
||||
try:
|
||||
from .config import settings
|
||||
@@ -351,37 +458,76 @@ def artifact_links(
|
||||
).rstrip("/")
|
||||
if not base or not repo:
|
||||
return []
|
||||
links: list[str] = []
|
||||
|
||||
items: list[str] = []
|
||||
rel_dir = f"docs/work-items/{work_item_id}" if work_item_id else None
|
||||
|
||||
def _file_exists(rel_path: str) -> bool:
|
||||
if not worktree_root:
|
||||
return True
|
||||
try:
|
||||
import os as _os
|
||||
return _os.path.isfile(_os.path.join(worktree_root, rel_path))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def _dir_exists(rel_path: str) -> bool:
|
||||
if not worktree_root:
|
||||
return True
|
||||
try:
|
||||
import os as _os
|
||||
return _os.path.isdir(_os.path.join(worktree_root, rel_path))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
if agent == "developer":
|
||||
if branch:
|
||||
links.append(
|
||||
f"\U0001f4c2 [Branch {branch}]({base}/{owner}/{repo}/src/branch/{branch})"
|
||||
items.append(
|
||||
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}">'
|
||||
f"Branch {branch}</a></li>"
|
||||
)
|
||||
if pr_number:
|
||||
links.append(
|
||||
f"\U0001f517 [PR #{pr_number}]({base}/{owner}/{repo}/pulls/{pr_number})"
|
||||
items.append(
|
||||
f'<li><a href="{base}/{owner}/{repo}/pulls/{pr_number}">'
|
||||
f"PR #{pr_number}</a></li>"
|
||||
)
|
||||
return links
|
||||
return items
|
||||
|
||||
if agent == "architect":
|
||||
if branch and work_item_id:
|
||||
adr_dir = (
|
||||
f"{base}/{owner}/{repo}/src/branch/{branch}/"
|
||||
f"docs/work-items/{work_item_id}/06-adr"
|
||||
)
|
||||
links.append(f"\U0001f4d0 [ADR]({adr_dir})")
|
||||
return links
|
||||
if branch and rel_dir:
|
||||
adr_rel = f"{rel_dir}/06-adr"
|
||||
if _dir_exists(adr_rel):
|
||||
items.append(
|
||||
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
|
||||
f'{adr_rel}">ADR</a></li>'
|
||||
)
|
||||
return items
|
||||
|
||||
if agent == "deployer":
|
||||
# Stage-aware (ORCH-35 + ORCH-016 §2.4): 'deploy-staging' picks the
|
||||
# staging log; 'deploy' (or unknown) picks the deploy log. Other
|
||||
# deployer artifacts (smoke output etc.) are out of scope.
|
||||
if branch and rel_dir:
|
||||
if (stage or "").strip() == "deploy-staging":
|
||||
fname, label = "15-staging-log.md", "Staging log"
|
||||
else:
|
||||
fname, label = "14-deploy-log.md", "Deploy log"
|
||||
if _file_exists(f"{rel_dir}/{fname}"):
|
||||
items.append(
|
||||
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
|
||||
f'{rel_dir}/{fname}">{label}</a></li>'
|
||||
)
|
||||
return items
|
||||
|
||||
spec = AGENT_ARTIFACT.get(agent)
|
||||
if spec and branch and work_item_id:
|
||||
if spec and branch and rel_dir:
|
||||
label, fname = spec
|
||||
href = (
|
||||
f"{base}/{owner}/{repo}/src/branch/{branch}/"
|
||||
f"docs/work-items/{work_item_id}/{fname}"
|
||||
)
|
||||
links.append(f"\U0001f4c4 [{label}]({href})")
|
||||
return links
|
||||
if _file_exists(f"{rel_dir}/{fname}"):
|
||||
items.append(
|
||||
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
|
||||
f'{rel_dir}/{fname}">{label}</a></li>'
|
||||
)
|
||||
return items
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -396,6 +542,295 @@ AGENT_ICON = {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-016: unified status comment for every agent (analyst..deployer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Per-agent one-line description used in the status comment header (ADR-001 §2).
|
||||
# Trailing periods are kept to match the literal assertions in AC-1..AC-5.
|
||||
_AGENT_DESCRIPTIONS = {
|
||||
"analyst": (
|
||||
"Подготовил BRD / "
|
||||
"ТЗ / Acceptance Criteria. "
|
||||
"Для продвижения "
|
||||
"переведите задачу "
|
||||
"в статус Approved. "
|
||||
"Для отклонения — "
|
||||
"напишите причину "
|
||||
"комментом и "
|
||||
"переведите в Rejected."
|
||||
),
|
||||
"architect": (
|
||||
"Завершил "
|
||||
"архитектурную "
|
||||
"проработку. "
|
||||
"См. ADR ниже."
|
||||
),
|
||||
"developer": (
|
||||
"Завершил "
|
||||
"разработку. "
|
||||
"См. PR / branch ниже."
|
||||
),
|
||||
"reviewer": (
|
||||
"Завершил "
|
||||
"ревью "
|
||||
"изменений."
|
||||
),
|
||||
"tester": (
|
||||
"Завершил "
|
||||
"прогон "
|
||||
"тестов."
|
||||
),
|
||||
"deployer": (
|
||||
"Завершил деплой."
|
||||
),
|
||||
}
|
||||
|
||||
# Analyst-specific candidate artifact list (label -> filename in docs/work-items/<wid>/).
|
||||
# Matches the legacy _build_analyst_ready_comment list 1:1 so the BUG-C
|
||||
# regression test (tests/test_analyst_comment.py) keeps passing under the
|
||||
# unified format.
|
||||
_ANALYST_CANDIDATES = [
|
||||
("Business request", "00-business-request.md"),
|
||||
("BRD", "01-brd.md"),
|
||||
("ТЗ (TRZ)", "02-trz.md"),
|
||||
("Acceptance Criteria", "03-acceptance-criteria.md"),
|
||||
("Test Plan", "04-test-plan.yaml"),
|
||||
("UI Test Cases", "04b-ui-test-cases.md"),
|
||||
]
|
||||
|
||||
|
||||
def _read_verdict_line(
|
||||
agent: str, stage: str | None, worktree_root: str | None, work_item_id: str | None
|
||||
) -> str | None:
|
||||
"""Render the optional Verdict / Status line for reviewer / tester / deployer.
|
||||
|
||||
Sources (machine-readable YAML frontmatter, via src/frontmatter.py):
|
||||
reviewer -> 12-review.md verdict: -> 'Verdict: <VALUE>'
|
||||
tester -> 13-test-report.md verdict: (or status:) -> 'Verdict: <VALUE>'
|
||||
deployer -> deploy-staging -> 15-staging-log.md staging_status: -> 'Status: <VALUE>'
|
||||
else (deploy) -> 14-deploy-log.md deploy_status: -> 'Status: <VALUE>'
|
||||
|
||||
Returns None (line suppressed) for analyst / architect / developer, when
|
||||
the worktree is unknown, the work-item id is missing, the artifact file is
|
||||
absent, or the relevant frontmatter key is not present. Never raises.
|
||||
"""
|
||||
if agent not in ("reviewer", "tester", "deployer"):
|
||||
return None
|
||||
if not worktree_root or not work_item_id:
|
||||
return None
|
||||
try:
|
||||
import os as _os
|
||||
from .frontmatter import read_frontmatter_value
|
||||
base_dir = _os.path.join(worktree_root, "docs/work-items", work_item_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if agent == "reviewer":
|
||||
v = read_frontmatter_value(_os.path.join(base_dir, "12-review.md"), "verdict")
|
||||
return f"Verdict: {v}" if v else None
|
||||
if agent == "tester":
|
||||
path = _os.path.join(base_dir, "13-test-report.md")
|
||||
v = read_frontmatter_value(path, "verdict")
|
||||
if not v:
|
||||
v = read_frontmatter_value(path, "status")
|
||||
return f"Verdict: {v}" if v else None
|
||||
# deployer
|
||||
if (stage or "").strip() == "deploy-staging":
|
||||
v = read_frontmatter_value(
|
||||
_os.path.join(base_dir, "15-staging-log.md"), "staging_status"
|
||||
)
|
||||
else:
|
||||
v = read_frontmatter_value(
|
||||
_os.path.join(base_dir, "14-deploy-log.md"), "deploy_status"
|
||||
)
|
||||
return f"Status: {v}" if v else None
|
||||
|
||||
|
||||
def _analyst_doc_items(
|
||||
repo: str, branch: str, work_item_id: str, worktree_root: str | None
|
||||
) -> list[str]:
|
||||
"""Build the analyst's <li><a>...</a></li> list (mirrors legacy behaviour).
|
||||
|
||||
Files absent from the worktree are skipped (graceful, as in BUG-C / PR #13).
|
||||
"""
|
||||
if not (repo and branch and work_item_id):
|
||||
return []
|
||||
from .config import settings as _settings
|
||||
owner = getattr(_settings, "gitea_owner", "admin")
|
||||
base = (
|
||||
getattr(_settings, "gitea_public_url", "") or getattr(_settings, "gitea_url", "")
|
||||
).rstrip("/")
|
||||
if not base:
|
||||
return []
|
||||
rel_dir = f"docs/work-items/{work_item_id}"
|
||||
items: list[str] = []
|
||||
for label, fname in _ANALYST_CANDIDATES:
|
||||
if worktree_root:
|
||||
try:
|
||||
import os as _os
|
||||
if not _os.path.isfile(_os.path.join(worktree_root, rel_dir, fname)):
|
||||
continue
|
||||
except Exception:
|
||||
# On filesystem error, fall through and link the candidate anyway
|
||||
# (best-effort) rather than blanking the whole document list.
|
||||
pass
|
||||
href = f"{base}/{owner}/{repo}/src/branch/{branch}/{rel_dir}/{fname}"
|
||||
items.append(f'<li><a href="{href}">{label}</a></li>')
|
||||
return items
|
||||
|
||||
|
||||
def _usage_tail(usage: dict | None) -> str | None:
|
||||
"""Render the technical token/cost tail (``<sub>...</sub>``) or None when empty.
|
||||
|
||||
Format (ADR-001 §3): ``<sub>{fmt_in} / {out} out · {cost}</sub>``.
|
||||
Returns None when usage is missing entirely AND all of the relevant
|
||||
components are zero — i.e. nothing meaningful to print.
|
||||
"""
|
||||
if not usage:
|
||||
return None
|
||||
in_total = _input_total(usage)
|
||||
try:
|
||||
out = int(usage.get("output_tokens") or 0)
|
||||
except (TypeError, ValueError):
|
||||
out = 0
|
||||
try:
|
||||
cost = float(usage.get("cost_usd") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
cost = 0.0
|
||||
if in_total == 0 and out == 0 and cost == 0.0:
|
||||
return None
|
||||
return f"<sub>{fmt_in(usage)} / {fmt_tokens(out)} out · {fmt_cost(cost)}</sub>"
|
||||
|
||||
|
||||
def build_status_comment(
|
||||
agent: str,
|
||||
*,
|
||||
repo: str | None = None,
|
||||
branch: str | None = None,
|
||||
work_item_id: str | None = None,
|
||||
pr_number=None,
|
||||
stage: str | None = None,
|
||||
usage: dict | None = None,
|
||||
duration_s=None,
|
||||
task_id=None,
|
||||
worktree_root: str | None = None,
|
||||
) -> str:
|
||||
"""Build the unified per-agent finish comment (ORCH-016 / ADR-001).
|
||||
|
||||
Single hot path for every agent's "I just finished a stage" comment in
|
||||
Plane. Replaces the old ``usage_comment(...)`` one-liner AND the analyst-
|
||||
special ``stage_engine._build_analyst_ready_comment(...)`` HTML; both now
|
||||
flow through here. Format (HTML, rendered by Plane), separated by ``<br>``::
|
||||
|
||||
{ICON} {RoleName} — {DESCRIPTION}
|
||||
[Verdict|Status: VALUE] # reviewer/tester/deployer + FM
|
||||
[Длительность: 4m 12s]
|
||||
<b>Документы:</b><ul><li><a href="...">label</a></li>...</ul>
|
||||
[<sub>8.5M in / 45.8k out · $7.29</sub>]
|
||||
|
||||
Arguments (all keyword-only except ``agent``):
|
||||
agent one of analyst|architect|developer|reviewer|tester|deployer.
|
||||
Unknown agents get a generic header — defensive.
|
||||
repo/branch repository name + feature branch. Required for artifact
|
||||
links; without them the Документы block is omitted.
|
||||
work_item_id Plane work-item id used as the docs/work-items/<id>/ slug.
|
||||
pr_number developer only — appended as a PR link when set.
|
||||
stage deployer only — 'deploy' vs 'deploy-staging' picks the
|
||||
log file (14- vs 15-) and the verdict frontmatter key.
|
||||
usage parsed token/cost dict (from parse_usage_from_text). When
|
||||
None or all-zero the ``<sub>`` tail is suppressed.
|
||||
duration_s explicit per-agent wall-clock seconds. If None and
|
||||
task_id is given, falls back to
|
||||
get_agent_duration(task_id, agent). Negative / non-int
|
||||
values are treated as unknown.
|
||||
task_id tasks.id — required for the DB duration fallback. The
|
||||
verdict / artifact code paths do NOT depend on it.
|
||||
worktree_root path to the task's git worktree. Drives AC-8 graceful
|
||||
skipping of missing files AND the verdict frontmatter
|
||||
read. Omit (None) in unit tests where only format matters.
|
||||
|
||||
The function MUST NOT raise — at worst it returns a degraded one-liner
|
||||
header, with the exception logged. Self-hosting risk R-1: a crash here
|
||||
blinds the stakeholder for that very ORCH task.
|
||||
"""
|
||||
try:
|
||||
name = AGENT_DISPLAY.get(agent, (agent or "agent").capitalize())
|
||||
icon = AGENT_ICON.get(agent, "✅")
|
||||
description = _AGENT_DESCRIPTIONS.get(
|
||||
agent,
|
||||
"завершил стадию.",
|
||||
)
|
||||
if agent == "deployer":
|
||||
if (stage or "").strip() == "deploy-staging":
|
||||
description = (
|
||||
"Завершил "
|
||||
"staging-деплой."
|
||||
)
|
||||
elif (stage or "").strip() == "deploy":
|
||||
description = (
|
||||
"Завершил "
|
||||
"прод-деплой."
|
||||
)
|
||||
|
||||
lines: list[str] = [f"{icon} {name} — {description}"]
|
||||
|
||||
verdict_line = _read_verdict_line(agent, stage, worktree_root, work_item_id)
|
||||
if verdict_line:
|
||||
lines.append(verdict_line)
|
||||
|
||||
# Duration: explicit param wins; otherwise DB fallback (ADR-001 §6).
|
||||
resolved_duration: int | None = None
|
||||
if duration_s is not None:
|
||||
try:
|
||||
if int(duration_s) >= 0:
|
||||
resolved_duration = int(duration_s)
|
||||
except (TypeError, ValueError):
|
||||
resolved_duration = None
|
||||
if resolved_duration is None and task_id is not None:
|
||||
resolved_duration = get_agent_duration(task_id, agent)
|
||||
d_text = fmt_duration(resolved_duration)
|
||||
if d_text:
|
||||
lines.append(
|
||||
"Длительность: "
|
||||
f"{d_text}"
|
||||
)
|
||||
|
||||
# Documents block (analyst gets its full BRD/TRZ/AC/Test Plan list).
|
||||
if agent == "analyst":
|
||||
doc_items = _analyst_doc_items(
|
||||
repo or "", branch or "", work_item_id or "", worktree_root
|
||||
)
|
||||
else:
|
||||
doc_items = artifact_links(
|
||||
agent, repo, branch, work_item_id, pr_number,
|
||||
stage=stage, worktree_root=worktree_root,
|
||||
)
|
||||
if doc_items:
|
||||
lines.append(
|
||||
"<b>Документы:</b><ul>"
|
||||
+ "".join(doc_items)
|
||||
+ "</ul>"
|
||||
)
|
||||
|
||||
tail = _usage_tail(usage)
|
||||
if tail:
|
||||
lines.append(tail)
|
||||
|
||||
return "<br>".join(lines)
|
||||
except Exception as e: # defensive — R-1 fallback
|
||||
logger.exception(f"build_status_comment failed for agent={agent}: {e}")
|
||||
try:
|
||||
name = AGENT_DISPLAY.get(agent, str(agent).capitalize())
|
||||
icon = AGENT_ICON.get(agent, "✅")
|
||||
return (
|
||||
f"{icon} {name} "
|
||||
"готов"
|
||||
)
|
||||
except Exception:
|
||||
return "✅ Agent готов"
|
||||
|
||||
|
||||
def task_usage_summary(task_id: int) -> dict:
|
||||
"""Aggregate agent_runs usage for a task.
|
||||
|
||||
@@ -441,14 +876,14 @@ def task_summary_comment(task_id: int) -> str:
|
||||
s = task_usage_summary(task_id)
|
||||
cached = s.get("total_cached", 0)
|
||||
head_in = (
|
||||
f"{fmt_tokens(s['total_in'])} \u0432\u0445\u043e\u0434 ({fmt_tokens(cached)} cached)"
|
||||
f"{fmt_tokens(s['total_in'])} вход ({fmt_tokens(cached)} cached)"
|
||||
if cached > 0
|
||||
else f"{fmt_tokens(s['total_in'])} \u0432\u0445\u043e\u0434"
|
||||
else f"{fmt_tokens(s['total_in'])} вход"
|
||||
)
|
||||
lines = [
|
||||
f"\U0001f4ca \u0418\u0442\u043e\u0433\u043e \u043f\u043e \u0437\u0430\u0434\u0430\u0447\u0435: "
|
||||
f"\U0001f4ca Итого по задаче: "
|
||||
f"{head_in} / "
|
||||
f"{fmt_tokens(s['total_out'])} \u0432\u044b\u0445\u043e\u0434 \u00b7 "
|
||||
f"{fmt_tokens(s['total_out'])} выход · "
|
||||
f"{fmt_cost(s['total_cost'])}"
|
||||
]
|
||||
for agent, ti, tc, to, cost in s["per_agent"]:
|
||||
@@ -459,6 +894,6 @@ def task_summary_comment(task_id: int) -> str:
|
||||
else f"{fmt_tokens(ti)} in"
|
||||
)
|
||||
lines.append(
|
||||
f"\u2022 {name}: {in_str} / {fmt_tokens(to)} out \u00b7 {fmt_cost(cost)}"
|
||||
f"• {name}: {in_str} / {fmt_tokens(to)} out · {fmt_cost(cost)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
126
tests/test_analyst_comment_regression.py
Normal file
126
tests/test_analyst_comment_regression.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""ORCH-016 / TC-11 + AC-6: analyst status-comment regression.
|
||||
|
||||
Status-only verdict model from PR #12 / #13 must be preserved exactly:
|
||||
- the analyst comment still asks the stakeholder for the **Approved** status,
|
||||
- it still rejects the obsolete ``:approved:`` reaction and "move to In Progress",
|
||||
- it still links the documents that actually exist (BRD / TRZ / AC / Test Plan,
|
||||
skipping anything not on disk),
|
||||
- it now also carries the new «Длительность: …» line when an agent_runs row
|
||||
exists for (task_id, analyst).
|
||||
"""
|
||||
|
||||
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_orch016_analyst_regression.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(monkeypatch):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
def _seed_task_and_analyst_run(task_id=42, agent="analyst", duration_seconds=180):
|
||||
"""Insert a task and a finished analyst run with a measurable duration."""
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
|
||||
"VALUES (?, 'orchestrator', 'feature/ORCH-016', 'analysis', 'ORCH-016')",
|
||||
(task_id,),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent, started_at, finished_at) "
|
||||
"VALUES (?, ?, datetime('now', ?), datetime('now'))",
|
||||
(task_id, agent, f"-{duration_seconds} seconds"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_tc11_analyst_text_preserved_with_links(monkeypatch, tmp_path):
|
||||
"""Analyst comment must keep all existing assertions from PR #12 / #13."""
|
||||
from src import stage_engine as SE
|
||||
from src.config import settings
|
||||
|
||||
wt = tmp_path / "wt"
|
||||
docs = wt / "docs" / "work-items" / "ET-011"
|
||||
docs.mkdir(parents=True)
|
||||
for fname in (
|
||||
"00-business-request.md", "01-brd.md", "02-trz.md",
|
||||
"03-acceptance-criteria.md", "04-test-plan.yaml",
|
||||
):
|
||||
(docs / fname).write_text("x")
|
||||
# 04b-ui-test-cases.md intentionally absent
|
||||
|
||||
monkeypatch.setattr(SE, "get_worktree_path", lambda repo, branch: str(wt))
|
||||
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)
|
||||
|
||||
html = SE._build_analyst_ready_comment(
|
||||
"enduro-trails", "ET-011", "feature/ET-011-gpx-upload-feature",
|
||||
)
|
||||
|
||||
# Status-only verdict text (PR #12 contract).
|
||||
assert "Approved" in html
|
||||
assert "Rejected" in html
|
||||
assert ":approved:" not in html
|
||||
assert "In Progress" not in html
|
||||
|
||||
# Clickable links via public URL only.
|
||||
assert "<a href=" in html
|
||||
base = ("https://git.mva154.duckdns.org/admin/enduro-trails/src/branch/"
|
||||
"feature/ET-011-gpx-upload-feature/docs/work-items/ET-011/")
|
||||
assert base + "01-brd.md" in html
|
||||
assert base + "04-test-plan.yaml" in html
|
||||
|
||||
# Missing file NOT linked.
|
||||
assert "04b-ui-test-cases.md" not in html
|
||||
|
||||
# Internal URL must NOT leak into clickable links.
|
||||
assert "localhost:3000" not in html
|
||||
|
||||
|
||||
def test_tc11_analyst_includes_duration_when_db_has_run(monkeypatch, tmp_path):
|
||||
"""When an agent_runs row exists for (task_id, analyst), the comment carries
|
||||
a «Длительность:» line populated via the DB fallback (AC-14)."""
|
||||
from src import stage_engine as SE
|
||||
from src.config import settings
|
||||
|
||||
wt = tmp_path / "wt"
|
||||
(wt / "docs" / "work-items" / "ORCH-016").mkdir(parents=True)
|
||||
(wt / "docs" / "work-items" / "ORCH-016" / "01-brd.md").write_text("x")
|
||||
|
||||
_seed_task_and_analyst_run(task_id=42, agent="analyst", duration_seconds=125)
|
||||
|
||||
monkeypatch.setattr(SE, "get_worktree_path", lambda repo, branch: str(wt))
|
||||
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
|
||||
monkeypatch.setattr(settings, "gitea_public_url", "", raising=False)
|
||||
monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
|
||||
|
||||
html = SE._build_analyst_ready_comment(
|
||||
"orchestrator", "ORCH-016", "feature/ORCH-016", task_id=42,
|
||||
)
|
||||
|
||||
# Two-digit seconds rounding may shave ~1s — accept either neighbour.
|
||||
assert any(
|
||||
s in html
|
||||
for s in ("Длительность: 2m 05s", "Длительность: 2m 04s", "Длительность: 2m 06s")
|
||||
), html
|
||||
135
tests/test_analyst_status_only_regression.py
Normal file
135
tests/test_analyst_status_only_regression.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""ORCH-016 / TC-16 + AC-6: analyst status-only regression.
|
||||
|
||||
Status-only verdict model (PR #12 / #13):
|
||||
- analyst finishes its run -> Plane state becomes In Review,
|
||||
- ONE status comment is posted asking the stakeholder to flip the status to
|
||||
Approved (or write a reason and switch to Rejected),
|
||||
- NO auto-advance happens — the next stage waits for human approval.
|
||||
|
||||
The ORCH-016 PR refactors the comment text into the unified status-comment
|
||||
helper. This regression test guards against:
|
||||
(a) the analyst path silently auto-advancing,
|
||||
(b) the analyst comment losing the «Approved» / «Rejected» instruction text,
|
||||
(c) the comment switching authorship away from the analyst bot.
|
||||
|
||||
We exercise `_handle_analysis_approved_flow` directly (the launcher path).
|
||||
"""
|
||||
|
||||
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_orch016_analyst_so.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
|
||||
|
||||
REPO = "enduro-trails"
|
||||
BRANCH = "feature/ET-016-x"
|
||||
WID = "ET-016"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
|
||||
"VALUES (1, ?, ?, 'analysis', ?)",
|
||||
(REPO, BRANCH, WID),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_worktree(monkeypatch, tmp_path):
|
||||
base = tmp_path / "wt"
|
||||
docs = base / "docs" / "work-items" / WID
|
||||
docs.mkdir(parents=True)
|
||||
# All analyst artifacts present -> "files_check" returns True.
|
||||
for f in ("01-brd.md", "02-trz.md", "03-acceptance-criteria.md",
|
||||
"04-test-plan.yaml"):
|
||||
(docs / f).write_text("x")
|
||||
monkeypatch.setattr("src.git_worktree.get_worktree_path", lambda r, b: str(base))
|
||||
monkeypatch.setattr("src.stage_engine.get_worktree_path", lambda r, b: str(base))
|
||||
monkeypatch.setattr("src.qg.checks.get_worktree_path", lambda r, b: str(base))
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collect_calls(monkeypatch):
|
||||
calls = {"in_review": 0, "advance": 0, "comments": [], "enqueued": []}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.stage_engine.set_issue_in_review",
|
||||
lambda wid: calls.__setitem__("in_review", calls["in_review"] + 1),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.stage_engine.notify_approve_requested", lambda tid: None
|
||||
)
|
||||
|
||||
def _add_comment(wid, body, author=None, **kw):
|
||||
calls["comments"].append({"wid": wid, "body": body, "author": author})
|
||||
|
||||
monkeypatch.setattr("src.stage_engine.plane_add_comment", _add_comment)
|
||||
|
||||
# advance_stage isn't directly hit; if anything calls update_task_stage to
|
||||
# 'architecture', we'd see it here.
|
||||
def _update_task_stage(task_id, stage):
|
||||
calls["advance"] += 1
|
||||
|
||||
monkeypatch.setattr("src.stage_engine.update_task_stage", _update_task_stage)
|
||||
|
||||
def _enqueue(*a, **k):
|
||||
calls["enqueued"].append((a, k))
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr("src.stage_engine.enqueue_job", _enqueue)
|
||||
return calls
|
||||
|
||||
|
||||
def test_tc16_analyst_goes_to_in_review_no_advance(fake_worktree, collect_calls):
|
||||
"""When the analyst finishes with complete artifacts, the task goes to In
|
||||
Review and NO advance/enqueue happens — the human approves via Plane status.
|
||||
"""
|
||||
from src.stage_engine import _handle_analysis_approved_flow, AdvanceResult
|
||||
|
||||
result = AdvanceResult(from_stage="analysis")
|
||||
_handle_analysis_approved_flow(
|
||||
task_id=1, current_stage="analysis", repo=REPO, work_item_id=WID,
|
||||
branch=BRANCH, agent="analyst", result=result,
|
||||
)
|
||||
|
||||
# In Review state requested in Plane.
|
||||
assert collect_calls["in_review"] == 1, collect_calls
|
||||
# NO stage-machine advance.
|
||||
assert collect_calls["advance"] == 0, collect_calls
|
||||
# NO new job enqueued by the analyst path.
|
||||
assert collect_calls["enqueued"] == [], collect_calls
|
||||
|
||||
# Exactly one comment posted, authored by analyst, with required text bits.
|
||||
assert len(collect_calls["comments"]) == 1, collect_calls["comments"]
|
||||
c = collect_calls["comments"][0]
|
||||
assert c["wid"] == WID
|
||||
assert c["author"] == "analyst"
|
||||
body = c["body"]
|
||||
assert "Approved" in body
|
||||
assert "Rejected" in body
|
||||
assert ":approved:" not in body
|
||||
assert "In Progress" not in body
|
||||
# AC-6 +: the new unified format adds a Длительность line (DB fallback).
|
||||
# No agent_runs row exists in this test, so the line should be ABSENT.
|
||||
assert "Длительность" not in body
|
||||
68
tests/test_fmt_duration.py
Normal file
68
tests/test_fmt_duration.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""ORCH-016 / AC-13 + AC-22: fmt_duration formatting contract.
|
||||
|
||||
Pure-function tests for the duration formatter used by build_status_comment.
|
||||
No DB, no I/O — just the table in ADR-001 §8 / AC-13.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
from src.usage import fmt_duration # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-21: table-driven happy path (AC-13)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_fmt_duration_boundary_table():
|
||||
cases = [
|
||||
(0, "0s"),
|
||||
(12, "12s"),
|
||||
(59, "59s"),
|
||||
(60, "1m 00s"),
|
||||
(252, "4m 12s"),
|
||||
(3599, "59m 59s"),
|
||||
(3600, "1h 00m"),
|
||||
(3780, "1h 03m"),
|
||||
(10020, "2h 47m"),
|
||||
]
|
||||
for seconds, expected in cases:
|
||||
assert fmt_duration(seconds) == expected, (
|
||||
f"fmt_duration({seconds}) -> {fmt_duration(seconds)!r}; expected {expected!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-22: None / negative -> empty string (caller drops the line) (AC-13)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_fmt_duration_none_returns_empty():
|
||||
assert fmt_duration(None) == ""
|
||||
|
||||
|
||||
def test_fmt_duration_negative_returns_empty():
|
||||
assert fmt_duration(-1) == ""
|
||||
assert fmt_duration(-3600) == ""
|
||||
|
||||
|
||||
def test_fmt_duration_garbage_returns_empty():
|
||||
# Non-coercible input must not raise (defensive).
|
||||
assert fmt_duration("abc") == ""
|
||||
assert fmt_duration([1, 2]) == ""
|
||||
|
||||
|
||||
def test_fmt_duration_float_seconds_truncated():
|
||||
# int(12.9) == 12 — integer truncation, not rounding.
|
||||
assert fmt_duration(12.9) == "12s"
|
||||
assert fmt_duration(61.4) == "1m 01s"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caller contract: empty string => the 'Длительность:' line is NOT printed.
|
||||
# build_status_comment is unit-tested in test_status_comment_format; here we
|
||||
# just sanity-check the helper used to gate that decision.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_empty_string_is_falsy():
|
||||
assert not fmt_duration(None)
|
||||
assert not fmt_duration(-5)
|
||||
assert fmt_duration(0) # "0s" IS truthy: AC-13 wants the line printed
|
||||
79
tests/test_notify_done_regression.py
Normal file
79
tests/test_notify_done_regression.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""ORCH-016 / TC-18 + AC-7: notify_done / set_issue_done not regressed.
|
||||
|
||||
The final deploy -> done transition still posts the «✅ Task completed!»
|
||||
comment under the deployer bot, alongside the new ORCH-016 status comment
|
||||
the deployer publishes when it finishes the stage. The two comments are
|
||||
independent — the status comment doesn't replace `notify_done`.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
from src import plane_sync as PS # noqa: E402
|
||||
|
||||
|
||||
def test_notify_done_constants_unchanged():
|
||||
# Emoji + message body — pinned to lock the contract.
|
||||
assert PS.EMOJI_DONE == "✅"
|
||||
|
||||
|
||||
def test_notify_done_posts_completed_comment(monkeypatch):
|
||||
"""plane_sync.notify_done still posts the ✅ Task completed! comment
|
||||
authored by the deployer."""
|
||||
captured = {}
|
||||
|
||||
def _spy_update(work_item_id, state, project_id=None):
|
||||
captured["update"] = (work_item_id, state, project_id)
|
||||
|
||||
def _spy_add(work_item_id, body, project_id=None, author=None, **kw):
|
||||
captured.setdefault("comments", []).append(
|
||||
{"wid": work_item_id, "body": body, "author": author}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(PS, "update_issue_state", _spy_update)
|
||||
monkeypatch.setattr(PS, "add_comment", _spy_add)
|
||||
monkeypatch.setattr(PS, "_resolve_project_id", lambda wid, pid=None: "p-1")
|
||||
|
||||
PS.notify_done("ET-016")
|
||||
|
||||
assert captured["update"] == ("ET-016", "done", "p-1")
|
||||
assert len(captured["comments"]) == 1
|
||||
c = captured["comments"][0]
|
||||
assert c["wid"] == "ET-016"
|
||||
assert c["author"] == "deployer"
|
||||
# Body untouched: emoji + canonical Russian/English copy.
|
||||
assert "✅" in c["body"]
|
||||
assert "Task completed" in c["body"]
|
||||
|
||||
|
||||
def test_set_issue_done_still_exported():
|
||||
"""set_issue_done must remain importable from plane_sync — stage_engine
|
||||
line ~269 invokes it on deploy->done. ORCH-016 must not remove or rename it.
|
||||
"""
|
||||
assert callable(getattr(PS, "set_issue_done", None))
|
||||
# And stage_engine still imports it at the module level (regression: ORCH-016
|
||||
# touches stage_engine to wire the new analyst comment helper).
|
||||
from src import stage_engine as SE
|
||||
assert getattr(SE, "set_issue_done", None) is PS.set_issue_done
|
||||
|
||||
|
||||
def test_orch016_does_not_steal_done_signal(monkeypatch):
|
||||
"""build_status_comment is just a comment — it must NOT call set_issue_done
|
||||
or notify_done as a side effect (that's stage_engine's job)."""
|
||||
from src import usage as U
|
||||
called = {"done": 0, "in_review": 0}
|
||||
|
||||
def _fail(*a, **k):
|
||||
called["done"] += 1
|
||||
|
||||
monkeypatch.setattr(PS, "set_issue_done", _fail)
|
||||
monkeypatch.setattr(PS, "notify_done", _fail)
|
||||
|
||||
html = U.build_status_comment(
|
||||
"deployer", repo="enduro-trails", branch="b", work_item_id="ET-016",
|
||||
stage="deploy", duration_s=12,
|
||||
)
|
||||
assert "\U0001f680 Deployer" in html
|
||||
assert called["done"] == 0
|
||||
199
tests/test_post_usage_comments_integration.py
Normal file
199
tests/test_post_usage_comments_integration.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""ORCH-016 / TC-13..TC-15: _post_usage_comments integration tests.
|
||||
|
||||
End-to-end (DB + filesystem worktree, no network) verification that
|
||||
AgentLauncher._post_usage_comments:
|
||||
- resolves the task by (repo, branch),
|
||||
- threads the explicit duration_s into build_status_comment,
|
||||
- posts exactly ONE status comment authored by the finishing agent,
|
||||
- for deployer: ALSO posts the per-task usage summary (deployer authorship).
|
||||
|
||||
The actual Plane HTTP call (plane_sync.add_comment) is patched out; we only
|
||||
check the (work_item_id, body, author) tuples the launcher passes to it.
|
||||
"""
|
||||
|
||||
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_orch016_post_usage.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src.agents.launcher import AgentLauncher # noqa: E402
|
||||
|
||||
|
||||
REPO = "enduro-trails"
|
||||
BRANCH = "feature/ET-016-x"
|
||||
WID = "ET-016"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
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()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
|
||||
"VALUES (1, ?, ?, 'review', ?)",
|
||||
(REPO, BRANCH, WID),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_worktree(monkeypatch, tmp_path):
|
||||
"""Stub get_worktree_path inside the launcher module to a tmp_path location."""
|
||||
wt = tmp_path / "wt"
|
||||
(wt / "docs" / "work-items" / WID).mkdir(parents=True)
|
||||
|
||||
def _get_wt(repo, branch):
|
||||
return str(wt)
|
||||
|
||||
# The launcher imports get_worktree_path lazily inside the function body
|
||||
# (`from ..git_worktree import get_worktree_path`); patch the source module.
|
||||
monkeypatch.setattr("src.git_worktree.get_worktree_path", _get_wt)
|
||||
monkeypatch.setattr("src.usage._input_total", lambda u: 0) # quiet <sub> tail
|
||||
return wt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def capture_comments(monkeypatch):
|
||||
posts = []
|
||||
|
||||
def _spy(work_item_id, body, author=None, **kwargs):
|
||||
posts.append({"wid": work_item_id, "body": body, "author": author})
|
||||
|
||||
monkeypatch.setattr("src.agents.launcher.plane_add_comment", _spy)
|
||||
return posts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def public_url(monkeypatch):
|
||||
from src.config import settings
|
||||
monkeypatch.setattr(
|
||||
settings, "gitea_public_url", "https://git.mva154.duckdns.org", raising=False
|
||||
)
|
||||
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
|
||||
monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-13: reviewer comment.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc13_reviewer_posts_one_status_comment(
|
||||
setup_db, fake_worktree, capture_comments, public_url
|
||||
):
|
||||
(fake_worktree / "docs" / "work-items" / WID / "12-review.md").write_text(
|
||||
"---\nverdict: APPROVE\n---\nReviewed.",
|
||||
)
|
||||
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=99, agent="reviewer", repo=REPO, branch=BRANCH,
|
||||
usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
|
||||
duration_s=180,
|
||||
)
|
||||
|
||||
assert len(capture_comments) == 1
|
||||
post = capture_comments[0]
|
||||
assert post["wid"] == WID
|
||||
assert post["author"] == "reviewer"
|
||||
body = post["body"]
|
||||
assert "\U0001f50e Reviewer" in body
|
||||
assert "Verdict: APPROVE" in body
|
||||
assert "Длительность: 3m 00s" in body
|
||||
assert "12-review.md" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14: tester comment.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_tester_posts_one_status_comment(
|
||||
setup_db, fake_worktree, capture_comments, public_url
|
||||
):
|
||||
(fake_worktree / "docs" / "work-items" / WID / "13-test-report.md").write_text(
|
||||
"---\nverdict: PASS\n---\n",
|
||||
)
|
||||
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=100, agent="tester", repo=REPO, branch=BRANCH,
|
||||
usage=None, duration_s=42,
|
||||
)
|
||||
|
||||
assert len(capture_comments) == 1
|
||||
post = capture_comments[0]
|
||||
assert post["author"] == "tester"
|
||||
body = post["body"]
|
||||
assert "\U0001f9ea Tester" in body
|
||||
assert "Verdict: PASS" in body
|
||||
assert "Длительность: 42s" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-15: deployer comment + per-task summary (two comments, both from deployer).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc15_deployer_posts_status_then_summary(
|
||||
setup_db, fake_worktree, capture_comments, public_url
|
||||
):
|
||||
# Task stage = 'deploy' so build_status_comment uses 14-deploy-log.md.
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage='deploy' WHERE id=1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
(fake_worktree / "docs" / "work-items" / WID / "14-deploy-log.md").write_text(
|
||||
"---\ndeploy_status: SUCCESS\n---\nDeployed.",
|
||||
)
|
||||
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=101, agent="deployer", repo=REPO, branch=BRANCH,
|
||||
usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
|
||||
duration_s=300,
|
||||
)
|
||||
|
||||
# 2 comments: status + per-task summary.
|
||||
assert len(capture_comments) == 2
|
||||
status, summary = capture_comments
|
||||
assert status["author"] == "deployer"
|
||||
assert "Status: SUCCESS" in status["body"]
|
||||
assert "Длительность: 5m 00s" in status["body"]
|
||||
assert "14-deploy-log.md" in status["body"]
|
||||
|
||||
assert summary["author"] == "deployer"
|
||||
# task_summary_comment header (Russian "Итого по задаче").
|
||||
assert "\U0001f4ca" in summary["body"]
|
||||
assert "Итого" in summary["body"]
|
||||
|
||||
|
||||
def test_deployer_staging_picks_15_log(
|
||||
setup_db, fake_worktree, capture_comments, public_url
|
||||
):
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage='deploy-staging' WHERE id=1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
(fake_worktree / "docs" / "work-items" / WID / "15-staging-log.md").write_text(
|
||||
"---\nstaging_status: SUCCESS\n---\n",
|
||||
)
|
||||
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=102, agent="deployer", repo=REPO, branch=BRANCH,
|
||||
usage=None, duration_s=10,
|
||||
)
|
||||
|
||||
# deployer always also posts the summary; check the FIRST comment is status.
|
||||
assert len(capture_comments) == 2
|
||||
status = capture_comments[0]
|
||||
assert "Status: SUCCESS" in status["body"]
|
||||
assert "15-staging-log.md" in status["body"]
|
||||
assert "14-deploy-log.md" not in status["body"]
|
||||
assert "staging-деплой" in status["body"]
|
||||
64
tests/test_qg_registry_snapshot.py
Normal file
64
tests/test_qg_registry_snapshot.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""ORCH-016 / TC-20 + AC-11: Quality Gates + stage machine are unchanged.
|
||||
|
||||
Smoke / change-detector test: the ORCH-016 PR touches comment formatting only.
|
||||
The QG registry (src/qg/checks.QG_CHECKS) and the stage-machine table
|
||||
(src/stages.STAGE_TRANSITIONS) MUST remain bit-identical to the contracts the
|
||||
pipeline depends on. If a future change moves the comment hot path into these
|
||||
files by accident, this guard breaks first.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
from src.qg.checks import QG_CHECKS # noqa: E402
|
||||
from src.stages import STAGE_TRANSITIONS # noqa: E402
|
||||
|
||||
|
||||
# The set of QG names the pipeline DEPLOYS on. Order doesn't matter, identity does.
|
||||
_EXPECTED_QGS = {
|
||||
"check_analysis_approved",
|
||||
"check_analysis_complete",
|
||||
"check_architecture_done",
|
||||
"check_ci_green",
|
||||
"check_review_approved",
|
||||
"check_tests_passed",
|
||||
"check_reviewer_verdict",
|
||||
"check_tests_local",
|
||||
"check_deploy_status",
|
||||
"check_staging_status",
|
||||
}
|
||||
|
||||
|
||||
def test_tc20_qg_registry_unchanged():
|
||||
assert set(QG_CHECKS.keys()) == _EXPECTED_QGS
|
||||
|
||||
|
||||
def test_tc20_qg_callables_unchanged():
|
||||
# All entries must be callable — no stub / lambda / None.
|
||||
for name, fn in QG_CHECKS.items():
|
||||
assert callable(fn), f"QG {name} is not callable"
|
||||
|
||||
|
||||
# Reference snapshot of STAGE_TRANSITIONS (mirrors what's in docs/architecture
|
||||
# and src/stages.py — duplicated here on purpose as a regression yardstick).
|
||||
_EXPECTED_TRANSITIONS = {
|
||||
"created": {"next": "analysis", "agent": "analyst", "qg": None},
|
||||
"analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"},
|
||||
"architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"},
|
||||
"development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"},
|
||||
"review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"},
|
||||
"testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"},
|
||||
"deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
|
||||
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
|
||||
"done": {"next": None, "agent": None, "qg": None},
|
||||
}
|
||||
|
||||
|
||||
def test_tc20_stage_transitions_unchanged():
|
||||
assert STAGE_TRANSITIONS == _EXPECTED_TRANSITIONS, (
|
||||
"STAGE_TRANSITIONS drift detected — ORCH-016 must not change the "
|
||||
"stage machine. Touched stage_engine or stages.py? Update the snapshot "
|
||||
"in a separate, intentional PR."
|
||||
)
|
||||
122
tests/test_status_comment_authorship.py
Normal file
122
tests/test_status_comment_authorship.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""ORCH-016 / TC-19 + AC-1..AC-5 authorship: status comments use per-agent bots.
|
||||
|
||||
When a status comment is posted by AgentLauncher._post_usage_comments, the
|
||||
underlying plane_sync.add_comment must be invoked with ``author=<agent>`` so
|
||||
plane_sync._headers_for(<agent>) picks the agent's bot token
|
||||
(PLANE_BOT_TOKENS[role]) — falling back to PLANE_HEADERS when the bot token
|
||||
is empty / role unknown. Comment FORMAT changes (ORCH-016) must not affect
|
||||
that authorship contract.
|
||||
"""
|
||||
|
||||
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_orch016_authorship.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src.agents.launcher import AgentLauncher # noqa: E402
|
||||
|
||||
REPO = "enduro-trails"
|
||||
BRANCH = "feature/ET-016-x"
|
||||
WID = "ET-016"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def 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()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
|
||||
"VALUES (1, ?, ?, 'review', ?)",
|
||||
(REPO, BRANCH, WID),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_wt(monkeypatch, tmp_path):
|
||||
base = tmp_path / "wt"
|
||||
(base / "docs" / "work-items" / WID).mkdir(parents=True)
|
||||
monkeypatch.setattr("src.git_worktree.get_worktree_path", lambda r, b: str(base))
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def capture(monkeypatch):
|
||||
posts = []
|
||||
|
||||
def _spy(work_item_id, body, author=None, **kwargs):
|
||||
posts.append({"wid": work_item_id, "body": body, "author": author})
|
||||
|
||||
monkeypatch.setattr("src.agents.launcher.plane_add_comment", _spy)
|
||||
return posts
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent", ["architect", "developer", "reviewer", "tester"])
|
||||
def test_tc19_status_comment_carries_agent_author(agent, db, fake_wt, capture):
|
||||
"""Each agent's status comment must be POST-ed under that agent's bot."""
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=1, agent=agent, repo=REPO, branch=BRANCH,
|
||||
usage=None, duration_s=10,
|
||||
)
|
||||
assert len(capture) >= 1
|
||||
assert capture[0]["author"] == agent, (
|
||||
f"Expected author={agent!r}, got {capture[0]['author']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_tc19_deployer_status_and_summary_both_authored_by_deployer(db, fake_wt, capture):
|
||||
"""Deployer posts TWO comments (status + per-task summary) — both ``author='deployer'``."""
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage='deploy' WHERE id=1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=2, agent="deployer", repo=REPO, branch=BRANCH,
|
||||
usage=None, duration_s=10,
|
||||
)
|
||||
|
||||
assert len(capture) == 2
|
||||
assert {c["author"] for c in capture} == {"deployer"}
|
||||
|
||||
|
||||
def test_tc19_headers_for_unknown_role_falls_back(monkeypatch):
|
||||
"""Ensure plane_sync._headers_for handles unknown agents (fallback contract)."""
|
||||
from src import plane_sync
|
||||
h = plane_sync._headers_for("unknown_role_xyz")
|
||||
# PLANE_HEADERS fallback uses settings.plane_api_token (set to 'test-token').
|
||||
assert isinstance(h, dict) and "X-API-Key" in h
|
||||
|
||||
|
||||
def test_tc19_status_comment_format_preserves_author_contract(db, fake_wt, capture):
|
||||
"""The ORCH-016 format change must not strip the author= kw from the call site."""
|
||||
(fake_wt / "docs" / "work-items" / WID / "12-review.md").write_text(
|
||||
"---\nverdict: APPROVE\n---\n",
|
||||
)
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=3, agent="reviewer", repo=REPO, branch=BRANCH,
|
||||
usage={"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0},
|
||||
duration_s=180,
|
||||
)
|
||||
assert len(capture) == 1
|
||||
post = capture[0]
|
||||
assert post["author"] == "reviewer"
|
||||
# And the new format is present in the body (sanity).
|
||||
assert "\U0001f50e Reviewer" in post["body"]
|
||||
assert "Verdict: APPROVE" in post["body"]
|
||||
assert "Длительность: 3m 00s" in post["body"]
|
||||
124
tests/test_status_comment_dedup_regression.py
Normal file
124
tests/test_status_comment_dedup_regression.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""ORCH-016 / TC-17 + AC-7: status-comment de-dup contract.
|
||||
|
||||
The «one comment per agent per stage» guarantee is enforced upstream of
|
||||
build_status_comment by:
|
||||
- the webhook event-dedup table (events.delivery_id PARTIAL UNIQUE, ORCH-5 /
|
||||
src.db.insert_event_dedup),
|
||||
- the job queue claim-once contract (src.db.claim_next_job, ORCH-1).
|
||||
|
||||
The ORCH-016 PR introduces a new comment FORMAT but must not weaken these
|
||||
guarantees. This regression test:
|
||||
1. exercises insert_event_dedup directly to confirm the same delivery_id is
|
||||
accepted exactly once (sanity for the dedup primitive),
|
||||
2. exercises build_status_comment to confirm it is a PURE function (same
|
||||
inputs -> same output), so a retried call from a poorly-isolated test or a
|
||||
misbehaving caller doesn't silently produce two different comment bodies.
|
||||
"""
|
||||
|
||||
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_orch016_dedup_regression.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, insert_event_dedup # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(monkeypatch):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primitive: event-dedup still rejects a re-delivered webhook.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_event_dedup_inserts_once_for_same_delivery_id():
|
||||
"""Two webhook deliveries with the same delivery_id -> one row inserted.
|
||||
|
||||
First call returns True (new row); second call returns False (rejected).
|
||||
This is the primitive every status-comment trigger relies on.
|
||||
"""
|
||||
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-XYZ") is True
|
||||
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-XYZ") is False
|
||||
|
||||
|
||||
def test_tc17_event_dedup_distinguishes_delivery_ids():
|
||||
"""Distinct delivery IDs are independent — two different webhooks both go through."""
|
||||
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-A") is True
|
||||
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-B") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format: build_status_comment is deterministic. A double-fire from buggy code
|
||||
# still produces an IDENTICAL body -- so the upstream dedup primitive can
|
||||
# safely treat the second call as no-op without comparing prose.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_build_status_comment_is_pure(tmp_path):
|
||||
"""Same inputs produce byte-identical output (deterministic / side-effect free)."""
|
||||
from src import usage as U
|
||||
|
||||
wt = tmp_path / "wt"
|
||||
(wt / "docs" / "work-items" / "ET-016").mkdir(parents=True)
|
||||
(wt / "docs" / "work-items" / "ET-016" / "12-review.md").write_text(
|
||||
"---\nverdict: APPROVE\n---\n",
|
||||
)
|
||||
|
||||
args = dict(
|
||||
repo="enduro-trails",
|
||||
branch="feature/ET-016-x",
|
||||
work_item_id="ET-016",
|
||||
duration_s=120,
|
||||
worktree_root=str(wt),
|
||||
usage={"input_tokens": 100, "output_tokens": 50, "cost_usd": 0.05},
|
||||
)
|
||||
a = U.build_status_comment("reviewer", **args)
|
||||
b = U.build_status_comment("reviewer", **args)
|
||||
c = U.build_status_comment("reviewer", **args)
|
||||
|
||||
assert a == b == c
|
||||
|
||||
|
||||
def test_tc17_build_status_comment_no_db_side_effects(tmp_path):
|
||||
"""A status-comment build must NOT write to the DB.
|
||||
|
||||
Otherwise a webhook-dedup hit would still touch state via the comment
|
||||
builder. We check by counting rows in `tasks`/`agent_runs`/`jobs` before
|
||||
and after.
|
||||
"""
|
||||
from src import usage as U
|
||||
from src.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
counts_before = [
|
||||
conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0],
|
||||
conn.execute("SELECT COUNT(*) FROM agent_runs").fetchone()[0],
|
||||
conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0],
|
||||
]
|
||||
conn.close()
|
||||
|
||||
U.build_status_comment(
|
||||
"developer", repo="enduro-trails", branch="b",
|
||||
work_item_id="ET-016", pr_number=1, duration_s=10,
|
||||
usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
|
||||
)
|
||||
|
||||
conn = get_db()
|
||||
counts_after = [
|
||||
conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0],
|
||||
conn.execute("SELECT COUNT(*) FROM agent_runs").fetchone()[0],
|
||||
conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0],
|
||||
]
|
||||
conn.close()
|
||||
assert counts_before == counts_after
|
||||
145
tests/test_status_comment_duration_db_fallback.py
Normal file
145
tests/test_status_comment_duration_db_fallback.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""ORCH-016 / TC-24 + TC-25 + AC-14: DB fallback for the duration line.
|
||||
|
||||
When build_status_comment is called WITHOUT an explicit duration_s but with a
|
||||
task_id, it must:
|
||||
- read the last finished agent_runs row for (task_id, agent),
|
||||
- compute (julianday(finished_at) - julianday(started_at)) * 86400 in seconds,
|
||||
- format it via fmt_duration and inject the «Длительность: …» line.
|
||||
|
||||
Failure modes (DB locked / row missing / NULL finished_at / negative diff) must
|
||||
NEVER raise; they simply suppress the duration line and let the rest of the
|
||||
comment publish.
|
||||
"""
|
||||
|
||||
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_orch016_duration_fallback.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import usage as U # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(monkeypatch):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
def _insert_run(task_id, agent, *, seconds_ago_start=None, finished=True):
|
||||
"""Insert an agent_runs row with controllable timestamps."""
|
||||
conn = get_db()
|
||||
if seconds_ago_start is None:
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent) VALUES (?, ?)",
|
||||
(task_id, agent),
|
||||
)
|
||||
else:
|
||||
if finished:
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent, started_at, finished_at) "
|
||||
"VALUES (?, ?, datetime('now', ?), datetime('now'))",
|
||||
(task_id, agent, f"-{seconds_ago_start} seconds"),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent, started_at) "
|
||||
"VALUES (?, ?, datetime('now', ?))",
|
||||
(task_id, agent, f"-{seconds_ago_start} seconds"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-24: explicit duration_s missing -> DB lookup populates the line.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc24_fallback_reads_agent_runs_for_last_finished():
|
||||
_insert_run(7, "reviewer", seconds_ago_start=240)
|
||||
secs = U.get_agent_duration(7, "reviewer")
|
||||
# SQLite's julianday math can be off by a second on either side.
|
||||
assert secs is not None and abs(secs - 240) <= 1, secs
|
||||
|
||||
html = U.build_status_comment("reviewer", task_id=7)
|
||||
assert any(
|
||||
s in html for s in (
|
||||
"Длительность: 4m 00s",
|
||||
"Длительность: 4m 01s",
|
||||
"Длительность: 3m 59s",
|
||||
)
|
||||
), html
|
||||
|
||||
|
||||
def test_tc24_fallback_picks_last_run_when_multiple():
|
||||
_insert_run(11, "developer", seconds_ago_start=120)
|
||||
_insert_run(11, "developer", seconds_ago_start=10)
|
||||
secs = U.get_agent_duration(11, "developer")
|
||||
assert secs is not None and abs(secs - 10) <= 1, secs
|
||||
|
||||
|
||||
def test_tc24_no_row_returns_none():
|
||||
assert U.get_agent_duration(999, "tester") is None
|
||||
|
||||
|
||||
def test_tc24_finished_at_null_returns_none():
|
||||
_insert_run(13, "tester", seconds_ago_start=100, finished=False)
|
||||
assert U.get_agent_duration(13, "tester") is None
|
||||
|
||||
|
||||
def test_tc24_missing_args_returns_none():
|
||||
assert U.get_agent_duration(None, "tester") is None
|
||||
assert U.get_agent_duration(7, "") is None
|
||||
assert U.get_agent_duration(0, "tester") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-25: read failure -> logged at debug, NO exception, comment still ships.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc25_db_read_failure_no_raise(monkeypatch, caplog):
|
||||
"""A locked / broken DB must not crash the status comment hot path."""
|
||||
import logging
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("simulated DB outage")
|
||||
|
||||
monkeypatch.setattr(U, "get_db", _boom)
|
||||
with caplog.at_level(logging.DEBUG, logger="orchestrator.usage"):
|
||||
assert U.get_agent_duration(1, "developer") is None
|
||||
# build_status_comment must still publish (no duration line, no crash).
|
||||
html = U.build_status_comment("developer", task_id=1, repo="r", branch="b")
|
||||
assert "Длительность" not in html
|
||||
assert "\U0001f4bb Developer" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sanity: explicit duration_s wins over DB fallback (no SELECT at all).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_explicit_duration_wins_over_db_fallback(monkeypatch):
|
||||
called = {"n": 0}
|
||||
real = U.get_agent_duration
|
||||
|
||||
def _spy(task_id, agent):
|
||||
called["n"] += 1
|
||||
return real(task_id, agent)
|
||||
|
||||
monkeypatch.setattr(U, "get_agent_duration", _spy)
|
||||
_insert_run(5, "architect", seconds_ago_start=300)
|
||||
|
||||
html = U.build_status_comment(
|
||||
"architect", task_id=5, duration_s=12, repo="r", branch="b",
|
||||
)
|
||||
assert "Длительность: 12s" in html
|
||||
# Explicit value supplied -> DB fallback is short-circuited.
|
||||
assert called["n"] == 0
|
||||
354
tests/test_status_comment_format.py
Normal file
354
tests/test_status_comment_format.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user