From 883b8550c2a3dcc8622d67b7392a1d73b7a52f71 Mon Sep 17 00:00:00 2001 From: SampurnaNiyogi Date: Mon, 5 Jan 2026 00:39:34 +0530 Subject: [PATCH 1/2] Added dynamic profile stats --- backend/app/api/v1/endpoints/github.py | 16 +++ backend/app/services/github_service.py | 138 +++++++++++++++++++++++++ frontend/app/profile/page.tsx | 61 +++++++++-- 3 files changed, 204 insertions(+), 11 deletions(-) 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..94f0a4d 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,136 @@ async def get_profile_text_data(token: str, max_repos_for_readme: int = MAX_REPO } return final_result +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 Contriutions(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: + try: + # 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"} + + # Starred repos + repos_url = f"{GITHUB_API_URL}/users/{username}/repos" + repos_params = {"per_page": 100} + + # 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.get(repos_url, headers=headers, params=repos_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) + else: + print(f"WARN [GitHub Service]: Failed to fetch PRs. {pr_res}") + + # 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) + else: + print(f"WARN [GitHub Service]: Failed to fetch Issues. {issues_res}") + + # Process Repos/Stars Response + repos_res = responses[2] + total_stars = 0 + if isinstance(repos_res, httpx.Response) and repos_res.status_code == 200: + repos_data = repos_res.json() + if isinstance(repos_data, list): + total_stars = sum(repo.get("stargazers_count", 0) for repo in repos_data) + else: + print(f"WARN [GitHub Service]: Failed to fetch Repos. {repos_res}") + + total_contributions = 0 + graphql_res = responses[3] + if isinstance(graphql_res, httpx.Response) and graphql_res.status_code == 200: + try: + total_contributions = ( + graphql_res.json()["data"]["user"] + ["contributionsCollection"]["contributionCalendar"] + ["totalContributions"] + ) + except (KeyError, TypeError): + print("WARN [GitHub GraphQL]: Unexpected response", graphql_res.json()) + + 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 + + except Exception as e: + print(f"ERROR [GitHub Service]: Unexpected error fetching stats: {str(e)}") + # Return 0s so the frontend doesn't break + return { + "contributions": 0, + "pullRequests": 0, + "issuesClosed": 0, + "stars": 0 + } \ No newline at end of file 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}
From 1f1711018a23eaf54d1d5abfb12c174e1f0a05da Mon Sep 17 00:00:00 2001 From: SampurnaNiyogi Date: Wed, 7 Jan 2026 13:01:30 +0530 Subject: [PATCH 2/2] Fixed star count for more than 100 and error handling for stats --- backend/app/services/github_service.py | 237 +++++++++++++++---------- 1 file changed, 143 insertions(+), 94 deletions(-) diff --git a/backend/app/services/github_service.py b/backend/app/services/github_service.py index 94f0a4d..09d3166 100644 --- a/backend/app/services/github_service.py +++ b/backend/app/services/github_service.py @@ -213,13 +213,64 @@ 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 Contriutions(Using GraphQL) + 4. Total Contributions(Using GraphQL) """ # Implementing in-memory cache @@ -249,100 +300,98 @@ async def get_user_stats(token: str, username: str) -> Dict[str, Any]: } async with httpx.AsyncClient() as client: - try: - # 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"} - - # Starred repos - repos_url = f"{GITHUB_API_URL}/users/{username}/repos" - repos_params = {"per_page": 100} - - # To get the contributions - graphql_url = "https://api.github.com/graphql" - query = """ query($username: String!) { - user(login: $username) { - contributionsCollection { - contributionCalendar { - totalContributions - } - } + + # 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.get(repos_url, headers=headers, params=repos_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) - else: - print(f"WARN [GitHub Service]: Failed to fetch PRs. {pr_res}") - - # 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) - else: - print(f"WARN [GitHub Service]: Failed to fetch Issues. {issues_res}") - - # Process Repos/Stars Response - repos_res = responses[2] - total_stars = 0 - if isinstance(repos_res, httpx.Response) and repos_res.status_code == 200: - repos_data = repos_res.json() - if isinstance(repos_data, list): - total_stars = sum(repo.get("stargazers_count", 0) for repo in repos_data) - else: - print(f"WARN [GitHub Service]: Failed to fetch Repos. {repos_res}") - - total_contributions = 0 - graphql_res = responses[3] - if isinstance(graphql_res, httpx.Response) and graphql_res.status_code == 200: - try: - total_contributions = ( - graphql_res.json()["data"]["user"] - ["contributionsCollection"]["contributionCalendar"] - ["totalContributions"] - ) - except (KeyError, TypeError): - print("WARN [GitHub GraphQL]: Unexpected response", graphql_res.json()) - - stats = { - "contributions": total_contributions, - "pullRequests": total_prs, - "issuesClosed": closed_issues, - "stars": total_stars - } + } + } + } """ + 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')}") - print(f"DEBUG [GitHub Service]: Stats fetched: {stats}") - _STATS_CACHE[username] = { - "data": stats, - "timestamp": current_time - } + # 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')}") - return stats + 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 + } - except Exception as e: - print(f"ERROR [GitHub Service]: Unexpected error fetching stats: {str(e)}") - # Return 0s so the frontend doesn't break - return { - "contributions": 0, - "pullRequests": 0, - "issuesClosed": 0, - "stars": 0 - } \ No newline at end of file + return stats