Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions client/src/components/GitHubIntelligenceModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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!');
Expand All @@ -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);
}
Expand All @@ -74,6 +80,7 @@ const GitHubIntelligenceModal = ({ isOpen, onClose, username, onSyncSuccess }) =
}
}, [isOpen, fetchIntelligence]);


if (!isOpen) return null;

const intelligence = data?.intelligence || {};
Expand Down Expand Up @@ -319,6 +326,21 @@ const GitHubIntelligenceModal = ({ isOpen, onClose, username, onSyncSuccess }) =
<RotateCcw size={28} className="animate-spin" style={{ margin: '0 auto 0.75rem auto', color: 'var(--accent-purple)' }} />
<p>Analyzing GitHub developer data...</p>
</div>
) : fetchError ? (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<AlertCircle size={36} color="var(--accent-amber, #f59e0b)" style={{ margin: '0 auto 0.75rem auto' }} />
<h4 style={{ color: 'white', marginBottom: '0.5rem' }}>GitHub Intelligence Unavailable</h4>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', maxWidth: '400px', margin: '0 auto 1.25rem auto', lineHeight: '1.4' }}>
{fetchError}
</p>
<button
onClick={fetchIntelligence}
className="btn btn-outline btn-sm"
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem', margin: '0 auto' }}
>
<RotateCcw size={13} /> Retry Loading
</button>
</div>
) : !data?.linked ? (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<AlertCircle size={36} color="var(--accent-amber)" style={{ margin: '0 auto 0.75rem auto' }} />
Expand Down
3 changes: 2 additions & 1 deletion server/src/models/Ranking.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

142 changes: 101 additions & 41 deletions server/src/services/githubIntelligenceService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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') {
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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',
};
Expand Down Expand Up @@ -268,11 +327,12 @@ 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',
},
};
};

module.exports = {
normalizeGitHubIntelligence,
};

Loading
Loading