From 831e05aeef2231439e6379d4ccf535e5ad8ec708 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 11:55:19 +0000 Subject: [PATCH 1/5] Initial plan From 8343948aff52ff1af4f671b3ec9c5dd34106d55e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:11:54 +0000 Subject: [PATCH 2/5] fix: improve error logging for update-discussion GraphQL failures Add logGraphQLError helper to update_discussion.cjs that surfaces the full GraphQL error details (errors array with type/path/locations, HTTP status, and actionable permission hints for INSUFFICIENT_SCOPES and NOT_FOUND) when a discussion fetch or mutation fails. Previously, failures only reported ERR_API: update discussion #N failed (attempt 1) with the root cause buried in a multi-line enhanced error. Now every GraphQL failure also emits structured diagnostic output that enables fast root-cause analysis without a debug re-run. Wrap both the fetch-discussion query and the updateDiscussion mutation in try/catch blocks that call logGraphQLError before re-throwing. Add debug: vi.fn() to the test mock core and 3 new tests covering INSUFFICIENT_SCOPES, FORBIDDEN, and NOT_FOUND error scenarios. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b7ebe3f3-899f-4f11-9dff-5f93dce4189e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_discussion.cjs | 62 +++++++++++++++++--- actions/setup/js/update_discussion.test.cjs | 63 +++++++++++++++++++++ 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/update_discussion.cjs b/actions/setup/js/update_discussion.cjs index c2e86ea1019..9f33382a396 100644 --- a/actions/setup/js/update_discussion.cjs +++ b/actions/setup/js/update_discussion.cjs @@ -15,6 +15,43 @@ const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs"); const { MAX_LABELS } = require("./constants.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); +/** + * Log detailed GraphQL error information to aid diagnosing discussion API failures. + * Surfaces the errors array, type codes, paths, HTTP status, and permission hints. + * @param {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown, status?: number }} error - GraphQL error + * @param {string} operation - Human-readable description of the failing operation + */ +function logGraphQLError(error, operation) { + core.info(`GraphQL error during: ${operation}`); + core.info(`Message: ${getErrorMessage(error)}`); + + const errorList = Array.isArray(error.errors) ? error.errors : []; + const hasInsufficientScopes = errorList.some(e => e?.type === "INSUFFICIENT_SCOPES"); + const hasNotFound = errorList.some(e => e?.type === "NOT_FOUND"); + + if (hasInsufficientScopes) { + core.info( + `This looks like a token permission problem. The GitHub token requires 'discussions: write' permission. Add 'permissions: discussions: write' to your workflow, or set 'safe-outputs.update-discussion.github-token' to a PAT with the appropriate scopes.` + ); + } else if (hasNotFound) { + core.info(`GitHub returned NOT_FOUND for the discussion. Check that the discussion number is correct and that the token has read access to the repository.`); + } + + if (error.errors) { + core.info(`Errors array (${error.errors.length} error(s)):`); + error.errors.forEach((err, idx) => { + core.info(` [${idx + 1}] ${err.message}`); + if (err.type) core.info(` Type: ${err.type}`); + if (err.path) core.info(` Path: ${JSON.stringify(err.path)}`); + if (err.locations) core.info(` Locations: ${JSON.stringify(err.locations)}`); + }); + } + + if (error.status) core.info(`HTTP status: ${error.status}`); + if (error.request) core.info(`Request: ${JSON.stringify(error.request, null, 2)}`); + if (error.data) core.info(`Response data: ${JSON.stringify(error.data, null, 2)}`); +} + /** * Fetches label node IDs for the given label names from the repository * @param {any} githubClient - GitHub API client @@ -132,11 +169,17 @@ async function executeDiscussionUpdate(github, context, discussionNumber, update } `; - const queryResult = await github.graphql(getDiscussionQuery, { - owner: context.repo.owner, - repo: context.repo.repo, - number: discussionNumber, - }); + let queryResult; + try { + queryResult = await github.graphql(getDiscussionQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + number: discussionNumber, + }); + } catch (fetchError) { + logGraphQLError(/** @type {any} */ fetchError, `fetch discussion #${discussionNumber} from ${context.repo.owner}/${context.repo.repo}`); + throw fetchError; + } const discussion = queryResult?.repository?.discussion; if (!discussion) { @@ -172,8 +215,13 @@ async function executeDiscussionUpdate(github, context, discussionNumber, update body: hasBodyUpdate ? updateData.body : discussion.body, }; - const mutationResult = await github.graphql(mutation, variables); - updatedDiscussion = mutationResult.updateDiscussion.discussion; + try { + const mutationResult = await github.graphql(mutation, variables); + updatedDiscussion = mutationResult.updateDiscussion.discussion; + } catch (mutationError) { + logGraphQLError(/** @type {any} */ mutationError, `updateDiscussion mutation for discussion #${discussionNumber} in ${context.repo.owner}/${context.repo.repo}`); + throw mutationError; + } } // Handle label replacement if labels were provided diff --git a/actions/setup/js/update_discussion.test.cjs b/actions/setup/js/update_discussion.test.cjs index 35772d7ecdf..232afc46b5a 100644 --- a/actions/setup/js/update_discussion.test.cjs +++ b/actions/setup/js/update_discussion.test.cjs @@ -61,6 +61,7 @@ describe("update_discussion", () => { info: /** @param {string} msg */ msg => mockCore.infos.push(msg), warning: /** @param {string} msg */ msg => mockCore.warnings.push(msg), error: /** @param {string} msg */ msg => mockCore.errors.push(msg), + debug: vi.fn(), setOutput: vi.fn(), setFailed: vi.fn(), }; @@ -627,4 +628,66 @@ describe("update_discussion", () => { expect(typeof handler).toBe("function"); }); }); + + describe("GraphQL error logging", () => { + it("should log detailed error info and re-throw when fetch query fails", async () => { + const graphqlError = Object.assign(new Error("Request failed due to following response errors"), { + errors: [{ type: "INSUFFICIENT_SCOPES", message: "Your token has not been granted the required scopes.", path: ["repository", "discussion"] }], + status: 401, + }); + mockGithub.graphql = vi.fn().mockRejectedValue(graphqlError); + + const handler = await main({ target: "*", allow_body: true }); + const result = await handler({ type: "update_discussion", body: "New body", discussion_number: 45 }, {}); + + expect(result.success).toBe(false); + + // Verify logGraphQLError was called: it emits the operation name, message, and permission hint + expect(mockCore.infos.some(msg => msg.includes("GraphQL error during:"))).toBe(true); + expect(mockCore.infos.some(msg => msg.includes("fetch discussion #45"))).toBe(true); + expect(mockCore.infos.some(msg => msg.includes("Request failed"))).toBe(true); + expect(mockCore.infos.some(msg => msg.includes("discussions: write"))).toBe(true); + expect(mockCore.infos.some(msg => msg.includes("INSUFFICIENT_SCOPES"))).toBe(true); + }); + + it("should log detailed error info and re-throw when updateDiscussion mutation fails", async () => { + const mutationError = Object.assign(new Error("Request failed due to following response errors"), { + errors: [{ type: "FORBIDDEN", message: "Resource not accessible by integration", path: ["updateDiscussion"] }], + status: 403, + }); + + mockGithub.graphql = vi.fn().mockImplementation(async query => { + if (query.includes("discussion(number:")) { + return { repository: { discussion: { ...defaultDiscussion } } }; + } + throw mutationError; + }); + + const handler = await main({ target: "*", allow_body: true }); + const result = await handler({ type: "update_discussion", body: "Updated body", discussion_number: 45 }, {}); + + expect(result.success).toBe(false); + + // Verify logGraphQLError was called with the mutation operation name + expect(mockCore.infos.some(msg => msg.includes("updateDiscussion mutation"))).toBe(true); + expect(mockCore.infos.some(msg => msg.includes("discussion #45"))).toBe(true); + expect(mockCore.infos.some(msg => msg.includes("HTTP status: 403"))).toBe(true); + expect(mockCore.infos.some(msg => msg.includes("FORBIDDEN"))).toBe(true); + }); + + it("should log NOT_FOUND hint when discussion fetch returns NOT_FOUND error", async () => { + const notFoundError = Object.assign(new Error("Could not resolve to a Discussion"), { + errors: [{ type: "NOT_FOUND", message: "Could not resolve to a Discussion with the number of 45.", path: ["repository", "discussion"] }], + status: 200, + }); + mockGithub.graphql = vi.fn().mockRejectedValue(notFoundError); + + const handler = await main({ target: "*", allow_body: true }); + const result = await handler({ type: "update_discussion", body: "New body", discussion_number: 45 }, {}); + + expect(result.success).toBe(false); + expect(mockCore.infos.some(msg => msg.includes("NOT_FOUND"))).toBe(true); + expect(mockCore.infos.some(msg => msg.includes("Check that the discussion number is correct"))).toBe(true); + }); + }); }); From af8d72fd8df78fd648e3c0e3446e160d8eb47b90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:23:48 +0000 Subject: [PATCH 3/5] refactor: extract shared logGraphQLError into github_api_helpers.cjs Move the duplicated logGraphQLError helper from update_discussion.cjs, update_project.cjs, create_project.cjs, and create_project_status_update.cjs into a single generalized implementation in github_api_helpers.cjs. The shared function accepts an optional `hints` (GraphQLErrorHints) parameter for domain-specific INSUFFICIENT_SCOPES / NOT_FOUND messages and an optional notFoundPredicate to gate the NOT_FOUND hint on the error message content (used by project callers to match /projectV2\b/). Each caller defines a module-level *_GRAPHQL_HINTS constant and passes it to logGraphQLError. The shared function also surfaces error.status (HTTP status code), which was previously only logged by update_discussion.cjs. Tests added to github_api_helpers.test.cjs covering: operation/message logging, errors array details, HTTP status, request/response data, insufficientScopesHint, notFoundHint with and without notFoundPredicate, and no-hints invocation. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/fd81583a-3cf5-4b0c-89d0-94d252259614 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_project.cjs | 46 ++----- .../setup/js/create_project_status_update.cjs | 47 ++----- actions/setup/js/github_api_helpers.cjs | 50 ++++++++ actions/setup/js/github_api_helpers.test.cjs | 118 ++++++++++++++++++ actions/setup/js/update_discussion.cjs | 47 ++----- actions/setup/js/update_project.cjs | 57 +++------ 6 files changed, 212 insertions(+), 153 deletions(-) diff --git a/actions/setup/js/create_project.cjs b/actions/setup/js/create_project.cjs index 5b3803e05be..ced7fe6b6f8 100644 --- a/actions/setup/js/create_project.cjs +++ b/actions/setup/js/create_project.cjs @@ -7,41 +7,15 @@ const { normalizeTemporaryId, isTemporaryId, generateTemporaryId, getOrGenerateT const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); const { ERR_CONFIG, ERR_NOT_FOUND, ERR_VALIDATION } = require("./error_codes.cjs"); +const { logGraphQLError } = require("./github_api_helpers.cjs"); -/** - * Log detailed GraphQL error information - * @param {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} error - GraphQL error - * @param {string} operation - Operation description - */ -function logGraphQLError(error, operation) { - core.info(`GraphQL Error during: ${operation}`); - core.info(`Message: ${getErrorMessage(error)}`); - - const errorList = Array.isArray(error.errors) ? error.errors : []; - const hasInsufficientScopes = errorList.some(e => e?.type === "INSUFFICIENT_SCOPES"); - const hasNotFound = errorList.some(e => e?.type === "NOT_FOUND"); - - if (hasInsufficientScopes) { - core.info( - "This looks like a token permission problem for Projects v2. The GraphQL fields used by create_project require a token with Projects access (classic PAT: scope 'project'; fine-grained PAT: Organization permission 'Projects' and access to the org). Fix: set safe-outputs.create-project.github-token to a secret PAT that can create projects in the target org." - ); - } else if (hasNotFound && /projectV2\b/.test(getErrorMessage(error))) { - core.info("GitHub returned NOT_FOUND for ProjectV2. This can mean either: (1) the owner does not exist, or (2) the token does not have access to that org/user."); - } - - if (error.errors) { - core.info(`Errors array (${error.errors.length} error(s)):`); - error.errors.forEach((err, idx) => { - core.info(` [${idx + 1}] ${err.message}`); - if (err.type) core.info(` Type: ${err.type}`); - if (err.path) core.info(` Path: ${JSON.stringify(err.path)}`); - if (err.locations) core.info(` Locations: ${JSON.stringify(err.locations)}`); - }); - } - - if (error.request) core.info(`Request: ${JSON.stringify(error.request, null, 2)}`); - if (error.data) core.info(`Response data: ${JSON.stringify(error.data, null, 2)}`); -} +/** @type {import('./github_api_helpers.cjs').GraphQLErrorHints} */ +const PROJECT_GRAPHQL_HINTS = { + insufficientScopesHint: + "This looks like a token permission problem for Projects v2. The GraphQL fields used by create_project require a token with Projects access (classic PAT: scope 'project'; fine-grained PAT: Organization permission 'Projects' and access to the org). Fix: set safe-outputs.create-project.github-token to a secret PAT that can create projects in the target org.", + notFoundHint: "GitHub returned NOT_FOUND for ProjectV2. This can mean either: (1) the owner does not exist, or (2) the token does not have access to that org/user.", + notFoundPredicate: msg => /projectV2\b/.test(msg), +}; /** * Get owner ID for an org or user @@ -481,7 +455,7 @@ async function main(config = {}, githubClient = null) { // prettier-ignore const error = /** @type {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} */ (err); core.error(`Failed to create configured view ${i + 1}: ${viewConfig.name}`); - logGraphQLError(error, `Creating configured view: ${viewConfig.name}`); + logGraphQLError(error, `Creating configured view: ${viewConfig.name}`, PROJECT_GRAPHQL_HINTS); } } @@ -502,7 +476,7 @@ async function main(config = {}, githubClient = null) { } catch (err) { // prettier-ignore const error = /** @type {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} */ (err); - logGraphQLError(error, "create_project"); + logGraphQLError(error, "create_project", PROJECT_GRAPHQL_HINTS); return { success: false, error: getErrorMessage(error), diff --git a/actions/setup/js/create_project_status_update.cjs b/actions/setup/js/create_project_status_update.cjs index 10305296bbc..2debe3a5b76 100644 --- a/actions/setup/js/create_project_status_update.cjs +++ b/actions/setup/js/create_project_status_update.cjs @@ -8,6 +8,7 @@ const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); const { isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs"); const { ERR_CONFIG, ERR_NOT_FOUND, ERR_PARSE, ERR_VALIDATION } = require("./error_codes.cjs"); +const { logGraphQLError } = require("./github_api_helpers.cjs"); /** * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction @@ -16,42 +17,14 @@ const { ERR_CONFIG, ERR_NOT_FOUND, ERR_PARSE, ERR_VALIDATION } = require("./erro /** @type {string} Safe output type handled by this module */ const HANDLER_TYPE = "create_project_status_update"; -/** - * Log detailed GraphQL error information - * @param {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} error - GraphQL error - * @param {string} operation - Operation description - */ -function logGraphQLError(error, operation) { - core.info(`GraphQL Error during: ${operation}`); - core.info(`Message: ${getErrorMessage(error)}`); - - const errorList = Array.isArray(error.errors) ? error.errors : []; - const hasInsufficientScopes = errorList.some(e => e?.type === "INSUFFICIENT_SCOPES"); - const hasNotFound = errorList.some(e => e?.type === "NOT_FOUND"); - - if (hasInsufficientScopes) { - core.info( - "This looks like a token permission problem for Projects v2. The GraphQL fields used by create-project-status-update require a token with Projects access (classic PAT: scope 'project'; fine-grained PAT: Organization permission 'Projects' and access to the org). Fix: set safe-outputs.create-project-status-update.github-token to a secret PAT that can access the target org project." - ); - } else if (hasNotFound && /projectV2\b/.test(getErrorMessage(error))) { - core.info( - "GitHub returned NOT_FOUND for ProjectV2. This can mean either: (1) the project number is wrong for Projects v2, (2) the project is a classic Projects board (not Projects v2), or (3) the token does not have access to that org/user project." - ); - } - - if (error.errors) { - core.info(`Errors array (${error.errors.length} error(s)):`); - error.errors.forEach((err, idx) => { - core.info(` [${idx + 1}] ${err.message}`); - if (err.type) core.info(` Type: ${err.type}`); - if (err.path) core.info(` Path: ${JSON.stringify(err.path)}`); - if (err.locations) core.info(` Locations: ${JSON.stringify(err.locations)}`); - }); - } - - if (error.request) core.info(`Request: ${JSON.stringify(error.request, null, 2)}`); - if (error.data) core.info(`Response data: ${JSON.stringify(error.data, null, 2)}`); -} +/** @type {import('./github_api_helpers.cjs').GraphQLErrorHints} */ +const PROJECT_GRAPHQL_HINTS = { + insufficientScopesHint: + "This looks like a token permission problem for Projects v2. The GraphQL fields used by create-project-status-update require a token with Projects access (classic PAT: scope 'project'; fine-grained PAT: Organization permission 'Projects' and access to the org). Fix: set safe-outputs.create-project-status-update.github-token to a secret PAT that can access the target org project.", + notFoundHint: + "GitHub returned NOT_FOUND for ProjectV2. This can mean either: (1) the project number is wrong for Projects v2, (2) the project is a classic Projects board (not Projects v2), or (3) the token does not have access to that org/user project.", + notFoundPredicate: msg => /projectV2\b/.test(msg), +}; /** * Parse project URL into components @@ -471,7 +444,7 @@ async function main(config = {}, githubClient = null) { // prettier-ignore const error = /** @type {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} */ (err); core.error(`Failed to create project status update: ${getErrorMessage(error)}`); - logGraphQLError(error, "Creating project status update"); + logGraphQLError(error, "Creating project status update", PROJECT_GRAPHQL_HINTS); return { success: false, diff --git a/actions/setup/js/github_api_helpers.cjs b/actions/setup/js/github_api_helpers.cjs index bba862507b7..c9a37f05404 100644 --- a/actions/setup/js/github_api_helpers.cjs +++ b/actions/setup/js/github_api_helpers.cjs @@ -8,6 +8,55 @@ const { getErrorMessage } = require("./error_helpers.cjs"); +/** + * @typedef {Object} GraphQLErrorHints + * @property {string} [insufficientScopesHint] - Message shown when INSUFFICIENT_SCOPES error type is present + * @property {string} [notFoundHint] - Message shown when NOT_FOUND error type is present + * @property {function(string): boolean} [notFoundPredicate] - Additional condition for showing the NOT_FOUND hint (receives the error message string) + */ + +/** + * Log detailed GraphQL error information for diagnosing API failures. + * Surfaces the errors array, type codes, paths, HTTP status, and optional domain-specific hints. + * + * @param {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown, status?: number }} error - GraphQL error + * @param {string} operation - Human-readable description of the failing operation + * @param {GraphQLErrorHints} [hints] - Optional domain-specific hint messages + */ +function logGraphQLError(error, operation, hints = {}) { + core.info(`GraphQL error during: ${operation}`); + core.info(`Message: ${getErrorMessage(error)}`); + + const errorList = Array.isArray(error.errors) ? error.errors : []; + const hasInsufficientScopes = errorList.some(e => e?.type === "INSUFFICIENT_SCOPES"); + const hasNotFound = errorList.some(e => e?.type === "NOT_FOUND"); + + if (hasInsufficientScopes && hints.insufficientScopesHint) { + core.info(hints.insufficientScopesHint); + } + + if (hasNotFound && hints.notFoundHint) { + const predicatePasses = !hints.notFoundPredicate || hints.notFoundPredicate(getErrorMessage(error)); + if (predicatePasses) { + core.info(hints.notFoundHint); + } + } + + if (error.errors) { + core.info(`Errors array (${error.errors.length} error(s)):`); + error.errors.forEach((err, idx) => { + core.info(` [${idx + 1}] ${err.message}`); + if (err.type) core.info(` Type: ${err.type}`); + if (err.path) core.info(` Path: ${JSON.stringify(err.path)}`); + if (err.locations) core.info(` Locations: ${JSON.stringify(err.locations)}`); + }); + } + + if (error.status) core.info(`HTTP status: ${error.status}`); + if (error.request) core.info(`Request: ${JSON.stringify(error.request, null, 2)}`); + if (error.data) core.info(`Response data: ${JSON.stringify(error.data, null, 2)}`); +} + /** * Get file content from GitHub repository using the API * @param {Object} github - GitHub API client (@actions/github) @@ -53,4 +102,5 @@ async function getFileContent(github, owner, repo, path, ref) { module.exports = { getFileContent, + logGraphQLError, }; diff --git a/actions/setup/js/github_api_helpers.test.cjs b/actions/setup/js/github_api_helpers.test.cjs index 1c7199d7461..0779fe3843f 100644 --- a/actions/setup/js/github_api_helpers.test.cjs +++ b/actions/setup/js/github_api_helpers.test.cjs @@ -8,6 +8,7 @@ global.core = mockCore; describe("github_api_helpers.cjs", () => { let getFileContent; + let logGraphQLError; let mockGithub; beforeEach(async () => { @@ -24,6 +25,7 @@ describe("github_api_helpers.cjs", () => { // Dynamically import the module const module = await import("./github_api_helpers.cjs"); getFileContent = module.getFileContent; + logGraphQLError = module.logGraphQLError; }); describe("getFileContent", () => { @@ -115,4 +117,120 @@ describe("github_api_helpers.cjs", () => { expect(result).toBeNull(); }); }); + + describe("logGraphQLError", () => { + it("should log operation name and message", () => { + const error = new Error("Something went wrong"); + logGraphQLError(error, "test operation"); + + expect(mockCore.info).toHaveBeenCalledWith("GraphQL error during: test operation"); + expect(mockCore.info).toHaveBeenCalledWith("Message: Something went wrong"); + }); + + it("should log errors array with type, path, and locations", () => { + const error = Object.assign(new Error("GraphQL error"), { + errors: [ + { + type: "NOT_FOUND", + message: "Resource not found", + path: ["repository", "discussion"], + locations: [{ line: 1, column: 1 }], + }, + ], + }); + + logGraphQLError(error, "test"); + + expect(mockCore.info).toHaveBeenCalledWith("Errors array (1 error(s)):"); + expect(mockCore.info).toHaveBeenCalledWith(" [1] Resource not found"); + expect(mockCore.info).toHaveBeenCalledWith(" Type: NOT_FOUND"); + expect(mockCore.info).toHaveBeenCalledWith(' Path: ["repository","discussion"]'); + }); + + it("should log HTTP status when present", () => { + const error = Object.assign(new Error("Unauthorized"), { status: 401 }); + logGraphQLError(error, "test"); + + expect(mockCore.info).toHaveBeenCalledWith("HTTP status: 401"); + }); + + it("should log request and response data when present", () => { + const error = Object.assign(new Error("Error"), { + request: { query: "..." }, + data: { repository: null }, + }); + logGraphQLError(error, "test"); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Request:")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Response data:")); + }); + + it("should show insufficientScopesHint when INSUFFICIENT_SCOPES error is present", () => { + const error = Object.assign(new Error("Scopes error"), { + errors: [{ type: "INSUFFICIENT_SCOPES", message: "Missing scope" }], + }); + + logGraphQLError(error, "test", { + insufficientScopesHint: "You need to add permission X.", + }); + + expect(mockCore.info).toHaveBeenCalledWith("You need to add permission X."); + }); + + it("should not show insufficientScopesHint when no INSUFFICIENT_SCOPES error", () => { + const error = Object.assign(new Error("Other error"), { + errors: [{ type: "NOT_FOUND", message: "Not found" }], + }); + + logGraphQLError(error, "test", { + insufficientScopesHint: "You need to add permission X.", + }); + + expect(mockCore.info).not.toHaveBeenCalledWith("You need to add permission X."); + }); + + it("should show notFoundHint when NOT_FOUND error is present and no predicate", () => { + const error = Object.assign(new Error("Not found"), { + errors: [{ type: "NOT_FOUND", message: "Resource missing" }], + }); + + logGraphQLError(error, "test", { + notFoundHint: "Check the resource ID.", + }); + + expect(mockCore.info).toHaveBeenCalledWith("Check the resource ID."); + }); + + it("should show notFoundHint only when notFoundPredicate returns true", () => { + const errorWithMatch = Object.assign(new Error("projectV2 not found"), { + errors: [{ type: "NOT_FOUND", message: "projectV2 not found" }], + }); + const errorNoMatch = Object.assign(new Error("discussion not found"), { + errors: [{ type: "NOT_FOUND", message: "discussion not found" }], + }); + + const hints = { + notFoundHint: "Check project settings.", + notFoundPredicate: /** @param {string} msg */ msg => /projectV2\b/.test(msg), + }; + + logGraphQLError(errorWithMatch, "test", hints); + expect(mockCore.info).toHaveBeenCalledWith("Check project settings."); + + vi.clearAllMocks(); + + logGraphQLError(errorNoMatch, "test", hints); + expect(mockCore.info).not.toHaveBeenCalledWith("Check project settings."); + }); + + it("should work without hints (no hints argument)", () => { + const error = Object.assign(new Error("Error"), { + errors: [{ type: "INSUFFICIENT_SCOPES", message: "Missing scope" }], + }); + + // Should not throw - just logs the generic info without hints + expect(() => logGraphQLError(error, "test")).not.toThrow(); + expect(mockCore.info).toHaveBeenCalledWith("GraphQL error during: test"); + }); + }); }); diff --git a/actions/setup/js/update_discussion.cjs b/actions/setup/js/update_discussion.cjs index 9f33382a396..a3ba984133e 100644 --- a/actions/setup/js/update_discussion.cjs +++ b/actions/setup/js/update_discussion.cjs @@ -14,43 +14,14 @@ const { validateLabels } = require("./safe_output_validator.cjs"); const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs"); const { MAX_LABELS } = require("./constants.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); +const { logGraphQLError } = require("./github_api_helpers.cjs"); -/** - * Log detailed GraphQL error information to aid diagnosing discussion API failures. - * Surfaces the errors array, type codes, paths, HTTP status, and permission hints. - * @param {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown, status?: number }} error - GraphQL error - * @param {string} operation - Human-readable description of the failing operation - */ -function logGraphQLError(error, operation) { - core.info(`GraphQL error during: ${operation}`); - core.info(`Message: ${getErrorMessage(error)}`); - - const errorList = Array.isArray(error.errors) ? error.errors : []; - const hasInsufficientScopes = errorList.some(e => e?.type === "INSUFFICIENT_SCOPES"); - const hasNotFound = errorList.some(e => e?.type === "NOT_FOUND"); - - if (hasInsufficientScopes) { - core.info( - `This looks like a token permission problem. The GitHub token requires 'discussions: write' permission. Add 'permissions: discussions: write' to your workflow, or set 'safe-outputs.update-discussion.github-token' to a PAT with the appropriate scopes.` - ); - } else if (hasNotFound) { - core.info(`GitHub returned NOT_FOUND for the discussion. Check that the discussion number is correct and that the token has read access to the repository.`); - } - - if (error.errors) { - core.info(`Errors array (${error.errors.length} error(s)):`); - error.errors.forEach((err, idx) => { - core.info(` [${idx + 1}] ${err.message}`); - if (err.type) core.info(` Type: ${err.type}`); - if (err.path) core.info(` Path: ${JSON.stringify(err.path)}`); - if (err.locations) core.info(` Locations: ${JSON.stringify(err.locations)}`); - }); - } - - if (error.status) core.info(`HTTP status: ${error.status}`); - if (error.request) core.info(`Request: ${JSON.stringify(error.request, null, 2)}`); - if (error.data) core.info(`Response data: ${JSON.stringify(error.data, null, 2)}`); -} +/** @type {import('./github_api_helpers.cjs').GraphQLErrorHints} */ +const DISCUSSION_GRAPHQL_HINTS = { + insufficientScopesHint: + "This looks like a token permission problem. The GitHub token requires 'discussions: write' permission. Add 'permissions: discussions: write' to your workflow, or set 'safe-outputs.update-discussion.github-token' to a PAT with the appropriate scopes.", + notFoundHint: "GitHub returned NOT_FOUND for the discussion. Check that the discussion number is correct and that the token has read access to the repository.", +}; /** * Fetches label node IDs for the given label names from the repository @@ -177,7 +148,7 @@ async function executeDiscussionUpdate(github, context, discussionNumber, update number: discussionNumber, }); } catch (fetchError) { - logGraphQLError(/** @type {any} */ fetchError, `fetch discussion #${discussionNumber} from ${context.repo.owner}/${context.repo.repo}`); + logGraphQLError(/** @type {any} */ fetchError, `fetch discussion #${discussionNumber} from ${context.repo.owner}/${context.repo.repo}`, DISCUSSION_GRAPHQL_HINTS); throw fetchError; } @@ -219,7 +190,7 @@ async function executeDiscussionUpdate(github, context, discussionNumber, update const mutationResult = await github.graphql(mutation, variables); updatedDiscussion = mutationResult.updateDiscussion.discussion; } catch (mutationError) { - logGraphQLError(/** @type {any} */ mutationError, `updateDiscussion mutation for discussion #${discussionNumber} in ${context.repo.owner}/${context.repo.repo}`); + logGraphQLError(/** @type {any} */ mutationError, `updateDiscussion mutation for discussion #${discussionNumber} in ${context.repo.owner}/${context.repo.repo}`, DISCUSSION_GRAPHQL_HINTS); throw mutationError; } } diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 1bd8090e35e..162bf69be8e 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -8,6 +8,16 @@ const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); const { ERR_API, ERR_CONFIG, ERR_NOT_FOUND, ERR_PARSE, ERR_VALIDATION } = require("./error_codes.cjs"); const { parseRepoSlug, resolveTargetRepoConfig, isRepoAllowed } = require("./repo_helpers.cjs"); +const { logGraphQLError } = require("./github_api_helpers.cjs"); + +/** @type {import('./github_api_helpers.cjs').GraphQLErrorHints} */ +const PROJECT_GRAPHQL_HINTS = { + insufficientScopesHint: + "This looks like a token permission problem for Projects v2. The GraphQL fields used by update_project require a token with Projects access (classic PAT: scope 'project'; fine-grained PAT: Organization permission 'Projects' and access to the org). Fix: set safe-outputs.update-project.github-token to a secret PAT that can access the target org project.", + notFoundHint: + "GitHub returned NOT_FOUND for ProjectV2. This can mean either: (1) the project number is wrong for Projects v2, (2) the project is a classic Projects board (not Projects v2), or (3) the token does not have access to that org/user project.", + notFoundPredicate: msg => /projectV2\b/.test(msg), +}; /** * Normalize agent output keys for update_project. @@ -36,43 +46,6 @@ function normalizeUpdateProjectOutput(value) { return output; } - -/** - * Log detailed GraphQL error information - * @param {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} error - GraphQL error - * @param {string} operation - Operation description - */ -function logGraphQLError(error, operation) { - core.info(`GraphQL Error during: ${operation}`); - core.info(`Message: ${getErrorMessage(error)}`); - - const errorList = Array.isArray(error.errors) ? error.errors : []; - const hasInsufficientScopes = errorList.some(e => e?.type === "INSUFFICIENT_SCOPES"); - const hasNotFound = errorList.some(e => e?.type === "NOT_FOUND"); - - if (hasInsufficientScopes) { - core.info( - "This looks like a token permission problem for Projects v2. The GraphQL fields used by update_project require a token with Projects access (classic PAT: scope 'project'; fine-grained PAT: Organization permission 'Projects' and access to the org). Fix: set safe-outputs.update-project.github-token to a secret PAT that can access the target org project." - ); - } else if (hasNotFound && /projectV2\b/.test(getErrorMessage(error))) { - core.info( - "GitHub returned NOT_FOUND for ProjectV2. This can mean either: (1) the project number is wrong for Projects v2, (2) the project is a classic Projects board (not Projects v2), or (3) the token does not have access to that org/user project." - ); - } - - if (error.errors) { - core.info(`Errors array (${error.errors.length} error(s)):`); - error.errors.forEach((err, idx) => { - core.info(` [${idx + 1}] ${err.message}`); - if (err.type) core.info(` Type: ${err.type}`); - if (err.path) core.info(` Path: ${JSON.stringify(err.path)}`); - if (err.locations) core.info(` Locations: ${JSON.stringify(err.locations)}`); - }); - } - - if (error.request) core.info(`Request: ${JSON.stringify(error.request, null, 2)}`); - if (error.data) core.info(`Response data: ${JSON.stringify(error.data, null, 2)}`); -} /** * Parse project number from URL * @param {unknown} projectUrl - Project URL @@ -528,7 +501,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } catch (err) { // prettier-ignore const error = /** @type {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} */ (err); - logGraphQLError(error, "Fetching repository information"); + logGraphQLError(error, "Fetching repository information", PROJECT_GRAPHQL_HINTS); throw error; } @@ -577,7 +550,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } catch (err) { // prettier-ignore const error = /** @type {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} */ (err); - logGraphQLError(error, "Resolving project from URL"); + logGraphQLError(error, "Resolving project from URL", PROJECT_GRAPHQL_HINTS); throw error; } @@ -1389,7 +1362,7 @@ async function main(config = {}, githubClient = null) { // prettier-ignore const error = /** @type {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} */ (err); core.error("Failed to create configured fields"); - logGraphQLError(error, "Creating configured fields"); + logGraphQLError(error, "Creating configured fields", PROJECT_GRAPHQL_HINTS); } } } @@ -1446,7 +1419,7 @@ async function main(config = {}, githubClient = null) { // prettier-ignore const error = /** @type {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} */ (err); core.error(`Failed to create configured view ${i + 1}: ${viewConfig.name}`); - logGraphQLError(error, `Creating configured view: ${viewConfig.name}`); + logGraphQLError(error, `Creating configured view: ${viewConfig.name}`, PROJECT_GRAPHQL_HINTS); } } } @@ -1466,7 +1439,7 @@ async function main(config = {}, githubClient = null) { } catch (err) { // prettier-ignore const error = /** @type {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} */ (err); - logGraphQLError(error, "update_project"); + logGraphQLError(error, "update_project", PROJECT_GRAPHQL_HINTS); return { success: false, error: getErrorMessage(error), From 40f1d689f234300bad52be5f0b0e14ee8c72623a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:33:25 +0000 Subject: [PATCH 4/5] merge: main and add github_api_helpers.cjs to setup.sh SAFE_OUTPUTS_FILES Agent-Logs-Url: https://github.com/github/gh-aw/sessions/5efa4be0-4e09-4f0a-9b32-2136eeb931e9 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/setup.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/actions/setup/setup.sh b/actions/setup/setup.sh index 3e7168259ca..f882305cd23 100755 --- a/actions/setup/setup.sh +++ b/actions/setup/setup.sh @@ -262,6 +262,7 @@ SAFE_OUTPUTS_FILES=( "error_codes.cjs" "constants.cjs" "git_helpers.cjs" + "github_api_helpers.cjs" "find_repo_checkout.cjs" "mcp_enhanced_errors.cjs" "comment_limit_helpers.cjs" From 62dd016b8e3ee3ea8d124fbaf12a13fbd10763fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:24:47 +0000 Subject: [PATCH 5/5] fix: use JSDoc type cast pattern for catch variables to fix TypeScript errors Agent-Logs-Url: https://github.com/github/gh-aw/sessions/673c29ea-7718-47bd-b05c-c07b8c0c2aef Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_discussion.cjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/update_discussion.cjs b/actions/setup/js/update_discussion.cjs index 5912612f4fc..f0b22ab37cc 100644 --- a/actions/setup/js/update_discussion.cjs +++ b/actions/setup/js/update_discussion.cjs @@ -148,8 +148,10 @@ async function executeDiscussionUpdate(github, context, discussionNumber, update repo: context.repo.repo, number: discussionNumber, }); - } catch (fetchError) { - logGraphQLError(/** @type {any} */ fetchError, `fetch discussion #${discussionNumber} from ${context.repo.owner}/${context.repo.repo}`, DISCUSSION_GRAPHQL_HINTS); + } catch (err) { + // prettier-ignore + const fetchError = /** @type {any} */ (err); + logGraphQLError(fetchError, `fetch discussion #${discussionNumber} from ${context.repo.owner}/${context.repo.repo}`, DISCUSSION_GRAPHQL_HINTS); throw fetchError; } @@ -190,8 +192,10 @@ async function executeDiscussionUpdate(github, context, discussionNumber, update try { const mutationResult = await github.graphql(mutation, variables); updatedDiscussion = mutationResult.updateDiscussion.discussion; - } catch (mutationError) { - logGraphQLError(/** @type {any} */ mutationError, `updateDiscussion mutation for discussion #${discussionNumber} in ${context.repo.owner}/${context.repo.repo}`, DISCUSSION_GRAPHQL_HINTS); + } catch (err) { + // prettier-ignore + const mutationError = /** @type {any} */ (err); + logGraphQLError(mutationError, `updateDiscussion mutation for discussion #${discussionNumber} in ${context.repo.owner}/${context.repo.repo}`, DISCUSSION_GRAPHQL_HINTS); throw mutationError; } }