diff --git a/.github/workflows/pr_limit.yml b/.github/workflows/pr_limit.yml new file mode 100644 index 000000000000..2174a61ed351 --- /dev/null +++ b/.github/workflows/pr_limit.yml @@ -0,0 +1,66 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Limits the number of concurrently open pull requests a contributor +# without write access can have. This mirrors GitHub's native +# "pull request creation cap" setting, which requires admin rights and +# so can only be configured for ASF repositories via .asf.yaml. Once +# https://github.com/apache/infrastructure-asfyaml/pull/111 is merged, +# this workflow can be replaced by the `github.pull_requests.creation_cap` +# directive in .asf.yaml. + +name: PR Limit + +on: + pull_request_target: + types: + - opened + - reopened + +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + issues: write +env: + # Maximum number of open pull requests per contributor without write + # access. A pull request that takes the contributor over this limit is + # closed with an explanatory comment. + PR_LIMIT: 3 + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + # Always run the script from main, never from the PR head. + - uses: actions/checkout@v7 + with: + repository: apache/arrow + ref: main + persist-credentials: false + + - name: Check open pull request count + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require(`${process.env.GITHUB_WORKSPACE}/.github/workflows/pr_limit/check.js`); + await script({github, context, core}); diff --git a/.github/workflows/pr_limit/check.js b/.github/workflows/pr_limit/check.js new file mode 100644 index 000000000000..496895ddc0b8 --- /dev/null +++ b/.github/workflows/pr_limit/check.js @@ -0,0 +1,130 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +const fs = require("fs"); + +const WRITE_PERMISSIONS = new Set(["write", "maintain", "admin"]); + +/** + * Returns whether the user has write access to the repository. + * + * Note that `author_association` is not a reliable signal for this: + * ASF members show up as MEMBER regardless of their permission on this + * repository, and triage collaborators show up as COLLABORATOR. + * + * @param {Object} github + * @param {Object} context + * @param {String} username + */ +async function hasWriteAccess(github, context, username) { + try { + const {data} = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: username + }); + return WRITE_PERMISSIONS.has(data.permission); + } catch (error) { + if (error.status === 404) { + return false; + } + throw error; + } +} + +/** + * Returns the number of open pull requests authored by the user in this + * repository, including the one that triggered the workflow. + * + * @param {Object} github + * @param {Object} context + * @param {String} username + */ +async function countOpenPullRequests(github, context, username) { + const {data} = await github.rest.search.issuesAndPullRequests({ + q: `repo:${context.repo.owner}/${context.repo.repo} is:pr is:open author:${username}`, + per_page: 1 + }); + return data.total_count; +} + +/** + * Comments on the pull request explaining the limit, then closes it. + * + * @param {Object} github + * @param {Object} context + * @param {Number} pullRequestNumber + * @param {String} username + * @param {Number} limit + * @param {Number} count + */ +async function commentAndClose(github, context, pullRequestNumber, username, limit, count) { + const commentPath = ".github/workflows/pr_limit/comment.md"; + const comment = fs.readFileSync(commentPath).toString() + .replaceAll("${PR_LIMIT}", limit) + .replaceAll("${OPEN_COUNT}", count) + .replaceAll("${USERNAME}", username); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequestNumber, + body: comment + }); + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullRequestNumber, + state: "closed" + }); +} + +module.exports = async ({github, context, core}) => { + const limit = parseInt(process.env.PR_LIMIT, 10); + if (!Number.isInteger(limit) || limit < 1) { + throw new Error(`PR_LIMIT must be a positive integer, got: ${process.env.PR_LIMIT}`); + } + + const pullRequestNumber = context.payload.number; + const user = context.payload.pull_request.user; + + if (user.type === "Bot") { + core.info(`Skipping: ${user.login} is a bot.`); + return; + } + + if (await hasWriteAccess(github, context, user.login)) { + core.info(`Skipping: ${user.login} has write access.`); + return; + } + + // A committer reopening a previously closed pull request is a deliberate + // decision to accept it, so don't close it again. + const sender = context.payload.sender; + if (sender.login !== user.login && await hasWriteAccess(github, context, sender.login)) { + core.info(`Skipping: ${context.payload.action} by ${sender.login}, who has write access.`); + return; + } + + const count = await countOpenPullRequests(github, context, user.login); + core.info(`${user.login} has ${count} open pull request(s); limit is ${limit}.`); + if (count <= limit) { + return; + } + + core.info(`Closing #${pullRequestNumber}: over the limit.`); + await commentAndClose(github, context, pullRequestNumber, user.login, limit, count); +}; diff --git a/.github/workflows/pr_limit/comment.md b/.github/workflows/pr_limit/comment.md new file mode 100644 index 000000000000..cd6f76d76c47 --- /dev/null +++ b/.github/workflows/pr_limit/comment.md @@ -0,0 +1,31 @@ + + +Thanks for opening a pull request! + +**This pull request has been automatically closed because you currently have ${OPEN_COUNT} open pull requests, which is more than the limit of ${PR_LIMIT}.** + +Due to the increase in pull requests opened by AI bots, and in order to keep the review queue manageable, Apache Arrow limits contributors without write access to at most ${PR_LIMIT} concurrently open pull requests. This helps make sure each pull request gets the attention it needs and that work in progress does not go stale. + +Once one of [your other open pull requests](https://github.com/apache/arrow/pulls/${USERNAME}) has been merged or closed, you are welcome to reopen this one. + +See also: + + * [Contribution Guidelines - Limit on concurrent pull requests](https://arrow.apache.org/docs/dev/developers/bug_reports.html#pr-limit) + * [Contribution Guidelines - Contributing Overview](https://arrow.apache.org/docs/developers/overview.html) diff --git a/docs/source/developers/bug_reports.rst b/docs/source/developers/bug_reports.rst index 307261617acc..a715211fde8d 100644 --- a/docs/source/developers/bug_reports.rst +++ b/docs/source/developers/bug_reports.rst @@ -237,6 +237,18 @@ still active. ``Status: needs champion`` are confirmed-wanted enhancements that need a contributor. See :ref:`finding-issues` for more. +.. _pr-limit: + +Limit on concurrent pull requests ++++++++++++++++++++++++++++++++++ + +Due to the increase in pull requests opened by AI bots, and in order to keep +the review queue manageable, contributors without write access to the +repository may have at most **3 pull requests open at the same time**. +A pull request opened beyond that limit is automatically closed by a GitHub +Actions workflow, with a comment explaining why. Once one of your other pull +requests has been merged or closed, you can reopen it. + .. _issue-assignment: Issue assignment