From 2bddf055e524c4ab0a4660f95260ad13b67007c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 05:11:41 +0000 Subject: [PATCH 1/2] Initial plan From bc557ee224075a1113b47d4868cf9244e4e00e3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 05:19:45 +0000 Subject: [PATCH 2/2] Extract shared expired entity handler to eliminate duplicate code Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/close_expired_discussions.cjs | 77 ++--- actions/setup/js/close_expired_issues.cjs | 34 +-- .../setup/js/close_expired_pull_requests.cjs | 34 +-- actions/setup/js/expired_entity_handlers.cjs | 102 +++++++ .../setup/js/expired_entity_handlers.test.cjs | 274 ++++++++++++++++++ 5 files changed, 439 insertions(+), 82 deletions(-) create mode 100644 actions/setup/js/expired_entity_handlers.cjs create mode 100644 actions/setup/js/expired_entity_handlers.test.cjs diff --git a/actions/setup/js/close_expired_discussions.cjs b/actions/setup/js/close_expired_discussions.cjs index 0b78b9dbf1..63e31624dd 100644 --- a/actions/setup/js/close_expired_discussions.cjs +++ b/actions/setup/js/close_expired_discussions.cjs @@ -5,6 +5,7 @@ const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs" const { generateExpiredEntityFooter } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs"); +const { createExpiredEntityProcessor } = require("./expired_entity_handlers.cjs"); /** * Add comment to a GitHub Discussion using GraphQL @@ -96,67 +97,51 @@ async function main() { // Get workflow metadata for footer const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo); - await executeExpiredEntityCleanup(github, owner, repo, { - entityType: "discussions", - graphqlField: "discussions", - resultKey: "discussions", - entityLabel: "Discussion", - summaryHeading: "Expired Discussions Cleanup", - enableDedupe: true, // Discussions may have duplicates across pages - includeSkippedHeading: true, - processEntity: async discussion => { + // Create processor using shared handler with discussion-specific pre-check + const processEntity = createExpiredEntityProcessor(workflowName, runUrl, workflowId, { + entityType: "discussion", + addComment: async (discussion, message) => { + await addDiscussionComment(github, discussion.id, message); + }, + closeEntity: async discussion => { + await closeDiscussionAsOutdated(github, discussion.id); + }, + buildClosingMessage: (discussion, workflowName, runUrl, workflowId) => { + return `This discussion was automatically closed because it expired on ${discussion.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId) + "\n\n"; + }, + preCheck: async discussion => { 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`); return { - status: "skipped", - record: { - number: discussion.number, - url: discussion.url, - title: discussion.title, - }, + shouldSkip: true, + reason: `Discussion #${discussion.number} is already closed, skipping`, }; } if (hasComment) { - core.warning(` Discussion #${discussion.number} already has an expiration comment, skipping to avoid duplicate`); - - core.info(` Attempting to close discussion #${discussion.number} without adding another comment`); - await closeDiscussionAsOutdated(github, discussion.id); - core.info(` ✓ Discussion closed successfully`); - return { - status: "skipped", - record: { - number: discussion.number, - url: discussion.url, - title: discussion.title, - }, + shouldSkip: true, + shouldClose: true, + reason: `Discussion #${discussion.number} already has an expiration comment, skipping to avoid duplicate`, }; } - const closingMessage = `This discussion was automatically closed because it expired on ${discussion.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId) + "\n\n"; - - core.info(` Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(` ✓ Comment added successfully`); - - core.info(` Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - core.info(` ✓ Discussion closed successfully`); - - return { - status: "closed", - record: { - number: discussion.number, - url: discussion.url, - title: discussion.title, - }, - }; + return { shouldSkip: false }; }, }); + + await executeExpiredEntityCleanup(github, owner, repo, { + entityType: "discussions", + graphqlField: "discussions", + resultKey: "discussions", + entityLabel: "Discussion", + summaryHeading: "Expired Discussions Cleanup", + enableDedupe: true, // Discussions may have duplicates across pages + includeSkippedHeading: true, + processEntity, + }); } module.exports = { main }; diff --git a/actions/setup/js/close_expired_issues.cjs b/actions/setup/js/close_expired_issues.cjs index 454168ffaa..529090c091 100644 --- a/actions/setup/js/close_expired_issues.cjs +++ b/actions/setup/js/close_expired_issues.cjs @@ -5,6 +5,7 @@ const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs" const { generateExpiredEntityFooter } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs"); +const { createExpiredEntityProcessor } = require("./expired_entity_handlers.cjs"); /** * Add comment to a GitHub Issue using REST API @@ -53,30 +54,27 @@ async function main() { // Get workflow metadata for footer const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo); + // Create processor using shared handler + const processEntity = createExpiredEntityProcessor(workflowName, runUrl, workflowId, { + entityType: "issue", + addComment: async (issue, message) => { + await addIssueComment(github, owner, repo, issue.number, message); + }, + closeEntity: async issue => { + await closeIssue(github, owner, repo, issue.number); + }, + buildClosingMessage: (issue, workflowName, runUrl, workflowId) => { + return `This issue was automatically closed because it expired on ${issue.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId); + }, + }); + await executeExpiredEntityCleanup(github, owner, repo, { entityType: "issues", graphqlField: "issues", resultKey: "issues", entityLabel: "Issue", summaryHeading: "Expired Issues Cleanup", - processEntity: async issue => { - const closingMessage = `This issue was automatically closed because it expired on ${issue.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId); - - await addIssueComment(github, owner, repo, issue.number, closingMessage); - core.info(` ✓ Comment added successfully`); - - await closeIssue(github, owner, repo, issue.number); - core.info(` ✓ Issue closed successfully`); - - return { - status: "closed", - record: { - number: issue.number, - url: issue.url, - title: issue.title, - }, - }; - }, + processEntity, }); } diff --git a/actions/setup/js/close_expired_pull_requests.cjs b/actions/setup/js/close_expired_pull_requests.cjs index db4559fd2b..6958e66c7c 100644 --- a/actions/setup/js/close_expired_pull_requests.cjs +++ b/actions/setup/js/close_expired_pull_requests.cjs @@ -5,6 +5,7 @@ const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs" const { generateExpiredEntityFooter } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs"); +const { createExpiredEntityProcessor } = require("./expired_entity_handlers.cjs"); /** * Add comment to a GitHub Pull Request using REST API @@ -52,30 +53,27 @@ async function main() { // Get workflow metadata for footer const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo); + // Create processor using shared handler + const processEntity = createExpiredEntityProcessor(workflowName, runUrl, workflowId, { + entityType: "pull request", + addComment: async (pr, message) => { + await addPullRequestComment(github, owner, repo, pr.number, message); + }, + closeEntity: async pr => { + await closePullRequest(github, owner, repo, pr.number); + }, + buildClosingMessage: (pr, workflowName, runUrl, workflowId) => { + return `This pull request was automatically closed because it expired on ${pr.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId); + }, + }); + await executeExpiredEntityCleanup(github, owner, repo, { entityType: "pull requests", graphqlField: "pullRequests", resultKey: "pullRequests", entityLabel: "Pull Request", summaryHeading: "Expired Pull Requests Cleanup", - processEntity: async pr => { - const closingMessage = `This pull request was automatically closed because it expired on ${pr.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId); - - await addPullRequestComment(github, owner, repo, pr.number, closingMessage); - core.info(` ✓ Comment added successfully`); - - await closePullRequest(github, owner, repo, pr.number); - core.info(` ✓ Pull request closed successfully`); - - return { - status: "closed", - record: { - number: pr.number, - url: pr.url, - title: pr.title, - }, - }; - }, + processEntity, }); } diff --git a/actions/setup/js/expired_entity_handlers.cjs b/actions/setup/js/expired_entity_handlers.cjs new file mode 100644 index 0000000000..a7fa5775da --- /dev/null +++ b/actions/setup/js/expired_entity_handlers.cjs @@ -0,0 +1,102 @@ +// @ts-check +// + +/** + * Expired Entity Handlers + * + * This module provides reusable handlers for processing expired entities (issues, PRs, discussions). + * It extracts the common comment + close + return record flow that was duplicated across + * close_expired_issues.cjs, close_expired_pull_requests.cjs, and close_expired_discussions.cjs. + */ + +/** + * Configuration for entity-specific operations + * @typedef {Object} EntityHandlerConfig + * @property {string} entityType - Entity type for logging (e.g., "issue", "pull request", "discussion") + * @property {(entity: any, message: string) => Promise} addComment - Function to add a comment (receives entity and message) + * @property {(entity: any) => Promise} closeEntity - Function to close the entity (receives entity) + * @property {(entity: any, workflowName: string, runUrl: string, workflowId: string) => string} buildClosingMessage - Function to build the closing message + * @property {(entity: any) => Promise<{shouldSkip: boolean, reason?: string, shouldClose?: boolean}>} [preCheck] - Optional pre-check function (e.g., for duplicate detection) + */ + +/** + * Create a standard expired entity processor + * + * This function returns a processEntity function that can be passed to executeExpiredEntityCleanup. + * It handles the common flow: + * 1. Optional pre-check (e.g., checking for existing comments in discussions) + * 2. Add closing comment + * 3. Close entity + * 4. Return status and record + * + * @param {string} workflowName - Workflow name for footer + * @param {string} runUrl - Workflow run URL for footer + * @param {string} workflowId - Workflow ID for footer + * @param {EntityHandlerConfig} config - Entity-specific configuration + * @returns {(entity: any) => Promise<{status: "closed" | "skipped", record: any}>} + */ +function createExpiredEntityProcessor(workflowName, runUrl, workflowId, config) { + return async entity => { + // Step 1: Optional pre-check (e.g., duplicate detection for discussions) + if (config.preCheck) { + const preCheckResult = await config.preCheck(entity); + if (preCheckResult.shouldSkip) { + if (preCheckResult.reason) { + core.warning(` ${preCheckResult.reason}`); + } + + // If preCheck says to close without adding comment, do that + if (preCheckResult.shouldClose) { + core.info(` Attempting to close ${config.entityType} #${entity.number} without adding another comment`); + await config.closeEntity(entity); + core.info(` ✓ ${capitalize(config.entityType)} closed successfully`); + } + + return { + status: "skipped", + record: { + number: entity.number, + url: entity.url, + title: entity.title, + }, + }; + } + } + + // Step 2: Build closing message + const closingMessage = config.buildClosingMessage(entity, workflowName, runUrl, workflowId); + + // Step 3: Add closing comment + core.info(` Adding closing comment to ${config.entityType} #${entity.number}`); + await config.addComment(entity, closingMessage); + core.info(` ✓ Comment added successfully`); + + // Step 4: Close entity + core.info(` Closing ${config.entityType} #${entity.number}`); + await config.closeEntity(entity); + core.info(` ✓ ${capitalize(config.entityType)} closed successfully`); + + // Step 5: Return status and record + return { + status: "closed", + record: { + number: entity.number, + url: entity.url, + title: entity.title, + }, + }; + }; +} + +/** + * Capitalize the first letter of a string + * @param {string} str - String to capitalize + * @returns {string} + */ +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +module.exports = { + createExpiredEntityProcessor, +}; diff --git a/actions/setup/js/expired_entity_handlers.test.cjs b/actions/setup/js/expired_entity_handlers.test.cjs new file mode 100644 index 0000000000..f7fc2a2acd --- /dev/null +++ b/actions/setup/js/expired_entity_handlers.test.cjs @@ -0,0 +1,274 @@ +// @ts-check +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock core global +const mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), +}; + +global.core = mockCore; + +describe("expired_entity_handlers", () => { + let createExpiredEntityProcessor; + + beforeEach(async () => { + vi.clearAllMocks(); + const module = await import("./expired_entity_handlers.cjs"); + createExpiredEntityProcessor = module.createExpiredEntityProcessor; + }); + + describe("createExpiredEntityProcessor", () => { + it("should process entity with comment and close", async () => { + const mockAddComment = vi.fn().mockResolvedValue({}); + const mockCloseEntity = vi.fn().mockResolvedValue({}); + const mockBuildMessage = vi.fn().mockReturnValue("Test closing message"); + + const processor = createExpiredEntityProcessor("test-workflow", "https://github.com/test/repo/actions/runs/123", "test-workflow-id", { + entityType: "issue", + addComment: mockAddComment, + closeEntity: mockCloseEntity, + buildClosingMessage: mockBuildMessage, + }); + + const entity = { + number: 42, + url: "https://github.com/test/repo/issues/42", + title: "Test Issue", + expirationDate: new Date("2020-01-20T09:20:00.000Z"), + }; + + const result = await processor(entity); + + expect(result).toEqual({ + status: "closed", + record: { + number: 42, + url: "https://github.com/test/repo/issues/42", + title: "Test Issue", + }, + }); + + expect(mockBuildMessage).toHaveBeenCalledWith(entity, "test-workflow", "https://github.com/test/repo/actions/runs/123", "test-workflow-id"); + expect(mockAddComment).toHaveBeenCalledWith(entity, "Test closing message"); + expect(mockCloseEntity).toHaveBeenCalledWith(entity); + expect(mockCore.info).toHaveBeenCalledWith(" Adding closing comment to issue #42"); + expect(mockCore.info).toHaveBeenCalledWith(" ✓ Comment added successfully"); + expect(mockCore.info).toHaveBeenCalledWith(" Closing issue #42"); + expect(mockCore.info).toHaveBeenCalledWith(" ✓ Issue closed successfully"); + }); + + it("should skip entity when preCheck returns shouldSkip=true without closing", async () => { + const mockAddComment = vi.fn(); + const mockCloseEntity = vi.fn(); + const mockBuildMessage = vi.fn(); + const mockPreCheck = vi.fn().mockResolvedValue({ + shouldSkip: true, + reason: "Already closed", + }); + + const processor = createExpiredEntityProcessor("test-workflow", "https://github.com/test/repo/actions/runs/123", "test-workflow-id", { + entityType: "discussion", + addComment: mockAddComment, + closeEntity: mockCloseEntity, + buildClosingMessage: mockBuildMessage, + preCheck: mockPreCheck, + }); + + const entity = { + number: 99, + url: "https://github.com/test/repo/discussions/99", + title: "Already Closed", + id: "D_test123", + }; + + const result = await processor(entity); + + expect(result).toEqual({ + status: "skipped", + record: { + number: 99, + url: "https://github.com/test/repo/discussions/99", + title: "Already Closed", + }, + }); + + expect(mockPreCheck).toHaveBeenCalledWith(entity); + expect(mockCore.warning).toHaveBeenCalledWith(" Already closed"); + expect(mockAddComment).not.toHaveBeenCalled(); + expect(mockCloseEntity).not.toHaveBeenCalled(); + }); + + it("should skip entity and close when preCheck returns shouldSkip=true with shouldClose=true", async () => { + const mockAddComment = vi.fn(); + const mockCloseEntity = vi.fn().mockResolvedValue({}); + const mockBuildMessage = vi.fn(); + const mockPreCheck = vi.fn().mockResolvedValue({ + shouldSkip: true, + shouldClose: true, + reason: "Has existing comment, closing without adding another", + }); + + const processor = createExpiredEntityProcessor("test-workflow", "https://github.com/test/repo/actions/runs/123", "test-workflow-id", { + entityType: "discussion", + addComment: mockAddComment, + closeEntity: mockCloseEntity, + buildClosingMessage: mockBuildMessage, + preCheck: mockPreCheck, + }); + + const entity = { + number: 88, + url: "https://github.com/test/repo/discussions/88", + title: "Has Comment", + id: "D_test456", + }; + + const result = await processor(entity); + + expect(result).toEqual({ + status: "skipped", + record: { + number: 88, + url: "https://github.com/test/repo/discussions/88", + title: "Has Comment", + }, + }); + + expect(mockPreCheck).toHaveBeenCalledWith(entity); + expect(mockCore.warning).toHaveBeenCalledWith(" Has existing comment, closing without adding another"); + expect(mockCore.info).toHaveBeenCalledWith(" Attempting to close discussion #88 without adding another comment"); + expect(mockCloseEntity).toHaveBeenCalledWith(entity); + expect(mockCore.info).toHaveBeenCalledWith(" ✓ Discussion closed successfully"); + expect(mockAddComment).not.toHaveBeenCalled(); + }); + + it("should process entity when preCheck returns shouldSkip=false", async () => { + const mockAddComment = vi.fn().mockResolvedValue({}); + const mockCloseEntity = vi.fn().mockResolvedValue({}); + const mockBuildMessage = vi.fn().mockReturnValue("Closing message"); + const mockPreCheck = vi.fn().mockResolvedValue({ + shouldSkip: false, + }); + + const processor = createExpiredEntityProcessor("test-workflow", "https://github.com/test/repo/actions/runs/123", "test-workflow-id", { + entityType: "discussion", + addComment: mockAddComment, + closeEntity: mockCloseEntity, + buildClosingMessage: mockBuildMessage, + preCheck: mockPreCheck, + }); + + const entity = { + number: 77, + url: "https://github.com/test/repo/discussions/77", + title: "No Comment", + id: "D_test789", + }; + + const result = await processor(entity); + + expect(result).toEqual({ + status: "closed", + record: { + number: 77, + url: "https://github.com/test/repo/discussions/77", + title: "No Comment", + }, + }); + + expect(mockPreCheck).toHaveBeenCalledWith(entity); + expect(mockAddComment).toHaveBeenCalledWith(entity, "Closing message"); + expect(mockCloseEntity).toHaveBeenCalledWith(entity); + }); + + it("should work without preCheck function", async () => { + const mockAddComment = vi.fn().mockResolvedValue({}); + const mockCloseEntity = vi.fn().mockResolvedValue({}); + const mockBuildMessage = vi.fn().mockReturnValue("Simple close"); + + const processor = createExpiredEntityProcessor("test-workflow", "https://github.com/test/repo/actions/runs/123", "test-workflow-id", { + entityType: "pull request", + addComment: mockAddComment, + closeEntity: mockCloseEntity, + buildClosingMessage: mockBuildMessage, + // No preCheck provided + }); + + const entity = { + number: 55, + url: "https://github.com/test/repo/pull/55", + title: "Simple PR", + }; + + const result = await processor(entity); + + expect(result).toEqual({ + status: "closed", + record: { + number: 55, + url: "https://github.com/test/repo/pull/55", + title: "Simple PR", + }, + }); + + expect(mockAddComment).toHaveBeenCalledWith(entity, "Simple close"); + expect(mockCloseEntity).toHaveBeenCalledWith(entity); + }); + + it("should capitalize entity type in log messages", async () => { + const mockAddComment = vi.fn().mockResolvedValue({}); + const mockCloseEntity = vi.fn().mockResolvedValue({}); + const mockBuildMessage = vi.fn().mockReturnValue("Test"); + + const processor = createExpiredEntityProcessor("test-workflow", "https://github.com/test/repo/actions/runs/123", "test-workflow-id", { + entityType: "pull request", + addComment: mockAddComment, + closeEntity: mockCloseEntity, + buildClosingMessage: mockBuildMessage, + }); + + const entity = { + number: 1, + url: "https://github.com/test/repo/pull/1", + title: "Test", + }; + + await processor(entity); + + expect(mockCore.info).toHaveBeenCalledWith(" Adding closing comment to pull request #1"); + expect(mockCore.info).toHaveBeenCalledWith(" ✓ Pull request closed successfully"); + }); + + it("should handle all three entity types correctly", async () => { + const entities = [ + { type: "issue", number: 1, url: "https://github.com/test/repo/issues/1", title: "Issue" }, + { type: "pull request", number: 2, url: "https://github.com/test/repo/pull/2", title: "PR" }, + { type: "discussion", number: 3, url: "https://github.com/test/repo/discussions/3", title: "Discussion" }, + ]; + + for (const entity of entities) { + vi.clearAllMocks(); + + const mockAddComment = vi.fn().mockResolvedValue({}); + const mockCloseEntity = vi.fn().mockResolvedValue({}); + const mockBuildMessage = vi.fn().mockReturnValue("Close message"); + + const processor = createExpiredEntityProcessor("test-workflow", "https://github.com/test/repo/actions/runs/123", "test-workflow-id", { + entityType: entity.type, + addComment: mockAddComment, + closeEntity: mockCloseEntity, + buildClosingMessage: mockBuildMessage, + }); + + const result = await processor(entity); + + expect(result.status).toBe("closed"); + expect(result.record.number).toBe(entity.number); + expect(mockAddComment).toHaveBeenCalled(); + expect(mockCloseEntity).toHaveBeenCalled(); + } + }); + }); +});