Files
Peter Schilling 9058328e4c Follow-up to #266: make the pull request template actually reach GitHub
#266 adds `-T/--template` and a `pr_template` config option, which are the right
surface -- the names and the precedence are kept exactly as the contributor
designed them. The mechanism cannot work, though, and I could not find a variant
of it that does.

The command already passes `--body <issue url>`, and #266 appends `--template`
next to it. `gh` refuses that pair outright:

    `--template` is not supported when using `--body` or `--body-file`

So every use of the new flag fails, and setting `pr_template` in config breaks
`issue pr` on every invocation rather than only when the flag is passed.

Dropping `--body` to make room for `--template` -- the obvious repair -- is
worse. `gh` only consults a template when it is running interactively; without a
body a non-TTY caller gets

    must provide `--title` and `--body` (or `--fill` ...) when not running interactively

and no pull request at all. That would trade a broken flag for a command broken
in CI, scripts, and agents. Handing `gh` a temporary file that already contains
the template fails the same way, because the problem is the missing `--body`,
not the file's contents.

So the template is read here and folded into the body we already send, with the
issue URL appended after it. The URL is what Linear matches on to attach the pull
request to its issue, so it has to survive; putting it last leaves the template's
prose as the first thing a reviewer reads. Every existing flag keeps working,
because the argv shape is unchanged.

Reading the file ourselves means we own its failures, and per CLAUDE.md an
explicitly requested template that cannot be used is an error rather than a
silent fallback to a URL-only body -- otherwise the user gets a pull request
quietly missing the content they asked for. Missing paths, directories,
non-regular files and unreadable files all produce a message naming the path.
NUL bytes are rejected too: `Deno.readTextFile` does not refuse binary input, it
substitutes U+FFFD and keeps the NULs, which `Deno.Command` then rejects with a
bare TypeError that never mentions the file.

One deliberate surface change: #266's description suggests `-T ""` to override a
configured default. That worked only because an empty string happened to be
falsy. It is now an explicit `--no-template` flag, and `-T ""` errors with a
suggestion pointing at it.

The generated skill docs under skills/ are left alone; they are produced from an
installed binary out of band and are already stale on trunk.
2026-08-31 17:29:54 -07:00

114 lines
3.6 KiB
TypeScript

import { snapshotTest } from "@cliffy/testing"
import { assertEquals, assertRejects } from "@std/assert"
import {
composePullRequestBody,
pullRequestCommand,
readPullRequestTemplate,
} from "../../../src/commands/issue/issue-pull-request.ts"
import { ValidationError } from "../../../src/utils/errors.ts"
import { commonDenoArgs } from "../../utils/test-helpers.ts"
// The help output is the contract for the two new flags.
await snapshotTest({
name: "Issue Pull Request Command - Help Text",
meta: import.meta,
colors: false,
args: ["--help"],
denoArgs: commonDenoArgs,
async fn() {
await pullRequestCommand.parse()
},
})
// `gh pr create` rejects `--template` next to `--body`, and consults a template
// only when interactive -- so the template has to end up inside the body we
// already send. These assert the shape of that body.
Deno.test("composePullRequestBody - appends the issue URL after the template", () => {
assertEquals(
composePullRequestBody("## Summary\n\n## Testing", "https://linear.app/x"),
"## Summary\n\n## Testing\n\nhttps://linear.app/x",
)
})
Deno.test("composePullRequestBody - collapses the template's trailing whitespace", () => {
// Template files almost always end in a newline; without the trim the URL
// would drift further down the body with every blank line in the file.
assertEquals(
composePullRequestBody("## Summary\n\n\n", "https://linear.app/x"),
"## Summary\n\nhttps://linear.app/x",
)
})
Deno.test("composePullRequestBody - an empty template yields the URL alone", () => {
assertEquals(
composePullRequestBody(" \n", "https://linear.app/x"),
"https://linear.app/x",
)
})
Deno.test("readPullRequestTemplate - reads a regular file verbatim", async () => {
const dir = await Deno.makeTempDir()
try {
const path = `${dir}/tmpl.md`
await Deno.writeTextFile(path, "## Summary\n")
assertEquals(await readPullRequestTemplate(path), "## Summary\n")
} finally {
await Deno.remove(dir, { recursive: true })
}
})
// An explicitly requested template that cannot be used must fail loudly rather
// than quietly falling back to a URL-only body: the user would get a pull
// request silently missing the content they asked for.
Deno.test("readPullRequestTemplate - rejects a missing file", async () => {
const dir = await Deno.makeTempDir()
try {
await assertRejects(
() => readPullRequestTemplate(`${dir}/absent.md`),
ValidationError,
"does not exist",
)
} finally {
await Deno.remove(dir, { recursive: true })
}
})
Deno.test("readPullRequestTemplate - rejects a directory", async () => {
const dir = await Deno.makeTempDir()
try {
await assertRejects(
() => readPullRequestTemplate(dir),
ValidationError,
"is a directory, not a file",
)
} finally {
await Deno.remove(dir, { recursive: true })
}
})
Deno.test("readPullRequestTemplate - rejects an empty path", async () => {
await assertRejects(
() => readPullRequestTemplate(" "),
ValidationError,
"the path is empty",
)
})
// Deno.readTextFile does not reject binary input; it substitutes U+FFFD and
// keeps NUL bytes, which Deno.Command later rejects with a bare TypeError that
// never names the file.
Deno.test("readPullRequestTemplate - rejects a file containing NUL bytes", async () => {
const dir = await Deno.makeTempDir()
try {
const path = `${dir}/binary.md`
await Deno.writeFile(path, new Uint8Array([0x23, 0x00, 0x41]))
await assertRejects(
() => readPullRequestTemplate(path),
ValidationError,
"is not a text file",
)
} finally {
await Deno.remove(dir, { recursive: true })
}
})