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: a
human typing /to-pr is its own authorization, and so is a subagent invoking this skill
directly — narrowed by the guardrails below rather than by an interactive prompt. Those
guardrails restrict this path to an in-session subagent only, so a human is already
watching its work unfold and sees the required report regardless of any prompt.
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.
- A subagent's own given task already implies landing its work as a PR — for example a
subagent finishing worktree-based implementation work it was asked to land. The
subagent invokes this skill directly (via the Skill tool) instead of hand-rolling
gh pr create/gh pr edit, so the result still follows this skill's own grounding,
template-fidelity, and title-derivation rules. See "Agent-invocation guardrails" below
for the narrower scope this path is held to.
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.md is ever read.
- A subagent invoking this skill spontaneously because opening a PR "seems like a good
idea," rather than because the task it was actually given already implied landing the
work as a PR. A read-only or advisory task — a code review, an investigation, a status
report — never invokes this skill as a side effect.
- A subagent running unattended, with no session actively being watched — a scheduled
cron agent, or a background loop with no one there to see its output. This path is for
an in-session subagent only.
Agent-invocation guardrails
When a subagent invokes this skill directly, rather than a human typing /to-pr, the
scope narrows on top of everything above:
- Only two actions are available: opening a new PR (create path) or filling in the
description of a PR that the same task already opened (update path's
description-fill only). A subagent never targets a PR some other task or person
opened.
- The create path always produces a draft — never pass
--ready. A subagent never marks
its own PR ready for review; that stays a deliberate, later human decision.
- Never retarget an existing PR's base branch (
--base) and never toggle its
ready/draft state (--ready/--draft) on an already-open PR. Both stay reserved for a
human typing /to-pr directly — an agent mutating a PR's public state could collide
with review already underway.
- Whatever PR results, its link must be surfaced prominently — near the top, not buried
in a longer narrative — in whatever report of the subagent's work reaches the human,
so a PR opened without their being asked in the moment is never lost track of.
This restriction has no mechanical enforcement — Claude Code's invocation model offers no
lever meaning "a subagent may invoke this deliberately, but never spontaneously, and
never unattended." It is documented guidance the calling context is trusted to follow,
the same soft-governance basis this skill already relies on elsewhere (for instance, its
"confirm with the user before touching it" rule above for content written outside the
target template's own structure).
Argument grammar
Scan args for three optional pieces, in any order or position:
- A
--ready or --draft token. Both together is an error: report it and stop.
- A
--base token 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,closingIssuesReferences for 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
--base overrides 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-base distance
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.
- 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 push is
unambiguous. If no upstream is configured, push to whichever single remote git remote lists (git push -u <remote> HEAD); more than one configured remote is
ambiguous — report it and ask which one, rather than guessing origin.
- 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 --draft unless --ready was 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
url field (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
--base was 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
--ready or --draft was given, toggle the PR's state: gh pr ready <target> for
--ready, gh pr ready <target> --undo for --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 — -T dumps
a local file's raw text with no blank-filling, and --fill/--fill-verbose autofill
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 #9 into
something that reads like Closes #9 is 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 a docs/ 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. a Closes #<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 matches gh 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-Commits type(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,closingIssuesReferences returned an empty body and
closingIssuesReferences: [] — GitHub's own linking found no ticket, because this
repo's commit footers use Refs #9, which GitHub doesn't auto-link the way Closes
does.
- This repo's
CLAUDE.md documents the Closes #<n> / Refs #<n> footer convention.
The commit history's actual footer — Refs #9, not Closes #9 — is the evidence, so
Why ends with Refs #9, not the Closes #9 a naive reading of the linked issue
might suggest.
- What changed was written from
git log and git diff main...HEAD: adding the
to-pr-description skill and this repo's first PULL_REQUEST_TEMPLATE.md.
- Testing was written from what the diff and history actually showed:
claude plugin validate . --strict passing, 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.
1---2name: to-pr3description: Invoked by a human typing /to-pr, or by an in-session subagent whose own given task already implies landing its work as a PR. Open a new PR from the current branch (draft by default for a human, always draft when subagent-invoked; title and body derived from its commits and diff), or fill in an already-open PR's description using the target repo's own .github/PULL_REQUEST_TEMPLATE.md — falling back to a built-in What changed/Why/Testing structure. --ready/--draft toggle draft state; --base sets or retargets the base branch — see "Agent-invocation guardrails" for how a subagent's use of these two paths narrows.4---56# to-pr78Open a PR if the target branch doesn't have one yet; otherwise fill in the one that's9already open. Both paths compose the same way: ground every section in the actual diff10and commit history, respect the target repo's own PR template structure, and never11invent content. Neither path prompts for confirmation beyond the invocation itself: a12human typing `/to-pr` is its own authorization, and so is a subagent invoking this skill13directly — narrowed by the guardrails below rather than by an interactive prompt. Those14guardrails restrict this path to an in-session subagent only, so a human is already15watching its work unfold and sees the required report regardless of any prompt.1617## Dependencies1819Requires an authenticated `gh` CLI — every path shells out to it (`gh pr view`, `gh pr20create`, `gh pr edit`, `gh api`, `gh repo view`). `git` and `base64` are also used but are21ambient on any machine capable of running Claude Code, so they aren't listed here.2223## When to use2425- The user asks to open, create, or update a PR, or to fill in / refresh a PR26 description.27- The user types `/to-pr`, with or without a target, `--ready`/`--draft`, or `--base`.28- Refreshing a body already filled in the template's own structure, after new commits29 landed since it was last filled, or after a diff or convention change makes an earlier30 fill stale.31- Flipping an existing PR's ready-for-review state or base branch via `--ready`,32 `--draft`, or `--base`, with no other change intended.33- A subagent's own given task already implies landing its work as a PR — for example a34 subagent finishing worktree-based implementation work it was asked to land. The35 subagent invokes this skill directly (via the Skill tool) instead of hand-rolling `gh36 pr create`/`gh pr edit`, so the result still follows this skill's own grounding,37 template-fidelity, and title-derivation rules. See "Agent-invocation guardrails" below38 for the narrower scope this path is held to.3940## When not to use4142- An explicitly named target (a PR number or URL) that doesn't resolve to a real PR —43 report it as an error. Never fall back to opening a new one; a typo must never create44 an unrelated PR.45- Replacing real content written in a different structure than the target template, or46 content on a PR this fill isn't actually about — that's a rewrite, not a fill; confirm47 with the user before touching it.48- A template's Screenshot (or other visual-evidence) section — leave it exactly as the49 template has it. No live browser session backs this skill, so it cannot produce an "as50 it should appear" image.51- Closed or merged PRs, and GitHub's multi-template chooser folder52 (`.github/PULL_REQUEST_TEMPLATE/*.md`) — only the single-file53 `.github/PULL_REQUEST_TEMPLATE.md` is ever read.54- A subagent invoking this skill spontaneously because opening a PR "seems like a good55 idea," rather than because the task it was actually given already implied landing the56 work as a PR. A read-only or advisory task — a code review, an investigation, a status57 report — never invokes this skill as a side effect.58- A subagent running unattended, with no session actively being watched — a scheduled59 cron agent, or a background loop with no one there to see its output. This path is for60 an in-session subagent only.6162## Agent-invocation guardrails6364When a subagent invokes this skill directly, rather than a human typing `/to-pr`, the65scope narrows on top of everything above:6667- Only two actions are available: opening a new PR (create path) or filling in the68 description of a PR that the *same task* already opened (update path's69 description-fill only). A subagent never targets a PR some other task or person70 opened.71- The create path always produces a draft — never pass `--ready`. A subagent never marks72 its own PR ready for review; that stays a deliberate, later human decision.73- Never retarget an existing PR's base branch (`--base`) and never toggle its74 ready/draft state (`--ready`/`--draft`) on an already-open PR. Both stay reserved for a75 human typing `/to-pr` directly — an agent mutating a PR's public state could collide76 with review already underway.77- Whatever PR results, its link must be surfaced prominently — near the top, not buried78 in a longer narrative — in whatever report of the subagent's work reaches the human,79 so a PR opened without their being asked in the moment is never lost track of.8081This restriction has no mechanical enforcement — Claude Code's invocation model offers no82lever meaning "a subagent may invoke this deliberately, but never spontaneously, and83never unattended." It is documented guidance the calling context is trusted to follow,84the same soft-governance basis this skill already relies on elsewhere (for instance, its85"confirm with the user before touching it" rule above for content written outside the86target template's own structure).8788## Argument grammar8990Scan `args` for three optional pieces, in any order or position:9192- A `--ready` or `--draft` token. Both together is an error: report it and stop.93- A `--base` token immediately followed by a branch-name value.94- One leftover token, the target (a PR number or URL). More than one leftover token is95 ambiguous — report it and stop.9697## Routing9899- **A target was given** (explicit PR number or URL): always resolve it directly with100 `gh pr view <target> --json body,url,number,headRefName,baseRefName,title,closingIssuesReferences`.101 This is always the update path — an explicit target names a PR that must already102 exist.103- **No target was given**: run `gh pr view --json104 body,url,number,headRefName,baseRefName,title,closingIssuesReferences` for the current105 branch. If it succeeds, take the update path against that PR, reusing this same106 result. If it fails (no PR open for this branch), take the create path.107108## Create path1091101. **Resolve the base branch**, unless `--base` overrides it:111 - Read the current branch's upstream tracking ref: `git rev-parse --abbrev-ref112 --symbolic-full-name @{u}` (ignore failure — no upstream configured).113 - Use it only if it names a branch with a *different* short name than the current114 branch (strip the remote prefix, e.g. `origin/feature-x` → `feature-x`, and115 compare). Same-name tracking is push/pull plumbing, not a target signal.116 - Otherwise fall through to the repo's default branch: `gh repo view --json117 defaultBranchRef -q .defaultBranchRef.name`.118 - Deliberately no ancestor-detection heuristic (comparing `git merge-base` distance119 across candidate branches) — guessing a target from commit-graph shape is exactly120 the fragility rejected for title derivation below, and base resolution shouldn't121 reintroduce it.1222. **Push the branch** if it needs it. If an upstream is already configured but local123 commits aren't on it (`git rev-list @{u}..HEAD --count` > 0), `git push` is124 unambiguous. If no upstream is configured, push to whichever single remote `git125 remote` lists (`git push -u <remote> HEAD`); more than one configured remote is126 ambiguous — report it and ask which one, rather than guessing `origin`.1273. **Derive the title** from the first commit ahead of the resolved base — see128 "Deriving the title" below.1294. **Compose the body** — see "Composing the body" below, fetching the template from the130 base branch resolved in step 1.1315. **Open the PR**: `gh pr create -B <base> --title "<title>" --body-file <file>132 --assignee @me`, adding `--draft` unless `--ready` was given.1336. **Report**: the new PR's URL, title, base, draft/ready state, which title tier fired134 (verbatim / documented convention / inferred convention), whether a merge commit was135 detected ahead of base (informational only — see "Deriving the title"), and that it's136 assigned to you.137138## Update path1391401. Fetch the PR's current body and metadata (already done during routing, above). The141 `url` field (`https://github.com/<owner>/<repo>/pull/<n>`) names the PR's own repo —142 this may differ from whatever repo happens to be checked out locally, since a target143 can be a full URL into any repo.1442. If `--base` was given, retarget first: `gh pr edit <target> --base <new-base>`. Do145 this before composing the body, so the template fetch in the next step reads the146 *new* base, not the stale one.1473. If `--ready` or `--draft` was given, toggle the PR's state: `gh pr ready <target>` for148 `--ready`, `gh pr ready <target> --undo` for `--draft`.1494. **Compose the body** — see "Composing the body" below, fetching the template from the150 PR's `baseRefName` (the one just retargeted to, if step 2 ran; the original one151 otherwise).1525. Write the completed body to a scratch file, then update the PR in one call,153 self-assigning it at the same time: `gh pr edit <target> --body-file <file>154 --add-assignee @me`.1556. **Report**: which blanks were filled, which checkboxes were checked, which sections156 were left untouched and why, any retarget or draft-state change applied, and that the157 PR is assigned to you — so the reasoning is visible before anyone reads the PR itself.158159## Composing the body160161Shared by both paths. "The template" means the target repo's own162`.github/PULL_REQUEST_TEMPLATE.md`, fetched from whichever base branch the calling path163already settled on (the create path's step 1, or the update path's `baseRefName`) — never164the head branch, and never a root or `docs/` variant:165166```bash167gh api "repos/<owner>/<repo>/contents/.github/PULL_REQUEST_TEMPLATE.md?ref=<base>" \168 --jq .content | base64 --decode169```170171- If the file exists, keep every heading, HTML comment, and checkbox exactly where it172 puts them.173- If the API call 404s, use the built-in fallback structure: `## What changed`,174 `## Why`, `## Testing`.175- Never delegate this to `gh pr create`'s own `-T`/`--fill`/`--fill-verbose` — `-T` dumps176 a local file's raw text with no blank-filling, and `--fill`/`--fill-verbose` autofill177 from commit messages with no template awareness. Always compose the full body and pass178 it via `--body-file`/`-F`.179180On the update path only, preserve anything already appended after the template's own181structure — CI-generated sections, deploy-preview links, or anything else trailing the182body — byte-for-byte, at the end. Detect this positionally, by what comes after the183template's last section, never by matching a specific section name. The one exception:184drop any AI-attribution line — a trailer crediting an AI assistant or tool for writing or185generating the PR (e.g. "🤖 Generated with [Claude Code]", "Co-Authored-By: <bot>",186"Assisted by Copilot"). Recognize this by what it says, not a fixed list of tool names,187since it's an artifact of *how* the PR was authored, not the target repo's own content —188and never write one when composing a body. On the create path, there is nothing to189conflict with yet, so this step doesn't apply.190191Before grounding any blank, gather the diff evidence once per invocation. A real diff can192run arbitrarily large, unlike every other lookup this skill makes — dispatch a193`general-purpose` subagent to read it instead of pulling it into this session directly. Do194not override the subagent's model: the "never invent" grounding this evidence must hold195up under is a judgment call, not a mechanical extraction, and isn't a place to trade196quality for a cheaper tier.197198Task the subagent to run `git log --first-parent <base>..HEAD` (full commit messages, not199just subjects) and `git diff <base>...HEAD` against the base branch the calling path200already resolved, then report back only what it can ground in that output, in clearly201labeled sections:202203- **Change summary** — what the diff actually does, file by file or logically grouped;204 never what the change is *for*.205- **Testing evidence** — tests added or modified, commands the diff implies running, any206 manual verification steps visible in the diff or commit messages. Omit the category207 entirely rather than pad it with generic boilerplate.208- **Commit messages** — reproduce each one inside a fenced code block, character-for-209 character; never paraphrase, condense, or summarize, even under length pressure. The210 ticket-reasoning fallback below depends on exact footer text — blurring `Refs #9` into211 something that reads like `Closes #9` is exactly the failure this category exists to212 prevent.213- **Ticket references** — any ticket number or tracker URL appearing in a commit message214 or the diff itself.215216The report is evidence, not prose for any specific template — the subagent doesn't know217which template this run will fill, so it must never assume a target's section names or218structure. Mapping the evidence onto the actual template's blanks stays this skill's own219job, below.220221As a cheap cross-check before trusting the report, run `git diff --stat <base>...HEAD`222directly in this session — file list and line counts only, never the full diff — and223compare it against the subagent's change summary. A mismatch (fewer files mentioned than224`--stat` shows, for instance) means re-running the subagent, not composing the body from225an incomplete report.226227Ground every blank in something real, never invention:228229- **What changed** — the diff-evidence subagent's change summary.230- **Why** — see the ticket-reasoning step below.231- **Testing** — the diff-evidence subagent's testing evidence; if it omitted the232 category, leave **Testing** unfilled rather than inventing boilerplate.233- Any other checkbox or blank the template defines — fill only what the diff-evidence234 subagent's report demonstrably supports; leave the rest unchecked or untouched.235- A template's non-blank content — a fixed disclaimer, a Screenshot section — is left236 exactly as the template has it, filled only if the diff demonstrably supports it, never237 invented.238239Fill **Why**'s ticket-reasoning by tracker convention, with exactly one fallback path:240241- Look for a documented tracker or commit-footer convention in the target repo — a242 `CONTRIBUTING.md`, `CLAUDE.md`/`AGENTS.md`, or a `docs/` page describing one.243- If one exists, look for evidence tying *this* PR to a ticket in that convention's own244 terms: `closingIssuesReferences`, the branch name, the diff-evidence subagent's ticket245 references, or a commit message footer matching the convention's format. Follow the246 convention exactly — e.g. a `Closes #<n>` / `Refs #<n>` footer, or a linked tracker URL.247- Whether no convention is documented at all, or one is documented but no ticket applies248 to this PR, fall back identically: write **Why** as a plain prose summary built from the249 diff-evidence subagent's change summary and commit messages, with no ticket reference.250 One fallback path, not two.251252## Deriving the title253254Create path only — the update path never touches an existing PR's title.255256Never use `gh pr create`'s own `--fill-first`: it has a documented, open bug257([cli/cli#10604](https://github.com/cli/cli/issues/10604)) where, on a branch containing258a merge commit, `git log`'s default traversal can surface a commit from the *merged-in*259branch as "first" instead of the actual first commit made on the current branch. Instead,260run the equivalent yourself with `--first-parent`, which sidesteps this unconditionally:261262```bash263git log --reverse --first-parent <base>..HEAD --format=%s264```265266The first line is the first commit's subject.267268Regardless of commit count, check for a merge commit ahead of base: `git log269--first-parent <base>..HEAD --merges`. If non-empty, report it to the developer as270informational — it doesn't block creation or change title derivation (already immune to271it via `--first-parent` above, even when that first-parent commit is itself a merge); it272just surfaces non-linear history before it becomes a rebase-merge conflict at merge time.273274If the `%s` log above has only one line, the branch is single-commit: the title is that275subject, verbatim — done, skip the tier selection below entirely.276277On a multi-commit branch, pick a formatting tier, in priority order, and apply at most278one:279280- **Documented convention.** Check the target repo for a documented PR-title convention281 the same way the "Why" step above checks for a documented ticket-reasoning convention.282 A documented commit-message format convention (e.g. Conventional Commits in283 `AGENTS.md`/`CLAUDE.md`) counts as a documented PR-title convention too, unless the284 repo's docs distinguish PR titles from commit messages explicitly.285- **Inferred convention**, only if no documented one applies: `gh pr list --state merged286 --base <resolved-base> --json title -L 30` — 30 matches `gh pr list`'s own default result287 limit, so the sample size tracks the tool's natural page rather than an arbitrary number.288 Fewer than 5 results: skip this tier entirely — below 5 samples a shared prefix is as289 likely to be coincidence as convention, so there's not enough history to call anything290 established. Otherwise291 compare the sampled titles across three dimensions *together*: a leading prefix format292 (Conventional-Commits `type(scope): `, a ticket bracket like `[ABC-123]`, an emoji, or293 none), the capitalization of the first word after any prefix, and the presence or294 absence of a trailing period. Established only if at least 80% of the sample share the295 same combination of all three — high enough to rule out coincidence, but short of296 unanimity, since one bot-authored or revert PR that breaks the pattern shouldn't297 disqualify an otherwise-real convention. Any failure fetching this history (API error,298 timeout, rate limit) falls back silently to the plain default below — this tier is a299 nice-to-have signal, never load-bearing.300- **Plain default**: the first commit's subject, verbatim.301302Whichever tier applies (documented or inferred), it only ever *reformats* the first303commit's already-grounded subject into that shape — never invents substantive content (a304ticket number the skill has no evidence for, for instance). If the detected shape needs305information the skill can't ground in something real, skip adapting and fall through to306the plain default instead.307308## Worked examples309310**Update path**, dry-run against this skill's own predecessor PR,311[bgutschke/skills#20](https://github.com/bgutschke/skills/pull/20) — its body was still312the raw, unfilled template (`## What changed`, `## Why`, `## Testing`, nothing appended313after it):314315- `gh pr view 20 --json body,closingIssuesReferences` returned an empty body and316 `closingIssuesReferences: []` — GitHub's own linking found no ticket, because this317 repo's commit footers use `Refs #9`, which GitHub doesn't auto-link the way `Closes`318 does.319- This repo's `CLAUDE.md` documents the `Closes #<n>` / `Refs #<n>` footer convention.320 The commit history's actual footer — `Refs #9`, not `Closes #9` — is the evidence, so321 **Why** ends with `Refs #9`, not the `Closes #9` a naive reading of the linked issue322 might suggest.323- **What changed** was written from `git log` and `git diff main...HEAD`: adding the324 `to-pr-description` skill and this repo's first `PULL_REQUEST_TEMPLATE.md`.325- **Testing** was written from what the diff and history actually showed:326 `claude plugin validate . --strict` passing, plus this same dry run.327- The filled body was written and the PR self-assigned in one call — `gh pr edit 20328 --body-file <file> --add-assignee @me` — and the report listed all three sections as329 filled, none left untouched, and the new assignee.330331**Create path title derivation**, dry-run read-only against this repo's real history332(no PR opened by this check): `git log --reverse --first-parent main..HEAD --format=%s`333on a multi-commit working branch returns several conventional-commit subjects. Before334checking the inferred tier, the documented-convention check finds this repo's own335Conventional Commits rule in `CLAUDE.md` — so the documented tier applies and the336inferred-tier query never runs. Confirming what that inferred tier *would* have found337had no documented convention existed: `gh pr list --state merged --base main --json338title -L 30` returned 30 merged PRs, 29 of which share the `type(scope): ` or `type: `339prefix format, lowercase first word, and no trailing period (97%, above the 80% floor) —340so on an undocumented repo this same branch would have landed in the inferred tier341instead of the plain default, with the same result either way, since the first commit's342subject already matches that shape verbatim. Because the documented tier's reformatting343is a no-op whenever the first commit already conforms to it, the title in both cases is344simply that first commit's subject, unmodified.