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 ? (
+
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');
+ });
+});