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.
117 lines
5.1 KiB
Python
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
|