From 108ff186249927600c0e13af75268c62a66b30b4 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:16:53 -0400 Subject: [PATCH] feat(ci): limit Issue bursts and open submissions per author (#3723) Verified with 30 exact-head hosted checks, 31 combined PR and Issue tests, workflow security validation, and a live read-only Issue replay. --- .github/ISSUE_MODERATION.md | 68 +++++++++ .github/issue-submission-policy.json | 8 + .github/workflows/issue-submission-guard.yml | 38 +++++ CONTRIBUTING.md | 7 + scripts/ci/issue-submission-guard.js | 137 ++++++++++++++++++ scripts/ci/pr-burst-guard.js | 2 +- .../issue-submission-guard-workflow.test.js | 105 ++++++++++++++ tests/ci/issue-submission-guard.test.js | 112 ++++++++++++++ 8 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 .github/ISSUE_MODERATION.md create mode 100644 .github/issue-submission-policy.json create mode 100644 .github/workflows/issue-submission-guard.yml create mode 100644 scripts/ci/issue-submission-guard.js create mode 100644 tests/ci/issue-submission-guard-workflow.test.js create mode 100644 tests/ci/issue-submission-guard.test.js diff --git a/.github/ISSUE_MODERATION.md b/.github/ISSUE_MODERATION.md new file mode 100644 index 000000000..4913c76e3 --- /dev/null +++ b/.github/ISSUE_MODERATION.md @@ -0,0 +1,68 @@ +# Issue submission moderation + +The Issue Submission Guard protects maintainer triage capacity. It runs after a +new Issue opens or a closed Issue reopens. It examines creation metadata only, +without reading or executing Issue titles or bodies. It does not judge the value +of a report. Existing open Issues stay open when this policy ships. + +| Control | Default | Enforcement | +| --- | --- | --- | +| Burst allowance | Five Issue creations per author in 20 minutes | Close and lock later Issues; alert maintainers | +| Severe burst | More than ten creations in 20 minutes | Attempt owner account block; alert maintainers | +| Open-Issue cap | Ten open Issues per author | Close new excess or reopened Issues with an explanation; no lock | +| Trusted contributors | Repository write, maintain, or admin permission | Exempt from burst moderation, but subject to the open cap | + +Change `.github/issue-submission-policy.json` to adjust thresholds and alert +recipients. Pull requests are excluded from every Issue count. The PR guard uses +its own policy. The Issue guard skips its own bot-authored alert Issues. + +The open cap acts only on newly opened or reopened Issues. If an author already +has more than ten open Issues when this ships, those older Issues stay open. A +delayed opened run can also close newer excess Issues visible in its API snapshot. +Reopening an old Issue consumes a slot, so it closes again when the author is +above the cap. Closing is reversible after the author reduces their open count. + +Closed Issues still count toward the rolling burst window. The guard checks +newer creation windows when a run is delayed, and serializes each author's +events. GitHub's Actions queue can hold at most 100 pending runs for one +concurrency group. A longer backlog can leave individual events unchecked. +Moderation happens after creation, so it cannot prevent every downstream +notification or workflow from starting. + +The optional `PR_MODERATION_BLOCK_TOKEN` must authenticate as the personal +repository owner and have **Block another user: write** permission. Without it, +Issue closure and alerting still run; a severe burst fails the Actions job and +records that blocking was unavailable. A personal account block affects all +repositories owned by that account. The default `GITHUB_TOKEN` performs Issue +moderation only. If ownership moves to an organization, owner blocking needs +a separate implementation. + +The privileged workflow checks out the trusted default-branch SHA and only the +policy plus two scripts. It does not check out contributor code or install +dependencies. API failures fail the run; only a collaborator lookup `404` +means the author is an outsider. A bot-authored daily alert ledger prevents +contributor text from suppressing notifications. + +## Verify + +```sh +node tests/ci/issue-submission-guard.test.js +node tests/ci/issue-submission-guard-workflow.test.js +node scripts/ci/validate-workflow-security.js +``` + +The workflow test runs the actual CLI against a disposable HTTP fixture. After +deployment, inspect a real **Issue Submission Guard** run and its job summary. +For a live read-only replay, supply `GITHUB_TOKEN` securely and run: + +```sh +GITHUB_REPOSITORY=affaan-m/ECC node scripts/ci/issue-submission-guard.js --dry-run --issue +``` + +The dry run reads GitHub but does not close, lock, comment, alert, or block. +To roll back, disable **Issue Submission Guard** in Actions. To restore an +Issue, unlock and reopen it after adjusting policy or resolving the author's +open count. The repository owner must remove any account block separately. + +This advances ECC 2.2 maintenance reliability. It does not change runtime +sandboxing, ExecutionCapsule contracts, Fleet, or release publication. diff --git a/.github/issue-submission-policy.json b/.github/issue-submission-policy.json new file mode 100644 index 000000000..556eba87e --- /dev/null +++ b/.github/issue-submission-policy.json @@ -0,0 +1,8 @@ +{ + "windowMinutes": 20, + "closeAbove": 5, + "blockAbove": 10, + "maxOpen": 10, + "deniedUserIDs": [], + "notify": ["affaan-m", "haelyra"] +} diff --git a/.github/workflows/issue-submission-guard.yml b/.github/workflows/issue-submission-guard.yml new file mode 100644 index 000000000..dcee0c977 --- /dev/null +++ b/.github/workflows/issue-submission-guard.yml @@ -0,0 +1,38 @@ +name: Issue Submission Guard + +on: + issues: + types: [opened, reopened] + +concurrency: + group: issue-guard-${{ github.repository }}-${{ github.event.issue.user.id }} + cancel-in-progress: false + queue: max + +permissions: + contents: read + issues: write + +jobs: + moderate: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # The issues event uses the trusted default branch, never contributor code. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + sparse-checkout: | + .github/issue-submission-policy.json + scripts/ci/issue-submission-guard.js + scripts/ci/pr-burst-guard.js + sparse-checkout-cone-mode: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.x' + - name: Enforce Issue submission policy + env: + GITHUB_TOKEN: ${{ github.token }} + PR_MODERATION_BLOCK_TOKEN: ${{ secrets.PR_MODERATION_BLOCK_TOKEN }} + run: node scripts/ci/issue-submission-guard.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8fe695388..843771b15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -431,6 +431,13 @@ Translations live under `docs/` (e.g. `docs/zh-CN`, `docs/zh-TW`, `docs/ja-JP`). --- +## Issue submission + +Please submit at most five Issues within any 20-minute period and keep at +most ten Issues open at once. The [Issue moderation policy](.github/ISSUE_MODERATION.md) +explains the automatic closure, reopening, and maintainer alert rules. Existing +open Issues stay open when the policy takes effect. + ## Pull Request Process ### Submission volume diff --git a/scripts/ci/issue-submission-guard.js b/scripts/ci/issue-submission-guard.js new file mode 100644 index 000000000..5aaf72ab3 --- /dev/null +++ b/scripts/ci/issue-submission-guard.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** Moderate Issue creation metadata only. Never execute or print contributor text. */ +const fs = require('node:fs'); +const path = require('node:path'); +const { githubAPI, listPages, validatePolicy, strongestWindow, blockAuthor } = require('./pr-burst-guard'); + +const LOGIN = /^[a-z\d](?:[a-z\d-]{0,38})(?:\[bot\])?$/i; +const CAP_MARKER = ''; +const ALERT_MARKER = '`; + const rows = await listPages(api, `${root}/issues?state=all&sort=created&direction=desc`, + batch => batch.some(issue => issue.created_at?.slice(0, 10) < day)); + const existing = rows.find(issue => isIssue(issue) && issue.user?.login === 'github-actions[bot]' + && issue.body?.includes(marker)); + const escalation = ''; + if (existing?.body.includes(escalation) && !result.block) return; + const mentions = policy.notify.map(login => `@${login}`).join(' '); + const body = [marker, result.block ? escalation : '', `${mentions}: Issue submission moderation triggered.`, '', + `Author: @${current.user.login} (user ID ${current.user.id}).`, + `Observed: ${result.count} Issue creations between ${result.since} and ${result.until}.`, + `Policy: close Issues beyond ${policy.closeAbove}; block beyond ${policy.blockAbove} in ${policy.windowMinutes} minutes.`, + `Blocking: ${result.blockStatus}.`, `Excess Issues: ${result.excess.map(issue => `#${issue.number}`).join(', ') || 'none'}.`, + `Enforcement errors: ${result.errors.join('; ') || 'none'}.`, '', + 'Counts include closed Issues and exclude pull requests. Review the Actions run before lifting an account block.'] + .filter(Boolean).join('\n'); + if (!existing) await api('POST', `${root}/issues`, { title: `Issue burst alert: ${current.user.login}`, body }); + else { + if (result.block && !existing.body.includes(escalation)) { + await api('POST', `${root}/issues/${existing.number}/comments`, { + body: `${mentions}: the Issue burst escalated. Blocking: ${result.blockStatus}.`, + }); + } + await api('PATCH', `${root}/issues/${existing.number}`, { body }); + } +} + +async function enforceCap(api, root, issue, current, result) { + try { + await api('PATCH', `${root}/issues/${issue.number}`, { state: 'closed' }); + const comments = await listPages(api, `${root}/issues/${issue.number}/comments?sort=created`); + if (comments.some(comment => comment.user?.login === 'github-actions[bot]' + && comment.body?.includes(CAP_MARKER))) return; + await api('POST', `${root}/issues/${issue.number}/comments`, { body: [CAP_MARKER, + `Closed automatically: @${current.user.login} has ${result.openCount} open Issues in this repository, above the limit of ${result.maxOpen} per author.`, + '', 'Close older Issues or wait for them to be resolved, then reopen this one. This limit protects maintainer triage capacity.', + 'Policy: `.github/ISSUE_MODERATION.md`.'].join('\n') }); + } catch (error) { result.errors.push(`#${issue.number}: ${error.message}`); } +} + +async function moderate({ api, blockAPI, repository, number, action = 'opened', policy, dryRun = false, now = Date.now() }) { + validatePolicy(policy); + if (!/^[a-z\d-]+\/[a-z\d_.-]+$/i.test(repository) || !Number.isSafeInteger(number) || number < 1) { + throw new Error('Invalid repository or Issue number'); + } + if (!['opened', 'reopened'].includes(action)) throw new Error('Unsupported Issue event'); + const root = `/repos/${repository}`; + const repo = await api('GET', root); + const current = await api('GET', `${root}/issues/${number}`); + if (!isIssue(current)) return { skipped: 'pull request' }; + if (!LOGIN.test(current.user.login)) throw new Error('Invalid Issue author login'); + // The guard's own alert Issues must never enter the moderation ledger. + if (current.user.login === 'github-actions[bot]') return { skipped: 'guard bot' }; + let permission; + try { permission = (await api('GET', `${root}/collaborators/${current.user.login}/permission`)).permission; } + catch (error) { + if (error.status !== 404) throw error; + permission = 'none'; + } + const burstExempt = ['admin', 'maintain', 'write'].includes(permission); + let result; + if (burstExempt) result = { author: current.user.login, burstExempt, dryRun, errors: [], excess: [], + blockStatus: 'Exempt from burst threshold' }; + else { + const end = action === 'opened' ? Date.parse(current.created_at) : now; + const since = end - policy.windowMinutes * 60000; + const rows = (await listPages(api, `${root}/issues?state=all&sort=created&direction=desc`, + batch => batch.some(issue => Date.parse(issue.created_at) < since))).filter(isIssue); + result = { ...strongestWindow(rows, current, policy, end), author: current.user.login, + burstExempt, dryRun, errors: [], blockStatus: 'Below blocking threshold' }; + } + const openRows = (await listPages(api, `${root}/issues?state=open&sort=created&direction=desc`)).filter(isIssue); + Object.assign(result, capExcess(openRows, current, policy, new Set(result.excess.map(issue => issue.number)), action)); + if (dryRun) { + if (result.block) result.blockStatus = 'Dry run: would attempt owner-scoped blocking'; + return result; + } + if (result.excess.length) { + if (result.block) result.blockStatus = await blockAuthor(blockAPI, repo.owner, current.user.login); + for (const issue of result.excess) { + if (issue.state !== 'open') continue; + try { + await api('PATCH', `${root}/issues/${issue.number}`, { state: 'closed' }); + await api('PUT', `${root}/issues/${issue.number}/lock`, { lock_reason: 'spam' }); + } catch (error) { result.errors.push(`#${issue.number}: ${error.message}`); } + } + await notify(api, root, current, result, policy); + } + for (const issue of result.capExcess) await enforceCap(api, root, issue, current, result); + return result; +} + +async function main() { + const dryRun = process.argv.includes('--dry-run'); + const event = process.env.GITHUB_EVENT_PATH ? JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) : {}; + const issueIndex = process.argv.indexOf('--issue'); + const number = issueIndex >= 0 ? Number(process.argv[issueIndex + 1]) : event.issue?.number; + const policy = JSON.parse(fs.readFileSync(path.join(__dirname, '../../.github/issue-submission-policy.json'), 'utf8')); + const result = await moderate({ api: githubAPI(process.env.GITHUB_TOKEN), + blockAPI: !dryRun && process.env.PR_MODERATION_BLOCK_TOKEN ? githubAPI(process.env.PR_MODERATION_BLOCK_TOKEN) : undefined, + repository: process.env.GITHUB_REPOSITORY, number, action: event.action || 'opened', policy, dryRun }); + const report = JSON.stringify(result, null, 2); + console.log(report); + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, + `## Issue submission guard\n\n\`\`\`json\n${report}\n\`\`\`\n`); + if (result.errors?.length || /^(Failed|Unavailable):/.test(result.blockStatus || '')) process.exitCode = 1; +} + +if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; }); +module.exports = { capExcess, moderate }; diff --git a/scripts/ci/pr-burst-guard.js b/scripts/ci/pr-burst-guard.js index d7f3f8653..656d72f8b 100644 --- a/scripts/ci/pr-burst-guard.js +++ b/scripts/ci/pr-burst-guard.js @@ -230,4 +230,4 @@ async function main() { } if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; }); -module.exports = { evaluate, moderate, githubAPI, listPages, validatePolicy, strongestWindow, capExcess }; +module.exports = { evaluate, moderate, githubAPI, listPages, validatePolicy, strongestWindow, capExcess, blockAuthor }; diff --git a/tests/ci/issue-submission-guard-workflow.test.js b/tests/ci/issue-submission-guard-workflow.test.js new file mode 100644 index 000000000..32e3ac210 --- /dev/null +++ b/tests/ci/issue-submission-guard-workflow.test.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const http = require('node:http'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const exec = promisify(execFile); +const root = path.resolve(__dirname, '../..'); + +test('actual Issue workflow CLI caps a new Issue, handles bursts, and keeps credentials separate', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-issue-guard-')); + const mutations = []; + const start = Date.parse('2026-10-25T00:00:00Z'); + const rows = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, state: 'open', + user: { id: 42, login: 'contributor' }, created_at: new Date(start + i * 3600000).toISOString(), + title: '::error::untrusted title', body: '$(untrusted body)' })); + let alert; + let permission = 'read'; + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + let raw = ''; + for await (const chunk of req) raw += chunk; + const body = raw ? JSON.parse(raw) : undefined; + const send = value => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); }; + if (req.method !== 'GET') mutations.push({ method: req.method, route: url.pathname, + body, token: req.headers.authorization }); + if (url.pathname === '/repos/owner/repo') return send({ owner: { login: 'owner', type: 'User' } }); + if (url.pathname === '/user') { + assert.equal(req.headers.authorization, 'Bearer owner-token'); + return send({ login: 'owner' }); + } + if (url.pathname.includes('/collaborators/')) return send({ permission }); + if (req.method === 'GET' && /^\/repos\/owner\/repo\/issues\/\d+$/.test(url.pathname)) { + return send(rows.find(row => row.number === Number(url.pathname.split('/').pop()))); + } + if (req.method === 'GET' && url.pathname.endsWith('/issues')) { + return send([...rows.filter(row => url.searchParams.get('state') !== 'open' || row.state === 'open'), + ...(alert && url.searchParams.get('state') !== 'open' ? [alert] : [])].reverse()); + } + if (req.method === 'POST' && url.pathname.endsWith('/issues')) { + alert = { number: 999, created_at: new Date(start).toISOString(), + user: { login: 'github-actions[bot]' }, ...body }; return send(alert); + } + if (req.method === 'GET' && url.pathname.endsWith('/comments')) return send([]); + if (req.method === 'PATCH' && url.pathname.endsWith('/issues/999')) { + alert = { ...alert, ...body }; return send(alert); + } + if (req.method === 'PATCH' && /^\/repos\/owner\/repo\/issues\/\d+$/.test(url.pathname)) { + const issue = rows.find(row => row.number === Number(url.pathname.split('/').pop())); + issue.state = body.state; return send(issue); + } + if (req.method === 'POST' && url.pathname.endsWith('/comments')) return send({}); + if (req.method === 'PUT' && url.pathname.endsWith('/lock')) { res.statusCode = 204; return res.end(); } + if (req.method === 'PUT' && url.pathname.startsWith('/user/blocks/')) { res.statusCode = 204; return res.end(); } + res.statusCode = 404; send({ message: 'missing fixture route' }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const port = server.address().port; + const preload = path.join(tmp, 'transport.cjs'); + fs.writeFileSync(preload, `const original = global.fetch; global.fetch = (url, options) => original('http://127.0.0.1:${port}' + new URL(url).pathname + new URL(url).search, options);`); + const event = path.join(tmp, 'event.json'); + const summary = path.join(tmp, 'summary.md'); + const env = { ...process.env, NODE_OPTIONS: `--require=${preload}`, GITHUB_REPOSITORY: 'owner/repo', + GITHUB_TOKEN: 'issue-token', PR_MODERATION_BLOCK_TOKEN: 'owner-token', + GITHUB_EVENT_PATH: event, GITHUB_STEP_SUMMARY: summary }; + const run = args => exec(process.execPath, ['scripts/ci/issue-submission-guard.js', ...args], { cwd: root, env }); + rows.push({ number: 21, state: 'open', user: { id: 42, login: 'contributor' }, + created_at: new Date(start + 20 * 3600000).toISOString() }); + fs.writeFileSync(event, JSON.stringify({ action: 'opened', issue: { number: 21 } })); + const cap = JSON.parse((await run([])).stdout); + assert.deepEqual(cap.capExcess.map(issue => issue.number), [21]); + assert.equal(mutations.filter(m => m.method === 'PATCH' && m.route === '/repos/owner/repo/issues/21').length, 1); + assert.equal(mutations.filter(m => m.route.endsWith('/lock')).length, 0); + assert.equal(mutations.filter(m => m.method === 'POST' && m.route.endsWith('/comments')).length, 1); + permission = 'write'; + rows.push({ number: 22, state: 'open', user: { id: 42, login: 'contributor' }, + created_at: new Date(start + 21 * 3600000).toISOString() }); + fs.writeFileSync(event, JSON.stringify({ action: 'opened', issue: { number: 22 } })); + assert.equal(JSON.parse((await run([])).stdout).burstExempt, true); + assert.equal(mutations.filter(m => m.method === 'PATCH' && m.route === '/repos/owner/repo/issues/22').length, 1); + permission = 'read'; + rows.splice(0, rows.length, ...Array.from({ length: 11 }, (_, i) => ({ number: i + 1, + state: 'open', user: { id: 42, login: 'contributor' }, + created_at: new Date(start + i * 1000).toISOString() }))); + fs.writeFileSync(event, JSON.stringify({ action: 'opened', issue: { number: 11 } })); + const burst = JSON.parse((await run([])).stdout); + assert.equal(burst.count, 11); + assert.match(burst.blockStatus, /^Blocked/); + assert.equal(mutations.filter(m => m.route.endsWith('/lock')).length, 6); + assert(mutations.some(m => m.route === '/user/blocks/contributor' && m.token === 'Bearer owner-token')); + assert(mutations.filter(m => m.route.startsWith('/repos/')).every(m => m.token === 'Bearer issue-token')); + assert.doesNotMatch(fs.readFileSync(summary, 'utf8'), /untrusted|owner-token|issue-token/); + const before = mutations.length; + const dry = JSON.parse((await run(['--dry-run'])).stdout); + assert.equal(dry.block, true); + assert.equal(mutations.length, before); + } finally { + await new Promise(resolve => server.close(resolve)); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/ci/issue-submission-guard.test.js b/tests/ci/issue-submission-guard.test.js new file mode 100644 index 000000000..11844e5d5 --- /dev/null +++ b/tests/ci/issue-submission-guard.test.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { capExcess, moderate } = require('../../scripts/ci/issue-submission-guard'); + +const policy = { windowMinutes: 20, closeAbove: 5, blockAbove: 10, maxOpen: 10, + deniedUserIDs: [], notify: ['maintainer'] }; +const start = Date.parse('2026-10-25T00:00:00Z'); +const issue = (number, extra = {}) => ({ number, state: 'open', user: { id: 42, login: 'contributor' }, + created_at: new Date(start + number * 1000).toISOString(), ...extra }); +const spread = n => Array.from({ length: n }, (_, i) => issue(i + 1, + { created_at: new Date(start + i * 3600000).toISOString() })); + +function fixture(number, options = {}) { + const rows = options.rows || Array.from({ length: number }, (_, i) => issue(i + 1)); + const calls = []; + let alert; + const api = async (method, route, body) => { + calls.push({ method, route, body }); + if (route === '/repos/o/r') return { owner: { type: 'User', login: 'o' } }; + if (route === `/repos/o/r/issues/${number}` && method === 'GET') return rows.find(row => row.number === number); + if (route.includes('/collaborators/')) return { permission: options.permission || 'read' }; + if (route.includes('/issues?')) return [ + ...rows.filter(row => !route.includes('state=open') || row.state === 'open'), + ...(alert && !route.includes('state=open') ? [alert] : [])].reverse(); + if (route.endsWith('/issues') && method === 'POST') { + alert = { number: 999, user: { login: 'github-actions[bot]' }, created_at: new Date(start).toISOString(), ...body }; + return alert; + } + if (route.endsWith('/issues/999') && method === 'PATCH') { alert = { ...alert, ...body }; return alert; } + if (route.endsWith('/comments') && method === 'GET') return options.comments || []; + if (method === 'PATCH' || method === 'POST' || method === 'PUT') return {}; + throw new Error(`Unexpected ${method} ${route}`); + }; + return { args: { api, repository: 'o/r', number, policy, action: 'opened', ...options }, calls }; +} + +test('burst closes and locks the sixth Issue, excludes pull requests, and deduplicates bot alerts', async () => { + const rows = [...Array.from({ length: 6 }, (_, i) => issue(i + 1)), + issue(7, { pull_request: {}, user: { id: 42, login: 'contributor' } })]; + const f = fixture(6, { rows }); + const first = await moderate(f.args); + assert.equal(first.count, 6); + assert.deepEqual(first.excess.map(row => row.number), [6]); + await moderate(f.args); + assert.equal(f.calls.filter(call => call.method === 'POST' && call.route.endsWith('/issues')).length, 1); + assert.equal(f.calls.filter(call => call.method === 'PUT' && call.route.endsWith('/issues/6/lock')).length, 2); + assert.equal(f.calls.filter(call => call.route.endsWith('/issues/7/lock')).length, 0); +}); + +test('severe burst verifies owner identity and reports missing block credential', async () => { + const f = fixture(11); + const result = await moderate(f.args); + assert.equal(result.block, true); + assert.match(result.blockStatus, /not configured/); + const ownerCalls = []; + const g = fixture(11, { blockAPI: async (method, route) => { + ownerCalls.push([method, route]); + return route === '/user' ? { login: 'o' } : {}; + } }); + assert.match((await moderate(g.args)).blockStatus, /^Blocked/); + assert(ownerCalls.some(([method, route]) => method === 'PUT' && route === '/user/blocks/contributor')); +}); + +test('cap preserves twenty old open Issues, closes only a new Issue, and applies to writers', async () => { + const rows = spread(21); + for (const permission of ['read', 'write']) { + const f = fixture(21, { rows, permission }); + const result = await moderate(f.args); + assert.deepEqual(result.capExcess.map(row => row.number), [21]); + assert.deepEqual(f.calls.filter(call => call.method === 'PATCH').map(call => call.route), ['/repos/o/r/issues/21']); + assert.equal(f.calls.filter(call => call.method === 'PUT').length, 0); + assert.equal(result.burstExempt, permission === 'write'); + } + assert.deepEqual(capExcess(rows, rows[20], policy).capExcess.map(row => row.number), [21]); +}); + +test('reopened old Issue consumes a slot without closing newer peers', async () => { + const rows = spread(11); + const f = fixture(1, { rows, action: 'reopened', now: start + 24 * 3600000 }); + const result = await moderate(f.args); + assert.deepEqual(result.capExcess.map(row => row.number), [1]); + assert.equal(result.count, 0); + assert.deepEqual(f.calls.filter(call => call.method === 'PATCH').map(call => call.route), ['/repos/o/r/issues/1']); +}); + +test('out-of-order opened run closes newer excess Issues', async () => { + const rows = spread(12); + assert.deepEqual(capExcess(rows, rows[10], policy).capExcess.map(row => row.number), [11, 12]); +}); + +test('dry run does not mutate, and bot alerts plus pull requests skip moderation', async () => { + const dry = fixture(11, { dryRun: true, blockAPI: () => { throw new Error('secret used'); } }); + assert.equal((await moderate(dry.args)).block, true); + assert(dry.calls.every(call => call.method === 'GET')); + for (const extra of [{ pull_request: {} }, { user: { id: 1, login: 'github-actions[bot]' } }]) { + const f = fixture(1, { rows: [issue(1, extra)] }); + assert((await moderate(f.args)).skipped); + assert.equal(f.calls.length, 2); + } +}); + +test('permission failures do not silently exempt outsiders, and summaries omit contributor text', async () => { + const f = fixture(6, { rows: Array.from({ length: 6 }, (_, i) => issue(i + 1, + { title: 'untrusted title', body: 'untrusted body' })) }); + const original = f.args.api; + f.args.api = (method, route, body) => route.includes('/collaborators/') + ? Promise.reject(Object.assign(new Error('lookup failed'), { status: 403 })) : original(method, route, body); + await assert.rejects(moderate(f.args), /lookup failed/); + f.args.api = original; + assert.doesNotMatch(JSON.stringify(await moderate(f.args)), /untrusted/); +});