Skip to content

Random Issue Assignee and PR Reviewer #770

Random Issue Assignee and PR Reviewer

Random Issue Assignee and PR Reviewer #770

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`);
}