From 572876be1eace3dc0d12574e3975b64083ba3f2a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 14 Mar 2026 16:28:02 +0100 Subject: [PATCH] chore: add OSS weekend gating --- .github/scripts/oss-weekend.mjs | 220 ------------------- .github/workflows/oss-weekend-issues.yml | 98 +++++++++ .github/workflows/oss-weekend.yml | 88 -------- .github/workflows/pr-gate.yml | 118 +++++++---- scripts/oss-weekend.mjs | 259 +++++++++++++++++++++++ 5 files changed, 437 insertions(+), 346 deletions(-) delete mode 100644 .github/scripts/oss-weekend.mjs create mode 100644 .github/workflows/oss-weekend-issues.yml delete mode 100644 .github/workflows/oss-weekend.yml create mode 100644 scripts/oss-weekend.mjs diff --git a/.github/scripts/oss-weekend.mjs b/.github/scripts/oss-weekend.mjs deleted file mode 100644 index a72b757d..00000000 --- a/.github/scripts/oss-weekend.mjs +++ /dev/null @@ -1,220 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises"; -import process from "node:process"; - -const TIME_ZONE = "Europe/Berlin"; -const README_PATH = "README.md"; -const MARKER_START = ""; -const MARKER_END = ""; -const DISCORD_URL = "https://discord.com/invite/3cU7Bz4UPx"; - -function parseArgs(argv) { - const options = {}; - - for (const arg of argv) { - if (!arg.startsWith("--")) continue; - - const trimmedArg = arg.slice(2); - const separatorIndex = trimmedArg.indexOf("="); - - if (separatorIndex === -1) { - options[trimmedArg] = "true"; - continue; - } - - const key = trimmedArg.slice(0, separatorIndex); - const value = trimmedArg.slice(separatorIndex + 1); - options[key] = value; - } - - return options; -} - -function getOption(name, cliOptions, envName, fallback) { - const cliValue = cliOptions[name]; - if (cliValue !== undefined) return cliValue; - - const envValue = process.env[envName]; - if (envValue !== undefined && envValue !== "") return envValue; - - return fallback; -} - -function isTruthy(value) { - return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); -} - -function getBerlinParts(date) { - const formatter = new Intl.DateTimeFormat("en-CA", { - timeZone: TIME_ZONE, - weekday: "short", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }); - - const parts = formatter.formatToParts(date); - const values = {}; - - for (const part of parts) { - if (part.type === "literal") continue; - values[part.type] = part.value; - } - - return { - weekday: values.weekday, - year: Number(values.year), - month: Number(values.month), - day: Number(values.day), - hour: Number(values.hour), - minute: Number(values.minute), - }; -} - -function formatLongDate(date) { - return new Intl.DateTimeFormat("en-US", { - timeZone: TIME_ZONE, - weekday: "long", - month: "long", - day: "numeric", - year: "numeric", - }).format(date); -} - -function addDays(date, days) { - return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); -} - -function determineAction(mode, now) { - if (mode === "close" || mode === "open") return mode; - if (mode !== "auto") { - throw new Error(`Unsupported mode: ${mode}`); - } - - const berlinNow = getBerlinParts(now); - - if (berlinNow.weekday === "Fri" && berlinNow.hour === 17 && berlinNow.minute === 0) { - return "close"; - } - - if (berlinNow.weekday === "Mon" && berlinNow.hour === 0 && berlinNow.minute === 5) { - return "open"; - } - - return "none"; -} - -function buildBanner(now) { - const startDate = formatLongDate(now); - const reopenDate = formatLongDate(addDays(now, 3)); - - return [ - MARKER_START, - "# 🏖️ OSS Weekend", - "", - `**Issue tracker reopens ${reopenDate}.**`, - "", - `OSS weekend runs ${startDate} through ${reopenDate}. For support, join [Discord](${DISCORD_URL}).`, - MARKER_END, - "", - "---", - "", - "", - ].join("\n"); -} - -function upsertBanner(readme, now) { - const banner = buildBanner(now); - const bannerPattern = new RegExp( - `${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n\\n---\\n\\n?`, - "m", - ); - - if (bannerPattern.test(readme)) { - return readme.replace(bannerPattern, banner); - } - - return `${banner}${readme}`; -} - -function removeBanner(readme) { - const bannerPattern = new RegExp( - `^${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n\\n---\\n\\n?`, - "m", - ); - - return readme.replace(bannerPattern, ""); -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -async function writeGithubOutput(output) { - const githubOutputPath = process.env.GITHUB_OUTPUT; - if (!githubOutputPath) return; - - const lines = Object.entries(output).map(([key, value]) => `${key}=${value}`); - await writeFile(githubOutputPath, `${lines.join("\n")}\n`, { flag: "a" }); -} - -async function main() { - const cliOptions = parseArgs(process.argv.slice(2)); - const mode = getOption("mode", cliOptions, "OSS_WEEKEND_MODE", "auto"); - const dryRun = isTruthy(getOption("dry-run", cliOptions, "OSS_WEEKEND_DRY_RUN", "false")); - const nowInput = getOption("now", cliOptions, "OSS_WEEKEND_NOW", ""); - const readmePath = getOption("readme", cliOptions, "OSS_WEEKEND_README_PATH", README_PATH); - - const now = nowInput ? new Date(nowInput) : new Date(); - if (Number.isNaN(now.getTime())) { - throw new Error(`Invalid date: ${nowInput}`); - } - - const action = determineAction(mode, now); - const currentReadme = await readFile(readmePath, "utf8"); - - let nextReadme = currentReadme; - if (action === "close") nextReadme = upsertBanner(currentReadme, now); - if (action === "open") nextReadme = removeBanner(currentReadme); - - const readmeChanged = nextReadme !== currentReadme; - - if (readmeChanged && !dryRun) { - await writeFile(readmePath, nextReadme, "utf8"); - } - - const output = { - action, - dry_run: dryRun ? "true" : "false", - readme_path: readmePath, - readme_changed: readmeChanged ? "true" : "false", - issue_state: action === "close" ? "disabled" : action === "open" ? "enabled" : "unchanged", - commit_message: - action === "close" - ? "docs: enable OSS Weekend notice" - : action === "open" - ? "docs: disable OSS Weekend notice" - : "", - now_utc: now.toISOString(), - now_berlin: new Intl.DateTimeFormat("sv-SE", { - timeZone: TIME_ZONE, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hourCycle: "h23", - }).format(now), - }; - - await writeGithubOutput(output); - process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -}); diff --git a/.github/workflows/oss-weekend-issues.yml b/.github/workflows/oss-weekend-issues.yml new file mode 100644 index 00000000..75960cd6 --- /dev/null +++ b/.github/workflows/oss-weekend-issues.yml @@ -0,0 +1,98 @@ +name: OSS Weekend Issues + +on: + issues: + types: [opened] + +jobs: + close-issues-during-weekend: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Close new issues during OSS weekend + uses: actions/github-script@v7 + with: + script: | + const issueAuthor = context.payload.issue.user.login; + const defaultBranch = context.payload.repository.default_branch; + + if (issueAuthor.endsWith('[bot]') || issueAuthor === 'dependabot[bot]') { + console.log(`Skipping bot: ${issueAuthor}`); + return; + } + + async function getPermission(username) { + try { + const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + return permissionLevel.permission; + } catch { + return null; + } + } + + async function getTextFile(path) { + const { data: fileContent } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path, + ref: defaultBranch, + }); + + if (!('content' in fileContent) || typeof fileContent.content !== 'string') { + throw new Error(`Expected file content for ${path}`); + } + + return Buffer.from(fileContent.content, 'base64').toString('utf8'); + } + + const permission = await getPermission(issueAuthor); + if (['admin', 'maintain', 'write'].includes(permission)) { + console.log(`${issueAuthor} is a collaborator with ${permission} access`); + return; + } + + let weekendState; + try { + weekendState = JSON.parse(await getTextFile('.github/oss-weekend.json')); + } catch (error) { + if (error && typeof error === 'object' && 'status' in error && error.status === 404) { + console.log('OSS weekend is not active'); + return; + } + throw error; + } + + if (!weekendState?.active) { + console.log('OSS weekend is not active'); + return; + } + + const reopenDate = weekendState.reopensOnText || weekendState.reopensOn || 'after the weekend'; + const discordUrl = weekendState.discordUrl || 'https://discord.com/invite/3cU7Bz4UPx'; + const message = [ + `Hi @${issueAuthor}, thanks for opening an issue.`, + '', + `OSS weekend is active until ${reopenDate}, so new issues are being auto-closed for now.`, + '', + `Please reopen or submit this issue again after ${reopenDate}. For support, join [Discord](${discordUrl}).`, + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: message, + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: 'closed', + }); diff --git a/.github/workflows/oss-weekend.yml b/.github/workflows/oss-weekend.yml deleted file mode 100644 index e3432d41..00000000 --- a/.github/workflows/oss-weekend.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: OSS Weekend - -on: - schedule: - - cron: "0 15,16 * * 5" - - cron: "5 22,23 * * 0" - workflow_dispatch: - inputs: - mode: - description: "close, open, or auto" - required: false - default: auto - type: choice - options: - - auto - - close - - open - dry_run: - description: "Preview changes without toggling issues or pushing README updates" - required: false - default: true - type: boolean - now: - description: "Optional ISO timestamp override for testing, e.g. 2026-03-20T16:00:00Z" - required: false - type: string - -jobs: - oss-weekend: - runs-on: ubuntu-latest - environment: oss-weekend - permissions: - contents: write - env: - WORKFLOW_TOKEN: ${{ secrets.OSS_WEEKEND_TOKEN != '' && secrets.OSS_WEEKEND_TOKEN || github.token }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.event.repository.default_branch }} - token: ${{ secrets.OSS_WEEKEND_TOKEN != '' && secrets.OSS_WEEKEND_TOKEN || github.token }} - - - name: Plan OSS weekend action - id: plan - env: - OSS_WEEKEND_MODE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.mode || 'auto' }} - OSS_WEEKEND_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run || 'false' }} - OSS_WEEKEND_NOW: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.now || '' }} - run: node .github/scripts/oss-weekend.mjs - - - name: Stop when no action is due - if: steps.plan.outputs.action == 'none' - run: echo "No OSS weekend action due at this time" - - - name: Require OSS_WEEKEND_TOKEN for live runs - if: steps.plan.outputs.action != 'none' && steps.plan.outputs.dry_run != 'true' && secrets.OSS_WEEKEND_TOKEN == '' - run: | - echo "OSS_WEEKEND_TOKEN is required for live OSS weekend runs" - exit 1 - - - name: Toggle issue tracker - if: steps.plan.outputs.action != 'none' && steps.plan.outputs.dry_run != 'true' - env: - GH_TOKEN: ${{ env.WORKFLOW_TOKEN }} - run: | - if [ "${{ steps.plan.outputs.action }}" = "close" ]; then - gh api --method PATCH repos/${{ github.repository }} -f has_issues=false - else - gh api --method PATCH repos/${{ github.repository }} -f has_issues=true - fi - - - name: Commit README update - if: steps.plan.outputs.readme_changed == 'true' && steps.plan.outputs.dry_run != 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add README.md - git diff --staged --quiet || git commit -m "${{ steps.plan.outputs.commit_message }}" - git push - - - name: Summary - run: | - echo "action=${{ steps.plan.outputs.action }}" - echo "dry_run=${{ steps.plan.outputs.dry_run }}" - echo "readme_changed=${{ steps.plan.outputs.readme_changed }}" - echo "issue_state=${{ steps.plan.outputs.issue_state }}" - echo "now_utc=${{ steps.plan.outputs.now_utc }}" - echo "now_berlin=${{ steps.plan.outputs.now_berlin }}" diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index ad7c7ba8..60c4335a 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -19,47 +19,101 @@ jobs: const prAuthor = context.payload.pull_request.user.login; const defaultBranch = context.payload.repository.default_branch; - // Skip bots if (prAuthor.endsWith('[bot]') || prAuthor === 'dependabot[bot]') { console.log(`Skipping bot: ${prAuthor}`); return; } - // Check if user is a collaborator (has write access) - try { - const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: prAuthor - }); - if (['admin', 'write'].includes(permissionLevel.permission)) { - console.log(`${prAuthor} is a collaborator with ${permissionLevel.permission} access`); - return; + async function getPermission(username) { + try { + const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + return permissionLevel.permission; + } catch { + return null; } - } catch (e) { - // User is not a collaborator, continue with check } - // Fetch approved contributors list - const { data: fileContent } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/APPROVED_CONTRIBUTORS', - ref: defaultBranch - }); + async function getTextFile(path) { + const { data: fileContent } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path, + ref: defaultBranch, + }); - const content = Buffer.from(fileContent.content, 'base64').toString('utf8'); - const approvedList = content + if (!('content' in fileContent) || typeof fileContent.content !== 'string') { + throw new Error(`Expected file content for ${path}`); + } + + return Buffer.from(fileContent.content, 'base64').toString('utf8'); + } + + async function closePullRequest(message) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: message, + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + state: 'closed', + }); + } + + const permission = await getPermission(prAuthor); + if (['admin', 'maintain', 'write'].includes(permission)) { + console.log(`${prAuthor} is a collaborator with ${permission} access`); + return; + } + + const approvedContent = await getTextFile('.github/APPROVED_CONTRIBUTORS'); + const approvedList = approvedContent .split('\n') .map(line => line.trim().toLowerCase()) .filter(line => line && !line.startsWith('#')); + const isApprovedContributor = approvedList.includes(prAuthor.toLowerCase()); - if (approvedList.includes(prAuthor.toLowerCase())) { + let weekendState = null; + try { + weekendState = JSON.parse(await getTextFile('.github/oss-weekend.json')); + } catch (error) { + if (!(error && typeof error === 'object' && 'status' in error && error.status === 404)) { + throw error; + } + } + + if (weekendState?.active && isApprovedContributor) { + console.log(`${prAuthor} is approved, but OSS weekend is active`); + + const reopenDate = weekendState.reopensOnText || weekendState.reopensOn || 'after the weekend'; + const discordUrl = weekendState.discordUrl || 'https://discord.com/invite/3cU7Bz4UPx'; + const message = [ + `Hi @${prAuthor}, thanks for the PR.`, + '', + `OSS weekend is active until ${reopenDate}, so external PRs are being paused for now.`, + '', + 'You are already on the approved contributors list, so you can resubmit this PR after the weekend without reapproval.', + '', + `This PR will be closed automatically. For support, join [Discord](${discordUrl}).`, + ].join('\n'); + + await closePullRequest(message); + return; + } + + if (isApprovedContributor) { console.log(`${prAuthor} is in the approved contributors list`); return; } - // Not approved - close PR with comment console.log(`${prAuthor} is not approved, closing PR`); const message = [ @@ -72,19 +126,7 @@ jobs: '2. Once a maintainer approves with `lgtm`, you\'ll be added to the approved contributors list', '3. Then you can submit your PR', '', - `This PR will be closed automatically. See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for more details.` + `This PR will be closed automatically. See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for more details.`, ].join('\n'); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body: message - }); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - state: 'closed' - }); + await closePullRequest(message); diff --git a/scripts/oss-weekend.mjs b/scripts/oss-weekend.mjs new file mode 100644 index 00000000..782ecd3a --- /dev/null +++ b/scripts/oss-weekend.mjs @@ -0,0 +1,259 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import process from "node:process"; + +const TIME_ZONE = "Europe/Berlin"; +const DEFAULT_README_PATHS = ["README.md", "packages/coding-agent/README.md"]; +const DEFAULT_STATE_PATH = ".github/oss-weekend.json"; +const MARKER_START = ""; +const MARKER_END = ""; +const DISCORD_URL = "https://discord.com/invite/3cU7Bz4UPx"; + +function parseArgs(argv) { + const options = {}; + + for (const arg of argv) { + if (!arg.startsWith("--")) continue; + + const trimmedArg = arg.slice(2); + const separatorIndex = trimmedArg.indexOf("="); + + if (separatorIndex === -1) { + options[trimmedArg] = "true"; + continue; + } + + const key = trimmedArg.slice(0, separatorIndex); + const value = trimmedArg.slice(separatorIndex + 1); + options[key] = value; + } + + return options; +} + +function getOption(name, cliOptions, envName, fallback) { + const cliValue = cliOptions[name]; + if (cliValue !== undefined) return cliValue; + + const envValue = process.env[envName]; + if (envValue !== undefined && envValue !== "") return envValue; + + return fallback; +} + +function isTruthy(value) { + return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function formatLongDate(date) { + return new Intl.DateTimeFormat("en-US", { + timeZone: TIME_ZONE, + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }).format(date); +} + +function parseDateInput(value) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) { + throw new Error(`Invalid end date: ${value}. Use YYYY-MM-DD.`); + } + + const [, year, month, day] = match; + return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12, 0, 0)); +} + +function buildBanner(now, endDate) { + const startDate = formatLongDate(now); + const reopenDate = formatLongDate(endDate); + + return [ + MARKER_START, + "# 🏖️ OSS Weekend", + "", + `**Issue tracker reopens ${reopenDate}.**`, + "", + `OSS weekend runs ${startDate} through ${reopenDate}. New issues are auto-closed during this time. For support, join [Discord](${DISCORD_URL}).`, + MARKER_END, + "", + "---", + "", + "", + ].join("\n"); +} + +function upsertBanner(readme, now, endDate) { + const banner = buildBanner(now, endDate); + const bannerPattern = new RegExp( + `${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n\\n---\\n\\n?`, + "m", + ); + + if (bannerPattern.test(readme)) { + return readme.replace(bannerPattern, banner); + } + + return `${banner}${readme}`; +} + +function removeBanner(readme) { + const bannerPattern = new RegExp( + `^${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n\\n---\\n\\n?`, + "m", + ); + + return readme.replace(bannerPattern, ""); +} + +function parseReadmePaths(cliOptions) { + const readmeOption = getOption("readme", cliOptions, "OSS_WEEKEND_README_PATH", ""); + if (!readmeOption) return DEFAULT_README_PATHS; + + return readmeOption + .split(",") + .map((path) => path.trim()) + .filter(Boolean); +} + +function buildState(now, endDateInput, endDate) { + return JSON.stringify( + { + active: true, + mode: "weekend", + startsAt: now.toISOString(), + startsAtText: formatLongDate(now), + reopensOn: endDateInput, + reopensOnText: formatLongDate(endDate), + discordUrl: DISCORD_URL, + }, + null, + 2, + ); +} + +async function readOptionalFile(path) { + try { + return await readFile(path, "utf8"); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return null; + } + throw error; + } +} + +function printUsage() { + process.stdout.write( + [ + "Usage:", + " node scripts/oss-weekend.mjs --mode=close --end-date=2026-03-23", + " node scripts/oss-weekend.mjs --mode=open", + "", + "Options:", + " --mode=close|open Required. close enables OSS weekend mode. open disables it.", + " --end-date=YYYY-MM-DD Required for --mode=close.", + " --readme=PATHS Optional comma-separated README paths. Defaults to README.md,packages/coding-agent/README.md.", + " --state=PATH Optional state file path. Defaults to .github/oss-weekend.json.", + " --dry-run Preview without editing files.", + " --now=ISO Optional current timestamp override for testing.", + " --help Show this message.", + "", + ].join("\n"), + ); +} + +async function main() { + const cliOptions = parseArgs(process.argv.slice(2)); + + if (isTruthy(cliOptions.help ?? "false")) { + printUsage(); + return; + } + + const mode = getOption("mode", cliOptions, "OSS_WEEKEND_MODE", ""); + if (mode !== "close" && mode !== "open") { + throw new Error("--mode must be close or open."); + } + + const dryRun = isTruthy(getOption("dry-run", cliOptions, "OSS_WEEKEND_DRY_RUN", "false")); + const nowInput = getOption("now", cliOptions, "OSS_WEEKEND_NOW", ""); + const readmePaths = parseReadmePaths(cliOptions); + const statePath = getOption("state", cliOptions, "OSS_WEEKEND_STATE_PATH", DEFAULT_STATE_PATH); + const endDateInput = getOption("end-date", cliOptions, "OSS_WEEKEND_END_DATE", ""); + + const now = nowInput ? new Date(nowInput) : new Date(); + if (Number.isNaN(now.getTime())) { + throw new Error(`Invalid date: ${nowInput}`); + } + + if (mode === "close" && !endDateInput) { + throw new Error("--end-date is required when --mode=close."); + } + + const endDate = mode === "close" ? parseDateInput(endDateInput) : null; + const readmeResults = []; + + for (const readmePath of readmePaths) { + const currentReadme = await readFile(readmePath, "utf8"); + const nextReadme = mode === "close" ? upsertBanner(currentReadme, now, endDate) : removeBanner(currentReadme); + const changed = nextReadme !== currentReadme; + + if (changed && !dryRun) { + await writeFile(readmePath, nextReadme, "utf8"); + } + + readmeResults.push({ path: readmePath, changed }); + } + + const currentState = await readOptionalFile(statePath); + const nextState = mode === "close" ? buildState(now, endDateInput, endDate) : null; + const stateChanged = mode === "close" ? currentState !== nextState : currentState !== null; + + if (!dryRun) { + if (mode === "close") { + await writeFile(statePath, `${nextState}\n`, "utf8"); + } else { + await rm(statePath, { force: true }); + } + } + + const output = { + mode, + dry_run: dryRun ? "true" : "false", + weekend_active: mode === "close" ? "true" : "false", + readme_paths: readmeResults.map((result) => result.path).join(","), + readme_changed: readmeResults.some((result) => result.changed) ? "true" : "false", + readme_changed_paths: readmeResults + .filter((result) => result.changed) + .map((result) => result.path) + .join(","), + state_path: statePath, + state_changed: stateChanged ? "true" : "false", + end_date: endDate ? endDateInput : "", + end_date_text: endDate ? formatLongDate(endDate) : "", + now_utc: now.toISOString(), + now_berlin: new Intl.DateTimeFormat("sv-SE", { + timeZone: TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).format(now), + }; + + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + printUsage(); + process.exit(1); +});