From 3d2848021f1ec4f4a25bec4752572be9d22f2685 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 13 Aug 2026 21:47:13 +0300 Subject: [PATCH] feat(readme): combine growth metrics --- .github/workflows/pages.yml | 6 +- README.md | 4 +- scripts/generate-star-history.py | 144 +++++++++++++++++++++++++++---- 3 files changed, 132 insertions(+), 22 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b2402643..4a5f681f 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -177,10 +177,8 @@ jobs: print(f"supporters: {len(supporters)}") PY - # Publish an OpenCADStudio-branded star-history chart for the README. - # The timestamp media type returns when each current stargazer starred - # the repository; the script follows every API page and emits both themes. - - name: Generate star history charts + # Publish one themed growth chart for stars and release downloads. + - name: Generate project growth charts env: GITHUB_TOKEN: ${{ secrets.OCS_GITHUB_TOKEN }} run: python3 scripts/generate-star-history.py --output-dir dist diff --git a/README.md b/README.md index ae71fb7c..3c850767 100644 --- a/README.md +++ b/README.md @@ -156,13 +156,13 @@ cd OpenCADStudio cargo build --release --bin OpenCADStudio ./target/release/OpenCADStudio ``` -## Star History +## Stars & Release Downloads - OpenCADStudio GitHub star history + OpenCADStudio GitHub stars and release downloads diff --git a/scripts/generate-star-history.py b/scripts/generate-star-history.py index e4f523de..2840a5a0 100644 --- a/scripts/generate-star-history.py +++ b/scripts/generate-star-history.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate OpenCADStudio-branded GitHub star-history SVG charts.""" +"""Generate OpenCADStudio GitHub growth SVG charts.""" from __future__ import annotations @@ -16,11 +16,15 @@ from xml.sax.saxutils import escape API_VERSION = "2026-03-10" WIDTH = 960 -HEIGHT = 380 +HEIGHT = 410 PLOT_LEFT = 72 -PLOT_RIGHT = 928 -PLOT_TOP = 82 -PLOT_BOTTOM = 326 +PLOT_RIGHT = 876 +PLOT_TOP = 108 +PLOT_BOTTOM = 350 + + +# Isolated historical spike replaced by its neighboring-release mean. +DOWNLOAD_OVERRIDES = {"v0.4.1": 61} THEMES = { @@ -33,6 +37,8 @@ THEMES = { "line": "#0284c7", "area": "#38bdf8", "point": "#0369a1", + "download_line": "#ea580c", + "download_point": "#c2410c", }, "dark": { "background": "#0b1220", @@ -43,6 +49,8 @@ THEMES = { "line": "#38bdf8", "area": "#0ea5e9", "point": "#7dd3fc", + "download_line": "#fb923c", + "download_point": "#fdba74", }, } @@ -89,8 +97,53 @@ def fetch_star_dates(repository: str, token: str | None) -> list[datetime]: return dates -def nice_axis(stars: int) -> tuple[int, int]: - rough_step = max(stars / 5, 1) +def fetch_release_downloads( + repository: str, token: str | None +) -> list[tuple[str, datetime, int]]: + url = f"https://api.github.com/repos/{repository}/releases?per_page=100" + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "OpenCADStudio-growth-history", + "X-GitHub-Api-Version": API_VERSION, + } + if token: + headers["Authorization"] = f"Bearer {token}" + + releases: list[tuple[str, datetime, int]] = [] + for _ in range(100): + request = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.load(response) + link = response.headers.get("Link") + for release in payload: + published_at = release.get("published_at") + if release.get("draft") or not published_at: + continue + tag = str(release.get("tag_name", "")) + downloads = sum( + int(asset.get("download_count", 0)) for asset in release.get("assets", []) + ) + releases.append( + ( + tag, + datetime.fromisoformat(published_at.replace("Z", "+00:00")), + DOWNLOAD_OVERRIDES.get(tag, downloads), + ) + ) + url = next_link(link) + if not url: + break + else: + raise RuntimeError("release pagination exceeded 100 pages") + + releases.sort(key=lambda release: release[1]) + if len(releases) < 2: + raise RuntimeError("GitHub returned fewer than two published releases") + return releases[:-1] + + +def nice_axis(value: int) -> tuple[int, int]: + rough_step = max(value / 5, 1) magnitude = 10 ** math.floor(math.log10(rough_step)) step = magnitude for multiplier in (1, 2, 5, 10): @@ -98,21 +151,27 @@ def nice_axis(stars: int) -> tuple[int, int]: if candidate >= rough_step: step = candidate break - top = math.ceil(stars / step) * step - if top <= stars: + top = math.ceil(value / step) * step + if top <= value: top += step return int(top), int(step) -def render_svg(repository: str, dates: list[datetime], theme: str) -> str: +def render_svg( + repository: str, + dates: list[datetime], + releases: list[tuple[str, datetime, int]], + theme: str, +) -> str: colors = THEMES[theme] now = datetime.now(timezone.utc) - start = dates[0] - timedelta(days=2) - end = max(now, dates[-1] + timedelta(days=2)) + start = min(dates[0], releases[0][1]) - timedelta(days=2) + end = max(now, dates[-1], releases[-1][1]) + timedelta(days=2) duration = max((end - start).total_seconds(), 1) plot_width = PLOT_RIGHT - PLOT_LEFT plot_height = PLOT_BOTTOM - PLOT_TOP y_top, y_step = nice_axis(len(dates)) + download_top, download_step = nice_axis(max(downloads for _, _, downloads in releases)) def x_position(moment: datetime) -> float: ratio = (moment - start).total_seconds() / duration @@ -121,11 +180,21 @@ def render_svg(repository: str, dates: list[datetime], theme: str) -> str: def y_position(stars: int) -> float: return PLOT_BOTTOM - (stars / y_top) * plot_height + def download_y_position(downloads: int) -> float: + return PLOT_BOTTOM - (downloads / download_top) * plot_height + line_parts = [f"M {PLOT_LEFT:.1f} {PLOT_BOTTOM:.1f}"] for count, moment in enumerate(dates, 1): line_parts.append(f"H {x_position(moment):.1f} V {y_position(count):.1f}") line_path = " ".join(line_parts) area_path = f"{line_path} V {PLOT_BOTTOM:.1f} H {PLOT_LEFT:.1f} Z" + download_path = " ".join( + ( + "M" if index == 0 else "L" + ) + + f" {x_position(published):.1f} {download_y_position(downloads):.1f}" + for index, (_, published, downloads) in enumerate(releases) + ) grid = [] for value in range(0, y_top + 1, y_step): @@ -139,6 +208,18 @@ def render_svg(repository: str, dates: list[datetime], theme: str) -> str: f'fill="{colors["muted"]}" font-size="12">{value}' ) + download_axis = [] + for value in range(0, download_top + 1, download_step): + y = download_y_position(value) + download_axis.append( + f'' + ) + download_axis.append( + f'{value}' + ) + x_labels = [] for index in range(5): moment = start + (end - start) * (index / 4) @@ -156,11 +237,24 @@ def render_svg(repository: str, dates: list[datetime], theme: str) -> str: f'fill="{colors["muted"]}" font-size="12">{escape(label)}' ) - title = f"{repository.split('/')[-1]} Star History" - subtitle = f"{len(dates)} GitHub stars · Updated {now:%d %b %Y}" + title = f"{repository.split('/')[-1]} Growth" + total_downloads = sum(downloads for _, _, downloads in releases) + subtitle = ( + f"{len(dates)} GitHub stars · {total_downloads:,} cleaned asset downloads across " + f"{len(releases)} releases · Latest excluded · Updated {now:%d %b %Y}" + ) last_x = x_position(dates[-1]) last_y = y_position(len(dates)) label_y = max(PLOT_TOP + 16, last_y - 14) + last_release, last_published, last_downloads = releases[-1] + last_release_x = x_position(last_published) + last_release_y = download_y_position(last_downloads) + + download_points = "".join( + f'' + for _, published, downloads in releases + ) return f''' {escape(title)} @@ -175,12 +269,24 @@ def render_svg(repository: str, dates: list[datetime], theme: str) -> str: {escape(title)} {escape(subtitle)} + + GitHub stars + + + Release downloads + Stars + Downloads / release {''.join(grid)} + {''.join(download_axis)} {''.join(x_labels)} + + {download_points} {len(dates)} stars + + {escape(last_release)} · {last_downloads} ''' @@ -197,11 +303,17 @@ def main() -> None: token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") dates = fetch_star_dates(args.repository, token) + releases = fetch_release_downloads(args.repository, token) args.output_dir.mkdir(parents=True, exist_ok=True) for theme in THEMES: output = args.output_dir / f"star-history-{theme}.svg" - output.write_text(render_svg(args.repository, dates, theme), encoding="utf-8") - print(f"star history: {len(dates)} stars") + output.write_text( + render_svg(args.repository, dates, releases, theme), encoding="utf-8" + ) + print( + f"growth history: {len(dates)} stars, " + f"{sum(downloads for _, _, downloads in releases)} downloads" + ) if __name__ == "__main__":