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:
parent
2b9ee23b49
commit
1db2c800d4
13 changed files with 1179 additions and 1 deletions
63
.opencode/tools/commit.ts
Normal file
63
.opencode/tools/commit.ts
Normal 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}`
|
||||||
|
},
|
||||||
|
})
|
||||||
62
.opencode/tools/create-issue.ts
Normal file
62
.opencode/tools/create-issue.ts
Normal 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()}`
|
||||||
|
},
|
||||||
|
})
|
||||||
57
.opencode/tools/create-pr.ts
Normal file
57
.opencode/tools/create-pr.ts
Normal 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()}`
|
||||||
|
},
|
||||||
|
})
|
||||||
28
docs/decisions/015-pr-38-validation-tools.md
Normal file
28
docs/decisions/015-pr-38-validation-tools.md
Normal 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
|
||||||
40
docs/handoff/pr-38-validation-tools.md
Normal file
40
docs/handoff/pr-38-validation-tools.md
Normal 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)
|
||||||
|
|
@ -39,6 +39,9 @@ opencode-config/
|
||||||
│ │ ├── run-tests/SKILL.md # Test runner guide
|
│ │ ├── run-tests/SKILL.md # Test runner guide
|
||||||
│ │ └── spec/SKILL.md # 9-phase spec generation
|
│ │ └── spec/SKILL.md # 9-phase spec generation
|
||||||
│ ├── tools/
|
│ ├── 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
|
│ │ ├── 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
|
│ │ ├── memory-setup.ts # memory_setup tool wrapper (0 args, calls setup-memory.sh) — PR#36
|
||||||
│ │ ├── pipeline-status.ts # pipeline_status tool wrapper
|
│ │ ├── pipeline-status.ts # pipeline_status tool wrapper
|
||||||
|
|
@ -69,10 +72,16 @@ opencode-config/
|
||||||
│ ├── index.py # Indexing
|
│ ├── index.py # Indexing
|
||||||
│ └── search.py # Search
|
│ └── search.py # Search
|
||||||
├── tests/ # pytest + TS/MJS test suite — PR#17
|
├── 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_adr_refs.py # adr-check.yml validator
|
||||||
│ ├── test_check_permissions.py # permissions-check.yml validator
|
│ ├── test_check_permissions.py # permissions-check.yml validator
|
||||||
│ ├── test_cli.py # src/memory/cli.py
|
│ ├── 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_embedder.py # src/memory/embedder.py (mocks AI_PROVIDER_API_URL)
|
||||||
│ ├── test_index.py # src/memory/index.py
|
│ ├── test_index.py # src/memory/index.py
|
||||||
│ ├── test_memory_setup_tool.py # .opencode/tools/memory-setup.ts (via _ts_loader.mjs) — PR#36
|
│ ├── test_memory_setup_tool.py # .opencode/tools/memory-setup.ts (via _ts_loader.mjs) — PR#36
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,26 @@ function buildExecArgs(tool, rawValue) {
|
||||||
return { [first]: 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() {
|
function main() {
|
||||||
const mode = process.argv[2]
|
const mode = process.argv[2]
|
||||||
if (!mode) {
|
if (!mode) {
|
||||||
|
|
@ -166,6 +186,37 @@ function main() {
|
||||||
)
|
)
|
||||||
return
|
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") {
|
if (mode === "exec_real") {
|
||||||
const t = loadTool(spawnSync) // use real spawnSync
|
const t = loadTool(spawnSync) // use real spawnSync
|
||||||
const execArgs = buildExecArgs(t, process.argv[3])
|
const execArgs = buildExecArgs(t, process.argv[3])
|
||||||
|
|
|
||||||
149
tests/test_commit_tool.py
Normal file
149
tests/test_commit_tool.py
Normal 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
110
tests/test_commit_tool.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
199
tests/test_create_issue_tool.py
Normal file
199
tests/test_create_issue_tool.py
Normal 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}"
|
||||||
|
)
|
||||||
122
tests/test_create_issue_tool.ts
Normal file
122
tests/test_create_issue_tool.ts
Normal 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("## Контекст")
|
||||||
|
})
|
||||||
|
})
|
||||||
160
tests/test_create_pr_tool.py
Normal file
160
tests/test_create_pr_tool.py
Normal 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}"
|
||||||
|
)
|
||||||
128
tests/test_create_pr_tool.ts
Normal file
128
tests/test_create_pr_tool.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue