diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index 7c8ce5a9597..20f6fc94c7c 100644 --- a/actions/setup/js/add_comment.cjs +++ b/actions/setup/js/add_comment.cjs @@ -25,10 +25,78 @@ const { ERR_NOT_FOUND } = require("./error_codes.cjs"); const { isPayloadUserBot } = require("./resolve_mentions.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { generateHistoryUrl } = require("./generate_history_link.cjs"); +const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); /** @type {string} Safe output type handled by this module */ const HANDLER_TYPE = "add_comment"; +/** + * Resolve effective event name/payload for native and forwarded contexts. + * Supports: + * - workflow_dispatch with event_name/event_payload inputs (via resolveInvocationContext) + * - workflow_call/workflow_dispatch with aw_context input fallback + * + * Precedence: + * 1) Start with the raw GitHub Actions context + * 2) Apply resolveInvocationContext normalization/overrides + * 3) Apply aw_context fallback only for relayed pull_request_review_comment metadata + * (this intentionally overrides event name/payload identifiers when present) + * @param {any} rawContext + * @returns {{ eventName: string, payload: any }} + */ +function resolveEffectiveEventContext(rawContext) { + let eventName = rawContext?.eventName || ""; + let payload = rawContext?.payload || {}; + + try { + const invocation = resolveInvocationContext(rawContext); + if (invocation?.eventName) { + eventName = invocation.eventName; + } + if (invocation?.eventPayload && typeof invocation.eventPayload === "object") { + payload = invocation.eventPayload; + } + } catch { + // Best-effort only; fall back to the raw context. + } + + // For workflow_call (and workflow_dispatch relay cases), aw_context can carry + // the original event type/item/comment identifiers. This runs after + // resolveInvocationContext on purpose so aw_context can act as the final fallback. + const awContextRaw = rawContext?.payload?.inputs?.aw_context; + if (typeof awContextRaw === "string" && awContextRaw.trim() !== "") { + try { + const awContext = JSON.parse(awContextRaw); + const awEventType = typeof awContext?.event_type === "string" ? awContext.event_type : ""; + const awItemNumber = Number(awContext?.item_number); + const awCommentId = Number(awContext?.comment_id); + + if (awEventType === "pull_request_review_comment" && Number.isInteger(awItemNumber) && awItemNumber > 0) { + eventName = awEventType; + payload = { + ...payload, + pull_request: { + ...(payload?.pull_request || {}), + number: awItemNumber, + }, + ...(Number.isInteger(awCommentId) && awCommentId > 0 + ? { + comment: { + ...(payload?.comment || {}), + id: awCommentId, + }, + } + : {}), + }; + } + } catch { + // Ignore malformed aw_context and continue with existing context. + } + } + + return { eventName, payload }; +} + async function minimizeComment(github, nodeId, reason = "outdated") { const query = /* GraphQL */ ` mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { @@ -325,6 +393,13 @@ async function main(config = {}) { * @returns {Promise} Result */ return async function handleAddComment(message, resolvedTemporaryIds) { + const effectiveEventContext = resolveEffectiveEventContext(context); + const effectiveContext = { + ...context, + eventName: effectiveEventContext.eventName, + payload: effectiveEventContext.payload, + }; + // Check max limit if (processedCount >= maxCount) { core.warning(`Skipping add_comment: max count of ${maxCount} reached`); @@ -390,12 +465,12 @@ async function main(config = {}) { core.info(`Using explicitly provided item_number: #${itemNumber}`); } else { // Check if this is a discussion context - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; + const isDiscussionContext = effectiveContext.eventName === "discussion" || effectiveContext.eventName === "discussion_comment"; if (isDiscussionContext) { // For discussions, always use the discussion context isDiscussion = true; - itemNumber = context.payload?.discussion?.number; + itemNumber = effectiveContext.payload?.discussion?.number; if (!itemNumber) { core.warning("Discussion context detected but no discussion number found"); @@ -411,7 +486,7 @@ async function main(config = {}) { const targetResult = resolveTarget({ targetConfig: commentTarget, item: message, - context: context, + context: effectiveContext, itemType: "add_comment", supportsPR: true, // add_comment supports both issues and PRs supportsIssue: false, @@ -617,14 +692,32 @@ async function main(config = {}) { } comment = await commentOnDiscussion(githubClient, repoParts.owner, repoParts.repo, itemNumber, processedBody, replyToId); } else { - // Use REST API for issues/PRs - const { data } = await githubClient.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: itemNumber, - body: processedBody, - }); - comment = data; + const shouldReplyToTriggeringPRReviewComment = effectiveContext.eventName === "pull_request_review_comment" && explicitItemNumber === undefined; + const triggeringReviewCommentId = Number(effectiveContext.payload?.comment?.id); + + if (shouldReplyToTriggeringPRReviewComment && Number.isInteger(triggeringReviewCommentId) && triggeringReviewCommentId > 0) { + core.info(`Replying inline to triggering PR review comment ID: ${triggeringReviewCommentId}`); + const { data } = await githubClient.rest.pulls.createReplyForReviewComment({ + owner: repoParts.owner, + repo: repoParts.repo, + pull_number: itemNumber, + comment_id: triggeringReviewCommentId, + body: processedBody, + }); + comment = data; + } else { + if (shouldReplyToTriggeringPRReviewComment) { + core.warning("Triggering PR review comment ID is missing or invalid; falling back to top-level PR comment"); + } + // Use REST API for issues/PRs + const { data } = await githubClient.rest.issues.createComment({ + owner: repoParts.owner, + repo: repoParts.repo, + issue_number: itemNumber, + body: processedBody, + }); + comment = data; + } } core.info(`Created comment: ${comment.html_url}`); diff --git a/actions/setup/js/add_comment.test.cjs b/actions/setup/js/add_comment.test.cjs index 9bcd6bd5a49..e5fe5d258fb 100644 --- a/actions/setup/js/add_comment.test.cjs +++ b/actions/setup/js/add_comment.test.cjs @@ -42,6 +42,14 @@ describe("add_comment", () => { }), listComments: async () => ({ data: [] }), }, + pulls: { + createReplyForReviewComment: async () => ({ + data: { + id: 99999, + html_url: "https://github.com/owner/repo/pull/8535#discussion_r99999", + }, + }), + }, }, graphql: async () => ({ repository: { @@ -355,6 +363,215 @@ describe("add_comment", () => { expect(skipInfo).toBeTruthy(); expect(warningCalls.filter(msg => msg.includes("triggering")).length).toBe(0); }); + + it("should reply inline to triggering PR review comment when item_number is not provided", async () => { + const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); + + mockContext.eventName = "pull_request_review_comment"; + mockContext.payload = { + pull_request: { + number: 8535, + }, + comment: { + id: 777, + }, + }; + + let capturedReplyParams = null; + let issueCommentCalled = false; + mockGithub.rest.pulls.createReplyForReviewComment = async params => { + capturedReplyParams = params; + return { + data: { + id: 56789, + html_url: "https://github.com/owner/repo/pull/8535#discussion_r56789", + }, + }; + }; + mockGithub.rest.issues.createComment = async () => { + issueCommentCalled = true; + return { + data: { + id: 12345, + html_url: "https://github.com/owner/repo/issues/8535#issuecomment-12345", + }, + }; + }; + + const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); + + const message = { + type: "add_comment", + body: "Inline reply for review thread", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.itemNumber).toBe(8535); + expect(result.isDiscussion).toBe(false); + expect(issueCommentCalled).toBe(false); + expect(capturedReplyParams).toEqual( + expect.objectContaining({ + owner: "owner", + repo: "repo", + pull_number: 8535, + comment_id: 777, + }) + ); + }); + + it("should keep top-level comment behavior for pull_request_review_comment when item_number is explicitly provided", async () => { + const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); + + mockContext.eventName = "pull_request_review_comment"; + mockContext.payload = { + pull_request: { + number: 8535, + }, + comment: { + id: 777, + }, + }; + + let capturedIssueNumber = null; + let reviewReplyCalled = false; + mockGithub.rest.issues.createComment = async params => { + capturedIssueNumber = params.issue_number; + return { + data: { + id: 12345, + html_url: "https://github.com/owner/repo/issues/970#issuecomment-12345", + }, + }; + }; + mockGithub.rest.pulls.createReplyForReviewComment = async () => { + reviewReplyCalled = true; + return { + data: { + id: 56789, + html_url: "https://github.com/owner/repo/pull/8535#discussion_r56789", + }, + }; + }; + + const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); + + const message = { + type: "add_comment", + item_number: 970, + body: "Top-level comment on explicit item number", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(capturedIssueNumber).toBe(970); + expect(reviewReplyCalled).toBe(false); + }); + + it("should reply inline when pull_request_review_comment context is forwarded via workflow_dispatch inputs", async () => { + const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); + + mockContext.eventName = "workflow_dispatch"; + mockContext.payload = { + inputs: { + event_name: "pull_request_review_comment", + event_payload: JSON.stringify({ + pull_request: { number: 8535 }, + comment: { id: 777 }, + }), + }, + }; + + let capturedReplyParams = null; + let issueCommentCalled = false; + mockGithub.rest.pulls.createReplyForReviewComment = async params => { + capturedReplyParams = params; + return { + data: { + id: 56789, + html_url: "https://github.com/owner/repo/pull/8535#discussion_r56789", + }, + }; + }; + mockGithub.rest.issues.createComment = async () => { + issueCommentCalled = true; + return { + data: { + id: 12345, + html_url: "https://github.com/owner/repo/issues/8535#issuecomment-12345", + }, + }; + }; + + const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); + + const result = await handler({ type: "add_comment", body: "Inline reply from workflow_dispatch relay" }, {}); + + expect(result.success).toBe(true); + expect(issueCommentCalled).toBe(false); + expect(capturedReplyParams).toEqual( + expect.objectContaining({ + owner: "owner", + repo: "repo", + pull_number: 8535, + comment_id: 777, + }) + ); + }); + + it("should reply inline when pull_request_review_comment context is forwarded via workflow_call aw_context", async () => { + const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); + + mockContext.eventName = "workflow_call"; + mockContext.payload = { + inputs: { + aw_context: JSON.stringify({ + event_type: "pull_request_review_comment", + item_number: "8535", + comment_id: "777", + }), + }, + }; + + let capturedReplyParams = null; + let issueCommentCalled = false; + mockGithub.rest.pulls.createReplyForReviewComment = async params => { + capturedReplyParams = params; + return { + data: { + id: 56789, + html_url: "https://github.com/owner/repo/pull/8535#discussion_r56789", + }, + }; + }; + mockGithub.rest.issues.createComment = async () => { + issueCommentCalled = true; + return { + data: { + id: 12345, + html_url: "https://github.com/owner/repo/issues/8535#issuecomment-12345", + }, + }; + }; + + const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); + + const result = await handler({ type: "add_comment", body: "Inline reply from workflow_call relay" }, {}); + + expect(result.success).toBe(true); + expect(result.itemNumber).toBe(8535); + expect(issueCommentCalled).toBe(false); + expect(capturedReplyParams).toEqual( + expect.objectContaining({ + owner: "owner", + repo: "repo", + pull_number: 8535, + comment_id: 777, + }) + ); + }); }); describe("discussion support", () => {