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:
parent
0c1e3e7d39
commit
9dd462b764
7 changed files with 161 additions and 493 deletions
|
|
@ -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]
|
||||
try:
|
||||
pr_body = json.loads(out).get("body", "") or ""
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return PhaseResult(
|
||||
PhaseStatus.NOT_DONE, f"PR #{pr_number}: не удалось распарсить JSON body"
|
||||
)
|
||||
required = ["## Что сделано", "## Почему", "## Watch out", "## Pending"]
|
||||
missing = [h for h in required if h not in pr_body]
|
||||
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():
|
||||
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)",
|
||||
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),
|
||||
|
|
|
|||
38
docs/decisions/091-pr-204-remove-docs-phase.md
Normal file
38
docs/decisions/091-pr-204-remove-docs-phase.md
Normal 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.
|
||||
55
docs/handoff/pr-204-remove-docs-phase.md
Normal file
55
docs/handoff/pr-204-remove-docs-phase.md
Normal 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'ом.
|
||||
|
|
@ -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 ─────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue