Fix crawler review findings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

jmservera committed May 18, 2026 at 12:18 UTC 779f9ef288f26517b120d84ebf8c257dc945761d
4 files changed +289 -43
.squad/agents/bender/history.md
+1 -2
@@ -14,8 +14,7 @@
14
15 ## Learnings
16
17 +- **2026-05-18T12:07:20.778+02:00:** Copilot review follow-up on crawler hardening: keep star snapshots broad for `stars_gained`, but document that they intentionally cover pre-filter candidates; restore `get_json()` payload compatibility via an internal `get_json_entry()` helper; treat malformed JSON as non-retryable; and search both `RAW_ROOT` and custom `--output` parents when loading prior star snapshots so reruns keep working.
18 - **2026-05-18T10:06:38.734+02:00:** GitHub Actions can run the standalone `copilot` CLI (`@github/copilot`) in programmatic mode with `copilot -p ...`. The safest documented CI auth flow is a fine-grained PAT with the **Copilot Requests** account permission passed as `COPILOT_GITHUB_TOKEN`; `gh auth token` only exposes an existing `gh` token and `gh-copilot` is deprecated in favor of the standalone CLI. GitHub Models (`models: read`) is the clean fallback if direct Copilot CLI automation proves brittle.
19 - **2026-05-18T10:11:20Z:** Team decided Phase 0 PRD decomposition is final; 24 GitHub issues created (4 investigation + 20 implementation). PRD decomposition captures all decisions. MCP tools can crawl beyond GitHub with remote call allowlist. Ready for issue creation.
20 - **2026-05-18T10:27:35.339+02:00:** The crawler now uses `GET /search/repositories` for both `created:>{last_week_date} stars:>50` and `pushed:>{last_week_date} stars:>50`, comparing current stars against the most recent prior `data/raw/*.json` snapshot when available to estimate weekly star gains. It authenticates with `GITHUB_TOKEN`, paginates up to the GitHub Search API's 1,000-result ceiling, caches README checks in-process, applies exponential backoff with jitter for rate limits, and skips repos whose README lookup is blocked by org SAML enforcement.
20 -- **2026-05-18T10:59:10.800+02:00:** Hardening issue #6 required persistent `data/cache/` response caching, dedicated `data/snapshots/YYYY-WNN-stars.json` star maps, stronger low-signal heuristics (fork/template/tutorial/homework filters), and bounded README retries so a real `GITHUB_TOKEN=$(gh auth token) python3 scripts/crawl.py` run can complete while preserving partial data and logging rate-limit state.
21 -- **2026-05-18T10:59:10Z:** Issue #5 complete. Commit fb14275 (209 new repos, 215 trending in data/raw/2026-W21.json). Ready for Issue #6+. User directive: all future work follows branch → PR → Review → Merge workflow (no direct commits to main).
README.md
+6
@@ -28,6 +28,12 @@ SquadScope is a Hugo-powered GitHub Pages site for weekly, monthly, and yearly t
28 - `data/analyzed/` — analysis output
29 - `data/snapshots/` — star count snapshots
30
31 +## Crawler notes
32 +
33 +- `signals.top_topics` de-duplicates repositories by `full_name` across the new and trending buckets before counting topics, so a repo found by both searches only contributes once.
34 +- `data/snapshots/YYYY-WNN-stars.json` intentionally stores the broader pre-filter search candidate universe to preserve week-over-week `stars_gained` comparisons even when a repo is later filtered out of the published payload.
35 +- Live runs use open-ended `created:>` / `pushed:>` GitHub search filters; `--as-of` runs switch to bounded date ranges so historical backfills stay deterministic.
36 +
37 ## Deployment
38
39 Pushing to `main` triggers `.github/workflows/deploy-site.yml`, which builds the Hugo site and deploys the generated `public/` directory to GitHub Pages.
scripts/crawl.py
+109 -41
@@ -9,7 +9,6 @@ import json
9 import os
10 import random
11 import re
12 -import socket
12 import sys
13 import time
14 from collections import Counter
@@ -35,10 +34,7 @@ LOW_SIGNAL_TOPICS = {
34 "examples",
35 "exercise",
36 "homework",
38 - "lab",
37 "learning",
40 - "practice",
41 - "starter",
38 "starter-template",
39 "template",
40 "templates",
@@ -54,26 +50,28 @@ LOW_SIGNAL_TOKENS = {
50 "cheatsheet",
51 "course",
52 "courses",
57 - "demo",
53 "example",
54 "examples",
55 "exercise",
56 "exercises",
57 "homework",
58 + "leetcode",
59 + "template",
60 + "tutorial",
61 + "tutorials",
62 + "walkthrough",
63 + "workshop",
64 +}
65 +LOW_SIGNAL_NAME_TOKENS = {
66 + "demo",
67 "kata",
68 "lab",
69 "labs",
66 - "leetcode",
70 "lesson",
71 "lessons",
72 "practice",
73 "sample",
74 "starter",
72 - "template",
73 - "tutorial",
74 - "tutorials",
75 - "walkthrough",
76 - "workshop",
75 }
76 LOW_SIGNAL_PHRASES = {
77 "course project",
@@ -138,7 +136,7 @@ class ResponseCache:
136 "headers": headers,
137 "payload": payload,
138 }
141 - path.write_text(json.dumps(cache_payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
139 + path.write_text(json.dumps(cache_payload, separators=(",", ":"), ensure_ascii=False) + "\n", encoding="utf-8")
140
141 def _path_for(self, key: str) -> Path:
142 parsed = parse.urlparse(key)
@@ -179,6 +177,27 @@ class GitHubClient:
177 allow_stale: bool = True,
178 max_retries: int | None = None,
179 max_delay_seconds: float = 300.0,
180 + ) -> Any:
181 + return self.get_json_entry(
182 + url,
183 + params,
184 + acceptable_statuses=acceptable_statuses,
185 + ttl_seconds=ttl_seconds,
186 + allow_stale=allow_stale,
187 + max_retries=max_retries,
188 + max_delay_seconds=max_delay_seconds,
189 + ).payload
190 +
191 + def get_json_entry(
192 + self,
193 + url: str,
194 + params: dict[str, Any] | None = None,
195 + *,
196 + acceptable_statuses: set[int] | None = None,
197 + ttl_seconds: int | None = None,
198 + allow_stale: bool = True,
199 + max_retries: int | None = None,
200 + max_delay_seconds: float = 300.0,
201 ) -> CacheEntry:
202 query = f"{url}?{parse.urlencode(params)}" if params else url
203 accepted = acceptable_statuses or set()
@@ -209,8 +228,16 @@ class GitHubClient:
228 self.api_calls_used += 1
229 headers = {name: value for name, value in response.headers.items()}
230 self._update_rate_limit(headers)
212 - payload = json.loads(response.read().decode("utf-8", errors="replace"))
231 + body = response.read().decode("utf-8", errors="replace")
232 self._last_request_at = time.monotonic()
233 + try:
234 + payload = json.loads(body)
235 + except json.JSONDecodeError as exc:
236 + if stale_fallback is not None:
237 + self.stale_cache_hits += 1
238 + log(f"Using stale cache for {query} after malformed JSON response: {exc}")
239 + return stale_fallback
240 + raise RuntimeError(f"GitHub API returned malformed JSON for {query}: {exc}") from exc
241 self._cache.store(query, status=response.status, payload=payload, headers=self._cache_headers(headers))
242 self._log_rate_limit(query)
243 return CacheEntry(response.status, payload, headers, utc_now())
@@ -235,7 +262,7 @@ class GitHubClient:
262 ) from exc
263 self._sleep_before_retry(attempt, headers, body, query, retry_limit, max_delay_seconds)
264 attempt += 1
238 - except (error.URLError, TimeoutError, socket.timeout, json.JSONDecodeError) as exc:
265 + except (error.URLError, TimeoutError) as exc:
266 if attempt >= retry_limit:
267 if stale_fallback is not None:
268 self.stale_cache_hits += 1
@@ -251,7 +278,7 @@ class GitHubClient:
278 max_pages = min((max_results + per_page - 1) // per_page, 10)
279 for page in range(1, max_pages + 1):
280 try:
254 - response = self.get_json(
281 + response = self.get_json_entry(
282 SEARCH_REPOSITORIES,
283 params={
284 "q": query,
@@ -283,7 +310,7 @@ class GitHubClient:
310 return self._readme_cache[full_name]
311 url = f"{API_ROOT}/repos/{full_name}/readme"
312 try:
286 - response = self.get_json(
313 + response = self.get_json_entry(
314 url,
315 acceptable_statuses={404},
316 ttl_seconds=24 * 60 * 60,
@@ -331,6 +358,8 @@ class GitHubClient:
358 retry_after = max(float(headers["Retry-After"]), 1.0)
359 except ValueError:
360 retry_after = None
361 + if retry_after is not None and (self.rate_limit_reset is None or (self.rate_limit_remaining or 0) <= 0):
362 + self.rate_limit_reset = max(self.rate_limit_reset or 0, int(time.time() + retry_after))
363 base_delay = min(2**attempt, 60)
364 jitter = random.uniform(0.3, 1.7)
365 delay = retry_after or reset_delay or (base_delay + jitter)
@@ -355,8 +384,16 @@ class GitHubClient:
384 critical_threshold = max(3, min(10, int(self.rate_limit_limit * 0.03)))
385 if self.rate_limit_remaining > low_threshold:
386 return
358 - reset_delay = self._reset_delay({"X-RateLimit-Reset": str(self.rate_limit_reset)} if self.rate_limit_reset else None)
387 + reset_headers = {"X-RateLimit-Reset": str(self.rate_limit_reset)} if self.rate_limit_reset is not None else None
388 + reset_delay = self._reset_delay(reset_headers)
389 if reset_delay is None:
390 + if self.rate_limit_remaining <= critical_threshold:
391 + delay = 10.0 if self.rate_limit_remaining <= 0 else 3.0
392 + log(
393 + f"Rate limit low ({self.rate_limit_remaining}/{self.rate_limit_limit} {self.rate_limit_resource or 'requests'}) "
394 + f"without reset hint; cooling down {delay:.1f}s before {query}."
395 + )
396 + time.sleep(delay)
397 return
398 if self.rate_limit_remaining <= critical_threshold:
399 delay = min(reset_delay + random.uniform(0.3, 1.5), 300.0)
@@ -469,30 +506,46 @@ def decode_json_body(body: str) -> Any:
506 return {"message": body.strip()}
507
508
472 -def load_previous_star_snapshot(snapshot_dir: Path, raw_dir: Path, current_week: str) -> dict[str, int]:
509 +def load_previous_star_snapshot(snapshot_dir: Path, current_week: str, *raw_dirs: Path) -> dict[str, int]:
510 for snapshot in sorted(snapshot_dir.glob("*-stars.json"), reverse=True):
474 - stars = load_star_mapping(snapshot, current_week)
475 - if stars:
476 - return stars
477 - for snapshot in sorted(raw_dir.glob("*.json"), reverse=True):
478 - stars = load_star_mapping(snapshot, current_week)
511 + stars, reason = load_star_mapping_details(snapshot, current_week)
512 if stars:
513 return stars
514 + if reason and reason != "same-week snapshot":
515 + log(f"Skipping star snapshot {snapshot}: {reason}.")
516 + seen_dirs: set[Path] = set()
517 + for raw_dir in raw_dirs:
518 + if raw_dir in seen_dirs:
519 + continue
520 + seen_dirs.add(raw_dir)
521 + for snapshot in sorted(raw_dir.glob("*.json"), reverse=True):
522 + stars, reason = load_star_mapping_details(snapshot, current_week)
523 + if stars:
524 + return stars
525 + if reason and reason != "same-week snapshot":
526 + log(f"Skipping star snapshot {snapshot}: {reason}.")
527 return {}
528
529
530 def load_star_mapping(path: Path, current_week: str) -> dict[str, int]:
531 + return load_star_mapping_details(path, current_week)[0]
532 +
533 +
534 +def load_star_mapping_details(path: Path, current_week: str) -> tuple[dict[str, int], str | None]:
535 try:
536 payload = json.loads(path.read_text(encoding="utf-8"))
487 - except (OSError, json.JSONDecodeError):
488 - return {}
537 + except OSError as exc:
538 + return {}, f"read failed ({exc})"
539 + except json.JSONDecodeError as exc:
540 + return {}, f"invalid JSON ({exc})"
541 if payload.get("week") == current_week:
490 - return {}
542 + return {}, "same-week snapshot"
543 stars = payload.get("stars")
544 if isinstance(stars, dict):
545 mapping = {name: int(value) for name, value in stars.items() if isinstance(value, int)}
546 if mapping:
495 - return mapping
547 + return mapping, None
548 + return {}, "empty stars mapping"
549 mapping: dict[str, int] = {}
550 for section in ("new_repos", "trending_repos"):
551 for repo in payload.get(section, []):
@@ -500,7 +553,9 @@ def load_star_mapping(path: Path, current_week: str) -> dict[str, int]:
553 stars_value = repo.get("stars")
554 if full_name and isinstance(stars_value, int):
555 mapping[full_name] = stars_value
503 - return mapping
556 + if mapping:
557 + return mapping, None
558 + return {}, "no star data"
559
560
561 def tokenize(text: str) -> set[str]:
@@ -518,16 +573,13 @@ def significance_skip_reason(repo: dict[str, Any]) -> str | None:
573 topics = {str(topic).lower() for topic in repo.get("topics") or []}
574 if topics & LOW_SIGNAL_TOPICS:
575 return "low_signal_topic"
521 - combined_text = " ".join(
522 - [
523 - str(repo.get("name") or ""),
524 - description,
525 - " ".join(sorted(topics)),
526 - ]
527 - ).lower()
576 + name = str(repo.get("name") or "")
577 + combined_text = " ".join([name, description]).lower()
578 combined_tokens = tokenize(combined_text)
579 if combined_tokens & LOW_SIGNAL_TOKENS:
580 return "low_signal_keyword"
581 + if tokenize(name) & LOW_SIGNAL_NAME_TOKENS:
582 + return "low_signal_keyword"
583 if any(phrase in combined_text for phrase in LOW_SIGNAL_PHRASES):
584 return "low_signal_phrase"
585 return None
@@ -607,6 +659,7 @@ def collect_repositories(
659
660 def build_signals(*repo_groups: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
661 topic_counter: Counter[str] = Counter()
662 + # A repo can appear in both search buckets; count its topics once to avoid inflating weekly signals.
663 merged: dict[str, dict[str, Any]] = {}
664 for group in repo_groups:
665 for repo in group:
@@ -662,10 +715,18 @@ def validate_payload(payload: dict[str, Any]) -> None:
715 raise ValueError("metadata.api_calls_used must be an integer")
716 if not isinstance(metadata.get("cache_hits"), int):
717 raise ValueError("metadata.cache_hits must be an integer")
718 + if not isinstance(metadata.get("stale_cache_hits"), int):
719 + raise ValueError("metadata.stale_cache_hits must be an integer")
720 if metadata.get("rate_limit_remaining") is not None and not isinstance(metadata.get("rate_limit_remaining"), int):
721 raise ValueError("metadata.rate_limit_remaining must be an integer or null")
722 if metadata.get("rate_limit_limit") is not None and not isinstance(metadata.get("rate_limit_limit"), int):
723 raise ValueError("metadata.rate_limit_limit must be an integer or null")
724 + if metadata.get("rate_limit_reset") is not None and not isinstance(metadata.get("rate_limit_reset"), int):
725 + raise ValueError("metadata.rate_limit_reset must be an integer or null")
726 + if metadata.get("rate_limit_resource") is not None and not isinstance(metadata.get("rate_limit_resource"), str):
727 + raise ValueError("metadata.rate_limit_resource must be a string or null")
728 + if not isinstance(metadata.get("snapshot_path"), str):
729 + raise ValueError("metadata.snapshot_path must be a string")
730 partial_failures = metadata.get("partial_failures")
731 if partial_failures is not None and not isinstance(partial_failures, list):
732 raise ValueError("metadata.partial_failures must be a list when present")
@@ -692,12 +753,17 @@ def main() -> int:
753 client = GitHubClient(github_token)
754 max_results = max(1, min(args.max_results, 1000))
755
695 - date_range = f"{since.date().isoformat()}..{window_end.date().isoformat()}"
696 - new_query = f"created:{date_range} stars:>50"
697 - trending_query = f"pushed:{date_range} stars:>50"
756 + if args.as_of:
757 + created_filter = f"created:{since.date().isoformat()}..{window_end.date().isoformat()}"
758 + pushed_filter = f"pushed:{since.date().isoformat()}..{window_end.date().isoformat()}"
759 + else:
760 + created_filter = f"created:>{since.date().isoformat()}"
761 + pushed_filter = f"pushed:>{since.date().isoformat()}"
762 + new_query = f"{created_filter} stars:>50"
763 + trending_query = f"{pushed_filter} stars:>50"
764
765 new_candidates = client.search_repositories(new_query, max_results=max_results)
700 - previous_stars = load_previous_star_snapshot(SNAPSHOT_ROOT, output_path.parent, week)
766 + previous_stars = load_previous_star_snapshot(SNAPSHOT_ROOT, week, output_path.parent, RAW_ROOT)
767 trending_candidates = client.search_repositories(trending_query, max_results=max_results)
768
769 new_repos, new_filters = collect_repositories(client, new_candidates)
@@ -708,6 +774,8 @@ def main() -> int:
774 trending_cutoff=since,
775 )
776
777 + # Keep star snapshots broader than the filtered payload so future reruns can still compute deltas
778 + # even when a repo is later excluded as low-signal or missing a README.
779 star_snapshot = build_star_snapshot(new_candidates, trending_candidates)
780 snapshot_payload = {
781 "week": week,
@@ -728,7 +796,7 @@ def main() -> int:
796 "stale_cache_hits": client.stale_cache_hits,
797 "rate_limit_limit": client.rate_limit_limit,
798 "rate_limit_remaining": client.rate_limit_remaining,
731 - "rate_limit_reset": rate_limit_reset_text(client.rate_limit_reset),
799 + "rate_limit_reset": client.rate_limit_reset,
800 "rate_limit_resource": client.rate_limit_resource,
801 "partial_failures": client.errors,
802 "filter_summary": {
@@ -749,7 +817,7 @@ def main() -> int:
817 f"Wrote {output_path} with {len(new_repos)} new repos and {len(trending_repos)} trending repos, "
818 f"saved {snapshot_path}, used {client.api_calls_used} API calls, and served {client.cache_hits} cache hits."
819 )
752 - return 0 if (new_candidates or trending_candidates or not client.errors) else 1
820 + return 1 if client.errors else 0
821
822
823 if __name__ == "__main__":
tests/test_crawl.py new
+173
@@ -0,0 +1,173 @@
1 +import tempfile
2 +import unittest
3 +from argparse import Namespace
4 +from pathlib import Path
5 +from unittest import mock
6 +
7 +import scripts.crawl as crawl
8 +
9 +
10 +class CrawlTests(unittest.TestCase):
11 + def test_significance_skip_reason_allows_common_description_terms(self) -> None:
12 + repo = {
13 + "name": "awesome-tool",
14 + "description": "Includes sample data and a live demo for deployment.",
15 + "topics": ["ai", "demo"],
16 + "fork": False,
17 + "is_template": False,
18 + }
19 +
20 + self.assertIsNone(crawl.significance_skip_reason(repo))
21 +
22 + def test_significance_skip_reason_still_flags_name_tokens(self) -> None:
23 + repo = {
24 + "name": "starter-kit",
25 + "description": "Production-ready auth service.",
26 + "topics": ["ai"],
27 + "fork": False,
28 + "is_template": False,
29 + }
30 +
31 + self.assertEqual(crawl.significance_skip_reason(repo), "low_signal_keyword")
32 +
33 + def test_get_json_preserves_payload_contract(self) -> None:
34 + client = crawl.GitHubClient("token")
35 + entry = crawl.CacheEntry(status=200, payload={"ok": True}, headers={}, fetched_at=crawl.utc_now())
36 +
37 + with mock.patch.object(client, "get_json_entry", return_value=entry):
38 + self.assertEqual(client.get_json("https://example.com"), {"ok": True})
39 +
40 + def test_load_previous_star_snapshot_checks_all_raw_dirs_and_logs_failures(self) -> None:
41 + tests_root = Path(__file__).resolve().parent
42 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
43 + base = Path(tmpdir)
44 + snapshot_dir = base / "snapshots"
45 + raw_default_dir = base / "raw-default"
46 + custom_output_dir = base / "custom-output"
47 + snapshot_dir.mkdir()
48 + raw_default_dir.mkdir()
49 + custom_output_dir.mkdir()
50 +
51 + (snapshot_dir / "2026-W21-stars.json").write_text(
52 + '{"week": "2026-W21", "stars": {"owner/current": 1}}\n', encoding="utf-8"
53 + )
54 + (raw_default_dir / "2026-W20.json").write_text("{not-json}\n", encoding="utf-8")
55 + (raw_default_dir / "2026-W19.json").write_text(
56 + '{"week": "2026-W19", "stars": {"owner/older": 42}}\n', encoding="utf-8"
57 + )
58 +
59 + with mock.patch.object(crawl, "log") as log_mock:
60 + stars = crawl.load_previous_star_snapshot(snapshot_dir, "2026-W21", custom_output_dir, raw_default_dir)
61 +
62 + self.assertEqual(stars, {"owner/older": 42})
63 + logged = "\n".join(call.args[0] for call in log_mock.call_args_list)
64 + self.assertIn("invalid JSON", logged)
65 +
66 + def test_validate_payload_accepts_rate_limit_metadata_schema(self) -> None:
67 + payload = {
68 + "week": "2026-W21",
69 + "crawled_at": "2026-05-18T10:00:00Z",
70 + "new_repos": [],
71 + "trending_repos": [],
72 + "signals": {"top_topics": []},
73 + "metadata": {
74 + "api_calls_used": 1,
75 + "cache_hits": 2,
76 + "stale_cache_hits": 3,
77 + "rate_limit_limit": 5000,
78 + "rate_limit_remaining": 4990,
79 + "rate_limit_reset": 1747562400,
80 + "rate_limit_resource": "search",
81 + "partial_failures": [],
82 + "snapshot_path": "data/snapshots/2026-W21-stars.json",
83 + },
84 + }
85 +
86 + crawl.validate_payload(payload)
87 +
88 + def test_pause_for_rate_limit_cools_down_without_reset_hint(self) -> None:
89 + client = crawl.GitHubClient("token")
90 + client.rate_limit_limit = 5000
91 + client.rate_limit_remaining = 0
92 + client.rate_limit_resource = "core"
93 +
94 + with mock.patch("scripts.crawl.time.sleep") as sleep_mock:
95 + client._pause_for_rate_limit("https://example.com")
96 +
97 + sleep_mock.assert_called_once()
98 +
99 + def test_main_uses_open_ended_queries_for_live_runs(self) -> None:
100 + queries: list[str] = []
101 +
102 + class FakeClient:
103 + def __init__(self, token: str) -> None:
104 + self.token = token
105 + self.api_calls_used = 0
106 + self.cache_hits = 0
107 + self.stale_cache_hits = 0
108 + self.rate_limit_limit = None
109 + self.rate_limit_remaining = None
110 + self.rate_limit_reset = None
111 + self.rate_limit_resource = None
112 + self.errors = []
113 +
114 + def search_repositories(self, query: str, *, max_results: int = 1000):
115 + queries.append(query)
116 + return []
117 +
118 + def has_readme(self, full_name: str) -> bool:
119 + return True
120 +
121 + args = Namespace(since="2026-05-11", as_of=None, max_results=25, output="data/raw/test-live.json")
122 + with mock.patch.object(crawl, "parse_args", return_value=args), mock.patch.dict(
123 + "os.environ", {"GITHUB_TOKEN": "token"}, clear=False
124 + ), mock.patch.object(crawl, "GitHubClient", FakeClient), mock.patch.object(
125 + crawl, "load_previous_star_snapshot", return_value={}
126 + ), mock.patch.object(crawl, "write_payload"), mock.patch.object(crawl, "print"):
127 + exit_code = crawl.main()
128 +
129 + self.assertEqual(exit_code, 0)
130 + self.assertEqual(queries, ["created:>2026-05-11 stars:>50", "pushed:>2026-05-11 stars:>50"])
131 +
132 + def test_main_uses_bounded_queries_for_backfills_and_fails_on_partial_errors(self) -> None:
133 + queries: list[str] = []
134 +
135 + class FakeClient:
136 + def __init__(self, token: str) -> None:
137 + self.token = token
138 + self.api_calls_used = 0
139 + self.cache_hits = 0
140 + self.stale_cache_hits = 0
141 + self.rate_limit_limit = None
142 + self.rate_limit_remaining = None
143 + self.rate_limit_reset = None
144 + self.rate_limit_resource = None
145 + self.errors = ["README lookup failed"]
146 +
147 + def search_repositories(self, query: str, *, max_results: int = 1000):
148 + queries.append(query)
149 + return []
150 +
151 + def has_readme(self, full_name: str) -> bool:
152 + return True
153 +
154 + args = Namespace(since="2026-05-11", as_of="2026-05-18", max_results=25, output="data/raw/test-backfill.json")
155 + with mock.patch.object(crawl, "parse_args", return_value=args), mock.patch.dict(
156 + "os.environ", {"GITHUB_TOKEN": "token"}, clear=False
157 + ), mock.patch.object(crawl, "GitHubClient", FakeClient), mock.patch.object(
158 + crawl, "load_previous_star_snapshot", return_value={}
159 + ), mock.patch.object(crawl, "write_payload"), mock.patch.object(crawl, "print"):
160 + exit_code = crawl.main()
161 +
162 + self.assertEqual(exit_code, 1)
163 + self.assertEqual(
164 + queries,
165 + [
166 + "created:2026-05-11..2026-05-18 stars:>50",
167 + "pushed:2026-05-11..2026-05-18 stars:>50",
168 + ],
169 + )
170 +
171 +
172 +if __name__ == "__main__":
173 + unittest.main()