mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-21 07:21:46 +02:00
Extend the PR burst guard with an open-PR cap. On every opened or reopened PR by a non-collaborator, the guard lists the repository's open PRs, keeps the author's oldest `maxOpen` (10) open and closes the newer ones with a bot comment that names the count and the limit. No lock, no maintainer alert: the cap is about review capacity, not spam. PRs the burst logic already closed in the same run are excluded so nothing is mutated twice. Collaborators with write access stay exempt, dry run reports without mutating, and the comment is deduplicated on a bot-authored marker. Haley asked for this on 2026-10-19: each contributor should only have 10 to 15 PRs open at a time. Existing open PRs are not bulk-closed; the cap applies as each author opens or reopens their next PR. Verification: 22 unit tests and the HTTP workflow test pass, validate-workflow-security passes, eslint and markdownlint clean. Dry run against affaan-m/ECC on #3621 reports openCount 19 and nine cap excess PRs for that author, with no writes.
234 lines
13 KiB
JavaScript
234 lines
13 KiB
JavaScript
#!/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', 'maxOpen']) {
|
|
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()] };
|
|
}
|
|
|
|
/** Open-PR cap: keep the author's oldest `maxOpen` open PRs, close the rest. PRs the burst
|
|
* guard already closed in this run are treated as closed so nothing is mutated twice. */
|
|
function capExcess(openRows, current, policy, alreadyClosed = new Set()) {
|
|
validatePolicy(policy);
|
|
const open = [...new Map([...openRows, current].map(pr => [pr.number, pr])).values()]
|
|
.filter(pr => pr.user.id === current.user.id && pr.state === 'open' && !alreadyClosed.has(pr.number))
|
|
.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number);
|
|
return { openCount: open.length, maxOpen: policy.maxOpen,
|
|
capExcess: open.slice(policy.maxOpen).map(pr => ({ number: pr.number, state: pr.state })) };
|
|
}
|
|
|
|
const CAP_MARKER = '<!-- ecc-pr-open-cap -->';
|
|
|
|
async function enforceCap(api, root, pr, current, result) {
|
|
try {
|
|
await api('PATCH', `${root}/pulls/${pr.number}`, { state: 'closed' });
|
|
const comments = await listPages(api, `${root}/issues/${pr.number}/comments?sort=created`);
|
|
if (comments.some(c => c.user?.login === 'github-actions[bot]' && c.body?.includes(CAP_MARKER))) return;
|
|
await api('POST', `${root}/issues/${pr.number}/comments`, { body: [CAP_MARKER,
|
|
`Closed automatically: @${current.user.login} has ${result.openCount} open pull requests in this repository, above the limit of ${result.maxOpen} per contributor.`,
|
|
'', 'Get existing PRs merged, or close the ones you no longer need, then reopen this one. The limit is about review capacity, not the quality of this patch.',
|
|
'Contributors with repository write access are exempt. Policy: `.github/PR_MODERATION.md`.'].join('\n') });
|
|
} catch (error) {
|
|
result.errors.push(`#${pr.number}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
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' };
|
|
// Total open PRs is a separate, unwindowed list: the burst scan stops at the window edge.
|
|
const openRows = await listPages(api, `${root}/pulls?state=open&sort=created&direction=desc`);
|
|
Object.assign(result, capExcess(openRows, current, policy, new Set(result.excess.map(pr => pr.number))));
|
|
if (dryRun) {
|
|
if (result.block) result.blockStatus = 'Dry run: would attempt owner-scoped blocking';
|
|
return result;
|
|
}
|
|
if (result.excess.length > 0) await enforceBurst({ api, blockAPI, root, repo, current, result, policy });
|
|
for (const pr of result.capExcess) await enforceCap(api, root, pr, current, result);
|
|
return result;
|
|
}
|
|
|
|
async function enforceBurst({ api, blockAPI, root, repo, current, result, policy }) {
|
|
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);
|
|
}
|
|
|
|
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, capExcess };
|