Add PR template format gate (#3412)

* Add PR template format gate

* Address CodeRabbit PR template gate feedback
This commit is contained in:
Tom Boucher
2026-05-11 16:39:45 -04:00
committed by GitHub
parent 656fb5868c
commit dd49abd32f
3 changed files with 446 additions and 0 deletions

105
.github/workflows/pr-template-format.yml vendored Normal file
View File

@@ -0,0 +1,105 @@
name: PR Template Format
on:
pull_request_target:
types: [opened, edited, reopened, synchronize, ready_for_review]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
check-template-format:
name: Pull request template format
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Check out policy from base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Evaluate PR template format
id: policy
env:
PR_BODY: ${{ github.event.pull_request.body }}
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
run: node scripts/pr-template-policy.cjs
- name: Warn trusted contributor about missing template
if: steps.policy.outputs.action == 'warn'
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
with:
script: |
const marker = '<!-- gsd-pr-template-policy -->';
const pr = context.payload.pull_request;
const policy = JSON.parse(${{ toJSON(steps.policy.outputs.result) }});
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 100,
});
if (!comments.some((comment) => comment.body && comment.body.includes(marker))) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: [
marker,
'## Please use the PR template format',
'',
'This PR is allowed to stay open because the author is a contributor or higher, but the body does not follow one of the required PR templates.',
'',
'Please update the PR description to use the fix, enhancement, or feature template format from `CONTRIBUTING.md` so the automated documentation modules can do their job.',
'',
`Detected problem: ${policy.reason}`,
].join('\n'),
});
}
core.warning(policy.reason);
- name: Comment, close, and fail untrusted malformed PR
if: steps.policy.outputs.action == 'close'
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
with:
script: |
const marker = '<!-- gsd-pr-template-policy -->';
const pr = context.payload.pull_request;
const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`;
const policy = JSON.parse(${{ toJSON(steps.policy.outputs.result) }});
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 100,
});
if (!comments.some((comment) => comment.body && comment.body.includes(marker))) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: [
marker,
'## PR template required - PR auto-closed',
'',
'This PR does not follow one of the required pull request templates from the contribution guidelines.',
'',
'Please reopen with the correct typed template:',
'',
`- [Fix PR template](${repoUrl}/blob/main/.github/PULL_REQUEST_TEMPLATE/fix.md)`,
`- [Enhancement PR template](${repoUrl}/blob/main/.github/PULL_REQUEST_TEMPLATE/enhancement.md)`,
`- [Feature PR template](${repoUrl}/blob/main/.github/PULL_REQUEST_TEMPLATE/feature.md)`,
'',
'This check only validates template format. It does not close PRs for an unfilled issue number such as `Fixes #`; the issue-link workflow handles issue references separately after the PR exists.',
'',
`Detected problem: ${policy.reason}`,
].join('\n'),
});
}
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed',
});
core.setFailed(policy.reason);

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env node
const TRUSTED_AUTHOR_ASSOCIATIONS = new Set([
'CONTRIBUTOR',
'COLLABORATOR',
'MEMBER',
'OWNER',
]);
const DEFAULT_TEMPLATE_MARKERS = [
'Wrong template',
'Every PR must use a typed template',
'Select the template that matches your PR',
];
const TEMPLATES = [
{
name: 'fix',
heading: 'Fix PR',
requiredHeadings: [
'Fix PR',
'Linked Issue',
'What was broken',
'What this fix does',
'Testing',
'Checklist',
],
},
{
name: 'enhancement',
heading: 'Enhancement PR',
requiredHeadings: [
'Enhancement PR',
'Linked Issue',
'What this enhancement improves',
'Before / After',
'How it was implemented',
'Testing',
'Scope confirmation',
'Checklist',
],
},
{
name: 'feature',
heading: 'Feature PR',
requiredHeadings: [
'Feature PR',
'Linked Issue',
'Feature summary',
'What changed',
'Implementation notes',
'Spec compliance',
'Testing',
'Scope confirmation',
'Checklist',
],
},
];
function stripMarkdownDecoration(value) {
return value
.replace(/^\s*#+\s*/, '')
.replace(/\s*#+\s*$/, '')
.replace(/\*\*/g, '')
.trim()
.toLowerCase();
}
function extractHeadings(body) {
const headings = new Set();
for (const line of String(body || '').split(/\r?\n/)) {
if (/^\s*#{1,6}\s+\S/.test(line)) {
headings.add(stripMarkdownDecoration(line));
}
}
return headings;
}
function includesDefaultTemplate(body) {
const text = String(body || '').toLowerCase();
return DEFAULT_TEMPLATE_MARKERS.some((marker) => text.includes(marker.toLowerCase()));
}
function matchingTemplate(body) {
const headings = extractHeadings(body);
for (const template of TEMPLATES) {
if (!headings.has(stripMarkdownDecoration(template.heading))) continue;
const missingHeadings = template.requiredHeadings.filter((heading) => {
return !headings.has(stripMarkdownDecoration(heading));
});
return {
template: template.name,
missingHeadings,
};
}
return {
template: null,
missingHeadings: [],
};
}
function evaluatePrTemplate(body, authorAssociation) {
const association = String(authorAssociation || '').toUpperCase();
const trusted = TRUSTED_AUTHOR_ASSOCIATIONS.has(association);
const normalizedBody = String(body || '').trim();
let valid = true;
let reason = 'PR body uses a typed pull request template.';
let template = null;
let missingHeadings = [];
if (!normalizedBody) {
valid = false;
reason = 'PR body is empty; a typed pull request template is required.';
} else if (includesDefaultTemplate(normalizedBody)) {
valid = false;
reason = 'PR body still contains the default wrong-template guidance.';
} else {
const match = matchingTemplate(normalizedBody);
template = match.template;
missingHeadings = match.missingHeadings;
if (!template) {
valid = false;
reason = 'PR body does not match the fix, enhancement, or feature template.';
} else if (missingHeadings.length > 0) {
valid = false;
reason = `PR body appears to use the ${template} template but is missing required headings.`;
}
}
let action = 'pass';
if (!valid) {
action = trusted ? 'warn' : 'close';
}
return {
valid,
action,
trusted,
authorAssociation: association || 'UNKNOWN',
template,
reason,
missingHeadings,
};
}
function main() {
const result = evaluatePrTemplate(process.env.PR_BODY || '', process.env.AUTHOR_ASSOCIATION || '');
process.stdout.write(`${JSON.stringify(result)}\n`);
if (process.env.GITHUB_OUTPUT) {
const fs = require('fs');
fs.appendFileSync(process.env.GITHUB_OUTPUT, `result=${JSON.stringify(result)}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `action=${result.action}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `valid=${result.valid ? 'true' : 'false'}\n`);
}
}
if (require.main === module) {
main();
}
module.exports = {
evaluatePrTemplate,
extractHeadings,
includesDefaultTemplate,
matchingTemplate,
};

View File

@@ -0,0 +1,174 @@
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { evaluatePrTemplate } = require('../scripts/pr-template-policy.cjs');
const fixBody = [
'## Fix PR',
'',
'## Linked Issue',
'Fixes #123',
'',
'## What was broken',
'The thing was broken.',
'',
'## What this fix does',
'The thing now works.',
'',
'## Root cause',
'A missing guard.',
'',
'## Testing',
'node --test tests/example.test.cjs',
'',
'## Checklist',
'- [x] Issue linked above with `Fixes #NNN`',
].join('\n');
const enhancementBody = [
'## Enhancement PR',
'',
'## Linked Issue',
'Closes #123',
'',
'## What this enhancement improves',
'Existing output.',
'',
'## Before / After',
'**Before:** noisy',
'**After:** clear',
'',
'## How it was implemented',
'Small refactor.',
'',
'## Testing',
'node --test tests/example.test.cjs',
'',
'## Scope confirmation',
'- [x] Matches approved issue.',
'',
'## Checklist',
'- [x] Tests pass',
].join('\n');
const featureBody = [
'## Feature PR',
'',
'## Linked Issue',
'Closes #123',
'',
'## Feature summary',
'Adds a new thing.',
'',
'## What changed',
'### New files',
'None.',
'### Modified files',
'One file.',
'',
'## Implementation notes',
'Implemented as approved.',
'',
'## Spec compliance',
'- [x] Criterion met',
'',
'## Testing',
'node --test tests/example.test.cjs',
'',
'## Scope confirmation',
'- [x] Exact scope.',
'',
'## Checklist',
'- [x] Tests pass',
].join('\n');
describe('pr-template-policy', () => {
test('passes PR bodies that use the fix template', () => {
const result = evaluatePrTemplate(fixBody, 'NONE');
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.template, 'fix');
});
test('passes PR bodies that use the enhancement template', () => {
const result = evaluatePrTemplate(enhancementBody, 'FIRST_TIMER');
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.template, 'enhancement');
});
test('passes PR bodies that use the feature template', () => {
const result = evaluatePrTemplate(featureBody, 'FIRST_TIME_CONTRIBUTOR');
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.template, 'feature');
});
test('closes first-time PRs that keep the default template', () => {
const result = evaluatePrTemplate([
'## Wrong template - please use the correct one for your PR type',
'',
'Every PR must use a typed template.',
].join('\n'), 'FIRST_TIMER');
assert.equal(result.valid, false);
assert.equal(result.action, 'close');
assert.equal(result.trusted, false);
assert.match(result.reason, /default wrong-template guidance/);
});
test('warns contributors instead of closing when the template is missing', () => {
const result = evaluatePrTemplate('This is a free-form PR body.', 'CONTRIBUTOR');
assert.equal(result.valid, false);
assert.equal(result.action, 'warn');
assert.equal(result.trusted, true);
});
test('warns collaborators, members, and owners instead of closing', () => {
for (const association of ['COLLABORATOR', 'MEMBER', 'OWNER']) {
const result = evaluatePrTemplate('This is a free-form PR body.', association);
assert.equal(result.valid, false);
assert.equal(result.action, 'warn');
assert.equal(result.trusted, true);
}
});
test('does not close for an unfilled issue slug when the template is present', () => {
const body = fixBody.replace('Fixes #123', 'Fixes #');
const result = evaluatePrTemplate(body, 'FIRST_TIMER');
assert.equal(result.valid, true);
assert.equal(result.action, 'pass');
assert.equal(result.template, 'fix');
});
test('closes first-time PRs that remove required template sections', () => {
const result = evaluatePrTemplate(fixBody.replace('## What was broken', '## Background'), 'NONE');
assert.equal(result.valid, false);
assert.equal(result.action, 'close');
assert.deepEqual(result.missingHeadings, ['What was broken']);
});
test('closes first-time PRs with empty body', () => {
const result = evaluatePrTemplate('', 'FIRST_TIMER');
assert.equal(result.valid, false);
assert.equal(result.action, 'close');
assert.match(result.reason, /PR body is empty; a typed pull request template is required\./);
});
test('warns trusted contributors with empty body', () => {
const result = evaluatePrTemplate('', 'CONTRIBUTOR');
assert.equal(result.valid, false);
assert.equal(result.action, 'warn');
assert.equal(result.trusted, true);
assert.match(result.reason, /PR body is empty; a typed pull request template is required\./);
});
});