refactor(pipeline): remove DOCS phase from pipeline-status (#208)

* test(pipeline): remove adr and project-map test files

* refactor(pipeline): remove DOCS phase from pipeline-status

* test(pipeline): update tests for 6-phase pipeline

* docs(handoff): add handoff and ADR for remove-docs-phase

* docs(handoff): set PR number

* fix(pipeline): parse PR body via json.loads in check_implement

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-08-01 03:48:41 +03:00 committed by GitHub
parent 0c1e3e7d39
commit 9dd462b764
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 161 additions and 493 deletions

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Pipeline-status oracle: determine PR phase in 7-phase PR pipeline. """Pipeline-status oracle: determine PR phase in 6-phase PR pipeline.
Reads facts from GitHub (gh CLI), git, and memory files to deterministically Reads facts from GitHub (gh CLI), git, and memory files to deterministically
derive the current pipeline phase of a PR no state file, like ``git status`` derive the current pipeline phase of a PR no state file, like ``git status``
@ -10,19 +10,19 @@ Usage:
python3 config/scripts/pipeline-status.py <PR_NUMBER> # single PR status python3 config/scripts/pipeline-status.py <PR_NUMBER> # single PR status
python3 config/scripts/pipeline-status.py # table of open PRs python3 config/scripts/pipeline-status.py # table of open PRs
Seven phases: Six phases:
1. ISSUE GitHub issue exists and linked via Closes/Fixes #N 1. ISSUE GitHub issue exists and linked via Closes/Fixes #N
2. IMPLEMENT PR exists + handoff file docs/handoff/pr-N-slug.md in diff 2. IMPLEMENT PR exists + PR body has 4 required headings
3. DOCS handoff valid (4 sections) + mandatory ADR 3. CI all checks on PR head SHA completed & success (statusCheckRollup)
4. CI all checks on PR head SHA completed & success (statusCheckRollup) 4. REVIEW APPROVE found in PR comments
5. REVIEW APPROVE found in PR comments 5. MERGE PR state is MERGED
6. MERGE PR state is MERGED 6. MEMORY PR#N distilled into repos/{host}/{org}/{repo}.md
7. MEMORY PR#N distilled into repos/{host}/{org}/{repo}.md
""" """
from __future__ import annotations from __future__ import annotations
import functools import functools
import json
import os import os
import re import re
import subprocess import subprocess
@ -49,13 +49,8 @@ _MEMORY_BASE = os.environ.get(
str(REPO_ROOT / "app_data" / "opencode-memory"), str(REPO_ROOT / "app_data" / "opencode-memory"),
) )
MEMORY_DIR = Path(_MEMORY_BASE) / "repos" MEMORY_DIR = Path(_MEMORY_BASE) / "repos"
HANDOFF_DIR = REPO_ROOT / "docs" / "handoff"
ADR_DIR = REPO_ROOT / "docs" / "decisions"
PROJECT_MAP_DIR = REPO_ROOT / "docs" / "project-map"
REQUIRED_SECTIONS = ["## Что сделано", "## Почему", "## Pending", "## Watch out"] PHASE_NAMES = ["ISSUE", "IMPLEMENT", "CI", "REVIEW", "MERGE", "MEMORY"]
PHASE_NAMES = ["ISSUE", "IMPLEMENT", "DOCS", "CI", "REVIEW", "MERGE", "MEMORY"]
CI_WAIT_TIMEOUT = 300 CI_WAIT_TIMEOUT = 300
CI_POLL_INTERVAL = 10 CI_POLL_INTERVAL = 10
@ -71,7 +66,6 @@ REVIEW_VERDICT_RE = re.compile(
r"## Code Review Summary.*?###\s*Verdict:\s*(\w+)", r"## Code Review Summary.*?###\s*Verdict:\s*(\w+)",
re.IGNORECASE | re.DOTALL, re.IGNORECASE | re.DOTALL,
) )
DOCS_REVIEW_RE = re.compile(r"Docs Review", re.IGNORECASE)
JSON_FIELD_RE = re.compile(r'"(\w+)"\s*:\s*"?([^",}]*)"?', re.IGNORECASE) JSON_FIELD_RE = re.compile(r'"(\w+)"\s*:\s*"?([^",}]*)"?', re.IGNORECASE)
@ -248,114 +242,29 @@ def check_issue(pr_number: int) -> PhaseResult:
def check_implement(pr_number: int) -> PhaseResult: def check_implement(pr_number: int) -> PhaseResult:
"""Phase 2: IMPLEMENT — PR exists + handoff file in diff.""" """Phase 2: IMPLEMENT — PR exists + PR body has 4 required headings."""
rc, out, _ = run_cmd( rc, out, _ = run_cmd(
["gh", "pr", "view", str(pr_number), "--json", "files", "--repo", get_repo_full_name()] ["gh", "pr", "view", str(pr_number), "--json", "body", "--repo", get_repo_full_name()]
) )
if rc != 0: if rc != 0:
return PhaseResult(PhaseStatus.NOT_DONE, f"PR #{pr_number} не существует") return PhaseResult(PhaseStatus.NOT_DONE, f"PR #{pr_number} не существует")
try:
files = re.findall(r'"path"\s*:\s*"([^"]+)"', out) pr_body = json.loads(out).get("body", "") or ""
pattern = f"docs/handoff/pr-{pr_number}-" except (json.JSONDecodeError, ValueError):
handoff_files = [f for f in files if pattern in f] return PhaseResult(
if not handoff_files: PhaseStatus.NOT_DONE, f"PR #{pr_number}: не удалось распарсить JSON body"
return PhaseResult(PhaseStatus.NOT_DONE, f"handoff {pattern}*.md не найден в diff") )
required = ["## Что сделано", "## Почему", "## Watch out", "## Pending"]
return PhaseResult(PhaseStatus.DONE, f"handoff: {Path(handoff_files[0]).name}") missing = [h for h in required if h not in pr_body]
def check_docs(pr_number: int) -> PhaseResult:
"""Phase 3: DOCS — handoff valid (4 sections) + mandatory ADR + project map
+ docs-reviewer marker.
Deterministic checks:
1. Handoff file `docs/handoff/pr-N-*.md` exists.
2. 4 sections present in handoff content.
3. ADR file `docs/decisions/*-pr-N-*.md` exists.
4. Project map `docs/project-map/README.md` exists (ADR-028 contract).
5. PR comment with heading `## Docs Review Summary` from docs-reviewer (proves it ran).
Without the docs-reviewer comment, DOCS is NOT_DONE subagent may have written
handoff+ADR without docs-reviewer actually validating them. Symmetric to
``check_review`` which looks for ``## Code Review Summary`` in PR comments.
"""
handoff_files = sorted(HANDOFF_DIR.glob(f"pr-{pr_number}-*.md"))
if not handoff_files:
return PhaseResult(PhaseStatus.NOT_DONE, f"handoff pr-{pr_number}-*.md не найден")
content = handoff_files[0].read_text()
missing = [s for s in REQUIRED_SECTIONS if s not in content]
if missing: if missing:
return PhaseResult(PhaseStatus.NOT_DONE, f"отсутствуют секции: {', '.join(missing)}")
adr_result = check_adr(pr_number)
if adr_result.status != PhaseStatus.DONE:
return adr_result
pm_result = check_project_map()
if pm_result.status != PhaseStatus.DONE:
return pm_result
return _check_docs_reviewer_comment(pr_number)
def _check_docs_reviewer_comment(pr_number: int) -> PhaseResult:
"""Phase 3 part: PR comment with 'Docs Review' heading proves docs-reviewer ran."""
rc, out, _ = run_cmd(
["gh", "pr", "view", str(pr_number), "--json", "comments", "--repo", get_repo_full_name()]
)
if rc != 0:
return PhaseResult(PhaseStatus.AMBIGUOUS, "не удалось получить комментарии PR")
comment_bodies = _extract_comment_bodies(out)
for body in comment_bodies:
if DOCS_REVIEW_RE.search(body):
return PhaseResult(PhaseStatus.DONE, "handoff валиден, ADR, docs-review отработал")
return PhaseResult( return PhaseResult(
PhaseStatus.NOT_DONE, PhaseStatus.NOT_DONE, f"PR body не содержит heading'и: {', '.join(missing)}"
"docs-reviewer не запущен — запусти @docs-reviewer (pre-merge)",
)
def check_adr(pr_number: int) -> PhaseResult:
"""Phase 3 part: ADR is mandatory for every PR. Find by PR# in filename."""
pattern = f"*-pr-{pr_number}-*.md"
adr_files = sorted(ADR_DIR.glob(pattern)) if ADR_DIR.exists() else []
if adr_files:
return PhaseResult(PhaseStatus.DONE, f"ADR: {adr_files[0].name}")
return PhaseResult(
PhaseStatus.NOT_DONE,
f"ADR *-pr-{pr_number}-*.md не найден. "
f"Создай через bash .opencode/scripts/scaffold-handoff.sh {pr_number} <slug>",
)
def check_project_map() -> PhaseResult:
"""Phase 3 part: docs/project-map/README.md exists (mandatory project map).
Minimal guard against silent docs-reviewer failure. Does NOT validate
frontmatter or module-level files see ADR-028 for the contract.
"""
if not PROJECT_MAP_DIR.exists():
return PhaseResult(
PhaseStatus.NOT_DONE,
f"директория {PROJECT_MAP_DIR} не найдена — "
"docs-reviewer должен создать initial README.md (docs-reviewer.md:82-84)",
)
readme = PROJECT_MAP_DIR / "README.md"
if readme.exists():
size = readme.stat().st_size
return PhaseResult(PhaseStatus.DONE, f"project map: README.md ({size} bytes)")
return PhaseResult(
PhaseStatus.NOT_DONE,
"docs/project-map/README.md не найден — "
"docs-reviewer должен создать initial map (docs-reviewer.md:244)",
) )
return PhaseResult(PhaseStatus.DONE, "PR body: 4 heading'а валидны")
def check_ci(pr_number: int) -> PhaseResult: def check_ci(pr_number: int) -> PhaseResult:
"""Phase 4: CI — all checks on PR head SHA completed & success. """Phase 3: CI — all checks on PR head SHA completed & success.
Uses ``gh pr view --json statusCheckRollup`` which aggregates ALL Uses ``gh pr view --json statusCheckRollup`` which aggregates ALL
workflows for the PR head SHA (CI, CI (always), ADR check, etc.). workflows for the PR head SHA (CI, CI (always), ADR check, etc.).
@ -584,7 +493,6 @@ def get_pr_title(pr_number: int) -> str:
NEXT_ACTIONS: dict[str, str] = { NEXT_ACTIONS: dict[str, str] = {
"ISSUE": "dispatch subagent (subagent_type=general, template=A) for PR #N", "ISSUE": "dispatch subagent (subagent_type=general, template=A) for PR #N",
"IMPLEMENT": "dispatch subagent (subagent_type=general, template=A) for PR #N", "IMPLEMENT": "dispatch subagent (subagent_type=general, template=A) for PR #N",
"DOCS": "dispatch subagent (subagent_type=docs-reviewer, template=B) for PR #N",
"CI": "проверь статус CI вручную (gh run view)", "CI": "проверь статус CI вручную (gh run view)",
"REVIEW": "dispatch subagent (subagent_type=reviewer, template=C) for PR #N", "REVIEW": "dispatch subagent (subagent_type=reviewer, template=C) for PR #N",
"MERGE": "call merge_pr tool with pr_number=N", "MERGE": "call merge_pr tool with pr_number=N",
@ -622,11 +530,10 @@ def get_next_action_review(result: PhaseResult) -> str:
def run_all_checks(pr_number: int) -> list[PhaseResult]: def run_all_checks(pr_number: int) -> list[PhaseResult]:
"""Run all 7 phase checks, return results in order.""" """Run all 6 phase checks, return results in order."""
return [ return [
check_issue(pr_number), check_issue(pr_number),
check_implement(pr_number), check_implement(pr_number),
check_docs(pr_number),
check_ci(pr_number), check_ci(pr_number),
check_review(pr_number), check_review(pr_number),
check_merge(pr_number), check_merge(pr_number),

View file

@ -0,0 +1,38 @@
# ADR-091: Remove DOCS phase from pipeline-status
## Статус
Accepted (2026-08-01)
## Контекст
Пайплайн PR содержал 7 фаз: ISSUE → IMPLEMENT → DOCS → CI → REVIEW → MERGE →
MEMORY. DOCS phase проверяла handoff-файл (4 секции), mandatory ADR по PR#,
project-map README.md и PR-комментарий от docs-reviewer subagent'а.
DOCS phase дублировала работу: PR body уже содержит структуру (4 heading'а
enforc'ит `create-pr.ts` с issue #203), а ADR/handoff/project-map проверялись
отдельным subagent'ом. IMPLEMENT phase требовала handoff-файл в diff — хрупкий
gate, не связанный с реальным контрактом PR body.
## Решение
Убрать DOCS phase целиком. Пайплайн сокращён до 6 фаз:
ISSUE → IMPLEMENT → CI → REVIEW → MERGE → MEMORY.
`check_implement` переписан: вместо handoff-файла в diff проверяет PR body
на 4 heading'а (`## Что сделано`, `## Почему`, `## Watch out`, `## Pending`)
через `gh pr view N --json body`. Контракт уже гарантирован `create-pr.ts`.
Удалены: `check_docs`, `_check_docs_reviewer_comment`, `check_adr`,
`check_project_map`, dead-константы `HANDOFF_DIR`, `ADR_DIR`,
`PROJECT_MAP_DIR`, `REQUIRED_SECTIONS`, `DOCS_REVIEW_RE`. MEMORY-константы
и `REVIEW_VERDICT_RE` оставлены.
Существующие `docs/handoff/`, `docs/decisions/` файлы не удаляются —
исторический архив.
## Альтернативы
— Оставить DOCS phase, но ослабить проверки (только handoff, без ADR/pm).
Отклонено: дублирование PR body остаётся, gate всё ещё хрупкий.
— Перенести ADR-check в REVIEW phase. Отклонено: mixing concerns, REVIEW
должен проверять verdict reviewer'а, не файлы.
— Удалить handoff/ADR-генерацию целиком. Отклонено (вне scope): scaffold-handoff.sh
ещё используется для документирования решений — отдельный issue.

View file

@ -0,0 +1,55 @@
---
pr: 208
title: refactor(pipeline): remove DOCS phase from pipeline-status
---
## Что сделано
Убрал DOCS phase из `pipeline-status.py` — пайплайн сократился с 7 до 6 фаз:
ISSUE → IMPLEMENT → CI → REVIEW → MERGE → MEMORY.
Изменения в `.opencode/scripts/pipeline-status.py`:
- `PHASE_NAMES`: 7 → 6 фаз (убран `"DOCS"`).
- `check_implement`: вместо проверки handoff-файла в diff теперь проверяет
PR body на наличие 4 heading'ов (`## Что сделано`, `## Почему`, `## Watch out`,
`## Pending`) через `gh pr view N --json body`.
- Удалены функции `check_docs`, `_check_docs_reviewer_comment`, `check_adr`,
`check_project_map`.
- Удалены dead-константы `HANDOFF_DIR`, `ADR_DIR`, `PROJECT_MAP_DIR`,
`REQUIRED_SECTIONS`, `DOCS_REVIEW_RE` (MEMORY-константы оставлены).
- `run_all_checks`: 7 → 6 вызовов (убран `check_docs`).
- `NEXT_ACTIONS`: 7 → 6 записей (убран `"DOCS"`).
- Module docstring: 7 → 6 фаз, убрано DOCS описание.
Изменения в тестах:
- `tests/test_pipeline_status.py`: удалён `# ── check_docs ──` блок (~10 тестов),
удалены хелперы `make_handoff`/`make_project_map`, обновлены `check_implement`
тесты (handoff в diff → PR body с 4 heading'ами), format/zip тесты 7→6
элементов, `test_get_next_action` parametrize без DOCS row,
`find_current_phase` тесты 7→6.
- `tests/test_pipeline_status_adr.py`: удалён целиком.
- `tests/test_pipeline_status_project_map.py`: удалён целиком.
- `tests/test_pipeline_status_next_actions.py`: удалён
`test_next_action_docs_subagent_template_b`, `DISPATCH_PHASES` без `"DOCS"`,
`test_next_action_pr_number_substitution` без `("DOCS", 999)`.
## Почему
DOCS phase дублировал работу issue + PR body + reviewer. Handoff-файл в diff
как gate IMPLEMENT phase — хрупкий: требует handoff в diff даже когда PR body
уже содержит структуру. Замена на PR body heading check делает gate
самодостаточным (PR body = single source of truth для структуры) и убирает
целую фазу (DOCS), которая требовала отдельного docs-reviewer subagent'а.
4 heading'а enforc'ит `create-pr.ts` (issue #203), так что контракт уже
гарантирован на уровне tool'а.
## Pending
— Обновление run-pipeline/SKILL.md (Template B удаление) — отдельный issue.
— Обновление agent prompts (reviewer.md, memory-syncer.md) — отдельный issue.
— Удаление docs-reviewer.md, post-docs-review.ts, scaffold-handoff.sh — отдельный issue.
— Удаление check-adr-refs.py, adr-check.yml — отдельный issue.
## Watch out
MEMORY phase остаётся (6-я фаза) — `check_memory`, `get_memory_file_path`,
`MEMORY_DIR` не тронуты. `REVIEW_VERDICT_RE` оставлен (используется
`check_review`). Существующие `docs/handoff/`, `docs/decisions/` файлы НЕ
удаляем — исторический архив. ADR для этого PR всё ещё создан (scaffold-handoff.sh
работает), но больше не является pipeline gate'ом.

View file

@ -12,6 +12,7 @@ without extra boilerplate in every test.
""" """
import importlib.util import importlib.util
import json
import sys import sys
from pathlib import Path from pathlib import Path
@ -66,34 +67,6 @@ def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]):
return _mock return _mock
def make_handoff(
tmp_path: Path,
pr_number: int,
sections: list[str] | None = None,
extra: str = "",
) -> Path:
"""Create a handoff file in tmp_path, return its path."""
if sections is None:
sections = ps.REQUIRED_SECTIONS
content = "---\npr: {pr_number}\n---\n\n" + "\n\n".join(sections) + "\n"
if extra:
content += f"\n{extra}\n"
handoff = tmp_path / f"pr-{pr_number}-test-feature.md"
handoff.write_text(content)
return handoff
def make_project_map(tmp_path: Path) -> Path:
"""Create docs/project-map/README.md in tmp_path, return the dir path.
Satisfies the mandatory ``check_project_map`` guard (ADR-028 contract).
"""
pm_dir = tmp_path / "project-map"
pm_dir.mkdir(parents=True, exist_ok=True)
(pm_dir / "README.md").write_text("# Project Map\n")
return pm_dir
# ── parse_remote_url ──────────────────────────────────────────────────────── # ── parse_remote_url ────────────────────────────────────────────────────────
@ -263,37 +236,30 @@ def test_check_issue_not_done_issue_missing(monkeypatch):
def test_check_implement_done(monkeypatch): def test_check_implement_done(monkeypatch):
"""PR body with all 4 required headings → DONE."""
body = "## Что сделано\n...\n\n## Почему\n...\n\n## Watch out\n...\n\n## Pending\n..."
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd( mock_run_cmd({("gh", "pr", "view"): (0, json.dumps({"body": body}), "")}),
{
("gh", "pr", "view"): (
0,
'{"files": [{"path": "docs/handoff/pr-46-test.md"}]}',
"",
),
}
),
) )
result = ps.check_implement(46) result = ps.check_implement(46)
assert result.status == ps.PhaseStatus.DONE assert result.status == ps.PhaseStatus.DONE
assert "pr-46-test.md" in result.detail assert "heading" in result.detail
def test_check_implement_not_done_no_handoff(monkeypatch): def test_check_implement_not_done_missing_headings(monkeypatch):
"""PR body missing required headings → NOT_DONE."""
body = "## Что сделано\n...\n\n## Почему\n..."
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd( mock_run_cmd({("gh", "pr", "view"): (0, json.dumps({"body": body}), "")}),
{
("gh", "pr", "view"): (0, '{"files": [{"path": "src/main.py"}]}', ""),
}
),
) )
result = ps.check_implement(46) result = ps.check_implement(46)
assert result.status == ps.PhaseStatus.NOT_DONE assert result.status == ps.PhaseStatus.NOT_DONE
assert "handoff" in result.detail assert "Watch out" in result.detail
assert "Pending" in result.detail
def test_check_implement_not_done_pr_missing(monkeypatch): def test_check_implement_not_done_pr_missing(monkeypatch):
@ -306,211 +272,37 @@ def test_check_implement_not_done_pr_missing(monkeypatch):
assert result.status == ps.PhaseStatus.NOT_DONE assert result.status == ps.PhaseStatus.NOT_DONE
# ── check_docs ─────────────────────────────────────────────────────────────── def test_check_implement_done_with_quotes_in_body(monkeypatch):
"""PR body with escaped quotes (regression for PR#208 / json.loads fix).
Old regex ``"([^"]*)"`` stopped at the first escaped quote ``\\"`` inside
def test_check_docs_done(tmp_path, monkeypatch): body, truncating the parsed body and breaking heading detection even when
adr_dir = tmp_path / "decisions" the PR body was valid. ``json.loads`` correctly unescapes ``\\"`` → ``"``.
adr_dir.mkdir() """
(adr_dir / "002-pr-46-test.md").write_text("# ADR-002") body = (
monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) '## Что сделано\nfeat: "quoted" text\n\n## Почему\nreason\n'
monkeypatch.setattr(ps, "ADR_DIR", adr_dir) "\n## Watch out\nx\n\n## Pending\ny"
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path)) )
make_handoff(tmp_path, 46)
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd( mock_run_cmd({("gh", "pr", "view"): (0, json.dumps({"body": body}), "")}),
{
("gh", "pr", "view"): (
0,
'{"comments": [{"body": "## Docs Review Summary\\nVerdict: APPROVE"}]}',
"",
),
}
),
) )
result = ps.check_docs(46) result = ps.check_implement(208)
assert result.status == ps.PhaseStatus.DONE assert result.status == ps.PhaseStatus.DONE
assert "docs-review отработал" in result.detail assert "heading" in result.detail
def test_check_docs_not_done_no_handoff(tmp_path, monkeypatch): def test_check_implement_not_done_invalid_json(monkeypatch):
monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) """Invalid JSON output → NOT_DONE with parse error (not silent empty body)."""
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path))
result = ps.check_docs(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "не найден" in result.detail
def test_check_docs_not_done_no_adr(tmp_path, monkeypatch):
"""ADR missing → DOCS phase NOT_DONE (mandatory)."""
monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path)
monkeypatch.setattr(ps, "ADR_DIR", tmp_path / "decisions")
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path))
make_handoff(tmp_path, 46)
result = ps.check_docs(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "ADR" in result.detail
def test_check_docs_not_done_missing_sections(tmp_path, monkeypatch):
monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path)
monkeypatch.setattr(ps, "ADR_DIR", tmp_path / "decisions")
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path))
make_handoff(tmp_path, 46, sections=["## Что сделано", "## Почему"])
result = ps.check_docs(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "Pending" in result.detail
assert "Watch out" in result.detail
def test_check_docs_done_with_adr(tmp_path, monkeypatch):
adr_dir = tmp_path / "decisions"
adr_dir.mkdir()
(adr_dir / "002-pr-46-test.md").write_text("# ADR-002")
monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path)
monkeypatch.setattr(ps, "ADR_DIR", adr_dir)
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path))
make_handoff(tmp_path, 46, extra="Архитектурное изменение, см. ADR-002.")
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd( mock_run_cmd({("gh", "pr", "view"): (0, "not valid json{", "")}),
{
("gh", "pr", "view"): (
0,
'{"comments": [{"body": "## Docs Review Summary\\nVerdict: APPROVE"}]}',
"",
),
}
),
) )
result = ps.check_docs(46) result = ps.check_implement(46)
assert result.status == ps.PhaseStatus.DONE
assert "docs-review отработал" in result.detail
def test_check_docs_not_done_no_comment(tmp_path, monkeypatch):
"""Handoff+ADR valid but no docs-reviewer comment → NOT_DONE."""
adr_dir = tmp_path / "decisions"
adr_dir.mkdir()
(adr_dir / "002-pr-46-test.md").write_text("# ADR-002")
monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path)
monkeypatch.setattr(ps, "ADR_DIR", adr_dir)
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path))
make_handoff(tmp_path, 46)
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (0, '{"comments": []}', "")}),
)
result = ps.check_docs(46)
assert result.status == ps.PhaseStatus.NOT_DONE assert result.status == ps.PhaseStatus.NOT_DONE
assert "docs-reviewer не запущен" in result.detail assert "распарсить" in result.detail
def test_check_docs_not_done_comment_without_marker(tmp_path, monkeypatch):
"""Comments exist but no 'Docs Review' heading (e.g. only reviewer comment) → NOT_DONE."""
adr_dir = tmp_path / "decisions"
adr_dir.mkdir()
(adr_dir / "002-pr-46-test.md").write_text("# ADR-002")
monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path)
monkeypatch.setattr(ps, "ADR_DIR", adr_dir)
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path))
make_handoff(tmp_path, 46)
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"comments": [{"body": "## Code Review Summary\\nVerdict: APPROVE"}]}',
"",
),
}
),
)
result = ps.check_docs(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "docs-reviewer не запущен" in result.detail
def test_check_docs_false_positive_reviewer_comment(tmp_path, monkeypatch):
"""Reviewer comment '## Code Review Summary' must NOT trigger check_docs DONE."""
adr_dir = tmp_path / "decisions"
adr_dir.mkdir()
(adr_dir / "002-pr-46-test.md").write_text("# ADR-002")
monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path)
monkeypatch.setattr(ps, "ADR_DIR", adr_dir)
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path))
make_handoff(tmp_path, 46)
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"comments": [{"body": "## Code Review Summary\\n\\n### Verdict: APPROVE"}]}',
"",
),
}
),
)
result = ps.check_docs(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "docs-reviewer не запущен" in result.detail
def test_check_docs_ambiguous_api_error(tmp_path, monkeypatch):
"""gh pr view --json comments returns rc=1 → AMBIGUOUS."""
adr_dir = tmp_path / "decisions"
adr_dir.mkdir()
(adr_dir / "002-pr-46-test.md").write_text("# ADR-002")
monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path)
monkeypatch.setattr(ps, "ADR_DIR", adr_dir)
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path))
make_handoff(tmp_path, 46)
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (1, "", "HTTP 403: Forbidden")}),
)
result = ps.check_docs(46)
assert result.status == ps.PhaseStatus.AMBIGUOUS
assert "комментарии" in result.detail
def test_check_docs_done_with_fixed_verdict(tmp_path, monkeypatch):
"""Comment with Verdict: FIXED (docs-reviewer fixed something) → DONE."""
adr_dir = tmp_path / "decisions"
adr_dir.mkdir()
(adr_dir / "002-pr-46-test.md").write_text("# ADR-002")
monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path)
monkeypatch.setattr(ps, "ADR_DIR", adr_dir)
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path))
make_handoff(tmp_path, 46)
comment_body = (
"## Docs Review Summary\\n- Handoff: fixed: added Pending\\n\\n### Verdict: FIXED"
)
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
f'{{"comments": [{{"body": "{comment_body}"}}]}}',
"",
),
}
),
)
result = ps.check_docs(46)
assert result.status == ps.PhaseStatus.DONE
assert "docs-review отработал" in result.detail
# ── check_review ───────────────────────────────────────────────────────────── # ── check_review ─────────────────────────────────────────────────────────────
@ -911,8 +703,7 @@ def test_format_single_pr_complete(monkeypatch):
) )
results = [ results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"), ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"), ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"), ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.DONE, "APPROVE"), ps.PhaseResult(ps.PhaseStatus.DONE, "APPROVE"),
ps.PhaseResult(ps.PhaseStatus.DONE, "merged"), ps.PhaseResult(ps.PhaseStatus.DONE, "merged"),
@ -926,8 +717,7 @@ def test_format_single_pr_complete(monkeypatch):
def test_format_single_pr_review_not_done(monkeypatch): def test_format_single_pr_review_not_done(monkeypatch):
results = [ results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"), ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"), ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"), ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "APPROVE не найден"), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "APPROVE не найден"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"),
@ -945,8 +735,7 @@ def test_format_single_pr_review_request_changes(monkeypatch):
"""REVIEW not_done с verdict REQUEST_CHANGES -> NEXT про fix subagent, не re-run reviewer.""" """REVIEW not_done с verdict REQUEST_CHANGES -> NEXT про fix subagent, не re-run reviewer."""
results = [ results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"), ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"), ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"), ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: REQUEST_CHANGES"), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: REQUEST_CHANGES"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"),
@ -965,8 +754,7 @@ def test_format_single_pr_review_needs_discussion(monkeypatch):
"""REVIEW not_done с verdict NEEDS_DISCUSSION -> NEXT про уточнение, НЕ про re-run reviewer.""" """REVIEW not_done с verdict NEEDS_DISCUSSION -> NEXT про уточнение, НЕ про re-run reviewer."""
results = [ results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"), ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"), ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"), ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: NEEDS_DISCUSSION"), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: NEEDS_DISCUSSION"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"),
@ -983,8 +771,7 @@ def test_format_single_pr_review_needs_discussion(monkeypatch):
def test_format_single_pr_memory_not_done(monkeypatch): def test_format_single_pr_memory_not_done(monkeypatch):
results = [ results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"), ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"), ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"), ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.DONE, "APPROVE"), ps.PhaseResult(ps.PhaseStatus.DONE, "APPROVE"),
ps.PhaseResult(ps.PhaseStatus.DONE, "merged"), ps.PhaseResult(ps.PhaseStatus.DONE, "merged"),
@ -1000,7 +787,7 @@ def test_format_single_pr_memory_not_done(monkeypatch):
def test_format_single_pr_ambiguous(monkeypatch): def test_format_single_pr_ambiguous(monkeypatch):
results = [ results = [
ps.PhaseResult(ps.PhaseStatus.AMBIGUOUS, "несколько issue"), ps.PhaseResult(ps.PhaseStatus.AMBIGUOUS, "несколько issue"),
] + [ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "")] * 6 ] + [ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "")] * 5
monkeypatch.setattr( monkeypatch.setattr(
ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")}) ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")})
) )
@ -1020,8 +807,7 @@ def test_format_table_empty(monkeypatch):
def test_format_table_with_prs(monkeypatch): def test_format_table_with_prs(monkeypatch):
results = [ results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"), ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"), ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "docs"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "ci"), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "ci"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "review"), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "review"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "merge"), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "merge"),
@ -1031,15 +817,14 @@ def test_format_table_with_prs(monkeypatch):
monkeypatch.setattr(ps, "get_pr_title", lambda n: "test PR title") monkeypatch.setattr(ps, "get_pr_title", lambda n: "test PR title")
output = ps.format_table([47]) output = ps.format_table([47])
assert "PR#47" in output assert "PR#47" in output
assert "NEXT: dispatch subagent (subagent_type=docs-reviewer" in output assert "NEXT: проверь статус CI вручную" in output
def test_format_pr_row_review_request_changes(monkeypatch): def test_format_pr_row_review_request_changes(monkeypatch):
"""format_pr_row (table-view): REVIEW REQUEST_CHANGES -> NEXT про fix subagent.""" """format_pr_row (table-view): REVIEW REQUEST_CHANGES -> NEXT про fix subagent."""
results = [ results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"), ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"), ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"), ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: REQUEST_CHANGES"), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: REQUEST_CHANGES"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"),
@ -1060,7 +845,6 @@ def test_format_pr_row_review_request_changes(monkeypatch):
[ [
("ISSUE", "dispatch subagent (subagent_type=general, template=A) for PR #46"), ("ISSUE", "dispatch subagent (subagent_type=general, template=A) for PR #46"),
("IMPLEMENT", "dispatch subagent (subagent_type=general, template=A) for PR #46"), ("IMPLEMENT", "dispatch subagent (subagent_type=general, template=A) for PR #46"),
("DOCS", "dispatch subagent (subagent_type=docs-reviewer, template=B) for PR #46"),
("CI", "проверь статус CI вручную (gh run view)"), ("CI", "проверь статус CI вручную (gh run view)"),
("REVIEW", "dispatch subagent (subagent_type=reviewer, template=C) for PR #46"), ("REVIEW", "dispatch subagent (subagent_type=reviewer, template=C) for PR #46"),
("MERGE", "call merge_pr tool with pr_number=46"), ("MERGE", "call merge_pr tool with pr_number=46"),
@ -1096,7 +880,7 @@ def test_get_next_action_review(detail, expected_substring):
def test_find_current_phase_all_done(): def test_find_current_phase_all_done():
results = [ps.PhaseResult(ps.PhaseStatus.DONE, "")] * 7 results = [ps.PhaseResult(ps.PhaseStatus.DONE, "")] * 6
assert ps.find_current_phase(results) is None assert ps.find_current_phase(results) is None
@ -1104,15 +888,15 @@ def test_find_current_phase_first_not_done():
results = [ results = [
ps.PhaseResult(ps.PhaseStatus.DONE, ""), ps.PhaseResult(ps.PhaseStatus.DONE, ""),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, ""), ps.PhaseResult(ps.PhaseStatus.NOT_DONE, ""),
] + [ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "")] * 5 ] + [ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "")] * 4
assert ps.find_current_phase(results) == 1 assert ps.find_current_phase(results) == 1
def test_find_current_phase_skips_ambiguous(): def test_find_current_phase_skips_ambiguous():
results = [ps.PhaseResult(ps.PhaseStatus.DONE, "")] * 6 + [ results = [ps.PhaseResult(ps.PhaseStatus.DONE, "")] * 5 + [
ps.PhaseResult(ps.PhaseStatus.AMBIGUOUS, ""), ps.PhaseResult(ps.PhaseStatus.AMBIGUOUS, ""),
] ]
assert ps.find_current_phase(results) == 6 assert ps.find_current_phase(results) == 5
# ── main ───────────────────────────────────────────────────────────────────── # ── main ─────────────────────────────────────────────────────────────────────

View file

@ -1,53 +0,0 @@
"""Tests for .opencode/scripts/pipeline-status.py — mandatory ADR by PR#.
Verifies the deterministic check_adr: ADR is required for every PR and is
found by ``*-pr-<N>-*.md`` filename pattern (no regex guessing in handoff).
"""
import importlib.util
import sys
from pathlib import Path
SCRIPT_PATH = (
Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "pipeline-status.py"
)
spec = importlib.util.spec_from_file_location("pipeline_status", SCRIPT_PATH)
ps = importlib.util.module_from_spec(spec)
sys.modules["pipeline_status"] = ps
spec.loader.exec_module(ps)
def test_adr_exists_for_pr(tmp_path, monkeypatch):
adr_dir = tmp_path / "decisions"
adr_dir.mkdir()
(adr_dir / "002-pr-55-test.md").write_text("# ADR-002")
monkeypatch.setattr(ps, "ADR_DIR", adr_dir)
result = ps.check_adr(55)
assert result.status == ps.PhaseStatus.DONE
assert "002-pr-55-test.md" in result.detail
def test_adr_missing_for_pr(tmp_path, monkeypatch):
adr_dir = tmp_path / "decisions"
adr_dir.mkdir()
monkeypatch.setattr(ps, "ADR_DIR", adr_dir)
result = ps.check_adr(55)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "не найден" in result.detail
assert "scaffold-handoff.sh 55" in result.detail
def test_old_pr_without_pr_in_filename(tmp_path, monkeypatch):
adr_dir = tmp_path / "decisions"
adr_dir.mkdir()
(adr_dir / "001-data-bus-pipeline.md").write_text("# ADR-001")
monkeypatch.setattr(ps, "ADR_DIR", adr_dir)
result = ps.check_adr(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "не найден" in result.detail

View file

@ -36,14 +36,6 @@ def test_next_action_general_subagent_template_a(phase):
assert "PR #41" in action assert "PR #41" in action
def test_next_action_docs_subagent_template_b():
"""DOCS → subagent_type=docs-reviewer, template=B."""
action = ps.get_next_action("DOCS", 41)
assert "subagent_type=docs-reviewer" in action
assert "template=B" in action
assert "PR #41" in action
def test_next_action_review_subagent_template_c(): def test_next_action_review_subagent_template_c():
"""REVIEW → subagent_type=reviewer, template=C.""" """REVIEW → subagent_type=reviewer, template=C."""
action = ps.get_next_action("REVIEW", 41) action = ps.get_next_action("REVIEW", 41)
@ -72,7 +64,7 @@ def test_next_action_memory_subagent_template_e():
# ── NEXT_ACTIONS: every dispatchable phase has subagent_type ────────────────── # ── NEXT_ACTIONS: every dispatchable phase has subagent_type ──────────────────
DISPATCH_PHASES = ["ISSUE", "IMPLEMENT", "DOCS", "REVIEW", "MEMORY"] DISPATCH_PHASES = ["ISSUE", "IMPLEMENT", "REVIEW", "MEMORY"]
@pytest.mark.parametrize("phase", DISPATCH_PHASES) @pytest.mark.parametrize("phase", DISPATCH_PHASES)
@ -102,7 +94,7 @@ def test_merge_phase_does_not_dispatch_subagent():
@pytest.mark.parametrize( @pytest.mark.parametrize(
("phase", "pr_number"), ("phase", "pr_number"),
[("ISSUE", 1), ("DOCS", 999), ("REVIEW", 42), ("MERGE", 7), ("MEMORY", 100)], [("ISSUE", 1), ("REVIEW", 42), ("MERGE", 7), ("MEMORY", 100)],
) )
def test_next_action_pr_number_substitution(phase, pr_number): def test_next_action_pr_number_substitution(phase, pr_number):
"""``N`` placeholder in NEXT_ACTIONS is replaced with the actual PR number.""" """``N`` placeholder in NEXT_ACTIONS is replaced with the actual PR number."""

View file

@ -1,55 +0,0 @@
"""Tests for .opencode/scripts/pipeline-status.py — mandatory project map.
Verifies the deterministic check_project_map: docs/project-map/README.md is
required for every pipeline-driven PR (ADR-028). Loaded under a unique module
name (``pipeline_status_project_map``) so it does not collide with other test
modules that load the same script as ``pipeline_status``.
"""
import importlib.util
import sys
from pathlib import Path
SCRIPT_PATH = (
Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "pipeline-status.py"
)
spec = importlib.util.spec_from_file_location("pipeline_status_project_map", SCRIPT_PATH)
ps = importlib.util.module_from_spec(spec)
sys.modules["pipeline_status_project_map"] = ps
spec.loader.exec_module(ps)
def test_project_map_exists(tmp_path, monkeypatch):
"""PROJECT_MAP_DIR + README.md → DONE, "README.md" в detail."""
pm_dir = tmp_path / "project-map"
pm_dir.mkdir()
(pm_dir / "README.md").write_text("# Project Map\n")
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", pm_dir)
result = ps.check_project_map()
assert result.status == ps.PhaseStatus.DONE
assert "README.md" in result.detail
def test_project_map_dir_missing(tmp_path, monkeypatch):
"""PROJECT_MAP_DIR не существует → NOT_DONE, "директория" в detail."""
missing_dir = tmp_path / "does-not-exist"
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", missing_dir)
result = ps.check_project_map()
assert result.status == ps.PhaseStatus.NOT_DONE
assert "директория" in result.detail
def test_project_map_readme_missing(tmp_path, monkeypatch):
"""Директория есть, README.md нет → NOT_DONE, "README.md не найден" в detail."""
pm_dir = tmp_path / "project-map"
pm_dir.mkdir()
monkeypatch.setattr(ps, "PROJECT_MAP_DIR", pm_dir)
result = ps.check_project_map()
assert result.status == ps.PhaseStatus.NOT_DONE
assert "README.md не найден" in result.detail