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 (
+
+
+
+
+ | Contributor |
+ {displayRepos.map(repo => (
+
+ {repo.length > 10 ? repo.slice(0, 10) + '...' : repo}
+ |
+ ))}
+ {hasMoreRepos && ... | }
+ Total |
+
+
+
+ {data.contributors.map(contributor => (
+
+
+
+
+ {contributor.login}
+
+ |
+ {displayRepos.map(repo => {
+ const count = getActivityCount(contributor.login, repo);
+ return (
+
+ {count > 0 && {count}}
+ |
+ );
+ })}
+ {hasMoreRepos && +{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.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;
+}