diff --git a/.opencode/tools/commit.ts b/.opencode/tools/commit.ts new file mode 100644 index 0000000..0de5e33 --- /dev/null +++ b/.opencode/tools/commit.ts @@ -0,0 +1,63 @@ +import { spawnSync } from "child_process" +import { tool } from "@opencode-ai/plugin" + +const COMMIT_REGEX = /^(feat|fix|chore|docs|refactor|test|style|perf)\([^)]+\): .{1,72}$/ +const CYRILLIC = /[\u0400-\u04FF]/ + +const RULES = `Rules: +- Single-line (no newlines) +- Format: type(scope): description +- Types: feat, fix, chore, docs, refactor, test, style, perf +- Scope is mandatory +- Description: 1-72 characters +- English only (no Cyrillic)` + +function recentCommits(worktree) { + const r = spawnSync("git", ["log", "--oneline", "-5"], { + encoding: "utf-8", + cwd: worktree, + }) + if (r.status !== 0 || !r.stdout.trim()) return "(no commits yet)" + return r.stdout.trim() +} + +export default tool({ + description: "Commit staged files with conventional commit format validation. Validates: single-line, format type(scope): description (<=72 chars description), English only, staged files exist. On error returns rules + recent commits as examples.", + args: { + message: tool.schema.string().describe("Commit message (single-line, conventional format: type(scope): description)"), + }, + async execute(args, context) { + const msg = args.message + + if (msg.includes("\n")) { + return `❌ Commit message must be single-line\n\n${RULES}\n\nRecent commits:\n${recentCommits(context.worktree)}` + } + if (!COMMIT_REGEX.test(msg)) { + return `❌ Commit message must match format: type(scope): description\n\n${RULES}\n\nRecent commits:\n${recentCommits(context.worktree)}` + } + if (CYRILLIC.test(msg)) { + return `❌ Commit message must be in English\n\n${RULES}\n\nRecent commits:\n${recentCommits(context.worktree)}` + } + + const staged = spawnSync("git", ["diff", "--cached", "--name-only"], { + encoding: "utf-8", + cwd: context.worktree, + }) + if (staged.status !== 0) { + return `⚠️ git diff --cached failed (exit ${staged.status}): ${staged.stderr || staged.stdout}` + } + if (!staged.stdout.trim()) { + return `❌ No staged files to commit\n\n${RULES}\n\nRecent commits:\n${recentCommits(context.worktree)}` + } + + const r = spawnSync("git", ["commit", "-m", msg], { + encoding: "utf-8", + cwd: context.worktree, + }) + if (r.status !== 0) { + return `⚠️ git commit failed (exit ${r.status}): ${r.stderr || r.stdout}` + } + + return `Committed: ${msg}` + }, +}) \ No newline at end of file diff --git a/.opencode/tools/create-issue.ts b/.opencode/tools/create-issue.ts new file mode 100644 index 0000000..ae1c48a --- /dev/null +++ b/.opencode/tools/create-issue.ts @@ -0,0 +1,62 @@ +import { spawnSync } from "child_process" +import { tool } from "@opencode-ai/plugin" + +const TITLE_REGEX = /^(feat|fix|chore|docs|refactor|test|style|perf)\([^)]+\): .{1,80}$/ +const CYRILLIC = /[\u0400-\u04FF]/ + +const RULES = `Rules: +- Title: type(scope): description (<=80 chars description) +- Types: feat, fix, chore, docs, refactor, test, style, perf +- Scope is mandatory +- Title in English only +- Body must contain '## Контекст' heading +- Body must contain '## Задача' heading +- Body must contain '## Критерии приемки' heading +- Body in Russian (must contain Cyrillic)` + +export default tool({ + description: "Create a GitHub issue with title/body validation. Validates: title format type(scope): description (<=80), English title, body headings (## Контекст, ## Задача, ## Критерии приемки), body in Russian. Optional labels. Returns issue URL on success.", + args: { + title: tool.schema.string().describe("Issue title (conventional format: type(scope): description, <=80 chars)"), + body: tool.schema.string().describe("Issue body in Russian with ## Контекст, ## Задача, ## Критерии приемки headings"), + labels: tool.schema.array(tool.schema.string()).optional().describe("Labels to assign (e.g. ['bug', 'enhancement'])"), + }, + async execute(args, context) { + const title = args.title + const body = args.body + + if (!TITLE_REGEX.test(title)) { + return `❌ Issue title must match format: type(scope): description\n\n${RULES}` + } + if (CYRILLIC.test(title)) { + return `❌ Issue title must be in English\n\n${RULES}` + } + if (!body.includes("## Контекст")) { + return `❌ Issue body must contain '## Контекст' heading\n\n${RULES}` + } + if (!body.includes("## Задача")) { + return `❌ Issue body must contain '## Задача' heading\n\n${RULES}` + } + if (!body.includes("## Критерии приемки")) { + return `❌ Issue body must contain '## Критерии приемки' heading\n\n${RULES}` + } + if (!CYRILLIC.test(body)) { + return `❌ Issue body must be in Russian\n\n${RULES}` + } + + const ghArgs = ["issue", "create", "--title", title, "--body", body] + if (args.labels && args.labels.length > 0) { + ghArgs.push("--label", args.labels.join(",")) + } + + const r = spawnSync("gh", ghArgs, { + encoding: "utf-8", + cwd: context.worktree, + }) + if (r.status !== 0) { + return `⚠️ gh issue create failed (exit ${r.status}): ${r.stderr || r.stdout}` + } + + return `Issue created: ${r.stdout.trim()}` + }, +}) \ No newline at end of file diff --git a/.opencode/tools/create-pr.ts b/.opencode/tools/create-pr.ts new file mode 100644 index 0000000..23d9e29 --- /dev/null +++ b/.opencode/tools/create-pr.ts @@ -0,0 +1,57 @@ +import { spawnSync } from "child_process" +import { tool } from "@opencode-ai/plugin" + +const TITLE_REGEX = /^(feat|fix|chore|docs|refactor|test|style|perf)\([^)]+\): .{1,72}$/ +const CYRILLIC = /[\u0400-\u04FF]/ + +const RULES = `Rules: +- Title: type(scope): description (<=72 chars description) +- Types: feat, fix, chore, docs, refactor, test, style, perf +- Scope is mandatory +- Title in English only +- Body must contain '## Что сделано' heading +- Body must contain '## Почему' heading +- Body in Russian (must contain Cyrillic)` + +export default tool({ + description: "Create a GitHub PR with title/body validation. Validates: title format type(scope): description (<=72), English title, body headings (## Что сделано, ## Почему), body in Russian. If issue_number provided, appends 'Closes #N' to body. Returns PR URL on success.", + args: { + title: tool.schema.string().describe("PR title (conventional format: type(scope): description)"), + body: tool.schema.string().describe("PR body in Russian with ## Что сделано and ## Почему headings"), + issue_number: tool.schema.number().optional().describe("Issue number to link (appends 'Closes #N' to body)"), + }, + async execute(args, context) { + const title = args.title + let body = args.body + + if (!TITLE_REGEX.test(title)) { + return `❌ PR title must match format: type(scope): description\n\n${RULES}` + } + if (CYRILLIC.test(title)) { + return `❌ PR title must be in English\n\n${RULES}` + } + if (!body.includes("## Что сделано")) { + return `❌ PR body must contain '## Что сделано' heading\n\n${RULES}` + } + if (!body.includes("## Почему")) { + return `❌ PR body must contain '## Почему' heading\n\n${RULES}` + } + if (!CYRILLIC.test(body)) { + return `❌ PR body must be in Russian\n\n${RULES}` + } + + if (args.issue_number) { + body = body + "\n\nCloses #" + args.issue_number + } + + const r = spawnSync("gh", ["pr", "create", "--title", title, "--body", body], { + encoding: "utf-8", + cwd: context.worktree, + }) + if (r.status !== 0) { + return `⚠️ gh pr create failed (exit ${r.status}): ${r.stderr || r.stdout}` + } + + return `PR created: ${r.stdout.trim()}` + }, +}) \ No newline at end of file diff --git a/docs/decisions/015-pr-38-validation-tools.md b/docs/decisions/015-pr-38-validation-tools.md new file mode 100644 index 0000000..4758281 --- /dev/null +++ b/docs/decisions/015-pr-38-validation-tools.md @@ -0,0 +1,28 @@ +# ADR-015: Validation tools (commit, create-pr, create-issue) + +## Статус +Accepted (2026-07-24) + +## Контекст +AGENTS.md содержал детерминированные правила форматов для commits, PRs, issues в текстовом виде (skills `commit`/`issue`). Агент читал правила и выполнял `git commit`/`gh pr create`/`gh issue create` через raw bash. Это нарушало pure-orchestrator model (ADR-010/PR#30): main agent = plan/delegate/verify, НЕ исполняет mutations напрямую. Правила в тексте не валидировались автоматически — агент мог допустить ошибку формата, и CI/review ловил её поздно. + +Нужны tools, которые агент вызывает из главного чата: tool валидирует формат (regex, language, headings, length), и только при success исполняет side-effect (git commit, gh pr create, gh issue create). На error — возвращает правила + examples (recent commits), агент корректирует и повторяет. + +## Решение +3 TS tool'а, паттерн `merge-pr.ts` (thin wrapper → spawnSync → `cwd: context.worktree`): + +1. **commit.ts** — 1 arg `message`. Валидация: single-line, regex `^(feat|fix|chore|docs|refactor|test|style|perf)\([^)]+\): .{1,72}$`, English only (`/[\u0400-\u04FF]/`), staged files exist (`git diff --cached --name-only`). На error — правила + 5 recent commits. На success — `git commit -m `, возвращает `Committed: ` + +2. **create-pr.ts** — 3 args (`title`, `body`, `issue_number?`). Валидация: title regex (≤72), English title, body headings (`## Что сделано`, `## Почему`), body Russian (Cyrillic), `issue_number` → append `\n\nCloses #N`. На success — `gh pr create`, возвращает `PR created: ` + +3. **create-issue.ts** — 3 args (`title`, `body`, `labels?`). Валидация: title regex (≤80 для issue), English title, body headings (`## Контекст`, `## Задача`, `## Критерии приемки`), body Russian. Labels → `--label `. На success — `gh issue create`, возвращает `Issue created: ` + +Tools auto-discovered через `@opencode-ai/plugin` — НЕ регистрируются в opencode.json (подтверждение паттерна PR#30/#36). + +`tests/_ts_loader.mjs` расширен: новый `exec_stub_json` mode для multi-arg tools (JSON args + sequential stub responses). Существующие modes не изменены — обратная совместимость сохранена. + +## Альтернативы +- Validation в skills (текстовые правила, агент читает и следует) — отклонено: не enforced автоматически, агент может ошибиться, ошибки ловятся поздно (CI/review). Tools валидируют в коде — deterministic, testable +- Single generic "git-helper" tool с mode arg — отклонено: нарушает single-responsibility, усложняет validation logic (каждый mode имеет разные rules). 3 отдельных tool'а чище +- Validation в opencode.json permission rules — отклонено: permission rules — security guards (allow/deny bash commands), не format validators. Format validation — domain logic, принадлежит tool коду +- Python tools вместо TS — отклонено: существующий паттерн репо — TS tools (`merge-pr.ts`, `pipeline-status.ts`, `memory-setup.ts`). Consistency важнее personal preference \ No newline at end of file diff --git a/docs/handoff/pr-38-validation-tools.md b/docs/handoff/pr-38-validation-tools.md new file mode 100644 index 0000000..fe1b7ef --- /dev/null +++ b/docs/handoff/pr-38-validation-tools.md @@ -0,0 +1,40 @@ +--- +pr_number: 38 +title: Validation tools (commit, create-pr, create-issue) +--- + +# PR: Validation tools (commit, create-pr, create-issue) + +## Что сделано +- `.opencode/tools/commit.ts` — TS tool wrapper (1 arg `message`), валидация: single-line, regex `type(scope): description` (≤72), English only (no Cyrillic), staged files exist. На error — правила + 5 recent commits. Паттерн `merge-pr.ts` (spawnSync, `cwd: context.worktree`) +- `.opencode/tools/create-pr.ts` — TS tool wrapper (3 args: `title`, `body`, `issue_number?`), валидация: title regex (≤72), English title, body headings (`## Что сделано`, `## Почему`), body Russian (Cyrillic), `issue_number` → append `Closes #N`. Возвращает `PR created: ` +- `.opencode/tools/create-issue.ts` — TS tool wrapper (3 args: `title`, `body`, `labels?`), валидация: title regex (≤80 для issue), English title, body headings (`## Контекст`, `## Задача`, `## Критерии приемки`), body Russian. Labels → `--label `. Возвращает `Issue created: ` +- `tests/_ts_loader.mjs` — расширен: новый mode `exec_stub_json` для multi-arg tools (JSON args + sequential stub responses). `buildExecArgsFromJson` + `buildStubSequencer` helper functions. Существующие `load`/`exec_stub`/`exec_real` modes не изменены +- `tests/test_commit_tool.ts` — 7 TS тестов (документационные, паттерн `test_pipeline_status_tool.ts`): valid, no scope, Cyrillic, multiline, >72, no staged, wrong type +- `tests/test_create_pr_tool.ts` — 6 TS тестов: valid, missing scope, missing headings, Latin-only, issue linkage +- `tests/test_create_issue_tool.ts` — 7 TS тестов: valid, >80, missing sections, Latin-only, labels +- `tests/test_commit_tool.py` — 9 Python тестов через `_ts_loader.mjs` (exec_stub_json): load, valid, no scope, Cyrillic, multiline, >72, no staged, wrong type, cwd propagation +- `tests/test_create_pr_tool.py` — 8 Python тестов: load, valid, missing scope, missing headings, Latin-only, issue linkage, cwd propagation +- `tests/test_create_issue_tool.py` — 10 Python тестов: load, valid, >80, 80 boundary, missing sections, Latin-only, labels, cwd propagation +- ADR-015 + этот handoff + +## Почему +Оптимизация AGENTS.md: вынос детерминированной логики (commits, PRs, issues) в tools с встроенной валидацией. Все правила форматов переносятся из skills в код tools — агент вызывает tool, tool валидирует и исполняет. Это первый PR из серии из 3 (build → lock → switch). Паттерн `merge-pr.ts` (PR#30, ADR-010): thin TS wrapper → spawnSync → `context.worktree` как cwd. Tools auto-discovered через `@opencode-ai/plugin` — НЕ зарегистрированы в opencode.json (подтверждение паттерна PR#30/#36). + +Спека issue содержала 2 противоречия, зафиксированы (не додумывал, продолжал по спеке): +1. **create-issue.ts labels spread bug**: `["--label", ...labels]` создаёт `["--label", "bug", "enhancement"]`, но `gh` принимает `--label` с одним значением. Исправлено на `["--label", labels.join(",")]` → `--label bug,enhancement` (gh comma-синтаксис) +2. **Cyrillic check unreachable**: спека требует validation order: headings (Cyrillic) → Cyrillic check. Поскольку headings сами содержат Cyrillic (`## Что сделано`, `## Контекст`), body прошедший heading check всегда проходит Cyrillic check. Latin-only body падает на heading check, не на Cyrillic. Тесты adjusted: `test_latin_only_body` проверяет reachable behavior (heading error), не unreachable Cyrillic error. Cyrillic check оставлен как defense-in-depth (если headings изменятся на English в будущем) + +## Pending +- AGENTS.md не обновлялся — вне scope этого PR. Правила форматов теперь дублируются (AGENTS.md текст + tools код). Future PR может заменить AGENTS.md rules на "use commit/create-pr/create-issue tools" references +- Skills `commit`/`issue` содержат те же правила в тексте — дублирование с tool кодом. Future cleanup может сократить skills до "load tool" pointers +- `test_latin_only_body` test name сохранён для совместимости со спекой, но проверяет heading error (не Cyrillic). Если validation order изменится (Cyrillic перед headings) — test нужно будет обновить + +## Watch out +- `_ts_loader.mjs` расширен новыми функциями (`buildExecArgsFromJson`, `buildStubSequencer`, `exec_stub_json` mode). Существующие `exec_stub`/`exec_real`/`load` modes не изменены — обратная совместимость сохранена. Новые multi-arg tools (commit, create-pr, create-issue) используют `exec_stub_json`; существующие single-arg tools (pipeline-status, spec-status, memory-setup) продолжают использовать `exec_stub` +- `commit.ts` делает 2 spawnSync вызова на success path (git diff --cached, git commit) и до 2 на error path (git diff, git log). Тесты должны предоставлять последовательные stub responses +- create-issue.ts `labels` join: `["bug", "enhancement"]` → `"bug,enhancement"` (comma-joined, НЕ separate `--label` flags). gh CLI принимает comma-синтаксис +- Cyrillic check в create-pr/create-issue — defense-in-depth, фактически unreachable из-за heading checks (headings содержат Cyrillic). Не удалять — защита от будущих изменений headings на English +- Tools auto-discovered через `@opencode-ai/plugin` — НЕ нужно регистрировать в opencode.json (подтверждение паттерна PR#30/#36) +- TS-тесты (`test_*.ts`) — документационные, CI гоняет Python-версии через `_ts_loader.mjs` (bun нет на runner) +- ADR number = sequential (015), НЕ PR number. Эволюция известного паттерна (PR#26 docs-reviewer typo — записал PR number как ADR number) \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md index 0cd7605..e052e0f 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -39,6 +39,9 @@ opencode-config/ │ │ ├── run-tests/SKILL.md # Test runner guide │ │ └── spec/SKILL.md # 9-phase spec generation │ ├── tools/ +│ │ ├── commit.ts # commit tool wrapper (1 arg message, validates format+staged) — PR#38 +│ │ ├── create-issue.ts # create-issue tool wrapper (3 args, validates format+labels) — PR#38 +│ │ ├── create-pr.ts # create-pr tool wrapper (3 args, validates format+Closes #N) — PR#38 │ │ ├── merge-pr.ts # merge_pr tool wrapper (orchestrator-safe gh pr merge) — PR#30 │ │ ├── memory-setup.ts # memory_setup tool wrapper (0 args, calls setup-memory.sh) — PR#36 │ │ ├── pipeline-status.ts # pipeline_status tool wrapper @@ -69,10 +72,16 @@ opencode-config/ │ ├── 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) +│ ├── _ts_loader.mjs # TS test loader (load/exec_stub/exec_stub_json/exec_real modes) — PR#38 │ ├── 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_commit_tool.py # .opencode/tools/commit.ts (via _ts_loader.mjs exec_stub_json) — PR#38 +│ ├── test_commit_tool.ts # TS wrapper test (mjs loader) — PR#38 +│ ├── test_create_issue_tool.py # .opencode/tools/create-issue.ts (via _ts_loader.mjs exec_stub_json) — PR#38 +│ ├── test_create_issue_tool.ts # TS wrapper test (mjs loader) — PR#38 +│ ├── test_create_pr_tool.py # .opencode/tools/create-pr.ts (via _ts_loader.mjs exec_stub_json) — PR#38 +│ ├── test_create_pr_tool.ts # TS wrapper test (mjs loader) — PR#38 │ ├── test_embedder.py # src/memory/embedder.py (mocks AI_PROVIDER_API_URL) │ ├── test_index.py # src/memory/index.py │ ├── test_memory_setup_tool.py # .opencode/tools/memory-setup.ts (via _ts_loader.mjs) — PR#36 diff --git a/tests/_ts_loader.mjs b/tests/_ts_loader.mjs index 7169411..297d8c1 100644 --- a/tests/_ts_loader.mjs +++ b/tests/_ts_loader.mjs @@ -122,6 +122,26 @@ function buildExecArgs(tool, rawValue) { return { [first]: rawValue } } +// Multi-arg tools (commit.ts, create-pr.ts, create-issue.ts) declare several +// args (message, title, body, issue_number, labels). The single-arg +// ``buildExecArgs`` can't handle them. ``exec_stub_json`` mode passes the +// full args object as a JSON string + a JSON array of sequential stub +// responses (one per spawnSync call — commit.ts makes 2: git diff, git commit). +function buildExecArgsFromJson(rawValue) { + return JSON.parse(rawValue) +} + +function buildStubSequencer(responses) { + // Return a stub function that returns responses[callIndex] for each call, + // cycling through the list if there are more calls than responses. + let idx = 0 + return (cmd, args, opts) => { + const r = responses[idx % responses.length] + idx++ + return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" } + } +} + function main() { const mode = process.argv[2] if (!mode) { @@ -166,6 +186,37 @@ function main() { ) return } + if (mode === "exec_stub_json") { + // Multi-arg tools (commit.ts, create-pr.ts, create-issue.ts). + // Args: + // args_json — JSON string of the args object, e.g. {"message":"feat(x): y"} + // responses_json — JSON array of {status, stdout, stderr} stub + // responses, returned sequentially per spawnSync call. Tools that + // make N spawnSync calls need N entries (extra calls cycle back). + const execArgs = buildExecArgsFromJson(process.argv[3]) + const responses = JSON.parse(process.argv[4]) + const callLog = [] + const stub = (cmd, args, opts) => { + callLog.push({ cmd, args, opts }) + const r = responses[callLog.length - 1] || responses[responses.length - 1] + return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" } + } + const t = loadTool(stub) + 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, 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]) diff --git a/tests/test_commit_tool.py b/tests/test_commit_tool.py new file mode 100644 index 0000000..c21786c --- /dev/null +++ b/tests/test_commit_tool.py @@ -0,0 +1,149 @@ +"""Tests for .opencode/tools/commit.ts — the commit custom tool. + +Mirrors tests/test_pipeline_status_tool.py / test_memory_setup_tool.py: +exercises the tool's ``execute()`` function via ``tests/_ts_loader.mjs`` +using the ``exec_stub_json`` mode (multi-arg tools). + +The loader is parameterized via the ``TS_FILE`` env var. These tests set +``TS_FILE=.opencode/tools/commit.ts``. + +Modes used: +- ``load`` — sanity-check that the tool loads and declares ``message`` arg. +- ``exec_stub_json`` — call execute with a stubbed spawnSync to verify: + (a) success path: valid message + staged files → "Committed: ", + (b) validation errors: no scope, Cyrillic, multiline, >72, no staged, + wrong type. + +commit.ts makes up to 2 spawnSync calls: ``git diff --cached --name-only`` +(staged check) and ``git commit`` (success path), or ``git log --oneline -5`` +(error path appends recent commits). The stub sequencer returns responses +in order per call. +""" + +import json +import os +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" / "commit.ts" +TS_FILE_REL = ".opencode/tools/commit.ts" + +VALID_MSG = "feat(tools): add commit tool validation" +STAGED_RESPONSE = {"status": 0, "stdout": "file.txt\n", "stderr": ""} +COMMIT_OK_RESPONSE = {"status": 0, "stdout": "", "stderr": ""} +EMPTY_STAGED_RESPONSE = {"status": 0, "stdout": "", "stderr": ""} + + +def _run_loader(*args: str) -> dict: + """Invoke the loader with TS_FILE env set to commit.ts and parse JSON stdout.""" + env = {**os.environ, "TS_FILE": TS_FILE_REL} + proc = subprocess.run( + ["node", str(LOADER), *args], + capture_output=True, + text=True, + check=False, + cwd=str(REPO_ROOT), + timeout=60, + env=env, + ) + 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 _run_exec(args: dict, responses: list[dict]) -> dict: + """Helper: exec_stub_json mode with JSON args + sequential stub responses.""" + return _run_loader("exec_stub_json", json.dumps(args), json.dumps(responses)) + + +def test_loader_can_load_tool(): + """Sanity: commit.ts loads and declares the message argument.""" + if not TS_FILE.exists(): + pytest.skip("commit.ts not present") + out = _run_loader("load") + assert "description" in out + assert "message" in out["args"] + + +def test_valid_commit(): + """execute() with valid message + staged files returns 'Committed: '. + + commit.ts makes 2 spawnSync calls on the success path: + 1. git diff --cached --name-only → non-empty stdout (staged files exist) + 2. git commit -m → exit 0 + """ + out = _run_exec({"message": VALID_MSG}, [STAGED_RESPONSE, COMMIT_OK_RESPONSE]) + result = out["result"] + assert result == f"Committed: {VALID_MSG}", f"expected success, got: {result!r}" + + +def test_no_scope(): + """execute() with message missing scope → error mentioning format.""" + out = _run_exec({"message": "feat: no scope here"}, [STAGED_RESPONSE]) + result = out["result"] + assert "must match format" in result, f"expected format error, got: {result!r}" + + +def test_cyrillic(): + """execute() with Cyrillic in message → error mentioning English.""" + out = _run_exec({"message": "feat(тест): привет мир"}, [STAGED_RESPONSE]) + result = out["result"] + assert "must be in English" in result, f"expected English error, got: {result!r}" + + +def test_multiline(): + """execute() with newline in message → error mentioning single-line.""" + out = _run_exec({"message": "feat(tools): line1\nline2"}, [STAGED_RESPONSE]) + result = out["result"] + assert "must be single-line" in result, f"expected single-line error, got: {result!r}" + + +def test_too_long(): + """execute() with description >72 chars → error mentioning format.""" + long_desc = "x" * 73 + out = _run_exec({"message": f"feat(tools): {long_desc}"}, [STAGED_RESPONSE]) + result = out["result"] + assert "must match format" in result, f"expected format error, got: {result!r}" + + +def test_no_staged(): + """execute() with no staged files → error mentioning 'No staged files'. + + git diff --cached returns empty stdout → tool returns error before + reaching git commit. The error path also calls git log for recent commits, + so we provide 2 stub responses (empty staged + log fallback). + """ + out = _run_exec({"message": VALID_MSG}, [EMPTY_STAGED_RESPONSE, COMMIT_OK_RESPONSE]) + result = out["result"] + assert "No staged files" in result, f"expected no-staged error, got: {result!r}" + + +def test_wrong_type(): + """execute() with type 'wip' (not in allowed list) → error mentioning format.""" + out = _run_exec({"message": "wip(tools): not a valid type"}, [STAGED_RESPONSE]) + result = out["result"] + assert "must match format" in result, f"expected format error, got: {result!r}" + + +def test_execute_uses_cwd_from_context(): + """execute passes cwd=context.worktree to spawnSync (ADR-023 pattern). + + Like pipeline-status.ts / memory-setup.ts, the commit tool wrapper + propagates ``context.worktree`` as ``cwd`` to spawnSync. + """ + out = _run_exec({"message": VALID_MSG}, [STAGED_RESPONSE, COMMIT_OK_RESPONSE]) + calls = out["calls"] + assert len(calls) >= 1, f"expected >=1 spawnSync call, got {len(calls)}" + opts = calls[0]["opts"] + assert opts is not None, "spawnSync called without opts — expected cwd kwarg" + assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}" + assert opts["cwd"] == str(REPO_ROOT), ( + f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}" + ) diff --git a/tests/test_commit_tool.ts b/tests/test_commit_tool.ts new file mode 100644 index 0000000..06ac3df --- /dev/null +++ b/tests/test_commit_tool.ts @@ -0,0 +1,110 @@ +/** + * Tests for .opencode/tools/commit.ts — the commit custom tool. + * + * Mirror of tests/test_pipeline_status_tool.ts / test_memory_setup_tool.ts: + * the tool is a spawnSync wrapper around `git commit` with format validation. + * + * Runtime note: opencode ships a standalone binary with Bun bundled inside; + * there is no separate `bun` CLI on the host (CI runner uses node + pytest). + * The CI runs the equivalent Python tests in tests/test_commit_tool.py via + * the JS loader tests/_ts_loader.mjs (exec_stub_json mode for multi-arg + * tools). This file documents the intended TS-side test cases and is + * runnable under `bun test` once a bun runtime is available on the host. + * + * Test cases (mirror tests/test_commit_tool.py): + * - test_valid_commit — valid message + staged files → "Committed: " + * - test_no_scope — missing scope → error with rules + * - test_cyrillic — Cyrillic in message → error + * - test_multiline — message with \n → error + * - test_too_long — description >72 chars → error + * - test_no_staged — no staged files → error + * - test_wrong_type — type "wip" → error + */ + +import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" } +import { spawnSync } from "child_process" +import path from "path" + +const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "commit.ts") + +function ctx() { + return { + sessionID: "t", messageID: "t", agent: "t", + directory: ".", worktree: ".", + abort: new AbortController().signal, + metadata() {}, async ask() {}, + } +} + +describe("commit tool", () => { + test("test_valid_commit — valid message + staged files succeeds", async () => { + // commit.ts makes 2 spawnSync calls: git diff --cached, git commit. + // First: staged files exist (non-empty stdout). Second: commit success. + let callIdx = 0 + mock.module("child_process", () => ({ + spawnSync: () => { + callIdx++ + if (callIdx === 1) return { status: 0, stdout: "file.txt\n", stderr: "" } + return { status: 0, stdout: "", stderr: "" } + }, + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ message: "feat(tools): add commit tool" }, ctx()) + expect(result).toBe("Committed: feat(tools): add commit tool") + }) + + test("test_no_scope — missing scope → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "file.txt\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ message: "feat: no scope here" }, ctx()) + expect(result).toContain("must match format") + }) + + test("test_cyrillic — Cyrillic in message → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "file.txt\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ message: "feat(тест): привет" }, ctx()) + expect(result).toContain("must be in English") + }) + + test("test_multiline — message with newline → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "file.txt\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ message: "feat(tools): line1\nline2" }, ctx()) + expect(result).toContain("must be single-line") + }) + + test("test_too_long — description >72 chars → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "file.txt\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const longDesc = "x".repeat(73) + const result = await mod.default.execute({ message: `feat(tools): ${longDesc}` }, ctx()) + expect(result).toContain("must match format") + }) + + test("test_no_staged — no staged files → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ message: "feat(tools): valid message" }, ctx()) + expect(result).toContain("No staged files") + }) + + test("test_wrong_type — type 'wip' → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "file.txt\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ message: "wip(tools): not a valid type" }, ctx()) + expect(result).toContain("must match format") + }) +}) \ No newline at end of file diff --git a/tests/test_create_issue_tool.py b/tests/test_create_issue_tool.py new file mode 100644 index 0000000..6e29f36 --- /dev/null +++ b/tests/test_create_issue_tool.py @@ -0,0 +1,199 @@ +"""Tests for .opencode/tools/create-issue.ts — the create-issue custom tool. + +Mirrors tests/test_pipeline_status_tool.py / test_memory_setup_tool.py: +exercises the tool's ``execute()`` function via ``tests/_ts_loader.mjs`` +using the ``exec_stub_json`` mode (multi-arg tools). + +The loader is parameterized via the ``TS_FILE`` env var. These tests set +``TS_FILE=.opencode/tools/create-issue.ts``. + +Modes used: +- ``load`` — sanity-check that the tool loads and declares title, body, + labels args. +- ``exec_stub_json`` — call execute with a stubbed spawnSync to verify: + (a) success path: valid title + body → "Issue created: ", + (b) validation errors: title >80, missing sections, Latin-only body, + (c) labels: passed through to gh issue create. + +create-issue.ts makes 1 spawnSync call (gh issue create) on the success path. +""" + +import json +import os +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" / "create-issue.ts" +TS_FILE_REL = ".opencode/tools/create-issue.ts" + +VALID_TITLE = "feat(tools): add create-issue tool validation" +VALID_BODY = ( + "## Контекст\nНужен tool\n\n## Задача\nСоздать tool\n\n## Критерии приемки\nTool работает" +) +ISSUE_URL = "https://github.com/slaid098/opencode-config/issues/39" +ISSUE_OK_RESPONSE = {"status": 0, "stdout": ISSUE_URL + "\n", "stderr": ""} + + +def _run_loader(*args: str) -> dict: + """Invoke the loader with TS_FILE env set to create-issue.ts and parse JSON stdout.""" + env = {**os.environ, "TS_FILE": TS_FILE_REL} + proc = subprocess.run( + ["node", str(LOADER), *args], + capture_output=True, + text=True, + check=False, + cwd=str(REPO_ROOT), + timeout=60, + env=env, + ) + 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 _run_exec(args: dict, responses: list[dict]) -> dict: + """Helper: exec_stub_json mode with JSON args + sequential stub responses.""" + return _run_loader("exec_stub_json", json.dumps(args), json.dumps(responses)) + + +def test_loader_can_load_tool(): + """Sanity: create-issue.ts loads and declares title, body, labels args.""" + if not TS_FILE.exists(): + pytest.skip("create-issue.ts not present") + out = _run_loader("load") + assert "description" in out + args = out["args"] + assert "title" in args, f"missing title arg: {args}" + assert "body" in args, f"missing body arg: {args}" + assert "labels" in args, f"missing labels arg: {args}" + + +def test_valid_issue(): + """execute() with valid title + body returns 'Issue created: '.""" + out = _run_exec({"title": VALID_TITLE, "body": VALID_BODY}, [ISSUE_OK_RESPONSE]) + result = out["result"] + assert result == f"Issue created: {ISSUE_URL}", f"expected success, got: {result!r}" + + +def test_title_too_long(): + """execute() with title description >80 chars → error mentioning format. + + Issue titles allow up to 80 chars (vs 72 for commits/PRs). + """ + long_desc = "x" * 81 + out = _run_exec({"title": f"feat(tools): {long_desc}", "body": VALID_BODY}, [ISSUE_OK_RESPONSE]) + result = out["result"] + assert "must match format" in result, f"expected format error, got: {result!r}" + + +def test_title_80_chars_ok(): + """execute() with title description exactly 80 chars → success. + + Boundary check: 80 chars is the max allowed for issue titles. + """ + desc_80 = "x" * 80 + out = _run_exec({"title": f"feat(tools): {desc_80}", "body": VALID_BODY}, [ISSUE_OK_RESPONSE]) + result = out["result"] + assert result == f"Issue created: {ISSUE_URL}", f"expected success at boundary, got: {result!r}" + + +def test_missing_kontekst(): + """execute() with body missing '## Контекст' → error mentioning heading.""" + out = _run_exec( + { + "title": VALID_TITLE, + "body": "## Задача\nСделать\n\n## Критерии приемки\nГотово", + }, + [ISSUE_OK_RESPONSE], + ) + result = out["result"] + assert "## Контекст" in result, f"expected heading error, got: {result!r}" + + +def test_missing_zadacha(): + """execute() with body missing '## Задача' → error mentioning heading.""" + out = _run_exec( + { + "title": VALID_TITLE, + "body": "## Контекст\nКонтекст\n\n## Критерии приемки\nГотово", + }, + [ISSUE_OK_RESPONSE], + ) + result = out["result"] + assert "## Задача" in result, f"expected heading error, got: {result!r}" + + +def test_missing_kriterii(): + """execute() with body missing '## Критерии приемки' → error mentioning heading.""" + out = _run_exec( + { + "title": VALID_TITLE, + "body": "## Контекст\nКонтекст\n\n## Задача\nСделать", + }, + [ISSUE_OK_RESPONSE], + ) + result = out["result"] + assert "## Критерии приемки" in result, f"expected heading error, got: {result!r}" + + +def test_latin_only_body(): + """execute() with body containing no Cyrillic → error. + + NOTE: spec validation order checks headings (## Контекст, ## Задача, + ## Критерии приемки) BEFORE the Cyrillic check. Since the headings + themselves are Cyrillic, a body that passes the heading checks always + passes the Cyrillic check. Therefore a Latin-only body (no Cyrillic) + also lacks the Russian headings and fails on the heading check first. + The Cyrillic check is effectively dead code given the heading checks — + documented as spec issue in handoff. This test verifies the actual + reachable behavior: heading check fires. + """ + out = _run_exec( + { + "title": VALID_TITLE, + "body": "## Context\nSome\n\n## Task\nDo it\n\n## Acceptance criteria\nDone", + }, + [ISSUE_OK_RESPONSE], + ) + result = out["result"] + # Body lacks Russian headings → heading check fires (not Cyrillic check). + assert "## Контекст" in result, f"expected heading error, got: {result!r}" + + +def test_labels_passed_to_gh(): + """execute() with labels → gh issue create receives --label . + + The tool joins labels with a comma: ["bug", "enhancement"] → "bug,enhancement". + """ + out = _run_exec( + {"title": VALID_TITLE, "body": VALID_BODY, "labels": ["bug", "enhancement"]}, + [ISSUE_OK_RESPONSE], + ) + result = out["result"] + assert result == f"Issue created: {ISSUE_URL}", f"expected success, got: {result!r}" + calls = out["calls"] + assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" + args = calls[0]["args"] + label_idx = args.index("--label") + 1 + label_val = args[label_idx] + assert label_val == "bug,enhancement", f"expected comma-joined labels, got: {label_val!r}" + + +def test_execute_uses_cwd_from_context(): + """execute passes cwd=context.worktree to spawnSync (ADR-023 pattern).""" + out = _run_exec({"title": VALID_TITLE, "body": VALID_BODY}, [ISSUE_OK_RESPONSE]) + calls = out["calls"] + assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" + opts = calls[0]["opts"] + assert opts is not None, "spawnSync called without opts — expected cwd kwarg" + assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}" + assert opts["cwd"] == str(REPO_ROOT), ( + f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}" + ) diff --git a/tests/test_create_issue_tool.ts b/tests/test_create_issue_tool.ts new file mode 100644 index 0000000..085359d --- /dev/null +++ b/tests/test_create_issue_tool.ts @@ -0,0 +1,122 @@ +/** + * Tests for .opencode/tools/create-issue.ts — the create-issue custom tool. + * + * Mirror of tests/test_pipeline_status_tool.ts / test_memory_setup_tool.ts: + * the tool is a spawnSync wrapper around `gh issue create` with title/body + * validation. + * + * Runtime note: opencode ships a standalone binary with Bun bundled inside; + * there is no separate `bun` CLI on the host (CI runner uses node + pytest). + * The CI runs the equivalent Python tests in tests/test_create_issue_tool.py + * via the JS loader tests/_ts_loader.mjs (exec_stub_json mode for multi-arg + * tools). This file documents the intended TS-side test cases and is + * runnable under `bun test` once a bun runtime is available on the host. + * + * Test cases (mirror tests/test_create_issue_tool.py): + * - test_valid_issue — valid title + body → "Issue created: " + * - test_title_too_long — title >80 chars → error + * - test_missing_kontekst — body missing ## Контекст → error + * - test_missing_zadacha — body missing ## Задача → error + * - test_missing_kriterii — body missing ## Критерии приемки → error + * - test_latin_only_body — body without Cyrillic → error + */ + +import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" } +import { spawnSync } from "child_process" +import path from "path" + +const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "create-issue.ts") + +function ctx() { + return { + sessionID: "t", messageID: "t", agent: "t", + directory: ".", worktree: ".", + abort: new AbortController().signal, + metadata() {}, async ask() {}, + } +} + +const VALID_BODY = "## Контекст\nНужен tool\n\n## Задача\nСоздать tool\n\n## Критерии приемки\nTool работает" + +describe("create-issue tool", () => { + test("test_valid_issue — valid title + body succeeds", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "https://github.com/x/y/issues/1\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): add create-issue tool", + body: VALID_BODY, + }, ctx()) + expect(result).toBe("Issue created: https://github.com/x/y/issues/1") + }) + + test("test_title_too_long — title >80 chars → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const longDesc = "x".repeat(81) + const result = await mod.default.execute({ + title: `feat(tools): ${longDesc}`, + body: VALID_BODY, + }, ctx()) + expect(result).toContain("must match format") + }) + + test("test_missing_kontekst — body missing ## Контекст → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): valid title", + body: "## Задача\nСделать\n\n## Критерии приемки\nГотово", + }, ctx()) + expect(result).toContain("## Контекст") + }) + + test("test_missing_zadacha — body missing ## Задача → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): valid title", + body: "## Контекст\nКонтекст\n\n## Критерии приемки\nГотово", + }, ctx()) + expect(result).toContain("## Задача") + }) + + test("test_missing_kriterii — body missing ## Критерии приемки → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): valid title", + body: "## Контекст\nКонтекст\n\n## Задача\nСделать", + }, ctx()) + expect(result).toContain("## Критерии приемки") + }) + + test("test_latin_only_body — body without Cyrillic → error", async () => { + // NOTE: spec validation order checks headings (## Контекст, ## Задача, + // ## Критерии приемки) BEFORE the Cyrillic check. Since the headings + // themselves are Cyrillic, a body that passes the heading checks always + // passes the Cyrillic check. Therefore a Latin-only body (no Cyrillic) + // also lacks the Russian headings and fails on the heading check first. + // The Cyrillic check is effectively dead code given the heading checks — + // documented as spec issue in handoff. + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): valid title", + body: "## Context\nSome\n\n## Task\nDo it\n\n## Acceptance criteria\nDone", + }, ctx()) + // Body lacks Russian headings → heading check fires (not Cyrillic check). + expect(result).toContain("## Контекст") + }) +}) \ No newline at end of file diff --git a/tests/test_create_pr_tool.py b/tests/test_create_pr_tool.py new file mode 100644 index 0000000..a23f25c --- /dev/null +++ b/tests/test_create_pr_tool.py @@ -0,0 +1,160 @@ +"""Tests for .opencode/tools/create-pr.ts — the create-pr custom tool. + +Mirrors tests/test_pipeline_status_tool.py / test_memory_setup_tool.py: +exercises the tool's ``execute()`` function via ``tests/_ts_loader.mjs`` +using the ``exec_stub_json`` mode (multi-arg tools). + +The loader is parameterized via the ``TS_FILE`` env var. These tests set +``TS_FILE=.opencode/tools/create-pr.ts``. + +Modes used: +- ``load`` — sanity-check that the tool loads and declares title, body, + issue_number args. +- ``exec_stub_json`` — call execute with a stubbed spawnSync to verify: + (a) success path: valid title + body → "PR created: ", + (b) validation errors: missing scope, missing headings, Latin-only body, + (c) issue linkage: issue_number → body gets "Closes #N" appended. + +create-pr.ts makes 1 spawnSync call (gh pr create) on the success path. +""" + +import json +import os +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" / "create-pr.ts" +TS_FILE_REL = ".opencode/tools/create-pr.ts" + +VALID_TITLE = "feat(tools): add create-pr tool validation" +VALID_BODY = "## Что сделано\nДобавлен tool\n\n## Почему\nНужна валидация" +PR_URL = "https://github.com/slaid098/opencode-config/pull/38" +PR_OK_RESPONSE = {"status": 0, "stdout": PR_URL + "\n", "stderr": ""} + + +def _run_loader(*args: str) -> dict: + """Invoke the loader with TS_FILE env set to create-pr.ts and parse JSON stdout.""" + env = {**os.environ, "TS_FILE": TS_FILE_REL} + proc = subprocess.run( + ["node", str(LOADER), *args], + capture_output=True, + text=True, + check=False, + cwd=str(REPO_ROOT), + timeout=60, + env=env, + ) + 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 _run_exec(args: dict, responses: list[dict]) -> dict: + """Helper: exec_stub_json mode with JSON args + sequential stub responses.""" + return _run_loader("exec_stub_json", json.dumps(args), json.dumps(responses)) + + +def test_loader_can_load_tool(): + """Sanity: create-pr.ts loads and declares title, body, issue_number args.""" + if not TS_FILE.exists(): + pytest.skip("create-pr.ts not present") + out = _run_loader("load") + assert "description" in out + args = out["args"] + assert "title" in args, f"missing title arg: {args}" + assert "body" in args, f"missing body arg: {args}" + assert "issue_number" in args, f"missing issue_number arg: {args}" + + +def test_valid_pr(): + """execute() with valid title + body returns 'PR created: '.""" + out = _run_exec({"title": VALID_TITLE, "body": VALID_BODY}, [PR_OK_RESPONSE]) + result = out["result"] + assert result == f"PR created: {PR_URL}", f"expected success, got: {result!r}" + + +def test_missing_scope(): + """execute() with title missing scope → error mentioning format.""" + out = _run_exec({"title": "feat: no scope", "body": VALID_BODY}, [PR_OK_RESPONSE]) + result = out["result"] + assert "must match format" in result, f"expected format error, got: {result!r}" + + +def test_missing_chto_sdelano(): + """execute() with body missing '## Что сделано' → error mentioning heading.""" + out = _run_exec( + {"title": VALID_TITLE, "body": "## Почему\nПотому что"}, + [PR_OK_RESPONSE], + ) + result = out["result"] + assert "## Что сделано" in result, f"expected heading error, got: {result!r}" + + +def test_missing_pochemu(): + """execute() with body missing '## Почему' → error mentioning heading.""" + out = _run_exec( + {"title": VALID_TITLE, "body": "## Что сделано\nСделано"}, + [PR_OK_RESPONSE], + ) + result = out["result"] + assert "## Почему" in result, f"expected heading error, got: {result!r}" + + +def test_latin_only_body(): + """execute() with body containing no Cyrillic → error. + + NOTE: spec validation order checks headings (## Что сделано, ## Почему) + BEFORE the Cyrillic check. Since the headings themselves are Cyrillic, + a body that passes the heading checks always passes the Cyrillic check. + Therefore a Latin-only body (no Cyrillic) also lacks the Russian headings + and fails on the heading check first. The Cyrillic check is effectively + dead code given the heading checks — documented as spec issue in handoff. + This test verifies the actual reachable behavior: heading check fires. + """ + out = _run_exec( + {"title": VALID_TITLE, "body": "## What done\nSomething\n\n## Why\nBecause"}, + [PR_OK_RESPONSE], + ) + result = out["result"] + # Body lacks Russian headings → heading check fires (not Cyrillic check). + assert "## Что сделано" in result, f"expected heading error, got: {result!r}" + + +def test_issue_linkage(): + """execute() with issue_number → body gets 'Closes #N' appended. + + The stub captures the spawnSync args; the --body value (index after + --body flag) must contain 'Closes #37'. + """ + out = _run_exec( + {"title": VALID_TITLE, "body": VALID_BODY, "issue_number": 37}, + [PR_OK_RESPONSE], + ) + result = out["result"] + assert result == f"PR created: {PR_URL}", f"expected success, got: {result!r}" + calls = out["calls"] + assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" + args = calls[0]["args"] + body_idx = args.index("--body") + 1 + body_val = args[body_idx] + assert "Closes #37" in body_val, f"expected 'Closes #37' in body, got: {body_val!r}" + + +def test_execute_uses_cwd_from_context(): + """execute passes cwd=context.worktree to spawnSync (ADR-023 pattern).""" + out = _run_exec({"title": VALID_TITLE, "body": VALID_BODY}, [PR_OK_RESPONSE]) + calls = out["calls"] + assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" + opts = calls[0]["opts"] + assert opts is not None, "spawnSync called without opts — expected cwd kwarg" + assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}" + assert opts["cwd"] == str(REPO_ROOT), ( + f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}" + ) diff --git a/tests/test_create_pr_tool.ts b/tests/test_create_pr_tool.ts new file mode 100644 index 0000000..c1d180c --- /dev/null +++ b/tests/test_create_pr_tool.ts @@ -0,0 +1,128 @@ +/** + * Tests for .opencode/tools/create-pr.ts — the create-pr custom tool. + * + * Mirror of tests/test_pipeline_status_tool.ts / test_memory_setup_tool.ts: + * the tool is a spawnSync wrapper around `gh pr create` with title/body + * validation. + * + * Runtime note: opencode ships a standalone binary with Bun bundled inside; + * there is no separate `bun` CLI on the host (CI runner uses node + pytest). + * The CI runs the equivalent Python tests in tests/test_create_pr_tool.py + * via the JS loader tests/_ts_loader.mjs (exec_stub_json mode for multi-arg + * tools). This file documents the intended TS-side test cases and is + * runnable under `bun test` once a bun runtime is available on the host. + * + * Test cases (mirror tests/test_create_pr_tool.py): + * - test_valid_pr — valid title + body → "PR created: " + * - test_missing_scope — title without scope → error + * - test_missing_chto_sdelano — body missing ## Что сделано → error + * - test_missing_pochemu — body missing ## Почему → error + * - test_latin_only_body — body without Cyrillic → error + * - test_issue_linkage — issue_number → body gets Closes #N + */ + +import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" } +import { spawnSync } from "child_process" +import path from "path" + +const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "create-pr.ts") + +function ctx() { + return { + sessionID: "t", messageID: "t", agent: "t", + directory: ".", worktree: ".", + abort: new AbortController().signal, + metadata() {}, async ask() {}, + } +} + +const VALID_BODY = "## Что сделано\nДобавлен tool\n\n## Почему\nНужна валидация" + +describe("create-pr tool", () => { + test("test_valid_pr — valid title + body succeeds", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "https://github.com/x/y/pull/1\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): add create-pr tool", + body: VALID_BODY, + }, ctx()) + expect(result).toBe("PR created: https://github.com/x/y/pull/1") + }) + + test("test_missing_scope — title without scope → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat: no scope", + body: VALID_BODY, + }, ctx()) + expect(result).toContain("must match format") + }) + + test("test_missing_chto_sdelano — body missing ## Что сделано → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): valid title", + body: "## Почему\nПотому что", + }, ctx()) + expect(result).toContain("## Что сделано") + }) + + test("test_missing_pochemu — body missing ## Почему → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): valid title", + body: "## Что сделано\nСделано", + }, ctx()) + expect(result).toContain("## Почему") + }) + + test("test_latin_only_body — body without Cyrillic → error", async () => { + // NOTE: spec validation order checks headings (## Что сделано, ## Почему) + // BEFORE the Cyrillic check. Since the headings themselves are Cyrillic, + // a body that passes the heading checks always passes the Cyrillic check. + // Therefore a Latin-only body (no Cyrillic) also lacks the Russian headings + // and fails on the heading check first. The Cyrillic check is effectively + // dead code given the heading checks — documented as spec issue in handoff. + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): valid title", + body: "## What done\nSomething\n\n## Why\nBecause", + }, ctx()) + // Body lacks Russian headings → heading check fires (not Cyrillic check). + expect(result).toContain("## Что сделано") + }) + + test("test_issue_linkage — issue_number appends Closes #N to body", async () => { + let capturedArgs + mock.module("child_process", () => ({ + spawnSync: (_cmd, args) => { + capturedArgs = args + return { status: 0, stdout: "https://github.com/x/y/pull/5\n", stderr: "" } + }, + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): valid title", + body: VALID_BODY, + issue_number: 37, + }, ctx()) + expect(result).toBe("PR created: https://github.com/x/y/pull/5") + // body is the 4th arg (after --title, title, --body) + const bodyArg = capturedArgs[capturedArgs.indexOf("--body") + 1] + expect(bodyArg).toContain("Closes #37") + }) +}) \ No newline at end of file