Skip to content
Closed
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
83 changes: 75 additions & 8 deletions actions/setup/js/handle_agent_failure.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -660,31 +660,98 @@ function buildLockdownCheckFailedContext(hasLockdownCheckFailed) {

/**
* Build a context string when assigning the Copilot coding agent to created issues failed.
* Uses progressive disclosure via <details> sections to present actionable information.
* @param {boolean} hasAssignCopilotFailures - Whether any copilot assignments failed
* @param {string} assignCopilotErrors - Newline-separated list of "issue:number:copilot:error" entries
* @param {string} [runUrl] - URL of the current workflow run for the footer link
* @returns {string} Formatted context string, or empty string if no failures
*/
function buildAssignCopilotFailureContext(hasAssignCopilotFailures, assignCopilotErrors) {
function buildAssignCopilotFailureContext(hasAssignCopilotFailures, assignCopilotErrors, runUrl = "") {
if (!hasAssignCopilotFailures) {
return "";
}

// Build a list of failed issue assignments
let issueList = "";
// Parse and categorize errors
const failedIssues = [];
let hasAvailabilityErrors = false;
let hasPermissionErrors = false;
if (assignCopilotErrors) {
const errorLines = assignCopilotErrors.split("\n").filter(line => line.trim());
for (const errorLine of errorLines) {
const parts = errorLine.split(":");
if (parts.length >= 4) {
const number = parts[1];
const error = parts.slice(3).join(":"); // Rest is the error message
issueList += `- Issue #${number}: ${error}\n`;
failedIssues.push({ number, error });
if (error.includes("not available") || error.includes("ERR_PERMISSION")) {
hasAvailabilityErrors = true;
}
if (error.includes("Forbidden") || error.includes("Insufficient permissions") || error.includes("Resource not accessible")) {
hasPermissionErrors = true;
}
}
}
}

const templatePath = `${process.env.RUNNER_TEMP}/gh-aw/prompts/assign_copilot_to_created_issues_failure.md`;
return "\n" + renderTemplateFromFile(templatePath, { issues: issueList });
const agentLogin = "copilot-swe-agent";
const failureCount = failedIssues.length || 1; // Fallback to 1 if parsing produced no items

let context = `\n**⚠️ Copilot Agent Assignment Failed**: Could not assign the Copilot coding agent to ${failureCount} issue(s).\n`;

// Progressive disclosure: failed assignments list
if (failedIssues.length > 0) {
context += `\n<details>\n<summary>📋 ${failureCount} failed assignment(s)</summary>\n\n`;
for (const { number, error } of failedIssues) {
context += `- Issue #${number}: \`${error}\`\n`;
}
context += "\n</details>\n";
}

// Manual assignment instructions
context += `\n<details>\n<summary>🔧 Assign manually</summary>\n\nOpen each affected issue and assign \`@${agentLogin}\` as the assignee, or use the GitHub CLI:\n\n`;
context += "```bash\n";
if (failedIssues.length > 0) {
for (const { number } of failedIssues) {
context += `gh issue edit ${number} --add-assignee ${agentLogin}\n`;
}
} else {
context += `gh issue edit <issue-number> --add-assignee ${agentLogin}\n`;
}
context += "```\n\n</details>\n";

// Error-specific guidance
context += "\n<details>\n<summary>💡 Possible causes</summary>\n\n";
if (hasAvailabilityErrors) {
context += `**Copilot coding agent not available** (\`@${agentLogin}\` was not found as an assignable actor):\n\n`;
context += "- Copilot coding agent may not be enabled for this repository\n";
context += "- The `GH_AW_AGENT_TOKEN` may belong to an account without a Copilot subscription\n\n";
context += "Go to **Settings → Copilot** in your repository to enable the Copilot coding agent.\n\n";
}
if (hasPermissionErrors) {
context += "**Permission error** — the token may lack required access:\n\n";
context += "- Verify `GH_AW_AGENT_TOKEN` is set in repository secrets\n";
context += "- The token must have `issues: write` permission\n";
context += "- The token must belong to a user with an active Copilot subscription\n\n";
context += '```bash\ngh aw secrets set GH_AW_AGENT_TOKEN --value "YOUR_TOKEN"\n```\n\n';
}
if (!hasAvailabilityErrors && !hasPermissionErrors) {
context += "Common causes:\n\n";
context += "- The `GH_AW_AGENT_TOKEN` secret is missing or expired\n";
context += "- The Copilot coding agent is not enabled for this repository\n";
context += "- The token lacks `issues: write` permission\n";
context += "- GitHub API rate limiting\n\n";
}
context += "See: [gh-aw authentication reference](https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/auth.mdx)\n\n";
context += "</details>\n";

// Footer link to the workflow run
if (runUrl) {
const runIdMatch = runUrl.match(/\/actions\/runs\/(\d+)/);
const runId = runIdMatch ? runIdMatch[1] : "";
context += `\n> [View workflow run${runId ? ` #${runId}` : ""}](${runUrl})\n`;
}

return context;
}

/**
Expand Down Expand Up @@ -1046,7 +1113,7 @@ async function main() {
const lockdownCheckFailedContext = buildLockdownCheckFailedContext(hasLockdownCheckFailed);

// Build copilot assignment failure context for created issues
const assignCopilotFailureContext = buildAssignCopilotFailureContext(hasAssignCopilotFailures, assignCopilotErrors);
const assignCopilotFailureContext = buildAssignCopilotFailureContext(hasAssignCopilotFailures, assignCopilotErrors, runUrl);

// Create template context
const templateContext = {
Expand Down Expand Up @@ -1187,7 +1254,7 @@ async function main() {
const lockdownCheckFailedContext = buildLockdownCheckFailedContext(hasLockdownCheckFailed);

// Build copilot assignment failure context for created issues
const assignCopilotFailureContext = buildAssignCopilotFailureContext(hasAssignCopilotFailures, assignCopilotErrors);
const assignCopilotFailureContext = buildAssignCopilotFailureContext(hasAssignCopilotFailures, assignCopilotErrors, runUrl);

// Create template context with sanitized workflow name
const templateContext = {
Expand Down
107 changes: 106 additions & 1 deletion actions/setup/js/handle_agent_failure.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const require = createRequire(import.meta.url);
describe("handle_agent_failure", () => {
let buildCodePushFailureContext;
let buildPushRepoMemoryFailureContext;
let buildAssignCopilotFailureContext;

beforeEach(() => {
// Provide minimal GitHub Actions globals expected by require-time code
Expand All @@ -24,7 +25,7 @@ describe("handle_agent_failure", () => {

// Reset module registry so each test gets a fresh require
vi.resetModules();
({ buildCodePushFailureContext, buildPushRepoMemoryFailureContext } = require("./handle_agent_failure.cjs"));
({ buildCodePushFailureContext, buildPushRepoMemoryFailureContext, buildAssignCopilotFailureContext } = require("./handle_agent_failure.cjs"));
});

afterEach(() => {
Expand Down Expand Up @@ -443,4 +444,108 @@ describe("handle_agent_failure", () => {
expect(result).toContain("55");
});
});

// ──────────────────────────────────────────────────────
// buildAssignCopilotFailureContext
// ──────────────────────────────────────────────────────

describe("buildAssignCopilotFailureContext", () => {
it("returns empty string when no failures", () => {
expect(buildAssignCopilotFailureContext(false, "")).toBe("");
expect(buildAssignCopilotFailureContext(false, null)).toBe("");
expect(buildAssignCopilotFailureContext(false, "issue:42:copilot:some error")).toBe("");
});

it("returns formatted message when there are failures", () => {
const result = buildAssignCopilotFailureContext(true, "issue:42:copilot:some error");
expect(result).toContain("Copilot Agent Assignment Failed");
expect(result).toContain("#42");
});

it("includes failed assignment details in a collapsible section", () => {
const result = buildAssignCopilotFailureContext(true, "issue:42:copilot:some error");
expect(result).toContain("<details>");
expect(result).toContain("<summary>");
expect(result).toContain("failed assignment");
expect(result).toContain("#42");
expect(result).toContain("some error");
});

it("includes manual assignment instructions with copilot-swe-agent login", () => {
const result = buildAssignCopilotFailureContext(true, "issue:42:copilot:some error");
expect(result).toContain("copilot-swe-agent");
expect(result).toContain("gh issue edit");
expect(result).toContain("--add-assignee");
});

it("includes possible causes section", () => {
const result = buildAssignCopilotFailureContext(true, "issue:42:copilot:some error");
expect(result).toContain("Possible causes");
expect(result).toContain("GH_AW_AGENT_TOKEN");
});

it("shows availability error guidance when agent is not available", () => {
const errors = "issue:42:copilot:ERR_PERMISSION: copilot coding agent is not available for this repository";
const result = buildAssignCopilotFailureContext(true, errors);
expect(result).toContain("not available");
expect(result).toContain("Settings → Copilot");
});

it("shows permission error guidance when token has insufficient permissions", () => {
const errors = "issue:42:copilot:Forbidden";
const result = buildAssignCopilotFailureContext(true, errors);
expect(result).toContain("Permission error");
expect(result).toContain("issues: write");
expect(result).toContain("gh aw secrets set GH_AW_AGENT_TOKEN");
});

it("shows permission error guidance for Resource not accessible error", () => {
const errors = "issue:42:copilot:Resource not accessible by integration";
const result = buildAssignCopilotFailureContext(true, errors);
expect(result).toContain("Permission error");
});

it("shows generic causes for unrecognized errors", () => {
const errors = "issue:42:copilot:ERR_API: Failed to get issue details";
const result = buildAssignCopilotFailureContext(true, errors);
expect(result).toContain("Common causes");
expect(result).toContain("rate limiting");
});

it("includes a link to the authentication reference docs", () => {
const result = buildAssignCopilotFailureContext(true, "issue:42:copilot:some error");
expect(result).toContain("gh-aw authentication reference");
});

it("includes a footer link when runUrl is provided", () => {
const result = buildAssignCopilotFailureContext(true, "issue:42:copilot:some error", "https://github.com/owner/repo/actions/runs/12345678");
expect(result).toContain("https://github.com/owner/repo/actions/runs/12345678");
expect(result).toContain("#12345678");
});

it("includes footer link without run ID when URL does not contain a run ID", () => {
const result = buildAssignCopilotFailureContext(true, "issue:42:copilot:some error", "https://example.com/run");
expect(result).toContain("https://example.com/run");
expect(result).toContain("View workflow run");
});

it("omits footer link when runUrl is not provided", () => {
const result = buildAssignCopilotFailureContext(true, "issue:42:copilot:some error");
expect(result).not.toContain("View workflow run");
});

it("handles multiple failed issues", () => {
const errors = ["issue:42:copilot:some error", "issue:99:copilot:another error"].join("\n");
const result = buildAssignCopilotFailureContext(true, errors);
expect(result).toContain("#42");
expect(result).toContain("#99");
expect(result).toContain("2 failed assignment");
});

it("handles hasAssignCopilotFailures=true with empty errors string", () => {
const result = buildAssignCopilotFailureContext(true, "");
expect(result).toContain("Copilot Agent Assignment Failed");
expect(result).toContain("copilot-swe-agent");
});
});
});
21 changes: 0 additions & 21 deletions actions/setup/md/assign_copilot_to_created_issues_failure.md

This file was deleted.