mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user