diff --git a/actions/setup/js/close_expired_discussions.cjs b/actions/setup/js/close_expired_discussions.cjs index b7377b401e..4e77937ed4 100644 --- a/actions/setup/js/close_expired_discussions.cjs +++ b/actions/setup/js/close_expired_discussions.cjs @@ -179,6 +179,39 @@ async function closeDiscussionAsOutdated(github, discussionId) { return result.closeDiscussion.discussion; } +/** + * Check if a discussion already has an expiration comment + * @param {any} github - GitHub GraphQL instance + * @param {string} discussionId - Discussion node ID + * @returns {Promise} True if expiration comment exists + */ +async function hasExpirationComment(github, discussionId) { + const result = await github.graphql( + ` + query($dId: ID!) { + node(id: $dId) { + ... on Discussion { + comments(first: 100) { + nodes { + body + } + } + } + } + }`, + { dId: discussionId } + ); + + if (!result || !result.node || !result.node.comments) { + return false; + } + + const comments = result.node.comments.nodes || []; + const expirationCommentPattern = //; + + return comments.some(comment => comment.body && expirationCommentPattern.test(comment.body)); +} + async function main() { const owner = context.repo.owner; const repo = context.repo.repo; @@ -281,31 +314,61 @@ async function main() { const closedDiscussions = []; const failedDiscussions = []; + let skippedCount = 0; + const skippedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { const discussion = discussionsToClose[i]; core.info(`[${i + 1}/${discussionsToClose.length}] Processing discussion #${discussion.number}: ${discussion.url}`); try { - const closingMessage = `This discussion was automatically closed because it expired on ${discussion.expirationDate.toISOString()}.`; - - // Add comment first - core.info(` Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(` ✓ Comment added successfully`); - - // Then close the discussion as outdated - core.info(` Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - core.info(` ✓ Discussion closed successfully`); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - title: discussion.title, - }); + // Check if an expiration comment already exists + core.info(` Checking for existing expiration comment on discussion #${discussion.number}`); + const hasComment = await hasExpirationComment(github, discussion.id); + + if (hasComment) { + core.warning(` Discussion #${discussion.number} already has an expiration comment, skipping to avoid duplicate`); + skippedDiscussions.push({ + number: discussion.number, + url: discussion.url, + title: discussion.title, + }); + skippedCount++; + + // Still try to close it if it's somehow still open + core.info(` Attempting to close discussion #${discussion.number} without adding another comment`); + await closeDiscussionAsOutdated(github, discussion.id); + core.info(` ✓ Discussion closed successfully`); + + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + title: discussion.title, + }); + closedCount++; + } else { + const closingMessage = `This discussion was automatically closed because it expired on ${discussion.expirationDate.toISOString()}.\n\n`; + + // Add comment first + core.info(` Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(` ✓ Comment added successfully`); + + // Then close the discussion as outdated + core.info(` Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + core.info(` ✓ Discussion closed successfully`); + + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + title: discussion.title, + }); + + closedCount++; + } - closedCount++; core.info(`✓ Successfully processed discussion #${discussion.number}: ${discussion.url}`); } catch (error) { core.error(`✗ Failed to close discussion #${discussion.number}: ${getErrorMessage(error)}`); @@ -336,6 +399,9 @@ async function main() { summaryContent += `**Closing Summary**\n`; summaryContent += `- Successfully closed: ${closedCount} discussion(s)\n`; + if (skippedCount > 0) { + summaryContent += `- Skipped (already had comment): ${skippedCount} discussion(s)\n`; + } if (failedDiscussions.length > 0) { summaryContent += `- Failed to close: ${failedDiscussions.length} discussion(s)\n`; } @@ -352,6 +418,14 @@ async function main() { summaryContent += `\n`; } + if (skippedCount > 0) { + summaryContent += `### Skipped (Already Had Comment)\n\n`; + for (const skipped of skippedDiscussions) { + summaryContent += `- Discussion #${skipped.number}: [${skipped.title}](${skipped.url})\n`; + } + summaryContent += `\n`; + } + if (failedDiscussions.length > 0) { summaryContent += `### Failed to Close\n\n`; for (const failed of failedDiscussions) { diff --git a/actions/setup/js/close_expired_discussions.test.cjs b/actions/setup/js/close_expired_discussions.test.cjs new file mode 100644 index 0000000000..8d2d65beb0 --- /dev/null +++ b/actions/setup/js/close_expired_discussions.test.cjs @@ -0,0 +1,307 @@ +// @ts-check +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock core and context globals +const mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn(), + }, +}; + +const mockContext = { + repo: { + owner: "testowner", + repo: "testrepo", + }, +}; + +global.core = mockCore; +global.context = mockContext; + +describe("close_expired_discussions", () => { + let mockGithub; + + beforeEach(() => { + vi.clearAllMocks(); + mockGithub = { + graphql: vi.fn(), + }; + global.github = mockGithub; + }); + + describe("hasExpirationComment", () => { + it("should return true when expiration comment exists", async () => { + // Mock the import after globals are set + const module = await import("./close_expired_discussions.cjs"); + + // Mock GraphQL response with existing expiration comment + mockGithub.graphql.mockResolvedValueOnce({ + node: { + comments: { + nodes: [{ body: "Some other comment" }, { body: "This discussion was automatically closed because it expired on 2026-01-20T09:20:00.000Z.\n\n" }, { body: "Another comment" }], + }, + }, + }); + + // Access the function through module exports for testing + // Note: hasExpirationComment is not exported, so we test it indirectly through main() + // For now, we'll test the integration in the main() function tests below + expect(mockGithub.graphql).toBeDefined(); + }); + + it("should return false when no expiration comment exists", async () => { + mockGithub.graphql.mockResolvedValueOnce({ + node: { + comments: { + nodes: [{ body: "Some regular comment" }, { body: "Another regular comment" }], + }, + }, + }); + + expect(mockGithub.graphql).toBeDefined(); + }); + }); + + describe("main - duplicate comment prevention", () => { + it("should skip adding comment if one already exists", async () => { + const module = await import("./close_expired_discussions.cjs"); + + // Mock the search query to return an open discussion with expiration marker + mockGithub.graphql + // First call: searchDiscussionsWithExpiration + .mockResolvedValueOnce({ + repository: { + discussions: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "D_test123", + number: 11057, + title: "Test Discussion", + url: "https://github.com/testowner/testrepo/discussions/11057", + body: "> AI generated by Test Workflow\n>\n> - [x] expires on Jan 20, 2020, 9:20 AM UTC", + createdAt: "2020-01-19T09:20:00.000Z", + }, + ], + }, + }, + }) + // Second call: hasExpirationComment - returns true (comment exists) + .mockResolvedValueOnce({ + node: { + comments: { + nodes: [{ body: "This discussion was automatically closed because it expired on 2020-01-20T09:20:00.000Z.\n\n" }], + }, + }, + }) + // Third call: closeDiscussionAsOutdated + .mockResolvedValueOnce({ + closeDiscussion: { + discussion: { + id: "D_test123", + url: "https://github.com/testowner/testrepo/discussions/11057", + }, + }, + }); + + await module.main(); + + // Verify that the comment was NOT added (only 3 graphql calls, not 4) + expect(mockGithub.graphql).toHaveBeenCalledTimes(3); + + // Verify that we checked for existing comments + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("already has an expiration comment")); + + // Verify that we still tried to close the discussion + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Attempting to close discussion")); + }); + + it("should add comment if none exists", async () => { + const module = await import("./close_expired_discussions.cjs"); + + // Mock the search query to return an open discussion with expiration marker + mockGithub.graphql + // First call: searchDiscussionsWithExpiration + .mockResolvedValueOnce({ + repository: { + discussions: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "D_test456", + number: 11058, + title: "Another Test Discussion", + url: "https://github.com/testowner/testrepo/discussions/11058", + body: "> AI generated by Test Workflow\n>\n> - [x] expires on Jan 20, 2020, 9:20 AM UTC", + createdAt: "2020-01-19T09:20:00.000Z", + }, + ], + }, + }, + }) + // Second call: hasExpirationComment - returns false (no comment exists) + .mockResolvedValueOnce({ + node: { + comments: { + nodes: [{ body: "Some unrelated comment" }], + }, + }, + }) + // Third call: addDiscussionComment + .mockResolvedValueOnce({ + addDiscussionComment: { + comment: { + id: "C_comment123", + url: "https://github.com/testowner/testrepo/discussions/11058#comment-123", + }, + }, + }) + // Fourth call: closeDiscussionAsOutdated + .mockResolvedValueOnce({ + closeDiscussion: { + discussion: { + id: "D_test456", + url: "https://github.com/testowner/testrepo/discussions/11058", + }, + }, + }); + + await module.main(); + + // Verify that we made 4 graphql calls (search, check comments, add comment, close) + expect(mockGithub.graphql).toHaveBeenCalledTimes(4); + + // Verify that we added the comment + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Adding closing comment")); + + // Verify that we closed the discussion + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Discussion closed successfully")); + }); + + it("should handle empty comments gracefully", async () => { + const module = await import("./close_expired_discussions.cjs"); + + mockGithub.graphql + // First call: searchDiscussionsWithExpiration + .mockResolvedValueOnce({ + repository: { + discussions: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "D_test789", + number: 11059, + title: "Test Discussion with No Comments", + url: "https://github.com/testowner/testrepo/discussions/11059", + body: "> AI generated by Test Workflow\n>\n> - [x] expires on Jan 20, 2020, 9:20 AM UTC", + createdAt: "2020-01-19T09:20:00.000Z", + }, + ], + }, + }, + }) + // Second call: hasExpirationComment - empty comments + .mockResolvedValueOnce({ + node: { + comments: { + nodes: [], + }, + }, + }) + // Third call: addDiscussionComment + .mockResolvedValueOnce({ + addDiscussionComment: { + comment: { + id: "C_comment456", + url: "https://github.com/testowner/testrepo/discussions/11059#comment-456", + }, + }, + }) + // Fourth call: closeDiscussionAsOutdated + .mockResolvedValueOnce({ + closeDiscussion: { + discussion: { + id: "D_test789", + url: "https://github.com/testowner/testrepo/discussions/11059", + }, + }, + }); + + await module.main(); + + // Should add comment when no comments exist + expect(mockGithub.graphql).toHaveBeenCalledTimes(4); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Adding closing comment")); + }); + }); + + describe("main - no expired discussions", () => { + it("should handle case with no discussions", async () => { + const module = await import("./close_expired_discussions.cjs"); + + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussions: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [], + }, + }, + }); + + await module.main(); + + expect(mockCore.info).toHaveBeenCalledWith("No discussions with expiration markers found"); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + + it("should handle non-expired discussions", async () => { + const module = await import("./close_expired_discussions.cjs"); + + // Mock a discussion that expires in the future + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 7); // 7 days from now + + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussions: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "D_future", + number: 12000, + title: "Future Discussion", + url: "https://github.com/testowner/testrepo/discussions/12000", + body: `> AI generated by Test Workflow\n>\n> - [x] expires on Future Date UTC`, + createdAt: new Date().toISOString(), + }, + ], + }, + }, + }); + + await module.main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("is NOT expired")); + expect(mockCore.info).toHaveBeenCalledWith("No expired discussions found"); + }); + }); +});