Skip to content
Merged
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
51 changes: 39 additions & 12 deletions actions/setup/js/ephemerals.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@
/// <reference types="@actions/github-script" />

/**
* Regex pattern to match expiration marker with checked checkbox
* Allows flexible whitespace: - [x] expires <!-- gh-aw-expires: DATE --> 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 <!-- gh-aw-expires: ISO_DATE --> on HUMAN_DATE UTC
* Allows flexible whitespace and supports blockquote prefix
*/
const EXPIRATION_PATTERN = /^-\s*\[x\]\s+expires\s*<!--\s*gh-aw-expires:\s*([^>]+)\s*-->/m;
const EXPIRATION_PATTERN = /^>\s*-\s*\[x\]\s+expires\s*<!--\s*gh-aw-expires:\s*([^>]+)\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
Expand Down Expand Up @@ -34,25 +42,43 @@ function createExpirationLine(expirationDate) {

/**
* Extract expiration date from text body
* Supports two formats:
* 1. New format with HTML comment: > - [x] expires <!-- gh-aw-expires: ISO_DATE --> 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;
}

/**
Expand Down Expand Up @@ -126,6 +152,7 @@ function addExpirationToFooter(footer, expiresHours, entityType) {

module.exports = {
EXPIRATION_PATTERN,
LEGACY_EXPIRATION_PATTERN,
formatExpirationDate,
createExpirationLine,
extractExpirationDate,
Expand Down
92 changes: 90 additions & 2 deletions actions/setup/js/ephemerals.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <!-- gh-aw-expires: 2026-01-25T15:54:08.894Z --> 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";
Expand All @@ -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 <!-- gh-aw-expires: 2026-01-25T15:54:08.894Z --> on Jan 25, 2026\nMore text";
Expand All @@ -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 <!-- gh-aw-expires: 2026-01-25T15:54:08.894Z --> 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", () => {
Expand Down Expand Up @@ -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 <!-- gh-aw-expires: 2026-01-25T15:54:08.894Z --> 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 <!-- gh-aw-expires: 2026-01-25T15:54:08.894Z --> 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 <!-- gh-aw-expires: 2026-01-25T15:54:08.894Z -->";
const line = "> - [x] expires <!-- gh-aw-expires: 2026-01-25T15:54:08.894Z -->";
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 <!-- gh-aw-expires: 2026-01-25T15:54:08.894Z --> 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);
});
});
});