#!/usr/bin/env python3 """Generate a star-history chart for a GitHub repo, using the authenticated GitHub API. Unlike the anonymous star-history.com SVG API (which is rate-limited and returns an empty chart for large repos), this samples stargazer timestamps directly with a token, so it works reliably for repos with tens of thousands of stars. Output is a PNG, which always renders in GitHub markdown. Usage: GITHUB_TOKEN=... python scripts/gen_star_history.py [owner/repo] [output.png] [theme] theme is "light" (default) or "dark". """ import datetime import json import math import os import sys import urllib.request REPO = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("REPO", "lyogavin/airllm") OUT = sys.argv[2] if len(sys.argv) > 2 else "assets/star-history.png" THEME = (sys.argv[3] if len(sys.argv) > 3 else os.environ.get("THEME", "light")).lower() TOKEN = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") PER_PAGE = 100 MAX_SAMPLES = 30 THEMES = { "light": {"bg": "#ffffff", "fg": "#24292f", "grid": "#d0d7de", "line": "#e34a4a"}, "dark": {"bg": "#0d1117", "fg": "#c9d1d9", "grid": "#30363d", "line": "#ff6b6b"}, } def gh(url, accept="application/vnd.github+json"): headers = {"Accept": accept, "User-Agent": "airllm-star-history"} if TOKEN: headers["Authorization"] = f"Bearer {TOKEN}" req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=30) as r: return json.load(r) def main(): info = gh(f"https://api.github.com/repos/{REPO}") total = int(info["stargazers_count"]) if total <= 0: raise SystemExit("repo has no stars") max_page = max(1, math.ceil(total / PER_PAGE)) if max_page == 1: pages = [1] else: n = min(MAX_SAMPLES, max_page) pages = sorted({1, max_page} | { round(1 + i * (max_page - 1) / (n - 1)) for i in range(n) }) points = [] for p in pages: data = gh( f"https://api.github.com/repos/{REPO}/stargazers?per_page={PER_PAGE}&page={p}", accept="application/vnd.github.star+json", ) if not data: continue starred_at = data[0]["starred_at"] cumulative = (p - 1) * PER_PAGE + 1 dt = datetime.datetime.fromisoformat(starred_at.replace("Z", "+00:00")) points.append((dt, cumulative)) points.append((datetime.datetime.now(datetime.timezone.utc), total)) points = sorted(set(points)) if len(points) < 2: raise SystemExit("not enough data points to plot") import matplotlib matplotlib.use("Agg") import matplotlib.dates as mdates import matplotlib.pyplot as plt xs = [d for d, _ in points] ys = [c for _, c in points] c = THEMES.get(THEME, THEMES["light"]) fig, ax = plt.subplots(figsize=(10, 6)) fig.patch.set_facecolor(c["bg"]) ax.set_facecolor(c["bg"]) ax.plot(xs, ys, color=c["line"], linewidth=2.5, marker="o", markersize=4) ax.fill_between(xs, ys, color=c["line"], alpha=0.10) ax.set_title(f"Star History — {REPO}", fontsize=15, color=c["fg"]) ax.set_ylabel("GitHub Stars", fontsize=12, color=c["fg"]) ax.set_xlabel("Date", fontsize=12, color=c["fg"]) ax.grid(True, alpha=0.3, color=c["grid"]) ax.tick_params(colors=c["fg"]) for spine in ax.spines.values(): spine.set_color(c["grid"]) ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m")) fig.autofmt_xdate() fig.tight_layout() os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True) fig.savefig(OUT, dpi=130, facecolor=c["bg"]) print(f"wrote {OUT} ({THEME}): {len(points)} points, {total} stars") if __name__ == "__main__": main()