diff --git a/src/App.jsx b/src/App.jsx index e5e12d6..1afbbee 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,6 +8,7 @@ import HomePage from './pages/HomePage' import OverviewPage from './pages/OverviewPage' import RepositoriesPage from './pages/RepositoriesPage' import ContributorsPage from './pages/ContributorsPage' +import ContributorProfilePage from './pages/ContributorProfilePage' import NetworkPage from './pages/NetworkPage' import AnalyticsPage from './pages/AnalyticsPage' import GovernancePage from './pages/GovernancePage' @@ -33,6 +34,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/ContributorProfilePage.jsx b/src/pages/ContributorProfilePage.jsx new file mode 100644 index 0000000..55153b4 --- /dev/null +++ b/src/pages/ContributorProfilePage.jsx @@ -0,0 +1,593 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { FiArrowLeft, FiDownload, FiExternalLink, FiCalendar, FiBriefcase, FiAlertTriangle } from 'react-icons/fi' +import { useApp } from '../context/AppContext' +import { C, PageTitle, Spinner, StatCard } from '../components/UI' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' + +// Reusable ContributionTable component +function ContributionTable({ items, dateHeader, resolveStatus }) { + if (!items.length) { + return ( +
+ +
No items found for this reporting period.
+
+ ) + } + + return ( +
+ + + + + + + + + + + + {items.map((item, i) => { + const { status, color, bg } = resolveStatus(item) + return ( + + + + + + + + ) + })} + +
TITLE / NUMBERREPOSITORY{dateHeader}STATUSLINK
+
{item.title}
+ #{item.number} +
+ {item.repoName} + {item.created_at.slice(0, 10)} + {status.toUpperCase()} + + + GitHub + +
+
+ ) +} + +// Pagination helper to fetch up to 10 pages (1,000 results maximum) +async function fetchAllPages(initialUrl, headers, signal) { + let items = [] + let url = initialUrl + for (let page = 1; page <= 10; page++) { + const res = await fetch(url, { headers, signal }) + if (res.status === 403) { + throw new Error('RATE_LIMIT') + } + if (!res.ok) { + throw new Error(`HTTP_${res.status}`) + } + const data = await res.json() + items = items.concat(data.items || []) + + const linkHeader = res.headers.get('Link') + if (!linkHeader) break + + const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/) + if (!match) break + + url = match[1] + } + return items +} + +// Helper to escape table cell values for markdown +const cell = (val) => { + if (val === null || val === undefined) return '' + return String(val) + .replace(/\r?\n/g, ' ') + .replace(/\|/g, '\\|') +} + +// Helper to extract owner/repo from GitHub API repository URL +const getFullRepoFromUrl = (url) => { + if (!url) return '' + const parts = url.split('/') + return parts.slice(-2).join('/') +} + +export default function ContributorProfilePage() { + const { username } = useParams() + const navigate = useNavigate() + const { orgs, pat, pullsData } = useApp() + + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [rawContributions, setRawContributions] = useState([]) + const [mergedPRKeys, setMergedPRKeys] = useState(new Set()) + const [tab, setTab] = useState('prs') + + // Date Range Filters (Defaults to Last 1 Year) + const [startDate, setStartDate] = useState(() => { + const d = new Date() + d.setFullYear(d.getFullYear() - 1) + const pad = (n) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + }) + const [endDate, setEndDate] = useState(() => { + const d = new Date() + const pad = (n) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + }) + + // Determine organizations to search with hardened local storage fallback + const searchOrgs = useMemo(() => { + let list = orgs.map(o => o.login) + if (!list.length) { + try { + const rawRecent = localStorage.getItem('oe_recent') + if (rawRecent) { + const recent = JSON.parse(rawRecent) + if (Array.isArray(recent) && recent.length && typeof recent[0] === 'string') { + list = recent[0].split(',').map(s => s.trim()).filter(Boolean) + } + } + } catch (e) { + console.error('Failed to parse oe_recent from localStorage:', e) + } + } + return list + }, [orgs]) + + // Fetch contributor issues & PRs from GitHub Search API (Supports pagination & cleanup) + useEffect(() => { + // Reset data states to prevent rendering stale profile info + setRawContributions([]) + setMergedPRKeys(new Set()) + + if (!username) { + setLoading(false) + return + } + + if (!searchOrgs.length) { + setError('No organizations found. Please explore at least one organization to view the contributor profile.') + setLoading(false) + return + } + + let active = true + const controller = new AbortController() + + async function fetchData() { + setLoading(true) + setError('') + try { + const encodedUser = encodeURIComponent(username) + const orgQuery = searchOrgs.map(org => `org:${encodeURIComponent(org)}`).join('+') + const url = `https://api.github.com/search/issues?q=author:${encodedUser}+${orgQuery}&per_page=100` + const mergedUrl = `https://api.github.com/search/issues?q=author:${encodedUser}+is:pr+is:merged+${orgQuery}&per_page=100` + + const headers = { Accept: 'application/vnd.github.v3+json' } + if (pat) { + headers.Authorization = `token ${pat}` + } + + const [items, mergedItems] = await Promise.all([ + fetchAllPages(url, headers, controller.signal), + fetchAllPages(mergedUrl, headers, controller.signal) + ]) + + if (!active) return + + const mergedKeys = new Set( + mergedItems.map(item => { + const repo = getFullRepoFromUrl(item.repository_url) + return `${repo}/${item.number}` + }) + ) + + setMergedPRKeys(mergedKeys) + setRawContributions(items) + } catch (err) { + if (!active) return + if (err.name === 'AbortError') return + if (err.message === 'RATE_LIMIT') { + setError('GitHub API search rate limit reached. Please wait a minute or configure a PAT in Settings.') + } else { + setError(`Failed to fetch contributor details: ${err.message}`) + } + } finally { + if (active) { + setLoading(false) + } + } + } + + fetchData() + + return () => { + active = false + controller.abort() + } + }, [username, searchOrgs, pat]) + + // Presets using local date offsets + const setPreset = (type) => { + const d = new Date() + const pad = (n) => String(n).padStart(2, '0') + const todayStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + if (type === 'week') { + const lastWeek = new Date(d) + lastWeek.setDate(lastWeek.getDate() - 6) + const lastWeekStr = `${lastWeek.getFullYear()}-${pad(lastWeek.getMonth() + 1)}-${pad(lastWeek.getDate())}` + setStartDate(lastWeekStr) + setEndDate(todayStr) + } else if (type === 'month') { + const thirtyDaysAgo = new Date(d) + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29) + const thirtyDaysAgoStr = `${thirtyDaysAgo.getFullYear()}-${pad(thirtyDaysAgo.getMonth() + 1)}-${pad(thirtyDaysAgo.getDate())}` + setStartDate(thirtyDaysAgoStr) + setEndDate(todayStr) + } else if (type === 'year') { + const lastYear = new Date() + lastYear.setFullYear(d.getFullYear() - 1) + const lastYearStr = `${lastYear.getFullYear()}-${pad(lastYear.getMonth() + 1)}-${pad(lastYear.getDate())}` + setStartDate(lastYearStr) + setEndDate(todayStr) + } else { + setStartDate('') + setEndDate('') + } + } + + // Filter contributions by UTC date range limits + const filteredContribs = useMemo(() => { + return rawContributions.filter(item => { + const itemTime = new Date(item.created_at).getTime() + if (startDate) { + const startTime = Date.parse(startDate + 'T00:00:00.000Z') + if (isNaN(startTime) || itemTime < startTime) return false + } + if (endDate) { + const endTime = Date.parse(endDate + 'T23:59:59.999Z') + if (isNaN(endTime) || itemTime > endTime) return false + } + return true + }) + }, [rawContributions, startDate, endDate]) + + // Categorize contributions (Precomputes pulls calculations outside loop) + const { prs, issues } = useMemo(() => { + const prList = [] + const issueList = [] + const localPulls = Object.values(pullsData || {}).flat() + + filteredContribs.forEach(item => { + const repoName = item.repository_url ? item.repository_url.split('/').pop() : 'Unknown' + const parsedItem = { + ...item, + repoName, + isPR: Boolean(item.pull_request), + } + + if (parsedItem.isPR) { + let merged = false + // Prioritize explicit merged_at property on PR item + if (item.pull_request?.merged_at !== undefined && item.pull_request?.merged_at !== null) { + merged = true + } else { + const fullRepo = getFullRepoFromUrl(item.repository_url) + const localMatch = localPulls.find(p => p.number === item.number && p.base?.repo?.full_name === fullRepo) + if (localMatch) { + merged = Boolean(localMatch.merged_at) + } else { + merged = mergedPRKeys.has(`${fullRepo}/${item.number}`) + } + } + + prList.push({ + ...parsedItem, + isMerged: merged, + }) + } else { + issueList.push(parsedItem) + } + }) + + return { prs: prList, issues: issueList } + }, [filteredContribs, pullsData, mergedPRKeys]) + + // Time-series charting data (Chronological sorting by YYYY-MM) + const chartData = useMemo(() => { + const monthlyBuckets = {} + + filteredContribs.forEach(item => { + const date = new Date(item.created_at) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const yyyymm = `${year}-${month}` + const displayName = date.toLocaleString('default', { month: 'short', year: '2-digit' }) // e.g. "May 26" + + if (!monthlyBuckets[yyyymm]) { + monthlyBuckets[yyyymm] = { yyyymm, name: displayName, PRs: 0, Issues: 0 } + } + + if (item.pull_request) { + monthlyBuckets[yyyymm].PRs++ + } else { + monthlyBuckets[yyyymm].Issues++ + } + }) + + return Object.values(monthlyBuckets).sort((a, b) => a.yyyymm.localeCompare(b.yyyymm)) + }, [filteredContribs]) + + // Export to Markdown Report with pipe & newline escaping + const exportMarkdown = () => { + const dateStr = new Date().toLocaleDateString() + const orgsStr = searchOrgs.join(', ') + const dateRangeStr = (startDate || 'Beginning') + ' to ' + (endDate || 'Present') + + let md = `# Contribution Report: ${username}\n\n` + md += `* **Generated on:** ${dateStr}\n` + md += `* **Organizations explored:** ${orgsStr}\n` + md += `* **Reporting Period:** ${dateRangeStr}\n\n` + + md += `## 📊 Executive Summary\n\n` + md += `| Contribution Metric | Count |\n` + md += `| :--- | :---: |\n` + md += `| **Total Pull Requests** | ${prs.length} |\n` + md += `| **Total Issues Opened** | ${issues.length} |\n` + md += `| **Merged Pull Requests** | ${prs.filter(p => p.isMerged).length} |\n\n` + + md += `## 🚀 Pull Requests (${prs.length})\n\n` + if (prs.length) { + md += `| Repository | PR # | Title | Date | Status | Link |\n` + md += `| :--- | :--- | :--- | :--- | :--- | :--- |\n` + prs.forEach(p => { + const status = p.state === 'open' ? 'Open' : p.isMerged ? 'Merged' : 'Closed' + const date = p.created_at.slice(0, 10) + md += `| ${cell(p.repoName)} | #${p.number} | ${cell(p.title)} | ${date} | **${status}** | [PR Link](${p.html_url}) |\n` + }) + } else { + md += `No pull requests recorded in this period.\n` + } + md += `\n` + + md += `## 🐛 Issues Opened (${issues.length})\n\n` + if (issues.length) { + md += `| Repository | Issue # | Title | Date | Status | Link |\n` + md += `| :--- | :--- | :--- | :--- | :--- | :--- |\n` + issues.forEach(i => { + const status = i.state === 'open' ? 'Open' : 'Closed' + const date = i.created_at.slice(0, 10) + md += `| ${cell(i.repoName)} | #${i.number} | ${cell(i.title)} | ${date} | **${status}** | [Issue Link](${i.html_url}) |\n` + }) + } else { + md += `No issues opened in this period.\n` + } + md += `\n` + + md += `---\n` + md += `*Report generated automatically by **OrgExplorer**.*\n` + + // File download trigger (Hardened cleanup & revocation) + const blob = new Blob([md], { type: 'text/markdown' }) + const url = URL.createObjectURL(blob) + const a = Object.assign(document.createElement('a'), { + href: url, + download: `contribution-report-${username}-${new Date().toISOString().slice(0, 10)}.md` + }) + document.body.appendChild(a) + a.click() + setTimeout(() => { + document.body.removeChild(a) + URL.revokeObjectURL(url) + }, 100) + } + + if (loading) { + return ( +
+ +

Analyzing developer workspace history...

+
+ ) + } + + return ( +
+ {/* Back navigation & page header */} +
+ +
+ + + Export Contribution Report (.md) + + } + /> + + {error && ( +
+ + {error} +
+ )} + + {/* Date Filters Card */} +
+
+
+ + Reporting Window & Date Presets +
+
+ + + + +
+
+ +
+
+ + setStartDate(e.target.value)} + style={C.input} + /> +
+ to +
+ + setEndDate(e.target.value)} + style={C.input} + /> +
+
+
+ + {/* Key Metrics Stats Grid */} +
+ + p.isMerged).length} Merged`} accent="var(--blue)" /> + i.state === 'closed').length} Closed`} accent="var(--amber)" /> + i.repository_url?.split('/').pop())).size} + sub="distinct repositories" + accent="var(--green)" + /> +
+ + {/* Visual Activity Timeline Chart */} + {chartData.length > 0 ? ( +
+
Contribution Velocity Over Time
+
+ + + + + + + + + + +
+
+ + Pull Requests + + + Issues + +
+
+ ) : null} + + {/* Tabs for details list */} +
+
+ + +
+ + {tab === 'prs' ? ( + { + const status = p.state === 'open' ? 'Open' : p.isMerged ? 'Merged' : 'Closed' + const color = status === 'Merged' ? 'var(--green)' : status === 'Open' ? 'var(--blue)' : 'var(--text2)' + const bg = status === 'Merged' ? 'rgba(34,197,94,.12)' : status === 'Open' ? 'rgba(59,130,246,.12)' : 'var(--surface2)' + return { status, color, bg } + }} + /> + ) : ( + { + const status = i.state === 'open' ? 'Open' : 'Closed' + const color = status === 'Open' ? 'var(--blue)' : 'var(--text2)' + const bg = status === 'Open' ? 'rgba(59,130,246,.12)' : 'var(--surface2)' + return { status, color, bg } + }} + /> + )} +
+
+ ) +} diff --git a/src/pages/ContributorsPage.jsx b/src/pages/ContributorsPage.jsx index 436b0e5..e89fd97 100644 --- a/src/pages/ContributorsPage.jsx +++ b/src/pages/ContributorsPage.jsx @@ -1,10 +1,10 @@ import React, { useState, useMemo, useEffect, useRef } from 'react' -import { FiDatabase, FiDownload } from 'react-icons/fi' +import { FiDatabase, FiDownload, FiExternalLink } from 'react-icons/fi' import { useApp } from '../context/AppContext' import { C, SortTh, PageTitle, LoadMore } from '../components/UI' import { useSortedData } from '../hooks/useSortedData' import { computeBusFactor, exportContributorsCSV } from '../services/analytics' -import { useNavigate } from 'react-router-dom' +import { useNavigate, Link } from 'react-router-dom' import EmptyStateCard from '../components/EmptyStateCard' import { AiOutlineInfoCircle } from "react-icons/ai"; import AnalysisBanner from '../components/AnalysisBanner' @@ -317,23 +317,34 @@ export default function ContributorsPage() { {visible.map((c, i) => ( - -