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.

Diagram: template sections such as AI share and risk zones and which question each answers for the 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.<...