Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions backend/app/api/v1/endpoints/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
)
187 changes: 187 additions & 0 deletions backend/app/services/github_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
# <https://api.github.com/...&page=2>; rel="next",
# <https://api.github.com/...&page=5>; 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
61 changes: 50 additions & 11 deletions frontend/app/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,21 +66,18 @@ export default function ProfilePage() {
const [profile, setProfile] = useState<GitHubProfile | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [stats, setStats] = useState<Stats | null>(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 },
{ name: "TypeScript", level: 1 },
{ 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" },
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -205,6 +232,13 @@ export default function ProfilePage() {
)
}

const StatSkeleton = () => (
<div className="bg-gray-100 dark:bg-[#161b22] rounded-xl p-4 text-center shadow-md animate-pulse">
<div className="h-7 w-12 mx-auto bg-gray-300 dark:bg-gray-700 rounded mb-2"></div>
<div className="h-4 w-20 mx-auto bg-gray-300 dark:bg-gray-700 rounded"></div>
</div>
)

// --- Render Profile Page ---
return (
<div className="min-h-screen bg-white dark:bg-black text-black dark:text-gray-300 relative overflow-hidden">
Expand Down Expand Up @@ -353,10 +387,10 @@ export default function ProfilePage() {
{/* Main Content Section */}
<div className="lg:col-span-3">
{/* Stats Cards Grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
{/* Contributions Stat */}
<div className="bg-gray-100 dark:bg-[#161b22] rounded-xl p-4 text-center shadow-md"> {/* Added shadow */}
<div className="text-2xl font-bold text-black dark:text-white">{mockData.stats.contributions}</div>
<div className="text-2xl font-bold text-black dark:text-white">{stats ? stats.contributions : 0}</div>
<div className="text-sm text-gray-700 dark:text-gray-400 mt-1">Contributions</div> {/* Added mt-1 */}
</div>
{/* Repositories Stat */}
Expand All @@ -366,14 +400,19 @@ export default function ProfilePage() {
</div>
{/* Pull Requests Stat */}
<div className="bg-gray-100 dark:bg-[#161b22] rounded-xl p-4 text-center shadow-md">
<div className="text-2xl font-bold text-black dark:text-white">{mockData.stats.pullRequests}</div>
<div className="text-2xl font-bold text-black dark:text-white">{stats ? stats.pullRequests : 0}</div>
<div className="text-sm text-gray-700 dark:text-gray-400 mt-1">Pull Requests</div>
</div>
{/* Issues Closed Stat */}
<div className="bg-gray-100 dark:bg-[#161b22] rounded-xl p-4 text-center shadow-md">
<div className="text-2xl font-bold text-black dark:text-white">{mockData.stats.issuesClosed}</div>
<div className="text-2xl font-bold text-black dark:text-white">{stats ? stats.issuesClosed : 0}</div>
<div className="text-sm text-gray-700 dark:text-gray-400 mt-1">Issues Closed</div>
</div>
{/* Stars Stat*/}
<div className="bg-gray-100 dark:bg-[#161b22] rounded-xl p-4 text-center shadow-md"> {/* Added shadow */}
<div className="text-2xl font-bold text-black dark:text-white">{stats ? stats.stars : 0}</div>
<div className="text-sm text-gray-700 dark:text-gray-400 mt-1">Stars</div> {/* Added mt-1 */}
</div>
{/* Followers Stat */}
<div className="bg-gray-100 dark:bg-[#161b22] rounded-xl p-4 text-center shadow-md">
<div className="text-2xl font-bold text-black dark:text-white">{profile.followers}</div>
Expand Down