mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-07-09 17:28:45 +00:00
chore: add OSS weekend gating
This commit is contained in:
parent
78d184447e
commit
572876be1e
5 changed files with 437 additions and 346 deletions
220
.github/scripts/oss-weekend.mjs
vendored
220
.github/scripts/oss-weekend.mjs
vendored
|
|
@ -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 = "<!-- OSS_WEEKEND_START -->";
|
||||
const MARKER_END = "<!-- OSS_WEEKEND_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);
|
||||
});
|
||||
98
.github/workflows/oss-weekend-issues.yml
vendored
Normal file
98
.github/workflows/oss-weekend-issues.yml
vendored
Normal file
|
|
@ -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',
|
||||
});
|
||||
88
.github/workflows/oss-weekend.yml
vendored
88
.github/workflows/oss-weekend.yml
vendored
|
|
@ -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 }}"
|
||||
118
.github/workflows/pr-gate.yml
vendored
118
.github/workflows/pr-gate.yml
vendored
|
|
@ -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);
|
||||
|
|
|
|||
259
scripts/oss-weekend.mjs
Normal file
259
scripts/oss-weekend.mjs
Normal file
|
|
@ -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 = "<!-- OSS_WEEKEND_START -->";
|
||||
const MARKER_END = "<!-- OSS_WEEKEND_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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue