Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 104 additions & 11 deletions actions/setup/js/add_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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!) {
Expand Down Expand Up @@ -325,6 +393,13 @@ async function main(config = {}) {
* @returns {Promise<Object>} 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`);
Expand Down Expand Up @@ -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");
Expand All @@ -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,
Expand Down Expand Up @@ -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}`);
Expand Down
217 changes: 217 additions & 0 deletions actions/setup/js/add_comment.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading