diff --git a/actions/setup/js/assign_agent_helpers.cjs b/actions/setup/js/assign_agent_helpers.cjs index 1dd4005c10a..6353de7dd0b 100644 --- a/actions/setup/js/assign_agent_helpers.cjs +++ b/actions/setup/js/assign_agent_helpers.cjs @@ -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 []; } } @@ -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 @@ -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")) { @@ -630,4 +610,5 @@ module.exports = { logPermissionError, generatePermissionErrorSummary, assignAgentToIssueByName, + logGraphQLErrorDetails, }; diff --git a/actions/setup/js/assign_agent_helpers.test.cjs b/actions/setup/js/assign_agent_helpers.test.cjs index 6e76020a766..090db80d4e0 100644 --- a/actions/setup/js/assign_agent_helpers.test.cjs +++ b/actions/setup/js/assign_agent_helpers.test.cjs @@ -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(() => { @@ -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(); + }); + }); });