// Warns when a repository member has too many open pull requests. // // Review capacity is the bottleneck, so authors are expected to land or close // existing work before opening more. Drafts are not counted and never warned // about: they are not asking for review yet. // This is advisory only — it never closes a pull request or fails the job. import { appendFileSync } from "node:fs"; const MARKER = ""; // GITHUB_TOKEN can only edit comments authored by the Actions bot itself. const COMMENT_AUTHOR = "github-actions[bot]"; // Repository permissions that mark someone as part of the team. const TEAM_PERMISSIONS = ["admin", "maintain", "write"]; // Keep the comment readable for authors who are far over the limit. const MAX_LISTED_PRS = 10; function summary(text) { if (process.env.GITHUB_STEP_SUMMARY) { appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${text}\n`); } console.log(text); } // `author_association` cannot be used here: it is computed from what the caller // is allowed to see, so GITHUB_TOKEN reports a private organization member as // CONTRIBUTOR. Repository permission is viewer-independent. // // On error this returns true rather than false. A token that cannot read // permissions would otherwise make the whole check silently pass everyone. async function isTeamMember(octokit, owner, repo, username) { try { const { data } = await octokit.repos.getCollaboratorPermissionLevel({ owner, repo, username, }); // Never log the level itself: job logs are public. return TEAM_PERMISSIONS.includes(data.permission); } catch (error) { console.log( `Cannot read repository permission for \`${username}\` (HTTP ${error.status}); applying the limit anyway.` ); return true; } } function buildComment(author, total, limit, openPrs) { const listed = openPrs .slice(0, MAX_LISTED_PRS) .map((pr) => `- #${pr.number} ${pr.title}`); const hidden = openPrs.length - listed.length; const list = hidden > 0 ? `${listed.join("\n")}\n- ...and ${hidden} more` : listed.join("\n"); return `${MARKER} > [!WARNING] > @${author} has **${total}** open pull requests in this repository, over the limit of **${limit}**. Review is the scarcest resource here. Please land or close some of these before pushing this one forward: ${list} This check is advisory for now and blocks nothing.`; } async function upsertComment(octokit, params, body) { const comments = await octokit.paginate(octokit.issues.listComments, { ...params, per_page: 100, }); const existing = comments.find( (comment) => comment.user?.login === COMMENT_AUTHOR && comment.body?.includes(MARKER) ); if (existing) { await octokit.issues.updateComment({ owner: params.owner, repo: params.repo, comment_id: existing.id, body, }); } else { await octokit.issues.createComment({ ...params, body }); } } (async () => { const { Octokit } = await import("@octokit/rest"); const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); const prNumber = Number(process.env.PR_NUMBER); const author = process.env.PR_AUTHOR; const rawLimit = Number(process.env.MAX_OPEN_PRS); const limit = Number.isInteger(rawLimit) && rawLimit >= 0 ? rawLimit : 5; if (author.endsWith("[bot]")) { summary(`Skipping bot author \`${author}\`.`); return; } if (process.env.PR_IS_DRAFT === "true") { summary(`Skipping #${prNumber}: draft.`); return; } const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); if (!(await isTeamMember(octokit, owner, repo, author))) { summary(`Skipping \`${author}\`: not a team member.`); return; } const allOpen = await octokit.paginate(octokit.pulls.list, { owner, repo, state: "open", sort: "created", direction: "asc", per_page: 100, }); // The pull request that triggered this run is already open, so exclude it and // report the total separately. const others = allOpen.filter( (pr) => pr.user?.login === author && pr.number !== prNumber && !pr.draft ); const total = others.length + 1; if (others.length < limit) { summary(`\`${author}\` has ${total} open pull requests (limit ${limit}). OK.`); return; } summary(`\`${author}\` has ${total} open pull requests, over the limit of ${limit}.`); await upsertComment( octokit, { owner, repo, issue_number: prNumber }, buildComment(author, total, limit, others) ); })().catch((error) => { console.error(error); process.exit(1); });