Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
10 changes: 9 additions & 1 deletion actions/setup/js/assign_to_agent.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ async function main(config = {}) {
// Closure-level state
let processedCount = 0;
const agentCache = {};
const processedAssignmentTargets = new Set();

// Reset module-level results for this handler invocation
_allResults = [];
Expand Down Expand Up @@ -352,11 +353,18 @@ async function main(config = {}) {
core.info(`${type} ID: ${assignableId}`);

const hasPerItemPullRequestRepoOverride = !!message.pull_request_repo;
const normalizedPullRequestRepo = hasPerItemPullRequestRepoOverride ? String(message.pull_request_repo).trim() : "default";
const assignmentContextKey = `${effectiveOwner}/${effectiveRepo}:${type}:${number}:${normalizedPullRequestRepo}`;
const seenThisContextBefore = processedAssignmentTargets.has(assignmentContextKey);
// Track assignment context (target + per-item pull_request_repo) to prevent duplicate
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reassignment guard treats any truthy message.pull_request_repo as a valid per-item override. That means whitespace-only or invalid-format values (which are only warned about earlier) will still cause shouldAllowReassignment to become true on first encounter and bypass the "already assigned" short-circuit, triggering an unnecessary assignment mutation. Consider basing the override/reassignment decision on whether a per-item repo override was successfully resolved (e.g., effectivePullRequestRepoId differs from the global pullRequestRepoId), and use that validated/normalized value (or repo ID) in assignmentContextKey.

Suggested change
const hasPerItemPullRequestRepoOverride = !!message.pull_request_repo;
const normalizedPullRequestRepo = hasPerItemPullRequestRepoOverride ? String(message.pull_request_repo).trim() : "default";
const assignmentContextKey = `${effectiveOwner}/${effectiveRepo}:${type}:${number}:${normalizedPullRequestRepo}`;
const seenThisContextBefore = processedAssignmentTargets.has(assignmentContextKey);
// Track assignment context (target + per-item pull_request_repo) to prevent duplicate
const normalizedPerItemPullRequestRepo = typeof message.pull_request_repo === "string"
? message.pull_request_repo.trim()
: "";
const hasPerItemPullRequestRepoOverride = normalizedPerItemPullRequestRepo.length > 0
&& validateRepo(normalizedPerItemPullRequestRepo);
const normalizedPullRequestRepo = hasPerItemPullRequestRepoOverride
? normalizedPerItemPullRequestRepo
: "default";
const assignmentContextKey = `${effectiveOwner}/${effectiveRepo}:${type}:${number}:${normalizedPullRequestRepo}`;
const seenThisContextBefore = processedAssignmentTargets.has(assignmentContextKey);
// Track assignment context (target + validated per-item pull_request_repo) to prevent duplicate

Copilot uses AI. Check for mistakes.
// re-assignment calls while still allowing one global issue to fan out to multiple repos.
processedAssignmentTargets.add(assignmentContextKey);
const shouldAllowReassignment = hasPerItemPullRequestRepoOverride && !seenThisContextBefore;

// Skip if agent is already assigned and no explicit per-item pull_request_repo is specified.
// When a different pull_request_repo is provided on the message, allow re-assignment
// so Copilot can be triggered for a different target repository on the same issue.
if (currentAssignees.some(a => a.id === agentId) && !hasPerItemPullRequestRepoOverride) {
if (currentAssignees.some(a => a.id === agentId) && !shouldAllowReassignment) {
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With multi-repo assignment now possible, _allResults/step summary/output formatting can’t distinguish which pull_request_repo target was used (the summary will list duplicate “Issue #N → Agent: X” lines, and assigned output repeats issue:N:agent). Consider recording the effective PR target (slug and/or repo ID) in the per-item result and including it in the summary (at minimum) so multi-target assignments are observable and debuggable.

Copilot uses AI. Check for mistakes.
core.info(`${agentName} is already assigned to ${type} #${number}`);
_allResults.push({ issue_number: issueNumber, pull_number: pullNumber, agent: agentName, owner: effectiveOwner, repo: effectiveRepo, success: true });
return { success: true };
Expand Down
174 changes: 174 additions & 0 deletions actions/setup/js/assign_to_agent.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,180 @@ describe("assign_to_agent", () => {
expect(lastGraphQLCall[1].targetRepoId).toBe("other-platform-repo-id");
});

it("should process multiple assignments for the same temporary issue ID across different pull_request_repo targets", async () => {
process.env.GH_AW_AGENT_MAX_COUNT = "5";
process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({
aw_multi_repo: { repo: "test-owner/test-repo", number: 6587 },
});
process.env.GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS = "test-owner/ios-repo,test-owner/android-repo";

setAgentOutput({
items: [
{
type: "assign_to_agent",
issue_number: "aw_multi_repo",
agent: "copilot",
pull_request_repo: "test-owner/ios-repo",
},
{
type: "assign_to_agent",
issue_number: "aw_multi_repo",
agent: "copilot",
pull_request_repo: "test-owner/android-repo",
},
],
errors: [],
});

mockGithub.graphql
// Item 1: get per-item PR repository ID
.mockResolvedValueOnce({
repository: {
id: "ios-repo-id",
},
})
// Item 1: find agent
.mockResolvedValueOnce({
repository: {
suggestedActors: {
nodes: [{ login: "copilot-swe-agent", id: "agent-id" }],
},
},
})
// Item 1: issue details (not assigned yet)
.mockResolvedValueOnce({
repository: {
issue: {
id: "issue-id",
assignees: {
nodes: [],
},
},
},
})
// Item 1: assignment mutation
.mockResolvedValueOnce({
replaceActorsForAssignable: {
__typename: "ReplaceActorsForAssignablePayload",
},
})
// Item 2: get per-item PR repository ID
.mockResolvedValueOnce({
repository: {
id: "android-repo-id",
},
})
// Item 2: issue details (already assigned after item 1)
.mockResolvedValueOnce({
repository: {
issue: {
id: "issue-id",
assignees: {
nodes: [{ id: "agent-id", login: "copilot-swe-agent" }],
},
},
},
})
// Item 2: assignment mutation should still run
.mockResolvedValueOnce({
replaceActorsForAssignable: {
__typename: "ReplaceActorsForAssignablePayload",
},
});

await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`);

expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("copilot is already assigned to issue #6587"));
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Successfully assigned copilot coding agent to issue #6587"));

const assignmentCalls = mockGithub.graphql.mock.calls.filter(([query]) => query.includes("replaceActorsForAssignable"));
expect(assignmentCalls).toHaveLength(2);
expect(assignmentCalls[0][1].targetRepoId).toBe("ios-repo-id");
expect(assignmentCalls[1][1].targetRepoId).toBe("android-repo-id");
}, 20000);
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test processes 2 assignment items in a single run; the implementation intentionally await sleep(10000) between consecutive assignments, so this adds a real 10s delay to the test suite and may become flaky under CI load with a 20s timeout. Recommend stubbing/mocking sleep (e.g., patching ./error_recovery.cjs export in require.cache before eval) or using fake timers, rather than paying the real delay.

This issue also appears on line 553 of the same file.

Copilot uses AI. Check for mistakes.

it("should avoid duplicate re-assignment for the same issue and same pull_request_repo in one run", async () => {
process.env.GH_AW_AGENT_MAX_COUNT = "5";
process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({
aw_duplicate: { repo: "test-owner/test-repo", number: 6587 },
});
process.env.GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS = "test-owner/ios-repo";

setAgentOutput({
items: [
{
type: "assign_to_agent",
issue_number: "aw_duplicate",
agent: "copilot",
pull_request_repo: "test-owner/ios-repo",
},
{
type: "assign_to_agent",
issue_number: "aw_duplicate",
agent: "copilot",
pull_request_repo: "test-owner/ios-repo",
},
],
errors: [],
});

mockGithub.graphql
// Item 1: get per-item PR repository ID
.mockResolvedValueOnce({
repository: {
id: "ios-repo-id",
},
})
// Item 1: find agent
.mockResolvedValueOnce({
repository: {
suggestedActors: {
nodes: [{ login: "copilot-swe-agent", id: "agent-id" }],
},
},
})
// Item 1: issue details (not assigned yet)
.mockResolvedValueOnce({
repository: {
issue: {
id: "issue-id",
assignees: {
nodes: [],
},
},
},
})
// Item 1: assignment mutation
.mockResolvedValueOnce({
replaceActorsForAssignable: {
__typename: "ReplaceActorsForAssignablePayload",
},
})
// Item 2: get per-item PR repository ID
.mockResolvedValueOnce({
repository: {
id: "ios-repo-id",
},
})
// Item 2: issue details (already assigned after item 1)
.mockResolvedValueOnce({
repository: {
issue: {
id: "issue-id",
assignees: {
nodes: [{ id: "agent-id", login: "copilot-swe-agent" }],
},
},
},
});

await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`);

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("copilot is already assigned to issue #6587"));
const assignmentCalls = mockGithub.graphql.mock.calls.filter(([query]) => query.includes("replaceActorsForAssignable"));
expect(assignmentCalls).toHaveLength(1);
}, 20000);

it("should still skip when agent is already assigned with global pull-request-repo but no per-item override", async () => {
process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/global-pr-repo";
setAgentOutput({
Expand Down
Loading