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
-[](https://star-history.com/#commitizen-tools/commitizen)
+
+
+
+
## 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 @@
+
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",
+ ]
+ 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
-
-
-
-
-
-
-
## 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 @@
-
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",
- ]
- 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()