Random Issue Assignee and PR Reviewer #770
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Random Issue Assignee and PR Reviewer | |
| on: | |
| issues: | |
| types: [opened] | |
| # PRs from forks need a write-capable token for reviewer requests, but `pull_request` | |
| # workflows receive a read-only token for forked PRs. `pull_request_target` | |
| # would request reviewers immediately, but it runs in the base repository security context | |
| # for untrusted PR events and has been a common source of Actions incidents. | |
| # | |
| # A two-stage `workflow_run` flow can also request reviewers on fork PRs, but it still creates | |
| # a privileged workflow triggered by PR activity and is flagged by zizmor. | |
| # | |
| # Scheduled polling is less immediate, but it runs only from the default branch, | |
| # does not execute or consume PR-controlled code, and still requests reviewers | |
| # on recent fork PRs within a few minutes. | |
| schedule: | |
| - cron: "*/5 * * * *" | |
| workflow_dispatch: | |
| permissions: {} | |
| env: | |
| ASSIGNEE_LOGINS: '["joeyorlando","iskhakov","Konstantinov-Innokentii"]' | |
| CORE_TEAM_LOGINS: '["joeyorlando","iskhakov","Konstantinov-Innokentii","Matvey-Kuk"]' | |
| concurrency: | |
| group: random-issue-pr-assignee-${{ github.event.issue.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| assign-issue: | |
| name: Assign Issues | |
| if: github.event_name == 'issues' || github.event_name == 'workflow_dispatch' | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| permissions: | |
| issues: write # Required to assign issues. | |
| steps: | |
| - name: Randomly assign issue owner | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| github-token: ${{ github.token }} | |
| script: | | |
| const owners = JSON.parse(process.env.ASSIGNEE_LOGINS); | |
| const coreTeam = JSON.parse(process.env.CORE_TEAM_LOGINS); | |
| if (context.eventName === 'issues') { | |
| await assignIssue(context.payload.issue); | |
| return; | |
| } | |
| let processedIssues = 0; | |
| for await (const response of github.paginate.iterator(github.rest.issues.listForRepo, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| sort: 'updated', | |
| direction: 'desc', | |
| per_page: 100, | |
| })) { | |
| for (const issue of response.data) { | |
| if (issue.pull_request) { | |
| continue; | |
| } | |
| await assignIssue(issue); | |
| processedIssues += 1; | |
| if (processedIssues >= 100) { | |
| console.log('Processed 100 open issues during manual backfill'); | |
| return; | |
| } | |
| } | |
| } | |
| console.log(`Processed ${processedIssues} open issues during manual backfill`); | |
| async function assignIssue(issue) { | |
| const actor = issue.user.login; | |
| const existingOwner = issue.assignees.find((assignee) => | |
| owners.includes(assignee.login) | |
| ); | |
| if (coreTeam.includes(actor)) { | |
| console.log(`Skipping assignment because ${actor} is on the core team`); | |
| return; | |
| } | |
| if (existingOwner) { | |
| console.log(`Skipping assignment because #${issue.number} is already assigned to ${existingOwner.login}`); | |
| return; | |
| } | |
| const selectedOwner = owners[Math.floor(Math.random() * owners.length)]; | |
| await github.rest.issues.addAssignees({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| assignees: [selectedOwner], | |
| }); | |
| console.log(`Assigned #${issue.number} to ${selectedOwner}`); | |
| } | |
| request-recent-pr-reviewers: | |
| name: Request Recent PR Reviewers | |
| if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| permissions: | |
| pull-requests: write # Required to read PR metadata and request reviewers. | |
| steps: | |
| - name: Randomly request recent PR reviewers | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| github-token: ${{ github.token }} | |
| script: | | |
| const owners = JSON.parse(process.env.ASSIGNEE_LOGINS); | |
| const coreTeam = JSON.parse(process.env.CORE_TEAM_LOGINS); | |
| const isManualBackfill = context.eventName === 'workflow_dispatch'; | |
| const updatedSince = isManualBackfill | |
| ? null | |
| : new Date(Date.now() - 30 * 60 * 1000); | |
| let processedPrs = 0; | |
| for await (const response of github.paginate.iterator(github.rest.pulls.list, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| sort: 'updated', | |
| direction: 'desc', | |
| per_page: 100, | |
| })) { | |
| let reachedOlderPrs = false; | |
| for (const pr of response.data) { | |
| if (updatedSince && new Date(pr.updated_at) < updatedSince) { | |
| reachedOlderPrs = true; | |
| break; | |
| } | |
| processedPrs += 1; | |
| try { | |
| if (pr.draft) { | |
| console.log(`Skipping reviewer request because PR #${pr.number} is still a draft`); | |
| continue; | |
| } | |
| const reviewerLogins = new Set( | |
| pr.requested_reviewers.map((reviewer) => reviewer.login) | |
| ); | |
| for await (const reviewsResponse of github.paginate.iterator(github.rest.pulls.listReviews, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| per_page: 100, | |
| })) { | |
| for (const review of reviewsResponse.data) { | |
| if (review.user?.login) { | |
| reviewerLogins.add(review.user.login); | |
| } | |
| } | |
| } | |
| const existingReviewer = owners.find((owner) => reviewerLogins.has(owner)); | |
| if (existingReviewer) { | |
| console.log(`Skipping reviewer request because PR #${pr.number} already has ${existingReviewer} as a reviewer`); | |
| continue; | |
| } | |
| if (coreTeam.includes(pr.user.login)) { | |
| console.log(`Skipping reviewer request because ${pr.user.login} is on the core team`); | |
| continue; | |
| } | |
| const selectedOwner = owners[Math.floor(Math.random() * owners.length)]; | |
| await github.rest.pulls.requestReviewers({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| reviewers: [selectedOwner], | |
| }); | |
| console.log(`Requested ${selectedOwner} to review PR #${pr.number}`); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| console.error(`Failed to request a reviewer for PR #${pr.number}: ${message}`); | |
| } | |
| if (isManualBackfill && processedPrs >= 100) { | |
| console.log('Processed 100 open PRs during manual backfill'); | |
| return; | |
| } | |
| } | |
| if (reachedOlderPrs) { | |
| break; | |
| } | |
| } | |
| if (isManualBackfill) { | |
| console.log(`Processed ${processedPrs} open PRs during manual backfill`); | |
| } |