diff --git a/actions/setup/js/assign_agent_helpers.cjs b/actions/setup/js/assign_agent_helpers.cjs index 26e7666d2ff..24e314a197c 100644 --- a/actions/setup/js/assign_agent_helpers.cjs +++ b/actions/setup/js/assign_agent_helpers.cjs @@ -122,6 +122,19 @@ async function findAgent(owner, repo, agentName) { } catch (error) { const errorMessage = getErrorMessage(error); core.error(`Failed to find ${agentName} agent: ${errorMessage}`); + + // Re-throw authentication/permission errors so they can be handled by the caller + // This allows ignore-if-missing logic to work properly + if ( + errorMessage.includes("Bad credentials") || + errorMessage.includes("Not Authenticated") || + errorMessage.includes("Resource not accessible") || + errorMessage.includes("Insufficient permissions") || + errorMessage.includes("requires authentication") + ) { + throw error; + } + return null; } } diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index e3d79d26e1e..0f6384d7c07 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -50,6 +50,12 @@ async function main() { const targetConfig = process.env.GH_AW_AGENT_TARGET?.trim() || "triggering"; core.info(`Target configuration: ${targetConfig}`); + // Get ignore-if-error flag (defaults to false) + const ignoreIfError = process.env.GH_AW_AGENT_IGNORE_IF_ERROR === "true"; + if (ignoreIfError) { + core.info("Ignore-if-error mode enabled: Will not fail if agent assignment encounters errors"); + } + // Get allowed agents list (comma-separated) const allowedAgentsEnv = process.env.GH_AW_AGENT_ALLOWED?.trim(); const allowedAgents = allowedAgentsEnv @@ -264,6 +270,29 @@ async function main() { }); } catch (error) { let errorMessage = getErrorMessage(error); + + // Check if this is a token authentication error + const isAuthError = + errorMessage.includes("Bad credentials") || + errorMessage.includes("Not Authenticated") || + errorMessage.includes("Resource not accessible") || + errorMessage.includes("Insufficient permissions") || + errorMessage.includes("requires authentication"); + + // If ignore-if-error is enabled and this is an auth error, log warning and skip + 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}`); + results.push({ + issue_number: issueNumber, + pull_number: pullNumber, + agent: agentName, + success: true, // Treat as success when ignored + skipped: true, + }); + continue; + } + if (errorMessage.includes("coding agent is not available for this repository")) { // Enrich with available agent logins to aid troubleshooting - uses built-in github object try { @@ -287,15 +316,16 @@ async function main() { } // Generate step summary - const successCount = results.filter(r => r.success).length; - const failureCount = results.length - successCount; + const successCount = results.filter(r => r.success && !r.skipped).length; + const skippedCount = results.filter(r => r.skipped).length; + const failureCount = results.length - successCount - skippedCount; let summaryContent = "## Agent Assignment\n\n"; if (successCount > 0) { summaryContent += `✅ Successfully assigned ${successCount} agent(s):\n\n`; summaryContent += results - .filter(r => r.success) + .filter(r => r.success && !r.skipped) .map(r => { const itemType = r.issue_number ? `Issue #${r.issue_number}` : `Pull Request #${r.pull_number}`; return `- ${itemType} → Agent: ${r.agent}`; @@ -304,10 +334,22 @@ async function main() { summaryContent += "\n\n"; } + if (skippedCount > 0) { + summaryContent += `⏭️ Skipped ${skippedCount} agent assignment(s) (ignore-if-error enabled):\n\n`; + summaryContent += results + .filter(r => r.skipped) + .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)`; + }) + .join("\n"); + summaryContent += "\n\n"; + } + if (failureCount > 0) { summaryContent += `❌ Failed to assign ${failureCount} agent(s):\n\n`; summaryContent += results - .filter(r => !r.success) + .filter(r => !r.success && !r.skipped) .map(r => { const itemType = r.issue_number ? `Issue #${r.issue_number}` : `Pull Request #${r.pull_number}`; return `- ${itemType} → Agent: ${r.agent}: ${r.error}`; @@ -315,7 +357,7 @@ async function main() { .join("\n"); // Check if any failures were permission-related - const hasPermissionError = results.some(r => (!r.success && r.error?.includes("Resource not accessible")) || r.error?.includes("Insufficient permissions")); + const hasPermissionError = results.some(r => (!r.success && !r.skipped && r.error?.includes("Resource not accessible")) || r.error?.includes("Insufficient permissions")); if (hasPermissionError) { summaryContent += generatePermissionErrorSummary(); @@ -326,7 +368,7 @@ async function main() { // Set outputs const assignedAgents = results - .filter(r => r.success) + .filter(r => r.success && !r.skipped) .map(r => { const number = r.issue_number || r.pull_number; const prefix = r.issue_number ? "issue" : "pr"; @@ -337,7 +379,7 @@ async function main() { // Set assignment error output for failed assignments const assignmentErrors = results - .filter(r => !r.success) + .filter(r => !r.success && !r.skipped) .map(r => { const number = r.issue_number || r.pull_number; const prefix = r.issue_number ? "issue" : "pr"; @@ -347,7 +389,7 @@ async function main() { core.setOutput("assignment_errors", assignmentErrors); core.setOutput("assignment_error_count", failureCount.toString()); - // Fail if any assignments failed + // Fail if any assignments failed (but not if they were skipped) if (failureCount > 0) { core.setFailed(`Failed to assign ${failureCount} agent(s)`); } diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index 1191550e34d..05a75d51b2b 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -43,6 +43,10 @@ describe("assign_to_agent", () => { beforeEach(() => { vi.clearAllMocks(); + + // Reset mockGithub.graphql to ensure no lingering mock implementations + mockGithub.graphql = vi.fn(); + delete process.env.GH_AW_AGENT_OUTPUT; delete process.env.GH_AW_SAFE_OUTPUTS_STAGED; delete process.env.GH_AW_AGENT_DEFAULT; @@ -50,6 +54,7 @@ describe("assign_to_agent", () => { delete process.env.GH_AW_AGENT_TARGET; delete process.env.GH_AW_AGENT_ALLOWED; delete process.env.GH_AW_TARGET_REPO; + delete process.env.GH_AW_AGENT_IGNORE_IF_ERROR; // Reset context to default mockContext.eventName = "issues"; @@ -806,4 +811,115 @@ describe("assign_to_agent", () => { expect(mockCore.error).not.toHaveBeenCalled(); expect(mockCore.setFailed).not.toHaveBeenCalled(); }); + + it("should skip assignment and not fail when ignore-if-error is true and auth error occurs", 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 - use mockRejectedValueOnce to avoid affecting other tests + const authError = new Error("Bad credentials"); + mockGithub.graphql.mockRejectedValueOnce(authError); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + // Should log that ignore-if-error is enabled + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Ignore-if-error mode enabled: Will not fail if agent assignment encounters errors")); + + // Should warn about skipping but not fail + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Agent assignment failed")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("ignore-if-error=true")); + + // Should not fail the workflow + expect(mockCore.setFailed).not.toHaveBeenCalled(); + + // Summary should show skipped assignments + expect(mockCore.summary.addRaw).toHaveBeenCalled(); + const summaryCall = mockCore.summary.addRaw.mock.calls[0][0]; + expect(summaryCall).toContain("⏭️ Skipped"); + expect(summaryCall).toContain("assignment failed due to error"); + }); + + it("should fail when ignore-if-error is false (default) and auth error occurs", async () => { + // Don't set GH_AW_AGENT_IGNORE_IF_MISSING (defaults to false) + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + }, + ], + errors: [], + }); + + // Simulate authentication error + const authError = new Error("Bad credentials"); + mockGithub.graphql.mockRejectedValue(authError); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + // Should NOT log ignore-if-error mode + expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("ignore-if-error mode enabled")); + + // Should error and fail + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to assign agent")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to assign 1 agent(s)")); + }); + + it("should handle ignore-if-error when 'Resource not accessible' error", async () => { + process.env.GH_AW_AGENT_IGNORE_IF_ERROR = "true"; + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + }, + ], + errors: [], + }); + + // Simulate permission error + const permError = new Error("Resource not accessible by integration"); + mockGithub.graphql.mockRejectedValue(permError); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + // Should skip and not fail + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Agent assignment failed")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should still fail on non-auth errors even with ignore-if-error enabled", async () => { + process.env.GH_AW_AGENT_IGNORE_IF_MISSING = "true"; + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + }, + ], + errors: [], + }); + + // Simulate a different error (not auth-related) + const otherError = new Error("Network timeout"); + mockGithub.graphql.mockRejectedValue(otherError); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + // Should error and fail (not skipped because it's not an auth error) + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to assign agent")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to assign 1 agent(s)")); + }); }); diff --git a/actions/setup/js/types/safe-outputs-config.d.ts b/actions/setup/js/types/safe-outputs-config.d.ts index 9148908ca21..f1eb310512b 100644 --- a/actions/setup/js/types/safe-outputs-config.d.ts +++ b/actions/setup/js/types/safe-outputs-config.d.ts @@ -182,6 +182,7 @@ interface AssignToAgentConfig extends SafeOutputConfig { "default-agent"?: string; target?: string; "target-repo"?: string; + "ignore-if-error"?: boolean; } /** diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 23c1ea32126..82001886207 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4903,6 +4903,11 @@ "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository agent assignment. Takes precedence over trial target repo settings." }, + "ignore-if-error": { + "type": "boolean", + "description": "If true, the workflow continues gracefully when agent assignment fails (e.g., due to missing token or insufficient permissions), logging a warning instead of failing. Default is false. Useful for workflows that should not fail when agent assignment is optional.", + "default": false + }, "github-token": { "$ref": "#/$defs/github_token", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." diff --git a/pkg/workflow/assign_to_agent.go b/pkg/workflow/assign_to_agent.go index eafd3d1af20..743f3d2a3bc 100644 --- a/pkg/workflow/assign_to_agent.go +++ b/pkg/workflow/assign_to_agent.go @@ -10,8 +10,9 @@ var assignToAgentLog = logger.New("workflow:assign_to_agent") type AssignToAgentConfig struct { BaseSafeOutputConfig `yaml:",inline"` SafeOutputTargetConfig `yaml:",inline"` - DefaultAgent string `yaml:"name,omitempty"` // Default agent to assign (e.g., "copilot") - Allowed []string `yaml:"allowed,omitempty"` // Optional list of allowed agent names. If omitted, any agents are allowed. + DefaultAgent string `yaml:"name,omitempty"` // Default agent to assign (e.g., "copilot") + Allowed []string `yaml:"allowed,omitempty"` // Optional list of allowed agent names. If omitted, any agents are allowed. + IgnoreIfError bool `yaml:"ignore-if-error,omitempty"` // If true, workflow continues when agent assignment fails } // parseAssignToAgentConfig handles assign-to-agent configuration diff --git a/pkg/workflow/compiler_safe_outputs_specialized.go b/pkg/workflow/compiler_safe_outputs_specialized.go index 7f63477b0c0..f4743255a37 100644 --- a/pkg/workflow/compiler_safe_outputs_specialized.go +++ b/pkg/workflow/compiler_safe_outputs_specialized.go @@ -38,6 +38,11 @@ func (c *Compiler) buildAssignToAgentStepConfig(data *WorkflowData, mainJobName customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_AGENT_ALLOWED: %q\n", allowedStr)) } + // Add ignore-if-error flag if set + if cfg.IgnoreIfError { + customEnvVars = append(customEnvVars, " GH_AW_AGENT_IGNORE_IF_ERROR: \"true\"\n") + } + condition := BuildSafeOutputType("assign_to_agent") return SafeOutputStepConfig{