From f79ed099455136f4687b6e2b164d921244960fbf Mon Sep 17 00:00:00 2001 From: RutujaGharat-11 Date: Fri, 10 Jul 2026 20:44:33 +0530 Subject: [PATCH 1/2] feat(maintainer): add composite trust score badge --- src/app/(app)/maintainer/page.tsx | 20 ++------- src/app/actions/maintainer/analytics.ts | 23 ++++++++--- src/app/actions/maintainer/queue.ts | 23 ++++++++--- src/lib/maintainer/queue.test.ts | 1 + src/lib/maintainer/queue.ts | 1 + src/lib/maintainer/trust.test.ts | 55 +++++++++++++++++++++++++ src/lib/maintainer/trust.ts | 29 +++++++++++++ 7 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 src/lib/maintainer/trust.test.ts create mode 100644 src/lib/maintainer/trust.ts diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx index 200a9e6e..e62e2634 100644 --- a/src/app/(app)/maintainer/page.tsx +++ b/src/app/(app)/maintainer/page.tsx @@ -461,7 +461,7 @@ export default async function MaintainerPage({
@{r.authorLogin} - + · {relativeTime(r.githubUpdatedAt)}
@@ -584,24 +584,12 @@ function NoiseDonut({ noise }: { noise: NoiseBreakdown }) { ); } -function AuthorBadge({ - level, - xp, - merged, -}: { - level: number | null; - xp: number | null; - merged: number | null; -}) { - if (level === null) { +function AuthorBadge({ trustScore }: { trustScore: number | null }) { + if (trustScore === null) { return not on MergeShip; } return ( - - L{level} - {xp !== null && {xp.toLocaleString()} XP} - {merged !== null && merged > 0 && · {merged} merged} - + Trust {trustScore} ); } diff --git a/src/app/actions/maintainer/analytics.ts b/src/app/actions/maintainer/analytics.ts index a1c051ed..15f8b7d4 100644 --- a/src/app/actions/maintainer/analytics.ts +++ b/src/app/actions/maintainer/analytics.ts @@ -15,6 +15,7 @@ import { type MaintainerPrRow, type QueueFilters, } from '@/lib/maintainer/queue'; +import { computeTrustScore } from '@/lib/maintainer/trust'; import { xpForLevel, MAX_LEVEL } from '@/lib/xp/curve'; import { type RepoHealthRow, @@ -299,14 +300,14 @@ export async function exportPrQueueCsv( const profilesById = new Map< string, - { handle: string; level: number; xp: number; mergedPrs: number } + { handle: string; level: number; xp: number; mergedPrs: number; trustScore: number } >(); const ids = Array.from(new Set([...authorIds, ...mentorIds])); if (ids.length > 0) { const { data: profileRows } = await service .from('profiles') - .select('id, github_handle, level, xp') + .select('id, github_handle, level, xp, created_at') .in('id', ids); const merged = await service .from('xp_events') @@ -318,11 +319,22 @@ export async function exportPrQueueCsv( mergedCount.set(row.user_id, (mergedCount.get(row.user_id) ?? 0) + 1); } for (const p of profileRows ?? []) { + const createdTime = p.created_at ? new Date(p.created_at).getTime() : Date.now(); + const accountAgeDays = Math.max( + 0, + Math.floor((Date.now() - createdTime) / (1000 * 60 * 60 * 24)), + ); + const levelVal = p.level ?? 0; + const xpVal = p.xp ?? 0; + const mergedVal = mergedCount.get(p.id) ?? 0; + const trustScore = computeTrustScore(levelVal, xpVal, mergedVal, accountAgeDays); + profilesById.set(p.id, { handle: p.github_handle, - level: p.level ?? 0, - xp: p.xp ?? 0, - mergedPrs: mergedCount.get(p.id) ?? 0, + level: levelVal, + xp: xpVal, + mergedPrs: mergedVal, + trustScore, }); } } @@ -343,6 +355,7 @@ export async function exportPrQueueCsv( authorLevel: author?.level ?? null, authorXp: author?.xp ?? null, authorMergedPrs: author?.mergedPrs ?? null, + authorTrustScore: author?.trustScore ?? null, mentorVerified: r.mentor_verified, mentorReviewerHandle: mentor?.handle ?? null, mentorReviewerLevel: mentor?.level ?? null, diff --git a/src/app/actions/maintainer/queue.ts b/src/app/actions/maintainer/queue.ts index 35fc49e6..4a9a5ee9 100644 --- a/src/app/actions/maintainer/queue.ts +++ b/src/app/actions/maintainer/queue.ts @@ -11,6 +11,7 @@ import { type MaintainerPrRow, type QueueFilters, } from '@/lib/maintainer/queue'; +import { computeTrustScore } from '@/lib/maintainer/trust'; import { classifyTriage, type IssueTriageBucket } from '@/lib/maintainer/issue-triage'; import { inngest } from '@/inngest/client'; import { getInstallOctokit } from '@/lib/github/app'; @@ -131,14 +132,14 @@ export async function getMaintainerPrQueue(args: { const profilesById = new Map< string, - { handle: string; level: number; xp: number; mergedPrs: number } + { handle: string; level: number; xp: number; mergedPrs: number; trustScore: number } >(); const ids = Array.from(new Set([...authorIds, ...mentorIds])); if (ids.length > 0) { const { data: profileRows } = await service .from('profiles') - .select('id, github_handle, level, xp') + .select('id, github_handle, level, xp, created_at') .in('id', ids); const merged = await service .from('xp_events') @@ -150,11 +151,22 @@ export async function getMaintainerPrQueue(args: { mergedCount.set(row.user_id, (mergedCount.get(row.user_id) ?? 0) + 1); } for (const p of profileRows ?? []) { + const createdTime = p.created_at ? new Date(p.created_at).getTime() : Date.now(); + const accountAgeDays = Math.max( + 0, + Math.floor((Date.now() - createdTime) / (1000 * 60 * 60 * 24)), + ); + const levelVal = p.level ?? 0; + const xpVal = p.xp ?? 0; + const mergedVal = mergedCount.get(p.id) ?? 0; + const trustScore = computeTrustScore(levelVal, xpVal, mergedVal, accountAgeDays); + profilesById.set(p.id, { handle: p.github_handle, - level: p.level ?? 0, - xp: p.xp ?? 0, - mergedPrs: mergedCount.get(p.id) ?? 0, + level: levelVal, + xp: xpVal, + mergedPrs: mergedVal, + trustScore, }); } } @@ -175,6 +187,7 @@ export async function getMaintainerPrQueue(args: { authorLevel: author?.level ?? null, authorXp: author?.xp ?? null, authorMergedPrs: author?.mergedPrs ?? null, + authorTrustScore: author?.trustScore ?? null, mentorVerified: r.mentor_verified, mentorReviewerHandle: mentor?.handle ?? null, mentorReviewerLevel: mentor?.level ?? null, diff --git a/src/lib/maintainer/queue.test.ts b/src/lib/maintainer/queue.test.ts index c9e866e7..11dd415a 100644 --- a/src/lib/maintainer/queue.test.ts +++ b/src/lib/maintainer/queue.test.ts @@ -14,6 +14,7 @@ const row = (overrides: Partial): MaintainerPrRow => ({ authorLevel: null, authorXp: null, authorMergedPrs: null, + authorTrustScore: null, mentorVerified: false, mentorReviewerHandle: null, mentorReviewerLevel: null, diff --git a/src/lib/maintainer/queue.ts b/src/lib/maintainer/queue.ts index 84a45a1d..c17e19d8 100644 --- a/src/lib/maintainer/queue.ts +++ b/src/lib/maintainer/queue.ts @@ -22,6 +22,7 @@ export type MaintainerPrRow = { authorLevel: number | null; // null = not on MergeShip authorXp: number | null; authorMergedPrs: number | null; + authorTrustScore: number | null; mentorVerified: boolean; mentorReviewerHandle: string | null; mentorReviewerLevel: number | null; diff --git a/src/lib/maintainer/trust.test.ts b/src/lib/maintainer/trust.test.ts new file mode 100644 index 00000000..ba20fa18 --- /dev/null +++ b/src/lib/maintainer/trust.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { computeTrustScore } from './trust'; + +describe('computeTrustScore', () => { + it('should return 0 for zero or negative values', () => { + expect(computeTrustScore(0, 0, 0, 0)).toBe(0); + expect(computeTrustScore(-1, -100, -5, -10)).toBe(0); + }); + + it('should return 100 for maximum/saturated values', () => { + expect(computeTrustScore(5, 5000, 10, 90)).toBe(100); + expect(computeTrustScore(6, 6000, 15, 100)).toBe(100); + }); + + it('should calculate level weighting correctly', () => { + // Level weight is 40% (normalized level / 5). + expect(computeTrustScore(5, 0, 0, 0)).toBe(40); + expect(computeTrustScore(2.5, 0, 0, 0)).toBe(20); + expect(computeTrustScore(1, 0, 0, 0)).toBe(8); + }); + + it('should calculate XP weighting correctly', () => { + // XP weight is 20% (normalized xp / 5000). + expect(computeTrustScore(0, 5000, 0, 0)).toBe(20); + expect(computeTrustScore(0, 2500, 0, 0)).toBe(10); + }); + + it('should calculate merged PRs weighting correctly', () => { + // Merged PRs weight is 30% (normalized merged / 10). + expect(computeTrustScore(0, 0, 10, 0)).toBe(30); + expect(computeTrustScore(0, 0, 5, 0)).toBe(15); + }); + + it('should calculate account age weighting correctly', () => { + // Account age weight is 10% (normalized days / 90). + expect(computeTrustScore(0, 0, 0, 90)).toBe(10); + expect(computeTrustScore(0, 0, 0, 45)).toBe(5); + }); + + it('should handle intermediate values and round correctly', () => { + // Level 3 (3/5 * 40 = 24) + // XP 2000 (2000/5000 * 20 = 8) + // Merged 3 (3/10 * 30 = 9) + // Age 30 (30/90 * 10 = 3.33) + // Total = 24 + 8 + 9 + 3.33 = 44.33 -> round to 44 + expect(computeTrustScore(3, 2000, 3, 30)).toBe(44); + + // Level 4 (4/5 * 40 = 32) + // XP 4000 (4000/5000 * 20 = 16) + // Merged 8 (8/10 * 30 = 24) + // Age 80 (80/90 * 10 = 8.89) + // Total = 32 + 16 + 24 + 8.89 = 80.89 -> round to 81 + expect(computeTrustScore(4, 4000, 8, 80)).toBe(81); + }); +}); diff --git a/src/lib/maintainer/trust.ts b/src/lib/maintainer/trust.ts new file mode 100644 index 00000000..7658dfe6 --- /dev/null +++ b/src/lib/maintainer/trust.ts @@ -0,0 +1,29 @@ +/** + * Computes a composite trust score (0 to 100) for a PR author based on their attributes. + * + * Weighted formula: + * - Level: Weight = 40% (max level is 5, so normalized level is level / 5) + * - XP: Weight = 20% (XP is normalized against a saturation point of 5000 XP) + * - Merged PRs: Weight = 30% (Merged PR count is normalized against a saturation point of 10 PRs) + * - Account Age: Weight = 10% (Account age in days is normalized against a saturation point of 90 days) + * + * All inputs are normalized to [0, 1] before applying the weights. + * The final score is rounded to the nearest integer. + */ +export function computeTrustScore( + level: number, + xp: number, + mergedPrs: number, + accountAgeDays: number, +): number { + // Normalize inputs to [0, 1] range + const normLevel = Math.min(Math.max(0, level) / 5, 1); + const normXp = Math.min(Math.max(0, xp) / 5000, 1); + const normMergedPrs = Math.min(Math.max(0, mergedPrs) / 10, 1); + const normAccountAge = Math.min(Math.max(0, accountAgeDays) / 90, 1); + + // Apply weights + const score = normLevel * 40 + normXp * 20 + normMergedPrs * 30 + normAccountAge * 10; + + return Math.round(score); +} From b338b94b21680aef55eb0beeca0daf738cbfa7aa Mon Sep 17 00:00:00 2001 From: RutujaGharat-11 Date: Fri, 10 Jul 2026 20:58:59 +0530 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/maintainer/trust.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/maintainer/trust.test.ts b/src/lib/maintainer/trust.test.ts index ba20fa18..9bfacd83 100644 --- a/src/lib/maintainer/trust.test.ts +++ b/src/lib/maintainer/trust.test.ts @@ -2,9 +2,10 @@ import { describe, it, expect } from 'vitest'; import { computeTrustScore } from './trust'; describe('computeTrustScore', () => { - it('should return 0 for zero or negative values', () => { + it('should return 0 for zero, negative, or non-finite values', () => { expect(computeTrustScore(0, 0, 0, 0)).toBe(0); expect(computeTrustScore(-1, -100, -5, -10)).toBe(0); + expect(computeTrustScore(NaN, 0, 0, 0)).toBe(0); }); it('should return 100 for maximum/saturated values', () => {