to-pr
Open a PR if the target branch doesn't have one yet; otherwise fill in the one that's
already open. Both paths compose the same way: ground every section in the actual diff
and commit history, respect the target repo's own PR template structure, and never
invent content. Neither path prompts for confirmation beyond the invocation itself —
running /to-pr is the only authorization step, since it has exactly one user.
Dependencies
Requires an authenticated gh CLI — every path shells out to it (gh pr view, gh pr create, gh pr edit, gh api, gh repo view). git and base64 are also used but are
ambient on any machine capable of running Claude Code, so they aren't listed here.
When to use
- The user asks to open, create, or update a PR, or to fill in / refresh a PR description.
- The user types
/to-pr, with or without a target,--ready/--draft, or--base. - Refreshing a body already filled in the template's own structure, after new commits landed since it was last filled, or after a diff or convention change makes an earlier fill stale.
- Flipping an existing PR's ready-for-review state or base branch via
--ready,--draft, or--base, with no other change intended.
When not to use
- An explicitly named target (a PR number or URL) that doesn't resolve to a real PR — report it as an error. Never fall back to opening a new one; a typo must never create an unrelated PR.
- Replacing real content written in a different structure than the target template, or content on a PR this fill isn't actually about — that's a rewrite, not a fill; confirm with the user before touching it.
- A template's Screenshot (or other visual-evidence) section — leave it exactly as the template has it. No live browser session backs this skill, so it cannot produce an "as it should appear" image.
- Closed or merged PRs, and GitHub's multi-template chooser folder
(
.github/PULL_REQUEST_TEMPLATE/*.md) — only the single-file.github/PULL_REQUEST_TEMPLATE.mdis ever read.
Argument grammar
Scan args for three optional pieces, in any order or position:
- A
--readyor--drafttoken. Both together is an error: report it and stop. - A
--basetoken immediately followed by a branch-name value. - One leftover token, the target (a PR number or URL). More than one leftover token is ambiguous — report it and stop.
Routing
- A target was given (explicit PR number or URL): always resolve it directly with
gh pr view <target> --json body,url,number,headRefName,baseRefName,title,closingIssuesReferences. This is always the update path — an explicit target names a PR that must already exist. - No target was given: run
gh pr view --json body,url,number,headRefName,baseRefName,title,closingIssuesReferencesfor the current branch. If it succeeds, take the update path against that PR, reusing this same result. If it fails (no PR open for this branch), take the create path.
Create path
- Resolve the base branch, unless
--baseoverrides it:- Read the current branch's upstream tracking ref:
git rev-parse --abbrev-ref --symbolic-full-name @{u}(ignore failure — no upstream configured). - Use it only if it names a branch with a different short name than the current
branch (strip the remote prefix, e.g.
origin/feature-x→feature-x, and compare). Same-name tracking is push/pull plumbing, not a target signal. - Otherwise fall through to the repo's default branch:
gh repo view --json defaultBranchRef -q .defaultBranchRef.name. - Deliberately no ancestor-detection heuristic (comparing
git merge-basedistance across candidate branches) — guessing a target from commit-graph shape is exactly the fragility rejected for title derivation below, and base resolution shouldn't reintroduce it.
- Read the current branch's upstream tracking ref:
- Push the branch if it needs it. If an upstream is already configured but local
commits aren't on it (
git rev-list @{u}..HEAD --count> 0),git pushis unambiguous. If no upstream is configured, push to whichever single remotegit remotelists (git push -u <remote> HEAD); more than one configured remote is ambiguous — report it and ask which one, rather than guessingorigin. - Derive the title from the first commit ahead of the resolved base — see "Deriving the title" below.
- Compose the body — see "Composing the body" below, fetching the template from the base branch resolved in step 1.
- Open the PR:
gh pr create -B <base> --title "<title>" --body-file <file> --assignee @me, adding--draftunless--readywas given. - Report: the new PR's URL, title, base, draft/ready state, which title tier fired (verbatim / documented convention / inferred convention), whether a merge commit was detected ahead of base (informational only — see "Deriving the title"), and that it's assigned to you.
Update path
- Fetch the PR's current body and metadata (already done during routing, above). The
urlfield (https://github.com/<owner>/<repo>/pull/<n>) names the PR's own repo — this may differ from whatever repo happens to be checked out locally, since a target can be a full URL into any repo. - If
--basewas given, retarget first:gh pr edit <target> --base <new-base>. Do this before composing the body, so the template fetch in the next step reads the new base, not the stale one. - If
--readyor--draftwas given, toggle the PR's state:gh pr ready <target>for--ready,gh pr ready <target> --undofor--draft. - Compose the body — see "Composing the body" below, fetching the template from the
PR's
baseRefName(the one just retargeted to, if step 2 ran; the original one otherwise). - Write the completed body to a scratch file, then update the PR in one call,
self-assigning it at the same time:
gh pr edit <target> --body-file <file> --add-assignee @me. - Report: which blanks were filled, which checkboxes were checked, which sections were left untouched and why, any retarget or draft-state change applied, and that the PR is assigned to you — so the reasoning is visible before anyone reads the PR itself.
Composing the body
Shared by both paths. "The template" means the target repo's own
.github/PULL_REQUEST_TEMPLATE.md, fetched from whichever base branch the calling path
already settled on (the create path's step 1, or the update path's baseRefName) — never
the head branch, and never a root or docs/ variant:
gh api "repos/<owner>/<repo>/contents/.github/PULL_REQUEST_TEMPLATE.md?ref=<base>" \
--jq .content | base64 --decode
- If the file exists, keep every heading, HTML comment, and checkbox exactly where it puts them.
- If the API call 404s, use the built-in fallback structure:
## What changed,## Why,## Testing. - Never delegate this to
gh pr create's own-T/--fill/--fill-verbose—-Tdumps a local file's raw text with no blank-filling, and--fill/--fill-verboseautofill from commit messages with no template awareness. Always compose the full body and pass it via--body-file/-F.
On the update path only, preserve anything already appended after the template's own structure — CI-generated sections, deploy-preview links, or anything else trailing the body — byte-for-byte, at the end. Detect this positionally, by what comes after the template's last section, never by matching a specific section name. The one exception: drop any AI-attribution line — a trailer crediting an AI assistant or tool for writing or generating the PR (e.g. "🤖 Generated with [Claude Code]", "Co-Authored-By: ", "Assisted by Copilot"). Recognize this by what it says, not a fixed list of tool names, since it's an artifact of how the PR was authored, not the target repo's own content — and never write one when composing a body. On the create path, there is nothing to conflict with yet, so this step doesn't apply.
Before grounding any blank, gather the diff evidence once per invocation. A real diff can
run arbitrarily large, unlike every other lookup this skill makes — dispatch a
general-purpose subagent to read it instead of pulling it into this session directly. Do
not override the subagent's model: the "never invent" grounding this evidence must hold
up under is a judgment call, not a mechanical extraction, and isn't a place to trade
quality for a cheaper tier.
Task the subagent to run git log --first-parent <base>..HEAD (full commit messages, not
just subjects) and git diff <base>...HEAD against the base branch the calling path
already resolved, then report back only what it can ground in that output, in clearly
labeled sections:
- Change summary — what the diff actually does, file by file or logically grouped; never what the change is for.
- Testing evidence — tests added or modified, commands the diff implies running, any manual verification steps visible in the diff or commit messages. Omit the category entirely rather than pad it with generic boilerplate.
- Commit messages — reproduce each one inside a fenced code block, character-for-
character; never paraphrase, condense, or summarize, even under length pressure. The
ticket-reasoning fallback below depends on exact footer text — blurring
Refs #9into something that reads likeCloses #9is exactly the failure this category exists to prevent. - Ticket references — any ticket number or tracker URL appearing in a commit message or the diff itself.
The report is evidence, not prose for any specific template — the subagent doesn't know which template this run will fill, so it must never assume a target's section names or structure. Mapping the evidence onto the actual template's blanks stays this skill's own job, below.
As a cheap cross-check before trusting the report, run git diff --stat <base>...HEAD
directly in this session — file list and line counts only, never the full diff — and
compare it against the subagent's change summary. A mismatch (fewer files mentioned than
--stat shows, for instance) means re-running the subagent, not composing the body from
an incomplete report.
Ground every blank in something real, never invention:
- What changed — the diff-evidence subagent's change summary.
- Why — see the ticket-reasoning step below.
- Testing — the diff-evidence subagent's testing evidence; if it omitted the category, leave Testing unfilled rather than inventing boilerplate.
- Any other checkbox or blank the template defines — fill only what the diff-evidence subagent's report demonstrably supports; leave the rest unchecked or untouched.
- A template's non-blank content — a fixed disclaimer, a Screenshot section — is left exactly as the template has it, filled only if the diff demonstrably supports it, never invented.
Fill Why's ticket-reasoning by tracker convention, with exactly one fallback path:
- Look for a documented tracker or commit-footer convention in the target repo — a
CONTRIBUTING.md,CLAUDE.md/AGENTS.md, or adocs/page describing one. - If one exists, look for evidence tying this PR to a ticket in that convention's own
terms:
closingIssuesReferences, the branch name, the diff-evidence subagent's ticket references, or a commit message footer matching the convention's format. Follow the convention exactly — e.g. aCloses #<n>/Refs #<n>footer, or a linked tracker URL. - Whether no convention is documented at all, or one is documented but no ticket applies to this PR, fall back identically: write Why as a plain prose summary built from the diff-evidence subagent's change summary and commit messages, with no ticket reference. One fallback path, not two.
Deriving the title
Create path only — the update path never touches an existing PR's title.
Never use gh pr create's own --fill-first: it has a documented, open bug
(cli/cli#10604) where, on a branch containing
a merge commit, git log's default traversal can surface a commit from the merged-in
branch as "first" instead of the actual first commit made on the current branch. Instead,
run the equivalent yourself with --first-parent, which sidesteps this unconditionally:
git log --reverse --first-parent <base>..HEAD --format=%s
The first line is the first commit's subject.
Regardless of commit count, check for a merge commit ahead of base: git log --first-parent <base>..HEAD --merges. If non-empty, report it to the developer as
informational — it doesn't block creation or change title derivation (already immune to
it via --first-parent above, even when that first-parent commit is itself a merge); it
just surfaces non-linear history before it becomes a rebase-merge conflict at merge time.
If the %s log above has only one line, the branch is single-commit: the title is that
subject, verbatim — done, skip the tier selection below entirely.
On a multi-commit branch, pick a formatting tier, in priority order, and apply at most one:
- Documented convention. Check the target repo for a documented PR-title convention
the same way the "Why" step above checks for a documented ticket-reasoning convention.
A documented commit-message format convention (e.g. Conventional Commits in
AGENTS.md/CLAUDE.md) counts as a documented PR-title convention too, unless the repo's docs distinguish PR titles from commit messages explicitly. - Inferred convention, only if no documented one applies:
gh pr list --state merged --base <resolved-base> --json title -L 30— 30 matchesgh pr list's own default result limit, so the sample size tracks the tool's natural page rather than an arbitrary number. Fewer than 5 results: skip this tier entirely — below 5 samples a shared prefix is as likely to be coincidence as convention, so there's not enough history to call anything established. Otherwise compare the sampled titles across three dimensions together: a leading prefix format (Conventional-Commitstype(scope):, a ticket bracket like[ABC-123], an emoji, or none), the capitalization of the first word after any prefix, and the presence or absence of a trailing period. Established only if at least 80% of the sample share the same combination of all three — high enough to rule out coincidence, but short of unanimity, since one bot-authored or revert PR that breaks the pattern shouldn't disqualify an otherwise-real convention. Any failure fetching this history (API error, timeout, rate limit) falls back silently to the plain default below — this tier is a nice-to-have signal, never load-bearing. - Plain default: the first commit's subject, verbatim.
Whichever tier applies (documented or inferred), it only ever reformats the first commit's already-grounded subject into that shape — never invents substantive content (a ticket number the skill has no evidence for, for instance). If the detected shape needs information the skill can't ground in something real, skip adapting and fall through to the plain default instead.
Worked examples
Update path, dry-run against this skill's own predecessor PR,
bgutschke/skills#20 — its body was still
the raw, unfilled template (## What changed, ## Why, ## Testing, nothing appended
after it):
gh pr view 20 --json body,closingIssuesReferencesreturned an empty body andclosingIssuesReferences: []— GitHub's own linking found no ticket, because this repo's commit footers useRefs #9, which GitHub doesn't auto-link the wayClosesdoes.- This repo's
CLAUDE.mddocuments theCloses #<n>/Refs #<n>footer convention. The commit history's actual footer —Refs #9, notCloses #9— is the evidence, so Why ends withRefs #9, not theCloses #9a naive reading of the linked issue might suggest. - What changed was written from
git logandgit diff main...HEAD: adding theto-pr-descriptionskill and this repo's firstPULL_REQUEST_TEMPLATE.md. - Testing was written from what the diff and history actually showed:
claude plugin validate . --strictpassing, plus this same dry run. - The filled body was written and the PR self-assigned in one call —
gh pr edit 20 --body-file <file> --add-assignee @me— and the report listed all three sections as filled, none left untouched, and the new assignee.
Create path title derivation, dry-run read-only against this repo's real history
(no PR opened by this check): git log --reverse --first-parent main..HEAD --format=%s
on a multi-commit working branch returns several conventional-commit subjects. Before
checking the inferred tier, the documented-convention check finds this repo's own
Conventional Commits rule in CLAUDE.md — so the documented tier applies and the
inferred-tier query never runs. Confirming what that inferred tier would have found
had no documented convention existed: gh pr list --state merged --base main --json title -L 30 returned 30 merged PRs, 29 of which share the type(scope): or type:
prefix format, lowercase first word, and no trailing period (97%, above the 80% floor) —
so on an undocumented repo this same branch would have landed in the inferred tier
instead of the plain default, with the same result either way, since the first commit's
subject already matches that shape verbatim. Because the documented tier's reformatting
is a no-op whenever the first commit already conforms to it, the title in both cases is
simply that first commit's subject, unmodified.