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..9bfacd83
--- /dev/null
+++ b/src/lib/maintainer/trust.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect } from 'vitest';
+import { computeTrustScore } from './trust';
+
+describe('computeTrustScore', () => {
+ 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', () => {
+ 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);
+}