Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions .github/workflows/auto-assign-reviewers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
name: PR Review Assignment Bot

on:
pull_request_target:
types: [opened, reopened, labeled]

Comment on lines +3 to +6
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workflow triggers on every pull_request_target opened event, but then always fetches all open PRs to find ones with assign-for-review. For repos with frequent PR activity this can cause unnecessary API usage and increase the chance of hitting rate limits. Consider narrowing triggers (e.g., only labeled for assign-for-review) or adding a job-level if: to quickly exit when the event isn't workflow_dispatch and isn't the assign-for-review label being applied.

Copilot uses AI. Check for mistakes.
workflow_dispatch:
inputs:
dry_run:
description: 'Run in dry-run mode (no actual assignments)'
required: false
default: 'true'
type: choice
options:
- 'true'
- 'false'

# schedule:
# - cron: "*/10 * * * *"

permissions:
pull-requests: write
contents: read

concurrency:
group: pr-review-assignment
cancel-in-progress: false

jobs:
assign-reviewers:
runs-on: ubuntu-latest
steps:
- name: Assign reviewers to eligible PRs
uses: actions/github-script@v7
with:
script: |
const reviewers = ["souvikghosh04", "Aniruddh25", "aaronburtle", "anushakolan", "RubenCerna2079", "JerryNixon"];
const REQUIRED_REVIEWERS = 2;
const DRY_RUN = '${{ github.event.inputs.dry_run || 'true' }}' === 'true';
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DRY_RUN will always evaluate to true for pull_request_target runs because github.event.inputs.dry_run is only populated for workflow_dispatch. That means reviewer requests will never actually be made on PR open/label events, which prevents the workflow from achieving automated assignment as described. Consider sourcing DRY_RUN from an env/repo variable for PR-triggered runs (or defaulting to false on PR events and only honoring the input on workflow_dispatch).

Suggested change
const DRY_RUN = '${{ github.event.inputs.dry_run || 'true' }}' === 'true';
const DRY_RUN = context.eventName === "workflow_dispatch"
? '${{ github.event.inputs.dry_run || 'true' }}' === 'true'
: false;

Copilot uses AI. Check for mistakes.

const { owner, repo } = context.repo;

// Fetch all open PRs (paginated)
const allPRs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100,
});

// Fetch the set of configured reviewers who are requested OR have already submitted a review.
// GitHub removes reviewers from requested_reviewers once they submit, so we must check both.
async function getConfiguredReviewerSet(pr) {
const requested = (pr.requested_reviewers || [])
.map((r) => r.login)
.filter((r) => reviewers.includes(r));

Comment on lines +51 to +57
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow still ignores requested_teams when computing existing reviewers (configuredSet) and determining needed. If a PR already has team reviewers requested, this can result in extra individual assignments and incorrect load accounting. If teams should count toward the 2-reviewer threshold, include pr.requested_teams in the reviewer-count/eligibility logic (or explicitly encode that teams are intentionally excluded).

Copilot uses AI. Check for mistakes.
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: pr.number,
});
const submitted = reviews
.map((r) => r.user.login)
.filter((r) => reviewers.includes(r));

Comment on lines +58 to +66
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pulls.listReviews is not paginated here and doesn't set per_page, so only the first page of reviews is considered (default 30). On PRs with many reviews this can miss configured reviewers who already submitted, leading to incorrect configuredSet/load and potentially re-requesting reviewers. Use github.paginate (or paginate with per_page: 100) for listReviews.

Copilot uses AI. Check for mistakes.
return [...new Set([...requested, ...submitted])];
}

// Determine if a PR is eligible by labels and draft status.
// Reviewer count is checked later after fetching review data.
function isEligibleByLabels(pr) {
const labels = pr.labels.map((l) => l.name);
if (!labels.includes("assign-for-review")) return false;
if (pr.draft) return false;
return true;
}

// Calculate PR weight from labels
function getWeight(pr) {
const labels = pr.labels.map((l) => l.name);
let weight = 1;
if (labels.includes("size-medium")) weight = 2;
else if (labels.includes("size-large")) weight = 3;
if (labels.includes("priority-high")) weight += 1;
return weight;
}

// Build load map from all load-relevant PRs (assigned + unassigned)
const load = {};
for (const r of reviewers) {
load[r] = 0;
}

core.info(`Total open PRs fetched: ${allPRs.length}`);

const labelEligiblePRs = allPRs.filter(isEligibleByLabels);
core.info(`Label-eligible PRs (non-draft, has label): ${labelEligiblePRs.length}`);

// Fetch configured reviewer sets for all label-eligible PRs (used for load + eligibility)
const prReviewerMap = new Map();
for (const pr of labelEligiblePRs) {
const configuredSet = await getConfiguredReviewerSet(pr);
prReviewerMap.set(pr.number, configuredSet);
}

// Build load from all label-eligible PRs (includes fully-assigned ones)
for (const pr of labelEligiblePRs) {
const weight = getWeight(pr);
const configuredSet = prReviewerMap.get(pr.number);
for (const reviewer of configuredSet) {
if (reviewer in load) {
load[reviewer] += weight;
}
}
}

core.info(`Current load: ${JSON.stringify(load)}`);

// Filter to PRs that still need reviewers
const eligiblePRs = labelEligiblePRs.filter((pr) => {
return prReviewerMap.get(pr.number).length < REQUIRED_REVIEWERS;
});
core.info(`Total eligible PRs (need reviewers): ${eligiblePRs.length}`);

// Sort eligible PRs by weight descending (prioritize large PRs)
eligiblePRs.sort((a, b) => getWeight(b) - getWeight(a));

// Assign reviewers to each eligible PR
for (const pr of eligiblePRs) {
const weight = getWeight(pr);
const configuredSet = prReviewerMap.get(pr.number);
const needed = REQUIRED_REVIEWERS - configuredSet.length;

core.info(`PR #${pr.number} — weight: ${weight}, configured reviewers (requested + submitted): [${configuredSet.join(", ")}]`);

if (needed <= 0) {
core.info(`PR #${pr.number} already has ${REQUIRED_REVIEWERS} configured reviewers, skipping.`);
continue;
}

const author = pr.user.login;

// Build candidates: exclude author and already-assigned/reviewed reviewers
const candidates = reviewers
.filter((r) => r !== author && !configuredSet.includes(r));

if (candidates.length === 0) {
core.info(`PR #${pr.number} — no candidates after filtering.`);
continue;
}

candidates.sort((a, b) => {
if (load[a] !== load[b]) return load[a] - load[b];
return a.localeCompare(b);
});

core.info(`PR #${pr.number} candidates: ${JSON.stringify(candidates)}`);

const selected = candidates.slice(0, needed);

if (DRY_RUN) {
core.info(`[DRY RUN] Would assign [${selected.join(", ")}] to PR #${pr.number}`);
} else {
await github.rest.pulls.requestReviewers({
owner,
repo,
pull_number: pr.number,
reviewers: selected,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this action add the selected reviewer to the list of assignees of the PR or in the requested review column?

});
core.info(`Assigned [${selected.join(", ")}] to PR #${pr.number}`);
}

// Update load in-memory
for (const reviewer of selected) {
load[reviewer] += weight;
}

core.info(`Updated load: ${JSON.stringify(load)}`);
}

core.info("Review assignment complete.");