diff --git a/actions/setup/js/expired_entity_cleanup_helpers.cjs b/actions/setup/js/expired_entity_cleanup_helpers.cjs index 972ec934c1..6ba6e6e1d9 100644 --- a/actions/setup/js/expired_entity_cleanup_helpers.cjs +++ b/actions/setup/js/expired_entity_cleanup_helpers.cjs @@ -1,5 +1,5 @@ // @ts-check -// +/// /** * Expired Entity Cleanup Helpers @@ -163,7 +163,7 @@ function buildNotExpiredSection(notExpiredEntities, now, entityLabel) { let section = `### Not Yet Expired\n\n`; - const list = notExpiredEntities.length > 10 ? notExpiredEntities.slice(0, 10) : notExpiredEntities; + const list = notExpiredEntities.slice(0, 10); if (notExpiredEntities.length > 10) { section += `${notExpiredEntities.length} ${entityLabel.toLowerCase()}(s) not yet expired (showing first 10):\n\n`; } diff --git a/actions/setup/js/expired_entity_cleanup_helpers.test.cjs b/actions/setup/js/expired_entity_cleanup_helpers.test.cjs new file mode 100644 index 0000000000..6085b3f2a9 --- /dev/null +++ b/actions/setup/js/expired_entity_cleanup_helpers.test.cjs @@ -0,0 +1,293 @@ +// @ts-check +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createRequire } from "module"; + +const req = createRequire(import.meta.url); + +describe("expired_entity_cleanup_helpers", () => { + /** @type {Record} */ + let mockCore; + /** @type {Record} */ + let originalGlobals; + + beforeEach(() => { + originalGlobals = { core: global.core }; + mockCore = { + debug: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setOutput: vi.fn(), + setFailed: vi.fn(), + }; + global.core = mockCore; + }); + + afterEach(() => { + global.core = originalGlobals.core; + vi.clearAllMocks(); + }); + + const { delay, validateCreationDate, categorizeByExpiration, processExpiredEntities, buildExpirationSummary, DEFAULT_MAX_UPDATES_PER_RUN, DEFAULT_GRAPHQL_DELAY_MS } = req("./expired_entity_cleanup_helpers.cjs"); + + /** Build an expiration marker body in the expected gh-aw-expires format */ + const makeExpirationBody = date => `> - [x] expires on ${date.toUTCString()} UTC`; + + describe("delay", () => { + it("resolves after the specified time", async () => { + const start = Date.now(); + await delay(10); + expect(Date.now() - start).toBeGreaterThanOrEqual(10); + }); + + it("resolves immediately for 0 ms", async () => { + await expect(delay(0)).resolves.toBeUndefined(); + }); + }); + + describe("validateCreationDate", () => { + it("returns true for a valid ISO 8601 date", () => { + expect(validateCreationDate("2024-01-15T10:00:00Z")).toBe(true); + }); + + it("returns true for a valid date-only string", () => { + expect(validateCreationDate("2024-01-15")).toBe(true); + }); + + it("returns false for an invalid date string", () => { + expect(validateCreationDate("not-a-date")).toBe(false); + }); + + it("returns false for an empty string", () => { + expect(validateCreationDate("")).toBe(false); + }); + + it("returns false for a purely numeric string", () => { + expect(validateCreationDate("abc123")).toBe(false); + }); + }); + + describe("categorizeByExpiration", () => { + const makeEntity = (number, body, createdAt = "2024-01-01T00:00:00Z") => ({ + number, + title: `Issue ${number}`, + url: `https://github.com/owner/repo/issues/${number}`, + body, + createdAt, + }); + + it("puts expired entities in the expired array", () => { + const pastDate = new Date(Date.now() - 1000 * 60 * 60 * 24 * 2); // 2 days ago + const entity = makeEntity(1, makeExpirationBody(pastDate)); + const { expired, notExpired } = categorizeByExpiration([entity], { entityLabel: "Issue" }); + expect(expired).toHaveLength(1); + expect(notExpired).toHaveLength(0); + }); + + it("puts non-expired entities in the notExpired array", () => { + const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); // 7 days from now + const entity = makeEntity(2, makeExpirationBody(futureDate)); + const { expired, notExpired } = categorizeByExpiration([entity], { entityLabel: "Issue" }); + expect(expired).toHaveLength(0); + expect(notExpired).toHaveLength(1); + }); + + it("skips entities with invalid creation dates and logs a warning", () => { + const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24); + const entity = makeEntity(3, makeExpirationBody(futureDate), "invalid-date"); + const { expired, notExpired } = categorizeByExpiration([entity], { entityLabel: "Issue" }); + expect(expired).toHaveLength(0); + expect(notExpired).toHaveLength(0); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("invalid creation date")); + }); + + it("skips entities without expiration markers and logs a warning", () => { + const entity = makeEntity(4, "No expiration marker here"); + const { expired, notExpired } = categorizeByExpiration([entity], { entityLabel: "Issue" }); + expect(expired).toHaveLength(0); + expect(notExpired).toHaveLength(0); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("invalid expiration date format")); + }); + + it("returns a now Date object", () => { + const before = Date.now(); + const { now } = categorizeByExpiration([], { entityLabel: "Issue" }); + expect(now.getTime()).toBeGreaterThanOrEqual(before); + }); + + it("attaches the expirationDate to each categorized entity", () => { + const pastDate = new Date(Date.now() - 1000 * 60 * 60 * 24); + const entity = makeEntity(5, makeExpirationBody(pastDate)); + const { expired } = categorizeByExpiration([entity], { entityLabel: "Issue" }); + expect(expired[0].expirationDate).toBeInstanceOf(Date); + }); + }); + + describe("processExpiredEntities", () => { + const makeExpiredEntity = number => ({ + number, + title: `Issue ${number}`, + url: `https://github.com/owner/repo/issues/${number}`, + expirationDate: new Date(Date.now() - 1000), + }); + + it("processes all entities and returns closed records", async () => { + const entities = [makeExpiredEntity(1), makeExpiredEntity(2)]; + const processEntity = vi.fn().mockResolvedValue({ status: "closed", record: { number: 1 } }); + const { closed, skipped, failed } = await processExpiredEntities(entities, { + entityLabel: "Issue", + delayMs: 0, + processEntity, + }); + expect(closed).toHaveLength(2); + expect(skipped).toHaveLength(0); + expect(failed).toHaveLength(0); + }); + + it("tracks skipped entities separately", async () => { + const entities = [makeExpiredEntity(1)]; + const processEntity = vi.fn().mockResolvedValue({ status: "skipped", record: { number: 1 } }); + const { closed, skipped } = await processExpiredEntities(entities, { + entityLabel: "Issue", + delayMs: 0, + processEntity, + }); + expect(closed).toHaveLength(0); + expect(skipped).toHaveLength(1); + }); + + it("tracks failed entities when processEntity throws", async () => { + const entities = [makeExpiredEntity(1)]; + const processEntity = vi.fn().mockRejectedValue(new Error("API error")); + const { failed } = await processExpiredEntities(entities, { + entityLabel: "Issue", + delayMs: 0, + processEntity, + }); + expect(failed).toHaveLength(1); + expect(failed[0].error).toBe("API error"); + }); + + it("respects the maxPerRun limit", async () => { + const entities = [makeExpiredEntity(1), makeExpiredEntity(2), makeExpiredEntity(3)]; + const processEntity = vi.fn().mockResolvedValue({ status: "closed", record: {} }); + await processExpiredEntities(entities, { + entityLabel: "Issue", + maxPerRun: 2, + delayMs: 0, + processEntity, + }); + expect(processEntity).toHaveBeenCalledTimes(2); + }); + + it("returns empty arrays when input is empty", async () => { + const processEntity = vi.fn(); + const { closed, skipped, failed } = await processExpiredEntities([], { + entityLabel: "Issue", + delayMs: 0, + processEntity, + }); + expect(closed).toHaveLength(0); + expect(skipped).toHaveLength(0); + expect(failed).toHaveLength(0); + expect(processEntity).not.toHaveBeenCalled(); + }); + }); + + describe("buildExpirationSummary", () => { + const baseParams = { + heading: "Cleanup Summary", + entityLabel: "Issue", + searchStats: { totalScanned: 50, pageCount: 2 }, + withExpirationCount: 10, + expired: [{ number: 1, title: "Old Issue", url: "https://github.com/owner/repo/issues/1" }], + notExpired: [], + closed: [{ number: 1, title: "Old Issue", url: "https://github.com/owner/repo/issues/1" }], + failed: [], + maxPerRun: 100, + }; + + it("includes the heading and scan summary", () => { + const result = buildExpirationSummary(baseParams); + expect(result).toContain("## Cleanup Summary"); + expect(result).toContain("Scanned: 50"); + expect(result).toContain("With expiration markers: 10"); + }); + + it("lists successfully closed entities", () => { + const result = buildExpirationSummary(baseParams); + expect(result).toContain("Successfully Closed Issues"); + expect(result).toContain("Old Issue"); + }); + + it("shows failed entities when present", () => { + const params = { + ...baseParams, + failed: [{ number: 2, title: "Failed Issue", url: "https://example.com/2", error: "timeout" }], + }; + const result = buildExpirationSummary(params); + expect(result).toContain("Failed to Close"); + expect(result).toContain("timeout"); + }); + + it("shows remaining count when expired exceeds maxPerRun", () => { + const params = { + ...baseParams, + expired: Array.from({ length: 5 }, (_, i) => ({ number: i + 1, title: `Issue ${i + 1}`, url: "" })), + closed: [], + maxPerRun: 2, + }; + const result = buildExpirationSummary(params); + expect(result).toContain("Remaining for next run: 3"); + }); + + it("shows skipped section when includeSkippedHeading is true and skipped exist", () => { + const params = { + ...baseParams, + skipped: [{ number: 3, title: "Skipped Issue", url: "https://example.com/3" }], + includeSkippedHeading: true, + }; + const result = buildExpirationSummary(params); + expect(result).toContain("Skipped (Already Had Comment)"); + expect(result).toContain("Skipped Issue"); + }); + + it("omits skipped section when includeSkippedHeading is false", () => { + const params = { + ...baseParams, + skipped: [{ number: 3, title: "Skipped Issue", url: "https://example.com/3" }], + includeSkippedHeading: false, + }; + const result = buildExpirationSummary(params); + expect(result).not.toContain("Skipped (Already Had Comment)"); + }); + + it("caps notExpired list at 10 and shows 'showing first 10' text", () => { + const fixedNow = new Date("2024-06-01T00:00:00Z"); + const futureDate = new Date("2024-07-01T00:00:00Z"); + const notExpired = Array.from({ length: 12 }, (_, i) => ({ + number: i + 10, + title: `Not Expired ${i + 10}`, + url: `https://github.com/owner/repo/issues/${i + 10}`, + expirationDate: futureDate, + })); + const params = { ...baseParams, notExpired, expired: [], closed: [], now: fixedNow }; + const result = buildExpirationSummary(params); + expect(result).toContain("showing first 10"); + // Entries 10–19 should appear; entry 21 (index 11) must not + expect(result).toContain("Not Expired 10"); + expect(result).toContain("Not Expired 19"); + expect(result).not.toContain("Not Expired 21"); + }); + }); + + describe("constants", () => { + it("exports DEFAULT_MAX_UPDATES_PER_RUN as 100", () => { + expect(DEFAULT_MAX_UPDATES_PER_RUN).toBe(100); + }); + + it("exports DEFAULT_GRAPHQL_DELAY_MS as 500", () => { + expect(DEFAULT_GRAPHQL_DELAY_MS).toBe(500); + }); + }); +});