diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index a6f52827628..b17f6f7b27f 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -36,6 +36,9 @@ const HANDLER_TYPE = "create_pull_request"; /** @type {string} Label always added to fallback issues so the triage system can find them */ const MANAGED_FALLBACK_ISSUE_LABEL = "agentic-workflows"; +// GitHub Copilot reviewer bot username +const COPILOT_REVIEWER_BOT = "copilot-pull-request-reviewer[bot]"; + /** * Merges the required fallback label with any workflow-configured labels, * deduplicating and filtering empty values. @@ -117,6 +120,7 @@ async function main(config = {}) { // Extract configuration const titlePrefix = config.title_prefix || ""; const envLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : []; + const configReviewers = config.reviewers ? (Array.isArray(config.reviewers) ? config.reviewers : config.reviewers.split(",")).map(r => String(r).trim()).filter(r => r) : []; const draftDefault = parseBoolTemplatable(config.draft, true); const ifNoChanges = config.if_no_changes || "warn"; const allowEmpty = parseBoolTemplatable(config.allow_empty, false); @@ -166,6 +170,9 @@ async function main(config = {}) { if (envLabels.length > 0) { core.info(`Default labels: ${envLabels.join(", ")}`); } + if (configReviewers.length > 0) { + core.info(`Configured reviewers: ${configReviewers.join(", ")}`); + } if (titlePrefix) { core.info(`Title prefix: ${titlePrefix}`); } @@ -1030,6 +1037,42 @@ ${patchPreview}`; core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); } + // Add configured reviewers if specified + if (configReviewers.length > 0) { + const hasCopilot = configReviewers.includes("copilot"); + const otherReviewers = configReviewers.filter(r => r !== "copilot"); + + if (otherReviewers.length > 0) { + core.info(`Requesting ${otherReviewers.length} reviewer(s) for pull request #${pullRequest.number}: ${JSON.stringify(otherReviewers)}`); + try { + await githubClient.rest.pulls.requestReviewers({ + owner: repoParts.owner, + repo: repoParts.repo, + pull_number: pullRequest.number, + reviewers: otherReviewers, + }); + core.info(`Requested reviewers for pull request #${pullRequest.number}: ${JSON.stringify(otherReviewers)}`); + } catch (reviewerError) { + core.warning(`Failed to request reviewers for PR #${pullRequest.number}: ${reviewerError instanceof Error ? reviewerError.message : String(reviewerError)}`); + } + } + + if (hasCopilot) { + core.info(`Requesting copilot as reviewer for pull request #${pullRequest.number}`); + try { + await githubClient.rest.pulls.requestReviewers({ + owner: repoParts.owner, + repo: repoParts.repo, + pull_number: pullRequest.number, + reviewers: [COPILOT_REVIEWER_BOT], + }); + core.info(`Requested copilot as reviewer for pull request #${pullRequest.number}`); + } catch (copilotError) { + core.warning(`Failed to request copilot as reviewer for PR #${pullRequest.number}: ${copilotError instanceof Error ? copilotError.message : String(copilotError)}`); + } + } + } + // Enable auto-merge if configured if (autoMerge) { try { diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 6959948d87d..337ec29467a 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -758,3 +758,138 @@ ${diffs} expect(result.error || "").not.toContain("outside the allowed-files list"); }); }); + +describe("create_pull_request - configured reviewers", () => { + let tempDir; + let originalEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + process.env.GH_AW_WORKFLOW_ID = "test-workflow"; + process.env.GITHUB_REPOSITORY = "test-owner/test-repo"; + process.env.GITHUB_BASE_REF = "main"; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-reviewer-test-")); + + global.core = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + startGroup: vi.fn(), + endGroup: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(undefined), + }, + }; + global.github = { + rest: { + pulls: { + create: vi.fn().mockResolvedValue({ data: { number: 42, html_url: "https://github.com/test/pull/42", node_id: "PR_42" } }), + requestReviewers: vi.fn().mockResolvedValue({}), + }, + repos: { + get: vi.fn().mockResolvedValue({ data: { default_branch: "main" } }), + }, + issues: { + addLabels: vi.fn().mockResolvedValue({}), + }, + }, + graphql: vi.fn(), + }; + global.context = { + eventName: "workflow_dispatch", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: {}, + }; + global.exec = { + exec: vi.fn().mockResolvedValue(0), + getExecOutput: vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }), + }; + + delete require.cache[require.resolve("./create_pull_request.cjs")]; + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + Object.assign(process.env, originalEnv); + + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + + delete global.core; + delete global.github; + delete global.context; + delete global.exec; + vi.clearAllMocks(); + }); + + it("should request configured reviewers after creating the PR", async () => { + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ reviewers: ["user1", "user2"], allow_empty: true }); + + const result = await handler({ title: "Test PR", body: "Test body" }, {}); + + expect(result.success).toBe(true); + expect(global.github.rest.pulls.requestReviewers).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + pull_number: 42, + reviewers: ["user1", "user2"], + }) + ); + }); + + it("should handle copilot reviewer separately from regular reviewers", async () => { + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ reviewers: ["user1", "copilot"], allow_empty: true }); + + const result = await handler({ title: "Test PR", body: "Test body" }, {}); + + expect(result.success).toBe(true); + // Should be called twice: once for regular reviewers, once for copilot bot + expect(global.github.rest.pulls.requestReviewers).toHaveBeenCalledTimes(2); + expect(global.github.rest.pulls.requestReviewers).toHaveBeenCalledWith(expect.objectContaining({ reviewers: ["user1"] })); + expect(global.github.rest.pulls.requestReviewers).toHaveBeenCalledWith(expect.objectContaining({ reviewers: ["copilot-pull-request-reviewer[bot]"] })); + }); + + it("should not call requestReviewers when no reviewers are configured", async () => { + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ allow_empty: true }); + + const result = await handler({ title: "Test PR", body: "Test body" }, {}); + + expect(result.success).toBe(true); + expect(global.github.rest.pulls.requestReviewers).not.toHaveBeenCalled(); + }); + + it("should continue successfully even if requestReviewers fails", async () => { + global.github.rest.pulls.requestReviewers.mockRejectedValue(new Error("API error")); + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ reviewers: ["user1"], allow_empty: true }); + + const result = await handler({ title: "Test PR", body: "Test body" }, {}); + + expect(result.success).toBe(true); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to request reviewers")); + }); + + it("should accept reviewers as a comma-separated string", async () => { + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ reviewers: "user1,user2", allow_empty: true }); + + const result = await handler({ title: "Test PR", body: "Test body" }, {}); + + expect(result.success).toBe(true); + expect(global.github.rest.pulls.requestReviewers).toHaveBeenCalledWith(expect.objectContaining({ reviewers: ["user1", "user2"] })); + }); +});