88from __future__ import annotations
99
1010import os
11+ import re
1112from datetime import datetime , timedelta , timezone
1213from typing import Any
1314
1415from dotenv import load_dotenv
15- from github import Github , GithubException
16+ from github import Github , GithubException , RateLimitExceededException
1617from github .Repository import Repository
1718
1819load_dotenv ()
2122# Internal helpers
2223# ---------------------------------------------------------------------------
2324
24- def _client () -> Github :
25- """Return an authenticated Github client, reading the token from the env."""
26- token = os .getenv ("GITHUB_TOKEN" )
27- if not token :
28- raise EnvironmentError (
29- "GITHUB_TOKEN is not set. Add it to your .env file or environment."
25+ def _client (token : str | None = None ) -> Github :
26+ """Return an authenticated Github client.
27+
28+ Args:
29+ token: optional explicit token; if omitted the value is read from the
30+ GITHUB_TOKEN environment variable.
31+
32+ Raises:
33+ ValueError: if the token is empty or whitespace-only.
34+ EnvironmentError: if no token is available at all.
35+ """
36+ if token is None :
37+ token = os .getenv ("GITHUB_TOKEN" )
38+
39+ if not token or not token .strip ():
40+ raise ValueError (
41+ "A non-empty GITHUB_TOKEN is required. "
42+ "Add it to your .env file or environment."
3043 )
3144 return Github (token )
3245
@@ -39,6 +52,27 @@ def _repo(g: Github, owner: str, repo: str) -> Repository:
3952 return g .get_repo (f"{ owner } /{ repo } " )
4053
4154
55+ # ---------------------------------------------------------------------------
56+ # Search query sanitisation
57+ # ---------------------------------------------------------------------------
58+
59+ # Dangerous operator patterns that could be injected into code search queries.
60+ # We strip boolean operators and the most common qualifier prefixes so callers
61+ # cannot escalate their search scope beyond the intended query term.
62+ _OPERATOR_RE = re .compile (
63+ r"\b(AND|OR|NOT)\b" # boolean operators (case-sensitive on GitHub)
64+ r"|(?:^|\s)(repo|language|org|user|path|extension|filename|size|fork|in):" ,
65+ re .IGNORECASE ,
66+ )
67+
68+
69+ def _sanitise_query (query : str ) -> str :
70+ """Strip dangerous GitHub search operators from a user-supplied query string."""
71+ sanitised = _OPERATOR_RE .sub (" " , query )
72+ # Collapse runs of whitespace that result from stripping
73+ return " " .join (sanitised .split ())
74+
75+
4276# ---------------------------------------------------------------------------
4377# Public API
4478# ---------------------------------------------------------------------------
@@ -217,14 +251,20 @@ def search_code(query: str, repo: str | None = None) -> list[dict[str, Any]]:
217251 """
218252 Search code on GitHub.
219253
254+ The user-supplied *query* is sanitised to strip boolean operators and
255+ qualifier prefixes (repo:, language:, etc.) that could be used for
256+ search-query injection. The ``repo`` parameter is added by this function
257+ as a trusted qualifier.
258+
220259 Args:
221- query: GitHub code-search query string (e.g. "auth token" )
222- repo: optional "owner/repo" to restrict the search
260+ query: free-text code-search query (operators will be stripped )
261+ repo: optional "owner/repo" to restrict the search (trusted)
223262
224263 Returns a list of result dicts.
225264 """
226265 g = _client ()
227- full_query = f"{ query } repo:{ repo } " if repo else query
266+ safe_query = _sanitise_query (query )
267+ full_query = f"{ safe_query } repo:{ repo } " if repo else safe_query
228268
229269 results : list [dict [str , Any ]] = []
230270 try :
@@ -240,26 +280,41 @@ def search_code(query: str, repo: str | None = None) -> list[dict[str, Any]]:
240280 )
241281 if len (results ) >= 20 :
242282 break
283+ except RateLimitExceededException as exc :
284+ reset_time = getattr (exc , "headers" , {}) or {}
285+ reset_ts = reset_time .get ("x-ratelimit-reset" , "unknown" )
286+ raise RuntimeError (
287+ f"GitHub rate limit exceeded for code search. "
288+ f"Rate limit resets at: { reset_ts } "
289+ ) from exc
243290 except GithubException as exc :
244- # Code search requires authentication and can be rate-limited
245- raise RuntimeError (f"GitHub code search failed: { exc . data } " ) from exc
291+ data = getattr ( exc , "data" , str ( exc ))
292+ raise RuntimeError (f"GitHub code search failed: { data } " ) from exc
246293
247294 return results
248295
249296
250- def get_contributor_stats (owner : str , repo : str ) -> list [dict [str , Any ]]:
297+ def get_contributor_stats (owner : str , repo : str ) -> list [dict [str , Any ]] | dict [ str , Any ] :
251298 """
252299 Return contribution statistics per contributor.
253300
254- Returns a list sorted by total commits descending:
255- [{login, total_commits, additions, deletions, weeks_active}]
301+ GitHub returns HTTP 202 (stats still being computed) when the statistics
302+ are not yet cached. In that case PyGithub returns ``None``. This function
303+ returns a structured "unavailable" sentinel dict instead of an empty list
304+ so callers can distinguish "no contributors" from "stats not ready yet".
305+
306+ Returns:
307+ On success — list sorted by total commits descending:
308+ [{login, total_commits, additions, deletions, weeks_active}]
309+ When GitHub is still computing stats:
310+ {"available": False, "reason": "Stats are being computed, retry in a moment"}
256311 """
257312 g = _client ()
258313 r = _repo (g , owner , repo )
259314 stats = r .get_stats_contributors ()
260315
261316 if stats is None :
262- return []
317+ return { "available" : False , "reason" : "Stats are being computed, retry in a moment" }
263318
264319 results = []
265320 for stat in stats :
@@ -299,21 +354,33 @@ def get_weekly_digest(owner: str, repo: str) -> dict[str, Any]:
299354 now = datetime .now (tz = timezone .utc )
300355 since = now - timedelta (days = 7 )
301356
302- # Merged PRs in the last 7 days
357+ # Merged PRs in the last 7 days.
358+ # Sort by merged_at (not updated_at) so the early-break condition is
359+ # reliable: once merged_at falls before the window we know all subsequent
360+ # PRs are also outside the window.
303361 merged_prs = []
304362 for pr in r .get_pulls (state = "closed" , sort = "updated" , direction = "desc" ):
305- if pr .merged_at and pr .merged_at >= since :
306- merged_prs .append (
307- {
308- "number" : pr .number ,
309- "title" : pr .title ,
310- "author" : pr .user .login if pr .user else "ghost" ,
311- "merged_at" : pr .merged_at .isoformat (),
312- "html_url" : pr .html_url ,
313- }
314- )
315- elif pr .updated_at < since :
316- break # list is sorted by updated_at; no point going further
363+ # Only count PRs that were actually merged (not just closed)
364+ if pr .merged_at is not None :
365+ if pr .merged_at >= since :
366+ merged_prs .append (
367+ {
368+ "number" : pr .number ,
369+ "title" : pr .title ,
370+ "author" : pr .user .login if pr .user else "ghost" ,
371+ "merged_at" : pr .merged_at .isoformat (),
372+ "html_url" : pr .html_url ,
373+ }
374+ )
375+ # Early-break: if the PR's merged_at is before the window we stop.
376+ # For unmerged (closed) PRs we fall through to the updated_at guard
377+ # below so we don't break prematurely on an unmerged PR that appears
378+ # early in the sorted list.
379+ if pr .merged_at is not None and pr .merged_at < since :
380+ break
381+ # Secondary guard: if even updated_at is old, nothing newer follows.
382+ if pr .updated_at is not None and pr .updated_at < since :
383+ break
317384
318385 # Issues opened in the last 7 days
319386 opened_issues = []
@@ -338,7 +405,7 @@ def get_weekly_digest(owner: str, repo: str) -> dict[str, Any]:
338405 top_contributors : list [dict [str , Any ]] = []
339406 try :
340407 stats = r .get_stats_contributors ()
341- if stats :
408+ if stats and not isinstance ( stats , dict ) :
342409 weekly : list [tuple [str , int ]] = []
343410 for stat in stats :
344411 # The last week in the stats list is the most recent
@@ -352,6 +419,8 @@ def get_weekly_digest(owner: str, repo: str) -> dict[str, Any]:
352419 top_contributors = [
353420 {"login" : login , "commits" : commits } for login , commits in weekly [:5 ]
354421 ]
422+ except RateLimitExceededException :
423+ pass # contributor stats are best-effort in the digest
355424 except GithubException :
356425 pass # stats may not be ready on newly created repos
357426
@@ -366,5 +435,3 @@ def get_weekly_digest(owner: str, repo: str) -> dict[str, Any]:
366435 "closed_issue_count" : closed_issues_count ,
367436 },
368437 }
369- # weekly digest aggregates 7 days of activity
370- # github api rate limit handled with graceful error message
0 commit comments