Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/repository-metrics.yml
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 }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Supply a token permitted to read repository traffic

The workflow GITHUB_TOKEN can only receive the permissions exposed under permissions:, and contents: write does not grant the Administration (read) permission required by the /traffic/views and /traffic/clones endpoints called by fetch_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 👍 / 👎.

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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,15 @@ credential store, and deterministic per-agent projections.

<sub>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.</sub>

### Star history
### Repository growth

<p align="center">
<a href="https://www.star-history.com/#JNHFlow21/agent-switch&Date">
<img width="800" src="https://api.star-history.com/svg?repos=JNHFlow21/agent-switch&type=Date" alt="Agent Switch star history chart">
<a href="https://github.com/JNHFlow21/agent-switch/stargazers">
<img width="800" src="https://raw.githubusercontent.com/JNHFlow21/agent-switch/metrics/repository-metrics.svg" alt="Agent Switch repository growth and traffic metrics">
</a>
</p>

<sub>Live white-background chart from Star History. It follows public GitHub star data and becomes more informative as the repository grows.</sub>
<sub>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.</sub>

The native app follows the macOS language preference and ships complete
**English** and **Simplified Chinese** interface catalogs.
Expand Down
8 changes: 4 additions & 4 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@ Agent Switch 用一个本地 MCP 注册表、一个私密凭据存储和可重

<sub>数据快照日期:2026-08-10。GitHub 只向仓库维护者提供克隆与独立访客数据,因此这里采用注明日期的透明快照,而不是需要私密 Token 的公开徽章。</sub>

### Star 增长曲线
### 仓库增长曲线

<p align="center">
<a href="https://www.star-history.com/#JNHFlow21/agent-switch&Date">
<img width="800" src="https://api.star-history.com/svg?repos=JNHFlow21/agent-switch&type=Date" alt="Agent Switch Star 增长曲线">
<a href="https://github.com/JNHFlow21/agent-switch/stargazers">
<img width="800" src="https://raw.githubusercontent.com/JNHFlow21/agent-switch/metrics/repository-metrics.svg" alt="Agent Switch 仓库增长与流量指标">
</a>
</p>

<sub>这是由 Star History 提供的动态白底增长曲线,读取 GitHub 公开 Star 数据;随着项目获得更多 Star,曲线会自动变得更完整。</sub>
<sub>每次获得新 Star 后自动刷新,并每周定时更新。GitHub Traffic 使用仓库所有者可见的 14 天滚动窗口;README 中不嵌入任何长期 Token。</sub>

原生应用会跟随 macOS 的语言偏好,并完整提供**英文**与**简体中文**界面。

Expand Down
239 changes: 239 additions & 0 deletions scripts/render_repository_metrics.py
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())
8 changes: 5 additions & 3 deletions tests/test_localization_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
46 changes: 46 additions & 0 deletions tests/test_repository_metrics.py
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()