diff --git a/actions/setup/js/close_expired_discussions.cjs b/actions/setup/js/close_expired_discussions.cjs
index 1d0ecd540a..be6a9b0eee 100644
--- a/actions/setup/js/close_expired_discussions.cjs
+++ b/actions/setup/js/close_expired_discussions.cjs
@@ -1,38 +1,8 @@
// @ts-check
//
-const { getErrorMessage } = require("./error_helpers.cjs");
-const { extractExpirationDate } = require("./ephemerals.cjs");
const { searchEntitiesWithExpiration } = require("./expired_entity_search_helpers.cjs");
-
-/**
- * Maximum number of discussions to update per run
- */
-const MAX_UPDATES_PER_RUN = 100;
-
-/**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
-const GRAPHQL_DELAY_MS = 500;
-
-/**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
-function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
-}
-
-/**
- * Validate discussion creation date
- * @param {string} createdAt - ISO 8601 creation date
- * @returns {boolean} True if valid
- */
-function validateCreationDate(createdAt) {
- const creationDate = new Date(createdAt);
- return !isNaN(creationDate.getTime());
-}
+const { buildExpirationSummary, categorizeByExpiration, DEFAULT_GRAPHQL_DELAY_MS, DEFAULT_MAX_UPDATES_PER_RUN, processExpiredEntities } = require("./expired_entity_cleanup_helpers.cjs");
/**
* Add comment to a GitHub Discussion using GraphQL
@@ -145,54 +115,13 @@ async function main() {
core.info(`Found ${discussionsWithExpiration.length} discussion(s) with expiration markers`);
- // Check which discussions are expired
- const now = new Date();
- core.info(`Current date/time: ${now.toISOString()}`);
- const expiredDiscussions = [];
- const notExpiredDiscussions = [];
-
- for (const discussion of discussionsWithExpiration) {
- core.info(`Processing discussion #${discussion.number}: ${discussion.title}`);
-
- // Validate creation date
- if (!validateCreationDate(discussion.createdAt)) {
- core.warning(` Discussion #${discussion.number} has invalid creation date: ${discussion.createdAt}, skipping`);
- continue;
- }
- core.info(` Creation date: ${discussion.createdAt}`);
-
- // Extract and validate expiration date
- const expirationDate = extractExpirationDate(discussion.body);
- if (!expirationDate) {
- core.warning(` Discussion #${discussion.number} has invalid expiration date format, skipping`);
- continue;
- }
- core.info(` Expiration date: ${expirationDate.toISOString()}`);
-
- // Check if expired
- const isExpired = now >= expirationDate;
- const timeDiff = expirationDate.getTime() - now.getTime();
- const daysUntilExpiration = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
- const hoursUntilExpiration = Math.floor(timeDiff / (1000 * 60 * 60));
-
- if (isExpired) {
- const daysSinceExpiration = Math.abs(daysUntilExpiration);
- const hoursSinceExpiration = Math.abs(hoursUntilExpiration);
- core.info(` ✓ Discussion #${discussion.number} is EXPIRED (expired ${daysSinceExpiration} days, ${hoursSinceExpiration % 24} hours ago)`);
- expiredDiscussions.push({
- ...discussion,
- expirationDate: expirationDate,
- });
- } else {
- core.info(` ✗ Discussion #${discussion.number} is NOT expired (expires in ${daysUntilExpiration} days, ${hoursUntilExpiration % 24} hours)`);
- notExpiredDiscussions.push({
- ...discussion,
- expirationDate: expirationDate,
- });
- }
- }
-
- core.info(`Expiration check complete: ${expiredDiscussions.length} expired, ${notExpiredDiscussions.length} not yet expired`);
+ const {
+ expired: expiredDiscussions,
+ notExpired: notExpiredDiscussions,
+ now,
+ } = categorizeByExpiration(discussionsWithExpiration, {
+ entityLabel: "Discussion",
+ });
if (expiredDiscussions.length === 0) {
core.info("No expired discussions found");
@@ -210,174 +139,81 @@ async function main() {
core.info(`Found ${expiredDiscussions.length} expired discussion(s)`);
- // Limit to MAX_UPDATES_PER_RUN
- const discussionsToClose = expiredDiscussions.slice(0, MAX_UPDATES_PER_RUN);
-
- if (expiredDiscussions.length > MAX_UPDATES_PER_RUN) {
- core.warning(`Found ${expiredDiscussions.length} expired discussions, but only closing the first ${MAX_UPDATES_PER_RUN}`);
- core.info(`Remaining ${expiredDiscussions.length - MAX_UPDATES_PER_RUN} expired discussions will be closed in the next run`);
- }
-
- core.info(`Preparing to close ${discussionsToClose.length} discussion(s)`);
-
- let closedCount = 0;
- 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 {
- // Check if an expiration comment already exists and if discussion is closed
+ const { closed, skipped, failed } = await processExpiredEntities(expiredDiscussions, {
+ entityLabel: "Discussion",
+ maxPerRun: DEFAULT_MAX_UPDATES_PER_RUN,
+ delayMs: DEFAULT_GRAPHQL_DELAY_MS,
+ 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);
if (isClosed) {
core.warning(` Discussion #${discussion.number} is already closed, skipping`);
- skippedDiscussions.push({
- number: discussion.number,
- url: discussion.url,
- title: discussion.title,
- });
- skippedCount++;
- continue;
+ return {
+ status: "skipped",
+ record: {
+ number: discussion.number,
+ url: discussion.url,
+ title: discussion.title,
+ },
+ };
}
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++;
+ return {
+ status: "skipped",
+ record: {
+ number: discussion.number,
+ url: discussion.url,
+ title: discussion.title,
+ },
+ };
}
- core.info(`✓ Successfully processed discussion #${discussion.number}: ${discussion.url}`);
- } catch (error) {
- core.error(`✗ Failed to close discussion #${discussion.number}: ${getErrorMessage(error)}`);
- core.error(` Error details: ${JSON.stringify(error, null, 2)}`);
- failedDiscussions.push({
- number: discussion.number,
- url: discussion.url,
- title: discussion.title,
- error: getErrorMessage(error),
- });
- // Continue with other discussions even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < discussionsToClose.length - 1) {
- core.info(` Waiting ${GRAPHQL_DELAY_MS}ms before next operation...`);
- await delay(GRAPHQL_DELAY_MS);
- }
- }
+ const closingMessage = `This discussion was automatically closed because it expired on ${discussion.expirationDate.toISOString()}.\n\n`;
- // Write comprehensive summary
- let summaryContent = `## Expired Discussions Cleanup\n\n`;
- summaryContent += `**Scan Summary**\n`;
- summaryContent += `- Scanned: ${searchStats.totalScanned} discussions across ${searchStats.pageCount} page(s)\n`;
- summaryContent += `- With expiration markers: ${discussionsWithExpiration.length} discussion(s)\n`;
- summaryContent += `- Expired: ${expiredDiscussions.length} discussion(s)\n`;
- summaryContent += `- Not yet expired: ${notExpiredDiscussions.length} discussion(s)\n\n`;
-
- 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`;
- }
- if (expiredDiscussions.length > MAX_UPDATES_PER_RUN) {
- summaryContent += `- Remaining for next run: ${expiredDiscussions.length - MAX_UPDATES_PER_RUN} discussion(s)\n`;
- }
- summaryContent += `\n`;
-
- if (closedCount > 0) {
- summaryContent += `### Successfully Closed Discussions\n\n`;
- for (const closed of closedDiscussions) {
- summaryContent += `- Discussion #${closed.number}: [${closed.title}](${closed.url})\n`;
- }
- summaryContent += `\n`;
- }
+ core.info(` Adding closing comment to discussion #${discussion.number}`);
+ await addDiscussionComment(github, discussion.id, closingMessage);
+ core.info(` ✓ Comment added successfully`);
- 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`;
- }
+ core.info(` Closing discussion #${discussion.number} as outdated`);
+ await closeDiscussionAsOutdated(github, discussion.id);
+ core.info(` ✓ Discussion closed successfully`);
- if (failedDiscussions.length > 0) {
- summaryContent += `### Failed to Close\n\n`;
- for (const failed of failedDiscussions) {
- summaryContent += `- Discussion #${failed.number}: [${failed.title}](${failed.url}) - Error: ${failed.error}\n`;
- }
- summaryContent += `\n`;
- }
+ return {
+ status: "closed",
+ record: {
+ number: discussion.number,
+ url: discussion.url,
+ title: discussion.title,
+ },
+ };
+ },
+ });
- if (notExpiredDiscussions.length > 0 && notExpiredDiscussions.length <= 10) {
- summaryContent += `### Not Yet Expired\n\n`;
- for (const notExpired of notExpiredDiscussions) {
- const timeDiff = notExpired.expirationDate.getTime() - now.getTime();
- const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
- const hours = Math.floor(timeDiff / (1000 * 60 * 60)) % 24;
- summaryContent += `- Discussion #${notExpired.number}: [${notExpired.title}](${notExpired.url}) - Expires in ${days}d ${hours}h\n`;
- }
- } else if (notExpiredDiscussions.length > 10) {
- summaryContent += `### Not Yet Expired\n\n`;
- summaryContent += `${notExpiredDiscussions.length} discussion(s) not yet expired (showing first 10):\n\n`;
- for (let i = 0; i < 10; i++) {
- const notExpired = notExpiredDiscussions[i];
- const timeDiff = notExpired.expirationDate.getTime() - now.getTime();
- const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
- const hours = Math.floor(timeDiff / (1000 * 60 * 60)) % 24;
- summaryContent += `- Discussion #${notExpired.number}: [${notExpired.title}](${notExpired.url}) - Expires in ${days}d ${hours}h\n`;
- }
- }
+ 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 ${closedCount} expired discussion(s)`);
+ 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 febb8c5483..3872511ef0 100644
--- a/actions/setup/js/close_expired_issues.cjs
+++ b/actions/setup/js/close_expired_issues.cjs
@@ -1,38 +1,8 @@
// @ts-check
//
-const { getErrorMessage } = require("./error_helpers.cjs");
-const { extractExpirationDate } = require("./ephemerals.cjs");
const { searchEntitiesWithExpiration } = require("./expired_entity_search_helpers.cjs");
-
-/**
- * Maximum number of issues to update per run
- */
-const MAX_UPDATES_PER_RUN = 100;
-
-/**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
-const GRAPHQL_DELAY_MS = 500;
-
-/**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
-function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
-}
-
-/**
- * Validate issue creation date
- * @param {string} createdAt - ISO 8601 creation date
- * @returns {boolean} True if valid
- */
-function validateCreationDate(createdAt) {
- const creationDate = new Date(createdAt);
- return !isNaN(creationDate.getTime());
-}
+const { buildExpirationSummary, categorizeByExpiration, DEFAULT_GRAPHQL_DELAY_MS, DEFAULT_MAX_UPDATES_PER_RUN, processExpiredEntities } = require("./expired_entity_cleanup_helpers.cjs");
/**
* Add comment to a GitHub Issue using REST API
@@ -101,54 +71,13 @@ async function main() {
core.info(`Found ${issuesWithExpiration.length} issue(s) with expiration markers`);
- // Check which issues are expired
- const now = new Date();
- core.info(`Current date/time: ${now.toISOString()}`);
- const expiredIssues = [];
- const notExpiredIssues = [];
-
- for (const issue of issuesWithExpiration) {
- core.info(`Processing issue #${issue.number}: ${issue.title}`);
-
- // Validate creation date
- if (!validateCreationDate(issue.createdAt)) {
- core.warning(` Issue #${issue.number} has invalid creation date: ${issue.createdAt}, skipping`);
- continue;
- }
- core.info(` Creation date: ${issue.createdAt}`);
-
- // Extract and validate expiration date
- const expirationDate = extractExpirationDate(issue.body);
- if (!expirationDate) {
- core.warning(` Issue #${issue.number} has invalid expiration date format, skipping`);
- continue;
- }
- core.info(` Expiration date: ${expirationDate.toISOString()}`);
-
- // Check if expired
- const isExpired = now >= expirationDate;
- const timeDiff = expirationDate.getTime() - now.getTime();
- const daysUntilExpiration = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
- const hoursUntilExpiration = Math.floor(timeDiff / (1000 * 60 * 60));
-
- if (isExpired) {
- const daysSinceExpiration = Math.abs(daysUntilExpiration);
- const hoursSinceExpiration = Math.abs(hoursUntilExpiration);
- core.info(` ✓ Issue #${issue.number} is EXPIRED (expired ${daysSinceExpiration} days, ${hoursSinceExpiration % 24} hours ago)`);
- expiredIssues.push({
- ...issue,
- expirationDate: expirationDate,
- });
- } else {
- core.info(` ✗ Issue #${issue.number} is NOT expired (expires in ${daysUntilExpiration} days, ${hoursUntilExpiration % 24} hours)`);
- notExpiredIssues.push({
- ...issue,
- expirationDate: expirationDate,
- });
- }
- }
-
- core.info(`Expiration check complete: ${expiredIssues.length} expired, ${notExpiredIssues.length} not yet expired`);
+ const {
+ expired: expiredIssues,
+ notExpired: notExpiredIssues,
+ now,
+ } = categorizeByExpiration(issuesWithExpiration, {
+ entityLabel: "Issue",
+ });
if (expiredIssues.length === 0) {
core.info("No expired issues found");
@@ -166,122 +95,45 @@ async function main() {
core.info(`Found ${expiredIssues.length} expired issue(s)`);
- // Limit to MAX_UPDATES_PER_RUN
- const issuesToClose = expiredIssues.slice(0, MAX_UPDATES_PER_RUN);
-
- if (expiredIssues.length > MAX_UPDATES_PER_RUN) {
- core.warning(`Found ${expiredIssues.length} expired issues, but only closing the first ${MAX_UPDATES_PER_RUN}`);
- core.info(`Remaining ${expiredIssues.length - MAX_UPDATES_PER_RUN} expired issues will be closed in the next run`);
- }
-
- core.info(`Preparing to close ${issuesToClose.length} issue(s)`);
-
- let closedCount = 0;
- const closedIssues = [];
- const failedIssues = [];
-
- for (let i = 0; i < issuesToClose.length; i++) {
- const issue = issuesToClose[i];
-
- core.info(`[${i + 1}/${issuesToClose.length}] Processing issue #${issue.number}: ${issue.url}`);
-
- try {
+ const { closed, failed } = await processExpiredEntities(expiredIssues, {
+ entityLabel: "Issue",
+ maxPerRun: DEFAULT_MAX_UPDATES_PER_RUN,
+ delayMs: DEFAULT_GRAPHQL_DELAY_MS,
+ processEntity: async issue => {
const closingMessage = `This issue was automatically closed because it expired on ${issue.expirationDate.toISOString()}.`;
- // Add comment first
- core.info(` Adding closing comment to issue #${issue.number}`);
await addIssueComment(github, owner, repo, issue.number, closingMessage);
core.info(` ✓ Comment added successfully`);
- // Then close the issue as not planned
- core.info(` Closing issue #${issue.number} as not planned`);
await closeIssue(github, owner, repo, issue.number);
core.info(` ✓ Issue closed successfully`);
- closedIssues.push({
- number: issue.number,
- url: issue.url,
- title: issue.title,
- });
-
- closedCount++;
- core.info(`✓ Successfully processed issue #${issue.number}: ${issue.url}`);
- } catch (error) {
- core.error(`✗ Failed to close issue #${issue.number}: ${getErrorMessage(error)}`);
- core.error(` Error details: ${JSON.stringify(error, null, 2)}`);
- failedIssues.push({
- number: issue.number,
- url: issue.url,
- title: issue.title,
- error: getErrorMessage(error),
- });
- // Continue with other issues even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < issuesToClose.length - 1) {
- core.info(` Waiting ${GRAPHQL_DELAY_MS}ms before next operation...`);
- await delay(GRAPHQL_DELAY_MS);
- }
- }
-
- // Write comprehensive summary
- let summaryContent = `## Expired Issues Cleanup\n\n`;
- summaryContent += `**Scan Summary**\n`;
- summaryContent += `- Scanned: ${searchStats.totalScanned} issues across ${searchStats.pageCount} page(s)\n`;
- summaryContent += `- With expiration markers: ${issuesWithExpiration.length} issue(s)\n`;
- summaryContent += `- Expired: ${expiredIssues.length} issue(s)\n`;
- summaryContent += `- Not yet expired: ${notExpiredIssues.length} issue(s)\n\n`;
-
- summaryContent += `**Closing Summary**\n`;
- summaryContent += `- Successfully closed: ${closedCount} issue(s)\n`;
- if (failedIssues.length > 0) {
- summaryContent += `- Failed to close: ${failedIssues.length} issue(s)\n`;
- }
- if (expiredIssues.length > MAX_UPDATES_PER_RUN) {
- summaryContent += `- Remaining for next run: ${expiredIssues.length - MAX_UPDATES_PER_RUN} issue(s)\n`;
- }
- summaryContent += `\n`;
-
- if (closedCount > 0) {
- summaryContent += `### Successfully Closed Issues\n\n`;
- for (const closed of closedIssues) {
- summaryContent += `- Issue #${closed.number}: [${closed.title}](${closed.url})\n`;
- }
- summaryContent += `\n`;
- }
-
- if (failedIssues.length > 0) {
- summaryContent += `### Failed to Close\n\n`;
- for (const failed of failedIssues) {
- summaryContent += `- Issue #${failed.number}: [${failed.title}](${failed.url}) - Error: ${failed.error}\n`;
- }
- summaryContent += `\n`;
- }
+ return {
+ status: "closed",
+ record: {
+ number: issue.number,
+ url: issue.url,
+ title: issue.title,
+ },
+ };
+ },
+ });
- if (notExpiredIssues.length > 0 && notExpiredIssues.length <= 10) {
- summaryContent += `### Not Yet Expired\n\n`;
- for (const notExpired of notExpiredIssues) {
- const timeDiff = notExpired.expirationDate.getTime() - now.getTime();
- const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
- const hours = Math.floor(timeDiff / (1000 * 60 * 60)) % 24;
- summaryContent += `- Issue #${notExpired.number}: [${notExpired.title}](${notExpired.url}) - Expires in ${days}d ${hours}h\n`;
- }
- } else if (notExpiredIssues.length > 10) {
- summaryContent += `### Not Yet Expired\n\n`;
- summaryContent += `${notExpiredIssues.length} issue(s) not yet expired (showing first 10):\n\n`;
- for (let i = 0; i < 10; i++) {
- const notExpired = notExpiredIssues[i];
- const timeDiff = notExpired.expirationDate.getTime() - now.getTime();
- const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
- const hours = Math.floor(timeDiff / (1000 * 60 * 60)) % 24;
- summaryContent += `- Issue #${notExpired.number}: [${notExpired.title}](${notExpired.url}) - Expires in ${days}d ${hours}h\n`;
- }
- }
+ 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 ${closedCount} expired issue(s)`);
+ core.info(`Successfully closed ${closed.length} expired issue(s)`);
}
module.exports = { main };
diff --git a/actions/setup/js/close_expired_pull_requests.cjs b/actions/setup/js/close_expired_pull_requests.cjs
index a5858cc369..640c266481 100644
--- a/actions/setup/js/close_expired_pull_requests.cjs
+++ b/actions/setup/js/close_expired_pull_requests.cjs
@@ -1,38 +1,8 @@
// @ts-check
//
-const { getErrorMessage } = require("./error_helpers.cjs");
-const { extractExpirationDate } = require("./ephemerals.cjs");
const { searchEntitiesWithExpiration } = require("./expired_entity_search_helpers.cjs");
-
-/**
- * Maximum number of pull requests to update per run
- */
-const MAX_UPDATES_PER_RUN = 100;
-
-/**
- * Delay between GraphQL API calls in milliseconds to avoid rate limiting
- */
-const GRAPHQL_DELAY_MS = 500;
-
-/**
- * Delay execution for a specified number of milliseconds
- * @param {number} ms - Milliseconds to delay
- * @returns {Promise}
- */
-function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
-}
-
-/**
- * Validate pull request creation date
- * @param {string} createdAt - ISO 8601 creation date
- * @returns {boolean} True if valid
- */
-function validateCreationDate(createdAt) {
- const creationDate = new Date(createdAt);
- return !isNaN(creationDate.getTime());
-}
+const { buildExpirationSummary, categorizeByExpiration, DEFAULT_GRAPHQL_DELAY_MS, DEFAULT_MAX_UPDATES_PER_RUN, processExpiredEntities } = require("./expired_entity_cleanup_helpers.cjs");
/**
* Add comment to a GitHub Pull Request using REST API
@@ -100,54 +70,13 @@ async function main() {
core.info(`Found ${pullRequestsWithExpiration.length} pull request(s) with expiration markers`);
- // Check which pull requests are expired
- const now = new Date();
- core.info(`Current date/time: ${now.toISOString()}`);
- const expiredPullRequests = [];
- const notExpiredPullRequests = [];
-
- for (const pr of pullRequestsWithExpiration) {
- core.info(`Processing pull request #${pr.number}: ${pr.title}`);
-
- // Validate creation date
- if (!validateCreationDate(pr.createdAt)) {
- core.warning(` Pull request #${pr.number} has invalid creation date: ${pr.createdAt}, skipping`);
- continue;
- }
- core.info(` Creation date: ${pr.createdAt}`);
-
- // Extract and validate expiration date
- const expirationDate = extractExpirationDate(pr.body);
- if (!expirationDate) {
- core.warning(` Pull request #${pr.number} has invalid expiration date format, skipping`);
- continue;
- }
- core.info(` Expiration date: ${expirationDate.toISOString()}`);
-
- // Check if expired
- const isExpired = now >= expirationDate;
- const timeDiff = expirationDate.getTime() - now.getTime();
- const daysUntilExpiration = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
- const hoursUntilExpiration = Math.floor(timeDiff / (1000 * 60 * 60));
-
- if (isExpired) {
- const daysSinceExpiration = Math.abs(daysUntilExpiration);
- const hoursSinceExpiration = Math.abs(hoursUntilExpiration);
- core.info(` ✓ Pull request #${pr.number} is EXPIRED (expired ${daysSinceExpiration} days, ${hoursSinceExpiration % 24} hours ago)`);
- expiredPullRequests.push({
- ...pr,
- expirationDate: expirationDate,
- });
- } else {
- core.info(` ✗ Pull request #${pr.number} is NOT expired (expires in ${daysUntilExpiration} days, ${hoursUntilExpiration % 24} hours)`);
- notExpiredPullRequests.push({
- ...pr,
- expirationDate: expirationDate,
- });
- }
- }
-
- core.info(`Expiration check complete: ${expiredPullRequests.length} expired, ${notExpiredPullRequests.length} not yet expired`);
+ const {
+ expired: expiredPullRequests,
+ notExpired: notExpiredPullRequests,
+ now,
+ } = categorizeByExpiration(pullRequestsWithExpiration, {
+ entityLabel: "Pull Request",
+ });
if (expiredPullRequests.length === 0) {
core.info("No expired pull requests found");
@@ -165,122 +94,45 @@ async function main() {
core.info(`Found ${expiredPullRequests.length} expired pull request(s)`);
- // Limit to MAX_UPDATES_PER_RUN
- const pullRequestsToClose = expiredPullRequests.slice(0, MAX_UPDATES_PER_RUN);
-
- if (expiredPullRequests.length > MAX_UPDATES_PER_RUN) {
- core.warning(`Found ${expiredPullRequests.length} expired pull requests, but only closing the first ${MAX_UPDATES_PER_RUN}`);
- core.info(`Remaining ${expiredPullRequests.length - MAX_UPDATES_PER_RUN} expired pull requests will be closed in the next run`);
- }
-
- core.info(`Preparing to close ${pullRequestsToClose.length} pull request(s)`);
-
- let closedCount = 0;
- const closedPullRequests = [];
- const failedPullRequests = [];
-
- for (let i = 0; i < pullRequestsToClose.length; i++) {
- const pr = pullRequestsToClose[i];
-
- core.info(`[${i + 1}/${pullRequestsToClose.length}] Processing pull request #${pr.number}: ${pr.url}`);
-
- try {
+ const { closed, failed } = await processExpiredEntities(expiredPullRequests, {
+ entityLabel: "Pull Request",
+ maxPerRun: DEFAULT_MAX_UPDATES_PER_RUN,
+ delayMs: DEFAULT_GRAPHQL_DELAY_MS,
+ processEntity: async pr => {
const closingMessage = `This pull request was automatically closed because it expired on ${pr.expirationDate.toISOString()}.`;
- // Add comment first
- core.info(` Adding closing comment to pull request #${pr.number}`);
await addPullRequestComment(github, owner, repo, pr.number, closingMessage);
core.info(` ✓ Comment added successfully`);
- // Then close the pull request
- core.info(` Closing pull request #${pr.number}`);
await closePullRequest(github, owner, repo, pr.number);
core.info(` ✓ Pull request closed successfully`);
- closedPullRequests.push({
- number: pr.number,
- url: pr.url,
- title: pr.title,
- });
-
- closedCount++;
- core.info(`✓ Successfully processed pull request #${pr.number}: ${pr.url}`);
- } catch (error) {
- core.error(`✗ Failed to close pull request #${pr.number}: ${getErrorMessage(error)}`);
- core.error(` Error details: ${JSON.stringify(error, null, 2)}`);
- failedPullRequests.push({
- number: pr.number,
- url: pr.url,
- title: pr.title,
- error: getErrorMessage(error),
- });
- // Continue with other pull requests even if one fails
- }
-
- // Add delay between GraphQL operations to avoid rate limiting (except for the last item)
- if (i < pullRequestsToClose.length - 1) {
- core.info(` Waiting ${GRAPHQL_DELAY_MS}ms before next operation...`);
- await delay(GRAPHQL_DELAY_MS);
- }
- }
-
- // Write comprehensive summary
- let summaryContent = `## Expired Pull Requests Cleanup\n\n`;
- summaryContent += `**Scan Summary**\n`;
- summaryContent += `- Scanned: ${searchStats.totalScanned} pull requests across ${searchStats.pageCount} page(s)\n`;
- summaryContent += `- With expiration markers: ${pullRequestsWithExpiration.length} pull request(s)\n`;
- summaryContent += `- Expired: ${expiredPullRequests.length} pull request(s)\n`;
- summaryContent += `- Not yet expired: ${notExpiredPullRequests.length} pull request(s)\n\n`;
-
- summaryContent += `**Closing Summary**\n`;
- summaryContent += `- Successfully closed: ${closedCount} pull request(s)\n`;
- if (failedPullRequests.length > 0) {
- summaryContent += `- Failed to close: ${failedPullRequests.length} pull request(s)\n`;
- }
- if (expiredPullRequests.length > MAX_UPDATES_PER_RUN) {
- summaryContent += `- Remaining for next run: ${expiredPullRequests.length - MAX_UPDATES_PER_RUN} pull request(s)\n`;
- }
- summaryContent += `\n`;
-
- if (closedCount > 0) {
- summaryContent += `### Successfully Closed Pull Requests\n\n`;
- for (const closed of closedPullRequests) {
- summaryContent += `- Pull Request #${closed.number}: [${closed.title}](${closed.url})\n`;
- }
- summaryContent += `\n`;
- }
-
- if (failedPullRequests.length > 0) {
- summaryContent += `### Failed to Close\n\n`;
- for (const failed of failedPullRequests) {
- summaryContent += `- Pull Request #${failed.number}: [${failed.title}](${failed.url}) - Error: ${failed.error}\n`;
- }
- summaryContent += `\n`;
- }
+ return {
+ status: "closed",
+ record: {
+ number: pr.number,
+ url: pr.url,
+ title: pr.title,
+ },
+ };
+ },
+ });
- if (notExpiredPullRequests.length > 0 && notExpiredPullRequests.length <= 10) {
- summaryContent += `### Not Yet Expired\n\n`;
- for (const notExpired of notExpiredPullRequests) {
- const timeDiff = notExpired.expirationDate.getTime() - now.getTime();
- const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
- const hours = Math.floor(timeDiff / (1000 * 60 * 60)) % 24;
- summaryContent += `- Pull Request #${notExpired.number}: [${notExpired.title}](${notExpired.url}) - Expires in ${days}d ${hours}h\n`;
- }
- } else if (notExpiredPullRequests.length > 10) {
- summaryContent += `### Not Yet Expired\n\n`;
- summaryContent += `${notExpiredPullRequests.length} pull request(s) not yet expired (showing first 10):\n\n`;
- for (let i = 0; i < 10; i++) {
- const notExpired = notExpiredPullRequests[i];
- const timeDiff = notExpired.expirationDate.getTime() - now.getTime();
- const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
- const hours = Math.floor(timeDiff / (1000 * 60 * 60)) % 24;
- summaryContent += `- Pull Request #${notExpired.number}: [${notExpired.title}](${notExpired.url}) - Expires in ${days}d ${hours}h\n`;
- }
- }
+ 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 ${closedCount} expired pull request(s)`);
+ core.info(`Successfully closed ${closed.length} expired pull request(s)`);
}
module.exports = { main };
diff --git a/actions/setup/js/dispatch_workflow.test.cjs b/actions/setup/js/dispatch_workflow.test.cjs
index a11b4ef690..076ad7e50d 100644
--- a/actions/setup/js/dispatch_workflow.test.cjs
+++ b/actions/setup/js/dispatch_workflow.test.cjs
@@ -257,8 +257,9 @@ describe("dispatch_workflow handler factory", () => {
// Verify first dispatch had no delay
expect(firstDispatchTime - startTime).toBeLessThan(1000);
- // Verify second dispatch was delayed by at least 5 seconds
- expect(secondDispatchTime - firstDispatchTime).toBeGreaterThanOrEqual(5000);
+ // Verify second dispatch was delayed by approximately 5 seconds
+ // Use a slightly lower threshold (4995ms) to account for timing jitter
+ expect(secondDispatchTime - firstDispatchTime).toBeGreaterThanOrEqual(4995);
expect(secondDispatchTime - firstDispatchTime).toBeLessThan(6000);
});
});
diff --git a/actions/setup/js/expired_entity_cleanup_helpers.cjs b/actions/setup/js/expired_entity_cleanup_helpers.cjs
new file mode 100644
index 0000000000..1cd0b46803
--- /dev/null
+++ b/actions/setup/js/expired_entity_cleanup_helpers.cjs
@@ -0,0 +1,250 @@
+// @ts-check
+//
+
+const { extractExpirationDate } = require("./ephemerals.cjs");
+const { getErrorMessage } = require("./error_helpers.cjs");
+
+const DEFAULT_MAX_UPDATES_PER_RUN = 100;
+const DEFAULT_GRAPHQL_DELAY_MS = 500;
+
+/**
+ * Delay execution for a specified number of milliseconds
+ * @param {number} ms - Milliseconds to delay
+ * @returns {Promise}
+ */
+function delay(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+/**
+ * Validate entity creation date
+ * @param {string} createdAt - ISO 8601 creation date
+ * @returns {boolean} True if valid
+ */
+function validateCreationDate(createdAt) {
+ const creationDate = new Date(createdAt);
+ return !isNaN(creationDate.getTime());
+}
+
+/**
+ * Categorize entities by expiration state and log status
+ * @param {Array<{number: number, title: string, url: string, body: string, createdAt: string}>} entities
+ * @param {{entityLabel: string}} options
+ * @returns {{expired: Array, notExpired: Array, now: Date}}
+ */
+function categorizeByExpiration(entities, { entityLabel }) {
+ const now = new Date();
+ core.info(`Current date/time: ${now.toISOString()}`);
+
+ const expired = [];
+ const notExpired = [];
+
+ for (const entity of entities) {
+ core.info(`Processing ${entityLabel} #${entity.number}: ${entity.title}`);
+
+ if (!validateCreationDate(entity.createdAt)) {
+ core.warning(` ${entityLabel} #${entity.number} has invalid creation date: ${entity.createdAt}, skipping`);
+ continue;
+ }
+ core.info(` Creation date: ${entity.createdAt}`);
+
+ const expirationDate = extractExpirationDate(entity.body);
+ if (!expirationDate) {
+ core.warning(` ${entityLabel} #${entity.number} has invalid expiration date format, skipping`);
+ continue;
+ }
+ core.info(` Expiration date: ${expirationDate.toISOString()}`);
+
+ const isExpired = now >= expirationDate;
+ const timeDiff = expirationDate.getTime() - now.getTime();
+ const daysUntilExpiration = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
+ const hoursUntilExpiration = Math.floor(timeDiff / (1000 * 60 * 60));
+
+ if (isExpired) {
+ const daysSinceExpiration = Math.abs(daysUntilExpiration);
+ const hoursSinceExpiration = Math.abs(hoursUntilExpiration);
+ core.info(` ✓ ${entityLabel} #${entity.number} is EXPIRED (expired ${daysSinceExpiration} days, ${hoursSinceExpiration % 24} hours ago)`);
+ expired.push({
+ ...entity,
+ expirationDate,
+ });
+ } else {
+ core.info(` ✗ ${entityLabel} #${entity.number} is NOT expired (expires in ${daysUntilExpiration} days, ${hoursUntilExpiration % 24} hours)`);
+ notExpired.push({
+ ...entity,
+ expirationDate,
+ });
+ }
+ }
+
+ core.info(`Expiration check complete: ${expired.length} expired, ${notExpired.length} not yet expired`);
+ return { expired, notExpired, now };
+}
+
+/**
+ * Process expired entities with per-entity handler and rate limiting
+ * @param {Array} expiredEntities
+ * @param {{
+ * entityLabel: string,
+ * maxPerRun?: number,
+ * delayMs?: number,
+ * processEntity: (entity: any) => Promise<{status: "closed" | "skipped", record: any}>
+ * }} options
+ * @returns {Promise<{closed: Array, skipped: Array, failed: Array}>}
+ */
+async function processExpiredEntities(expiredEntities, { entityLabel, maxPerRun = DEFAULT_MAX_UPDATES_PER_RUN, delayMs = DEFAULT_GRAPHQL_DELAY_MS, processEntity }) {
+ const entitiesToProcess = expiredEntities.slice(0, maxPerRun);
+
+ if (expiredEntities.length > maxPerRun) {
+ core.warning(`Found ${expiredEntities.length} expired ${entityLabel.toLowerCase()}s, but only closing the first ${maxPerRun}`);
+ core.info(`Remaining ${expiredEntities.length - maxPerRun} expired ${entityLabel.toLowerCase()}s will be closed in the next run`);
+ }
+
+ core.info(`Preparing to close ${entitiesToProcess.length} ${entityLabel.toLowerCase()}(s)`);
+
+ const closed = [];
+ const failed = [];
+ const skipped = [];
+
+ for (let i = 0; i < entitiesToProcess.length; i++) {
+ const entity = entitiesToProcess[i];
+ core.info(`[${i + 1}/${entitiesToProcess.length}] Processing ${entityLabel.toLowerCase()} #${entity.number}: ${entity.url}`);
+
+ try {
+ const result = await processEntity(entity);
+
+ if (result.status === "skipped") {
+ skipped.push(result.record);
+ } else {
+ closed.push(result.record);
+ }
+
+ core.info(`✓ Successfully processed ${entityLabel.toLowerCase()} #${entity.number}: ${entity.url}`);
+ } catch (error) {
+ core.error(`✗ Failed to close ${entityLabel.toLowerCase()} #${entity.number}: ${getErrorMessage(error)}`);
+ core.error(` Error details: ${JSON.stringify(error, null, 2)}`);
+ failed.push({
+ number: entity.number,
+ url: entity.url,
+ title: entity.title,
+ error: getErrorMessage(error),
+ });
+ }
+
+ if (i < entitiesToProcess.length - 1) {
+ core.info(` Waiting ${delayMs}ms before next operation...`);
+ await delay(delayMs);
+ }
+ }
+
+ return { closed, skipped, failed };
+}
+
+/**
+ * Build not-yet-expired list section
+ * @param {Array} notExpiredEntities
+ * @param {Date} now
+ * @param {string} entityLabel
+ * @returns {string}
+ */
+function buildNotExpiredSection(notExpiredEntities, now, entityLabel) {
+ if (notExpiredEntities.length === 0) {
+ return "";
+ }
+
+ let section = `### Not Yet Expired\n\n`;
+
+ const list = notExpiredEntities.length > 10 ? notExpiredEntities.slice(0, 10) : notExpiredEntities;
+ if (notExpiredEntities.length > 10) {
+ section += `${notExpiredEntities.length} ${entityLabel.toLowerCase()}(s) not yet expired (showing first 10):\n\n`;
+ }
+
+ for (const entity of list) {
+ const timeDiff = entity.expirationDate.getTime() - now.getTime();
+ const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
+ const hours = Math.floor(timeDiff / (1000 * 60 * 60)) % 24;
+ section += `- ${entityLabel} #${entity.number}: [${entity.title}](${entity.url}) - Expires in ${days}d ${hours}h\n`;
+ }
+
+ return section;
+}
+
+/**
+ * Build standardized cleanup summary content
+ * @param {{
+ * heading: string,
+ * entityLabel: string,
+ * searchStats: {totalScanned: number, pageCount: number},
+ * withExpirationCount: number,
+ * expired: Array,
+ * notExpired: Array,
+ * closed: Array,
+ * failed: Array,
+ * skipped?: Array,
+ * maxPerRun: number,
+ * includeSkippedHeading?: boolean,
+ * now?: Date
+ * }} params
+ * @returns {string}
+ */
+function buildExpirationSummary(params) {
+ const { heading, entityLabel, searchStats, withExpirationCount, expired, notExpired, closed, failed, skipped = [], maxPerRun, includeSkippedHeading = false, now = new Date() } = params;
+
+ let summaryContent = `## ${heading}\n\n`;
+ summaryContent += `**Scan Summary**\n`;
+ summaryContent += `- Scanned: ${searchStats.totalScanned} ${entityLabel.toLowerCase()}s across ${searchStats.pageCount} page(s)\n`;
+ summaryContent += `- With expiration markers: ${withExpirationCount} ${entityLabel.toLowerCase()}(s)\n`;
+ summaryContent += `- Expired: ${expired.length} ${entityLabel.toLowerCase()}(s)\n`;
+ summaryContent += `- Not yet expired: ${notExpired.length} ${entityLabel.toLowerCase()}(s)\n\n`;
+
+ summaryContent += `**Closing Summary**\n`;
+ summaryContent += `- Successfully closed: ${closed.length} ${entityLabel.toLowerCase()}(s)\n`;
+ if (includeSkippedHeading && skipped.length > 0) {
+ summaryContent += `- Skipped (already had comment): ${skipped.length} ${entityLabel.toLowerCase()}(s)\n`;
+ }
+ if (failed.length > 0) {
+ summaryContent += `- Failed to close: ${failed.length} ${entityLabel.toLowerCase()}(s)\n`;
+ }
+ if (expired.length > maxPerRun) {
+ summaryContent += `- Remaining for next run: ${expired.length - maxPerRun} ${entityLabel.toLowerCase()}(s)\n`;
+ }
+ summaryContent += `\n`;
+
+ if (closed.length > 0) {
+ summaryContent += `### Successfully Closed ${entityLabel}s\n\n`;
+ for (const entity of closed) {
+ summaryContent += `- ${entityLabel} #${entity.number}: [${entity.title}](${entity.url})\n`;
+ }
+ summaryContent += `\n`;
+ }
+
+ if (includeSkippedHeading && skipped.length > 0) {
+ summaryContent += `### Skipped (Already Had Comment)\n\n`;
+ for (const entity of skipped) {
+ summaryContent += `- ${entityLabel} #${entity.number}: [${entity.title}](${entity.url})\n`;
+ }
+ summaryContent += `\n`;
+ }
+
+ if (failed.length > 0) {
+ summaryContent += `### Failed to Close\n\n`;
+ for (const entity of failed) {
+ summaryContent += `- ${entityLabel} #${entity.number}: [${entity.title}](${entity.url}) - Error: ${entity.error}\n`;
+ }
+ summaryContent += `\n`;
+ }
+
+ summaryContent += buildNotExpiredSection(notExpired, now, entityLabel);
+
+ return summaryContent;
+}
+
+module.exports = {
+ buildExpirationSummary,
+ categorizeByExpiration,
+ DEFAULT_GRAPHQL_DELAY_MS,
+ DEFAULT_MAX_UPDATES_PER_RUN,
+ delay,
+ processExpiredEntities,
+ validateCreationDate,
+};