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
152 changes: 140 additions & 12 deletions actions/setup/js/close_expired_discussions.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,17 @@ async function searchDiscussionsWithExpiration(github, owner, repo) {
const discussions = [];
let hasNextPage = true;
let cursor = null;
let pageCount = 0;
let totalScanned = 0;

const { getErrorMessage } = require("./error_helpers.cjs");

core.info(`Starting GraphQL search for open discussions in ${owner}/${repo}`);

while (hasNextPage) {
pageCount++;
core.info(`Fetching page ${pageCount} of open discussions (cursor: ${cursor || "initial"})`);

const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
Expand Down Expand Up @@ -65,17 +72,27 @@ async function searchDiscussionsWithExpiration(github, owner, repo) {
});

if (!result || !result.repository || !result.repository.discussions) {
core.warning(`GraphQL query returned no data at page ${pageCount}`);
break;
}

const nodes = result.repository.discussions.nodes || [];
totalScanned += nodes.length;
core.info(`Page ${pageCount}: Retrieved ${nodes.length} open discussions (total scanned: ${totalScanned})`);

let agenticCount = 0;
let withExpirationCount = 0;

// Filter for discussions with agentic workflow markers and expiration comments
for (const discussion of nodes) {
// Check if created by an agentic workflow (body contains "> AI generated by" at start of line)
const agenticPattern = /^> AI generated by/m;
const isAgenticWorkflow = discussion.body && agenticPattern.test(discussion.body);

if (isAgenticWorkflow) {
agenticCount++;
}

if (!isAgenticWorkflow) {
continue;
}
Expand All @@ -85,15 +102,26 @@ async function searchDiscussionsWithExpiration(github, owner, repo) {
const match = discussion.body ? discussion.body.match(expirationPattern) : null;

if (match) {
withExpirationCount++;
core.info(` Found discussion #${discussion.number} with expiration marker: "${match[1]}" - ${discussion.title}`);
discussions.push(discussion);
}
}

core.info(`Page ${pageCount} summary: ${agenticCount} agentic discussions, ${withExpirationCount} with expiration markers`);

hasNextPage = result.repository.discussions.pageInfo.hasNextPage;
cursor = result.repository.discussions.pageInfo.endCursor;
}

return discussions;
core.info(`Search complete: Scanned ${totalScanned} discussions across ${pageCount} pages, found ${discussions.length} with expiration markers`);
return {
discussions,
stats: {
pageCount,
totalScanned,
},
};
}

/**
Expand Down Expand Up @@ -184,44 +212,82 @@ async function main() {
core.info(`Searching for expired discussions in ${owner}/${repo}`);

// Search for discussions with expiration markers
const discussionsWithExpiration = await searchDiscussionsWithExpiration(github, owner, repo);
const { discussions: discussionsWithExpiration, stats: searchStats } = await searchDiscussionsWithExpiration(github, owner, repo);

if (discussionsWithExpiration.length === 0) {
core.info("No discussions with expiration markers found");

// Write summary even when no discussions found
let summaryContent = `## Expired Discussions Cleanup\n\n`;
summaryContent += `**Scanned**: ${searchStats.totalScanned} discussions across ${searchStats.pageCount} page(s)\n\n`;
summaryContent += `**Result**: No discussions with expiration markers found\n`;
await core.summary.addRaw(summaryContent).write();

return;
}

core.info(`Found ${discussionsWithExpiration.length} discussion(s) with expiration markers`);

// 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, skipping`);
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, skipping`);
core.warning(` Discussion #${discussion.number} has invalid expiration date format, skipping`);
continue;
}
core.info(` Expiration date: ${expirationDate.toISOString()}`);

// Check if expired
if (now >= expirationDate) {
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`);

if (expiredDiscussions.length === 0) {
core.info("No expired discussions found");

// Write summary when no expired discussions
let summaryContent = `## Expired Discussions Cleanup\n\n`;
summaryContent += `**Scanned**: ${searchStats.totalScanned} discussions across ${searchStats.pageCount} page(s)\n\n`;
summaryContent += `**With expiration markers**: ${discussionsWithExpiration.length} discussion(s)\n\n`;
summaryContent += `**Expired**: 0 discussions\n\n`;
summaryContent += `**Not yet expired**: ${notExpiredDiscussions.length} discussion(s)\n`;
await core.summary.addRaw(summaryContent).write();

return;
}

Expand All @@ -232,24 +298,32 @@ async function main() {

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 = [];

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 {
const closingMessage = `This discussion was automatically closed because it expired on ${discussion.expirationDate.toISOString()}.`;

// Add comment first
core.info(`Adding closing comment to discussion #${discussion.number}`);
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`);
core.info(` Closing discussion #${discussion.number} as outdated`);
await closeDiscussionAsOutdated(github, discussion.id);
core.info(` ✓ Discussion closed successfully`);

closedDiscussions.push({
number: discussion.number,
Expand All @@ -258,28 +332,82 @@ async function main() {
});

closedCount++;
core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`);
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);
}
}

// Write summary
// 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 (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) {
let summaryContent = `## Closed Expired Discussions\n\n`;
summaryContent += `Closed **${closedCount}** expired discussion(s):\n\n`;
summaryContent += `### Successfully Closed Discussions\n\n`;
for (const closed of closedDiscussions) {
summaryContent += `- Discussion #${closed.number}: [${closed.title}](${closed.url})\n`;
}
await core.summary.addRaw(summaryContent).write();
summaryContent += `\n`;
}

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`;
}

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`;
}
}

await core.summary.addRaw(summaryContent).write();

core.info(`Successfully closed ${closedCount} expired discussion(s)`);
}

Expand Down
Loading