diff --git a/actions/setup/js/close_older_discussions.cjs b/actions/setup/js/close_older_discussions.cjs index 732409cb5b9..a65ab526ece 100644 --- a/actions/setup/js/close_older_discussions.cjs +++ b/actions/setup/js/close_older_discussions.cjs @@ -5,26 +5,18 @@ const { getCloseOlderDiscussionMessage } = require("./messages_close_discussion. const { getErrorMessage } = require("./error_helpers.cjs"); const { getWorkflowIdMarkerContent } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); +const { closeOlderEntities, MAX_CLOSE_COUNT: SHARED_MAX_CLOSE_COUNT } = require("./close_older_entities.cjs"); /** * Maximum number of older discussions to close */ -const MAX_CLOSE_COUNT = 10; +const MAX_CLOSE_COUNT = SHARED_MAX_CLOSE_COUNT; /** * 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)); -} - /** * Search for open discussions with a matching workflow-id marker * @param {any} github - GitHub GraphQL instance @@ -142,11 +134,13 @@ async function searchOlderDiscussions(github, owner, repo, workflowId, categoryI /** * Add comment to a GitHub Discussion using GraphQL * @param {any} github - GitHub GraphQL instance + * @param {string} owner - Repository owner (unused for GraphQL, but kept for consistency) + * @param {string} repo - Repository name (unused for GraphQL, but kept for consistency) * @param {string} discussionId - Discussion node ID * @param {string} message - Comment body * @returns {Promise<{id: string, url: string}>} Comment details */ -async function addDiscussionComment(github, discussionId, message) { +async function addDiscussionComment(github, owner, repo, discussionId, message) { const result = await github.graphql( ` mutation($dId: ID!, $body: String!) { @@ -166,10 +160,12 @@ async function addDiscussionComment(github, discussionId, message) { /** * Close a GitHub Discussion as OUTDATED using GraphQL * @param {any} github - GitHub GraphQL instance + * @param {string} owner - Repository owner (unused for GraphQL, but kept for consistency) + * @param {string} repo - Repository name (unused for GraphQL, but kept for consistency) * @param {string} discussionId - Discussion node ID * @returns {Promise<{id: string, url: string}>} Discussion details */ -async function closeDiscussionAsOutdated(github, discussionId) { +async function closeDiscussionAsOutdated(github, owner, repo, discussionId) { const result = await github.graphql( ` mutation($dId: ID!) { @@ -199,105 +195,39 @@ async function closeDiscussionAsOutdated(github, discussionId) { * @returns {Promise>} List of closed discussions */ async function closeOlderDiscussions(github, owner, repo, workflowId, categoryId, newDiscussion, workflowName, runUrl) { - core.info("=".repeat(70)); - core.info("Starting closeOlderDiscussions operation"); - core.info("=".repeat(70)); - - core.info(`Search criteria: workflow ID marker: "${getWorkflowIdMarkerContent(workflowId)}"`); - core.info(`New discussion reference: #${newDiscussion.number} (${newDiscussion.url})`); - core.info(`Workflow: ${workflowName}`); - core.info(`Run URL: ${runUrl}`); - core.info(""); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, workflowId, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("✓ No older discussions found to close - operation complete"); - core.info("=".repeat(70)); - return []; - } - - core.info(""); - core.info(`Found ${olderDiscussions.length} older discussion(s) matching the criteria`); - for (const discussion of olderDiscussions) { - core.info(` - Discussion #${discussion.number}: ${discussion.title}`); - core.info(` URL: ${discussion.url}`); - } - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(""); - core.warning(`⚠️ Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - core.warning(` The remaining ${olderDiscussions.length - MAX_CLOSE_COUNT} discussion(s) will be processed in subsequent runs`); - } - - core.info(""); - core.info(`Preparing to close ${discussionsToClose.length} discussion(s)...`); - core.info(""); - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - core.info("-".repeat(70)); - core.info(`Processing discussion ${i + 1}/${discussionsToClose.length}: #${discussion.number}`); - core.info(` Title: ${discussion.title}`); - core.info(` URL: ${discussion.url}`); - - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - core.info(` Message length: ${closingMessage.length} characters`); - core.info(""); - - // Add comment first - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(""); - core.info(`✓ Successfully closed discussion #${discussion.number}`); - } catch (error) { - core.info(""); - core.error(`✗ Failed to close discussion #${discussion.number}`); - core.error(` Error: ${getErrorMessage(error)}`); - if (error instanceof Error && error.stack) { - core.error(` Stack trace: ${error.stack}`); - } - // 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(""); - core.info(`Waiting ${GRAPHQL_DELAY_MS}ms before processing next discussion to avoid rate limiting...`); - await delay(GRAPHQL_DELAY_MS); - } - } - - core.info(""); - core.info("=".repeat(70)); - core.info(`Closed ${closedDiscussions.length} of ${discussionsToClose.length} discussion(s) successfully`); - if (closedDiscussions.length < discussionsToClose.length) { - core.warning(`Failed to close ${discussionsToClose.length - closedDiscussions.length} discussion(s) - check logs above for details`); - } - core.info("=".repeat(70)); + const result = await closeOlderEntities( + github, + owner, + repo, + workflowId, + newDiscussion, + workflowName, + runUrl, + { + entityType: "discussion", + entityTypePlural: "discussions", + searchOlderEntities: searchOlderDiscussions, + getCloseMessage: params => + getCloseOlderDiscussionMessage({ + newDiscussionUrl: params.newEntityUrl, + newDiscussionNumber: params.newEntityNumber, + workflowName: params.workflowName, + runUrl: params.runUrl, + }), + addComment: addDiscussionComment, + closeEntity: closeDiscussionAsOutdated, + delayMs: GRAPHQL_DELAY_MS, + getEntityId: entity => entity.id, + getEntityUrl: entity => entity.url, + }, + categoryId // Pass categoryId as extra arg + ); - return closedDiscussions; + // Map to discussion-specific return type + return result.map(item => ({ + number: item.number, + url: item.url || "", + })); } module.exports = { diff --git a/actions/setup/js/close_older_entities.cjs b/actions/setup/js/close_older_entities.cjs new file mode 100644 index 00000000000..1a49462b5c8 --- /dev/null +++ b/actions/setup/js/close_older_entities.cjs @@ -0,0 +1,169 @@ +// @ts-check +/// + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { getWorkflowIdMarkerContent } = require("./generate_footer.cjs"); + +/** + * Maximum number of older entities to close + */ +const MAX_CLOSE_COUNT = 10; + +/** + * 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)); +} + +/** + * Configuration for entity-specific behavior + * @typedef {Object} EntityCloseConfig + * @property {string} entityType - Entity type name for logging (e.g., "issue", "discussion") + * @property {string} entityTypePlural - Plural form (e.g., "issues", "discussions") + * @property {(github: any, owner: string, repo: string, workflowId: string, ...args: any[]) => Promise>} searchOlderEntities - Function to search for older entities + * @property {(params: any) => string} getCloseMessage - Function to generate closing message + * @property {(github: any, owner: string, repo: string, entityId: any, message: string) => Promise<{id: any, url?: string, html_url?: string}>} addComment - Function to add comment to entity + * @property {(github: any, owner: string, repo: string, entityId: any) => Promise<{number?: number, id?: string, url?: string, html_url?: string}>} closeEntity - Function to close entity + * @property {number} delayMs - Delay between API operations in milliseconds + * @property {(entity: any) => any} getEntityId - Function to extract entity ID (for API calls) + * @property {(entity: any) => string} getEntityUrl - Function to extract entity URL + */ + +/** + * Close older entities that match the workflow-id marker + * + * This function orchestrates the complete flow: + * 1. Search for older entities with matching workflow-id marker + * 2. Log results and handle early exits (no entities found) + * 3. Apply MAX_CLOSE_COUNT limit + * 4. Process each entity: comment + close + * 5. Handle errors gracefully (continue with remaining entities) + * 6. Report summary statistics + * + * @param {any} github - GitHub API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} workflowId - Workflow ID to match in the marker + * @param {any} newEntity - The newly created entity (contains number and url/html_url) + * @param {string} workflowName - Name of the workflow + * @param {string} runUrl - URL of the workflow run + * @param {EntityCloseConfig} config - Entity-specific configuration + * @param {...any} extraArgs - Additional arguments to pass to searchOlderEntities + * @returns {Promise>} List of closed entities + */ +async function closeOlderEntities(github, owner, repo, workflowId, newEntity, workflowName, runUrl, config, ...extraArgs) { + core.info("=".repeat(70)); + core.info(`Starting closeOlder${config.entityType.charAt(0).toUpperCase() + config.entityType.slice(1)}s operation`); + core.info("=".repeat(70)); + + core.info(`Search criteria: workflow-id marker: "${getWorkflowIdMarkerContent(workflowId)}"`); + core.info(`New ${config.entityType} reference: #${newEntity.number} (${newEntity.url || newEntity.html_url})`); + core.info(`Workflow: ${workflowName}`); + core.info(`Run URL: ${runUrl}`); + core.info(""); + + // Step 1: Search for older entities + const olderEntities = await config.searchOlderEntities(github, owner, repo, workflowId, ...extraArgs, newEntity.number); + + if (olderEntities.length === 0) { + core.info(`✓ No older ${config.entityTypePlural} found to close - operation complete`); + core.info("=".repeat(70)); + return []; + } + + core.info(""); + core.info(`Found ${olderEntities.length} older ${config.entityType}(s) matching the criteria`); + for (const entity of olderEntities) { + core.info(` - ${config.entityType.charAt(0).toUpperCase() + config.entityType.slice(1)} #${entity.number}: ${entity.title}`); + if (entity.labels) { + core.info(` Labels: ${entity.labels.map(l => l.name).join(", ") || "(none)"}`); + } + core.info(` URL: ${config.getEntityUrl(entity)}`); + } + + // Step 2: Limit to MAX_CLOSE_COUNT entities + const entitiesToClose = olderEntities.slice(0, MAX_CLOSE_COUNT); + + if (olderEntities.length > MAX_CLOSE_COUNT) { + core.warning(""); + core.warning(`⚠️ Found ${olderEntities.length} older ${config.entityTypePlural}, but only closing the first ${MAX_CLOSE_COUNT}`); + core.warning(` The remaining ${olderEntities.length - MAX_CLOSE_COUNT} ${config.entityType}(s) will be processed in subsequent runs`); + } + + core.info(""); + core.info(`Preparing to close ${entitiesToClose.length} ${config.entityType}(s)...`); + core.info(""); + + const closedEntities = []; + + // Step 3: Process each entity + for (let i = 0; i < entitiesToClose.length; i++) { + const entity = entitiesToClose[i]; + core.info("-".repeat(70)); + core.info(`Processing ${config.entityType} ${i + 1}/${entitiesToClose.length}: #${entity.number}`); + core.info(` Title: ${entity.title}`); + core.info(` URL: ${config.getEntityUrl(entity)}`); + + try { + // Generate closing message + const closingMessage = config.getCloseMessage({ + newEntityUrl: newEntity.url || newEntity.html_url, + newEntityNumber: newEntity.number, + workflowName, + runUrl, + }); + + core.info(` Message length: ${closingMessage.length} characters`); + core.info(""); + + // Add comment first + await config.addComment(github, owner, repo, config.getEntityId(entity), closingMessage); + + // Then close the entity + await config.closeEntity(github, owner, repo, config.getEntityId(entity)); + + closedEntities.push({ + number: entity.number, + ...(entity.url && { url: entity.url }), + ...(entity.html_url && { html_url: entity.html_url }), + }); + + core.info(""); + core.info(`✓ Successfully closed ${config.entityType} #${entity.number}`); + } catch (error) { + core.info(""); + core.error(`✗ Failed to close ${config.entityType} #${entity.number}`); + core.error(` Error: ${getErrorMessage(error)}`); + if (error instanceof Error && error.stack) { + core.error(` Stack trace: ${error.stack}`); + } + // Continue with other entities even if one fails + } + + // Step 4: Add delay between API operations to avoid rate limiting (except for the last item) + if (i < entitiesToClose.length - 1) { + core.info(""); + core.info(`Waiting ${config.delayMs}ms before processing next ${config.entityType} to avoid rate limiting...`); + await delay(config.delayMs); + } + } + + core.info(""); + core.info("=".repeat(70)); + core.info(`Closed ${closedEntities.length} of ${entitiesToClose.length} ${config.entityType}(s) successfully`); + if (closedEntities.length < entitiesToClose.length) { + core.warning(`Failed to close ${entitiesToClose.length - closedEntities.length} ${config.entityType}(s) - check logs above for details`); + } + core.info("=".repeat(70)); + + return closedEntities; +} + +module.exports = { + closeOlderEntities, + MAX_CLOSE_COUNT, + delay, +}; diff --git a/actions/setup/js/close_older_entities.test.cjs b/actions/setup/js/close_older_entities.test.cjs new file mode 100644 index 00000000000..14997182dcd --- /dev/null +++ b/actions/setup/js/close_older_entities.test.cjs @@ -0,0 +1,266 @@ +// @ts-check + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { closeOlderEntities, MAX_CLOSE_COUNT, delay } from "./close_older_entities.cjs"; + +// Mock globals +global.core = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), +}; + +describe("close_older_entities", () => { + let mockGithub; + let mockSearchOlderEntities; + let mockGetCloseMessage; + let mockAddComment; + let mockCloseEntity; + let mockConfig; + + beforeEach(() => { + vi.clearAllMocks(); + mockGithub = {}; + + mockSearchOlderEntities = vi.fn(); + mockGetCloseMessage = vi.fn(); + mockAddComment = vi.fn(); + mockCloseEntity = vi.fn(); + + mockConfig = { + entityType: "issue", + entityTypePlural: "issues", + searchOlderEntities: mockSearchOlderEntities, + getCloseMessage: mockGetCloseMessage, + addComment: mockAddComment, + closeEntity: mockCloseEntity, + delayMs: 100, + getEntityId: entity => entity.number, + getEntityUrl: entity => entity.html_url, + }; + }); + + describe("MAX_CLOSE_COUNT", () => { + it("should be set to 10", () => { + expect(MAX_CLOSE_COUNT).toBe(10); + }); + }); + + describe("delay", () => { + it("should delay execution for specified milliseconds", async () => { + const start = Date.now(); + await delay(50); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(45); // Allow some tolerance + }); + }); + + describe("closeOlderEntities", () => { + it("should return empty array when no older entities found", async () => { + mockSearchOlderEntities.mockResolvedValue([]); + + const result = await closeOlderEntities( + mockGithub, + "owner", + "repo", + "test-workflow", + { number: 100, html_url: "https://github.com/owner/repo/issues/100" }, + "Test Workflow", + "https://github.com/owner/repo/actions/runs/123", + mockConfig + ); + + expect(result).toEqual([]); + expect(mockSearchOlderEntities).toHaveBeenCalledWith(mockGithub, "owner", "repo", "test-workflow", 100); + expect(mockAddComment).not.toHaveBeenCalled(); + expect(mockCloseEntity).not.toHaveBeenCalled(); + expect(global.core.info).toHaveBeenCalledWith("✓ No older issues found to close - operation complete"); + }); + + it("should close older entities successfully", async () => { + mockSearchOlderEntities.mockResolvedValue([ + { number: 98, title: "Old Issue 1", html_url: "https://github.com/owner/repo/issues/98" }, + { number: 99, title: "Old Issue 2", html_url: "https://github.com/owner/repo/issues/99" }, + ]); + + mockGetCloseMessage.mockReturnValue("Closing message"); + mockAddComment.mockResolvedValue({ id: 123, html_url: "https://github.com/owner/repo/issues/98#issuecomment-123" }); + mockCloseEntity.mockResolvedValue({ number: 98, html_url: "https://github.com/owner/repo/issues/98" }); + + const result = await closeOlderEntities( + mockGithub, + "owner", + "repo", + "test-workflow", + { number: 100, html_url: "https://github.com/owner/repo/issues/100" }, + "Test Workflow", + "https://github.com/owner/repo/actions/runs/123", + mockConfig + ); + + expect(result).toHaveLength(2); + expect(result[0].number).toBe(98); + expect(result[1].number).toBe(99); + expect(mockAddComment).toHaveBeenCalledTimes(2); + expect(mockCloseEntity).toHaveBeenCalledTimes(2); + }); + + it("should limit closed entities to MAX_CLOSE_COUNT", async () => { + const entities = Array.from({ length: 15 }, (_, i) => ({ + number: i + 1, + title: `Issue ${i + 1}`, + html_url: `https://github.com/owner/repo/issues/${i + 1}`, + })); + + mockSearchOlderEntities.mockResolvedValue(entities); + mockGetCloseMessage.mockReturnValue("Closing message"); + mockAddComment.mockResolvedValue({ id: 123, html_url: "https://github.com/owner/repo/issues/1#issuecomment-123" }); + mockCloseEntity.mockResolvedValue({ number: 1, html_url: "https://github.com/owner/repo/issues/1" }); + + const result = await closeOlderEntities( + mockGithub, + "owner", + "repo", + "test-workflow", + { number: 100, html_url: "https://github.com/owner/repo/issues/100" }, + "Test Workflow", + "https://github.com/owner/repo/actions/runs/123", + mockConfig + ); + + expect(result).toHaveLength(MAX_CLOSE_COUNT); + expect(global.core.warning).toHaveBeenCalledWith(`⚠️ Found 15 older issues, but only closing the first ${MAX_CLOSE_COUNT}`); + }); + + it("should continue closing other entities if one fails", async () => { + mockSearchOlderEntities.mockResolvedValue([ + { number: 98, title: "Will Fail", html_url: "https://github.com/owner/repo/issues/98" }, + { number: 99, title: "Will Succeed", html_url: "https://github.com/owner/repo/issues/99" }, + ]); + + mockGetCloseMessage.mockReturnValue("Closing message"); + + // First entity fails + mockAddComment.mockRejectedValueOnce(new Error("API Error")); + + // Second entity succeeds + mockAddComment.mockResolvedValueOnce({ id: 124, html_url: "https://github.com/owner/repo/issues/99#issuecomment-124" }); + mockCloseEntity.mockResolvedValueOnce({ number: 99, html_url: "https://github.com/owner/repo/issues/99" }); + + const result = await closeOlderEntities( + mockGithub, + "owner", + "repo", + "test-workflow", + { number: 100, html_url: "https://github.com/owner/repo/issues/100" }, + "Test Workflow", + "https://github.com/owner/repo/actions/runs/123", + mockConfig + ); + + expect(result).toHaveLength(1); + expect(result[0].number).toBe(99); + expect(global.core.error).toHaveBeenCalledWith(expect.stringContaining("Failed to close issue #98")); + expect(global.core.warning).toHaveBeenCalledWith("Failed to close 1 issue(s) - check logs above for details"); + }); + + it("should log entity labels if present", async () => { + mockSearchOlderEntities.mockResolvedValue([ + { + number: 98, + title: "Old Issue", + html_url: "https://github.com/owner/repo/issues/98", + labels: [{ name: "bug" }, { name: "help wanted" }], + }, + ]); + + mockGetCloseMessage.mockReturnValue("Closing message"); + mockAddComment.mockResolvedValue({ id: 123, html_url: "https://github.com/owner/repo/issues/98#issuecomment-123" }); + mockCloseEntity.mockResolvedValue({ number: 98, html_url: "https://github.com/owner/repo/issues/98" }); + + await closeOlderEntities(mockGithub, "owner", "repo", "test-workflow", { number: 100, html_url: "https://github.com/owner/repo/issues/100" }, "Test Workflow", "https://github.com/owner/repo/actions/runs/123", mockConfig); + + expect(global.core.info).toHaveBeenCalledWith(expect.stringContaining("Labels: bug, help wanted")); + }); + + it("should pass extra arguments to search function", async () => { + mockSearchOlderEntities.mockResolvedValue([]); + + await closeOlderEntities( + mockGithub, + "owner", + "repo", + "test-workflow", + { number: 100, html_url: "https://github.com/owner/repo/issues/100" }, + "Test Workflow", + "https://github.com/owner/repo/actions/runs/123", + mockConfig, + "extra-arg-1", + "extra-arg-2" + ); + + expect(mockSearchOlderEntities).toHaveBeenCalledWith(mockGithub, "owner", "repo", "test-workflow", "extra-arg-1", "extra-arg-2", 100); + }); + + it("should use url field if html_url is not present", async () => { + mockSearchOlderEntities.mockResolvedValue([{ number: 98, title: "Discussion", url: "https://github.com/owner/repo/discussions/98", id: "D_98" }]); + + const discussionConfig = { + ...mockConfig, + entityType: "discussion", + entityTypePlural: "discussions", + getEntityId: entity => entity.id, + getEntityUrl: entity => entity.url, + }; + + mockGetCloseMessage.mockReturnValue("Closing message"); + mockAddComment.mockResolvedValue({ id: "DC_123", url: "https://github.com/owner/repo/discussions/98#comment-123" }); + mockCloseEntity.mockResolvedValue({ id: "D_98", url: "https://github.com/owner/repo/discussions/98" }); + + const result = await closeOlderEntities( + mockGithub, + "owner", + "repo", + "test-workflow", + { number: 100, url: "https://github.com/owner/repo/discussions/100" }, + "Test Workflow", + "https://github.com/owner/repo/actions/runs/123", + discussionConfig + ); + + expect(result).toHaveLength(1); + expect(result[0].url).toBe("https://github.com/owner/repo/discussions/98"); + }); + + it("should call getCloseMessage with correct parameters", async () => { + mockSearchOlderEntities.mockResolvedValue([{ number: 98, title: "Old Issue", html_url: "https://github.com/owner/repo/issues/98" }]); + + mockGetCloseMessage.mockReturnValue("Closing message"); + mockAddComment.mockResolvedValue({ id: 123, html_url: "https://github.com/owner/repo/issues/98#issuecomment-123" }); + mockCloseEntity.mockResolvedValue({ number: 98, html_url: "https://github.com/owner/repo/issues/98" }); + + await closeOlderEntities(mockGithub, "owner", "repo", "test-workflow", { number: 100, html_url: "https://github.com/owner/repo/issues/100" }, "Test Workflow", "https://github.com/owner/repo/actions/runs/123", mockConfig); + + expect(mockGetCloseMessage).toHaveBeenCalledWith({ + newEntityUrl: "https://github.com/owner/repo/issues/100", + newEntityNumber: 100, + workflowName: "Test Workflow", + runUrl: "https://github.com/owner/repo/actions/runs/123", + }); + }); + + it("should log error stack trace when available", async () => { + mockSearchOlderEntities.mockResolvedValue([{ number: 98, title: "Will Fail", html_url: "https://github.com/owner/repo/issues/98" }]); + + mockGetCloseMessage.mockReturnValue("Closing message"); + + const error = new Error("API Error"); + error.stack = "Error: API Error\n at test.js:123"; + mockAddComment.mockRejectedValueOnce(error); + + await closeOlderEntities(mockGithub, "owner", "repo", "test-workflow", { number: 100, html_url: "https://github.com/owner/repo/issues/100" }, "Test Workflow", "https://github.com/owner/repo/actions/runs/123", mockConfig); + + expect(global.core.error).toHaveBeenCalledWith(expect.stringContaining("Stack trace:")); + }); + }); +}); diff --git a/actions/setup/js/close_older_issues.cjs b/actions/setup/js/close_older_issues.cjs index 7cba816d7ff..4a3e94a1cd2 100644 --- a/actions/setup/js/close_older_issues.cjs +++ b/actions/setup/js/close_older_issues.cjs @@ -1,29 +1,20 @@ // @ts-check /// -const { getErrorMessage } = require("./error_helpers.cjs"); const { getWorkflowIdMarkerContent } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); +const { closeOlderEntities, MAX_CLOSE_COUNT: SHARED_MAX_CLOSE_COUNT } = require("./close_older_entities.cjs"); /** * Maximum number of older issues to close */ -const MAX_CLOSE_COUNT = 10; +const MAX_CLOSE_COUNT = SHARED_MAX_CLOSE_COUNT; /** * Delay between API calls in milliseconds to avoid rate limiting */ const API_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)); -} - /** * Search for open issues with a matching workflow-id marker * @param {any} github - GitHub REST API instance @@ -195,106 +186,29 @@ function getCloseOlderIssueMessage({ newIssueUrl, newIssueNumber, workflowName, * @returns {Promise>} List of closed issues */ async function closeOlderIssues(github, owner, repo, workflowId, newIssue, workflowName, runUrl) { - core.info("=".repeat(70)); - core.info("Starting closeOlderIssues operation"); - core.info("=".repeat(70)); - - core.info(`Search criteria: workflow-id marker: "${getWorkflowIdMarkerContent(workflowId)}"`); - core.info(`New issue reference: #${newIssue.number} (${newIssue.html_url})`); - core.info(`Workflow: ${workflowName}`); - core.info(`Run URL: ${runUrl}`); - core.info(""); - - const olderIssues = await searchOlderIssues(github, owner, repo, workflowId, newIssue.number); - - if (olderIssues.length === 0) { - core.info("✓ No older issues found to close - operation complete"); - core.info("=".repeat(70)); - return []; - } - - core.info(""); - core.info(`Found ${olderIssues.length} older issue(s) matching the criteria`); - for (const issue of olderIssues) { - core.info(` - Issue #${issue.number}: ${issue.title}`); - core.info(` Labels: ${issue.labels.map(l => l.name).join(", ") || "(none)"}`); - core.info(` URL: ${issue.html_url}`); - } - - // Limit to MAX_CLOSE_COUNT issues - const issuesToClose = olderIssues.slice(0, MAX_CLOSE_COUNT); - - if (olderIssues.length > MAX_CLOSE_COUNT) { - core.warning(""); - core.warning(`⚠️ Found ${olderIssues.length} older issues, but only closing the first ${MAX_CLOSE_COUNT}`); - core.warning(` The remaining ${olderIssues.length - MAX_CLOSE_COUNT} issue(s) will be processed in subsequent runs`); - } - - core.info(""); - core.info(`Preparing to close ${issuesToClose.length} issue(s)...`); - core.info(""); - - const closedIssues = []; - - for (let i = 0; i < issuesToClose.length; i++) { - const issue = issuesToClose[i]; - core.info("-".repeat(70)); - core.info(`Processing issue ${i + 1}/${issuesToClose.length}: #${issue.number}`); - core.info(` Title: ${issue.title}`); - core.info(` URL: ${issue.html_url}`); - - try { - // Generate closing message - const closingMessage = getCloseOlderIssueMessage({ - newIssueUrl: newIssue.html_url, - newIssueNumber: newIssue.number, - workflowName, - runUrl, - }); - - core.info(` Message length: ${closingMessage.length} characters`); - core.info(""); - - // Add comment first - await addIssueComment(github, owner, repo, issue.number, closingMessage); - - // Then close the issue as "not planned" - await closeIssueAsNotPlanned(github, owner, repo, issue.number); - - closedIssues.push({ - number: issue.number, - html_url: issue.html_url, - }); - - core.info(""); - core.info(`✓ Successfully closed issue #${issue.number}`); - } catch (error) { - core.info(""); - core.error(`✗ Failed to close issue #${issue.number}`); - core.error(` Error: ${getErrorMessage(error)}`); - if (error instanceof Error && error.stack) { - core.error(` Stack trace: ${error.stack}`); - } - // Continue with other issues even if one fails - } - - // Add delay between API operations to avoid rate limiting (except for the last item) - if (i < issuesToClose.length - 1) { - core.info(""); - core.info(`Waiting ${API_DELAY_MS}ms before processing next issue to avoid rate limiting...`); - await delay(API_DELAY_MS); - } - } - - core.info(""); - core.info("=".repeat(70)); - core.info(`Closed ${closedIssues.length} of ${issuesToClose.length} issue(s) successfully`); - if (closedIssues.length < issuesToClose.length) { - core.warning(`Failed to close ${issuesToClose.length - closedIssues.length} issue(s) - check logs above for details`); - } - core.info("=".repeat(70)); + const result = await closeOlderEntities(github, owner, repo, workflowId, newIssue, workflowName, runUrl, { + entityType: "issue", + entityTypePlural: "issues", + searchOlderEntities: searchOlderIssues, + getCloseMessage: params => + getCloseOlderIssueMessage({ + newIssueUrl: params.newEntityUrl, + newIssueNumber: params.newEntityNumber, + workflowName: params.workflowName, + runUrl: params.runUrl, + }), + addComment: addIssueComment, + closeEntity: closeIssueAsNotPlanned, + delayMs: API_DELAY_MS, + getEntityId: entity => entity.number, + getEntityUrl: entity => entity.html_url, + }); - return closedIssues; + // Map to issue-specific return type + return result.map(item => ({ + number: item.number, + html_url: item.html_url || "", + })); } module.exports = {