diff --git a/src/components/ContributorExpertiseInsights.jsx b/src/components/ContributorExpertiseInsights.jsx new file mode 100644 index 0000000..d71795a --- /dev/null +++ b/src/components/ContributorExpertiseInsights.jsx @@ -0,0 +1,315 @@ +import React, { useMemo } from 'react'; +import { + analyzeExpertise, + generateInsights, + hasSufficientData, + getContributionSummary +} from '../utils/contributorExpertise'; +import { FiInfo, FiAlertCircle } from 'react-icons/fi'; + +const ExpertiseLevelBadge = ({ level }) => { + const styles = { + HIGH: { + background: 'rgba(34, 197, 94, 0.12)', + color: '#22c55e', + border: '1px solid rgba(34, 197, 94, 0.2)' + }, + MEDIUM: { + background: 'rgba(251, 191, 36, 0.12)', + color: '#d97706', + border: '1px solid rgba(251, 191, 36, 0.2)' + }, + LOW: { + background: 'rgba(107, 114, 128, 0.12)', + color: '#6b7280', + border: '1px solid rgba(107, 114, 128, 0.15)' + } + }; + + return ( + + {level} + + ); +}; + +const ExpertiseBar = ({ score, maxScore }) => { + const percentage = Math.min(100, (score / maxScore) * 100); + return ( +
+
+
+ ); +}; + +const ContributorExpertiseInsights = ({ contributions }) => { + const { expertise, insights, hasData, summary } = useMemo(() => { + if (!contributions || contributions.length === 0) { + return { + expertise: [], + insights: ['No contribution data available for analysis.'], + hasData: false, + summary: { total: 0, prs: 0, issues: 0, repos: [] } + }; + } + + const expertiseData = analyzeExpertise(contributions); + const insightsData = generateInsights(contributions, expertiseData); + const summaryData = getContributionSummary(contributions); + const sufficient = hasSufficientData(contributions, 3); + + return { + expertise: expertiseData, + insights: sufficient ? insightsData : ['Insufficient data to generate reliable insights. Need at least 3 contributions.'], + hasData: sufficient, + summary: summaryData + }; + }, [contributions]); + + if (!contributions || contributions.length === 0) { + return ( +
+ +

+ No Contributions Found +

+

+ Contributions will appear here once the contributor makes PRs or issues. +

+
+ ); + } + + return ( +
+
+
+ 🧠 +

+ Contributor Expertise & Insights +

+ {summary.total > 0 && ( + + {summary.total} contributions • {summary.uniqueRepos} repos + + )} +
+
+ +
+
+

+ 🎯 Areas of Expertise +

+ + {!hasData ? ( +
+ + Not enough data to determine expertise. More contributions needed. +
+ ) : expertise.length === 0 ? ( +
+ + No clear expertise identified from available contributions. +
+ ) : ( +
+ {expertise.map((item, index) => { + const maxScore = expertise[0]?.score || 1; + return ( +
+
+ {item.icon} + + {item.area} + +
+
+ + +
+
+ ); + })} +
+ )} +
+ +
+

+ 💡 Contribution Insights +

+ + {insights.length === 0 || (insights.length === 1 && insights[0].includes('No contribution')) ? ( +
+ + {insights[0] || 'No insights available.'} +
+ ) : ( +
    + {insights.map((insight, index) => ( +
  • + + {insight} +
  • + ))} +
+ )} +
+
+ +
+ + Based on {summary.total} contribution{summary.total > 1 ? 's' : ''} from the selected period + +
+
+ ); +}; + +export default ContributorExpertiseInsights; \ No newline at end of file diff --git a/src/pages/ContributorProfilePage.jsx b/src/pages/ContributorProfilePage.jsx index 3cafec8..5af9fa7 100644 --- a/src/pages/ContributorProfilePage.jsx +++ b/src/pages/ContributorProfilePage.jsx @@ -4,6 +4,7 @@ import { FiArrowLeft, FiDownload, FiExternalLink, FiCalendar, FiBriefcase, FiAle import { useApp } from '../context/AppContext' import { C, PageTitle, Spinner, StatCard } from '../components/UI' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' +import ContributorExpertiseInsights from '../components/ContributorExpertiseInsights' // Reusable ContributionTable component function ContributionTable({ items, dateHeader, resolveStatus }) { @@ -305,18 +306,18 @@ export default function ContributorProfilePage() { // 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 { @@ -327,6 +328,7 @@ export default function ContributorProfilePage() { 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() @@ -492,14 +494,19 @@ export default function ContributorProfilePage() { p.isMerged).length} Merged`} accent="var(--blue)" /> i.state === 'closed').length} Closed`} accent="var(--amber)" /> - i.repository_url?.split('/').pop())).size} + i.repository_url?.split('/').pop())).size} sub="distinct repositories" accent="var(--green)" />
+ {/* CONTRIBUTOR EXPERTISE & INSIGHTS SECTION */} + + + + {/* Visual Activity Timeline Chart */} {chartData.length > 0 ? (
diff --git a/src/utils/contributorExpertise.js b/src/utils/contributorExpertise.js new file mode 100644 index 0000000..f505371 --- /dev/null +++ b/src/utils/contributorExpertise.js @@ -0,0 +1,236 @@ +export const EXPERTISE_AREAS = { + 'AI / Machine Learning': { + keywords: ['ai', 'ml', 'machine learning', 'deep learning', 'neural', 'tensorflow', 'pytorch', 'nlp', 'llm', 'openai', 'claude', 'gemini', 'llama', 'rag', 'embedding', 'vector', 'model', 'training', 'inference', 'transformer', 'bert', 'gpt'], + icon: '🧠' + }, + 'Computer Vision': { + keywords: ['vision', 'image', 'cv', 'opencv', 'detection', 'segmentation', 'classification', 'object detection', 'face', 'video', 'pixel', 'yolo', 'cnn', 'convolution', 'pose', 'landmark'], + icon: '👁️' + }, + 'Frontend / UI': { + keywords: ['ui', 'ux', 'frontend', 'react', 'component', 'style', 'css', 'html', 'design', 'responsive', 'web', 'angular', 'vue', 'tailwind', 'bootstrap', 'sass', 'interface'], + icon: '🎨' + }, + 'Backend': { + keywords: ['backend', 'server', 'api', 'database', 'sql', 'nosql', 'service', 'microservice', 'rest', 'graphql', 'node', 'python', 'java', 'go', 'rust', 'endpoint', 'middleware'], + icon: '⚙️' + }, + 'APIs': { + keywords: ['api', 'rest', 'graphql', 'endpoint', 'swagger', 'openapi', 'postman', 'http', 'request', 'response', 'json', 'xml', 'soap', 'webhook'], + icon: '🔌' + }, + 'Testing': { + keywords: ['test', 'unit test', 'integration', 'e2e', 'jest', 'cypress', 'pytest', 'mocha', 'coverage', 'qa', 'quality', 'assert', 'mock', 'selenium', 'junit'], + icon: '✅' + }, + 'CI/CD': { + keywords: ['ci', 'cd', 'deployment', 'pipeline', 'docker', 'kubernetes', 'github actions', 'jenkins', 'devops', 'infra', 'terraform', 'ansible', 'release', 'build', 'automation'], + icon: '🚀' + }, + 'Documentation': { + keywords: ['docs', 'documentation', 'readme', 'guide', 'tutorial', 'wiki', 'comment', 'example', 'getting started', 'install', 'usage', 'api docs', 'swagger'], + icon: '📝' + }, + 'Security': { + keywords: ['security', 'auth', 'authentication', 'encryption', 'vulnerability', 'penetration', 'cve', 'firewall', 'ssl', 'jwt', 'oauth', 'csrf', 'xss', 'injection', 'crypto'], + icon: '🔒' + }, + 'Search / Embeddings': { + keywords: ['search', 'embedding', 'vector', 'index', 'elasticsearch', 'solr', 'lucene', 'query', 'retrieval', 'ranking', 'similarity', 'cosine', 'faiss'], + icon: '🔍' + } +}; + +export const analyzeExpertise = (contributions) => { + if (!contributions || contributions.length === 0) { + return []; + } + + const scores = {}; + Object.keys(EXPERTISE_AREAS).forEach(area => { + scores[area] = 0; + }); + + contributions.forEach(contribution => { + const text = [ + contribution.title || '', + contribution.body || '', + ...(contribution.labels || []).map(label => typeof label === 'string' ? label : label.name || ''), + contribution.repository_url || '', + contribution.type || '' + ].join(' ').toLowerCase(); + + Object.entries(EXPERTISE_AREAS).forEach(([area, data]) => { + let areaScore = 0; + + data.keywords.forEach(keyword => { + const regex = new RegExp(keyword.toLowerCase(), 'g'); + const matches = text.match(regex); + + if (matches) { + const titleText = (contribution.title || '').toLowerCase(); + const titleMatches = titleText.match(regex) || []; + const weight = titleMatches.length > 0 ? 2 : 1; + areaScore += matches.length * weight; + } + }); + + if (contribution.labels) { + const labelText = contribution.labels.map(l => typeof l === 'string' ? l : l.name || '').join(' ').toLowerCase(); + const labelRegex = new RegExp(data.keywords.join('|'), 'g'); + const labelMatches = labelText.match(labelRegex) || []; + areaScore += labelMatches.length * 1.5; + } + + scores[area] += areaScore; + }); + }); + + const maxScore = Math.max(...Object.values(scores), 1); + + const result = Object.entries(scores) + .map(([area, score]) => { + const normalizedScore = score / maxScore; + + let level; + if (normalizedScore >= 0.35) level = 'HIGH'; + else if (normalizedScore >= 0.15) level = 'MEDIUM'; + else if (normalizedScore >= 0.05) level = 'LOW'; + else level = null; + + return { + area, + score: Math.round(score * 100) / 100, + normalizedScore: Math.round(normalizedScore * 100), + level, + icon: EXPERTISE_AREAS[area]?.icon || '📁' + }; + }) + .filter(item => item.level !== null) + .sort((a, b) => b.score - a.score) + .slice(0, 6); + + return result; +}; + +export const generateInsights = (contributions, expertise) => { + if (!contributions || contributions.length === 0) { + return ['No contribution data available to generate insights.']; + } + + const insights = []; + const total = contributions.length; + + if (expertise.length > 0 && expertise[0].level === 'HIGH') { + const primary = expertise[0].area; + const secondary = expertise.length > 1 && expertise[1].level === 'HIGH' ? expertise[1].area : null; + + if (secondary) { + insights.push(`Strong focus on ${primary} and ${secondary}.`); + } else { + insights.push(`Specializes primarily in ${primary}.`); + } + } + + let featureCount = 0; + let bugFixCount = 0; + let docCount = 0; + + contributions.forEach(c => { + const text = `${c.title || ''} ${c.body || ''} ${(c.labels || []).map(l => typeof l === 'string' ? l : l.name || '').join(' ')}`.toLowerCase(); + + if (text.includes('feature') || text.includes('enhancement') || text.includes('add') || + text.includes('implement') || text.includes('new')) { + featureCount++; + } + + if (text.includes('fix') || text.includes('bug') || text.includes('issue') || + text.includes('patch') || text.includes('resolve') || text.includes('repair')) { + bugFixCount++; + } + + if (text.includes('doc') || text.includes('readme') || text.includes('guide') || + text.includes('tutorial') || text.includes('documentation')) { + docCount++; + } + }); + + if (featureCount > total * 0.3 && featureCount > bugFixCount * 1.5) { + insights.push('Primarily contributes new features and enhancements.'); + } else if (bugFixCount > total * 0.3 && bugFixCount > featureCount * 1.5) { + insights.push('Specializes in bug fixes and issue resolution.'); + } else if (docCount > total * 0.25) { + insights.push('Strong focus on documentation and knowledge sharing.'); + } + + const repoCounts = {}; + contributions.forEach(c => { + if (c.repository_url) { + const repoName = c.repository_url.split('/').pop() || 'unknown'; + repoCounts[repoName] = (repoCounts[repoName] || 0) + 1; + } + }); + + const sortedRepos = Object.entries(repoCounts).sort((a, b) => b[1] - a[1]); + + if (sortedRepos.length === 1) { + insights.push(`All contributions focused in ${sortedRepos[0][0]}.`); + } else if (sortedRepos.length > 0 && sortedRepos[0][1] > total * 0.4) { + const pct = Math.round((sortedRepos[0][1] / total) * 100); + insights.push(`Concentrated in ${sortedRepos[0][0]} (${pct}% of contributions).`); + } + + const now = new Date(); + const recentContribs = contributions.filter(c => { + if (!c.created_at) return false; + const date = new Date(c.created_at); + const diffDays = (now - date) / (1000 * 60 * 60 * 24); + return diffDays <= 30; + }); + + if (recentContribs.length > 0 && recentContribs.length / total > 0.3) { + insights.push('Recently active with consistent contribution pattern.'); + } else if (recentContribs.length === 0 && total > 5) { + insights.push('Historically active but no recent contributions (30+ days).'); + } + + if (sortedRepos.length >= 3) { + insights.push(`Active across ${sortedRepos.length} different repositories.`); + } + + const hasReviews = contributions.some(c => c.type === 'review' || c.pull_request); + if (hasReviews) { + insights.push('Engages in code review and collaboration.'); + } + + return insights.slice(0, 5); +}; + +export const hasSufficientData = (contributions, minRequired = 3) => { + return contributions && contributions.length >= minRequired; +}; + +export const getContributionSummary = (contributions) => { + if (!contributions || contributions.length === 0) { + return { total: 0, prs: 0, issues: 0, repos: [] }; + } + + const prs = contributions.filter(c => c.pull_request || c.type === 'pr' || c.html_url?.includes('/pull/')); + const issues = contributions.filter(c => c.type === 'issue' || c.html_url?.includes('/issues/')); + + const repos = []; + contributions.forEach(c => { + if (c.repository_url) { + const repoName = c.repository_url.split('/').pop() || 'unknown'; + if (!repos.includes(repoName)) repos.push(repoName); + } + }); + + return { + total: contributions.length, + prs: prs.length, + issues: issues.length, + repos: repos, + uniqueRepos: repos.length + }; +}; \ No newline at end of file