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
1 change: 1 addition & 0 deletions .github/workflows/aw.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"utc": "-08:00",
"maintenance": {
"action_failure_issue_expires": 12,
"label_triggers": true
Expand Down
3 changes: 2 additions & 1 deletion actions/setup/js/close_expired_discussions.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs");
const { generateExpiredEntityFooter, getExpiredEntityCautionAlert } = require("./generate_footer.cjs");
const { formatDateInProjectTimeZone } = require("./project_timezone.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs");
const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs");
Expand Down Expand Up @@ -139,7 +140,7 @@ async function main() {
}

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

core.info(` Adding closing comment to discussion #${discussion.number}`);
Expand Down
58 changes: 58 additions & 0 deletions actions/setup/js/close_expired_discussions.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ describe("close_expired_discussions", () => {

beforeEach(() => {
vi.clearAllMocks();
delete process.env.GH_AW_DEFAULT_UTC;
delete process.env.GITHUB_WORKSPACE;
mockGithub = {
graphql: vi.fn(),
};
Expand Down Expand Up @@ -239,6 +241,62 @@ describe("close_expired_discussions", () => {
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Discussion closed successfully"));
});

it("should format the closing comment in the configured project timezone", async () => {
const module = await import("./close_expired_discussions.cjs");
process.env.GH_AW_DEFAULT_UTC = "-08:00";

mockGithub.graphql
.mockResolvedValueOnce({
repository: {
discussions: {
pageInfo: {
hasNextPage: false,
endCursor: null,
},
nodes: [
{
id: "D_testtz",
number: 11061,
title: "Timezone Discussion",
url: "https://github.com/testowner/testrepo/discussions/11061",
body: "<!-- gh-aw-workflow-id: test-workflow -->\n> AI generated by Test Workflow\n>\n> - [x] expires <!-- gh-aw-expires: 2020-01-20T09:20:00.000Z --> on Jan 20, 2020, 9:20 AM UTC",
createdAt: "2020-01-19T09:20:00.000Z",
},
],
},
},
})
.mockResolvedValueOnce({
node: {
closed: false,
comments: {
nodes: [{ body: "Some unrelated comment" }],
},
},
})
.mockResolvedValueOnce({
addDiscussionComment: {
comment: {
id: "C_commenttz",
url: "https://github.com/testowner/testrepo/discussions/11061#comment-tz",
},
},
})
.mockResolvedValueOnce({
closeDiscussion: {
discussion: {
id: "D_testtz",
url: "https://github.com/testowner/testrepo/discussions/11061",
},
},
});

await module.main();

const addCommentVariables = mockGithub.graphql.mock.calls[2][1];
expect(addCommentVariables.body).toContain("This discussion was automatically closed because it expired on Jan 20, 2020, 1:20 AM UTC-08:00.");
});

it("should handle empty comments gracefully", async () => {
const module = await import("./close_expired_discussions.cjs");

Expand Down
3 changes: 2 additions & 1 deletion actions/setup/js/close_expired_issues.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs");
const { generateExpiredEntityFooter, getExpiredEntityCautionAlert } = require("./generate_footer.cjs");
const { formatDateInProjectTimeZone } = require("./project_timezone.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs");
const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs");
Expand Down Expand Up @@ -62,7 +63,7 @@ async function main() {
summaryHeading: "Expired Issues Cleanup",
processEntity: async issue => {
const cautionAlert = getExpiredEntityCautionAlert(workflowName, runUrl);
const expirationText = `This issue was automatically closed because it expired on ${issue.expirationDate.toISOString()}.`;
const expirationText = `This issue was automatically closed because it expired on ${formatDateInProjectTimeZone(issue.expirationDate) || issue.expirationDate.toISOString()}.`;
const closingMessage = (cautionAlert ? cautionAlert + "\n\n" : "") + expirationText + generateExpiredEntityFooter(workflowName, runUrl, workflowId);

await addIssueComment(github, owner, repo, issue.number, closingMessage);
Expand Down
3 changes: 2 additions & 1 deletion actions/setup/js/close_expired_pull_requests.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs");
const { generateExpiredEntityFooter, getExpiredEntityCautionAlert } = require("./generate_footer.cjs");
const { formatDateInProjectTimeZone } = require("./project_timezone.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs");
const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs");
Expand Down Expand Up @@ -61,7 +62,7 @@ async function main() {
summaryHeading: "Expired Pull Requests Cleanup",
processEntity: async pr => {
const cautionAlert = getExpiredEntityCautionAlert(workflowName, runUrl);
const expirationText = `This pull request was automatically closed because it expired on ${pr.expirationDate.toISOString()}.`;
const expirationText = `This pull request was automatically closed because it expired on ${formatDateInProjectTimeZone(pr.expirationDate) || pr.expirationDate.toISOString()}.`;
const closingMessage = (cautionAlert ? cautionAlert + "\n\n" : "") + expirationText + generateExpiredEntityFooter(workflowName, runUrl, workflowId);

await addPullRequestComment(github, owner, repo, pr.number, closingMessage);
Expand Down
10 changes: 10 additions & 0 deletions actions/setup/js/ephemerals.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { formatDateInProjectTimeZone, resolveProjectTimeZone } = require("./project_timezone.cjs");

/**
* 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
Expand All @@ -22,6 +24,11 @@ const LEGACY_EXPIRATION_PATTERN = /^>\s*-\s*\[x\]\s+expires\s+on\s+(.+?)\s+UTC\s
* @returns {string} Human-readable date string (e.g., "Jan 25, 2026, 1:53 PM")
*/
function formatExpirationDate(date) {
const projectDate = formatDateInProjectTimeZone(date);
if (projectDate) {
return projectDate;
}

return date.toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
Expand All @@ -37,6 +44,9 @@ function formatExpirationDate(date) {
function createExpirationLine(expirationDate) {
const expirationISO = expirationDate.toISOString();
const humanReadableDate = formatExpirationDate(expirationDate);
if (resolveProjectTimeZone()) {
return `- [x] expires <!-- gh-aw-expires: ${expirationISO} --> on ${humanReadableDate}`;
}
return `- [x] expires <!-- gh-aw-expires: ${expirationISO} --> on ${humanReadableDate} UTC`;
}

Expand Down
50 changes: 50 additions & 0 deletions actions/setup/js/ephemerals.test.cjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";

// Mock core global
const mockCore = {
info: vi.fn(),
warning: vi.fn(),
};
global.core = mockCore;

describe("ephemerals", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.GH_AW_DEFAULT_UTC;
delete process.env.GITHUB_WORKSPACE;
});

describe("formatExpirationDate", () => {
Expand All @@ -20,6 +26,17 @@ describe("ephemerals", () => {
// Note: formatExpirationDate returns format like "Jan 25, 2026, 3:54 PM"
// UTC is added by createExpirationLine, not by formatExpirationDate itself
});

it("should use configured default timezone when present", async () => {
process.env.GH_AW_DEFAULT_UTC = "-08:00";
const { formatExpirationDate } = await import("./ephemerals.cjs");
const date = new Date("2026-01-25T15:54:08.894Z");

const result = formatExpirationDate(date);

expect(result).toContain("Jan 25, 2026");
expect(result).toContain("UTC-08:00");
});
});

describe("createExpirationLine", () => {
Expand All @@ -34,6 +51,22 @@ describe("ephemerals", () => {
expect(result).toContain("UTC");
});

it("should use repo timezone ahead of the default timezone", async () => {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-ephemerals-"));
fs.mkdirSync(path.join(workspace, ".github", "workflows"), { recursive: true });
fs.writeFileSync(path.join(workspace, ".github", "workflows", "aw.json"), JSON.stringify({ utc: "-08:00" }));
process.env.GITHUB_WORKSPACE = workspace;
process.env.GH_AW_DEFAULT_UTC = "+00:00";

const { createExpirationLine } = await import("./ephemerals.cjs");
const date = new Date("2026-01-25T15:54:08.894Z");
const result = createExpirationLine(date);

expect(result).toContain("UTC-08:00");
expect(result).not.toContain("UTC+00:00");
expect(result).not.toMatch(/\sUTC$/);
});

it("should include ISO format in XML comment", async () => {
const { createExpirationLine } = await import("./ephemerals.cjs");
const date = new Date("2026-01-25T15:54:08.894Z");
Expand Down Expand Up @@ -127,6 +160,23 @@ describe("ephemerals", () => {
// Should use ISO date from HTML comment, not the human-readable date
expect(result?.toISOString()).toBe("2026-01-25T15:54:08.894Z");
});

it("should parse a generated expiration line when project utc is configured", async () => {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-ephemerals-"));
fs.mkdirSync(path.join(workspace, ".github", "workflows"), { recursive: true });
fs.writeFileSync(path.join(workspace, ".github", "workflows", "aw.json"), JSON.stringify({ utc: "-08:00" }));
process.env.GITHUB_WORKSPACE = workspace;

const { createExpirationLine, extractExpirationDate } = await import("./ephemerals.cjs");
const date = new Date("2026-01-25T15:54:08.894Z");
const body = `> ${createExpirationLine(date)}`;

const result = extractExpirationDate(body);

expect(body).toContain("UTC-08:00");
expect(result).toBeInstanceOf(Date);
expect(result?.toISOString()).toBe("2026-01-25T15:54:08.894Z");
});
});

describe("generateFooterWithExpiration", () => {
Expand Down
117 changes: 117 additions & 0 deletions actions/setup/js/project_timezone.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @ts-check

const fs = require("fs");
const path = require("path");

const REPO_CONFIG_PATH = [".github", "workflows", "aw.json"];

function normalizeUTCOffset(utcOffset) {
const trimmed = typeof utcOffset === "string" ? utcOffset.trim() : "";
const match = trimmed.match(/^([+-])(\d{2}):(\d{2})$/);
if (!match) {
return "";
}

const [, sign, hours, minutes] = match;
const hourValue = Number.parseInt(hours, 10);
const minuteValue = Number.parseInt(minutes, 10);
if (hourValue > 14 || minuteValue > 59 || (hourValue === 14 && minuteValue !== 0)) {
return "";
}

return `${sign}${hours}:${minutes}`;
}

function parseUTCOffsetMinutes(utcOffset) {
const normalized = normalizeUTCOffset(utcOffset);
if (!normalized) {
return Number.NaN;
}

const sign = normalized.startsWith("-") ? -1 : 1;
const hours = Number.parseInt(normalized.slice(1, 3), 10);
const minutes = Number.parseInt(normalized.slice(4, 6), 10);
return sign * (hours * 60 + minutes);
}

function getRepoConfigPath() {
return path.join(process.env.GITHUB_WORKSPACE || process.cwd(), ...REPO_CONFIG_PATH);
}

function warn(message) {
global.core?.warning?.(message);
}

function readRepoConfigTimeZone() {
const repoConfigPath = getRepoConfigPath();

try {
const raw = fs.readFileSync(repoConfigPath, "utf8");
const parsed = JSON.parse(raw);
const rawUTCOffset = typeof parsed?.utc === "string" ? parsed.utc.trim() : "";
if (!rawUTCOffset) {
return "";
}
const utcOffset = normalizeUTCOffset(rawUTCOffset);
if (!utcOffset) {
warn(`Ignoring invalid repo UTC offset in ${repoConfigPath}: ${rawUTCOffset}`);
return "";
}
return utcOffset;
} catch (error) {
if (error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
return "";
}
if (error instanceof SyntaxError) {
warn(`Ignoring invalid JSON in ${repoConfigPath}: ${error.message}`);
return "";
}
warn(`Failed to read ${repoConfigPath}: ${error instanceof Error ? error.message : String(error)}`);
return "";
}
}

function readDefaultTimeZone() {
const raw = process.env.GH_AW_DEFAULT_UTC || "";
if (!raw.trim()) {
return "";
}
const utcOffset = normalizeUTCOffset(raw);
if (!utcOffset) {
warn(`Ignoring invalid GH_AW_DEFAULT_UTC offset: ${raw.trim()}`);
return "";
}
return utcOffset;
}

function resolveProjectTimeZone() {
return readRepoConfigTimeZone() || readDefaultTimeZone();
}

function formatDateInProjectTimeZone(date) {
const utcOffset = resolveProjectTimeZone();
if (!utcOffset) {
return "";
}

const offsetMinutes = parseUTCOffsetMinutes(utcOffset);
if (Number.isNaN(offsetMinutes)) {
return "";
}

const shiftedDate = new Date(date.getTime() + offsetMinutes * 60 * 1000);
const formatted = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
}).format(shiftedDate);
return `${formatted} UTC${utcOffset}`;
}

module.exports = {
formatDateInProjectTimeZone,
resolveProjectTimeZone,
};
1 change: 1 addition & 0 deletions cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Common Tasks:
For detailed help on any command, use:
gh aw [command] --help`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
cli.ConfigureProjectTimezone()
if bannerFlag {
console.PrintBanner()
}
Expand Down
Loading