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''' @@ -210,7 +250,7 @@ def y_for(value: int) -> float: {start_label} {end_label} {''.join(cards)} - Auto-refreshed on stars and weekly · GitHub Traffic uses the rolling 14-day owner view · Updated {updated} + Stars, forks, and commits auto-refresh · {traffic_note} · Updated {updated} ''' @@ -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)