Enforce GitHub issue template compliance

Issues that don't follow the issue template are automatically labeled with
`needs-template` and receive a bot comment explaining what sections are
missing. The label is removed when the issue is edited to become compliant.
Includes the workflow, decision logic, compliance checker, and comprehensive
tests verifying sync with the actual template definitions.
This commit is contained in:
Jinjing
2026-09-07 09:27:44 -07:00
parent b8311d509a
commit b62e2ca158
3 changed files with 528 additions and 0 deletions
@@ -0,0 +1,163 @@
import { appendFileSync, readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
// Why hardcoded: the runner has no YAML parser without a pnpm install. The
// vitest drift guard fails whenever these diverge from .github/ISSUE_TEMPLATE.
export const ISSUE_TEMPLATES = [
{ file: 'bug_report.yml', name: 'Bug report', requiredFields: ['Operating system', 'Details'] },
{
file: 'feature_request.yml',
name: 'Feature request',
requiredFields: ['Problem or use case', 'Proposed solution']
},
{ file: 'other.yml', name: 'Other', requiredFields: ['Details'] }
]
export const COMPLIANCE_LABEL = 'needs-template'
export const COMMENT_MARKER = '<!-- orca-issue-template-compliance -->'
// Why: GitHub renders a skipped optional form field as this literal.
const EMPTY_FIELD_VALUE = '_No response_'
// Issue forms render each field as `### <label>` followed by the value.
export function parseIssueFormSections(body) {
const sections = new Map()
let current = null
let inFence = false
for (const rawLine of (body ?? '').split(/\r?\n/)) {
const line = rawLine.trimEnd()
if (/^\s*(`{3,}|~{3,})/.test(line)) {
inFence = !inFence
}
const heading = !inFence && line.match(/^### (.+)$/)
if (heading) {
current = heading[1].trim()
if (!sections.has(current)) {
sections.set(current, [])
}
continue
}
if (current) {
sections.get(current).push(rawLine)
}
}
return new Map(
[...sections].map(([label, lines]) => {
const value = lines.join('\n').trim()
return [label, value === EMPTY_FIELD_VALUE ? '' : value]
})
)
}
export function evaluateIssueTemplateCompliance(body, templates = ISSUE_TEMPLATES) {
const sections = parseIssueFormSections(body)
const candidates = templates.map((template) => {
const present = template.requiredFields.filter((field) => sections.has(field))
const missingFields = template.requiredFields.filter((field) => !sections.get(field))
return { template, present: present.length, missingFields }
})
// Why: pick the template the author most likely used, then judge its required fields.
const best = candidates.reduce((a, b) =>
b.present > a.present ||
(b.present === a.present && b.missingFields.length < a.missingFields.length)
? b
: a
)
if (best.present === 0) {
return { compliant: false, template: null, missingFields: [] }
}
return {
compliant: best.missingFields.length === 0,
template: best.template.name,
missingFields: best.missingFields
}
}
function hasComplianceLabel(issue) {
return (issue.labels ?? []).some(
(label) => (typeof label === 'string' ? label : label.name) === COMPLIANCE_LABEL
)
}
// Returns what the workflow should do; performing it stays in the workflow.
export function decideIssueTemplateAction(event, templates = ISSUE_TEMPLATES) {
const { action, issue } = event
if (!issue) {
return { kind: 'skip', reason: 'no issue in event payload' }
}
if (issue.pull_request) {
return { kind: 'skip', reason: 'pull requests are not checked' }
}
if (issue.user?.type === 'Bot') {
return { kind: 'skip', reason: `bot author ${issue.user.login}` }
}
const flagged = hasComplianceLabel(issue)
if (action === 'edited' && !flagged) {
// Why: older issues predate enforcement; only re-check ones this workflow already flagged.
return { kind: 'skip', reason: 'edited issue was never flagged' }
}
if (action !== 'opened' && action !== 'edited') {
return { kind: 'skip', reason: `unhandled action ${action}` }
}
const result = evaluateIssueTemplateCompliance(issue.body, templates)
if (result.compliant) {
return flagged
? { kind: 'clear', template: result.template }
: { kind: 'skip', reason: 'compliant' }
}
return { kind: 'flag', template: result.template, missingFields: result.missingFields }
}
export function renderComplianceComment(decision, repository) {
const chooser = `https://github.com/${repository}/issues/new/choose`
const detail = decision.template
? `It looks like the **${decision.template}** form, but these required sections are empty or missing:\n\n${decision.missingFields.map((field) => `- ${field}`).join('\n')}`
: 'It does not contain any of the sections from our issue forms, so it was probably filed without one (for example through the API or a third-party client).'
return [
COMMENT_MARKER,
'Thanks for the report! This issue was not filed with one of our issue templates, so it is missing information maintainers need to act on it.',
'',
detail,
'',
`Please edit the issue body to follow the matching template from ${chooser}. The \`${COMPLIANCE_LABEL}\` label is removed automatically once the required sections are filled in.`
].join('\n')
}
function writeOutputs(outputs) {
const target = process.env.GITHUB_OUTPUT
const lines = Object.entries(outputs).map(([key, value]) => {
const delimiter = `EOF_${key}_${process.pid}`
return `${key}<<${delimiter}\n${value}\n${delimiter}\n`
})
if (!target) {
process.stdout.write(lines.join(''))
return
}
appendFileSync(target, lines.join(''))
}
function main() {
const eventPath = process.env.GITHUB_EVENT_PATH
if (!eventPath) {
console.error('GITHUB_EVENT_PATH is not set')
return 2
}
const event = JSON.parse(readFileSync(eventPath, 'utf8'))
const decision = decideIssueTemplateAction(event)
console.log(
`Issue #${event.issue?.number}: ${decision.kind}${decision.reason ? ` (${decision.reason})` : ''}`
)
writeOutputs({
kind: decision.kind,
label: COMPLIANCE_LABEL,
marker: COMMENT_MARKER,
comment:
decision.kind === 'flag'
? renderComplianceComment(decision, process.env.GITHUB_REPOSITORY ?? '')
: ''
})
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exitCode = main()
}
@@ -0,0 +1,63 @@
name: Enforce issue templates
on:
issues:
types:
- opened
- edited
permissions:
contents: read
issues: write
concurrency:
group: issue-template-compliance-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Why: `issues` events check out the default branch, so only reviewed code runs
# under the write-scoped token. No dependency install: the script is node-only.
- name: Checkout compliance script
uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: .github/scripts/issue-template-compliance.mjs
sparse-checkout-cone-mode: false
- name: Decide whether the issue follows a template
id: decision
run: node .github/scripts/issue-template-compliance.mjs
- name: Label and comment on a non-compliant issue
if: steps.decision.outputs.kind == 'flag'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_URL: ${{ github.event.issue.html_url }}
LABEL: ${{ steps.decision.outputs.label }}
MARKER: ${{ steps.decision.outputs.marker }}
COMMENT: ${{ steps.decision.outputs.comment }}
run: |
set -euo pipefail
# Why no --force: a maintainer's recolor or reworded description must survive re-runs.
gh label create "$LABEL" --color d93f0b \
--description "Issue does not follow an issue template; awaiting author edit" \
|| echo "Label $LABEL already exists."
gh issue edit "$ISSUE_URL" --add-label "$LABEL"
# Why: an author may edit several times; keep a single bot comment per issue.
if gh issue view "$ISSUE_URL" --json comments --jq '.comments[].body' | grep -qF "$MARKER"; then
echo "Compliance comment already present; skipping."
else
gh issue comment "$ISSUE_URL" --body "$COMMENT"
fi
- name: Clear the label once the issue is compliant
if: steps.decision.outputs.kind == 'clear'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_URL: ${{ github.event.issue.html_url }}
LABEL: ${{ steps.decision.outputs.label }}
run: gh issue edit "$ISSUE_URL" --remove-label "$LABEL"