diff --git a/actions/setup/js/add_workflow_run_comment.cjs b/actions/setup/js/add_workflow_run_comment.cjs index 7b766e3177..b199783565 100644 --- a/actions/setup/js/add_workflow_run_comment.cjs +++ b/actions/setup/js/add_workflow_run_comment.cjs @@ -84,44 +84,25 @@ async function main() { try { switch (eventName) { - case "issues": { - const number = context.payload?.issue?.number; - if (!number) { - core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`); - return; - } - commentEndpoint = `/repos/${owner}/${repo}/issues/${number}/comments`; - break; - } - + case "issues": case "issue_comment": { const number = context.payload?.issue?.number; if (!number) { core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`); return; } - // Create new comment on the issue itself, not on the comment - commentEndpoint = `/repos/${owner}/${repo}/issues/${number}/comments`; - break; - } - - case "pull_request": { - const number = context.payload?.pull_request?.number; - if (!number) { - core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`); - return; - } commentEndpoint = `/repos/${owner}/${repo}/issues/${number}/comments`; break; } + case "pull_request": case "pull_request_review_comment": { const number = context.payload?.pull_request?.number; if (!number) { core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`); return; } - // Create new comment on the PR itself (using issues endpoint since PRs are issues) + // PRs use the issues comment endpoint commentEndpoint = `/repos/${owner}/${repo}/issues/${number}/comments`; break; } @@ -156,123 +137,118 @@ async function main() { await addCommentWithWorkflowLink(commentEndpoint, runUrl, eventName); } catch (error) { const errorMessage = getErrorMessage(error); - core.error(`Failed to create comment: ${errorMessage}`); // Don't fail the job - just warn since this is not critical core.warning(`Failed to create comment with workflow link: ${errorMessage}`); } } /** - * Add a comment with a workflow run link - * @param {string} endpoint - The GitHub API endpoint to create the comment (or special format for discussions) + * Build the comment body text for a workflow run link. + * Sanitizes the content and appends all required markers. + * @param {string} eventName - The event type * @param {string} runUrl - The URL of the workflow run - * @param {string} eventName - The event type (to determine the comment text) + * @returns {string} The assembled comment body */ -async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { - // Get workflow name from environment variable +function buildCommentBody(eventName, runUrl) { const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - - // Determine the event type description using lookup object const eventTypeDescription = EVENT_TYPE_DESCRIPTIONS[eventName] ?? "event"; - // Use getRunStartedMessage for the workflow link text (supports custom messages) - const workflowLinkText = getRunStartedMessage({ - workflowName: workflowName, - runUrl: 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 - let commentBody = sanitizeContent(workflowLinkText); + // Sanitize before adding markers (defense in depth for custom message templates) + let body = sanitizeContent(getRunStartedMessage({ workflowName, runUrl, eventType: eventTypeDescription })); // Add lock notice if lock-for-agent is enabled for issues or issue_comment - const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; - if (lockForAgent && (eventName === "issues" || eventName === "issue_comment")) { - commentBody += "\n\nšŸ”’ This issue has been locked while the workflow is running to prevent concurrent modifications."; + if (process.env.GH_AW_LOCK_FOR_AGENT === "true" && (eventName === "issues" || eventName === "issue_comment")) { + body += "\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 + // Add workflow-id marker 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)}`; + body += `\n\n${generateWorkflowIdMarker(workflowId)}`; } - // Add tracker-id marker if available (for backwards compatibility) + // Add tracker-id marker for backwards compatibility + const trackerId = process.env.GH_AW_TRACKER_ID || ""; if (trackerId) { - commentBody += `\n\n`; + body += `\n\n`; } - // Add comment type marker to identify this as a reaction comment - // This prevents it from being hidden by hide-older-comments - commentBody += `\n\n`; + // Identify this as a reaction comment (prevents it from being hidden by hide-older-comments) + body += `\n\n`; - // Handle discussion events specially - if (eventName === "discussion") { - // Parse discussion number from special format: "discussion:NUMBER" - const discussionNumber = parseInt(endpoint.split(":")[1], 10); + return body; +} - // Get discussion node ID using helper function - const discussionId = await getDiscussionNodeId(discussionNumber); +/** + * Post a GraphQL comment to a discussion, optionally as a threaded reply. + * @param {number} discussionNumber - The discussion number + * @param {string} commentBody - The comment body + * @param {string|null} replyToNodeId - Parent comment node ID for threading (null for top-level) + */ +async function postDiscussionComment(discussionNumber, commentBody, replyToNodeId = null) { + const discussionId = await getDiscussionNodeId(discussionNumber); - const result = await github.graphql( + /** @type {any} */ + let result; + if (replyToNodeId) { + result = await github.graphql( + ` + mutation($dId: ID!, $body: String!, $replyToId: ID!) { + addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { + comment { id url } + } + }`, + { dId: discussionId, body: commentBody, replyToId: replyToNodeId } + ); + } else { + result = await github.graphql( ` mutation($dId: ID!, $body: String!) { addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } + comment { id url } } }`, { dId: discussionId, body: commentBody } ); + } - const comment = result.addDiscussionComment.comment; - setCommentOutputs(comment.id, comment.url); + const comment = result.addDiscussionComment.comment; + setCommentOutputs(comment.id, comment.url); +} + +/** + * Add a comment with a workflow run link + * @param {string} endpoint - The GitHub API endpoint to create the comment (or special format for discussions) + * @param {string} runUrl - The URL of the workflow run + * @param {string} eventName - The event type (to determine the comment text) + */ +async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { + const commentBody = buildCommentBody(eventName, runUrl); + + if (eventName === "discussion") { + // Parse discussion number from special format: "discussion:NUMBER" + const discussionNumber = parseInt(endpoint.split(":")[1], 10); + await postDiscussionComment(discussionNumber, commentBody); return; - } else if (eventName === "discussion_comment") { + } + + if (eventName === "discussion_comment") { // Parse discussion number from special format: "discussion_comment:NUMBER:COMMENT_ID" const discussionNumber = parseInt(endpoint.split(":")[1], 10); - // Get discussion node ID using helper function - const discussionId = await getDiscussionNodeId(discussionNumber); - - // Get the comment node ID to use as the parent for threading. - // GitHub Discussions only supports two nesting levels, so if the triggering comment is - // itself a reply, we resolve the top-level parent's node ID. + // GitHub Discussions only supports two nesting levels, so resolve the top-level parent's node ID const commentNodeId = await resolveTopLevelDiscussionCommentId(github, context.payload?.comment?.node_id); - - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: commentBody, replyToId: commentNodeId } - ); - - const comment = result.addDiscussionComment.comment; - setCommentOutputs(comment.id, comment.url); + await postDiscussionComment(discussionNumber, commentBody, commentNodeId); return; } // Create a new comment for non-discussion events const createResponse = await github.request("POST " + endpoint, { body: commentBody, - headers: { - Accept: "application/vnd.github+json", - }, + headers: { Accept: "application/vnd.github+json" }, }); setCommentOutputs(createResponse.data.id, createResponse.data.html_url); } -module.exports = { main, addCommentWithWorkflowLink }; +module.exports = { main, addCommentWithWorkflowLink, buildCommentBody, postDiscussionComment }; diff --git a/actions/setup/js/add_workflow_run_comment.test.cjs b/actions/setup/js/add_workflow_run_comment.test.cjs index 398848a0c9..51cde9c5c4 100644 --- a/actions/setup/js/add_workflow_run_comment.test.cjs +++ b/actions/setup/js/add_workflow_run_comment.test.cjs @@ -230,6 +230,20 @@ describe("add_workflow_run_comment", () => { ); expect(mockCore.setFailed).not.toHaveBeenCalled(); }); + + it("should fail when PR number is missing in pull_request_review_comment event", async () => { + global.context = { + eventName: "pull_request_review_comment", + runId: 12345, + repo: { owner: "testowner", repo: "testrepo" }, + payload: {}, + }; + + await runScript(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Pull request number not found in event payload`); + expect(mockGithub.request).not.toHaveBeenCalled(); + }); }); describe("main() - discussion event", () => { @@ -341,9 +355,9 @@ describe("add_workflow_run_comment", () => { await runScript(); - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to create comment")); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to create comment with workflow link")); - // Should NOT call setFailed - errors should only warn + // Should NOT use core.error or core.setFailed for non-critical errors + expect(mockCore.error).not.toHaveBeenCalled(); expect(mockCore.setFailed).not.toHaveBeenCalled(); }); }); @@ -475,4 +489,84 @@ describe("add_workflow_run_comment", () => { ); }); }); + + describe("buildCommentBody()", () => { + it("should include the run URL in the comment body", async () => { + const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + const body = buildCommentBody("issues", "https://github.com/testowner/testrepo/actions/runs/99"); + expect(body).toContain("https://github.com/testowner/testrepo/actions/runs/99"); + }); + + it("should always include reaction comment type marker", async () => { + const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + const body = buildCommentBody("issues", "https://example.com/run/1"); + expect(body).toContain(""); + }); + + it("should include workflow-id marker when GITHUB_WORKFLOW is set", async () => { + process.env.GITHUB_WORKFLOW = "my-workflow.yml"; + const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + const body = buildCommentBody("issues", "https://example.com/run/1"); + expect(body).toContain(""); + }); + + it("should include tracker-id marker when GH_AW_TRACKER_ID is set", async () => { + process.env.GH_AW_TRACKER_ID = "my-tracker"; + const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + const body = buildCommentBody("issues", "https://example.com/run/1"); + expect(body).toContain(""); + }); + + it("should add lock notice for issues event when GH_AW_LOCK_FOR_AGENT=true", async () => { + process.env.GH_AW_LOCK_FOR_AGENT = "true"; + const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + const body = buildCommentBody("issues", "https://example.com/run/1"); + expect(body).toContain("šŸ”’ This issue has been locked"); + }); + + it("should not add lock notice for pull_request events", async () => { + process.env.GH_AW_LOCK_FOR_AGENT = "true"; + const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + const body = buildCommentBody("pull_request", "https://example.com/run/1"); + expect(body).not.toContain("šŸ”’ This issue has been locked"); + }); + + it("should use unknown event type description for unrecognized events", async () => { + const { buildCommentBody } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + // Should not throw for unknown event types + const body = buildCommentBody("some_unknown_event", "https://example.com/run/1"); + expect(body).toBeTruthy(); + expect(body).toContain(""); + }); + }); + + describe("postDiscussionComment()", () => { + it("should post a top-level discussion comment when no replyToNodeId", async () => { + const { postDiscussionComment } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + await postDiscussionComment(10, "Test body"); + + expect(mockGithub.graphql).toHaveBeenCalled(); + const mutationCall = mockGithub.graphql.mock.calls.find(call => call[0].includes("addDiscussionComment")); + expect(mutationCall).toBeDefined(); + expect(mutationCall[1]).toMatchObject({ body: "Test body" }); + expect(mutationCall[1]).not.toHaveProperty("replyToId"); + }); + + it("should post a threaded comment when replyToNodeId is provided", async () => { + const { postDiscussionComment } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + await postDiscussionComment(10, "Reply body", "DC_kwParent123"); + + const mutationCall = mockGithub.graphql.mock.calls.find(call => call[0].includes("replyToId")); + expect(mutationCall).toBeDefined(); + expect(mutationCall[1]).toMatchObject({ body: "Reply body", replyToId: "DC_kwParent123" }); + }); + + it("should set comment outputs after posting", async () => { + const { postDiscussionComment } = await import("./add_workflow_run_comment.cjs?" + Date.now()); + await postDiscussionComment(10, "Test body"); + + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-id", "DC_kwDOTest456"); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-url", expect.stringContaining("discussioncomment-456")); + }); + }); });