diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml new file mode 100644 index 00000000..a5d9afba --- /dev/null +++ b/.github/workflows/star-history.yml @@ -0,0 +1,55 @@ +name: Star History + +# Regenerates the README star-history chart from the repo's own stargazer timeline +# and publishes it to the unprotected `star-history` branch, which the README embeds +# by raw URL. GitHub restricted the public stargazers API to a repo's own +# admins/collaborators (June 2026), which broke the hosted api.star-history.com badge. +# Running here with the default GITHUB_TOKEN reads our own stars, so there's no +# third-party service and no rate-limited shared token pool. See scripts/generate-star-history.mjs. + +on: + schedule: + - cron: "23 7 * * 1" # Mondays 07:23 UTC + workflow_dispatch: + +concurrency: + group: star-history + cancel-in-progress: true + +permissions: + contents: write + +jobs: + refresh: + name: Refresh chart + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Generate chart + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STAR_HISTORY_REPO: ${{ github.repository }} + STAR_HISTORY_OUT: ${{ runner.temp }}/star-history.svg + run: node scripts/generate-star-history.mjs + + - name: Publish to star-history branch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + work="$(mktemp -d)" + cp "${{ runner.temp }}/star-history.svg" "$work/star-history.svg" + cd "$work" + git init -q + git checkout -q -b star-history + git add star-history.svg + git -c user.name="SnapOtter" -c user.email="snapotter.hq@gmail.com" \ + commit -q -m "chore: refresh star history chart [skip ci]" + git push -q --force \ + "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + HEAD:star-history diff --git a/README.md b/README.md index 3aa5a875..6244a7d6 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,8 @@ If SnapOtter has replaced a paid subscription or two in your workflow, a small s

- - Star History Chart + + SnapOtter star history chart

diff --git a/scripts/generate-star-history.mjs b/scripts/generate-star-history.mjs new file mode 100644 index 00000000..df0ff06c --- /dev/null +++ b/scripts/generate-star-history.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +// Generates a self-contained star-history SVG for the README from the repo's own +// stargazer timeline. GitHub restricted the public stargazers API to a repo's own +// admins/collaborators (June 2026), which broke the hosted api.star-history.com badge +// for everyone. Running here with the repo's GITHUB_TOKEN reads its own stars, so no +// third-party service and no rate-limited shared token pool. Pure Node, no deps. +// +// Env: +// GITHUB_TOKEN / GH_TOKEN token with read access to the repo (Actions default works) +// STAR_HISTORY_REPO "owner/repo" (default snapotter-hq/SnapOtter) +// STAR_HISTORY_OUT output path (default branding/star-history.svg) + +import { writeFileSync } from "node:fs"; + +const REPO = process.env.STAR_HISTORY_REPO ?? "snapotter-hq/SnapOtter"; +const OUT = process.env.STAR_HISTORY_OUT ?? "branding/star-history.svg"; +const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ""; + +// Otter palette (dark theme), mirrored from apps/landing globals.css. +const COLOR = { + bg: "#1A1210", + panel: "#241a15", + grid: "#33271f", + line: "#E07832", + lineSoft: "#F09550", + text: "#F0EBE4", + muted: "#9a8b7d", +}; + +const WIDTH = 800; +const HEIGHT = 480; +const M = { top: 74, right: 30, bottom: 54, left: 70 }; +const plotW = WIDTH - M.left - M.right; +const plotH = HEIGHT - M.top - M.bottom; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const nf = new Intl.NumberFormat("en-US"); + +async function fetchStarredAt() { + if (!TOKEN) throw new Error("GITHUB_TOKEN (or GH_TOKEN) is required"); + const times = []; + for (let page = 1; page <= 400; page++) { + const url = `https://api.github.com/repos/${REPO}/stargazers?per_page=100&page=${page}`; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${TOKEN}`, + Accept: "application/vnd.github.star+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "snapotter-star-history", + }, + }); + if (!res.ok) { + throw new Error(`GitHub API ${res.status} ${res.statusText}: ${await res.text()}`); + } + const batch = await res.json(); + if (!Array.isArray(batch) || batch.length === 0) break; + for (const item of batch) { + if (item?.starred_at) times.push(Date.parse(item.starred_at)); + } + if (batch.length < 100) break; + } + times.sort((a, b) => a - b); + return times; +} + +// Loose "nice" number for axis ticks (Heckbert). +function niceNum(range, round) { + const exp = Math.floor(Math.log10(range)); + const frac = range / 10 ** exp; + let nice; + if (round) nice = frac < 1.5 ? 1 : frac < 3 ? 2 : frac < 7 ? 5 : 10; + else nice = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10; + return nice * 10 ** exp; +} + +function yTicks(max) { + const target = 5; + const spacing = niceNum(Math.max(max, 1) / (target - 1), true); + const niceMax = Math.ceil(max / spacing) * spacing; + const ticks = []; + for (let v = 0; v <= niceMax + 1e-9; v += spacing) ticks.push(Math.round(v)); + return { ticks, niceMax }; +} + +const fmtMonth = (d) => `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`; + +// Ticks on month boundaries, anchored at the first star, thinned to <= 8 for old +// repos. Avoids the duplicate "Apr 2026 / Apr 2026" that evenly-spaced ticks produce +// on a short span, and never crowds a label against either end. +function xTicks(min, max) { + const range = max - min; + const minGap = range * 0.06; + const first = new Date(min); + const ticks = [{ t: min, label: fmtMonth(first) }]; + let d = Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 1); + while (d <= max - minGap) { + const dd = new Date(d); + if (d - min >= minGap) ticks.push({ t: d, label: fmtMonth(dd) }); + d = Date.UTC(dd.getUTCFullYear(), dd.getUTCMonth() + 1, 1); + } + const maxTicks = 8; + if (ticks.length > maxTicks) { + const step = Math.ceil(ticks.length / maxTicks); + const thinned = ticks.filter((_, i) => i % step === 0); + const lastGen = ticks[ticks.length - 1]; + if (thinned[thinned.length - 1] !== lastGen) thinned.push(lastGen); + return thinned; + } + return ticks; +} + +// Even-index sample of the cumulative curve so the SVG stays small and smooth. +function samplePoints(times, maxPoints) { + const n = times.length; + const pts = []; + if (n === 0) return pts; + const step = Math.max(1, Math.ceil(n / maxPoints)); + for (let i = 0; i < n; i += step) pts.push({ t: times[i], y: i + 1 }); + pts.push({ t: times[n - 1], y: n }); // real last star + pts.push({ t: Date.now(), y: n }); // extend the line to today + return pts; +} + +function esc(s) { + return String(s).replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c]); +} + +function render(times) { + const n = times.length; + const xMin = times[0]; + const xMax = Date.now(); + const { ticks: yt, niceMax } = yTicks(n); + const xt = xTicks(xMin, xMax); + const pts = samplePoints(times, 60); + + const xPx = (t) => M.left + ((t - xMin) / (xMax - xMin)) * plotW; + const yPx = (v) => M.top + plotH - (v / niceMax) * plotH; + const baseline = M.top + plotH; + + const linePts = pts.map((p) => `${xPx(p.t).toFixed(1)},${yPx(p.y).toFixed(1)}`).join(" "); + const areaD = + `M ${xPx(pts[0].t).toFixed(1)},${baseline.toFixed(1)} ` + + pts.map((p) => `L ${xPx(p.t).toFixed(1)},${yPx(p.y).toFixed(1)}`).join(" ") + + ` L ${xPx(pts[pts.length - 1].t).toFixed(1)},${baseline.toFixed(1)} Z`; + + const gridLines = yt + .map((v) => { + const y = yPx(v).toFixed(1); + return ( + `` + + `${nf.format(v)}` + ); + }) + .join("\n "); + + const xLabels = xt + .map((tk) => { + const x = xPx(tk.t).toFixed(1); + return `${tk.label}`; + }) + .join("\n "); + + const last = pts[pts.length - 1]; + const lastX = xPx(last.t); + const lastY = yPx(last.y); + const fontStack = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"; + + return ` + + + + + + + + ${esc(REPO)} + Star history + + + GitHub stars + + + ${gridLines} + + + + + ${nf.format(n)} + + ${xLabels} + + +`; +} + +const times = await fetchStarredAt(); +if (times.length === 0) throw new Error(`No stargazers returned for ${REPO}`); +const svg = render(times); +writeFileSync(OUT, svg); +console.log(`Wrote ${OUT} — ${times.length} stars, ${(svg.length / 1024).toFixed(1)} KiB`);