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
46 changes: 10 additions & 36 deletions actions/setup/js/create_project.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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),
Expand Down
47 changes: 10 additions & 37 deletions actions/setup/js/create_project_status_update.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions actions/setup/js/github_api_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
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.

The GraphQLErrorHints typedef is well-structured and enables clear domain-specific error guidance. Consider adding a JSDoc @example block showing typical usage to help future maintainers.

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)
Expand Down Expand Up @@ -53,4 +102,5 @@ async function getFileContent(github, owner, repo, path, ref) {

module.exports = {
getFileContent,
logGraphQLError,
};
118 changes: 118 additions & 0 deletions actions/setup/js/github_api_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ global.core = mockCore;

describe("github_api_helpers.cjs", () => {
let getFileContent;
let logGraphQLError;
let mockGithub;

beforeEach(async () => {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
});
37 changes: 30 additions & 7 deletions actions/setup/js/update_discussion.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@ 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");
const { resolveNumberFromTemporaryId } = require("./temporary_id.cjs");

/** @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
* @param {any} githubClient - GitHub API client
Expand Down Expand Up @@ -133,11 +141,19 @@ 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 (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;
}

const discussion = queryResult?.repository?.discussion;
if (!discussion) {
Expand Down Expand Up @@ -173,8 +189,15 @@ 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 (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;
}
}

// Handle label replacement if labels were provided
Expand Down
Loading
Loading