Skip to content
Merged
Show file tree
Hide file tree
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
// SPDX-License-Identifier: Apache-2.0

/**
* @fileoverview
* Unit tests for the bot-pr-add-reviewers-as-assignees.js script using Jest.
*/

describe('Bot: Add Reviewers as Assignees', () => {
let handler;

beforeAll(() => {
handler = require('../bot-pr-add-reviewers-as-assignees.js');
});

beforeEach(() => {
jest.clearAllMocks();
});

const createTestState = () => ({
addAssigneesCalls: [],
pullsGetCalls: 0,
currentPrData: null,
});

const createMockContext = (prData = {}) => ({
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' },
eventName: 'pull_request_target',
payload: {
pull_request: {
number: 123,
requested_reviewers: [],
requested_teams: [],
assignees: [],
...prData
}
}
});

const createMockGithub = (state) => ({
rest: {
pulls: {
get: async ({ pull_number }) => {
state.pullsGetCalls++;
return {
data: {
number: pull_number,
requested_reviewers: state.currentPrData?.requested_reviewers || [],
requested_teams: state.currentPrData?.requested_teams || [],
assignees: state.currentPrData?.assignees || []
}
};
}
},
issues: {
addAssignees: async (params) => {
state.addAssigneesCalls.push(params);
return { data: {} };
}
}
}
});

test('adds individual reviewers correctly as assignees', async () => {
const state = createTestState();
state.currentPrData = {
requested_reviewers: [{ login: 'alice' }, { login: 'bob' }],
assignees: []
};

const ctx = createMockContext({
requested_reviewers: [{ login: 'alice' }, { login: 'bob' }]
});

await handler({ github: createMockGithub(state), context: ctx });

expect(state.addAssigneesCalls).toHaveLength(1);
const call = state.addAssigneesCalls[0];
expect(call.assignees.sort()).toEqual(['alice', 'bob']);
});

test('ignores team reviewers', async () => {
const state = createTestState();
state.currentPrData = {
requested_reviewers: [{ login: 'charlie' }],
requested_teams: [{ slug: 'team1' }],
assignees: []
};

const ctx = createMockContext({
requested_reviewers: [{ login: 'charlie' }],
requested_teams: [{ slug: 'team1' }]
});

await handler({ github: createMockGithub(state), context: ctx });

expect(state.addAssigneesCalls).toHaveLength(1);
expect(state.addAssigneesCalls[0].assignees).toEqual(['charlie']);
});

test('skips users who are already assignees (deduplication)', async () => {
const state = createTestState();
state.currentPrData = {
requested_reviewers: [{ login: 'alice' }],
assignees: [{ login: 'alice' }]
};

const ctx = createMockContext({
requested_reviewers: [{ login: 'alice' }],
assignees: [{ login: 'alice' }]
});

await handler({ github: createMockGithub(state), context: ctx });

expect(state.addAssigneesCalls).toHaveLength(0);
});

test('respects MAX_ASSIGNEES = 2 cap', async () => {
const state = createTestState();
state.currentPrData = {
requested_reviewers: [{ login: 'u1' }, { login: 'u2' }, { login: 'u3' }],
assignees: []
};

const ctx = createMockContext({
requested_reviewers: [{ login: 'u1' }, { login: 'u2' }, { login: 'u3' }]
});

await handler({ github: createMockGithub(state), context: ctx });

expect(state.addAssigneesCalls).toHaveLength(1);
expect(state.addAssigneesCalls[0].assignees).toHaveLength(2);
});

test('does nothing when no reviewers are requested', async () => {
const state = createTestState();
const ctx = createMockContext({ requested_reviewers: [] });

await handler({ github: createMockGithub(state), context: ctx });

expect(state.addAssigneesCalls).toHaveLength(0);
});

test('does nothing when only team reviewers are requested', async () => {
const state = createTestState();
const ctx = createMockContext({
requested_reviewers: [],
requested_teams: [{ slug: 'team' }]
});

await handler({ github: createMockGithub(state), context: ctx });

expect(state.addAssigneesCalls).toHaveLength(0);
});

test('supports workflow_dispatch with pr_number input', async () => {
const state = createTestState();
state.currentPrData = { requested_reviewers: [{ login: 'eve' }], assignees: [] };

const ctx = {
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' },
eventName: 'workflow_dispatch',
payload: { inputs: { pr_number: 128 } }
};

await handler({ github: createMockGithub(state), context: ctx });

expect(state.addAssigneesCalls).toHaveLength(1);
expect(state.addAssigneesCalls[0].issue_number).toBe(128);
});

test('handles invalid pr_number in workflow_dispatch', async () => {
const badValues = ['0', '-1', '12.5', 'abc', ''];

for (const v of badValues) {
const state = createTestState();
const ctx = {
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' },
eventName: 'workflow_dispatch',
payload: { inputs: { pr_number: v } }
};

await handler({ github: createMockGithub(state), context: ctx });
expect(state.pullsGetCalls).toBe(0);
expect(state.addAssigneesCalls).toHaveLength(0);
}
});

test('gracefully handles 403 permission errors', async () => {
const ctx = createMockContext({ requested_reviewers: [{ login: 'x' }] });

const errorMock = {
rest: {
pulls: { get: async () => ({ data: { requested_reviewers: [{ login: 'x' }] } }) },
issues: {
addAssignees: async () => {
const err = new Error('Forbidden');
err.status = 403;
throw err;
}
}
}
};

await expect(handler({ github: errorMock, context: ctx })).resolves.not.toThrow();
});

test('rethrows non-403 errors', async () => {
const ctx = createMockContext({ requested_reviewers: [{ login: 'x' }] });

const errorMock = {
rest: {
pulls: { get: async () => ({ data: { requested_reviewers: [{ login: 'x' }] } }) },
issues: {
addAssignees: async () => {
const err = new Error('Internal Server Error');
err.status = 500;
throw err;
}
}
}
};

await expect(handler({ github: errorMock, context: ctx })).rejects.toHaveProperty('status', 500);
});
});
151 changes: 151 additions & 0 deletions .github/scripts/bot-pr-add-reviewers-as-assignees.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: Apache-2.0

/**
* @fileoverview
* Automatically adds requested individual reviewers as assignees on Pull Requests.
*
* This is part of the generic "on-review" infrastructure.
* Team reviewers are intentionally ignored (only individual users are assigned).
* Caps the number of assignees at MAX_ASSIGNEES (default: 2).
*/

const { createLogger, MAX_ASSIGNEES, BOT_NAME_ASSIGNEES } = require('./shared/helpers/reviewers-assignee-index.js');

const logger = createLogger(BOT_NAME_ASSIGNEES);

/**
* Resolves the PR number from context (pull_request_target or workflow_dispatch).
*
* @param {Object} context - GitHub Actions context object
* @returns {number|null} Valid PR number or null if invalid/missing
*/
function resolvePrNumber(context) {
let prNumber;

if (context.eventName === 'workflow_dispatch') {
prNumber = Number(context.payload.inputs?.pr_number);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
logger.warn('Invalid PR number supplied. Skipping.');
return null;
}
} else {
// pull_request_target
prNumber = context.payload.pull_request?.number;
if (!Number.isInteger(prNumber) || prNumber <= 0) {
logger.warn('No PR number found. Skipping.');
return null;
}
}

return prNumber;
}

/**
* Extracts unique users to assign from requested reviewers.
*
* @param {Array<Object>} requestedReviewers - List of requested reviewers from PR
* @param {Set<string>} currentAssignees - Set of current assignee logins
* @returns {Set<string>} Set of logins that should be assigned
*/
function getUsersToAssign(requestedReviewers, currentAssignees) {
const usersToAssign = new Set();

for (const reviewer of requestedReviewers) {
if (reviewer?.login && !currentAssignees.has(reviewer.login)) {
usersToAssign.add(reviewer.login);
}
}
return usersToAssign;
}

/**
* Logs a warning if some reviewers were dropped due to the assignee cap.
*
* @param {Set<string>} usersToAssign
*/
function logAssigneeCapWarning(usersToAssign, maxToAdd) {
if (usersToAssign.size > maxToAdd) {
const dropped = Array.from(usersToAssign).slice(maxToAdd);
logger.warn(`Assignee cap (${MAX_ASSIGNEES}) reached. Dropping: ${dropped.join(', ')}`);
}
}

/**
* Main handler that adds requested reviewers as assignees on a PR.
*
* Triggered by:
* - `pull_request_target: review_requested`
* - `workflow_dispatch` (for manual testing)
*
* Behavior:
* - Only processes individual reviewers (`requested_reviewers`)
* - Ignores team reviewers (`requested_teams`)
* - Skips users who are already assignees
* - Caps at `MAX_ASSIGNEES` (default: 2)
* - Logs a warning when reviewers are dropped due to the cap
*
* @param {Object} params
* @param {Object} params.github - GitHub Octokit client instance
* @param {Object} params.context - GitHub Actions context object
* @returns {Promise<void>}
*/
module.exports = async ({ github, context }) => {
try {
const prNumber = resolvePrNumber(context);
if (!prNumber) return;

const owner = context.repo.owner;
const repo = context.repo.repo;

logger.log(`Processing PR #${prNumber}`);

const payloadPr = context.payload.pull_request;
const pr = (payloadPr && Number.isInteger(payloadPr.number))
? payloadPr
: (await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber
})).data;

const requestedReviewers = pr.requested_reviewers || [];
const requestedTeams = pr.requested_teams || [];

const currentAssignees = new Set(
(pr.assignees || []).map(a => a.login)
);

if (requestedTeams.length > 0) {
logger.info(`${requestedTeams.length} team reviewer(s) detected but ignored (only individual users are assigned)`);
}
const usersToAssign = getUsersToAssign(requestedReviewers, currentAssignees);
const currentCount = currentAssignees.size;
const maxNewAssignees = Math.max(0, MAX_ASSIGNEES - currentCount);
const assigneesList = Array.from(usersToAssign).slice(0, maxNewAssignees);

if (assigneesList.length === 0) {
logger.log('No new users to assign. Done.');
return;
}

logger.log(`Will assign: ${assigneesList.join(', ')}`);
logAssigneeCapWarning(usersToAssign, maxNewAssignees);

await github.rest.issues.addAssignees({
Comment thread
gangulysiddhartha22-cmyk marked this conversation as resolved.
owner,
repo,
issue_number: prNumber,
assignees: assigneesList
});

logger.log(`✅ Successfully added ${assigneesList.length} reviewer(s) as assignee(s)`);

} catch (error) {
logger.error('Failed:', error.message);
if (error.status === 403) {
logger.warn(`403 returned: ${error.message}`);
return;
}
throw error;
}
};
12 changes: 12 additions & 0 deletions .github/scripts/shared/helpers/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: Apache-2.0

/**
* Shared constants used by review-related GitHub Actions bots.
*
* @module constants
*/

module.exports = {
MAX_ASSIGNEES: 2,
BOT_NAME_ASSIGNEES: 'reviewers-assignee',
};
Loading
Loading