* 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>
237 lines
No EOL
9.8 KiB
TypeScript
237 lines
No EOL
9.8 KiB
TypeScript
/**
|
||
* 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")
|
||
})
|
||
}) |