Skip to content

Commit 0bc02d0

Browse files
add reviewers as assignees on PRs
Signed-off-by: Siddhartha Ganguly <gangulysiddhartha22@gmail.com>
1 parent 7d3d212 commit 0bc02d0

7 files changed

Lines changed: 679 additions & 0 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
/**
4+
* @fileoverview
5+
* Automatically adds requested individual reviewers as assignees on Pull Requests.
6+
*
7+
* This is part of the generic "on-review" infrastructure.
8+
* Team reviewers are intentionally ignored (only individual users are assigned).
9+
* Caps the number of assignees at MAX_ASSIGNEES (default: 2).
10+
*/
11+
12+
const { createLogger, MAX_ASSIGNEES, BOT_NAME_ASSIGNEES } = require('./helpers/index.js');
13+
14+
const logger = createLogger(BOT_NAME_ASSIGNEES);
15+
16+
/**
17+
* Resolves the PR number from context (pull_request_target or workflow_dispatch).
18+
*
19+
* @param {Object} context - GitHub Actions context object
20+
* @returns {number|null} Valid PR number or null if invalid/missing
21+
*/
22+
function resolvePrNumber(context) {
23+
if (context.eventName === 'workflow_dispatch') {
24+
const manual = Number(context.payload.inputs?.pr_number);
25+
if (!Number.isInteger(manual) || manual <= 0) {
26+
logger.warn('Invalid PR number supplied. Skipping.');
27+
return null;
28+
}
29+
return manual;
30+
}
31+
32+
const prNumber = context.payload.pull_request?.number;
33+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
34+
logger.warn('No PR number found. Skipping.');
35+
return null;
36+
}
37+
return prNumber;
38+
}
39+
40+
/**
41+
* Extracts unique users to assign from requested reviewers.
42+
*
43+
* @param {Array<Object>} requestedReviewers - List of requested reviewers from PR
44+
* @param {Set<string>} currentAssignees - Set of current assignee logins
45+
* @returns {Set<string>} Set of logins that should be assigned
46+
*/
47+
function getUsersToAssign(requestedReviewers, currentAssignees) {
48+
const usersToAssign = new Set();
49+
50+
for (const reviewer of requestedReviewers) {
51+
if (reviewer?.login && !currentAssignees.has(reviewer.login)) {
52+
usersToAssign.add(reviewer.login);
53+
}
54+
}
55+
return usersToAssign;
56+
}
57+
58+
/**
59+
* Logs a warning if some reviewers were dropped due to the assignee cap.
60+
*
61+
* @param {Set<string>} usersToAssign
62+
*/
63+
function logAssigneeCapWarning(usersToAssign) {
64+
if (usersToAssign.size > MAX_ASSIGNEES) {
65+
const dropped = Array.from(usersToAssign).slice(MAX_ASSIGNEES);
66+
logger.warn(`Assignee cap (${MAX_ASSIGNEES}) reached. Dropping: ${dropped.join(', ')}`);
67+
}
68+
}
69+
70+
/**
71+
* Main handler that adds requested reviewers as assignees on a PR.
72+
*
73+
* Triggered by:
74+
* - `pull_request_target: review_requested`
75+
* - `workflow_dispatch` (for manual testing)
76+
*
77+
* Behavior:
78+
* - Only processes individual reviewers (`requested_reviewers`)
79+
* - Ignores team reviewers (`requested_teams`)
80+
* - Skips users who are already assignees
81+
* - Caps at `MAX_ASSIGNEES` (default: 2)
82+
* - Logs a warning when reviewers are dropped due to the cap
83+
*
84+
* @param {Object} params
85+
* @param {Object} params.github - GitHub Octokit client instance
86+
* @param {Object} params.context - GitHub Actions context object
87+
* @returns {Promise<void>}
88+
*/
89+
module.exports = async ({ github, context }) => {
90+
try {
91+
const prNumber = resolvePrNumber(context);
92+
if (!prNumber) return;
93+
94+
const owner = context.repo.owner;
95+
const repo = context.repo.repo;
96+
97+
logger.log(`Processing PR #${prNumber}`);
98+
99+
const payloadPr = context.payload.pull_request;
100+
const pr = (payloadPr && Number.isInteger(payloadPr.number))
101+
? payloadPr
102+
: (await github.rest.pulls.get({
103+
owner,
104+
repo,
105+
pull_number: prNumber
106+
})).data;
107+
108+
const requestedReviewers = pr.requested_reviewers || [];
109+
const requestedTeams = pr.requested_teams || [];
110+
const currentAssignees = new Set((pr.assignees || []).map(a => a.login));
111+
112+
if (requestedTeams.length > 0) {
113+
logger.info(`${requestedTeams.length} team reviewer(s) detected but ignored (only individual users are assigned)`);
114+
}
115+
const usersToAssign = getUsersToAssign(requestedReviewers, currentAssignees);
116+
const assigneesList = Array.from(usersToAssign).slice(0, MAX_ASSIGNEES);
117+
118+
if (assigneesList.length === 0) {
119+
logger.log('No new users to assign. Done.');
120+
return;
121+
}
122+
123+
logger.log(`Will assign: ${assigneesList.join(', ')}`);
124+
logAssigneeCapWarning(usersToAssign);
125+
126+
await github.rest.issues.addAssignees({
127+
owner,
128+
repo,
129+
issue_number: prNumber,
130+
assignees: assigneesList
131+
});
132+
133+
logger.log(`✅ Successfully added ${assigneesList.length} reviewer(s) as assignee(s)`);
134+
135+
} catch (error) {
136+
logger.error('Failed:', error.message);
137+
if (error.status === 403) {
138+
logger.warn('Insufficient permissions - skipping');
139+
return;
140+
}
141+
throw error;
142+
}
143+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
/**
4+
* Shared constants used by review-related GitHub Actions bots.
5+
*
6+
* @module constants
7+
*/
8+
9+
module.exports = {
10+
MAX_ASSIGNEES: 2,
11+
BOT_NAME_ASSIGNEES: 'reviewers-assignee',
12+
};

.github/scripts/helpers/index.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
/**
4+
* @fileoverview
5+
* Central export file for all helper modules used by review bots.
6+
*/
7+
8+
const { createLogger } = require('./logger.js');
9+
const constants = require('./constants.js');
10+
11+
module.exports = {
12+
createLogger,
13+
MAX_ASSIGNEES: constants.MAX_ASSIGNEES,
14+
BOT_NAME_ASSIGNEES: constants.BOT_NAME_ASSIGNEES,
15+
};

.github/scripts/helpers/logger.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
/**
4+
* @fileoverview
5+
* Simple logger utility for GitHub Actions bots with consistent
6+
* prefixes and levels.
7+
*/
8+
9+
/**
10+
* Creates a logger instance for a specific bot.
11+
*
12+
* @param {string} botName - Name of the bot (e.g. 'reviewers-assignee')
13+
* @returns {Object} Logger with info, warn, error methods
14+
*/
15+
function createLogger(botName) {
16+
const prefix = `[${botName}]`;
17+
return {
18+
log: (...args) => console.log(prefix, ...args),
19+
info: (...args) => console.info(prefix, ...args),
20+
warn: (...args) => console.warn(prefix, ...args),
21+
error: (...args) => console.error(prefix, ...args),
22+
};
23+
}
24+
25+
module.exports = { createLogger };

0 commit comments

Comments
 (0)