Add PR template format gate (#3412)
* Add PR template format gate * Address CodeRabbit PR template gate feedback
This commit is contained in:
105
.github/workflows/pr-template-format.yml
vendored
Normal file
105
.github/workflows/pr-template-format.yml
vendored
Normal 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);
|
||||
167
scripts/pr-template-policy.cjs
Normal file
167
scripts/pr-template-policy.cjs
Normal 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,
|
||||
};
|
||||
174
tests/pr-template-policy.test.cjs
Normal file
174
tests/pr-template-policy.test.cjs
Normal 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\./);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user