Skip to content

Commit 1b77834

Browse files
feat: add bot to automatically assign requested reviewers as assignees (#2211)
Signed-off-by: Siddhartha Ganguly <gangulysiddhartha22@gmail.com>
1 parent 2666bd3 commit 1b77834

7 files changed

Lines changed: 515 additions & 0 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
/**
4+
* @fileoverview
5+
* Unit tests for the bot-pr-add-reviewers-as-assignees.js script using Jest.
6+
*/
7+
8+
describe('Bot: Add Reviewers as Assignees', () => {
9+
let handler;
10+
11+
beforeAll(() => {
12+
handler = require('../bot-pr-add-reviewers-as-assignees.js');
13+
});
14+
15+
beforeEach(() => {
16+
jest.clearAllMocks();
17+
});
18+
19+
const createTestState = () => ({
20+
addAssigneesCalls: [],
21+
pullsGetCalls: 0,
22+
currentPrData: null,
23+
});
24+
25+
const createMockContext = (prData = {}) => ({
26+
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' },
27+
eventName: 'pull_request_target',
28+
payload: {
29+
pull_request: {
30+
number: 123,
31+
requested_reviewers: [],
32+
requested_teams: [],
33+
assignees: [],
34+
...prData
35+
}
36+
}
37+
});
38+
39+
const createMockGithub = (state) => ({
40+
rest: {
41+
pulls: {
42+
get: async ({ pull_number }) => {
43+
state.pullsGetCalls++;
44+
return {
45+
data: {
46+
number: pull_number,
47+
requested_reviewers: state.currentPrData?.requested_reviewers || [],
48+
requested_teams: state.currentPrData?.requested_teams || [],
49+
assignees: state.currentPrData?.assignees || []
50+
}
51+
};
52+
}
53+
},
54+
issues: {
55+
addAssignees: async (params) => {
56+
state.addAssigneesCalls.push(params);
57+
return { data: {} };
58+
}
59+
}
60+
}
61+
});
62+
63+
test('adds individual reviewers correctly as assignees', async () => {
64+
const state = createTestState();
65+
state.currentPrData = {
66+
requested_reviewers: [{ login: 'alice' }, { login: 'bob' }],
67+
assignees: []
68+
};
69+
70+
const ctx = createMockContext({
71+
requested_reviewers: [{ login: 'alice' }, { login: 'bob' }]
72+
});
73+
74+
await handler({ github: createMockGithub(state), context: ctx });
75+
76+
expect(state.addAssigneesCalls).toHaveLength(1);
77+
const call = state.addAssigneesCalls[0];
78+
expect(call.assignees.sort()).toEqual(['alice', 'bob']);
79+
});
80+
81+
test('ignores team reviewers', async () => {
82+
const state = createTestState();
83+
state.currentPrData = {
84+
requested_reviewers: [{ login: 'charlie' }],
85+
requested_teams: [{ slug: 'team1' }],
86+
assignees: []
87+
};
88+
89+
const ctx = createMockContext({
90+
requested_reviewers: [{ login: 'charlie' }],
91+
requested_teams: [{ slug: 'team1' }]
92+
});
93+
94+
await handler({ github: createMockGithub(state), context: ctx });
95+
96+
expect(state.addAssigneesCalls).toHaveLength(1);
97+
expect(state.addAssigneesCalls[0].assignees).toEqual(['charlie']);
98+
});
99+
100+
test('skips users who are already assignees (deduplication)', async () => {
101+
const state = createTestState();
102+
state.currentPrData = {
103+
requested_reviewers: [{ login: 'alice' }],
104+
assignees: [{ login: 'alice' }]
105+
};
106+
107+
const ctx = createMockContext({
108+
requested_reviewers: [{ login: 'alice' }],
109+
assignees: [{ login: 'alice' }]
110+
});
111+
112+
await handler({ github: createMockGithub(state), context: ctx });
113+
114+
expect(state.addAssigneesCalls).toHaveLength(0);
115+
});
116+
117+
test('respects MAX_ASSIGNEES = 2 cap', async () => {
118+
const state = createTestState();
119+
state.currentPrData = {
120+
requested_reviewers: [{ login: 'u1' }, { login: 'u2' }, { login: 'u3' }],
121+
assignees: []
122+
};
123+
124+
const ctx = createMockContext({
125+
requested_reviewers: [{ login: 'u1' }, { login: 'u2' }, { login: 'u3' }]
126+
});
127+
128+
await handler({ github: createMockGithub(state), context: ctx });
129+
130+
expect(state.addAssigneesCalls).toHaveLength(1);
131+
expect(state.addAssigneesCalls[0].assignees).toHaveLength(2);
132+
});
133+
134+
test('does nothing when no reviewers are requested', async () => {
135+
const state = createTestState();
136+
const ctx = createMockContext({ requested_reviewers: [] });
137+
138+
await handler({ github: createMockGithub(state), context: ctx });
139+
140+
expect(state.addAssigneesCalls).toHaveLength(0);
141+
});
142+
143+
test('does nothing when only team reviewers are requested', async () => {
144+
const state = createTestState();
145+
const ctx = createMockContext({
146+
requested_reviewers: [],
147+
requested_teams: [{ slug: 'team' }]
148+
});
149+
150+
await handler({ github: createMockGithub(state), context: ctx });
151+
152+
expect(state.addAssigneesCalls).toHaveLength(0);
153+
});
154+
155+
test('supports workflow_dispatch with pr_number input', async () => {
156+
const state = createTestState();
157+
state.currentPrData = { requested_reviewers: [{ login: 'eve' }], assignees: [] };
158+
159+
const ctx = {
160+
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' },
161+
eventName: 'workflow_dispatch',
162+
payload: { inputs: { pr_number: 128 } }
163+
};
164+
165+
await handler({ github: createMockGithub(state), context: ctx });
166+
167+
expect(state.addAssigneesCalls).toHaveLength(1);
168+
expect(state.addAssigneesCalls[0].issue_number).toBe(128);
169+
});
170+
171+
test('handles invalid pr_number in workflow_dispatch', async () => {
172+
const badValues = ['0', '-1', '12.5', 'abc', ''];
173+
174+
for (const v of badValues) {
175+
const state = createTestState();
176+
const ctx = {
177+
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' },
178+
eventName: 'workflow_dispatch',
179+
payload: { inputs: { pr_number: v } }
180+
};
181+
182+
await handler({ github: createMockGithub(state), context: ctx });
183+
expect(state.pullsGetCalls).toBe(0);
184+
expect(state.addAssigneesCalls).toHaveLength(0);
185+
}
186+
});
187+
188+
test('gracefully handles 403 permission errors', async () => {
189+
const ctx = createMockContext({ requested_reviewers: [{ login: 'x' }] });
190+
191+
const errorMock = {
192+
rest: {
193+
pulls: { get: async () => ({ data: { requested_reviewers: [{ login: 'x' }] } }) },
194+
issues: {
195+
addAssignees: async () => {
196+
const err = new Error('Forbidden');
197+
err.status = 403;
198+
throw err;
199+
}
200+
}
201+
}
202+
};
203+
204+
await expect(handler({ github: errorMock, context: ctx })).resolves.not.toThrow();
205+
});
206+
207+
test('rethrows non-403 errors', async () => {
208+
const ctx = createMockContext({ requested_reviewers: [{ login: 'x' }] });
209+
210+
const errorMock = {
211+
rest: {
212+
pulls: { get: async () => ({ data: { requested_reviewers: [{ login: 'x' }] } }) },
213+
issues: {
214+
addAssignees: async () => {
215+
const err = new Error('Internal Server Error');
216+
err.status = 500;
217+
throw err;
218+
}
219+
}
220+
}
221+
};
222+
223+
await expect(handler({ github: errorMock, context: ctx })).rejects.toHaveProperty('status', 500);
224+
});
225+
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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('./shared/helpers/reviewers-assignee-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+
let prNumber;
24+
25+
if (context.eventName === 'workflow_dispatch') {
26+
prNumber = Number(context.payload.inputs?.pr_number);
27+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
28+
logger.warn('Invalid PR number supplied. Skipping.');
29+
return null;
30+
}
31+
} else {
32+
// pull_request_target
33+
prNumber = context.payload.pull_request?.number;
34+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
35+
logger.warn('No PR number found. Skipping.');
36+
return null;
37+
}
38+
}
39+
40+
return prNumber;
41+
}
42+
43+
/**
44+
* Extracts unique users to assign from requested reviewers.
45+
*
46+
* @param {Array<Object>} requestedReviewers - List of requested reviewers from PR
47+
* @param {Set<string>} currentAssignees - Set of current assignee logins
48+
* @returns {Set<string>} Set of logins that should be assigned
49+
*/
50+
function getUsersToAssign(requestedReviewers, currentAssignees) {
51+
const usersToAssign = new Set();
52+
53+
for (const reviewer of requestedReviewers) {
54+
if (reviewer?.login && !currentAssignees.has(reviewer.login)) {
55+
usersToAssign.add(reviewer.login);
56+
}
57+
}
58+
return usersToAssign;
59+
}
60+
61+
/**
62+
* Logs a warning if some reviewers were dropped due to the assignee cap.
63+
*
64+
* @param {Set<string>} usersToAssign
65+
*/
66+
function logAssigneeCapWarning(usersToAssign, maxToAdd) {
67+
if (usersToAssign.size > maxToAdd) {
68+
const dropped = Array.from(usersToAssign).slice(maxToAdd);
69+
logger.warn(`Assignee cap (${MAX_ASSIGNEES}) reached. Dropping: ${dropped.join(', ')}`);
70+
}
71+
}
72+
73+
/**
74+
* Main handler that adds requested reviewers as assignees on a PR.
75+
*
76+
* Triggered by:
77+
* - `pull_request_target: review_requested`
78+
* - `workflow_dispatch` (for manual testing)
79+
*
80+
* Behavior:
81+
* - Only processes individual reviewers (`requested_reviewers`)
82+
* - Ignores team reviewers (`requested_teams`)
83+
* - Skips users who are already assignees
84+
* - Caps at `MAX_ASSIGNEES` (default: 2)
85+
* - Logs a warning when reviewers are dropped due to the cap
86+
*
87+
* @param {Object} params
88+
* @param {Object} params.github - GitHub Octokit client instance
89+
* @param {Object} params.context - GitHub Actions context object
90+
* @returns {Promise<void>}
91+
*/
92+
module.exports = async ({ github, context }) => {
93+
try {
94+
const prNumber = resolvePrNumber(context);
95+
if (!prNumber) return;
96+
97+
const owner = context.repo.owner;
98+
const repo = context.repo.repo;
99+
100+
logger.log(`Processing PR #${prNumber}`);
101+
102+
const payloadPr = context.payload.pull_request;
103+
const pr = (payloadPr && Number.isInteger(payloadPr.number))
104+
? payloadPr
105+
: (await github.rest.pulls.get({
106+
owner,
107+
repo,
108+
pull_number: prNumber
109+
})).data;
110+
111+
const requestedReviewers = pr.requested_reviewers || [];
112+
const requestedTeams = pr.requested_teams || [];
113+
114+
const currentAssignees = new Set(
115+
(pr.assignees || []).map(a => a.login)
116+
);
117+
118+
if (requestedTeams.length > 0) {
119+
logger.info(`${requestedTeams.length} team reviewer(s) detected but ignored (only individual users are assigned)`);
120+
}
121+
const usersToAssign = getUsersToAssign(requestedReviewers, currentAssignees);
122+
const currentCount = currentAssignees.size;
123+
const maxNewAssignees = Math.max(0, MAX_ASSIGNEES - currentCount);
124+
const assigneesList = Array.from(usersToAssign).slice(0, maxNewAssignees);
125+
126+
if (assigneesList.length === 0) {
127+
logger.log('No new users to assign. Done.');
128+
return;
129+
}
130+
131+
logger.log(`Will assign: ${assigneesList.join(', ')}`);
132+
logAssigneeCapWarning(usersToAssign, maxNewAssignees);
133+
134+
await github.rest.issues.addAssignees({
135+
owner,
136+
repo,
137+
issue_number: prNumber,
138+
assignees: assigneesList
139+
});
140+
141+
logger.log(`✅ Successfully added ${assigneesList.length} reviewer(s) as assignee(s)`);
142+
143+
} catch (error) {
144+
logger.error('Failed:', error.message);
145+
if (error.status === 403) {
146+
logger.warn(`403 returned: ${error.message}`);
147+
return;
148+
}
149+
throw error;
150+
}
151+
};
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+
};

0 commit comments

Comments
 (0)