Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions .github/scripts/issue-status-fallback.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -15,15 +15,15 @@ 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: {
issues: {
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]);
Expand All @@ -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]);
Expand Down
40 changes: 34 additions & 6 deletions .github/workflows/issue-status-fallback.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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 });
15 changes: 10 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading