Skip to content

Commit 57b9a2b

Browse files
authored
feat(add-comment): support reply_to_id for discussion threading from any trigger (#24367)
1 parent 50f9435 commit 57b9a2b

5 files changed

Lines changed: 401 additions & 17 deletions

File tree

actions/setup/js/add_comment.cjs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,13 @@ async function main(config = {}) {
573573
return { success: true, commentId: comment.id, url: comment.html_url, itemNumber, repo: itemRepo, isDiscussion: isDiscussionFlag };
574574
};
575575

576+
// Normalize reply_to_id once so both the main discussion path and the
577+
// 404 discussion fallback path use the same validated value.
578+
const normalizedExplicitReplyToId = message.reply_to_id === undefined || message.reply_to_id === null ? null : String(message.reply_to_id).trim();
579+
if (message.reply_to_id !== undefined && message.reply_to_id !== null && !normalizedExplicitReplyToId) {
580+
core.warning("Ignoring empty discussion reply_to_id after normalization");
581+
}
582+
576583
try {
577584
// Hide older comments if enabled AND append-only-comments is not enabled
578585
// When append-only-comments is true, we want to keep all comments visible
@@ -590,7 +597,18 @@ async function main(config = {}) {
590597
// GitHub Discussions only supports two nesting levels, so if the triggering comment is
591598
// itself a reply, we resolve the top-level parent's node ID to use as replyToId.
592599
const hasExplicitItemNumber = message.item_number !== undefined && message.item_number !== null;
593-
const replyToId = context.eventName === "discussion_comment" && !hasExplicitItemNumber ? await resolveTopLevelDiscussionCommentId(githubClient, context.payload?.comment?.node_id) : null;
600+
let replyToId;
601+
if (context.eventName === "discussion_comment" && !hasExplicitItemNumber) {
602+
// When triggered by a discussion_comment event, thread the reply under the triggering comment.
603+
replyToId = await resolveTopLevelDiscussionCommentId(githubClient, context.payload?.comment?.node_id);
604+
} else if (normalizedExplicitReplyToId) {
605+
// Allow the agent to explicitly specify a reply_to_id (e.g. for workflow_dispatch-triggered
606+
// workflows that know the target comment node ID). Apply resolveTopLevelDiscussionCommentId
607+
// to handle cases where the caller passes a reply node ID instead of a top-level one.
608+
replyToId = await resolveTopLevelDiscussionCommentId(githubClient, normalizedExplicitReplyToId);
609+
} else {
610+
replyToId = null;
611+
}
594612
if (replyToId) {
595613
core.info(`Replying as threaded comment to discussion comment node ID: ${replyToId}`);
596614
}
@@ -621,7 +639,13 @@ async function main(config = {}) {
621639

622640
try {
623641
core.info(`Trying #${itemNumber} as discussion...`);
624-
const comment = await commentOnDiscussion(githubClient, repoParts.owner, repoParts.repo, itemNumber, processedBody, null);
642+
// When retrying as discussion, honour the normalized reply_to_id from the message.
643+
// Apply resolveTopLevelDiscussionCommentId to handle nested reply node IDs.
644+
const fallbackReplyToId = normalizedExplicitReplyToId ? await resolveTopLevelDiscussionCommentId(githubClient, normalizedExplicitReplyToId) : null;
645+
if (fallbackReplyToId) {
646+
core.info(`Replying as threaded comment to discussion comment node ID: ${fallbackReplyToId}`);
647+
}
648+
const comment = await commentOnDiscussion(githubClient, repoParts.owner, repoParts.repo, itemNumber, processedBody, fallbackReplyToId);
625649

626650
core.info(`Created comment on discussion: ${comment.html_url}`);
627651
return recordComment(comment, true);

actions/setup/js/add_comment.test.cjs

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,311 @@ describe("add_comment", () => {
594594
// Should NOT be threaded since item_number was explicitly provided
595595
expect(capturedReplyToId).toBeUndefined();
596596
});
597+
598+
it("should use reply_to_id from message when not triggered by discussion_comment event", async () => {
599+
const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
600+
601+
// workflow_dispatch trigger (not discussion_comment); item_number refers to a discussion
602+
mockContext.eventName = "workflow_dispatch";
603+
mockContext.payload = {};
604+
605+
// Make REST API return 404 so the code falls back to the discussion path
606+
mockGithub.rest.issues.createComment = async () => {
607+
const err = new Error("Not Found");
608+
// @ts-expect-error - Simulating GitHub REST API error
609+
err.status = 404;
610+
throw err;
611+
};
612+
613+
let capturedReplyToId = undefined;
614+
mockGithub.graphql = async (query, variables) => {
615+
if (query.includes("addDiscussionComment")) {
616+
capturedReplyToId = variables?.replyToId;
617+
return {
618+
addDiscussionComment: {
619+
comment: {
620+
id: "DC_kwDOTest456",
621+
body: variables?.body,
622+
createdAt: "2024-01-01",
623+
url: "https://github.com/owner/repo/discussions/10#discussioncomment-456",
624+
},
625+
},
626+
};
627+
}
628+
// Mock replyTo resolution: the provided reply_to_id is already top-level (no parent)
629+
if (query.includes("replyTo")) {
630+
return { node: { replyTo: null } };
631+
}
632+
return {
633+
repository: {
634+
discussion: {
635+
id: "D_kwDOTest123",
636+
url: "https://github.com/owner/repo/discussions/10",
637+
},
638+
},
639+
};
640+
};
641+
642+
const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`);
643+
644+
const message = {
645+
type: "add_comment",
646+
item_number: 10,
647+
body: "🔄 Updated finding...",
648+
reply_to_id: "DC_kwDOParentComment456",
649+
};
650+
651+
const result = await handler(message, {});
652+
653+
expect(result.success).toBe(true);
654+
expect(result.isDiscussion).toBe(true);
655+
// Should use the reply_to_id from the message
656+
expect(capturedReplyToId).toBe("DC_kwDOParentComment456");
657+
});
658+
659+
it("should resolve top-level parent when reply_to_id points to a nested reply", async () => {
660+
const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
661+
662+
// workflow_dispatch trigger (not discussion_comment); item_number refers to a discussion
663+
mockContext.eventName = "workflow_dispatch";
664+
mockContext.payload = {};
665+
666+
// Make REST API return 404 so the code falls back to the discussion path
667+
mockGithub.rest.issues.createComment = async () => {
668+
const err = new Error("Not Found");
669+
// @ts-expect-error - Simulating GitHub REST API error
670+
err.status = 404;
671+
throw err;
672+
};
673+
674+
let capturedReplyToId = undefined;
675+
mockGithub.graphql = async (query, variables) => {
676+
if (query.includes("addDiscussionComment")) {
677+
capturedReplyToId = variables?.replyToId;
678+
return {
679+
addDiscussionComment: {
680+
comment: {
681+
id: "DC_kwDOTest456",
682+
body: variables?.body,
683+
createdAt: "2024-01-01",
684+
url: "https://github.com/owner/repo/discussions/10#discussioncomment-456",
685+
},
686+
},
687+
};
688+
}
689+
// Mock replyTo resolution: the provided reply_to_id is itself a reply, return its parent
690+
if (query.includes("replyTo")) {
691+
return { node: { replyTo: { id: "DC_kwDOTopLevelComment123" } } };
692+
}
693+
return {
694+
repository: {
695+
discussion: {
696+
id: "D_kwDOTest123",
697+
url: "https://github.com/owner/repo/discussions/10",
698+
},
699+
},
700+
};
701+
};
702+
703+
const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`);
704+
705+
const message = {
706+
type: "add_comment",
707+
item_number: 10,
708+
body: "🔄 Updated finding...",
709+
reply_to_id: "DC_kwDONestedReply789",
710+
};
711+
712+
const result = await handler(message, {});
713+
714+
expect(result.success).toBe(true);
715+
expect(result.isDiscussion).toBe(true);
716+
// Should resolve to the top-level parent, not the nested reply
717+
expect(capturedReplyToId).toBe("DC_kwDOTopLevelComment123");
718+
});
719+
720+
it("should ignore reply_to_id when triggered by discussion_comment event without explicit item_number", async () => {
721+
const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
722+
723+
// discussion_comment trigger takes precedence over reply_to_id
724+
mockContext.eventName = "discussion_comment";
725+
mockContext.payload = {
726+
discussion: {
727+
number: 10,
728+
},
729+
comment: {
730+
node_id: "DC_kwDOTriggeringComment123",
731+
},
732+
};
733+
734+
let capturedReplyToId = undefined;
735+
mockGithub.graphql = async (query, variables) => {
736+
if (query.includes("addDiscussionComment")) {
737+
capturedReplyToId = variables?.replyToId;
738+
return {
739+
addDiscussionComment: {
740+
comment: {
741+
id: "DC_kwDOTest456",
742+
body: variables?.body,
743+
createdAt: "2024-01-01",
744+
url: "https://github.com/owner/repo/discussions/10#discussioncomment-456",
745+
},
746+
},
747+
};
748+
}
749+
// Mock replyTo resolution: triggering comment is top-level (no parent)
750+
if (query.includes("replyTo")) {
751+
return { node: { replyTo: null } };
752+
}
753+
return {
754+
repository: {
755+
discussion: {
756+
id: "D_kwDOTest123",
757+
url: "https://github.com/owner/repo/discussions/10",
758+
},
759+
},
760+
};
761+
};
762+
763+
const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`);
764+
765+
const message = {
766+
type: "add_comment",
767+
body: "Reply that provides reply_to_id but should use triggering comment instead",
768+
reply_to_id: "DC_kwDOShouldBeIgnored999",
769+
};
770+
771+
const result = await handler(message, {});
772+
773+
expect(result.success).toBe(true);
774+
expect(result.isDiscussion).toBe(true);
775+
// Should use the triggering comment node_id, not the reply_to_id from the message
776+
expect(capturedReplyToId).toBe("DC_kwDOTriggeringComment123");
777+
});
778+
779+
it("should ignore and warn when reply_to_id is a whitespace-only string", async () => {
780+
const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
781+
782+
mockContext.eventName = "workflow_dispatch";
783+
mockContext.payload = {};
784+
785+
// Capture warning calls
786+
const warningCalls = [];
787+
mockCore.warning = msg => {
788+
warningCalls.push(msg);
789+
};
790+
791+
// Make REST API return 404 so the code falls back to the discussion path
792+
mockGithub.rest.issues.createComment = async () => {
793+
const err = new Error("Not Found");
794+
// @ts-expect-error - Simulating GitHub REST API error
795+
err.status = 404;
796+
throw err;
797+
};
798+
799+
let capturedReplyToId = undefined;
800+
mockGithub.graphql = async (query, variables) => {
801+
if (query.includes("addDiscussionComment")) {
802+
capturedReplyToId = variables?.replyToId;
803+
return {
804+
addDiscussionComment: {
805+
comment: {
806+
id: "DC_kwDOTest456",
807+
body: variables?.body,
808+
createdAt: "2024-01-01",
809+
url: "https://github.com/owner/repo/discussions/10#discussioncomment-456",
810+
},
811+
},
812+
};
813+
}
814+
return {
815+
repository: {
816+
discussion: {
817+
id: "D_kwDOTest123",
818+
url: "https://github.com/owner/repo/discussions/10",
819+
},
820+
},
821+
};
822+
};
823+
824+
const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`);
825+
826+
const message = {
827+
type: "add_comment",
828+
item_number: 10,
829+
body: "Comment with blank reply_to_id",
830+
reply_to_id: " ",
831+
};
832+
833+
const result = await handler(message, {});
834+
835+
expect(result.success).toBe(true);
836+
expect(result.isDiscussion).toBe(true);
837+
// Whitespace-only reply_to_id should be ignored (post top-level)
838+
expect(capturedReplyToId).toBeUndefined();
839+
expect(warningCalls).toContain("Ignoring empty discussion reply_to_id after normalization");
840+
});
841+
842+
it("should coerce numeric reply_to_id to string before use", async () => {
843+
const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
844+
845+
mockContext.eventName = "workflow_dispatch";
846+
mockContext.payload = {};
847+
848+
// Make REST API return 404 so the code falls back to the discussion path
849+
mockGithub.rest.issues.createComment = async () => {
850+
const err = new Error("Not Found");
851+
// @ts-expect-error - Simulating GitHub REST API error
852+
err.status = 404;
853+
throw err;
854+
};
855+
856+
let capturedReplyToId = undefined;
857+
mockGithub.graphql = async (query, variables) => {
858+
if (query.includes("addDiscussionComment")) {
859+
capturedReplyToId = variables?.replyToId;
860+
return {
861+
addDiscussionComment: {
862+
comment: {
863+
id: "DC_kwDOTest456",
864+
body: variables?.body,
865+
createdAt: "2024-01-01",
866+
url: "https://github.com/owner/repo/discussions/10#discussioncomment-456",
867+
},
868+
},
869+
};
870+
}
871+
// Mock replyTo resolution: node ID is top-level (no parent)
872+
if (query.includes("replyTo")) {
873+
return { node: { replyTo: null } };
874+
}
875+
return {
876+
repository: {
877+
discussion: {
878+
id: "D_kwDOTest123",
879+
url: "https://github.com/owner/repo/discussions/10",
880+
},
881+
},
882+
};
883+
};
884+
885+
const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`);
886+
887+
const message = {
888+
type: "add_comment",
889+
item_number: 10,
890+
body: "Comment with numeric reply_to_id",
891+
// @ts-expect-error - intentionally passing a number to test coercion
892+
reply_to_id: 12345,
893+
};
894+
895+
const result = await handler(message, {});
896+
897+
expect(result.success).toBe(true);
898+
expect(result.isDiscussion).toBe(true);
899+
// Numeric reply_to_id should be coerced to "12345"
900+
expect(capturedReplyToId).toBe("12345");
901+
});
597902
});
598903

599904
describe("regression test for wrong PR bug", () => {

0 commit comments

Comments
 (0)