Skip to content

Commit 99bfe96

Browse files
add reviewers as assignees on PRs
Signed-off-by: Siddhartha Ganguly <gangulysiddhartha22@gmail.com>
1 parent ce4713d commit 99bfe96

4 files changed

Lines changed: 561 additions & 0 deletions
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// =============================================================================
2+
// Add Reviewers as Assignees Bot
3+
// =============================================================================
4+
//
5+
// This script automatically adds requested reviewers (individuals + expanded
6+
// team members) as assignees on a PR. Makes it easier for reviewers to find
7+
// their PRs via the "Assigned to me" filter.
8+
//
9+
// Triggered by:
10+
// - pull_request review_requested event
11+
// - workflow_dispatch (for manual testing)
12+
//
13+
// Key behavior:
14+
// - Only adds users who aren't already assignees
15+
// - Expands teams with graceful fallback on permission/visibility issues
16+
// - Supports dry-run mode
17+
// - Batches assignee updates (GitHub limit = 10 per call)
18+
// - Comprehensive logging for debugging
19+
//
20+
// Called by: .github/workflows/bot-pr-add-reviewers-as-assignees.yml
21+
22+
/**
23+
* Checks if the script is running in dry-run mode.
24+
*
25+
* Returns true if DRY_RUN env var is set to "true" (case-insensitive).
26+
*/
27+
function isDryRun() {
28+
return /^true$/i.test(process.env.DRY_RUN || '');
29+
}
30+
31+
/**
32+
* Main handler that adds requested reviewers (individuals + expanded team members)
33+
* as assignees on a Pull Request.
34+
*
35+
* @param {Object} params
36+
* @param {Object} params.github - GitHub Octokit client from actions/github-script
37+
* @param {Object} params.context - GitHub Actions context
38+
* @returns {Promise<void>}
39+
*/
40+
module.exports = async ({ github, context }) => {
41+
const dryRun = isDryRun();
42+
if (dryRun) {
43+
console.log('[reviewers-assignee] Running in DRY-RUN mode - no changes will be made');
44+
}
45+
46+
try {
47+
let prNumber = context.payload.pull_request?.number;
48+
let owner = context.repo.owner;
49+
let repo = context.repo.repo;
50+
51+
// Support workflow_dispatch with manual PR number
52+
if (context.eventName === 'workflow_dispatch' && context.payload.inputs?.pr_number !== undefined) {
53+
const manualPrNumber = Number(context.payload.inputs.pr_number);
54+
if (!Number.isInteger(manualPrNumber) || manualPrNumber <= 0) {
55+
console.log('[reviewers-assignee] Invalid PR number supplied. Skipping.');
56+
return;
57+
}
58+
prNumber = manualPrNumber;
59+
}
60+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
61+
console.log('[reviewers-assignee] No PR number found. Skipping.');
62+
return;
63+
}
64+
65+
console.log(`[reviewers-assignee] Processing PR #${prNumber} in ${owner}/${repo}`);
66+
67+
// Fetch fresh PR data
68+
const { data: pr } = await github.rest.pulls.get({
69+
owner,
70+
repo,
71+
pull_number: prNumber
72+
});
73+
74+
const requestedReviewers = pr.requested_reviewers || [];
75+
const requestedTeams = pr.requested_teams || [];
76+
const currentAssignees = new Set(pr.assignees?.map(a => a.login) || []);
77+
78+
console.log(`[reviewers-assignee] Requested reviewers: ${requestedReviewers.length}, teams: ${requestedTeams.length}`);
79+
80+
const usersToAssign = new Set();
81+
82+
// Add individual reviewers
83+
for (const reviewer of requestedReviewers) {
84+
if (reviewer?.login && !currentAssignees.has(reviewer.login)) {
85+
usersToAssign.add(reviewer.login);
86+
}
87+
}
88+
89+
// Expand teams with pagination
90+
for (const team of requestedTeams) {
91+
if (!team?.slug) continue;
92+
try {
93+
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
94+
org: owner,
95+
team_slug: team.slug,
96+
per_page: 100
97+
});
98+
99+
for (const member of members) {
100+
if (member?.login && !currentAssignees.has(member.login)) {
101+
usersToAssign.add(member.login);
102+
}
103+
}
104+
console.log(`[reviewers-assignee] Expanded team ${team.slug}${members.length} members`);
105+
} catch (error) {
106+
if (error?.status === 403 || error?.status === 404) {
107+
console.log(`[reviewers-assignee] Could not expand team ${team.slug} due to permissions/visibility: ${error.message}`);
108+
continue;
109+
}
110+
console.error(`[reviewers-assignee] Failed to expand team ${team.slug}: ${error.message}`);
111+
throw error;
112+
}
113+
}
114+
115+
if (usersToAssign.size === 0) {
116+
console.log('[reviewers-assignee] No new users to assign. Done.');
117+
return;
118+
}
119+
120+
const assigneesList = Array.from(usersToAssign);
121+
console.log(`[reviewers-assignee] Will assign: ${assigneesList.join(', ')}`);
122+
123+
if (dryRun) {
124+
console.log('[reviewers-assignee] DRY-RUN: Would have added the above users as assignees');
125+
return;
126+
}
127+
128+
// Add up to 10 assignees at a time
129+
const ASSIGNEE_BATCH_SIZE = 10;
130+
let addedCount = 0;
131+
let permissionDenied = false;
132+
for (let i = 0; i < assigneesList.length; i += ASSIGNEE_BATCH_SIZE) {
133+
const batch = assigneesList.slice(i, i + ASSIGNEE_BATCH_SIZE);
134+
try {
135+
await github.rest.issues.addAssignees({
136+
owner,
137+
repo,
138+
issue_number: prNumber,
139+
assignees: batch
140+
});
141+
addedCount += batch.length;
142+
console.log(`[reviewers-assignee] Added batch of ${batch.length} assignees`);
143+
} catch (error) {
144+
if (error?.status === 403) {
145+
permissionDenied = true;
146+
console.log('[reviewers-assignee] Skipping remaining assignment due to insufficient permissions');
147+
break;
148+
}
149+
console.error(`[reviewers-assignee] Failed to add assignees batch: ${error.message}`);
150+
throw error; // Re-throw other errors
151+
}
152+
}
153+
154+
console.log(
155+
permissionDenied
156+
? `[reviewers-assignee] Added ${addedCount} reviewer(s) before permissions blocked further assignment on PR #${prNumber}`
157+
: `[reviewers-assignee] Successfully added ${addedCount} reviewer(s) as assignee(s) on PR #${prNumber}`
158+
);
159+
160+
} catch (error) {
161+
console.error('[reviewers-assignee] Error:', error.message);
162+
if (error.status) console.error('Status:', error.status);
163+
throw error;
164+
}
165+
};

0 commit comments

Comments
 (0)