feat(tools): add commit, create-pr, create-issue tools (#38)

* feat(tools): add commit tool with validation

* feat(tools): add create-pr tool with validation

* feat(tools): add create-issue tool with validation

* test(tools): add tests for validation tools

* docs(handoff): add handoff and ADR for validation tools

* docs(handoff): set PR number

* docs: update project map + fix ADR-015 typo

---------

Co-authored-by: opencode-agent <agent@slaid098.dev>
This commit is contained in:
Sergey 2026-07-24 04:17:44 +03:00 committed by GitHub
parent 2b9ee23b49
commit 1db2c800d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1179 additions and 1 deletions

63
.opencode/tools/commit.ts Normal file
View file

@ -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}`
},
})

View file

@ -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()}`
},
})

View file

@ -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()}`
},
})

View file

@ -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 <msg>`, возвращает `Committed: <msg>`
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: <url>`
3. **create-issue.ts** — 3 args (`title`, `body`, `labels?`). Валидация: title regex (≤80 для issue), English title, body headings (`## Контекст`, `## Задача`, `## Критерии приемки`), body Russian. Labels → `--label <comma-joined>`. На success — `gh issue create`, возвращает `Issue created: <url>`
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

View file

@ -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: <url>`
- `.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 <comma-joined>`. Возвращает `Issue created: <url>`
- `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)

View file

@ -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

View file

@ -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> <responses_json>
// 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])

149
tests/test_commit_tool.py Normal file
View file

@ -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: <msg>",
(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: <msg>'.
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 <msg> 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}"
)

110
tests/test_commit_tool.ts Normal file
View file

@ -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: <msg>"
* - 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")
})
})

View file

@ -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: <url>",
(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: <url>'."""
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 <comma-joined>.
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}"
)

View file

@ -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: <url>"
* - 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("## Контекст")
})
})

View file

@ -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: <url>",
(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: <url>'."""
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}"
)

View file

@ -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: <url>"
* - 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")
})
})