diff --git a/.github/scripts/issue-status-fallback.test.mjs b/.github/scripts/issue-status-fallback.test.mjs index 4d54d0b8d..9a1827909 100644 --- a/.github/scripts/issue-status-fallback.test.mjs +++ b/.github/scripts/issue-status-fallback.test.mjs @@ -6,7 +6,7 @@ const workflow = () => fs.readFileSync('.github/workflows/issue-status-fallback. const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; // Execute the actual github-script body, not a separate implementation of its policy. -async function runWorkflow(currentLabels, { eventLabels = [], readError, writeError } = {}) { +async function runWorkflow(currentLabels, { eventLabels = [], body = null, eventBody = null, readError, writeError } = {}) { const source = workflow(); const script = source.match(/ script: \|\r?\n([\s\S]+)$/)?.[1]; assert.ok(script, 'workflow contains an executable github-script body'); @@ -15,7 +15,7 @@ async function runWorkflow(currentLabels, { eventLabels = [], readError, writeEr const context = { repo: { owner: 'example', repo: 'engram' }, issue: { number: 42 }, - payload: { issue: { labels: eventLabels.map((name) => ({ name })) } }, + payload: { issue: { body: eventBody, title: 'Bug feature documentation question', labels: eventLabels.map((name) => ({ name })) } }, }; const github = { rest: { @@ -23,7 +23,7 @@ async function runWorkflow(currentLabels, { eventLabels = [], readError, writeEr async get(args) { calls.push(['get', args]); if (readError) throw readError; - return { data: { labels: currentLabels.map((name) => ({ name })) } }; + return { data: { body, labels: currentLabels.map((name) => ({ name })) } }; }, async addLabels(args) { calls.push(['addLabels', args]); @@ -44,6 +44,67 @@ async function runWorkflow(currentLabels, { eventLabels = [], readError, writeEr const issue = { owner: 'example', repo: 'engram', issue_number: 42 }; +// Required field labels are metadata, not a classification of the answers. +const signatures = { + bug: ['Existing Issue Search', '📝 Observed Consequence', '🔄 Steps to Reproduce', '✅ Expected Behavior', '❌ Actual Behavior', 'Operating System', 'Release Version (Report Context)', 'Agent / Client'], + feature: ['Existing Issue Search', '🔍 Observed Consequence', '💡 Desired Outcome', '📦 Affected Area', 'Release Context'], + docs: ['Existing Issue Search', '📄 Documentation Reference', '📄 Which Document?', '🔍 Observed Consequence', 'Release Context'], + question: ['Existing Issue Search', 'Question', 'Why does this need issue tracking?', 'Affected Area', 'Release Context'], +}; +const formBody = (type) => signatures[type].map((heading) => `### ${heading}\n\nAnswer`).join('\n\n'); + +async function expectPolicy(initial, body, additions, options = {}) { + const result = await runWorkflow([...initial], { body, ...options }); + assert.equal(result.error, options.writeError ?? options.readError); + assert.deepEqual(result.calls, [ + ['get', issue], + ...(!options.readError && additions.length ? [['addLabels', { ...issue, labels: additions }]] : []), + ]); + assert.deepEqual(result.labels, options.writeError || options.readError ? initial : [...initial, ...additions]); + assert.deepEqual(result.originalLabels, initial); +} + +test('recovers each current form type independently of status and preserves labels', async () => { + for (const type of Object.keys(signatures)) { + for (const status of [[], ['status:approved']]) { + await expectPolicy([...status, 'help wanted'], formBody(type), [...(status.length ? [] : ['status:needs-review']), `type:${type}`]); + } + await expectPolicy(['type:custom', 'priority:high'], formBody(type), ['status:needs-review']); + await expectPolicy(['type:custom', 'status:blocked'], formBody(type), []); + } +}); + +test('uses only current body and accepts ordinary Markdown line endings', async () => { + await expectPolicy(['status:approved'], formBody('docs').replaceAll('\n', '\r\n'), ['type:docs'], { eventBody: formBody('bug') }); + await expectPolicy(['status:approved'], null, [], { eventBody: formBody('bug') }); +}); + +test('unknown incomplete ambiguous and fenced structures do not infer types', async () => { + for (const body of [null, '', 'bug documentation feature question', '### Question', formBody('bug') + '\n' + formBody('docs'), '```markdown\n' + formBody('bug') + '\n```', '~~~~markdown\n' + formBody('docs') + '\n~~~~']) { + await expectPolicy([], body, ['status:needs-review']); + } + for (const type of Object.keys(signatures)) { + for (const missing of signatures[type]) { + await expectPolicy(['status:approved'], formBody(type).replace(`### ${missing}\n`, ''), []); + } + } + await expectPolicy(['status:approved'], formBody('bug') + '\n```\n' + formBody('docs') + '\n```', ['type:bug']); +}); + +test('type recovery API failures preserve input and propagate', async () => { + await expectPolicy(['status:approved'], formBody('bug'), [], { readError: new Error('read denied') }); + await expectPolicy(['status:approved'], formBody('bug'), ['type:bug'], { writeError: new Error('write denied') }); +}); + +test('signatures remain aligned with required fields of current forms', () => { + const files = { bug: 'bug_report', feature: 'feature_request', docs: 'docs_improvement', question: 'tracked_question' }; + for (const [type, file] of Object.entries(files)) { + const source = fs.readFileSync(`.github/ISSUE_TEMPLATE/${file}.yml`, 'utf8'); + const required = source.split(/\r?\n - type:/).slice(1).filter((block) => /required: true/.test(block)).map((block) => block.match(/ label: (.+)/)[1].replace(/^"|"$/g, '')); + assert.deepEqual(signatures[type], required); + } +}); + test('adds only the review status when current labels have no status', async () => { for (const initial of [[], ['type:bug', 'priority:high', 'help wanted']]) { const result = await runWorkflow([...initial]); diff --git a/.github/workflows/issue-status-fallback.yml b/.github/workflows/issue-status-fallback.yml index 3e555e801..bcb0ca3e2 100644 --- a/.github/workflows/issue-status-fallback.yml +++ b/.github/workflows/issue-status-fallback.yml @@ -11,7 +11,7 @@ jobs: ensure-review-status: runs-on: ubuntu-latest steps: - - name: Add review status only when missing + - name: Recover missing review status and form type uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | @@ -21,8 +21,36 @@ jobs: issue_number: context.issue.number, }; const { data } = await github.rest.issues.get(issue); - if (data.labels.some((label) => label.name.startsWith('status:'))) return; - await github.rest.issues.addLabels({ - ...issue, - labels: ['status:needs-review'], - }); + const additions = []; + if (!data.labels.some((label) => label.name.startsWith('status:'))) { + additions.push('status:needs-review'); + } + if (!data.labels.some((label) => label.name.startsWith('type:'))) { + // Required form field headings only; answers and titles are not classifiers. + const signatures = { + bug: ['Existing Issue Search', '📝 Observed Consequence', '🔄 Steps to Reproduce', '✅ Expected Behavior', '❌ Actual Behavior', 'Operating System', 'Release Version (Report Context)', 'Agent / Client'], + feature: ['Existing Issue Search', '🔍 Observed Consequence', '💡 Desired Outcome', '📦 Affected Area', 'Release Context'], + docs: ['Existing Issue Search', '📄 Documentation Reference', '📄 Which Document?', '🔍 Observed Consequence', 'Release Context'], + question: ['Existing Issue Search', 'Question', 'Why does this need issue tracking?', 'Affected Area', 'Release Context'], + }; + const headings = new Set(); + let fence; + for (const line of (data.body ?? '').split(/\r\n|\n|\r/)) { + if (fence) { + const close = line.match(/^ {0,3}(`+|~+)\s*$/); + if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = undefined; + continue; + } + const open = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + if (open && (open[1][0] !== '`' || !open[2].includes('`'))) { + fence = open[1]; + continue; + } + const heading = line.match(/^### (.+?)\s*$/); + if (heading) headings.add(heading[1]); + } + const matches = Object.entries(signatures).filter(([, required]) => required.every((heading) => headings.has(heading))); + if (matches.length === 1) additions.push(`type:${matches[0][0]}`); + } + // Additive only; the GET and write are inherently non-atomic. + if (additions.length) await github.rest.issues.addLabels({ ...issue, labels: additions }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8d60436a..dd61bb463 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,11 +23,16 @@ Use the correct template: Search existing issues for a canonical tracker before opening a new one. Fill in all required fields; report symptoms, impact, release context, and logs without having to diagnose the root cause. Your issue receives `status:needs-review`. -For newly opened issues that bypass the forms (for example, through the CLI or API), -the Issue Status Fallback workflow checks current labels and adds only -`status:needs-review` when no `status:*` label exists. It preserves all existing -labels and does not infer type, approval, priority, or effort. It does not backfill -existing issues or respond to later label removals. +For newly opened issues, the Issue Status Fallback workflow reads current API +labels and body. It adds `status:needs-review` only when no `status:*` exists. +Independently, when no `type:*` exists, it recovers `type:bug`, `type:feature`, +`type:docs`, or `type:question` only if the body uniquely contains a current form's +required field headings (outside fenced code). Unknown, incomplete, or ambiguous +structures remain for triage; titles and answer prose are never classifiers. +All existing labels are preserved, including existing types and statuses. +It does not infer approval, priority, or effort, backfill existing issues, or +respond to edits or later label removals. The API read and additive write are +not atomic. This fallback does not diagnose why native form labels were missing. ### Step 2: Investigation and Proportional Approval