Skip to content
Open
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
20 changes: 4 additions & 16 deletions src/app/(app)/maintainer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ export default async function MaintainerPage({
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
<span>@{r.authorLogin}</span>
<AuthorBadge level={r.authorLevel} xp={r.authorXp} merged={r.authorMergedPrs} />
<AuthorBadge trustScore={r.authorTrustScore} />
<span className="text-zinc-600">·</span>
<span>{relativeTime(r.githubUpdatedAt)}</span>
</div>
Expand Down Expand Up @@ -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 <span className="text-zinc-600">not on MergeShip</span>;
}
return (
<span className="inline-flex items-center gap-1.5 text-zinc-500">
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-zinc-300">L{level}</span>
{xp !== null && <span>{xp.toLocaleString()} XP</span>}
{merged !== null && merged > 0 && <span>· {merged} merged</span>}
</span>
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-zinc-300">Trust {trustScore}</span>
);
}

Expand Down
23 changes: 18 additions & 5 deletions src/app/actions/maintainer/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand All @@ -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,
});
}
}
Expand All @@ -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,
Expand Down
23 changes: 18 additions & 5 deletions src/app/actions/maintainer/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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')
Expand All @@ -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,
});
}
}
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/lib/maintainer/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const row = (overrides: Partial<MaintainerPrRow>): MaintainerPrRow => ({
authorLevel: null,
authorXp: null,
authorMergedPrs: null,
authorTrustScore: null,
mentorVerified: false,
mentorReviewerHandle: null,
mentorReviewerLevel: null,
Expand Down
1 change: 1 addition & 0 deletions src/lib/maintainer/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 56 additions & 0 deletions src/lib/maintainer/trust.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 29 additions & 0 deletions src/lib/maintainer/trust.ts
Original file line number Diff line number Diff line change
@@ -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);
}