Merge pull request #3596 from affaan-m/feat/pr-burst-protection

feat(ci): limit PR bursts and alert maintainers
This commit is contained in:
haelyra
2026-10-15 15:17:00 -04:00
committed by GitHub
7 changed files with 649 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
# PR submission moderation
The PR Burst Guard protects review capacity using creation metadata. It runs on
new and reopened PRs targeting any branch, after this workflow merges. It does not
judge whether a patch is useful. Existing open PRs are not bulk-closed on rollout.
## Policy
| Control | Default | Enforcement |
| --- | --- | --- |
| Burst allowance | Five creations per author in 20 minutes | Close and lock excess PRs |
| Severe burst | More than ten creations in 20 minutes | Attempt account block; alert maintainers |
| Known denied account | User ID `108231307`, currently `dajiaohuang` | Close and lock its incoming/reopened PR |
| Trusted contributors | Live repository write, maintain, or admin permission | Exempt from the burst guard |
| Notifications | `@affaan-m @haelyra` | One bot-authored issue per author per UTC day, plus one escalation comment |
Edit `pr-burst-policy.json` to change thresholds, notification recipients or
denied numeric IDs. This policy limits submission rate, with no limit on the
total number of open PRs a contributor can accumulate over time.
Closed and draft PRs count toward burst detection. Counting uses stable author
IDs and paginated REST creation metadata, without the search index. A run checks
historical windows from the triggering PR's creation time through newer API
rows. Queue delays, out-of-order delivery and replaced pending runs cannot hide
a burst already present in that snapshot. Excess PRs are determined in creation
order across all scanned windows, with PR number breaking timestamp ties.
Already closed excess PRs count
but are not mutated again.
Reopening an old ordinary PR does not add a new creation to the burst count.
Reopening a denied author's PR triggers denial even outside the time window.
Actions moderation happens after creation and cannot prevent every CI job from starting. Account
blocks cannot stop someone from using a different GitHub account.
## Activate
1. Merge the reviewed workflow and script into `main`.
2. To enable automatic account blocking, `affaan-m` creates a dedicated
fine-grained token with the account permission **Block another user: write**.
Store it as repository Actions secret `PR_MODERATION_BLOCK_TOKEN`. Use GitHub's
secret UI or `gh secret set PR_MODERATION_BLOCK_TOKEN --repo affaan-m/ECC`
with its hidden prompt. Do not paste credentials into chat or source files.
3. Verify a genuine subsequent guard run and its job summary. Without the secret,
closure and alerting still run. A severe burst records blocking as unavailable
in the alert and fails the job so maintainers see the missing protection.
ECC currently belongs to the personal account `affaan-m`. GitHub account blocking
applies across **all repositories owned by that account**. The script checks the
token's authenticated identity before calling the personal blocking API. A
collaborator's personal block cannot protect this repository. The normal
`GITHUB_TOKEN` cannot perform account blocking. Organization blocking requires
a separate implementation if repository ownership changes.
Closing PRs cannot reset burst counts. Rapid closing and resubmission still
count as creations within the same rolling window.
## Verify without moderating anyone
```sh
node tests/ci/pr-burst-guard.test.js
node tests/ci/pr-burst-guard-workflow.test.js
node scripts/ci/validate-workflow-security.js
```
The workflow test executes the actual CLI against a disposable HTTP fixture.
It verifies close, lock, owner-identity blocking, secret separation, deduplicated
notifications, missing-secret failure and collaborator exemption.
For a live read-only replay, supply `GITHUB_TOKEN` through your secure shell
environment, then run:
```sh
GITHUB_REPOSITORY=affaan-m/ECC node scripts/ci/pr-burst-guard.js --dry-run --pr 3580
```
Expected for the October 14 snapshot: author `jwcastillo`, count `14`, `block:
true`, and nine excess PRs. Dry run never uses the blocking secret or writes to
GitHub. Hosted event delivery and real account blocking require post-merge
verification; the local fixture cannot certify those platform operations.
## Security and recovery
- The privileged event checks out its trusted default-branch SHA and two moderation files.
It never checks out PR code, installs dependencies, or executes PR text.
- Credentials enter through environment variables. Owner identity verification
and account blocking receive the owner credential. Repository moderation uses
`GITHUB_TOKEN`.
- API failures cannot silently become an allowlist exemption. Only collaborator
lookup `404` means an outsider; other errors fail the run.
- Close or lock failures appear in the alert and fail the job. Block failures
preserve closure and alerting, then fail the job.
- Repeated runs update the bot-authored alert instead of creating new issues.
A contributor-created lookalike cannot suppress alerts. Escalation remains
recorded if a later run has a lower count.
- To roll back, disable **PR Burst Guard** in Actions. Remove its secret if
abandoning automatic blocking.
- To restore a contributor, the owner removes the account block. Remove any
denied user ID from the policy, then unlock and reopen selected PRs manually.
## October 15 investigation
Authenticated GitHub GraphQL returned zero `CLOSED_EVENT` or `REOPENED_EVENT`
entries across all 55 `dajiaohuang` PRs that were open at the investigation's start. Their latest update was
October 8. The public timeline therefore supplies no evidence that they reopened
on October 15. Restored visibility after GitHub spam classification is a possible
explanation, but GitHub's private moderation history was unavailable. A block on
a collaborator's personal account would also have a different scope from an
owner block. The owner's actual block state was not available to this session.
The visible newer burst was 14 PRs from `jwcastillo`, #3567 through #3580, between
October 14 at 19:01:37 and 19:07:14 UTC. Volume alone does not establish the quality
of those patches. The maintainer subsequently authorized closing all 55 open
`dajiaohuang` PRs. All closures were verified, and a fresh paginated REST scan
found zero remaining open PRs from numeric user ID `108231307`.
This advances M0 maintenance reliability. Runtime sandbox, Capsule, Fleet,
release publication and shared application installation remain separate work.
## GitHub references
- [Personal account blocking REST endpoints](https://docs.github.com/en/rest/users/blocking)
- [Privileged pull request event security](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target)
+7
View File
@@ -0,0 +1,7 @@
{
"windowMinutes": 20,
"closeAbove": 5,
"blockAbove": 10,
"deniedUserIDs": [108231307],
"notify": ["affaan-m", "haelyra"]
}
+39
View File
@@ -0,0 +1,39 @@
name: PR Burst Guard
on:
pull_request_target:
types: [opened, reopened]
# Process one author's ledger serially. A newer pending event replaces an older
# pending event; each run reads all PR states instead of relying on event counts.
concurrency:
group: pr-burst-${{ github.repository }}-${{ github.event.pull_request.user.id }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: write
issues: write
jobs:
moderate:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# pull_request_target's SHA is the trusted default branch, never a PR head.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
sparse-checkout: |
.github/pr-burst-policy.json
scripts/ci/pr-burst-guard.js
sparse-checkout-cone-mode: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20.x'
- name: Enforce PR submission policy
env:
GITHUB_TOKEN: ${{ github.token }}
PR_MODERATION_BLOCK_TOKEN: ${{ secrets.PR_MODERATION_BLOCK_TOKEN }}
run: node scripts/ci/pr-burst-guard.js
+11
View File
@@ -433,6 +433,17 @@ Translations live under `docs/` (e.g. `docs/zh-CN`, `docs/zh-TW`, `docs/ja-JP`).
## Pull Request Process
### Submission volume
Please submit at most five PRs within any 20-minute period. Automated moderation
closes and locks excess PRs, including drafts. More than ten submissions within
20 minutes triggers a maintainer alert and an account-block attempt. Closed PRs
still count toward the window. Contributors with repository write access bypass
this policy. Maintainers can review exceptions through the moderation alert.
See the [moderation runbook](.github/PR_MODERATION.md)
for activation, credential requirements, and recovery.
### 1. PR Title Format
```
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
/** Metadata-only moderation. Never execute PR code or use PR titles/bodies. */
const fs = require('node:fs');
const path = require('node:path');
const LOGIN = /^[a-z\d](?:[a-z\d-]{0,38})(?:\[bot\])?$/i;
function validatePolicy(policy) {
for (const key of ['windowMinutes', 'closeAbove', 'blockAbove']) {
if (!Number.isSafeInteger(policy[key]) || policy[key] < 1) throw new Error(`Invalid policy ${key}`);
}
if (policy.blockAbove <= policy.closeAbove) throw new Error('blockAbove must exceed closeAbove');
if (!Array.isArray(policy.deniedUserIDs) || !policy.deniedUserIDs.every(Number.isSafeInteger)) {
throw new Error('Invalid deniedUserIDs');
}
if (!Array.isArray(policy.notify) || !policy.notify.every(login => LOGIN.test(login))) {
throw new Error('Invalid notification logins');
}
}
function githubAPI(token, fetcher = fetch) {
if (!token) throw new Error('GitHub token is required');
return async (method, route, body) => {
if (!/^\/[a-zA-Z0-9][a-zA-Z0-9/_.?=&%[\]-]*$/.test(route)) throw new Error('Invalid GitHub API path');
const response = await fetcher(`https://api.github.com${route}`, {
method, redirect: 'error', signal: AbortSignal.timeout(30000),
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2026-03-10', 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!response.ok) {
// Never print response bodies, which can contain attacker-controlled text.
const error = new Error(`GitHub ${method} failed: HTTP ${response.status}`);
error.status = response.status;
throw error;
}
return response.status === 204 ? null : response.json();
};
}
async function listPages(api, route, stop = () => false) {
const rows = [];
for (let page = 1; page <= 100; page++) {
const batch = await api('GET', `${route}&per_page=100&page=${page}`);
if (!Array.isArray(batch)) throw new Error('Expected GitHub list response');
rows.push(...batch);
if (batch.length < 100 || stop(batch)) return rows;
}
throw new Error('Pagination limit reached; refusing an incomplete moderation decision');
}
function evaluate(rows, current, policy, end) {
validatePolicy(policy);
if (!Number.isFinite(end)) throw new Error('Invalid window timestamp');
const since = end - policy.windowMinutes * 60000;
const recent = [...new Map([...rows, current].map(pr => [pr.number, pr])).values()]
.filter(pr => pr.user.id === current.user.id && Date.parse(pr.created_at) >= since && Date.parse(pr.created_at) <= end)
.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number);
const denied = policy.deniedUserIDs.includes(current.user.id);
const excess = denied ? [current] : recent.slice(policy.closeAbove);
return { count: recent.length, denied, block: denied || recent.length > policy.blockAbove,
excess: excess.map(pr => ({ number: pr.number, state: pr.state })),
since: new Date(since).toISOString(), until: new Date(end).toISOString() };
}
function strongestWindow(rows, current, policy, eventTime) {
const ordered = [...new Map([...rows, current].map(pr => [pr.number, pr])).values()]
.filter(pr => pr.user.id === current.user.id)
.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number);
const times = ordered.map(pr => Date.parse(pr.created_at));
const excess = new Map(evaluate(rows, current, policy, eventTime).excess.map(pr => [pr.number, pr]));
let left = 0;
let count = 0;
let end = eventTime;
for (let right = 0; right < times.length; right++) {
while (times[left] < times[right] - policy.windowMinutes * 60000) left++;
if (times[right] >= eventTime && right - left + 1 >= count) {
count = right - left + 1;
end = times[right];
}
if (times[right] >= eventTime && right - left + 1 > policy.closeAbove) {
excess.set(ordered[right].number, { number: ordered[right].number, state: ordered[right].state });
}
}
const peak = evaluate(rows, current, policy, end);
return { ...peak, excess: peak.denied ? peak.excess : [...excess.values()] };
}
async function blockAuthor(blockAPI, owner, login) {
if (!blockAPI) return 'Unavailable: PR_MODERATION_BLOCK_TOKEN is not configured';
try {
if (owner.type !== 'User') throw new Error('Only personal repository owners are supported');
const identity = await blockAPI('GET', '/user');
if (identity.login.toLowerCase() !== owner.login.toLowerCase()) {
throw new Error('Block token must use the repository owner identity');
}
await blockAPI('PUT', `/user/blocks/${login}`);
return 'Blocked by repository owner (all repositories owned by this account)';
} catch (error) {
return `Failed: ${error.message}`;
}
}
async function notify(api, root, current, result, policy) {
// A bot-authored ledger prevents contributors from spoofing a suppression marker.
const marker = `<!-- ecc-pr-burst:${current.user.id}:${result.until.slice(0, 10)} -->`;
const issues = await listPages(api, `${root}/issues?state=all&sort=created&direction=desc`,
batch => batch.some(issue => issue.created_at && issue.created_at.slice(0, 10) < result.until.slice(0, 10)));
const existing = issues.find(issue => !issue.pull_request && issue.user.login === 'github-actions[bot]'
&& issue.body?.includes(marker));
const escalation = '<!-- ecc-pr-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}: PR submission moderation triggered.`,
'', `Author: @${current.user.login} (user ID ${current.user.id}).`,
`Observed: ${result.count} PR creations between ${result.since} and ${result.until}.`,
`Policy: close PRs beyond ${policy.closeAbove}; block beyond ${policy.blockAbove} in ${policy.windowMinutes} minutes.`,
`Denied account: ${result.denied}.`, `Blocking: ${result.blockStatus}.`,
`Excess PRs across scanned windows: ${result.excess.map(pr => `#${pr.number}`).join(', ') || 'none'}.`,
`Enforcement errors: ${result.errors.join('; ') || 'none'}.`,
'', 'Counts include closed and draft PRs. Volume triggers moderation, not a judgment about individual patch quality.',
'Review the Actions run and PR timeline before lifting an account block.'].filter(Boolean).join('\n');
if (!existing) {
await api('POST', `${root}/issues`, { title: `PR burst alert: ${current.user.login}`, body });
} else {
// Notify again only when a soft-limit alert escalates to blocking.
if (result.block && !existing.body.includes(escalation)) {
await api('POST', `${root}/issues/${existing.number}/comments`, {
body: `${mentions}: the PR burst escalated. Blocking: ${result.blockStatus}.`,
});
}
await api('PATCH', `${root}/issues/${existing.number}`, { body });
}
}
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 PR number');
}
if (!['opened', 'reopened'].includes(action)) throw new Error('Unsupported PR event');
const root = `/repos/${repository}`;
const repo = await api('GET', root);
const current = await api('GET', `${root}/pulls/${number}`);
if (!LOGIN.test(current.user.login)) throw new Error('Invalid PR author login');
let permission;
try {
permission = (await api('GET', `${root}/collaborators/${current.user.login}/permission`)).permission;
} catch (error) {
if (error.status !== 404) throw error;
permission = 'none';
}
if (['admin', 'maintain', 'write'].includes(permission)) return { exempt: true, author: current.user.login };
// Anchor opened events to GitHub's creation time so Actions queue delays cannot erase a burst.
const end = action === 'opened' ? Date.parse(current.created_at) : now;
const since = end - policy.windowMinutes * 60000;
const rows = await listPages(api, `${root}/pulls?state=all&sort=created&direction=desc`,
batch => batch.some(pr => Date.parse(pr.created_at) < since));
// Inspect later windows too: delivery order and replaced pending runs must not
// let an older event conceal a newer burst already present in the API snapshot.
const result = { ...strongestWindow(rows, current, policy, end), author: current.user.login,
dryRun, errors: [], blockStatus: 'Below blocking threshold' };
if (dryRun) {
if (result.block) result.blockStatus = 'Dry run: would attempt owner-scoped blocking';
return result;
}
if (result.excess.length === 0) return result;
if (result.block) result.blockStatus = await blockAuthor(blockAPI, repo.owner, current.user.login);
for (const pr of result.excess) {
if (pr.state !== 'open') continue;
try {
await api('PATCH', `${root}/pulls/${pr.number}`, { state: 'closed' });
await api('PUT', `${root}/issues/${pr.number}/lock`, { lock_reason: 'spam' });
} catch (error) {
result.errors.push(`#${pr.number}: ${error.message}`);
}
}
await notify(api, root, current, result, policy);
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 prIndex = process.argv.indexOf('--pr');
const number = prIndex >= 0 ? Number(process.argv[prIndex + 1]) : event.pull_request?.number;
const repository = process.env.GITHUB_REPOSITORY;
const policy = JSON.parse(fs.readFileSync(path.join(__dirname, '../../.github/pr-burst-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, 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, `## PR burst 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 = { evaluate, moderate, githubAPI, listPages, validatePolicy, strongestWindow };
+87
View File
@@ -0,0 +1,87 @@
#!/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 CLI closes six excess PRs, locks them, blocks with owner identity, and deduplicates alerts over HTTP', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-pr-guard-'));
const mutations = [];
let issue;
let permission = 'read';
const rows = Array.from({ length: 11 }, (_, i) => ({ number: i + 1,
user: { id: 42, login: 'contributor' }, state: 'open', draft: i === 4,
created_at: new Date(Date.UTC(2026, 9, 14, 19, 0, i)).toISOString(),
body: '::error::untrusted PR body', title: '$(untrusted title)' }));
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
let text = '';
for await (const chunk of req) text += chunk;
const body = text ? JSON.parse(text) : 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 (req.method === 'GET' && url.pathname.endsWith('/pulls/11')) return send(rows[10]);
if (url.pathname.includes('/collaborators/')) return send({ permission });
if (req.method === 'GET' && url.pathname.endsWith('/pulls')) return send(rows.slice().reverse());
if (url.pathname.endsWith('/issues')) {
if (req.method === 'GET') return send(issue ? [issue] : []);
issue = { number: 88, user: { login: 'github-actions[bot]' }, ...body };
return send(issue);
}
if (url.pathname.endsWith('/issues/88') && req.method === 'PATCH') { issue = { ...issue, ...body }; return send(issue); }
if (req.method === 'PATCH' && /\/pulls\/\d+$/.test(url.pathname)) {
const pr = rows.find(row => row.number === Number(url.pathname.split('/').pop()));
pr.state = body.state; return send(pr);
}
if (req.method === 'PUT') { res.statusCode = 204; return res.end(); }
res.statusCode = 404; send({ message: 'fixture route missing' });
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
try {
const port = server.address().port;
// Test-only transport redirection. Production offers no API host override.
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');
fs.writeFileSync(event, JSON.stringify({ action: 'opened', sender: { login: 'owner' }, pull_request: { number: 11 } }));
const summary = path.join(tmp, 'summary.md');
const env = { ...process.env, NODE_OPTIONS: `--require=${preload}`, GITHUB_REPOSITORY: 'owner/repo',
GITHUB_TOKEN: 'moderation-token', PR_MODERATION_BLOCK_TOKEN: 'owner-token',
GITHUB_EVENT_PATH: event, GITHUB_STEP_SUMMARY: summary };
const run = () => exec(process.execPath, ['scripts/ci/pr-burst-guard.js'], { cwd: root, env });
const first = JSON.parse((await run()).stdout);
assert.equal(first.count, 11);
assert.match(first.blockStatus, /^Blocked/);
await run();
assert.equal(mutations.filter(m => m.route.endsWith('/issues') && m.method === 'POST').length, 1);
assert.equal(mutations.filter(m => /\/pulls\/\d+$/.test(m.route)).length, 6);
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 moderation-token'));
assert.doesNotMatch(fs.readFileSync(summary, 'utf8'), /untrusted|owner-token|moderation-token/);
assert(issue.body.includes('@affaan-m @haelyra'));
delete env.PR_MODERATION_BLOCK_TOKEN;
await assert.rejects(run(), error => {
assert.equal(error.code, 1);
assert.match(JSON.parse(error.stdout).blockStatus, /not configured/);
return true;
});
permission = 'write';
assert.equal(JSON.parse((await run()).stdout).exempt, true);
} finally {
await new Promise(resolve => server.close(resolve));
fs.rmSync(tmp, { recursive: true, force: true });
}
});
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { evaluate, moderate, githubAPI, listPages, strongestWindow } = require('../../scripts/ci/pr-burst-guard');
const policy = { windowMinutes: 20, closeAbove: 5, blockAbove: 10, deniedUserIDs: [999], notify: ['maintainer'] };
const start = Date.parse('2026-10-14T19:00:00Z');
const pull = (number, extra = {}) => ({ number, user: { id: 42, login: 'contributor' },
created_at: new Date(start + number * 1000).toISOString(), state: 'open', ...extra });
const pulls = n => Array.from({ length: n }, (_, i) => pull(i + 1));
function fixture(n, options = {}) {
const rows = options.rows || pulls(n);
const calls = [];
let issue;
const api = async (method, route, body) => {
calls.push({ method, route, body });
if (route === '/repos/owner/repo') return { owner: { login: 'owner', type: 'User' } };
if (route.endsWith(`/pulls/${n}`)) return rows.find(p => p.number === n);
if (route.includes('/collaborators/')) return { permission: options.permission || 'read' };
if (route.includes('/pulls?')) return rows.slice().reverse();
if (route.includes('/issues?')) return issue ? [issue] : [];
if (route.endsWith('/issues') && method === 'POST') {
issue = { number: 101, user: { login: 'github-actions[bot]' }, ...body };
return issue;
}
if (route.endsWith('/issues/101') && method === 'PATCH') { issue = { ...issue, ...body }; return issue; }
if (route.endsWith('/issues/101/comments')) return {};
if (method === 'PATCH') return {};
if (method === 'PUT') return {};
throw new Error(`Unexpected ${method} ${route}`);
};
const args = { api, repository: 'owner/repo', number: n, action: 'opened', policy, ...options };
return { args, calls, api };
}
test('five allowed, sixth closed, ten not blocked, eleventh escalates', () => {
for (const n of [5, 6, 10, 11]) {
const result = evaluate(pulls(n), pull(n), policy, start + n * 1000);
assert.equal(result.count, n);
assert.equal(result.block, n > 10);
assert.deepEqual(result.excess.map(p => p.number), Array.from({ length: Math.max(0, n - 5) }, (_, i) => i + 6));
}
});
test('counts closed and draft PRs by stable user ID, excludes other authors and out-of-window rows', () => {
const rows = [...pulls(5), pull(6, { state: 'closed', draft: true }), pull(7, { user: { id: 99 } }),
pull(8, { created_at: new Date(start - 21 * 60000).toISOString() })];
assert.equal(evaluate(rows, pull(6), policy, start + 6000).count, 6);
});
test('delayed events retain their creation window and deterministic number tie-break', () => {
const rows = pulls(11).map(p => ({ ...p, created_at: new Date(start).toISOString() }));
assert.equal(evaluate(rows.reverse(), rows[0], policy, start).block, true);
assert.deepEqual(evaluate(rows, rows[0], policy, start).excess.map(p => p.number), [6, 7, 8, 9, 10, 11]);
});
test('moderation closes and locks excess, then deduplicates maintainer alert', async () => {
const f = fixture(6);
await moderate(f.args);
await moderate(f.args);
assert.equal(f.calls.filter(c => c.method === 'POST' && c.route.endsWith('/issues')).length, 1);
assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/6') && c.body.state === 'closed'));
assert(f.calls.some(c => c.method === 'PUT' && c.route.endsWith('/issues/6/lock')));
});
test('no owner credential reports blocking unavailable and still notifies', async () => {
const f = fixture(11);
const result = await moderate(f.args);
assert.match(result.blockStatus, /not configured/);
assert(f.calls.some(c => c.method === 'POST' && c.body.body.includes('not configured')));
});
test('owner token identity checked before personal block, escalation notifies exactly once', async () => {
const ownerCalls = [];
const blockAPI = async (method, route) => {
ownerCalls.push({ method, route });
if (route === '/user') return { login: 'owner' };
return {};
};
const f = fixture(6, { blockAPI });
await moderate(f.args);
f.args.number = 11;
// Use a second snapshot while retaining the alert ledger.
const original = f.args.api;
f.args.api = (method, route, body) => route.endsWith('/pulls/11') ? pull(11)
: route.includes('/pulls?') ? pulls(11).reverse() : original(method, route, body);
await moderate(f.args);
await moderate(f.args);
assert(ownerCalls.some(c => c.method === 'PUT' && c.route === '/user/blocks/contributor'));
assert.equal(f.calls.filter(c => c.route.endsWith('/comments')).length, 1);
});
test('wrong personal identity never blocks and records the failure', async () => {
const f = fixture(11, { blockAPI: async (method, route) => {
assert.equal(route, '/user'); return { login: 'someone-else' };
} });
assert.match((await moderate(f.args)).blockStatus, /owner identity/);
});
test('write/admin collaborators bypass; actor identity cannot exempt an outsider', async () => {
for (const permission of ['write', 'maintain', 'admin']) {
const f = fixture(11, { permission });
assert.equal((await moderate(f.args)).exempt, true);
assert.equal(f.calls.filter(c => ['POST', 'PATCH', 'PUT'].includes(c.method)).length, 0);
}
const f = fixture(6, { actor: 'owner' });
assert.equal((await moderate(f.args)).count, 6);
});
test('denylisted user is closed on reopen even outside the creation window', async () => {
const f = fixture(1, { action: 'reopened', rows: [pull(1, { user: { id: 999, login: 'denied' } })] });
assert.equal((await moderate(f.args)).denied, true);
assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/1')));
});
test('dry run performs no mutations or owner-token calls', async () => {
const f = fixture(11, { dryRun: true, blockAPI: async () => { throw new Error('secret used'); } });
assert.equal((await moderate(f.args)).block, true);
assert(f.calls.every(c => c.method === 'GET'));
});
test('REST transport rejects off-host paths and never retries mutations', async () => {
const api = githubAPI('test-token', async () => { throw new Error('must not fetch'); });
await assert.rejects(api('GET', '//attacker.test'), /path/);
let attempts = 0;
const limited = githubAPI('token', async () => {
attempts++; return { ok: false, status: 403 };
});
await assert.rejects(limited('PUT', '/user/blocks/example'), /403/);
assert.equal(attempts, 1);
});
test('out-of-order older event sees newer burst and retains historical peak', async () => {
const rows = [...pulls(11), pull(12, { created_at: new Date(start + 30 * 60000).toISOString() })];
assert.equal(strongestWindow(rows, pull(6), policy, start + 6000).count, 11);
const f = fixture(6, { rows });
assert.equal((await moderate(f.args)).block, true);
});
test('separate bursts close excess from each window even if pending events disappear', () => {
const rows = [...pulls(6), ...pulls(6).map(p => ({ ...p, number: p.number + 6,
created_at: new Date(start + 60 * 60000 + p.number * 1000).toISOString() }))];
const result = strongestWindow(rows, pull(6), policy, start + 6000);
assert.equal(result.count, 6);
assert.deepEqual(result.excess.map(p => p.number), [6, 12]);
});
test('pagination includes page two and refuses silent truncation', async () => {
let calls = 0;
const rows = await listPages(async () => { calls++; return calls === 1 ? pulls(100) : pulls(1); }, '/repos/o/r/pulls?state=all');
assert.equal(rows.length, 101);
assert.equal(calls, 2);
await assert.rejects(listPages(async () => pulls(100), '/repos/o/r/pulls?state=all'), /Pagination limit/);
});
test('permission errors fail safely; outsider404 permits moderation', async () => {
for (const status of [403, 404]) {
const f = fixture(6);
const original = f.args.api;
f.args.api = (method, route, body) => {
if (route.includes('/collaborators/')) throw Object.assign(new Error('permission lookup failed'), { status });
return original(method, route, body);
};
if (status === 403) await assert.rejects(moderate(f.args), /permission lookup/);
else assert.equal((await moderate(f.args)).count, 6);
}
});
test('closure failure still creates an alert with enforcement errors', async () => {
const f = fixture(6);
const original = f.args.api;
f.args.api = (method, route, body) => {
if (method === 'PATCH' && route.endsWith('/pulls/6')) throw new Error('close denied');
return original(method, route, body);
};
assert.match((await moderate(f.args)).errors[0], /close denied/);
assert(f.calls.some(c => c.method === 'POST' && c.body.body.includes('close denied')));
});
test('reports only moderation metadata, never PR bodies or titles', async () => {
const f = fixture(6, { rows: pulls(6).map(p => ({ ...p, title: 'untrusted title', body: 'untrusted body' })) });
assert.doesNotMatch(JSON.stringify(await moderate(f.args)), /untrusted/);
});