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, +};