diff --git a/.github/workflows/star-history.yaml b/.github/workflows/star-history.yaml
index 6a2e4a6a5..4d3e028b6 100644
--- a/.github/workflows/star-history.yaml
+++ b/.github/workflows/star-history.yaml
@@ -7,6 +7,7 @@ on:
- .github/workflows/star-history.yaml
- scripts/star_history.py
- scripts/star_history_lib/**
+ - scripts/test_star_history.py
schedule:
- cron: '23 4 * * *'
@@ -27,7 +28,6 @@ env:
RELEASE_TAG: star-history/latest
OUTPUT_DIR: dist/star-history
SCALE: '2'
- PYNACL_VERSION: '1.6.2'
jobs:
publish:
@@ -43,20 +43,19 @@ jobs:
with:
python-version: '3.x'
- - name: Install chart dependencies
+ - name: Run chart unit tests
run: |
set -euo pipefail
- python3 -m pip install --disable-pip-version-check --quiet \
- "pynacl==$PYNACL_VERSION"
+ python3 -m unittest discover -s scripts -p 'test_star_history.py'
- - name: Mirror upstream charts
+ - name: Render star history charts
env:
- STAR_HISTORY_TOKEN: ${{ secrets.STAR_HISTORY_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
- python3 scripts/star_history.py mirror \
+ python3 scripts/star_history.py build \
--repository "$GITHUB_REPOSITORY" \
--output-dir "$OUTPUT_DIR" \
> manifest.json
@@ -100,10 +99,13 @@ jobs:
ASSET_LIST=$(python3 scripts/star_history.py assets --manifest manifest.json)
mapfile -t ASSETS <<< "$ASSET_LIST"
+ STARS=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["stars"])' manifest.json)
+
printf '%s\n' \
- "Star history charts for \`$GITHUB_REPOSITORY\`, sanitized and mirrored from star-history.com." \
+ "Star history charts for \`$GITHUB_REPOSITORY\`, rendered from the GitHub stargazer API." \
"" \
"- **Updated:** $UPDATED_AT" \
+ "- **Stars:** $STARS" \
"" \
"The \`.png\` assets are the ones embedded in the README. GitHub serves" \
"release assets as \`application/octet-stream\`, which its image proxy" \
diff --git a/scripts/star_history_lib/assets/README.md b/scripts/star_history_lib/assets/README.md
new file mode 100644
index 000000000..35d6eea36
--- /dev/null
+++ b/scripts/star_history_lib/assets/README.md
@@ -0,0 +1,16 @@
+# Chart assets
+
+## `xkcd-script.woff`
+
+The handwriting face the star history charts are drawn in. It is embedded into
+every generated SVG as a `data:` URI so the charts rasterize identically on any
+runner and stay self-contained when viewed on their own.
+
+- **Font:** xkcd Script, version 1.0
+- **Upstream:** https://github.com/ipython/xkcd-font
+- **License:** Creative Commons Attribution-NonCommercial 3.0
+
+Note the **NonCommercial** term before reusing the generated charts in a
+commercial context. These are the same font bytes the previously mirrored
+star-history.com charts already carried, so publishing them is not a change in
+what this repository distributes.
diff --git a/scripts/star_history_lib/assets/xkcd-script.woff b/scripts/star_history_lib/assets/xkcd-script.woff
new file mode 100644
index 000000000..9fa3f8807
Binary files /dev/null and b/scripts/star_history_lib/assets/xkcd-script.woff differ
diff --git a/scripts/star_history_lib/chart.py b/scripts/star_history_lib/chart.py
new file mode 100644
index 000000000..fb099095a
--- /dev/null
+++ b/scripts/star_history_lib/chart.py
@@ -0,0 +1,332 @@
+"""Renders star history charts in star-history.com's hand-drawn style.
+
+The layout, the xkcdify turbulence filter and the type scale are matched to the
+charts this repository previously mirrored from star-history.com, so the README
+keeps the look it had. Their watermark and their logo are deliberately left out:
+these charts are built from the GitHub API, so crediting their service would
+misstate where the data came from. The repository owner's avatar takes the place
+their mark used to occupy.
+"""
+
+import base64
+import functools
+import math
+from datetime import datetime, timedelta
+from pathlib import Path
+from xml.sax.saxutils import escape
+
+from . import ChartError
+
+
+WIDTH = 800
+HEIGHT = 533.333
+# Chrome needs whole pixels for the screenshot viewport.
+PAGE_WIDTH = 800
+PAGE_HEIGHT = 534
+
+PLOT_ORIGIN = (70, 60)
+PLOT_WIDTH = 700
+PLOT_HEIGHT = 423.333
+# Where a count of zero sits inside the translated plot group.
+BASELINE = 423.833
+
+FONT_FAMILY = "xkcd"
+FONT_PATH = Path(__file__).parent / "assets" / "xkcd-script.woff"
+
+# The owner avatar sits left of the centred title, circular-clipped, at the same
+# coordinates star-history used for its own mark.
+LOGO_SIZE = 22
+LOGO_X = 316
+LOGO_Y = 12
+LOGO_CLIP_ID = "clip-circle-title"
+
+TITLE = "Star History"
+TITLE_SIZE = 20
+TITLE_BASELINE = 30
+
+PALETTES = {
+ "light": {"background": "#fff", "ink": "#000", "series": "#dd4528"},
+ "dark": {"background": "#0d1117", "ink": "#fff", "series": "#ff6b6b"},
+}
+
+# star-history plots a sampled curve rather than every star; sampling first is
+# what lets the spline read as a trend instead of tracing every single star.
+MAX_CHART_POINTS = 11
+X_TICK_TARGET = 5
+Y_TICK_TARGET = 6
+MONTH_STEPS = (1, 3, 6, 12)
+DAYS_PER_MONTH = 30.44
+WEEK_SPAN_DAYS = 14
+DAY_SPAN_DAYS = 60
+
+LEGEND_TEXT_ORIGIN = 29
+LEGEND_CHARACTER_WIDTH = 7.5
+
+E10 = math.sqrt(50)
+E5 = math.sqrt(10)
+E2 = math.sqrt(2)
+
+
+def palette_of(theme: str) -> dict:
+ try:
+ return PALETTES[theme]
+ except KeyError:
+ raise ChartError(f"unknown chart theme '{theme}'") from None
+
+
+@functools.lru_cache(maxsize=1)
+def font_face() -> str:
+ try:
+ woff = FONT_PATH.read_bytes()
+ except OSError as error:
+ raise ChartError(f"the embedded chart font is missing: {error}") from error
+ encoded = base64.b64encode(woff).decode("ascii")
+ return (
+ f'@font-face{{font-family:"{FONT_FAMILY}";'
+ f"src:url(data:application/font-woff;charset=utf-8;base64,{encoded})"
+ 'format("woff")}'
+ )
+
+
+def number(value: float) -> str:
+ return f"{round(value, 3):g}"
+
+
+def tick_step(maximum: float, count: int) -> float:
+ """d3's tickIncrement, so the axis lands on the same numbers d3 would pick."""
+ raw = maximum / max(1, count)
+ if raw <= 0:
+ return 1
+ power = math.floor(math.log10(raw))
+ error = raw / 10**power
+ if error >= E10:
+ factor = 10
+ elif error >= E5:
+ factor = 5
+ elif error >= E2:
+ factor = 2
+ else:
+ factor = 1
+ return factor * 10**power
+
+
+def value_ticks(maximum: int) -> list[int]:
+ step = max(1, round(tick_step(maximum, Y_TICK_TARGET)))
+ return [tick for tick in range(0, maximum + 1, step)]
+
+
+def month_step(span: timedelta) -> int:
+ months = span.days / DAYS_PER_MONTH
+ for step in MONTH_STEPS:
+ if months / step <= X_TICK_TARGET:
+ return step
+ return MONTH_STEPS[-1]
+
+
+def month_ticks(start: datetime, end: datetime, step: int) -> list[datetime]:
+ ticks = []
+ cursor = start.year * 12 + start.month - 1
+ while True:
+ year, month = divmod(cursor, 12)
+ moment = datetime(year, month + 1, 1, tzinfo=start.tzinfo)
+ if moment > end:
+ return ticks
+ if moment >= start and month % step == 0:
+ ticks.append(moment)
+ cursor += 1
+
+
+def spaced_ticks(start: datetime, end: datetime, stride: timedelta) -> list[datetime]:
+ ticks = []
+ moment = start.replace(hour=0, minute=0, second=0, microsecond=0) + stride
+ while moment <= end:
+ ticks.append(moment)
+ moment += stride
+ return ticks
+
+
+def time_ticks(start: datetime, end: datetime) -> list[tuple[datetime, str]]:
+ span = end - start
+ if span.days <= WEEK_SPAN_DAYS:
+ return [(tick, f"{tick:%b %d}") for tick in spaced_ticks(start, end, timedelta(days=2))]
+ if span.days <= DAY_SPAN_DAYS:
+ return [(tick, f"{tick:%b %d}") for tick in spaced_ticks(start, end, timedelta(days=7))]
+
+ ticks = month_ticks(start, end, month_step(span))
+ return [
+ (tick, f"{tick:%Y}" if tick.month == 1 else f"{tick:%B}") for tick in ticks
+ ]
+
+
+def downsample(points: list[tuple], limit: int = MAX_CHART_POINTS) -> list[tuple]:
+ if len(points) <= limit:
+ return points
+ stride = (len(points) - 1) / (limit - 1)
+ return [points[round(index * stride)] for index in range(limit)]
+
+
+def cubic(before: tuple, current: tuple, following: tuple) -> str:
+ """One uniform cubic B-spline span, in d3 curveBasis' bezier form."""
+ x0, y0 = before
+ x1, y1 = current
+ x2, y2 = following
+ return (
+ f"C{number((2 * x0 + x1) / 3)} {number((2 * y0 + y1) / 3)}"
+ f" {number((x0 + 2 * x1) / 3)} {number((y0 + 2 * y1) / 3)}"
+ f" {number((x0 + 4 * x1 + x2) / 6)} {number((y0 + 4 * y1 + y2) / 6)}"
+ )
+
+
+def basis_path(coordinates: list[tuple[float, float]]) -> str:
+ """A B-spline through the samples, matching d3's curveBasis.
+
+ The spline approximates rather than interpolates, which is what smooths the
+ corners a plain interpolation leaves behind. Being variation-diminishing, it
+ still cannot dip below a series that only ever rises.
+ """
+ first = coordinates[0]
+ path = f"M{number(first[0])} {number(first[1])}"
+ if len(coordinates) == 1:
+ return path
+ if len(coordinates) == 2:
+ last = coordinates[1]
+ return f"{path}L{number(last[0])} {number(last[1])}"
+
+ second = coordinates[1]
+ path += (
+ f"L{number((5 * first[0] + second[0]) / 6)}"
+ f" {number((5 * first[1] + second[1]) / 6)}"
+ )
+ for index in range(2, len(coordinates)):
+ path += cubic(coordinates[index - 2], coordinates[index - 1], coordinates[index])
+
+ last = coordinates[-1]
+ path += cubic(coordinates[-2], last, last)
+ return f"{path}L{number(last[0])} {number(last[1])}"
+
+
+def projector(start: datetime, end: datetime, maximum: int):
+ span = (end - start).total_seconds() or 1.0
+
+ def project(moment: datetime, value: float) -> tuple[float, float]:
+ x = (moment - start).total_seconds() / span * PLOT_WIDTH
+ y = BASELINE - (value / maximum if maximum else 0) * PLOT_HEIGHT
+ return x, y
+
+ return project
+
+
+def x_axis(ticks: list[tuple[datetime, str]], project, ink: str) -> str:
+ labels = "".join(
+ f''
+ f"{escape(label)}"
+ for moment, label in ticks
+ )
+ return (
+ ''
+ f''
+ f"{labels}"
+ )
+
+
+def y_axis(ticks: list[int], maximum: int, ink: str) -> str:
+ rows = ""
+ for tick in ticks:
+ y = BASELINE - (tick / maximum if maximum else 0) * PLOT_HEIGHT
+ # star-history blanks the zero label so it does not collide with the x axis.
+ label = " " if tick == 0 else str(tick)
+ rows += (
+ ''
+ f''
+ f'{escape(label)}'
+ )
+ return (
+ ''
+ f''
+ f"{rows}"
+ )
+
+
+def legend(repository: str, colors: dict) -> str:
+ box = LEGEND_TEXT_ORIGIN + LEGEND_CHARACTER_WIDTH * len(repository)
+ return (
+ f''
+ f''
+ f'{escape(repository)}'
+ )
+
+
+def logo(avatar: str) -> str:
+ if not avatar:
+ return ""
+ radius = LOGO_SIZE / 2
+ return (
+ f''
+ f''
+ f''
+ )
+
+
+def titles(ink: str, avatar: str) -> str:
+ return (
+ f"{logo(avatar)}"
+ f'{escape(TITLE)}'
+ f'Date'
+ f'GitHub Stars'
+ )
+
+
+def render(repository: str, timeline: dict, theme: str) -> str:
+ points = downsample(timeline["points"])
+ if not points:
+ raise ChartError(f"no stargazer points to chart for {repository}")
+
+ colors = palette_of(theme)
+ start, end = points[0][0], points[-1][0]
+ if end <= start:
+ end = start + timedelta(days=1)
+
+ maximum = max(timeline["total"], max(count for _, count in points))
+ project = projector(start, end, maximum)
+ curve = basis_path([project(moment, count) for moment, count in points])
+
+ return (
+ f'"
+ )
diff --git a/scripts/star_history_lib/cli.py b/scripts/star_history_lib/cli.py
index d711dbe2a..4f0f0659c 100644
--- a/scripts/star_history_lib/cli.py
+++ b/scripts/star_history_lib/cli.py
@@ -2,32 +2,30 @@
import json
import os
import sys
+from datetime import datetime, timezone
from . import ChartError
-from .client import CHART_ENDPOINT, chart_url, fetch_svg
+from .chart import PAGE_HEIGHT, PAGE_WIDTH, palette_of, render
from .raster import asset_paths, rasterize, write_raster_page
-from .seal import seal_token
-from .svg import background_of, sanitize_svg, viewport_of
+from .stars import API_ROOT, star_timeline
THEMES = ("light", "dark")
-def add_mirror_parser(subparsers) -> None:
- parser = subparsers.add_parser("mirror")
+def add_build_parser(subparsers) -> None:
+ parser = subparsers.add_parser("build")
parser.add_argument("--repository", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--basename", default="star-history")
- parser.add_argument("--token", default=os.environ.get("STAR_HISTORY_TOKEN", ""))
- parser.add_argument("--chart-type", default="date")
- parser.add_argument("--legend", default="top-left")
- parser.set_defaults(handler=mirror)
+ parser.add_argument("--token", default=os.environ.get("GITHUB_TOKEN", ""))
+ parser.set_defaults(handler=build)
def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Mirror star-history.com charts")
+ parser = argparse.ArgumentParser(description="Chart a repository's star history")
subparsers = parser.add_subparsers(required=True)
- add_mirror_parser(subparsers)
+ add_build_parser(subparsers)
raster_parser = subparsers.add_parser("rasterize")
raster_parser.add_argument("--manifest", required=True)
@@ -41,65 +39,55 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args()
-def fetch_sanitized_svg(url: str) -> str:
- return sanitize_svg(fetch_svg(url))
+def write_chart(args: argparse.Namespace, timeline: dict, theme: str) -> dict:
+ svg = render(args.repository, timeline, theme)
+ svg_path = os.path.join(args.output_dir, f"{args.basename}-{theme}.svg")
+ png_path = os.path.join(args.output_dir, f"{args.basename}-{theme}.png")
+ with open(svg_path, "w", encoding="utf-8") as handle:
+ handle.write(svg)
+ background = palette_of(theme)["background"]
+ return {
+ "theme": theme,
+ "svg": svg_path,
+ "png": png_path,
+ "raster_page": write_raster_page(
+ svg_path, background, PAGE_WIDTH, PAGE_HEIGHT
+ ),
+ "background": background,
+ "width": PAGE_WIDTH,
+ "height": PAGE_HEIGHT,
+ "bytes": len(svg.encode("utf-8")),
+ }
-def build_manifest(args: argparse.Namespace) -> dict:
- if args.repository.count("/") != 1:
- raise ChartError(f"--repository must be owner/name, got '{args.repository}'")
+def build_manifest(args: argparse.Namespace, now: datetime) -> dict:
os.makedirs(args.output_dir, exist_ok=True)
- charts = []
- assets = []
-
- sealed = seal_token(args.token) if args.token else ""
- if not sealed:
- print(
- "::warning::No STAR_HISTORY_TOKEN set; falling back to the shared "
- "star-history.com token pool, which is often rate-limited",
- file=sys.stderr,
- )
+ timeline = star_timeline(args.repository, args.token, now)
- for theme in THEMES:
- url = chart_url(
- args.repository, theme, sealed, args.chart_type, args.legend
- )
- svg = fetch_sanitized_svg(url)
- svg_path = os.path.join(args.output_dir, f"{args.basename}-{theme}.svg")
- png_path = os.path.join(args.output_dir, f"{args.basename}-{theme}.png")
- with open(svg_path, "w", encoding="utf-8") as handle:
- handle.write(svg)
-
- width, height = viewport_of(svg, theme)
- background = background_of(svg, theme)
- charts.append(
- {
- "theme": theme,
- "svg": svg_path,
- "png": png_path,
- "raster_page": write_raster_page(
- svg_path, background, width, height
- ),
- "background": background,
- "width": width,
- "height": height,
- "bytes": len(svg.encode("utf-8")),
- }
- )
- assets.extend((svg_path, png_path))
+ charts = [write_chart(args, timeline, theme) for theme in THEMES]
+ assets = [path for chart in charts for path in (chart["svg"], chart["png"])]
return {
"repository": args.repository,
- "source": CHART_ENDPOINT,
+ "source": f"{API_ROOT}/repos/{args.repository}/stargazers",
"authenticated": bool(args.token),
+ "stars": timeline["total"],
+ "sampled": timeline["sampled"],
+ "points": len(timeline["points"]),
+ "generated_at": now.isoformat(timespec="seconds"),
"assets": assets,
"charts": charts,
}
-def mirror(args: argparse.Namespace) -> int:
- print(json.dumps(build_manifest(args)))
+def build(args: argparse.Namespace) -> int:
+ if not args.token:
+ raise ChartError(
+ "no GitHub token available; the workflow must pass GITHUB_TOKEN so the "
+ "stargazer pages can be read without hitting the anonymous rate limit"
+ )
+ print(json.dumps(build_manifest(args, datetime.now(timezone.utc))))
return 0
diff --git a/scripts/star_history_lib/client.py b/scripts/star_history_lib/client.py
deleted file mode 100644
index e6ade0d2d..000000000
--- a/scripts/star_history_lib/client.py
+++ /dev/null
@@ -1,92 +0,0 @@
-import re
-import sys
-import time
-import urllib.error
-import urllib.parse
-import urllib.request
-
-from . import ChartError
-
-
-CHART_ENDPOINT = "https://api.star-history.com/chart"
-USER_AGENT = "opentaint-star-history"
-REQUEST_TIMEOUT = 60
-RETRIES = 3
-RETRY_BACKOFF_SECONDS = 5
-MIN_CHART_BYTES = 2048
-ERROR_BODY_LIMIT = 200
-
-TERMINAL_STATUS_HINTS = {
- 400: (
- "the sealed token was rejected before it was decrypted; star-history.com "
- "may have rotated the key in star_history_lib.seal.SEALING_PUBLIC_KEY"
- ),
- 401: (
- "the GitHub token was decrypted but refused; refresh the "
- "STAR_HISTORY_TOKEN secret with a valid personal access token"
- ),
-}
-
-
-def chart_url(
- repository: str, theme: str, token: str, chart_type: str, legend: str
-) -> str:
- query = [("repos", repository), ("type", chart_type), ("legend", legend)]
- if theme != "light":
- query.append(("theme", theme))
- if token:
- query.append(("sealed_token", token))
- return f"{CHART_ENDPOINT}?{urllib.parse.urlencode(query)}"
-
-
-def redact(url: str) -> str:
- return re.sub(r"(sealed_token=)[^&]*", r"\1***", url)
-
-
-def describe_http_error(error: urllib.error.HTTPError) -> str:
- try:
- body = error.read().decode("utf-8", "replace").strip()
- except OSError:
- body = ""
- if not body:
- return f"HTTP {error.code}"
- return f"HTTP {error.code}: {redact(body[:ERROR_BODY_LIMIT])}"
-
-
-def fetch_svg(url: str) -> str:
- request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
- last_error = ""
-
- for attempt in range(1, RETRIES + 1):
- try:
- with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
- content_type = response.headers.get("Content-Type", "")
- body = response.read().decode("utf-8")
- except urllib.error.HTTPError as error:
- last_error = describe_http_error(error)
- hint = TERMINAL_STATUS_HINTS.get(error.code)
- if hint:
- raise ChartError(f"{redact(url)} failed: {last_error} — {hint}")
- except (urllib.error.URLError, TimeoutError) as error:
- last_error = str(error)
- except UnicodeDecodeError as error:
- raise ChartError(
- f"{redact(url)} returned non-text data: {error}"
- ) from error
- else:
- if "svg" not in content_type:
- last_error = f"unexpected Content-Type '{content_type}'"
- elif len(body) < MIN_CHART_BYTES:
- last_error = f"chart is suspiciously small ({len(body)} bytes)"
- else:
- return body
-
- if attempt < RETRIES:
- print(
- f"::warning::Attempt {attempt}/{RETRIES} for {redact(url)} failed: "
- f"{last_error}; retrying",
- file=sys.stderr,
- )
- time.sleep(RETRY_BACKOFF_SECONDS * attempt)
-
- raise ChartError(f"{redact(url)} failed after {RETRIES} attempts: {last_error}")
diff --git a/scripts/star_history_lib/seal.py b/scripts/star_history_lib/seal.py
deleted file mode 100644
index 77bc7b082..000000000
--- a/scripts/star_history_lib/seal.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import base64
-import binascii
-import hashlib
-
-from . import ChartError
-
-
-SEALING_PUBLIC_KEY = "vP9uLIF/19VVvLUEZMms8LXJo/RCjRys/Z9S543eG10="
-
-NONCE_BYTES = 24
-PUBLIC_KEY_BYTES = 32
-
-GITHUB_TOKEN_PREFIXES = (
- "ghp_",
- "gho_",
- "ghu_",
- "ghs_",
- "ghr_",
- "github_pat_",
-)
-
-
-def require_nacl():
- try:
- from nacl.public import Box, PrivateKey, PublicKey
- except ImportError as error:
- raise ChartError(
- "sealing a token requires PyNaCl; install it with 'pip install pynacl'"
- ) from error
- return Box, PrivateKey, PublicKey
-
-
-def validate_token(token: str) -> None:
- if token != token.strip():
- raise ChartError("the GitHub token has leading or trailing whitespace")
- if not token.startswith(GITHUB_TOKEN_PREFIXES):
- raise ChartError(
- "the GitHub token must be a personal access token starting with one "
- f"of {', '.join(GITHUB_TOKEN_PREFIXES)}. The workflow seals the token "
- "itself, so store the bare token rather than a pre-sealed one."
- )
-
-
-def decode_public_key(public_key: str) -> bytes:
- try:
- key = base64.b64decode(public_key, validate=True)
- except (binascii.Error, ValueError) as error:
- raise ChartError(f"the sealing public key is not valid base64: {error}") from error
- if len(key) != PUBLIC_KEY_BYTES:
- raise ChartError(
- f"the sealing public key must be {PUBLIC_KEY_BYTES} bytes, got {len(key)}"
- )
- return key
-
-
-def seal_token(token: str, public_key: str = SEALING_PUBLIC_KEY) -> str:
- validate_token(token)
- Box, PrivateKey, PublicKey = require_nacl()
-
- recipient_key = decode_public_key(public_key)
- recipient = PublicKey(recipient_key)
- ephemeral = PrivateKey.generate()
- ephemeral_key = bytes(ephemeral.public_key)
-
- nonce = hashlib.sha512(ephemeral_key + recipient_key).digest()[:NONCE_BYTES]
- ciphertext = Box(ephemeral, recipient).encrypt(
- token.encode("utf-8"), nonce
- ).ciphertext
-
- return base64.urlsafe_b64encode(ephemeral_key + ciphertext).decode("ascii").rstrip("=")
diff --git a/scripts/star_history_lib/stars.py b/scripts/star_history_lib/stars.py
new file mode 100644
index 000000000..14c6492b9
--- /dev/null
+++ b/scripts/star_history_lib/stars.py
@@ -0,0 +1,233 @@
+import base64
+import json
+import math
+import sys
+import time
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone
+
+from . import ChartError
+
+
+API_ROOT = "https://api.github.com"
+USER_AGENT = "opentaint-star-history"
+API_VERSION = "2022-11-28"
+STAR_MEDIA_TYPE = "application/vnd.github.star+json"
+JSON_MEDIA_TYPE = "application/vnd.github+json"
+
+AVATAR_PIXELS = 128
+PER_PAGE = 100
+# GitHub refuses to paginate past 400 pages of stargazers.
+MAX_PAGE = 400
+# Beyond this many pages we sample the curve instead of reading every star.
+MAX_PAGE_REQUESTS = 30
+
+REQUEST_TIMEOUT = 30
+RETRIES = 4
+RETRY_BACKOFF_SECONDS = 3
+ERROR_BODY_LIMIT = 200
+
+TERMINAL_STATUS_HINTS = {
+ 401: (
+ "the GitHub token was refused; refresh the token the workflow passes as "
+ "GITHUB_TOKEN"
+ ),
+ 404: (
+ "the repository was not found; check the name and that the token can see it"
+ ),
+}
+
+
+def headers(token: str, accept: str) -> dict[str, str]:
+ sent = {
+ "Accept": accept,
+ "User-Agent": USER_AGENT,
+ "X-GitHub-Api-Version": API_VERSION,
+ }
+ if token:
+ sent["Authorization"] = f"Bearer {token}"
+ return sent
+
+
+def describe_http_error(error: urllib.error.HTTPError) -> str:
+ try:
+ body = error.read().decode("utf-8", "replace").strip()
+ except OSError:
+ body = ""
+ if not body:
+ return f"HTTP {error.code}"
+ return f"HTTP {error.code}: {body[:ERROR_BODY_LIMIT]}"
+
+
+def rate_limited(error: urllib.error.HTTPError) -> bool:
+ if error.code not in (403, 429):
+ return False
+ return error.headers.get("X-RateLimit-Remaining") == "0"
+
+
+def fetch(url: str, request_headers: dict[str, str]) -> tuple[str, bytes]:
+ request = urllib.request.Request(url, headers=request_headers)
+ last_error = ""
+
+ for attempt in range(1, RETRIES + 1):
+ try:
+ with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
+ return response.headers.get("Content-Type", ""), response.read()
+ except urllib.error.HTTPError as error:
+ last_error = describe_http_error(error)
+ if rate_limited(error):
+ raise ChartError(
+ f"{url} failed: {last_error} — the GitHub API rate limit is "
+ "exhausted for this token"
+ )
+ hint = TERMINAL_STATUS_HINTS.get(error.code)
+ if hint:
+ raise ChartError(f"{url} failed: {last_error} — {hint}")
+ except (urllib.error.URLError, TimeoutError) as error:
+ last_error = str(error)
+
+ if attempt < RETRIES:
+ print(
+ f"::warning::Attempt {attempt}/{RETRIES} for {url} failed: "
+ f"{last_error}; retrying",
+ file=sys.stderr,
+ )
+ time.sleep(RETRY_BACKOFF_SECONDS * attempt)
+
+ raise ChartError(f"{url} failed after {RETRIES} attempts: {last_error}")
+
+
+def fetch_json(url: str, token: str, accept: str = JSON_MEDIA_TYPE):
+ _, payload = fetch(url, headers(token, accept))
+ try:
+ return json.loads(payload.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
+ raise ChartError(f"{url} returned invalid JSON: {error}") from error
+
+
+def avatar_data_uri(url: str) -> str:
+ """The owner's avatar, inlined so the chart stays a single self-contained file."""
+ if not url:
+ return ""
+
+ separator = "&" if "?" in url else "?"
+ sized = f"{url}{separator}s={AVATAR_PIXELS}"
+ try:
+ # A different host, so the GitHub token deliberately does not travel here.
+ content_type, payload = fetch(sized, {"User-Agent": USER_AGENT})
+ except ChartError as error:
+ print(f"::warning::{error}; the chart will omit the logo", file=sys.stderr)
+ return ""
+
+ media_type = content_type.split(";")[0].strip()
+ if not media_type.startswith("image/"):
+ print(
+ f"::warning::the avatar came back as '{media_type}' rather than an "
+ "image; the chart will omit the logo",
+ file=sys.stderr,
+ )
+ return ""
+
+ encoded = base64.b64encode(payload).decode("ascii")
+ return f"data:{media_type};base64,{encoded}"
+
+
+def parse_timestamp(value: str) -> datetime:
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(
+ timezone.utc
+ )
+ except (AttributeError, ValueError) as error:
+ raise ChartError(f"unparseable starred_at timestamp {value!r}") from error
+
+
+def repository_facts(repository: str, token: str) -> tuple[int, datetime, str]:
+ """The star total, the creation date the curve starts from, and the logo."""
+ payload = fetch_json(f"{API_ROOT}/repos/{repository}", token)
+ count = payload.get("stargazers_count")
+ if not isinstance(count, int):
+ raise ChartError(f"{repository} returned no stargazers_count")
+ created = payload.get("created_at")
+ if not created:
+ raise ChartError(f"{repository} returned no created_at")
+ owner = payload.get("owner") or {}
+ return count, parse_timestamp(created), owner.get("avatar_url", "")
+
+
+def stargazer_page(repository: str, page: int, token: str) -> list[dict]:
+ url = (
+ f"{API_ROOT}/repos/{repository}/stargazers"
+ f"?per_page={PER_PAGE}&page={page}"
+ )
+ payload = fetch_json(url, token, accept=STAR_MEDIA_TYPE)
+ if not isinstance(payload, list):
+ raise ChartError(f"{url} returned {type(payload).__name__}, expected a list")
+ return payload
+
+
+def sampled_pages(page_count: int, budget: int = MAX_PAGE_REQUESTS) -> list[int]:
+ """Evenly spaced page numbers, always including the first and the last."""
+ if page_count <= budget or budget < 2:
+ return list(range(1, min(page_count, max(budget, 1)) + 1))
+ step = (page_count - 1) / (budget - 1)
+ pages = {1 + round(index * step) for index in range(budget)}
+ return sorted(pages)
+
+
+def exact_timeline(repository: str, page_count: int, token: str) -> list[tuple]:
+ timeline = []
+ for page in range(1, page_count + 1):
+ for entry in stargazer_page(repository, page, token):
+ timeline.append(parse_timestamp(entry["starred_at"]))
+ timeline.sort()
+ return [(moment, index) for index, moment in enumerate(timeline, start=1)]
+
+
+def sampled_timeline(repository: str, page_count: int, token: str) -> list[tuple]:
+ timeline = []
+ for page in sampled_pages(page_count):
+ entries = stargazer_page(repository, page, token)
+ if not entries:
+ continue
+ moment = parse_timestamp(entries[0]["starred_at"])
+ timeline.append((moment, (page - 1) * PER_PAGE + 1))
+ return timeline
+
+
+def star_timeline(repository: str, token: str, now: datetime) -> dict:
+ """Cumulative star counts over time, plus the metadata the manifest reports."""
+ if repository.count("/") != 1:
+ raise ChartError(f"repository must be owner/name, got '{repository}'")
+
+ total, created, avatar_url = repository_facts(repository, token)
+ if total < 1:
+ raise ChartError(f"{repository} has no stargazers yet, so there is no chart")
+
+ page_count = min(math.ceil(total / PER_PAGE), MAX_PAGE)
+ sampled = page_count > MAX_PAGE_REQUESTS
+ if sampled:
+ points = sampled_timeline(repository, page_count, token)
+ print(
+ f"::notice::{repository} has {total} stars; sampling "
+ f"{MAX_PAGE_REQUESTS} of {page_count} stargazer pages",
+ file=sys.stderr,
+ )
+ else:
+ points = exact_timeline(repository, page_count, token)
+
+ if not points:
+ raise ChartError(f"{repository} returned no stargazer timestamps")
+
+ # Anchor the curve where the repository started, at zero stars.
+ if created < points[0][0]:
+ points = [(created, 0), *points]
+ if now > points[-1][0]:
+ points = [*points, (now, total)]
+
+ return {
+ "total": total,
+ "sampled": sampled,
+ "points": points,
+ "avatar": avatar_data_uri(avatar_url),
+ }
diff --git a/scripts/star_history_lib/svg.py b/scripts/star_history_lib/svg.py
deleted file mode 100644
index 1cde575df..000000000
--- a/scripts/star_history_lib/svg.py
+++ /dev/null
@@ -1,137 +0,0 @@
-import math
-import re
-import sys
-import xml.etree.ElementTree as ET
-
-from . import ChartError
-
-
-SVG_NAMESPACE = "http://www.w3.org/2000/svg"
-DEFAULT_BACKGROUNDS = {"light": "#ffffff", "dark": "#0d1117"}
-DEFAULT_SIZE = (800, 534)
-
-BACKGROUND_RE = re.compile(r"background:\s*(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)")
-WIDTH_RE = re.compile(r'\bwidth="([0-9.]+)"')
-HEIGHT_RE = re.compile(r'\bheight="([0-9.]+)"')
-CSS_URL_RE = re.compile(r"url\(\s*(['\"]?)(.*?)\1\s*\)", re.IGNORECASE | re.DOTALL)
-CSS_URL_START_RE = re.compile(r"url\s*\(", re.IGNORECASE)
-UNSAFE_CSS_RE = re.compile(
- r"@import|expression\s*\(|behavior\s*:|"
- r"(?:file|ftp|https?|javascript|vbscript)\s*:",
- re.IGNORECASE,
-)
-SAFE_RASTER_DATA_RE = re.compile(
- r"^data:image/(?:gif|jpeg|png|webp)(?:[;,])", re.IGNORECASE
-)
-SAFE_FONT_DATA_RE = re.compile(
- r"^data:(?:application/font-woff|font/woff2?)(?:[;,])", re.IGNORECASE
-)
-DANGEROUS_ELEMENTS = {
- "a",
- "animate",
- "animatemotion",
- "animatetransform",
- "discard",
- "embed",
- "foreignobject",
- "handler",
- "iframe",
- "listener",
- "object",
- "script",
- "set",
-}
-
-
-def split_xml_name(name: str) -> tuple[str, str]:
- if name.startswith("{"):
- namespace, local_name = name[1:].split("}", 1)
- return namespace, local_name
- return "", name
-
-
-def validate_resource_reference(reference: str, *, allow_font: bool) -> None:
- reference = reference.strip()
- if reference.startswith("#") or SAFE_RASTER_DATA_RE.match(reference):
- return
- if allow_font and SAFE_FONT_DATA_RE.match(reference):
- return
- raise ChartError(f"unsafe SVG resource reference: {reference[:80]!r}")
-
-
-def validate_css(css: str) -> None:
- if "\\" in css:
- raise ChartError("SVG CSS escapes are not allowed")
- if UNSAFE_CSS_RE.search(css):
- raise ChartError("SVG CSS contains an active or external construct")
-
- matches = list(CSS_URL_RE.finditer(css))
- if len(matches) != len(CSS_URL_START_RE.findall(css)):
- raise ChartError("SVG CSS contains a malformed url() reference")
- for match in matches:
- validate_resource_reference(match.group(2), allow_font=True)
-
-
-def sanitize_svg(svg: str) -> str:
- lowered = svg.lower()
- if " is not allowed"
- )
- if element_name.lower() in DANGEROUS_ELEMENTS:
- raise ChartError(f"active SVG element <{element_name}> is not allowed")
-
- for attribute_name, value in element.attrib.items():
- _, local_name = split_xml_name(attribute_name)
- lowered_name = local_name.lower()
- if lowered_name.startswith("on"):
- raise ChartError(f"SVG event attribute {local_name!r} is not allowed")
- if lowered_name in {"href", "src"}:
- validate_resource_reference(value, allow_font=False)
- if lowered_name == "style" or CSS_URL_START_RE.search(value):
- validate_css(value)
-
- if element_name.lower() == "style":
- validate_css(element.text or "")
-
- ET.register_namespace("", SVG_NAMESPACE)
- return ET.tostring(root, encoding="unicode")
-
-
-def background_of(svg: str, theme: str) -> str:
- match = BACKGROUND_RE.search(svg[:1024])
- if match:
- return match.group(1)
- print(
- f"::warning::No background declared in the {theme} chart; "
- f"falling back to {DEFAULT_BACKGROUNDS[theme]}",
- file=sys.stderr,
- )
- return DEFAULT_BACKGROUNDS[theme]
-
-
-def viewport_of(svg: str, theme: str) -> tuple[int, int]:
- root = svg[:1024]
- width, height = WIDTH_RE.search(root), HEIGHT_RE.search(root)
- if width and height:
- return math.ceil(float(width.group(1))), math.ceil(float(height.group(1)))
- print(
- f"::warning::No intrinsic size on the {theme} chart; "
- f"falling back to {DEFAULT_SIZE[0]}x{DEFAULT_SIZE[1]}",
- file=sys.stderr,
- )
- return DEFAULT_SIZE
diff --git a/scripts/test_star_history.py b/scripts/test_star_history.py
new file mode 100644
index 000000000..5fd4592d7
--- /dev/null
+++ b/scripts/test_star_history.py
@@ -0,0 +1,634 @@
+import argparse
+import base64
+import json
+import re
+import os
+import sys
+import tempfile
+import unittest
+import urllib.error
+import xml.etree.ElementTree as ET
+from datetime import datetime, timedelta, timezone
+from email.message import Message
+from io import BytesIO
+from unittest import mock
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from star_history_lib import ChartError, chart, cli, raster, stars
+
+
+UTC = timezone.utc
+
+
+def moment(day: int, month: int = 1, year: int = 2025) -> datetime:
+ return datetime(year, month, day, tzinfo=UTC)
+
+
+AVATAR = "data:image/png;base64,aGVsbG8="
+
+
+def timeline(points, total=None, sampled=False, avatar=AVATAR) -> dict:
+ return {
+ "points": points,
+ "total": total if total is not None else points[-1][1],
+ "sampled": sampled,
+ "avatar": avatar,
+ }
+
+
+def http_error(code: int, body: bytes = b"", headers: dict | None = None):
+ message = Message()
+ for key, value in (headers or {}).items():
+ message[key] = value
+ return urllib.error.HTTPError("https://api.github.com", code, "", message, None)
+
+
+AVATAR_URL = "https://avatars.githubusercontent.com/u/1?v=4"
+AVATAR_BYTES = b"\x89PNG\r\n\x1a\nfake-avatar"
+
+
+class FakeResponse:
+ def __init__(self, payload=None, *, body=None, content_type="application/json"):
+ if body is None:
+ body = json.dumps(payload).encode("utf-8")
+ self.stream = BytesIO(body)
+ self.headers = {"Content-Type": content_type}
+
+ def read(self):
+ return self.stream.read()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_):
+ return False
+
+
+class FakeGitHub:
+ """Serves repository metadata and stargazer pages from an in-memory clock."""
+
+ def __init__(self, count: int, first: datetime = moment(1), spacing_hours=6):
+ self.count = count
+ self.first = first
+ self.created = first - timedelta(days=1)
+ self.spacing = timedelta(hours=spacing_hours)
+ self.requests = []
+
+ def starred_at(self, index: int) -> str:
+ return (self.first + self.spacing * index).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ def __call__(self, request, timeout=None):
+ url = request.full_url
+ self.requests.append(url)
+ if "avatars.githubusercontent.com" in url:
+ self.avatar_headers = dict(request.headers)
+ return FakeResponse(body=AVATAR_BYTES, content_type="image/png")
+ if "/stargazers" not in url:
+ return FakeResponse(
+ {
+ "stargazers_count": self.count,
+ "created_at": self.created.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "owner": {"avatar_url": AVATAR_URL},
+ }
+ )
+
+ page = int(url.split("page=")[-1])
+ start = (page - 1) * stars.PER_PAGE
+ end = min(start + stars.PER_PAGE, self.count)
+ return FakeResponse(
+ [{"starred_at": self.starred_at(index)} for index in range(start, end)]
+ )
+
+
+class TestFetchJson(unittest.TestCase):
+ def test_returns_decoded_payload(self):
+ with mock.patch("urllib.request.urlopen", FakeGitHub(7)):
+ payload = stars.fetch_json("https://api.github.com/repos/a/b", "t")
+ self.assertEqual(payload["stargazers_count"], 7)
+
+ def test_sends_bearer_token_and_media_type(self):
+ sent = stars.headers("secret", stars.STAR_MEDIA_TYPE)
+ self.assertEqual(sent["Authorization"], "Bearer secret")
+ self.assertEqual(sent["Accept"], stars.STAR_MEDIA_TYPE)
+
+ def test_omits_authorization_without_a_token(self):
+ self.assertNotIn("Authorization", stars.headers("", stars.JSON_MEDIA_TYPE))
+
+ def test_retries_transient_failures_then_succeeds(self):
+ attempts = []
+
+ def flaky(request, timeout=None):
+ attempts.append(request.full_url)
+ if len(attempts) < 3:
+ raise urllib.error.URLError("connection reset")
+ return FakeResponse({"stargazers_count": 3})
+
+ with mock.patch("urllib.request.urlopen", flaky), mock.patch("time.sleep"):
+ payload = stars.fetch_json("https://api.github.com/repos/a/b", "t")
+
+ self.assertEqual(payload, {"stargazers_count": 3})
+ self.assertEqual(len(attempts), 3)
+
+ def test_gives_up_after_the_retry_budget(self):
+ def always_failing(request, timeout=None):
+ raise TimeoutError("timed out")
+
+ with mock.patch("urllib.request.urlopen", always_failing), mock.patch(
+ "time.sleep"
+ ):
+ with self.assertRaises(ChartError) as caught:
+ stars.fetch_json("https://api.github.com/repos/a/b", "t")
+
+ self.assertIn(f"after {stars.RETRIES} attempts", str(caught.exception))
+
+ def test_does_not_retry_a_missing_repository(self):
+ attempts = []
+
+ def missing(request, timeout=None):
+ attempts.append(request.full_url)
+ raise http_error(404, b'{"message":"Not Found"}')
+
+ with mock.patch("urllib.request.urlopen", missing), mock.patch("time.sleep"):
+ with self.assertRaises(ChartError) as caught:
+ stars.fetch_json("https://api.github.com/repos/a/b", "t")
+
+ self.assertEqual(len(attempts), 1)
+ self.assertIn("repository was not found", str(caught.exception))
+
+ def test_reports_an_exhausted_rate_limit(self):
+ def limited(request, timeout=None):
+ raise http_error(403, b"rate limited", {"X-RateLimit-Remaining": "0"})
+
+ with mock.patch("urllib.request.urlopen", limited), mock.patch("time.sleep"):
+ with self.assertRaises(ChartError) as caught:
+ stars.fetch_json("https://api.github.com/repos/a/b", "t")
+
+ self.assertIn("rate limit is exhausted", str(caught.exception))
+
+ def test_retries_a_server_error(self):
+ attempts = []
+
+ def unstable(request, timeout=None):
+ attempts.append(request.full_url)
+ if len(attempts) < 2:
+ raise http_error(500, b"upstream boom")
+ return FakeResponse({"stargazers_count": 1})
+
+ with mock.patch("urllib.request.urlopen", unstable), mock.patch("time.sleep"):
+ stars.fetch_json("https://api.github.com/repos/a/b", "t")
+
+ self.assertEqual(len(attempts), 2)
+
+
+class TestSampling(unittest.TestCase):
+ def test_returns_every_page_within_budget(self):
+ self.assertEqual(stars.sampled_pages(4, budget=10), [1, 2, 3, 4])
+
+ def test_samples_endpoints_inclusively(self):
+ pages = stars.sampled_pages(400, budget=30)
+ self.assertEqual(pages[0], 1)
+ self.assertEqual(pages[-1], 400)
+ self.assertLessEqual(len(pages), 30)
+ self.assertEqual(pages, sorted(set(pages)))
+
+
+class TestStarTimeline(unittest.TestCase):
+ def test_rejects_a_malformed_repository(self):
+ with self.assertRaises(ChartError):
+ stars.star_timeline("opentaint", "t", moment(2))
+
+ def test_rejects_a_repository_with_no_stars(self):
+ with mock.patch("urllib.request.urlopen", FakeGitHub(0)):
+ with self.assertRaises(ChartError):
+ stars.star_timeline("seqra/opentaint", "t", moment(2))
+
+ def test_counts_every_star_when_pages_fit_the_budget(self):
+ github = FakeGitHub(127)
+ now = moment(1, month=6)
+ with mock.patch("urllib.request.urlopen", github):
+ result = stars.star_timeline("seqra/opentaint", "t", now)
+
+ self.assertFalse(result["sampled"])
+ self.assertEqual(result["total"], 127)
+ # A zero-star anchor, 127 stars, then a point carrying the curve to `now`.
+ self.assertEqual(len(result["points"]), 129)
+ self.assertEqual(result["points"][0], (github.created, 0))
+ self.assertEqual(result["points"][1][1], 1)
+ self.assertEqual(result["points"][-1], (now, 127))
+
+ def test_counts_rise_monotonically(self):
+ with mock.patch("urllib.request.urlopen", FakeGitHub(150)):
+ points = stars.star_timeline("seqra/opentaint", "t", moment(1, 6))["points"]
+
+ counts = [count for _, count in points]
+ dates = [when for when, _ in points]
+ self.assertEqual(counts, sorted(counts))
+ self.assertEqual(dates, sorted(dates))
+
+ def test_samples_pages_for_a_large_repository(self):
+ github = FakeGitHub(50_000, spacing_hours=1)
+ with mock.patch("urllib.request.urlopen", github):
+ result = stars.star_timeline("seqra/opentaint", "t", moment(1, 6, 2030))
+
+ self.assertTrue(result["sampled"])
+ stargazer_calls = [url for url in github.requests if "/stargazers" in url]
+ self.assertLessEqual(len(stargazer_calls), stars.MAX_PAGE_REQUESTS)
+ self.assertEqual(result["points"][-1][1], 50_000)
+
+ def test_skips_the_trailing_point_when_now_is_not_newer(self):
+ github = FakeGitHub(3)
+ last = github.first + github.spacing * 2
+ with mock.patch("urllib.request.urlopen", github):
+ result = stars.star_timeline("seqra/opentaint", "t", last)
+
+ self.assertEqual(len(result["points"]), 4)
+ self.assertEqual(result["points"][-1][1], 3)
+
+ def test_rejects_an_unparseable_timestamp(self):
+ def broken(request, timeout=None):
+ if "/stargazers" in request.full_url:
+ return FakeResponse([{"starred_at": "not-a-date"}])
+ return FakeResponse(
+ {"stargazers_count": 1, "created_at": "2025-01-01T00:00:00Z"}
+ )
+
+ with mock.patch("urllib.request.urlopen", broken):
+ with self.assertRaises(ChartError):
+ stars.star_timeline("seqra/opentaint", "t", moment(2))
+
+
+class TestAvatar(unittest.TestCase):
+ def test_the_timeline_carries_the_owner_avatar(self):
+ with mock.patch("urllib.request.urlopen", FakeGitHub(5)):
+ result = stars.star_timeline("seqra/opentaint", "t", moment(1, 6))
+
+ expected = base64.b64encode(AVATAR_BYTES).decode("ascii")
+ self.assertEqual(result["avatar"], f"data:image/png;base64,{expected}")
+
+ def test_the_avatar_is_requested_at_a_usable_size(self):
+ github = FakeGitHub(5)
+ with mock.patch("urllib.request.urlopen", github):
+ stars.star_timeline("seqra/opentaint", "t", moment(1, 6))
+
+ avatar_calls = [url for url in github.requests if "avatars." in url]
+ self.assertEqual(len(avatar_calls), 1)
+ self.assertIn(f"s={stars.AVATAR_PIXELS}", avatar_calls[0])
+
+ def test_the_github_token_is_not_sent_to_the_avatar_host(self):
+ github = FakeGitHub(5)
+ with mock.patch("urllib.request.urlopen", github):
+ stars.star_timeline("seqra/opentaint", "t", moment(1, 6))
+
+ sent = {key.lower() for key in github.avatar_headers}
+ self.assertNotIn("authorization", sent)
+
+ def test_a_failing_avatar_does_not_fail_the_chart(self):
+ def flaky(request, timeout=None):
+ if "avatars." in request.full_url:
+ raise urllib.error.URLError("avatar host down")
+ return FakeGitHub(5)(request, timeout)
+
+ with mock.patch("urllib.request.urlopen", flaky), mock.patch(
+ "time.sleep"
+ ), mock.patch("sys.stderr", new_callable=lambda: open(os.devnull, "w")):
+ result = stars.star_timeline("seqra/opentaint", "t", moment(1, 6))
+
+ self.assertEqual(result["avatar"], "")
+ self.assertEqual(result["total"], 5)
+
+ def test_a_non_image_response_is_ignored(self):
+ def wrong_type(request, timeout=None):
+ if "avatars." in request.full_url:
+ return FakeResponse(body=b"", content_type="text/html")
+ return FakeGitHub(5)(request, timeout)
+
+ with mock.patch("urllib.request.urlopen", wrong_type), mock.patch(
+ "sys.stderr", new_callable=lambda: open(os.devnull, "w")
+ ):
+ result = stars.star_timeline("seqra/opentaint", "t", moment(1, 6))
+
+ self.assertEqual(result["avatar"], "")
+
+
+class TestAxes(unittest.TestCase):
+ def test_tick_step_matches_d3(self):
+ # The values d3.tickIncrement produces for the same inputs.
+ for maximum, expected in ((126, 20), (5, 1), (1000, 200), (4200, 500)):
+ self.assertEqual(chart.tick_step(maximum, chart.Y_TICK_TARGET), expected)
+
+ def test_value_ticks_reproduce_the_reference_axis(self):
+ self.assertEqual(chart.value_ticks(126), [0, 20, 40, 60, 80, 100, 120])
+
+ def test_value_ticks_never_exceed_the_maximum(self):
+ self.assertLessEqual(chart.value_ticks(127)[-1], 127)
+
+ def test_a_tiny_repository_still_gets_integer_ticks(self):
+ self.assertEqual(chart.value_ticks(3), [0, 1, 2, 3])
+
+ def test_a_year_of_history_steps_by_three_months(self):
+ self.assertEqual(chart.month_step(timedelta(days=310)), 3)
+
+ def test_a_long_history_steps_by_a_year(self):
+ self.assertEqual(chart.month_step(timedelta(days=2000)), 12)
+
+ def test_quarter_ticks_land_on_january_april_july_october(self):
+ ticks = chart.time_ticks(moment(30, 9, 2025), moment(6, 8, 2026))
+ self.assertEqual(
+ [label for _, label in ticks], ["October", "2026", "April", "July"]
+ )
+
+ def test_january_is_labelled_with_the_year(self):
+ ticks = chart.time_ticks(moment(30, 9, 2025), moment(6, 8, 2026))
+ january = [tick for tick, label in ticks if label == "2026"][0]
+ self.assertEqual((january.month, january.year), (1, 2026))
+
+ def test_ticks_stay_inside_the_domain(self):
+ start, end = moment(14, 10, 2025), moment(6, 8, 2026)
+ for tick, _ in chart.time_ticks(start, end):
+ self.assertGreaterEqual(tick, start)
+ self.assertLessEqual(tick, end)
+
+ def test_a_short_history_falls_back_to_day_ticks(self):
+ ticks = chart.time_ticks(moment(1), moment(9))
+ self.assertTrue(ticks)
+ self.assertRegex(ticks[0][1], r"^[A-Z][a-z]{2} \d{2}$")
+
+
+def path_numbers(path: str) -> list[float]:
+ return [float(value) for value in re.findall(r"-?\d+\.?\d*", path)]
+
+
+class TestCurve(unittest.TestCase):
+ def test_a_single_point_is_just_a_move(self):
+ self.assertEqual(chart.basis_path([(0.0, 10.0)]), "M0 10")
+
+ def test_two_points_are_a_straight_line(self):
+ self.assertEqual(chart.basis_path([(0.0, 0.0), (5.0, 5.0)]), "M0 0L5 5")
+
+ def test_each_span_emits_one_cubic(self):
+ path = chart.basis_path([(0.0, 0.0), (1.0, 1.0), (2.0, 4.0)])
+ self.assertEqual(path.count("C"), 2)
+ self.assertTrue(path.startswith("M0 0"))
+
+ def test_the_curve_starts_and_ends_on_the_data(self):
+ points = [(0.0, 0.0), (10.0, 5.0), (20.0, 30.0), (30.0, 31.0)]
+ numbers = path_numbers(chart.basis_path(points))
+ self.assertEqual((numbers[0], numbers[1]), points[0])
+ self.assertEqual((numbers[-2], numbers[-1]), points[-1])
+
+ def test_a_rising_series_never_dips(self):
+ # curveBasis is variation-diminishing, so a falling y (rising count)
+ # must never produce a control point above where it started.
+ points = [(0.0, 100.0), (10.0, 100.0), (20.0, 40.0), (30.0, 0.0)]
+ ys = path_numbers(chart.basis_path(points))[1::2]
+ self.assertTrue(all(y <= 100.0001 for y in ys))
+ self.assertTrue(all(y >= -0.0001 for y in ys))
+
+ def test_the_spline_smooths_rather_than_interpolates(self):
+ # A corner point is approximated, which is exactly what removes the kink.
+ points = [(0.0, 0.0), (10.0, 0.0), (20.0, 100.0), (30.0, 100.0)]
+ self.assertNotIn("100 100", chart.basis_path(points)[: -len("L30 100")])
+
+ def test_downsample_keeps_the_ends(self):
+ points = [(moment(1) + timedelta(days=i), i) for i in range(200)]
+ reduced = chart.downsample(points, 11)
+ self.assertEqual(len(reduced), 11)
+ self.assertEqual(reduced[0], points[0])
+ self.assertEqual(reduced[-1], points[-1])
+
+ def test_downsample_leaves_short_series_alone(self):
+ points = [(moment(1), 1), (moment(2), 2)]
+ self.assertEqual(chart.downsample(points, 11), points)
+
+
+class TestRender(unittest.TestCase):
+ def setUp(self):
+ self.timeline = timeline(
+ [(moment(1, month), month * 10) for month in range(1, 13)]
+ )
+
+ def series_path(self, svg: str) -> str:
+ match = re.search(
+ r'stroke="#[0-9a-f]+" d="([^"]+)" class="xkcd-chart-xyline"', svg
+ )
+ self.assertIsNotNone(match, "the series path is missing")
+ return match.group(1)
+
+ def test_renders_well_formed_svg_for_both_themes(self):
+ for theme in cli.THEMES:
+ svg = chart.render("seqra/opentaint", self.timeline, theme)
+ root = ET.fromstring(svg)
+ self.assertEqual(root.tag, "{http://www.w3.org/2000/svg}svg")
+ self.assertEqual(root.get("width"), str(chart.WIDTH))
+ self.assertEqual(root.get("height"), "533.333")
+ self.assertIn(
+ f'background:{chart.PALETTES[theme]["background"]}', root.get("style")
+ )
+
+ def test_carries_the_xkcdify_filter(self):
+ svg = chart.render("a/b", self.timeline, "light")
+ self.assertIn('&", timeline([(moment(1), 5)]), "light")
+ self.assertNotIn("", svg)
+ ET.fromstring(svg)
+
+ def test_the_curve_stays_inside_the_plot_area(self):
+ svg = chart.render("a/b", self.timeline, "light")
+ numbers = path_numbers(self.series_path(svg))
+ xs, ys = numbers[0::2], numbers[1::2]
+ self.assertGreaterEqual(min(xs), 0)
+ self.assertLessEqual(max(xs), chart.PLOT_WIDTH)
+ self.assertGreaterEqual(min(ys), 0)
+ self.assertLessEqual(max(ys), chart.BASELINE)
+
+ def test_the_curve_spans_the_full_width(self):
+ numbers = path_numbers(
+ self.series_path(chart.render("a/b", self.timeline, "light"))
+ )
+ self.assertEqual(numbers[0], 0)
+ self.assertEqual(numbers[-2], chart.PLOT_WIDTH)
+
+ def test_embeds_the_owner_avatar_beside_the_title(self):
+ svg = chart.render("a/b", self.timeline, "light")
+ self.assertIn(f'href="{AVATAR}"', svg)
+ self.assertIn(
+ f' dict:
+ args = argparse.Namespace(
+ repository="seqra/opentaint",
+ output_dir=os.path.join(directory, "dist"),
+ basename="star-history",
+ token="t",
+ )
+ with mock.patch("urllib.request.urlopen", FakeGitHub(count)):
+ return cli.build_manifest(args, moment(1, 6))
+
+ def test_writes_an_svg_and_a_raster_page_per_theme(self):
+ with tempfile.TemporaryDirectory() as directory:
+ manifest = self.build(directory)
+
+ self.assertEqual([c["theme"] for c in manifest["charts"]], ["light", "dark"])
+ for entry in manifest["charts"]:
+ self.assertTrue(os.path.isfile(entry["svg"]))
+ self.assertTrue(os.path.isfile(entry["raster_page"]))
+ self.assertEqual(entry["width"], chart.WIDTH)
+ self.assertGreater(entry["bytes"], 0)
+
+ def test_assets_list_the_svg_and_png_of_each_theme(self):
+ with tempfile.TemporaryDirectory() as directory:
+ manifest = self.build(directory)
+ self.assertEqual(len(manifest["assets"]), 4)
+ self.assertEqual(
+ [os.path.basename(path) for path in manifest["assets"]],
+ [
+ "star-history-light.svg",
+ "star-history-light.png",
+ "star-history-dark.svg",
+ "star-history-dark.png",
+ ],
+ )
+
+ def test_records_the_star_count_and_source(self):
+ with tempfile.TemporaryDirectory() as directory:
+ manifest = self.build(directory)
+ self.assertEqual(manifest["stars"], 127)
+ self.assertFalse(manifest["sampled"])
+ self.assertTrue(manifest["authenticated"])
+ self.assertIn("seqra/opentaint/stargazers", manifest["source"])
+
+ def test_the_manifest_round_trips_through_the_asset_reader(self):
+ with tempfile.TemporaryDirectory() as directory:
+ manifest = self.build(directory)
+ path = os.path.join(directory, "manifest.json")
+ with open(path, "w", encoding="utf-8") as handle:
+ json.dump(manifest, handle)
+
+ self.assertEqual(raster.asset_paths(path), manifest["assets"])
+
+ def test_build_refuses_to_run_without_a_token(self):
+ args = argparse.Namespace(
+ repository="seqra/opentaint",
+ output_dir="dist",
+ basename="star-history",
+ token="",
+ )
+ with self.assertRaises(ChartError):
+ cli.build(args)
+
+
+class TestRasterPage(unittest.TestCase):
+ def test_page_references_the_svg_and_its_background(self):
+ with tempfile.TemporaryDirectory() as directory:
+ svg_path = os.path.join(directory, "star-history-dark.svg")
+ page = raster.write_raster_page(svg_path, "#0d1117", 800, 534)
+
+ with open(page, encoding="utf-8") as handle:
+ document = handle.read()
+
+ self.assertIn('src="star-history-dark.svg"', document)
+ self.assertIn("background:#0d1117", document)
+ self.assertIn("Content-Security-Policy", document)
+
+
+class TestEntryPoint(unittest.TestCase):
+ def test_main_reports_chart_errors_as_workflow_errors(self):
+ argv = ["star_history.py", "build", "--repository", "bad", "--output-dir", "d"]
+ with mock.patch.object(sys, "argv", argv), mock.patch.dict(
+ os.environ, {"GITHUB_TOKEN": "t"}
+ ), mock.patch("star_history_lib.cli.build_manifest", side_effect=ChartError("x")):
+ with mock.patch("sys.stderr", new_callable=lambda: open(os.devnull, "w")):
+ self.assertEqual(cli.main(), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()