Skip to content
Closed
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
77 changes: 31 additions & 46 deletions actions/setup/js/close_expired_discussions.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs"
const { generateExpiredEntityFooter } = require("./generate_footer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs");
const { createExpiredEntityProcessor } = require("./expired_entity_handlers.cjs");

/**
* Add comment to a GitHub Discussion using GraphQL
Expand Down Expand Up @@ -96,67 +97,51 @@ async function main() {
// Get workflow metadata for footer
const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo);

await executeExpiredEntityCleanup(github, owner, repo, {
entityType: "discussions",
graphqlField: "discussions",
resultKey: "discussions",
entityLabel: "Discussion",
summaryHeading: "Expired Discussions Cleanup",
enableDedupe: true, // Discussions may have duplicates across pages
includeSkippedHeading: true,
processEntity: async discussion => {
// Create processor using shared handler with discussion-specific pre-check
const processEntity = createExpiredEntityProcessor(workflowName, runUrl, workflowId, {
entityType: "discussion",
addComment: async (discussion, message) => {
await addDiscussionComment(github, discussion.id, message);
},
closeEntity: async discussion => {
await closeDiscussionAsOutdated(github, discussion.id);
},
buildClosingMessage: (discussion, workflowName, runUrl, workflowId) => {
return `This discussion was automatically closed because it expired on ${discussion.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId) + "\n\n<!-- gh-aw-closed -->";
},
preCheck: 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`);
return {
status: "skipped",
record: {
number: discussion.number,
url: discussion.url,
title: discussion.title,
},
shouldSkip: true,
reason: `Discussion #${discussion.number} is already closed, skipping`,
};
}

if (hasComment) {
core.warning(` Discussion #${discussion.number} already has an expiration comment, skipping to avoid duplicate`);

core.info(` Attempting to close discussion #${discussion.number} without adding another comment`);
await closeDiscussionAsOutdated(github, discussion.id);
core.info(` ✓ Discussion closed successfully`);

return {
status: "skipped",
record: {
number: discussion.number,
url: discussion.url,
title: discussion.title,
},
shouldSkip: true,
shouldClose: true,
reason: `Discussion #${discussion.number} already has an expiration comment, skipping to avoid duplicate`,
};
}

const closingMessage = `This discussion was automatically closed because it expired on ${discussion.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId) + "\n\n<!-- gh-aw-closed -->";

core.info(` Adding closing comment to discussion #${discussion.number}`);
await addDiscussionComment(github, discussion.id, closingMessage);
core.info(` ✓ Comment added successfully`);

core.info(` Closing discussion #${discussion.number} as outdated`);
await closeDiscussionAsOutdated(github, discussion.id);
core.info(` ✓ Discussion closed successfully`);

return {
status: "closed",
record: {
number: discussion.number,
url: discussion.url,
title: discussion.title,
},
};
return { shouldSkip: false };
},
});

await executeExpiredEntityCleanup(github, owner, repo, {
entityType: "discussions",
graphqlField: "discussions",
resultKey: "discussions",
entityLabel: "Discussion",
summaryHeading: "Expired Discussions Cleanup",
enableDedupe: true, // Discussions may have duplicates across pages
includeSkippedHeading: true,
processEntity,
});
}

module.exports = { main };
34 changes: 16 additions & 18 deletions actions/setup/js/close_expired_issues.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs"
const { generateExpiredEntityFooter } = require("./generate_footer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs");
const { createExpiredEntityProcessor } = require("./expired_entity_handlers.cjs");

/**
* Add comment to a GitHub Issue using REST API
Expand Down Expand Up @@ -53,30 +54,27 @@ async function main() {
// Get workflow metadata for footer
const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo);

// Create processor using shared handler
const processEntity = createExpiredEntityProcessor(workflowName, runUrl, workflowId, {
entityType: "issue",
addComment: async (issue, message) => {
await addIssueComment(github, owner, repo, issue.number, message);
},
closeEntity: async issue => {
await closeIssue(github, owner, repo, issue.number);
},
buildClosingMessage: (issue, workflowName, runUrl, workflowId) => {
return `This issue was automatically closed because it expired on ${issue.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId);
},
});

await executeExpiredEntityCleanup(github, owner, repo, {
entityType: "issues",
graphqlField: "issues",
resultKey: "issues",
entityLabel: "Issue",
summaryHeading: "Expired Issues Cleanup",
processEntity: async issue => {
const closingMessage = `This issue was automatically closed because it expired on ${issue.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId);

await addIssueComment(github, owner, repo, issue.number, closingMessage);
core.info(` ✓ Comment added successfully`);

await closeIssue(github, owner, repo, issue.number);
core.info(` ✓ Issue closed successfully`);

return {
status: "closed",
record: {
number: issue.number,
url: issue.url,
title: issue.title,
},
};
},
processEntity,
});
}

Expand Down
34 changes: 16 additions & 18 deletions actions/setup/js/close_expired_pull_requests.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs"
const { generateExpiredEntityFooter } = require("./generate_footer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs");
const { createExpiredEntityProcessor } = require("./expired_entity_handlers.cjs");

/**
* Add comment to a GitHub Pull Request using REST API
Expand Down Expand Up @@ -52,30 +53,27 @@ async function main() {
// Get workflow metadata for footer
const { workflowName, workflowId, runUrl } = getWorkflowMetadata(owner, repo);

// Create processor using shared handler
const processEntity = createExpiredEntityProcessor(workflowName, runUrl, workflowId, {
entityType: "pull request",
addComment: async (pr, message) => {
await addPullRequestComment(github, owner, repo, pr.number, message);
},
closeEntity: async pr => {
await closePullRequest(github, owner, repo, pr.number);
},
buildClosingMessage: (pr, workflowName, runUrl, workflowId) => {
return `This pull request was automatically closed because it expired on ${pr.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId);
},
});

await executeExpiredEntityCleanup(github, owner, repo, {
entityType: "pull requests",
graphqlField: "pullRequests",
resultKey: "pullRequests",
entityLabel: "Pull Request",
summaryHeading: "Expired Pull Requests Cleanup",
processEntity: async pr => {
const closingMessage = `This pull request was automatically closed because it expired on ${pr.expirationDate.toISOString()}.` + generateExpiredEntityFooter(workflowName, runUrl, workflowId);

await addPullRequestComment(github, owner, repo, pr.number, closingMessage);
core.info(` ✓ Comment added successfully`);

await closePullRequest(github, owner, repo, pr.number);
core.info(` ✓ Pull request closed successfully`);

return {
status: "closed",
record: {
number: pr.number,
url: pr.url,
title: pr.title,
},
};
},
processEntity,
});
}

Expand Down
102 changes: 102 additions & 0 deletions actions/setup/js/expired_entity_handlers.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// @ts-check
// <reference types="@actions/github-script" />

/**
* Expired Entity Handlers
*
* This module provides reusable handlers for processing expired entities (issues, PRs, discussions).
* It extracts the common comment + close + return record flow that was duplicated across
* close_expired_issues.cjs, close_expired_pull_requests.cjs, and close_expired_discussions.cjs.
*/

/**
* Configuration for entity-specific operations
* @typedef {Object} EntityHandlerConfig
* @property {string} entityType - Entity type for logging (e.g., "issue", "pull request", "discussion")
* @property {(entity: any, message: string) => Promise<any>} addComment - Function to add a comment (receives entity and message)
* @property {(entity: any) => Promise<any>} closeEntity - Function to close the entity (receives entity)
* @property {(entity: any, workflowName: string, runUrl: string, workflowId: string) => string} buildClosingMessage - Function to build the closing message
* @property {(entity: any) => Promise<{shouldSkip: boolean, reason?: string, shouldClose?: boolean}>} [preCheck] - Optional pre-check function (e.g., for duplicate detection)
*/

/**
* Create a standard expired entity processor
*
* This function returns a processEntity function that can be passed to executeExpiredEntityCleanup.
* It handles the common flow:
* 1. Optional pre-check (e.g., checking for existing comments in discussions)
* 2. Add closing comment
* 3. Close entity
* 4. Return status and record
*
* @param {string} workflowName - Workflow name for footer
* @param {string} runUrl - Workflow run URL for footer
* @param {string} workflowId - Workflow ID for footer
* @param {EntityHandlerConfig} config - Entity-specific configuration
* @returns {(entity: any) => Promise<{status: "closed" | "skipped", record: any}>}
*/
function createExpiredEntityProcessor(workflowName, runUrl, workflowId, config) {
return async entity => {
// Step 1: Optional pre-check (e.g., duplicate detection for discussions)
if (config.preCheck) {
const preCheckResult = await config.preCheck(entity);
if (preCheckResult.shouldSkip) {
if (preCheckResult.reason) {
core.warning(` ${preCheckResult.reason}`);
}

// If preCheck says to close without adding comment, do that
if (preCheckResult.shouldClose) {
core.info(` Attempting to close ${config.entityType} #${entity.number} without adding another comment`);
await config.closeEntity(entity);
core.info(` ✓ ${capitalize(config.entityType)} closed successfully`);
}

return {
status: "skipped",
record: {
number: entity.number,
url: entity.url,
title: entity.title,
},
};
}
}

// Step 2: Build closing message
const closingMessage = config.buildClosingMessage(entity, workflowName, runUrl, workflowId);

// Step 3: Add closing comment
core.info(` Adding closing comment to ${config.entityType} #${entity.number}`);
await config.addComment(entity, closingMessage);
core.info(` ✓ Comment added successfully`);

// Step 4: Close entity
core.info(` Closing ${config.entityType} #${entity.number}`);
await config.closeEntity(entity);
core.info(` ✓ ${capitalize(config.entityType)} closed successfully`);

// Step 5: Return status and record
return {
status: "closed",
record: {
number: entity.number,
url: entity.url,
title: entity.title,
},
};
};
}

/**
* Capitalize the first letter of a string
* @param {string} str - String to capitalize
* @returns {string}
*/
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}

module.exports = {
createExpiredEntityProcessor,
};
Loading