From 87a943632eaca2cb708bb254c6bb35f341c116ec Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:32:15 -0500 Subject: [PATCH] ci(labels): add missing review status to new issues --- .../scripts/issue-status-fallback.test.mjs | 110 ++++++++++++++++++ .github/workflows/issue-status-fallback.yml | 28 +++++ CONTRIBUTING.md | 6 + 3 files changed, 144 insertions(+) create mode 100644 .github/scripts/issue-status-fallback.test.mjs create mode 100644 .github/workflows/issue-status-fallback.yml diff --git a/.github/scripts/issue-status-fallback.test.mjs b/.github/scripts/issue-status-fallback.test.mjs new file mode 100644 index 000000000..4d54d0b8d --- /dev/null +++ b/.github/scripts/issue-status-fallback.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const workflow = () => fs.readFileSync('.github/workflows/issue-status-fallback.yml', 'utf8'); +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 } = {}) { + const source = workflow(); + const script = source.match(/ script: \|\r?\n([\s\S]+)$/)?.[1]; + assert.ok(script, 'workflow contains an executable github-script body'); + const calls = []; + const originalLabels = structuredClone(currentLabels); + const context = { + repo: { owner: 'example', repo: 'engram' }, + issue: { number: 42 }, + payload: { issue: { labels: eventLabels.map((name) => ({ name })) } }, + }; + const github = { + rest: { + issues: { + async get(args) { + calls.push(['get', args]); + if (readError) throw readError; + return { data: { labels: currentLabels.map((name) => ({ name })) } }; + }, + async addLabels(args) { + calls.push(['addLabels', args]); + if (writeError) throw writeError; + currentLabels.push(...args.labels.filter((name) => !currentLabels.includes(name))); + }, + }, + }, + }; + let error; + try { + await new AsyncFunction('github', 'context', script)(github, context); + } catch (caught) { + error = caught; + } + return { calls, error, labels: currentLabels, originalLabels }; +} + +const issue = { owner: 'example', repo: 'engram', issue_number: 42 }; + +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]); + assert.equal(result.error, undefined); + assert.deepEqual(result.calls, [ + ['get', issue], + ['addLabels', { ...issue, labels: ['status:needs-review'] }], + ]); + assert.deepEqual(result.labels, [...initial, 'status:needs-review']); + } +}); + +test('preserves every existing status and unrelated label without any write', async () => { + for (const status of ['status:needs-review', 'status:approved', 'status:blocked', 'status:custom']) { + const initial = ['type:feature', status, 'effort:small']; + const result = await runWorkflow([...initial]); + assert.equal(result.error, undefined); + assert.deepEqual(result.calls, [['get', issue]]); + assert.deepEqual(result.labels, initial); + } +}); + +test('uses current API labels rather than the opened event snapshot', async () => { + const approved = await runWorkflow(['status:approved'], { eventLabels: [] }); + assert.equal(approved.error, undefined); + assert.deepEqual(approved.calls, [['get', issue]]); + assert.deepEqual(approved.labels, ['status:approved']); + + const missing = await runWorkflow(['type:docs'], { eventLabels: ['status:needs-review'] }); + assert.equal(missing.error, undefined); + assert.deepEqual(missing.calls, [ + ['get', issue], + ['addLabels', { ...issue, labels: ['status:needs-review'] }], + ]); + assert.deepEqual(missing.labels, ['type:docs', 'status:needs-review']); +}); + +test('read failures propagate without a label write', async () => { + const failure = new Error('API read denied'); + const result = await runWorkflow(['type:bug'], { readError: failure }); + assert.equal(result.error, failure); + assert.deepEqual(result.calls, [['get', issue]]); + assert.deepEqual(result.labels, result.originalLabels); +}); + +test('write failures propagate without retries or replacement', async () => { + const failure = new Error('API write denied'); + const result = await runWorkflow(['type:bug'], { writeError: failure }); + assert.equal(result.error, failure); + assert.deepEqual(result.calls, [ + ['get', issue], + ['addLabels', { ...issue, labels: ['status:needs-review'] }], + ]); + assert.deepEqual(result.labels, result.originalLabels); +}); + +test('workflow only handles issue opens with minimal permissions and no checkout', () => { + const source = workflow(); + assert.match(source, /^on:\r?\n issues:\r?\n types: \[opened\]\r?\n\r?\npermissions:/m); + assert.match(source, /^permissions:\r?\n issues: write\r?\n\r?\njobs:/m); + assert.equal((source.match(/uses:/g) ?? []).length, 1); + assert.match(source, /uses: actions\/github-script@[a-f0-9]{40} # v8/); + assert.doesNotMatch(source, /\$\{\{|checkout@|run:|pull_request_target:|workflow_dispatch:/); +}); diff --git a/.github/workflows/issue-status-fallback.yml b/.github/workflows/issue-status-fallback.yml new file mode 100644 index 000000000..3e555e801 --- /dev/null +++ b/.github/workflows/issue-status-fallback.yml @@ -0,0 +1,28 @@ +name: Issue Status Fallback + +on: + issues: + types: [opened] + +permissions: + issues: write + +jobs: + ensure-review-status: + runs-on: ubuntu-latest + steps: + - name: Add review status only when missing + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const issue = { + owner: context.repo.owner, + repo: context.repo.repo, + 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'], + }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3cb454bc7..ef2736d75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,12 @@ 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. + ### Step 2: Investigation and Proportional Approval A maintainer triages the report and assigns a real investigator/owner. Keep evidence on the canonical issue; cluster duplicates only when their root cause and affected contract match, not just their symptoms. Move useful occurrence evidence there before duplicate closure.