Skip to content

Conversation

@Codex
Copy link
Contributor

@Codex Codex AI commented Feb 3, 2026

Duplicate expiration evaluation and summary logic in the issue/PR/discussion cleanup scripts made drift likely and maintenance costly. This refactor centralizes the shared flow while preserving per-entity specifics.

  • Shared helper: Added expired_entity_cleanup_helpers.cjs to handle expiration parsing, rate-limited processing, and standardized summaries for any entity type.
  • Script updates: close_expired_issues.cjs, close_expired_pull_requests.cjs, and close_expired_discussions.cjs now delegate categorization, processing, and summary rendering to the helper; discussion-specific skip/duplicate handling is preserved via callback.
  • Behavior parity: Max-per-run limits, logging, and summaries remain consistent; non-expired listings and skip reporting reuse unified formatting.

Example (per-entity processor hook):

await processExpiredEntities(expiredIssues, {
  entityLabel: "Issue",
  processEntity: async issue => {
    await addIssueComment(github, owner, repo, issue.number, closingMessage);
    await closeIssue(github, owner, repo, issue.number);
    return { status: "closed", record: { number: issue.number, url: issue.url, title: issue.title } };
  },
});
Original prompt

This section details on the original issue you should resolve

<issue_title>🔍 Duplicate Code Detected: Expired Entity Cleanup Scripts</issue_title>
<issue_description>Analysis of commit b2f8b8a

Assignee: @copilot

Summary

The expired-entity cleanup scripts for issues, pull requests, and discussions contain large, near-identical logic for expiration evaluation, per-item processing, delay handling, and summary generation. This duplication exceeds 10 lines and appears in 3+ files, making future changes error-prone and difficult to keep consistent.

Duplication Details

Pattern: Expiration evaluation + close loop + summary rendering

  • Severity: Medium
  • Occurrences: 3
  • Locations:
    • actions/setup/js/close_expired_issues.cjs (lines 120-279)
    • actions/setup/js/close_expired_pull_requests.cjs (lines 120-279)
    • actions/setup/js/close_expired_discussions.cjs (lines 164-340)
  • Code Sample:
// 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(`  ✓ ... is EXPIRED (expired ${daysSinceExpiration} days, ${hoursSinceExpiration % 24} hours ago)`);
  expiredItems.push({ ...item, expirationDate });
} else {
  core.info(`  ✗ ... is NOT expired (expires in ${daysUntilExpiration} days, ${hoursUntilExpiration % 24} hours)`);
  notExpiredItems.push({ ...item, expirationDate });
}

Impact Analysis

  • Maintainability: Changes to expiration logic or summary formatting must be duplicated across three files, increasing drift risk.
  • Bug Risk: Fixes in one script can easily be missed in the others (e.g., edge cases around expiration parsing or summary formatting).
  • Code Bloat: The duplicated sections are large (loop + summary builder), increasing file size and review cost.

Refactoring Recommendations

  1. Extract shared expiration processing utility

    • Extract to: actions/setup/js/expiration_helpers.cjs or a new expired_entity_cleanup_helpers.cjs
    • Estimated effort: Medium (2-4 hours)
    • Benefits: Single source of truth for expiration calculations, logging, and summary rendering
  2. Create a shared “cleanup runner” helper

    • Extract the common loop for: selecting items, processing with delay, and summary building
    • Estimated effort: Medium
    • Benefits: Reduces drift and eases adding new entity types in future

Implementation Checklist

  • Review duplication findings
  • Prioritize refactoring tasks
  • Create refactoring plan
  • Implement shared helpers
  • Update tests
  • Verify no functionality broken

Analysis Metadata

  • Analyzed Files: 3
  • Detection Method: Serena semantic code analysis
  • Commit: b2f8b8a
  • Analysis Date: 2026-02-03 05:49:16 UTC

AI generated by Duplicate Code Detector

Comments on the Issue (you are @codex[agent] in this section)

@Codex Codex AI changed the title [WIP] Refactor expired entity cleanup scripts to reduce duplication Refactor expired-entity cleanup scripts to share expiration processing Feb 3, 2026
@Codex Codex AI requested a review from pelikhan February 3, 2026 06:23
@github-actions
Copy link
Contributor

github-actions bot commented Feb 3, 2026

🔍 PR Triage Results

Category: refactor | Risk: medium | Priority: 52/100

Scores Breakdown

  • Impact: 30/50 - Refactoring to reduce code duplication across 3 cleanup scripts; Medium-scale changes (4 files, -210 net lines); Improves maintainability; JavaScript code in actions/setup/js/
  • Urgency: 14/30 - Less than 1 day old (17 minutes); Refactoring has moderate urgency; PR is draft
  • Quality: 8/20 - CI pending; Detailed description with code examples; Draft status; Good refactoring approach (extracted shared helper)

📋 Recommended Action: batch_review

This is a well-structured refactoring PR that addresses code duplication. The changes are moderate in scope and improve maintainability. Once CI passes and draft status is removed, it should be reviewed alongside other JavaScript refactoring PRs.

Batch ID: batch-refactor-001 - JavaScript refactoring PRs for code cleanup


Triaged by PR Triage Agent on 2026-02-03

AI generated by PR Triage Agent

@pelikhan pelikhan marked this pull request as ready for review February 3, 2026 11:09
Copilot AI review requested due to automatic review settings February 3, 2026 11:09
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR refactors expired-entity cleanup scripts to eliminate code duplication and centralize shared logic for expiration processing across issues, pull requests, and discussions.

Changes:

  • Introduced expired_entity_cleanup_helpers.cjs to centralize expiration date parsing, categorization, rate-limited processing, and summary generation
  • Updated close_expired_issues.cjs, close_expired_pull_requests.cjs, and close_expired_discussions.cjs to use the new shared helper functions
  • Preserved entity-specific behaviors (e.g., discussion duplicate comment detection) via callback-based architecture

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
expired_entity_cleanup_helpers.cjs New shared helper module containing expiration categorization, processing loop, and summary generation functions
close_expired_pull_requests.cjs Refactored to delegate expiration logic to shared helper, reducing from 279 to 139 lines
close_expired_issues.cjs Refactored to delegate expiration logic to shared helper, reducing from 279 to 140 lines
close_expired_discussions.cjs Refactored to delegate expiration logic to shared helper while preserving skip/duplicate handling, reducing from 340 to 220 lines

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 251 to +176
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,
},
};
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.
Comment on lines +1 to +250
// @ts-check
// <reference types="@actions/github-script" />

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<void>}
*/
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<any>, notExpired: Array<any>, 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<any>} expiredEntities
* @param {{
* entityLabel: string,
* maxPerRun?: number,
* delayMs?: number,
* processEntity: (entity: any) => Promise<{status: "closed" | "skipped", record: any}>
* }} options
* @returns {Promise<{closed: Array<any>, skipped: Array<any>, failed: Array<any>}>}
*/
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<any>} 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<any>,
* notExpired: Array<any>,
* closed: Array<any>,
* failed: Array<any>,
* skipped?: Array<any>,
* 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,
};
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 new helper module expired_entity_cleanup_helpers.cjs lacks test coverage. This module contains critical logic for expiration categorization, entity processing, and summary generation that was previously duplicated across multiple scripts. Given that the codebase has comprehensive test coverage (e.g., expired_entity_search_helpers.test.cjs, close_expired_discussions.test.cjs, close_expired_pull_requests.test.cjs), and this module contains 250 lines of shared business logic, it should have its own test file to verify:

  1. categorizeByExpiration correctly categorizes entities by expiration state
  2. processExpiredEntities handles the processing loop, rate limiting, and error cases correctly
  3. buildExpirationSummary generates the expected summary format
  4. buildNotExpiredSection handles different entity counts and formats correctly
  5. Edge cases like invalid dates, empty arrays, and error handling

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Contributor

github-actions bot commented Feb 3, 2026

🔍 PR Triage Results

Category: refactor | Risk: medium | Priority: 52/100

Scores Breakdown

  • Impact: 30/50 - Refactoring to reduce code duplication across 3 cleanup scripts; Medium-scale changes (5 files, -209 net lines); Improves maintainability; JavaScript code in actions/setup/js/
  • Urgency: 14/30 - 6 hours old; Ready for review (not draft); Refactoring has moderate urgency; Addresses technical debt
  • Quality: 8/20 - CI pending; Ready for review; Detailed description with code examples; Good refactoring approach (extracted shared helper); Has review comments

📋 Recommended Action: batch_review

This is a well-structured refactoring PR that addresses code duplication detected by the duplicate-code-detector workflow. The changes are moderate in scope (net -209 lines) and improve maintainability by extracting shared expiration processing logic. The PR is ready for review (not draft) and has received some review comments. Once CI passes, it should be reviewed alongside other JavaScript refactoring PRs in batch batch-refactor-001.

Batch ID: batch-refactor-001 - JavaScript refactoring PRs for code cleanup


Triaged by PR Triage Agent on 2026-02-03T12:19

AI generated by PR Triage Agent

@pelikhan pelikhan merged commit 81fc97b into main Feb 3, 2026
51 checks passed
@pelikhan pelikhan deleted the codex/refactor-expired-entity-scripts branch February 3, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🔍 Duplicate Code Detected: Expired Entity Cleanup Scripts

3 participants