From 20ad557f316ed1b02b4b7123895e78a219bc9e10 Mon Sep 17 00:00:00 2001 From: Mayur51015 Date: Mon, 31 Aug 2026 22:20:15 +0530 Subject: [PATCH] fix(github): resolve GitHub intelligence metrics calculation and sync 500 error --- .../components/GitHubIntelligenceModal.jsx | 26 +- server/src/models/Ranking.js | 3 +- .../src/services/githubIntelligenceService.js | 142 ++++++--- server/src/services/githubService.js | 121 +++++++ server/src/services/githubSyncService.js | 34 +- server/src/services/platformService.js | 25 +- server/tests/githubIntelligence.test.js | 294 ++++++++++++++++++ 7 files changed, 591 insertions(+), 54 deletions(-) create mode 100644 server/tests/githubIntelligence.test.js diff --git a/client/src/components/GitHubIntelligenceModal.jsx b/client/src/components/GitHubIntelligenceModal.jsx index 616e6f6..a7d27a4 100644 --- a/client/src/components/GitHubIntelligenceModal.jsx +++ b/client/src/components/GitHubIntelligenceModal.jsx @@ -29,19 +29,23 @@ const GitHubIntelligenceModal = ({ isOpen, onClose, username, onSyncSuccess }) = const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); + const [fetchError, setFetchError] = useState(null); const [repoSearch, setRepoSearch] = useState(''); const [activeTab, setActiveTab] = useState('overview'); // 'overview' | 'repos' | 'scoring' const fetchIntelligence = useCallback(async () => { try { setLoading(true); + setFetchError(null); const res = await api.get('/platforms/github/intelligence'); if (res.data?.success) { setData(res.data.data); } } catch (err) { console.error('Failed to load GitHub intelligence:', err); - toast.error('Failed to load GitHub Developer Intelligence.'); + const errorMsg = err.response?.data?.message || 'Failed to load GitHub Developer Intelligence.'; + setFetchError(errorMsg); + toast.error(errorMsg); } finally { setLoading(false); } @@ -50,6 +54,7 @@ const GitHubIntelligenceModal = ({ isOpen, onClose, username, onSyncSuccess }) = const handleSync = async () => { try { setSyncing(true); + setFetchError(null); const res = await api.post('/platforms/github/sync'); if (res.data?.success) { toast.success(res.data.message || 'GitHub intelligence updated!'); @@ -62,7 +67,8 @@ const GitHubIntelligenceModal = ({ isOpen, onClose, username, onSyncSuccess }) = } } catch (err) { console.error('Sync failed:', err); - toast.error(err.response?.data?.message || 'Synchronization failed.'); + const errorMsg = err.response?.data?.message || 'Synchronization failed.'; + toast.error(errorMsg); } finally { setSyncing(false); } @@ -74,6 +80,7 @@ const GitHubIntelligenceModal = ({ isOpen, onClose, username, onSyncSuccess }) = } }, [isOpen, fetchIntelligence]); + if (!isOpen) return null; const intelligence = data?.intelligence || {}; @@ -319,6 +326,21 @@ const GitHubIntelligenceModal = ({ isOpen, onClose, username, onSyncSuccess }) =

Analyzing GitHub developer data...

+ ) : fetchError ? ( +
+ +

GitHub Intelligence Unavailable

+

+ {fetchError} +

+ +
) : !data?.linked ? (
diff --git a/server/src/models/Ranking.js b/server/src/models/Ranking.js index 71d20e2..df9bb7e 100644 --- a/server/src/models/Ranking.js +++ b/server/src/models/Ranking.js @@ -47,5 +47,6 @@ const rankingSchema = new mongoose.Schema( rankingSchema.index({ score: -1 }); rankingSchema.index({ globalRank: 1 }); rankingSchema.index({ departmentRank: 1 }); -rankingSchema.index({ userId: 1 }, { unique: true }); + module.exports = mongoose.model('Ranking', rankingSchema); + diff --git a/server/src/services/githubIntelligenceService.js b/server/src/services/githubIntelligenceService.js index 3bfbf25..55e7d85 100644 --- a/server/src/services/githubIntelligenceService.js +++ b/server/src/services/githubIntelligenceService.js @@ -4,7 +4,15 @@ * Provides a canonical single source of truth for developer identity. */ -const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [], username = '', previousData = null, syncMeta = {}) => { +const normalizeGitHubIntelligence = ( + rawProfile, + rawRepos = [], + rawEvents = [], + username = '', + previousData = null, + syncMeta = {}, + searchContributions = null +) => { const canonicalUsername = (rawProfile?.login || username || previousData?.profile?.username || '').trim(); // 1. Profile Intelligence (Validate numeric and string fields) @@ -87,18 +95,18 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [], const commitsByRepo = {}; const activeDaysSet = new Set(); - let prsOpened = 0; - let prsMerged = 0; - let prsClosed = 0; - let reviewsSubmitted = 0; - let issuesCreated = 0; - let issuesClosed = 0; - let releaseCount = 0; + let eventPrsOpened = 0; + let eventPrsMerged = 0; + let eventPrsClosed = 0; + let eventReviewsSubmitted = 0; + let eventIssuesCreated = 0; + let eventIssuesClosed = 0; + let eventReleaseCount = 0; let latestRelease = null; - const externalReposSet = new Set(); - let externalPRCount = 0; - let externalIssueCount = 0; + const eventExternalReposSet = new Set(); + let eventExternalPRCount = 0; + let eventExternalIssueCount = 0; const eventsToProcess = Array.isArray(rawEvents) && rawEvents.length > 0 ? rawEvents @@ -114,10 +122,10 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [], const repoFullName = ev.repo?.name || ''; const repoOwner = repoFullName.split('/')[0]?.toLowerCase(); - const isExternal = repoOwner && canonicalUsername && repoOwner !== canonicalUsername.toLowerCase(); + const isExternal = Boolean(repoOwner && canonicalUsername && repoOwner !== canonicalUsername.toLowerCase()); - if (isExternal) { - externalReposSet.add(repoFullName); + if (isExternal && repoFullName) { + eventExternalReposSet.add(repoFullName); } if (eventType === 'PushEvent') { @@ -130,22 +138,27 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [], const action = ev.payload?.action; const isMerged = Boolean(ev.payload?.pull_request?.merged); - if (action === 'opened') prsOpened++; - if (action === 'closed') { - prsClosed++; - if (isMerged) prsMerged++; + if (action === 'opened') { + eventPrsOpened++; + if (isExternal) eventExternalPRCount++; + } else if (action === 'closed') { + eventPrsClosed++; + if (isMerged) { + eventPrsMerged++; + } } - - if (isExternal) externalPRCount++; } else if (eventType === 'PullRequestReviewEvent' || eventType === 'PullRequestReviewCommentEvent') { - reviewsSubmitted++; + eventReviewsSubmitted++; } else if (eventType === 'IssuesEvent') { const action = ev.payload?.action; - if (action === 'opened') issuesCreated++; - if (action === 'closed') issuesClosed++; - if (isExternal) externalIssueCount++; + if (action === 'opened') { + eventIssuesCreated++; + if (isExternal) eventExternalIssueCount++; + } else if (action === 'closed') { + eventIssuesClosed++; + } } else if (eventType === 'ReleaseEvent') { - releaseCount++; + eventReleaseCount++; if (!latestRelease && ev.payload?.release) { latestRelease = { name: ev.payload.release.name || ev.payload.release.tag_name, @@ -157,6 +170,53 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [], } }); + // Combine external repos from search and events + const combinedExternalReposSet = new Set([ + ...Array.from(eventExternalReposSet), + ...(Array.isArray(searchContributions?.externalRepos) ? searchContributions.externalRepos : []), + ...(Array.isArray(previousData?.openSource?.externalReposList) ? previousData.openSource.externalReposList : []), + ]); + + // Aggregate Metrics with Single Source of Truth + const rawOpened = searchContributions?.prsOpened != null + ? Math.max(searchContributions.prsOpened, eventPrsOpened) + : Math.max(eventPrsOpened, previousData?.pullRequests?.opened || 0); + + const rawMerged = searchContributions?.prsMerged != null + ? Math.max(searchContributions.prsMerged, eventPrsMerged) + : Math.max(eventPrsMerged, previousData?.pullRequests?.merged || 0); + + // Merged PR count cannot exceed opened PR count + const prsOpened = Math.max(0, rawOpened); + const prsMerged = Math.max(0, Math.min(rawMerged, prsOpened || rawMerged)); + const prsClosed = Math.max(eventPrsClosed, prsMerged, previousData?.pullRequests?.closed || 0); + + // Merge rate calculation: Merged / Opened * 100 (handles 0 opened without NaN/Infinity/undefined) + const mergeRate = prsOpened > 0 ? Math.round((prsMerged / prsOpened) * 100) : 0; + + const reviewsSubmitted = searchContributions?.reviewsSubmitted != null + ? Math.max(searchContributions.reviewsSubmitted, eventReviewsSubmitted) + : Math.max(eventReviewsSubmitted, previousData?.reviews?.submitted || previousData?.reviews?.total || 0); + + const externalPRCount = searchContributions?.externalPRs != null + ? Math.max(searchContributions.externalPRs, eventExternalPRCount) + : Math.max(eventExternalPRCount, previousData?.openSource?.externalPRs || 0); + + const externalIssueCount = searchContributions?.externalIssues != null + ? Math.max(searchContributions.externalIssues, eventExternalIssueCount) + : Math.max(eventExternalIssueCount, previousData?.openSource?.externalIssues || 0); + + const releaseCount = Math.max( + eventReleaseCount, + previousData?.releases?.count || previousData?.releases?.published || 0 + ); + + const issuesCreated = searchContributions?.externalIssues != null + ? Math.max(searchContributions.externalIssues, eventIssuesCreated) + : Math.max(eventIssuesCreated, previousData?.issues?.created || 0); + + const issuesClosed = Math.max(eventIssuesClosed, previousData?.issues?.closed || 0); + // Calculate daily streak from active days const sortedActiveDays = Array.from(activeDaysSet).sort().reverse(); let dailyStreak = 0; @@ -181,47 +241,45 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [], // 5. Commit Intelligence const commits = { - available: eventsToProcess.length > 0, + available: eventsToProcess.length > 0 || recentCommitCount > 0, recentCount30Days: recentCommitCount, activeRepositoriesCount: Object.keys(commitsByRepo).length, commitsByRepo, - status: eventsToProcess.length > 0 ? 'active' : 'no_recent_events', + status: (eventsToProcess.length > 0 || recentCommitCount > 0) ? 'active' : 'no_recent_events', }; // 6. Pull Request Intelligence - const totalCompletedPRs = prsClosed + prsMerged; - const mergeRate = totalCompletedPRs > 0 ? Math.round((prsMerged / totalCompletedPRs) * 100) : null; - const pullRequests = { - available: eventsToProcess.length > 0, + available: true, opened: prsOpened, closed: prsClosed, merged: prsMerged, - mergeRate: mergeRate !== null ? `${mergeRate}%` : 'Insufficient data', + mergeRate: `${mergeRate}%`, externalPRs: externalPRCount, - status: eventsToProcess.length > 0 ? 'available' : 'insufficient_data', + status: prsOpened > 0 ? 'available' : 'none_recorded', }; // 7. Open Source Intelligence const openSource = { - available: eventsToProcess.length > 0, - externalReposContributed: externalReposSet.size, - externalReposList: Array.from(externalReposSet), + available: true, + externalReposContributed: combinedExternalReposSet.size, + externalReposList: Array.from(combinedExternalReposSet), externalPRs: externalPRCount, externalIssues: externalIssueCount, - status: externalReposSet.size > 0 ? 'contributor' : 'personal_focus', + status: combinedExternalReposSet.size > 0 ? 'contributor' : 'personal_focus', }; // 8. Reviews & Collaboration const reviews = { - available: eventsToProcess.length > 0, + available: true, submitted: reviewsSubmitted, + total: reviewsSubmitted, status: reviewsSubmitted > 0 ? 'active' : 'none_recorded', }; // 9. Issues Intelligence const issues = { - available: eventsToProcess.length > 0, + available: true, created: issuesCreated, closed: issuesClosed, externalIssues: externalIssueCount, @@ -230,8 +288,9 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [], // 10. Software Delivery & Releases const releases = { - available: eventsToProcess.length > 0, + available: true, count: releaseCount, + published: releaseCount, latestRelease, status: releaseCount > 0 ? 'active' : 'none_recorded', }; @@ -268,7 +327,7 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [], completedAt: syncMeta.completedAt || new Date(), durationMs: syncMeta.durationMs || 0, error: syncMeta.error || null, - source: 'github_rest_api', + source: searchContributions?.source || 'github_rest_api', }, }; }; @@ -276,3 +335,4 @@ const normalizeGitHubIntelligence = (rawProfile, rawRepos = [], rawEvents = [], module.exports = { normalizeGitHubIntelligence, }; + diff --git a/server/src/services/githubService.js b/server/src/services/githubService.js index eba8104..8b21787 100644 --- a/server/src/services/githubService.js +++ b/server/src/services/githubService.js @@ -99,9 +99,130 @@ const fetchUserEvents = async (username, maxCount = 100) => { } }; +/** + * Fetch user contribution intelligence metrics from GitHub Search API. + * Gracefully handles search rate limits and errors. + */ +const fetchUserContributionsData = async (username) => { + const result = { + prsOpened: null, + prsMerged: null, + reviewsSubmitted: null, + externalPRs: null, + externalIssues: null, + externalRepos: [], + source: 'github_search_api', + }; + + const externalReposSet = new Set(); + const cleanUser = String(username || '').trim(); + if (!cleanUser) return result; + + try { + // 1. Total PRs opened by user + try { + const prUrl = `https://api.github.com/search/issues?q=type:pr+author:${encodeURIComponent(cleanUser)}&per_page=30`; + const { payload: prPayload } = await safeGitHubFetch(prUrl); + if (typeof prPayload?.total_count === 'number') { + result.prsOpened = prPayload.total_count; + if (Array.isArray(prPayload.items)) { + prPayload.items.forEach((item) => { + if (item.repository_url) { + const parts = item.repository_url.split('/'); + const owner = parts[parts.length - 2]; + const repo = parts[parts.length - 1]; + if (owner && owner.toLowerCase() !== cleanUser.toLowerCase()) { + externalReposSet.add(`${owner}/${repo}`); + } + } + }); + } + } + } catch (e) { + console.warn(`[GitHub API] Search PRs query note for ${cleanUser}:`, e.message); + } + + // 2. Merged PRs authored by user + try { + const mergedUrl = `https://api.github.com/search/issues?q=type:pr+author:${encodeURIComponent(cleanUser)}+is:merged&per_page=1`; + const { payload: mergedPayload } = await safeGitHubFetch(mergedUrl); + if (typeof mergedPayload?.total_count === 'number') { + result.prsMerged = mergedPayload.total_count; + } + } catch (e) { + console.warn(`[GitHub API] Search merged PRs query note for ${cleanUser}:`, e.message); + } + + // 3. PRs reviewed by user + try { + const reviewUrl = `https://api.github.com/search/issues?q=type:pr+reviewed-by:${encodeURIComponent(cleanUser)}&per_page=1`; + const { payload: reviewPayload } = await safeGitHubFetch(reviewUrl); + if (typeof reviewPayload?.total_count === 'number') { + result.reviewsSubmitted = reviewPayload.total_count; + } + } catch (e) { + console.warn(`[GitHub API] Search reviews query note for ${cleanUser}:`, e.message); + } + + // 4. External PRs (PRs on repos not owned by user) + try { + const extPrUrl = `https://api.github.com/search/issues?q=type:pr+author:${encodeURIComponent(cleanUser)}+-user:${encodeURIComponent(cleanUser)}&per_page=30`; + const { payload: extPrPayload } = await safeGitHubFetch(extPrUrl); + if (typeof extPrPayload?.total_count === 'number') { + result.externalPRs = extPrPayload.total_count; + if (Array.isArray(extPrPayload.items)) { + extPrPayload.items.forEach((item) => { + if (item.repository_url) { + const parts = item.repository_url.split('/'); + const owner = parts[parts.length - 2]; + const repo = parts[parts.length - 1]; + if (owner && owner.toLowerCase() !== cleanUser.toLowerCase()) { + externalReposSet.add(`${owner}/${repo}`); + } + } + }); + } + } + } catch (e) { + console.warn(`[GitHub API] Search external PRs query note for ${cleanUser}:`, e.message); + } + + // 5. External Issues (Issues on repos not owned by user) + try { + const extIssueUrl = `https://api.github.com/search/issues?q=type:issue+author:${encodeURIComponent(cleanUser)}+-user:${encodeURIComponent(cleanUser)}&per_page=30`; + const { payload: extIssuePayload } = await safeGitHubFetch(extIssueUrl); + if (typeof extIssuePayload?.total_count === 'number') { + result.externalIssues = extIssuePayload.total_count; + if (Array.isArray(extIssuePayload.items)) { + extIssuePayload.items.forEach((item) => { + if (item.repository_url) { + const parts = item.repository_url.split('/'); + const owner = parts[parts.length - 2]; + const repo = parts[parts.length - 1]; + if (owner && owner.toLowerCase() !== cleanUser.toLowerCase()) { + externalReposSet.add(`${owner}/${repo}`); + } + } + }); + } + } + } catch (e) { + console.warn(`[GitHub API] Search external issues query note for ${cleanUser}:`, e.message); + } + + result.externalRepos = Array.from(externalReposSet); + } catch (err) { + console.warn(`[GitHub API] Search API general fallback for ${cleanUser}:`, err.message); + } + + return result; +}; + module.exports = { safeGitHubFetch, fetchUserProfile, fetchUserRepositories, fetchUserEvents, + fetchUserContributionsData, }; + diff --git a/server/src/services/githubSyncService.js b/server/src/services/githubSyncService.js index 9a8e3fe..d748b29 100644 --- a/server/src/services/githubSyncService.js +++ b/server/src/services/githubSyncService.js @@ -1,8 +1,16 @@ const User = require('../models/User'); const Activity = require('../models/Activity'); -const { fetchUserProfile, fetchUserRepositories, fetchUserEvents } = require('./githubService'); +const { + fetchUserProfile, + fetchUserRepositories, + fetchUserEvents, + fetchUserContributionsData, +} = require('./githubService'); const { normalizeGitHubIntelligence } = require('./githubIntelligenceService'); +const { evaluateUserIntelligence } = require('./careerIntelligenceService'); +const { recordSyncSuccess, recordSyncFailure } = require('./syncConsistencyService'); const ExternalIdentity = require('../models/ExternalIdentity'); + // In-memory sync lock map to prevent overlapping sync operations const syncLocks = new Map(); @@ -114,6 +122,7 @@ const syncGitHubAccount = async (userId, customUsername = null) => { let rawProfile = null; let rawRepos = []; let rawEvents = []; + let searchContributions = null; const partialErrors = []; try { @@ -150,6 +159,12 @@ const syncGitHubAccount = async (userId, customUsername = null) => { partialErrors.push(`Events: ${err.message}`); } + try { + searchContributions = await fetchUserContributionsData(username); + } catch (err) { + console.warn(`[GitHub Sync] Contributions search warning for @${username}:`, err.message); + } + const completedAt = new Date(); const durationMs = completedAt.getTime() - startedAt.getTime(); const syncStatus = partialErrors.length > 0 ? 'partial' : 'complete'; @@ -162,14 +177,15 @@ const syncGitHubAccount = async (userId, customUsername = null) => { error: partialErrors.length > 0 ? partialErrors.join(' | ') : null, }; - // 2. Normalize Intelligence Data (merging with previous data if partial) + // 2. Normalize Intelligence Data (merging with previous data and search metrics) const githubData = normalizeGitHubIntelligence( rawProfile, rawRepos, rawEvents, username, previousGithubData, - syncMeta + syncMeta, + searchContributions ); // 3. Persist Activities into Activity Collection (deduplicated) @@ -202,7 +218,13 @@ const syncGitHubAccount = async (userId, customUsername = null) => { user.githubUsername = username; // Legacy mirror for backwards compatibility user.platformData = user.platformData || {}; + user.platformData.github = githubData; + user.lastSyncedAt = completedAt; + recordSyncSuccess(user, 'github', githubData); + await user.save(); + + // 5. Evaluate Career Intelligence, Scores & Developer DNA const updatedUser = await evaluateUserIntelligence(user._id); return { @@ -210,7 +232,7 @@ const syncGitHubAccount = async (userId, customUsername = null) => { status: syncStatus, durationMs, data: githubData, - user: updatedUser, + user: updatedUser || user, message: syncStatus === 'partial' ? 'GitHub synchronized partially (some endpoints unavailable).' : 'GitHub intelligence synchronized successfully.', @@ -229,8 +251,10 @@ const syncGitHubAccount = async (userId, customUsername = null) => { throw error; } finally { syncLocks.delete(userId.toString()); - }}; + } +}; module.exports = { syncGitHubAccount, }; + diff --git a/server/src/services/platformService.js b/server/src/services/platformService.js index 9433ad8..71bbbf5 100644 --- a/server/src/services/platformService.js +++ b/server/src/services/platformService.js @@ -26,15 +26,29 @@ const fetchPlatformProfile = async (platform, username) => { } }; -const { fetchUserProfile, fetchUserRepositories, fetchUserEvents } = require('./githubService'); +const { + fetchUserProfile, + fetchUserRepositories, + fetchUserEvents, + fetchUserContributionsData, +} = require('./githubService'); const { normalizeGitHubIntelligence } = require('./githubIntelligenceService'); const fetchGitHubProfile = async (username) => { const profilePayload = await fetchUserProfile(username); - const reposPayload = await fetchUserRepositories(username, 30); - const eventsPayload = await fetchUserEvents(username, 50); - - const intelligence = normalizeGitHubIntelligence(profilePayload, reposPayload, eventsPayload, username); + const reposPayload = await fetchUserRepositories(username, 100).catch(() => []); + const eventsPayload = await fetchUserEvents(username, 100).catch(() => []); + const contributionsPayload = await fetchUserContributionsData(username).catch(() => null); + + const intelligence = normalizeGitHubIntelligence( + profilePayload, + reposPayload, + eventsPayload, + username, + null, + { status: 'complete', completedAt: new Date() }, + contributionsPayload + ); // Return intelligence structure with top-level backwards compatibility aliases return { @@ -55,6 +69,7 @@ const fetchGitHubProfile = async (username) => { }; }; + const fetchCodeforcesProfile = async (username) => { const url = `https://codeforces.com/api/user.info?handles=${encodeURIComponent(username)}`; const response = await fetch(url); diff --git a/server/tests/githubIntelligence.test.js b/server/tests/githubIntelligence.test.js new file mode 100644 index 0000000..c4aaae0 --- /dev/null +++ b/server/tests/githubIntelligence.test.js @@ -0,0 +1,294 @@ +const { normalizeGitHubIntelligence } = require('../src/services/githubIntelligenceService'); + +describe('GitHub Developer Intelligence Unit Tests', () => { + const mockUsername = 'octocat'; + + const mockProfile = { + login: 'octocat', + name: 'The Octocat', + avatar_url: 'https://avatars.githubusercontent.com/u/583231', + bio: 'GitHub mascot', + public_repos: 8, + followers: 4000, + following: 9, + created_at: '2011-01-25T18:44:36Z', + }; + + const mockRepos = [ + { + name: 'Hello-World', + full_name: 'octocat/Hello-World', + owner: { login: 'octocat' }, + language: 'JavaScript', + stargazers_count: 1500, + forks_count: 500, + open_issues_count: 2, + }, + { + name: 'Spoon-Knife', + full_name: 'octocat/Spoon-Knife', + owner: { login: 'octocat' }, + language: 'HTML', + stargazers_count: 800, + forks_count: 200, + open_issues_count: 0, + }, + ]; + + test('PRs opened, merged, and merge rate with valid data (10 opened, 6 merged -> 60%)', () => { + const searchContributions = { + prsOpened: 10, + prsMerged: 6, + reviewsSubmitted: 4, + externalPRs: 3, + externalIssues: 2, + externalRepos: ['facebook/react', 'vercel/next.js'], + }; + + const result = normalizeGitHubIntelligence( + mockProfile, + mockRepos, + [], + mockUsername, + null, + {}, + searchContributions + ); + + expect(result.pullRequests.opened).toBe(10); + expect(result.pullRequests.merged).toBe(6); + expect(result.pullRequests.mergeRate).toBe('60%'); + expect(result.reviews.submitted).toBe(4); + expect(result.openSource.externalPRs).toBe(3); + expect(result.openSource.externalIssues).toBe(2); + expect(result.openSource.externalReposContributed).toBe(2); + }); + + test('PRs opened 0 and merged 0 handles division by zero without NaN, Infinity, or undefined', () => { + const searchContributions = { + prsOpened: 0, + prsMerged: 0, + reviewsSubmitted: 0, + externalPRs: 0, + externalIssues: 0, + externalRepos: [], + }; + + const result = normalizeGitHubIntelligence( + mockProfile, + mockRepos, + [], + mockUsername, + null, + {}, + searchContributions + ); + + expect(result.pullRequests.opened).toBe(0); + expect(result.pullRequests.merged).toBe(0); + expect(result.pullRequests.mergeRate).toBe('0%'); + expect(result.pullRequests.mergeRate).not.toContain('NaN'); + expect(result.pullRequests.mergeRate).not.toContain('undefined'); + }); + + test('Example test case: 10 opened, 7 merged -> 70%', () => { + const searchContributions = { + prsOpened: 10, + prsMerged: 7, + reviewsSubmitted: 2, + externalPRs: 4, + externalIssues: 1, + externalRepos: ['expressjs/express'], + }; + + const result = normalizeGitHubIntelligence( + mockProfile, + mockRepos, + [], + mockUsername, + null, + {}, + searchContributions + ); + + expect(result.pullRequests.opened).toBe(10); + expect(result.pullRequests.merged).toBe(7); + expect(result.pullRequests.mergeRate).toBe('70%'); + }); + + test('Closed PR is NOT counted as merged unless merged == true', () => { + const rawEvents = [ + { + type: 'PullRequestEvent', + payload: { + action: 'opened', + pull_request: { id: 101, merged: false }, + }, + repo: { name: 'octocat/Hello-World' }, + created_at: '2026-08-01T10:00:00Z', + }, + { + type: 'PullRequestEvent', + payload: { + action: 'closed', + pull_request: { id: 101, merged: false }, // Closed without merging! + }, + repo: { name: 'octocat/Hello-World' }, + created_at: '2026-08-02T10:00:00Z', + }, + { + type: 'PullRequestEvent', + payload: { + action: 'opened', + pull_request: { id: 102, merged: false }, + }, + repo: { name: 'octocat/Hello-World' }, + created_at: '2026-08-03T10:00:00Z', + }, + { + type: 'PullRequestEvent', + payload: { + action: 'closed', + pull_request: { id: 102, merged: true }, // Merged! + }, + repo: { name: 'octocat/Hello-World' }, + created_at: '2026-08-04T10:00:00Z', + }, + ]; + + const result = normalizeGitHubIntelligence( + mockProfile, + mockRepos, + rawEvents, + mockUsername + ); + + expect(result.pullRequests.opened).toBe(2); + expect(result.pullRequests.merged).toBe(1); + expect(result.pullRequests.closed).toBe(2); + expect(result.pullRequests.mergeRate).toBe('50%'); + }); + + test('External repositories, external PRs, and external issues are distinguished from own repos', () => { + const rawEvents = [ + // Own repo PR + { + type: 'PullRequestEvent', + payload: { action: 'opened' }, + repo: { name: 'octocat/Hello-World' }, + created_at: '2026-08-01T10:00:00Z', + }, + // External repo PR 1 + { + type: 'PullRequestEvent', + payload: { action: 'opened' }, + repo: { name: 'facebook/react' }, + created_at: '2026-08-02T10:00:00Z', + }, + // External repo PR 2 + { + type: 'PullRequestEvent', + payload: { action: 'opened' }, + repo: { name: 'vercel/next.js' }, + created_at: '2026-08-03T10:00:00Z', + }, + // External repo Issue + { + type: 'IssuesEvent', + payload: { action: 'opened' }, + repo: { name: 'facebook/react' }, + created_at: '2026-08-04T10:00:00Z', + }, + // Own repo Issue (should not increment externalIssues) + { + type: 'IssuesEvent', + payload: { action: 'opened' }, + repo: { name: 'octocat/Hello-World' }, + created_at: '2026-08-05T10:00:00Z', + }, + ]; + + const result = normalizeGitHubIntelligence( + mockProfile, + mockRepos, + rawEvents, + mockUsername + ); + + expect(result.pullRequests.opened).toBe(3); + expect(result.openSource.externalPRs).toBe(2); + expect(result.openSource.externalIssues).toBe(1); + expect(result.openSource.externalReposContributed).toBe(2); // 'facebook/react' and 'vercel/next.js' + expect(result.openSource.externalReposList).toContain('facebook/react'); + expect(result.openSource.externalReposList).toContain('vercel/next.js'); + expect(result.openSource.externalReposList).not.toContain('octocat/Hello-World'); + }); + + test('Published releases only count ReleaseEvent and published releases', () => { + const rawEvents = [ + { + type: 'ReleaseEvent', + payload: { + release: { + name: 'v1.0.0', + tag_name: 'v1.0.0', + published_at: '2026-08-01T12:00:00Z', + }, + }, + repo: { name: 'octocat/Hello-World' }, + created_at: '2026-08-01T12:00:00Z', + }, + { + type: 'CreateEvent', + payload: { ref_type: 'tag', ref: 'v1.0.1' }, // Tag creation should NOT count as release + repo: { name: 'octocat/Hello-World' }, + created_at: '2026-08-02T12:00:00Z', + }, + { + type: 'PushEvent', + payload: { commits: [{ message: 'Release v1.0.2' }] }, // Commit message should NOT count as release + repo: { name: 'octocat/Hello-World' }, + created_at: '2026-08-03T12:00:00Z', + }, + ]; + + const result = normalizeGitHubIntelligence( + mockProfile, + mockRepos, + rawEvents, + mockUsername + ); + + expect(result.releases.count).toBe(1); + expect(result.releases.published).toBe(1); + expect(result.releases.latestRelease?.tagName).toBe('v1.0.0'); + }); + + test('Code review counting does not fabricate data', () => { + const rawEvents = [ + { + type: 'PullRequestReviewEvent', + payload: { action: 'submitted' }, + repo: { name: 'facebook/react' }, + created_at: '2026-08-01T10:00:00Z', + }, + { + type: 'PullRequestReviewCommentEvent', + payload: { action: 'created' }, + repo: { name: 'facebook/react' }, + created_at: '2026-08-02T10:00:00Z', + }, + ]; + + const result = normalizeGitHubIntelligence( + mockProfile, + mockRepos, + rawEvents, + mockUsername + ); + + expect(result.reviews.submitted).toBe(2); + expect(result.reviews.total).toBe(2); + expect(result.reviews.status).toBe('active'); + }); +});