opencode-config/tests/test_create_pr_tool.ts
Sergey 0c1e3e7d39
feat(create-pr): enforce Watch out and Pending headings in PR body (#207)
* feat(create-pr): enforce Watch out and Pending headings

* test(create-pr): cover Watch out and Pending headings

* docs(handoff): add handoff and ADR for PR #207

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-01 03:28:35 +03:00

237 lines
No EOL
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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_missing_watch_out — body missing ## Watch out → error
* - test_missing_pending — body missing ## Pending → error
* - test_all_four_headings_success — all 4 headings → success
* - 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Нужна валидация\n\n" +
"## Watch out\n—\n\n" +
"## Pending\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_missing_watch_out — body missing ## Watch out → error", async () => {
// Body has the first two required headings (## Что сделано, ## Почему) so
// the earlier checks pass; the third heading check (## Watch out) fires.
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Потому что\n\n## Pending\n—",
}, ctx())
expect(result).toContain("## Watch out")
})
test("test_missing_pending — body missing ## Pending → error", async () => {
// Body has the first three required headings (## Что сделано, ## Почему,
// ## Watch out) so the earlier checks pass; the fourth heading check
// (## Pending) fires.
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Потому что\n\n## Watch out\n—",
}, ctx())
expect(result).toContain("## Pending")
})
test("test_all_four_headings_success — all 4 headings → success", async () => {
// A body with all four required headings passes validation and returns
// the PR URL. Uses em-dash placeholders for empty Watch out / Pending
// sections (ADR pattern) — heading presence is sufficient.
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): valid title",
body: (
"## Что сделано\nДобавлен enforcement\n\n" +
"## Почему\nPipeline migration делает PR body single source of truth\n\n" +
"## Watch out\n—\n\n" +
"## Pending\n—"
),
}, ctx())
expect(result).toBe("PR created: https://github.com/x/y/pull/1")
})
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")
})
test("test_repo_explicit — repo='foo/bar' prepends --repo to gh argv", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return { status: 0, stdout: "https://github.com/foo/bar/pull/1\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,
repo: "foo/bar",
}, ctx())
expect(result).toBe("PR created: https://github.com/foo/bar/pull/1")
expect(capturedArgs[0]).toBe("--repo")
expect(capturedArgs[1]).toBe("foo/bar")
expect(capturedArgs[2]).toBe("pr")
})
test("test_repo_omitted — no --repo flag (auto-detect from cwd)", async () => {
let capturedArgs
mock.module("child_process", () => ({
spawnSync: (_cmd, args) => {
capturedArgs = args
return { status: 0, stdout: "https://github.com/x/y/pull/1\n", stderr: "" }
},
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
await mod.default.execute({
title: "feat(tools): valid title",
body: VALID_BODY,
}, ctx())
// Backward-compat: --repo MUST NOT be present when repo arg omitted.
expect(capturedArgs).not.toContain("--repo")
expect(capturedArgs[0]).toBe("pr")
})
test("test_repo_invalid — invalid repo + gh failure → tool error", async () => {
mock.module("child_process", () => ({
spawnSync: () => ({ status: 1, stdout: "", stderr: 'expected the "owner/repo" format' }),
}))
const mod = await import(TOOL_SRC + "?t=" + Date.now())
const result = await mod.default.execute({
title: "feat(tools): valid title",
body: VALID_BODY,
repo: "not-a-valid-repo",
}, ctx())
expect(result).toContain("gh pr create failed")
expect(result).toContain("exit 1")
})
})