-
Notifications
You must be signed in to change notification settings - Fork 0
feat: publish secure dynamic repository metrics #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| name: repository-metrics | ||
|
|
||
| on: | ||
| watch: | ||
| types: [started] | ||
| schedule: | ||
| - cron: "17 5 * * 1" | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: write | ||
|
|
||
| concurrency: | ||
| group: repository-metrics | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| render: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v7 | ||
| - name: Render privacy-safe metrics SVG | ||
| env: | ||
| GITHUB_TOKEN: ${{ github.token }} | ||
| run: >- | ||
| python3 scripts/render_repository_metrics.py | ||
| --repository "$GITHUB_REPOSITORY" | ||
| --output "$RUNNER_TEMP/repository-metrics.svg" | ||
| - name: Publish generated image to the metrics branch | ||
| run: | | ||
| set -euo pipefail | ||
| git config user.name "github-actions[bot]" | ||
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | ||
| git checkout --orphan repository-metrics-output | ||
| git rm -rf . | ||
| cp "$RUNNER_TEMP/repository-metrics.svg" repository-metrics.svg | ||
| git add repository-metrics.svg | ||
| git commit -m "chore: refresh repository metrics" | ||
| git push --force origin HEAD:metrics | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,239 @@ | ||
| #!/usr/bin/env python3 | ||
| """Render a privacy-safe repository metrics SVG from GitHub-owned data.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| from datetime import datetime, timezone | ||
| from html import escape | ||
| import json | ||
| import math | ||
| import os | ||
| from pathlib import Path | ||
| import re | ||
| from typing import Any | ||
| from urllib.parse import quote | ||
| from urllib.request import Request, urlopen | ||
|
|
||
|
|
||
| API = "https://api.github.com" | ||
| API_VERSION = "2022-11-28" | ||
|
|
||
|
|
||
| def _request_json(path: str, token: str, *, accept: str = "application/vnd.github+json") -> tuple[Any, Any]: | ||
| request = Request( | ||
| f"{API}{path}", | ||
| headers={ | ||
| "Accept": accept, | ||
| "Authorization": f"Bearer {token}", | ||
| "User-Agent": "repository-metrics-renderer", | ||
| "X-GitHub-Api-Version": API_VERSION, | ||
| }, | ||
| ) | ||
| with urlopen(request, timeout=30) as response: # noqa: S310 - fixed GitHub API origin | ||
| return json.load(response), response.headers | ||
|
|
||
|
|
||
| def fetch_snapshot(repository: str, token: str) -> dict[str, Any]: | ||
| if not token: | ||
| raise RuntimeError("GITHUB_TOKEN is required when --snapshot is not supplied") | ||
| if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): | ||
| raise ValueError("repository must use owner/name format") | ||
|
|
||
| encoded = "/".join(quote(part, safe="") for part in repository.split("/", 1)) | ||
| repo, _ = _request_json(f"/repos/{encoded}", token) | ||
| views, _ = _request_json(f"/repos/{encoded}/traffic/views", token) | ||
| clones, _ = _request_json(f"/repos/{encoded}/traffic/clones", token) | ||
| _, commit_headers = _request_json(f"/repos/{encoded}/commits?per_page=1", token) | ||
|
|
||
| link = commit_headers.get("Link", "") | ||
| last_page = re.search(r"[?&]page=(\d+)[^>]*>; rel=\"last\"", link) | ||
| commit_count = int(last_page.group(1)) if last_page else 1 | ||
|
|
||
| starred_at: list[str] = [] | ||
| page = 1 | ||
| while True: | ||
| entries, _ = _request_json( | ||
| f"/repos/{encoded}/stargazers?per_page=100&page={page}", | ||
| token, | ||
| accept="application/vnd.github.star+json", | ||
| ) | ||
| starred_at.extend( | ||
| item["starred_at"] | ||
| for item in entries | ||
| if isinstance(item, dict) and isinstance(item.get("starred_at"), str) | ||
| ) | ||
| if len(entries) < 100: | ||
| break | ||
| page += 1 | ||
|
|
||
| return { | ||
| "repository": repository, | ||
| "created_at": repo["created_at"], | ||
| "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), | ||
| "stars": int(repo["stargazers_count"]), | ||
| "forks": int(repo["forks_count"]), | ||
| "commits": commit_count, | ||
| "unique_visitors_14d": int(views["uniques"]), | ||
| "views_14d": int(views["count"]), | ||
| "unique_cloners_14d": int(clones["uniques"]), | ||
| "clones_14d": int(clones["count"]), | ||
| "starred_at": sorted(starred_at), | ||
| } | ||
|
|
||
|
|
||
| def _parse_time(value: str) -> datetime: | ||
| return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) | ||
|
|
||
|
|
||
| def _compact(value: int) -> str: | ||
| if value >= 1_000_000: | ||
| return f"{value / 1_000_000:.1f}m" | ||
| if value >= 1_000: | ||
| return f"{value / 1_000:.1f}k" | ||
| return str(value) | ||
|
|
||
|
|
||
| def render_svg(snapshot: dict[str, Any]) -> str: | ||
| repository = escape(str(snapshot["repository"])) | ||
| created = _parse_time(str(snapshot["created_at"])) | ||
| generated = _parse_time(str(snapshot["generated_at"])) | ||
| if generated <= created: | ||
| generated = created.replace(microsecond=0) | ||
|
|
||
| stars = max(0, int(snapshot["stars"])) | ||
| star_dates = sorted( | ||
| date for date in (_parse_time(str(value)) for value in snapshot.get("starred_at", [])) | ||
| if created <= date <= generated | ||
| ) | ||
|
|
||
| chart_x, chart_y, chart_w, chart_h = 64.0, 150.0, 540.0, 292.0 | ||
| baseline = chart_y + chart_h | ||
| seconds = max((generated - created).total_seconds(), 1.0) | ||
| y_max = max(stars, 1) | ||
|
|
||
| def x_for(date: datetime) -> float: | ||
| return chart_x + chart_w * max(0.0, min(1.0, (date - created).total_seconds() / seconds)) | ||
|
|
||
| def y_for(value: int) -> float: | ||
| return baseline - chart_h * max(0.0, min(1.0, value / y_max)) | ||
|
|
||
| path = [f"M {chart_x:.1f} {y_for(0):.1f}"] | ||
| count = 0 | ||
| for date in star_dates: | ||
| x = x_for(date) | ||
| path.append(f"L {x:.1f} {y_for(count):.1f}") | ||
| count += 1 | ||
| path.append(f"L {x:.1f} {y_for(count):.1f}") | ||
| if count < stars: | ||
| path.append(f"L {chart_x + chart_w:.1f} {y_for(stars):.1f}") | ||
| else: | ||
| path.append(f"L {chart_x + chart_w:.1f} {y_for(count):.1f}") | ||
| line_path = " ".join(path) | ||
| area_path = f"{line_path} L {chart_x + chart_w:.1f} {baseline:.1f} Z" | ||
|
|
||
| ticks = sorted({0, max(1, math.ceil(y_max / 2)), y_max}) | ||
| grid = [] | ||
| for value in ticks: | ||
| y = y_for(value) | ||
| grid.append( | ||
| f'<line x1="{chart_x:.1f}" y1="{y:.1f}" x2="{chart_x + chart_w:.1f}" y2="{y:.1f}" class="grid"/>' | ||
| f'<text x="{chart_x - 14:.1f}" y="{y + 5:.1f}" text-anchor="end" class="axis">{value}</text>' | ||
| ) | ||
|
|
||
| metrics = ( | ||
| ("Stars", stars), | ||
| ("Forks", int(snapshot["forks"])), | ||
| ("Commits", int(snapshot["commits"])), | ||
| ("Unique visitors · 14d", int(snapshot["unique_visitors_14d"])), | ||
| ("Unique cloners · 14d", int(snapshot["unique_cloners_14d"])), | ||
| ("Total clones · 14d", int(snapshot["clones_14d"])), | ||
| ) | ||
| cards = [] | ||
| for index, (label, value) in enumerate(metrics): | ||
| column, row = index % 2, index // 2 | ||
| x, y = 650 + column * 142, 154 + row * 104 | ||
| label_parts = label.split(" · ", 1) | ||
| label_svg = f'<text x="15" y="25" class="metric-label">{escape(label_parts[0])}</text>' | ||
| if len(label_parts) == 2: | ||
| label_svg += f'<text x="15" y="40" class="metric-period">{escape(label_parts[1])}</text>' | ||
| cards.append( | ||
| f'<g transform="translate({x} {y})">' | ||
| '<rect width="128" height="84" rx="17" class="card-shadow"/>' | ||
| '<rect width="128" height="84" rx="17" class="card"/>' | ||
| f'{label_svg}' | ||
| f'<text x="15" y="70" class="metric-value">{escape(_compact(value))}</text>' | ||
| '</g>' | ||
| ) | ||
|
|
||
| empty_note = "" | ||
| if stars == 0: | ||
| empty_note = '<text x="334" y="292" text-anchor="middle" class="empty">Waiting for the first star</text>' | ||
|
|
||
| updated = generated.strftime("%Y-%m-%d UTC") | ||
| start_label = created.strftime("%Y-%m") | ||
| end_label = generated.strftime("%Y-%m") | ||
| return f'''<svg xmlns="http://www.w3.org/2000/svg" width="960" height="560" viewBox="0 0 960 560" role="img" aria-labelledby="title desc"> | ||
| <title id="title">{repository} repository metrics</title> | ||
| <desc id="desc">Star growth curve with stars, forks, commits, visitors, cloners, and clone totals.</desc> | ||
| <style> | ||
| .background {{ fill: #ffffff; }} | ||
| .outline {{ fill: none; stroke: #171717; stroke-width: 2.4; }} | ||
| .title {{ font: 700 30px ui-rounded, "Comic Sans MS", system-ui, sans-serif; fill: #171717; }} | ||
| .subtitle {{ font: 500 15px ui-rounded, "Comic Sans MS", system-ui, sans-serif; fill: #666666; }} | ||
| .section {{ font: 700 18px ui-rounded, "Comic Sans MS", system-ui, sans-serif; fill: #252525; }} | ||
| .grid {{ stroke: #dedede; stroke-width: 1.2; stroke-dasharray: 5 7; }} | ||
| .axis {{ font: 12px ui-rounded, "Comic Sans MS", system-ui, sans-serif; fill: #777777; }} | ||
| .area {{ fill: #dff7e5; opacity: .76; }} | ||
| .curve-echo {{ fill: none; stroke: #171717; stroke-width: 5.6; opacity: .12; stroke-linejoin: round; }} | ||
| .curve {{ fill: none; stroke: #18a558; stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; }} | ||
| .card-shadow {{ fill: #ececec; transform: translate(3px, 4px); }} | ||
| .card {{ fill: #ffffff; stroke: #222222; stroke-width: 2; }} | ||
| .metric-label {{ font: 650 11px ui-rounded, "Comic Sans MS", system-ui, sans-serif; fill: #555555; }} | ||
| .metric-period {{ font: 600 9.5px ui-rounded, "Comic Sans MS", system-ui, sans-serif; fill: #888888; }} | ||
| .metric-value {{ font: 800 28px ui-rounded, "Comic Sans MS", system-ui, sans-serif; fill: #171717; }} | ||
| .empty {{ font: 600 16px ui-rounded, "Comic Sans MS", system-ui, sans-serif; fill: #888888; }} | ||
| .footer {{ font: 12px ui-rounded, "Comic Sans MS", system-ui, sans-serif; fill: #666666; }} | ||
| </style> | ||
| <rect class="background" width="960" height="560" rx="24"/> | ||
| <rect class="outline" x="12" y="12" width="936" height="536" rx="22"/> | ||
| <text x="54" y="61" class="title">Repository Pulse</text> | ||
| <text x="54" y="88" class="subtitle">{repository} · privacy-safe public activity</text> | ||
| <path d="M 844 47 l 8 16 18 3 -13 12 3 18 -16 -9 -16 9 3 -18 -13 -12 18 -3 z" fill="#18a558" opacity=".9"/> | ||
| <text x="64" y="128" class="section">Stars over time</text> | ||
| {''.join(grid)} | ||
| <line x1="{chart_x:.1f}" y1="{baseline:.1f}" x2="{chart_x + chart_w:.1f}" y2="{baseline:.1f}" class="outline"/> | ||
| <path d="{area_path}" class="area"/> | ||
| <path d="{line_path}" class="curve-echo"/> | ||
| <path d="{line_path}" class="curve"/> | ||
| {empty_note} | ||
| <text x="{chart_x:.1f}" y="{baseline + 24:.1f}" class="axis">{start_label}</text> | ||
| <text x="{chart_x + chart_w:.1f}" y="{baseline + 24:.1f}" text-anchor="end" class="axis">{end_label}</text> | ||
| {''.join(cards)} | ||
| <text x="54" y="520" class="footer">Auto-refreshed on stars and weekly · GitHub Traffic uses the rolling 14-day owner view · Updated {updated}</text> | ||
| </svg> | ||
| ''' | ||
|
|
||
|
|
||
| def main() -> int: | ||
| parser = argparse.ArgumentParser() | ||
| parser.add_argument("--repository", required=True, help="GitHub owner/name") | ||
| parser.add_argument("--output", required=True, type=Path) | ||
| parser.add_argument("--snapshot", type=Path, help="Render from a local JSON snapshot instead of the API") | ||
| args = parser.parse_args() | ||
|
|
||
| if args.snapshot: | ||
| snapshot = json.loads(args.snapshot.read_text()) | ||
| else: | ||
| snapshot = fetch_snapshot(args.repository, os.environ.get("GITHUB_TOKEN", "")) | ||
| if snapshot.get("repository") != args.repository: | ||
| raise ValueError("snapshot repository does not match --repository") | ||
|
|
||
| args.output.parent.mkdir(parents=True, exist_ok=True) | ||
| args.output.write_text(render_svg(snapshot)) | ||
| print(f"rendered {args.output} for {args.repository}") | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import importlib.util | ||
| from pathlib import Path | ||
| import unittest | ||
|
|
||
|
|
||
| ROOT = Path(__file__).resolve().parents[1] | ||
| SCRIPT = ROOT / "scripts" / "render_repository_metrics.py" | ||
| SPEC = importlib.util.spec_from_file_location("render_repository_metrics", SCRIPT) | ||
| MODULE = importlib.util.module_from_spec(SPEC) | ||
| assert SPEC.loader is not None | ||
| SPEC.loader.exec_module(MODULE) | ||
|
|
||
|
|
||
| class RepositoryMetricsTests(unittest.TestCase): | ||
| def test_svg_is_white_privacy_safe_and_contains_requested_metrics(self) -> None: | ||
| svg = MODULE.render_svg({ | ||
| "repository": "Example/project", | ||
| "created_at": "2026-01-01T00:00:00Z", | ||
| "generated_at": "2026-08-10T00:00:00Z", | ||
| "stars": 3, | ||
| "forks": 2, | ||
| "commits": 42, | ||
| "unique_visitors_14d": 7, | ||
| "views_14d": 9, | ||
| "unique_cloners_14d": 4, | ||
| "clones_14d": 6, | ||
| "starred_at": [ | ||
| "2026-02-01T00:00:00Z", | ||
| "2026-04-01T00:00:00Z", | ||
| "2026-07-01T00:00:00Z", | ||
| ], | ||
| }) | ||
|
|
||
| self.assertIn("Repository Pulse", svg) | ||
| self.assertIn("Stars over time", svg) | ||
| self.assertIn("Unique visitors", svg) | ||
| self.assertIn("Unique cloners", svg) | ||
| self.assertIn("Total clones", svg) | ||
| self.assertEqual(svg.count('class="metric-period">14d'), 3) | ||
| self.assertIn('fill: #ffffff', svg) | ||
| self.assertIn('class="curve"', svg) | ||
| self.assertNotIn("GITHUB_TOKEN", svg) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The workflow
GITHUB_TOKENcan only receive the permissions exposed underpermissions:, andcontents: writedoes not grant the Administration (read) permission required by the/traffic/viewsand/traffic/clonesendpoints called byfetch_snapshot(). Consequently, every watch, scheduled, or manual run reaches those calls with${{ github.token }}and receives a 403 before generating or publishing the SVG; use a GitHub App or other credential with repository traffic access, keeping the branch-push credential separate if necessary.Useful? React with 👍 / 👎.