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
37 changes: 34 additions & 3 deletions actions/setup/js/handle_agent_failure.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1347,6 +1347,31 @@ function buildTimeoutContext(isTimedOut, timeoutMinutes) {
return "\n" + renderTemplateFromFile(templatePath, { current_minutes: currentMinutes, suggested_minutes: suggestedMinutes });
}

/**
* Determine whether engine-failure context should be included.
* Timeout outcomes should rely on dedicated timeout messaging instead.
* @param {string} agentConclusion
* @param {boolean} hasToolDenialsExceeded
* @param {boolean} isTimedOut
* @returns {boolean}
*/
function shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut) {
return agentConclusion === "failure" && !hasToolDenialsExceeded && !isTimedOut;
}

/**
* Determine whether issue create/update failed due to token permission limits.
* @param {unknown} error
* @returns {boolean}
*/
function isIssueWritePermissionError(error) {
/** @type {{status?: unknown} | null} */
const typedError = error && typeof error === "object" ? error : null;
const status = Number(typedError?.status);
const message = getErrorMessage(error).toLowerCase();
return status === 403 && (message.includes("resource not accessible by integration") || message.includes("resource not accessible by personal access token") || message.includes("insufficient permissions"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overly broad "insufficient permissions" match string: this substring appears in many GitHub API 403 responses (labeling, sub-issue linking, repo content writes, etc.), not only issue write failures. Because isIssueWritePermissionError is now exported, a future caller in a different catch block could inadvertently silence warnings for unrelated permission errors.

💡 Suggested fix

Either:

  1. Tighten the match to the specific GitHub token-permission phrasing ("insufficient scopes" is more precise for that class of error, though you should verify the exact strings returned by your Octokit version), or
  2. Rename the predicate to isTokenPermissionError and document in its JSDoc that it is deliberately broad across all 403 permission flavors so future callers understand the intent.

If the function is truly meant to be a general-purpose 403-classifier for any GitHub write, the current name isIssueWritePermissionError is misleading; callers will be surprised when it also matches a 403 on a label API call.

}

/**
* Build a context string when the Copilot CLI failed due to the token lacking inference access.
* @param {boolean} hasInferenceAccessError - Whether an inference access error was detected
Expand Down Expand Up @@ -2724,7 +2749,7 @@ async function main() {
// Suppress when tool-denials-exceeded is present: the engine termination is a
// direct consequence of the SDK hitting the denial threshold, so the tool-denials
// context is the more actionable signal.
const engineFailureContext = agentConclusion === "failure" && !hasToolDenialsExceeded ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : "";
const engineFailureContext = shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut) ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : "";

// Build timeout context
const timeoutContext = buildTimeoutContext(isTimedOut, timeoutMinutes);
Expand Down Expand Up @@ -2948,7 +2973,7 @@ async function main() {
// Suppress when tool-denials-exceeded is present: the engine termination is a
// direct consequence of the SDK hitting the denial threshold, so the tool-denials
// context is the more actionable signal.
const engineFailureContext = agentConclusion === "failure" && !hasToolDenialsExceeded ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : "";
const engineFailureContext = shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut) ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : "";

// Build timeout context
const timeoutContext = buildTimeoutContext(isTimedOut, timeoutMinutes);
Expand Down Expand Up @@ -3077,7 +3102,11 @@ async function main() {
await detectAndHandleFailureCascade(owner, repo, newIssue.data.number);
}
} catch (error) {
core.warning(`Failed to create or update failure tracking issue: ${getErrorMessage(error)}`);
if (isIssueWritePermissionError(error)) {
core.info(`Skipping failure tracking issue creation/update: token lacks issues:write permission (${getErrorMessage(error)})`);
} else {
core.warning(`Failed to create or update failure tracking issue: ${getErrorMessage(error)}`);
}
// Don't fail the workflow if we can't create the issue
}
} catch (error) {
Expand All @@ -3095,6 +3124,8 @@ module.exports = {
buildStaleLockFileFailedContext,
buildDailyAICExceededContext,
buildTimeoutContext,
shouldBuildEngineFailureContext,
isIssueWritePermissionError,
buildAssignCopilotFailureContext,
buildEngineFailureContext,
buildReportIncompleteContext,
Expand Down
41 changes: 41 additions & 0 deletions actions/setup/js/handle_agent_failure.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1462,6 +1462,47 @@ describe("handle_agent_failure", () => {
});
});

describe("shouldBuildEngineFailureContext", () => {
const { shouldBuildEngineFailureContext } = require("./handle_agent_failure.cjs");

it("returns true for plain failure without timeout or tool-denials-exceeded", () => {
expect(shouldBuildEngineFailureContext("failure", false, false)).toBe(true);
});

it("returns false when timeout is detected", () => {
expect(shouldBuildEngineFailureContext("failure", false, true)).toBe(false);
});

it("returns false when tool-denials-exceeded is present", () => {
expect(shouldBuildEngineFailureContext("failure", true, false)).toBe(false);
});

it("returns false for non-failure conclusions", () => {
expect(shouldBuildEngineFailureContext("timed_out", false, true)).toBe(false);
expect(shouldBuildEngineFailureContext("success", false, false)).toBe(false);
});
});

describe("isIssueWritePermissionError", () => {
const { isIssueWritePermissionError } = require("./handle_agent_failure.cjs");

it("returns true for 403 Resource not accessible by integration", () => {
expect(isIssueWritePermissionError({ status: 403, message: "Resource not accessible by integration" })).toBe(true);
});

it("returns true for 403 insufficient permissions", () => {
expect(isIssueWritePermissionError({ status: 403, message: "Insufficient permissions to create issue" })).toBe(true);
});

it("returns true for 403 resource not accessible by personal access token", () => {
expect(isIssueWritePermissionError({ status: 403, message: "Resource not accessible by personal access token" })).toBe(true);
});

it("returns false for non-403 errors", () => {
expect(isIssueWritePermissionError({ status: 500, message: "Internal server error" })).toBe(false);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test for "resource not accessible by personal access token" message pattern: isIssueWritePermissionError explicitly matches three strings, but the test suite only exercises two of them — the third pattern goes untested and could silently break if the string is ever typo-ed or refactored.

💡 Suggested fix

Add a third test case in the same describe block:

it("returns true for 403 resource not accessible by personal access token", () => {
  expect(
    isIssueWritePermissionError({ status: 403, message: "Resource not accessible by personal access token" })
  ).toBe(true);
});

All three listed patterns should be covered so a future edit to the match list produces a test failure rather than a silent regression.

});

// ──────────────────────────────────────────────────────
// buildEngineFailureContext
// ──────────────────────────────────────────────────────
Expand Down
Loading