From 6005c88203d79080cf3785352a520bb9abae67bd Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:17:18 +0300 Subject: [PATCH] feat: migrate src/ second-brain + tests + python tooling (#17) * feat(src): migrate second-brain RAG CLI + tests * fix(embedder): strip hardcoded ai.slaid098.dev endpoint * refactor: rename slaid098/opencode to opencode-config * docs(handoff): add pr-5 handoff + ADR-001 * fix(docs): rebase handoff/ADR naming to PR number + drop dangling ADR-009 refs * fix(tests): update script paths + assertions for opencode-config migration * fix(pyproject): update cov + ruff paths config/scripts -> .opencode/scripts * docs: update project map + handoff + ADR --------- Co-authored-by: opencode-agent --- .pre-commit-config.yaml | 16 + .python-version | 2 +- .../002-pr-23-migrate-opencode-config.md | 2 +- docs/decisions/003-pr-17-migrate-src-tests.md | 20 + .../handoff/pr-17-migrate-src-tests-python.md | 29 + docs/project-map/README.md | 31 +- pyproject.toml | 128 ++ src/memory/__init__.py | 0 src/memory/__main__.py | 4 + src/memory/cli.py | 34 + src/memory/embedder.py | 48 + src/memory/index.py | 49 + src/memory/search.py | 42 + tests/_ts_loader.mjs | 187 +++ tests/test_check_adr_refs.py | 157 +++ tests/test_check_permissions.py | 152 +++ tests/test_cli.py | 35 + tests/test_embedder.py | 62 + tests/test_index.py | 52 + tests/test_observability.py | 341 +++++ tests/test_pipeline_status.py | 1178 +++++++++++++++++ tests/test_pipeline_status_adr.py | 53 + tests/test_pipeline_status_ci.py | 501 +++++++ tests/test_pipeline_status_tool.py | 211 +++ tests/test_pipeline_status_tool.ts | 110 ++ tests/test_search.py | 58 + tests/test_spec_status.py | 757 +++++++++++ tests/test_spec_status_tool.py | 172 +++ uv.lock | 814 ++++++++++++ 29 files changed, 5241 insertions(+), 4 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 docs/decisions/003-pr-17-migrate-src-tests.md create mode 100644 docs/handoff/pr-17-migrate-src-tests-python.md create mode 100644 pyproject.toml create mode 100644 src/memory/__init__.py create mode 100644 src/memory/__main__.py create mode 100644 src/memory/cli.py create mode 100644 src/memory/embedder.py create mode 100644 src/memory/index.py create mode 100644 src/memory/search.py create mode 100644 tests/_ts_loader.mjs create mode 100644 tests/test_check_adr_refs.py create mode 100644 tests/test_check_permissions.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_embedder.py create mode 100644 tests/test_index.py create mode 100644 tests/test_observability.py create mode 100644 tests/test_pipeline_status.py create mode 100644 tests/test_pipeline_status_adr.py create mode 100644 tests/test_pipeline_status_ci.py create mode 100644 tests/test_pipeline_status_tool.py create mode 100644 tests/test_pipeline_status_tool.ts create mode 100644 tests/test_search.py create mode 100644 tests/test_spec_status.py create mode 100644 tests/test_spec_status_tool.py create mode 100644 uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3960d0c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.18.2 + hooks: + - id: mypy + additional_dependencies: + - httpx + - numpy + - tenacity diff --git a/.python-version b/.python-version index e4fba21..24ee5b1 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12 +3.13 diff --git a/docs/decisions/002-pr-23-migrate-opencode-config.md b/docs/decisions/002-pr-23-migrate-opencode-config.md index 26ac1a8..0078a66 100644 --- a/docs/decisions/002-pr-23-migrate-opencode-config.md +++ b/docs/decisions/002-pr-23-migrate-opencode-config.md @@ -17,5 +17,5 @@ Accepted ## Альтернативы -- Сохранить config/ + bind-mount — отклонено (LSP-конфликт ADR-009, не zero-config) +- Сохранить config/ + bind-mount — отклонено (LSP-конфликт pyproject.toml, не zero-config) - Дропнуть skills (использовать только глобальные) — отклонено (skills нужны в репо для публичного shareable config) diff --git a/docs/decisions/003-pr-17-migrate-src-tests.md b/docs/decisions/003-pr-17-migrate-src-tests.md new file mode 100644 index 0000000..fc82a27 --- /dev/null +++ b/docs/decisions/003-pr-17-migrate-src-tests.md @@ -0,0 +1,20 @@ +# ADR-003: Migrate src/ second-brain + tests from fix-branch + +## Статус + +Accepted + +## Контекст + +Миграция Python RAG CLI (`second-brain`) из приватного репо в публичный. Source: ветка `fix/pipeline-status/review-next-by-verdict` (содержит фикс pipeline-status REVIEW verdict branching). Hardcoded приватный API endpoint `ai.slaid098.dev` в embedder.py. + +## Решение + +- Копировать файлы из fix-ветки (не master) — фикс включён автоматически +- Strip `ai.slaid098.dev` → env-only `AI_PROVIDER_API_URL` +- Rename `slaid098/opencode` → `slaid098/opencode-config` в URLs и тестах + +## Альтернативы + +- Дропнуть `src/memory/` полностью (memory plugin `@mathew-cf/opencode-memory` покрывает RAG) — отклонено, second-brain используется как standalone CLI +- Копировать из master + отдельный PR для фикса — отклонено, fix-ветка fast-forward, проще взять целиком diff --git a/docs/handoff/pr-17-migrate-src-tests-python.md b/docs/handoff/pr-17-migrate-src-tests-python.md new file mode 100644 index 0000000..d0a9052 --- /dev/null +++ b/docs/handoff/pr-17-migrate-src-tests-python.md @@ -0,0 +1,29 @@ +# PR #17: Migrate src/ + tests/ + Python tooling + +## Что сделано + +- Перенесён Python-пакет `second-brain` (RAG CLI): `src/memory/` (6 файлов) +- Перенесены тесты: `tests/` (15 файлов: 13 .py + 1 .mjs + 1 .ts) +- Перенесён Python tooling: `pyproject.toml`, `uv.lock`, `.pre-commit-config.yaml`, `.python-version` +- `src/memory/embedder.py`: вынесен hardcoded endpoint `ai.slaid098.dev` → env-only (`AI_PROVIDER_API_URL`) +- `pyproject.toml`: rename `slaid098/opencode` → `slaid098/opencode-config` в [project.urls] +- Тесты: mock URLs обновлены `slaid098/opencode` → `slaid098/opencode-config` +- Source: ветка `fix/pipeline-status/review-next-by-verdict` (включает фикс `610452f` + тесты `01212b5`) + +## Почему + +Миграция из приватного репо `slaid098/opencode` в публичный `slaid098/opencode-config`. Hardcoded приватный API endpoint удалён — env-only для публичного релиза. + +## Pending + +- `pipeline-status.py` (fix `610452f`) мигрирует через #7 (`.opencode/scripts/`), не через #5 +- `pyproject.toml` в корне может триггерить LSP-конфликт на bare-metal Windows — отслеживать + +## Watch out + +- Issue #5 заявляла "14 tests файлов", по факту 15 (13 .py + .mjs + .ts) — расхождение +1 +- `config/scripts/pipeline-status.py` НЕ в scope #5 — он в `config/scripts/` → мигрирует через #7 +- `tests/test_pipeline_status.py` содержит тесты для REQUEST_CHANGES/NEEDS_DISCUSSION NEXT (из fix-ветки) +- 10 из 15 тестов падают (FileNotFoundError) — они зависят от `config/scripts/` и `config/tools/` которых нет в репо (мигрируют через #7) +- `tests/test_embedder.py` обновлён: `os.environ.setdefault("AI_PROVIDER_API_URL", "http://test/v1")` перед import — необходим из-за strip hardcoded URL в embedder.py +- `ruff format` реформатнул 2 файла (test_pipeline_status.py, test_spec_status.py) — строки превысили 100 chars после добавления `-config` к URL diff --git a/docs/project-map/README.md b/docs/project-map/README.md index 975c6cd..70a77e1 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -54,6 +54,33 @@ opencode-config/ │ ├── handoff/ # PR handoffs (pr--.md) │ ├── decisions/ # ADRs (NNN-pr--.md) │ └── project-map/ # This file — structure snapshot +├── src/ # Python RAG CLI (second-brain) — PR#17 +│ └── memory/ +│ ├── __init__.py +│ ├── __main__.py # Entry point for `python -m memory` +│ ├── cli.py # CLI commands +│ ├── embedder.py # Embedding via AI_PROVIDER_API_URL (env-only) +│ ├── index.py # Indexing +│ └── search.py # Search +├── tests/ # pytest + TS/MJS test suite — PR#17 +│ ├── _ts_loader.mjs # TS test loader (imports pipeline-status.ts / spec-status.ts) +│ ├── test_check_adr_refs.py # adr-check.yml validator +│ ├── test_check_permissions.py # permissions-check.yml validator +│ ├── test_cli.py # src/memory/cli.py +│ ├── test_embedder.py # src/memory/embedder.py (mocks AI_PROVIDER_API_URL) +│ ├── test_index.py # src/memory/index.py +│ ├── test_observability.py # .opencode/scripts/observability.py +│ ├── test_pipeline_status.py # .opencode/scripts/pipeline-status.py (REVIEW verdict branching) +│ ├── test_pipeline_status_adr.py +│ ├── test_pipeline_status_ci.py +│ ├── test_pipeline_status_tool.py +│ ├── test_pipeline_status_tool.ts # TS wrapper test (mjs loader) +│ ├── test_search.py # src/memory/search.py +│ ├── test_spec_status.py # .opencode/scripts/spec-status.py +│ └── test_spec_status_tool.py +├── pyproject.toml # Python project (uv, ruff, pytest config) +├── uv.lock # Locked deps for Python project +├── .pre-commit-config.yaml # ruff + UV hooks ├── app_data/ │ ├── workspaces/ # Agent working directory (.gitkeep) │ └── ssh/ # SSH keys, not in git (.gitkeep) @@ -66,8 +93,8 @@ opencode-config/ ## Pending (future PRs) -- `src/` — Python RAG CLI (second-brain) — after #5 (PR#17) -- `tests/` — pytest test suite — after #5 +- `.opencode/scripts/pipeline-status.py` fix `610452f` — PR#7 (config/scripts/ migration) +- `.opencode/tools/` TS wrappers — PR#7 (config/tools/ migration) ## Update Protocol diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ffd4a90 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,128 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "second-brain" +version = "0.1.0" +description = "Docker-based AI coding assistant with persistent memory" +readme = "README.md" +license = "MIT" +requires-python = ">=3.12" +authors = [{ name = "slaid098" }] +keywords = [] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] + +dependencies = [ + "httpx", + "numpy", + "tenacity", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "pytest-timeout>=2.2", + "mypy>=1.10", + "ruff>=0.5", + "xenon>=0.9", + "pre-commit>=3.7", +] + +[project.urls] +Homepage = "https://github.com/slaid098/opencode-config" +Repository = "https://github.com/slaid098/opencode-config" +Issues = "https://github.com/slaid098/opencode-config/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +# ── Ruff ────────────────────────────────────────────────────────────────── + +[tool.ruff] +target-version = "py312" +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", "W", + "F", + "I", + "B", + "UP", + "SIM", + "C90", + "PL", + "RUF", + "S", + "TRY", + "LOG", +] +ignore = [ + "S101", + "S311", + "RUF001", + "RUF002", + "RUF003", + "TRY003", + "PLR2004", + "S106", +] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pylint] +max-args = 5 +max-branches = 12 +max-returns = 5 +max-statements = 50 + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607"] +".opencode/scripts/*" = ["S603", "S607"] + +# ── mypy ────────────────────────────────────────────────────────────────── + +[tool.mypy] +python_version = "3.12" +strict = true +explicit_package_bases = true +warn_return_any = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true + +# ── pytest ──────────────────────────────────────────────────────────────── + +[tool.pytest.ini_options] +addopts = "--cov=src --cov=.opencode/scripts --cov-report=term-missing --cov-fail-under=80 --timeout=120" +testpaths = ["tests"] +asyncio_mode = "auto" + +# ── coverage ────────────────────────────────────────────────────────────── + +[tool.coverage.run] +source = ["src", ".opencode/scripts"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/src/memory/__init__.py b/src/memory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/memory/__main__.py b/src/memory/__main__.py new file mode 100644 index 0000000..ea46b3f --- /dev/null +++ b/src/memory/__main__.py @@ -0,0 +1,4 @@ +from src.memory.cli import main + +if __name__ == "__main__": + main() diff --git a/src/memory/cli.py b/src/memory/cli.py new file mode 100644 index 0000000..0fbf9ad --- /dev/null +++ b/src/memory/cli.py @@ -0,0 +1,34 @@ +import argparse + +from src.memory.index import run_index +from src.memory.search import run_search + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(prog="rag") + sub = parser.add_subparsers(dest="command", required=True) + + search_p = sub.add_parser("search") + search_p.add_argument("query") + search_p.add_argument("-i", "--index-dir", required=True) + search_p.add_argument("-k", type=int, default=15) + search_p.add_argument("--json", action="store_true") + + index_p = sub.add_parser("index") + index_p.add_argument("memory_dir") + index_p.add_argument("-o", "--output", required=True) + + sub.add_parser("download") + + args = parser.parse_args(argv) + + if args.command == "search": + run_search(args) + elif args.command == "index": + run_index(args) + elif args.command == "download": + print("Gemini API: model download not needed") + + +if __name__ == "__main__": + main() diff --git a/src/memory/embedder.py b/src/memory/embedder.py new file mode 100644 index 0000000..9c552bb --- /dev/null +++ b/src/memory/embedder.py @@ -0,0 +1,48 @@ +import os + +import httpx +from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential + +API_URL = os.environ.get("AI_PROVIDER_API_URL") +if not API_URL: + raise RuntimeError("AI_PROVIDER_API_URL env var not set") +API_KEY = os.environ.get("AI_PROVIDER_API_KEY", "") +EMBEDDING_MODEL = "gemini-embedding-2-preview" + + +def _is_retryable(exc: BaseException) -> bool: + if isinstance(exc, (httpx.ConnectError, httpx.TimeoutException)): + return True + if isinstance(exc, httpx.HTTPStatusError): + status = exc.response.status_code + return status == 429 or status >= 500 + return False + + +@retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=2, max=10), + retry=retry_if_exception(_is_retryable), +) +def _call_embedding_api( + client: httpx.Client, + url: str, + headers: dict[str, str], + payload: dict[str, str | list[str]], +) -> list[list[float]]: + resp = client.post(url, json=payload, headers=headers) + resp.raise_for_status() + data = resp.json() + return [d["embedding"] for d in data["data"]] + + +def embed_texts(texts: list[str]) -> list[list[float]]: + if not texts: + return [] + + url = f"{API_URL}/embeddings" + headers = {"Authorization": f"Bearer {API_KEY}"} + payload: dict[str, str | list[str]] = {"model": EMBEDDING_MODEL, "input": texts} + + with httpx.Client(timeout=120.0) as client: + return _call_embedding_api(client, url, headers, payload) diff --git a/src/memory/index.py b/src/memory/index.py new file mode 100644 index 0000000..b47cd8a --- /dev/null +++ b/src/memory/index.py @@ -0,0 +1,49 @@ +import argparse +import json +from pathlib import Path + +from src.memory.embedder import embed_texts + +INDEX_FILENAME = "index.json" + + +def _extract_text(content: str) -> str: + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + return parts[2].strip() + return content.strip() + + +def run_index(args: argparse.Namespace) -> None: + memory_dir = Path(args.memory_dir) + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + + md_files = sorted(memory_dir.rglob("*.md")) + md_files = [f for f in md_files if ".rag" not in f.parts] + + if not md_files: + print("No .md files found") + return + + texts: list[str] = [] + file_map: list[dict[str, str]] = [] + + for fpath in md_files: + rel = fpath.relative_to(memory_dir) + content = fpath.read_text(encoding="utf-8") + text = _extract_text(content) + texts.append(text) + file_map.append({"source": str(rel), "text": text[:500]}) + + embeddings = embed_texts(texts) + + index = { + "files": [{**fm, "embedding": emb} for fm, emb in zip(file_map, embeddings, strict=False)], + } + + (output_dir / INDEX_FILENAME).write_text( + json.dumps(index, ensure_ascii=False), encoding="utf-8" + ) + print(f"Indexed {len(md_files)} files to {output_dir / INDEX_FILENAME}") diff --git a/src/memory/search.py b/src/memory/search.py new file mode 100644 index 0000000..af52d39 --- /dev/null +++ b/src/memory/search.py @@ -0,0 +1,42 @@ +import argparse +import json +import sys +from pathlib import Path + +import numpy as np +from src.memory.embedder import embed_texts + + +def _cosine_sim(a: np.ndarray, b: np.ndarray) -> float: + norm_a = np.linalg.norm(a) + norm_b = np.linalg.norm(b) + if norm_a == 0 or norm_b == 0: + return 0.0 + return float(np.dot(a, b) / (norm_a * norm_b)) + + +def run_search(args: argparse.Namespace) -> None: + index_path = Path(args.index_dir) / "index.json" + if not index_path.exists(): + json.dump([], sys.stdout) + return + + index = json.loads(index_path.read_text(encoding="utf-8")) + files = index.get("files", []) + if not files: + json.dump([], sys.stdout) + return + + query_emb = embed_texts([args.query])[0] + query_vec = np.array(query_emb) + + results: list[dict[str, str | float]] = [] + for f in files: + file_vec = np.array(f["embedding"]) + sim = _cosine_sim(query_vec, file_vec) + results.append({"source": f["source"], "score": sim, "text": f["text"]}) + + results.sort(key=lambda r: r["score"], reverse=True) + top = results[: args.k] + + print(json.dumps(top, ensure_ascii=False)) diff --git a/tests/_ts_loader.mjs b/tests/_ts_loader.mjs new file mode 100644 index 0000000..7169411 --- /dev/null +++ b/tests/_ts_loader.mjs @@ -0,0 +1,187 @@ +// Test harness for .opencode/tools/pipeline-status.ts. +// +// Why this file: there is no bun/tsx/esbuild on the CI runner (only node + +// pytest). The pipeline-status.ts tool is a TypeScript module (Bun runtime +// for opencode, not runnable with plain `node`). To exercise the tool's +// execute() function in pytest, we strip TS-only syntax (import type +// annotations + ESM import -> CJS require) and load the resulting JS as a +// CommonJS module. The tool() factory from @opencode-ai/plugin is identity +// (returns its argument), so we can replace it with a passthrough shim +// without changing the tool semantics. +// +// This file is consumed by tests/test_pipeline_status_tool.py via: +// node tests/_ts_loader.mjs [args...] +// where is one of: load, exec, exec_real. +// +// - load: print the {description, args keys} of the tool — sanity check. +// - exec: call execute({pr_number: }) with a stubbed spawnSync that +// returns whatever argv it received, plus a fixture stdout. Used by unit +// tests to verify args passed and output trimming. +// - exec_real: call execute({pr_number: }) with the REAL spawnSync, +// used by the integration test against the real pipeline-status.py. + +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import path from "node:path" +import { spawnSync } from "node:child_process" + +const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), "..", "..") +const DEFAULT_TS_FILE = path.join(REPO_ROOT, ".opencode", "tools", "pipeline-status.ts") +// Parameterize via env var TS_FILE (relative to REPO_ROOT) so other TS tool +// wrappers (e.g. spec-status.ts) can be loaded by the same harness without +// breaking existing callers that don't set TS_FILE (default: pipeline-status.ts). +const TS_FILE = process.env.TS_FILE + ? path.resolve(REPO_ROOT, process.env.TS_FILE) + : DEFAULT_TS_FILE + +// Minimal zod shim covering the methods the tool actually uses: +// tool.schema.number().describe("...") +// At runtime (in opencode/Bun) `tool.schema` is the real zod. Here we +// only need a chainable builder that returns an object with .describe() +// — no validation occurs in execute(). +function makeZodShim() { + const chain = () => { + const obj = { + describe() { return obj }, + optional() { return obj }, + // Add more methods as future tool definitions need them. + } + return obj + } + return { + number: chain, + string: chain, + boolean: chain, + array: chain, + object: chain, + } +} +const zodShim = makeZodShim() + +function stripTs(src) { + // Minimal TS -> JS for this specific file: + // 1) `import { spawnSync } from "child_process"` -> `const { spawnSync } = require("child_process")` + // 2) `import { tool } from "@opencode-ai/plugin"` -> `const tool = (x) => x` + // 3) `import path from "path"` -> `const path = require("path")` +// 4) `import.meta.dir` -> a stub pointing at .opencode/tools (so the script +// path resolves to .opencode/scripts/pipeline-status.py) + // 5) `args: z.ZodObject` -> the args are referenced inside execute as + // `args.pr_number`; the schema itself is unused at runtime here. + // 6) Strip `: type` annotations and `async execute(args)` stays. + let out = src + out = out.replace(/^import\s+\{\s*spawnSync\s*\}\s+from\s+["']child_process["'];?\s*$/m, 'const { spawnSync } = require("child_process");') + out = out.replace(/^import\s+path\s+from\s+["']path["'];?\s*$/m, 'const path = require("path");') + out = out.replace(/^import\s+\{\s*tool\s*\}\s+from\s+["']@opencode-ai\/plugin["'];?\s*$/m, 'const tool = (x) => x;') + // Replace `import.meta.dir` with the directory of the TS file. + out = out.replace(/import\.meta\.dir/g, JSON.stringify(path.dirname(TS_FILE))) + return out +} + +function loadTool(spawnSyncImpl) { + // Provide a CommonJS module sandbox so the tool file's `export default` + // becomes accessible via `module.exports.default`. + let src = stripTs(readFileSync(TS_FILE, "utf-8")) + // Strip the require/const declarations we replace with sandbox args, so + // we don't get "Identifier already declared" between Function args and + // the in-source `const { spawnSync } = require(...)` lines. + src = src.replace(/^const\s+\{\s*spawnSync\s*\}\s*=\s*require\(["']child_process["']\);?\s*$/m, "") + src = src.replace(/^const\s+path\s*=\s*require\(["']path["']\);?\s*$/m, "") + src = src.replace(/^const\s+tool\s*=\s*\(x\)\s*=>\s*x;?\s*$/m, "") + // Convert `export default tool({...})` into `module.exports.default = tool({...})` + const cjs = src.replace(/^export default /m, "module.exports.default = ") + // tool shim with .schema = zodShim (since pipeline-status.ts uses tool.schema.number()) + const toolShim = (x) => x + toolShim.schema = zodShim + const fn = new Function( + "module", + "require", + "spawnSync", + "path", + "tool", + cjs + "\nreturn module.exports.default;", + ) + return fn({ exports: {} }, (name) => { + if (name === "child_process") return { spawnSync: spawnSyncImpl } + if (name === "path") return path + throw new Error("unexpected require: " + name) + }, spawnSyncImpl, path, toolShim) +} + +function buildExecArgs(tool, rawValue) { + // Interpret ``rawValue`` (argv string) as the tool's first declared arg. + // - pipeline-status.ts: ``pr_number`` (int) → parseInt + // - spec-status.ts: ``validate`` (bool) → /true/i match + // Detection is dynamic so the harness works for any single-arg tool + // without hardcoding tool names. If the tool declares no args, return {}. + const keys = Object.keys(tool.args || {}) + if (keys.length === 0) return {} + const first = keys[0] + if (first === "pr_number") return { pr_number: parseInt(rawValue, 10) } + if (first === "validate") return { validate: /^true$/i.test(rawValue || "") } + // Fallback heuristic: numeric → int, else bool-ish. + return { [first]: rawValue } +} + +function main() { + const mode = process.argv[2] + if (!mode) { + console.error("usage: node _ts_loader.mjs [args...]") + process.exit(2) + } + if (mode === "load") { + const t = loadTool(spawnSync) + console.log(JSON.stringify({ description: t.description, args: Object.keys(t.args) })) + return + } + if (mode === "exec_stub") { + // Args: + // The first arg is interpreted based on which tool is loaded: + // - pipeline-status.ts declares ``pr_number`` (int) + // - spec-status.ts declares ``validate`` (bool) + // Detected dynamically from ``t.args`` keys so the harness stays generic. + const stubStatus = parseInt(process.argv[4], 10) + const stubStdout = process.argv[5] + const stubStderr = process.argv[6] || "" + const callLog = [] + const stub = (cmd, args, opts) => { + callLog.push({ cmd, args, opts }) + return { status: stubStatus, stdout: stubStdout, stderr: stubStderr } + } + const t = loadTool(stub) + const execArgs = buildExecArgs(t, process.argv[3]) + t.execute(execArgs, { + // ToolContext — only fields the tool actually touches. Our tool + // touches none of the ctx fields, so this can be empty-ish. + sessionID: "test", messageID: "test", agent: "test", + directory: REPO_ROOT, worktree: REPO_ROOT, + abort: new AbortController().signal, + metadata() {}, async ask() {}, + }).then( + (result) => { + console.log(JSON.stringify({ result, calls: callLog })) + }, + (err) => { + console.log(JSON.stringify({ error: String(err), calls: callLog })) + }, + ) + return + } + if (mode === "exec_real") { + const t = loadTool(spawnSync) // use real spawnSync + const execArgs = buildExecArgs(t, process.argv[3]) + t.execute(execArgs, { + sessionID: "test", messageID: "test", agent: "test", + directory: REPO_ROOT, worktree: REPO_ROOT, + abort: new AbortController().signal, + metadata() {}, async ask() {}, + }).then( + (result) => console.log(JSON.stringify({ result })), + (err) => console.log(JSON.stringify({ error: String(err) })), + ) + return + } + console.error("unknown mode: " + mode) + process.exit(2) +} + +main() \ No newline at end of file diff --git a/tests/test_check_adr_refs.py b/tests/test_check_adr_refs.py new file mode 100644 index 0000000..8f896b0 --- /dev/null +++ b/tests/test_check_adr_refs.py @@ -0,0 +1,157 @@ +"""Tests for .opencode/scripts/check-adr-refs.py — dangling ADR reference guard. + +Covers the deterministic ADR-ref guard: clean repo passes, a dangling +``ADR-999`` in a temp handoff is detected, self-reference inside an +``NNN-*.md`` file is excluded, a valid cross-ref (``ADR-002``) passes, +a file with no ADR refs passes, and ``node_modules/`` is excluded. + +Strategy mirrors ``tests/test_check_permissions.py``: +- ``test_clean_repo_passes`` runs the script as a subprocess on the + real (clean) repo — black-box, returncode 0, OK message in stdout. +- The remaining tests load the script in-process via importlib and + monkeypatch ``REPO_ROOT`` / ``ADR_DIR`` to point at a tmp_path tree, + then call ``main()`` and assert on captured stdout / SystemExit code. +""" + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_PATH = REPO_ROOT / ".opencode" / "scripts" / "check-adr-refs.py" + + +def _load_script(): + spec = importlib.util.spec_from_file_location("check_adr_refs", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules["check_adr_refs"] = module + spec.loader.exec_module(module) + return module + + +car = _load_script() + + +# ── clean repo (subprocess black-box) ─────────────────────────────────────── + + +def test_clean_repo_passes(): + """On the current (clean) repo the script exits 0 with the OK message.""" + result = subprocess.run( + ["python3", str(SCRIPT_PATH)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert "OK: No dangling ADR references." in result.stdout + + +# ── helpers for in-process tests ──────────────────────────────────────────── + + +def _setup_tmp_repo(tmp_path: Path, monkeypatch) -> tuple[Path, Path]: + """Point the script's REPO_ROOT and ADR_DIR at a tmp_path tree. + + Creates the ``docs/decisions/`` dir and returns (repo_root, adr_dir). + """ + repo = tmp_path / "repo" + adr_dir = repo / "docs" / "decisions" + adr_dir.mkdir(parents=True) + handoff_dir = repo / "docs" / "handoff" + handoff_dir.mkdir(parents=True) + monkeypatch.setattr(car, "REPO_ROOT", repo) + monkeypatch.setattr(car, "ADR_DIR", adr_dir) + return repo, adr_dir + + +# ── dangling reference detected ───────────────────────────────────────────── + + +def test_dangling_ref_detected(tmp_path, monkeypatch, capsys): + """A handoff referencing ADR-999 (no 999-*.md) → exit 1 + ADR-999 in output.""" + repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch) + (repo / "docs" / "handoff" / "pr-1-test.md").write_text("See ADR-999 for context.\n") + + with pytest.raises(SystemExit) as exc_info: + car.main() + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "ADR-999" in captured.out + assert "FAIL" in captured.out + + +# ── self-reference excluded ───────────────────────────────────────────────── + + +def test_self_reference_excluded(tmp_path, monkeypatch, capsys): + """An ADR file ``019-pr-94-test.md`` mentioning ADR-019 → passes (self-ref).""" + _repo, adr_dir = _setup_tmp_repo(tmp_path, monkeypatch) + (adr_dir / "019-pr-94-test.md").write_text("# ADR-019: self-reference is OK.\n") + + with pytest.raises(SystemExit) as exc_info: + car.main() + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "OK: No dangling ADR references." in captured.out + + +# ── valid cross-reference passes ──────────────────────────────────────────── + + +def test_valid_cross_ref_passes(tmp_path, monkeypatch, capsys): + """A handoff referencing ADR-002 when 002-*.md exists → passes.""" + repo, adr_dir = _setup_tmp_repo(tmp_path, monkeypatch) + (adr_dir / "002-pr-56-pipeline-mandatory-adr.md").write_text("# ADR-002\n") + (repo / "docs" / "handoff" / "pr-3-test.md").write_text("Per ADR-002, ADR is mandatory.\n") + + with pytest.raises(SystemExit) as exc_info: + car.main() + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "OK: No dangling ADR references." in captured.out + + +# ── no ADR refs in a file passes ──────────────────────────────────────────── + + +def test_no_adr_refs_passes(tmp_path, monkeypatch, capsys): + """A handoff with no ADR-NNN references → passes.""" + repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch) + (repo / "docs" / "handoff" / "pr-4-test.md").write_text("No ADR references here.\n") + + with pytest.raises(SystemExit) as exc_info: + car.main() + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "OK: No dangling ADR references." in captured.out + + +# ── node_modules excluded ─────────────────────────────────────────────────── + + +def test_exclude_node_modules(tmp_path, monkeypatch, capsys): + """A ``.md`` file under ``node_modules/`` with ADR-999 is NOT scanned.""" + repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch) + nm = repo / "node_modules" / "some-pkg" + nm.mkdir(parents=True) + (nm / "README.md").write_text("Dangling ADR-999 should be ignored.\n") + + with pytest.raises(SystemExit) as exc_info: + car.main() + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "OK: No dangling ADR references." in captured.out + assert "ADR-999" not in captured.out + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_check_permissions.py b/tests/test_check_permissions.py new file mode 100644 index 0000000..3c0072e --- /dev/null +++ b/tests/test_check_permissions.py @@ -0,0 +1,152 @@ +"""Tests for .opencode/scripts/check-permissions.py — dangerous permission rules guard. + +Covers the ``DANGEROUS_PATTERNS`` guard: clean configs pass, ``gh pr checks*`` +in agent frontmatter or in ``opencode.json`` triggers a violation with the +ADR-005 reference. + +Strategy: +- ``test_clean_configs_pass`` runs the script as a subprocess on the real + (clean) repo configs — black-box, returncode 0, OK message in stdout. +- ``test_gh_pr_checks_in_agent_violation`` writes a temporary agent .md file + into a temp ``AGENTS_DIR`` and points the script at it via monkeypatch, then + calls ``main`` in-process and captures stdout/stderr. +- ``test_gh_pr_checks_in_opencode_json_violation`` mocks + ``parse_global_bash_rules`` to return a rule containing ``gh pr checks*`` + and asserts ``check_rules`` flags it with the ADR-005 reference. +- ``test_gh_pr_view_statuscheckrollup_violation`` does the same for the + narrower ``gh pr view *--json statusCheckRollup*`` pattern. +""" + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_PATH = REPO_ROOT / ".opencode" / "scripts" / "check-permissions.py" + + +def _load_script(): + spec = importlib.util.spec_from_file_location("check_permissions", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules["check_permissions"] = module + spec.loader.exec_module(module) + return module + + +cp = _load_script() + + +# ── clean configs (subprocess black-box) ──────────────────────────────────── + + +def test_clean_configs_pass(): + """On current (clean) repo configs the script exits 0 with OK message.""" + result = subprocess.run( + ["python3", str(SCRIPT_PATH)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert "OK: No dangerous permission rules found." in result.stdout + + +# ── gh pr checks in agent frontmatter (in-process, tmp AGENTS_DIR) ────────── + + +def test_gh_pr_checks_in_agent_violation(tmp_path, monkeypatch, capsys): + """A temp agent .md with ``"gh pr checks*": allow`` → exit 1 + ADR-005.""" + agents_dir = tmp_path / "agents" + agents_dir.mkdir() + agent_file = agents_dir / "test-agent.md" + agent_file.write_text( + "---\n" + "description: test agent with dangerous rule\n" + "mode: subagent\n" + "permission:\n" + " bash:\n" + ' "*": deny\n' + ' "gh pr checks*": allow\n' + "---\n\n" + "# test agent\n" + ) + + monkeypatch.setattr(cp, "AGENTS_DIR", agents_dir) + monkeypatch.setattr(cp, "OPENCODE_JSON", tmp_path / "missing-opencode.json") + + with pytest.raises(SystemExit) as exc_info: + cp.main() + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "gh pr checks" in captured.out + assert "ADR-005" in captured.out + assert "Checks API" in captured.out + + +# ── gh pr checks in opencode.json (mock parse_global_bash_rules) ──────────── + + +def test_gh_pr_checks_in_opencode_json_violation(monkeypatch): + """Mock parse_global_bash_rules returns gh pr checks* → check_rules flags it.""" + fake_rules = {"gh pr checks*": "allow"} + monkeypatch.setattr(cp, "parse_global_bash_rules", lambda _path: fake_rules) + + violations = cp.check_rules(fake_rules, "opencode.json") + assert len(violations) == 1 + v = violations[0] + assert "gh pr checks" in v + assert "ADR-005" in v + assert "Checks API" in v + + +# ── gh pr view --json statusCheckRollup (mock parse_global_bash_rules) ────── + + +def test_gh_pr_view_statuscheckrollup_violation(): + """The narrower statusCheckRollup pattern is also flagged with ADR-005.""" + fake_rules = {"gh pr view *--json statusCheckRollup*": "allow"} + violations = cp.check_rules(fake_rules, "opencode.json") + assert len(violations) == 1 + v = violations[0] + assert "statusCheckRollup" in v + assert "ADR-005" in v + assert "Checks API" in v + + +# ── deny action is not a violation ────────────────────────────────────────── + + +def test_deny_action_not_violation(): + """A dangerous pattern with action=deny is not flagged (only allow is).""" + fake_rules = {"gh pr checks*": "deny"} + violations = cp.check_rules(fake_rules, "opencode.json") + assert violations == [] + + +# ── gh pr merge in agent allow-list (CI guard, ADR-017) ────────────────────── + + +def test_gh_pr_merge_in_agent_detected(tmp_path, monkeypatch): + """gh pr merge* in agent allow-list triggers violation (CI guard).""" + agent_file = tmp_path / "agents" / "reviewer.md" + agent_file.parent.mkdir(parents=True) + agent_file.write_text( + '---\nmode: subagent\npermission:\n bash:\n "gh pr merge*": allow\n---\ntest agent\n' + ) + monkeypatch.setattr(cp, "AGENTS_DIR", tmp_path / "agents") + monkeypatch.setattr(cp, "OPENCODE_JSON", tmp_path / "nonexistent.json") + violations = cp.check_rules( + cp.parse_agent_bash_rules(agent_file), + f"agents/{agent_file.name}", + ) + assert len(violations) == 1 + assert "gh pr merge*" in violations[0] + assert "merge is done by main agent" in violations[0] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..88696bb --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,35 @@ +import subprocess +import sys + + +def test_cli_download() -> None: + result = subprocess.run( + [sys.executable, "-m", "src.memory", "download"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert "not needed" in result.stdout + + +def test_cli_search_no_index(tmp_path) -> None: + result = subprocess.run( + [ + sys.executable, + "-m", + "src.memory", + "search", + "test query", + "-i", + str(tmp_path), + "-k", + "5", + "--json", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "[]" diff --git a/tests/test_embedder.py b/tests/test_embedder.py new file mode 100644 index 0000000..6c4956e --- /dev/null +++ b/tests/test_embedder.py @@ -0,0 +1,62 @@ +import os + +os.environ.setdefault("AI_PROVIDER_API_URL", "http://test/v1") + +from typing import NoReturn +from unittest.mock import patch + +import httpx +import pytest +from src.memory.embedder import embed_texts + + +def test_embed_texts_success() -> None: + fake_embedding = [0.1, 0.2, 0.3] + + class FakeResponse: + status_code = 200 + + def json(self): + return { + "data": [{"embedding": fake_embedding, "index": 0}], + "model": "gemini-embedding-2-preview", + } + + def raise_for_status(self) -> None: + pass + + def mock_post(self, url, **kwargs): + assert "embeddings" in url + return FakeResponse() + + with patch.object(httpx.Client, "post", mock_post): + result = embed_texts(["test text"]) + + assert len(result) == 1 + assert result[0] == fake_embedding + + +def test_embed_texts_empty() -> None: + assert embed_texts([]) == [] + + +def test_embed_texts_api_error() -> None: + class FakeErrorResponse: + status_code = 401 + + def raise_for_status(self) -> NoReturn: + msg = "Unauthorized" + raise httpx.HTTPStatusError( + msg, + request=None, + response=self, + ) + + def json(self): + return {} + + def mock_post(self, url, **kwargs): + return FakeErrorResponse() + + with patch.object(httpx.Client, "post", mock_post), pytest.raises(httpx.HTTPStatusError): + embed_texts(["test"]) diff --git a/tests/test_index.py b/tests/test_index.py new file mode 100644 index 0000000..b6a2df0 --- /dev/null +++ b/tests/test_index.py @@ -0,0 +1,52 @@ +import json +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch + +from src.memory.index import _extract_text, run_index + + +class TestExtractText: + def test_with_frontmatter(self) -> None: + md = "---\ntitle: test\n---\n\nbody content" + assert _extract_text(md) == "body content" + + def test_no_frontmatter(self) -> None: + md = "just content" + assert _extract_text(md) == "just content" + + def test_empty(self) -> None: + assert _extract_text("") == "" + + +class TestRunIndex: + def test_index_creates_json(self, tmp_path: Path) -> None: + memory_dir = tmp_path / "memory" + index_dir = tmp_path / ".rag" + memory_dir.mkdir(parents=True) + + (memory_dir / "test.md").write_text("---\ntitle: test\n---\n\nhello world") + + def fake_embed_texts(texts): + return [[0.1, 0.2, 0.3]] * len(texts) + + with patch("src.memory.index.embed_texts", fake_embed_texts): + args = Namespace(memory_dir=str(memory_dir), output=str(index_dir)) + run_index(args) + + assert (index_dir / "index.json").exists() + index = json.loads((index_dir / "index.json").read_text()) + assert len(index["files"]) == 1 + assert index["files"][0]["source"] == "test.md" + assert index["files"][0]["embedding"] == [0.1, 0.2, 0.3] + + def test_no_md_files(self, tmp_path: Path, capsys) -> None: + memory_dir = tmp_path / "empty" + index_dir = tmp_path / ".rag" + memory_dir.mkdir(parents=True) + + args = Namespace(memory_dir=str(memory_dir), output=str(index_dir)) + run_index(args) + + captured = capsys.readouterr() + assert "No .md files found" in captured.out diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 0000000..597c5eb --- /dev/null +++ b/tests/test_observability.py @@ -0,0 +1,341 @@ +"""Tests for .opencode/scripts/observability.py — log parser for denials/errors. + +Pattern: direct calls for parse_line/_process_line, tmp_path + monkeypatch on +obs.LOG_PATH for main() tests. Loading via importlib.util.spec_from_file_location +(same pattern as test_pipeline_status.py / test_spec_status.py). +""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "observability.py" +spec = importlib.util.spec_from_file_location("observability", SCRIPT_PATH) +obs = importlib.util.module_from_spec(spec) +sys.modules["observability"] = obs +spec.loader.exec_module(obs) + + +# ── parse_line ────────────────────────────────────────────────────────────── + + +def test_parse_line_with_timestamp_and_run(): + assert obs.parse_line("timestamp=2026-07-20T10:00:00 run=abc123") == ( + "2026-07-20T10:00:00", + "abc123", + ) + + +def test_parse_line_no_timestamp(): + assert obs.parse_line("run=abc123") == ("?", "abc123") + + +def test_parse_line_no_run(): + assert obs.parse_line("timestamp=2026-07-20T10:00:00") == ( + "2026-07-20T10:00:00", + "?", + ) + + +def test_parse_line_no_matches(): + assert obs.parse_line("random line") == ("?", "?") + + +def test_parse_line_empty(): + assert obs.parse_line("") == ("?", "?") + + +# ── _process_line: sessions ────────────────────────────────────────────────── + + +def test_process_line_session_created(): + sessions: dict = {} + denials: list = [] + errors: list = [] + obs._process_line( + 'message=created agent=build run=abc id=sess1 title="Test"', + sessions, + denials, + errors, + ) + assert sessions == {"abc": {"agent": "build", "session": "sess1", "title": "Test"}} + assert denials == [] + assert errors == [] + + +def test_process_line_session_no_agent(): + # `agent=` present but value empty — `agent=(\S+)` regex misses → fallback "unknown". + # NOTE: spec issue #111 used `message=created run=abc id=sess1` (no `agent=` at all), + # but `observability.py:23` requires `"agent=" in line` to enter the session branch. + # Fix: `agent=` with empty value exercises the `unknown` fallback (the spec's intent). + sessions: dict = {} + denials: list = [] + errors: list = [] + obs._process_line( + "message=created agent= run=abc id=sess1", + sessions, + denials, + errors, + ) + assert sessions == {"abc": {"agent": "unknown", "session": "sess1", "title": ""}} + + +def test_process_line_session_no_title(): + sessions: dict = {} + denials: list = [] + errors: list = [] + obs._process_line( + "message=created agent=build run=abc id=sess1", + sessions, + denials, + errors, + ) + assert sessions == {"abc": {"agent": "build", "session": "sess1", "title": ""}} + + +# ── _process_line: denials ─────────────────────────────────────────────────── + + +def test_process_line_denial(): + sessions: dict = {} + denials: list = [] + errors: list = [] + obs._process_line( + 'action.action=deny run=abc pattern="rm *" permission=bash', + sessions, + denials, + errors, + ) + assert len(denials) == 1 + d = denials[0] + assert d["run"] == "abc" + assert d["pattern"] == "rm *" + assert d["perm"] == "bash" + assert d["ts"] == "?" + assert sessions == {} + assert errors == [] + + +def test_process_line_denial_no_pattern(): + sessions: dict = {} + denials: list = [] + errors: list = [] + obs._process_line( + "action.action=deny run=abc permission=bash", + sessions, + denials, + errors, + ) + assert len(denials) == 1 + assert denials[0]["pattern"] == "?" + + +# ── _process_line: errors ───────────────────────────────────────────────────── + + +def test_process_line_error(): + sessions: dict = {} + denials: list = [] + errors: list = [] + obs._process_line( + "message=process level=ERROR run=abc error=ToolExecFailed session.id=sess1", + sessions, + denials, + errors, + ) + assert len(errors) == 1 + e = errors[0] + assert e["run"] == "abc" + assert e["error"] == "ToolExecFailed" + assert e["session"] == "sess1" + assert e["ts"] == "?" + assert sessions == {} + assert denials == [] + + +def test_process_line_error_no_session(): + sessions: dict = {} + denials: list = [] + errors: list = [] + obs._process_line( + "message=process level=ERROR run=abc error=ToolExecFailed", + sessions, + denials, + errors, + ) + assert len(errors) == 1 + assert errors[0]["session"] == "?" + + +def test_process_line_error_no_error_field(): + """No error= field → error default 'unknown'.""" + sessions: dict = {} + denials: list = [] + errors: list = [] + obs._process_line( + "message=process level=ERROR run=abc session.id=sess1", + sessions, + denials, + errors, + ) + assert len(errors) == 1 + assert errors[0]["error"] == "unknown" + + +# ── _process_line: unmatched ────────────────────────────────────────────────── + + +def test_process_line_unmatched(): + sessions: dict = {} + denials: list = [] + errors: list = [] + obs._process_line("random log line", sessions, denials, errors) + assert sessions == {} + assert denials == [] + assert errors == [] + + +def test_process_line_message_process_no_error(): + """message=process without level=ERROR → not classified as error.""" + sessions: dict = {} + denials: list = [] + errors: list = [] + obs._process_line( + "message=process level=INFO run=abc", + sessions, + denials, + errors, + ) + assert sessions == {} + assert denials == [] + assert errors == [] + + +def test_process_line_created_without_agent_keyword(): + """message=created present but no 'agent=' substring → not a session line.""" + sessions: dict = {} + denials: list = [] + errors: list = [] + obs._process_line( + "message=created run=abc id=sess1", + sessions, + denials, + errors, + ) + # 'message=created' in line AND 'agent=' in line — second condition fails, + # so falls through. No 'action.action=deny' or 'message=process level=ERROR'. + assert sessions == {} + assert denials == [] + assert errors == [] + + +# ── main ───────────────────────────────────────────────────────────────────── + + +def test_main_no_log_file(tmp_path, monkeypatch, capsys): + monkeypatch.setattr(obs, "LOG_PATH", tmp_path / "nonexistent.log") + with pytest.raises(SystemExit) as exc_info: + obs.main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "Log file not found" in captured.out + + +def test_main_empty_log(tmp_path, monkeypatch, capsys): + log_file = tmp_path / "test.log" + log_file.write_text("") + monkeypatch.setattr(obs, "LOG_PATH", log_file) + obs.main() + captured = capsys.readouterr() + assert "No denials or errors found in log." in captured.out + + +def test_main_only_sessions(tmp_path, monkeypatch, capsys): + log_file = tmp_path / "test.log" + log_file.write_text('message=created agent=build run=abc id=sess1 title="Test"\n') + monkeypatch.setattr(obs, "LOG_PATH", log_file) + obs.main() + captured = capsys.readouterr() + assert "No denials or errors found in log." in captured.out + + +def test_main_with_denials(tmp_path, monkeypatch, capsys): + log_file = tmp_path / "test.log" + log_file.write_text('action.action=deny run=abc pattern="rm *" permission=bash\n') + monkeypatch.setattr(obs, "LOG_PATH", log_file) + obs.main() + captured = capsys.readouterr() + assert "## Permission Denials" in captured.out + assert "`rm *`" in captured.out + assert "bash" in captured.out + assert "unknown" in captured.out # agent lookup falls back to unknown + + +def test_main_with_errors(tmp_path, monkeypatch, capsys): + log_file = tmp_path / "test.log" + log_file.write_text( + "message=process level=ERROR run=abc error=ToolExecFailed session.id=sess1\n" + ) + monkeypatch.setattr(obs, "LOG_PATH", log_file) + obs.main() + captured = capsys.readouterr() + assert "## Process Errors" in captured.out + assert "ToolExecFailed" in captured.out + assert "sess1" in captured.out + + +def test_main_mixed(tmp_path, monkeypatch, capsys): + log_file = tmp_path / "test.log" + log_file.write_text( + 'message=created agent=build run=abc id=sess1 title="Test"\n' + 'action.action=deny run=abc pattern="rm *" permission=bash\n' + "message=process level=ERROR run=abc error=ToolExecFailed session.id=sess1\n" + ) + monkeypatch.setattr(obs, "LOG_PATH", log_file) + obs.main() + captured = capsys.readouterr() + assert "# Observability Report" in captured.out + assert "## Permission Denials" in captured.out + assert "### build (1 denials)" in captured.out + assert "## Process Errors" in captured.out + # Denial row references session from sessions dict. + assert "sess1" in captured.out + + +def test_main_denials_grouped_by_agent(tmp_path, monkeypatch, capsys): + log_file = tmp_path / "test.log" + log_file.write_text( + 'message=created agent=zebra run=r1 id=s1 title="A"\n' + 'message=created agent=alpha run=r2 id=s2 title="B"\n' + 'action.action=deny run=r1 pattern="p1" permission=bash\n' + 'action.action=deny run=r2 pattern="p2" permission=edit\n' + ) + monkeypatch.setattr(obs, "LOG_PATH", log_file) + obs.main() + captured = capsys.readouterr() + out = captured.out + # sorted alphabetically by agent + alpha_idx = out.find("### alpha") + zebra_idx = out.find("### zebra") + assert alpha_idx != -1 + assert zebra_idx != -1 + assert alpha_idx < zebra_idx + + +def test_main_errors_last_20(tmp_path, monkeypatch, capsys): + log_file = tmp_path / "test.log" + lines = [] + for i in range(25): + lines.append(f"message=process level=ERROR run=run{i} error=Err{i} session.id=s{i}\n") + log_file.write_text("".join(lines)) + monkeypatch.setattr(obs, "LOG_PATH", log_file) + obs.main() + captured = capsys.readouterr() + out = captured.out + # Last 20 of 25 errors: indices 5..24 → Err5..Err24 + assert "Err24" in out + assert "Err5" in out + assert "Err4" not in out # not in last 20 + assert "Err0" not in out diff --git a/tests/test_pipeline_status.py b/tests/test_pipeline_status.py new file mode 100644 index 0000000..12bde8b --- /dev/null +++ b/tests/test_pipeline_status.py @@ -0,0 +1,1178 @@ +"""Tests for .opencode/scripts/pipeline-status.py — pipeline oracle. + +All gh/git calls are mocked via monkeypatch on the module's ``run_cmd`` +helper. Filesystem checks (handoff, ADR, memory) use tmp_path. + +``get_repo_full_name`` is cached via ``functools.cache`` and now called by +every ``gh pr view``/``gh pr list``/``gh issue view`` site (``--repo`` flag, +PR#101). The ``_clear_repo_cache`` autouse fixture clears the cache before +and after each test; ``mock_run_cmd`` automatically includes a ``git remote +get-url origin`` mock (``GIT_REMOTE_MOCK``) so ``get_repo_full_name()`` works +without extra boilerplate in every test. +""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +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) + +GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = ( + ("git", "remote"), + (0, "https://github.com/slaid098/opencode-config.git\n", ""), +) + + +@pytest.fixture(autouse=True) +def _clear_repo_cache(): + """Clear ``functools.cache`` on ``get_repo_full_name`` before/after each test. + + ``get_repo_full_name`` is now invoked by every ``gh pr view``/``gh pr list``/ + ``gh issue view`` call site (``--repo`` flag, PR#101). Without cache_clear, + a test that mocks ``git remote`` first would leak its cached value into the + next test. + """ + ps.get_repo_full_name.cache_clear() + yield + ps.get_repo_full_name.cache_clear() + + +def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]): + """Factory: mock run_cmd matching by command prefix. + + Keys are tuples of leading args (e.g. ("gh", "pr", "view")), + values are (returncode, stdout, stderr). + + Automatically includes a ``git remote get-url origin`` mock + (``GIT_REMOTE_MOCK``) so ``get_repo_full_name()`` works without extra + boilerplate in every test. + """ + merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **responses} + + def _mock(args: list[str]) -> tuple[int, str, str]: + for prefix, result in merged.items(): + if tuple(args[: len(prefix)]) == tuple(prefix): + return result + return (1, "", f"unmocked call: {args}") + + 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 + + +# ── parse_remote_url ──────────────────────────────────────────────────────── + + +def test_parse_remote_url_https(): + assert ps.parse_remote_url("https://github.com/slaid098/opencode-config.git") == ( + "github.com", + "slaid098", + "opencode-config", + ) + + +def test_parse_remote_url_https_no_git_suffix(): + assert ps.parse_remote_url("https://github.com/slaid098/opencode-config") == ( + "github.com", + "slaid098", + "opencode-config", + ) + + +def test_parse_remote_url_ssh(): + assert ps.parse_remote_url("git@github.com:slaid098/opencode-config.git") == ( + "github.com", + "slaid098", + "opencode-config", + ) + + +def test_parse_remote_url_ssh_no_git_suffix(): + assert ps.parse_remote_url("git@github.com:slaid098/opencode-config") == ( + "github.com", + "slaid098", + "opencode-config", + ) + + +def test_parse_remote_url_media_gen_https(): + """parse_remote_url для не-opencode репо (HTTPS).""" + assert ps.parse_remote_url("https://github.com/slaid098/media-gen.git") == ( + "github.com", + "slaid098", + "media-gen", + ) + + +def test_parse_remote_url_mediakit_ssh(): + """parse_remote_url для не-opencode репо (SSH, другой org).""" + assert ps.parse_remote_url("git@github.com:anomaly/mediakit.git") == ( + "github.com", + "anomaly", + "mediakit", + ) + + +def test_parse_remote_url_invalid(): + with pytest.raises(ValueError, match="Cannot parse remote URL"): + ps.parse_remote_url("not-a-valid-url") + + +# ── check_issue ────────────────────────────────────────────────────────────── + + +def test_check_issue_done(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): (0, '{"body": "Closes #45"}', ""), + ("gh", "issue", "view"): (0, "issue body", ""), + } + ), + ) + result = ps.check_issue(46) + assert result.status == ps.PhaseStatus.DONE + assert "#45" in result.detail + + +def test_check_issue_not_done_no_closes(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("gh", "pr", "view"): (0, '{"body": "no closure"}', "")}), + ) + result = ps.check_issue(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "Closes" in result.detail + + +def test_check_issue_not_done_pr_missing(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("gh", "pr", "view"): (1, "", "not found")}), + ) + result = ps.check_issue(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "не существует" in result.detail + + +def test_check_issue_ambiguous_multiple(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): (0, '{"body": "Closes #45 Fixes #47"}', ""), + ("gh", "issue", "view"): (0, "issue", ""), + } + ), + ) + result = ps.check_issue(46) + assert result.status == ps.PhaseStatus.AMBIGUOUS + assert "несколько issue" in result.detail + + +def test_check_issue_not_done_issue_missing(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): (0, '{"body": "Closes #45"}', ""), + ("gh", "issue", "view"): (1, "", "not found"), + } + ), + ) + result = ps.check_issue(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "не существует" in result.detail + + +# ── check_implement ────────────────────────────────────────────────────────── + + +def test_check_implement_done(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + '{"files": [{"path": "docs/handoff/pr-46-test.md"}]}', + "", + ), + } + ), + ) + result = ps.check_implement(46) + assert result.status == ps.PhaseStatus.DONE + assert "pr-46-test.md" in result.detail + + +def test_check_implement_not_done_no_handoff(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): (0, '{"files": [{"path": "src/main.py"}]}', ""), + } + ), + ) + result = ps.check_implement(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "handoff" in result.detail + + +def test_check_implement_not_done_pr_missing(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("gh", "pr", "view"): (1, "", "not found")}), + ) + result = ps.check_implement(46) + assert result.status == ps.PhaseStatus.NOT_DONE + + +# ── check_docs ─────────────────────────────────────────────────────────────── + + +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) + make_handoff(tmp_path, 46) + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + '{"comments": [{"body": "## Docs Review Summary\\nVerdict: APPROVE"}]}', + "", + ), + } + ), + ) + result = ps.check_docs(46) + assert result.status == ps.PhaseStatus.DONE + assert "docs-review отработал" in result.detail + + +def test_check_docs_not_done_no_handoff(tmp_path, monkeypatch): + monkeypatch.setattr(ps, "HANDOFF_DIR", 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") + 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") + 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) + make_handoff(tmp_path, 46, extra="Архитектурное изменение, см. ADR-002.") + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + '{"comments": [{"body": "## Docs Review Summary\\nVerdict: APPROVE"}]}', + "", + ), + } + ), + ) + 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) + make_handoff(tmp_path, 46) + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("gh", "pr", "view"): (0, '{"comments": []}', "")}), + ) + result = ps.check_docs(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "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) + 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) + 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) + 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) + make_handoff(tmp_path, 46) + comment_body = ( + "## Docs Review Summary\\n- Handoff: fixed: added Pending\\n\\n### Verdict: FIXED" + ) + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + f'{{"comments": [{{"body": "{comment_body}"}}]}}', + "", + ), + } + ), + ) + result = ps.check_docs(46) + assert result.status == ps.PhaseStatus.DONE + assert "docs-review отработал" in result.detail + + +# ── check_review ───────────────────────────────────────────────────────────── + + +def test_check_review_done(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + '{"comments": [{"body": "## Code Review Summary\\n\\n### Verdict: APPROVE"}]}', + "", + ), + } + ), + ) + result = ps.check_review(46) + assert result.status == ps.PhaseStatus.DONE + assert "APPROVE" in result.detail + + +def test_check_review_not_done(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("gh", "pr", "view"): (0, '{"comments": [{"body": "changes needed"}]}', "")}), + ) + result = ps.check_review(46) + assert result.status == ps.PhaseStatus.NOT_DONE + + +def test_check_review_not_done_error(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("gh", "pr", "view"): (1, "", "error")}), + ) + result = ps.check_review(46) + assert result.status == ps.PhaseStatus.NOT_DONE + + +def test_check_review_false_positive_docs_reviewer_comment(monkeypatch): + """docs-reviewer comment with '### Verdict: APPROVE' must NOT trigger check_review DONE.""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + '{"comments": [{"body": "## Docs Review Summary\\n\\n### Verdict: APPROVE"}]}', + "", + ), + } + ), + ) + result = ps.check_review(46) + assert result.status == ps.PhaseStatus.NOT_DONE + + +def test_check_review_done_code_review_summary(monkeypatch): + """Reviewer comment with '## Code Review Summary' + '### Verdict: APPROVE' -> DONE.""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + '{"comments": [{"body": "## Code Review Summary\\n\\n### Verdict: APPROVE"}]}', + "", + ), + } + ), + ) + result = ps.check_review(46) + assert result.status == ps.PhaseStatus.DONE + + +def test_check_review_not_done_approve_in_metadata(monkeypatch): + """'approv' in author login (not body) must NOT trigger check_review.""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + '{"comments": [{"author": {"login": "approver-bot"}, "body": "LGTM"}]}', + "", + ), + } + ), + ) + result = ps.check_review(46) + assert result.status == ps.PhaseStatus.NOT_DONE + + +def test_check_review_stale_approve_then_request_changes(monkeypatch): + """Old APPROVE + new REQUEST_CHANGES -> NOT_DONE (only latest verdict counts).""" + body1 = "## Code Review Summary\\n\\n### Verdict: APPROVE" + body2 = "## Code Review Summary\\n\\n### Verdict: REQUEST_CHANGES" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + f'{{"comments": [{{"body": "{body1}"}}, {{"body": "{body2}"}}]}}', + "", + ), + } + ), + ) + result = ps.check_review(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "REQUEST_CHANGES" in result.detail + + +def test_check_review_request_changes(monkeypatch): + """Reviewer '### Verdict: REQUEST_CHANGES' -> NOT_DONE.""" + body = "## Code Review Summary\\n\\n### Verdict: REQUEST_CHANGES" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + f'{{"comments": [{{"body": "{body}"}}]}}', + "", + ), + } + ), + ) + result = ps.check_review(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "REQUEST_CHANGES" in result.detail + + +# ── check_merge ────────────────────────────────────────────────────────────── + + +def test_check_merge_done(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("gh", "pr", "view"): (0, '{"state": "MERGED"}', "")}), + ) + result = ps.check_merge(46) + assert result.status == ps.PhaseStatus.DONE + assert "merged" in result.detail + + +def test_check_merge_not_done_open(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("gh", "pr", "view"): (0, '{"state": "OPEN"}', "")}), + ) + result = ps.check_merge(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "OPEN" in result.detail + + +def test_check_merge_not_done_pr_missing(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("gh", "pr", "view"): (1, "", "not found")}), + ) + result = ps.check_merge(46) + assert result.status == ps.PhaseStatus.NOT_DONE + + +# ── check_memory ───────────────────────────────────────────────────────────── + + +def test_check_memory_done(tmp_path, monkeypatch): + memory_file = tmp_path / "opencode.md" + memory_file.write_text("- [2026-07-19, PR#46] test entry\n") + monkeypatch.setattr(ps, "get_memory_file_path", lambda: memory_file) + result = ps.check_memory(46) + assert result.status == ps.PhaseStatus.DONE + assert "PR#46" in result.detail + + +def test_check_memory_not_done_no_file(tmp_path, monkeypatch): + memory_file = tmp_path / "opencode.md" + monkeypatch.setattr(ps, "get_memory_file_path", lambda: memory_file) + result = ps.check_memory(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "не существует" in result.detail + + +def test_check_memory_not_done_no_pattern(tmp_path, monkeypatch): + memory_file = tmp_path / "opencode.md" + memory_file.write_text("- some other entry\n") + monkeypatch.setattr(ps, "get_memory_file_path", lambda: memory_file) + result = ps.check_memory(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "не найден" in result.detail + + +def test_check_memory_not_done_remote_error(monkeypatch): + monkeypatch.setattr( + ps, + "get_memory_file_path", + lambda: (_ for _ in ()).throw(RuntimeError("remote error")), + ) + result = ps.check_memory(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "remote error" in result.detail + + +# ── get_memory_file_path ───────────────────────────────────────────────────── + + +def test_get_memory_file_path(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("git", "remote"): (0, "https://github.com/slaid098/opencode-config.git\n", ""), + } + ), + ) + path = ps.get_memory_file_path() + assert path == ps.MEMORY_DIR / "github.com" / "slaid098" / "opencode-config.md" + + +def test_get_memory_file_path_ssh(monkeypatch): + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("git", "remote"): (0, "git@github.com:slaid098/opencode-config.git\n", "")}), + ) + path = ps.get_memory_file_path() + assert path.name == "opencode-config.md" + assert "github.com" in str(path) + assert "slaid098" in str(path) + + +# ── get_repo_full_name ─────────────────────────────────────────────────────── + + +def test_get_repo_full_name_current_repo(monkeypatch): + """get_repo_full_name возвращает org/repo из git remote (current repo).""" + ps.get_repo_full_name.cache_clear() + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + {("git", "remote"): (0, "https://github.com/slaid098/opencode-config.git\n", "")} + ), + ) + assert ps.get_repo_full_name() == "slaid098/opencode-config" + ps.get_repo_full_name.cache_clear() + + +def test_get_repo_full_name_media_gen(monkeypatch): + """get_repo_full_name для не-opencode репо (HTTPS).""" + ps.get_repo_full_name.cache_clear() + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("git", "remote"): (0, "https://github.com/slaid098/media-gen.git\n", "")}), + ) + assert ps.get_repo_full_name() == "slaid098/media-gen" + ps.get_repo_full_name.cache_clear() + + +def test_get_repo_full_name_mediakit_ssh(monkeypatch): + """get_repo_full_name для не-opencode репо (SSH, другой org).""" + ps.get_repo_full_name.cache_clear() + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("git", "remote"): (0, "git@github.com:anomaly/mediakit.git\n", "")}), + ) + assert ps.get_repo_full_name() == "anomaly/mediakit" + ps.get_repo_full_name.cache_clear() + + +def test_get_repo_full_name_cached(monkeypatch): + """functools.cache: second call does not hit run_cmd again.""" + ps.get_repo_full_name.cache_clear() + call_count = [0] + + def _counting_mock(args: list[str]) -> tuple[int, str, str]: + if tuple(args[:2]) == ("git", "remote"): + call_count[0] += 1 + return (0, "https://github.com/slaid098/opencode-config.git\n", "") + return (1, "", f"unmocked: {args}") + + monkeypatch.setattr(ps, "run_cmd", _counting_mock) + assert ps.get_repo_full_name() == "slaid098/opencode-config" + assert ps.get_repo_full_name() == "slaid098/opencode-config" + assert call_count[0] == 1 + ps.get_repo_full_name.cache_clear() + + +def test_get_repo_full_name_remote_error(monkeypatch): + """git remote fails → RuntimeError with explicit message.""" + ps.get_repo_full_name.cache_clear() + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd({("git", "remote"): (1, "", "not a git repository")}), + ) + with pytest.raises(RuntimeError, match="Cannot get git remote URL"): + ps.get_repo_full_name() + ps.get_repo_full_name.cache_clear() + + +# ── MEMORY_DIR env var ────────────────────────────────────────────────────── + + +@pytest.fixture +def reload_ps_after_test(): + """Re-execute the ps module after test to restore module-level constants. + + Uses spec_from_file_location + exec_module (same as initial load) instead of + importlib.reload — reload tries _find_spec via sys.path, which doesn't know + about pipeline_status (loaded from a file path, not on sys.path). + + Must be listed BEFORE monkeypatch in the test signature so monkeypatch + finalizes first (env var reverted), then this fixture re-execs ps with + the reverted env. + """ + yield + _reload_ps() + + +def _reload_ps() -> None: + """Re-execute the pipeline_status module from disk with current env.""" + spec = importlib.util.spec_from_file_location("pipeline_status", SCRIPT_PATH) + spec.loader.exec_module(ps) + + +def test_memory_dir_uses_env_var(reload_ps_after_test, monkeypatch): + """OPENCODE_MEMORY_DIR env var overrides default path.""" + monkeypatch.setenv("OPENCODE_MEMORY_DIR", "/custom/memory") + _reload_ps() + assert Path("/custom/memory") / "repos" == ps.MEMORY_DIR + + +def test_memory_dir_fallback_no_env_var(reload_ps_after_test, monkeypatch): + """Without OPENCODE_MEMORY_DIR, fallback to REPO_ROOT/app_data/opencode-memory.""" + monkeypatch.delenv("OPENCODE_MEMORY_DIR", raising=False) + _reload_ps() + expected = ps.REPO_ROOT / "app_data" / "opencode-memory" / "repos" + assert expected == ps.MEMORY_DIR + + +# ── format_single_pr (integration) ─────────────────────────────────────────── + + +def test_format_single_pr_complete(monkeypatch): + monkeypatch.setattr( + ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test PR"}', "")}) + ) + 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, "CI green"), + ps.PhaseResult(ps.PhaseStatus.DONE, "APPROVE"), + ps.PhaseResult(ps.PhaseStatus.DONE, "merged"), + ps.PhaseResult(ps.PhaseStatus.DONE, "PR#46"), + ] + output = ps.format_single_pr(46, results) + assert "Status: COMPLETE" in output + assert "PR #46" in output + + +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, "CI green"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "APPROVE не найден"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "PR#46 не найден"), + ] + monkeypatch.setattr( + ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")}) + ) + output = ps.format_single_pr(46, results) + assert "NEXT: запустить reviewer" in output + assert "❌" in output + + +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, "CI green"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: REQUEST_CHANGES"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "PR#46 не найден"), + ] + monkeypatch.setattr( + ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")}) + ) + output = ps.format_single_pr(46, results) + assert "NEXT: запусти fix subagent" in output + assert "re-loop" in output + assert "запустить reviewer" not in output.split("NEXT:")[1] + + +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, "CI green"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: NEEDS_DISCUSSION"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "PR#46 не найден"), + ] + monkeypatch.setattr( + ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")}) + ) + output = ps.format_single_pr(46, results) + assert "NEXT: уточни вопросы" in output + assert "запустить reviewer" not in output.split("NEXT:")[1] + + +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, "CI green"), + ps.PhaseResult(ps.PhaseStatus.DONE, "APPROVE"), + ps.PhaseResult(ps.PhaseStatus.DONE, "merged"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "PR#46 не найден в memory"), + ] + monkeypatch.setattr( + ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")}) + ) + output = ps.format_single_pr(46, results) + assert "NEXT: запустить memory-syncer" in output + + +def test_format_single_pr_ambiguous(monkeypatch): + results = [ + ps.PhaseResult(ps.PhaseStatus.AMBIGUOUS, "несколько issue"), + ] + [ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "")] * 6 + monkeypatch.setattr( + ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")}) + ) + output = ps.format_single_pr(46, results) + assert "AMBIGUOUS" in output + + +# ── format_table (no-args mode) ────────────────────────────────────────────── + + +def test_format_table_empty(monkeypatch): + monkeypatch.setattr(ps, "list_open_pr_numbers", lambda: []) + output = ps.format_table([]) + assert "Нет открытых PR" in output + + +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.NOT_DONE, "ci"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "review"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "merge"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "memory"), + ] + monkeypatch.setattr(ps, "run_all_checks", lambda n: results) + monkeypatch.setattr(ps, "get_pr_title", lambda n: "test PR title") + output = ps.format_table([47]) + assert "PR#47" in output + assert "NEXT: запустить docs-reviewer (режим pre-merge)" 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, "CI green"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: REQUEST_CHANGES"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"), + ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "PR#46 не найден"), + ] + monkeypatch.setattr(ps, "run_all_checks", lambda n: results) + monkeypatch.setattr(ps, "get_pr_title", lambda n: "test") + output = ps.format_pr_row(47) + assert "NEXT: запусти fix subagent" in output + assert "запустить reviewer" not in output.split("NEXT:")[1] + + +# ── get_next_action ────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("phase", "expected"), + [ + ("ISSUE", "создать issue и связать через Closes #46 в body PR"), + ("IMPLEMENT", "добавить handoff docs/handoff/pr-46-slug.md в diff"), + ("DOCS", "запустить docs-reviewer (режим pre-merge)"), + ("CI", "проверь статус CI вручную (gh run view)"), + ("REVIEW", "запустить reviewer (task subagent_type=reviewer)"), + ("MERGE", "смержить PR (gh pr merge 46 --squash --delete-branch)"), + ("MEMORY", "запустить memory-syncer"), + ], +) +def test_get_next_action(phase, expected): + assert ps.get_next_action(phase, 46) == expected + + +@pytest.mark.parametrize( + ("detail", "expected_substring"), + [ + ("последний verdict reviewer'а: REQUEST_CHANGES", "запусти fix subagent"), + ("последний verdict reviewer'а: NEEDS_DISCUSSION", "уточни вопросы с автором"), + ("Code Review Summary не найден в комментариях", "запустить reviewer"), + ("APPROVE не найден", "запустить reviewer"), + ("нет комментариев PR", "запустить reviewer"), + ], +) +def test_get_next_action_review(detail, expected_substring): + result = ps.PhaseResult(ps.PhaseStatus.NOT_DONE, detail) + assert expected_substring in ps.get_next_action_review(result) + + +# ── find_current_phase ─────────────────────────────────────────────────────── + + +def test_find_current_phase_all_done(): + results = [ps.PhaseResult(ps.PhaseStatus.DONE, "")] * 7 + assert ps.find_current_phase(results) is None + + +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 + assert ps.find_current_phase(results) == 1 + + +def test_find_current_phase_skips_ambiguous(): + results = [ps.PhaseResult(ps.PhaseStatus.DONE, "")] * 6 + [ + ps.PhaseResult(ps.PhaseStatus.AMBIGUOUS, ""), + ] + assert ps.find_current_phase(results) == 6 + + +# ── main ───────────────────────────────────────────────────────────────────── + + +def test_main_pr_not_found(monkeypatch, capsys): + monkeypatch.setattr(ps, "check_gh_auth", lambda: None) + monkeypatch.setattr(ps, "pr_exists", lambda n: False) + monkeypatch.setattr("sys.argv", ["pipeline-status.py", "999"]) + with pytest.raises(SystemExit) as exc_info: + ps.main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "не существует" in captured.err + + +def test_main_invalid_pr_number(monkeypatch, capsys): + monkeypatch.setattr(ps, "check_gh_auth", lambda: None) + monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({})) + monkeypatch.setattr("sys.argv", ["pipeline-status.py", "abc"]) + with pytest.raises(SystemExit) as exc_info: + ps.main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "Некорректный" in captured.err + + +def test_main_gh_not_authenticated(monkeypatch, capsys): + monkeypatch.setattr(ps, "check_gh_auth", lambda: "gh CLI не авторизован") + with pytest.raises(SystemExit) as exc_info: + ps.main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "не авторизован" in captured.err + + +# ── _resolve_repo_root ──────────────────────────────────────────────────────── + + +def test_resolve_repo_root_via_git(monkeypatch, tmp_path): + """When git rev-parse succeeds, use its output as repo root (cwd-aware).""" + fake_root = tmp_path / "some-repo" + fake_root.mkdir() + + class _FakeResult: + returncode = 0 + stdout = f"{fake_root}\n" + stderr = "" + + def fake_run(args, **kwargs): + if tuple(args[:3]) == ("git", "rev-parse", "--show-toplevel"): + return _FakeResult() + raise AssertionError(f"unmocked: {args}") + + monkeypatch.setattr(ps.subprocess, "run", fake_run) + root = ps._resolve_repo_root() + assert root == fake_root.resolve() + + +def test_resolve_repo_root_fallback_to_file(monkeypatch): + """When git rev-parse fails, fallback to Path(__file__).parent.parent.parent.""" + + class _FakeResult: + returncode = 1 + stdout = "" + stderr = "not a git repo" + + def fake_run(args, **kwargs): + if tuple(args[:3]) == ("git", "rev-parse", "--show-toplevel"): + return _FakeResult() + raise AssertionError(f"unmocked: {args}") + + monkeypatch.setattr(ps.subprocess, "run", fake_run) + root = ps._resolve_repo_root() + expected = Path(ps.__file__).resolve().parent.parent.parent + assert root == expected + + +# ── --repo flag on gh calls (PR#101) ────────────────────────────────────────── + + +def test_pr_exists_with_explicit_repo(monkeypatch): + """pr_exists passes --repo flag, so it works from non-git cwd. + + PR#101: every ``gh pr view``/``gh pr list``/``gh issue view`` call site + appends ``--repo {get_repo_full_name()}`` (defense in depth — even if + the TS tool wrapper fails to pass ``cwd``, the script still resolves + the repo explicitly). This test captures the argv passed to ``run_cmd`` + and verifies ``--repo /`` is present with a slash in the value. + """ + calls: list[list[str]] = [] + + def _capture(args: list[str]) -> tuple[int, str, str]: + calls.append(args) + return (0, '{"number": 100}', "") + + monkeypatch.setattr(ps, "run_cmd", _capture) + ps.get_repo_full_name.cache_clear() + monkeypatch.setattr( + ps, + "run_cmd", + lambda a: ( + (0, "https://github.com/slaid098/opencode-config.git\n", "") + if tuple(a[:2]) == ("git", "remote") + else _capture(a) + ), + ) + ps.pr_exists(100) + assert "--repo" in calls[0] + repo_idx = calls[0].index("--repo") + assert "/" in calls[0][repo_idx + 1], ( + f"expected org/repo format after --repo, got: {calls[0][repo_idx + 1]!r}" + ) + + +def test_check_issue_uses_repo_flag(monkeypatch): + """check_issue passes --repo to gh pr view + gh issue view, works from any cwd. + + PR#101: ``check_issue`` issues two gh calls (``gh pr view --json body`` + for the closure pattern + ``gh issue view`` for issue existence). Both + must carry ``--repo {get_repo_full_name()}`` so they work when the + process cwd is not a git repo. + """ + calls: list[list[str]] = [] + + def _capture(args: list[str]) -> tuple[int, str, str]: + calls.append(args) + if tuple(args[:3]) == ("gh", "pr", "view"): + return (0, '{"body": "Closes #45"}', "") + if tuple(args[:3]) == ("gh", "issue", "view"): + return (0, "issue body", "") + return (1, "", f"unmocked: {args}") + + monkeypatch.setattr(ps, "run_cmd", _capture) + ps.get_repo_full_name.cache_clear() + monkeypatch.setattr( + ps, + "run_cmd", + lambda a: ( + (0, "https://github.com/slaid098/opencode-config.git\n", "") + if tuple(a[:2]) == ("git", "remote") + else _capture(a) + ), + ) + result = ps.check_issue(46) + assert result.status == ps.PhaseStatus.DONE + # Two gh calls captured (pr view + issue view), each with --repo. + gh_calls = [c for c in calls if c[0] == "gh"] + assert len(gh_calls) == 2, f"expected 2 gh calls, got {len(gh_calls)}: {gh_calls}" + for call in gh_calls: + assert "--repo" in call, f"missing --repo in gh call: {call}" + repo_idx = call.index("--repo") + assert "/" in call[repo_idx + 1], ( + f"expected org/repo format after --repo, got: {call[repo_idx + 1]!r}" + ) diff --git a/tests/test_pipeline_status_adr.py b/tests/test_pipeline_status_adr.py new file mode 100644 index 0000000..d100630 --- /dev/null +++ b/tests/test_pipeline_status_adr.py @@ -0,0 +1,53 @@ +"""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_ci.py b/tests/test_pipeline_status_ci.py new file mode 100644 index 0000000..26a516e --- /dev/null +++ b/tests/test_pipeline_status_ci.py @@ -0,0 +1,501 @@ +"""Tests for ``check_ci`` in .opencode/scripts/pipeline-status.py — CI phase. + +Covers Actions API gate via ``gh api repos///actions/runs``. +All gh/git calls are mocked via monkeypatch on the module's ``run_cmd`` +helper. ``time.sleep`` is mocked to no-op via autouse fixture (polling loop +would otherwise hang tests for up to 5 minutes). + +Mocking strategy: ``check_ci`` issues calls in sequence: +1. ``gh pr view N --json headRefName`` → returns branch name JSON (once) +2. ``git remote get-url origin`` → returns remote URL (for ``get_repo_full_name``) +3. ``gh api repos///actions/runs --jq `` → returns CI run + JSON; may be called multiple times (polling loop / no-runs retries). + +``mock_run_cmd`` dispatches by command prefix (single response per prefix). +``mock_run_cmd_seq`` supports a sequence of responses for the ``gh api`` +prefix (consumed in order; last repeats if exhausted) — for polling tests. + +``get_repo_full_name`` is cached via ``functools.cache`` — cleared before +each test via the ``_clear_repo_cache`` autouse fixture. +""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +SCRIPT_PATH = ( + Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "pipeline-status.py" +) +spec = importlib.util.spec_from_file_location("pipeline_status_ci", SCRIPT_PATH) +ps = importlib.util.module_from_spec(spec) +sys.modules["pipeline_status_ci"] = ps +spec.loader.exec_module(ps) + +GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = ( + ("git", "remote"), + (0, "https://github.com/slaid098/opencode-config.git\n", ""), +) + + +@pytest.fixture(autouse=True) +def _clear_repo_cache(): + """Clear ``functools.cache`` on ``get_repo_full_name`` + mock ``time.sleep``. + + ``time.sleep`` must be mocked — polling loop sleeps ``CI_POLL_INTERVAL`` + between queries, up to ``CI_WAIT_TIMEOUT`` (default 300s). Without mock, + tests polling until timeout would hang for minutes. + """ + ps.get_repo_full_name.cache_clear() + sleep_calls: list[float] = [] + monkey = pytest.MonkeyPatch() + + def _record_sleep(seconds: float) -> None: + sleep_calls.append(seconds) + + monkey.setattr(ps.time, "sleep", _record_sleep) + ps._test_sleep_calls = sleep_calls # type: ignore[attr-defined] + yield + monkey.undo() + ps.get_repo_full_name.cache_clear() + del ps._test_sleep_calls # type: ignore[attr-defined] + + +def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]): + """Factory: mock run_cmd matching by command prefix (single response). + + Keys are tuples of leading args (e.g. ("gh", "pr", "view")), + values are (returncode, stdout, stderr). + + Automatically includes a ``git remote get-url origin`` mock + (``GIT_REMOTE_MOCK``) so ``get_repo_full_name()`` works without extra + boilerplate in every test. + """ + merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **responses} + + def _mock(args: list[str]) -> tuple[int, str, str]: + for prefix, result in merged.items(): + if tuple(args[: len(prefix)]) == tuple(prefix): + return result + return (1, "", f"unmocked call: {args}") + + return _mock + + +def mock_run_cmd_seq( + pr_view: tuple[int, str, str] = (0, '{"headRefName": "feat/test-branch"}', ""), + api_responses: list[tuple[int, str, str]] | None = None, + extra: dict[tuple, tuple[int, str, str]] | None = None, +): + """Factory: mock run_cmd with sequence of responses for ``gh api``. + + ``gh pr view`` returns ``pr_view`` for every call (single response). + ``gh api`` returns ``api_responses[i]`` on the i-th call; if exhausted, + repeats the last response (so polling loops don't run out of responses + and hit the "unmocked" fallback). ``extra`` adds fixed overrides for + other prefixes (e.g. ``git remote``). + """ + api_responses = api_responses or [] + api_idx = [0] + merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **(extra or {})} + + def _mock(args: list[str]) -> tuple[int, str, str]: + for prefix, result in merged.items(): + if tuple(args[: len(prefix)]) == tuple(prefix): + return result + if tuple(args[:3]) == ("gh", "pr", "view"): + return pr_view + if tuple(args[:2]) == ("gh", "api"): + if not api_responses: + return (1, "", "no api_responses configured") + idx = min(api_idx[0], len(api_responses) - 1) + api_idx[0] += 1 + return api_responses[idx] + return (1, "", f"unmocked call: {args}") + + return _mock + + +# ── check_ci — completed (no polling) ────────────────────────────────────────── + + +def test_ci_success(monkeypatch): + """CI run completed & success → DONE, 0 sleeps.""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + '{"headRefName": "feat/test-branch"}', + "", + ), + ("gh", "api"): ( + 0, + '{"status": "completed", "conclusion": "success"}', + "", + ), + } + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.DONE + assert "CI green" in result.detail + assert ps._test_sleep_calls == [] # type: ignore[attr-defined] + + +def test_ci_failure(monkeypatch): + """CI run completed but conclusion=failure → NOT_DONE, 0 sleeps (don't wait for fail).""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + '{"headRefName": "feat/test-branch"}', + "", + ), + ("gh", "api"): ( + 0, + '{"status": "completed", "conclusion": "failure"}', + "", + ), + } + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "failure" in result.detail + assert ps._test_sleep_calls == [] # type: ignore[attr-defined] + + +# ── check_ci — polling loop ──────────────────────────────────────────────────── + + +def test_ci_wait_then_success(monkeypatch): + """in_progress → in_progress → completed+success → DONE, sleep called 2 times.""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd_seq( + api_responses=[ + (0, '{"status": "in_progress", "conclusion": null}', ""), + (0, '{"status": "in_progress", "conclusion": null}', ""), + (0, '{"status": "completed", "conclusion": "success"}', ""), + ], + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.DONE + assert "CI green" in result.detail + assert len(ps._test_sleep_calls) == 2 # type: ignore[attr-defined] + + +def test_ci_wait_timeout(monkeypatch): + """All calls in_progress, timeout 1s via env → AMBIGUOUS with "после 1s".""" + monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd_seq( + api_responses=[ + (0, '{"status": "in_progress", "conclusion": null}', ""), + ], + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.AMBIGUOUS + assert "после 1s" in result.detail + assert ps._test_sleep_calls == [] # type: ignore[attr-defined] + + +def test_ci_wait_poll_interval(monkeypatch): + """Poll interval is used as sleep argument — capture via mock.""" + monkeypatch.setenv("OPENCODE_CI_POLL_INTERVAL", "7") + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd_seq( + api_responses=[ + (0, '{"status": "in_progress", "conclusion": null}', ""), + (0, '{"status": "completed", "conclusion": "success"}', ""), + ], + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.DONE + assert ps._test_sleep_calls == [7] # type: ignore[attr-defined] + + +def test_ci_in_progress(monkeypatch): + """status=in_progress, all polls in_progress, timeout 1s → AMBIGUOUS.""" + monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd_seq( + api_responses=[ + (0, '{"status": "in_progress", "conclusion": null}', ""), + ], + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.AMBIGUOUS + assert "после 1s" in result.detail + + +def test_ci_queued(monkeypatch): + """status=queued, all polls in_progress, timeout 1s → AMBIGUOUS.""" + monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd_seq( + api_responses=[ + (0, '{"status": "queued", "conclusion": null}', ""), + ], + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.AMBIGUOUS + assert "после 1s" in result.detail + + +# ── check_ci — no runs (short retry) ─────────────────────────────────────────── + + +def test_ci_no_runs_retry_then_appears(monkeypatch): + """1st call null, 2nd null, 3rd success → DONE (after CI_NO_RUNS_RETRY retries).""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd_seq( + api_responses=[ + (0, "null", ""), + (0, "null", ""), + (0, '{"status": "completed", "conclusion": "success"}', ""), + ], + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.DONE + assert "CI green" in result.detail + + +def test_ci_no_runs_retry_exhausted(monkeypatch): + """All calls null, CI_NO_RUNS_RETRY=3 → AMBIGUOUS.""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd_seq( + api_responses=[ + (0, "null", ""), + ], + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.AMBIGUOUS + assert "нет CI run" in result.detail + + +def test_ci_no_runs(monkeypatch): + """No CI run (jq null) with single response → AMBIGUOUS (retry exhausted).""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + '{"headRefName": "feat/test-branch"}', + "", + ), + ("gh", "api"): (0, "null", ""), + } + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.AMBIGUOUS + assert "нет CI run" in result.detail + + +# ── check_ci — API error (no retry) ──────────────────────────────────────────── + + +def test_ci_api_error(monkeypatch): + """Actions API returns rc=1 (403) → AMBIGUOUS, no retries/sleeps.""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "pr", "view"): ( + 0, + '{"headRefName": "feat/test-branch"}', + "", + ), + ("gh", "api"): ( + 1, + "", + "HTTP 403: Forbidden", + ), + } + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.AMBIGUOUS + assert "Actions API" in result.detail + assert ps._test_sleep_calls == [] # type: ignore[attr-defined] + + +def test_ci_api_error_no_retry(monkeypatch): + """1st call rc=1 (403) → сразу AMBIGUOUS, 0 sleeps/retries.""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd_seq( + api_responses=[ + (1, "", "HTTP 403: Forbidden"), + (0, '{"status": "completed", "conclusion": "success"}', ""), + ], + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.AMBIGUOUS + assert "Actions API" in result.detail + assert ps._test_sleep_calls == [] # type: ignore[attr-defined] + + +# ── check_ci — config priority (CLI > env > constant) ────────────────────────── + + +def test_ci_wait_env_var_override(monkeypatch): + """OPENCODE_CI_WAIT_TIMEOUT=1 → timeout 1s instead of default 300s.""" + monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd_seq( + api_responses=[ + (0, '{"status": "in_progress", "conclusion": null}', ""), + ], + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.AMBIGUOUS + assert "после 1s" in result.detail + + +def test_ci_wait_cli_flag_overrides_env(monkeypatch): + """CLI flag --ci-wait-timeout 2 + env OPENCODE_CI_WAIT_TIMEOUT=1 → timeout 2s.""" + monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") + + def _fixed_config(argv=None): + return ps.CiPollConfig(wait_timeout=2, poll_interval=ps.CI_POLL_INTERVAL) + + monkeypatch.setattr(ps, "_load_ci_config", _fixed_config) + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd_seq( + api_responses=[ + (0, '{"status": "in_progress", "conclusion": null}', ""), + ], + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.AMBIGUOUS + assert "после 2s" in result.detail + + +def test_ci_wait_failure_no_wait(monkeypatch): + """1st call completed+failure → сразу NOT_DONE, 0 sleeps (don't wait for fail).""" + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd_seq( + api_responses=[ + (0, '{"status": "completed", "conclusion": "failure"}', ""), + ], + ), + ) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.NOT_DONE + assert "failure" in result.detail + assert ps._test_sleep_calls == [] # type: ignore[attr-defined] + + +# ── check_ci — dynamic repo full name ────────────────────────────────────────── + + +def test_ci_uses_dynamic_repo_full_name(monkeypatch): + """``get_repo_full_name`` is derived from ``git remote``, not hardcoded. + + Mocks a non-opencode remote (``slaid098/media-gen``) and asserts that the + ``gh api`` call uses ``repos/slaid098/media-gen/actions/runs`` (dynamic), + not a hardcoded ``slaid098/opencode-config``. + """ + captured_args: list[list[str]] = [] + + def _capture_mock(args: list[str]) -> tuple[int, str, str]: + captured_args.append(args) + if tuple(args[:3]) == ("git", "remote", "get-url"): + return (0, "https://github.com/slaid098/media-gen.git\n", "") + if tuple(args[:3]) == ("gh", "pr", "view"): + return (0, '{"headRefName": "feat/test-branch"}', "") + if tuple(args[:2]) == ("gh", "api"): + return (0, '{"status": "completed", "conclusion": "success"}', "") + return (1, "", f"unmocked call: {args}") + + monkeypatch.setattr(ps, "run_cmd", _capture_mock) + result = ps.check_ci(46) + assert result.status == ps.PhaseStatus.DONE + + api_calls = [a for a in captured_args if a[:2] == ["gh", "api"]] + assert len(api_calls) == 1 + assert "repos/slaid098/media-gen/actions/runs" in " ".join(api_calls[0]) + assert "slaid098/opencode-config" not in " ".join(api_calls[0]) + + +# ── _load_ci_config — priority ──────────────────────────────────────────────── + + +def test_load_ci_config_defaults(monkeypatch): + """No env, no CLI → constants.""" + monkeypatch.delenv("OPENCODE_CI_WAIT_TIMEOUT", raising=False) + monkeypatch.delenv("OPENCODE_CI_POLL_INTERVAL", raising=False) + cfg = ps._load_ci_config(argv=[]) + assert cfg.wait_timeout == ps.CI_WAIT_TIMEOUT + assert cfg.poll_interval == ps.CI_POLL_INTERVAL + + +def test_load_ci_config_env_override(monkeypatch): + """Env vars override constants.""" + monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "42") + monkeypatch.setenv("OPENCODE_CI_POLL_INTERVAL", "7") + cfg = ps._load_ci_config(argv=[]) + assert cfg.wait_timeout == 42 + assert cfg.poll_interval == 7 + + +def test_load_ci_config_cli_overrides_env(monkeypatch): + """CLI flags override env vars.""" + monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") + monkeypatch.setenv("OPENCODE_CI_POLL_INTERVAL", "1") + cfg = ps._load_ci_config(argv=["--ci-wait-timeout", "2", "--ci-poll-interval", "3"]) + assert cfg.wait_timeout == 2 + assert cfg.poll_interval == 3 + + +def test_load_ci_config_env_invalid_falls_back(monkeypatch): + """Invalid env var value → fall back to constant.""" + monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "not-a-number") + cfg = ps._load_ci_config(argv=[]) + assert cfg.wait_timeout == ps.CI_WAIT_TIMEOUT + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_pipeline_status_tool.py b/tests/test_pipeline_status_tool.py new file mode 100644 index 0000000..daaabfa --- /dev/null +++ b/tests/test_pipeline_status_tool.py @@ -0,0 +1,211 @@ +"""Tests for .opencode/tools/pipeline-status.ts — the pipeline_status custom tool. + +Covers the spawnSync-based implementation that replaced the original +``Bun.$`` spawn (issue #99 / PR-?). + +The TS tool file is Bun-runtime code (TypeScript + ``import.meta.dir``) +and there is no bun/tsx/esbuild on the CI runner — only node + pytest. We +exercise the tool's ``execute()`` function via a tiny CommonJS loader +(``tests/_ts_loader.mjs``) which: +- strips TS-only import type annotations, +- stubs ``@opencode-ai/plugin``'s ``tool()`` (identity) + ``tool.schema`` + (chainable zod shim), +- replaces ``import.meta.dir`` with the real ``.opencode/tools`` directory, +- exposes the ``execute()`` function via a JSON-stdout protocol. + +Three modes are used by these tests: +- ``load`` — sanity-check that the tool loads and has ``pr_number`` arg. +- ``exec_stub`` — call execute with a stubbed spawnSync to verify: + (a) args passed correctly, + (b) stdout is trimmed on success, + (c) non-zero exit returns an actionable error message. +- ``exec_real`` — call execute against the real pipeline-status.py + (integration test, PR #23 is a known-good reference). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs" +TS_FILE = REPO_ROOT / ".opencode" / "tools" / "pipeline-status.ts" + + +def _gh_available() -> bool: + """True if `gh auth status` succeeds (local dev machine, not CI runner).""" + try: + return ( + subprocess.run( + ["gh", "auth", "status"], + capture_output=True, + check=False, + ).returncode + == 0 + ) + except FileNotFoundError: + return False + + +# Integration tests below hit the real pipeline-status.py which makes gh/git +# calls to GitHub. The CI runner has no gh auth — pipeline-status.py returns +# non-zero exit and our tool surfaces the error string. Skip those tests in +# that environment; the unit tests cover the same code paths. +_GH_OK = _gh_available() +_SKIP_REASON = "gh CLI not authenticated — skip real pipeline-status.py call" + + +def _run_loader(*args: str, stdin: str | None = None) -> dict: + """Invoke the loader and parse its JSON stdout.""" + proc = subprocess.run( + ["node", str(LOADER), *args], + capture_output=True, + text=True, + check=False, + cwd=str(REPO_ROOT), + input=stdin, + timeout=60, + ) + if proc.returncode != 0: + raise RuntimeError( + f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n" + f"stdout: {proc.stdout}\nstderr: {proc.stderr}" + ) + return json.loads(proc.stdout) + + +def test_loader_can_load_tool(): + """Sanity: the TS tool loads and has the pr_number argument.""" + if not TS_FILE.exists(): + pytest.skip("pipeline-status.ts not present") + out = _run_loader("load") + assert "description" in out + assert "pr_number" in out["args"] + + +def test_execute_passes_correct_args(): + """execute calls spawnSync with ["python3",