diff --git a/.github/workflows/collect.yml b/.github/workflows/collect.yml index 9bb8eb2..43b71c9 100644 --- a/.github/workflows/collect.yml +++ b/.github/workflows/collect.yml @@ -28,10 +28,10 @@ jobs: - name: Collect env: + # The built-in token is all this needs. GitHub doesn't let it list stargazers + # (403), so stars.csv only grows on local runs with a user token; the charts + # use the daily stargazers_count snapshots instead, so they never go stale. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Optional user token (public read is enough). Without it, per-star timestamps - # stop updating and the star chart continues from daily stargazers_count. - STATS_GH_TOKEN: ${{ secrets.STATS_GH_TOKEN }} FULL_STARS: ${{ inputs.full_stars || false }} run: | if [ "$FULL_STARS" = "true" ]; then diff --git a/README.md b/README.md index 8d25a34..e124fa6 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Usage and community stats for [gptme](https://github.com/gptme/gptme), collected [![gptme GitHub stars over time](charts/stars.svg)](data/stars.csv) +[![GitHub stars added per day, 7-day rolling average](charts/stars_growth.svg)](data/daily.csv) + [![gptme PyPI downloads](charts/pypi.svg)](data/pypi_daily.csv) Embed the star chart elsewhere with: @@ -24,7 +26,9 @@ _Latest snapshot: 2026-10-13 (UTC). Machine-readable: [`data/summary.json`](data | Metric | Value | |---|---:| | GitHub stars | 4,448 | -| Stars gained, last 30 days | 1 | +| Stars gained, last 7 days | 3 | +| Stars gained, previous 7 days | 8 | +| Stars gained, last 30 days | 36 | | Forks | 451 | | Watchers | 40 | | Contributors | 57 | @@ -52,6 +56,8 @@ _Latest snapshot: 2026-10-13 (UTC). Machine-readable: [`data/summary.json`](data ### Definitions and caveats - **Stars** (`daily.csv`) is the repo's `stargazers_count` that day. People can unstar, so this number can go down. +- **Stars added per day** (`charts/stars_growth.svg`, and the "stars gained" rows above) is the day-over-day change of one daily series: the `daily.csv` snapshots from the first snapshot (2026-09-14) on, `stars.csv` before that. Before snapshots it counts current stargazers by the day they starred; with snapshots it is net of unstars and can be negative. A snapshot is taken around 04:17 UTC, so a snapshot day's change mostly reflects the previous UTC day. Missing snapshot days are filled by linear interpolation, so a collection gap spreads out instead of showing as a spike. The chart covers the full history on a log scale, so a 1/day week stays readable next to the October 2024 launch week (~1,400 stars), and doubling looks the same at any level. +- **`stars.csv` needs a user token to update.** GitHub returns 401 for unauthenticated stargazer listings and 403 for the workflow's `GITHUB_TOKEN` (REST and GraphQL alike), so the daily job can't refresh it and keeps it as is. Nothing depends on it staying current: the cumulative chart continues from the `daily.csv` snapshots after its last row, and the growth chart and "stars gained" rows use snapshots for every day they exist. Running `collect.py` locally with `GITHUB_TOKEN=$(gh auth token)` fills it in, fetching only the newest pages (a few requests) unless it is far behind. - **`stars.csv`** holds only current stargazers. When someone unstars, their row disappears at the next resync, so the cumulative chart shows today's stargazers by the date they starred, not a historical peak. `daily.csv` records what the total actually was on each day. Logins are deliberately not stored: the charts only need timestamps, and there is no reason to publish a permanent, append-only list of who starred. - **Watchers** is `subscribers_count` (people watching the repo), not the legacy `watchers_count` alias for stars. - **Open issues** excludes pull requests. GitHub's `open_issues_count` includes PRs, so open PRs are counted separately and subtracted. diff --git a/charts/stars_growth.svg b/charts/stars_growth.svg new file mode 100644 index 0000000..54cd301 --- /dev/null +++ b/charts/stars_growth.svg @@ -0,0 +1,7960 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/collect.py b/collect.py index f10bfe8..3104b23 100644 --- a/collect.py +++ b/collect.py @@ -7,8 +7,9 @@ every source has been fetched and validated, so a failed run never leaves a partial or garbage row behind. -Needs no secrets beyond an optional GITHUB_TOKEN (or GH_TOKEN), which only -raises the GitHub API rate limit. +Needs no secrets beyond an optional GITHUB_TOKEN (or GH_TOKEN). In Actions the built-in +token raises the rate limit; per-star timestamps (stars.csv) are only refreshed with a +user token, e.g. GITHUB_TOKEN=$(gh auth token) locally. See TokenNotAccepted. """ from __future__ import annotations @@ -63,8 +64,10 @@ def log(msg: str) -> None: class TokenNotAccepted(RuntimeError): """401, or 403 "Resource not accessible by integration". - Listing stargazers with timestamps requires a user token: the workflow's - repo-scoped GITHUB_TOKEN gets a 403 and unauthenticated requests get a 401. + Listing stargazers requires a user token. Unauthenticated requests get a 401, and the + workflow's GITHUB_TOKEN gets a 403 "Resource not accessible by integration" from REST + (with or without the star+json media type) and from GraphQL's `stargazers` alike + (checked 2026-10-13). Reading another public repo's metadata works with it. """ @@ -78,11 +81,7 @@ def __init__(self) -> None: "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } - token = ( - os.environ.get("STATS_GH_TOKEN") - or os.environ.get("GITHUB_TOKEN") - or os.environ.get("GH_TOKEN") - ) + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") if token: self.gh_headers["Authorization"] = f"Bearer {token}" else: @@ -253,8 +252,8 @@ def collect_stars(http: Http, stored: list[str], total: int, force_full: bool) - try: return _collect_stars(http, stored, total, force_full) except TokenNotAccepted as e: - # Per-star timestamps need a user token (STATS_GH_TOKEN secret). Without one, keep - # the stored history; daily.csv still records the stargazers_count for the chart. + # Per-star timestamps need a user token. Without one, keep the stored history; + # daily.csv still records stargazers_count, which the charts continue from. log(f"warning: star timestamps unavailable, keeping stars.csv as is ({e})") return stored diff --git a/data/summary.json b/data/summary.json index e7c6912..f9afe92 100644 --- a/data/summary.json +++ b/data/summary.json @@ -3,7 +3,9 @@ "source": "https://github.com/gptme/stats", "repo": "gptme/gptme", "stars": 4448, - "stars_last_30d": 1, + "stars_last_7d": 3, + "stars_prev_7d": 8, + "stars_last_30d": 36, "forks": 451, "watchers": 40, "open_issues": 7, diff --git a/render.py b/render.py index f26a469..8a7b8f2 100644 --- a/render.py +++ b/render.py @@ -21,7 +21,7 @@ matplotlib.use("Agg") import matplotlib.dates as mdates # noqa: E402 import matplotlib.pyplot as plt # noqa: E402 -from matplotlib.ticker import FuncFormatter, MaxNLocator # noqa: E402 +from matplotlib.ticker import FixedLocator, FuncFormatter, MaxNLocator # noqa: E402 ROOT = Path(__file__).resolve().parent DATA = ROOT / "data" @@ -143,6 +143,95 @@ def render_stars(stars: list[str], daily: list[dict[str, str]], as_of: str) -> N save(fig, "stars.svg") +def star_totals(stars: list[str], daily: list[dict[str, str]]) -> dict[dt.date, int]: + """Star count for every calendar day, from the first star to the last snapshot. + + From the first daily.csv snapshot on, this is the snapshot stargazers_count (net of + unstars), whether or not stars.csv is current. Before snapshots existed, it is + stars.csv (current stargazers by the day they starred), shifted by a constant so the + two meet at the first snapshot; only day-over-day differences are meaningful there. + Missing snapshot days are filled by linear interpolation, so a gap in collection + spreads its stars over the gap instead of showing up as a one-day spike. + """ + snaps = { + dt.date.fromisoformat(r["date"]): int(r["stars"]) for r in daily if r.get("stars", "").isdigit() + } + per_day = Counter(dt.date.fromisoformat(s[:10]) for s in stars) + # Without snapshots, stars.csv is all there is; windows it doesn't cover stay n/a. + first_snap = min(snaps) if snaps else max(per_day, default=dt.date.min) + totals: dict[dt.date, int] = {} + if per_day: + day, running = min(per_day), 0 + while day <= first_snap: + running += per_day.get(day, 0) + totals[day] = running + day += dt.timedelta(days=1) + if first_snap in snaps: + offset = snaps[first_snap] - totals[first_snap] + totals = {d: v + offset for d, v in totals.items()} + days = sorted(snaps) + for a, b in zip(days, days[1:]): + gap = (b - a).days + for i in range(1, gap): + totals[a + dt.timedelta(days=i)] = snaps[a] + round((snaps[b] - snaps[a]) * i / gap) + totals.update(snaps) + return dict(sorted(totals.items())) + + +def render_star_growth(totals: dict[dt.date, int]) -> bool: + days = sorted(totals) + if len(days) < 2: + return False + added = {d: totals[d] - totals[d - dt.timedelta(days=1)] for d in days[1:]} + shown = sorted(added) + end = shown[-1] + xs, ys = rolling_mean(shown, added, window=7) + + def week_ending(d: dt.date) -> int: + return sum(added.get(d - dt.timedelta(days=i), 0) for i in range(7)) + + week, prev = week_ending(end), week_ending(end - dt.timedelta(days=7)) + fig, ax = new_figure( + "GitHub stars added per day (7-day rolling avg)", + f"Net new stargazers of gptme/gptme per day, log scale. Last 7 days: {week:,}, previous 7: {prev:,}", + ) + # One filled step path instead of ~1,000 bar patches keeps the SVG (and daily diffs) small. + ax.fill_between( + shown, [added[d] for d in shown], step="mid", color=BLUE_LIGHT, linewidth=0, label="Daily" + ) + if xs: + ax.plot(xs, ys, color=BLUE, linewidth=1.5, label="7-day average") + end_label(ax, xs[-1], ys[-1], f"{ys[-1]:,.1f}/day") + # Daily counts span 0 to a few hundred (the October 2024 launch). A log scale keeps + # a quiet week at 1/day readable next to that spike, and equal ratios get equal + # heights, so "twice last week" looks the same at any level. symlog is linear in + # [-1, 1] so zero and unstar days still have a place. + hi = max(added.values()) + ax.set_yscale("symlog", linthresh=1, linscale=0.4) + ticks = [0] + [v for v in (1, 3, 10, 30, 100, 300, 1000, 3000) if v <= hi * 1.5] + ax.yaxis.set_major_locator(FixedLocator(ticks)) + ax.yaxis.set_minor_locator(FixedLocator([])) + ax.yaxis.set_major_formatter(comma) + ax.set_ylim(min(-0.6, min(added.values()) * 1.05), max(2, hi) * 1.6) + ax.axhline(0, color=BASELINE, linewidth=1, zorder=1) + ax.set_xlim(shown[0], end + dt.timedelta(days=1)) + peak = max(shown, key=week_ending) + if week_ending(peak) >= 4 * max(1, week): + top = max(shown[max(0, shown.index(peak) - 6) : shown.index(peak) + 1], key=added.__getitem__) + ax.annotate( + f"busiest week: {week_ending(peak):,} stars (to {peak:%Y-%m-%d})", + (top, added[top]), + xytext=(8, 0), + textcoords="offset points", + va="center", + fontsize=8.5, + color=INK_2, + ) + ax.legend(loc="upper left", fontsize=9, labelcolor=INK_2, handlelength=1.5) + save(fig, "stars_growth.svg") + return True + + def rolling_mean(dates: list[dt.date], values: dict[dt.date, int], window: int = 30): xs, ys = [], [] first = dates[0] @@ -196,10 +285,13 @@ def pypi_window(pypi: list[dict[str, str]], days: int) -> int: return sum(int(r["downloads"]) for r in pypi[-days:]) -def build_summary(daily, stars, pypi) -> dict: +def build_summary(daily, totals, pypi) -> dict: latest = daily[-1] as_of = dt.date.fromisoformat(latest["date"]) - cutoff = (as_of - dt.timedelta(days=30)).isoformat() + + def stars_gained(days: int, offset: int = 0) -> int | None: + hi, lo = as_of - dt.timedelta(days=offset), as_of - dt.timedelta(days=offset + days) + return totals[hi] - totals[lo] if hi in totals and lo in totals else None def num(key: str) -> int | None: return int(latest[key]) if latest.get(key) else None @@ -209,7 +301,9 @@ def num(key: str) -> int | None: "source": "https://github.com/gptme/stats", "repo": "gptme/gptme", "stars": num("stars"), - "stars_last_30d": sum(1 for s in stars if s[:10] > cutoff), + "stars_last_7d": stars_gained(7), + "stars_prev_7d": stars_gained(7, offset=7), + "stars_last_30d": stars_gained(30), "forks": num("forks"), "watchers": num("watchers"), "open_issues": num("open_issues"), @@ -241,6 +335,8 @@ def update_readme(s: dict, has_downloads_chart: bool) -> None: "| Metric | Value |", "|---|---:|", f"| GitHub stars | {fmt(s['stars'])} |", + f"| Stars gained, last 7 days | {fmt(s['stars_last_7d'])} |", + f"| Stars gained, previous 7 days | {fmt(s['stars_prev_7d'])} |", f"| Stars gained, last 30 days | {fmt(s['stars_last_30d'])} |", f"| Forks | {fmt(s['forks'])} |", f"| Watchers | {fmt(s['watchers'])} |", @@ -271,8 +367,10 @@ def main() -> None: if not (daily and stars and pypi): raise SystemExit("missing data, run collect.py first") - summary = build_summary(daily, stars, pypi) + totals = star_totals(stars, daily) + summary = build_summary(daily, totals, pypi) render_stars(stars, daily, summary["generated_at"]) + render_star_growth(totals) render_pypi(pypi) has_downloads = render_downloads(daily) (DATA / "summary.json").write_text(json.dumps(summary, indent=2) + "\n")