From ab34833d350526a2d95410d67813ebded335cb2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:05:29 +0000 Subject: [PATCH 1/4] Initial plan From 42c920e48d7cc75d3e1ea9cf0b8a88a18f8bb79d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:11:24 +0000 Subject: [PATCH 2/4] feat: self-host star history chart to fix broken embed GitHub restricted detailed stargazer history to repo admins/collaborators, which broke star-history.com's unauthenticated README embed. - Add scripts/generate-star-history: Python script that fetches star data from GitHub API and renders a self-contained SVG chart - Add .github/workflows/star-history.yml: weekly CI job that regenerates the chart using the workflow's GITHUB_TOKEN and commits the updated SVG - Add docs/images/star-history.svg: initial chart generated from seed data - Update docs/README.md: replace broken api.star-history.com embed with a reference to the self-hosted SVG Closes #2040 --- .github/workflows/star-history.yml | 50 +++ docs/README.md | 8 +- docs/images/star-history.svg | 36 ++ scripts/generate-star-history | 536 +++++++++++++++++++++++++++++ 4 files changed, 629 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/star-history.yml create mode 100644 docs/images/star-history.svg create mode 100644 scripts/generate-star-history diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml new file mode 100644 index 000000000..c7b9075b6 --- /dev/null +++ b/.github/workflows/star-history.yml @@ -0,0 +1,50 @@ +name: Star history chart + +## Regenerates the README's star-history chart. GitHub restricted +## detailed stargazer history to repo admins/collaborators, which broke +## star-history.com's unauthenticated embed — so we render the chart +## ourselves with the repo-scoped workflow token and commit the SVG. +on: + workflow_dispatch: + schedule: + ## Weekly, Monday 06:20 UTC — off the top of the hour to dodge the + ## scheduled-workflow load spike. + - cron: "20 6 * * 1" + +permissions: + contents: write + +jobs: + update-chart: + runs-on: ubuntu-latest + ## Scheduled runs on forks would fail (no stargazer access) and spam. + if: github.repository == 'commitizen-tools/commitizen' + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Generate chart + env: + GITHUB_TOKEN: ${{ github.token }} + ## Optional fallback: a collaborator PAT, only needed if GitHub + ## ever stops granting the workflow token stargazer access. + STAR_HISTORY_TOKEN: ${{ secrets.STAR_HISTORY_TOKEN }} + run: python scripts/generate-star-history --repo "$GITHUB_REPOSITORY" + + - name: Commit updated chart + run: | + ## Stage first so a brand-new (untracked) chart is detected too — + ## `git diff --quiet` alone ignores untracked files. + git add docs/images/star-history.svg docs/images/star-history.json + if git diff --staged --quiet; then + echo "chart unchanged, 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 commit -m "chore: update star history chart [skip ci]" + git push diff --git a/docs/README.md b/docs/README.md index b4b97884a..27a1cb573 100644 --- a/docs/README.md +++ b/docs/README.md @@ -305,7 +305,13 @@ For more detailed information about argcomplete configuration and troubleshootin ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=commitizen-tools/commitizen)](https://star-history.com/#commitizen-tools/commitizen) + + + Star History Chart + ## Sponsors diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg new file mode 100644 index 000000000..fabb09fa2 --- /dev/null +++ b/docs/images/star-history.svg @@ -0,0 +1,36 @@ + + Star history of commitizen-tools/commitizen + + + + + + + + + + + + + + + + + + + + commitizen-tools/commitizen + GitHub stars over time + 02,0004,0006,000 + Apr 2019Oct 2020Apr 2022Oct 2023Apr 2025 + + + + + + 5,500 stars + + + updated 2026-07-29 + + diff --git a/scripts/generate-star-history b/scripts/generate-star-history new file mode 100644 index 000000000..862250343 --- /dev/null +++ b/scripts/generate-star-history @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +"""Generate a static star-history SVG chart for the README. + +GitHub restricted detailed stargazer history (the `starred_at` timestamps +behind `Accept: application/vnd.github.star+json`) to repository admins and +collaborators, which broke star-history.com's unauthenticated README embed. +This script regenerates the chart from the GitHub API using an authenticated +token (the workflow's GITHUB_TOKEN) and writes a self-contained SVG that the +README references as a normal committed file. + +The chart renders as a dark "night sky" card so it reads well on both GitHub +light and dark themes. Everything is deterministic for a given (repo, data, +date) — the starfield is seeded from the repo name — so reruns with unchanged +data produce byte-identical output and the workflow's "chart unchanged" check +keeps working. + +Two data sources, in preference order. Exact per-star history rebuilds the +whole curve and is used whenever the stargazers endpoint is reachable. When it +refuses, the run keeps the history banked in `--series` and extends it with +the repo's `stargazers_count`, which needs only `metadata=read`. A run that +can reach neither fails rather than drawing an invented curve. + +Usage: + GITHUB_TOKEN=... scripts/generate-star-history [--repo owner/name] [--out path] + scripts/generate-star-history --seed "2019-03-01:0,2026-07-29:5000" --out path + +`--seed` renders a chart from explicit `date:count` points without touching +the network — used to commit an initial chart and for offline testing; it +neither reads nor writes the series. + +Stdlib only; no dependencies. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +import urllib.error +import urllib.request +import zlib +from datetime import date, datetime, timezone + +API_ROOT = "https://api.github.com" +# The stargazers endpoint is capped at 400 pages (40k stars) by GitHub. +MAX_PAGES = 400 +PER_PAGE = 100 + +WIDTH = 700 +HEIGHT = 360 +MARGIN_LEFT = 64 +MARGIN_RIGHT = 24 +MARGIN_TOP = 56 +MARGIN_BOTTOM = 52 + +# Night-sky palette; the card supplies its own background so the same SVG +# works on GitHub light and dark themes alike. +CARD_TOP = "#101527" +CARD_BOTTOM = "#1a2033" +CARD_BORDER = "#2e3650" +TEXT_BRIGHT = "#e6edf3" +TEXT_MUTED = "#8b95a7" +GRID_COLOR = "#4a5470" +LINE_START = "#4f9cff" +LINE_END = "#ffd166" +GOLD = "#ffd166" + + +class StargazerFetchError(SystemExit): + """A refused stargazers request, carrying the full diagnostic. + + Derives from SystemExit so an uncaught one still exits 1 with the message + printed, while callers that have a stored series can catch it specifically + and carry on. + """ + + +def describe_http_error(err: urllib.error.HTTPError, url: str, token_source: str) -> str: + """Explain a refused stargazers request using GitHub's own answer.""" + try: + api_message = json.loads(err.read().decode("utf-8", "replace")).get("message") + except (ValueError, OSError): + api_message = None + + headers = err.headers or {} + remaining = headers.get("x-ratelimit-remaining") + retry_after = headers.get("retry-after") + throttled_message = bool(api_message) and "rate limit" in api_message.lower() + rate_limited = err.code == 429 or ( + err.code == 403 and (remaining == "0" or retry_after is not None or throttled_message) + ) + + lines = [ + f"GitHub API returned {err.code} for {url}.", + f'GitHub says: "{api_message}"' if api_message else "GitHub returned no message body.", + f"Token used: {token_source}.", + ] + + if rate_limited: + detail = [] + if remaining is not None: + detail.append(f"{remaining} requests remaining") + reset = headers.get("x-ratelimit-reset") + if reset and reset.isdigit(): + detail.append( + "resets " + + datetime.fromtimestamp(int(reset), timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + ) + if retry_after: + detail.append(f"retry after {retry_after}s") + suffix = f" ({'; '.join(detail)})" if detail else "" + lines.append(f"Rate limited{suffix}. Retry later.") + elif err.code == 401: + lines.append( + "The credential itself was rejected — the token is expired, revoked, " + "or malformed. Issue a new collaborator PAT and update the " + "STAR_HISTORY_TOKEN secret." + ) + elif err.code == 403: + lines.append( + "The token is live but not authorized for this repo's stargazer " + "history. Check, in order: the org's personal-access-token policy " + "(a policy change can revoke a working token), whether the token's " + "approval or repository-access grant was withdrawn, and whether it " + "has expired. Note the workflow's built-in GITHUB_TOKEN is a GitHub " + "App token, not a collaborator, and is always refused here." + ) + elif err.code == 404: + lines.append( + f"The token cannot see {url.split('/repos/')[-1].split('/stargazers')[0]} " + "at all — check the repo name and the token's repository access." + ) + + return "\n".join(lines) + + +def fetch_star_dates(repo: str, token: str, token_source: str = "STAR_HISTORY_TOKEN") -> list[date]: + """Return the starred_at date of every stargazer, oldest first.""" + dates: list[date] = [] + for page in range(1, MAX_PAGES + 1): + url = f"{API_ROOT}/repos/{repo}/stargazers?per_page={PER_PAGE}&page={page}" + req = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github.star+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + batch = json.load(resp) + except urllib.error.HTTPError as err: + if 400 <= err.code < 500: + raise StargazerFetchError(describe_http_error(err, url, token_source)) from err + raise + if not batch: + break + for entry in batch: + starred_at = entry.get("starred_at") + if starred_at: + dates.append(datetime.fromisoformat(starred_at.replace("Z", "+00:00")).date()) + if len(batch) < PER_PAGE: + break + dates.sort() + return dates + + +def fetch_star_count(repo: str, token: str, token_source: str) -> int: + """Return the repo's current stargazers_count. + + Deliberately a different endpoint from fetch_star_dates: /repos/{repo} + answers on `metadata=read` alone and kept working even when the stargazers + list started demanding `contents=write`. It gives a total but no history, + which is why the series file exists. + """ + url = f"{API_ROOT}/repos/{repo}" + req = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.load(resp) + except urllib.error.HTTPError as err: + if 400 <= err.code < 500: + raise StargazerFetchError(describe_http_error(err, url, token_source)) from err + raise + count = payload.get("stargazers_count") + if not isinstance(count, int): + raise StargazerFetchError(f"{url} returned no stargazers_count") + return count + + +def load_series(path: str, repo: str) -> list[tuple[date, int]]: + """Read the accumulated (date, count) series for `repo`, or [] if absent.""" + try: + with open(path, encoding="utf-8") as fh: + raw = fh.read() + except FileNotFoundError: + return [] + if not raw.strip(): + return [] + try: + payload = json.loads(raw) + except ValueError as err: + raise SystemExit(f"{path} is not valid JSON: {err}") from err + + stored = payload.get("repo") + if stored and stored != repo: + raise SystemExit( + f"{path} holds the series for {stored}, not {repo}. Point --series " + f"at that repo's file, or delete it to start a fresh series — " + f"grafting one repo's history onto another would corrupt both." + ) + points = [(date.fromisoformat(d), int(c)) for d, c in payload.get("points", [])] + return sorted(points) + + +def save_series(path: str, repo: str, points: list[tuple[date, int]]) -> None: + """Write the series, stamped with the repo it belongs to.""" + payload = { + "repo": repo, + "points": [[d.isoformat(), c] for d, c in points], + } + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=1) + fh.write("\n") + + +def append_today(series: list[tuple[date, int]], count: int, today: date) -> list[tuple[date, int]]: + """Extend the series with today's total, replacing a same-day point.""" + points = [(d, c) for d, c in series if d != today] + points.append((today, count)) + return sorted(points) + + +def cumulative_points(star_dates: list[date]) -> list[tuple[date, int]]: + """Collapse per-star dates into (date, cumulative_count) steps.""" + points: list[tuple[date, int]] = [] + count = 0 + for day in star_dates: + count += 1 + if points and points[-1][0] == day: + points[-1] = (day, count) + else: + points.append((day, count)) + today = datetime.now(timezone.utc).date() + if points and points[-1][0] != today: + points.append((today, count)) + return points + + +def parse_seed(seed: str) -> list[tuple[date, int]]: + points = [] + for chunk in seed.split(","): + day, _, count = chunk.strip().partition(":") + points.append((date.fromisoformat(day), int(count))) + return sorted(points) + + +def _nice_axis(value: int) -> tuple[int, int]: + """Pick a (y_max, step) so the curve fills the plot.""" + value = max(value, 1) + headroom = value * 1.06 + best: tuple[int, int] | None = None + magnitude = 1 + while magnitude <= headroom * 10: + for factor in (1.0, 2.0, 2.5, 5.0): + step = factor * magnitude + if step != int(step): + continue + step = int(step) + intervals = -(-int(headroom + 1) // step) # ceil + if 3 <= intervals <= 5: + y_max = step * intervals + if best is None or y_max < best[0] or (y_max == best[0] and step > best[1]): + best = (y_max, step) + magnitude *= 10 + if best is None: + y_max = value + 1 + return y_max, max(1, y_max // 4) + return best + + +def _fmt(n: int) -> str: + return f"{n:,}" + + +def _starfield(repo: str) -> str: + """Deterministic background stars, seeded from the repo name.""" + state = zlib.crc32(repo.encode()) or 1 + + def rnd() -> float: + nonlocal state + state = (state * 1103515245 + 12345) % (1 << 31) + return state / (1 << 31) + + parts = [] + for _ in range(60): + x = round(8 + rnd() * (WIDTH - 16), 1) + y = round(8 + rnd() * (HEIGHT - 16), 1) + r = round(0.4 + rnd() * 0.9, 2) + opacity = round(0.12 + rnd() * 0.55, 2) + tint = "#cfe1ff" if rnd() < 0.7 else "#ffe9b8" + parts.append(f'') + for i in range(4): + x = round(8 + rnd() * (WIDTH - 16), 1) + y = round(8 + rnd() * (HEIGHT * 0.45), 1) + parts.append( + f'' + f'' + "" + ) + return "".join(parts) + + +def _star_path(cx: float, cy: float, r: float) -> str: + """Five-point star path centered on (cx, cy).""" + pts = [] + for i in range(10): + radius = r if i % 2 == 0 else r * 0.42 + angle = math.pi / 2 + i * math.pi / 5 + pts.append(f"{cx + radius * math.cos(angle):.1f},{cy - radius * math.sin(angle):.1f}") + return "M" + " L".join(pts) + " Z" + + +def _month_ticks(x0: int, x1: int) -> list[tuple[date, str]]: + """Ticks at month starts, thinned to at most 5.""" + firsts = [] + d = date.fromordinal(x0) + year, month = d.year, d.month + while True: + month += 1 + if month == 13: + year, month = year + 1, 1 + first = date(year, month, 1) + if first.toordinal() > x1: + break + firsts.append(first) + if len(firsts) >= 2: + step = -(-len(firsts) // 5) + firsts = firsts[::step] + labels = [] + prev_year = None + for d in firsts: + labels.append( + (d, d.strftime("%b %Y") if d.year != prev_year else d.strftime("%b")) + ) + prev_year = d.year + return labels + span = max(x1 - x0, 1) + ticks = [] + for i in range(4): + day = date.fromordinal(x0 + round(span * i / 3)) + ticks.append((day, day.strftime("%b %d"))) + return ticks + + +def render_svg(points: list[tuple[date, int]], repo: str) -> str: + if len(points) < 2: + raise SystemExit("need at least 2 data points to draw a chart") + + x0, x1 = points[0][0].toordinal(), points[-1][0].toordinal() + x_span = max(x1 - x0, 1) + y_max, y_step = _nice_axis(points[-1][1]) + plot_w = WIDTH - MARGIN_LEFT - MARGIN_RIGHT + plot_h = HEIGHT - MARGIN_TOP - MARGIN_BOTTOM + baseline = MARGIN_TOP + plot_h + + def px(d: date) -> float: + return MARGIN_LEFT + (d.toordinal() - x0) / x_span * plot_w + + def py(count: int) -> float: + return MARGIN_TOP + plot_h - (count / y_max) * plot_h + + poly = " ".join(f"{px(d):.1f},{py(c):.1f}" for d, c in points) + area = ( + f"{px(points[0][0]):.1f},{baseline:.1f} " + + poly + + f" {px(points[-1][0]):.1f},{baseline:.1f}" + ) + + grid_lines = [] + for value in range(0, y_max + 1, y_step): + y = py(value) + dash = "" if value == 0 else ' stroke-dasharray="3 5"' + opacity = "0.9" if value == 0 else "0.45" + grid_lines.append( + f'' + f'{_fmt(value)}' + ) + + x_labels = [] + for day, label in _month_ticks(x0, x1): + x = px(day) + x_labels.append( + f'' + f'{label}' + ) + + last_date, last_count = points[-1] + end_x, end_y = px(last_date), py(last_count) + badge_text = f"{_fmt(last_count)} stars" + badge_w = round(len(badge_text) * 7.0 + 20) + badge_h = 22 + badge_x = end_x - 14 - badge_w + badge_y = min(max(end_y - badge_h / 2, MARGIN_TOP - 18), baseline - badge_h) + + generated = datetime.now(timezone.utc).strftime("%Y-%m-%d") + parts = [ + f'\n', + f" Star history of {repo}\n", + " \n", + ' \n', + f' ', + f'\n', + " \n", + ' \n', + f' ', + f'\n', + " \n", + ' \n', + f' \n', + f' \n', + " \n", + ' \n', + ' \n', + " \n", + " \n", + f' \n', + f" {_starfield(repo)}\n", + " \n', + f' \n', + ' {repo}\n', + f' GitHub stars over time\n ', + "".join(grid_lines), + "\n ", + "".join(x_labels), + f'\n \n', + f' \n', + f' \n', + f' \n', + f' \n', + f' {badge_text}\n', + f' \n', + f' \n', + f' ', + f"updated {generated}\n", + " \n\n", + ] + return "".join(parts) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default="commitizen-tools/commitizen") + parser.add_argument("--out", default="docs/images/star-history.svg") + parser.add_argument( + "--series", + default="docs/images/star-history.json", + help="accumulated date:count points, carried between runs so the " + "chart survives the stargazers endpoint being unavailable", + ) + parser.add_argument( + "--seed", + help="comma-separated date:count points to render offline " + '(e.g. "2019-03-01:0,2026-07-29:5000")', + ) + args = parser.parse_args() + + if args.seed: + points = parse_seed(args.seed) + else: + token_source = "STAR_HISTORY_TOKEN" + token = os.environ.get("STAR_HISTORY_TOKEN") + if not token: + token_source = "GITHUB_TOKEN (fallback)" + token = os.environ.get("GITHUB_TOKEN") + if not token: + raise SystemExit("set GITHUB_TOKEN (or STAR_HISTORY_TOKEN), or use --seed") + + series = load_series(args.series, args.repo) + try: + star_dates = fetch_star_dates(args.repo, token, token_source) + if not star_dates: + raise SystemExit(f"no stargazer data returned for {args.repo}") + points = cumulative_points(star_dates) + except StargazerFetchError as err: + if not series: + raise + print( + f"stargazer history unavailable, extending the stored series " + f"with today's total instead.\n{err}", + file=sys.stderr, + ) + count = fetch_star_count(args.repo, token, token_source) + points = append_today(series, count, datetime.now(timezone.utc).date()) + + save_series(args.series, args.repo, points) + + svg = render_svg(points, args.repo) + with open(args.out, "w", encoding="utf-8") as fh: + fh.write(svg) + print(f"wrote {args.out} ({len(points)} points, {points[-1][1]} stars)") + + +if __name__ == "__main__": + main() From 57fd03812245e4aefe22c374b65918efd126c27d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:19:49 +0000 Subject: [PATCH 3/4] refactor: rename generate-star-history to generate_star_history.py --- .github/workflows/star-history.yml | 2 +- scripts/{generate-star-history => generate_star_history.py} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename scripts/{generate-star-history => generate_star_history.py} (99%) diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml index c7b9075b6..7aeb061cd 100644 --- a/.github/workflows/star-history.yml +++ b/.github/workflows/star-history.yml @@ -33,7 +33,7 @@ jobs: ## Optional fallback: a collaborator PAT, only needed if GitHub ## ever stops granting the workflow token stargazer access. STAR_HISTORY_TOKEN: ${{ secrets.STAR_HISTORY_TOKEN }} - run: python scripts/generate-star-history --repo "$GITHUB_REPOSITORY" + run: python scripts/generate_star_history.py --repo "$GITHUB_REPOSITORY" - name: Commit updated chart run: | diff --git a/scripts/generate-star-history b/scripts/generate_star_history.py similarity index 99% rename from scripts/generate-star-history rename to scripts/generate_star_history.py index 862250343..6da991026 100644 --- a/scripts/generate-star-history +++ b/scripts/generate_star_history.py @@ -21,8 +21,8 @@ can reach neither fails rather than drawing an invented curve. Usage: - GITHUB_TOKEN=... scripts/generate-star-history [--repo owner/name] [--out path] - scripts/generate-star-history --seed "2019-03-01:0,2026-07-29:5000" --out path + GITHUB_TOKEN=... python scripts/generate_star_history.py [--repo owner/name] [--out path] + python scripts/generate_star_history.py --seed "2019-03-01:0,2026-07-29:5000" --out path `--seed` renders a chart from explicit `date:count` points without touching the network — used to commit an initial chart and for offline testing; it From c42dc5740f0f0b445646e3e1a993fcc87f6478c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:26:58 +0000 Subject: [PATCH 4/4] docs: remove star history section and supporting files Drop the Star History section from the README and remove the weekly-commit workflow, generation script, and committed SVG. The weekly CI commit approach was deemed unnecessary overhead. --- .github/workflows/star-history.yml | 50 --- docs/README.md | 11 - docs/images/star-history.svg | 36 -- scripts/generate_star_history.py | 536 ----------------------------- 4 files changed, 633 deletions(-) delete mode 100644 .github/workflows/star-history.yml delete mode 100644 docs/images/star-history.svg delete mode 100644 scripts/generate_star_history.py diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml deleted file mode 100644 index 7aeb061cd..000000000 --- a/.github/workflows/star-history.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Star history chart - -## Regenerates the README's star-history chart. GitHub restricted -## detailed stargazer history to repo admins/collaborators, which broke -## star-history.com's unauthenticated embed — so we render the chart -## ourselves with the repo-scoped workflow token and commit the SVG. -on: - workflow_dispatch: - schedule: - ## Weekly, Monday 06:20 UTC — off the top of the hour to dodge the - ## scheduled-workflow load spike. - - cron: "20 6 * * 1" - -permissions: - contents: write - -jobs: - update-chart: - runs-on: ubuntu-latest - ## Scheduled runs on forks would fail (no stargazer access) and spam. - if: github.repository == 'commitizen-tools/commitizen' - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Generate chart - env: - GITHUB_TOKEN: ${{ github.token }} - ## Optional fallback: a collaborator PAT, only needed if GitHub - ## ever stops granting the workflow token stargazer access. - STAR_HISTORY_TOKEN: ${{ secrets.STAR_HISTORY_TOKEN }} - run: python scripts/generate_star_history.py --repo "$GITHUB_REPOSITORY" - - - name: Commit updated chart - run: | - ## Stage first so a brand-new (untracked) chart is detected too — - ## `git diff --quiet` alone ignores untracked files. - git add docs/images/star-history.svg docs/images/star-history.json - if git diff --staged --quiet; then - echo "chart unchanged, 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 commit -m "chore: update star history chart [skip ci]" - git push diff --git a/docs/README.md b/docs/README.md index 27a1cb573..dab631a76 100644 --- a/docs/README.md +++ b/docs/README.md @@ -303,17 +303,6 @@ After installation, you can verify the completion is working by: For more detailed information about argcomplete configuration and troubleshooting, visit the [argcomplete documentation](https://kislyuk.github.io/argcomplete/). -## Star History - - - - Star History Chart - - - ## Sponsors These are our cool sponsors! diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg deleted file mode 100644 index fabb09fa2..000000000 --- a/docs/images/star-history.svg +++ /dev/null @@ -1,36 +0,0 @@ - - Star history of commitizen-tools/commitizen - - - - - - - - - - - - - - - - - - - - commitizen-tools/commitizen - GitHub stars over time - 02,0004,0006,000 - Apr 2019Oct 2020Apr 2022Oct 2023Apr 2025 - - - - - - 5,500 stars - - - updated 2026-07-29 - - diff --git a/scripts/generate_star_history.py b/scripts/generate_star_history.py deleted file mode 100644 index 6da991026..000000000 --- a/scripts/generate_star_history.py +++ /dev/null @@ -1,536 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a static star-history SVG chart for the README. - -GitHub restricted detailed stargazer history (the `starred_at` timestamps -behind `Accept: application/vnd.github.star+json`) to repository admins and -collaborators, which broke star-history.com's unauthenticated README embed. -This script regenerates the chart from the GitHub API using an authenticated -token (the workflow's GITHUB_TOKEN) and writes a self-contained SVG that the -README references as a normal committed file. - -The chart renders as a dark "night sky" card so it reads well on both GitHub -light and dark themes. Everything is deterministic for a given (repo, data, -date) — the starfield is seeded from the repo name — so reruns with unchanged -data produce byte-identical output and the workflow's "chart unchanged" check -keeps working. - -Two data sources, in preference order. Exact per-star history rebuilds the -whole curve and is used whenever the stargazers endpoint is reachable. When it -refuses, the run keeps the history banked in `--series` and extends it with -the repo's `stargazers_count`, which needs only `metadata=read`. A run that -can reach neither fails rather than drawing an invented curve. - -Usage: - GITHUB_TOKEN=... python scripts/generate_star_history.py [--repo owner/name] [--out path] - python scripts/generate_star_history.py --seed "2019-03-01:0,2026-07-29:5000" --out path - -`--seed` renders a chart from explicit `date:count` points without touching -the network — used to commit an initial chart and for offline testing; it -neither reads nor writes the series. - -Stdlib only; no dependencies. -""" - -from __future__ import annotations - -import argparse -import json -import math -import os -import sys -import urllib.error -import urllib.request -import zlib -from datetime import date, datetime, timezone - -API_ROOT = "https://api.github.com" -# The stargazers endpoint is capped at 400 pages (40k stars) by GitHub. -MAX_PAGES = 400 -PER_PAGE = 100 - -WIDTH = 700 -HEIGHT = 360 -MARGIN_LEFT = 64 -MARGIN_RIGHT = 24 -MARGIN_TOP = 56 -MARGIN_BOTTOM = 52 - -# Night-sky palette; the card supplies its own background so the same SVG -# works on GitHub light and dark themes alike. -CARD_TOP = "#101527" -CARD_BOTTOM = "#1a2033" -CARD_BORDER = "#2e3650" -TEXT_BRIGHT = "#e6edf3" -TEXT_MUTED = "#8b95a7" -GRID_COLOR = "#4a5470" -LINE_START = "#4f9cff" -LINE_END = "#ffd166" -GOLD = "#ffd166" - - -class StargazerFetchError(SystemExit): - """A refused stargazers request, carrying the full diagnostic. - - Derives from SystemExit so an uncaught one still exits 1 with the message - printed, while callers that have a stored series can catch it specifically - and carry on. - """ - - -def describe_http_error(err: urllib.error.HTTPError, url: str, token_source: str) -> str: - """Explain a refused stargazers request using GitHub's own answer.""" - try: - api_message = json.loads(err.read().decode("utf-8", "replace")).get("message") - except (ValueError, OSError): - api_message = None - - headers = err.headers or {} - remaining = headers.get("x-ratelimit-remaining") - retry_after = headers.get("retry-after") - throttled_message = bool(api_message) and "rate limit" in api_message.lower() - rate_limited = err.code == 429 or ( - err.code == 403 and (remaining == "0" or retry_after is not None or throttled_message) - ) - - lines = [ - f"GitHub API returned {err.code} for {url}.", - f'GitHub says: "{api_message}"' if api_message else "GitHub returned no message body.", - f"Token used: {token_source}.", - ] - - if rate_limited: - detail = [] - if remaining is not None: - detail.append(f"{remaining} requests remaining") - reset = headers.get("x-ratelimit-reset") - if reset and reset.isdigit(): - detail.append( - "resets " - + datetime.fromtimestamp(int(reset), timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - ) - if retry_after: - detail.append(f"retry after {retry_after}s") - suffix = f" ({'; '.join(detail)})" if detail else "" - lines.append(f"Rate limited{suffix}. Retry later.") - elif err.code == 401: - lines.append( - "The credential itself was rejected — the token is expired, revoked, " - "or malformed. Issue a new collaborator PAT and update the " - "STAR_HISTORY_TOKEN secret." - ) - elif err.code == 403: - lines.append( - "The token is live but not authorized for this repo's stargazer " - "history. Check, in order: the org's personal-access-token policy " - "(a policy change can revoke a working token), whether the token's " - "approval or repository-access grant was withdrawn, and whether it " - "has expired. Note the workflow's built-in GITHUB_TOKEN is a GitHub " - "App token, not a collaborator, and is always refused here." - ) - elif err.code == 404: - lines.append( - f"The token cannot see {url.split('/repos/')[-1].split('/stargazers')[0]} " - "at all — check the repo name and the token's repository access." - ) - - return "\n".join(lines) - - -def fetch_star_dates(repo: str, token: str, token_source: str = "STAR_HISTORY_TOKEN") -> list[date]: - """Return the starred_at date of every stargazer, oldest first.""" - dates: list[date] = [] - for page in range(1, MAX_PAGES + 1): - url = f"{API_ROOT}/repos/{repo}/stargazers?per_page={PER_PAGE}&page={page}" - req = urllib.request.Request( - url, - headers={ - "Accept": "application/vnd.github.star+json", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - }, - ) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - batch = json.load(resp) - except urllib.error.HTTPError as err: - if 400 <= err.code < 500: - raise StargazerFetchError(describe_http_error(err, url, token_source)) from err - raise - if not batch: - break - for entry in batch: - starred_at = entry.get("starred_at") - if starred_at: - dates.append(datetime.fromisoformat(starred_at.replace("Z", "+00:00")).date()) - if len(batch) < PER_PAGE: - break - dates.sort() - return dates - - -def fetch_star_count(repo: str, token: str, token_source: str) -> int: - """Return the repo's current stargazers_count. - - Deliberately a different endpoint from fetch_star_dates: /repos/{repo} - answers on `metadata=read` alone and kept working even when the stargazers - list started demanding `contents=write`. It gives a total but no history, - which is why the series file exists. - """ - url = f"{API_ROOT}/repos/{repo}" - req = urllib.request.Request( - url, - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - }, - ) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - payload = json.load(resp) - except urllib.error.HTTPError as err: - if 400 <= err.code < 500: - raise StargazerFetchError(describe_http_error(err, url, token_source)) from err - raise - count = payload.get("stargazers_count") - if not isinstance(count, int): - raise StargazerFetchError(f"{url} returned no stargazers_count") - return count - - -def load_series(path: str, repo: str) -> list[tuple[date, int]]: - """Read the accumulated (date, count) series for `repo`, or [] if absent.""" - try: - with open(path, encoding="utf-8") as fh: - raw = fh.read() - except FileNotFoundError: - return [] - if not raw.strip(): - return [] - try: - payload = json.loads(raw) - except ValueError as err: - raise SystemExit(f"{path} is not valid JSON: {err}") from err - - stored = payload.get("repo") - if stored and stored != repo: - raise SystemExit( - f"{path} holds the series for {stored}, not {repo}. Point --series " - f"at that repo's file, or delete it to start a fresh series — " - f"grafting one repo's history onto another would corrupt both." - ) - points = [(date.fromisoformat(d), int(c)) for d, c in payload.get("points", [])] - return sorted(points) - - -def save_series(path: str, repo: str, points: list[tuple[date, int]]) -> None: - """Write the series, stamped with the repo it belongs to.""" - payload = { - "repo": repo, - "points": [[d.isoformat(), c] for d, c in points], - } - with open(path, "w", encoding="utf-8") as fh: - json.dump(payload, fh, indent=1) - fh.write("\n") - - -def append_today(series: list[tuple[date, int]], count: int, today: date) -> list[tuple[date, int]]: - """Extend the series with today's total, replacing a same-day point.""" - points = [(d, c) for d, c in series if d != today] - points.append((today, count)) - return sorted(points) - - -def cumulative_points(star_dates: list[date]) -> list[tuple[date, int]]: - """Collapse per-star dates into (date, cumulative_count) steps.""" - points: list[tuple[date, int]] = [] - count = 0 - for day in star_dates: - count += 1 - if points and points[-1][0] == day: - points[-1] = (day, count) - else: - points.append((day, count)) - today = datetime.now(timezone.utc).date() - if points and points[-1][0] != today: - points.append((today, count)) - return points - - -def parse_seed(seed: str) -> list[tuple[date, int]]: - points = [] - for chunk in seed.split(","): - day, _, count = chunk.strip().partition(":") - points.append((date.fromisoformat(day), int(count))) - return sorted(points) - - -def _nice_axis(value: int) -> tuple[int, int]: - """Pick a (y_max, step) so the curve fills the plot.""" - value = max(value, 1) - headroom = value * 1.06 - best: tuple[int, int] | None = None - magnitude = 1 - while magnitude <= headroom * 10: - for factor in (1.0, 2.0, 2.5, 5.0): - step = factor * magnitude - if step != int(step): - continue - step = int(step) - intervals = -(-int(headroom + 1) // step) # ceil - if 3 <= intervals <= 5: - y_max = step * intervals - if best is None or y_max < best[0] or (y_max == best[0] and step > best[1]): - best = (y_max, step) - magnitude *= 10 - if best is None: - y_max = value + 1 - return y_max, max(1, y_max // 4) - return best - - -def _fmt(n: int) -> str: - return f"{n:,}" - - -def _starfield(repo: str) -> str: - """Deterministic background stars, seeded from the repo name.""" - state = zlib.crc32(repo.encode()) or 1 - - def rnd() -> float: - nonlocal state - state = (state * 1103515245 + 12345) % (1 << 31) - return state / (1 << 31) - - parts = [] - for _ in range(60): - x = round(8 + rnd() * (WIDTH - 16), 1) - y = round(8 + rnd() * (HEIGHT - 16), 1) - r = round(0.4 + rnd() * 0.9, 2) - opacity = round(0.12 + rnd() * 0.55, 2) - tint = "#cfe1ff" if rnd() < 0.7 else "#ffe9b8" - parts.append(f'') - for i in range(4): - x = round(8 + rnd() * (WIDTH - 16), 1) - y = round(8 + rnd() * (HEIGHT * 0.45), 1) - parts.append( - f'' - f'' - "" - ) - return "".join(parts) - - -def _star_path(cx: float, cy: float, r: float) -> str: - """Five-point star path centered on (cx, cy).""" - pts = [] - for i in range(10): - radius = r if i % 2 == 0 else r * 0.42 - angle = math.pi / 2 + i * math.pi / 5 - pts.append(f"{cx + radius * math.cos(angle):.1f},{cy - radius * math.sin(angle):.1f}") - return "M" + " L".join(pts) + " Z" - - -def _month_ticks(x0: int, x1: int) -> list[tuple[date, str]]: - """Ticks at month starts, thinned to at most 5.""" - firsts = [] - d = date.fromordinal(x0) - year, month = d.year, d.month - while True: - month += 1 - if month == 13: - year, month = year + 1, 1 - first = date(year, month, 1) - if first.toordinal() > x1: - break - firsts.append(first) - if len(firsts) >= 2: - step = -(-len(firsts) // 5) - firsts = firsts[::step] - labels = [] - prev_year = None - for d in firsts: - labels.append( - (d, d.strftime("%b %Y") if d.year != prev_year else d.strftime("%b")) - ) - prev_year = d.year - return labels - span = max(x1 - x0, 1) - ticks = [] - for i in range(4): - day = date.fromordinal(x0 + round(span * i / 3)) - ticks.append((day, day.strftime("%b %d"))) - return ticks - - -def render_svg(points: list[tuple[date, int]], repo: str) -> str: - if len(points) < 2: - raise SystemExit("need at least 2 data points to draw a chart") - - x0, x1 = points[0][0].toordinal(), points[-1][0].toordinal() - x_span = max(x1 - x0, 1) - y_max, y_step = _nice_axis(points[-1][1]) - plot_w = WIDTH - MARGIN_LEFT - MARGIN_RIGHT - plot_h = HEIGHT - MARGIN_TOP - MARGIN_BOTTOM - baseline = MARGIN_TOP + plot_h - - def px(d: date) -> float: - return MARGIN_LEFT + (d.toordinal() - x0) / x_span * plot_w - - def py(count: int) -> float: - return MARGIN_TOP + plot_h - (count / y_max) * plot_h - - poly = " ".join(f"{px(d):.1f},{py(c):.1f}" for d, c in points) - area = ( - f"{px(points[0][0]):.1f},{baseline:.1f} " - + poly - + f" {px(points[-1][0]):.1f},{baseline:.1f}" - ) - - grid_lines = [] - for value in range(0, y_max + 1, y_step): - y = py(value) - dash = "" if value == 0 else ' stroke-dasharray="3 5"' - opacity = "0.9" if value == 0 else "0.45" - grid_lines.append( - f'' - f'{_fmt(value)}' - ) - - x_labels = [] - for day, label in _month_ticks(x0, x1): - x = px(day) - x_labels.append( - f'' - f'{label}' - ) - - last_date, last_count = points[-1] - end_x, end_y = px(last_date), py(last_count) - badge_text = f"{_fmt(last_count)} stars" - badge_w = round(len(badge_text) * 7.0 + 20) - badge_h = 22 - badge_x = end_x - 14 - badge_w - badge_y = min(max(end_y - badge_h / 2, MARGIN_TOP - 18), baseline - badge_h) - - generated = datetime.now(timezone.utc).strftime("%Y-%m-%d") - parts = [ - f'\n', - f" Star history of {repo}\n", - " \n", - ' \n', - f' ', - f'\n', - " \n", - ' \n', - f' ', - f'\n', - " \n", - ' \n', - f' \n', - f' \n', - " \n", - ' \n', - ' \n', - " \n", - " \n", - f' \n', - f" {_starfield(repo)}\n", - " \n', - f' \n', - ' {repo}\n', - f' GitHub stars over time\n ', - "".join(grid_lines), - "\n ", - "".join(x_labels), - f'\n \n', - f' \n', - f' \n', - f' \n', - f' \n', - f' {badge_text}\n', - f' \n', - f' \n', - f' ', - f"updated {generated}\n", - " \n\n", - ] - return "".join(parts) - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo", default="commitizen-tools/commitizen") - parser.add_argument("--out", default="docs/images/star-history.svg") - parser.add_argument( - "--series", - default="docs/images/star-history.json", - help="accumulated date:count points, carried between runs so the " - "chart survives the stargazers endpoint being unavailable", - ) - parser.add_argument( - "--seed", - help="comma-separated date:count points to render offline " - '(e.g. "2019-03-01:0,2026-07-29:5000")', - ) - args = parser.parse_args() - - if args.seed: - points = parse_seed(args.seed) - else: - token_source = "STAR_HISTORY_TOKEN" - token = os.environ.get("STAR_HISTORY_TOKEN") - if not token: - token_source = "GITHUB_TOKEN (fallback)" - token = os.environ.get("GITHUB_TOKEN") - if not token: - raise SystemExit("set GITHUB_TOKEN (or STAR_HISTORY_TOKEN), or use --seed") - - series = load_series(args.series, args.repo) - try: - star_dates = fetch_star_dates(args.repo, token, token_source) - if not star_dates: - raise SystemExit(f"no stargazer data returned for {args.repo}") - points = cumulative_points(star_dates) - except StargazerFetchError as err: - if not series: - raise - print( - f"stargazer history unavailable, extending the stored series " - f"with today's total instead.\n{err}", - file=sys.stderr, - ) - count = fetch_star_count(args.repo, token, token_source) - points = append_today(series, count, datetime.now(timezone.utc).date()) - - save_series(args.series, args.repo, points) - - svg = render_svg(points, args.repo) - with open(args.out, "w", encoding="utf-8") as fh: - fh.write(svg) - print(f"wrote {args.out} ({len(points)} points, {points[-1][1]} stars)") - - -if __name__ == "__main__": - main()