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
"""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
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 # table of open PRs
Seven phases:
Six phases:
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
3. DOCS handoff valid (4 sections) + mandatory ADR
4. CI all checks on PR head SHA completed & success (statusCheckRollup)
5. REVIEW APPROVE found in PR comments
6. MERGE PR state is MERGED
7. MEMORY PR#N distilled into repos/{host}/{org}/{repo}.md
2. IMPLEMENT PR exists + PR body has 4 required headings
3. CI all checks on PR head SHA completed & success (statusCheckRollup)
4. REVIEW APPROVE found in PR comments
5. MERGE PR state is MERGED
6. MEMORY PR#N distilled into repos/{host}/{org}/{repo}.md
"""
from __future__ import annotations
import functools
import json
import os
import re
import subprocess
@ -49,13 +49,8 @@ _MEMORY_BASE = os.environ.get(
str(REPO_ROOT / "app_data" / "opencode-memory"),
)
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", "DOCS", "CI", "REVIEW", "MERGE", "MEMORY"]
PHASE_NAMES = ["ISSUE", "IMPLEMENT", "CI", "REVIEW", "MERGE", "MEMORY"]
CI_WAIT_TIMEOUT = 300
CI_POLL_INTERVAL = 10
@ -71,7 +66,6 @@ REVIEW_VERDICT_RE = re.compile(
r"## Code Review Summary.*?###\s*Verdict:\s*(\w+)",
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)
@ -248,114 +242,29 @@ def check_issue(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(
["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:
return PhaseResult(PhaseStatus.NOT_DONE, f"PR #{pr_number} не существует")
files = re.findall(r'"path"\s*:\s*"([^"]+)"', out)
pattern = f"docs/handoff/pr-{pr_number}-"
handoff_files = [f for f in files if pattern in f]
if not handoff_files:
return PhaseResult(PhaseStatus.NOT_DONE, f"handoff {pattern}*.md не найден в diff")
return PhaseResult(PhaseStatus.DONE, f"handoff: {Path(handoff_files[0]).name}")
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:
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(
PhaseStatus.NOT_DONE,
"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():
try:
pr_body = json.loads(out).get("body", "") or ""
except (json.JSONDecodeError, ValueError):
return PhaseResult(
PhaseStatus.NOT_DONE,
f"директория {PROJECT_MAP_DIR} не найдена — "
"docs-reviewer должен создать initial README.md (docs-reviewer.md:82-84)",
PhaseStatus.NOT_DONE, f"PR #{pr_number}: не удалось распарсить JSON body"
)
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)",
)
required = ["## Что сделано", "## Почему", "## Watch out", "## Pending"]
missing = [h for h in required if h not in pr_body]
if missing:
return PhaseResult(
PhaseStatus.NOT_DONE, f"PR body не содержит heading'и: {', '.join(missing)}"
)
return PhaseResult(PhaseStatus.DONE, "PR body: 4 heading'а валидны")
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
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] = {
"ISSUE": "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)",
"REVIEW": "dispatch subagent (subagent_type=reviewer, template=C) for PR #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]:
"""Run all 7 phase checks, return results in order."""
"""Run all 6 phase checks, return results in order."""
return [
check_issue(pr_number),
check_implement(pr_number),
check_docs(pr_number),
check_ci(pr_number),
check_review(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 json
import sys
from pathlib import Path
@ -66,34 +67,6 @@ def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]):
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 ────────────────────────────────────────────────────────
@ -263,37 +236,30 @@ def test_check_issue_not_done_issue_missing(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(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"files": [{"path": "docs/handoff/pr-46-test.md"}]}',
"",
),
}
),
mock_run_cmd({("gh", "pr", "view"): (0, json.dumps({"body": body}), "")}),
)
result = ps.check_implement(46)
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(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (0, '{"files": [{"path": "src/main.py"}]}', ""),
}
),
mock_run_cmd({("gh", "pr", "view"): (0, json.dumps({"body": body}), "")}),
)
result = ps.check_implement(46)
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):
@ -306,211 +272,37 @@ def test_check_implement_not_done_pr_missing(monkeypatch):
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).
def test_check_docs_done(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)
Old regex ``"([^"]*)"`` stopped at the first escaped quote ``\\"`` inside
body, truncating the parsed body and breaking heading detection even when
the PR body was valid. ``json.loads`` correctly unescapes ``\\"`` → ``"``.
"""
body = (
'## Что сделано\nfeat: "quoted" text\n\n## Почему\nreason\n'
"\n## Watch out\nx\n\n## Pending\ny"
)
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"comments": [{"body": "## Docs Review Summary\\nVerdict: APPROVE"}]}',
"",
),
}
),
mock_run_cmd({("gh", "pr", "view"): (0, json.dumps({"body": body}), "")}),
)
result = ps.check_docs(46)
result = ps.check_implement(208)
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):
monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path)
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.")
def test_check_implement_not_done_invalid_json(monkeypatch):
"""Invalid JSON output → NOT_DONE with parse error (not silent empty body)."""
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"comments": [{"body": "## Docs Review Summary\\nVerdict: APPROVE"}]}',
"",
),
}
),
mock_run_cmd({("gh", "pr", "view"): (0, "not valid json{", "")}),
)
result = ps.check_docs(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)
result = ps.check_implement(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "docs-reviewer не запущен" 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
assert "распарсить" in result.detail
# ── check_review ─────────────────────────────────────────────────────────────
@ -911,8 +703,7 @@ def test_format_single_pr_complete(monkeypatch):
)
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.DONE, "APPROVE"),
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):
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "APPROVE не найден"),
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."""
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: REQUEST_CHANGES"),
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."""
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: NEEDS_DISCUSSION"),
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):
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.DONE, "APPROVE"),
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):
results = [
ps.PhaseResult(ps.PhaseStatus.AMBIGUOUS, "несколько issue"),
] + [ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "")] * 6
] + [ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "")] * 5
monkeypatch.setattr(
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):
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "docs"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "ci"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "review"),
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")
output = ps.format_table([47])
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):
"""format_pr_row (table-view): REVIEW REQUEST_CHANGES -> NEXT про fix subagent."""
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "handoff"),
ps.PhaseResult(ps.PhaseStatus.DONE, "docs valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: REQUEST_CHANGES"),
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"),
("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)"),
("REVIEW", "dispatch subagent (subagent_type=reviewer, template=C) for PR #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():
results = [ps.PhaseResult(ps.PhaseStatus.DONE, "")] * 7
results = [ps.PhaseResult(ps.PhaseStatus.DONE, "")] * 6
assert ps.find_current_phase(results) is None
@ -1104,15 +888,15 @@ def test_find_current_phase_first_not_done():
results = [
ps.PhaseResult(ps.PhaseStatus.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
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, ""),
]
assert ps.find_current_phase(results) == 6
assert ps.find_current_phase(results) == 5
# ── 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
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():
"""REVIEW → subagent_type=reviewer, template=C."""
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 ──────────────────
DISPATCH_PHASES = ["ISSUE", "IMPLEMENT", "DOCS", "REVIEW", "MEMORY"]
DISPATCH_PHASES = ["ISSUE", "IMPLEMENT", "REVIEW", "MEMORY"]
@pytest.mark.parametrize("phase", DISPATCH_PHASES)
@ -102,7 +94,7 @@ def test_merge_phase_does_not_dispatch_subagent():
@pytest.mark.parametrize(
("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):
"""``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