From f2c2a7509ce57d5db77641995958a68d90448da2 Mon Sep 17 00:00:00 2001 From: Muneerali199 Date: Mon, 2 Mar 2026 22:57:09 +0530 Subject: [PATCH] feat: add Contributor Activity Matrix with GitHub API integration --- .env.example | 2 + package-lock.json | 64 ++- package.json | 3 +- src/App.css | 455 +++++++++++++++++- src/App.tsx | 69 ++- .../ContributorActivityFeature.tsx | 198 ++++++++ .../components/ContributorFilters.tsx | 86 ++++ .../components/ContributorMatrix.tsx | 101 ++++ .../components/ContributorStats.tsx | 54 +++ .../components/ErrorState.tsx | 17 + .../components/GitHubTokenInput.tsx | 71 +++ .../components/LoadingState.tsx | 8 + src/features/contributor-activity/index.ts | 6 + src/features/contributor-activity/useCases.ts | 100 ++++ src/index.css | 15 +- src/lib/github/api.ts | 217 +++++++++ src/lib/github/client.ts | 409 ++++++++++++++++ src/lib/github/types.ts | 60 +++ 18 files changed, 1919 insertions(+), 16 deletions(-) create mode 100644 .env.example create mode 100644 src/features/contributor-activity/ContributorActivityFeature.tsx create mode 100644 src/features/contributor-activity/components/ContributorFilters.tsx create mode 100644 src/features/contributor-activity/components/ContributorMatrix.tsx create mode 100644 src/features/contributor-activity/components/ContributorStats.tsx create mode 100644 src/features/contributor-activity/components/ErrorState.tsx create mode 100644 src/features/contributor-activity/components/GitHubTokenInput.tsx create mode 100644 src/features/contributor-activity/components/LoadingState.tsx create mode 100644 src/features/contributor-activity/index.ts create mode 100644 src/features/contributor-activity/useCases.ts create mode 100644 src/lib/github/api.ts create mode 100644 src/lib/github/client.ts create mode 100644 src/lib/github/types.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9b8c8b0 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# GitHub Personal Access Token with repo and read:org scopes +VITE_GITHUB_TOKEN=your_github_token_here diff --git a/package-lock.json b/package-lock.json index ab0e168..fc665b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { - "name": "orgexplorer", + "name": "OrgExplorer", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "orgexplorer", + "name": "OrgExplorer", "version": "0.0.0", "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -1480,6 +1481,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2607,6 +2621,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2673,6 +2725,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index d75669c..497079b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/src/App.css b/src/App.css index 027945e..456da35 100644 --- a/src/App.css +++ b/src/App.css @@ -1,6 +1,457 @@ #root { - max-width: 1280px; + max-width: 1400px; margin: 0 auto; + padding: 0; + text-align: left; +} + +.app { + min-height: 100vh; +} + +.navbar { + background: #ffffff; + border-bottom: 1px solid #d0d7de; + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-brand h1 { + color: #1f2328; + margin: 0; + font-size: 1.5rem; + font-weight: 700; +} + +.nav-links { + display: flex; + gap: 1.5rem; +} + +.nav-links a { + color: #57606a; + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; +} + +.nav-links a:hover { + color: #0969da; +} + +.main-content { padding: 2rem; + background-color: #ffffff; + min-height: calc(100vh - 80px); +} + +.home-page { + color: #1f2328; +} + +.home-page h1 { + margin-bottom: 0.5rem; + color: #1f2328; +} + +.home-page > p { + color: #57606a; +} + +.org-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-top: 2rem; +} + +.org-card { + background: #ffffff; + border: 1px solid #d0d7de; + border-radius: 6px; + padding: 1.5rem; + text-decoration: none; + color: #1f2328; + transition: all 0.2s; +} + +.org-card:hover { + background: #f6f8fa; + border-color: #0969da; +} + +.org-card h2 { + margin: 0 0 0.5rem; + font-size: 1.25rem; + color: #0969da; +} + +.org-card p { + margin: 0; + color: #57606a; +} + +.feature-header { + margin-bottom: 2rem; +} + +.feature-header h1 { + margin: 0 0 0.5rem; + color: #1f2328; +} + +.feature-header p { + color: #57606a; + margin: 0; +} + +.contributor-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + background: #f6f8fa; + border: 1px solid #d0d7de; + border-radius: 6px; + padding: 1rem; +} + +.stat-card h3 { + margin: 0 0 0.5rem; + font-size: 0.875rem; + color: #57606a; +} + +.stat-value { + font-size: 2rem; + font-weight: 600; + margin: 0; +} + +.top-list { + list-style: none; + padding: 0; + margin: 0; +} + +.top-list li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; + font-size: 0.875rem; +} + +.top-list .rank { + color: #57606a; + width: 24px; +} + +.top-list img { + width: 20px; + height: 20px; + border-radius: 50%; +} + +.top-list .name { + flex: 1; +} + +.top-list .count { + font-weight: 600; +} + +.contributor-filters { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + margin-bottom: 1.5rem; + padding: 1rem; + background: #f6f8fa; + border-radius: 6px; +} + +.search-box input { + padding: 0.5rem; + border: 1px solid #d0d7de; + border-radius: 6px; + width: 250px; +} + +.filter-group { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.filter-group label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.875rem; + color: #57606a; +} + +.filter-group input, +.filter-group select { + padding: 0.375rem; + border: 1px solid #d0d7de; + border-radius: 6px; +} + +.actions { + display: flex; + gap: 0.5rem; + margin-left: auto; +} + +.actions button { + padding: 0.5rem 1rem; + border: 1px solid #d0d7de; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; +} + +.btn-refresh { + background: #f6f8fa; +} + +.btn-export { + background: #0969da; + color: white; + border: none; +} + +.btn-export:hover { + background: #0860ca; +} + +.contributor-matrix { + overflow-x: auto; +} + +.contributor-matrix table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.contributor-matrix th, +.contributor-matrix td { + border: 1px solid #d0d7de; + padding: 0.5rem; text-align: center; -} \ No newline at end of file +} + +.contributor-matrix th { + background: #f6f8fa; + font-weight: 600; +} + +.contributor-matrix .repo-header { + min-width: 80px; +} + +.contributor-matrix .contributor-cell { + text-align: left; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.contributor-matrix .avatar { + width: 24px; + height: 24px; + border-radius: 50%; +} + +.contributor-matrix .activity-cell { + min-width: 40px; + padding: 0.25rem; +} + +.contributor-matrix .activity-cell .count { + font-size: 0.75rem; + color: white; + text-shadow: 0 0 2px rgba(0,0,0,0.5); +} + +.contributor-matrix .total-cell { + font-weight: 600; + background: #f6f8fa; +} + +.matrix-legend { + display: flex; + align-items: center; + gap: 0.25rem; + margin-top: 1rem; + font-size: 0.75rem; + color: #57606a; +} + +.legend-item { + width: 16px; + height: 16px; + border-radius: 2px; +} + +.matrix-empty { + text-align: center; + padding: 3rem; + color: #57606a; +} + +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid #d0d7de; + border-top-color: #0969da; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.error-state { + text-align: center; + padding: 3rem; +} + +.error-message { + color: #cf222e; + margin-bottom: 1rem; +} + +.btn-retry { + padding: 0.5rem 1rem; + background: #0969da; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.github-token-input { + background: #f6f8fa; + border: 1px solid #d0d7de; + border-radius: 6px; + padding: 1rem; + margin-bottom: 1.5rem; +} + +.github-token-input .token-field { + margin-bottom: 0.75rem; +} + +.github-token-input label { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: #1f2328; + margin-bottom: 0.5rem; +} + +.token-input-row { + display: flex; + gap: 0.5rem; +} + +.token-input-row input { + flex: 1; + padding: 0.5rem; + border: 1px solid #d0d7de; + border-radius: 6px; + font-family: monospace; +} + +.toggle-visibility { + padding: 0.5rem 0.75rem; + background: #f6f8fa; + border: 1px solid #d0d7de; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; +} + +.token-actions { + display: flex; + gap: 0.5rem; +} + +.btn-save { + padding: 0.5rem 1rem; + background: #0969da; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; +} + +.btn-save:hover { + background: #0860ca; +} + +.btn-clear { + padding: 0.5rem 1rem; + background: #f6f8fa; + border: 1px solid #d0d7de; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; +} + +.token-help { + margin: 0.5rem 0 0; + font-size: 0.75rem; + color: #57606a; +} + +.rate-limit-warning { + color: #9a6700; + font-weight: normal; +} + +.cache-cleared { + color: #1a7f37; + font-weight: normal; +} + +.header-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + +.btn-clear-cache { + padding: 0.375rem 0.75rem; + background: #f6f8fa; + border: 1px solid #d0d7de; + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; + color: #57606a; +} + +.btn-clear-cache:hover { + background: #f3f4f6; + border-color: #0969da; +} diff --git a/src/App.tsx b/src/App.tsx index 0a3deb1..77762aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,67 @@ -import './App.css' +import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'; +import { ContributorActivityFeature } from './features/contributor-activity'; +import './App.css'; function App() { - return ( - <> -

Hello, OrgExplorer!

- - ) + +
+ + +
+ + } /> + } + /> + +
+
+
+ ); } -export default App +const HomePage: React.FC = () => { + return ( +
+

Welcome to OrgExplorer

+

Track contributor activity across your GitHub organizations.

+ +
+ +

AOSSIE-Org

+

View contributor activity matrix

+ + +

StabilityNexus

+

View contributor activity matrix

+ + +

DjedAlliance

+

View contributor activity matrix

+ +
+
+ ); +}; + +const ContributorActivityFeatureWrapper: React.FC = () => { + const params = new URLSearchParams(window.location.search); + const org = window.location.pathname.split('/').pop() || 'AOSSIE-Org'; + const fromDate = params.get('from') || undefined; + + return ; +}; + +export default App; diff --git a/src/features/contributor-activity/ContributorActivityFeature.tsx b/src/features/contributor-activity/ContributorActivityFeature.tsx new file mode 100644 index 0000000..fb96a47 --- /dev/null +++ b/src/features/contributor-activity/ContributorActivityFeature.tsx @@ -0,0 +1,198 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { + FetchContributorActivityUseCase, + GetContributorStatsUseCase, + FilterContributorsUseCase, + SearchContributorsUseCase, + ExportContributorDataUseCase +} from './useCases'; +import { ContributorMatrix } from './components/ContributorMatrix'; +import { ContributorStats } from './components/ContributorStats'; +import { ContributorFilters } from './components/ContributorFilters'; +import { LoadingState } from './components/LoadingState'; +import { ErrorState } from './components/ErrorState'; +import { GitHubTokenInput } from './components/GitHubTokenInput'; +import { clearCache } from '../../lib/github/client'; +import type { ContributorMatrixData } from '../../lib/github/types'; + +type SortBy = 'contributions' | 'prs' | 'issues' | 'merged'; + +interface FeatureProps { + organization: string; + defaultFromDate?: string; +} + +const fetchUseCase = new FetchContributorActivityUseCase(); +const statsUseCase = new GetContributorStatsUseCase(); +const filterUseCase = new FilterContributorsUseCase(); +const searchUseCase = new SearchContributorsUseCase(); +const exportUseCase = new ExportContributorDataUseCase(); + +export const ContributorActivityFeature: React.FC = ({ + organization, + defaultFromDate, +}) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tokenProvided, setTokenProvided] = useState(false); + const [cacheCleared, setCacheCleared] = useState(false); + + const [searchQuery, setSearchQuery] = useState(''); + const [filters, setFilters] = useState<{ + minContributions: number; + repository: string; + sortBy: SortBy; + }>({ + minContributions: 0, + repository: '', + sortBy: 'contributions', + }); + + const fromDate = useMemo(() => { + if (defaultFromDate) return defaultFromDate; + const date = new Date(); + date.setMonth(date.getMonth() - 1); + return date.toISOString().split('T')[0]; + }, [defaultFromDate]); + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const result = await fetchUseCase.execute(organization, fromDate); + setData(result); + setCacheCleared(false); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch contributor activity'; + + if (message.includes('rate limit')) { + setError(`${message}\n\nTip: Add a GitHub token for higher rate limits (60 → 5000 requests/hour)`); + } else { + setError(message); + } + } finally { + setLoading(false); + } + }, [organization, fromDate]); + + useEffect(() => { + const savedToken = localStorage.getItem('github_token'); + setTokenProvided(!!savedToken); + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleTokenChange = useCallback((token: string) => { + setTokenProvided(!!token); + clearCache(); + loadData(); + }, [loadData]); + + const handleClearCache = useCallback(() => { + clearCache(); + setCacheCleared(true); + loadData(); + }, [loadData]); + + const stats = useMemo(() => { + if (!data) return null; + return statsUseCase.execute(data); + }, [data]); + + const filteredData = useMemo(() => { + if (!data) return null; + + let result = filterUseCase.execute(data, filters); + + if (searchQuery) { + const searchResults = searchUseCase.execute(data, searchQuery); + const searchLogins = new Set(searchResults.map((c: { login: string }) => c.login)); + result = { + ...result, + contributors: result.contributors.filter(c => searchLogins.has(c.login)), + }; + } + + return result; + }, [data, filters, searchQuery]); + + const handleFilterChange = (newFilters: typeof filters) => { + setFilters(newFilters); + }; + + const handleSearchChange = (query: string) => { + setSearchQuery(query); + }; + + const handleRefresh = () => { + loadData(); + }; + + const handleExport = (format: 'csv' | 'json') => { + if (!data) return; + + const content = exportUseCase.execute(data, format); + + const blob = new Blob([content], { type: format === 'json' ? 'application/json' : 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `contributor-activity-${organization}-${new Date().toISOString().split('T')[0]}.${format}`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ + + {loading ? ( + + ) : error ? ( + + ) : !data || !filteredData ? ( + + ) : ( + <> +
+

Contributor Activity Matrix

+

+ Tracking activity for {organization} since {fromDate} + {!tokenProvided && (Add token for more data)} + {cacheCleared && Cache cleared!} +

+
+ +
+ +
+ + {stats && } + + + + + + )} +
+ ); +}; + +export default ContributorActivityFeature; diff --git a/src/features/contributor-activity/components/ContributorFilters.tsx b/src/features/contributor-activity/components/ContributorFilters.tsx new file mode 100644 index 0000000..6705134 --- /dev/null +++ b/src/features/contributor-activity/components/ContributorFilters.tsx @@ -0,0 +1,86 @@ +interface ContributorFiltersProps { + filters: { + minContributions: number; + repository: string; + sortBy: 'contributions' | 'prs' | 'issues' | 'merged'; + }; + onFilterChange: (filters: ContributorFiltersProps['filters']) => void; + searchQuery: string; + onSearchChange: (query: string) => void; + repositories: string[]; + onRefresh: () => void; + onExport: (format: 'csv' | 'json') => void; +} + +export const ContributorFilters: React.FC = ({ + filters, + onFilterChange, + searchQuery, + onSearchChange, + repositories, + onRefresh, + onExport, +}) => { + return ( +
+
+ onSearchChange(e.target.value)} + /> +
+ +
+ + + + + +
+ +
+ + + +
+
+ ); +}; diff --git a/src/features/contributor-activity/components/ContributorMatrix.tsx b/src/features/contributor-activity/components/ContributorMatrix.tsx new file mode 100644 index 0000000..2671552 --- /dev/null +++ b/src/features/contributor-activity/components/ContributorMatrix.tsx @@ -0,0 +1,101 @@ +import type { ContributorMatrixData } from '../../../lib/github/types'; + +interface ContributorMatrixProps { + data: ContributorMatrixData; + allRepositories: string[]; +} + +export const ContributorMatrix: React.FC = ({ + data, + allRepositories, +}) => { + const displayRepos = allRepositories.slice(0, 15); + const hasMoreRepos = allRepositories.length > 15; + + const getActivityCount = (login: string, repo: string): number => { + const contributor = data.contributors.find(c => c.login === login); + return contributor?.repositories.get(repo) || 0; + }; + + const getActivityColor = (count: number): string => { + if (count === 0) return '#f0f0f0'; + if (count === 1) return '#c6e48b'; + if (count <= 3) return '#7bc96f'; + if (count <= 5) return '#239a3b'; + return '#196127'; + }; + + if (data.contributors.length === 0) { + return ( +
+

No contributor activity found for the selected period.

+
+ ); + } + + return ( +
+ + + + + {displayRepos.map(repo => ( + + ))} + {hasMoreRepos && } + + + + + {data.contributors.map(contributor => ( + + + {displayRepos.map(repo => { + const count = getActivityCount(contributor.login, repo); + return ( + + ); + })} + {hasMoreRepos && } + + + ))} + +
Contributor + {repo.length > 10 ? repo.slice(0, 10) + '...' : repo} + ...Total
+ {contributor.login} + + {contributor.login} + + + {count > 0 && {count}} + +{allRepositories.length - 15} + {contributor.totalContributions} +
+ +
+ Less +
+
+
+
+
+ More +
+
+ ); +}; diff --git a/src/features/contributor-activity/components/ContributorStats.tsx b/src/features/contributor-activity/components/ContributorStats.tsx new file mode 100644 index 0000000..91a1c65 --- /dev/null +++ b/src/features/contributor-activity/components/ContributorStats.tsx @@ -0,0 +1,54 @@ +interface ContributorStatsProps { + stats: { + totalContributors: number; + topContributors: Array<{ + login: string; + avatarUrl: string; + totalContributions: number; + pullRequestCount: number; + mergedPRCount: number; + issueCount: number; + }>; + topRepositories: [string, number][]; + averageContributions: number; + }; +} + +export const ContributorStats: React.FC = ({ stats }) => { + return ( +
+
+

Total Contributors

+

{stats.totalContributors}

+
+
+

Avg. Contributions

+

{stats.averageContributions}

+
+
+

Top Contributors

+
    + {stats.topContributors.slice(0, 5).map((contributor, index) => ( +
  • + #{index + 1} + {contributor.login} + {contributor.login} + {contributor.totalContributions} +
  • + ))} +
+
+
+

Most Active Repos

+
    + {stats.topRepositories.slice(0, 5).map(([repo, count]) => ( +
  • + {repo} + {count} +
  • + ))} +
+
+
+ ); +}; diff --git a/src/features/contributor-activity/components/ErrorState.tsx b/src/features/contributor-activity/components/ErrorState.tsx new file mode 100644 index 0000000..10a7195 --- /dev/null +++ b/src/features/contributor-activity/components/ErrorState.tsx @@ -0,0 +1,17 @@ +interface ErrorStateProps { + message: string; + onRetry?: () => void; +} + +export const ErrorState: React.FC = ({ message, onRetry }) => { + return ( +
+

{message}

+ {onRetry && ( + + )} +
+ ); +}; diff --git a/src/features/contributor-activity/components/GitHubTokenInput.tsx b/src/features/contributor-activity/components/GitHubTokenInput.tsx new file mode 100644 index 0000000..08f0163 --- /dev/null +++ b/src/features/contributor-activity/components/GitHubTokenInput.tsx @@ -0,0 +1,71 @@ +import { useState, useEffect, useCallback } from 'react'; + +const TOKEN_KEY = 'github_token'; + +interface GitHubTokenInputProps { + onTokenChange: (token: string) => void; +} + +export const GitHubTokenInput: React.FC = ({ onTokenChange }) => { + const [token, setToken] = useState(''); + const [showToken, setShowToken] = useState(false); + const [saved, setSaved] = useState(false); + + useEffect(() => { + const savedToken = localStorage.getItem(TOKEN_KEY); + if (savedToken) { + onTokenChange(savedToken); + } + }, [onTokenChange]); + + const handleSave = useCallback(() => { + if (token.trim()) { + localStorage.setItem(TOKEN_KEY, token.trim()); + onTokenChange(token.trim()); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } + }, [token, onTokenChange]); + + const handleClear = useCallback(() => { + setToken(''); + localStorage.removeItem(TOKEN_KEY); + onTokenChange(''); + }, [onTokenChange]); + + return ( +
+
+ +
+ setToken(e.target.value)} + placeholder="ghp_xxxxxxxxxxxxxxxxxxxx" + /> + +
+
+
+ + {token && ( + + )} +
+

+ Get a token at: Settings → Developer settings → Personal access tokens +

+
+ ); +}; diff --git a/src/features/contributor-activity/components/LoadingState.tsx b/src/features/contributor-activity/components/LoadingState.tsx new file mode 100644 index 0000000..c0b2984 --- /dev/null +++ b/src/features/contributor-activity/components/LoadingState.tsx @@ -0,0 +1,8 @@ +export const LoadingState: React.FC = () => { + return ( +
+
+

Loading contributor activity...

+
+ ); +}; diff --git a/src/features/contributor-activity/index.ts b/src/features/contributor-activity/index.ts new file mode 100644 index 0000000..9cab921 --- /dev/null +++ b/src/features/contributor-activity/index.ts @@ -0,0 +1,6 @@ +export { ContributorActivityFeature } from './ContributorActivityFeature'; +export { ContributorMatrix } from './components/ContributorMatrix'; +export { ContributorStats } from './components/ContributorStats'; +export { ContributorFilters } from './components/ContributorFilters'; +export { LoadingState } from './components/LoadingState'; +export { ErrorState } from './components/ErrorState'; diff --git a/src/features/contributor-activity/useCases.ts b/src/features/contributor-activity/useCases.ts new file mode 100644 index 0000000..9a21be7 --- /dev/null +++ b/src/features/contributor-activity/useCases.ts @@ -0,0 +1,100 @@ +import { fetchOrgActivity, calculateContributorStats } from '../../lib/github/api'; +import type { ContributorMatrixData } from '../../lib/github/types'; + +export class FetchContributorActivityUseCase { + async execute(org: string, fromDate: string): Promise { + return fetchOrgActivity(org, fromDate); + } +} + +export class GetContributorStatsUseCase { + execute(data: ContributorMatrixData) { + return calculateContributorStats(data); + } +} + +export class FilterContributorsUseCase { + execute( + data: ContributorMatrixData, + filters: { + minContributions?: number; + repository?: string; + sortBy?: 'contributions' | 'prs' | 'issues' | 'merged'; + } + ) { + let contributors = [...data.contributors]; + + if (filters.minContributions && filters.minContributions > 0) { + contributors = contributors.filter( + c => c.totalContributions >= filters.minContributions! + ); + } + + if (filters.repository) { + contributors = contributors.filter( + c => c.repositories.has(filters.repository!) + ); + } + + switch (filters.sortBy) { + case 'prs': + contributors.sort((a, b) => b.pullRequestCount - a.pullRequestCount); + break; + case 'issues': + contributors.sort((a, b) => b.issueCount - a.issueCount); + break; + case 'merged': + contributors.sort((a, b) => b.mergedPRCount - a.mergedPRCount); + break; + default: + contributors.sort((a, b) => b.totalContributions - a.totalContributions); + } + + return { + ...data, + contributors, + }; + } +} + +export class SearchContributorsUseCase { + execute(data: ContributorMatrixData, query: string) { + const normalizedQuery = query.toLowerCase().trim(); + + if (!normalizedQuery) { + return data.contributors; + } + + return data.contributors.filter( + c => c.login.toLowerCase().includes(normalizedQuery) + ); + } +} + +export class GetContributorDetailsUseCase { + execute(data: ContributorMatrixData, login: string) { + return data.contributors.find(c => c.login === login) || null; + } +} + +export class ExportContributorDataUseCase { + execute(data: ContributorMatrixData, format: 'csv' | 'json') { + if (format === 'json') { + return JSON.stringify(data, null, 2); + } + + const headers = ['Login', 'Total Contributions', 'PRs', 'Merged PRs', 'Issues', 'Repositories']; + const rows = data.contributors.map(c => [ + c.login, + c.totalContributions.toString(), + c.pullRequestCount.toString(), + c.mergedPRCount.toString(), + c.issueCount.toString(), + Array.from(c.repositories.entries()) + .map(([repo, count]) => `${repo}(${count})`) + .join('; ') + ]); + + return [headers, ...rows].map(row => row.join(',')).join('\n'); + } +} diff --git a/src/index.css b/src/index.css index e0dbee4..9a436e4 100644 --- a/src/index.css +++ b/src/index.css @@ -3,9 +3,9 @@ line-height: 1.5; font-weight: 400; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + color-scheme: light; + color: #1f2328; + background-color: #ffffff; font-synthesis: none; text-rendering: optimizeLegibility; @@ -13,3 +13,12 @@ -moz-osx-font-smoothing: grayscale; } +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +#root { + min-height: 100vh; +} diff --git a/src/lib/github/api.ts b/src/lib/github/api.ts new file mode 100644 index 0000000..e96dc18 --- /dev/null +++ b/src/lib/github/api.ts @@ -0,0 +1,217 @@ +import { searchAllPRs, searchAllIssues, fetchAllOrgRepositories, getRateLimit, type RateLimitInfo, type GitHubSearchItem, type GitHubRepository } from './client'; +import type { ContributorMatrixData, ContributorSummary } from './types'; + +export const fetchOrgActivity = async ( + org: string, + fromDate: string, + maxResults: number = 500 +): Promise => { + try { + const [allPullRequests, allIssues, allRepos] = await Promise.all([ + searchAllPRs(org, fromDate, maxResults), + searchAllIssues(org, fromDate, maxResults), + fetchAllOrgRepositories(org, 300) + ]); + + return transformToContributorMatrix(allPullRequests, allIssues, allRepos, fromDate); + } catch (error) { + console.error('Error fetching org activity:', error); + throw error; + } +}; + +interface ContributorData { + login: string; + avatarUrl: string; + pullRequestCount: number; + issueCount: number; + mergedPRCount: number; + closedPRCount: number; + openPRCount: number; + repositories: Map; + totalContributions: number; +} + +const transformToContributorMatrix = ( + pullRequests: GitHubSearchItem[], + issues: GitHubSearchItem[], + allRepos: GitHubRepository[], + fromDate: string +): ContributorMatrixData => { + const contributorMap = new Map(); + const repoSet = new Set(allRepos.map(r => r.name)); + const activityMatrix = new Map>(); + + pullRequests.forEach(pr => { + if (!pr.user) return; + + const login = pr.user.login; + const repoName = pr.repository_url?.split('/').pop() || ''; + repoSet.add(repoName); + + if (!contributorMap.has(login)) { + contributorMap.set(login, { + login, + avatarUrl: pr.user.avatar_url, + pullRequestCount: 0, + issueCount: 0, + mergedPRCount: 0, + closedPRCount: 0, + openPRCount: 0, + repositories: new Map(), + totalContributions: 0, + }); + } + + const contributor = contributorMap.get(login)!; + contributor.pullRequestCount++; + contributor.totalContributions++; + + const isMerged = pr.state === 'closed' && pr.merged_at; + const isClosed = pr.state === 'closed' && !pr.merged_at; + const isOpen = pr.state === 'open'; + + if (isMerged) { + contributor.mergedPRCount++; + } else if (isClosed) { + contributor.closedPRCount++; + } else if (isOpen) { + contributor.openPRCount++; + } + + if (!contributor.repositories.has(repoName)) { + contributor.repositories.set(repoName, { + prs: 0, + merged: 0, + closed: 0, + open: 0, + issues: 0, + }); + } + const repoData = contributor.repositories.get(repoName)!; + repoData.prs++; + if (isMerged) repoData.merged++; + if (isClosed) repoData.closed++; + if (isOpen) repoData.open++; + + if (!activityMatrix.has(login)) { + activityMatrix.set(login, new Map()); + } + const repoActivity = activityMatrix.get(login)!; + const count = repoActivity.get(repoName) || 0; + repoActivity.set(repoName, count + 1); + }); + + issues.forEach(issue => { + if (!issue.user) return; + + const login = issue.user.login; + const repoName = issue.repository_url?.split('/').pop() || ''; + repoSet.add(repoName); + + if (!contributorMap.has(login)) { + contributorMap.set(login, { + login, + avatarUrl: issue.user.avatar_url, + pullRequestCount: 0, + issueCount: 0, + mergedPRCount: 0, + closedPRCount: 0, + openPRCount: 0, + repositories: new Map(), + totalContributions: 0, + }); + } + + const contributor = contributorMap.get(login)!; + contributor.issueCount++; + contributor.totalContributions++; + + if (!contributor.repositories.has(repoName)) { + contributor.repositories.set(repoName, { + prs: 0, + merged: 0, + closed: 0, + open: 0, + issues: 0, + }); + } + const repoData = contributor.repositories.get(repoName)!; + repoData.issues++; + + if (!activityMatrix.has(login)) { + activityMatrix.set(login, new Map()); + } + const repoActivity = activityMatrix.get(login)!; + const count = repoActivity.get(repoName) || 0; + repoActivity.set(repoName, count + 1); + }); + + const contributors: ContributorSummary[] = Array.from(contributorMap.values()) + .map(c => ({ + login: c.login, + avatarUrl: c.avatarUrl, + pullRequestCount: c.pullRequestCount, + issueCount: c.issueCount, + mergedPRCount: c.mergedPRCount, + repositories: new Map( + Array.from(c.repositories.entries()).map(([repo, data]) => [ + repo, + data.prs + data.issues + ]) + ), + totalContributions: c.totalContributions, + })) + .sort((a, b) => b.totalContributions - a.totalContributions); + + const repositories = Array.from(repoSet).sort(); + + return { + contributors, + repositories, + activityByContributor: activityMatrix, + totalPRs: pullRequests.length, + totalIssues: issues.length, + dateRange: { + start: fromDate, + end: new Date().toISOString().split('T')[0], + }, + }; +}; + +export const calculateContributorStats = (data: ContributorMatrixData) => { + const { contributors } = data; + + const topContributors = contributors.slice(0, 10); + const mostActiveRepo = new Map(); + + contributors.forEach(c => { + c.repositories.forEach((count, repo) => { + const current = mostActiveRepo.get(repo) || 0; + mostActiveRepo.set(repo, current + count); + }); + }); + + const topRepositories = Array.from(mostActiveRepo.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + return { + totalContributors: contributors.length, + topContributors, + topRepositories, + averageContributions: contributors.length > 0 + ? Math.round(contributors.reduce((sum, c) => sum + c.totalContributions, 0) / contributors.length) + : 0, + }; +}; + +export const checkRateLimit = async (): Promise => { + return getRateLimit(); +}; diff --git a/src/lib/github/client.ts b/src/lib/github/client.ts new file mode 100644 index 0000000..798d447 --- /dev/null +++ b/src/lib/github/client.ts @@ -0,0 +1,409 @@ +const GITHUB_API_BASE = 'https://api.github.com'; + +export interface RateLimitInfo { + limit: number; + remaining: number; + reset: number; +} + +const CACHE_KEY = 'github_api_cache_'; +const CACHE_DURATION = 5 * 60 * 1000; + +interface CacheData { + data: unknown; + timestamp: number; +} + +const getCachedData = (key: string): unknown | null => { + try { + const cached = sessionStorage.getItem(CACHE_KEY + key); + if (cached) { + const parsed: CacheData = JSON.parse(cached); + if (Date.now() - parsed.timestamp < CACHE_DURATION) { + return parsed.data; + } + sessionStorage.removeItem(CACHE_KEY + key); + } + } catch { + // Ignore cache errors + } + return null; +}; + +const setCachedData = (key: string, data: unknown): void => { + try { + const cacheData: CacheData = { + data, + timestamp: Date.now(), + }; + sessionStorage.setItem(CACHE_KEY + key, JSON.stringify(cacheData)); + } catch { + // Ignore cache errors + } +}; + +export const clearCache = (): void => { + try { + const keys = Object.keys(sessionStorage); + keys.forEach(key => { + if (key.startsWith(CACHE_KEY)) { + sessionStorage.removeItem(key); + } + }); + } catch { + // Ignore + } +}; + +const getHeaders = (): HeadersInit => { + const headers: HeadersInit = { + 'Accept': 'application/vnd.github.v3+json', + }; + + const token = localStorage.getItem('github_token'); + if (token) { + headers['Authorization'] = `token ${token}`; + } + + return headers; +}; + +export const getRateLimit = async (): Promise => { + try { + const response = await fetch(`${GITHUB_API_BASE}/rate_limit`, { + headers: getHeaders(), + }); + + if (response.ok) { + const data = await response.json(); + return { + limit: data.rate.limit, + remaining: data.rate.remaining, + reset: data.rate.reset, + }; + } + } catch (error) { + console.error('Failed to get rate limit:', error); + } + + const hasToken = !!localStorage.getItem('github_token'); + return { + limit: hasToken ? 5000 : 60, + remaining: hasToken ? 5000 : 60, + reset: 0 + }; +}; + +export interface GitHubRepository { + id: number; + name: string; + full_name: string; + html_url: string; +} + +export interface GitHubSearchItem { + id: number; + title: string; + state: 'open' | 'closed'; + created_at: string; + html_url: string; + user: { + login: string; + avatar_url: string; + html_url: string; + } | null; + repository_url?: string; + merged_at?: string | null; + pull_request?: { + url: string; + html_url: string; + state: string; + }; +} + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const fetchWithRetry = async ( + url: string, + options: RequestInit = {}, + maxRetries: number = 3 +): Promise => { + let lastError: Error | null = null; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + const rateLimit = await getRateLimit(); + + if (rateLimit.remaining < 1) { + const waitTime = (rateLimit.reset - Date.now() / 1000) * 1000; + if (waitTime > 0 && waitTime < 60000) { + console.log(`Rate limit hit. Waiting ${Math.round(waitTime / 1000)}s...`); + await delay(waitTime); + continue; + } + } + + try { + const response = await fetch(url, options); + + if (response.status === 403) { + const errorData = await response.json().catch(() => ({})); + if (errorData.message?.includes('rate limit')) { + lastError = new Error('Rate limit exceeded'); + const waitTime = (rateLimit.reset - Date.now() / 1000) * 1000; + if (waitTime > 0 && waitTime < 60000 && attempt < maxRetries - 1) { + console.log(`Rate limited. Waiting ${Math.round(waitTime / 1000)}s...`); + await delay(waitTime); + continue; + } + throw new Error(`API rate limit exceeded. ${!localStorage.getItem('github_token') ? 'Add a GitHub token for higher limits.' : ''}`); + } + } + + return response; + } catch (error) { + lastError = error instanceof Error ? error : new Error('Unknown error'); + if (attempt < maxRetries - 1) { + await delay(1000 * (attempt + 1)); + } + } + } + + throw lastError || new Error('Failed after retries'); +}; + +export const fetchAllOrgRepositories = async ( + org: string, + maxRepos: number = 300 +): Promise => { + const cacheKey = `repos_${org}_${maxRepos}`; + const cached = getCachedData(cacheKey); + if (cached) { + return cached as GitHubRepository[]; + } + + const allRepos: GitHubRepository[] = []; + let page = 1; + const perPage = 100; + + while (allRepos.length < maxRepos) { + const response = await fetchWithRetry( + `${GITHUB_API_BASE}/orgs/${org}/repos?page=${page}&per_page=${perPage}&sort=updated&type=all`, + { headers: getHeaders() } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to fetch repositories: ${response.status} - ${errorText}`); + } + + const repos = await response.json(); + + if (!repos || repos.length === 0) { + break; + } + + allRepos.push(...repos); + + if (repos.length < perPage || allRepos.length >= maxRepos) { + break; + } + + page++; + await delay(200); + } + + const result = allRepos.slice(0, maxRepos); + setCachedData(cacheKey, result); + return result; +}; + +export const searchAllPRs = async ( + org: string, + sinceDate?: string, + maxResults: number = 500 +): Promise => { + const cacheKey = `prs_${org}_${sinceDate}_${maxResults}`; + const cached = getCachedData(cacheKey); + if (cached) { + return cached as GitHubSearchItem[]; + } + + const allPRs: GitHubSearchItem[] = []; + let page = 1; + const perPage = 100; + + while (allPRs.length < maxResults) { + const query = `org:${org} is:pr${sinceDate ? ` created:>${sinceDate}` : ''}`; + + const response = await fetchWithRetry( + `${GITHUB_API_BASE}/search/issues?q=${encodeURIComponent(query)}&page=${page}&per_page=${perPage}&sort=created&order=desc`, + { headers: getHeaders() } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to search PRs: ${response.status} - ${errorText}`); + } + + const data = await response.json(); + + if (!data.items || data.items.length === 0) { + break; + } + + allPRs.push(...data.items); + + if (data.items.length < perPage || allPRs.length >= maxResults) { + break; + } + + page++; + await delay(300); + } + + const result = allPRs.slice(0, maxResults); + setCachedData(cacheKey, result); + return result; +}; + +export const searchAllIssues = async ( + org: string, + sinceDate?: string, + maxResults: number = 500 +): Promise => { + const cacheKey = `issues_${org}_${sinceDate}_${maxResults}`; + const cached = getCachedData(cacheKey); + if (cached) { + return cached as GitHubSearchItem[]; + } + + const allIssues: GitHubSearchItem[] = []; + let page = 1; + const perPage = 100; + + while (allIssues.length < maxResults) { + const query = `org:${org} is:issue is:open${sinceDate ? ` created:>${sinceDate}` : ''}`; + + const response = await fetchWithRetry( + `${GITHUB_API_BASE}/search/issues?q=${encodeURIComponent(query)}&page=${page}&per_page=${perPage}&sort=created&order=desc`, + { headers: getHeaders() } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to search issues: ${response.status} - ${errorText}`); + } + + const data = await response.json(); + + if (!data.items || data.items.length === 0) { + break; + } + + allIssues.push(...data.items); + + if (data.items.length < perPage || allIssues.length >= maxResults) { + break; + } + + page++; + await delay(300); + } + + const result = allIssues.slice(0, maxResults); + setCachedData(cacheKey, result); + return result; +}; + +export const searchRepositories = async ( + org: string, + page: number = 1, + perPage: number = 100 +): Promise<{ repos: GitHubRepository[]; hasMore: boolean }> => { + const response = await fetchWithRetry( + `${GITHUB_API_BASE}/orgs/${org}/repos?page=${page}&per_page=${perPage}&sort=updated`, + { headers: getHeaders() } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch repositories: ${response.statusText}`); + } + + const repos = await response.json(); + const linkHeader = response.headers.get('Link'); + const hasMore = linkHeader?.includes('rel="next"') || false; + + return { repos, hasMore }; +}; + +export const searchPullRequests = async ( + org: string, + state: string = 'all', + sinceDate?: string, + page: number = 1, + perPage: number = 100 +): Promise<{ prs: GitHubSearchItem[]; hasMore: boolean }> => { + let query = `org:${org} is:pr`; + + if (state === 'merged') { + query += ' is:merged'; + } else if (state === 'open') { + query += ' is:open'; + } + + if (sinceDate) { + query += ` created:>${sinceDate}`; + } + + const response = await fetchWithRetry( + `${GITHUB_API_BASE}/search/issues?q=${encodeURIComponent(query)}&page=${page}&per_page=${perPage}&sort=created&order=desc`, + { headers: getHeaders() } + ); + + if (!response.ok) { + throw new Error(`Failed to search pull requests: ${response.statusText}`); + } + + const data = await response.json(); + + return { + prs: data.items || [], + hasMore: page * perPage < data.total_count, + }; +}; + +export const searchIssues = async ( + org: string, + state: string = 'open', + sinceDate?: string, + page: number = 1, + perPage: number = 100 +): Promise<{ issues: GitHubSearchItem[]; hasMore: boolean }> => { + let query = `org:${org} is:issue`; + + if (state === 'open') { + query += ' is:open'; + } else if (state === 'closed') { + query += ' is:closed'; + } + + if (sinceDate) { + query += ` created:>${sinceDate}`; + } + + const response = await fetchWithRetry( + `${GITHUB_API_BASE}/search/issues?q=${encodeURIComponent(query)}&page=${page}&per_page=${perPage}&sort=created&order=desc`, + { headers: getHeaders() } + ); + + if (!response.ok) { + throw new Error(`Failed to search issues: ${response.statusText}`); + } + + const data = await response.json(); + + return { + issues: data.items || [], + hasMore: page * perPage < data.total_count, + }; +}; diff --git a/src/lib/github/types.ts b/src/lib/github/types.ts new file mode 100644 index 0000000..d0a2198 --- /dev/null +++ b/src/lib/github/types.ts @@ -0,0 +1,60 @@ +export interface Repository { + name: string; + full_name: string; + html_url: string; +} + +export interface Author { + login: string; + avatar_url: string; + html_url: string; +} + +export interface PullRequest { + id: number; + title: string; + repository: Repository; + user: Author; + created_at: string; + html_url: string; + state: string; + merged_at?: string; +} + +export interface Issue { + id: number; + title: string; + repository: Repository; + user: Author; + created_at: string; + html_url: string; + state: string; +} + +export interface ContributorSummary { + login: string; + avatarUrl: string; + pullRequestCount: number; + issueCount: number; + mergedPRCount: number; + repositories: Map; + totalContributions: number; +} + +export interface ContributorMatrixData { + contributors: ContributorSummary[]; + repositories: string[]; + activityByContributor: Map>; + totalPRs: number; + totalIssues: number; + dateRange: { + start: string; + end: string; + }; +} + +export interface RateLimitInfo { + limit: number; + remaining: number; + reset: number; +}