diff --git a/actions/setup/js/ephemerals.cjs b/actions/setup/js/ephemerals.cjs index 846dbb6a20..0f09ea270f 100644 --- a/actions/setup/js/ephemerals.cjs +++ b/actions/setup/js/ephemerals.cjs @@ -2,11 +2,19 @@ /// /** - * Regex pattern to match expiration marker with checked checkbox - * Allows flexible whitespace: - [x] expires on ... - * Pattern is more resilient to spacing variations + * Regex pattern to match expiration marker with checked checkbox and HTML comment (new format) + * Format: > - [x] expires on HUMAN_DATE UTC + * Allows flexible whitespace and supports blockquote prefix */ -const EXPIRATION_PATTERN = /^-\s*\[x\]\s+expires\s*/m; +const EXPIRATION_PATTERN = /^>\s*-\s*\[x\]\s+expires\s*/m; + +/** + * Regex pattern to match legacy expiration marker without HTML comment (old format) + * Format: > - [x] expires on HUMAN_DATE UTC + * Allows flexible whitespace and supports blockquote prefix + * Captures the human-readable date for parsing + */ +const LEGACY_EXPIRATION_PATTERN = /^>\s*-\s*\[x\]\s+expires\s+on\s+(.+?)\s+UTC\s*$/m; /** * Format a Date object to human-readable string in UTC @@ -34,25 +42,43 @@ function createExpirationLine(expirationDate) { /** * Extract expiration date from text body + * Supports two formats: + * 1. New format with HTML comment: > - [x] expires on HUMAN_DATE UTC + * 2. Legacy format without HTML comment: > - [x] expires on HUMAN_DATE UTC * @param {string} body - Text body containing expiration marker * @returns {Date|null} Expiration date or null if not found/invalid */ function extractExpirationDate(body) { + // Try new format with HTML comment first (preferred) const match = body.match(EXPIRATION_PATTERN); - if (!match) { - return null; + if (match) { + const expirationISO = match[1].trim(); + const expirationDate = new Date(expirationISO); + + // Validate the date + if (!isNaN(expirationDate.getTime())) { + return expirationDate; + } } - const expirationISO = match[1].trim(); - const expirationDate = new Date(expirationISO); + // Fall back to legacy format without HTML comment + const legacyMatch = body.match(LEGACY_EXPIRATION_PATTERN); + + if (legacyMatch) { + const humanReadableDate = legacyMatch[1].trim(); + // Parse human-readable date format: "Jan 20, 2026, 9:20 AM" + // Add "UTC" timezone explicitly if not present to ensure UTC parsing + const dateString = humanReadableDate.includes("UTC") ? humanReadableDate : `${humanReadableDate} UTC`; + const expirationDate = new Date(dateString); - // Validate the date - if (isNaN(expirationDate.getTime())) { - return null; + // Validate the date + if (!isNaN(expirationDate.getTime())) { + return expirationDate; + } } - return expirationDate; + return null; } /** @@ -126,6 +152,7 @@ function addExpirationToFooter(footer, expiresHours, entityType) { module.exports = { EXPIRATION_PATTERN, + LEGACY_EXPIRATION_PATTERN, formatExpirationDate, createExpirationLine, extractExpirationDate, diff --git a/actions/setup/js/ephemerals.test.cjs b/actions/setup/js/ephemerals.test.cjs index d5db0fc2c2..60110d211f 100644 --- a/actions/setup/js/ephemerals.test.cjs +++ b/actions/setup/js/ephemerals.test.cjs @@ -53,6 +53,37 @@ describe("ephemerals", () => { expect(result?.toISOString()).toBe("2026-01-25T15:54:08.894Z"); }); + it("should extract date from body with blockquote prefix (new format)", async () => { + const { extractExpirationDate } = await import("./ephemerals.cjs"); + const body = "> AI generated by Workflow\n>\n> - [x] expires on Jan 25, 2026, 3:54 PM UTC"; + const result = extractExpirationDate(body); + + expect(result).toBeInstanceOf(Date); + expect(result?.toISOString()).toBe("2026-01-25T15:54:08.894Z"); + }); + + it("should extract date from legacy format without HTML comment", async () => { + const { extractExpirationDate } = await import("./ephemerals.cjs"); + const body = "> AI generated by Daily Team Status\n>\n> To add this workflow in your repository\n> - [x] expires on Jan 20, 2026, 9:20 AM UTC"; + const result = extractExpirationDate(body); + + expect(result).toBeInstanceOf(Date); + expect(result?.toISOString()).toBe("2026-01-20T09:20:00.000Z"); + }); + + it("should extract date from actual issue #10667 format", async () => { + const { extractExpirationDate } = await import("./ephemerals.cjs"); + const body = `> AI generated by Daily Team Status +> +> To add this workflow in your repository, run \`gh aw add githubnext/agentics/workflows/daily-team-status.md@d3422bf940923ef1d43db5559652b8e1e71869f3\`. See usage guide. +> - [x] expires on Jan 20, 2026, 9:20 AM UTC +`; + const result = extractExpirationDate(body); + + expect(result).toBeInstanceOf(Date); + expect(result?.toISOString()).toBe("2026-01-20T09:20:00.000Z"); + }); + it("should return null when no expiration marker found", async () => { const { extractExpirationDate } = await import("./ephemerals.cjs"); const body = "Some text without expiration marker"; @@ -69,6 +100,14 @@ describe("ephemerals", () => { expect(result).toBeNull(); }); + it("should return null for invalid legacy date", async () => { + const { extractExpirationDate } = await import("./ephemerals.cjs"); + const body = "> - [x] expires on Invalid Date, 9999 UTC"; + const result = extractExpirationDate(body); + + expect(result).toBeNull(); + }); + it("should handle standard expiration marker format", async () => { const { extractExpirationDate } = await import("./ephemerals.cjs"); const body = "Some text\n- [x] expires on Jan 25, 2026\nMore text"; @@ -77,6 +116,17 @@ describe("ephemerals", () => { expect(result).toBeInstanceOf(Date); expect(result?.toISOString()).toBe("2026-01-25T15:54:08.894Z"); }); + + it("should prefer new format over legacy when both present", async () => { + const { extractExpirationDate } = await import("./ephemerals.cjs"); + // This would be malformed, but tests the priority + const body = "> - [x] expires on Jan 20, 2026, 9:20 AM UTC"; + const result = extractExpirationDate(body); + + expect(result).toBeInstanceOf(Date); + // Should use ISO date from HTML comment, not the human-readable date + expect(result?.toISOString()).toBe("2026-01-25T15:54:08.894Z"); + }); }); describe("generateFooterWithExpiration", () => { @@ -238,16 +288,54 @@ describe("ephemerals", () => { expect(EXPIRATION_PATTERN).toBeInstanceOf(RegExp); }); - it("should match standard expiration line", async () => { + it("should match standard expiration line without blockquote", async () => { const { EXPIRATION_PATTERN } = await import("./ephemerals.cjs"); const line = "- [x] expires on Jan 25, 2026"; + // This should NOT match because the pattern requires blockquote prefix + expect(EXPIRATION_PATTERN.test(line)).toBe(false); + }); + + it("should match expiration line with blockquote prefix", async () => { + const { EXPIRATION_PATTERN } = await import("./ephemerals.cjs"); + const line = "> - [x] expires on Jan 25, 2026"; expect(EXPIRATION_PATTERN.test(line)).toBe(true); }); it("should handle some whitespace variations", async () => { const { EXPIRATION_PATTERN } = await import("./ephemerals.cjs"); - const line = "- [x] expires "; + const line = "> - [x] expires "; expect(EXPIRATION_PATTERN.test(line)).toBe(true); }); }); + + describe("LEGACY_EXPIRATION_PATTERN", () => { + it("should be exported", async () => { + const { LEGACY_EXPIRATION_PATTERN } = await import("./ephemerals.cjs"); + expect(LEGACY_EXPIRATION_PATTERN).toBeInstanceOf(RegExp); + }); + + it("should match legacy format without HTML comment", async () => { + const { LEGACY_EXPIRATION_PATTERN } = await import("./ephemerals.cjs"); + const line = "> - [x] expires on Jan 20, 2026, 9:20 AM UTC"; + expect(LEGACY_EXPIRATION_PATTERN.test(line)).toBe(true); + }); + + it("should match legacy format with minimal whitespace", async () => { + const { LEGACY_EXPIRATION_PATTERN } = await import("./ephemerals.cjs"); + const line = "> - [x] expires on Jan 20, 2026, 9:20 AM UTC"; + expect(LEGACY_EXPIRATION_PATTERN.test(line)).toBe(true); + }); + + it("should NOT match new format with HTML comment", async () => { + const { LEGACY_EXPIRATION_PATTERN } = await import("./ephemerals.cjs"); + const line = "> - [x] expires on Jan 25, 2026 UTC"; + expect(LEGACY_EXPIRATION_PATTERN.test(line)).toBe(false); + }); + + it("should NOT match without UTC suffix", async () => { + const { LEGACY_EXPIRATION_PATTERN } = await import("./ephemerals.cjs"); + const line = "> - [x] expires on Jan 20, 2026, 9:20 AM"; + expect(LEGACY_EXPIRATION_PATTERN.test(line)).toBe(false); + }); + }); });