From 42877d78c052cf40c1f9b174a1e463a67774d195 Mon Sep 17 00:00:00 2001
From: JNHFlow21
Date: Mon, 10 Aug 2026 23:14:18 -0400
Subject: [PATCH] fix: fall back safely when GitHub traffic is private
---
.github/repository-metrics-traffic.json | 7 +++
.github/workflows/repository-metrics.yml | 1 +
README.md | 2 +-
README.zh-CN.md | 2 +-
scripts/render_repository_metrics.py | 70 ++++++++++++++++++++----
tests/test_repository_metrics.py | 35 ++++++++++++
6 files changed, 105 insertions(+), 12 deletions(-)
create mode 100644 .github/repository-metrics-traffic.json
diff --git a/.github/repository-metrics-traffic.json b/.github/repository-metrics-traffic.json
new file mode 100644
index 0000000..2211b7d
--- /dev/null
+++ b/.github/repository-metrics-traffic.json
@@ -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"
+}
diff --git a/.github/workflows/repository-metrics.yml b/.github/workflows/repository-metrics.yml
index 9110ba0..fe6e004 100644
--- a/.github/workflows/repository-metrics.yml
+++ b/.github/workflows/repository-metrics.yml
@@ -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: |
diff --git a/README.md b/README.md
index b4caeb4..b6513f6 100644
--- a/README.md
+++ b/README.md
@@ -68,7 +68,7 @@ credential store, and deterministic per-agent projections.
-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.
+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.
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 0af7816..5f66000 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -66,7 +66,7 @@ Agent Switch 用一个本地 MCP 注册表、一个私密凭据存储和可重
-每次获得新 Star 后自动刷新,并每周定时更新。GitHub Traffic 使用仓库所有者可见的 14 天滚动窗口;README 中不嵌入任何长期 Token。
+Star、Fork 与 Commit 会在每次获得新 Star 后及每周自动刷新。由于 GitHub 不允许 Actions Token 读取仓库 Traffic,流量卡片使用图中注明日期的仓库所有者聚合快照;README 中不嵌入任何长期 Token。
原生应用会跟随 macOS 的语言偏好,并完整提供**英文**与**简体中文**界面。
diff --git a/scripts/render_repository_metrics.py b/scripts/render_repository_metrics.py
index 758d2c7..7d2e371 100755
--- a/scripts/render_repository_metrics.py
+++ b/scripts/render_repository_metrics.py
@@ -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
@@ -34,7 +35,11 @@ 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):
@@ -42,8 +47,40 @@ def fetch_snapshot(repository: str, token: str) -> dict[str, Any]:
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", "")
@@ -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),
}
@@ -171,6 +206,11 @@ def y_for(value: int) -> float:
empty_note = 'Waiting for the first star'
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'''
'''
@@ -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")
diff --git a/tests/test_repository_metrics.py b/tests/test_repository_metrics.py
index cb0fe8d..dea9eb1 100644
--- a/tests/test_repository_metrics.py
+++ b/tests/test_repository_metrics.py
@@ -1,6 +1,7 @@
import importlib.util
from pathlib import Path
import unittest
+from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
@@ -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",
@@ -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",
@@ -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)