Files
roadmap/.github/scripts/storage-weekly.mjs
E M ae8d788d6c Workflow now creates PR instead of Claude
Moving the PR creation to the workflow and using a classic PAT with repo scope should hopefully allow the PR to be created. Previously, using a fine-grained PAT required pull-requests: write permissions on logos-co/roadmap which needed to be approved.
2026-08-13 14:09:49 +10:00

547 lines
21 KiB
JavaScript

#!/usr/bin/env node
/**
* Logos storage weekly update — deterministic worker.
*
* Runs inside GitHub Actions on logos-storage/roadmap (see .github/workflows/storage-weekly.yml).
* Everything judgement-free lives here; the claude.ai routine that dispatches this
* workflow writes the Highlights bullets afterwards (see .github/storage-weekly-routine.md).
*
* Steps (mirroring the original routine):
* 1. Establish the reported week (the ISO week before the current one, UTC).
* 2. Find last week's HackMD note BY TITLE (exact, then tolerant fallback).
* Guard: stop if the note is an unfilled skeleton.
* 3. Branch off upstream logos-co/roadmap v5, generate the update file, push to fork.
* Sections reporting nothing (bare achieved/next labels only) are dropped.
* Guards: stop if the branch or an open upstream PR already exists.
* 4. Create the upstream PR (fork → upstream) with PAT_PR; best-effort, falls back to
* the one-click compare URL if it fails.
* 5. Emit pr_url / compare_url + facts to $GITHUB_OUTPUT / $GITHUB_STEP_SUMMARY.
* 6. Create the upcoming week's skeleton HackMD note (no-op if it already exists).
*
* Env:
* HACKMD_TOKEN (required) HackMD user token — never printed.
* PAT_PR (optional) used for the upstream open-PR check and to create the upstream PR
* — must have pull-requests: write on logos-co/roadmap.
* DRY_RUN 'true' to skip the git push, the PR creation and the HackMD note creation.
*
* Exit codes: 0 = success; 1 = guard stop or error (status in $GITHUB_OUTPUT).
*/
import { execFileSync } from 'node:child_process';
import { appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
const HACKMD_BASE = 'https://api.hackmd.io/v1';
const TEAM_PATH = 'codex-storage';
const WEEKLY_FOLDER_ID = 'd6af4a7d-f414-4301-9859-5554b64e181c'; // "Weekly updates"
const UPSTREAM = 'logos-co/roadmap';
const FORK = 'logos-storage/roadmap';
const BASE_BRANCH = 'v5';
const UPDATES_DIR = 'content/storage/updates';
const NOTE_SUFFIX = 'Logos storage weekly';
const HIGHLIGHTS_MARKER = 'HIGHLIGHTS-PENDING';
// ---------------------------------------------------------------------------
// Date helpers (all UTC)
// ---------------------------------------------------------------------------
export const DAY_MS = 24 * 60 * 60 * 1000;
/** Monday 00:00 UTC of the ISO week containing `date`. */
export function mondayOfISOWeek(date) {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const dow = (d.getUTCDay() + 6) % 7; // Mon=0 .. Sun=6
return new Date(d.getTime() - dow * DAY_MS);
}
/** ISO week number and ISO week-year for `date`. */
export function isoWeek(date) {
const monday = mondayOfISOWeek(date);
const thursday = new Date(monday.getTime() + 3 * DAY_MS);
const isoYear = thursday.getUTCFullYear();
const jan4 = new Date(Date.UTC(isoYear, 0, 4));
const week1Monday = mondayOfISOWeek(jan4);
const week = Math.round((monday.getTime() - week1Monday.getTime()) / (7 * DAY_MS)) + 1;
return { week, isoYear };
}
export function addDays(date, days) {
return new Date(date.getTime() + days * DAY_MS);
}
/** YYYY-MM-DD */
export function isoDate(date) {
return date.toISOString().slice(0, 10);
}
/** "Jun 22" — short month, no zero-padded day (matches existing update files). */
export function humanDate(date) {
const month = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
return `${month} ${date.getUTCDate()}`;
}
/**
* All facts derived from "today":
* reported week = the ISO week that ended most recently (the week before today's).
*/
export function weekFacts(today) {
const currentMonday = mondayOfISOWeek(today);
const reportedMonday = addDays(currentMonday, -7);
const reportedFriday = addDays(reportedMonday, 4);
const { week, isoYear } = isoWeek(reportedMonday);
const publishMonday = addDays(reportedMonday, 7); // filename/title date
const upcoming = isoWeek(currentMonday);
return {
reported: {
week,
isoYear,
monday: reportedMonday,
friday: reportedFriday,
publishDate: isoDate(publishMonday),
heading: weekHeading(week, isoYear, reportedMonday, reportedFriday),
noteTitle: `${isoDate(publishMonday)} ${NOTE_SUFFIX}`,
branch: `weeklies/storage-weekly-week-${String(week).padStart(2, '0')}`,
fileName: `${isoDate(publishMonday)}.md`,
},
upcoming: {
week: upcoming.week,
isoYear: upcoming.isoYear,
monday: currentMonday,
friday: addDays(currentMonday, 4),
publishDate: isoDate(addDays(currentMonday, 7)),
heading: weekHeading(upcoming.week, upcoming.isoYear, currentMonday, addDays(currentMonday, 4)),
noteTitle: `${isoDate(addDays(currentMonday, 7))} ${NOTE_SUFFIX}`,
},
};
}
export function weekHeading(week, year, monday, friday) {
return `## Week ${week} ${year} (Mon ${humanDate(monday)} - Fri ${humanDate(friday)}, ${year})`;
}
// ---------------------------------------------------------------------------
// HackMD API
// ---------------------------------------------------------------------------
async function hackmd(path, options = {}) {
const res = await fetch(`${HACKMD_BASE}${path}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.HACKMD_TOKEN}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!res.ok) {
throw new Error(`HackMD ${options.method ?? 'GET'} ${path} failed: HTTP ${res.status}`);
}
return res.json();
}
/**
* Find a weekly note by title: exact match first, then tolerant fallback
* (title contains both the date and the weekly suffix — stray prefixes happen).
* Returns { note, fuzzy } or { error } when zero/ambiguous.
*/
export function findNoteByTitle(notes, title, dateStr) {
const exact = notes.filter((n) => n.title === title);
if (exact.length === 1) return { note: exact[0], fuzzy: false };
if (exact.length > 1) return { error: `multiple notes titled exactly "${title}"`, ambiguous: true };
const fuzzy = notes.filter((n) => n.title?.includes(dateStr) && n.title?.includes(NOTE_SUFFIX));
if (fuzzy.length === 1) return { note: fuzzy[0], fuzzy: true };
if (fuzzy.length === 0) return { error: `no note matching "${title}"`, none: true };
return { error: `ambiguous: ${fuzzy.length} notes match date ${dateStr} + "${NOTE_SUFFIX}"`, ambiguous: true };
}
export function noteUrl(note) {
return note.publishLink || `https://hackmd.io/${note.id}`;
}
/**
* Guard: a note counts as filled when at least one achieved/next label has an
* indented sub-bullet beneath it.
*/
export function noteIsFilled(markdown) {
let underLabel = false;
for (const line of markdown.split('\n')) {
if (/^[-*]\s*\*\*\s*(achieved|next)\s*:?\s*\*\*/i.test(line)) {
underLabel = true;
continue;
}
if (underLabel && /^\s+[-*+]\s+\S/.test(line)) return true;
if (/^\S/.test(line)) underLabel = false;
}
return false;
}
// ---------------------------------------------------------------------------
// Markdown transforms
// ---------------------------------------------------------------------------
/** Body of the note after its `## Week ...` heading (frontmatter + heading dropped). */
export function noteBody(markdown) {
const lines = markdown.split('\n');
const headingIdx = lines.findIndex((l) => /^##\s/.test(l) && !/^###/.test(l));
if (headingIdx === -1) throw new Error('note has no "## Week ..." heading');
return lines.slice(headingIdx + 1).join('\n').replace(/^\n+/, '');
}
/**
* Normalise labels to exactly `- **achieved:**` / `- **next:**` (colon inside
* the bold, none outside). Only rewrites labels that exist — never invents them.
*/
export function normalizeLabels(markdown) {
return markdown.replace(
/^([ \t]*)[-*][ \t]*\*\*[ \t]*(achieved|next)[ \t]*:?[ \t]*\*\*[ \t]*:?/gim,
(_, indent, label) => `${indent}- **${label.toLowerCase()}:**`
);
}
/** Drop an existing `### Highlights` section (up to the next heading), if any. */
export function stripHighlights(markdown) {
return markdown.replace(/^### Highlights\s*\n[\s\S]*?(?=^#{2,3}\s)/m, '');
}
/** A line carrying an achieved/next label and nothing else. */
const BARE_LABEL = /^[ \t]*[-*+][ \t]*\*\*[ \t]*(?:achieved|next)[ \t]*:?[ \t]*\*\*[ \t]*:?[ \t\r]*$/i;
/**
* Drop `###` sections that report nothing: their only content is bare
* achieved/next labels with no work items beneath. The team leaves a section
* blank when there was no movement that week, and blank sections are noise in
* the published update. A section keeps everything up to the next heading of
* level 3 or shallower, so `####` sub-headings count as content.
*
* Everything that survives is passed through verbatim: a section runs up to
* the next heading, so it carries its own trailing blank line away with it and
* the seam needs no tidying.
*/
export function stripEmptySections(markdown) {
const lines = markdown.split('\n');
const kept = [];
for (let i = 0; i < lines.length; ) {
if (!/^###[ \t]/.test(lines[i])) {
kept.push(lines[i++]);
continue;
}
let end = i + 1;
while (end < lines.length && !/^#{1,3}[ \t]/.test(lines[end])) end++;
const hasContent = lines
.slice(i + 1, end)
.some((l) => l.trim() !== '' && !BARE_LABEL.test(l));
if (hasContent) kept.push(...lines.slice(i, end));
i = end;
}
return kept.join('\n');
}
/** The complete update file for the repo. */
export function buildUpdateFile(facts, body, sourceUrl) {
const r = facts.reported;
return `---
title: "${r.publishDate} ${NOTE_SUFFIX}"
tags:
- "storage-updates"
date: ${isoDate(r.monday)}
draft: false
---
${r.heading}
### Highlights
<!-- ${HIGHLIGHTS_MARKER} source: ${sourceUrl} -->
${stripEmptySections(normalizeLabels(stripHighlights(body))).trim()}
`;
}
/** Blank skeleton note for the upcoming week, from the reported note's sections. */
export function buildSkeleton(facts, reportedNoteContent) {
const u = facts.upcoming;
const sections = reportedNoteContent
.split('\n')
.filter((l) => /^###\s+\[/.test(l));
if (sections.length === 0) throw new Error('no "### [Section](url)" headings found in source note');
const body = sections
.map((h) => `${h}\n\n- **achieved:**\n- **next:**`)
.join('\n\n');
return `---
title: "${u.publishDate} ${NOTE_SUFFIX}"
tags:
- "storage-updates"
date: ${isoDate(u.monday)}
draft: false
---
${u.heading}
${body}
`;
}
// ---------------------------------------------------------------------------
// PR body / compare URL
// ---------------------------------------------------------------------------
export function prTitle(facts) {
const r = facts.reported;
return `docs(storage): ${r.publishDate} weekly`;
}
export function prBody(facts, sourceUrl) {
const r = facts.reported;
return [
`Weekly storage update for week ${r.week} ${r.isoYear} (Mon ${humanDate(r.monday)} - Fri ${humanDate(r.friday)}, ${r.isoYear}).`,
'',
`Source note: ${sourceUrl}`,
'',
'⚠️ The Highlights section was generated and needs review before merge.',
'',
'/cc @gmega — please review.',
].join('\n');
}
export function compareUrl(facts, sourceUrl) {
const r = facts.reported;
return (
`https://github.com/${UPSTREAM}/compare/${BASE_BRANCH}...logos-storage:roadmap:${encodeURIComponent(r.branch)}` +
`?expand=1&title=${encodeURIComponent(prTitle(facts))}&body=${encodeURIComponent(prBody(facts, sourceUrl))}`
);
}
/**
* The cross-repo pull request payload. The API `head` is "owner:branch" (not the
* compare URL's owner:repo:branch triple).
*/
export function buildPrPayload(facts, sourceUrl) {
return {
title: prTitle(facts),
body: prBody(facts, sourceUrl),
head: `${FORK.split('/')[0]}:${facts.reported.branch}`,
base: BASE_BRANCH,
};
}
/**
* Create the PR in the upstream repo. Returns the created PR JSON, or
* { exists: true } when GitHub rejects the create because a PR already exists
* for the branch (a race between the open-PR guard and this call).
*/
async function createUpstreamPr(payload) {
const res = await fetch(`https://api.github.com/repos/${UPSTREAM}/pulls`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAT_PR}`,
Accept: 'application/vnd.github+json',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (res.status === 422) return { exists: true };
if (!res.ok) {
throw new Error(`GitHub create PR failed: HTTP ${res.status} ${await res.text()}`);
}
return res.json();
}
// ---------------------------------------------------------------------------
// Git / GitHub helpers
// ---------------------------------------------------------------------------
function git(...args) {
return execFileSync('git', args, { encoding: 'utf8' }).trim();
}
function forkBranchExists(branch) {
return git('ls-remote', 'origin', `refs/heads/${branch}`) !== '';
}
async function upstreamOpenPr(branch) {
const headers = { Accept: 'application/vnd.github+json' };
if (process.env.PAT_PR) headers.Authorization = `Bearer ${process.env.PAT_PR}`;
const res = await fetch(
`https://api.github.com/repos/${UPSTREAM}/pulls?state=open&head=logos-storage:${encodeURIComponent(branch)}`,
{ headers }
);
if (!res.ok) throw new Error(`GitHub PR check failed: HTTP ${res.status}`);
const prs = await res.json();
return prs.length > 0 ? prs[0].html_url : null;
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
function setOutput(key, value) {
if (!process.env.GITHUB_OUTPUT) return;
const delim = `EOF_${key}_${value.length}`;
appendFileSync(process.env.GITHUB_OUTPUT, `${key}<<${delim}\n${value}\n${delim}\n`);
}
function summary(markdown) {
console.log(markdown);
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${markdown}\n`);
}
}
/** Guard stop: record a machine-readable status, summarise, exit non-zero. */
function stop(status, message) {
setOutput('status', status);
summary(`## ⛔ Stopped: \`${status}\`\n\n${message}`);
process.exit(1);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
if (!process.env.HACKMD_TOKEN) throw new Error('HACKMD_TOKEN is not set');
const dryRun = process.env.DRY_RUN === 'true';
const warnings = [];
// Step 1 — establish the reported week
// FAKE_TODAY (YYYY-MM-DD) is for local dry-run testing only — never set in CI.
const facts = weekFacts(process.env.FAKE_TODAY ? new Date(`${process.env.FAKE_TODAY}T12:00:00Z`) : new Date());
const r = facts.reported;
summary(`# Storage weekly — week ${r.week} ${r.isoYear}${dryRun ? ' (DRY RUN)' : ''}`);
summary(`Reported week: Mon ${isoDate(r.monday)} - Fri ${isoDate(r.friday)} · file \`${r.fileName}\` · branch \`${r.branch}\``);
// Step 2 — find last week's HackMD note by title
const notes = await hackmd(`/teams/${TEAM_PATH}/notes`);
const found = findNoteByTitle(notes, r.noteTitle, r.publishDate);
if (found.error) stop('stopped:note-not-found', `Looking for "${r.noteTitle}": ${found.error}`);
if (found.fuzzy) {
warnings.push(`⚠️ Title fallback match used: note is titled "${found.note.title}", expected "${r.noteTitle}".`);
}
const sourceUrl = noteUrl(found.note);
const note = await hackmd(`/teams/${TEAM_PATH}/notes/${found.note.id}`);
if (!noteIsFilled(note.content)) {
stop('stopped:empty-note', `The note "${found.note.title}" (${sourceUrl}) has no sub-bullets under any achieved/next label — the team has not filled it in yet.`);
}
// Step 3 — guards, then generate the update file on a branch off upstream v5
if (forkBranchExists(r.branch)) {
stop('stopped:duplicate-branch', `Branch \`${r.branch}\` already exists on ${FORK}.`);
}
const openPr = await upstreamOpenPr(r.branch);
if (openPr) {
stop('stopped:duplicate-pr', `An open upstream PR already exists for \`${r.branch}\`: ${openPr}`);
}
const fileContent = buildUpdateFile(facts, noteBody(note.content), sourceUrl);
git('fetch', `https://github.com/${UPSTREAM}.git`, BASE_BRANCH);
// Guard: the update may already be merged upstream (branch deleted, PR closed) —
// e.g. a manual re-run after the weekly PR landed. Don't propose a duplicate.
let alreadyPublished = true;
try {
git('cat-file', '-e', `FETCH_HEAD:${UPDATES_DIR}/${r.fileName}`);
} catch {
alreadyPublished = false;
}
if (alreadyPublished) {
stop('stopped:already-published', `\`${UPDATES_DIR}/${r.fileName}\` already exists on ${UPSTREAM} \`${BASE_BRANCH}\` — week ${r.week}'s update has been published.`);
}
git('checkout', '-B', r.branch, 'FETCH_HEAD');
mkdirSync(UPDATES_DIR, { recursive: true });
const filePath = `${UPDATES_DIR}/${r.fileName}`;
writeFileSync(filePath, fileContent);
git('add', filePath);
git('-c', 'user.name=github-actions[bot]',
'-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com',
'-c', 'commit.gpgsign=false', // local dry-runs may have a signing agent configured
'commit', '-m', `docs(storage): ${r.publishDate} weekly update\n\nSource-Note: ${sourceUrl}`);
if (dryRun) {
summary('🧪 DRY RUN — skipping `git push`.');
} else {
git('push', 'origin', r.branch);
summary(`✅ Pushed \`${r.branch}\` to ${FORK} with \`${filePath}\`.`);
}
// Step 4 — create the upstream PR (fork → upstream). Best-effort like the skeleton
// note: a failure here must not blank the facts the routine reports from — the
// compare URL below remains a working fallback.
let prUrl = '';
if (dryRun) {
summary('🧪 DRY RUN — skipping PR creation.');
} else if (!process.env.PAT_PR) {
warnings.push('⚠️ PR creation skipped — PAT_PR is not set (the routine can still open the PR via the compare URL).');
} else {
try {
const created = await createUpstreamPr(buildPrPayload(facts, sourceUrl));
if (created.exists) {
const existing = await upstreamOpenPr(r.branch);
if (existing) {
prUrl = existing;
warnings.push(`⚠️ PR already exists for \`${r.branch}\` — using it: ${prUrl}`);
} else {
warnings.push('⚠️ PR creation rejected as a duplicate, but no open PR was found for the branch.');
}
} else {
prUrl = created.html_url;
summary(`✅ Created PR: ${prUrl}`);
}
} catch (err) {
warnings.push(`⚠️ PR creation failed (branch push was unaffected): ${err.message} — open it manually via the compare URL.`);
}
}
// Step 5 — surface the PR / one-click compare URL and record success outputs.
// Outputs are written BEFORE the best-effort skeleton step so a HackMD
// failure there cannot blank the facts the routine reports from.
const url = compareUrl(facts, sourceUrl);
if (prUrl) summary(`## Pull request\n\n${prUrl}`);
summary(`## One-click PR URL\n\n${url}`);
setOutput('status', 'success');
setOutput('branch', r.branch);
setOutput('file', filePath);
setOutput('week', `${r.week}`);
setOutput('pr_url', prUrl);
setOutput('compare_url', url);
setOutput('source_note_url', sourceUrl);
// Step 6 — create the upcoming week's skeleton note (often a no-op; best-effort)
const u = facts.upcoming;
const existing = findNoteByTitle(notes, u.noteTitle, u.publishDate);
let newNoteUrl = '';
try {
if (existing.note) {
summary(`Upcoming week's note already exists ("${existing.note.title}") — skipping creation.`);
newNoteUrl = noteUrl(existing.note);
} else if (existing.ambiguous) {
warnings.push(`⚠️ Skipping skeleton creation — ${existing.error}. Creating another would add a duplicate.`);
} else if (dryRun) {
summary(`🧪 DRY RUN — would create skeleton note "${u.noteTitle}".`);
} else {
const created = await hackmd(`/teams/${TEAM_PATH}/notes`, {
method: 'POST',
body: JSON.stringify({
title: u.noteTitle,
content: buildSkeleton(facts, note.content),
parentFolderId: WEEKLY_FOLDER_ID,
readPermission: 'signed_in',
writePermission: 'owner',
}),
});
newNoteUrl = noteUrl(created);
summary(`📝 Created upcoming week's skeleton note: ${newNoteUrl}\n(folder placement cannot be confirmed via the read API — assumed "Weekly updates")`);
}
} catch (err) {
warnings.push(`⚠️ Skeleton note creation failed (branch push was unaffected): ${err.message}`);
}
setOutput('new_note_url', newNoteUrl);
if (warnings.length) summary(`## Warnings\n\n${warnings.join('\n')}`);
setOutput('warnings', warnings.join('\n'));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((err) => {
setOutput('status', 'error');
summary(`## ❌ Error\n\n\`\`\`\n${err.message}\n\`\`\``);
process.exit(1);
});
}