/** * The `/changelog` page's data: every timever release apps/cli/release-topics.ts * published — one per finished topic and per finished `Review ` issue — * read from the GitHub API at build time (pages/changelog.astro awaits it). * * One page, no per-release subpages: each release becomes one timeline card, * so the fields come from the release itself rather than a staged content * entry. The date is the timever tag (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — the * instant the last required problem closed), never `published_at`, which the * API cannot backdate and which reads the backfill day for every old release. * The problems are the rows of the body's `### Closed …` tables, the shape * release-topics.ts's render() writes: `| #issue | LC n · Name | set | diff | * closed | [py](…) · [docs](…) |`. Only `### Closed …` sections are read, so * a release published before open problems were dropped from the body still * shows only what finished. The row's own `[docs]` link is not used: it is * frozen at release time (a problem solved before the docs existed has * none), while the page links each problem by its LC number to its page now. * * Auth: GITHUB_TOKEN when set (deploy.yml passes it), else unauthenticated — * one request per 100 releases fits the anonymous limit. A failed request * fails the build: a changelog silently missing its releases is worse. */ const REPO = "prdlk/leetcode"; const MARKER = //u; const TIMEVER = /^(\d{4})(\d{2})(\d{2})\.(\d{2})(\d{2})(\d{2})\.\d{9}$/u; const KIND = /^(Topic|Review) \d+ — /u; export interface ReleaseProblem { issue: number; /** The LeetCode number, the key of the docs `/` short links. */ lc: number; /** `LC 57 · Insert Interval`. */ title: string; set: string; /** `easy` / `medium` / `hard`, the problem's `diff:*` label. */ difficulty: string; } export interface Release { /** Topic or Review issue the release records. */ issue: number; name: string; kind: "Topic" | "Review"; /** UTC instant the last required problem closed, from the timever tag. */ finishedAt: Date; url: string; problems: ReleaseProblem[]; } /** Rows of every `### Closed …` table in a release body. */ function closedProblems(body: string): ReleaseProblem[] { const problems: ReleaseProblem[] = []; let closed = false; for (const line of body.split("\n")) { if (line.startsWith("### ")) closed = line.startsWith("### Closed"); if (!closed || !line.startsWith("| #")) continue; const [issue, title, set, difficulty] = line.slice(1, -1).split("|").map((cell) => cell.trim()); const lc = title?.match(/^LC (\d+) · /u)?.[1]; if (!issue || !title || !lc || !set || !difficulty) continue; problems.push({ issue: Number(issue.slice(1)), lc: Number(lc), title, set, difficulty }); } return problems; } export async function readReleases(): Promise { const headers: Record = { accept: "application/vnd.github+json" }; if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`; const releases: Release[] = []; for (let page = 1; ; page++) { const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`, { headers }); if (!res.ok) throw new Error(`GET ${REPO} releases -> ${res.status} ${await res.text()}`); const batch = (await res.json()) as { tag_name: string; name: string | null; body: string | null; html_url: string }[]; for (const r of batch) { const issue = r.body?.match(MARKER)?.[1]; const t = r.tag_name.match(TIMEVER); const kind = r.name?.match(KIND)?.[1]; if (!issue || !t || !kind || !r.name || !r.body) continue; releases.push({ issue: Number(issue), name: r.name, kind: kind as Release["kind"], finishedAt: new Date(`${t[1]}-${t[2]}-${t[3]}T${t[4]}:${t[5]}:${t[6]}Z`), url: r.html_url, problems: closedProblems(r.body), }); } if (batch.length < 100) break; } return releases.sort((a, b) => b.finishedAt.getTime() - a.finishedAt.getTime()); }