#!/usr/bin/env python3 """Fetch stargazer history for a repo and render it as a static SVG. Runs in CI with the automatic GITHUB_TOKEN. No third-party dependencies. Usage: GITHUB_TOKEN=... python star_history.py owner/repo output.svg """ import json import math import os import sys import urllib.request from datetime import datetime, timezone API = "https://api.github.com/graphql" PER_PAGE = 100 MAX_PAGES = 400 # preserve the REST implementation's 40k-star cap MAX_POINTS = 120 # downsample the curve to at most this many points def gh_post(query: str, variables: dict, token: str) -> dict: payload = json.dumps({"query": query, "variables": variables}).encode() req = urllib.request.Request(API, data=payload) req.add_header("Accept", "application/vnd.github+json") req.add_header("Content-Type", "application/json") req.add_header("X-GitHub-Api-Version", "2022-11-28") if token: req.add_header("Authorization", f"Bearer {token}") with urllib.request.urlopen(req, timeout=30) as resp: result = json.loads(resp.read()) if result.get("errors"): raise RuntimeError(json.dumps(result["errors"])) return result def fetch_star_dates(repo: str, token: str) -> list[datetime]: owner, name = repo.split("/", 1) query = """ query($owner: String!, $name: String!, $cursor: String, $perPage: Int!) { repository(owner: $owner, name: $name) { stargazers(first: $perPage, after: $cursor) { edges { starredAt } pageInfo { hasNextPage endCursor } } } } """ dates = [] page = 1 cursor = None while True: result = gh_post( query, { "owner": owner, "name": name, "cursor": cursor, "perPage": PER_PAGE, }, token, ) stargazers = result["data"]["repository"]["stargazers"] for edge in stargazers["edges"]: ts = edge.get("starredAt") if ts: dates.append( datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace( tzinfo=timezone.utc ) ) page_info = stargazers["pageInfo"] if not page_info["hasNextPage"]: break page += 1 if page > MAX_PAGES: break cursor = page_info["endCursor"] dates.sort() return dates def downsample(dates: list[datetime]) -> list[tuple[datetime, int]]: n = len(dates) points = [(dates[0], 1)] if n > 1: step = max(1, n // MAX_POINTS) for i in range(step, n, step): points.append((dates[i], i + 1)) points.append((dates[-1], n)) now = datetime.now(timezone.utc) points.append((now, n)) return points def nice_ceil(v: float) -> int: if v <= 0: return 1 mag = 10 ** int(math.floor(math.log10(v))) for mult in (1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10): if v <= mag * mult: return int(mag * mult) return int(mag * 10) def render_svg(points: list[tuple[datetime, int]], repo: str) -> str: width, height = 800, 420 ml, mr, mt, mb = 70, 30, 50, 60 pw, ph = width - ml - mr, height - mt - mb t0 = points[0][0].timestamp() t1 = points[-1][0].timestamp() tspan = max(t1 - t0, 1) ymax = nice_ceil(points[-1][1] * 1.08) def x(ts: float) -> float: return ml + (ts - t0) / tspan * pw def y(v: float) -> float: return mt + ph - v / ymax * ph line = " ".join( f"{'M' if i == 0 else 'L'}{x(d.timestamp()):.1f},{y(c):.1f}" for i, (d, c) in enumerate(points) ) area = ( line + f" L{x(points[-1][0].timestamp()):.1f},{y(0):.1f}" + f" L{x(points[0][0].timestamp()):.1f},{y(0):.1f} Z" ) grid, ylabels = [], [] for i in range(6): v = ymax * i / 5 yy = y(v) grid.append( f'' ) label = f"{v / 1000:.1f}k".replace(".0k", "k") if v >= 1000 else f"{int(v)}" ylabels.append( f'{label}' ) xlabels = [] for i in range(6): ts = t0 + tspan * i / 5 d = datetime.fromtimestamp(ts, tz=timezone.utc) xlabels.append( f'{d.strftime("%b %Y")}' ) total = points[-1][1] return f""" {repo} star history {total:,} stars {"".join(grid)} {"".join(ylabels)} {"".join(xlabels)} """ def main() -> int: if len(sys.argv) != 3: print(__doc__) return 2 repo, out = sys.argv[1], sys.argv[2] token = os.environ.get("GITHUB_TOKEN", "") dates = fetch_star_dates(repo, token) if not dates: print("no stargazers found", file=sys.stderr) return 1 os.makedirs(os.path.dirname(out) or ".", exist_ok=True) svg = render_svg(downsample(dates), repo) with open(out, "w") as f: f.write(svg) print(f"wrote {out} ({len(dates):,} stars)") return 0 if __name__ == "__main__": sys.exit(main())