A pull request template is a small Markdown file in your repository that GitHub automatically inserts into every new PR description. Sounds unremarkable, and for a long time it was: “What changes?”, “How was it tested?”, done. Since a large share of code now comes from Copilot, Claude Code or other agents, this file has taken on a new job. It is where the team sees how a change came about and where the review needs to look more closely.
This topic matters to me because whenever I think about automation bias, I end up in the same place: good intentions from a team meeting rarely survive until the next sprint. What helps are small guardrails right inside the workflow. In this article you build a template with an AI section and risk zones, back it up with CODEOWNERS and let a GitHub Action check whether it was actually filled in.
In short
A pull request template with a short AI section and a few risk zones gives the review exactly the information that is otherwise missing for AI-generated code. Only an automated check turns it into a real rule. The biggest pitfall is a checklist so long that after two weeks everyone ticks it blindly.
What you will learn in this article
- Where GitHub looks for the pull request template and when it applies
- What an AI section looks like that informs rather than accuses
- How to combine risk zones with CODEOWNERS and a checklist
- How a GitHub Action checks the PR description in TypeScript
- How to avoid script injection in the workflow
Why a pull request template helps with AI code
In a classic review you read code that a human wrote. You can assume the person typed every line deliberately and can explain why it is there. With AI-generated code that assumption no longer holds automatically. An agent produces 400 lines in five minutes that look clean, are neatly formatted and can still contain a wrong assumption. That is exactly where automation bias kicks in: whatever looks professional gets read less critically.
Of course a pull request template does not solve this on its own. It does make sure that three pieces of information show up every single time. First: how much AI is in the change? Second: has the author read and understood the code themselves? Third: does the PR touch an area where a mistake really hurts, for example permissions or payments? With these answers the review can direct its attention to where it is needed.
On top of that, the template is cheap. You need no new tool, no license and no process workshop. One file in the repository, a few lines of YAML, and the team has a shared language for the topic.
Where GitHub looks for the pull request template
GitHub recognizes a file named pull_request_template.md in three places: the repository root, the docs/ folder or the hidden .github/ folder. I always put it in .github/, next to workflows and CODEOWNERS, which keeps the root tidy.
The branch matters: the template only becomes available once it is in the repository’s default branch, usually main. As long as it only exists in your feature branch, you will still see an empty field when creating the PR. That regularly causes confusion the first time you try it.
Multiple templates and the GitHub CLI
If you need different templates, say for features and for hotfixes, create a PULL_REQUEST_TEMPLATE/ subfolder. GitHub then no longer picks a file automatically. You choose the template with the URL parameter template, for example ?template=hotfix.md on the compare link. For most teams a single template is enough. Every additional one is a decision someone has to make before each PR.
If you create PRs in the terminal with the GitHub CLI, you can pass the template as starting content with --template. Agents that open PRs via gh pr create in particular should be told about it, for example in the AGENTS.md. Otherwise a free-form description ends up in the PR and the check further down fails.
gh pr create --title "Rentals: return via QR code" --template .github/pull_request_template.md
The pull request template for AI code in detail
For the example I use a fictional repository of a bike rental: a booking API with Lambda functions, infrastructure under infra/, database migrations and a payments module. This is what the template looks like:
## What changes?
<!-- One to three sentences: what does this PR do, from the user's or the system's point of view? -->
## Why?
<!-- Link to the ticket or a short reason. -->
## How was it tested?
<!-- What did you run? Which cases did you check by hand? -->
## AI share
<!-- Tick exactly one option. This is not a confession, it is a hint for the review. -->
- [ ] No AI-generated code
- [ ] Accepted AI suggestions (autocomplete, small parts)
- [ ] Larger parts generated with AI (agent, chat, whole files)
Tool and model (optional):
## Checklist
- [ ] I have read every changed line and can explain it.
- [ ] I checked new dependencies for name, origin and license.
- [ ] Tests also cover a failure case, not just the happy path.
### Risk zones (only tick if affected)
- [ ] Permissions checked: IAM roles and policies as narrow as possible
- [ ] Data model checked: migration is backward compatible
- [ ] Payments checked: amounts, rounding and idempotency tested
## Notes for the review
<!-- Where should someone look especially closely? -->
The hints in <!-- … --> are HTML comments. They show up in the text field while writing, and GitHub hides them in the rendered PR view. That way the instructions stay in the template without cluttering the description.
Why the AI section is not a pillory
The trickiest part is the question about the AI share. If it lands the wrong way, people tick “No AI-generated code” to be on the safe side, and the information is worthless. That is why the comment says explicitly that this is a hint for the review. Using AI is welcome, we just want to know where to read more carefully. Leads also have to model this attitude, for example by stating openly in their own PRs what the agent wrote.
The three levels are deliberately coarse. Nobody can honestly say whether 30 or 60 percent of a file came from autocomplete. “Small parts” versus “whole files from the agent”, on the other hand, anyone can answer right away.
Risk zones instead of endless checklists
Long checklists die quickly. By the tenth PR, 15 boxes get ticked on autopilot and nobody reads what they say anymore. I therefore keep the general checklist at three items and add a separate line only for a few risk zones. Those lines only need to be ticked when the PR actually touches the zone. Which zones they are is up to the team. How to find them and write them down as glob patterns is covered in detail in my article on team rules for AI code review.
Anatomy of the template: each section answers a question the review would otherwise have to ask.
CODEOWNERS complements the template
A tick next to “Permissions checked” is a self-assessment. For risk zones, someone who knows the area should approve as well. That is what the CODEOWNERS file does: it maps paths to people or teams that GitHub automatically requests as reviewers.
# .github/CODEOWNERS
# Default: the whole team may review
* @bike-rental/development
# Risk zones: someone with domain knowledge has to approve here
/infra/ @bike-rental/platform
/src/payments/ @bike-rental/payments
/src/db/migrations/ @bike-rental/data
# Protect the rules themselves
/.github/ @bike-rental/platform
You should know three details from the documentation. When several patterns match, the last one wins, which is why the general rule with * sits at the very top. The listed teams need write access to the repository, otherwise they are silently ignored. And only the setting “Require review from Code Owners” in a branch protection rule or a ruleset makes the approval mandatory. The team names in the example are made up. Replace them with your real teams.
Step by step: check the pull request template automatically
A template nobody fills in is decoration. So a GitHub Action checks on every PR whether the most important fields are filled in and whether the risk zones match the changed files. The script is written in TypeScript and runs without a build step because Node.js 24 can strip type annotations directly.
Prerequisites
- A GitHub repository where you are allowed to create workflows and rulesets
- Node.js 22.18 or newer locally (for the tests), Node.js 24 in the workflow
- Basic knowledge of TypeScript and GitHub Actions
Step 1: Add the template and CODEOWNERS
Create both files from above under .github/, adjust risk zones and teams, and merge them into the default branch. From then on the description field of every new PR is prefilled.
Step 2: Write the check script
The script splits the description at the H2 headings, removes HTML comments and reads all checkboxes. It then checks four rules: the test section is not empty, exactly one option is selected for the AI share, the read confirmation is ticked for AI code, and every touched risk zone is confirmed. Errors are printed in the ::error:: format, so they show up directly as annotations in the PR.
// scripts/check-pr-body.ts
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
export type Checkbox = { checked: boolean; text: string };
// Paths that need an extra confirmation in the PR description
export const riskZones = [
{ prefixes: ['infra/', 'serverless.yml'], confirm: 'Permissions checked' },
{ prefixes: ['src/db/migrations/'], confirm: 'Data model checked' },
{ prefixes: ['src/payments/'], confirm: 'Payments checked' },
];
const NO_AI = 'No AI-generated code';
const READ_EVERY_LINE = 'I have read every changed line';
// HTML comments from the template do not count as content
export function stripComments(markdown: string): string {
return markdown.replace(/<!--[\s\S]*?-->/g, '');
}
// Splits the text at the H2 headings ("## Title")
export function sections(markdown: string): Map<string, string> {
const result = new Map<string, string>();
const parts = stripComments(markdown).split(/^##\s+/m).slice(1);
for (const part of parts) {
const [heading, ...rest] = part.split('\n');
result.set(heading.trim(), rest.join('\n'));
}
return result;
}
export function checkboxes(markdown: string): Checkbox[] {
const pattern = /^\s*[-*]\s+\[([ xX])\]\s+(.+)$/gm;
return [...markdown.matchAll(pattern)].map((m) => ({
checked: m[1].toLowerCase() === 'x',
text: m[2].trim(),
}));
}
function isChecked(boxes: Checkbox[], start: string): boolean {
return boxes.some((b) => b.checked && b.text.startsWith(start));
}
export function checkPrBody(body: string, changedFiles: string[]): string[] {
const errors: string[] = [];
const parts = sections(body);
const allBoxes = checkboxes(stripComments(body));
const tested = (parts.get('How was it tested?') ?? '').trim();
if (tested.length < 10) {
errors.push('Please describe under “How was it tested?” what you checked.');
}
const aiSection = parts.get('AI share');
if (aiSection === undefined) {
errors.push('The “AI share” section is missing. Was the template deleted?');
} else {
const chosen = checkboxes(aiSection).filter((b) => b.checked);
if (chosen.length !== 1) {
errors.push(`Please tick exactly one option under “AI share” (currently: ${chosen.length}).`);
} else if (!chosen[0].text.startsWith(NO_AI) && !isChecked(allBoxes, READ_EVERY_LINE)) {
errors.push('For AI code, please confirm: “I have read every changed line and can explain it.”');
}
}
for (const zone of riskZones) {
const touched = changedFiles.some((file) => zone.prefixes.some((p) => file.startsWith(p)));
if (touched && !isChecked(allBoxes, zone.confirm)) {
errors.push(`This PR changes a risk zone. Please tick “${zone.confirm}”.`);
}
}
return errors;
}
// Call in the workflow: node scripts/check-pr-body.ts changed-files.txt
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const body = process.env.PR_BODY ?? '';
const listFile = process.argv[2];
const changed = listFile
? readFileSync(listFile, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean)
: [];
const errors = checkPrBody(body, changed);
for (const message of errors) console.log(`::error title=PR description::${message}`);
if (errors.length > 0) process.exit(1);
console.log('PR description is complete.');
}
Matching risk zones deliberately uses simple path prefixes instead of glob patterns. That covers most cases and needs no extra dependency. If you need finer patterns, you can add a package such as picomatch later.
Step 3: Tests with the Node.js test runner
The tests use the real template from the repository. If someone renames a heading later, you notice immediately that template and script no longer fit together.
// scripts/check-pr-body.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { checkPrBody } from './check-pr-body.ts';
const template = readFileSync('.github/pull_request_template.md', 'utf8');
function filled(edit: (t: string) => string): string {
const base = template.replace(
'## How was it tested?\n',
'## How was it tested?\n\nRan the unit tests and created a booking in the test system.\n',
);
return edit(base);
}
test('empty template fails', () => {
const errors = checkPrBody(template, []);
assert.equal(errors.length, 2);
assert.match(errors[0], /How was it tested/);
assert.match(errors[1], /exactly one option/);
});
test('without AI code one option is enough', () => {
const body = filled((t) => t.replace('- [ ] No AI', '- [x] No AI'));
assert.deepEqual(checkPrBody(body, ['src/booking/rent.ts']), []);
});
test('AI code requires the read confirmation', () => {
const body = filled((t) => t.replace('- [ ] Larger parts', '- [x] Larger parts'));
const errors = checkPrBody(body, []);
assert.equal(errors.length, 1);
assert.match(errors[0], /every changed line/);
const fixed = body.replace('- [ ] I have read every', '- [x] I have read every');
assert.deepEqual(checkPrBody(fixed, []), []);
});
test('risk zone requires the matching confirmation', () => {
const body = filled((t) => t.replace('- [ ] No AI', '- [x] No AI'));
const errors = checkPrBody(body, ['infra/bike-api-stack.ts']);
assert.deepEqual(errors, ['This PR changes a risk zone. Please tick “Permissions checked”.']);
const fixed = body.replace('- [ ] Permissions', '- [X] Permissions');
assert.deepEqual(checkPrBody(fixed, ['infra/bike-api-stack.ts']), []);
});
test('missing section is reported', () => {
const errors = checkPrBody('## How was it tested?\n\nTested everything locally.\n', []);
assert.deepEqual(errors, ['The “AI share” section is missing. Was the template deleted?']);
});
For type checking you need a small tsconfig.json with allowImportingTsExtensions and noEmit, because the test imports the file with the .ts extension. Then run type checking and tests like this:
npm install --save-dev typescript @types/node
npx tsc --noEmit
node --test scripts/check-pr-body.test.ts
Expected result: five passing tests (# pass 5). If your local Node.js is older than 22.18, it reports an error about an unknown file extension. Then update, or use npx tsx --test for the time being.
Step 4: Create the workflow
# .github/workflows/pr-description.yml
name: Check PR description
on:
pull_request:
types: [opened, edited, reopened, synchronize, ready_for_review]
permissions:
contents: read
jobs:
check:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v5
with:
node-version: 24
- name: Find changed files
env:
BASE_REF: ${{ github.base_ref }}
run: git diff --name-only "origin/${BASE_REF}...HEAD" > changed-files.txt
- name: Check description
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: node scripts/check-pr-body.ts changed-files.txt
The edited trigger makes the check run again as soon as someone improves the description. The job skips drafts so nobody sees red crosses on the first push. With fetch-depth: 0 the checkout fetches the full history, otherwise git diff cannot find the target branch.
Avoid script injection
One point deserves special attention: the PR description is external input. If you wrote it directly into the run line with ${{ github.event.pull_request.body }}, GitHub would insert the text into the shell script before execution. A description with a cleverly placed $(…) could then run commands on the runner. GitHub therefore recommends passing such values through an environment variable, exactly as the workflow above does with PR_BODY. For the same reason the workflow uses pull_request and not pull_request_target, which runs with write permissions in the context of the target repository. Why that is risky is explained in the article on pull_request_target.
Step 5: Make the check mandatory
In the repository settings, open the ruleset for the default branch under “Rules” (or create one). Enable “Require status checks to pass” and add the job check. In the same ruleset, enable “Require review from Code Owners”. From now on a PR can only be merged once the description is complete and, for risk zones, the responsible people have approved.
🎬 Flow: pull request with template, automated check and code owner review – this animated diagram only runs on my site: watch it on sascha-ms.de
What you see in the end
If you open a PR with an empty template, the “Check PR description” check turns red, and the summary lists the specific hints, such as “Please tick exactly one option under AI share”. Once you fill in the description and save, the check restarts and turns green. If the PR changes files under infra/, the check additionally asks for the “Permissions checked” tick, and GitHub requests the platform team as reviewers.
Costs are negligible: the job runs on a standard runner and usually takes less than a minute. Standard runners are free in public repositories, in private ones the runtime counts against your plan’s minutes.
Common mistakes with PR templates
The most common mistake is a checklist that tries to cover everything. If every PR needs 15 ticks, each one is worth next to nothing. Start with three general items and only extend the list when a real incident shows that something is missing.
Second, people often test the template in a feature branch and then think it is broken. It only applies after the merge into the default branch. Third, someone renames a heading, say “How was it tested?” to “Tests”, and suddenly the automated check reports an error on every PR. That is why the tests read the real template.
A fourth mistake is about culture: the template gets sold as a control instrument. Then people tick boxes strategically. Explain to the team what the information is needed for, and never use it to assess individuals. Also, the description should not contain secrets, customer data or internal links that anyone can read in public repositories.
My take
I consider the pull request template one of the most underrated tools for working with AI in a team. It costs almost nothing, takes an hour to set up and still changes what the review talks about. The sentence “I have read every changed line and can explain it” is my favorite. It forbids nothing, but it reminds everyone of the responsibility that stays with the human.
I am skeptical about attempts to measure the AI share or even state it as a percentage. That creates false precision and invites window dressing. Nor do I think much of requiring a second review for all AI code across the board. The risk zone determines how deep the review goes, not the tool the code was written with. A hand-written mistake in an IAM policy is just as dangerous as one from an agent.
You can check whether the template works in practice with a few metrics for AI-generated code at team level, for example follow-up defects and review time by risk zone.
FAQ
Where do I put the pull request template on GitHub?
As pull_request_template.md in the repository root, in docs/ or in .github/. It takes effect once it is in the default branch.
Can I have multiple PR templates in one repository?
Yes. Put them in a PULL_REQUEST_TEMPLATE/ subfolder and select the one you want with the URL parameter template. There is no automatic selection in that case.
Can I set a PR template for all repositories of an organization?
Yes, GitHub supports default templates for an organization or a personal account as so-called community health files. A template in the individual repository takes precedence.
Should you label AI-generated code in pull requests?
I recommend it, as a hint for the review rather than as a control. A rough classification into three levels is enough. Exact percentages can neither be measured honestly nor do they help.
Your next step
Add the template to one repository today, without a workflow and without making it mandatory. Collect experience for two weeks and talk in the retro about which information actually helped in reviews. Only then add the automated check and the ruleset.
Which question in your PR template would you never give up again? Let me know in the comments.
Some pull requests implement a larger architecture decision. In that case, a separate document pays off: I show how this works with architecture decision records, including a template, an example and a small tool.
Sources
- GitHub Docs: Creating a pull request template for your repository
- GitHub Docs: About code owners
- GitHub Docs: Security hardening for GitHub Actions
- GitHub CLI Manual: gh pr create
- Node.js Docs: Modules: TypeScript
This post was created with AI assistance.
👉 Next step: New here? My start page collects the best articles, tools and learning paths in one place.
📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)