diff --git a/.github/workflows/pr-template-format.yml b/.github/workflows/pr-template-format.yml new file mode 100644 index 000000000..33fd2d325 --- /dev/null +++ b/.github/workflows/pr-template-format.yml @@ -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 = ''; + 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 = ''; + 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); diff --git a/scripts/pr-template-policy.cjs b/scripts/pr-template-policy.cjs new file mode 100644 index 000000000..f8dcc5455 --- /dev/null +++ b/scripts/pr-template-policy.cjs @@ -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, +}; diff --git a/tests/pr-template-policy.test.cjs b/tests/pr-template-policy.test.cjs new file mode 100644 index 000000000..e5c818c3d --- /dev/null +++ b/tests/pr-template-policy.test.cjs @@ -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\./); + }); +});