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
91 changes: 36 additions & 55 deletions actions/setup/js/assign_agent_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ async function getAvailableAgentLogins(owner, repo) {
const available = actors.filter(actor => actor?.login && knownValues.includes(actor.login)).map(actor => actor.login);
return available.sort();
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
core.debug(`Failed to list available agent logins: ${errorMessage}`);
core.debug(`Failed to list available agent logins: ${getErrorMessage(e)}`);
return [];
}
}
Expand Down Expand Up @@ -239,6 +238,37 @@ async function getPullRequestDetails(owner, repo, pullNumber) {
}
}

/**
* Log GraphQL error details line-by-line for troubleshooting
* @param {unknown} error - Error to extract and log details from
* @param {string} header - Header message to log before details
* @param {(msg: string) => void} logFn - Logger function (core.info, core.error, etc.)
*/
function logGraphQLErrorDetails(error, header, logFn) {
try {
if (!error || typeof error !== "object") return;
const err = /** @type {any} */ error;
const details = {
...(err.errors && { errors: err.errors }),
...(err.response && { response: err.response }),
...(err.data && { data: err.data }),
};
if (Array.isArray(err.errors)) {
details.compactMessages = err.errors.map(/** @param {any} e */ e => e.message).filter(Boolean);
}
const serialized = JSON.stringify(details, null, 2);
if (serialized !== "{}") {
logFn(header);
serialized
.split("\n")
.filter(line => line.trim())
.forEach(line => logFn(line));
}
} catch (loggingErr) {
core.debug(`Failed to serialize error details: ${getErrorMessage(loggingErr)}`);
}
}

/**
* Assign agent to issue or pull request using GraphQL replaceActorsForAssignable mutation
* @param {string} assignableId - GitHub issue or pull request ID
Expand Down Expand Up @@ -394,65 +424,15 @@ async function assignAgentToIssue(assignableId, agentId, currentAssignees, agent

if (is502Error) {
core.warning(`Received 502 error from cloud gateway during agent assignment, but assignment may have succeeded`);
core.info(`502 error details logged for troubleshooting`);

// Log the 502 error details without failing
try {
if (error && typeof error === "object") {
const details = {
...(err.errors && { errors: err.errors }),
...(err.response && { response: err.response }),
...(err.data && { data: err.data }),
};
const serialized = JSON.stringify(details, null, 2);
if (serialized !== "{}") {
core.info("502 error details (for troubleshooting):");
serialized
.split("\n")
.filter(line => line.trim())
.forEach(line => core.info(line));
}
}
} catch (loggingErr) {
const loggingErrMsg = loggingErr instanceof Error ? loggingErr.message : String(loggingErr);
core.debug(`Failed to serialize 502 error details: ${loggingErrMsg}`);
}

logGraphQLErrorDetails(error, "502 error details (for troubleshooting):", core.info);
// Treat 502 as success since assignment typically succeeds despite the error
core.info(`Treating 502 error as success - agent assignment likely completed`);
return true;
}

// Debug: surface the raw GraphQL error structure for troubleshooting fine-grained permission issues
try {
core.debug(`Raw GraphQL error message: ${errorMessage}`);
if (error && typeof error === "object") {
// Common GraphQL error shapes: error.errors (array), error.data, error.response
const details = {
...(err.errors && { errors: err.errors }),
...(err.response && { response: err.response }),
...(err.data && { data: err.data }),
};
// If GitHub returns an array of errors with 'type'/'message'
if (Array.isArray(err.errors)) {
details.compactMessages = err.errors.map(e => e.message).filter(Boolean);
}
const serialized = JSON.stringify(details, null, 2);
if (serialized !== "{}") {
core.debug(`Raw GraphQL error details: ${serialized}`);
// Also emit non-debug version so users without ACTIONS_STEP_DEBUG can see it
core.error("Raw GraphQL error details (for troubleshooting):");
serialized
.split("\n")
.filter(line => line.trim())
.forEach(line => core.error(line));
}
}
} catch (loggingErr) {
// Never fail assignment because of debug logging
const loggingErrMsg = loggingErr instanceof Error ? loggingErr.message : String(loggingErr);
core.debug(`Failed to serialize GraphQL error details: ${loggingErrMsg}`);
}
core.debug(`Raw GraphQL error message: ${errorMessage}`);
logGraphQLErrorDetails(error, "Raw GraphQL error details (for troubleshooting):", core.error);

// Check for permission-related errors
if (errorMessage.includes("Resource not accessible by personal access token") || errorMessage.includes("Resource not accessible by integration") || errorMessage.includes("Insufficient permissions to assign")) {
Expand Down Expand Up @@ -630,4 +610,5 @@ module.exports = {
logPermissionError,
generatePermissionErrorSummary,
assignAgentToIssueByName,
logGraphQLErrorDetails,
};
170 changes: 169 additions & 1 deletion actions/setup/js/assign_agent_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,19 @@ const mockGithub = {
globalThis.core = mockCore;
globalThis.github = mockGithub;

const { AGENT_LOGIN_NAMES, getAgentName, getAvailableAgentLogins, findAgent, getIssueDetails, assignAgentToIssue, generatePermissionErrorSummary, assignAgentToIssueByName } = await import("./assign_agent_helpers.cjs");
const {
AGENT_LOGIN_NAMES,
getAgentName,
getAvailableAgentLogins,
findAgent,
getIssueDetails,
getPullRequestDetails,
assignAgentToIssue,
logPermissionError,
generatePermissionErrorSummary,
assignAgentToIssueByName,
logGraphQLErrorDetails,
} = await import("./assign_agent_helpers.cjs");

describe("assign_agent_helpers.cjs", () => {
beforeEach(() => {
Expand Down Expand Up @@ -597,4 +609,160 @@ describe("assign_agent_helpers.cjs", () => {
expect(mockCore.info).toHaveBeenCalledWith("copilot is already assigned to issue #123");
});
});

describe("getPullRequestDetails", () => {
it("should return pull request ID and current assignees", async () => {
mockGithub.graphql.mockResolvedValueOnce({
repository: {
pullRequest: {
id: "PR_123",
assignees: {
nodes: [
{ id: "USER_1", login: "user1" },
{ id: "USER_2", login: "user2" },
],
},
},
},
});

const result = await getPullRequestDetails("owner", "repo", 123);

expect(result).toEqual({
pullRequestId: "PR_123",
currentAssignees: [
{ id: "USER_1", login: "user1" },
{ id: "USER_2", login: "user2" },
],
});
});

it("should return null when pull request is not found", async () => {
mockGithub.graphql.mockResolvedValueOnce({
repository: {
pullRequest: null,
},
});

const result = await getPullRequestDetails("owner", "repo", 999);

expect(result).toBeNull();
expect(mockCore.error).toHaveBeenCalledWith("Could not get pull request data");
});

it("should handle GraphQL errors by re-throwing", async () => {
mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error"));

await expect(getPullRequestDetails("owner", "repo", 123)).rejects.toThrow("GraphQL error");
expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to get pull request details"));
});

it("should return empty assignees when none exist", async () => {
mockGithub.graphql.mockResolvedValueOnce({
repository: {
pullRequest: {
id: "PR_123",
assignees: {
nodes: [],
},
},
},
});

const result = await getPullRequestDetails("owner", "repo", 123);

expect(result).toEqual({
pullRequestId: "PR_123",
currentAssignees: [],
});
});

it("should return null when pull request has no id", async () => {
mockGithub.graphql.mockResolvedValueOnce({
repository: {
pullRequest: {
id: null,
assignees: { nodes: [] },
},
},
});

const result = await getPullRequestDetails("owner", "repo", 123);

expect(result).toBeNull();
});
});

describe("logPermissionError", () => {
it("should log multiple error messages about required permissions", () => {
logPermissionError("copilot");

expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to assign copilot"));
expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("actions: write"));
expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("contents: write"));
expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("issues: write"));
expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("pull-requests: write"));
});

it("should include repository settings guidance", () => {
logPermissionError("copilot");

expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Settings > Actions > General"));
});

it("should log info with documentation link", () => {
logPermissionError("copilot");

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("docs.github.com"));
});
});

describe("logGraphQLErrorDetails", () => {
it("should log error details line by line", () => {
const logFn = vi.fn();
const error = { errors: [{ message: "Forbidden" }], response: { status: 403 } };

logGraphQLErrorDetails(error, "Error details:", logFn);

expect(logFn).toHaveBeenCalledWith("Error details:");
expect(logFn).toHaveBeenCalledWith(expect.stringContaining("Forbidden"));
});

it("should do nothing when error is null", () => {
const logFn = vi.fn();

logGraphQLErrorDetails(null, "Error details:", logFn);

expect(logFn).not.toHaveBeenCalled();
});

it("should do nothing when error has no relevant fields", () => {
const logFn = vi.fn();

logGraphQLErrorDetails({}, "Error details:", logFn);

expect(logFn).not.toHaveBeenCalled();
});

it("should extract compactMessages from errors array", () => {
const logFn = vi.fn();
const error = { errors: [{ message: "Error one" }, { message: "Error two" }] };

logGraphQLErrorDetails(error, "Errors:", logFn);

expect(logFn).toHaveBeenCalledWith("Errors:");
// The serialized JSON should contain compactMessages
const calls = logFn.mock.calls.map(c => c[0]).join("\n");
expect(calls).toContain("Error one");
expect(calls).toContain("Error two");
});

it("should handle non-object errors gracefully", () => {
const logFn = vi.fn();

logGraphQLErrorDetails("string error", "Error:", logFn);

expect(logFn).not.toHaveBeenCalled();
});
});
});
Loading