diff --git a/.github/scripts/__tests__/test-bot-pr-add-reviewers-as-assignees.test.js b/.github/scripts/__tests__/test-bot-pr-add-reviewers-as-assignees.test.js new file mode 100644 index 000000000..dee899053 --- /dev/null +++ b/.github/scripts/__tests__/test-bot-pr-add-reviewers-as-assignees.test.js @@ -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); + }); +}); diff --git a/.github/scripts/bot-pr-add-reviewers-as-assignees.js b/.github/scripts/bot-pr-add-reviewers-as-assignees.js new file mode 100644 index 000000000..feb819e2f --- /dev/null +++ b/.github/scripts/bot-pr-add-reviewers-as-assignees.js @@ -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} requestedReviewers - List of requested reviewers from PR + * @param {Set} currentAssignees - Set of current assignee logins + * @returns {Set} 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} 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} + */ +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({ + 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; + } +}; diff --git a/.github/scripts/shared/helpers/constants.js b/.github/scripts/shared/helpers/constants.js new file mode 100644 index 000000000..fc3d3cbc4 --- /dev/null +++ b/.github/scripts/shared/helpers/constants.js @@ -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', +}; diff --git a/.github/scripts/shared/helpers/logger.js b/.github/scripts/shared/helpers/logger.js new file mode 100644 index 000000000..0b56c5198 --- /dev/null +++ b/.github/scripts/shared/helpers/logger.js @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * @fileoverview + * Simple logger utility for GitHub Actions bots with consistent + * prefixes and levels. + */ + +/** + * Creates a logger instance for a specific bot. + * + * @param {string} botName - Name of the bot (e.g. 'reviewers-assignee') + * @returns {{ + * log: (...args: any[]) => void, + * info: (...args: any[]) => void, + * warn: (...args: any[]) => void, + * error: (...args: any[]) => void + * }} Logger with consistent prefix + */ +function createLogger(botName) { + const prefix = `[${botName}]`; + return { + log: (...args) => console.log(prefix, ...args), + info: (...args) => console.info(prefix, ...args), + warn: (...args) => console.warn(prefix, ...args), + error: (...args) => console.error(prefix, ...args), + }; +} + +module.exports = { createLogger }; diff --git a/.github/scripts/shared/helpers/reviewers-assignee-index.js b/.github/scripts/shared/helpers/reviewers-assignee-index.js new file mode 100644 index 000000000..b7ab7273b --- /dev/null +++ b/.github/scripts/shared/helpers/reviewers-assignee-index.js @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * @fileoverview + * Central export file for all helper modules used by review bots. + */ + +const { createLogger } = require('./logger.js'); +const constants = require('./constants.js'); + +module.exports = { + createLogger, + MAX_ASSIGNEES: constants.MAX_ASSIGNEES, + BOT_NAME_ASSIGNEES: constants.BOT_NAME_ASSIGNEES, +}; diff --git a/.github/workflows/on-review.yml b/.github/workflows/on-review.yml new file mode 100644 index 000000000..37bc846ed --- /dev/null +++ b/.github/workflows/on-review.yml @@ -0,0 +1,45 @@ +name: Bot - On Review + +on: + pull_request_target: + types: + - review_requested + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to process (for manual testing)' + required: true + type: number + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + add-reviewers-as-assignees: + name: Add Reviewers as Assignees + runs-on: hl-sdk-py-lin-md + + concurrency: + group: reviewer-assignee-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }} + cancel-in-progress: false + + steps: + - name: Harden Runner + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ github.event.pull_request.base.sha || github.ref }} + persist-credentials: false + + - name: Run Add Reviewers as Assignees + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 + with: + script: | + const script = require('./.github/scripts/bot-pr-add-reviewers-as-assignees.js'); + await script({ github, context }); diff --git a/.github/workflows/test-on-review.yml b/.github/workflows/test-on-review.yml new file mode 100644 index 000000000..17b796385 --- /dev/null +++ b/.github/workflows/test-on-review.yml @@ -0,0 +1,37 @@ +name: Test - On Review Bots + +on: + pull_request: + paths: + - '.github/workflows/on-review.yml' + - '.github/workflows/test-on-review.yml' + - '.github/scripts/bot-pr-add-reviewers-as-assignees.js' + - '.github/scripts/__tests__/test-bot-pr-add-reviewers-as-assignees.test.js' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Run Review Bot Tests + # runs-on: hl-sdk-py-lin-md # Remove self-hosted for tests + runs-on: ubuntu-latest # Safer for untrusted code + + steps: + - name: Harden Runner + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Run Tests + working-directory: .github/scripts + run: npx jest __tests__/test-bot-pr-add-reviewers-as-assignees.test.js --verbose