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
13 changes: 13 additions & 0 deletions actions/setup/js/assign_agent_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
58 changes: 50 additions & 8 deletions actions/setup/js/assign_to_agent.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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}`;
Expand All @@ -304,18 +334,30 @@ 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}`;
})
.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();
Expand All @@ -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";
Expand All @@ -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";
Expand All @@ -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)`);
}
Expand Down
116 changes: 116 additions & 0 deletions actions/setup/js/assign_to_agent.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,18 @@ 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;
delete process.env.GH_AW_AGENT_MAX_COUNT;
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";
Expand Down Expand Up @@ -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)"));
});
});
1 change: 1 addition & 0 deletions actions/setup/js/types/safe-outputs-config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ interface AssignToAgentConfig extends SafeOutputConfig {
"default-agent"?: string;
target?: string;
"target-repo"?: string;
"ignore-if-error"?: boolean;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
5 changes: 3 additions & 2 deletions pkg/workflow/assign_to_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions pkg/workflow/compiler_safe_outputs_specialized.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading