返回 last30days-skill
github.py
根目录 / skills / last30days / scripts / lib / github.py
1 """GitHub Issues/PRs search via the public GitHub Search API.
2
3 Uses api.github.com/search/issues for issue/PR discovery and
4 per-item comment enrichment. Auth via GITHUB_TOKEN env var or
5 `gh auth token` subprocess fallback.
6 """
7
8 import json
9 import math
10 import os
11 import re
12 import subprocess
13 import sys
14 import urllib.error
15 import urllib.parse
16 import urllib.request
17 from concurrent.futures import ThreadPoolExecutor, as_completed
18 from typing import Any, Dict, List, Optional
19
20 from . import dates, env, http, log, schema
21 from .query import extract_core_subject
22 from .relevance import token_overlap_relevance
23
24 SEARCH_URL = "https://api.github.com/search/issues"
25
26 DEPTH_LIMITS = {
27 "quick": 15,
28 "default": 30,
29 "deep": 60,
30 }
31
32 ENRICH_LIMITS = {
33 "quick": 3,
34 "default": 5,
35 "deep": 8,
36 }
37
38 # Unauthenticated GitHub search allows ~10 requests/min, so cap result volume
39 # conservatively when running without a token to stay within the anon tier.
40 UNAUTH_COUNT_CAP = 10
41
42 USER_AGENT = "last30days/3.0 (research tool)"
43
44
45 def _log(msg: str):
46 log.source_log("GitHub", msg, tty_only=False)
47
48
49 def _resolve_token(token: Optional[str] = None) -> Optional[str]:
50 """Resolve GitHub auth token from argument, env, or gh CLI."""
51 if token:
52 return token
53 env_token = env.read_secret_env("GITHUB_TOKEN")
54 if env_token:
55 return env_token
56 # Fallback: try gh CLI
57 try:
58 result = subprocess.run(
59 ["gh", "auth", "token"],
60 capture_output=True, text=True, timeout=5,
61 )
62 if result.returncode == 0 and result.stdout.strip():
63 return result.stdout.strip()
64 except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
65 pass
66 return None
67
68
69 def resolve_token(token: Optional[str] = None) -> Optional[str]:
70 """Public alias for ``_resolve_token``.
71
72 The pipeline calls this once before ``search_github`` and
73 ``enrich_with_comments`` so the ``gh auth token`` subprocess fallback
74 only fires once per query when ``GITHUB_TOKEN`` is unset, instead of
75 twice (once per call site).
76 """
77 return _resolve_token(token)
78
79
80 def _fetch_json(
81 url: str,
82 token: Optional[str] = None,
83 timeout: int = 15,
84 failure_out: Optional[List[str]] = None,
85 ) -> Optional[Dict[str, Any]]:
86 """Fetch JSON from GitHub API. Returns None on failure.
87
88 When ``failure_out`` is provided, a short human-readable reason is
89 appended for every failure branch so callers can distinguish transport
90 failures from genuinely empty results (issue #384).
91 """
92
93 def _note(msg: str) -> None:
94 if failure_out is not None:
95 failure_out.append(msg)
96 headers = {
97 "User-Agent": USER_AGENT,
98 "Accept": "application/vnd.github+json",
99 }
100 if token:
101 headers["Authorization"] = f"Bearer {token}"
102
103 req = urllib.request.Request(url, headers=headers)
104 try:
105 with urllib.request.urlopen(req, timeout=timeout) as resp:
106 body = resp.read().decode("utf-8")
107 return json.loads(body)
108 except urllib.error.HTTPError as e:
109 if e.code == 403:
110 _log(f"403 rate limited or forbidden: {url}")
111 _note("HTTP 403: rate limited or forbidden")
112 return None
113 if e.code == 422:
114 _log(f"422 unprocessable: {url}")
115 _note("HTTP 422: unprocessable query")
116 return None
117 _log(f"HTTP {e.code}: {e.reason}")
118 _note(f"HTTP {e.code}: {e.reason}")
119 return None
120 except (urllib.error.URLError, OSError, TimeoutError) as e:
121 _log(f"Network error: {e}")
122 _note(f"network error: {e}")
123 return None
124 except json.JSONDecodeError as e:
125 _log(f"JSON decode error: {e}")
126 _note(f"invalid JSON: {e}")
127 return None
128
129
130 def _parse_repo_from_url(html_url: str) -> str:
131 """Extract 'owner/repo' from a GitHub issue/PR URL."""
132 parts = html_url.replace("https://github.com/", "").split("/")
133 if len(parts) >= 2:
134 return f"{parts[0]}/{parts[1]}"
135 return ""
136
137
138 def _parse_date(iso_str: Optional[str]) -> Optional[str]:
139 """Parse a GitHub ISO 8601 datetime string and return YYYY-MM-DD.
140
141 Returns None for non-date input. GitHub's API always emits ISO 8601
142 (e.g. "2026-02-26T16:00:00Z"), but we defer to dates.parse_date() so
143 garbage input gets rejected instead of silently sliced.
144 """
145 dt = dates.parse_date(iso_str)
146 return dt.strftime("%Y-%m-%d") if dt else None
147
148
149 def _compute_relevance(
150 query: str,
151 title: str,
152 rank_index: int,
153 reactions: int,
154 comments: int,
155 ) -> float:
156 """Blend text relevance with engagement signals."""
157 rank_score = max(0.3, 1.0 - (rank_index * 0.02))
158 engagement_boost = min(0.2, math.log1p(reactions + comments) / 20)
159
160 if query:
161 content_score = token_overlap_relevance(query, title)
162 relevance = min(1.0, 0.6 * rank_score + 0.4 * content_score + engagement_boost)
163 else:
164 relevance = min(1.0, rank_score * 0.7 + engagement_boost + 0.1)
165
166 return round(relevance, 2)
167
168
169 # GitHub search qualifiers the planner sometimes writes straight into the topic
170 # string (e.g. "open source AI stars:>1000 created:>2025-03-20"). They must not
171 # reach the query builder in `search_github`: it appends its own
172 # `created:>{from_date}`, and when two `created:` qualifiers collide GitHub
173 # honours the FIRST and silently ignores ours. The API then returns
174 # out-of-window items that `parse_github_response`'s date filter drops
175 # wholesale — a source that fetches results and reports zero (issue #949).
176 # Qualifiers may also arrive fully wrapped in one pair of parens/brackets/
177 # quotes ("(created:>2025-03-20)", '"stars:>1000"'), which an LLM planner
178 # plausibly emits; _WRAPPED_QUALIFIER_RE consumes the whole wrapper pair with
179 # the qualifier so no stray `()`/`""` residue reaches the query (issue #952).
180 # The plain regex's boundary also accepts wrapper openers so a qualifier with
181 # a missing closer ("(created:>2025-03-20") is still stripped rather than
182 # leaking into the query; only the stray opener survives, harmlessly.
183 QUALIFIER_KEYS = frozenset({
184 "archived", "assignee", "author", "base", "closed", "comments", "commenter",
185 "created", "fork", "forks", "head", "in", "interactions", "involves", "is",
186 "label", "language", "license", "linked", "mentions", "merged", "milestone",
187 "no", "org", "project", "pushed", "reactions", "repo", "review",
188 "review-requested", "reviewed-by", "size", "sort", "stars", "state", "team",
189 "topic", "topics", "type", "updated", "user",
190 })
191
192 _QUALIFIER_KEYS_ALT = "|".join(sorted(QUALIFIER_KEYS))
193
194 _QUALIFIER_RE = re.compile(
195 r"(?:(?<=[\s,;(\[\"'])|^)(?:" + _QUALIFIER_KEYS_ALT + r"):(?:[<>]=?)?(?:\"[^\"]*\"|[^\s,;()\[\]]+)[,;]?",
196 re.IGNORECASE,
197 )
198
199 # A qualifier fully wrapped in a single pair of parens/brackets/quotes. The
200 # wrapper pair is consumed with the qualifier, so stripping leaves no residue.
201 # An unbalanced quote (e.g. `label:"bug`) is not a wrapper shape and is left
202 # to _QUALIFIER_RE, which strips the qualifier as before.
203 _WRAPPED_QUALIFIER_RE = re.compile(
204 r"[\(\[\"'](?:" + _QUALIFIER_KEYS_ALT + r"):(?:[<>]=?)?(?:\"[^\"]*\"|[^\s,;()\[\]]+)[\)\]\"']",
205 re.IGNORECASE,
206 )
207
208 # Empty wrapper pairs left behind when a nested wrapper collapses (e.g. the
209 # `()` from "((created:>2025-03-20))"). Removed to fixpoint; a pair enclosing
210 # real text stays.
211 _EMPTY_WRAPPER_RE = re.compile(r"\(\s*\)|\[\s*\]|\"\s*\"|'\s*'")
212
213
214 def strip_search_qualifiers(text: str) -> str:
215 """Strip GitHub search qualifiers from a topic, leaving plain-language text.
216
217 Whitespace is collapsed. Returns an empty string when the topic was nothing
218 but qualifiers; callers must handle that rather than searching on an empty
219 term, which would match the entire site.
220 """
221 stripped = _WRAPPED_QUALIFIER_RE.sub(" ", text)
222 stripped = _QUALIFIER_RE.sub(" ", stripped)
223 while True:
224 cleaned = _EMPTY_WRAPPER_RE.sub("", stripped)
225 if cleaned == stripped:
226 break
227 stripped = cleaned
228 return " ".join(stripped.split())
229
230
231 # Bound topic fragments in logs/error envelopes so fanout cannot blow them up (#954).
232 _DIAGNOSTIC_TOPIC_LIMIT = 120
233
234
235 def _truncate_diagnostic(text: str, limit: int = _DIAGNOSTIC_TOPIC_LIMIT) -> str:
236 """Cap a topic fragment used in logs or error envelopes."""
237 if len(text) <= limit:
238 return text
239 return f"{text[:limit]}..."
240
241
242 def search_github(
243 topic: str,
244 from_date: str,
245 to_date: str,
246 depth: str = "default",
247 token: Optional[str] = None,
248 ) -> Dict[str, Any]:
249 """Search GitHub Issues and PRs (HTTP fetch only).
250
251 Returns a raw envelope shaped like every other adapter's ``search_X``:
252 ``{"items": [raw GitHub API items], "context": {core, from_date,
253 to_date, count}}``. Normalization, date filtering, and sorting move
254 to ``parse_github_response``; comment enrichment moves to
255 ``enrich_with_comments``.
256
257 Args:
258 topic: Search topic
259 from_date: Start date (YYYY-MM-DD)
260 to_date: End date (YYYY-MM-DD)
261 depth: 'quick', 'default', or 'deep'
262 token: Optional GitHub token (falls back to env/gh CLI)
263
264 Returns:
265 Dict envelope. Empty ``items`` list on any failure.
266 """
267 count = DEPTH_LIMITS.get(depth, DEPTH_LIMITS["default"])
268 core = extract_core_subject(topic)
269 plain_core = strip_search_qualifiers(core)
270 if not plain_core:
271 # A qualifier-only (or empty) topic leaves nothing to search on.
272 # Skip the network rather than querying an empty term, which would
273 # match the whole site (#949). Return a clean empty envelope, not an
274 # error, so the pipeline records NO_RESULTS instead of ERROR (#953)
275 # and GitHub is not marked as a failed attempt. Mixed-topic strip
276 # logs below stay bounded by _truncate_diagnostic (#954).
277 _log("Topic contained only search qualifiers or was empty; nothing to search")
278 return {
279 "items": [],
280 "context": {"core": core, "from_date": from_date,
281 "to_date": to_date, "count": count},
282 }
283 if plain_core != core:
284 _log(
285 "Stripped search qualifiers: "
286 f"'{_truncate_diagnostic(core)}' -> '{_truncate_diagnostic(plain_core)}'"
287 )
288 core = plain_core
289 resolved_token = _resolve_token(token)
290 authed = bool(resolved_token)
291 if not authed:
292 # Fall back to the unauthenticated REST tier instead of returning nothing.
293 # It is rate-limited, so cap the request volume.
294 count = min(count, UNAUTH_COUNT_CAP)
295 _log("No GitHub token; using the unauthenticated REST tier (low rate limit)")
296 _log(f"Searching for '{core}' (raw: '{topic}', since {from_date}, count={count})")
297
298 # Build search query with date filter
299 base_q = f"{core} created:>{from_date}"
300
301 def _search(qualifier: Optional[str]) -> Optional[Dict[str, Any]]:
302 q = f"{base_q} {qualifier}" if qualifier else base_q
303 params = {
304 "q": q,
305 "sort": "reactions",
306 "order": "desc",
307 "per_page": str(min(count, 100)),
308 }
309 url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
310 return _fetch_json(url, token=resolved_token, timeout=30,
311 failure_out=fetch_failures)
312
313 fetch_failures: List[str] = []
314 partition_failure: Optional[str] = None
315 if authed:
316 # GitHub rejects AUTHENTICATED /search/issues queries that carry
317 # neither `is:issue` nor `is:pull-request` (HTTP 422). Anonymous
318 # queries are still grandfathered, which is why this only bites once
319 # a token is present -- including via the `gh auth token` fallback.
320 #
321 # Appending a single qualifier would silently halve the corpus: for
322 # "rust async created:>2026-08-02" GitHub reports 1,072 issues and
323 # 7,044 pull requests against 8,115 combined, so `is:issue` alone
324 # drops ~87% of matches. Query both and merge instead, which keeps
325 # coverage AND the authenticated rate limit.
326 merged: List[Dict[str, Any]] = []
327 seen_ids = set()
328 failed_qualifiers: List[str] = []
329 for qualifier in ("is:issue", "is:pull-request"):
330 part = _search(qualifier)
331 if part is None:
332 failed_qualifiers.append(qualifier)
333 continue
334 for item in part.get("items", []):
335 item_id = item.get("id")
336 if item_id in seen_ids:
337 continue
338 seen_ids.add(item_id)
339 merged.append(item)
340 # Both sub-queries are reaction-sorted; the merge is not, so re-sort
341 # before truncating or the second query's tail would outrank the
342 # first query's head.
343 merged.sort(key=lambda i: (i.get("reactions") or {}).get("total_count", 0),
344 reverse=True)
345 data = {"items": merged[:count]} if merged else None
346 if failed_qualifiers:
347 partition_failure = (
348 f"GitHub partition(s) failed: {', '.join(failed_qualifiers)}"
349 + (f" ({fetch_failures[-1]})" if fetch_failures else "")
350 )
351 else:
352 data = _search(None)
353 if not data:
354 envelope = {"items": [], "context": {"core": core, "from_date": from_date,
355 "to_date": to_date, "count": count}}
356 if authed and partition_failure:
357 envelope["error"] = partition_failure
358 elif authed and fetch_failures:
359 # Authenticated transport failures must not be laundered into a
360 # clean no-results outcome (issue #384).
361 envelope["error"] = f"GitHub API request failed: {fetch_failures[-1]}"
362 elif not authed:
363 # Could be the anon rate limit (403) or an unprocessable query (422)
364 # -- _fetch_json maps both to None. Don't over-claim which; suggest a
365 # token since that fixes the common (rate-limit) case.
366 envelope["error"] = (
367 "GitHub unauthenticated request returned no data (anon rate limit "
368 "or unprocessable query; set GITHUB_TOKEN or run gh auth login)"
369 )
370 return envelope
371
372 raw_items = data.get("items", [])
373 _log(f"Found {len(raw_items)} issues/PRs")
374
375 envelope: Dict[str, Any] = {
376 "items": raw_items,
377 "context": {
378 "core": core,
379 "from_date": from_date,
380 "to_date": to_date,
381 "count": count,
382 },
383 }
384 if partition_failure:
385 envelope["error"] = partition_failure
386 return envelope
387
388
389 def parse_github_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
390 """Normalize a ``search_github`` envelope into the skill's item shape.
391
392 Pure function: no I/O, no token, no enrichment. Applies the date
393 filter using the search context and sorts by relevance.
394 """
395 if not isinstance(response, dict):
396 return []
397 raw_items = response.get("items") or []
398 if not isinstance(raw_items, list):
399 return []
400 context = response.get("context") or {}
401 core = context.get("core") or ""
402 from_date = context.get("from_date") or ""
403 to_date = context.get("to_date") or ""
404 count = context.get("count") or DEPTH_LIMITS["default"]
405
406 items: List[Dict[str, Any]] = []
407 for i, item in enumerate(raw_items[:count]):
408 html_url = item.get("html_url", "")
409 repo = _parse_repo_from_url(html_url)
410 title = item.get("title", "")
411 body_text = item.get("body") or ""
412 reactions_total = item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0
413 comment_count = item.get("comments") or 0
414 labels = [
415 lbl.get("name", "") for lbl in (item.get("labels") or [])
416 if isinstance(lbl, dict)
417 ]
418 state = item.get("state", "")
419 is_pr = "pull_request" in item
420 author = item.get("user", {}).get("login", "") if isinstance(item.get("user"), dict) else ""
421
422 relevance = _compute_relevance(core, title, i, reactions_total, comment_count)
423
424 items.append({
425 "id": f"GH{i + 1}",
426 "title": title,
427 "url": html_url,
428 "date": _parse_date(item.get("created_at")),
429 "author": author,
430 "source": "github",
431 "score": reactions_total,
432 "container": repo,
433 "snippet": body_text[:300] if body_text else "",
434 "relevance": relevance,
435 "why_relevant": f"GitHub {'PR' if is_pr else 'issue'}: {title[:60]}",
436 "engagement": {
437 "reactions": reactions_total,
438 "comments": comment_count,
439 },
440 "metadata": {
441 "labels": labels,
442 "state": state,
443 "comment_count": comment_count,
444 "reactions": reactions_total,
445 "is_pr": is_pr,
446 },
447 })
448
449 # Date filter
450 if from_date and to_date:
451 items = [
452 item for item in items
453 if item.get("date") is None or (from_date <= item["date"] <= to_date)
454 ]
455
456 items.sort(key=lambda x: x.get("relevance", 0), reverse=True)
457 return items
458
459
460 def enrich_with_comments(
461 items: List[Dict[str, Any]],
462 depth: str = "default",
463 token: Optional[str] = None,
464 ) -> List[Dict[str, Any]]:
465 """Fetch top comments for top-K items by reactions and attach to metadata.
466
467 Mutates and returns ``items``. Resolves ``token`` via env/gh CLI when
468 not supplied, matching ``search_github``'s fallback chain.
469 """
470 if not items:
471 return items
472 resolved_token = _resolve_token(token)
473 if not resolved_token:
474 _log("No GitHub token available for comment enrichment")
475 return items
476 return _enrich_top_items(items, depth, resolved_token)
477
478
479 def _enrich_top_items(
480 items: List[Dict[str, Any]],
481 depth: str,
482 token: str,
483 ) -> List[Dict[str, Any]]:
484 """Fetch comments for top N items by reactions."""
485 if not items:
486 return items
487
488 limit = ENRICH_LIMITS.get(depth, ENRICH_LIMITS["default"])
489
490 by_reactions = sorted(
491 range(len(items)),
492 key=lambda i: items[i].get("score", 0),
493 reverse=True,
494 )
495 to_enrich = by_reactions[:limit]
496
497 _log(f"Enriching top {len(to_enrich)} items with comments")
498
499 with ThreadPoolExecutor(max_workers=5) as executor:
500 futures = {
501 executor.submit(
502 _fetch_item_comments,
503 items[idx]["url"],
504 token,
505 ): idx
506 for idx in to_enrich
507 }
508
509 for future in as_completed(futures):
510 idx = futures[future]
511 try:
512 comments = future.result(timeout=15)
513 items[idx]["metadata"]["top_comments"] = comments
514 except (KeyError, TypeError, OSError) as exc:
515 _log(f"Comment enrichment failed for {items[idx].get('url', '?')}: {type(exc).__name__}: {exc}")
516 items[idx]["metadata"]["top_comments"] = []
517
518 return items
519
520
521 def _fetch_item_comments(
522 issue_url: str,
523 token: str,
524 max_comments: int = 5,
525 ) -> List[Dict[str, Any]]:
526 """Fetch comments for a GitHub issue/PR.
527
528 Args:
529 issue_url: HTML URL like https://github.com/owner/repo/issues/123
530 token: GitHub auth token
531 max_comments: Max comments to return
532
533 Returns:
534 List of comment dicts with score, excerpt, author.
535 """
536 path = issue_url.replace("https://github.com/", "")
537 path = path.replace("/pull/", "/issues/")
538 api_url = f"https://api.github.com/repos/{path}/comments?per_page={max_comments}&sort=reactions&direction=desc"
539
540 data = _fetch_json(api_url, token=token, timeout=15)
541 if not data or not isinstance(data, list):
542 return []
543
544 comments = []
545 for c in data[:max_comments]:
546 body = c.get("body") or ""
547 excerpt = body[:300] + "..." if len(body) > 300 else body
548 reactions = c.get("reactions", {})
549 reaction_count = reactions.get("total_count", 0) if isinstance(reactions, dict) else 0
550 author = c.get("user", {}).get("login", "") if isinstance(c.get("user"), dict) else ""
551
552 comments.append({
553 "score": reaction_count,
554 "excerpt": excerpt,
555 "author": author,
556 })
557
558 return comments
559
560
561 # ---------------------------------------------------------------------------
562 # Person-mode search: author-scoped queries, star enrichment, release notes
563 # ---------------------------------------------------------------------------
564
565 PERSON_DEPTH_LIMITS = {
566 "quick": {"pr_pages": 1, "own_repos": 3, "external_repos": 5},
567 "default": {"pr_pages": 1, "own_repos": 5, "external_repos": 10},
568 "deep": {"pr_pages": 2, "own_repos": 5, "external_repos": 15},
569 }
570
571 PERSON_EVENTS_PER_PAGE = 100
572
573
574 def _fetch_readme_snippet(repo: str, token: str, max_chars: int = 500) -> Optional[str]:
575 """Fetch README content for a repo, truncated to first ~max_chars."""
576 url = f"https://api.github.com/repos/{repo}/readme"
577 headers = {
578 "User-Agent": USER_AGENT,
579 "Accept": "application/vnd.github.raw+json",
580 }
581 if token:
582 headers["Authorization"] = f"Bearer {token}"
583
584 req = urllib.request.Request(url, headers=headers)
585 try:
586 with urllib.request.urlopen(req, timeout=10) as resp:
587 raw = resp.read().decode("utf-8", errors="replace")
588 except (urllib.error.HTTPError, urllib.error.URLError, OSError, TimeoutError):
589 return None
590
591 if not raw:
592 return None
593 # Try to break at a paragraph boundary
594 if len(raw) <= max_chars:
595 return raw
596 cut = raw[:max_chars]
597 last_double_newline = cut.rfind("\n\n")
598 if last_double_newline > max_chars // 3:
599 return cut[:last_double_newline].rstrip()
600 return cut.rstrip() + "..."
601
602
603 def _fetch_latest_releases(
604 repo: str, token: str, count: int = 3, max_body: int = 300,
605 ) -> List[Dict[str, str]]:
606 """Fetch latest releases for a repo."""
607 url = f"https://api.github.com/repos/{repo}/releases?per_page={count}"
608 data = _fetch_json(url, token=token, timeout=10)
609 if not data or not isinstance(data, list):
610 return []
611 releases = []
612 for r in data[:count]:
613 tag = r.get("tag_name", "")
614 date = _parse_date(r.get("published_at"))
615 body = (r.get("body") or "")[:max_body]
616 name = r.get("name") or tag
617 releases.append({"tag": tag, "name": name, "date": date, "body": body})
618 return releases
619
620
621 def _fetch_top_issues(repo: str, token: str) -> Dict[str, Any]:
622 """Fetch top feature request (by reactions) and top complaint (by comments)."""
623 result: Dict[str, Any] = {}
624
625 # Top feature request: issues with enhancement label, sorted by reactions
626 feat_q = urllib.parse.quote(f"repo:{repo} is:issue is:open label:enhancement")
627 feat_url = f"{SEARCH_URL}?q={feat_q}&sort=reactions&order=desc&per_page=1"
628 feat_data = _fetch_json(feat_url, token=token, timeout=10)
629 if feat_data and feat_data.get("items"):
630 item = feat_data["items"][0]
631 result["top_feature_request"] = {
632 "title": item.get("title", ""),
633 "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
634 "comments": item.get("comments") or 0,
635 "url": item.get("html_url", ""),
636 }
637 elif feat_data and feat_data.get("total_count", 0) == 0:
638 # No enhancement label; fall back to top issue by reactions
639 fallback_q = urllib.parse.quote(f"repo:{repo} is:issue is:open")
640 fallback_url = f"{SEARCH_URL}?q={fallback_q}&sort=reactions&order=desc&per_page=1"
641 fallback_data = _fetch_json(fallback_url, token=token, timeout=10)
642 if fallback_data and fallback_data.get("items"):
643 item = fallback_data["items"][0]
644 result["top_feature_request"] = {
645 "title": item.get("title", ""),
646 "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
647 "comments": item.get("comments") or 0,
648 "url": item.get("html_url", ""),
649 }
650
651 # Top complaint: most-discussed open issue (by comments)
652 bug_q = urllib.parse.quote(f"repo:{repo} is:issue is:open")
653 bug_url = f"{SEARCH_URL}?q={bug_q}&sort=comments&order=desc&per_page=1"
654 bug_data = _fetch_json(bug_url, token=token, timeout=10)
655 if bug_data and bug_data.get("items"):
656 item = bug_data["items"][0]
657 result["top_complaint"] = {
658 "title": item.get("title", ""),
659 "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
660 "comments": item.get("comments") or 0,
661 "url": item.get("html_url", ""),
662 }
663
664 return result
665
666
667 def _fetch_repo_info(repo: str, token: str) -> Optional[Dict[str, Any]]:
668 """Fetch repo metadata (stars, forks, description, language)."""
669 url = f"https://api.github.com/repos/{repo}"
670 data = _fetch_json(url, token=token, timeout=10)
671 if not data or not isinstance(data, dict):
672 return None
673 return {
674 "stars": data.get("stargazers_count", 0),
675 "forks": data.get("forks_count", 0),
676 "description": (data.get("description") or "")[:200],
677 "language": data.get("language") or "",
678 "open_issues": data.get("open_issues_count", 0),
679 }
680
681
682 def _format_stars(n: int) -> str:
683 """Format star count as human-readable (e.g., 349K, 2.9K, 42)."""
684 if n >= 1_000_000:
685 return f"{n / 1_000_000:.1f}M"
686 if n >= 1_000:
687 return f"{n / 1_000:.0f}K" if n >= 10_000 else f"{n / 1_000:.1f}K"
688 return str(n)
689
690
691 def refetch_datum(item: schema.SourceItem | None, datum_key: str) -> dict[str, Any]:
692 """Re-fetch one repository counter through the shared HTTP wrapper.
693
694 ``datum_key`` is either the literal ``"stars"`` (item-level claim; the
695 repo derives from the grounding item) or an ``owner/repo`` slug
696 (candidate-enrichment claim; the repo itself is the refetch subject and
697 the item is not consulted, so it may be ``None``).
698 """
699 if re.fullmatch(r"[^/\s]+/[^/\s]+", datum_key):
700 repo = datum_key
701 elif datum_key != "stars":
702 raise KeyError(f"Unsupported GitHub datum: {datum_key}")
703 else:
704 if item is None:
705 raise ValueError("Item-level star refetch requires the grounding item")
706 repo = item.container or ""
707 if not re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
708 match = re.match(r"https?://github\.com/([^/]+/[^/#?]+)", item.url)
709 repo = match.group(1).removesuffix(".git") if match else ""
710 if not repo:
711 raise ValueError("GitHub item has no owner/repository reference")
712 headers = {"Accept": "application/vnd.github+json"}
713 token = _resolve_token()
714 if token:
715 headers["Authorization"] = f"Bearer {token}"
716 data = http.request(
717 "GET", f"https://api.github.com/repos/{repo}",
718 headers=headers, timeout=10, retries=2,
719 )
720 if not isinstance(data, dict) or not isinstance(data.get("stargazers_count"), int):
721 raise KeyError("GitHub star count was not returned")
722 fallback_url = item.url if item is not None else f"https://github.com/{repo}"
723 return {
724 "value": data["stargazers_count"],
725 "url": str(data.get("html_url") or fallback_url),
726 "timestamp": data.get("updated_at"),
727 }
728
729
730 def search_github_person(
731 username: str,
732 from_date: str,
733 to_date: str,
734 depth: str = "default",
735 token: Optional[str] = None,
736 ) -> List[Dict[str, Any]]:
737 """Person-mode GitHub search: author-scoped queries with star enrichment.
738
739 Returns SourceItems for:
740 - 1 velocity summary item
741 - Per-repo items for top external repos (with stars + release notes)
742 - Per-repo items for own repos (with stars + README + top issues + releases)
743 """
744 resolved_token = _resolve_token(token)
745 if not resolved_token:
746 _log("No GitHub token available for person-mode search")
747 return []
748
749 limits = PERSON_DEPTH_LIMITS.get(depth, PERSON_DEPTH_LIMITS["default"])
750 _log(f"Person-mode search for @{username} (since {from_date})")
751
752 # Phase 1: PR velocity via search API
753 total_q = urllib.parse.quote(f"author:{username} type:pr created:>{from_date}")
754 merged_q = urllib.parse.quote(f"author:{username} type:pr is:merged created:>{from_date}")
755
756 total_url = f"{SEARCH_URL}?q={total_q}&per_page=1"
757 merged_url = f"{SEARCH_URL}?q={merged_q}&sort=reactions&order=desc&per_page=100"
758
759 total_data = _fetch_json(total_url, token=resolved_token, timeout=20)
760 merged_data = _fetch_json(merged_url, token=resolved_token, timeout=20)
761
762 total_prs = total_data.get("total_count", 0) if total_data else 0
763 merged_count = merged_data.get("total_count", 0) if merged_data else 0
764 merged_items = merged_data.get("items", []) if merged_data else []
765
766 _log(f"Found {total_prs} total PRs, {merged_count} merged")
767
768 if total_prs == 0 and merged_count == 0:
769 # An empty PR search can mean no PRs in the window or an account that
770 # GitHub's issue index cannot search. Public PushEvents provide an
771 # actor-attributed fallback for either case.
772 search_unavailable = total_data is None or merged_data is None
773 recent = _person_recent_pushes(
774 username, from_date, to_date, limits, resolved_token,
775 )
776 if recent:
777 reason = "account not searchable" if search_unavailable else "no PRs in window"
778 _log(f"PR search empty ({reason}); public events returned {len(recent)} items")
779 return recent
780 _log("No PRs found, falling back to keyword search")
781 return []
782
783 # Phase 2: Group merged PRs by repo
784 repo_pr_counts: Dict[str, int] = {}
785 for item in merged_items:
786 repo = _parse_repo_from_url(item.get("html_url", ""))
787 if repo:
788 repo_pr_counts[repo] = repo_pr_counts.get(repo, 0) + 1
789
790 # Sort repos by PR count (most active first)
791 sorted_repos = sorted(repo_pr_counts.items(), key=lambda x: x[1], reverse=True)
792
793 # Phase 3: Fetch own repos
794 own_repos_url = f"https://api.github.com/users/{username}/repos?sort=stars&per_page={limits['own_repos']}&direction=desc"
795 own_repos_data = _fetch_json(own_repos_url, token=resolved_token, timeout=15)
796 own_repo_names = set()
797 own_repos_info: List[Dict[str, Any]] = []
798 if own_repos_data and isinstance(own_repos_data, list):
799 for r in own_repos_data:
800 full_name = r.get("full_name", "")
801 if full_name and not r.get("fork"):
802 own_repo_names.add(full_name)
803 own_repos_info.append({
804 "full_name": full_name,
805 "stars": r.get("stargazers_count", 0),
806 "forks": r.get("forks_count", 0),
807 "description": (r.get("description") or "")[:200],
808 "language": r.get("language") or "",
809 "open_issues": r.get("open_issues_count", 0),
810 })
811
812 # Separate external repos from own repos
813 external_repos = [(repo, count) for repo, count in sorted_repos if repo not in own_repo_names]
814 external_repos = external_repos[:limits["external_repos"]]
815
816 # Phase 4: Parallel enrichment (star counts, releases, READMEs, top issues)
817 items: List[Dict[str, Any]] = []
818 idx = 0
819
820 # Build velocity summary
821 open_prs = total_prs - merged_count
822 merge_rate = round(100 * merged_count / total_prs) if total_prs > 0 else 0
823 num_repos = len(repo_pr_counts)
824 velocity_text = (
825 f"GitHub Person Profile: @{username}\n\n"
826 f"CONTRIBUTION VELOCITY (last {(to_date > from_date) and 30 or 30} days)\n"
827 f"- {merged_count} PRs merged across {num_repos} repos ({merge_rate}% merge rate)\n"
828 f"- {total_prs} total PRs submitted, {open_prs} still open\n"
829 )
830
831 idx += 1
832 items.append({
833 "id": f"GH{idx}",
834 "title": f"@{username}: {merged_count} PRs merged across {num_repos} repos ({merge_rate}% merge rate)",
835 "url": f"https://github.com/{username}",
836 "date": to_date,
837 "author": username,
838 "source": "github",
839 "score": merged_count,
840 "container": f"@{username}",
841 "snippet": velocity_text,
842 "relevance": 0.95,
843 "why_relevant": f"GitHub profile: @{username} - {merged_count} PRs merged across {num_repos} repos",
844 "engagement": {"merged_prs": merged_count, "comments": total_prs},
845 "metadata": {
846 "labels": ["person-profile", "velocity"],
847 "state": "open",
848 "comment_count": 0,
849 "reactions": merged_count,
850 "is_pr": False,
851 },
852 })
853
854 # Phase 5: Enrich external repos (parallel: star counts + releases)
855 _log(f"Enriching {len(external_repos)} external repos + {len(own_repos_info)} own repos")
856
857 with ThreadPoolExecutor(max_workers=8) as executor:
858 # External repo enrichment: stars + releases
859 ext_futures = {}
860 for repo, pr_count in external_repos:
861 ext_futures[executor.submit(_enrich_external_repo, repo, resolved_token)] = (repo, pr_count)
862
863 # Own repo enrichment: README + releases + top issues
864 own_futures = {}
865 for own_repo in own_repos_info:
866 own_futures[executor.submit(_enrich_own_repo, own_repo["full_name"], resolved_token)] = own_repo
867
868 # Collect external repo results
869 for future in as_completed(ext_futures):
870 repo, pr_count = ext_futures[future]
871 try:
872 enrichment = future.result(timeout=20)
873 except Exception as exc:
874 _log(f"External repo enrichment failed for {repo}: {exc}")
875 enrichment = {}
876
877 repo_info = enrichment.get("info")
878 releases = enrichment.get("releases", [])
879
880 stars = repo_info["stars"] if repo_info else 0
881 stars_str = _format_stars(stars)
882 desc = repo_info["description"] if repo_info else ""
883
884 snippet_parts = [f"Contributed {pr_count} merged PRs to {repo} ({stars_str} stars)"]
885 if desc:
886 snippet_parts.append(f" {desc}")
887 if releases:
888 for rel in releases[:2]:
889 body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
890 snippet_parts.append(f" Latest release: {rel['name']} ({rel['date']}){body_preview}")
891
892 idx += 1
893 items.append({
894 "id": f"GH{idx}",
895 "title": f"{repo} ({stars_str} stars) - {pr_count} PRs merged",
896 "url": f"https://github.com/{repo}",
897 "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
898 "author": username,
899 "source": "github",
900 "score": stars,
901 "container": repo,
902 "snippet": "\n".join(snippet_parts),
903 "relevance": min(0.9, 0.6 + math.log1p(stars) / 30 + min(0.15, pr_count / 20)),
904 "why_relevant": f"GitHub contribution: {pr_count} PRs merged to {repo} ({stars_str} stars)",
905 "engagement": {"stars": stars, "comments": pr_count},
906 "metadata": {
907 "labels": ["person-profile", "external-repo"],
908 "state": "open",
909 "comment_count": pr_count,
910 "reactions": stars,
911 "is_pr": False,
912 },
913 })
914
915 # Collect own repo results
916 for future in as_completed(own_futures):
917 own_repo = own_futures[future]
918 try:
919 enrichment = future.result(timeout=25)
920 except Exception as exc:
921 _log(f"Own repo enrichment failed for {own_repo['full_name']}: {exc}")
922 enrichment = {}
923
924 repo_name = own_repo["full_name"]
925 stars = own_repo["stars"]
926 stars_str = _format_stars(stars)
927 open_issues = own_repo["open_issues"]
928 desc = own_repo["description"]
929
930 readme = enrichment.get("readme")
931 releases = enrichment.get("releases", [])
932 top_issues = enrichment.get("top_issues", {})
933
934 snippet_parts = [f"Own project: {repo_name} ({stars_str} stars, {open_issues} open issues)"]
935 if desc:
936 snippet_parts.append(f" {desc}")
937 if readme:
938 snippet_parts.append(f" README: {readme[:300]}")
939 if releases:
940 for rel in releases[:2]:
941 body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
942 snippet_parts.append(f" Latest release: {rel['name']} ({rel['date']}){body_preview}")
943 feat = top_issues.get("top_feature_request")
944 if feat:
945 snippet_parts.append(f" Top feature request: \"{feat['title']}\" ({feat['reactions']} reactions, {feat['comments']} comments)")
946 complaint = top_issues.get("top_complaint")
947 if complaint:
948 snippet_parts.append(f" Top complaint: \"{complaint['title']}\" ({complaint['comments']} comments)")
949
950 idx += 1
951 items.append({
952 "id": f"GH{idx}",
953 "title": f"{repo_name} ({stars_str} stars) - own project, {open_issues} open issues",
954 "url": f"https://github.com/{repo_name}",
955 "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
956 "author": username,
957 "source": "github",
958 "score": stars,
959 "container": repo_name,
960 "snippet": "\n".join(snippet_parts),
961 "relevance": min(0.95, 0.7 + math.log1p(stars) / 25),
962 "why_relevant": f"GitHub own project: {repo_name} ({stars_str} stars)",
963 "engagement": {"stars": stars, "comments": open_issues},
964 "metadata": {
965 "labels": ["person-profile", "own-repo"],
966 "state": "open",
967 "comment_count": open_issues,
968 "reactions": stars,
969 "is_pr": False,
970 },
971 })
972
973 # Sort by relevance
974 items.sort(key=lambda x: x.get("relevance", 0), reverse=True)
975 _log(f"Person-mode returned {len(items)} items")
976 return items
977
978
979 def _person_recent_pushes(
980 username: str,
981 from_date: str,
982 to_date: str,
983 limits: Dict[str, int],
984 token: str,
985 ) -> List[Dict[str, Any]]:
986 """Return repos the selected actor publicly pushed inside the window."""
987 latest_by_repo: Dict[str, Dict[str, str]] = {}
988 encoded_username = urllib.parse.quote(username, safe="")
989
990 page = 1
991 while True:
992 url = (
993 f"https://api.github.com/users/{encoded_username}/events/public"
994 f"?per_page={PERSON_EVENTS_PER_PAGE}&page={page}"
995 )
996 data = _fetch_json(url, token=token, timeout=15)
997 if not data or not isinstance(data, list):
998 break
999
1000 reached_before_window = False
1001 for event in data:
1002 created_at = event.get("created_at")
1003 pushed = _parse_date(created_at)
1004 if not pushed:
1005 continue
1006 if pushed < from_date:
1007 reached_before_window = True
1008 break
1009 if pushed > to_date or event.get("type") != "PushEvent":
1010 continue
1011
1012 actor = event.get("actor")
1013 actor_login = actor.get("login", "") if isinstance(actor, dict) else ""
1014 if actor_login.casefold() != username.casefold():
1015 continue
1016
1017 repo = event.get("repo")
1018 full_name = repo.get("name", "") if isinstance(repo, dict) else ""
1019 if not re.fullmatch(r"[^/\s]+/[^/\s]+", full_name):
1020 continue
1021
1022 previous = latest_by_repo.get(full_name)
1023 if previous is None or created_at > previous["created_at"]:
1024 latest_by_repo[full_name] = {
1025 "full_name": full_name,
1026 "pushed": pushed,
1027 "created_at": created_at,
1028 "actor": actor_login,
1029 "event_id": str(event.get("id") or ""),
1030 }
1031
1032 if reached_before_window or len(data) < PERSON_EVENTS_PER_PAGE:
1033 break
1034 page += 1
1035
1036 if not latest_by_repo:
1037 return []
1038
1039 recent = sorted(
1040 latest_by_repo.values(),
1041 key=lambda r: r["created_at"],
1042 reverse=True,
1043 )
1044 _log(
1045 f"Public events: {len(recent)} actor-attributed repos pushed in window, "
1046 "loading repository metadata for ranking"
1047 )
1048
1049 repo_info: Dict[str, Dict[str, Any]] = {}
1050 with ThreadPoolExecutor(max_workers=8) as executor:
1051 info_futures = {
1052 executor.submit(_fetch_repo_info, r["full_name"], token): r["full_name"]
1053 for r in recent
1054 }
1055 for future in as_completed(info_futures):
1056 name = info_futures[future]
1057 try:
1058 repo_info[name] = future.result(timeout=20) or {}
1059 except Exception as exc:
1060 _log(f"Push-event repo metadata failed for {name}: {exc}")
1061 repo_info[name] = {}
1062
1063 recent.sort(
1064 key=lambda r: (
1065 repo_info.get(r["full_name"], {}).get("stars", 0),
1066 r["created_at"],
1067 ),
1068 reverse=True,
1069 )
1070 selected = recent[:limits["own_repos"]]
1071
1072 enrichments: Dict[str, Dict[str, Any]] = {}
1073 _log(f"Public events: enriching {len(selected)} top-ranked repositories")
1074 with ThreadPoolExecutor(max_workers=8) as executor:
1075 enrichment_futures = {
1076 executor.submit(_enrich_own_repo, r["full_name"], token): r["full_name"]
1077 for r in selected
1078 }
1079 for future in as_completed(enrichment_futures):
1080 name = enrichment_futures[future]
1081 try:
1082 enrichments[name] = future.result(timeout=25)
1083 except Exception as exc:
1084 _log(f"Push-event enrichment failed for {name}: {exc}")
1085 enrichments[name] = {}
1086
1087 items: List[Dict[str, Any]] = []
1088 for idx, repo in enumerate(selected, start=1):
1089 name = repo["full_name"]
1090 info = repo_info.get(name, {})
1091 stars = info.get("stars", 0)
1092 stars_str = _format_stars(stars)
1093 open_issues = info.get("open_issues", 0)
1094 enrichment = enrichments.get(name, {})
1095 readme = enrichment.get("readme")
1096 releases = enrichment.get("releases", [])
1097
1098 snippet_parts = [
1099 f"@{repo['actor']} pushed {name} on {repo['pushed']} "
1100 f"({stars_str} stars, {open_issues} open issues)"
1101 ]
1102 if info.get("description"):
1103 snippet_parts.append(f" {info['description']}")
1104 if readme:
1105 snippet_parts.append(f" README: {readme[:300]}")
1106 for rel in releases[:2]:
1107 body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
1108 snippet_parts.append(f" Release: {rel['name']} ({rel['date']}){body_preview}")
1109
1110 items.append({
1111 "id": f"GH{idx}",
1112 "title": f"@{repo['actor']} pushed {name} on {repo['pushed']}",
1113 "url": f"https://github.com/{name}",
1114 "date": repo["pushed"],
1115 "author": repo["actor"],
1116 "source": "github",
1117 "score": stars,
1118 "container": name,
1119 "snippet": "\n".join(snippet_parts),
1120 "relevance": min(0.9, 0.6 + math.log1p(stars) / 30),
1121 "why_relevant": (
1122 f"GitHub activity: @{repo['actor']} pushed {name} on {repo['pushed']} "
1123 f"({stars_str} stars)"
1124 ),
1125 "engagement": {"stars": stars, "comments": open_issues},
1126 "metadata": {
1127 "labels": ["person-profile", "recent-push"],
1128 "state": "open",
1129 "comment_count": open_issues,
1130 "reactions": stars,
1131 "is_pr": False,
1132 "event_type": "PushEvent",
1133 "event_id": repo["event_id"],
1134 },
1135 })
1136
1137 return items
1138
1139
1140 def _enrich_external_repo(repo: str, token: str) -> Dict[str, Any]:
1141 """Fetch star count + releases for an external repo."""
1142 info = _fetch_repo_info(repo, token)
1143 releases = _fetch_latest_releases(repo, token, count=3)
1144 return {"info": info, "releases": releases}
1145
1146
1147 def _enrich_own_repo(repo: str, token: str) -> Dict[str, Any]:
1148 """Fetch README + releases + top issues for an own repo."""
1149 readme = _fetch_readme_snippet(repo, token, max_chars=500)
1150 releases = _fetch_latest_releases(repo, token, count=3)
1151 top_issues = _fetch_top_issues(repo, token)
1152 return {"readme": readme, "releases": releases, "top_issues": top_issues}
1153
1154
1155 # ---------------------------------------------------------------------------
1156 # Project-mode search: fetch comprehensive data for specific repos
1157 # ---------------------------------------------------------------------------
1158
1159 def search_github_project(
1160 repos: List[str],
1161 from_date: str,
1162 to_date: str,
1163 depth: str = "default",
1164 token: Optional[str] = None,
1165 ) -> List[Dict[str, Any]]:
1166 """Project-mode GitHub search: fetch stars, README, releases, top issues for repos.
1167
1168 Args:
1169 repos: List of 'owner/repo' strings.
1170 from_date: Start date (YYYY-MM-DD).
1171 to_date: End date (YYYY-MM-DD).
1172 depth: 'quick', 'default', or 'deep'.
1173 token: Optional GitHub token.
1174
1175 Returns:
1176 List of SourceItems, one per repo.
1177 """
1178 resolved_token = _resolve_token(token)
1179 if not resolved_token:
1180 _log("No GitHub token available for project-mode search")
1181 return []
1182
1183 _log(f"Project-mode search for {len(repos)} repos: {', '.join(repos)}")
1184
1185 items: List[Dict[str, Any]] = []
1186
1187 with ThreadPoolExecutor(max_workers=min(8, len(repos))) as executor:
1188 futures = {
1189 executor.submit(_enrich_project_repo, repo, resolved_token): repo
1190 for repo in repos
1191 }
1192
1193 for idx, future in enumerate(as_completed(futures)):
1194 repo = futures[future]
1195 try:
1196 enrichment = future.result(timeout=25)
1197 except Exception as exc:
1198 _log(f"Project enrichment failed for {repo}: {exc}")
1199 continue
1200
1201 info = enrichment.get("info")
1202 if not info:
1203 _log(f"No repo info for {repo}, skipping")
1204 continue
1205
1206 readme = enrichment.get("readme")
1207 releases = enrichment.get("releases", [])
1208 top_issues = enrichment.get("top_issues", {})
1209
1210 stars = info["stars"]
1211 stars_str = _format_stars(stars)
1212 open_issues = info["open_issues"]
1213 desc = info["description"]
1214 lang = info["language"]
1215
1216 snippet_parts = [f"Project: {repo} ({stars_str} stars, {open_issues} open issues, {lang})"]
1217 if desc:
1218 snippet_parts.append(f" {desc}")
1219 if readme:
1220 snippet_parts.append(f" README: {readme[:400]}")
1221 if releases:
1222 for rel in releases[:2]:
1223 body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
1224 snippet_parts.append(f" Latest release: {rel['name']} ({rel['date']}){body_preview}")
1225 feat = top_issues.get("top_feature_request")
1226 if feat:
1227 snippet_parts.append(f" Top feature request: \"{feat['title']}\" ({feat['reactions']} reactions, {feat['comments']} comments)")
1228 complaint = top_issues.get("top_complaint")
1229 if complaint:
1230 snippet_parts.append(f" Top complaint: \"{complaint['title']}\" ({complaint['comments']} comments)")
1231
1232 items.append({
1233 "id": f"GH{idx + 1}",
1234 "title": f"{repo} ({stars_str} stars) - {open_issues} open issues",
1235 "url": f"https://github.com/{repo}",
1236 "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
1237 "author": repo.split("/")[0],
1238 "source": "github",
1239 "score": stars,
1240 "container": repo,
1241 "snippet": "\n".join(snippet_parts),
1242 "relevance": min(0.95, 0.7 + math.log1p(stars) / 25),
1243 "why_relevant": f"GitHub project: {repo} ({stars_str} stars, live)",
1244 "engagement": {"stars": stars, "comments": open_issues},
1245 "metadata": {
1246 "labels": ["project-mode"],
1247 "state": "open",
1248 "comment_count": open_issues,
1249 "reactions": stars,
1250 "is_pr": False,
1251 "github_stars": {repo: stars},
1252 },
1253 })
1254
1255 items.sort(key=lambda x: x.get("relevance", 0), reverse=True)
1256 _log(f"Project-mode returned {len(items)} items")
1257 return items
1258
1259
1260 def _enrich_project_repo(repo: str, token: str) -> Dict[str, Any]:
1261 """Fetch all project data for a repo: info + README + releases + top issues."""
1262 info = _fetch_repo_info(repo, token)
1263 readme = _fetch_readme_snippet(repo, token, max_chars=500)
1264 releases = _fetch_latest_releases(repo, token, count=3)
1265 top_issues = _fetch_top_issues(repo, token)
1266 return {"info": info, "readme": readme, "releases": releases, "top_issues": top_issues}
1267
1268
1269 # ---------------------------------------------------------------------------
1270 # Post-rerank star enrichment: annotate candidates with live star counts
1271 # ---------------------------------------------------------------------------
1272
1273 _REPO_URL_PATTERN = re.compile(r"github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)")
1274 _SKIP_PATHS = {"topics", "search", "orgs", "settings", "features", "about", "pricing", "enterprise", "explore", "marketplace", "sponsors"}
1275
1276
1277 def extract_repo_refs(candidates: List[Any]) -> List[str]:
1278 """Extract unique owner/repo strings from candidate URLs, titles, and snippets."""
1279 seen: set = set()
1280 repos: List[str] = []
1281 for c in candidates:
1282 texts = [
1283 getattr(c, "url", "") or "",
1284 getattr(c, "title", "") or "",
1285 ]
1286 # Also check evidence snippets if available
1287 evidence = getattr(c, "evidence", None)
1288 if evidence:
1289 texts.append(str(evidence))
1290 for text in texts:
1291 for match in _REPO_URL_PATTERN.findall(text):
1292 # Normalize: strip trailing .git, lowercase
1293 repo = match.rstrip(".git").lower()
1294 owner = repo.split("/")[0]
1295 if owner in _SKIP_PATHS:
1296 continue
1297 if repo not in seen:
1298 seen.add(repo)
1299 repos.append(match) # preserve original case
1300 return repos
1301
1302
1303 def enrich_candidates_with_stars(
1304 candidates: List[Any],
1305 token: Optional[str] = None,
1306 already_enriched: Optional[set] = None,
1307 max_repos: int = 10,
1308 collect_map: Optional[Dict[str, int]] = None,
1309 ) -> int:
1310 """Annotate candidates with live GitHub star counts.
1311
1312 Returns the number of repos enriched.
1313 """
1314 resolved_token = _resolve_token(token)
1315 if not resolved_token:
1316 return 0
1317
1318 refs = extract_repo_refs(candidates)
1319 if not refs:
1320 return 0
1321
1322 skip = already_enriched or set()
1323 to_fetch = [r for r in refs if r.lower() not in {s.lower() for s in skip}][:max_repos]
1324 if not to_fetch:
1325 return 0
1326
1327 _log(f"Star enrichment: fetching {len(to_fetch)} repos")
1328
1329 # Parallel fetch star counts
1330 star_map: Dict[str, int] = {}
1331 with ThreadPoolExecutor(max_workers=min(8, len(to_fetch))) as executor:
1332 futures = {executor.submit(_fetch_repo_info, repo, resolved_token): repo for repo in to_fetch}
1333 for future in as_completed(futures):
1334 repo = futures[future]
1335 try:
1336 info = future.result(timeout=10)
1337 if info:
1338 star_map[repo.lower()] = info["stars"]
1339 except Exception:
1340 pass
1341
1342 if collect_map is not None:
1343 collect_map.update(star_map)
1344 if not star_map:
1345 return 0
1346
1347 return apply_star_map(candidates, star_map)
1348
1349
1350 def apply_star_map(candidates: List[Any], star_map: Dict[str, int]) -> int:
1351 """Annotate candidates from a repo->stars map (fetch/apply split).
1352
1353 Split out so offline replay (the eval harness) can apply a recorded map
1354 without any network or gh-credential access.
1355 """
1356 if not star_map:
1357 return 0
1358 # Annotate candidates
1359 enriched_count = 0
1360 for c in candidates:
1361 texts = [getattr(c, "url", "") or "", getattr(c, "title", "") or ""]
1362 evidence = getattr(c, "evidence", None)
1363 if evidence:
1364 texts.append(str(evidence))
1365 combined = " ".join(texts)
1366 for match in _REPO_URL_PATTERN.findall(combined):
1367 repo_lower = match.rstrip(".git").lower()
1368 if repo_lower in star_map:
1369 stars = star_map[repo_lower]
1370 stars_str = _format_stars(stars)
1371 # Add to metadata
1372 if not hasattr(c, "metadata") or c.metadata is None:
1373 continue
1374 if "github_stars" not in c.metadata:
1375 c.metadata["github_stars"] = {}
1376 c.metadata["github_stars"][match] = stars
1377 # Append to evidence if present
1378 if hasattr(c, "evidence") and c.evidence and f"(live:" not in c.evidence:
1379 c.evidence = c.evidence + f" (live: {stars_str} stars)"
1380 enriched_count += 1
1381 break # one annotation per candidate
1382
1383 _log(f"Star enrichment: annotated {enriched_count} candidates")
1384 return enriched_count
1385
1385 lines PYTHON