Files
hermes-agent/tests/website/test_fetch_plugin_stars.py
teknium1 471d8a427a ci(plugin-catalog): probe stars only from the scheduled skills-index run, in one GraphQL request
Aligns the star ranking with the rule the skills index already follows:
GitHub is consulted only by the twice-daily skills-index.yml schedule, whose
artifact every docs deploy reuses. deploy-site.yml now runs
fetch-plugin-stars.py without --probe (reuse-only: artifact → live site copy →
disk → empty) and cannot call the API at all, so a same-day merge train adds
zero requests regardless of cache age.

The probe itself collapses from one REST call per repo (~26 today, growing
with the catalog) to a single GraphQL query with aliased repository fields,
so the scheduled run costs one request no matter how big the catalog gets. A
failed probe (rate limit, renamed repo, bad token) keeps the previous counts.
2026-09-15 04:39:38 -07:00

81 lines
3.3 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 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_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}