diff --git a/.github/scripts/pr_limit_moderation.js b/.github/scripts/pr_limit_moderation.js new file mode 100644 index 0000000000..489a06ea78 --- /dev/null +++ b/.github/scripts/pr_limit_moderation.js @@ -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, +}; diff --git a/.github/tests/test_pr_limit_moderation.js b/.github/tests/test_pr_limit_moderation.js new file mode 100644 index 0000000000..67192c8327 --- /dev/null +++ b/.github/tests/test_pr_limit_moderation.js @@ -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); + }); +}); diff --git a/.github/workflows/limit-community-prs.yml b/.github/workflows/limit-community-prs.yml new file mode 100644 index 0000000000..2fe66ccd20 --- /dev/null +++ b/.github/workflows/limit-community-prs.yml @@ -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, + });