diff --git a/actions/setup/js/close_expired_discussions.cjs b/actions/setup/js/close_expired_discussions.cjs index be6a9b0eeec..027d26a5b84 100644 --- a/actions/setup/js/close_expired_discussions.cjs +++ b/actions/setup/js/close_expired_discussions.cjs @@ -1,8 +1,7 @@ // @ts-check // -const { searchEntitiesWithExpiration } = require("./expired_entity_search_helpers.cjs"); -const { buildExpirationSummary, categorizeByExpiration, DEFAULT_GRAPHQL_DELAY_MS, DEFAULT_MAX_UPDATES_PER_RUN, processExpiredEntities } = require("./expired_entity_cleanup_helpers.cjs"); +const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs"); /** * Add comment to a GitHub Discussion using GraphQL @@ -91,58 +90,14 @@ async function main() { const owner = context.repo.owner; const repo = context.repo.repo; - core.info(`Searching for expired discussions in ${owner}/${repo}`); - - // Search for discussions with expiration markers (enable dedupe for discussions) - const { items: discussionsWithExpiration, stats: searchStats } = await searchEntitiesWithExpiration(github, owner, repo, { + await executeExpiredEntityCleanup(github, owner, repo, { entityType: "discussions", graphqlField: "discussions", resultKey: "discussions", - enableDedupe: true, // Discussions may have duplicates across pages - }); - - if (discussionsWithExpiration.length === 0) { - core.info("No discussions with expiration markers found"); - - // Write summary even when no discussions found - let summaryContent = `## Expired Discussions Cleanup\n\n`; - summaryContent += `**Scanned**: ${searchStats.totalScanned} discussions across ${searchStats.pageCount} page(s)\n\n`; - summaryContent += `**Result**: No discussions with expiration markers found\n`; - await core.summary.addRaw(summaryContent).write(); - - return; - } - - core.info(`Found ${discussionsWithExpiration.length} discussion(s) with expiration markers`); - - const { - expired: expiredDiscussions, - notExpired: notExpiredDiscussions, - now, - } = categorizeByExpiration(discussionsWithExpiration, { - entityLabel: "Discussion", - }); - - if (expiredDiscussions.length === 0) { - core.info("No expired discussions found"); - - // Write summary when no expired discussions - let summaryContent = `## Expired Discussions Cleanup\n\n`; - summaryContent += `**Scanned**: ${searchStats.totalScanned} discussions across ${searchStats.pageCount} page(s)\n\n`; - summaryContent += `**With expiration markers**: ${discussionsWithExpiration.length} discussion(s)\n\n`; - summaryContent += `**Expired**: 0 discussions\n\n`; - summaryContent += `**Not yet expired**: ${notExpiredDiscussions.length} discussion(s)\n`; - await core.summary.addRaw(summaryContent).write(); - - return; - } - - core.info(`Found ${expiredDiscussions.length} expired discussion(s)`); - - const { closed, skipped, failed } = await processExpiredEntities(expiredDiscussions, { entityLabel: "Discussion", - maxPerRun: DEFAULT_MAX_UPDATES_PER_RUN, - delayMs: DEFAULT_GRAPHQL_DELAY_MS, + summaryHeading: "Expired Discussions Cleanup", + enableDedupe: true, // Discussions may have duplicates across pages + includeSkippedHeading: true, processEntity: async discussion => { core.info(` Checking for existing expiration comment and closed state on discussion #${discussion.number}`); const { hasComment, isClosed } = await hasExpirationComment(github, discussion.id); @@ -196,24 +151,6 @@ async function main() { }; }, }); - - const summaryContent = buildExpirationSummary({ - heading: "Expired Discussions Cleanup", - entityLabel: "Discussion", - searchStats, - withExpirationCount: discussionsWithExpiration.length, - expired: expiredDiscussions, - notExpired: notExpiredDiscussions, - closed, - skipped, - failed, - maxPerRun: DEFAULT_MAX_UPDATES_PER_RUN, - includeSkippedHeading: true, - now, - }); - - await core.summary.addRaw(summaryContent).write(); - core.info(`Successfully closed ${closed.length} expired discussion(s)`); } module.exports = { main }; diff --git a/actions/setup/js/close_expired_issues.cjs b/actions/setup/js/close_expired_issues.cjs index 3872511ef0f..6a22889ada6 100644 --- a/actions/setup/js/close_expired_issues.cjs +++ b/actions/setup/js/close_expired_issues.cjs @@ -1,8 +1,7 @@ // @ts-check // -const { searchEntitiesWithExpiration } = require("./expired_entity_search_helpers.cjs"); -const { buildExpirationSummary, categorizeByExpiration, DEFAULT_GRAPHQL_DELAY_MS, DEFAULT_MAX_UPDATES_PER_RUN, processExpiredEntities } = require("./expired_entity_cleanup_helpers.cjs"); +const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs"); /** * Add comment to a GitHub Issue using REST API @@ -48,57 +47,12 @@ async function main() { const owner = context.repo.owner; const repo = context.repo.repo; - core.info(`Searching for expired issues in ${owner}/${repo}`); - - // Search for issues with expiration markers - const { items: issuesWithExpiration, stats: searchStats } = await searchEntitiesWithExpiration(github, owner, repo, { + await executeExpiredEntityCleanup(github, owner, repo, { entityType: "issues", graphqlField: "issues", resultKey: "issues", - }); - - if (issuesWithExpiration.length === 0) { - core.info("No issues with expiration markers found"); - - // Write summary even when no issues found - let summaryContent = `## Expired Issues Cleanup\n\n`; - summaryContent += `**Scanned**: ${searchStats.totalScanned} issues across ${searchStats.pageCount} page(s)\n\n`; - summaryContent += `**Result**: No issues with expiration markers found\n`; - await core.summary.addRaw(summaryContent).write(); - - return; - } - - core.info(`Found ${issuesWithExpiration.length} issue(s) with expiration markers`); - - const { - expired: expiredIssues, - notExpired: notExpiredIssues, - now, - } = categorizeByExpiration(issuesWithExpiration, { - entityLabel: "Issue", - }); - - if (expiredIssues.length === 0) { - core.info("No expired issues found"); - - // Write summary when no expired issues - let summaryContent = `## Expired Issues Cleanup\n\n`; - summaryContent += `**Scanned**: ${searchStats.totalScanned} issues across ${searchStats.pageCount} page(s)\n\n`; - summaryContent += `**With expiration markers**: ${issuesWithExpiration.length} issue(s)\n\n`; - summaryContent += `**Expired**: 0 issues\n\n`; - summaryContent += `**Not yet expired**: ${notExpiredIssues.length} issue(s)\n`; - await core.summary.addRaw(summaryContent).write(); - - return; - } - - core.info(`Found ${expiredIssues.length} expired issue(s)`); - - const { closed, failed } = await processExpiredEntities(expiredIssues, { entityLabel: "Issue", - maxPerRun: DEFAULT_MAX_UPDATES_PER_RUN, - delayMs: DEFAULT_GRAPHQL_DELAY_MS, + summaryHeading: "Expired Issues Cleanup", processEntity: async issue => { const closingMessage = `This issue was automatically closed because it expired on ${issue.expirationDate.toISOString()}.`; @@ -118,22 +72,6 @@ async function main() { }; }, }); - - const summaryContent = buildExpirationSummary({ - heading: "Expired Issues Cleanup", - entityLabel: "Issue", - searchStats, - withExpirationCount: issuesWithExpiration.length, - expired: expiredIssues, - notExpired: notExpiredIssues, - closed, - failed, - maxPerRun: DEFAULT_MAX_UPDATES_PER_RUN, - now, - }); - - await core.summary.addRaw(summaryContent).write(); - core.info(`Successfully closed ${closed.length} expired issue(s)`); } module.exports = { main }; diff --git a/actions/setup/js/close_expired_issues.test.cjs b/actions/setup/js/close_expired_issues.test.cjs new file mode 100644 index 00000000000..daf0a5d496f --- /dev/null +++ b/actions/setup/js/close_expired_issues.test.cjs @@ -0,0 +1,290 @@ +// @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_issues", () => { + let mockGithub; + + beforeEach(() => { + vi.clearAllMocks(); + mockGithub = { + graphql: vi.fn(), + rest: { + issues: { + createComment: vi.fn(), + update: vi.fn(), + }, + }, + }; + global.github = mockGithub; + }); + + describe("main - no issues found", () => { + it("should handle case when no issues with expiration markers exist", async () => { + const module = await import("./close_expired_issues.cjs"); + + // Mock the search query to return no issues + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + issues: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [], + }, + }, + }); + + await module.main(); + + // Verify that summary was written + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("No issues with expiration markers found")); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + }); + + describe("main - expired issue", () => { + it("should close an expired issue", async () => { + const module = await import("./close_expired_issues.cjs"); + + // Mock the search query to return an open issue with expiration marker + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + issues: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "issue_test123", + number: 456, + title: "Test Issue", + url: "https://github.com/testowner/testrepo/issues/456", + body: "\n> AI generated by Test Workflow\n>\n> - [x] expires on Jan 20, 2020, 9:20 AM UTC", + createdAt: "2020-01-19T09:20:00.000Z", + }, + ], + }, + }, + }); + + // Mock createComment and update (close issue) responses + mockGithub.rest.issues.createComment.mockResolvedValueOnce({ + data: { + id: 12345, + url: "https://github.com/testowner/testrepo/issues/456#issuecomment-12345", + }, + }); + + mockGithub.rest.issues.update.mockResolvedValueOnce({ + data: { + id: 456, + state: "closed", + state_reason: "not_planned", + url: "https://github.com/testowner/testrepo/issues/456", + }, + }); + + await module.main(); + + // Verify that comment was added + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + issue_number: 456, + body: expect.stringContaining("automatically closed because it expired"), + }); + + // Verify that issue was closed + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + issue_number: 456, + state: "closed", + state_reason: "not_planned", + }); + + // Verify that success was logged + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Successfully processed issue #456")); + + // Verify that summary was written with closed issue + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Successfully Closed Issues")); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + }); + + describe("main - not expired issue", () => { + it("should skip issue that is not yet expired", async () => { + const module = await import("./close_expired_issues.cjs"); + + // Mock the search query to return an open issue with future expiration + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 7); // 7 days in future + + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + issues: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "issue_test456", + number: 789, + title: "Future Issue", + url: "https://github.com/testowner/testrepo/issues/789", + body: `\n> AI generated by Test Workflow\n>\n> - [x] expires on ${futureDate.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC`, + createdAt: new Date().toISOString(), + }, + ], + }, + }, + }); + + await module.main(); + + // Verify that comment was NOT added + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + + // Verify that issue was NOT closed + expect(mockGithub.rest.issues.update).not.toHaveBeenCalled(); + + // Verify that info was logged about not expired + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("is NOT expired")); + + // Verify that summary was written with not expired issue + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("**Not yet expired**: 1 issue(s)")); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + }); + + describe("main - error handling", () => { + it("should handle errors when closing issue", async () => { + const module = await import("./close_expired_issues.cjs"); + + // Mock the search query to return an open issue with expiration marker + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + issues: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "issue_error123", + number: 999, + title: "Error Issue", + url: "https://github.com/testowner/testrepo/issues/999", + body: "\n> AI generated by Test Workflow\n>\n> - [x] expires on Jan 20, 2020, 9:20 AM UTC", + createdAt: "2020-01-19T09:20:00.000Z", + }, + ], + }, + }, + }); + + // Mock createComment to throw error + mockGithub.rest.issues.createComment.mockRejectedValueOnce(new Error("API error")); + + await module.main(); + + // Verify that error was logged + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to close issue #999")); + + // Verify that summary includes failed issue + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Failed to Close")); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + }); + + describe("main - pagination", () => { + it("should handle paginated results", async () => { + const module = await import("./close_expired_issues.cjs"); + + // Mock the search query to return paginated results + mockGithub.graphql + // First page + .mockResolvedValueOnce({ + repository: { + issues: { + pageInfo: { + hasNextPage: true, + endCursor: "cursor123", + }, + nodes: [ + { + id: "issue_page1", + number: 100, + title: "Page 1 Issue", + url: "https://github.com/testowner/testrepo/issues/100", + body: "\n> 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 page + .mockResolvedValueOnce({ + repository: { + issues: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "issue_page2", + number: 200, + title: "Page 2 Issue", + url: "https://github.com/testowner/testrepo/issues/200", + body: "\n> AI generated by Test Workflow\n>\n> - [x] expires on Jan 20, 2020, 9:20 AM UTC", + createdAt: "2020-01-19T09:20:00.000Z", + }, + ], + }, + }, + }); + + // Mock REST API calls + mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 1 } }); + mockGithub.rest.issues.update.mockResolvedValue({ data: { id: 1, state: "closed" } }); + + await module.main(); + + // Verify that GraphQL was called twice (pagination) + expect(mockGithub.graphql).toHaveBeenCalledTimes(2); + + // Verify that both issues were processed + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledTimes(2); + expect(mockGithub.rest.issues.update).toHaveBeenCalledTimes(2); + + // Verify that summary shows 2 issues closed + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Successfully closed: 2 issue(s)")); + }); + }); +}); diff --git a/actions/setup/js/close_expired_pull_requests.cjs b/actions/setup/js/close_expired_pull_requests.cjs index 640c2664818..7526ca0c19e 100644 --- a/actions/setup/js/close_expired_pull_requests.cjs +++ b/actions/setup/js/close_expired_pull_requests.cjs @@ -1,8 +1,7 @@ // @ts-check // -const { searchEntitiesWithExpiration } = require("./expired_entity_search_helpers.cjs"); -const { buildExpirationSummary, categorizeByExpiration, DEFAULT_GRAPHQL_DELAY_MS, DEFAULT_MAX_UPDATES_PER_RUN, processExpiredEntities } = require("./expired_entity_cleanup_helpers.cjs"); +const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs"); /** * Add comment to a GitHub Pull Request using REST API @@ -47,57 +46,12 @@ async function main() { const owner = context.repo.owner; const repo = context.repo.repo; - core.info(`Searching for expired pull requests in ${owner}/${repo}`); - - // Search for pull requests with expiration markers - const { items: pullRequestsWithExpiration, stats: searchStats } = await searchEntitiesWithExpiration(github, owner, repo, { + await executeExpiredEntityCleanup(github, owner, repo, { entityType: "pull requests", graphqlField: "pullRequests", resultKey: "pullRequests", - }); - - if (pullRequestsWithExpiration.length === 0) { - core.info("No pull requests with expiration markers found"); - - // Write summary even when no pull requests found - let summaryContent = `## Expired Pull Requests Cleanup\n\n`; - summaryContent += `**Scanned**: ${searchStats.totalScanned} pull requests across ${searchStats.pageCount} page(s)\n\n`; - summaryContent += `**Result**: No pull requests with expiration markers found\n`; - await core.summary.addRaw(summaryContent).write(); - - return; - } - - core.info(`Found ${pullRequestsWithExpiration.length} pull request(s) with expiration markers`); - - const { - expired: expiredPullRequests, - notExpired: notExpiredPullRequests, - now, - } = categorizeByExpiration(pullRequestsWithExpiration, { - entityLabel: "Pull Request", - }); - - if (expiredPullRequests.length === 0) { - core.info("No expired pull requests found"); - - // Write summary when no expired pull requests - let summaryContent = `## Expired Pull Requests Cleanup\n\n`; - summaryContent += `**Scanned**: ${searchStats.totalScanned} pull requests across ${searchStats.pageCount} page(s)\n\n`; - summaryContent += `**With expiration markers**: ${pullRequestsWithExpiration.length} pull request(s)\n\n`; - summaryContent += `**Expired**: 0 pull requests\n\n`; - summaryContent += `**Not yet expired**: ${notExpiredPullRequests.length} pull request(s)\n`; - await core.summary.addRaw(summaryContent).write(); - - return; - } - - core.info(`Found ${expiredPullRequests.length} expired pull request(s)`); - - const { closed, failed } = await processExpiredEntities(expiredPullRequests, { entityLabel: "Pull Request", - maxPerRun: DEFAULT_MAX_UPDATES_PER_RUN, - delayMs: DEFAULT_GRAPHQL_DELAY_MS, + summaryHeading: "Expired Pull Requests Cleanup", processEntity: async pr => { const closingMessage = `This pull request was automatically closed because it expired on ${pr.expirationDate.toISOString()}.`; @@ -117,22 +71,6 @@ async function main() { }; }, }); - - const summaryContent = buildExpirationSummary({ - heading: "Expired Pull Requests Cleanup", - entityLabel: "Pull Request", - searchStats, - withExpirationCount: pullRequestsWithExpiration.length, - expired: expiredPullRequests, - notExpired: notExpiredPullRequests, - closed, - failed, - maxPerRun: DEFAULT_MAX_UPDATES_PER_RUN, - now, - }); - - await core.summary.addRaw(summaryContent).write(); - core.info(`Successfully closed ${closed.length} expired pull request(s)`); } module.exports = { main }; diff --git a/actions/setup/js/expired_entity_main_flow.cjs b/actions/setup/js/expired_entity_main_flow.cjs new file mode 100644 index 00000000000..f192d7c20d8 --- /dev/null +++ b/actions/setup/js/expired_entity_main_flow.cjs @@ -0,0 +1,116 @@ +// @ts-check +// + +const { searchEntitiesWithExpiration } = require("./expired_entity_search_helpers.cjs"); +const { buildExpirationSummary, categorizeByExpiration, DEFAULT_GRAPHQL_DELAY_MS, DEFAULT_MAX_UPDATES_PER_RUN, processExpiredEntities } = require("./expired_entity_cleanup_helpers.cjs"); + +/** + * Configuration for entity-specific behavior + * @typedef {Object} EntityFlowConfig + * @property {string} entityType - Entity type name for logging (e.g., "issues", "pull requests", "discussions") + * @property {string} graphqlField - GraphQL field name (e.g., "issues", "pullRequests", "discussions") + * @property {string} resultKey - Key to use in return object (e.g., "issues", "pullRequests", "discussions") + * @property {string} entityLabel - Capitalized label for display (e.g., "Issue", "Pull Request", "Discussion") + * @property {string} summaryHeading - Heading for summary (e.g., "Expired Issues Cleanup", "Expired Pull Requests Cleanup") + * @property {boolean} [enableDedupe] - Enable duplicate ID tracking in search (default: false) + * @property {boolean} [includeSkippedHeading] - Include skipped section in summary (default: false) + * @property {(entity: any) => Promise<{status: "closed" | "skipped", record: any}>} processEntity - Function to process each expired entity + */ + +/** + * Execute the standardized expired entity cleanup flow + * + * This function orchestrates the complete flow: + * 1. Search for entities with expiration markers + * 2. Categorize by expiration status + * 3. Handle early exits (no entities, none expired) + * 4. Process expired entities (comment + close) + * 5. Generate and write summary + * + * @param {any} github - GitHub API instance (GraphQL + REST) + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {EntityFlowConfig} config - Entity-specific configuration + * @returns {Promise} + */ +async function executeExpiredEntityCleanup(github, owner, repo, config) { + core.info(`Searching for expired ${config.entityType} in ${owner}/${repo}`); + + // Step 1: Search for entities with expiration markers + const { items: entitiesWithExpiration, stats: searchStats } = await searchEntitiesWithExpiration(github, owner, repo, { + entityType: config.entityType, + graphqlField: config.graphqlField, + resultKey: config.resultKey, + enableDedupe: config.enableDedupe || false, + }); + + // Early exit: No entities found + if (entitiesWithExpiration.length === 0) { + core.info(`No ${config.entityType} with expiration markers found`); + + const summaryContent = + `## ${config.summaryHeading}\n\n` + `**Scanned**: ${searchStats.totalScanned} ${config.entityType} across ${searchStats.pageCount} page(s)\n\n` + `**Result**: No ${config.entityType} with expiration markers found\n`; + + await core.summary.addRaw(summaryContent).write(); + return; + } + + core.info(`Found ${entitiesWithExpiration.length} ${config.entityType.slice(0, -1)}(s) with expiration markers`); + + // Step 2: Categorize by expiration status + const { + expired: expiredEntities, + notExpired: notExpiredEntities, + now, + } = categorizeByExpiration(entitiesWithExpiration, { + entityLabel: config.entityLabel, + }); + + // Early exit: None expired + if (expiredEntities.length === 0) { + core.info(`No expired ${config.entityType} found`); + + const summaryContent = + `## ${config.summaryHeading}\n\n` + + `**Scanned**: ${searchStats.totalScanned} ${config.entityType} across ${searchStats.pageCount} page(s)\n\n` + + `**With expiration markers**: ${entitiesWithExpiration.length} ${config.entityType.slice(0, -1)}(s)\n\n` + + `**Expired**: 0 ${config.entityType}\n\n` + + `**Not yet expired**: ${notExpiredEntities.length} ${config.entityType.slice(0, -1)}(s)\n`; + + await core.summary.addRaw(summaryContent).write(); + return; + } + + core.info(`Found ${expiredEntities.length} expired ${config.entityType.slice(0, -1)}(s)`); + + // Step 3: Process expired entities with entity-specific handler + const { closed, skipped, failed } = await processExpiredEntities(expiredEntities, { + entityLabel: config.entityLabel, + maxPerRun: DEFAULT_MAX_UPDATES_PER_RUN, + delayMs: DEFAULT_GRAPHQL_DELAY_MS, + processEntity: config.processEntity, + }); + + // Step 4: Build and write summary + const summaryContent = buildExpirationSummary({ + heading: config.summaryHeading, + entityLabel: config.entityLabel, + searchStats, + withExpirationCount: entitiesWithExpiration.length, + expired: expiredEntities, + notExpired: notExpiredEntities, + closed, + skipped, + failed, + maxPerRun: DEFAULT_MAX_UPDATES_PER_RUN, + includeSkippedHeading: config.includeSkippedHeading || false, + now, + }); + + await core.summary.addRaw(summaryContent).write(); + core.info(`Successfully closed ${closed.length} expired ${config.entityType.slice(0, -1)}(s)`); +} + +module.exports = { + executeExpiredEntityCleanup, +}; diff --git a/actions/setup/js/expired_entity_main_flow.test.cjs b/actions/setup/js/expired_entity_main_flow.test.cjs new file mode 100644 index 00000000000..6411c4808f8 --- /dev/null +++ b/actions/setup/js/expired_entity_main_flow.test.cjs @@ -0,0 +1,207 @@ +// @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("expired_entity_main_flow", () => { + let mockGithub; + + beforeEach(() => { + vi.clearAllMocks(); + mockGithub = { + graphql: vi.fn(), + rest: { + issues: { + createComment: vi.fn(), + }, + }, + }; + global.github = mockGithub; + }); + + describe("executeExpiredEntityCleanup - integration", () => { + it("should handle no entities found scenario", async () => { + const module = await import("./expired_entity_main_flow.cjs"); + + // Mock the search query to return no entities + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + issues: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [], + }, + }, + }); + + const config = { + entityType: "issues", + graphqlField: "issues", + resultKey: "issues", + entityLabel: "Issue", + summaryHeading: "Expired Issues Cleanup", + processEntity: vi.fn(), + }; + + await module.executeExpiredEntityCleanup(mockGithub, "owner", "repo", config); + + expect(mockCore.info).toHaveBeenCalledWith("Searching for expired issues in owner/repo"); + expect(mockCore.info).toHaveBeenCalledWith("No issues with expiration markers found"); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Expired Issues Cleanup")); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("No issues with expiration markers found")); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + + it("should handle entities found but none expired", async () => { + const module = await import("./expired_entity_main_flow.cjs"); + + // Mock the search query to return an entity that expires in the future + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 7); // 7 days in future + + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + issues: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "issue_123", + number: 1, + title: "Test Issue", + url: "https://github.com/testowner/testrepo/issues/1", + body: `\n> AI generated by Test\n>\n> - [x] expires on future date`, + createdAt: new Date().toISOString(), + }, + ], + }, + }, + }); + + const config = { + entityType: "issues", + graphqlField: "issues", + resultKey: "issues", + entityLabel: "Issue", + summaryHeading: "Expired Issues Cleanup", + processEntity: vi.fn(), + }; + + await module.executeExpiredEntityCleanup(mockGithub, "owner", "repo", config); + + expect(mockCore.info).toHaveBeenCalledWith("No expired issues found"); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("**Expired**: 0 issues")); + expect(config.processEntity).not.toHaveBeenCalled(); + }); + + it("should process expired entities successfully", async () => { + const module = await import("./expired_entity_main_flow.cjs"); + + // Mock the search query to return an expired entity + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + issues: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "issue_expired", + number: 42, + title: "Expired Issue", + url: "https://github.com/testowner/testrepo/issues/42", + body: "\n> AI generated\n>\n> - [x] expires on Jan 20, 2020", + createdAt: "2020-01-19T09:20:00.000Z", + }, + ], + }, + }, + }); + + mockGithub.rest.issues.createComment.mockResolvedValueOnce({ data: { id: 1 } }); + + const processEntityMock = vi.fn().mockResolvedValue({ + status: "closed", + record: { number: 42, url: "https://github.com/testowner/testrepo/issues/42", title: "Expired Issue" }, + }); + + const config = { + entityType: "issues", + graphqlField: "issues", + resultKey: "issues", + entityLabel: "Issue", + summaryHeading: "Expired Issues Cleanup", + processEntity: processEntityMock, + }; + + await module.executeExpiredEntityCleanup(mockGithub, "owner", "repo", config); + + expect(processEntityMock).toHaveBeenCalledTimes(1); + expect(processEntityMock).toHaveBeenCalledWith( + expect.objectContaining({ + number: 42, + title: "Expired Issue", + }) + ); + + expect(mockCore.info).toHaveBeenCalledWith("Successfully closed 1 expired issue(s)"); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Successfully Closed Issues")); + }); + + it("should enable dedupe when configured", async () => { + const module = await import("./expired_entity_main_flow.cjs"); + + // Mock the search query to return no entities + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussions: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [], + }, + }, + }); + + const config = { + entityType: "discussions", + graphqlField: "discussions", + resultKey: "discussions", + entityLabel: "Discussion", + summaryHeading: "Expired Discussions Cleanup", + enableDedupe: true, + processEntity: vi.fn(), + }; + + await module.executeExpiredEntityCleanup(mockGithub, "owner", "repo", config); + + // Just verify it completes without error + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + }); +});