| from __future__ import annotations |
|
|
| from datetime import UTC, datetime, timedelta |
| import time |
| from typing import Any |
|
|
| import httpx |
|
|
| from app.clients.queries import ANALYZE_PROFILE_QUERY |
| from app.core.config import settings |
|
|
|
|
| class GitHubAPIError(RuntimeError): |
| pass |
|
|
|
|
| _CACHE: dict[str, tuple[float, dict[str, Any]]] = {} |
|
|
|
|
| def _headers(use_token: bool = True) -> dict[str, str]: |
| headers = {"Accept": "application/vnd.github+json"} |
| if use_token and settings.github_token: |
| headers["Authorization"] = f"Bearer {settings.github_token}" |
| return headers |
|
|
|
|
| def _extract_total_count(response: httpx.Response, fallback: int) -> int: |
| link = response.headers.get("Link", "") |
| for part in link.split(","): |
| if 'rel="last"' not in part: |
| continue |
| marker = "page=" |
| if marker not in part: |
| continue |
| value = part.split(marker, 1)[1].split("&", 1)[0].split(">", 1)[0] |
| try: |
| return int(value) |
| except ValueError: |
| return fallback |
| return fallback |
|
|
|
|
| def _cache_get(key: str) -> dict[str, Any] | None: |
| item = _CACHE.get(key) |
| if item is None: |
| return None |
| expires_at, value = item |
| if expires_at < time.time(): |
| _CACHE.pop(key, None) |
| return None |
| return value |
|
|
|
|
| def _cache_set(key: str, value: dict[str, Any]) -> dict[str, Any]: |
| _CACHE[key] = (time.time() + settings.github_cache_ttl_seconds, value) |
| return value |
|
|
|
|
| def _github_error(response: httpx.Response) -> GitHubAPIError: |
| if response.status_code == 403 and "rate limit" in response.text.lower(): |
| reset = response.headers.get("X-RateLimit-Reset") |
| reset_hint = "" |
| if reset and reset.isdigit(): |
| reset_at = datetime.fromtimestamp(int(reset), tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC") |
| reset_hint = f" Rate limit resets at {reset_at}." |
| return GitHubAPIError( |
| "GitHub public API rate limit exceeded." |
| " Add a valid GITHUB_TOKEN in .env and restart the server, or wait for the limit to reset." |
| f"{reset_hint}" |
| ) |
| return GitHubAPIError(f"GitHub API status={response.status_code}: {response.text}") |
|
|
|
|
| class GitHubGraphQLClient: |
| async def analyze_user(self, username: str) -> dict[str, Any]: |
| cache_key = f"github:{username.lower()}:{'graphql' if settings.github_token else 'public'}" |
| cached = _cache_get(cache_key) |
| if cached is not None: |
| return cached |
|
|
| if not settings.github_token: |
| return _cache_set(cache_key, await self._analyze_public_user(username)) |
|
|
| payload = {"query": ANALYZE_PROFILE_QUERY, "variables": {"username": username}} |
|
|
| async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client: |
| response = await client.post(settings.github_api_url, headers=_headers(), json=payload) |
|
|
| if response.status_code >= 400: |
| if response.status_code == 401: |
| public_key = f"github:{username.lower()}:public" |
| cached_public = _cache_get(public_key) |
| if cached_public is not None: |
| return cached_public |
| return _cache_set(public_key, await self._analyze_public_user(username, use_token=False)) |
| raise _github_error(response) |
|
|
| data = response.json() |
| if data.get("errors"): |
| raise GitHubAPIError(str(data["errors"])) |
| if data.get("data", {}).get("user") is None: |
| raise GitHubAPIError(f"GitHub user '{username}' not found.") |
|
|
| data["source"] = "graphql" |
| return _cache_set(cache_key, data) |
|
|
| async def _analyze_public_user(self, username: str, use_token: bool = False) -> dict[str, Any]: |
| base_url = settings.github_rest_api_url.rstrip("/") |
| async with httpx.AsyncClient(base_url=base_url, timeout=settings.request_timeout_seconds, headers=_headers(use_token=use_token)) as client: |
| user_response = await client.get(f"/users/{username}") |
| if user_response.status_code == 404: |
| raise GitHubAPIError(f"GitHub user '{username}' not found.") |
| if user_response.status_code >= 400: |
| raise _github_error(user_response) |
|
|
| repos_response = await client.get( |
| f"/users/{username}/repos", |
| params={ |
| "per_page": min(max(settings.github_public_repo_limit, 1), 100), |
| "sort": "updated", |
| "direction": "desc", |
| "type": "owner", |
| }, |
| ) |
| if repos_response.status_code >= 400: |
| raise _github_error(repos_response) |
|
|
| events_response = await client.get(f"/users/{username}/events/public", params={"per_page": 100}) |
| events = events_response.json() if events_response.status_code < 400 else [] |
|
|
| user = user_response.json() |
| repos = repos_response.json() |
| repo_nodes = self._build_public_repositories(username, repos) |
| weeks = _weeks_from_events(events) |
| public_commits = sum(_event_commit_count(event) for event in events) |
| public_prs_created = sum(1 for event in events if _is_pr_created_event(event)) |
|
|
| return { |
| "source": "rest-public", |
| "data": { |
| "user": { |
| "login": user.get("login", username), |
| "name": user.get("name"), |
| "bio": user.get("bio"), |
| "createdAt": user.get("created_at"), |
| "followers": {"totalCount": int(user.get("followers") or 0)}, |
| "contributionsCollection": { |
| "totalCommitContributions": public_commits, |
| "totalPullRequestContributions": public_prs_created, |
| "contributionCalendar": { |
| "totalContributions": len(events), |
| "weeks": weeks, |
| }, |
| }, |
| "repositories": {"nodes": repo_nodes}, |
| "pullRequests": {"totalCount": public_prs_created}, |
| "publicActivity": { |
| "publicCommits": public_commits, |
| "publicPRsCreated": public_prs_created, |
| "sourceWindow": "recent public events", |
| }, |
| } |
| }, |
| } |
|
|
| def _build_public_repositories(self, username: str, repos: list[dict[str, Any]]) -> list[dict[str, Any]]: |
| nodes: list[dict[str, Any]] = [] |
| for repo in repos: |
| owner = repo.get("owner", {}).get("login") or username |
| name = repo.get("name") |
| if not name: |
| continue |
|
|
| primary_language = repo.get("language") |
| repo_size = int(repo.get("size") or 0) |
| nodes.append( |
| { |
| "name": name, |
| "nameWithOwner": repo.get("full_name") or f"{owner}/{name}", |
| "description": repo.get("description"), |
| "stargazerCount": int(repo.get("stargazers_count") or 0), |
| "forkCount": int(repo.get("forks_count") or 0), |
| "primaryLanguage": {"name": primary_language} if primary_language else None, |
| "languages": { |
| "edges": [ |
| {"size": max(repo_size, 1), "node": {"name": primary_language}} |
| ] if primary_language else [] |
| }, |
| "defaultBranchRef": { |
| "target": { |
| "history": {"totalCount": 0}, |
| } |
| }, |
| "publicSignals": { |
| "updatedAt": repo.get("updated_at"), |
| "pushedAt": repo.get("pushed_at"), |
| "isFork": bool(repo.get("fork")), |
| "openIssues": int(repo.get("open_issues_count") or 0), |
| }, |
| } |
| ) |
| return nodes |
|
|
| async def _fetch_repo_languages(self, client: httpx.AsyncClient, owner: str, repo: str) -> dict[str, int]: |
| response = await client.get(f"/repos/{owner}/{repo}/languages") |
| if response.status_code >= 400: |
| return {} |
| return {str(language): int(size) for language, size in response.json().items()} |
|
|
| async def _fetch_commit_count(self, client: httpx.AsyncClient, owner: str, repo: str, branch: str | None) -> int: |
| if not settings.github_fetch_commit_counts: |
| return 0 |
| params: dict[str, Any] = {"per_page": 1} |
| if branch: |
| params["sha"] = branch |
| response = await client.get(f"/repos/{owner}/{repo}/commits", params=params) |
| if response.status_code >= 400: |
| return 0 |
| return _extract_total_count(response, len(response.json())) |
|
|
|
|
| def _event_commit_count(event: dict[str, Any]) -> int: |
| if event.get("type") != "PushEvent": |
| return 0 |
| return len(event.get("payload", {}).get("commits", [])) |
|
|
|
|
| def _is_pr_created_event(event: dict[str, Any]) -> bool: |
| if event.get("type") != "PullRequestEvent": |
| return False |
| return event.get("payload", {}).get("action") in {"opened", "reopened"} |
|
|
|
|
| def _weeks_from_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]: |
| today = datetime.now(UTC).date() |
| start = today - timedelta(days=55) |
| counts: dict[str, int] = {} |
| for event in events: |
| created_at = event.get("created_at") |
| if not created_at: |
| continue |
| try: |
| day = datetime.fromisoformat(created_at.replace("Z", "+00:00")).date() |
| except ValueError: |
| continue |
| if day >= start: |
| counts[day.isoformat()] = counts.get(day.isoformat(), 0) + 1 |
|
|
| weeks: list[dict[str, Any]] = [] |
| for offset in range(0, 56, 7): |
| days = [] |
| for day_offset in range(7): |
| date = start + timedelta(days=offset + day_offset) |
| days.append({"date": date.isoformat(), "contributionCount": counts.get(date.isoformat(), 0)}) |
| weeks.append({"contributionDays": days}) |
| return weeks |
|
|