diff --git a/backend/app/api/v1/endpoints/github.py b/backend/app/api/v1/endpoints/github.py index 9e0b060..12294d2 100644 --- a/backend/app/api/v1/endpoints/github.py +++ b/backend/app/api/v1/endpoints/github.py @@ -75,3 +75,19 @@ async def get_profile_text_data( detail=f"GitHub API error: {str(e)}" ) +@router.get("/stats/{username}") +async def get_github_stats(username: str, token: str = Depends(get_github_token)): + """ + Fetches real-time statistics for a specific GitHub user: + - Total Pull Requests + - Closed Issues + - Total Stars received + """ + try: + stats = await github_service.get_user_stats(token, username) + return stats + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"GitHub API error fetching stats: {str(e)}" + ) \ No newline at end of file diff --git a/backend/app/services/github_service.py b/backend/app/services/github_service.py index 24eaba4..09d3166 100644 --- a/backend/app/services/github_service.py +++ b/backend/app/services/github_service.py @@ -6,6 +6,11 @@ import traceback from fastapi import HTTPException, status from typing import Dict, List, Set, Optional, Any +import time + +_STATS_CACHE: dict[str, dict] = {} +CACHE_TTL_SECONDS = 60 * 60 # 1 hour + # --- GitHub API Constants --- GITHUB_API_URL = "https://api.github.com" @@ -208,3 +213,185 @@ async def get_profile_text_data(token: str, max_repos_for_readme: int = MAX_REPO } return final_result +# Helper Function to get the next page +def get_next_link(link_header: Optional[str]) -> Optional[str]: + """ + Parses the GitHub Link header and returns the URL with rel="next", + or None if no next page exists. + """ + if not link_header: + return None + + # Example Link header: + # ; rel="next", + # ; rel="last" + parts = link_header.split(",") + + for part in parts: + if 'rel="next"' in part: + start = part.find("<") + 1 + end = part.find(">") + return part[start:end] + + return None + +# Helper function to fetch stars using pagination(>100) +async def fetch_total_stars(client: httpx.AsyncClient, username: str, headers: dict) -> int: + url = f"{GITHUB_API_URL}/users/{username}/repos?per_page=100&page=1" + total_stars = 0 + + while url: + try: + response = await client.get(url, headers=headers, timeout=15.0) + response.raise_for_status() + except httpx.RequestError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="GitHub API unreachable while fetching stars" + ) from exc + except httpx.HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail="GitHub API error while fetching stars" + ) from exc + + repos = response.json() + if not isinstance(repos, list): + break + + total_stars += sum(repo.get("stargazers_count", 0) for repo in repos) + url = get_next_link(response.headers.get("Link")) + + return total_stars + +async def get_user_stats(token: str, username: str) -> Dict[str, Any]: + """ + Fetches real-time stats for the user: + 1. Pull Requests count (Search API) + 2. Issues Closed count (Search API) + 3. Total Stars (Sum of stars from user's repos) + 4. Total Contributions(Using GraphQL) + """ + + # Implementing in-memory cache + current_time = time.time() + + cached = _STATS_CACHE.get(username) + if cached: + if current_time - cached["timestamp"] < CACHE_TTL_SECONDS: + print(f"DEBUG [GitHub Service]: Returning cached stats for {username}") + return cached["data"] + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="GitHub token required for stats" + ) + + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28" + } + + graphql_headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + async with httpx.AsyncClient() as client: + + # to get pull requests count + pr_url = f"{GITHUB_API_URL}/search/issues" + pr_params = {"q": f"author:{username} type:pr"} + + # Issues Closed Count + issues_url = f"{GITHUB_API_URL}/search/issues" + issues_params = {"q": f"author:{username} type:issue is:closed"} + + + # To get the contributions + graphql_url = "https://api.github.com/graphql" + query = """ query($username: String!) { + user(login: $username) { + contributionsCollection { + contributionCalendar { + totalContributions + } + } + } + } """ + payload = {"query": query, "variables": {"username": username} } + # Execute requests concurrently for performance + print(f"DEBUG [GitHub Service]: Fetching stats for {username}...") + responses = await asyncio.gather( + client.get(pr_url, headers=headers, params=pr_params), + client.get(issues_url, headers=headers, params=issues_params), + client.post(graphql_url, json=payload, headers=graphql_headers), + return_exceptions=True + ) + + # Process PR Response + pr_res = responses[0] + total_prs = 0 + if isinstance(pr_res, httpx.Response) and pr_res.status_code == 200: + total_prs = pr_res.json().get("total_count", 0) + elif isinstance(pr_res, Exception): + print(f"WARN [GitHub Service]: PR fetch network error: {str(pr_res)}") + else: + print(f"WARN [GitHub Service]: Failed to fetch PRs. Status: {getattr(pr_res, 'status_code', 'Unknown')}") + + # Process Issues Response + issues_res = responses[1] + closed_issues = 0 + if isinstance(issues_res, httpx.Response) and issues_res.status_code == 200: + closed_issues = issues_res.json().get("total_count", 0) + elif isinstance(issues_res, Exception): + print(f"WARN [GitHub Service]: Issues fetch network error: {str(issues_res)}") + else: + print(f"WARN [GitHub Service]: Failed to fetch Issues. Status: {getattr(issues_res, 'status_code', 'Unknown')}") + + # Process Repos/Stars Response + total_stars = 0 + try: + total_stars = await fetch_total_stars(client, username, headers) + except HTTPException: + raise + except Exception as e: + print(f"ERROR [GitHub Service]: Failed to fetch total stars: {str(e)}") + total_stars = 0 + + graphql_res = responses[2] + total_contributions = 0 + + # Process Contributions + if isinstance(graphql_res, httpx.Response) and graphql_res.status_code == 200: + try: + data = graphql_res.json() + total_contributions = ( + data["data"]["user"] + ["contributionsCollection"]["contributionCalendar"] + ["totalContributions"] + ) + except (KeyError, TypeError) as e: + # This catches API schema changes or empty data specifically + print(f"WARN [GitHub GraphQL]: Error parsing contribution data: {e}. Payload: {data}") + elif isinstance(graphql_res, Exception): + print(f"WARN [GitHub GraphQL]: Network error: {str(graphql_res)}") + else: + print(f"WARN [GitHub GraphQL]: Query failed. Status: {getattr(graphql_res, 'status_code', 'Unknown')}") + + stats = { + "contributions": total_contributions, + "pullRequests": total_prs, + "issuesClosed": closed_issues, + "stars": total_stars + } + + print(f"DEBUG [GitHub Service]: Stats fetched: {stats}") + _STATS_CACHE[username] = { + "data": stats, + "timestamp": current_time + } + + return stats diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 3b7ac8d..bb45c9c 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -66,8 +66,11 @@ export default function ProfilePage() { const [profile, setProfile] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState("") + const [stats, setStats] = useState(null) + const [statsLoading, setStatsLoading] = useState(true) + const [statsError, setStatsError] = useState(false) - const mockData: { skills: Skill[]; stats: Stats; achievements: Achievement[]; resumeUploaded: boolean } = { + const mockData: { skills: Skill[]; achievements: Achievement[]; resumeUploaded: boolean } = { skills: [ { name: "JavaScript", level: 5 }, { name: "React", level: 4.2 }, @@ -75,12 +78,6 @@ export default function ProfilePage() { { name: "Node.js", level: 2 }, { name: "CSS/Tailwind", level: 3 }, ], - stats: { - contributions: 149, - pullRequests: 86, - issuesClosed: 53, - stars: 128, - }, achievements: [ { name: "First Contribution", icon: "GitMerge", date: "Feb 2022" }, { name: "Pull Request Pro", icon: "GitPullRequest", date: "May 2022" }, @@ -129,6 +126,36 @@ export default function ProfilePage() { fetchProfile() }, [router]) // Dependency array includes router for the push navigation + useEffect(() => { + if (!profile) return + + const fetchStats = async () => { + setStatsLoading(true) + setStatsError(false) + + try { + const res = await fetch( + `http://localhost:8000/api/v1/github/stats/${profile.login}`, + { + credentials: "include", + } + ) + + if (!res.ok) { + throw new Error("Stats request failed") + } + + const data: Stats = await res.json() + setStats(data) + } catch (err) { + setStatsError(true) + setStats(null) + } + } + + fetchStats() + }, [profile]) + // Helper function to get skill level label const getSkillLevelLabel = (level: number): string => { switch (level) { @@ -205,6 +232,13 @@ export default function ProfilePage() { ) } + const StatSkeleton = () => ( +
+
+
+
+) + // --- Render Profile Page --- return (
@@ -353,10 +387,10 @@ export default function ProfilePage() { {/* Main Content Section */}
{/* Stats Cards Grid */} -
+
{/* Contributions Stat */}
{/* Added shadow */} -
{mockData.stats.contributions}
+
{stats ? stats.contributions : 0}
Contributions
{/* Added mt-1 */}
{/* Repositories Stat */} @@ -366,14 +400,19 @@ export default function ProfilePage() {
{/* Pull Requests Stat */}
-
{mockData.stats.pullRequests}
+
{stats ? stats.pullRequests : 0}
Pull Requests
{/* Issues Closed Stat */}
-
{mockData.stats.issuesClosed}
+
{stats ? stats.issuesClosed : 0}
Issues Closed
+ {/* Stars Stat*/} +
{/* Added shadow */} +
{stats ? stats.stars : 0}
+
Stars
{/* Added mt-1 */} +
{/* Followers Stat */}
{profile.followers}