fix: replace broken star-history.com chart with a self-generated one (#156)

* fix: replace broken star-history.com chart with a self-generated one

The chart in the README rendered as a broken image. The cause is upstream,
not our URL: api.star-history.com returns 404 for this repo and 500 for
facebook/react, so their API is failing generally. Every parameter variant
I tried returned 404.

Swapping to a different third-party chart service would just relocate the
same dependency, so this generates the chart from the GitHub API instead -
data we already own - and commits the SVG into the repo. The README now
points at a local file that cannot 404.

Rebuilding the curve does not need all 35k stargazers: requesting
per_page=1&page=N returns exactly the Nth one, so 40 sampled points
describe the shape just as well. That is ~40 API calls rather than ~350.

The SVG carries a prefers-color-scheme block so it reads correctly in both
GitHub themes, which the old two-source picture element never did - both
its sources pointed at the same URL.

Regenerates weekly and commits only when the chart actually changes.
Ships with a self-check covering axis scaling, monotonicity, frame bounds
and the zero-star case; CI runs it before every regeneration.

Signed-off-by: ashishpatel26 <3095771+ashishpatel26@users.noreply.github.com>

* fix: scope the star+json Accept header to the stargazers endpoint only

Sourcery flagged this in review. application/vnd.github.star+json is only
documented for the stargazers endpoint - it is what makes starred_at
appear in the response. Sending it on /repos/{repo} too worked in testing,
but relies on undocumented tolerance rather than the documented contract,
and a future GitHub API change could break the metadata request for no
reason related to what that header is for.

Signed-off-by: ashishpatel26 <3095771+ashishpatel26@users.noreply.github.com>

---------

Signed-off-by: ashishpatel26 <3095771+ashishpatel26@users.noreply.github.com>
Co-authored-by: ashishpatel26 <3095771+ashishpatel26@users.noreply.github.com>
This commit is contained in:
Ashish Patel
2026-07-25 14:01:55 +05:30
committed by GitHub
parent 35e6670cdc
commit a1e05e2ab6
4 changed files with 295 additions and 14 deletions

44
.github/workflows/star-history.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Star History
on:
schedule:
- cron: '30 2 * * 1' # Mondays, 02:30 UTC
workflow_dispatch:
permissions:
contents: write
concurrency:
group: star-history
cancel-in-progress: true
jobs:
regenerate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Verify the generator still works
run: node scripts/star-history.mjs --self-check
- name: Regenerate chart
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/star-history.mjs "$GITHUB_REPOSITORY" images/star-history.svg
- name: Commit if the chart changed
run: |
if git diff --quiet -- images/star-history.svg; then
echo "No change in star history, nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add images/star-history.svg
# Signed off so the DCO check stays satisfied on any PR that includes this.
git commit -s -m "chore: refresh star history chart [skip ci]"
git push

View File

@@ -296,20 +296,9 @@ See [CONTRIBUTION.md](CONTRIBUTION.md) for full requirements (metadata.yaml, req
## Star History
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="https://api.star-history.com/svg?repos=ashishpatel26/500-AI-Agents-Projects&type=date&legend=top-left"
/>
<source
media="(prefers-color-scheme: light)"
srcset="https://api.star-history.com/svg?repos=ashishpatel26/500-AI-Agents-Projects&type=date&legend=top-left"
/>
<img
alt="Star History Chart"
src="https://api.star-history.com/svg?repos=ashishpatel26/500-AI-Agents-Projects&type=date&legend=top-left"
/>
</picture>
[![Star History Chart](images/star-history.svg)](https://star-history.com/#ashishpatel26/500-AI-Agents-Projects&Date)
<sub>Regenerated weekly from the GitHub API by [`.github/workflows/star-history.yml`](.github/workflows/star-history.yml). Click the chart for the interactive version.</sub>
---

34
images/star-history.svg Normal file
View File

@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="400" viewBox="0 0 800 400" role="img" aria-label="Star history for ashishpatel26/500-AI-Agents-Projects">
<style>
.bg{fill:#ffffff} .grid{stroke:#d8dee4;stroke-width:1}
.lbl{fill:#57606a;font:12px -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif}
.ttl{fill:#1f2328;font:600 14px -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif}
.ln{fill:none;stroke:#ffc107;stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}
.ar{fill:#ffc107;opacity:.15}
@media (prefers-color-scheme: dark){
.bg{fill:#0d1117} .grid{stroke:#30363d} .lbl{fill:#8b949e} .ttl{fill:#e6edf3}
}
</style>
<rect width="800" height="400" class="bg"/>
<line x1="70" y1="350.0" x2="770" y2="350.0" class="grid"/>
<line x1="70" y1="286.0" x2="770" y2="286.0" class="grid"/>
<line x1="70" y1="222.0" x2="770" y2="222.0" class="grid"/>
<line x1="70" y1="158.0" x2="770" y2="158.0" class="grid"/>
<line x1="70" y1="94.0" x2="770" y2="94.0" class="grid"/>
<line x1="70" y1="30.0" x2="770" y2="30.0" class="grid"/>
<polygon points="70,350 70.0,350.0 265.7,344.2 327.4,338.5 335.4,332.7 336.8,326.9 341.9,321.2 371.3,315.4 375.8,309.6 377.3,303.9 378.3,298.1 379.8,292.3 384.5,286.6 388.8,280.8 395.1,275.0 403.9,269.3 421.8,263.5 446.2,257.7 469.0,252.0 476.1,246.2 489.2,240.4 491.6,234.7 494.2,228.9 497.9,223.1 510.4,217.4 525.7,211.6 551.0,205.9 567.9,200.1 589.2,194.3 600.4,188.6 612.0,182.8 625.8,177.0 643.5,171.3 662.5,165.5 674.0,159.7 689.3,154.0 708.9,148.2 723.1,142.4 741.6,136.7 755.7,130.9 770.0,125.1 770.0,350" class="ar"/>
<polyline points="70.0,350.0 265.7,344.2 327.4,338.5 335.4,332.7 336.8,326.9 341.9,321.2 371.3,315.4 375.8,309.6 377.3,303.9 378.3,298.1 379.8,292.3 384.5,286.6 388.8,280.8 395.1,275.0 403.9,269.3 421.8,263.5 446.2,257.7 469.0,252.0 476.1,246.2 489.2,240.4 491.6,234.7 494.2,228.9 497.9,223.1 510.4,217.4 525.7,211.6 551.0,205.9 567.9,200.1 589.2,194.3 600.4,188.6 612.0,182.8 625.8,177.0 643.5,171.3 662.5,165.5 674.0,159.7 689.3,154.0 708.9,148.2 723.1,142.4 741.6,136.7 755.7,130.9 770.0,125.1" class="ln"/>
<text x="60" y="354.0" class="lbl" text-anchor="end">0</text>
<text x="60" y="290.0" class="lbl" text-anchor="end">10k</text>
<text x="60" y="226.0" class="lbl" text-anchor="end">20k</text>
<text x="60" y="162.0" class="lbl" text-anchor="end">30k</text>
<text x="60" y="98.0" class="lbl" text-anchor="end">40k</text>
<text x="60" y="34.0" class="lbl" text-anchor="end">50k</text>
<text x="70.0" y="372" class="lbl" text-anchor="middle">Dec 2024</text>
<text x="245.0" y="372" class="lbl" text-anchor="middle">May 2025</text>
<text x="420.0" y="372" class="lbl" text-anchor="middle">Oct 2025</text>
<text x="595.0" y="372" class="lbl" text-anchor="middle">Mar 2026</text>
<text x="770.0" y="372" class="lbl" text-anchor="middle">Jul 2026</text>
<text x="70" y="20" class="ttl">ashishpatel26/500-AI-Agents-Projects &#183; 35,136 stars</text>
<text x="770" y="20" class="lbl" text-anchor="end">updated 25 Jul 2026</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

214
scripts/star-history.mjs Normal file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env node
/**
* Generate a star-history SVG from the GitHub API.
*
* star-history.com's public API started returning 404/500, which left a broken
* image in the README. This renders the same chart from data we already own.
*
* Reconstructing the curve does not need all N stargazers: asking for
* `per_page=1&page=N` returns exactly the Nth one, so a few dozen sampled
* points describe the shape as well as tens of thousands would. That keeps this
* to ~40 API calls instead of ~350.
*
* GITHUB_TOKEN=... node scripts/star-history.mjs owner/repo images/star-history.svg
* node scripts/star-history.mjs --self-check
*/
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { pathToFileURL } from 'node:url';
const API = 'https://api.github.com';
const SAMPLES = 40;
const W = 800, H = 400;
const PAD_L = 70, PAD_R = 30, PAD_T = 30, PAD_B = 50;
async function get(url, token, accept = 'application/vnd.github+json') {
const headers = { Accept: accept, 'User-Agent': 'star-history-generator' };
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { headers });
if (!res.ok) {
const err = new Error(`HTTP ${res.status} for ${url}`);
err.status = res.status;
throw err;
}
return res.json();
}
// Only the stargazers endpoint documents/needs this media type - it's what
// makes starred_at appear in the response. Sending it on other endpoints
// (like /repos/{repo}) is undocumented behaviour we shouldn't rely on.
const STAR_JSON = 'application/vnd.github.star+json';
export async function fetchPoints(repo, token, samples = SAMPLES) {
const meta = await get(`${API}/repos/${repo}`, token);
const total = meta.stargazers_count;
if (!total) return [];
// Evenly spaced 1-based indices, always including the first and last star.
let indices;
if (total <= samples) {
indices = Array.from({ length: total }, (_, i) => i + 1);
} else {
const step = (total - 1) / (samples - 1);
indices = [...new Set(
Array.from({ length: samples }, (_, i) => Math.round(1 + i * step))
)].sort((a, b) => a - b);
}
const points = [];
for (const n of indices) {
let page;
try {
page = await get(`${API}/repos/${repo}/stargazers?per_page=1&page=${n}`, token, STAR_JSON);
} catch (e) {
// GitHub caps deep pagination on some endpoints. A gap mid-curve is
// survivable, so skip rather than abort the whole run.
console.error(` skip index ${n}: ${e.message}`);
continue;
}
const at = page?.[0]?.starred_at;
if (at) points.push([new Date(at), n]);
}
points.sort((a, b) => a[0] - b[0]);
return points;
}
/** Round the axis maximum up to a readable step (1/2/2.5/5 x 10^n). */
export function niceTicks(hi, count = 5) {
if (hi <= 0) return { ticks: [0], top: 1 };
const raw = hi / count;
const mag = 10 ** Math.floor(Math.log10(raw));
let step = 10 * mag;
for (const m of [1, 2, 2.5, 5, 10]) {
if (m * mag >= raw) { step = m * mag; break; }
}
const top = Math.round(step * count);
return { ticks: Array.from({ length: count + 1 }, (_, i) => Math.round(step * i)), top };
}
export function fmt(n) {
if (n < 1000) return String(n);
return n % 1000 === 0 ? `${n / 1000}k` : `${(n / 1000).toFixed(1)}k`;
}
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const monthYear = (d) => `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
export function renderSvg(points, repo) {
if (!points.length) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"></svg>\n`;
}
const t0 = points[0][0], t1 = points[points.length - 1][0];
const span = Math.max(t1 - t0, 1);
const { ticks, top } = niceTicks(points[points.length - 1][1]);
const px = (d) => PAD_L + ((d - t0) / span) * (W - PAD_L - PAD_R);
const py = (v) => H - PAD_B - (v / top) * (H - PAD_T - PAD_B);
const line = points.map(([d, v]) => `${px(d).toFixed(1)},${py(v).toFixed(1)}`).join(' ');
const area = `${PAD_L},${H - PAD_B} ${line} ${px(t1).toFixed(1)},${H - PAD_B}`;
const grid = ticks.map((t) =>
`<line x1="${PAD_L}" y1="${py(t).toFixed(1)}" x2="${W - PAD_R}" y2="${py(t).toFixed(1)}" class="grid"/>`
).join('\n');
const ylab = ticks.map((t) =>
`<text x="${PAD_L - 10}" y="${(py(t) + 4).toFixed(1)}" class="lbl" text-anchor="end">${fmt(t)}</text>`
).join('\n');
const xlab = Array.from({ length: 5 }, (_, i) => {
const d = new Date(t0.getTime() + (t1 - t0) * (i / 4));
return `<text x="${px(d).toFixed(1)}" y="${H - PAD_B + 22}" class="lbl" text-anchor="middle">${monthYear(d)}</text>`;
}).join('\n');
const stars = points[points.length - 1][1].toLocaleString('en-US');
const updated = `${new Date().getUTCDate()} ${monthYear(new Date())}`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-label="Star history for ${repo}">
<style>
.bg{fill:#ffffff} .grid{stroke:#d8dee4;stroke-width:1}
.lbl{fill:#57606a;font:12px -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif}
.ttl{fill:#1f2328;font:600 14px -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif}
.ln{fill:none;stroke:#ffc107;stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}
.ar{fill:#ffc107;opacity:.15}
@media (prefers-color-scheme: dark){
.bg{fill:#0d1117} .grid{stroke:#30363d} .lbl{fill:#8b949e} .ttl{fill:#e6edf3}
}
</style>
<rect width="${W}" height="${H}" class="bg"/>
${grid}
<polygon points="${area}" class="ar"/>
<polyline points="${line}" class="ln"/>
${ylab}
${xlab}
<text x="${PAD_L}" y="20" class="ttl">${repo} &#183; ${stars} stars</text>
<text x="${W - PAD_R}" y="20" class="lbl" text-anchor="end">updated ${updated}</text>
</svg>
`;
}
/** Smallest thing that fails if the maths breaks. */
function selfCheck() {
const pts = [
[new Date('2024-01-01T00:00:00Z'), 1],
[new Date('2024-06-01T00:00:00Z'), 500],
[new Date('2025-01-01T00:00:00Z'), 1000],
];
const svg = renderSvg(pts, 'owner/repo');
const ok = (c, m) => { if (!c) throw new Error(`self-check failed: ${m}`); };
ok(svg.startsWith('<svg'), 'must emit an svg root');
ok(svg.includes('1,000 stars'), 'final count must be rendered');
ok(svg.includes('polyline'), 'curve must be drawn');
// Monotonic time must map to monotonic x, and stay inside the plot area.
const xs = svg.match(/<polyline points="([^"]+)"/)[1]
.split(' ').map((p) => parseFloat(p.split(',')[0]));
ok(xs.every((x, i) => i === 0 || x >= xs[i - 1]), 'x must increase with time');
ok(xs.every((x) => x >= PAD_L - 0.5 && x <= W - PAD_R + 0.5), 'curve must stay in frame');
const ys = svg.match(/<polyline points="([^"]+)"/)[1]
.split(' ').map((p) => parseFloat(p.split(',')[1]));
ok(ys.every((y) => y >= PAD_T - 0.5 && y <= H - PAD_B + 0.5), 'curve must stay vertically in frame');
const { ticks, top } = niceTicks(35133);
ok(top >= 35133, 'axis must not clip the highest value');
ok(ticks[0] === 0 && ticks.every((t, i) => i === 0 || t > ticks[i - 1]), 'ticks ascend from zero');
ok(fmt(1000) === '1k' && fmt(1500) === '1.5k' && fmt(999) === '999', 'number formatting');
ok(renderSvg([], 'x/y').startsWith('<svg'), 'zero stars must not crash');
console.log('self-check OK');
}
async function main() {
if (process.argv.includes('--self-check')) return selfCheck(), 0;
const repo = process.argv[2] || process.env.GITHUB_REPOSITORY;
const out = process.argv[3] || 'images/star-history.svg';
if (!repo) {
console.error('usage: star-history.mjs owner/repo [out.svg]');
return 2;
}
console.log(`sampling ${repo} ...`);
const points = await fetchPoints(repo, process.env.GITHUB_TOKEN);
if (!points.length) {
console.error('no stargazer data returned');
return 1;
}
mkdirSync(dirname(out) || '.', { recursive: true });
writeFileSync(out, renderSvg(points, repo), 'utf8');
console.log(`wrote ${out} from ${points.length} sampled points, ${points[points.length - 1][1]} stars`);
return 0;
}
// pathToFileURL handles the Windows drive-letter form (file:///d:/...) that
// naive string concatenation gets wrong.
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main());
}