Add community PR limit workflow (#6229)

* Add community PR limit workflow

* Address PR limit workflow review feedback
This commit is contained in:
Evan Mattson
2026-06-01 18:12:31 +09:00
committed by GitHub
Unverified
parent 5affc9c333
commit 8b0db48d33
3 changed files with 532 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
// Copyright (c) Microsoft. All rights reserved.
function getPullRequest(context) {
const pullRequest = context.payload.pull_request;
if (!pullRequest?.number || !pullRequest.user?.login) {
throw new Error('This script must be run from a pull_request_target event.');
}
return {
author: pullRequest.user.login,
labels: pullRequest.labels?.map((label) => label.name).filter(Boolean) ?? [],
number: pullRequest.number,
};
}
async function ensureLabel({ github, owner, repo, labelName }) {
try {
await github.rest.issues.getLabel({
owner,
repo,
name: labelName,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
try {
await github.rest.issues.createLabel({
owner,
repo,
name: labelName,
color: 'd93f0b',
description: 'Community author has exceeded the open pull request limit.',
});
} catch (createError) {
if (createError.status !== 422) {
throw createError;
}
}
}
}
function hasLabel(labels, labelName) {
if (!labelName) {
return false;
}
return labels.some((label) => label.toLowerCase() === labelName.toLowerCase());
}
function buildLimitMessage({ author, exemptLabelName, maxOpenPrs, openPrCount }) {
return [
`Thank you for your contribution, @${author}.`,
'',
`To keep the review queue manageable, we currently limit community contributors to ${maxOpenPrs} `
+ `open pull requests at a time. This PR would put you at ${openPrCount} open pull requests, `
+ 'so we are closing it automatically.',
'',
'Please focus on getting your existing PRs reviewed, merged, or closed before opening another one. '
+ `If a maintainer asked you to open this PR, they can apply the \`${exemptLabelName}\` label and reopen it.`,
].join('\n');
}
async function getOpenPrCount({ github, owner, repo, author, pullRequestNumber }) {
const query = `repo:${owner}/${repo} is:pr is:open author:${author}`;
const response = await github.rest.search.issuesAndPullRequests({
q: query,
per_page: 100,
});
const indexedPrNumbers = response.data.items.map((item) => item.number);
const currentPrIsIndexed = indexedPrNumbers.includes(pullRequestNumber);
if (currentPrIsIndexed || response.data.total_count >= 100) {
return response.data.total_count;
}
return response.data.total_count + 1;
}
async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) {
const { owner, repo } = context.repo;
const { author, labels, number } = getPullRequest(context);
if (hasLabel(labels, exemptLabelName)) {
core.info(`PR #${number} has the ${exemptLabelName} label; skipping open PR limit enforcement.`);
return {
author,
closed: false,
exempt: true,
openPrCount: null,
};
}
const openPrCount = await getOpenPrCount({
github,
owner,
repo,
author,
pullRequestNumber: number,
});
if (openPrCount <= maxOpenPrs) {
core.info(
`${author} has ${openPrCount} open pull request(s), which is within the limit of ${maxOpenPrs}.`,
);
return {
author,
closed: false,
openPrCount,
};
}
await ensureLabel({
github,
owner,
repo,
labelName,
});
await github.rest.issues.addLabels({
owner,
repo,
issue_number: number,
labels: [labelName],
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: number,
body: buildLimitMessage({
author,
exemptLabelName,
maxOpenPrs,
openPrCount,
}),
});
await github.rest.pulls.update({
owner,
repo,
pull_number: number,
state: 'closed',
});
core.info(
`${author} has ${openPrCount} open pull request(s), which exceeds the limit of ${maxOpenPrs}. `
+ `Closed PR #${number}.`,
);
return {
author,
closed: true,
openPrCount,
};
}
module.exports = {
buildLimitMessage,
enforcePrLimit,
getOpenPrCount,
};
+286
View File
@@ -0,0 +1,286 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Tests for pr_limit_moderation.js.
*
* Run with: node --test .github/tests/test_pr_limit_moderation.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { enforcePrLimit } = require('../scripts/pr_limit_moderation.js');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createContext({ author = 'community-user', labels = [], number = 123 } = {}) {
return {
repo: {
owner: 'microsoft',
repo: 'agent-framework',
},
payload: {
pull_request: {
number,
labels: labels.map((name) => ({ name })),
user: {
login: author,
},
},
},
};
}
function createCore() {
const messages = [];
return {
messages,
info(message) {
messages.push(message);
},
};
}
function createGithub({ totalCount, itemNumbers, labelExists = true }) {
const calls = [];
return {
calls,
rest: {
search: {
async issuesAndPullRequests(params) {
calls.push({ api: 'search.issuesAndPullRequests', params });
return {
data: {
total_count: totalCount,
items: itemNumbers.map((number) => ({ number })),
},
};
},
},
issues: {
async getLabel(params) {
calls.push({ api: 'issues.getLabel', params });
if (!labelExists) {
const error = new Error('Not Found');
error.status = 404;
throw error;
}
return { data: { name: params.name } };
},
async createLabel(params) {
calls.push({ api: 'issues.createLabel', params });
return { data: { name: params.name } };
},
async addLabels(params) {
calls.push({ api: 'issues.addLabels', params });
return { data: [] };
},
async createComment(params) {
calls.push({ api: 'issues.createComment', params });
return { data: { id: 1 } };
},
},
pulls: {
async update(params) {
calls.push({ api: 'pulls.update', params });
return { data: { state: params.state } };
},
},
},
};
}
// ---------------------------------------------------------------------------
// PR limit enforcement
// ---------------------------------------------------------------------------
describe('PR limit enforcement', () => {
it('does not close the PR when the author is at the open PR limit', async () => {
const github = createGithub({
totalCount: 10,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 123],
});
const result = await enforcePrLimit({
github,
context: createContext(),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, false);
assert.equal(result.openPrCount, 10);
assert.deepEqual(
github.calls.map((call) => call.api),
['search.issuesAndPullRequests'],
);
});
it('counts the new PR when search has not indexed it yet', async () => {
const github = createGithub({
totalCount: 10,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
});
const result = await enforcePrLimit({
github,
context: createContext(),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, true);
assert.equal(result.openPrCount, 11);
assert.deepEqual(
github.calls.map((call) => call.api),
[
'search.issuesAndPullRequests',
'issues.getLabel',
'issues.addLabels',
'issues.createComment',
'pulls.update',
],
);
});
it('creates the label when it does not already exist', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
labelExists: false,
});
const result = await enforcePrLimit({
github,
context: createContext(),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, true);
assert.deepEqual(
github.calls.map((call) => call.api),
[
'search.issuesAndPullRequests',
'issues.getLabel',
'issues.createLabel',
'issues.addLabels',
'issues.createComment',
'pulls.update',
],
);
assert.equal(
github.calls.find((call) => call.api === 'issues.createLabel').params.name,
'too-many-prs',
);
});
it('tolerates a 422 race when creating the label', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
labelExists: false,
});
github.rest.issues.createLabel = async (params) => {
github.calls.push({ api: 'issues.createLabel', params });
const error = new Error('Validation Failed');
error.status = 422;
throw error;
};
const result = await enforcePrLimit({
github,
context: createContext(),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, true);
assert.deepEqual(
github.calls.map((call) => call.api),
[
'search.issuesAndPullRequests',
'issues.getLabel',
'issues.createLabel',
'issues.addLabels',
'issues.createComment',
'pulls.update',
],
);
});
it('uses a diplomatic close message with the configured limit', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
});
await enforcePrLimit({
github,
context: createContext({ author: 'octo-contributor' }),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
const comment = github.calls.find((call) => call.api === 'issues.createComment').params.body;
assert.match(comment, /Thank you for your contribution/);
assert.match(comment, /limit community contributors to 10 open pull requests/);
assert.match(comment, /@octo-contributor/);
assert.match(comment, /`pr-limit-exempt` label and reopen/);
});
it('does not close an exempt PR when it is reopened', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
});
const result = await enforcePrLimit({
github,
context: createContext({ labels: ['PR-LIMIT-EXEMPT'] }),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, false);
assert.equal(result.exempt, true);
assert.equal(result.openPrCount, null);
assert.deepEqual(github.calls, []);
});
it('does not over-count when the current PR is not on the first search page', async () => {
const github = createGithub({
totalCount: 101,
itemNumbers: Array.from({ length: 100 }, (_, index) => index + 1),
});
const result = await enforcePrLimit({
github,
context: createContext({ number: 123 }),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, true);
assert.equal(result.openPrCount, 101);
});
});
+83
View File
@@ -0,0 +1,83 @@
name: Limit community pull requests
on:
pull_request_target:
types: [opened, reopened]
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: pr-limit-${{ github.repository }}-${{ github.event.pull_request.user.login }}
cancel-in-progress: false
env:
MAX_OPEN_PULL_REQUESTS: '10'
PR_LIMIT_EXEMPT_LABEL: pr-limit-exempt
TOO_MANY_PRS_LABEL: too-many-prs
jobs:
team_check:
runs-on: ubuntu-latest
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
persist-credentials: false
- name: Check PR author team membership
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
const { author, isTeamMember } = await checkTeamMembership({
github,
context,
core,
teamSlug: process.env.TEAM_NAME,
issueNumber: process.env.PR_NUMBER,
});
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
if (isTeamMember) {
core.info(`Author ${author} is a team member; skipping open PR limit.`);
} else {
core.info(`Author ${author} is not a team member; checking open PR limit.`);
}
limit_open_prs:
runs-on: ubuntu-latest
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
persist-credentials: false
- name: Enforce open PR limit
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
const { enforcePrLimit } = require('./.github/scripts/pr_limit_moderation.js');
await enforcePrLimit({
github,
context,
core,
exemptLabelName: process.env.PR_LIMIT_EXEMPT_LABEL,
maxOpenPrs: Number.parseInt(process.env.MAX_OPEN_PULL_REQUESTS, 10),
labelName: process.env.TOO_MANY_PRS_LABEL,
});