diff --git a/.github/workflows/label-pr-review-state.yml b/.github/workflows/label-pr-review-state.yml new file mode 100644 index 0000000000..2c83ecc6a9 --- /dev/null +++ b/.github/workflows/label-pr-review-state.yml @@ -0,0 +1,81 @@ +name: Label PR review state + +on: + schedule: + - cron: '0 * * * *' # hourly + workflow_dispatch: + +permissions: + pull-requests: read + issues: write + +jobs: + reconcile: + runs-on: ubuntu-latest + steps: + - name: Reconcile PR review state labels + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const stateLabels = ['awaiting-author', 'awaiting-review']; + + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', per_page: 100, + }); + + for (const pr of prs) { + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + + // Reviews are returned chronologically, so later entries replace + // each reviewer's earlier decision. + const latest = new Map(); + for (const r of reviews) { + if (r.state !== 'COMMENTED') { + latest.set(r.user.login, r); + } + } + + const changeRequestReviewers = [...latest.entries()] + .filter(([, review]) => review.state === 'CHANGES_REQUESTED') + .map(([login]) => login); + const requestedReviewers = new Set( + pr.requested_reviewers.map(reviewer => reviewer.login), + ); + + let desiredLabel = null; + if (changeRequestReviewers.length > 0) { + desiredLabel = changeRequestReviewers.every( + reviewer => requestedReviewers.has(reviewer), + ) + ? 'awaiting-review' + : 'awaiting-author'; + } + + const currentLabels = new Set(pr.labels.map(label => label.name)); + for (const label of stateLabels) { + if (label !== desiredLabel && currentLabels.has(label)) { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pr.number, name: label, + }); + } + } + + if (desiredLabel && !currentLabels.has(desiredLabel)) { + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr.number, labels: [desiredLabel], + }); + } + + if ( + desiredLabel !== 'awaiting-author' && + currentLabels.has('stale-awaiting-author') + ) { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pr.number, + name: 'stale-awaiting-author', + }); + } + } diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000..3e51b44850 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,60 @@ +name: Close stale PRs + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + pull-requests: write + issues: write + +jobs: + stale-inactivity: + name: 60-day inactivity + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + days-before-issue-stale: -1 + days-before-issue-close: -1 + days-before-pr-stale: 60 + days-before-pr-close: 7 + + stale-pr-label: stale-inactive + stale-pr-message: > + This PR has had no activity for 60 days and will be automatically + closed in 7 days unless there is new activity. If you'd like to + continue, please rebase and leave a comment. + close-pr-message: > + Closing due to 60+ days of inactivity. Feel free to reopen if + you'd like to resume this work. + + exempt-pr-labels: 'do-not-close,pinned,work-in-progress' + + stale-review-inactivity: + name: 14-day author inactivity after requested changes + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + only-labels: awaiting-author + days-before-issue-stale: -1 + days-before-issue-close: -1 + days-before-pr-stale: 14 + days-before-pr-close: 7 + + stale-pr-label: stale-awaiting-author + stale-pr-message: > + This PR has been awaiting author changes for 14 days and will be + automatically closed in 7 days. Please address the review comments + or leave a comment if you need more time. + close-pr-message: > + Closing due to author inactivity after requested changes. Feel free + to reopen once the requested changes have been addressed. + + exempt-pr-labels: 'do-not-close,pinned,work-in-progress' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d65a7a3535..2ce81bd722 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,6 +150,13 @@ Pull requests should be reviewable, tested, and maintainable. Before opening a P Maintainers may close PRs that are incomplete, too broad, inactive, not aligned with the project direction, or that create disproportionate review or maintenance burden. Closing a PR is not a judgment on the contributor; it is a maintainer decision that the change cannot be accepted in its present form. +PRs are also closed automatically by bot: + +- **60-day inactivity:** A PR with no activity for 60 days is marked stale and closed after a further 7 days if there is still no activity. Any new comment, commit, or review resets the timer. +- **14-day author inactivity:** After a reviewer requests changes, the PR is labelled `awaiting-author`. Author activity resets the inactivity timer. Once the changes are ready, re-request review from the reviewer; the PR will move to `awaiting-review` and is no longer eligible for automatic closure under this policy. + +To opt a PR out of automatic closure, apply the `do-not-close`, `pinned`, or `work-in-progress` label. + ### AI-Assisted Contributions Use of AI tools is allowed, but contributors remain fully responsible for their submissions.