diff --git a/actions/setup/js/add_reaction_and_edit_comment.cjs b/actions/setup/js/add_reaction_and_edit_comment.cjs index 2f9810ab6bc..4787b7ab1f1 100644 --- a/actions/setup/js/add_reaction_and_edit_comment.cjs +++ b/actions/setup/js/add_reaction_and_edit_comment.cjs @@ -7,8 +7,19 @@ const { generateWorkflowIdMarker } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { ERR_API, ERR_NOT_FOUND, ERR_VALIDATION } = require("./error_codes.cjs"); +/** + * Event type descriptions for comment messages + */ +const EVENT_TYPE_DESCRIPTIONS = { + issues: "issue", + pull_request: "pull request", + issue_comment: "issue comment", + pull_request_review_comment: "pull request review comment", + discussion: "discussion", + discussion_comment: "discussion comment", +}; + async function main() { - // Read inputs from environment variables const reaction = process.env.GH_AW_REACTION || "eyes"; const command = process.env.GH_AW_COMMAND; // Only present for command workflows const runId = context.runId; @@ -27,7 +38,6 @@ async function main() { return; } - // Determine the API endpoint based on the event type let reactionEndpoint; let commentUpdateEndpoint; let shouldCreateComment = false; @@ -37,7 +47,7 @@ async function main() { try { switch (eventName) { - case "issues": + case "issues": { const issueNumber = context.payload?.issue?.number; if (!issueNumber) { core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`); @@ -45,11 +55,11 @@ async function main() { } reactionEndpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/reactions`; commentUpdateEndpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/comments`; - // Create comments for all workflows using reactions shouldCreateComment = true; break; + } - case "issue_comment": + case "issue_comment": { const commentId = context.payload?.comment?.id; const issueNumberForComment = context.payload?.issue?.number; if (!commentId) { @@ -63,11 +73,11 @@ async function main() { reactionEndpoint = `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`; // Create new comment on the issue itself, not on the comment commentUpdateEndpoint = `/repos/${owner}/${repo}/issues/${issueNumberForComment}/comments`; - // Create comments for all workflows using reactions shouldCreateComment = true; break; + } - case "pull_request": + case "pull_request": { const prNumber = context.payload?.pull_request?.number; if (!prNumber) { core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`); @@ -76,11 +86,11 @@ async function main() { // PRs are "issues" for the reactions endpoint reactionEndpoint = `/repos/${owner}/${repo}/issues/${prNumber}/reactions`; commentUpdateEndpoint = `/repos/${owner}/${repo}/issues/${prNumber}/comments`; - // Create comments for all workflows using reactions shouldCreateComment = true; break; + } - case "pull_request_review_comment": + case "pull_request_review_comment": { const reviewCommentId = context.payload?.comment?.id; const prNumberForReviewComment = context.payload?.pull_request?.number; if (!reviewCommentId) { @@ -94,11 +104,11 @@ async function main() { reactionEndpoint = `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}/reactions`; // Create new comment on the PR itself (using issues endpoint since PRs are issues) commentUpdateEndpoint = `/repos/${owner}/${repo}/issues/${prNumberForReviewComment}/comments`; - // Create comments for all workflows using reactions shouldCreateComment = true; break; + } - case "discussion": + case "discussion": { const discussionNumber = context.payload?.discussion?.number; if (!discussionNumber) { core.setFailed(`${ERR_NOT_FOUND}: Discussion number not found in event payload`); @@ -108,18 +118,17 @@ async function main() { const discussion = await getDiscussionId(owner, repo, discussionNumber); reactionEndpoint = discussion.id; // Store node ID for GraphQL commentUpdateEndpoint = `discussion:${discussionNumber}`; // Special format to indicate discussion - // Create comments for all workflows using reactions shouldCreateComment = true; break; + } - case "discussion_comment": + case "discussion_comment": { const discussionCommentNumber = context.payload?.discussion?.number; const discussionCommentId = context.payload?.comment?.id; if (!discussionCommentNumber || !discussionCommentId) { core.setFailed(`${ERR_NOT_FOUND}: Discussion or comment information not found in event payload`); return; } - // Get the comment node ID from the payload const commentNodeId = context.payload?.comment?.node_id; if (!commentNodeId) { core.setFailed(`${ERR_NOT_FOUND}: Discussion comment node ID not found in event payload`); @@ -127,9 +136,9 @@ async function main() { } reactionEndpoint = commentNodeId; // Store node ID for GraphQL commentUpdateEndpoint = `discussion_comment:${discussionCommentNumber}:${discussionCommentId}`; // Special format - // Create comments for all workflows using reactions shouldCreateComment = true; break; + } default: core.setFailed(`${ERR_VALIDATION}: Unsupported event type: ${eventName}`); @@ -138,7 +147,6 @@ async function main() { core.info(`Reaction API endpoint: ${reactionEndpoint}`); - // Add reaction first // For discussions, reactionEndpoint is a node ID (GraphQL), otherwise it's a REST API path const isDiscussionEvent = eventName === "discussion" || eventName === "discussion_comment"; if (isDiscussionEvent) { @@ -147,7 +155,6 @@ async function main() { await addReaction(reactionEndpoint, reaction); } - // Then add comment if applicable if (shouldCreateComment && commentUpdateEndpoint) { core.info(`Comment endpoint: ${commentUpdateEndpoint}`); await addCommentWithWorkflowLink(commentUpdateEndpoint, runUrl, eventName); @@ -157,19 +164,16 @@ async function main() { } catch (error) { const errorMessage = getErrorMessage(error); - // Check if the error is due to a locked issue/PR/discussion // GitHub API returns 403 with specific messages for locked resources const is403Error = error && typeof error === "object" && "status" in error && error.status === 403; const hasLockedMessage = errorMessage && (errorMessage.includes("locked") || errorMessage.includes("Lock conversation")); // Only ignore the error if it's BOTH a 403 status code AND mentions locked if (is403Error && hasLockedMessage) { - // Silently ignore locked resource errors - just log for debugging core.info(`Cannot add reaction: resource is locked (this is expected and not an error)`); return; } - // For other errors, fail as before core.error(`Failed to process reaction and comment creation: ${errorMessage}`); core.setFailed(`${ERR_API}: Failed to process reaction and comment creation: ${errorMessage}`); } @@ -271,34 +275,18 @@ async function getDiscussionId(owner, repo, discussionNumber) { } /** - * Get the node ID for a discussion comment - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {number} discussionNumber - Discussion number - * @param {number} commentId - Comment ID (database ID, not node ID) - * @returns {Promise<{id: string, url: string}>} Comment details + * Helper function to set comment outputs + * @param {string} commentId - The comment ID + * @param {string} commentUrl - The comment URL */ -async function getDiscussionCommentId(owner, repo, discussionNumber, commentId) { - // First, get the discussion ID - const discussion = await getDiscussionId(owner, repo, discussionNumber); - if (!discussion) throw new Error(`${ERR_NOT_FOUND}: Discussion #${discussionNumber} not found in ${owner}/${repo}`); - - // Then fetch the comment by traversing discussion comments - // Note: GitHub's GraphQL API doesn't provide a direct way to query comment by database ID - // We need to use the comment's node ID from the event payload if available - // For now, we'll use a simplified approach - the commentId from context.payload.comment.node_id - - // If the event payload provides node_id, we can use it directly - // Otherwise, this would need to fetch all comments and find the matching one - const nodeId = context.payload?.comment?.node_id; - if (nodeId) { - return { - id: nodeId, - url: context.payload.comment?.html_url || discussion?.url, - }; - } - - throw new Error(`${ERR_NOT_FOUND}: Discussion comment node ID not found in event payload for comment ${commentId}`); +function setCommentOutputs(commentId, commentUrl) { + core.info(`Successfully created comment with workflow link`); + core.info(`Comment ID: ${commentId}`); + core.info(`Comment URL: ${commentUrl}`); + core.info(`Comment Repo: ${context.repo.owner}/${context.repo.repo}`); + core.setOutput("comment-id", commentId); + core.setOutput("comment-url", commentUrl); + core.setOutput("comment-repo", `${context.repo.owner}/${context.repo.repo}`); } /** @@ -309,43 +297,17 @@ async function getDiscussionCommentId(owner, repo, discussionNumber, commentId) */ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { try { - // Get workflow name from environment variable const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - - // Determine the event type description - let eventTypeDescription; - switch (eventName) { - case "issues": - eventTypeDescription = "issue"; - break; - case "pull_request": - eventTypeDescription = "pull request"; - break; - case "issue_comment": - eventTypeDescription = "issue comment"; - break; - case "pull_request_review_comment": - eventTypeDescription = "pull request review comment"; - break; - case "discussion": - eventTypeDescription = "discussion"; - break; - case "discussion_comment": - eventTypeDescription = "discussion comment"; - break; - default: - eventTypeDescription = "event"; - } + const eventTypeDescription = EVENT_TYPE_DESCRIPTIONS[eventName] ?? "event"; // Use getRunStartedMessage for the workflow link text (supports custom messages) const workflowLinkText = getRunStartedMessage({ - workflowName: workflowName, - runUrl: runUrl, + workflowName, + runUrl, eventType: eventTypeDescription, }); - // Sanitize the workflow link text to prevent injection attacks (defense in depth for custom message templates) - // This must happen BEFORE adding workflow markers to preserve them + // Sanitize before adding workflow markers to preserve them let commentBody = sanitizeContent(workflowLinkText); // Add lock notice if lock-for-agent is enabled for issues or issue_comment @@ -354,11 +316,9 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { commentBody += "\n\nšŸ”’ This issue has been locked while the workflow is running to prevent concurrent modifications."; } - // Add workflow-id and tracker-id markers for hide-older-comments feature const workflowId = process.env.GITHUB_WORKFLOW || ""; const trackerId = process.env.GH_AW_TRACKER_ID || ""; - // Add workflow-id marker if available if (workflowId) { commentBody += `\n\n${generateWorkflowIdMarker(workflowId)}`; } @@ -369,28 +329,12 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { } // Add comment type marker to identify this as a reaction comment - // This prevents it from being hidden by hide-older-comments commentBody += `\n\n`; - // Handle discussion events specially if (eventName === "discussion") { // Parse discussion number from special format: "discussion:NUMBER" const discussionNumber = parseInt(endpoint.split(":")[1], 10); - - // Create a new comment on the discussion using GraphQL - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - } - } - }`, - { owner: context.repo.owner, repo: context.repo.repo, num: discussionNumber } - ); - - const discussionId = repository.discussion.id; + const { id: discussionId } = await getDiscussionId(context.repo.owner, context.repo.repo, discussionNumber); const result = await github.graphql( ` @@ -406,32 +350,12 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { ); const comment = result.addDiscussionComment.comment; - core.info(`Successfully created discussion comment with workflow link`); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - core.info(`Comment Repo: ${context.repo.owner}/${context.repo.repo}`); - core.setOutput("comment-id", comment.id); - core.setOutput("comment-url", comment.url); - core.setOutput("comment-repo", `${context.repo.owner}/${context.repo.repo}`); + setCommentOutputs(comment.id, comment.url); return; } else if (eventName === "discussion_comment") { // Parse discussion number from special format: "discussion_comment:NUMBER:COMMENT_ID" const discussionNumber = parseInt(endpoint.split(":")[1], 10); - - // Create a new comment on the discussion using GraphQL - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - } - } - }`, - { owner: context.repo.owner, repo: context.repo.repo, num: discussionNumber } - ); - - const discussionId = repository.discussion.id; + const { id: discussionId } = await getDiscussionId(context.repo.owner, context.repo.repo, discussionNumber); // Get the comment node ID to use as the parent for threading const commentNodeId = context.payload?.comment?.node_id; @@ -450,13 +374,7 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { ); const comment = result.addDiscussionComment.comment; - core.info(`Successfully created discussion comment with workflow link`); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - core.info(`Comment Repo: ${context.repo.owner}/${context.repo.repo}`); - core.setOutput("comment-id", comment.id); - core.setOutput("comment-url", comment.url); - core.setOutput("comment-repo", `${context.repo.owner}/${context.repo.repo}`); + setCommentOutputs(comment.id, comment.url); return; } @@ -468,13 +386,7 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { }, }); - core.info(`Successfully created comment with workflow link`); - core.info(`Comment ID: ${createResponse.data.id}`); - core.info(`Comment URL: ${createResponse.data.html_url}`); - core.info(`Comment Repo: ${context.repo.owner}/${context.repo.repo}`); - core.setOutput("comment-id", createResponse.data.id.toString()); - core.setOutput("comment-url", createResponse.data.html_url); - core.setOutput("comment-repo", `${context.repo.owner}/${context.repo.repo}`); + setCommentOutputs(createResponse.data.id.toString(), createResponse.data.html_url); } catch (error) { // Don't fail the entire job if comment creation fails - just log it const errorMessage = getErrorMessage(error); @@ -482,4 +394,4 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { } } -module.exports = { main }; +module.exports = { main, addCommentWithWorkflowLink, addReaction, addDiscussionReaction }; diff --git a/actions/setup/js/add_reaction_and_edit_comment.test.cjs b/actions/setup/js/add_reaction_and_edit_comment.test.cjs index dc5c1a6c3b1..6bfde6003b1 100644 --- a/actions/setup/js/add_reaction_and_edit_comment.test.cjs +++ b/actions/setup/js/add_reaction_and_edit_comment.test.cjs @@ -1,287 +1,500 @@ +// @ts-check import { describe, it, expect, beforeEach, vi } from "vitest"; -import fs from "fs"; -import path from "path"; -const { ERR_NOT_FOUND, ERR_VALIDATION } = require("./error_codes.cjs"); +const { ERR_NOT_FOUND, ERR_VALIDATION, ERR_API } = require("./error_codes.cjs"); + const mockCore = { - debug: vi.fn(), - info: vi.fn(), - notice: vi.fn(), - warning: vi.fn(), - error: vi.fn(), - setFailed: vi.fn(), - setOutput: vi.fn(), - exportVariable: vi.fn(), - setSecret: vi.fn(), - getInput: vi.fn(), - getBooleanInput: vi.fn(), - getMultilineInput: vi.fn(), - getState: vi.fn(), - saveState: vi.fn(), - startGroup: vi.fn(), - endGroup: vi.fn(), - group: vi.fn(), - addPath: vi.fn(), - setCommandEcho: vi.fn(), - isDebug: vi.fn().mockReturnValue(!1), - getIDToken: vi.fn(), - toPlatformPath: vi.fn(), - toPosixPath: vi.fn(), - toWin32Path: vi.fn(), - summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() }, + debug: vi.fn(), + info: vi.fn(), + notice: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + exportVariable: vi.fn(), + setSecret: vi.fn(), + getInput: vi.fn(), + summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue(undefined) }, +}; + +const mockGithub = { + request: vi.fn(), + graphql: vi.fn(), + rest: { issues: { createComment: vi.fn() } }, +}; + +const mockContext = { + eventName: "issues", + runId: 12345, + repo: { owner: "testowner", repo: "testrepo" }, + payload: { + issue: { number: 123 }, + repository: { html_url: "https://github.com/testowner/testrepo" }, }, - mockGithub = { request: vi.fn(), graphql: vi.fn(), rest: { issues: { createComment: vi.fn() } } }, - mockContext = { eventName: "issues", runId: 12345, repo: { owner: "testowner", repo: "testrepo" }, payload: { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } } }; -((global.core = mockCore), - (global.github = mockGithub), - (global.context = mockContext), - describe("add_reaction_and_edit_comment.cjs", () => { - let reactionScript; - (beforeEach(() => { - (vi.clearAllMocks(), - delete process.env.GH_AW_REACTION, - delete process.env.GH_AW_COMMAND, - delete process.env.GH_AW_WORKFLOW_NAME, - (global.context.eventName = "issues"), - (global.context.payload = { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } })); - const scriptPath = path.join(process.cwd(), "add_reaction_and_edit_comment.cjs"); - reactionScript = fs.readFileSync(scriptPath, "utf8"); - }), - describe("Issue reactions", () => { - (it("should add reaction to issue successfully", async () => { - ((process.env.GH_AW_REACTION = "eyes"), - (global.context.eventName = "issues"), - (global.context.payload.issue = { number: 123 }), - mockGithub.request.mockResolvedValue({ data: { id: 456 } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/123/reactions", expect.objectContaining({ content: "eyes" })), - expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "456")); - }), - it("should reject invalid reaction type", async () => { - ((process.env.GH_AW_REACTION = "invalid"), - (global.context.eventName = "issues"), - (global.context.payload.issue = { number: 123 }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid reaction type: invalid")), - expect(mockGithub.request).not.toHaveBeenCalled()); - })); - }), - describe("Pull request reactions", () => { - it("should add reaction to pull request and create comment", async () => { - ((process.env.GH_AW_REACTION = "heart"), - (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"), - (global.context.eventName = "pull_request"), - (global.context.payload = { pull_request: { number: 456 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.request.mockResolvedValueOnce({ data: { id: 789 } }).mockResolvedValueOnce({ data: { id: 999, html_url: "https://github.com/testowner/testrepo/pull/456#issuecomment-999" } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/456/reactions", expect.objectContaining({ content: "heart" })), - expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/456/comments", expect.objectContaining({ body: expect.stringContaining("has started processing this pull request") })), - expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "789"), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "999"), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", "https://github.com/testowner/testrepo/pull/456#issuecomment-999")); +}; + +global.core = mockCore; +global.github = mockGithub; +global.context = mockContext; + +// Helper to import the module fresh (bust module cache) +async function loadModule() { + const { main, addCommentWithWorkflowLink, addReaction, addDiscussionReaction } = await import("./add_reaction_and_edit_comment.cjs?" + Date.now()); + return { main, addCommentWithWorkflowLink, addReaction, addDiscussionReaction }; +} + +describe("add_reaction_and_edit_comment.cjs", () => { + beforeEach(() => { + vi.resetAllMocks(); + delete process.env.GH_AW_REACTION; + delete process.env.GH_AW_COMMAND; + delete process.env.GH_AW_WORKFLOW_NAME; + delete process.env.GH_AW_LOCK_FOR_AGENT; + delete process.env.GITHUB_WORKFLOW; + delete process.env.GH_AW_TRACKER_ID; + delete process.env.GITHUB_SERVER_URL; + + global.context = { + eventName: "issues", + runId: 12345, + repo: { owner: "testowner", repo: "testrepo" }, + payload: { + issue: { number: 123 }, + repository: { html_url: "https://github.com/testowner/testrepo" }, + }, + }; + + mockGithub.request.mockResolvedValue({ data: { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" } }); + mockGithub.graphql.mockResolvedValue({ + repository: { discussion: { id: "D_kwDOABcD1M4AaBbC", url: "https://github.com/testowner/testrepo/discussions/10" } }, + addReaction: { reaction: { id: "MDg6UmVhY3Rpb24xMjM0NTY3ODk=", content: "EYES" } }, + addDiscussionComment: { comment: { id: "DC_kwDOABcD1M4AaBbE", url: "https://github.com/testowner/testrepo/discussions/10#discussioncomment-999" } }, + }); + }); + + describe("Issue reactions", () => { + it("should add reaction to issue successfully", async () => { + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "issues"; + global.context.payload = { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } }; + mockGithub.request.mockResolvedValueOnce({ data: { id: 456 } }); + + const { main } = await loadModule(); + await main(); + + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/123/reactions", expect.objectContaining({ content: "eyes" })); + expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "456"); + }); + + it("should reject invalid reaction type", async () => { + process.env.GH_AW_REACTION = "invalid"; + global.context.eventName = "issues"; + + const { main } = await loadModule(); + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid reaction type: invalid")); + expect(mockGithub.request).not.toHaveBeenCalled(); + }); + + it("should fail when issue number is missing", async () => { + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "issues"; + global.context.payload = {}; + + const { main } = await loadModule(); + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Issue number not found in event payload`); + expect(mockGithub.request).not.toHaveBeenCalled(); + }); + }); + + describe("Pull request reactions", () => { + it("should add reaction to pull request and create comment", async () => { + process.env.GH_AW_REACTION = "heart"; + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + global.context.eventName = "pull_request"; + global.context.payload = { + pull_request: { number: 456 }, + repository: { html_url: "https://github.com/testowner/testrepo" }, + }; + mockGithub.request.mockResolvedValueOnce({ data: { id: 789 } }).mockResolvedValueOnce({ data: { id: 999, html_url: "https://github.com/testowner/testrepo/pull/456#issuecomment-999" } }); + + const { main } = await loadModule(); + await main(); + + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/456/reactions", expect.objectContaining({ content: "heart" })); + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/456/comments", expect.objectContaining({ body: expect.stringContaining("has started processing this pull request") })); + expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "789"); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "999"); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", "https://github.com/testowner/testrepo/pull/456#issuecomment-999"); + }); + + it("should fail when PR number is missing", async () => { + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "pull_request"; + global.context.payload = {}; + + const { main } = await loadModule(); + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Pull request number not found in event payload`); + }); + }); + + describe("Issue comment reactions", () => { + it("should create new comment for issue_comment event (not edit)", async () => { + process.env.GH_AW_REACTION = "eyes"; + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + global.context.eventName = "issue_comment"; + global.context.payload = { + issue: { number: 123 }, + comment: { id: 456 }, + repository: { html_url: "https://github.com/testowner/testrepo" }, + }; + mockGithub.request.mockResolvedValueOnce({ data: { id: 111 } }).mockResolvedValueOnce({ data: { id: 789, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-789" } }); + + const { main } = await loadModule(); + await main(); + + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/123/comments", expect.objectContaining({ body: expect.stringContaining("has started processing this issue comment") })); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "789"); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", "https://github.com/testowner/testrepo/issues/123#issuecomment-789"); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-repo", "testowner/testrepo"); + }); + + it("should fail when comment ID is missing", async () => { + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "issue_comment"; + global.context.payload = { issue: { number: 123 } }; + + const { main } = await loadModule(); + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_VALIDATION}: Comment ID not found in event payload`); + }); + }); + + describe("Pull request review comment reactions", () => { + it("should create new comment for pull_request_review_comment event (not edit)", async () => { + process.env.GH_AW_REACTION = "rocket"; + process.env.GH_AW_WORKFLOW_NAME = "PR Review Bot"; + global.context.eventName = "pull_request_review_comment"; + global.context.payload = { + pull_request: { number: 456 }, + comment: { id: 789 }, + repository: { html_url: "https://github.com/testowner/testrepo" }, + }; + mockGithub.request.mockResolvedValueOnce({ data: { id: 222 } }).mockResolvedValueOnce({ data: { id: 999, html_url: "https://github.com/testowner/testrepo/pull/456#discussion_r999" } }); + + const { main } = await loadModule(); + await main(); + + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/456/comments", expect.objectContaining({ body: expect.stringContaining("has started processing this pull request review comment") })); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "999"); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", "https://github.com/testowner/testrepo/pull/456#discussion_r999"); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-repo", "testowner/testrepo"); + }); + }); + + describe("Discussion reactions", () => { + it("should add reaction to discussion using GraphQL", async () => { + mockGithub.graphql.mockResolvedValueOnce({ addReaction: { reaction: { id: "MDg6UmVhY3Rpb24xMjM0NTY3ODk=", content: "ROCKET" } } }); + + const { addDiscussionReaction } = await loadModule(); + await addDiscussionReaction("D_kwDOABcD1M4AaBbC", "rocket"); + + expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("mutation"), expect.objectContaining({ subjectId: "D_kwDOABcD1M4AaBbC", content: "ROCKET" })); + expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "MDg6UmVhY3Rpb24xMjM0NTY3ODk="); + }); + + it("should map all reaction types correctly for GraphQL", async () => { + const reactionTests = [ + { input: "+1", expected: "THUMBS_UP" }, + { input: "-1", expected: "THUMBS_DOWN" }, + { input: "laugh", expected: "LAUGH" }, + { input: "confused", expected: "CONFUSED" }, + { input: "heart", expected: "HEART" }, + { input: "hooray", expected: "HOORAY" }, + { input: "rocket", expected: "ROCKET" }, + { input: "eyes", expected: "EYES" }, + ]; + + for (const test of reactionTests) { + vi.clearAllMocks(); + mockGithub.graphql.mockResolvedValueOnce({ addReaction: { reaction: { id: "abc", content: test.expected } } }); + + const { addDiscussionReaction } = await loadModule(); + await addDiscussionReaction("D_kwDOABcD1M4AaBbC", test.input); + + expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("mutation"), expect.objectContaining({ content: test.expected })); + } + }); + + it("should create comment on discussion", async () => { + process.env.GH_AW_REACTION = "eyes"; + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + global.context.eventName = "discussion"; + global.context.payload = { + discussion: { number: 10 }, + repository: { html_url: "https://github.com/testowner/testrepo" }, + }; + mockGithub.graphql + .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOABcD1M4AaBbC", url: "https://github.com/testowner/testrepo/discussions/10" } } }) + .mockResolvedValueOnce({ addReaction: { reaction: { id: "MDg6UmVhY3Rpb24xMjM0NTY3ODk=", content: "EYES" } } }) + .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOABcD1M4AaBbC", url: "https://github.com/testowner/testrepo/discussions/10" } } }) + .mockResolvedValueOnce({ addDiscussionComment: { comment: { id: "DC_kwDOABcD1M4AaBbE", url: "https://github.com/testowner/testrepo/discussions/10#discussioncomment-999" } } }); + + const { main } = await loadModule(); + await main(); + + expect(mockGithub.graphql).toHaveBeenCalledTimes(4); + expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("addDiscussionComment"), expect.objectContaining({ dId: "D_kwDOABcD1M4AaBbC", body: expect.stringContaining("has started processing this discussion") })); + expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "MDg6UmVhY3Rpb24xMjM0NTY3ODk="); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "DC_kwDOABcD1M4AaBbE"); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", "https://github.com/testowner/testrepo/discussions/10#discussioncomment-999"); + }); + + it("should fail when discussion number is missing", async () => { + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "discussion"; + global.context.payload = { repository: { html_url: "https://github.com/testowner/testrepo" } }; + + const { main } = await loadModule(); + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Discussion number not found in event payload`); + }); + }); + + describe("Discussion comment reactions", () => { + it("should add reaction to discussion comment using GraphQL", async () => { + process.env.GH_AW_REACTION = "heart"; + global.context.eventName = "discussion_comment"; + global.context.payload = { + discussion: { number: 10 }, + comment: { id: 123, node_id: "DC_kwDOABcD1M4AaBbC", html_url: "https://github.com/testowner/testrepo/discussions/10#discussioncomment-123" }, + repository: { html_url: "https://github.com/testowner/testrepo" }, + }; + mockGithub.graphql.mockResolvedValueOnce({ addReaction: { reaction: { id: "MDg6UmVhY3Rpb24xMjM0NTY3ODk=", content: "HEART" } } }); + + const { addDiscussionReaction } = await loadModule(); + await addDiscussionReaction("DC_kwDOABcD1M4AaBbC", "heart"); + + expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("mutation"), expect.objectContaining({ subjectId: "DC_kwDOABcD1M4AaBbC", content: "HEART" })); + expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "MDg6UmVhY3Rpb24xMjM0NTY3ODk="); + }); + + it("should fail when discussion comment node_id is missing", async () => { + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "discussion_comment"; + global.context.payload = { + discussion: { number: 10 }, + comment: { id: 123 }, + repository: { html_url: "https://github.com/testowner/testrepo" }, + }; + + const { main } = await loadModule(); + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Discussion comment node ID not found in event payload`); + expect(mockGithub.graphql).not.toHaveBeenCalled(); + }); + + it("should create threaded comment for discussion_comment events", async () => { + process.env.GH_AW_REACTION = "eyes"; + process.env.GH_AW_WORKFLOW_NAME = "Discussion Bot"; + global.context.eventName = "discussion_comment"; + global.context.payload = { + discussion: { number: 10 }, + comment: { id: 123, node_id: "DC_kwDOABcD1M4AaBbC" }, + repository: { html_url: "https://github.com/testowner/testrepo" }, + }; + mockGithub.graphql + .mockResolvedValueOnce({ addReaction: { reaction: { id: "MDg6UmVhY3Rpb24xMjM0NTY3ODk=", content: "EYES" } } }) + .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOABcD1M4AaBbC", url: "https://github.com/testowner/testrepo/discussions/10" } } }) + .mockResolvedValueOnce({ + addDiscussionComment: { + comment: { id: "DC_kwDOABcD1M4AaBbE", url: "https://github.com/testowner/testrepo/discussions/10#discussioncomment-789" }, + }, }); - }), - describe("Discussion reactions", () => { - (it("should add reaction to discussion using GraphQL", async () => { - ((process.env.GH_AW_REACTION = "rocket"), - (global.context.eventName = "discussion"), - (global.context.payload = { discussion: { number: 10 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.graphql - .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOABcD1M4AaBbC", url: "https://github.com/testowner/testrepo/discussions/10" } } }) - .mockResolvedValueOnce({ addReaction: { reaction: { id: "MDg6UmVhY3Rpb24xMjM0NTY3ODk=", content: "ROCKET" } } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("query"), expect.objectContaining({ owner: "testowner", repo: "testrepo", num: 10 })), - expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("mutation"), expect.objectContaining({ subjectId: "D_kwDOABcD1M4AaBbC", content: "ROCKET" })), - expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "MDg6UmVhY3Rpb24xMjM0NTY3ODk=")); - }), - it("should map reaction types correctly for GraphQL", async () => { - const reactionTests = [ - { input: "+1", expected: "THUMBS_UP" }, - { input: "-1", expected: "THUMBS_DOWN" }, - { input: "laugh", expected: "LAUGH" }, - { input: "confused", expected: "CONFUSED" }, - { input: "heart", expected: "HEART" }, - { input: "hooray", expected: "HOORAY" }, - { input: "rocket", expected: "ROCKET" }, - { input: "eyes", expected: "EYES" }, - ]; - for (const test of reactionTests) - (vi.clearAllMocks(), - (process.env.GH_AW_REACTION = test.input), - (global.context.eventName = "discussion"), - (global.context.payload = { discussion: { number: 10 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.graphql - .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOABcD1M4AaBbC", url: "https://github.com/testowner/testrepo/discussions/10" } } }) - .mockResolvedValueOnce({ addReaction: { reaction: { id: "MDg6UmVhY3Rpb24xMjM0NTY3ODk=", content: test.expected } } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("mutation"), expect.objectContaining({ content: test.expected }))); - })); - }), - describe("Discussion comment reactions", () => { - (it("should add reaction to discussion comment using GraphQL", async () => { - ((process.env.GH_AW_REACTION = "heart"), - (global.context.eventName = "discussion_comment"), - (global.context.payload = { - discussion: { number: 10 }, - comment: { id: 123, node_id: "DC_kwDOABcD1M4AaBbC", html_url: "https://github.com/testowner/testrepo/discussions/10#discussioncomment-123" }, - repository: { html_url: "https://github.com/testowner/testrepo" }, - }), - mockGithub.graphql.mockResolvedValueOnce({ addReaction: { reaction: { id: "MDg6UmVhY3Rpb24xMjM0NTY3ODk=", content: "HEART" } } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("mutation"), expect.objectContaining({ subjectId: "DC_kwDOABcD1M4AaBbC", content: "HEART" })), - expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "MDg6UmVhY3Rpb24xMjM0NTY3ODk=")); - }), - it("should fail when discussion comment node_id is missing", async () => { - ((process.env.GH_AW_REACTION = "eyes"), - (global.context.eventName = "discussion_comment"), - (global.context.payload = { discussion: { number: 10 }, comment: { id: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Discussion comment node ID not found in event payload`), - expect(mockGithub.graphql).not.toHaveBeenCalled()); - })); - }), - describe("Comment creation (always creates new comments)", () => { - (it("should create comment for issue event", async () => { - ((process.env.GH_AW_REACTION = "eyes"), - (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"), - (global.context.eventName = "issues"), - (global.context.payload = { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.request.mockResolvedValueOnce({ data: { id: 456 } }).mockResolvedValueOnce({ data: { id: 789, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-789" } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/123/reactions", expect.objectContaining({ content: "eyes" })), - expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/123/comments", expect.objectContaining({ body: expect.stringContaining("has started processing this issue") })), - expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "456"), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "789"), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", "https://github.com/testowner/testrepo/issues/123#issuecomment-789")); - }), - it("should create new comment for issue_comment event (not edit)", async () => { - ((process.env.GH_AW_REACTION = "eyes"), - (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"), - (global.context.eventName = "issue_comment"), - (global.context.payload = { issue: { number: 123 }, comment: { id: 456 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.request.mockResolvedValueOnce({ data: { id: 111 } }).mockResolvedValueOnce({ data: { id: 789, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-789" } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/123/comments", expect.objectContaining({ body: expect.stringContaining("has started processing this issue comment") })), - expect(mockGithub.request).not.toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/comments/456", expect.anything()), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "789"), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", "https://github.com/testowner/testrepo/issues/123#issuecomment-789"), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-repo", "testowner/testrepo")); - }), - it("should create new comment for pull_request_review_comment event (not edit)", async () => { - ((process.env.GH_AW_REACTION = "rocket"), - (process.env.GH_AW_WORKFLOW_NAME = "PR Review Bot"), - (global.context.eventName = "pull_request_review_comment"), - (global.context.payload = { pull_request: { number: 456 }, comment: { id: 789 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.request.mockResolvedValueOnce({ data: { id: 222 } }).mockResolvedValueOnce({ data: { id: 999, html_url: "https://github.com/testowner/testrepo/pull/456#discussion_r999" } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/456/comments", expect.objectContaining({ body: expect.stringContaining("has started processing this pull request review comment") })), - expect(mockGithub.request).not.toHaveBeenCalledWith("POST /repos/testowner/testrepo/pulls/comments/789", expect.anything()), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "999"), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", "https://github.com/testowner/testrepo/pull/456#discussion_r999"), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-repo", "testowner/testrepo")); - }), - it("should create comment on discussion", async () => { - ((process.env.GH_AW_REACTION = "eyes"), - (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"), - (global.context.eventName = "discussion"), - (global.context.payload = { discussion: { number: 10 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.graphql - .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOABcD1M4AaBbC", url: "https://github.com/testowner/testrepo/discussions/10" } } }) - .mockResolvedValueOnce({ addReaction: { reaction: { id: "MDg6UmVhY3Rpb24xMjM0NTY3ODk=", content: "EYES" } } }) - .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOABcD1M4AaBbC" } } }) - .mockResolvedValueOnce({ addDiscussionComment: { comment: { id: "DC_kwDOABcD1M4AaBbE", url: "https://github.com/testowner/testrepo/discussions/10#discussioncomment-999" } } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockGithub.graphql).toHaveBeenCalledTimes(4), - expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("addDiscussionComment"), expect.objectContaining({ dId: "D_kwDOABcD1M4AaBbC", body: expect.stringContaining("has started processing this discussion") })), - expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "MDg6UmVhY3Rpb24xMjM0NTY3ODk="), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "DC_kwDOABcD1M4AaBbE"), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", "https://github.com/testowner/testrepo/discussions/10#discussioncomment-999")); - }), - it("should create new comment for discussion_comment events", async () => { - ((process.env.GH_AW_REACTION = "eyes"), - (process.env.GH_AW_WORKFLOW_NAME = "Discussion Bot"), - (global.context.eventName = "discussion_comment"), - (global.context.payload = { discussion: { number: 10 }, comment: { id: 123, node_id: "DC_kwDOABcD1M4AaBbC" }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.graphql - .mockResolvedValueOnce({ addReaction: { reaction: { id: "MDg6UmVhY3Rpb24xMjM0NTY3ODk=", content: "EYES" } } }) - .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOABcD1M4AaBbC" } } }) - .mockResolvedValueOnce({ addDiscussionComment: { comment: { id: "DC_kwDOABcD1M4AaBbE", url: "https://github.com/testowner/testrepo/discussions/10#discussioncomment-789" } } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockGithub.graphql).toHaveBeenCalledWith( - expect.stringContaining("addDiscussionComment"), - expect.objectContaining({ dId: "D_kwDOABcD1M4AaBbC", body: expect.stringContaining("has started processing this discussion comment"), replyToId: "DC_kwDOABcD1M4AaBbC" }) - ), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "DC_kwDOABcD1M4AaBbE"), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", "https://github.com/testowner/testrepo/discussions/10#discussioncomment-789"), - expect(mockCore.setOutput).toHaveBeenCalledWith("comment-repo", "testowner/testrepo")); - })); - }), - describe("Error handling", () => { - (it("should handle missing discussion number", async () => { - ((process.env.GH_AW_REACTION = "eyes"), - (global.context.eventName = "discussion"), - (global.context.payload = { repository: { html_url: "https://github.com/testowner/testrepo" } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Discussion number not found in event payload`)); - }), - it("should handle missing discussion or comment info for discussion_comment", async () => { - ((process.env.GH_AW_REACTION = "eyes"), - (global.context.eventName = "discussion_comment"), - (global.context.payload = { discussion: { number: 10 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Discussion or comment information not found in event payload`)); - }), - it("should handle unsupported event types", async () => { - ((process.env.GH_AW_REACTION = "eyes"), - (global.context.eventName = "push"), - (global.context.payload = { repository: { html_url: "https://github.com/testowner/testrepo" } }), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_VALIDATION}: Unsupported event type: push`)); - }), - it("should silently ignore locked issue errors (status 403)", async () => { - const lockedError = new Error("Issue is locked"); - lockedError.status = 403; - ((process.env.GH_AW_REACTION = "eyes"), - (global.context.eventName = "issues"), - (global.context.payload = { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.request.mockRejectedValueOnce(lockedError), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("resource is locked")), - expect(mockCore.error).not.toHaveBeenCalled(), - expect(mockCore.setFailed).not.toHaveBeenCalled()); - }), - it("should fail for errors with 'locked' message but non-403 status", async () => { - // Errors mentioning "locked" should only be ignored if they have 403 status - const lockedError = new Error("Lock conversation is enabled"); - lockedError.status = 500; // Not 403 - ((process.env.GH_AW_REACTION = "eyes"), - (global.context.eventName = "issues"), - (global.context.payload = { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.request.mockRejectedValueOnce(lockedError), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to process reaction")), - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to process reaction"))); - }), - it("should fail for 403 errors that don't mention locked", async () => { - const forbiddenError = new Error("Forbidden: insufficient permissions"); - forbiddenError.status = 403; - ((process.env.GH_AW_REACTION = "eyes"), - (global.context.eventName = "issues"), - (global.context.payload = { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.request.mockRejectedValueOnce(forbiddenError), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to process reaction")), - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to process reaction"))); - }), - it("should fail for other non-403 errors", async () => { - const serverError = new Error("Internal server error"); - serverError.status = 500; - ((process.env.GH_AW_REACTION = "eyes"), - (global.context.eventName = "issues"), - (global.context.payload = { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } }), - mockGithub.request.mockRejectedValueOnce(serverError), - await eval(`(async () => { ${reactionScript}; await main(); })()`), - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to process reaction")), - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to process reaction"))); - })); - })); - })); + + const { main } = await loadModule(); + await main(); + + expect(mockGithub.graphql).toHaveBeenCalledWith( + expect.stringContaining("addDiscussionComment"), + expect.objectContaining({ + dId: "D_kwDOABcD1M4AaBbC", + body: expect.stringContaining("has started processing this discussion comment"), + replyToId: "DC_kwDOABcD1M4AaBbC", + }) + ); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "DC_kwDOABcD1M4AaBbE"); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", "https://github.com/testowner/testrepo/discussions/10#discussioncomment-789"); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-repo", "testowner/testrepo"); + }); + + it("should fail when discussion or comment fields are missing", async () => { + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "discussion_comment"; + global.context.payload = { + discussion: { number: 10 }, + // Missing comment field + }; + + const { main } = await loadModule(); + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Discussion or comment information not found in event payload`); + }); + }); + + describe("Unsupported event types", () => { + it("should fail for unsupported event type", async () => { + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "push"; + global.context.payload = { repository: { html_url: "https://github.com/testowner/testrepo" } }; + + const { main } = await loadModule(); + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_VALIDATION}: Unsupported event type: push`); + }); + }); + + describe("Error handling", () => { + it("should silently ignore locked issue errors (status 403 + locked message)", async () => { + const lockedError = new Error("Issue is locked"); + /** @type {any} */ lockedError.status = 403; + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "issues"; + global.context.payload = { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } }; + mockGithub.request.mockRejectedValueOnce(lockedError); + + const { main } = await loadModule(); + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("resource is locked")); + expect(mockCore.error).not.toHaveBeenCalled(); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should fail for errors with 'locked' message but non-403 status", async () => { + const lockedError = new Error("Lock conversation is enabled"); + /** @type {any} */ lockedError.status = 500; + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "issues"; + global.context.payload = { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } }; + mockGithub.request.mockRejectedValueOnce(lockedError); + + const { main } = await loadModule(); + await main(); + + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to process reaction")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to process reaction")); + }); + + it("should fail for 403 errors that don't mention locked", async () => { + const forbiddenError = new Error("Forbidden: insufficient permissions"); + /** @type {any} */ forbiddenError.status = 403; + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "issues"; + global.context.payload = { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } }; + mockGithub.request.mockRejectedValueOnce(forbiddenError); + + const { main } = await loadModule(); + await main(); + + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to process reaction")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to process reaction")); + }); + + it("should fail for other non-403 errors", async () => { + const serverError = new Error("Internal server error"); + /** @type {any} */ serverError.status = 500; + process.env.GH_AW_REACTION = "eyes"; + global.context.eventName = "issues"; + global.context.payload = { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } }; + mockGithub.request.mockRejectedValueOnce(serverError); + + const { main } = await loadModule(); + await main(); + + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to process reaction")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining(`${ERR_API}: Failed to process reaction`)); + }); + }); + + describe("addCommentWithWorkflowLink() - markers", () => { + it("should include workflow-id marker when GITHUB_WORKFLOW is set", async () => { + process.env.GITHUB_WORKFLOW = "test-workflow.yml"; + mockGithub.request.mockResolvedValueOnce({ data: { id: 123, html_url: "https://example.com" } }); + + const { addCommentWithWorkflowLink } = await loadModule(); + await addCommentWithWorkflowLink("/repos/testowner/testrepo/issues/123/comments", "https://github.com/testowner/testrepo/actions/runs/12345", "issues"); + + expect(mockGithub.request).toHaveBeenCalledWith(expect.stringContaining("POST"), expect.objectContaining({ body: expect.stringContaining("") })); + }); + + it("should include tracker-id marker when GH_AW_TRACKER_ID is set", async () => { + process.env.GH_AW_TRACKER_ID = "tracker-123"; + mockGithub.request.mockResolvedValueOnce({ data: { id: 123, html_url: "https://example.com" } }); + + const { addCommentWithWorkflowLink } = await loadModule(); + await addCommentWithWorkflowLink("/repos/testowner/testrepo/issues/123/comments", "https://github.com/testowner/testrepo/actions/runs/12345", "issues"); + + expect(mockGithub.request).toHaveBeenCalledWith(expect.stringContaining("POST"), expect.objectContaining({ body: expect.stringContaining("") })); + }); + + it("should always include reaction comment type marker", async () => { + mockGithub.request.mockResolvedValueOnce({ data: { id: 123, html_url: "https://example.com" } }); + + const { addCommentWithWorkflowLink } = await loadModule(); + await addCommentWithWorkflowLink("/repos/testowner/testrepo/issues/123/comments", "https://github.com/testowner/testrepo/actions/runs/12345", "issues"); + + expect(mockGithub.request).toHaveBeenCalledWith(expect.stringContaining("POST"), expect.objectContaining({ body: expect.stringContaining("") })); + }); + + it("should add lock notice for issues event when GH_AW_LOCK_FOR_AGENT=true", async () => { + process.env.GH_AW_LOCK_FOR_AGENT = "true"; + mockGithub.request.mockResolvedValueOnce({ data: { id: 123, html_url: "https://example.com" } }); + + const { addCommentWithWorkflowLink } = await loadModule(); + await addCommentWithWorkflowLink("/repos/testowner/testrepo/issues/123/comments", "https://github.com/testowner/testrepo/actions/runs/12345", "issues"); + + expect(mockGithub.request).toHaveBeenCalledWith(expect.stringContaining("POST"), expect.objectContaining({ body: expect.stringContaining("šŸ”’ This issue has been locked") })); + }); + + it("should not add lock notice for pull_request events", async () => { + process.env.GH_AW_LOCK_FOR_AGENT = "true"; + mockGithub.request.mockResolvedValueOnce({ data: { id: 123, html_url: "https://example.com" } }); + + const { addCommentWithWorkflowLink } = await loadModule(); + await addCommentWithWorkflowLink("/repos/testowner/testrepo/issues/123/comments", "https://github.com/testowner/testrepo/actions/runs/12345", "pull_request"); + + expect(mockGithub.request).toHaveBeenCalledWith(expect.stringContaining("POST"), expect.objectContaining({ body: expect.not.stringContaining("šŸ”’ This issue has been locked") })); + }); + }); + + describe("addReaction()", () => { + it("should add reaction via REST API and set output", async () => { + mockGithub.request.mockResolvedValueOnce({ data: { id: 789 } }); + + const { addReaction } = await loadModule(); + await addReaction("/repos/testowner/testrepo/issues/123/reactions", "eyes"); + + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/testowner/testrepo/issues/123/reactions", expect.objectContaining({ content: "eyes" })); + expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", "789"); + }); + + it("should set empty reaction-id when response has no id", async () => { + mockGithub.request.mockResolvedValueOnce({ data: {} }); + + const { addReaction } = await loadModule(); + await addReaction("/repos/testowner/testrepo/issues/123/reactions", "eyes"); + + expect(mockCore.setOutput).toHaveBeenCalledWith("reaction-id", ""); + }); + }); +});