From 598f20aa9f9727d377abafe29cb856411e9ad656 Mon Sep 17 00:00:00 2001 From: JeevaRamanathan Date: Thu, 30 Oct 2025 01:04:12 +0530 Subject: [PATCH] feat: add github profile analyzer Signed-off-by: JeevaRamanathan --- .env.example | 3 +- CONTRIBUTING.md | 7 + README.md | 1 + src/App.jsx | 2 + src/components/Navbar.jsx | 1 + src/pages/GitHubProfileAnalyzer.css | 1209 +++++++++++++++++++++++++++ src/pages/GitHubProfileAnalyzer.jsx | 612 ++++++++++++++ src/pages/Home.jsx | 3 +- src/utilities/githubUtils.js | 262 ++++++ 9 files changed, 2098 insertions(+), 2 deletions(-) create mode 100644 src/pages/GitHubProfileAnalyzer.css create mode 100644 src/pages/GitHubProfileAnalyzer.jsx create mode 100644 src/utilities/githubUtils.js diff --git a/.env.example b/.env.example index 76a436e..fd06e76 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ VITE_OMDB_API_KEY=your_api_key_here -VITE_WEATHER_API_KEY=your_api_key_here \ No newline at end of file +VITE_WEATHER_API_KEY=your_api_key_here +VITE_GITHUB_TOKEN=your_github_personal_access_token \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a9f26d..fd48c57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,6 +87,13 @@ Task Flow Board: - Implement drag-to-connect nodes feature - Add task dependencies validation +GitHub Profile Analyzer: +- View GitHub profile stats, repositories, and pull request activity at a glance +- Compare two profiles side-by-side +- Language usage and repo stats charts +- Contribution calendar visualization +- Error handling for missing/invalid tokens + Global Enhancements: - Extract API calls into services folder diff --git a/README.md b/README.md index 49f8424..5507357 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ A collection of interactive dashboards that fetch and display data from **free, | ๐Ÿถ๐Ÿฑ Pets | [Dog CEO](https://dog.ceo/dog-api/) + [Cataas](https://cataas.com/#/) | Random cute dog and cat images | | ๐Ÿฆ  COVID-19 Tracker | [COVID19 API](https://covid19api.com/) | Track pandemic stats and trends globally | | ๐Ÿ“‹ Task Flow Board | [React Flow](https://reactflow.dev/) | Visual task management with draggable nodes | +| ๐Ÿง‘โ€๐Ÿ’ป GitHub Profile Analyzer | [GitHub GraphQL API](https://docs.github.com/en/graphql) | Discover, analyze, and compare GitHub profiles with advanced stats and insights | --- diff --git a/src/App.jsx b/src/App.jsx index b571290..43b4871 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -44,6 +44,7 @@ import Navbar from './components/Navbar.jsx'; import ContributorsWall from './pages/Contributors.jsx' import Pokedex from './pages/Pokedex.jsx'; import TaskFlowBoard from './pages/TaskFlowBoard.jsx'; +import GitHubProfileAnalyzer from './pages/GitHubProfileAnalyzer.jsx'; // TODO: Extract theme state into context (see todo 5). import { useState, useEffect } from 'react'; @@ -84,6 +85,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 86fda67..3c8cc31 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -17,6 +17,7 @@ export default function Navbar({ theme, toggleTheme }) {
  • Pets
  • COVID-19
  • TaskFlow
  • +
  • GitHub Profile Analyzer
  • + + + + )} +
    +

    GitHub Profile Analyzer

    +

    Discover, analyze, and compare GitHub profiles with advanced stats and insights

    +
    +
    + setUsername(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSearch()} + className="search-input" + /> + +
    +
    + +
    + {showCompare && ( +
    + setCompareUsername(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleCompareSearch()} + className="search-input" + /> + +
    + )} +
    +
    + + {loading && ( +
    +
    +
    +

    Loading GitHub profile...

    +
    +
    + )} + + {(profile || error) && ( +
    + {error && } + {profile && ( + <> +
    + + + + + + {showCompare && } +
    + + {activeTab === 'profile' && ( +
    + {showCompare && compareProfile ? ( +
    +
    +

    {profile.name || profile.login}

    + +
    +
    +

    {compareProfile.name || compareProfile.login}

    + +
    +
    + ) : ( + + )} +
    + )} + + {activeTab === 'repos' && ( +
    + {showCompare && compareProfile ? ( +
    +
    +

    {profile.name || profile.login}'s Repositories

    + l.language)} + /> +
    +
    +

    {compareProfile.name || compareProfile.login}'s Repositories

    + l.language)} + isCompare + /> +
    +
    + ) : ( + l.language)} + /> + )} +
    + )} + + {activeTab === 'stats' && ( +
    + {showCompare && compareProfile ? ( +
    +
    +

    {profile.name || profile.login}'s Stats

    + + +
    +
    +

    {compareProfile.name || compareProfile.login}'s Stats

    + + +
    +
    + ) : ( + <> + + + + )} +
    + )} + + {activeTab === 'activity' && ( +
    + {showCompare && compareProfile ? ( +
    +
    +

    {profile.name || profile.login}'s Activity

    + +
    +
    +

    {compareProfile.name || compareProfile.login}'s Activity

    + +
    +
    + ) : ( + + )} +
    + )} + + {activeTab === 'calendar' && ( +
    + + {showCompare && compareProfile && } +
    + )} + + {activeTab === 'compare' && showCompare && ( +
    + +
    + )} + + )} +
    + )} + + ); +}; + +const ProfileHeader = ({ profile, isCompare = false }) => ( +
    +
    + {profile.name} +
    +
    +
    +

    {profile.name || profile.login}

    +

    @{profile.login}

    +

    {profile.bio}

    +
    +
    + ๐Ÿ“ + {profile.location || 'Not specified'} +
    +
    + ๐Ÿข + {profile.company || 'Not specified'} +
    +
    + ๐Ÿ”— + View Profile +
    +
    +
    +
    + {formatNumber(profile.followers.totalCount)} + Followers +
    +
    + {formatNumber(profile.following.totalCount)} + Following +
    +
    + {formatNumber(profile.repositories.totalCount)} + Repositories +
    +
    +
    +
    +); + +const StatsPanel = ({ repoStats, activity, topLanguages, isCompare = false }) => ( +
    +

    Advanced Stats

    +
    +
    + {formatNumber(repoStats.totalStars)} + Total Stars +
    +
    + {formatNumber(repoStats.totalForks)} + Total Forks +
    +
    + {activity?.contributionsCollection?.totalCommitContributions || 0} + Commits (Last Year) +
    +
    + {activity?.contributionsCollection?.totalPullRequestContributions || 0} + PRs (Last Year) +
    +
    + {activity?.contributionsCollection?.totalIssueContributions || 0} + Issues (Last Year) +
    +
    + {repoStats.averageStars.toFixed(1)} + Avg Stars/Repo +
    +
    + {repoStats.mostStarred && ( +
    +

    Most Starred Repository

    +

    {repoStats.mostStarred.name} ({formatNumber(repoStats.mostStarred.stargazerCount)} stars)

    +
    + )} + {repoStats.mostForked && ( +
    +

    Most Forked Repository

    +

    {repoStats.mostForked.name} ({formatNumber(repoStats.mostForked.forkCount)} forks)

    +
    + )} +
    +

    Top Languages

    +
      + {topLanguages.map((lang, index) => ( +
    • + {lang.language} + {lang.count} repos +
    • + ))} +
    +
    +
    +); + +const LanguageChart = ({ languages, isCompare = false }) => { + const maxCount = Math.max(...languages.map(l => l.count)); + const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dda0dd', '#98d8c8']; + return ( +
    +

    Language Usage

    +
    + + {languages.map((lang, index) => { + const height = (lang.count / maxCount) * 200; + const y = 250 - height; + return ( + + + + {lang.language} + + + {lang.count} + + + ); + })} + +
    +
    + {languages.map((lang, index) => ( +
    + + {lang.language}: {lang.count} +
    + ))} +
    +
    + ); +}; + +const RepoList = ({ repos, sortBy, setSortBy, filterLanguage, setFilterLanguage, allLanguages, isCompare = false }) => ( +
    +

    Repositories

    +
    +
    + + +
    +
    + + +
    +
    +
    + {repos.map(repo => ( +
    +
    +

    + {repo.name} +

    + {repo.primaryLanguage?.name || 'N/A'} +
    +

    {repo.description}

    +
    + โญ {formatNumber(repo.stargazerCount)} + ๐Ÿด {formatNumber(repo.forkCount)} + ๐Ÿ“… {formatDate(repo.updatedAt)} +
    +
    + ))} +
    +
    +); + +const ActivityFeed = ({ activity, isCompare = false }) => ( +
    +

    Recent Activity

    +
    +
    +

    Recent Pull Requests

    +
      + {activity?.pullRequests?.nodes?.map(pr => ( +
    • + {pr.title} + {formatDate(pr.createdAt)} +
    • + ))} +
    +
    +
    +

    Recent Issues

    +
      + {activity?.issues?.nodes?.map(issue => ( +
    • + {issue.title} + {formatDate(issue.createdAt)} +
    • + ))} +
    +
    +
    +

    Top Contributing Repositories

    +
      + {activity?.contributionsCollection?.commitContributionsByRepository + ?.sort((a, b) => b.contributions.totalCount - a.contributions.totalCount) + ?.slice(0, 5) + .map(contrib => ( +
    • + {contrib.repository.name} + {contrib.contributions.totalCount} commits +
    • + ))} +
    +
    +
    +
    +); + +const ContributionCalendar = ({ calendar, isCompare = false }) => { + if (!calendar) return
    No contribution data available
    ; + + const weeks = calendar.weeks.slice(-52); // Last 52 weeks + const maxContributions = Math.max(...weeks.flatMap(w => w.contributionDays.map(d => d.contributionCount))); + + return ( +
    +

    Contribution Calendar

    +

    Total contributions in the last year: {formatNumber(calendar.totalContributions)}

    +
    + {weeks.map((week, weekIndex) => ( +
    + {week.contributionDays.map((day, dayIndex) => ( +
    + ))} +
    + ))} +
    +
    + Less +
    +
    +
    +
    +
    +
    +
    + More +
    +
    + ); +}; + +const CompareProfiles = ({ profile1, profile2, repoStats1, repoStats2 }) => ( +
    +

    Profile Comparison

    +
    +
    +

    {profile1.name || profile1.login}

    +
    +

    Followers: {formatNumber(profile1.followers.totalCount)}

    +

    Following: {formatNumber(profile1.following.totalCount)}

    +

    Repos: {formatNumber(profile1.repositories.totalCount)}

    +

    Total Stars: {formatNumber(repoStats1.totalStars)}

    +

    Total Forks: {formatNumber(repoStats1.totalForks)}

    +
    +
    +
    +

    {profile2.name || profile2.login}

    +
    +

    Followers: {formatNumber(profile2.followers.totalCount)}

    +

    Following: {formatNumber(profile2.following.totalCount)}

    +

    Repos: {formatNumber(profile2.repositories.totalCount)}

    +

    Total Stars: {formatNumber(repoStats2.totalStars)}

    +

    Total Forks: {formatNumber(repoStats2.totalForks)}

    +
    +
    +
    +
    +); + +export default GitHubProfileAnalyzer; \ No newline at end of file diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index cb16903..1beb78b 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -35,7 +35,8 @@ const dashboards = [ { path: '/covid', title: 'COVID-19 Stats', desc: 'Global & country data' }, { path: '/pokedex', title: 'Pokรฉdex', desc: 'Explore Pokรฉmon species' }, { path: '/taskflow', title: 'Task Flow Board', desc: 'Visual task management with nodes' }, - { path: '/contributors', title: 'Contributor Wall', desc: 'Our Contributors' }, + { path: '/github-profile-analyzer', title: 'GitHub Profile Analyzer', desc: 'Analyze GitHub profiles and repositories' }, + { path: '/contributors', title: 'Contributor Wall', desc: 'Our Contributors' }, ]; diff --git a/src/utilities/githubUtils.js b/src/utilities/githubUtils.js new file mode 100644 index 0000000..20679ba --- /dev/null +++ b/src/utilities/githubUtils.js @@ -0,0 +1,262 @@ +let GITHUB_TOKEN = import.meta.env.VITE_GITHUB_TOKEN || null; + +export function setGitHubToken(token) { + GITHUB_TOKEN = token; +} + +const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql'; + +export async function fetchProfileData(username) { + const query = ` + query($username: String!) { + user(login: $username) { + avatarUrl + name + login + bio + location + company + followers { + totalCount + } + following { + totalCount + } + repositories { + totalCount + } + url + } + } + `; + + if (!GITHUB_TOKEN) throw new Error('GitHub token is required. Please provide a valid token.'); + const response = await fetch(GITHUB_GRAPHQL_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${GITHUB_TOKEN}`, + }, + body: JSON.stringify({ query, variables: { username } }), + }); + + const data = await response.json(); + if (data.errors) throw new Error(data.errors[0].message); + return data.data.user; +} + +export async function fetchRepoStats(username) { + const query = ` + query($username: String!, $first: Int!) { + user(login: $username) { + repositories(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + name + description + stargazerCount + forkCount + primaryLanguage { + name + } + updatedAt + url + } + } + } + } + `; + + if (!GITHUB_TOKEN) throw new Error('GitHub token is required. Please provide a valid token.'); + const response = await fetch(GITHUB_GRAPHQL_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${GITHUB_TOKEN}`, + }, + body: JSON.stringify({ query, variables: { username, first: 100 } }), + }); + + const data = await response.json(); + if (data.errors) throw new Error(data.errors[0].message); + return data.data.user.repositories.nodes; +} + +export async function fetchRecentActivity(username) { + // Fetch recent commits, PRs, issues + const query = ` + query($username: String!) { + user(login: $username) { + contributionsCollection { + totalCommitContributions + totalPullRequestContributions + totalIssueContributions + commitContributionsByRepository { + repository { + name + } + contributions { + totalCount + } + } + } + pullRequests(first: 5, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + title + url + createdAt + } + } + issues(first: 5, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + title + url + createdAt + } + } + } + } + `; + + if (!GITHUB_TOKEN) throw new Error('GitHub token is required. Please provide a valid token.'); + const response = await fetch(GITHUB_GRAPHQL_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${GITHUB_TOKEN}`, + }, + body: JSON.stringify({ query, variables: { username } }), + }); + + const data = await response.json(); + if (data.errors) throw new Error(data.errors[0].message); + return data.data.user; +} + +export function calculateLanguageUsage(repos) { + const languageMap = {}; + repos.forEach(repo => { + const lang = repo.primaryLanguage?.name || 'Unknown'; + languageMap[lang] = (languageMap[lang] || 0) + 1; + }); + return Object.entries(languageMap).map(([language, count]) => ({ language, count })); +} + +export function sortRepos(repos, sortBy) { + return [...repos].sort((a, b) => { + switch (sortBy) { + case 'stars': + return b.stargazerCount - a.stargazerCount; + case 'forks': + return b.forkCount - a.forkCount; + case 'updated': + return new Date(b.updatedAt) - new Date(a.updatedAt); + default: + return 0; + } + }); +} + +export function filterRepos(repos, language) { + if (!language) return repos; + return repos.filter(repo => repo.primaryLanguage?.name === language); +} + +export async function fetchContributionCalendar(username) { + const query = ` + query($username: String!) { + user(login: $username) { + contributionsCollection { + contributionCalendar { + totalContributions + weeks { + contributionDays { + contributionCount + date + } + } + } + } + } + } + `; + + if (!GITHUB_TOKEN) throw new Error('GitHub token is required. Please provide a valid token.'); + const response = await fetch(GITHUB_GRAPHQL_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${GITHUB_TOKEN}`, + }, + body: JSON.stringify({ query, variables: { username } }), + }); + + const data = await response.json(); + if (data.errors) throw new Error(data.errors[0].message); + return data.data.user.contributionsCollection.contributionCalendar; +} + +export function getTopLanguages(languages, topN = 5) { + return languages.sort((a, b) => b.count - a.count).slice(0, topN); +} + +export function calculateRepoStats(repos) { + const stats = { + totalStars: 0, + totalForks: 0, + totalSize: 0, + languages: {}, + mostStarred: null, + mostForked: null, + averageStars: 0, + averageForks: 0, + }; + + repos.forEach(repo => { + stats.totalStars += repo.stargazerCount; + stats.totalForks += repo.forkCount; + stats.totalSize += repo.size || 0; + const lang = repo.primaryLanguage?.name || 'Unknown'; + stats.languages[lang] = (stats.languages[lang] || 0) + 1; + + if (!stats.mostStarred || repo.stargazerCount > stats.mostStarred.stargazerCount) { + stats.mostStarred = repo; + } + if (!stats.mostForked || repo.forkCount > stats.mostForked.forkCount) { + stats.mostForked = repo; + } + }); + + stats.averageStars = stats.totalStars / repos.length; + stats.averageForks = stats.totalForks / repos.length; + + return stats; +} + +export function formatDate(dateString) { + if (!dateString) return 'N/A'; + try { + const date = new Date(dateString); + // Check if date is valid + if (isNaN(date.getTime())) return 'Invalid Date'; + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch (error) { + console.error('Error formatting date:', error, dateString); + return 'Error'; + } +} + +export function formatNumber(num) { + return num.toLocaleString(); +} + +export function getContributionLevel(count) { + if (count === 0) return 'none'; + if (count < 5) return 'low'; + if (count < 10) return 'medium'; + if (count < 20) return 'high'; + return 'very-high'; +} \ No newline at end of file