From 6afbdbed3dd2ceaac79d54c8b0860bf9bfba6e6f Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:20:55 -0700 Subject: [PATCH 1/3] Avoid backtracking in issue spam checks Copilot-Session: 38cc48ab-6686-41b4-b35e-459fb890f924 --- .../scripts/test_check_for_spammy_issues.js | 129 ++++++++++++++++++ .github/workflows/check-for-spammy-issues.yml | 7 +- .github/workflows/scripts-tests.yml | 8 ++ CONTRIBUTING.md | 9 ++ 4 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/test_check_for_spammy_issues.js diff --git a/.github/scripts/test_check_for_spammy_issues.js b/.github/scripts/test_check_for_spammy_issues.js new file mode 100644 index 0000000000..da224e4273 --- /dev/null +++ b/.github/scripts/test_check_for_spammy_issues.js @@ -0,0 +1,129 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const workflow = readFileSync( + path.join(__dirname, '../workflows/check-for-spammy-issues.yml'), + 'utf8', +); +const script = workflow.split(' script: |\n')[1]; +assert.ok(script, 'The workflow must include its github-script body'); + +const harness = ` + const { readFileSync } = require('node:fs'); + const input = JSON.parse(readFileSync(0, 'utf8')); + const calls = []; + const context = { + payload: { + issue: { number: 42, title: input.title, body: input.body }, + sender: { login: 'reporter' }, + }, + }; + const github = { + teams: { + async getMembershipForUserInOrg() { + if (!input.employee) throw new Error('Not a team member'); + }, + }, + issues: { + async update(params) { calls.push({ method: 'update', params }); }, + async createComment(params) { calls.push({ method: 'createComment', params }); }, + }, + }; + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + new AsyncFunction('github', 'context', input.script)(github, context) + .then(() => process.stdout.write(JSON.stringify(calls))) + .catch(error => { + console.error(error); + process.exitCode = 1; + }); +`; + +function runWorkflow(body, { title = 'Too short', employee = false } = {}) { + const result = spawnSync(process.execPath, ['-e', harness], { + input: JSON.stringify({ script, body, title, employee }), + encoding: 'utf8', + timeout: 2000, + }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); +} + +function assertClosed(body, options) { + const calls = runWorkflow(body, options); + assert.deepEqual(calls.map(call => call.method), ['update', 'createComment']); + assert.deepEqual(calls[0].params, { + owner: 'github', + repo: 'rest-api-description', + issue_number: 42, + labels: ['invalid'], + state: 'closed', + }); + assert.equal(calls[1].params.issue_number, 42); +} + +test('closes issues with short titles and bodies', () => { + assertClosed('Not enough detail'); +}); + +test('accepts a descriptive title', () => { + assert.deepEqual(runWorkflow('', { title: 'A descriptive issue title' }), []); +}); + +test('accepts a descriptive body', () => { + assert.deepEqual(runWorkflow('This issue contains enough words to explain the problem'), []); +}); + +test('does not count template comments or indented headings as body words', () => { + assertClosed('\n # This heading should not count either\nToo short'); +}); + +test('preserves descriptive text between headings', () => { + assert.deepEqual( + runWorkflow('# First heading\nThis issue contains enough words to explain the problem\n## Next heading\n'), + [], + ); +}); + +test('ignores a heading without a trailing newline', () => { + assertClosed('# These heading words alone should not keep an issue open'); +}); + +test('handles CRLF headings and body text', () => { + assertClosed(' \t## These heading words should not count\r\nToo short\r\n'); + assert.deepEqual( + runWorkflow('# Heading\r\nThis issue contains enough words to explain the problem\r\n'), + [], + ); +}); + +test('keeps hash characters that are not at the start of a line', () => { + assert.deepEqual(runWorkflow('This body refers to #123 and explains the problem'), []); +}); + +test('handles empty, null, and missing bodies', () => { + for (const body of ['', null, undefined]) { + assertClosed(body); + } +}); + +test('leaves employee issues open', () => { + assert.deepEqual(runWorkflow(null, { employee: true }), []); +}); + +test('still rejects the unchanged schema template title', () => { + assertClosed('This issue contains enough words to explain the problem', { + title: '[Schema Inaccuracy] ', + }); +}); + +test('handles a maximum-length heading followed by whitespace within two seconds', () => { + assertClosed('#' + ' '.repeat(65535)); +}); + +test('handles maximum-length whitespace before a heading within two seconds', () => { + assertClosed(' '.repeat(65534) + '#x'); +}); diff --git a/.github/workflows/check-for-spammy-issues.yml b/.github/workflows/check-for-spammy-issues.yml index a0acc6607c..f023ddfac0 100644 --- a/.github/workflows/check-for-spammy-issues.yml +++ b/.github/workflows/check-for-spammy-issues.yml @@ -31,8 +31,11 @@ jobs: } const titleWordCountMin = 3 - let body = issue.body.replace(/<\!---.*?-->/g, ""); - body = body.replace(/^\s*(#)\s*(.+?)\s*\n/gm, ""); + let body = (issue.body ?? '').replace(/<\!---.*?-->/g, ""); + body = body + .split('\n') + .filter(line => !line.trimStart().startsWith('#')) + .join('\n'); let bodyWordCount = 0 if (body) { diff --git a/.github/workflows/scripts-tests.yml b/.github/workflows/scripts-tests.yml index b8761e8ccd..cdc7798611 100644 --- a/.github/workflows/scripts-tests.yml +++ b/.github/workflows/scripts-tests.yml @@ -8,10 +8,12 @@ on: paths: - '.github/scripts/**' - '.github/workflows/scripts-tests.yml' + - '.github/workflows/check-for-spammy-issues.yml' pull_request: paths: - '.github/scripts/**' - '.github/workflows/scripts-tests.yml' + - '.github/workflows/check-for-spammy-issues.yml' workflow_dispatch: jobs: @@ -27,3 +29,9 @@ jobs: python-version: '3.12' - run: python3 -m unittest discover --start-directory .github/scripts --verbose name: Run script tests + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + name: Install NodeJS + with: + node-version: '22' + - run: node --test .github/scripts/test_*.js + name: Run JavaScript script tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f0126ff4e..408080257a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,15 @@ This workflow is defined in `.github/workflows/linter-failure-notifier.yml`. 0. Push to your fork and [submit a pull request][pr] 0. Pat your self on the back and wait for your pull request to be reviewed and merged. +## Workflow script tests + +Run the workflow script tests with Python 3 and Node.js 22: + +```sh +python3 -m unittest discover --start-directory .github/scripts --verbose +node --test .github/scripts/test_*.js +``` + ## Resources - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) From d9a17ecab97f2a54899fdb69d41fb947d67ca186 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:35:49 -0700 Subject: [PATCH 2/3] Preserve legacy issue spam decisions Copilot-Session: 38cc48ab-6686-41b4-b35e-459fb890f924 --- .../scripts/test_check_for_spammy_issues.js | 77 +++++++++++++++++-- .github/workflows/check-for-spammy-issues.yml | 57 ++++++++++++-- 2 files changed, 123 insertions(+), 11 deletions(-) diff --git a/.github/scripts/test_check_for_spammy_issues.js b/.github/scripts/test_check_for_spammy_issues.js index da224e4273..4ed476e01a 100644 --- a/.github/scripts/test_check_for_spammy_issues.js +++ b/.github/scripts/test_check_for_spammy_issues.js @@ -10,6 +10,15 @@ const workflow = readFileSync( ); const script = workflow.split(' script: |\n')[1]; assert.ok(script, 'The workflow must include its github-script body'); +const preprocessing = script.slice(0, script.indexOf('let bodyWordCount = 0')); +const preprocess = new Function( + 'context', + `${preprocessing}\nreturn body;`, +); + +function stripBody(body) { + return preprocess({ payload: { issue: { title: 'Too short', body } } }); +} const harness = ` const { readFileSync } = require('node:fs'); @@ -88,8 +97,8 @@ test('preserves descriptive text between headings', () => { ); }); -test('ignores a heading without a trailing newline', () => { - assertClosed('# These heading words alone should not keep an issue open'); +test('preserves a heading without a trailing newline', () => { + assert.deepEqual(runWorkflow('# These heading words alone should keep an issue open'), []); }); test('handles CRLF headings and body text', () => { @@ -104,14 +113,18 @@ test('keeps hash characters that are not at the start of a line', () => { assert.deepEqual(runWorkflow('This body refers to #123 and explains the problem'), []); }); -test('handles empty, null, and missing bodies', () => { - for (const body of ['', null, undefined]) { - assertClosed(body); +test('preserves empty body handling', () => { + assertClosed(''); +}); + +test('does not change unsupported null or missing body handling', () => { + for (const body of [null, undefined]) { + assert.throws(() => stripBody(body), TypeError); } }); test('leaves employee issues open', () => { - assert.deepEqual(runWorkflow(null, { employee: true }), []); + assert.deepEqual(runWorkflow('', { employee: true }), []); }); test('still rejects the unchanged schema template title', () => { @@ -127,3 +140,55 @@ test('handles a maximum-length heading followed by whitespace within two seconds test('handles maximum-length whitespace before a heading within two seconds', () => { assertClosed(' '.repeat(65534) + '#x'); }); + +test('preserves legacy whitespace, end-of-file, and multiline matching', () => { + const fixtures = [ + ['', ''], + ['# Heading', '# Heading'], + ['# Heading\n', ''], + ['#\n', '#\n'], + ['# \n', ''], + ['#\n\n', '#\n\n'], + ['#\n \n', ''], + ['#\nContent\n', ''], + ['#\nContent', '#\nContent'], + ['# \nContent', 'Content'], + ['# Heading\nContent', 'Content'], + ['# Heading\n\n \nContent', 'Content'], + ['\n \n# Heading\n \n \nContent', 'Content'], + ['# Heading\rContent\n', '# Heading\rContent\n'], + ['# Heading\u2028Content\n', '# Heading\u2028Content\n'], + ['# Heading\u2029Content\n', '# Heading\u2029Content\n'], + ['# Heading\r\nContent\r\n', 'Content\r\n'], + ['# Heading\r\n \r\nContent', 'Content'], + ['Content\n# Heading\n', 'Content\n'], + ['Content\r# Heading\n', 'Content\r'], + ['\t#\tHeading\t\n\t\nContent', 'Content'], + ['\n# Heading\nContent', 'Content'], + ]; + for (const [body, expected] of fixtures) { + assert.equal(stripBody(body), expected, JSON.stringify(body)); + } +}); + +test('preserves template decisions and exact title/body thresholds', () => { + const template = readFileSync( + path.join(__dirname, '../ISSUE_TEMPLATE/schema-inaccuracy.md'), + 'utf8', + ).split('\n---\n')[1]; + assert.ok(template); + assertClosed(template, { title: '[Schema Inaccuracy] ' }); + assert.deepEqual(runWorkflow(template, { title: 'A descriptive issue title' }), []); + assertClosed('one two three four five'); + assert.deepEqual(runWorkflow('one two three four five six'), []); + assertClosed('', { title: 'two words' }); + assert.deepEqual(runWorkflow('', { title: 'three title words' }), []); + assertClosed('#\nThis issue contains enough words to explain the problem\n'); +}); + +test('handles maximum-length blank lines and multiple headings within two seconds', () => { + assertClosed('\n'.repeat(65536)); + assertClosed('# \n'.repeat(21845)); + assertClosed('#\r'.repeat(32768)); + assertClosed('#' + '\r'.repeat(65535)); +}); diff --git a/.github/workflows/check-for-spammy-issues.yml b/.github/workflows/check-for-spammy-issues.yml index f023ddfac0..d9c2cfa8b9 100644 --- a/.github/workflows/check-for-spammy-issues.yml +++ b/.github/workflows/check-for-spammy-issues.yml @@ -31,11 +31,58 @@ jobs: } const titleWordCountMin = 3 - let body = (issue.body ?? '').replace(/<\!---.*?-->/g, ""); - body = body - .split('\n') - .filter(line => !line.trimStart().startsWith('#')) - .join('\n'); + let body = issue.body.replace(/<\!---.*?-->/g, ""); + const isLineBreak = character => /[\r\n\u2028\u2029]/.test(character); + const parts = []; + let keptStart = 0; + let headingStart = 0; + let index = 0; + + // Preserve the legacy matcher's cross-line whitespace behavior without backtracking. + while (index < body.length) { + const character = body[index]; + if (character === '#' && headingStart !== null) { + let next = index + 1; + let end = -1; + let hasInlineWhitespace = false; + while (next < body.length && /\s/.test(body[next])) { + if (!isLineBreak(body[next])) { + hasInlineWhitespace = true; + } + if (body[next] === '\n' && hasInlineWhitespace) { + end = next + 1; + } + next++; + } + + // A content line takes precedence over a whitespace-only heading. + while (next < body.length && !isLineBreak(body[next])) { + next++; + } + while (next < body.length && /\s/.test(body[next])) { + if (body[next] === '\n') { + end = next + 1; + } + next++; + } + if (end !== -1) { + parts.push(body.slice(keptStart, headingStart)); + keptStart = end; + index = end; + headingStart = end; + continue; + } + } + if (!/\s/.test(character)) { + headingStart = null; + } + if (isLineBreak(character) && headingStart === null) { + headingStart = index + 1; + } + index++; + } + parts.push(body.slice(keptStart)); + body = parts.join(''); let bodyWordCount = 0 if (body) { From 8e9a487abcb1f663e72435f1a90a4493c49edadd Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:15:32 -0700 Subject: [PATCH 3/3] Cover template body decisions and CI triggers Copilot-Session: 38cc48ab-6686-41b4-b35e-459fb890f924 --- .github/scripts/test_check_for_spammy_issues.js | 1 + .github/workflows/scripts-tests.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/scripts/test_check_for_spammy_issues.js b/.github/scripts/test_check_for_spammy_issues.js index 4ed476e01a..e77d9ce8d4 100644 --- a/.github/scripts/test_check_for_spammy_issues.js +++ b/.github/scripts/test_check_for_spammy_issues.js @@ -179,6 +179,7 @@ test('preserves template decisions and exact title/body thresholds', () => { assert.ok(template); assertClosed(template, { title: '[Schema Inaccuracy] ' }); assert.deepEqual(runWorkflow(template, { title: 'A descriptive issue title' }), []); + assert.deepEqual(runWorkflow(template, { title: 'Too short' }), []); assertClosed('one two three four five'); assert.deepEqual(runWorkflow('one two three four five six'), []); assertClosed('', { title: 'two words' }); diff --git a/.github/workflows/scripts-tests.yml b/.github/workflows/scripts-tests.yml index cdc7798611..6b68b300b4 100644 --- a/.github/workflows/scripts-tests.yml +++ b/.github/workflows/scripts-tests.yml @@ -9,11 +9,13 @@ on: - '.github/scripts/**' - '.github/workflows/scripts-tests.yml' - '.github/workflows/check-for-spammy-issues.yml' + - '.github/ISSUE_TEMPLATE/schema-inaccuracy.md' pull_request: paths: - '.github/scripts/**' - '.github/workflows/scripts-tests.yml' - '.github/workflows/check-for-spammy-issues.yml' + - '.github/ISSUE_TEMPLATE/schema-inaccuracy.md' workflow_dispatch: jobs: