diff --git a/actions/setup/js/close_expired_discussions.cjs b/actions/setup/js/close_expired_discussions.cjs index 4e77937ed4..f41d62a0c8 100644 --- a/actions/setup/js/close_expired_discussions.cjs +++ b/actions/setup/js/close_expired_discussions.cjs @@ -180,10 +180,10 @@ async function closeDiscussionAsOutdated(github, discussionId) { } /** - * Check if a discussion already has an expiration comment + * Check if a discussion already has an expiration comment and fetch its closed state * @param {any} github - GitHub GraphQL instance * @param {string} discussionId - Discussion node ID - * @returns {Promise} True if expiration comment exists + * @returns {Promise<{hasComment: boolean, isClosed: boolean}>} Object with comment existence and closed state */ async function hasExpirationComment(github, discussionId) { const result = await github.graphql( @@ -191,6 +191,7 @@ async function hasExpirationComment(github, discussionId) { query($dId: ID!) { node(id: $dId) { ... on Discussion { + closed comments(first: 100) { nodes { body @@ -202,14 +203,16 @@ async function hasExpirationComment(github, discussionId) { { dId: discussionId } ); - if (!result || !result.node || !result.node.comments) { - return false; + if (!result || !result.node) { + return { hasComment: false, isClosed: false }; } - const comments = result.node.comments.nodes || []; + const isClosed = result.node.closed || false; + const comments = result.node.comments?.nodes || []; const expirationCommentPattern = //; + const hasComment = comments.some(comment => comment.body && expirationCommentPattern.test(comment.body)); - return comments.some(comment => comment.body && expirationCommentPattern.test(comment.body)); + return { hasComment, isClosed }; } async function main() { @@ -323,9 +326,20 @@ async function main() { core.info(`[${i + 1}/${discussionsToClose.length}] Processing discussion #${discussion.number}: ${discussion.url}`); try { - // 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); + // Check if an expiration comment already exists and if discussion is closed + core.info(` Checking for existing expiration comment and closed state on discussion #${discussion.number}`); + const { hasComment, isClosed } = await hasExpirationComment(github, discussion.id); + + if (isClosed) { + core.warning(` Discussion #${discussion.number} is already closed, skipping`); + skippedDiscussions.push({ + number: discussion.number, + url: discussion.url, + title: discussion.title, + }); + skippedCount++; + continue; + } if (hasComment) { core.warning(` Discussion #${discussion.number} already has an expiration comment, skipping to avoid duplicate`); diff --git a/actions/setup/js/close_expired_discussions.test.cjs b/actions/setup/js/close_expired_discussions.test.cjs index 8d2d65beb0..3534b0f5a9 100644 --- a/actions/setup/js/close_expired_discussions.test.cjs +++ b/actions/setup/js/close_expired_discussions.test.cjs @@ -93,9 +93,10 @@ describe("close_expired_discussions", () => { }, }, }) - // Second call: hasExpirationComment - returns true (comment exists) + // Second call: hasExpirationComment - returns true (comment exists) and not closed .mockResolvedValueOnce({ node: { + closed: false, comments: { nodes: [{ body: "This discussion was automatically closed because it expired on 2020-01-20T09:20:00.000Z.\n\n" }], }, @@ -123,6 +124,55 @@ describe("close_expired_discussions", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Attempting to close discussion")); }); + it("should skip already closed discussions entirely", 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_closed123", + number: 11060, + title: "Already Closed Discussion", + url: "https://github.com/testowner/testrepo/discussions/11060", + 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 discussion is closed + .mockResolvedValueOnce({ + node: { + closed: true, + comments: { + nodes: [{ body: "This discussion was automatically closed because it expired on 2020-01-20T09:20:00.000Z.\n\n" }], + }, + }, + }); + + await module.main(); + + // Verify that we only made 2 graphql calls (search and check closed state) + // Should NOT attempt to close or add comment + expect(mockGithub.graphql).toHaveBeenCalledTimes(2); + + // Verify that we detected the discussion was already closed + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("is already closed")); + + // Verify that we did NOT try to close the discussion + expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("Attempting to close discussion")); + }); + it("should add comment if none exists", async () => { const module = await import("./close_expired_discussions.cjs"); @@ -149,9 +199,10 @@ describe("close_expired_discussions", () => { }, }, }) - // Second call: hasExpirationComment - returns false (no comment exists) + // Second call: hasExpirationComment - returns false (no comment exists) and not closed .mockResolvedValueOnce({ node: { + closed: false, comments: { nodes: [{ body: "Some unrelated comment" }], }, @@ -213,9 +264,10 @@ describe("close_expired_discussions", () => { }, }, }) - // Second call: hasExpirationComment - empty comments + // Second call: hasExpirationComment - empty comments and not closed .mockResolvedValueOnce({ node: { + closed: false, comments: { nodes: [], },