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
7 changes: 7 additions & 0 deletions .github/repository-metrics-traffic.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"unique_visitors_14d": 0,
"views_14d": 0,
"unique_cloners_14d": 11,
"clones_14d": 12,
"traffic_as_of": "2026-08-10T00:00:00Z"
}
1 change: 1 addition & 0 deletions .github/workflows/repository-metrics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jobs:
run: >-
python3 scripts/render_repository_metrics.py
--repository "$GITHUB_REPOSITORY"
--traffic-snapshot .github/repository-metrics-traffic.json
--output "$RUNNER_TEMP/repository-metrics.svg"
- name: Publish generated image to the metrics branch
run: |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ credential store, and deterministic per-agent projections.
</a>
</p>

<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>
<sub>Stars, forks, and commits refresh after every new star and once a week. Traffic cards use the dated aggregate owner snapshot shown in the chart because GitHub does not grant its Actions token access to repository Traffic. 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
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Agent Switch 用一个本地 MCP 注册表、一个私密凭据存储和可重
</a>
</p>

<sub>每次获得新 Star 后自动刷新,并每周定时更新。GitHub Traffic 使用仓库所有者可见的 14 天滚动窗口;README 中不嵌入任何长期 Token。</sub>
<sub>Star、Fork 与 Commit 会在每次获得新 Star 后及每周自动刷新。由于 GitHub 不允许 Actions Token 读取仓库 Traffic,流量卡片使用图中注明日期的仓库所有者聚合快照;README 中不嵌入任何长期 Token。</sub>

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

Expand Down
70 changes: 60 additions & 10 deletions scripts/render_repository_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pathlib import Path
import re
from typing import Any
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen

Expand All @@ -34,16 +35,52 @@ def _request_json(path: str, token: str, *, accept: str = "application/vnd.githu
return json.load(response), response.headers


def fetch_snapshot(repository: str, token: str) -> dict[str, Any]:
def fetch_snapshot(
repository: str,
token: str,
traffic_fallback: dict[str, Any] | None = None,
) -> 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)
generated_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
traffic_live = True
try:
views, _ = _request_json(f"/repos/{encoded}/traffic/views", token)
clones, _ = _request_json(f"/repos/{encoded}/traffic/clones", token)
traffic = {
"unique_visitors_14d": int(views["uniques"]),
"views_14d": int(views["count"]),
"unique_cloners_14d": int(clones["uniques"]),
"clones_14d": int(clones["count"]),
"traffic_as_of": generated_at,
}
except HTTPError as error:
error.close()
if error.code != 403 or traffic_fallback is None:
raise
required = {
"unique_visitors_14d",
"views_14d",
"unique_cloners_14d",
"clones_14d",
"traffic_as_of",
}
missing = sorted(required - traffic_fallback.keys())
if missing:
raise ValueError(f"traffic snapshot is missing: {', '.join(missing)}") from error
traffic_live = False
traffic = {
"unique_visitors_14d": int(traffic_fallback["unique_visitors_14d"]),
"views_14d": int(traffic_fallback["views_14d"]),
"unique_cloners_14d": int(traffic_fallback["unique_cloners_14d"]),
"clones_14d": int(traffic_fallback["clones_14d"]),
"traffic_as_of": str(traffic_fallback["traffic_as_of"]),
}
_, commit_headers = _request_json(f"/repos/{encoded}/commits?per_page=1", token)

link = commit_headers.get("Link", "")
Expand All @@ -70,14 +107,12 @@ def fetch_snapshot(repository: str, token: str) -> dict[str, Any]:
return {
"repository": repository,
"created_at": repo["created_at"],
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
"generated_at": generated_at,
"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"]),
**traffic,
"traffic_live": traffic_live,
"starred_at": sorted(starred_at),
}

Expand Down Expand Up @@ -171,6 +206,11 @@ def y_for(value: int) -> float:
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")
traffic_as_of = _parse_time(str(snapshot.get("traffic_as_of", snapshot["generated_at"])))
if snapshot.get("traffic_live", False):
traffic_note = "GitHub Traffic: rolling 14-day owner view"
else:
traffic_note = f"GitHub Traffic owner snapshot: {traffic_as_of.strftime('%Y-%m-%d')}"
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">
Expand Down Expand Up @@ -210,7 +250,7 @@ def y_for(value: int) -> float:
<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>
<text x="54" y="520" class="footer">Stars, forks, and commits auto-refresh · {traffic_note} · Updated {updated}</text>
</svg>
'''

Expand All @@ -220,12 +260,22 @@ def main() -> int:
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")
parser.add_argument(
"--traffic-snapshot",
type=Path,
help="Aggregate owner snapshot used only when GitHub Actions cannot read the Traffic 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", ""))
traffic_fallback = json.loads(args.traffic_snapshot.read_text()) if args.traffic_snapshot else None
snapshot = fetch_snapshot(
args.repository,
os.environ.get("GITHUB_TOKEN", ""),
traffic_fallback,
)
if snapshot.get("repository") != args.repository:
raise ValueError("snapshot repository does not match --repository")

Expand Down
35 changes: 35 additions & 0 deletions tests/test_repository_metrics.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch


ROOT = Path(__file__).resolve().parents[1]
Expand All @@ -12,6 +13,37 @@


class RepositoryMetricsTests(unittest.TestCase):
def test_actions_uses_dated_aggregate_when_traffic_api_is_forbidden(self) -> None:
def fake_request(path, _credential, *, accept="application/vnd.github+json"):
if path == "/repos/Example/project":
return ({
"created_at": "2026-01-01T00:00:00Z",
"stargazers_count": 1,
"forks_count": 2,
}, {})
if path.endswith("/traffic/views"):
raise MODULE.HTTPError(path, 403, "Forbidden", {}, None)
if path.endswith("/commits?per_page=1"):
return ([{"sha": "example"}], {})
if "/stargazers?" in path:
return ([{"starred_at": "2026-02-01T00:00:00Z"}], {})
self.fail(f"unexpected request: {path} ({accept})")

fallback = {
"unique_visitors_14d": 7,
"views_14d": 9,
"unique_cloners_14d": 4,
"clones_14d": 6,
"traffic_as_of": "2026-08-09T00:00:00Z",
}
with patch.object(MODULE, "_request_json", side_effect=fake_request):
snapshot = MODULE.fetch_snapshot("Example/project", "workflow-credential", fallback)

self.assertFalse(snapshot["traffic_live"])
self.assertEqual(snapshot["unique_visitors_14d"], 7)
self.assertEqual(snapshot["unique_cloners_14d"], 4)
self.assertEqual(snapshot["traffic_as_of"], "2026-08-09T00:00:00Z")

def test_svg_is_white_privacy_safe_and_contains_requested_metrics(self) -> None:
svg = MODULE.render_svg({
"repository": "Example/project",
Expand All @@ -24,6 +56,8 @@ def test_svg_is_white_privacy_safe_and_contains_requested_metrics(self) -> None:
"views_14d": 9,
"unique_cloners_14d": 4,
"clones_14d": 6,
"traffic_as_of": "2026-08-09T00:00:00Z",
"traffic_live": False,
"starred_at": [
"2026-02-01T00:00:00Z",
"2026-04-01T00:00:00Z",
Expand All @@ -37,6 +71,7 @@ def test_svg_is_white_privacy_safe_and_contains_requested_metrics(self) -> None:
self.assertIn("Unique cloners", svg)
self.assertIn("Total clones", svg)
self.assertEqual(svg.count('class="metric-period">14d'), 3)
self.assertIn("GitHub Traffic owner snapshot: 2026-08-09", svg)
self.assertIn('fill: #ffffff', svg)
self.assertIn('class="curve"', svg)
self.assertNotIn("GITHUB_TOKEN", svg)
Expand Down