返回 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 def search_github(
170 topic: str,
171 from_date: str,
172 to_date: str,
173 depth: str = "default",
174 token: Optional[str] = None,
175 ) -> Dict[str, Any]:
176 """Search GitHub Issues and PRs (HTTP fetch only).
177
178 Returns a raw envelope shaped like every other adapter's ``search_X``:
179 ``{"items": [raw GitHub API items], "context": {core, from_date,
180 to_date, count}}``. Normalization, date filtering, and sorting move
181 to ``parse_github_response``; comment enrichment moves to
182 ``enrich_with_comments``.
183
184 Args:
185 topic: Search topic
186 from_date: Start date (YYYY-MM-DD)
187 to_date: End date (YYYY-MM-DD)
188 depth: 'quick', 'default', or 'deep'
189 token: Optional GitHub token (falls back to env/gh CLI)
190
191 Returns:
192 Dict envelope. Empty ``items`` list on any failure.
193 """
194 count = DEPTH_LIMITS.get(depth, DEPTH_LIMITS["default"])
195 core = extract_core_subject(topic)
196 resolved_token = _resolve_token(token)
197 authed = bool(resolved_token)
198 if not authed:
199 # Fall back to the unauthenticated REST tier instead of returning nothing.
200 # It is rate-limited, so cap the request volume.
201 count = min(count, UNAUTH_COUNT_CAP)
202 _log("No GitHub token; using the unauthenticated REST tier (low rate limit)")
203 _log(f"Searching for '{core}' (raw: '{topic}', since {from_date}, count={count})")
204
205 # Build search query with date filter
206 q = f"{core} created:>{from_date}"
207 params = {
208 "q": q,
209 "sort": "reactions",
210 "order": "desc",
211 "per_page": str(min(count, 100)),
212 }
213 url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
214
215 fetch_failures: List[str] = []
216 data = _fetch_json(url, token=resolved_token, timeout=30, failure_out=fetch_failures)
217 if not data:
218 envelope = {"items": [], "context": {"core": core, "from_date": from_date,
219 "to_date": to_date, "count": count}}
220 if authed and fetch_failures:
221 # Authenticated transport failures must not be laundered into a
222 # clean no-results outcome (issue #384).
223 envelope["error"] = f"GitHub API request failed: {fetch_failures[-1]}"
224 elif not authed:
225 # Could be the anon rate limit (403) or an unprocessable query (422)
226 # -- _fetch_json maps both to None. Don't over-claim which; suggest a
227 # token since that fixes the common (rate-limit) case.
228 envelope["error"] = (
229 "GitHub unauthenticated request returned no data (anon rate limit "
230 "or unprocessable query; set GITHUB_TOKEN or run gh auth login)"
231 )
232 return envelope
233
234 raw_items = data.get("items", [])
235 _log(f"Found {len(raw_items)} issues/PRs")
236
237 return {
238 "items": raw_items,
239 "context": {
240 "core": core,
241 "from_date": from_date,
242 "to_date": to_date,
243 "count": count,
244 },
245 }
246
247
248 def parse_github_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
249 """Normalize a ``search_github`` envelope into the skill's item shape.
250
251 Pure function: no I/O, no token, no enrichment. Applies the date
252 filter using the search context and sorts by relevance.
253 """
254 if not isinstance(response, dict):
255 return []
256 raw_items = response.get("items") or []
257 if not isinstance(raw_items, list):
258 return []
259 context = response.get("context") or {}
260 core = context.get("core") or ""
261 from_date = context.get("from_date") or ""
262 to_date = context.get("to_date") or ""
263 count = context.get("count") or DEPTH_LIMITS["default"]
264
265 items: List[Dict[str, Any]] = []
266 for i, item in enumerate(raw_items[:count]):
267 html_url = item.get("html_url", "")
268 repo = _parse_repo_from_url(html_url)
269 title = item.get("title", "")
270 body_text = item.get("body") or ""
271 reactions_total = item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0
272 comment_count = item.get("comments") or 0
273 labels = [
274 lbl.get("name", "") for lbl in (item.get("labels") or [])
275 if isinstance(lbl, dict)
276 ]
277 state = item.get("state", "")
278 is_pr = "pull_request" in item
279 author = item.get("user", {}).get("login", "") if isinstance(item.get("user"), dict) else ""
280
281 relevance = _compute_relevance(core, title, i, reactions_total, comment_count)
282
283 items.append({
284 "id": f"GH{i + 1}",
285 "title": title,
286 "url": html_url,
287 "date": _parse_date(item.get("created_at")),
288 "author": author,
289 "source": "github",
290 "score": reactions_total,
291 "container": repo,
292 "snippet": body_text[:300] if body_text else "",
293 "relevance": relevance,
294 "why_relevant": f"GitHub {'PR' if is_pr else 'issue'}: {title[:60]}",
295 "engagement": {
296 "reactions": reactions_total,
297 "comments": comment_count,
298 },
299 "metadata": {
300 "labels": labels,
301 "state": state,
302 "comment_count": comment_count,
303 "reactions": reactions_total,
304 "is_pr": is_pr,
305 },
306 })
307
308 # Date filter
309 if from_date and to_date:
310 items = [
311 item for item in items
312 if item.get("date") is None or (from_date <= item["date"] <= to_date)
313 ]
314
315 items.sort(key=lambda x: x.get("relevance", 0), reverse=True)
316 return items
317
318
319 def enrich_with_comments(
320 items: List[Dict[str, Any]],
321 depth: str = "default",
322 token: Optional[str] = None,
323 ) -> List[Dict[str, Any]]:
324 """Fetch top comments for top-K items by reactions and attach to metadata.
325
326 Mutates and returns ``items``. Resolves ``token`` via env/gh CLI when
327 not supplied, matching ``search_github``'s fallback chain.
328 """
329 if not items:
330 return items
331 resolved_token = _resolve_token(token)
332 if not resolved_token:
333 _log("No GitHub token available for comment enrichment")
334 return items
335 return _enrich_top_items(items, depth, resolved_token)
336
337
338 def _enrich_top_items(
339 items: List[Dict[str, Any]],
340 depth: str,
341 token: str,
342 ) -> List[Dict[str, Any]]:
343 """Fetch comments for top N items by reactions."""
344 if not items:
345 return items
346
347 limit = ENRICH_LIMITS.get(depth, ENRICH_LIMITS["default"])
348
349 by_reactions = sorted(
350 range(len(items)),
351 key=lambda i: items[i].get("score", 0),
352 reverse=True,
353 )
354 to_enrich = by_reactions[:limit]
355
356 _log(f"Enriching top {len(to_enrich)} items with comments")
357
358 with ThreadPoolExecutor(max_workers=5) as executor:
359 futures = {
360 executor.submit(
361 _fetch_item_comments,
362 items[idx]["url"],
363 token,
364 ): idx
365 for idx in to_enrich
366 }
367
368 for future in as_completed(futures):
369 idx = futures[future]
370 try:
371 comments = future.result(timeout=15)
372 items[idx]["metadata"]["top_comments"] = comments
373 except (KeyError, TypeError, OSError) as exc:
374 _log(f"Comment enrichment failed for {items[idx].get('url', '?')}: {type(exc).__name__}: {exc}")
375 items[idx]["metadata"]["top_comments"] = []
376
377 return items
378
379
380 def _fetch_item_comments(
381 issue_url: str,
382 token: str,
383 max_comments: int = 5,
384 ) -> List[Dict[str, Any]]:
385 """Fetch comments for a GitHub issue/PR.
386
387 Args:
388 issue_url: HTML URL like https://github.com/owner/repo/issues/123
389 token: GitHub auth token
390 max_comments: Max comments to return
391
392 Returns:
393 List of comment dicts with score, excerpt, author.
394 """
395 path = issue_url.replace("https://github.com/", "")
396 path = path.replace("/pull/", "/issues/")
397 api_url = f"https://api.github.com/repos/{path}/comments?per_page={max_comments}&sort=reactions&direction=desc"
398
399 data = _fetch_json(api_url, token=token, timeout=15)
400 if not data or not isinstance(data, list):
401 return []
402
403 comments = []
404 for c in data[:max_comments]:
405 body = c.get("body") or ""
406 excerpt = body[:300] + "..." if len(body) > 300 else body
407 reactions = c.get("reactions", {})
408 reaction_count = reactions.get("total_count", 0) if isinstance(reactions, dict) else 0
409 author = c.get("user", {}).get("login", "") if isinstance(c.get("user"), dict) else ""
410
411 comments.append({
412 "score": reaction_count,
413 "excerpt": excerpt,
414 "author": author,
415 })
416
417 return comments
418
419
420 # ---------------------------------------------------------------------------
421 # Person-mode search: author-scoped queries, star enrichment, release notes
422 # ---------------------------------------------------------------------------
423
424 PERSON_DEPTH_LIMITS = {
425 "quick": {"pr_pages": 1, "own_repos": 3, "external_repos": 5},
426 "default": {"pr_pages": 1, "own_repos": 5, "external_repos": 10},
427 "deep": {"pr_pages": 2, "own_repos": 5, "external_repos": 15},
428 }
429
430 PERSON_EVENTS_PER_PAGE = 100
431
432
433 def _fetch_readme_snippet(repo: str, token: str, max_chars: int = 500) -> Optional[str]:
434 """Fetch README content for a repo, truncated to first ~max_chars."""
435 url = f"https://api.github.com/repos/{repo}/readme"
436 headers = {
437 "User-Agent": USER_AGENT,
438 "Accept": "application/vnd.github.raw+json",
439 }
440 if token:
441 headers["Authorization"] = f"Bearer {token}"
442
443 req = urllib.request.Request(url, headers=headers)
444 try:
445 with urllib.request.urlopen(req, timeout=10) as resp:
446 raw = resp.read().decode("utf-8", errors="replace")
447 except (urllib.error.HTTPError, urllib.error.URLError, OSError, TimeoutError):
448 return None
449
450 if not raw:
451 return None
452 # Try to break at a paragraph boundary
453 if len(raw) <= max_chars:
454 return raw
455 cut = raw[:max_chars]
456 last_double_newline = cut.rfind("\n\n")
457 if last_double_newline > max_chars // 3:
458 return cut[:last_double_newline].rstrip()
459 return cut.rstrip() + "..."
460
461
462 def _fetch_latest_releases(
463 repo: str, token: str, count: int = 3, max_body: int = 300,
464 ) -> List[Dict[str, str]]:
465 """Fetch latest releases for a repo."""
466 url = f"https://api.github.com/repos/{repo}/releases?per_page={count}"
467 data = _fetch_json(url, token=token, timeout=10)
468 if not data or not isinstance(data, list):
469 return []
470 releases = []
471 for r in data[:count]:
472 tag = r.get("tag_name", "")
473 date = _parse_date(r.get("published_at"))
474 body = (r.get("body") or "")[:max_body]
475 name = r.get("name") or tag
476 releases.append({"tag": tag, "name": name, "date": date, "body": body})
477 return releases
478
479
480 def _fetch_top_issues(repo: str, token: str) -> Dict[str, Any]:
481 """Fetch top feature request (by reactions) and top complaint (by comments)."""
482 result: Dict[str, Any] = {}
483
484 # Top feature request: issues with enhancement label, sorted by reactions
485 feat_q = urllib.parse.quote(f"repo:{repo} is:issue is:open label:enhancement")
486 feat_url = f"{SEARCH_URL}?q={feat_q}&sort=reactions&order=desc&per_page=1"
487 feat_data = _fetch_json(feat_url, token=token, timeout=10)
488 if feat_data and feat_data.get("items"):
489 item = feat_data["items"][0]
490 result["top_feature_request"] = {
491 "title": item.get("title", ""),
492 "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
493 "comments": item.get("comments") or 0,
494 "url": item.get("html_url", ""),
495 }
496 elif feat_data and feat_data.get("total_count", 0) == 0:
497 # No enhancement label; fall back to top issue by reactions
498 fallback_q = urllib.parse.quote(f"repo:{repo} is:issue is:open")
499 fallback_url = f"{SEARCH_URL}?q={fallback_q}&sort=reactions&order=desc&per_page=1"
500 fallback_data = _fetch_json(fallback_url, token=token, timeout=10)
501 if fallback_data and fallback_data.get("items"):
502 item = fallback_data["items"][0]
503 result["top_feature_request"] = {
504 "title": item.get("title", ""),
505 "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
506 "comments": item.get("comments") or 0,
507 "url": item.get("html_url", ""),
508 }
509
510 # Top complaint: most-discussed open issue (by comments)
511 bug_q = urllib.parse.quote(f"repo:{repo} is:issue is:open")
512 bug_url = f"{SEARCH_URL}?q={bug_q}&sort=comments&order=desc&per_page=1"
513 bug_data = _fetch_json(bug_url, token=token, timeout=10)
514 if bug_data and bug_data.get("items"):
515 item = bug_data["items"][0]
516 result["top_complaint"] = {
517 "title": item.get("title", ""),
518 "reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
519 "comments": item.get("comments") or 0,
520 "url": item.get("html_url", ""),
521 }
522
523 return result
524
525
526 def _fetch_repo_info(repo: str, token: str) -> Optional[Dict[str, Any]]:
527 """Fetch repo metadata (stars, forks, description, language)."""
528 url = f"https://api.github.com/repos/{repo}"
529 data = _fetch_json(url, token=token, timeout=10)
530 if not data or not isinstance(data, dict):
531 return None
532 return {
533 "stars": data.get("stargazers_count", 0),
534 "forks": data.get("forks_count", 0),
535 "description": (data.get("description") or "")[:200],
536 "language": data.get("language") or "",
537 "open_issues": data.get("open_issues_count", 0),
538 }
539
540
541 def _format_stars(n: int) -> str:
542 """Format star count as human-readable (e.g., 349K, 2.9K, 42)."""
543 if n >= 1_000_000:
544 return f"{n / 1_000_000:.1f}M"
545 if n >= 1_000:
546 return f"{n / 1_000:.0f}K" if n >= 10_000 else f"{n / 1_000:.1f}K"
547 return str(n)
548
549
550 def refetch_datum(item: schema.SourceItem | None, datum_key: str) -> dict[str, Any]:
551 """Re-fetch one repository counter through the shared HTTP wrapper.
552
553 ``datum_key`` is either the literal ``"stars"`` (item-level claim; the
554 repo derives from the grounding item) or an ``owner/repo`` slug
555 (candidate-enrichment claim; the repo itself is the refetch subject and
556 the item is not consulted, so it may be ``None``).
557 """
558 if re.fullmatch(r"[^/\s]+/[^/\s]+", datum_key):
559 repo = datum_key
560 elif datum_key != "stars":
561 raise KeyError(f"Unsupported GitHub datum: {datum_key}")
562 else:
563 if item is None:
564 raise ValueError("Item-level star refetch requires the grounding item")
565 repo = item.container or ""
566 if not re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
567 match = re.match(r"https?://github\.com/([^/]+/[^/#?]+)", item.url)
568 repo = match.group(1).removesuffix(".git") if match else ""
569 if not repo:
570 raise ValueError("GitHub item has no owner/repository reference")
571 headers = {"Accept": "application/vnd.github+json"}
572 token = _resolve_token()
573 if token:
574 headers["Authorization"] = f"Bearer {token}"
575 data = http.request(
576 "GET", f"https://api.github.com/repos/{repo}",
577 headers=headers, timeout=10, retries=2,
578 )
579 if not isinstance(data, dict) or not isinstance(data.get("stargazers_count"), int):
580 raise KeyError("GitHub star count was not returned")
581 fallback_url = item.url if item is not None else f"https://github.com/{repo}"
582 return {
583 "value": data["stargazers_count"],
584 "url": str(data.get("html_url") or fallback_url),
585 "timestamp": data.get("updated_at"),
586 }
587
588
589 def search_github_person(
590 username: str,
591 from_date: str,
592 to_date: str,
593 depth: str = "default",
594 token: Optional[str] = None,
595 ) -> List[Dict[str, Any]]:
596 """Person-mode GitHub search: author-scoped queries with star enrichment.
597
598 Returns SourceItems for:
599 - 1 velocity summary item
600 - Per-repo items for top external repos (with stars + release notes)
601 - Per-repo items for own repos (with stars + README + top issues + releases)
602 """
603 resolved_token = _resolve_token(token)
604 if not resolved_token:
605 _log("No GitHub token available for person-mode search")
606 return []
607
608 limits = PERSON_DEPTH_LIMITS.get(depth, PERSON_DEPTH_LIMITS["default"])
609 _log(f"Person-mode search for @{username} (since {from_date})")
610
611 # Phase 1: PR velocity via search API
612 total_q = urllib.parse.quote(f"author:{username} type:pr created:>{from_date}")
613 merged_q = urllib.parse.quote(f"author:{username} type:pr is:merged created:>{from_date}")
614
615 total_url = f"{SEARCH_URL}?q={total_q}&per_page=1"
616 merged_url = f"{SEARCH_URL}?q={merged_q}&sort=reactions&order=desc&per_page=100"
617
618 total_data = _fetch_json(total_url, token=resolved_token, timeout=20)
619 merged_data = _fetch_json(merged_url, token=resolved_token, timeout=20)
620
621 total_prs = total_data.get("total_count", 0) if total_data else 0
622 merged_count = merged_data.get("total_count", 0) if merged_data else 0
623 merged_items = merged_data.get("items", []) if merged_data else []
624
625 _log(f"Found {total_prs} total PRs, {merged_count} merged")
626
627 if total_prs == 0 and merged_count == 0:
628 # An empty PR search can mean no PRs in the window or an account that
629 # GitHub's issue index cannot search. Public PushEvents provide an
630 # actor-attributed fallback for either case.
631 search_unavailable = total_data is None or merged_data is None
632 recent = _person_recent_pushes(
633 username, from_date, to_date, limits, resolved_token,
634 )
635 if recent:
636 reason = "account not searchable" if search_unavailable else "no PRs in window"
637 _log(f"PR search empty ({reason}); public events returned {len(recent)} items")
638 return recent
639 _log("No PRs found, falling back to keyword search")
640 return []
641
642 # Phase 2: Group merged PRs by repo
643 repo_pr_counts: Dict[str, int] = {}
644 for item in merged_items:
645 repo = _parse_repo_from_url(item.get("html_url", ""))
646 if repo:
647 repo_pr_counts[repo] = repo_pr_counts.get(repo, 0) + 1
648
649 # Sort repos by PR count (most active first)
650 sorted_repos = sorted(repo_pr_counts.items(), key=lambda x: x[1], reverse=True)
651
652 # Phase 3: Fetch own repos
653 own_repos_url = f"https://api.github.com/users/{username}/repos?sort=stars&per_page={limits['own_repos']}&direction=desc"
654 own_repos_data = _fetch_json(own_repos_url, token=resolved_token, timeout=15)
655 own_repo_names = set()
656 own_repos_info: List[Dict[str, Any]] = []
657 if own_repos_data and isinstance(own_repos_data, list):
658 for r in own_repos_data:
659 full_name = r.get("full_name", "")
660 if full_name and not r.get("fork"):
661 own_repo_names.add(full_name)
662 own_repos_info.append({
663 "full_name": full_name,
664 "stars": r.get("stargazers_count", 0),
665 "forks": r.get("forks_count", 0),
666 "description": (r.get("description") or "")[:200],
667 "language": r.get("language") or "",
668 "open_issues": r.get("open_issues_count", 0),
669 })
670
671 # Separate external repos from own repos
672 external_repos = [(repo, count) for repo, count in sorted_repos if repo not in own_repo_names]
673 external_repos = external_repos[:limits["external_repos"]]
674
675 # Phase 4: Parallel enrichment (star counts, releases, READMEs, top issues)
676 items: List[Dict[str, Any]] = []
677 idx = 0
678
679 # Build velocity summary
680 open_prs = total_prs - merged_count
681 merge_rate = round(100 * merged_count / total_prs) if total_prs > 0 else 0
682 num_repos = len(repo_pr_counts)
683 velocity_text = (
684 f"GitHub Person Profile: @{username}\n\n"
685 f"CONTRIBUTION VELOCITY (last {(to_date > from_date) and 30 or 30} days)\n"
686 f"- {merged_count} PRs merged across {num_repos} repos ({merge_rate}% merge rate)\n"
687 f"- {total_prs} total PRs submitted, {open_prs} still open\n"
688 )
689
690 idx += 1
691 items.append({
692 "id": f"GH{idx}",
693 "title": f"@{username}: {merged_count} PRs merged across {num_repos} repos ({merge_rate}% merge rate)",
694 "url": f"https://github.com/{username}",
695 "date": to_date,
696 "author": username,
697 "source": "github",
698 "score": merged_count,
699 "container": f"@{username}",
700 "snippet": velocity_text,
701 "relevance": 0.95,
702 "why_relevant": f"GitHub profile: @{username} - {merged_count} PRs merged across {num_repos} repos",
703 "engagement": {"merged_prs": merged_count, "comments": total_prs},
704 "metadata": {
705 "labels": ["person-profile", "velocity"],
706 "state": "open",
707 "comment_count": 0,
708 "reactions": merged_count,
709 "is_pr": False,
710 },
711 })
712
713 # Phase 5: Enrich external repos (parallel: star counts + releases)
714 _log(f"Enriching {len(external_repos)} external repos + {len(own_repos_info)} own repos")
715
716 with ThreadPoolExecutor(max_workers=8) as executor:
717 # External repo enrichment: stars + releases
718 ext_futures = {}
719 for repo, pr_count in external_repos:
720 ext_futures[executor.submit(_enrich_external_repo, repo, resolved_token)] = (repo, pr_count)
721
722 # Own repo enrichment: README + releases + top issues
723 own_futures = {}
724 for own_repo in own_repos_info:
725 own_futures[executor.submit(_enrich_own_repo, own_repo["full_name"], resolved_token)] = own_repo
726
727 # Collect external repo results
728 for future in as_completed(ext_futures):
729 repo, pr_count = ext_futures[future]
730 try:
731 enrichment = future.result(timeout=20)
732 except Exception as exc:
733 _log(f"External repo enrichment failed for {repo}: {exc}")
734 enrichment = {}
735
736 repo_info = enrichment.get("info")
737 releases = enrichment.get("releases", [])
738
739 stars = repo_info["stars"] if repo_info else 0
740 stars_str = _format_stars(stars)
741 desc = repo_info["description"] if repo_info else ""
742
743 snippet_parts = [f"Contributed {pr_count} merged PRs to {repo} ({stars_str} stars)"]
744 if desc:
745 snippet_parts.append(f" {desc}")
746 if releases:
747 for rel in releases[:2]:
748 body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
749 snippet_parts.append(f" Latest release: {rel['name']} ({rel['date']}){body_preview}")
750
751 idx += 1
752 items.append({
753 "id": f"GH{idx}",
754 "title": f"{repo} ({stars_str} stars) - {pr_count} PRs merged",
755 "url": f"https://github.com/{repo}",
756 "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
757 "author": username,
758 "source": "github",
759 "score": stars,
760 "container": repo,
761 "snippet": "\n".join(snippet_parts),
762 "relevance": min(0.9, 0.6 + math.log1p(stars) / 30 + min(0.15, pr_count / 20)),
763 "why_relevant": f"GitHub contribution: {pr_count} PRs merged to {repo} ({stars_str} stars)",
764 "engagement": {"stars": stars, "comments": pr_count},
765 "metadata": {
766 "labels": ["person-profile", "external-repo"],
767 "state": "open",
768 "comment_count": pr_count,
769 "reactions": stars,
770 "is_pr": False,
771 },
772 })
773
774 # Collect own repo results
775 for future in as_completed(own_futures):
776 own_repo = own_futures[future]
777 try:
778 enrichment = future.result(timeout=25)
779 except Exception as exc:
780 _log(f"Own repo enrichment failed for {own_repo['full_name']}: {exc}")
781 enrichment = {}
782
783 repo_name = own_repo["full_name"]
784 stars = own_repo["stars"]
785 stars_str = _format_stars(stars)
786 open_issues = own_repo["open_issues"]
787 desc = own_repo["description"]
788
789 readme = enrichment.get("readme")
790 releases = enrichment.get("releases", [])
791 top_issues = enrichment.get("top_issues", {})
792
793 snippet_parts = [f"Own project: {repo_name} ({stars_str} stars, {open_issues} open issues)"]
794 if desc:
795 snippet_parts.append(f" {desc}")
796 if readme:
797 snippet_parts.append(f" README: {readme[:300]}")
798 if releases:
799 for rel in releases[:2]:
800 body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
801 snippet_parts.append(f" Latest release: {rel['name']} ({rel['date']}){body_preview}")
802 feat = top_issues.get("top_feature_request")
803 if feat:
804 snippet_parts.append(f" Top feature request: \"{feat['title']}\" ({feat['reactions']} reactions, {feat['comments']} comments)")
805 complaint = top_issues.get("top_complaint")
806 if complaint:
807 snippet_parts.append(f" Top complaint: \"{complaint['title']}\" ({complaint['comments']} comments)")
808
809 idx += 1
810 items.append({
811 "id": f"GH{idx}",
812 "title": f"{repo_name} ({stars_str} stars) - own project, {open_issues} open issues",
813 "url": f"https://github.com/{repo_name}",
814 "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
815 "author": username,
816 "source": "github",
817 "score": stars,
818 "container": repo_name,
819 "snippet": "\n".join(snippet_parts),
820 "relevance": min(0.95, 0.7 + math.log1p(stars) / 25),
821 "why_relevant": f"GitHub own project: {repo_name} ({stars_str} stars)",
822 "engagement": {"stars": stars, "comments": open_issues},
823 "metadata": {
824 "labels": ["person-profile", "own-repo"],
825 "state": "open",
826 "comment_count": open_issues,
827 "reactions": stars,
828 "is_pr": False,
829 },
830 })
831
832 # Sort by relevance
833 items.sort(key=lambda x: x.get("relevance", 0), reverse=True)
834 _log(f"Person-mode returned {len(items)} items")
835 return items
836
837
838 def _person_recent_pushes(
839 username: str,
840 from_date: str,
841 to_date: str,
842 limits: Dict[str, int],
843 token: str,
844 ) -> List[Dict[str, Any]]:
845 """Return repos the selected actor publicly pushed inside the window."""
846 latest_by_repo: Dict[str, Dict[str, str]] = {}
847 encoded_username = urllib.parse.quote(username, safe="")
848
849 page = 1
850 while True:
851 url = (
852 f"https://api.github.com/users/{encoded_username}/events/public"
853 f"?per_page={PERSON_EVENTS_PER_PAGE}&page={page}"
854 )
855 data = _fetch_json(url, token=token, timeout=15)
856 if not data or not isinstance(data, list):
857 break
858
859 reached_before_window = False
860 for event in data:
861 created_at = event.get("created_at")
862 pushed = _parse_date(created_at)
863 if not pushed:
864 continue
865 if pushed < from_date:
866 reached_before_window = True
867 break
868 if pushed > to_date or event.get("type") != "PushEvent":
869 continue
870
871 actor = event.get("actor")
872 actor_login = actor.get("login", "") if isinstance(actor, dict) else ""
873 if actor_login.casefold() != username.casefold():
874 continue
875
876 repo = event.get("repo")
877 full_name = repo.get("name", "") if isinstance(repo, dict) else ""
878 if not re.fullmatch(r"[^/\s]+/[^/\s]+", full_name):
879 continue
880
881 previous = latest_by_repo.get(full_name)
882 if previous is None or created_at > previous["created_at"]:
883 latest_by_repo[full_name] = {
884 "full_name": full_name,
885 "pushed": pushed,
886 "created_at": created_at,
887 "actor": actor_login,
888 "event_id": str(event.get("id") or ""),
889 }
890
891 if reached_before_window or len(data) < PERSON_EVENTS_PER_PAGE:
892 break
893 page += 1
894
895 if not latest_by_repo:
896 return []
897
898 recent = sorted(
899 latest_by_repo.values(),
900 key=lambda r: r["created_at"],
901 reverse=True,
902 )
903 _log(
904 f"Public events: {len(recent)} actor-attributed repos pushed in window, "
905 "loading repository metadata for ranking"
906 )
907
908 repo_info: Dict[str, Dict[str, Any]] = {}
909 with ThreadPoolExecutor(max_workers=8) as executor:
910 info_futures = {
911 executor.submit(_fetch_repo_info, r["full_name"], token): r["full_name"]
912 for r in recent
913 }
914 for future in as_completed(info_futures):
915 name = info_futures[future]
916 try:
917 repo_info[name] = future.result(timeout=20) or {}
918 except Exception as exc:
919 _log(f"Push-event repo metadata failed for {name}: {exc}")
920 repo_info[name] = {}
921
922 recent.sort(
923 key=lambda r: (
924 repo_info.get(r["full_name"], {}).get("stars", 0),
925 r["created_at"],
926 ),
927 reverse=True,
928 )
929 selected = recent[:limits["own_repos"]]
930
931 enrichments: Dict[str, Dict[str, Any]] = {}
932 _log(f"Public events: enriching {len(selected)} top-ranked repositories")
933 with ThreadPoolExecutor(max_workers=8) as executor:
934 enrichment_futures = {
935 executor.submit(_enrich_own_repo, r["full_name"], token): r["full_name"]
936 for r in selected
937 }
938 for future in as_completed(enrichment_futures):
939 name = enrichment_futures[future]
940 try:
941 enrichments[name] = future.result(timeout=25)
942 except Exception as exc:
943 _log(f"Push-event enrichment failed for {name}: {exc}")
944 enrichments[name] = {}
945
946 items: List[Dict[str, Any]] = []
947 for idx, repo in enumerate(selected, start=1):
948 name = repo["full_name"]
949 info = repo_info.get(name, {})
950 stars = info.get("stars", 0)
951 stars_str = _format_stars(stars)
952 open_issues = info.get("open_issues", 0)
953 enrichment = enrichments.get(name, {})
954 readme = enrichment.get("readme")
955 releases = enrichment.get("releases", [])
956
957 snippet_parts = [
958 f"@{repo['actor']} pushed {name} on {repo['pushed']} "
959 f"({stars_str} stars, {open_issues} open issues)"
960 ]
961 if info.get("description"):
962 snippet_parts.append(f" {info['description']}")
963 if readme:
964 snippet_parts.append(f" README: {readme[:300]}")
965 for rel in releases[:2]:
966 body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
967 snippet_parts.append(f" Release: {rel['name']} ({rel['date']}){body_preview}")
968
969 items.append({
970 "id": f"GH{idx}",
971 "title": f"@{repo['actor']} pushed {name} on {repo['pushed']}",
972 "url": f"https://github.com/{name}",
973 "date": repo["pushed"],
974 "author": repo["actor"],
975 "source": "github",
976 "score": stars,
977 "container": name,
978 "snippet": "\n".join(snippet_parts),
979 "relevance": min(0.9, 0.6 + math.log1p(stars) / 30),
980 "why_relevant": (
981 f"GitHub activity: @{repo['actor']} pushed {name} on {repo['pushed']} "
982 f"({stars_str} stars)"
983 ),
984 "engagement": {"stars": stars, "comments": open_issues},
985 "metadata": {
986 "labels": ["person-profile", "recent-push"],
987 "state": "open",
988 "comment_count": open_issues,
989 "reactions": stars,
990 "is_pr": False,
991 "event_type": "PushEvent",
992 "event_id": repo["event_id"],
993 },
994 })
995
996 return items
997
998
999 def _enrich_external_repo(repo: str, token: str) -> Dict[str, Any]:
1000 """Fetch star count + releases for an external repo."""
1001 info = _fetch_repo_info(repo, token)
1002 releases = _fetch_latest_releases(repo, token, count=3)
1003 return {"info": info, "releases": releases}
1004
1005
1006 def _enrich_own_repo(repo: str, token: str) -> Dict[str, Any]:
1007 """Fetch README + releases + top issues for an own repo."""
1008 readme = _fetch_readme_snippet(repo, token, max_chars=500)
1009 releases = _fetch_latest_releases(repo, token, count=3)
1010 top_issues = _fetch_top_issues(repo, token)
1011 return {"readme": readme, "releases": releases, "top_issues": top_issues}
1012
1013
1014 # ---------------------------------------------------------------------------
1015 # Project-mode search: fetch comprehensive data for specific repos
1016 # ---------------------------------------------------------------------------
1017
1018 def search_github_project(
1019 repos: List[str],
1020 from_date: str,
1021 to_date: str,
1022 depth: str = "default",
1023 token: Optional[str] = None,
1024 ) -> List[Dict[str, Any]]:
1025 """Project-mode GitHub search: fetch stars, README, releases, top issues for repos.
1026
1027 Args:
1028 repos: List of 'owner/repo' strings.
1029 from_date: Start date (YYYY-MM-DD).
1030 to_date: End date (YYYY-MM-DD).
1031 depth: 'quick', 'default', or 'deep'.
1032 token: Optional GitHub token.
1033
1034 Returns:
1035 List of SourceItems, one per repo.
1036 """
1037 resolved_token = _resolve_token(token)
1038 if not resolved_token:
1039 _log("No GitHub token available for project-mode search")
1040 return []
1041
1042 _log(f"Project-mode search for {len(repos)} repos: {', '.join(repos)}")
1043
1044 items: List[Dict[str, Any]] = []
1045
1046 with ThreadPoolExecutor(max_workers=min(8, len(repos))) as executor:
1047 futures = {
1048 executor.submit(_enrich_project_repo, repo, resolved_token): repo
1049 for repo in repos
1050 }
1051
1052 for idx, future in enumerate(as_completed(futures)):
1053 repo = futures[future]
1054 try:
1055 enrichment = future.result(timeout=25)
1056 except Exception as exc:
1057 _log(f"Project enrichment failed for {repo}: {exc}")
1058 continue
1059
1060 info = enrichment.get("info")
1061 if not info:
1062 _log(f"No repo info for {repo}, skipping")
1063 continue
1064
1065 readme = enrichment.get("readme")
1066 releases = enrichment.get("releases", [])
1067 top_issues = enrichment.get("top_issues", {})
1068
1069 stars = info["stars"]
1070 stars_str = _format_stars(stars)
1071 open_issues = info["open_issues"]
1072 desc = info["description"]
1073 lang = info["language"]
1074
1075 snippet_parts = [f"Project: {repo} ({stars_str} stars, {open_issues} open issues, {lang})"]
1076 if desc:
1077 snippet_parts.append(f" {desc}")
1078 if readme:
1079 snippet_parts.append(f" README: {readme[:400]}")
1080 if releases:
1081 for rel in releases[:2]:
1082 body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
1083 snippet_parts.append(f" Latest release: {rel['name']} ({rel['date']}){body_preview}")
1084 feat = top_issues.get("top_feature_request")
1085 if feat:
1086 snippet_parts.append(f" Top feature request: \"{feat['title']}\" ({feat['reactions']} reactions, {feat['comments']} comments)")
1087 complaint = top_issues.get("top_complaint")
1088 if complaint:
1089 snippet_parts.append(f" Top complaint: \"{complaint['title']}\" ({complaint['comments']} comments)")
1090
1091 items.append({
1092 "id": f"GH{idx + 1}",
1093 "title": f"{repo} ({stars_str} stars) - {open_issues} open issues",
1094 "url": f"https://github.com/{repo}",
1095 "date": releases[0]["date"] if releases and releases[0].get("date") else to_date,
1096 "author": repo.split("/")[0],
1097 "source": "github",
1098 "score": stars,
1099 "container": repo,
1100 "snippet": "\n".join(snippet_parts),
1101 "relevance": min(0.95, 0.7 + math.log1p(stars) / 25),
1102 "why_relevant": f"GitHub project: {repo} ({stars_str} stars, live)",
1103 "engagement": {"stars": stars, "comments": open_issues},
1104 "metadata": {
1105 "labels": ["project-mode"],
1106 "state": "open",
1107 "comment_count": open_issues,
1108 "reactions": stars,
1109 "is_pr": False,
1110 "github_stars": {repo: stars},
1111 },
1112 })
1113
1114 items.sort(key=lambda x: x.get("relevance", 0), reverse=True)
1115 _log(f"Project-mode returned {len(items)} items")
1116 return items
1117
1118
1119 def _enrich_project_repo(repo: str, token: str) -> Dict[str, Any]:
1120 """Fetch all project data for a repo: info + README + releases + top issues."""
1121 info = _fetch_repo_info(repo, token)
1122 readme = _fetch_readme_snippet(repo, token, max_chars=500)
1123 releases = _fetch_latest_releases(repo, token, count=3)
1124 top_issues = _fetch_top_issues(repo, token)
1125 return {"info": info, "readme": readme, "releases": releases, "top_issues": top_issues}
1126
1127
1128 # ---------------------------------------------------------------------------
1129 # Post-rerank star enrichment: annotate candidates with live star counts
1130 # ---------------------------------------------------------------------------
1131
1132 _REPO_URL_PATTERN = re.compile(r"github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)")
1133 _SKIP_PATHS = {"topics", "search", "orgs", "settings", "features", "about", "pricing", "enterprise", "explore", "marketplace", "sponsors"}
1134
1135
1136 def extract_repo_refs(candidates: List[Any]) -> List[str]:
1137 """Extract unique owner/repo strings from candidate URLs, titles, and snippets."""
1138 seen: set = set()
1139 repos: List[str] = []
1140 for c in candidates:
1141 texts = [
1142 getattr(c, "url", "") or "",
1143 getattr(c, "title", "") or "",
1144 ]
1145 # Also check evidence snippets if available
1146 evidence = getattr(c, "evidence", None)
1147 if evidence:
1148 texts.append(str(evidence))
1149 for text in texts:
1150 for match in _REPO_URL_PATTERN.findall(text):
1151 # Normalize: strip trailing .git, lowercase
1152 repo = match.rstrip(".git").lower()
1153 owner = repo.split("/")[0]
1154 if owner in _SKIP_PATHS:
1155 continue
1156 if repo not in seen:
1157 seen.add(repo)
1158 repos.append(match) # preserve original case
1159 return repos
1160
1161
1162 def enrich_candidates_with_stars(
1163 candidates: List[Any],
1164 token: Optional[str] = None,
1165 already_enriched: Optional[set] = None,
1166 max_repos: int = 10,
1167 collect_map: Optional[Dict[str, int]] = None,
1168 ) -> int:
1169 """Annotate candidates with live GitHub star counts.
1170
1171 Returns the number of repos enriched.
1172 """
1173 resolved_token = _resolve_token(token)
1174 if not resolved_token:
1175 return 0
1176
1177 refs = extract_repo_refs(candidates)
1178 if not refs:
1179 return 0
1180
1181 skip = already_enriched or set()
1182 to_fetch = [r for r in refs if r.lower() not in {s.lower() for s in skip}][:max_repos]
1183 if not to_fetch:
1184 return 0
1185
1186 _log(f"Star enrichment: fetching {len(to_fetch)} repos")
1187
1188 # Parallel fetch star counts
1189 star_map: Dict[str, int] = {}
1190 with ThreadPoolExecutor(max_workers=min(8, len(to_fetch))) as executor:
1191 futures = {executor.submit(_fetch_repo_info, repo, resolved_token): repo for repo in to_fetch}
1192 for future in as_completed(futures):
1193 repo = futures[future]
1194 try:
1195 info = future.result(timeout=10)
1196 if info:
1197 star_map[repo.lower()] = info["stars"]
1198 except Exception:
1199 pass
1200
1201 if collect_map is not None:
1202 collect_map.update(star_map)
1203 if not star_map:
1204 return 0
1205
1206 return apply_star_map(candidates, star_map)
1207
1208
1209 def apply_star_map(candidates: List[Any], star_map: Dict[str, int]) -> int:
1210 """Annotate candidates from a repo->stars map (fetch/apply split).
1211
1212 Split out so offline replay (the eval harness) can apply a recorded map
1213 without any network or gh-credential access.
1214 """
1215 if not star_map:
1216 return 0
1217 # Annotate candidates
1218 enriched_count = 0
1219 for c in candidates:
1220 texts = [getattr(c, "url", "") or "", getattr(c, "title", "") or ""]
1221 evidence = getattr(c, "evidence", None)
1222 if evidence:
1223 texts.append(str(evidence))
1224 combined = " ".join(texts)
1225 for match in _REPO_URL_PATTERN.findall(combined):
1226 repo_lower = match.rstrip(".git").lower()
1227 if repo_lower in star_map:
1228 stars = star_map[repo_lower]
1229 stars_str = _format_stars(stars)
1230 # Add to metadata
1231 if not hasattr(c, "metadata") or c.metadata is None:
1232 continue
1233 if "github_stars" not in c.metadata:
1234 c.metadata["github_stars"] = {}
1235 c.metadata["github_stars"][match] = stars
1236 # Append to evidence if present
1237 if hasattr(c, "evidence") and c.evidence and f"(live:" not in c.evidence:
1238 c.evidence = c.evidence + f" (live: {stars_str} stars)"
1239 enriched_count += 1
1240 break # one annotation per candidate
1241
1242 _log(f"Star enrichment: annotated {enriched_count} candidates")
1243 return enriched_count
1244
1244 lines PYTHON