diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index aa407c4f91e..5697e233bfa 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -229,6 +229,8 @@ async function main() { issue_number: item.issue_number || null, pull_number: item.pull_number || null, agent: agentName, + owner: null, + repo: null, success: false, error: repoResult.error, }); @@ -248,6 +250,8 @@ async function main() { issue_number: item.issue_number, pull_number: item.pull_number ?? null, agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, success: false, error: resolvedTarget.errorMessage || `Failed to resolve issue target: ${item.issue_number}`, }); @@ -286,6 +290,8 @@ async function main() { issue_number: item.issue_number || null, pull_number: item.pull_number || null, agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, success: false, error: pullRequestRepoValidation.error, }); @@ -310,6 +316,8 @@ async function main() { issue_number: item.issue_number || null, pull_number: item.pull_number || null, agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, success: false, error: `Failed to fetch pull request repository ID for ${itemPullRequestRepo}`, }); @@ -338,6 +346,8 @@ async function main() { issue_number: item.issue_number || null, pull_number: item.pull_number || null, agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, success: false, error: targetResult.error, }); @@ -359,6 +369,8 @@ async function main() { issue_number: issueNumber, pull_number: pullNumber, agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, success: false, error: `Invalid ${type} number: ${number}`, }); @@ -372,6 +384,8 @@ async function main() { issue_number: issueNumber, pull_number: pullNumber, agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, success: false, error: `Unsupported agent: ${agentName}`, }); @@ -385,6 +399,8 @@ async function main() { issue_number: issueNumber, pull_number: pullNumber, agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, success: false, error: `Agent not allowed: ${agentName}`, }); @@ -438,6 +454,8 @@ async function main() { issue_number: issueNumber, pull_number: pullNumber, agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, success: true, }); continue; @@ -471,6 +489,8 @@ async function main() { issue_number: issueNumber, pull_number: pullNumber, agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, success: true, }); } catch (error) { @@ -487,6 +507,8 @@ async function main() { issue_number: issueNumber, pull_number: pullNumber, agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, success: true, // Treat as success when ignored skipped: true, }); @@ -509,6 +531,8 @@ async function main() { issue_number: issueNumber, pull_number: pullNumber, agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, success: false, error: errorMessage, }); @@ -573,6 +597,30 @@ async function main() { await core.summary.addRaw(summaryContent).write(); + // Post failure comments on each issue/PR that failed assignment. + // This is needed because the agent may have already posted an "assigned to agent" comment + // before the assignment step runs. If assignment fails, users need to see the actual failure + // status directly on their issue/PR, not just in the general failure tracking issue. + for (const r of results) { + if (r.success || r.skipped || !r.owner || !r.repo || (!r.issue_number && !r.pull_number)) { + continue; + } + const failedNumber = r.issue_number || r.pull_number; + const failedType = r.issue_number ? "issue" : "pull request"; + try { + await github.rest.issues.createComment({ + owner: r.owner, + repo: r.repo, + issue_number: failedNumber, + body: `⚠️ **Assignment failed**: Failed to assign ${r.agent} coding agent to this ${failedType}.\n\nError: ${r.error}`, + }); + core.info(`Posted failure comment on ${failedType} #${failedNumber} in ${r.owner}/${r.repo}`); + } catch (commentError) { + // Best-effort: log but don't fail the step if we can't post the comment + core.warning(`Failed to post failure comment on ${failedType} #${failedNumber}: ${getErrorMessage(commentError)}`); + } + } + // Set outputs const assignedAgents = results .filter(r => r.success && !r.skipped) diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index 47bfa8f2887..775820d50aa 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -24,6 +24,11 @@ const mockContext = { const mockGithub = { graphql: vi.fn(), + rest: { + issues: { + createComment: vi.fn().mockResolvedValue({ data: { id: 12345 } }), + }, + }, }; global.core = mockCore; @@ -47,6 +52,9 @@ describe("assign_to_agent", () => { // Reset mockGithub.graphql to ensure no lingering mock implementations mockGithub.graphql = vi.fn(); + // Reset mockGithub.rest.issues.createComment + mockGithub.rest.issues.createComment = vi.fn().mockResolvedValue({ data: { id: 12345 } }); + delete process.env.GH_AW_AGENT_OUTPUT; delete process.env.GH_AW_SAFE_OUTPUTS_STAGED; delete process.env.GH_AW_AGENT_DEFAULT; @@ -929,6 +937,16 @@ describe("assign_to_agent", () => { // Should error and fail expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to assign agent")); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to assign 1 agent(s)")); + + // Should post a failure comment on the issue with all required properties + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + issue_number: 42, + body: expect.stringMatching(/Assignment failed.*Bad credentials/s), + }) + ); }); it("should handle ignore-if-error when 'Resource not accessible' error", async () => { @@ -979,6 +997,119 @@ describe("assign_to_agent", () => { expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to assign 1 agent(s)")); }); + it("should not post failure comment on success", async () => { + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + }, + ], + errors: [], + }); + + mockGithub.graphql + .mockResolvedValueOnce({ + repository: { + suggestedActors: { + nodes: [{ login: "copilot-swe-agent", id: "MDQ6VXNlcjE=" }], + }, + }, + }) + .mockResolvedValueOnce({ + repository: { + issue: { + id: "I_abc123", + assignees: { nodes: [] }, + }, + }, + }) + .mockResolvedValueOnce({ + replaceActorsForAssignable: { __typename: "ReplaceActorsForAssignablePayload" }, + }); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + // Should NOT post a failure comment on success + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + }); + + it("should post failure comment on single failed assignment", async () => { + setAgentOutput({ + items: [{ type: "assign_to_agent", issue_number: 11, agent: "copilot" }], + errors: [], + }); + + // Fail all assignments with auth error + const authError = new Error("Bad credentials"); + mockGithub.graphql.mockRejectedValue(authError); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + // Should post a failure comment for the failed issue with all required properties + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledTimes(1); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + issue_number: 11, + body: expect.stringMatching(/Assignment failed.*Bad credentials/s), + }) + ); + }); + + it("should not post failure comment when ignore-if-error skips the assignment", async () => { + process.env.GH_AW_AGENT_IGNORE_IF_ERROR = "true"; + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + }, + ], + errors: [], + }); + + // Simulate authentication error (will be skipped by ignore-if-error) + const authError = new Error("Bad credentials"); + mockGithub.graphql.mockRejectedValue(authError); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + // Should NOT post a failure comment since it was skipped + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + }); + + it("should still set outputs and log warning when failure comment post fails", async () => { + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + }, + ], + errors: [], + }); + + const authError = new Error("Bad credentials"); + mockGithub.graphql.mockRejectedValue(authError); + + // Simulate failure to post comment + mockGithub.rest.issues.createComment.mockRejectedValue(new Error("Could not post comment")); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + // Should still set the assignment_error outputs even if comment fails + expect(mockCore.setOutput).toHaveBeenCalledWith("assignment_error_count", "1"); + expect(mockCore.setOutput).toHaveBeenCalledWith("assignment_errors", expect.stringContaining("Bad credentials")); + + // Should warn about failure to post comment (best-effort) + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to post failure comment")); + }); + it.skip("should add 10-second delay between multiple agent assignments", async () => { // Note: This test is skipped because testing actual delays with eval() is complex. // The implementation has been manually verified to include the delay logic.