From a49f1ca3540bb1590a256acdb7c91ae941cc930c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:44:22 +0000 Subject: [PATCH 1/6] Initial plan From a487ae7a7494e01a41ce7b5f3077658b531aedc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:19:05 +0000 Subject: [PATCH 2/6] fix(assign_to_agent): allow repeated issue assignments in multi-repo flows Agent-Logs-Url: https://github.com/github/gh-aw/sessions/35aa1b6a-6d55-47e5-b040-3f7167d9bf21 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_to_agent.cjs | 7 +- actions/setup/js/assign_to_agent.test.cjs | 92 +++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index 8a308cea146..0512d908327 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -128,6 +128,7 @@ async function main(config = {}) { // Closure-level state let processedCount = 0; const agentCache = {}; + const seenAssignmentTargets = new Set(); // Reset module-level results for this handler invocation _allResults = []; @@ -291,6 +292,9 @@ async function main(config = {}) { const type = targetResult.contextType; const issueNumber = type === "issue" ? number : null; const pullNumber = type === "pull request" ? number : null; + const assignmentTargetKey = `${effectiveOwner}/${effectiveRepo}:${type}:${number}`; + const seenThisTargetBefore = seenAssignmentTargets.has(assignmentTargetKey); + seenAssignmentTargets.add(assignmentTargetKey); if (isNaN(number) || number <= 0) { const error = `Invalid ${type} number: ${number}`; @@ -352,11 +356,12 @@ async function main(config = {}) { core.info(`${type} ID: ${assignableId}`); const hasPerItemPullRequestRepoOverride = !!message.pull_request_repo; + const hasExplicitReassignmentIntent = hasPerItemPullRequestRepoOverride || seenThisTargetBefore; // 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) && !hasExplicitReassignmentIntent) { 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 }; diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index 2492d7b5c94..60aa5b97f07 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -458,6 +458,98 @@ 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); + 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({ From 0bef8acde901e9608e996f3ad5c353ae2f6d229f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:30:03 +0000 Subject: [PATCH 3/6] Changes before error encountered Agent-Logs-Url: https://github.com/github/gh-aw/sessions/35aa1b6a-6d55-47e5-b040-3f7167d9bf21 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_to_agent.cjs | 15 +++-- actions/setup/js/assign_to_agent.test.cjs | 82 +++++++++++++++++++++++ 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index 0512d908327..725c171c7bb 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -128,7 +128,7 @@ async function main(config = {}) { // Closure-level state let processedCount = 0; const agentCache = {}; - const seenAssignmentTargets = new Set(); + const processedAssignmentTargets = new Set(); // Reset module-level results for this handler invocation _allResults = []; @@ -292,9 +292,6 @@ async function main(config = {}) { const type = targetResult.contextType; const issueNumber = type === "issue" ? number : null; const pullNumber = type === "pull request" ? number : null; - const assignmentTargetKey = `${effectiveOwner}/${effectiveRepo}:${type}:${number}`; - const seenThisTargetBefore = seenAssignmentTargets.has(assignmentTargetKey); - seenAssignmentTargets.add(assignmentTargetKey); if (isNaN(number) || number <= 0) { const error = `Invalid ${type} number: ${number}`; @@ -356,12 +353,18 @@ async function main(config = {}) { core.info(`${type} ID: ${assignableId}`); const hasPerItemPullRequestRepoOverride = !!message.pull_request_repo; - const hasExplicitReassignmentIntent = hasPerItemPullRequestRepoOverride || seenThisTargetBefore; + 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 + // 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) && !hasExplicitReassignmentIntent) { + if (currentAssignees.some(a => a.id === agentId) && !shouldAllowReassignment) { 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 }; diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index 60aa5b97f07..d79f4cd2c09 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -550,6 +550,88 @@ describe("assign_to_agent", () => { expect(assignmentCalls[1][1].targetRepoId).toBe("android-repo-id"); }, 20000); + 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({ From a1118608b7664b57535f23fa14a048554b9d81ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:06:25 +0000 Subject: [PATCH 4/6] fix(assign_to_agent): address review feedback for multi-repo assignment Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d2b4077d-7102-41a5-930e-a274fbe9e182 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_to_agent.cjs | 35 ++++++----- actions/setup/js/assign_to_agent.test.cjs | 71 ++++++++++++++++++++++- 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index 725c171c7bb..ed97f5548e2 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -15,7 +15,7 @@ const { sanitizeContent } = require("./sanitize_content.cjs"); * Module-level state — populated by main(), read by the exported getters below. * Using module-level variables (rather than closure-only state) allows the handler * manager to read final output values after all messages have been processed. - * @type {Array<{issue_number: number|null, pull_number: number|null, agent: string, owner: string|null, repo: string|null, success: boolean, skipped?: boolean, error?: string}>} + * @type {Array<{issue_number: number|null, pull_number: number|null, agent: string, owner: string|null, repo: string|null, pull_request_repo?: string|null, success: boolean, skipped?: boolean, error?: string}>} */ let _allResults = []; @@ -233,10 +233,15 @@ async function main(config = {}) { const hasExplicitTarget = itemForTarget.issue_number != null || itemForTarget.pull_number != null; const effectiveTarget = hasExplicitTarget ? "*" : targetConfig; + const defaultPullRequestRepoSlug = pullRequestOwner && pullRequestRepo ? `${pullRequestOwner}/${pullRequestRepo}` : `${effectiveOwner}/${effectiveRepo}`; + // Handle per-item pull_request_repo override let effectivePullRequestRepoId = pullRequestRepoId; - if (message.pull_request_repo) { - const itemPullRequestRepo = String(message.pull_request_repo).trim(); + let effectivePullRequestRepoSlug = defaultPullRequestRepoSlug; + let hasValidatedPerItemPullRequestRepoOverride = false; + const rawPullRequestRepoOverride = typeof message.pull_request_repo === "string" ? message.pull_request_repo.trim() : ""; + if (rawPullRequestRepoOverride) { + const itemPullRequestRepo = rawPullRequestRepoOverride; const pullRequestRepoParts = itemPullRequestRepo.split("/"); if (pullRequestRepoParts.length === 2) { const defaultPullRequestRepo = pullRequestRepoConfig || defaultTargetRepo; @@ -255,6 +260,8 @@ async function main(config = {}) { `; const itemPullRequestRepoResponse = await githubClient.graphql(itemPullRequestRepoQuery, { owner: pullRequestRepoParts[0], name: pullRequestRepoParts[1] }); effectivePullRequestRepoId = itemPullRequestRepoResponse.repository.id; + effectivePullRequestRepoSlug = itemPullRequestRepo; + hasValidatedPerItemPullRequestRepoOverride = true; core.info(`Using per-item pull request repository: ${itemPullRequestRepo} (ID: ${effectivePullRequestRepoId})`); } catch (error) { const errorMsg = `Failed to fetch pull request repository ID for ${itemPullRequestRepo}: ${getErrorMessage(error)}`; @@ -265,6 +272,8 @@ async function main(config = {}) { } else { core.warning(`Invalid pull_request_repo format: ${itemPullRequestRepo}. Expected owner/repo. Using global pull-request-repo if configured.`); } + } else if (message.pull_request_repo != null && message.pull_request_repo !== "") { + core.warning("Invalid pull_request_repo value. Expected a non-empty owner/repo string. Using global pull-request-repo if configured."); } // Resolve the target issue or pull request number from context @@ -352,21 +361,19 @@ 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 assignmentContextKey = `${effectiveOwner}/${effectiveRepo}:${type}:${number}:${effectivePullRequestRepoSlug}`; const seenThisContextBefore = processedAssignmentTargets.has(assignmentContextKey); // Track assignment context (target + per-item pull_request_repo) to prevent duplicate // re-assignment calls while still allowing one global issue to fan out to multiple repos. processedAssignmentTargets.add(assignmentContextKey); - const shouldAllowReassignment = hasPerItemPullRequestRepoOverride && !seenThisContextBefore; + const shouldAllowReassignment = hasValidatedPerItemPullRequestRepoOverride && !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) && !shouldAllowReassignment) { 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 }); + _allResults.push({ issue_number: issueNumber, pull_number: pullNumber, agent: agentName, owner: effectiveOwner, repo: effectiveRepo, pull_request_repo: effectivePullRequestRepoSlug, success: true }); return { success: true }; } @@ -380,7 +387,7 @@ async function main(config = {}) { if (!success) throw new Error(`Failed to assign ${agentName} via GraphQL`); core.info(`Successfully assigned ${agentName} coding agent to ${type} #${number}`); - _allResults.push({ issue_number: issueNumber, pull_number: pullNumber, agent: agentName, owner: effectiveOwner, repo: effectiveRepo, success: true }); + _allResults.push({ issue_number: issueNumber, pull_number: pullNumber, agent: agentName, owner: effectiveOwner, repo: effectiveRepo, pull_request_repo: effectivePullRequestRepoSlug, success: true }); return { success: true }; } catch (error) { let errorMessage = getErrorMessage(error); @@ -390,7 +397,7 @@ async function main(config = {}) { if (ignoreIfError && isAuthError) { core.warning(`Agent assignment failed for ${agentName} on ${type} #${number} due to authentication/permission error. Skipping due to ignore-if-error=true.`); core.info(`Error details: ${errorMessage}`); - _allResults.push({ issue_number: issueNumber, pull_number: pullNumber, agent: agentName, owner: effectiveOwner, repo: effectiveRepo, success: true, skipped: true }); + _allResults.push({ issue_number: issueNumber, pull_number: pullNumber, agent: agentName, owner: effectiveOwner, repo: effectiveRepo, pull_request_repo: effectivePullRequestRepoSlug, success: true, skipped: true }); return { success: true, skipped: true }; } @@ -418,7 +425,7 @@ async function main(config = {}) { core.warning(`Failed to post failure comment on ${type} #${number}: ${getErrorMessage(commentError)}`); } - _allResults.push({ issue_number: issueNumber, pull_number: pullNumber, agent: agentName, owner: effectiveOwner, repo: effectiveRepo, success: false, error: errorMessage }); + _allResults.push({ issue_number: issueNumber, pull_number: pullNumber, agent: agentName, owner: effectiveOwner, repo: effectiveRepo, pull_request_repo: effectivePullRequestRepoSlug, success: false, error: errorMessage }); return { success: false, error: errorMessage }; } }; @@ -483,7 +490,7 @@ async function writeAssignToAgentSummary() { summaryContent += successResults .map(r => { const itemType = r.issue_number ? `Issue #${r.issue_number}` : `Pull Request #${r.pull_number}`; - return `- ${itemType} → Agent: ${r.agent}`; + return `- ${itemType} → Agent: ${r.agent}${r.pull_request_repo ? ` (PR target: ${r.pull_request_repo})` : ""}`; }) .join("\n"); summaryContent += "\n\n"; @@ -494,7 +501,7 @@ async function writeAssignToAgentSummary() { summaryContent += skippedResults .map(r => { const itemType = r.issue_number ? `Issue #${r.issue_number}` : `Pull Request #${r.pull_number}`; - return `- ${itemType} → Agent: ${r.agent} (assignment failed due to error)`; + return `- ${itemType} → Agent: ${r.agent}${r.pull_request_repo ? ` (PR target: ${r.pull_request_repo})` : ""} (assignment failed due to error)`; }) .join("\n"); summaryContent += "\n\n"; @@ -505,7 +512,7 @@ async function writeAssignToAgentSummary() { summaryContent += failedResults .map(r => { const itemType = r.issue_number ? `Issue #${r.issue_number}` : `Pull Request #${r.pull_number}`; - return `- ${itemType} → Agent: ${r.agent}: ${r.error}`; + return `- ${itemType} → Agent: ${r.agent}${r.pull_request_repo ? ` (PR target: ${r.pull_request_repo})` : ""}: ${r.error}`; }) .join("\n"); diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index d79f4cd2c09..77ff8362689 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -38,6 +38,8 @@ global.github = mockGithub; describe("assign_to_agent", () => { let assignToAgentScript; let tempFilePath; + let errorRecoveryModulePath; + const mockSleep = vi.fn().mockResolvedValue(); // Simulates the safe-output handler manager: builds handler config from env vars, // calls main() as a factory, then processes items from GH_AW_AGENT_OUTPUT. @@ -92,6 +94,7 @@ describe("assign_to_agent", () => { beforeEach(() => { vi.clearAllMocks(); + mockSleep.mockClear(); // Reset mockGithub.graphql to ensure no lingering mock implementations mockGithub.graphql = vi.fn(); @@ -122,6 +125,14 @@ describe("assign_to_agent", () => { // Clear module cache to ensure we get the latest version of assign_agent_helpers const helpersPath = require.resolve("./assign_agent_helpers.cjs"); delete require.cache[helpersPath]; + errorRecoveryModulePath = require.resolve("./error_recovery.cjs"); + delete require.cache[errorRecoveryModulePath]; + require.cache[errorRecoveryModulePath] = { + id: errorRecoveryModulePath, + filename: errorRecoveryModulePath, + loaded: true, + exports: { sleep: mockSleep }, + }; const scriptPath = path.join(process.cwd(), "assign_to_agent.cjs"); assignToAgentScript = fs.readFileSync(scriptPath, "utf8"); @@ -131,6 +142,9 @@ describe("assign_to_agent", () => { if (tempFilePath && fs.existsSync(tempFilePath)) { fs.unlinkSync(tempFilePath); } + if (errorRecoveryModulePath) { + delete require.cache[errorRecoveryModulePath]; + } }); it("should handle empty agent output", async () => { @@ -548,7 +562,13 @@ describe("assign_to_agent", () => { expect(assignmentCalls).toHaveLength(2); expect(assignmentCalls[0][1].targetRepoId).toBe("ios-repo-id"); expect(assignmentCalls[1][1].targetRepoId).toBe("android-repo-id"); - }, 20000); + expect(mockSleep).toHaveBeenCalledTimes(1); + expect(mockSleep).toHaveBeenCalledWith(10000); + + const summaryCall = mockCore.summary.addRaw.mock.calls[0][0]; + expect(summaryCall).toContain("PR target: test-owner/ios-repo"); + expect(summaryCall).toContain("PR target: test-owner/android-repo"); + }); 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"; @@ -630,7 +650,50 @@ describe("assign_to_agent", () => { 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); + expect(mockSleep).toHaveBeenCalledTimes(1); + expect(mockSleep).toHaveBeenCalledWith(10000); + }); + + it("should not treat whitespace pull_request_repo as a reassignment override", async () => { + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + pull_request_repo: " ", + }, + ], + errors: [], + }); + + mockGithub.graphql + // Find agent + .mockResolvedValueOnce({ + repository: { + suggestedActors: { + nodes: [{ login: "copilot-swe-agent", id: "agent-id" }], + }, + }, + }) + // Get issue details - already assigned + .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 #42")); + const assignmentCalls = mockGithub.graphql.mock.calls.filter(([query]) => query.includes("replaceActorsForAssignable")); + expect(assignmentCalls).toHaveLength(0); + }); 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"; @@ -1530,7 +1593,9 @@ describe("assign_to_agent", () => { // Verify delay message was logged twice (2 delays between 3 items) const delayMessages = mockCore.info.mock.calls.filter(call => call[0].includes("Waiting 10 seconds before processing next agent assignment")); expect(delayMessages).toHaveLength(2); - }, 30000); // Increase timeout to 30 seconds to account for 2x10s delays + expect(mockSleep).toHaveBeenCalledTimes(2); + expect(mockSleep).toHaveBeenCalledWith(10000); + }); describe("Cross-repository allowlist validation", () => { it("should reject target repository not in allowlist", async () => { From 5b6d98587d388a6b0404cdd475b9ccfc610ba718 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:09:33 +0000 Subject: [PATCH 5/6] test(assign_to_agent): remove real delays and harden per-item repo override handling Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d2b4077d-7102-41a5-930e-a274fbe9e182 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_to_agent.cjs | 6 ++++-- actions/setup/js/assign_to_agent.test.cjs | 16 ++++------------ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index ed97f5548e2..1d272f702eb 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -233,11 +233,11 @@ async function main(config = {}) { const hasExplicitTarget = itemForTarget.issue_number != null || itemForTarget.pull_number != null; const effectiveTarget = hasExplicitTarget ? "*" : targetConfig; - const defaultPullRequestRepoSlug = pullRequestOwner && pullRequestRepo ? `${pullRequestOwner}/${pullRequestRepo}` : `${effectiveOwner}/${effectiveRepo}`; + const basePullRequestRepoSlug = pullRequestOwner && pullRequestRepo ? `${pullRequestOwner}/${pullRequestRepo}` : `${effectiveOwner}/${effectiveRepo}`; // Handle per-item pull_request_repo override let effectivePullRequestRepoId = pullRequestRepoId; - let effectivePullRequestRepoSlug = defaultPullRequestRepoSlug; + let effectivePullRequestRepoSlug = basePullRequestRepoSlug; let hasValidatedPerItemPullRequestRepoOverride = false; const rawPullRequestRepoOverride = typeof message.pull_request_repo === "string" ? message.pull_request_repo.trim() : ""; if (rawPullRequestRepoOverride) { @@ -272,6 +272,8 @@ async function main(config = {}) { } else { core.warning(`Invalid pull_request_repo format: ${itemPullRequestRepo}. Expected owner/repo. Using global pull-request-repo if configured.`); } + } else if (typeof message.pull_request_repo === "string" && message.pull_request_repo.trim() === "") { + core.warning("Invalid pull_request_repo value. Expected owner/repo. Using global pull-request-repo if configured."); } else if (message.pull_request_repo != null && message.pull_request_repo !== "") { core.warning("Invalid pull_request_repo value. Expected a non-empty owner/repo string. Using global pull-request-repo if configured."); } diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index 77ff8362689..95de8eb3977 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -38,7 +38,7 @@ global.github = mockGithub; describe("assign_to_agent", () => { let assignToAgentScript; let tempFilePath; - let errorRecoveryModulePath; + let sleepSpy; const mockSleep = vi.fn().mockResolvedValue(); // Simulates the safe-output handler manager: builds handler config from env vars, @@ -125,14 +125,8 @@ describe("assign_to_agent", () => { // Clear module cache to ensure we get the latest version of assign_agent_helpers const helpersPath = require.resolve("./assign_agent_helpers.cjs"); delete require.cache[helpersPath]; - errorRecoveryModulePath = require.resolve("./error_recovery.cjs"); - delete require.cache[errorRecoveryModulePath]; - require.cache[errorRecoveryModulePath] = { - id: errorRecoveryModulePath, - filename: errorRecoveryModulePath, - loaded: true, - exports: { sleep: mockSleep }, - }; + const errorRecovery = require("./error_recovery.cjs"); + sleepSpy = vi.spyOn(errorRecovery, "sleep").mockImplementation(mockSleep); const scriptPath = path.join(process.cwd(), "assign_to_agent.cjs"); assignToAgentScript = fs.readFileSync(scriptPath, "utf8"); @@ -142,9 +136,7 @@ describe("assign_to_agent", () => { if (tempFilePath && fs.existsSync(tempFilePath)) { fs.unlinkSync(tempFilePath); } - if (errorRecoveryModulePath) { - delete require.cache[errorRecoveryModulePath]; - } + sleepSpy?.mockRestore(); }); it("should handle empty agent output", async () => { From c99393d5daab014c75c70e3237770d900b6270c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:10:49 +0000 Subject: [PATCH 6/6] refactor(assign_to_agent): clarify pull_request_repo override validation Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d2b4077d-7102-41a5-930e-a274fbe9e182 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_to_agent.cjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index 1d272f702eb..7c6ad87d590 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -239,9 +239,10 @@ async function main(config = {}) { let effectivePullRequestRepoId = pullRequestRepoId; let effectivePullRequestRepoSlug = basePullRequestRepoSlug; let hasValidatedPerItemPullRequestRepoOverride = false; - const rawPullRequestRepoOverride = typeof message.pull_request_repo === "string" ? message.pull_request_repo.trim() : ""; - if (rawPullRequestRepoOverride) { - const itemPullRequestRepo = rawPullRequestRepoOverride; + const hasPullRequestRepoOverrideField = message.pull_request_repo != null; + const trimmedPullRequestRepoOverride = typeof message.pull_request_repo === "string" ? message.pull_request_repo.trim() : ""; + if (trimmedPullRequestRepoOverride) { + const itemPullRequestRepo = trimmedPullRequestRepoOverride; const pullRequestRepoParts = itemPullRequestRepo.split("/"); if (pullRequestRepoParts.length === 2) { const defaultPullRequestRepo = pullRequestRepoConfig || defaultTargetRepo; @@ -272,9 +273,9 @@ async function main(config = {}) { } else { core.warning(`Invalid pull_request_repo format: ${itemPullRequestRepo}. Expected owner/repo. Using global pull-request-repo if configured.`); } - } else if (typeof message.pull_request_repo === "string" && message.pull_request_repo.trim() === "") { + } else if (hasPullRequestRepoOverrideField && typeof message.pull_request_repo === "string") { core.warning("Invalid pull_request_repo value. Expected owner/repo. Using global pull-request-repo if configured."); - } else if (message.pull_request_repo != null && message.pull_request_repo !== "") { + } else if (hasPullRequestRepoOverrideField) { core.warning("Invalid pull_request_repo value. Expected a non-empty owner/repo string. Using global pull-request-repo if configured."); }