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.
This commit is contained in:
haelyra authored and GitHub committed 2026-10-25 15:16:53 -04:00
1 parent 4c28a7be9e
commit 108ff18624
8 files changed
+476 -1

No files matched your search

+68
View File
@@ -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 <NUMBER>
```
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.
+8
View File
@@ -0,0 +1,8 @@
{
"windowMinutes": 20,
"closeAbove": 5,
"blockAbove": 10,
"maxOpen": 10,
"deniedUserIDs": [],
"notify": ["affaan-m", "haelyra"]
}
@@ -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
+7
View File
@@ -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
+137
View File
@@ -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 = '<!-- ecc-issue-open-cap -->';
const ALERT_MARKER = '<!-- ecc-issue-burst:';
const isIssue = issue => !issue.pull_request;
const byCreation = (a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number;
function capExcess(openRows, current, policy, alreadyClosed = new Set(), action = 'opened') {
validatePolicy(policy);
const open = [...new Map([...openRows, current].map(issue => [issue.number, issue])).values()]
.filter(issue => isIssue(issue) && issue.user.id === current.user.id
&& issue.state === 'open' && !alreadyClosed.has(issue.number)).sort(byCreation);
const currentIndex = open.findIndex(issue => issue.number === current.number);
const excess = open.length <= policy.maxOpen || currentIndex < 0 ? []
: action === 'reopened' ? [open[currentIndex]]
: open.filter((issue, index) => index >= policy.maxOpen && byCreation(issue, current) >= 0);
return { openCount: open.length, maxOpen: policy.maxOpen,
capExcess: excess.map(issue => ({ number: issue.number, state: issue.state })) };
}
async function notify(api, root, current, result, policy) {
const day = result.until.slice(0, 10);
const marker = `${ALERT_MARKER}${current.user.id}:${day} -->`;
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 = '<!-- ecc-issue-burst:block-threshold -->';
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 };
+1 -1
View File
@@ -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 };
@@ -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 });
}
});
+112
View File
@@ -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/);
});