From 45da010ed44a1293b03d2118ed2867d21f487be8 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 12 Oct 2026 15:15:42 +0200 Subject: [PATCH] chore: add "approve" label for issues that can accept PRs (#11547) --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/scripts/pr-redirect.js | 134 ++++++++++++++++++++++--------- .github/workflows/ai-policy.yml | 3 +- AGENTS.md | 2 +- CONTRIBUTING.md | 9 ++- 5 files changed, 108 insertions(+), 42 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7bb3af2c9..850bb911f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,5 @@ ' +const POLICY_MARKER = '' +const DUPLICATE_MARKER = '' +const APPROVED_LABEL = 'approved' function createRedirect({ github, context, core }) { const { owner, repo } = context.repo const policyUrl = `https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md#pull-request-policy` + // closing keywords can point to any repository + function isInRepository(node) { + return node?.repository.nameWithOwner.toLowerCase() === `${owner}/${repo}`.toLowerCase() + } + // a missing list fails the run, an empty list would close the PRs of every approved contributor async function readApprovedContributors() { const { data: file } = await github.rest.repos.getContent({ @@ -62,7 +69,18 @@ function createRedirect({ github, context, core }) { closingIssuesReferences(first: 10) { nodes { number + state repository { nameWithOwner } + labels(first: 100) { + nodes { name } + } + closedByPullRequestsReferences(first: 100) { + nodes { + number + state + repository { nameWithOwner } + } + } } } } @@ -70,50 +88,94 @@ function createRedirect({ github, context, core }) { }`, { owner, repo, number: pr.number }, ) - return repository.pullRequest.closingIssuesReferences.nodes.filter( + return repository.pullRequest.closingIssuesReferences.nodes.filter(isInRepository) + } + + function createComment(marker, pr, lines) { + const body = [ + marker, + `Hello @${pr.user.login}. Thank you for taking the time to contribute!`, + '', + ...lines, + ].join('\n') + return { marker, body } + } + + async function checkLinkedIssues(pr, { reopened }) { + const issues = await findLinkedIssues(pr) + const approvedIssues = issues.filter( (issue) => - // closing keywords can point to any repository - issue?.repository.nameWithOwner.toLowerCase() === `${owner}/${repo}`.toLowerCase(), + issue.state === 'OPEN' && issue.labels.nodes.some((label) => label.name === APPROVED_LABEL), ) + for (const issue of approvedIssues) { + const others = issue.closedByPullRequestsReferences.nodes.filter( + (other) => + isInRepository(other) && + other.state === 'OPEN' && + other.number !== pr.number && + // a reopened PR must not replace the PR that was opened while it was closed + (reopened || other.number < pr.number), + ) + if (others.length) { + const list = others.map((other) => `#${other.number}`).join(', ') + return createComment(DUPLICATE_MARKER, pr, [ + `The team accepts one pull request per issue, and #${issue.number} already has an open pull request (${list}), so this pull request was closed automatically. If you think that your approach is better, please explain why in #${issue.number}.`, + '', + `Your changes are not lost: a maintainer can reopen this pull request if the team decides to go forward with it. See our [pull request policy](${policyUrl}) for more context.`, + ]) + } + } + if (approvedIssues.length) { + return { reason: `it fixes the approved issue #${approvedIssues[0].number}` } + } + const nextStep = issues.length + ? `Please keep the discussion in ${issues.map((issue) => `#${issue.number}`).join(', ')}.` + : `If there is no issue for this change yet, please [open one](https://github.com/${owner}/${repo}/issues/new/choose) to discuss it with the team first.` + return createComment(POLICY_MARKER, pr, [ + `Unfortunately, the team accepts pull requests only from maintainers and approved contributors, or for issues with the \`${APPROVED_LABEL}\` label, so this pull request was closed automatically. We are sorry about that, it is not a judgement of your work. The number of pull requests grew beyond what the team can review, and this policy gives maintainers the space to triage and prioritize issues at their own pace.`, + '', + `${nextStep} Your changes are not lost: a maintainer can reopen this pull request if the team decides to go forward with it. See our [pull request policy](${policyUrl}) for more context.`, + ]) } - async function wasRedirected(pr) { + async function findRedirectMarkers(pr) { const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100, }) - return comments.some( - (comment) => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(MARKER), + return new Set( + [POLICY_MARKER, DUPLICATE_MARKER].filter((marker) => + comments.some( + (comment) => + comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker), + ), + ), ) } - async function close(pr, { comment }) { + async function close(pr, comment) { if (comment) { - const issues = await findLinkedIssues(pr) - const nextStep = issues.length - ? `Please keep the discussion in ${issues.map((issue) => `#${issue.number}`).join(', ')}.` - : `If there is no issue for this change yet, please [open one](https://github.com/${owner}/${repo}/issues/new/choose) to discuss it with the team first.` await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, - body: [ - MARKER, - `Hello @${pr.user.login}. Thank you for taking the time to contribute!`, - '', - 'Unfortunately, the team accepts pull requests only from maintainers and approved contributors, so this pull request was closed automatically. We are sorry about that, it is not a judgement of your work. The number of pull requests grew beyond what the team can review, and this policy gives maintainers the space to triage and prioritize issues at their own pace.', - '', - `${nextStep} Your changes are not lost: a maintainer can reopen this pull request if the team decides to go forward with it. See our [pull request policy](${policyUrl}) for more context.`, - ].join('\n'), + body: comment.body, }) } await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' }) core.info(`#${pr.number} by @${pr.user.login} is closed`) } - return { readApprovedContributors, hasWriteAccess, findReasonToSkip, wasRedirected, close } + return { + readApprovedContributors, + hasWriteAccess, + findReasonToSkip, + checkLinkedIssues, + findRedirectMarkers, + close, + } } /** @@ -131,21 +193,20 @@ export default async function redirectPullRequest({ github, context, core }) { return } + const reopened = context.payload.action === 'reopened' const approved = await redirect.readApprovedContributors() let reason = await redirect.findReasonToSkip(pr, approved) - if ( - !reason && - context.payload.action === 'reopened' && - (await redirect.hasWriteAccess(context.payload.sender.login)) - ) { + if (!reason && reopened && (await redirect.hasWriteAccess(context.payload.sender.login))) { reason = 'a maintainer reopened it' } - if (reason) { - core.info(`#${pr.number} is skipped: ${reason}`) + const result = reason ? { reason } : await redirect.checkLinkedIssues(pr, { reopened }) + if (result.reason) { + core.info(`#${pr.number} is skipped: ${result.reason}`) return } - // a PR reopened by its author is closed again without a second comment - await redirect.close(pr, { comment: !(await redirect.wasRedirected(pr)) }) + // a PR reopened by its author gets the same comment only once + const markers = await redirect.findRedirectMarkers(pr) + await redirect.close(pr, markers.has(result.marker) ? null : result) } /** @@ -172,12 +233,13 @@ export async function redirectOpenPullRequests({ github, context, core }) { // a PR that cannot be processed stays open and must not block the other PRs try { let reason = await redirect.findReasonToSkip(pr, approved) - // the event workflow closes a PR reopened by its author, so only a maintainer could reopen it - if (!reason && (await redirect.wasRedirected(pr))) { - reason = 'a maintainer reopened it' + // the event workflow checks a PR again when someone reopens it + if (!reason && (await redirect.findRedirectMarkers(pr)).size) { + reason = 'it was reopened after a redirect' } - if (reason) { - core.info(`#${pr.number} is skipped: ${reason}`) + const result = reason ? { reason } : await redirect.checkLinkedIssues(pr, { reopened: false }) + if (result.reason) { + core.info(`#${pr.number} is skipped: ${result.reason}`) continue } closed++ @@ -185,7 +247,7 @@ export async function redirectOpenPullRequests({ github, context, core }) { core.info(`#${pr.number} by @${pr.user.login} would be closed`) continue } - await redirect.close(pr, { comment: true }) + await redirect.close(pr, result) // GitHub limits how fast a token can create content await new Promise((resolve) => setTimeout(resolve, 2000)) } catch (error) { diff --git a/.github/workflows/ai-policy.yml b/.github/workflows/ai-policy.yml index 3a030926a..ac0024b49 100644 --- a/.github/workflows/ai-policy.yml +++ b/.github/workflows/ai-policy.yml @@ -1,7 +1,8 @@ name: AI Policy # Closes pull requests from accounts flagged as bots, then closes pull requests from the community -# and asks to discuss the change in an issue, see "Pull Request Policy" in CONTRIBUTING.md. +# that do not fix an approved issue and asks to discuss the change in an issue, +# see "Pull Request Policy" in CONTRIBUTING.md. # # SECURITY: this workflow must never check out or run code from a pull request. # The jobs read the pull request through the API and use its content only as data. diff --git a/AGENTS.md b/AGENTS.md index c2460707e..5aff90368 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -262,4 +262,4 @@ PRs are squash-merged, so the PR title becomes the commit message. Nothing in CI This repository has a limit of 1 PR if you don't have write access. DO NOT try to bypass it by creating draft PRs. If you cannot create a pull request, let a human know that you will not breach this repository's policy because it will ban the PR author in Vitest organisation. -A PR from a user who is not an approved contributor is closed automatically as soon as it is opened (see "Pull Request Policy" in CONTRIBUTING.md). Tell your operator about this policy before you open a PR. DO NOT try to bypass it. +A PR from a user who is not an approved contributor is closed automatically as soon as it is opened, unless it fixes an open issue with the `approved` label that has no other open PR (see "Pull Request Policy" in CONTRIBUTING.md). Tell your operator about this policy before you open a PR. DO NOT try to bypass it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc9ffc8c3..77d70d246 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,9 @@ npm i https://pkg.pr.new/vitest@{commit} ## Pull Request Policy -The team accepts pull requests only from members of the Vitest team and [approved contributors](https://github.com/vitest-dev/vitest/blob/approved-contributors/APPROVED_CONTRIBUTORS). Any other pull request is closed automatically as soon as it is opened. +The team accepts pull requests from members of the Vitest team, from [approved contributors](https://github.com/vitest-dev/vitest/blob/approved-contributors/APPROVED_CONTRIBUTORS), and for open issues with the [`approved`](https://github.com/vitest-dev/vitest/issues?q=is%3Aissue%20state%3Aopen%20label%3Aapproved) label. Any other pull request is closed automatically as soon as it is opened. + +To work on an approved issue, open the pull request against `main` and link the issue in its description with a [closing keyword](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword), for example `Fixes #123`. The team accepts one pull request per issue: if another pull request for the same issue is already open, the new one is closed automatically. We know this is unfortunate, and it is not a judgement of your work. The number of pull requests grew beyond what the team can review, and this policy gives maintainers the space to triage and prioritize issues at their own pace. Please describe the bug or the feature in an [issue](https://github.com/vitest-dev/vitest/issues/new/choose) instead, so the team and the community can discuss it first. Your work is not lost: a maintainer can reopen a closed pull request if the team decides to go forward with the change. @@ -249,10 +251,11 @@ flowchart TD The [`AI Policy`](./.github/workflows/ai-policy.yml) workflow applies the [pull request policy](#pull-request-policy) when a pull request is opened or reopened. It comments on the pull request, pointing to the linked issues or asking to open a new one, and closes it. It never touches pull requests from members of the `vitest-dev` organization, from repository collaborators, or from apps that push branches to this repository (for example, Renovate). Users with write access to the repository control the rest: -- Reopen a pull request to keep it open. The workflow closes it again only if someone without write access reopens it. +- Add the `approved` label to an issue to accept a pull request for it from anyone. The workflow keeps such a pull request open if it links the issue with a closing keyword, the issue is open, and no other open pull request links the same issue. The label does not reopen pull requests that the workflow closed before. +- Reopen a pull request to keep it open. If someone without write access reopens it, the workflow applies the policy again. - Comment `/approve-user` on an issue or a pull request to add its author to the approved contributors. Comment `/approve-user username` to add a specific user. It must be a regular comment, not a review, and it must contain nothing but the command. The [`Approve Contributor`](./.github/workflows/approve-contributor.yml) workflow reacts to it with 🚀 when the user is on the list. - The list is the `APPROVED_CONTRIBUTORS` file on the [`approved-contributors`](https://github.com/vitest-dev/vitest/blob/approved-contributors/APPROVED_CONTRIBUTORS) branch. Each line is an account id followed by the login as a comment, for example `12345 # username`. Only the id counts, so a renamed account stays approved and nobody else can take its place; the login is there for people to read. Delete a line to remove a contributor. Both workflows fail, and no pull request is closed, if the branch or the file is missing. -- Run the [`PR Redirect All`](./.github/workflows/pr-redirect-all.yml) workflow manually to close every open pull request that the policy would close. It skips pull requests that a maintainer reopened after they were closed. Keep the `dry-run` option on first to see which pull requests it would close. +- Run the [`PR Redirect All`](./.github/workflows/pr-redirect-all.yml) workflow manually to close every open pull request that the policy would close. It skips pull requests that were reopened after the workflow closed them. If more than one pull request links the same approved issue, it keeps the oldest one. Keep the `dry-run` option on first to see which pull requests it would close. ## Notes on Dependencies -- 2.51.2