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();
+ });
+ });
+});