main
py 1,368 lines 51.3 KB
Raw
1 #!/usr/bin/env python3
2 """Collect weekly GitHub repository signals for SquadScope."""
3
4 from __future__ import annotations
5
6 import argparse
7 import hashlib
8 import json
9 import os
10 import re
11 import secrets
12 import sys
13 import time
14 from collections import Counter
15 from dataclasses import dataclass
16 from datetime import UTC, datetime, timedelta
17 from pathlib import Path
18 from typing import Any, Iterable
19 from urllib import error, parse, request
20
21 from scripts.observability_metrics import (
22 DEFAULT_OBSERVABILITY_DIR,
23 METRICS_SCHEMA_VERSION,
24 CrawlMetrics,
25 ObservabilityLedger,
26 emit_ledger,
27 )
28 from scripts.topic_paths import cache_dir, raw_dir, snapshots_dir
29
30 API_ROOT = "https://api.github.com"
31 SEARCH_REPOSITORIES = f"{API_ROOT}/search/repositories"
32 _JITTER_RANDOM = secrets.SystemRandom()
33 CACHE_ROOT = Path("data/cache")
34 RAW_ROOT = Path("data/raw")
35 SNAPSHOT_ROOT = Path("data/snapshots")
36 RETRYABLE_STATUSES = {403, 429, 500, 502, 503, 504}
37 LOW_SIGNAL_TOPICS = {
38 "assignment",
39 "assignments",
40 "bootcamp",
41 "course",
42 "courses",
43 "example",
44 "examples",
45 "exercise",
46 "homework",
47 "learning",
48 "starter-template",
49 "template",
50 "templates",
51 "tutorial",
52 "tutorials",
53 "workshop",
54 "workshops",
55 }
56 LOW_SIGNAL_TOKENS = {
57 "assignment",
58 "assignments",
59 "bootcamp",
60 "cheatsheet",
61 "course",
62 "courses",
63 "example",
64 "examples",
65 "exercise",
66 "exercises",
67 "homework",
68 "leetcode",
69 "template",
70 "tutorial",
71 "tutorials",
72 "walkthrough",
73 "workshop",
74 }
75 LOW_SIGNAL_NAME_TOKENS = {
76 "demo",
77 "kata",
78 "lab",
79 "labs",
80 "lesson",
81 "lessons",
82 "practice",
83 "sample",
84 "starter",
85 }
86 LOW_SIGNAL_PHRASES = {
87 "course project",
88 "for beginners",
89 "getting started tutorial",
90 "my solution",
91 "starter template",
92 "step by step",
93 "step-by-step",
94 "study notes",
95 }
96 DEFAULT_HEADERS = {
97 "Accept": "application/vnd.github+json, application/vnd.github.mercy-preview+json",
98 "X-GitHub-Api-Version": "2022-11-28",
99 "User-Agent": "SquadScope-Crawler/1.0",
100 }
101 GITHUB_SOURCE_ID = "github-search"
102
103
104 def log(message: str) -> None:
105 print(f"[crawl {iso_timestamp(utc_now())}] {message}", file=sys.stderr)
106
107
108 @dataclass(slots=True)
109 class CacheEntry:
110 status: int
111 payload: Any
112 headers: dict[str, str]
113 fetched_at: datetime
114 stale: bool = False
115
116
117 class ResponseCache:
118 def __init__(self, root: Path) -> None:
119 self.root = root
120 self.root.mkdir(parents=True, exist_ok=True)
121
122 def load(self, key: str, ttl_seconds: int) -> CacheEntry | None:
123 path = self._path_for(key)
124 if not path.exists():
125 return None
126 try:
127 payload = json.loads(path.read_text(encoding="utf-8"))
128 fetched_at = datetime.fromisoformat(payload["fetched_at"].replace("Z", "+00:00"))
129 headers = payload.get("headers") or {}
130 age = (utc_now() - fetched_at).total_seconds()
131 return CacheEntry(
132 status=int(payload["status"]),
133 payload=payload.get("payload"),
134 headers={str(name): str(value) for name, value in headers.items()},
135 fetched_at=fetched_at,
136 stale=age > ttl_seconds,
137 )
138 except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError):
139 return None
140
141 def store(self, key: str, *, status: int, payload: Any, headers: dict[str, str]) -> None:
142 path = self._path_for(key)
143 path.parent.mkdir(parents=True, exist_ok=True)
144 cache_payload = {
145 "status": status,
146 "fetched_at": iso_timestamp(utc_now()),
147 "headers": headers,
148 "payload": payload,
149 }
150 path.write_text(
151 json.dumps(cache_payload, separators=(",", ":"), ensure_ascii=False) + "\n",
152 encoding="utf-8",
153 )
154
155 def _path_for(self, key: str) -> Path:
156 parsed = parse.urlparse(key)
157 label = parsed.path.strip("/").replace("/", "-") or "root"
158 digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
159 return self.root / f"{label}-{digest}.json"
160
161
162 class GitHubClient:
163 def __init__(
164 self, token: str, *, cache_dir: Path = CACHE_ROOT, timeout: int = 30, max_retries: int = 6
165 ) -> None:
166 self.token = token
167 self.timeout = timeout
168 self.max_retries = max_retries
169 self.api_calls_used = 0
170 self.cache_hits = 0
171 self.cache_misses = 0
172 self.stale_cache_hits = 0
173 self.rate_limit_events = 0
174 self.secondary_rate_limit_hit = False
175 self.rate_limit_limit: int | None = None
176 self.rate_limit_remaining: int | None = None
177 self.rate_limit_reset: int | None = None
178 self.rate_limit_resource: str | None = None
179 self._last_request_at = 0.0
180 self._readme_cache: dict[str, bool] = {}
181 self._cache = ResponseCache(cache_dir)
182 self.errors: list[str] = []
183
184 def _headers(self) -> dict[str, str]:
185 headers = dict(DEFAULT_HEADERS)
186 headers["Authorization"] = f"Bearer {self.token}"
187 return headers
188
189 def get_json(
190 self,
191 url: str,
192 params: dict[str, Any] | None = None,
193 *,
194 acceptable_statuses: set[int] | None = None,
195 ttl_seconds: int | None = None,
196 allow_stale: bool = True,
197 max_retries: int | None = None,
198 max_delay_seconds: float = 300.0,
199 ) -> Any:
200 return self.get_json_entry(
201 url,
202 params,
203 acceptable_statuses=acceptable_statuses,
204 ttl_seconds=ttl_seconds,
205 allow_stale=allow_stale,
206 max_retries=max_retries,
207 max_delay_seconds=max_delay_seconds,
208 ).payload
209
210 def get_json_entry(
211 self,
212 url: str,
213 params: dict[str, Any] | None = None,
214 *,
215 acceptable_statuses: set[int] | None = None,
216 ttl_seconds: int | None = None,
217 allow_stale: bool = True,
218 max_retries: int | None = None,
219 max_delay_seconds: float = 300.0,
220 ) -> CacheEntry:
221 query = f"{url}?{parse.urlencode(params)}" if params else url
222 self._validate_github_api_url(query)
223 accepted = acceptable_statuses or set()
224 retry_limit = self.max_retries if max_retries is None else max_retries
225 ttl = ttl_seconds if ttl_seconds is not None else self._cache_ttl(url)
226 cached = self._cache.load(query, ttl)
227 if cached and not cached.stale and (cached.status == 200 or cached.status in accepted):
228 self.cache_hits += 1
229 return cached
230
231 self.cache_misses += 1
232 stale_fallback = None
233 if cached and allow_stale and (cached.status == 200 or cached.status in accepted):
234 stale_fallback = CacheEntry(
235 status=cached.status,
236 payload=cached.payload,
237 headers=cached.headers,
238 fetched_at=cached.fetched_at,
239 stale=True,
240 )
241
242 attempt = 0
243 while True:
244 self._pause_for_rate_limit(query)
245 self._respect_min_interval(url)
246 req = request.Request(query, headers=self._headers())
247 try:
248 with request.urlopen(req, timeout=self.timeout) as response: # nosec B310
249 self.api_calls_used += 1
250 headers = {name: value for name, value in response.headers.items()}
251 self._update_rate_limit(headers)
252 body = response.read().decode("utf-8", errors="replace")
253 self._last_request_at = time.monotonic()
254 try:
255 payload = json.loads(body)
256 except json.JSONDecodeError as exc:
257 if stale_fallback is not None:
258 self.stale_cache_hits += 1
259 log(
260 f"Using stale cache for {query} after malformed JSON response: {exc}"
261 )
262 return stale_fallback
263 raise RuntimeError(
264 f"GitHub API returned malformed JSON for {query}: {exc}"
265 ) from exc
266 self._cache.store(
267 query,
268 status=response.status,
269 payload=payload,
270 headers=self._cache_headers(headers),
271 )
272 self._log_rate_limit(query)
273 return CacheEntry(response.status, payload, headers, utc_now())
274 except error.HTTPError as exc:
275 self.api_calls_used += 1
276 headers = {
277 name: value for name, value in (exc.headers.items() if exc.headers else [])
278 }
279 self._update_rate_limit(headers)
280 body = exc.read().decode("utf-8", errors="replace")
281 lowered_body = body.lower()
282 if (
283 exc.code in {403, 429}
284 or "rate limit" in lowered_body
285 or "abuse" in lowered_body
286 ):
287 self.rate_limit_events += 1
288 if "secondary rate limit" in lowered_body or "abuse" in lowered_body:
289 self.secondary_rate_limit_hit = True
290 self._last_request_at = time.monotonic()
291 payload = decode_json_body(body)
292 if exc.code in accepted:
293 self._cache.store(
294 query,
295 status=exc.code,
296 payload=payload,
297 headers=self._cache_headers(headers),
298 )
299 self._log_rate_limit(query)
300 return CacheEntry(exc.code, payload, headers, utc_now())
301 if attempt >= retry_limit or not self._should_retry(exc.code, body):
302 if stale_fallback is not None:
303 self.stale_cache_hits += 1
304 log(f"Using stale cache for {query} after HTTP {exc.code}.")
305 return stale_fallback
306 raise RuntimeError(
307 f"GitHub API request failed with status {exc.code}: {body.strip() or exc.reason}"
308 ) from exc
309 self._sleep_before_retry(
310 attempt, headers, body, query, retry_limit, max_delay_seconds
311 )
312 attempt += 1
313 except (error.URLError, TimeoutError) as exc:
314 if attempt >= retry_limit:
315 if stale_fallback is not None:
316 self.stale_cache_hits += 1
317 log(f"Using stale cache for {query} after network error: {exc}")
318 return stale_fallback
319 raise RuntimeError(f"GitHub API request failed: {exc}") from exc
320 self._sleep_before_retry(
321 attempt, None, str(exc), query, retry_limit, max_delay_seconds
322 )
323 attempt += 1
324
325 def search_repositories(self, query: str, *, max_results: int = 1000) -> list[dict[str, Any]]:
326 results: list[dict[str, Any]] = []
327 per_page = 100
328 max_pages = min((max_results + per_page - 1) // per_page, 10)
329 for page in range(1, max_pages + 1):
330 try:
331 response = self.get_json_entry(
332 SEARCH_REPOSITORIES,
333 params={
334 "q": query,
335 "sort": "stars",
336 "order": "desc",
337 "per_page": per_page,
338 "page": page,
339 },
340 ttl_seconds=6 * 60 * 60,
341 )
342 except RuntimeError as exc:
343 self.record_error(f"Search failed for '{query}' page {page}: {exc}")
344 break
345 payload = response.payload if isinstance(response.payload, dict) else {}
346 items = payload.get("items")
347 if not isinstance(items, list):
348 self.record_error(
349 f"Malformed search payload for '{query}' page {page}: missing items list"
350 )
351 break
352 if payload.get("incomplete_results"):
353 self.record_error(
354 f"GitHub marked search results incomplete for '{query}' page {page}"
355 )
356 results.extend(item for item in items if isinstance(item, dict))
357 total_count = payload.get("total_count")
358 if len(items) < per_page or len(results) >= min(
359 int(total_count or 0), max_results, 1000
360 ):
361 break
362 return results[:max_results]
363
364 def has_readme(self, full_name: str) -> bool:
365 if full_name in self._readme_cache:
366 return self._readme_cache[full_name]
367 url = f"{API_ROOT}/repos/{full_name}/readme"
368 try:
369 response = self.get_json_entry(
370 url,
371 acceptable_statuses={404},
372 ttl_seconds=24 * 60 * 60,
373 max_retries=2,
374 max_delay_seconds=60.0,
375 )
376 except RuntimeError as exc:
377 message = str(exc)
378 if "SAML enforcement" in message:
379 self._readme_cache[full_name] = False
380 return False
381 raise
382 has_readme = response.status != 404
383 self._readme_cache[full_name] = has_readme
384 return has_readme
385
386 def record_error(self, message: str) -> None:
387 self.errors.append(message)
388 log(message)
389
390 def _cache_ttl(self, url: str) -> int:
391 if "/search/" in url:
392 return 6 * 60 * 60
393 if url.endswith("/readme"):
394 return 24 * 60 * 60
395 return 12 * 60 * 60
396
397 def _should_retry(self, status: int, body: str) -> bool:
398 lowered = body.lower()
399 return status in RETRYABLE_STATUSES or "secondary rate limit" in lowered
400
401 def _sleep_before_retry(
402 self,
403 attempt: int,
404 headers: dict[str, str] | None,
405 body: str,
406 query: str,
407 retry_limit: int,
408 max_delay_seconds: float,
409 ) -> None:
410 reset_delay = self._reset_delay(headers)
411 retry_after = None
412 if headers and headers.get("Retry-After"):
413 try:
414 retry_after = max(float(headers["Retry-After"]), 1.0)
415 except ValueError:
416 retry_after = None
417 if retry_after is not None and (
418 self.rate_limit_reset is None or (self.rate_limit_remaining or 0) <= 0
419 ):
420 self.rate_limit_reset = max(self.rate_limit_reset or 0, int(time.time() + retry_after))
421 base_delay = min(2**attempt, 60)
422 jitter = _JITTER_RANDOM.uniform(0.3, 1.7)
423 delay = retry_after or reset_delay or (base_delay + jitter)
424 if "secondary rate limit" in body.lower():
425 delay = max(delay, 8.0 + _JITTER_RANDOM.uniform(0.0, 5.0))
426 delay = min(delay, max_delay_seconds)
427 log(f"Retrying {query} in {delay:.1f}s (attempt {attempt + 1}/{retry_limit}).")
428 time.sleep(delay)
429
430 def _respect_min_interval(self, url: str) -> None:
431 minimum_interval = 0.35 if url.endswith("/readme") else 0.0
432 if minimum_interval <= 0:
433 return
434 elapsed = time.monotonic() - self._last_request_at
435 if elapsed < minimum_interval:
436 time.sleep(minimum_interval - elapsed)
437
438 def _pause_for_rate_limit(self, query: str) -> None:
439 if self.rate_limit_remaining is None or self.rate_limit_limit is None:
440 return
441 low_threshold = max(5, min(25, int(self.rate_limit_limit * 0.1)))
442 critical_threshold = max(3, min(10, int(self.rate_limit_limit * 0.03)))
443 if self.rate_limit_remaining > low_threshold:
444 return
445 reset_headers = (
446 {"X-RateLimit-Reset": str(self.rate_limit_reset)}
447 if self.rate_limit_reset is not None
448 else None
449 )
450 reset_delay = self._reset_delay(reset_headers)
451 if reset_delay is None:
452 if self.rate_limit_remaining <= critical_threshold:
453 delay = 10.0 if self.rate_limit_remaining <= 0 else 3.0
454 self.rate_limit_events += 1
455 log(
456 f"Rate limit low ({self.rate_limit_remaining}/{self.rate_limit_limit} {self.rate_limit_resource or 'requests'}) "
457 f"without reset hint; cooling down {delay:.1f}s before {query}."
458 )
459 time.sleep(delay)
460 return
461 if self.rate_limit_remaining <= critical_threshold:
462 delay = min(reset_delay + _JITTER_RANDOM.uniform(0.3, 1.5), 300.0)
463 self.rate_limit_events += 1
464 log(
465 f"Rate limit nearly exhausted before {query}; pausing {delay:.1f}s until reset window."
466 )
467 time.sleep(delay)
468 return
469 delay = min(max(reset_delay / 10, 1.0), 30.0)
470 self.rate_limit_events += 1
471 log(
472 f"Rate limit low ({self.rate_limit_remaining}/{self.rate_limit_limit} {self.rate_limit_resource or 'requests'}); "
473 f"cooling down {delay:.1f}s before {query}."
474 )
475 time.sleep(delay)
476
477 def _reset_delay(self, headers: dict[str, str] | None) -> float | None:
478 if not headers:
479 return None
480 reset = headers.get("X-RateLimit-Reset")
481 if not reset:
482 return None
483 try:
484 return max(int(reset) - int(time.time()), 1)
485 except ValueError:
486 return None
487
488 def _validate_github_api_url(self, url: str) -> None:
489 parsed = parse.urlparse(url)
490 if parsed.scheme.lower() != "https":
491 raise ValueError(f"GitHub API URL must use HTTPS: {url}")
492 if parsed.username or parsed.password:
493 raise ValueError(f"GitHub API URL must not include credentials: {url}")
494 host = (parsed.hostname or "").rstrip(".").lower()
495 if host != "api.github.com":
496 raise ValueError(f"GitHub API URL must target api.github.com: {url}")
497 try:
498 port = parsed.port
499 except ValueError as exc:
500 raise ValueError(f"GitHub API URL has an invalid port: {url}") from exc
501 if port not in (None, 443):
502 raise ValueError(f"GitHub API URL must not use unexpected ports: {url}")
503
504 def _update_rate_limit(self, headers: dict[str, str] | None) -> None:
505 limit = headers.get("X-RateLimit-Limit") if headers else None
506 remaining = headers.get("X-RateLimit-Remaining") if headers else None
507 reset = headers.get("X-RateLimit-Reset") if headers else None
508 resource = headers.get("X-RateLimit-Resource") if headers else None
509 if limit is not None:
510 try:
511 self.rate_limit_limit = int(limit)
512 except ValueError:
513 self.rate_limit_limit = None
514 if remaining is not None:
515 try:
516 self.rate_limit_remaining = int(remaining)
517 except ValueError:
518 self.rate_limit_remaining = None
519 if reset is not None:
520 try:
521 self.rate_limit_reset = int(reset)
522 except ValueError:
523 self.rate_limit_reset = None
524 if resource is not None:
525 self.rate_limit_resource = resource
526
527 def _log_rate_limit(self, query: str) -> None:
528 if self.rate_limit_remaining is None:
529 return
530 reset_text = rate_limit_reset_text(self.rate_limit_reset)
531 limit_text = self.rate_limit_limit if self.rate_limit_limit is not None else "?"
532 resource_text = self.rate_limit_resource or "unknown"
533 log(
534 f"Rate limit after {query}: remaining={self.rate_limit_remaining}/{limit_text} "
535 f"({resource_text}), resets={reset_text}."
536 )
537
538 def _cache_headers(self, headers: dict[str, str]) -> dict[str, str]:
539 names = {
540 "Date",
541 "Retry-After",
542 "X-RateLimit-Limit",
543 "X-RateLimit-Remaining",
544 "X-RateLimit-Reset",
545 }
546 return {name: value for name, value in headers.items() if name in names}
547
548
549 def parse_args() -> argparse.Namespace:
550 parser = argparse.ArgumentParser(description=__doc__)
551 parser.add_argument(
552 "--since",
553 help="UTC date cutoff for repository queries (YYYY-MM-DD). Defaults to 7 days before --as-of/current time.",
554 )
555 parser.add_argument(
556 "--as-of",
557 help="Anchor date for the crawl window and output week (YYYY-MM-DD). Defaults to now in UTC.",
558 )
559 parser.add_argument(
560 "--max-results",
561 type=int,
562 default=250,
563 help="Maximum repositories to fetch per search query (default: 250, capped at 1000).",
564 )
565 parser.add_argument(
566 "--output",
567 help="Optional explicit output path. Defaults to data/raw/YYYY-WNN.json.",
568 )
569 parser.add_argument(
570 "--topic",
571 default=None,
572 help="Topic ID for namespaced data directories. Defaults to 'general' (flat layout).",
573 )
574 parser.add_argument(
575 "--config",
576 default=None,
577 help="Path to a topic YAML config file (e.g. squadscope.topic.yml). "
578 "When provided, queries are read from the config instead of using hardcoded defaults.",
579 )
580 parser.add_argument(
581 "--force-refresh",
582 action="store_true",
583 help="Refresh GitHub data even when a same-day raw artifact is reusable.",
584 )
585 parser.add_argument(
586 "--reuse-artifact",
587 default=None,
588 help="Existing raw GitHub artifact to reuse when it is fresh for this run window.",
589 )
590 parser.add_argument(
591 "--source-refresh-policy",
592 choices=["reuse-same-day", "refresh-missing-stale", "force-refresh"],
593 default="reuse-same-day",
594 help="Source refresh policy for reruns (default: reuse eligible same-day artifacts).",
595 )
596 parser.add_argument(
597 "--run-started-at",
598 default=None,
599 help="UTC run start timestamp used for same-day reuse checks (ISO 8601). Defaults to now.",
600 )
601 parser.add_argument(
602 "--current-code-sha",
603 default=None,
604 help="Optional crawler/config fingerprint; reused artifacts with a conflicting fingerprint are stale.",
605 )
606 return parser.parse_args()
607
608
609 def load_topic_queries(config_path: str, template_vars: dict[str, str]) -> dict[str, Any]:
610 """Load and resolve queries from a topic YAML config file.
611
612 Returns a dict with keys: primary (list[str]), secondary (list[str]), min_repos_per_week (int).
613 Template variables in queries (e.g. {last_week}, {today}) are replaced with values from template_vars.
614 """
615 import yaml
616
617 path = Path(config_path)
618 if not path.exists():
619 raise FileNotFoundError(f"Topic config not found: {config_path}")
620
621 with open(path, encoding="utf-8") as f:
622 data = yaml.safe_load(f)
623
624 queries_section = data.get("queries", {})
625 primary = queries_section.get("primary", [])
626 secondary = queries_section.get("secondary", [])
627 quality_section = data.get("quality", {})
628 min_repos = quality_section.get("min_repos_per_week", 5)
629
630 def resolve(q: str) -> str:
631 for key, value in template_vars.items():
632 q = q.replace(f"{{{key}}}", value)
633 return q
634
635 return {
636 "primary": [resolve(q) for q in primary],
637 "secondary": [resolve(q) for q in secondary],
638 "min_repos_per_week": min_repos,
639 }
640
641
642 def utc_now() -> datetime:
643 return datetime.now(UTC).replace(microsecond=0)
644
645
646 def iso_timestamp(value: datetime) -> str:
647 return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
648
649
650 def week_slug(value: datetime) -> str:
651 year, week, _ = value.isocalendar()
652 return f"{year}-W{week:02d}"
653
654
655 def rate_limit_reset_text(reset_timestamp: int | None) -> str | None:
656 if reset_timestamp is None:
657 return None
658 return iso_timestamp(datetime.fromtimestamp(reset_timestamp, tz=UTC))
659
660
661 def decode_json_body(body: str) -> Any:
662 try:
663 return json.loads(body)
664 except json.JSONDecodeError:
665 return {"message": body.strip()}
666
667
668 def sha256_text(value: str) -> str:
669 return hashlib.sha256(value.encode("utf-8")).hexdigest()
670
671
672 def sha256_file(path: Path) -> str | None:
673 if not path.exists() or not path.is_file():
674 return None
675 digest = hashlib.sha256()
676 with path.open("rb") as handle:
677 for chunk in iter(lambda: handle.read(1024 * 1024), b""):
678 digest.update(chunk)
679 return digest.hexdigest()
680
681
682 def github_schema_checksum() -> str:
683 contract = {
684 "schema": "github_raw_v1",
685 "top_level": ["week", "crawled_at", "new_repos", "trending_repos", "signals", "metadata"],
686 "metadata": [
687 "crawl_window",
688 "crawl_config_checksum",
689 "schema_checksum",
690 "artifact_checksum",
691 "same_day_reuse",
692 ],
693 }
694 return sha256_text(json.dumps(contract, sort_keys=True, separators=(",", ":")))
695
696
697 def github_crawl_config_checksum(
698 args: argparse.Namespace, since: datetime, window_end: datetime, max_results: int
699 ) -> str:
700 config_digest = sha256_file(Path(args.config)) if args.config else None
701 payload = {
702 "since": since.date().isoformat(),
703 "until": window_end.date().isoformat(),
704 "as_of": args.as_of,
705 "max_results": max_results,
706 "topic": args.topic,
707 "config": args.config,
708 "config_sha256": config_digest,
709 }
710 return sha256_text(json.dumps(payload, sort_keys=True, separators=(",", ":")))
711
712
713 def github_artifact_checksum(payload: dict[str, Any]) -> str:
714 candidate = dict(payload)
715 candidate.pop("crawled_at", None)
716 metadata = dict(candidate.get("metadata", {}))
717 metadata.pop("artifact_checksum", None)
718 metadata.pop("same_day_reuse", None)
719 candidate["metadata"] = metadata
720 return sha256_text(
721 json.dumps(candidate, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
722 )
723
724
725 def parse_datetime(value: Any) -> datetime | None:
726 if not isinstance(value, str) or not value.strip():
727 return None
728 candidate = value.strip()
729 if candidate.endswith("Z"):
730 candidate = f"{candidate[:-1]}+00:00"
731 try:
732 parsed = datetime.fromisoformat(candidate)
733 except ValueError:
734 return None
735 return parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC)
736
737
738 def load_json_artifact(path: Path) -> dict[str, Any] | None:
739 try:
740 payload = json.loads(path.read_text(encoding="utf-8"))
741 except (OSError, json.JSONDecodeError):
742 return None
743 return payload if isinstance(payload, dict) else None
744
745
746 def load_reusable_github_payload(
747 path: Path,
748 *,
749 week: str,
750 crawled_at: datetime,
751 since: datetime,
752 window_end: datetime,
753 config_checksum: str,
754 policy: str = "reuse-same-day",
755 current_code_sha: str | None = None,
756 ) -> dict[str, Any] | None:
757 if policy == "force-refresh":
758 return None
759 try:
760 payload = json.loads(path.read_text(encoding="utf-8"))
761 except (OSError, json.JSONDecodeError):
762 return None
763 if not isinstance(payload, dict):
764 return None
765 try:
766 validate_payload(payload)
767 except ValueError:
768 return None
769 metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
770 parsed = None
771 raw_crawled_at = payload.get("crawled_at")
772 if isinstance(raw_crawled_at, str):
773 try:
774 parsed = datetime.fromisoformat(raw_crawled_at.replace("Z", "+00:00"))
775 except ValueError:
776 parsed = None
777 window = metadata.get("crawl_window")
778 if (
779 payload.get("week") != week
780 or parsed is None
781 or parsed.astimezone(UTC).date() != crawled_at.astimezone(UTC).date()
782 or not isinstance(window, dict)
783 or window.get("since") != since.date().isoformat()
784 or window.get("until") != window_end.date().isoformat()
785 or metadata.get("crawl_config_checksum") != config_checksum
786 or metadata.get("schema_checksum") != github_schema_checksum()
787 or metadata.get("artifact_checksum") != github_artifact_checksum(payload)
788 ):
789 return None
790 artifact_code_sha = metadata.get("crawler_code_sha")
791 if current_code_sha and artifact_code_sha != current_code_sha:
792 return None
793 original_checksum = metadata.get("artifact_checksum")
794 metadata["same_day_reuse"] = {
795 "status": "reused",
796 "source": "github",
797 "source_id": GITHUB_SOURCE_ID,
798 "original_run_id": metadata.get("run_id", ""),
799 "original_crawled_at": payload.get("crawled_at"),
800 "reused_at": iso_timestamp(crawled_at),
801 "week": week,
802 "crawl_window": window,
803 "crawl_config_checksum": config_checksum,
804 "schema_checksum": github_schema_checksum(),
805 "content_checksum": original_checksum,
806 }
807 metadata["source_refresh_policy"] = policy
808 if current_code_sha:
809 metadata.setdefault("crawler_code_sha", current_code_sha)
810 payload["metadata"] = metadata
811 metadata["artifact_checksum"] = github_artifact_checksum(payload)
812 return payload
813
814
815 def _safe_snapshot_destination(
816 snapshot_path: str, expected_snapshot_dir: Path = SNAPSHOT_ROOT
817 ) -> Path | None:
818 destination = Path(snapshot_path)
819 expected_root = Path("data") / "snapshots"
820 if destination.is_absolute() or ".." in destination.parts:
821 return None
822 if len(destination.parts) < 3 or destination.parts[:2] != expected_root.parts:
823 return None
824 expected_dir = expected_snapshot_dir.resolve()
825 resolved_destination = destination.resolve()
826 if (
827 expected_dir != resolved_destination.parent
828 and expected_dir not in resolved_destination.parents
829 ):
830 return None
831 return destination
832
833
834 def restore_reused_snapshot(
835 reuse_path: Path,
836 metadata: dict[str, Any],
837 *,
838 expected_snapshot_dir: Path = SNAPSHOT_ROOT,
839 ) -> None:
840 snapshot_path = metadata.get("snapshot_path")
841 if not isinstance(snapshot_path, str) or not snapshot_path:
842 return
843 destination = _safe_snapshot_destination(snapshot_path, expected_snapshot_dir)
844 if destination is None:
845 return
846 source_snapshot = reuse_path.parent.parent / "snapshots" / Path(snapshot_path).name
847 if not source_snapshot.exists():
848 return
849 snapshot_payload = load_json_artifact(source_snapshot)
850 if snapshot_payload is not None:
851 write_payload(destination, snapshot_payload)
852
853
854 def load_previous_star_snapshot(
855 snapshot_dir: Path, current_week: str, *raw_dirs: Path
856 ) -> dict[str, int]:
857 for snapshot in sorted(snapshot_dir.glob("*-stars.json"), reverse=True):
858 stars, reason = load_star_mapping_details(snapshot, current_week)
859 if stars:
860 return stars
861 if reason and reason != "same-week snapshot":
862 log(f"Skipping star snapshot {snapshot}: {reason}.")
863 seen_dirs: set[Path] = set()
864 for raw_dir_path in raw_dirs:
865 if raw_dir_path in seen_dirs:
866 continue
867 seen_dirs.add(raw_dir_path)
868 for snapshot in sorted(raw_dir_path.glob("*.json"), reverse=True):
869 stars, reason = load_star_mapping_details(snapshot, current_week)
870 if stars:
871 return stars
872 if reason and reason != "same-week snapshot":
873 log(f"Skipping star snapshot {snapshot}: {reason}.")
874 return {}
875
876
877 def load_star_mapping(path: Path, current_week: str) -> dict[str, int]:
878 return load_star_mapping_details(path, current_week)[0]
879
880
881 def load_star_mapping_details(path: Path, current_week: str) -> tuple[dict[str, int], str | None]:
882 try:
883 payload = json.loads(path.read_text(encoding="utf-8"))
884 except OSError as exc:
885 return {}, f"read failed ({exc})"
886 except json.JSONDecodeError as exc:
887 return {}, f"invalid JSON ({exc})"
888 if payload.get("week") == current_week:
889 return {}, "same-week snapshot"
890 stars = payload.get("stars")
891 if isinstance(stars, dict):
892 mapping = {name: int(value) for name, value in stars.items() if isinstance(value, int)}
893 if mapping:
894 return mapping, None
895 return {}, "empty stars mapping"
896 mapping: dict[str, int] = {}
897 for section in ("new_repos", "trending_repos"):
898 for repo in payload.get(section, []):
899 full_name = repo.get("full_name") or ""
900 stars_value = repo.get("stars")
901 if full_name and isinstance(stars_value, int):
902 mapping[full_name] = stars_value
903 if mapping:
904 return mapping, None
905 return {}, "no star data"
906
907
908 def tokenize(text: str) -> set[str]:
909 return set(re.findall(r"[a-z0-9]+", text.lower()))
910
911
912 def significance_skip_reason(repo: dict[str, Any]) -> str | None:
913 if repo.get("fork"):
914 return "fork"
915 if repo.get("is_template"):
916 return "template_repo"
917 description = (repo.get("description") or "").strip()
918 if not description:
919 return "missing_description"
920 topics = {str(topic).lower() for topic in repo.get("topics") or []}
921 if topics & LOW_SIGNAL_TOPICS:
922 return "low_signal_topic"
923 name = str(repo.get("name") or "")
924 combined_text = " ".join([name, description]).lower()
925 combined_tokens = tokenize(combined_text)
926 if combined_tokens & LOW_SIGNAL_TOKENS:
927 return "low_signal_keyword"
928 if tokenize(name) & LOW_SIGNAL_NAME_TOKENS:
929 return "low_signal_keyword"
930 if any(phrase in combined_text for phrase in LOW_SIGNAL_PHRASES):
931 return "low_signal_phrase"
932 return None
933
934
935 def to_repo_record(repo: dict[str, Any], *, stars_gained: int | None = None) -> dict[str, Any]:
936 license_info = repo.get("license") or {}
937 record = {
938 "name": repo.get("name"),
939 "owner": (repo.get("owner") or {}).get("login"),
940 "full_name": repo.get("full_name"),
941 "description": repo.get("description"),
942 "language": repo.get("language"),
943 "stars": repo.get("stargazers_count"),
944 "forks": repo.get("forks_count"),
945 "created_at": repo.get("created_at"),
946 "topics": sorted(str(topic).lower() for topic in (repo.get("topics") or [])),
947 "license": license_info.get("spdx_id") or license_info.get("name"),
948 "url": repo.get("html_url"),
949 }
950 if stars_gained is not None:
951 record["stars_gained"] = stars_gained
952 return record
953
954
955 def collect_repositories(
956 client: GitHubClient,
957 repositories: Iterable[dict[str, Any]],
958 *,
959 previous_stars: dict[str, int] | None = None,
960 trending_cutoff: datetime | None = None,
961 ) -> tuple[list[dict[str, Any]], dict[str, int]]:
962 collected: list[dict[str, Any]] = []
963 seen: set[str] = set()
964 filter_stats: Counter[str] = Counter()
965 has_prior_snapshot = bool(previous_stars)
966 for repo in repositories:
967 full_name = repo.get("full_name")
968 if not full_name:
969 filter_stats["missing_full_name"] += 1
970 continue
971 if full_name in seen:
972 filter_stats["duplicate"] += 1
973 continue
974 seen.add(full_name)
975 skip_reason = significance_skip_reason(repo)
976 if skip_reason:
977 filter_stats[skip_reason] += 1
978 continue
979 try:
980 if not client.has_readme(full_name):
981 filter_stats["missing_readme"] += 1
982 continue
983 except RuntimeError as exc:
984 filter_stats["readme_lookup_failed"] += 1
985 client.record_error(f"README lookup failed for {full_name}: {exc}")
986 continue
987 stars_gained: int | None = None
988 if previous_stars is not None:
989 previous_value = previous_stars.get(full_name)
990 current_stars = int(repo.get("stargazers_count") or 0)
991 if previous_value is not None:
992 stars_gained = max(current_stars - previous_value, 0)
993 elif has_prior_snapshot and trending_cutoff is not None:
994 created_at = repo.get("created_at")
995 if isinstance(created_at, str):
996 created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
997 if created >= trending_cutoff:
998 stars_gained = current_stars
999 collected.append(to_repo_record(repo, stars_gained=stars_gained))
1000 if previous_stars is not None and has_prior_snapshot:
1001 collected.sort(key=lambda item: (item.get("stars_gained", -1), item["stars"]), reverse=True)
1002 else:
1003 collected.sort(key=lambda item: item["stars"], reverse=True)
1004 return collected, dict(filter_stats)
1005
1006
1007 def build_signals(*repo_groups: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
1008 topic_counter: Counter[str] = Counter()
1009 # A repo can appear in both search buckets; count its topics once to avoid inflating weekly signals.
1010 merged: dict[str, dict[str, Any]] = {}
1011 for group in repo_groups:
1012 for repo in group:
1013 full_name = repo.get("full_name")
1014 if full_name:
1015 merged[full_name] = repo
1016 for repo in merged.values():
1017 topic_counter.update(topic.lower() for topic in repo.get("topics") or [])
1018 top_topics = [
1019 {"topic": topic, "count": count} for topic, count in topic_counter.most_common(15)
1020 ]
1021 return {"top_topics": top_topics}
1022
1023
1024 def build_star_snapshot(*repo_groups: Iterable[dict[str, Any]]) -> dict[str, int]:
1025 stars: dict[str, int] = {}
1026 for group in repo_groups:
1027 for repo in group:
1028 full_name = repo.get("full_name")
1029 value = repo.get("stargazers_count")
1030 if full_name and isinstance(value, int):
1031 stars[full_name] = value
1032 return dict(sorted(stars.items()))
1033
1034
1035 def validate_payload(payload: dict[str, Any]) -> None:
1036 required_top_level = {
1037 "week",
1038 "crawled_at",
1039 "new_repos",
1040 "trending_repos",
1041 "signals",
1042 "metadata",
1043 }
1044 missing = required_top_level - payload.keys()
1045 if missing:
1046 raise ValueError(f"Missing top-level keys: {sorted(missing)}")
1047 if not isinstance(payload["new_repos"], list) or not isinstance(
1048 payload["trending_repos"], list
1049 ):
1050 raise ValueError("new_repos and trending_repos must be lists")
1051 if not isinstance(payload["signals"], dict) or not isinstance(payload["metadata"], dict):
1052 raise ValueError("signals and metadata must be objects")
1053 repo_fields = {
1054 "name",
1055 "owner",
1056 "full_name",
1057 "description",
1058 "language",
1059 "stars",
1060 "forks",
1061 "created_at",
1062 "topics",
1063 "license",
1064 "url",
1065 }
1066 for section in ("new_repos", "trending_repos"):
1067 for repo in payload[section]:
1068 missing_fields = repo_fields - repo.keys()
1069 if missing_fields:
1070 raise ValueError(
1071 f"Repository in {section} missing fields: {sorted(missing_fields)}"
1072 )
1073 metadata = payload["metadata"]
1074 if not isinstance(metadata.get("api_calls_used"), int):
1075 raise ValueError("metadata.api_calls_used must be an integer")
1076 if not isinstance(metadata.get("cache_hits"), int):
1077 raise ValueError("metadata.cache_hits must be an integer")
1078 if not isinstance(metadata.get("stale_cache_hits"), int):
1079 raise ValueError("metadata.stale_cache_hits must be an integer")
1080 if metadata.get("rate_limit_remaining") is not None and not isinstance(
1081 metadata.get("rate_limit_remaining"), int
1082 ):
1083 raise ValueError("metadata.rate_limit_remaining must be an integer or null")
1084 if metadata.get("rate_limit_limit") is not None and not isinstance(
1085 metadata.get("rate_limit_limit"), int
1086 ):
1087 raise ValueError("metadata.rate_limit_limit must be an integer or null")
1088 if metadata.get("rate_limit_reset") is not None and not isinstance(
1089 metadata.get("rate_limit_reset"), int
1090 ):
1091 raise ValueError("metadata.rate_limit_reset must be an integer or null")
1092 if metadata.get("rate_limit_resource") is not None and not isinstance(
1093 metadata.get("rate_limit_resource"), str
1094 ):
1095 raise ValueError("metadata.rate_limit_resource must be a string or null")
1096 if not isinstance(metadata.get("snapshot_path"), str):
1097 raise ValueError("metadata.snapshot_path must be a string")
1098 partial_failures = metadata.get("partial_failures")
1099 if partial_failures is not None and not isinstance(partial_failures, list):
1100 raise ValueError("metadata.partial_failures must be a list when present")
1101
1102
1103 def write_payload(path: Path, payload: dict[str, Any]) -> None:
1104 path.parent.mkdir(parents=True, exist_ok=True)
1105 path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
1106
1107
1108 def main() -> int:
1109 args = parse_args()
1110 crawl_started = time.monotonic()
1111
1112 topic_id = args.topic
1113 topic_raw = raw_dir(topic_id)
1114 topic_snapshots = snapshots_dir(topic_id)
1115 topic_cache = cache_dir(topic_id)
1116
1117 crawled_at = utc_now()
1118 run_started_at_arg = getattr(args, "run_started_at", None)
1119 run_started_at = parse_datetime(run_started_at_arg) if run_started_at_arg else crawled_at
1120 if run_started_at is None:
1121 print("--run-started-at must be an ISO 8601 timestamp", file=sys.stderr)
1122 return 1
1123 window_end = (
1124 datetime.strptime(args.as_of, "%Y-%m-%d").replace(tzinfo=UTC) if args.as_of else crawled_at
1125 )
1126 since = (
1127 datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=UTC)
1128 if args.since
1129 else window_end - timedelta(days=7)
1130 )
1131 week = week_slug(window_end)
1132 output_path = Path(args.output) if args.output else topic_raw / f"{week}.json"
1133 snapshot_path = topic_snapshots / f"{week}-stars.json"
1134 max_results = max(1, min(args.max_results, 1000))
1135 config_checksum = github_crawl_config_checksum(args, since, window_end, max_results)
1136 source_refresh_policy = (
1137 "force-refresh"
1138 if getattr(args, "force_refresh", False)
1139 else getattr(args, "source_refresh_policy", "reuse-same-day")
1140 )
1141 current_code_sha = getattr(args, "current_code_sha", None) or os.environ.get("CRAWLER_CODE_SHA")
1142
1143 if source_refresh_policy != "force-refresh":
1144 reuse_path = (
1145 Path(getattr(args, "reuse_artifact", ""))
1146 if getattr(args, "reuse_artifact", None)
1147 else output_path
1148 )
1149 reusable = load_reusable_github_payload(
1150 reuse_path,
1151 week=week,
1152 crawled_at=run_started_at,
1153 since=since,
1154 window_end=window_end,
1155 config_checksum=config_checksum,
1156 policy=source_refresh_policy,
1157 current_code_sha=current_code_sha,
1158 )
1159 if reusable is not None:
1160 write_payload(output_path, reusable)
1161 restore_reused_snapshot(
1162 reuse_path, reusable.get("metadata", {}), expected_snapshot_dir=topic_snapshots
1163 )
1164 observability_path = DEFAULT_OBSERVABILITY_DIR / f"{week}-github-crawl.json"
1165 duration_seconds = round(time.monotonic() - crawl_started, 3)
1166 emit_ledger(
1167 ObservabilityLedger(
1168 schema_version=METRICS_SCHEMA_VERSION,
1169 run_id=os.environ.get("GITHUB_RUN_ID", "local"),
1170 week=week,
1171 timestamp=iso_timestamp(crawled_at),
1172 crawl_metrics=[
1173 CrawlMetrics(
1174 duration_seconds=duration_seconds,
1175 duration_p95_seconds=duration_seconds,
1176 duration_sample_count=1,
1177 api_calls=0,
1178 cache_hits=0,
1179 cache_misses=0,
1180 stale_cache_hits=0,
1181 rate_limit_events=0,
1182 secondary_rate_limit_hit=False,
1183 source_type="github",
1184 )
1185 ],
1186 analysis_metrics=None,
1187 environment={
1188 "pipeline": "github-crawl",
1189 "topic": topic_id,
1190 "output_path": output_path.as_posix(),
1191 "snapshot_path": snapshot_path.as_posix(),
1192 "source_refresh_policy": source_refresh_policy,
1193 "same_day_reuse_status": "reused",
1194 "partial_failures": [],
1195 "rate_limit_snapshot": {
1196 "limit": None,
1197 "remaining": None,
1198 "reset": None,
1199 "resource": None,
1200 },
1201 },
1202 ),
1203 observability_path,
1204 )
1205 print(
1206 f"Reused same-day GitHub raw artifact {reuse_path} -> {output_path}; "
1207 f"used 0 API calls; observability={observability_path}."
1208 )
1209 return 0
1210
1211 github_token = os.environ.get("GITHUB_TOKEN")
1212 if not github_token:
1213 print("GITHUB_TOKEN is required", file=sys.stderr)
1214 return 1
1215 client = GitHubClient(github_token, cache_dir=topic_cache)
1216
1217 if args.config:
1218 template_vars = {
1219 "last_week": since.date().isoformat(),
1220 "today": window_end.date().isoformat(),
1221 }
1222 topic_queries = load_topic_queries(args.config, template_vars)
1223 all_candidates: list[Any] = []
1224 for q in topic_queries["primary"]:
1225 all_candidates.extend(client.search_repositories(q, max_results=max_results))
1226 if len(all_candidates) < topic_queries["min_repos_per_week"]:
1227 for q in topic_queries["secondary"]:
1228 all_candidates.extend(client.search_repositories(q, max_results=max_results))
1229 new_candidates = all_candidates
1230 previous_stars = load_previous_star_snapshot(
1231 SNAPSHOT_ROOT, week, output_path.parent, RAW_ROOT
1232 )
1233 trending_candidates: list[Any] = []
1234 else:
1235 if args.as_of:
1236 created_filter = f"created:{since.date().isoformat()}..{window_end.date().isoformat()}"
1237 pushed_filter = f"pushed:{since.date().isoformat()}..{window_end.date().isoformat()}"
1238 else:
1239 created_filter = f"created:>{since.date().isoformat()}"
1240 pushed_filter = f"pushed:>{since.date().isoformat()}"
1241 new_query = f"{created_filter} stars:>50"
1242 trending_query = f"{pushed_filter} stars:>50"
1243
1244 new_candidates = client.search_repositories(new_query, max_results=max_results)
1245 previous_stars = load_previous_star_snapshot(
1246 SNAPSHOT_ROOT, week, output_path.parent, RAW_ROOT
1247 )
1248 trending_candidates = client.search_repositories(trending_query, max_results=max_results)
1249
1250 new_repos, new_filters = collect_repositories(client, new_candidates)
1251 trending_repos, trending_filters = collect_repositories(
1252 client,
1253 trending_candidates,
1254 previous_stars=previous_stars,
1255 trending_cutoff=since,
1256 )
1257
1258 # Keep star snapshots broader than the filtered payload so future reruns can still compute deltas
1259 # even when a repo is later excluded as low-signal or missing a README.
1260 star_snapshot = build_star_snapshot(new_candidates, trending_candidates)
1261 snapshot_payload = {
1262 "week": week,
1263 "captured_at": iso_timestamp(crawled_at),
1264 "repository_count": len(star_snapshot),
1265 "stars": star_snapshot,
1266 }
1267
1268 payload = {
1269 "week": week,
1270 "crawled_at": iso_timestamp(crawled_at),
1271 "new_repos": new_repos,
1272 "trending_repos": trending_repos,
1273 "signals": build_signals(new_repos, trending_repos),
1274 "metadata": {
1275 "api_calls_used": client.api_calls_used,
1276 "cache_hits": client.cache_hits,
1277 "stale_cache_hits": client.stale_cache_hits,
1278 "rate_limit_limit": client.rate_limit_limit,
1279 "rate_limit_remaining": client.rate_limit_remaining,
1280 "rate_limit_reset": client.rate_limit_reset,
1281 "rate_limit_resource": client.rate_limit_resource,
1282 "partial_failures": client.errors,
1283 "run_id": os.environ.get("GITHUB_RUN_ID", "local"),
1284 "crawl_window": {
1285 "since": since.date().isoformat(),
1286 "until": window_end.date().isoformat(),
1287 },
1288 "crawl_config_checksum": config_checksum,
1289 "schema_checksum": github_schema_checksum(),
1290 "same_day_reuse": {
1291 "status": "not_reused",
1292 "source": "github",
1293 "source_id": GITHUB_SOURCE_ID,
1294 },
1295 "filter_summary": {
1296 "new_repos": new_filters,
1297 "trending_repos": trending_filters,
1298 },
1299 "snapshot_path": snapshot_path.as_posix(),
1300 "source_refresh_policy": source_refresh_policy,
1301 "crawler_code_sha": current_code_sha or "",
1302 },
1303 }
1304 payload["metadata"]["artifact_checksum"] = github_artifact_checksum(payload)
1305 validate_payload(payload)
1306 write_payload(output_path, payload)
1307 write_payload(snapshot_path, snapshot_payload)
1308 observability_path = DEFAULT_OBSERVABILITY_DIR / f"{week}-github-crawl.json"
1309 duration_seconds = round(time.monotonic() - crawl_started, 3)
1310 secondary_rate_limit_hit = bool(getattr(client, "secondary_rate_limit_hit", False)) or any(
1311 "secondary rate limit" in str(message).lower() or "abuse" in str(message).lower()
1312 for message in client.errors
1313 )
1314 emit_ledger(
1315 ObservabilityLedger(
1316 schema_version=METRICS_SCHEMA_VERSION,
1317 run_id=os.environ.get("GITHUB_RUN_ID", "local"),
1318 week=week,
1319 timestamp=iso_timestamp(crawled_at),
1320 crawl_metrics=[
1321 CrawlMetrics(
1322 duration_seconds=duration_seconds,
1323 duration_p95_seconds=duration_seconds,
1324 duration_sample_count=1,
1325 api_calls=int(getattr(client, "api_calls_used", 0)),
1326 cache_hits=int(getattr(client, "cache_hits", 0)),
1327 cache_misses=int(
1328 getattr(client, "cache_misses", getattr(client, "api_calls_used", 0))
1329 ),
1330 stale_cache_hits=int(getattr(client, "stale_cache_hits", 0)),
1331 rate_limit_events=int(getattr(client, "rate_limit_events", 0)),
1332 secondary_rate_limit_hit=secondary_rate_limit_hit,
1333 source_type="github",
1334 )
1335 ],
1336 analysis_metrics=None,
1337 environment={
1338 "pipeline": "github-crawl",
1339 "topic": topic_id,
1340 "output_path": output_path.as_posix(),
1341 "snapshot_path": snapshot_path.as_posix(),
1342 "source_refresh_policy": source_refresh_policy,
1343 "same_day_reuse_status": payload["metadata"]["same_day_reuse"]["status"],
1344 "partial_failures": list(client.errors),
1345 "rate_limit_snapshot": {
1346 "limit": client.rate_limit_limit,
1347 "remaining": client.rate_limit_remaining,
1348 "reset": client.rate_limit_reset,
1349 "resource": client.rate_limit_resource,
1350 },
1351 },
1352 ),
1353 observability_path,
1354 )
1355
1356 if client.errors:
1357 log(f"Completed with {len(client.errors)} partial failure(s).")
1358
1359 print(
1360 f"Wrote {output_path} with {len(new_repos)} new repos and {len(trending_repos)} trending repos, "
1361 f"saved {snapshot_path}, used {client.api_calls_used} API calls, served {client.cache_hits} cache hits, "
1362 f"and emitted observability metrics to {observability_path}."
1363 )
1364 return 1 if client.errors else 0
1365
1366
1367 if __name__ == "__main__":
1368 raise SystemExit(main())