diff --git a/.opencode/scripts/pipeline-status.py b/.opencode/scripts/pipeline-status.py index b3e925e..c835174 100644 --- a/.opencode/scripts/pipeline-status.py +++ b/.opencode/scripts/pipeline-status.py @@ -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 # 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} ", - ) - - -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), diff --git a/docs/decisions/091-pr-204-remove-docs-phase.md b/docs/decisions/091-pr-204-remove-docs-phase.md new file mode 100644 index 0000000..f54dd34 --- /dev/null +++ b/docs/decisions/091-pr-204-remove-docs-phase.md @@ -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. \ No newline at end of file diff --git a/docs/handoff/pr-204-remove-docs-phase.md b/docs/handoff/pr-204-remove-docs-phase.md new file mode 100644 index 0000000..7fbc4f9 --- /dev/null +++ b/docs/handoff/pr-204-remove-docs-phase.md @@ -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'ом. \ No newline at end of file diff --git a/tests/test_pipeline_status.py b/tests/test_pipeline_status.py index 98742ae..c14aa89 100644 --- a/tests/test_pipeline_status.py +++ b/tests/test_pipeline_status.py @@ -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 ───────────────────────────────────────────────────────────────────── diff --git a/tests/test_pipeline_status_adr.py b/tests/test_pipeline_status_adr.py deleted file mode 100644 index d100630..0000000 --- a/tests/test_pipeline_status_adr.py +++ /dev/null @@ -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--*.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 diff --git a/tests/test_pipeline_status_next_actions.py b/tests/test_pipeline_status_next_actions.py index 11c0f4d..8e794fa 100644 --- a/tests/test_pipeline_status_next_actions.py +++ b/tests/test_pipeline_status_next_actions.py @@ -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.""" diff --git a/tests/test_pipeline_status_project_map.py b/tests/test_pipeline_status_project_map.py deleted file mode 100644 index 9674069..0000000 --- a/tests/test_pipeline_status_project_map.py +++ /dev/null @@ -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