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
43 changes: 43 additions & 0 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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 {
Expand Down
135 changes: 135 additions & 0 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }));
});
});
Loading