From a6b0859ab7111578b941d17beee7e7722eb247dc Mon Sep 17 00:00:00 2001 From: JNHFlow21 Date: Mon, 10 Aug 2026 23:05:48 -0400 Subject: [PATCH] feat: publish secure dynamic repository metrics --- .github/workflows/repository-metrics.yml | 39 ++++ README.md | 8 +- README.zh-CN.md | 8 +- scripts/render_repository_metrics.py | 239 +++++++++++++++++++++++ tests/test_localization_catalog.py | 8 +- tests/test_repository_metrics.py | 46 +++++ 6 files changed, 337 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/repository-metrics.yml create mode 100755 scripts/render_repository_metrics.py create mode 100644 tests/test_repository_metrics.py diff --git a/.github/workflows/repository-metrics.yml b/.github/workflows/repository-metrics.yml new file mode 100644 index 0000000..9110ba0 --- /dev/null +++ b/.github/workflows/repository-metrics.yml @@ -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 diff --git a/README.md b/README.md index dfd3efe..b4caeb4 100644 --- a/README.md +++ b/README.md @@ -60,15 +60,15 @@ credential store, and deterministic per-agent projections. Traffic snapshot: 2026-08-10. GitHub exposes clone and unique-visitor analytics only to maintainers, so those values are a dated, transparent snapshot rather than a token-backed public badge. -### Star history +### Repository growth

- - Agent Switch star history chart + + Agent Switch repository growth and traffic metrics

-Live white-background chart from Star History. It follows public GitHub star data and becomes more informative as the repository grows. +Auto-refreshed after every new star and once a week. GitHub Traffic uses the rolling 14-day owner view. No long-lived token is embedded in this README. The native app follows the macOS language preference and ships complete **English** and **Simplified Chinese** interface catalogs. diff --git a/README.zh-CN.md b/README.zh-CN.md index db47b31..0af7816 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -58,15 +58,15 @@ Agent Switch 用一个本地 MCP 注册表、一个私密凭据存储和可重 数据快照日期:2026-08-10。GitHub 只向仓库维护者提供克隆与独立访客数据,因此这里采用注明日期的透明快照,而不是需要私密 Token 的公开徽章。 -### Star 增长曲线 +### 仓库增长曲线

- - Agent Switch Star 增长曲线 + + Agent Switch 仓库增长与流量指标

-这是由 Star History 提供的动态白底增长曲线,读取 GitHub 公开 Star 数据;随着项目获得更多 Star,曲线会自动变得更完整。 +每次获得新 Star 后自动刷新,并每周定时更新。GitHub Traffic 使用仓库所有者可见的 14 天滚动窗口;README 中不嵌入任何长期 Token。 原生应用会跟随 macOS 的语言偏好,并完整提供**英文**与**简体中文**界面。 diff --git a/scripts/render_repository_metrics.py b/scripts/render_repository_metrics.py new file mode 100755 index 0000000..758d2c7 --- /dev/null +++ b/scripts/render_repository_metrics.py @@ -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'' + f'{value}' + ) + + 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'{escape(label_parts[0])}' + if len(label_parts) == 2: + label_svg += f'{escape(label_parts[1])}' + cards.append( + f'' + '' + '' + f'{label_svg}' + f'{escape(_compact(value))}' + '' + ) + + empty_note = "" + if stars == 0: + empty_note = 'Waiting for the first star' + + updated = generated.strftime("%Y-%m-%d UTC") + start_label = created.strftime("%Y-%m") + end_label = generated.strftime("%Y-%m") + return f''' + {repository} repository metrics + Star growth curve with stars, forks, commits, visitors, cloners, and clone totals. + + + + Repository Pulse + {repository} · privacy-safe public activity + + Stars over time + {''.join(grid)} + + + + + {empty_note} + {start_label} + {end_label} + {''.join(cards)} + Auto-refreshed on stars and weekly · GitHub Traffic uses the rolling 14-day owner view · Updated {updated} + +''' + + +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()) diff --git a/tests/test_localization_catalog.py b/tests/test_localization_catalog.py index 7045779..22936e3 100644 --- a/tests/test_localization_catalog.py +++ b/tests/test_localization_catalog.py @@ -38,10 +38,12 @@ def test_readmes_use_the_sanitized_english_dashboard(self) -> None: for readme in ("README.md", "README.zh-CN.md"): self.assertIn(image, (ROOT / readme).read_text()) - def test_readmes_embed_the_live_star_history_chart(self) -> None: - chart = "https://api.star-history.com/svg?repos=JNHFlow21/agent-switch&type=Date" + def test_readmes_embed_the_privacy_safe_repository_metrics_chart(self) -> None: + chart = "https://raw.githubusercontent.com/JNHFlow21/agent-switch/metrics/repository-metrics.svg" for readme in ("README.md", "README.zh-CN.md"): - self.assertIn(chart, (ROOT / readme).read_text()) + content = (ROOT / readme).read_text() + self.assertIn(chart, content) + self.assertNotIn("api.star-history.com", content) if __name__ == "__main__": diff --git a/tests/test_repository_metrics.py b/tests/test_repository_metrics.py new file mode 100644 index 0000000..cb0fe8d --- /dev/null +++ b/tests/test_repository_metrics.py @@ -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()