Development How-to

A Claude Code skill that writes your PR description from the commits, and says so when it can't

A 25-line SKILL.md that pulls the branch's commits and diff into the prompt and drafts the PR body and changelog line. Ten minutes to set up.

Three glass slabs etched with tangled light lines, with one clean lit pane floating above them

Most pull request descriptions fail in one of two ways. They’re empty, or they’re a paragraph of confident filler that restates the branch name. An agent will happily write the second kind, because it has no idea why you made the change unless something tells it.

The fix is to stop asking an agent to remember the branch and start handing it the evidence: the commit messages, the diffstat and the changelog’s existing format, injected into the prompt before the model sees it. Add one rule, that missing reasons get flagged instead of invented, and the output is something you can paste into the PR box after a ten-second read.

Claude Code skills do the injection part natively. This piece builds one, shows what it prints on a throwaway repo, and covers the three ways it goes wrong.

What a skill gives you here

A skill is a folder with a SKILL.md. Claude Code’s skills documentation lists where they can live: ~/.claude/skills/<name>/SKILL.md for everything you work on, or .claude/skills/<name>/SKILL.md inside a repo so the whole team gets it when it’s committed. A project skill beats nothing and loses to a personal skill of the same name, so pick a distinctive name.

The feature that matters for this job is dynamic context injection. A line written as !`command` runs before Claude sees the skill, and its output replaces the line. The model never gets a chance to skip git log or summarise from memory, because by the time it reads the prompt the log is already in it.

The skill

Create .claude/skills/pr-description/SKILL.md in the repo:

---
name: pr-description
description: Draft the pull request description and CHANGELOG entry for the current branch from its commits and diff. Use when the user asks for a PR description, PR body or changelog line.
disable-model-invocation: true
allowed-tools: Bash(git log *) Bash(git diff *) Bash(git merge-base *)
---

Write a pull request description for the current branch against `main`.

## Commits on this branch
!`git log --reverse --format='%h %s%n%b' main..HEAD`

## Files changed
!`git diff --stat main...HEAD`

## Existing changelog format
!`sed -n '1,12p' CHANGELOG.md`

## Output

Print exactly two blocks and nothing else.

1. `PR description`: a "What changed" paragraph of at most three sentences, then a "Why" paragraph, then "How to check" as a short list of commands or manual steps. Only state what the commits and diff show. If the reason for a change isn't in the commit messages, write "Reason not stated in commits" instead of guessing.
2. `Changelog entry`: one line in the format of the existing changelog, under the right heading.

No filler such as "This PR" openings, no emoji, no list of every file.

Each part earns its place.

disable-model-invocation: true means only you can run it, by typing /pr-description. Without it, Claude can decide mid-session that a PR description would be nice and run the skill itself, which is rarely what you want from something that reads your whole branch history.

allowed-tools pre-approves the git commands for the turn that runs the skill. The documentation is explicit that outside auto mode, an injected command that isn’t pre-approved or covered by a permission rule aborts the invocation. It’s also explicit that deny rules still win, so a team that bans git writes can’t be surprised by this skill. The read-only commands above need nothing more.

The three-dot form main...HEAD in the diff compares against the merge base, so commits that landed on main after you branched don’t pollute the stat. The log uses two dots, which for git log gives you the commits on your branch that aren’t on main.

The rule in the output section is the important line in the file. Commit messages are the only record of intent the model has. When they don’t carry a reason, the useful behaviour is to say so, not to produce a plausible one.

Run it

Here’s a scratch repo with a changelog and a branch of two commits. The first has a real message body, and the second is a bare “Add rounding tests”. The history the skill sees:

5a62eae Add rounding tests

ad17338 Round discounts away from zero
Banker's rounding made 0.125 discounts differ from invoices.

Run the skill non-interactively (in a normal session, type /pr-description):

claude -p "/pr-description" < /dev/null

It prints:

PR description

What changed
Discount rounding in src/Pricing.cs now rounds midpoint values away from zero
instead of using banker's rounding. A test in src/PricingTests.cs covers the case.

Why
Banker's rounding made discounts with a 0.125 midpoint differ from the amounts
on invoices.

How to check
- Run dotnet test and confirm the new rounding test passes.
- Apply a discount that comes to 0.125 and confirm it rounds the same way as
  the invoice.

Changelog entry

Under ## Unreleased, ### Fixed:
- Round discounts away from zero so 0.125 discounts match invoices

Every claim traces to a commit message or a file in the diffstat. The “Why” is the commit body, nearly verbatim. The changelog line landed under the existing ### Fixed heading, because the skill injected the first dozen lines of CHANGELOG.md and the model could see that heading was already there.

The case where it must not guess

Now a branch with one commit, “Tweak cart”, no body, adding a file called src/Cart.cs. This is what most real branches look like at 6pm on a Friday:

PR description

What changed
Adds src/Cart.cs containing a single line, x. [...]

Why
Reason not stated in commits. The only commit message is "Tweak cart".

How to check
- git diff main...HEAD -- src/Cart.cs
- Build the project and confirm src/Cart.cs compiles, since its only content is x.

That “Reason not stated in commits” line is the skill doing its job. The honest output of a thin commit history is a thin description, and now you know exactly which paragraph you have to write yourself. Without the rule, the model’s usual behaviour is to infer a motive from the file name and state it as fact.

What goes wrong

Three failures are worth knowing before you hand the skill to a team.

The base branch is hard-coded. The skill says main. On a repo whose default branch is trunk, the injected git log fails, and the documentation says any failed injected command aborts the whole invocation so Claude never sees the skill. Run against a repo with no main, the skill printed nothing at all and the process exited with status 0. A silent empty result is easy to mistake for “nothing to describe”. If your repos use different default branches, commit one skill per repo, or use the skill’s arguments frontmatter to pass the base name in (the documentation shows arguments: [component, from, to] with $component style substitution; this skill doesn’t use it, so check the behaviour on your version first). One trap: ${1:-main} is shell syntax, and a skill body isn’t a shell. Claude Code’s placeholders are $ARGUMENTS, $N and named arguments, and none of them has a default value.

The changelog format drifts. In the “Tweak cart” run, the changelog block came back as a full ## Unreleased section with a ### Changed heading, although the file only had ### Fixed. It’s a reasonable guess and a format departure. If your changelog has a strict set of headings, name them in the skill (“use only Added, Changed, Fixed, Removed”) and tell it which heading applies to which kind of change. The model will otherwise pick by vibes.

The diff is not in the prompt. The skill injects only the diffstat, which keeps the prompt small and the run cheap, but it also means the “What changed” paragraph is built from commit messages and file names, with the model free to read files when it needs to. For a large branch with vague commits, that’s the wrong trade. Swap git diff --stat for git diff main...HEAD and accept the extra tokens, or exclude noisy files with a pathspec such as git diff main...HEAD -- . ':!*.lock' so a lockfile bump doesn’t swamp the context (not run here). Injected output counts against the context like any other text, and a skill’s text stays in context after it runs.

One smaller note from the same run: the second branch’s diff included the skill file itself, because the skill had been committed on the feature branch. Land the skill on main first, or the PR it describes will be describing itself.

Variations

For a team that wants PR bodies in a fixed template, put the template in a second file next to SKILL.md, such as template.md, and link it from the skill. Supporting files are loaded only when the skill points Claude at them, so the template doesn’t cost anything on days nobody writes a PR. The documentation recommends keeping SKILL.md itself under 500 lines.

For a monorepo, scope the log to the package: git log --reverse --format='%h %s%n%b' main..HEAD -- packages/billing. Pair it with a changelog path for that package so the entry lands in the right file.

To stop the skill from running shell at all in a locked-down environment, there’s a setting for that: "disableSkillShellExecution": true replaces every injection in user, project and plugin skills with a policy notice. The skill then returns nothing useful, which is the point, and the setting is documented as leaving bundled and managed skills alone.

If the PR already exists on GitHub, the same skill works as the first step of the flow in the issue-to-draft-PR piece : let the skill draft the body, read it, then paste it into the PR.

When not to bother

Skip it for branches of one or two commits with good messages. The commit message already is the description, and git log is shorter than anything the model will write. It also doesn’t replace thinking about reviewer context: a description tells a reviewer what to look at first, and no skill can know that your risky change is the one-line edit in the middle of the diff.

And if your team’s commit messages read “fix”, “wip” and “more”, the skill will give you “Reason not stated in commits” forever. That’s accurate. It’s also a prompt to write better commit messages.

First ten minutes

Make the skill directory in a repo you have a branch open in, paste the file above, change main if your default branch is named differently, and run /pr-description in Claude Code 2.1.296 or later. Then read the “Why” paragraph and check it against what you know. If it’s right, commit the skill to your default branch and tell the team the command.