Files
hermes-agent/tests/website/test_fetch_plugin_stars.py
teknium1 80512c90dc fix(website): batch the plugin stars probe at 100 repos per GraphQL request
A single request for all 317 catalog repos now exceeds GitHub's per-query
resource limit: the reply carries partial data plus an error, the script
prints 'Probed 317', and every repo past the limit keeps its stale count
(hindsight showed 26.5k stars while GitHub had 34.5k). Batch at 100; probed
live: 317/317 repos in 4 requests, no errors.
2026-09-27 03:34:56 -07:00

117 lines
5.1 KiB
Python

"""fetch-plugin-stars.py: plugin-catalog star counts, GitHub consulted only from the scheduled run.
The contract under test is rate-limit discipline, not the numbers: a deploy (no ``--probe``)
must never reach GitHub, the scheduled probe must be ONE request for every repo, and a failed
probe must keep the previous counts rather than zeroing them.
"""
from __future__ import annotations
import importlib.util
import json
import urllib.error
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "website" / "scripts" / "fetch-plugin-stars.py"
@pytest.fixture(scope="module")
def mod():
spec = importlib.util.spec_from_file_location("fetch_plugin_stars", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _catalog(tmp_path: Path, *repos: str) -> Path:
import hermes_yaml as yaml
cat = tmp_path / "plugin-catalog"
cat.mkdir()
for i, repo in enumerate(repos):
(cat / f"p{i}.yaml").write_text(yaml.safe_dump({
"name": f"p{i}", "repo": repo, "sha": "38fe0fb53eff98d477f807432e965429e665ca33",
"description": "d", "maintainer": "m"}), encoding="utf-8")
return cat
def test_deploy_reuses_the_cache_without_any_github_call(mod, tmp_path, monkeypatch):
cat = _catalog(tmp_path, "https://github.com/a/one")
out = tmp_path / "plugin-stars.json"
out.write_text(json.dumps({"fetched_at": "2026-01-01T00:00:00+00:00", "stars": {"a/one": 7}}), encoding="utf-8")
def boom(*a, **k):
raise AssertionError("GitHub must not be called without --probe")
monkeypatch.setattr(mod, "_graphql", boom)
monkeypatch.setattr(mod, "_http_json", boom)
assert mod.main(catalog_dir=cat, output=out, probe=False, live_url=None) == 0
assert json.loads(out.read_text())["stars"] == {"a/one": 7}
def test_probe_is_one_graphql_request_for_a_small_catalog_and_a_failure_keeps_previous_counts(mod, tmp_path, monkeypatch):
cat = _catalog(tmp_path, "https://github.com/a/one", "https://github.com/b/two", "https://gitlab.com/c/three")
out = tmp_path / "plugin-stars.json"
out.write_text(json.dumps({"fetched_at": "2026-01-01T00:00:00+00:00", "stars": {"a/one": 7, "b/two": 9}}),
encoding="utf-8")
calls: list[str] = []
def one_request(query, token):
calls.append(query)
# b/two errored (renamed repo): its node is null, previous count must survive.
return {"data": {"r0": {"stargazerCount": 42}, "r1": None},
"errors": [{"message": "Could not resolve to a Repository"}]}
monkeypatch.setattr(mod, "_graphql", one_request)
assert mod.main(catalog_dir=cat, output=out, probe=True, live_url=None, token="t") == 0
data = json.loads(out.read_text())
assert data["stars"] == {"a/one": 42, "b/two": 9}
assert len(calls) == 1 and "gitlab" not in calls[0] and 'owner: "a"' in calls[0] and 'owner: "b"' in calls[0]
assert data["fetched_at"] > "2026-01-01"
# A rate-limited / failed probe keeps everything as it was.
def limited(query, token):
raise urllib.error.HTTPError("u", 403, "rate limited", hdrs=None, fp=None)
monkeypatch.setattr(mod, "_graphql", limited)
assert mod.main(catalog_dir=cat, output=out, probe=True, live_url=None, token="t") == 0
assert json.loads(out.read_text())["stars"] == {"a/one": 42, "b/two": 9}
def test_failed_probe_keeps_the_previous_timestamp_and_warns(mod, tmp_path, monkeypatch, capsys):
"""An expired-token 401 must not restamp ``fetched_at``: the catalog footer reads it as
"ranking as of <date>" and showed today's date over five-day-old counts (#118113)."""
cat = _catalog(tmp_path, "https://github.com/a/one", "https://github.com/b/two")
out = tmp_path / "plugin-stars.json"
out.write_text(json.dumps({"fetched_at": "2026-09-16T18:40:16+00:00", "stars": {"a/one": 7}}), encoding="utf-8")
def unauthorized(query, token):
raise urllib.error.HTTPError("u", 401, "Unauthorized", hdrs=None, fp=None)
monkeypatch.setattr(mod, "_graphql", unauthorized)
assert mod.main(catalog_dir=cat, output=out, probe=True, live_url=None, token="expired") == 0
data = json.loads(out.read_text())
assert data == {"fetched_at": "2026-09-16T18:40:16+00:00", "stars": {"a/one": 7}}
assert "::warning::" in capsys.readouterr().out
def test_probe_batches_large_catalogs_and_every_repo_gets_a_count(mod, monkeypatch):
# One request for the whole catalog tripped GitHub's per-query resource limit at ~300
# repos and silently left the tail without counts; batches must cover every slug and the
# alias index must restart per request.
slugs = [f"o/r{i}" for i in range(mod._BATCH * 2 + 5)]
seen: list[int] = []
def per_batch(query, token):
n = query.count("repository(")
seen.append(n)
return {"data": {f"r{i}": {"stargazerCount": i} for i in range(n)}}
monkeypatch.setattr(mod, "_graphql", per_batch)
stars, probed = mod.probe_stars(slugs, {}, token="t")
assert probed and len(stars) == len(slugs)
assert seen == [mod._BATCH, mod._BATCH, 5]
assert stars[f"o/r{mod._BATCH}"] == 0 and stars[f"o/r{mod._BATCH + 1}"] == 1