Skip to content

Commit c7b4481

Browse files
committed
Prod readiness fixes + tests
1 parent 4b2d8d1 commit c7b4481

4 files changed

Lines changed: 808 additions & 179 deletions

File tree

src/github_client.py

Lines changed: 99 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
from __future__ import annotations
99

1010
import os
11+
import re
1112
from datetime import datetime, timedelta, timezone
1213
from typing import Any
1314

1415
from dotenv import load_dotenv
15-
from github import Github, GithubException
16+
from github import Github, GithubException, RateLimitExceededException
1617
from github.Repository import Repository
1718

1819
load_dotenv()
@@ -21,12 +22,24 @@
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

Comments
 (0)