diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index 7f85c86c74d..90888bbd240 100644 --- a/actions/setup/js/add_comment.cjs +++ b/actions/setup/js/add_comment.cjs @@ -20,7 +20,7 @@ const { getMessages } = require("./messages_core.cjs"); const { getBodyHeader } = require("./messages_header.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { MAX_COMMENT_LENGTH, MAX_MENTIONS, MAX_LINKS, enforceCommentLimits } = require("./comment_limit_helpers.cjs"); -const { resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs"); +const { createDiscussionComment, resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { ERR_NOT_FOUND } = require("./error_codes.cjs"); const { isPayloadUserBot } = require("./resolve_mentions.cjs"); @@ -379,33 +379,7 @@ async function commentOnDiscussion(github, owner, repo, discussionNumber, messag const discussionUrl = repository.discussion.url; // 2. Add comment (with optional replyToId for threading) - const mutation = replyToId - ? /* GraphQL */ ` - mutation ($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - url - } - } - } - ` - : /* GraphQL */ ` - mutation ($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - } - `; - - const variables = { dId: discussionId, body: message, ...(replyToId ? { replyToId } : {}) }; - - const result = await github.graphql(mutation, variables); - - const comment = result.addDiscussionComment.comment; + const comment = await createDiscussionComment(github, discussionId, message, replyToId); return { id: comment.id, diff --git a/actions/setup/js/add_reaction_and_edit_comment.cjs b/actions/setup/js/add_reaction_and_edit_comment.cjs index fa00fedee60..34a08807637 100644 --- a/actions/setup/js/add_reaction_and_edit_comment.cjs +++ b/actions/setup/js/add_reaction_and_edit_comment.cjs @@ -7,7 +7,7 @@ const { generateWorkflowIdMarker } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { ERR_API, ERR_NOT_FOUND, ERR_VALIDATION } = require("./error_codes.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); -const { resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs"); +const { createDiscussionComment, resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs"); const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); const { addReaction, addDiscussionReaction, getDiscussionNodeId } = require("./add_reaction.cjs"); @@ -250,19 +250,7 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio // For discussion_comment events, thread the reply under the triggering comment. // GitHub Discussions only supports two nesting levels, so resolve the top-level parent node ID. const replyToId = eventName === "discussion_comment" ? await resolveTopLevelDiscussionCommentId(github, eventPayload?.comment?.node_id) : null; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { id url } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { id url } - } - }`; - const result = await github.graphql(mutation, { dId: discussionId, body: commentBody, ...(replyToId ? { replyToId } : {}) }); - const comment = result.addDiscussionComment.comment; + const comment = await createDiscussionComment(github, discussionId, commentBody, replyToId); setCommentOutputs(comment.id, comment.url, eventRepo); return; } diff --git a/actions/setup/js/github_api_helpers.cjs b/actions/setup/js/github_api_helpers.cjs index af324c5ea3e..17fb7a16cd9 100644 --- a/actions/setup/js/github_api_helpers.cjs +++ b/actions/setup/js/github_api_helpers.cjs @@ -175,7 +175,44 @@ async function resolveTopLevelDiscussionCommentId(github, commentNodeId) { } } +/** + * Create a discussion comment with optional threaded reply target. + * @param {Object} github - GitHub API client (must support graphql) + * @param {string} discussionId - Discussion GraphQL node ID + * @param {string} body - Comment body + * @param {string|null|undefined} [replyToId] - Optional top-level discussion comment node ID for threading + * @returns {Promise<{id: string, url: string}>} + */ +async function createDiscussionComment(github, discussionId, body, replyToId) { + const mutation = replyToId + ? /* GraphQL */ ` + mutation ($dId: ID!, $body: String!, $replyToId: ID!) { + addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { + comment { + id + url + } + } + } + ` + : /* GraphQL */ ` + mutation ($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + } + `; + + const variables = { dId: discussionId, body, ...(replyToId ? { replyToId } : {}) }; + const result = await github.graphql(mutation, variables); + return result.addDiscussionComment.comment; +} + module.exports = { + createDiscussionComment, fetchAllRepoLabels, getFileContent, logGraphQLError, diff --git a/actions/setup/js/github_api_helpers.test.cjs b/actions/setup/js/github_api_helpers.test.cjs index ac1f571f3af..0e4932d0f10 100644 --- a/actions/setup/js/github_api_helpers.test.cjs +++ b/actions/setup/js/github_api_helpers.test.cjs @@ -11,6 +11,7 @@ describe("github_api_helpers.cjs", () => { let logGraphQLError; let fetchAllRepoLabels; let resolveTopLevelDiscussionCommentId; + let createDiscussionComment; let mockGithub; beforeEach(async () => { @@ -30,6 +31,7 @@ describe("github_api_helpers.cjs", () => { logGraphQLError = module.logGraphQLError; fetchAllRepoLabels = module.fetchAllRepoLabels; resolveTopLevelDiscussionCommentId = module.resolveTopLevelDiscussionCommentId; + createDiscussionComment = module.createDiscussionComment; }); describe("getFileContent", () => { @@ -418,4 +420,56 @@ describe("github_api_helpers.cjs", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("resolving top-level discussion comment")); }); }); + + describe("createDiscussionComment", () => { + it("should create a top-level discussion comment when replyToId is omitted", async () => { + const mockGraphql = vi.fn().mockResolvedValueOnce({ + addDiscussionComment: { comment: { id: "DC_1", url: "https://github.com/o/r/discussions/1#discussioncomment-1" } }, + }); + + const result = await createDiscussionComment({ graphql: mockGraphql }, "D_1", "hello"); + + expect(result).toEqual({ id: "DC_1", url: "https://github.com/o/r/discussions/1#discussioncomment-1" }); + expect(mockGraphql).toHaveBeenCalledWith(expect.stringContaining("addDiscussionComment"), { + dId: "D_1", + body: "hello", + }); + expect(String(mockGraphql.mock.calls[0][0])).not.toContain("$replyToId"); + }); + + it("should create a threaded discussion comment when replyToId is provided", async () => { + const mockGraphql = vi.fn().mockResolvedValueOnce({ + addDiscussionComment: { comment: { id: "DC_2", url: "https://github.com/o/r/discussions/1#discussioncomment-2" } }, + }); + + await createDiscussionComment({ graphql: mockGraphql }, "D_1", "reply", "DC_parent"); + + expect(mockGraphql).toHaveBeenCalledWith(expect.stringContaining("$replyToId"), { + dId: "D_1", + body: "reply", + replyToId: "DC_parent", + }); + }); + + it("should treat null replyToId as omitted", async () => { + const mockGraphql = vi.fn().mockResolvedValueOnce({ + addDiscussionComment: { comment: { id: "DC_3", url: "https://github.com/o/r/discussions/1#discussioncomment-3" } }, + }); + + await createDiscussionComment({ graphql: mockGraphql }, "D_1", "top-level", null); + + expect(mockGraphql).toHaveBeenCalledWith(expect.stringContaining("addDiscussionComment"), { + dId: "D_1", + body: "top-level", + }); + expect(String(mockGraphql.mock.calls[0][0])).not.toContain("$replyToId"); + }); + + it("should propagate graphql errors", async () => { + const error = new Error("GraphQL failed"); + const mockGraphql = vi.fn().mockRejectedValueOnce(error); + + await expect(createDiscussionComment({ graphql: mockGraphql }, "D_1", "hello")).rejects.toThrow("GraphQL failed"); + }); + }); });