Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 61 additions & 225 deletions actions/setup/js/close_expired_discussions.cjs
Original file line number Diff line number Diff line change
@@ -1,38 +1,8 @@
// @ts-check
// <reference types="@actions/github-script" />

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<void>}
*/
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
Expand Down Expand Up @@ -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");
Expand All @@ -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<!-- gh-aw-closed -->`;

// 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,
},
};
Comment on lines 251 to +176
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refactored code changes the behavior when a discussion already has an expiration comment. In the original code, when a discussion had an existing comment, it would:

  1. Add the discussion to the skipped list
  2. Close the discussion
  3. Also add it to the closed list

This meant the discussion appeared in both lists (skipped for having a comment, but still counted as closed).

The refactored code returns with status "skipped" immediately after closing (line 170), which means the discussion is only counted as "skipped" and not as "closed". This changes the summary statistics and could be misleading, as the discussion is actually being closed but not reflected in the "Successfully closed" count.

Copilot uses AI. Check for mistakes.
}

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<!-- gh-aw-closed -->`;

// 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 };
Loading
Loading