Skip to content

Commit cc6c638

Browse files
PR review bot (#3326)
## Why make this change? Automate PR reviewer assignment to distribute review workload evenly across the team instead of relying on manual assignment or CODEOWNERS (which adds all owners). ## What is this change? Adds auto-assign-reviewers.yml, a label-driven GitHub Actions workflow that assigns exactly 2 reviewers per PR using load-balanced selection. High-level workflow steps- 1. Fetches all open PRs → filters to eligible ones (label + non-draft + fresh) 2. Reads pr.assignees for each PR → determines who's already assigned 3. Calls listReviews for PRs with assignees → determines who's actively reviewing vs freed 4. Builds load map from scratch → sums active review weights per reviewer 5. Assigns based on current state ## How is this tested? - Tested with DRY_RUN and non-DRY_RUN mode and validated the assigned reviewers. --------- Co-authored-by: Aniruddh Munde <anmunde@microsoft.com>
1 parent f2b4cbd commit cc6c638

1 file changed

Lines changed: 212 additions & 0 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
name: PR Review Assignment Bot
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, reopened, labeled]
6+
7+
workflow_dispatch:
8+
inputs:
9+
dry_run:
10+
description: 'Run in dry-run mode (no actual assignments)'
11+
required: false
12+
default: 'true'
13+
type: choice
14+
options:
15+
- 'true'
16+
- 'false'
17+
18+
# schedule:
19+
# - cron: "*/10 * * * *"
20+
21+
permissions:
22+
pull-requests: write
23+
issues: write
24+
contents: read
25+
26+
concurrency:
27+
group: pr-review-assignment
28+
cancel-in-progress: false
29+
30+
jobs:
31+
assign-reviewers:
32+
runs-on: ubuntu-latest
33+
steps:
34+
- name: Assign reviewers to eligible PRs
35+
uses: actions/github-script@v7
36+
with:
37+
script: |
38+
// ── Configuration ──
39+
const REVIEWERS = ["souvikghosh04", "Aniruddh25", "aaronburtle", "anushakolan", "RubenCerna2079"];
40+
const REQUIRED_REVIEWERS = 2;
41+
const STALE_DAYS = 90;
42+
const DRY_RUN = context.eventName === "workflow_dispatch"
43+
? '${{ github.event.inputs.dry_run }}' === 'true'
44+
: false;
45+
46+
const { owner, repo } = context.repo;
47+
const staleCutoff = new Date();
48+
staleCutoff.setDate(staleCutoff.getDate() - STALE_DAYS);
49+
50+
// ── Helper functions ──
51+
52+
function getPoolAssignees(pr) {
53+
return (pr.assignees || [])
54+
.map((a) => a.login)
55+
.filter((a) => REVIEWERS.includes(a));
56+
}
57+
58+
function isEligible(pr) {
59+
if (pr.draft) return false;
60+
if (!pr.labels.some((l) => l.name === "assign-for-review")) return false;
61+
if (new Date(pr.updated_at) < staleCutoff) return false;
62+
return true;
63+
}
64+
65+
function getWeight(pr) {
66+
const labels = pr.labels.map((l) => l.name);
67+
let w = 1;
68+
if (labels.includes("size-medium")) w = 2;
69+
else if (labels.includes("size-large")) w = 3;
70+
if (labels.includes("priority-high")) w += 1;
71+
return w;
72+
}
73+
74+
async function getActiveReviewers(pr) {
75+
const assigned = getPoolAssignees(pr);
76+
if (assigned.length === 0) return [];
77+
const reviews = await github.paginate(github.rest.pulls.listReviews, {
78+
owner, repo, pull_number: pr.number, per_page: 100,
79+
});
80+
const submitted = new Set(
81+
reviews.map((r) => r.user.login).filter((r) => REVIEWERS.includes(r))
82+
);
83+
return assigned.filter((r) => !submitted.has(r));
84+
}
85+
86+
// Assigns reviewers to a single PR, mutating load/activeCount in place.
87+
async function assignReviewers(pr, load, activeCount) {
88+
const author = pr.user.login;
89+
// Exclude the author from assigned count — they can't review their own PR.
90+
const assigned = getPoolAssignees(pr).filter((a) => a !== author);
91+
const needed = REQUIRED_REVIEWERS - assigned.length;
92+
const weight = getWeight(pr);
93+
94+
if (needed <= 0) {
95+
core.info(`PR #${pr.number} — already has ${assigned.length} pool assignees, skipping.`);
96+
return;
97+
}
98+
99+
const candidates = REVIEWERS.filter((r) => r !== author && !assigned.includes(r));
100+
if (candidates.length === 0) {
101+
core.info(`PR #${pr.number} — no candidates after filtering.`);
102+
return;
103+
}
104+
105+
// Sort: prefer unblocked → then lowest load → then random tiebreak.
106+
candidates.sort((a, b) => {
107+
const aBlocked = activeCount[a] > 0 ? 1 : 0;
108+
const bBlocked = activeCount[b] > 0 ? 1 : 0;
109+
if (aBlocked !== bBlocked) return aBlocked - bBlocked;
110+
if (load[a] !== load[b]) return load[a] - load[b];
111+
return Math.random() - 0.5;
112+
});
113+
114+
const selected = candidates.slice(0, needed);
115+
core.info(`PR #${pr.number} — weight: ${weight}, assigned: [${assigned}], candidates: ${JSON.stringify(candidates.map((c) => `${c}(active:${activeCount[c]},load:${load[c]})`))}, selected: [${selected}]`);
116+
117+
if (DRY_RUN) {
118+
core.info(`[DRY RUN] Would assign [${selected}] to PR #${pr.number}`);
119+
} else {
120+
await github.rest.issues.addAssignees({
121+
owner, repo, issue_number: pr.number, assignees: selected,
122+
});
123+
core.info(`Assigned [${selected}] to PR #${pr.number}`);
124+
125+
// Remove the label once reviewers are fully assigned so the bot
126+
// doesn't re-process this PR on subsequent runs.
127+
const newAssignedCount = assigned.length + selected.length;
128+
if (newAssignedCount >= REQUIRED_REVIEWERS) {
129+
await github.rest.issues.removeLabel({
130+
owner, repo, issue_number: pr.number, name: "assign-for-review",
131+
}).catch((e) => core.warning(`Could not remove label from PR #${pr.number}: ${e.message}`));
132+
core.info(`Removed 'assign-for-review' label from PR #${pr.number}`);
133+
}
134+
}
135+
136+
for (const r of selected) {
137+
load[r] += weight;
138+
activeCount[r] += 1;
139+
}
140+
}
141+
142+
// ── Main logic ──
143+
144+
// For pull_request_target events, early-exit if the triggering PR isn't eligible.
145+
if (context.eventName === "pull_request_target") {
146+
const triggerPR = context.payload.pull_request;
147+
if (!isEligible(triggerPR)) {
148+
core.info(`Triggering PR #${triggerPR.number} is not eligible (draft, missing label, or stale). Exiting.`);
149+
return;
150+
}
151+
const assigned = getPoolAssignees(triggerPR);
152+
const author = triggerPR.user.login;
153+
if (assigned.filter((a) => a !== author).length >= REQUIRED_REVIEWERS) {
154+
core.info(`Triggering PR #${triggerPR.number} already has ${assigned.length} pool assignees (excl. author). Exiting.`);
155+
return;
156+
}
157+
core.info(`Triggering PR #${triggerPR.number} needs reviewers. Proceeding with load calculation.`);
158+
}
159+
160+
// Fetch all open PRs (sorted by most recently updated, skip stale).
161+
const allPRs = await github.paginate(github.rest.pulls.list, {
162+
owner, repo, state: "open", sort: "updated", direction: "desc", per_page: 100,
163+
});
164+
const freshPRs = allPRs.filter((pr) => new Date(pr.updated_at) >= staleCutoff);
165+
const eligiblePRs = freshPRs.filter(isEligible);
166+
167+
core.info(`Open PRs: ${allPRs.length}, fresh (≤${STALE_DAYS}d): ${freshPRs.length}, eligible: ${eligiblePRs.length}`);
168+
169+
// Build load map from active reviews across all eligible PRs.
170+
const load = {};
171+
const activeCount = {};
172+
for (const r of REVIEWERS) { load[r] = 0; activeCount[r] = 0; }
173+
174+
for (const pr of eligiblePRs) {
175+
const assigned = getPoolAssignees(pr);
176+
// Only call listReviews if this PR has pool assignees (saves API calls).
177+
if (assigned.length === 0) continue;
178+
const active = await getActiveReviewers(pr);
179+
const weight = getWeight(pr);
180+
for (const r of active) {
181+
load[r] += weight;
182+
activeCount[r] += 1;
183+
}
184+
}
185+
core.info(`Active load: ${JSON.stringify(load)}`);
186+
core.info(`Active counts: ${JSON.stringify(activeCount)}`);
187+
188+
// Determine which PRs to process.
189+
if (context.eventName === "pull_request_target") {
190+
// Single-PR mode: only assign the triggering PR.
191+
const triggerPR = context.payload.pull_request;
192+
// Re-fetch full PR object to get latest assignees.
193+
const { data: freshPR } = await github.rest.pulls.get({
194+
owner, repo, pull_number: triggerPR.number,
195+
});
196+
await assignReviewers(freshPR, load, activeCount);
197+
} else {
198+
// Full-scan mode (workflow_dispatch / schedule): process all eligible PRs that need reviewers.
199+
const needsReviewers = eligiblePRs
200+
.filter((pr) => {
201+
const assigned = getPoolAssignees(pr).filter((a) => a !== pr.user.login);
202+
return assigned.length < REQUIRED_REVIEWERS;
203+
})
204+
.sort((a, b) => getWeight(b) - getWeight(a));
205+
206+
core.info(`PRs needing reviewers: ${needsReviewers.length}`);
207+
for (const pr of needsReviewers) {
208+
await assignReviewers(pr, load, activeCount);
209+
}
210+
}
211+
212+
core.info("Review assignment complete.");

0 commit comments

Comments
 (0)