From 523a4c54c967fade818b8a99cc9cc28e78f2a7e6 Mon Sep 17 00:00:00 2001 From: ashishsinghbora <135435891+ashishsinghbora@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:54:34 +0530 Subject: [PATCH] fix: use authoritative metrics in progression pipeline - Implemented getContributorMetrics in src/lib/metrics/contributor-metrics.ts - Authoritatively computes merged contributions on official approved projects - Strictly excludes self-reviews, dismissed reviews, and non-approved repositories - Deduplicates multiple review events on the same pull request - Connected getContributorMetrics to progression evaluation pipeline and work dashboard - Added full unit test coverage for zero metrics, contributor/active/core thresholds, self-review exclusion, and dismissed reviews --- src/app/dashboard/work/page.tsx | 30 +- src/lib/metrics/contributor-metrics.test.ts | 344 ++++++++++++++++++++ src/lib/metrics/contributor-metrics.ts | 149 +++++++++ src/lib/metrics/index.ts | 2 + src/lib/progression/pipeline.test.ts | 3 + src/lib/progression/pipeline.ts | 27 +- 6 files changed, 509 insertions(+), 46 deletions(-) create mode 100644 src/lib/metrics/contributor-metrics.test.ts create mode 100644 src/lib/metrics/contributor-metrics.ts diff --git a/src/app/dashboard/work/page.tsx b/src/app/dashboard/work/page.tsx index 6270945..d48e74b 100644 --- a/src/app/dashboard/work/page.tsx +++ b/src/app/dashboard/work/page.tsx @@ -7,6 +7,7 @@ import { eq, and, desc } from "drizzle-orm"; import { determineNextAction } from "@/lib/progression/next-action"; import { evaluateProgression, type ContributorLevel } from "@/lib/progression/rules"; import { expireOverdueClaims } from "@/lib/issues/claims"; +import { getContributorMetrics } from "@/lib/metrics"; export const metadata = { title: "My Work & Contributions — TechNexusOrg", @@ -112,32 +113,9 @@ export default async function MyWorkPage() { .where(eq(schema.credentials.userId, sessionUser.id)) .orderBy(desc(schema.credentials.issuedAt)); - // 5. Progression metrics - const completedClaimsCount = await db - .select({ id: schema.issueClaims.id }) - .from(schema.issueClaims) - .where( - and( - eq(schema.issueClaims.userId, sessionUser.id), - eq(schema.issueClaims.status, "completed") - ) - ); - - const reviewsCount = await db - .select({ id: schema.pullRequestReviews.id }) - .from(schema.pullRequestReviews) - .where(eq(schema.pullRequestReviews.reviewerGithubId, sessionUser.githubId)); - - const uniqueProjects = new Set(userContributions.map((c: any) => c.githubRepo)); - - const progression = evaluateProgression(sessionUser.level as ContributorLevel, { - prsOpened: userPrs.length, - prsMerged: userContributions.filter((c: any) => c.state === "merged").length, - issuesResolved: completedClaimsCount.length, - reviewsCompleted: reviewsCount.length, - projectsContributedCount: uniqueProjects.size, - isOnboarded: true, - }); + // 5. Gather authoritative progression metrics + const metrics = await getContributorMetrics(sessionUser.id, db); + const progression = evaluateProgression(sessionUser.level as ContributorLevel, metrics); // 6. Calculate Next Action const activeClaim = activeClaims[0] || null; diff --git a/src/lib/metrics/contributor-metrics.test.ts b/src/lib/metrics/contributor-metrics.test.ts new file mode 100644 index 0000000..bcc912d --- /dev/null +++ b/src/lib/metrics/contributor-metrics.test.ts @@ -0,0 +1,344 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { getDb, schema } from "@/lib/db"; +import { runMigrations } from "@/lib/db/migrate"; +import { getContributorMetrics } from "./contributor-metrics"; +import { evaluateProgression } from "@/lib/progression/rules"; + +describe("Authoritative Contributor Metrics Engine", () => { + const userZeroId = "usr_metrics_zero"; + const userAliceId = "usr_metrics_alice"; + const projectOfficial1Id = "proj_metrics_off_1"; + const projectOfficial2Id = "proj_metrics_off_2"; + const projectUnapprovedId = "proj_metrics_unapproved"; + + beforeAll(async () => { + (process.env as Record).NODE_ENV = "test"; + await runMigrations(); + const db = await getDb(); + + // Seed test users + await db.insert(schema.users).values([ + { + id: userZeroId, + githubId: 889901, + githubUsername: "metrics_zero", + displayName: "Zero Metrics User", + role: "contributor", + level: "explorer", + isOnboarded: false, + }, + { + id: userAliceId, + githubId: 889902, + githubUsername: "metrics_alice", + displayName: "Alice Contributor", + role: "contributor", + level: "explorer", + isOnboarded: true, + }, + ]); + + // Seed official approved projects + await db.insert(schema.projects).values([ + { + id: projectOfficial1Id, + name: "Official Repo 1", + slug: "off-repo-1", + githubRepo: "TechNexusOrg/off-repo-1", + description: "Official repository 1", + primaryLanguage: "TypeScript", + isOfficial: true, + contributionEnabled: true, + firstPrEnabled: true, + approvedAt: new Date(), + }, + { + id: projectOfficial2Id, + name: "Official Repo 2", + slug: "off-repo-2", + githubRepo: "TechNexusOrg/off-repo-2", + description: "Official repository 2", + primaryLanguage: "TypeScript", + isOfficial: true, + contributionEnabled: true, + firstPrEnabled: true, + approvedAt: new Date(), + }, + { + id: projectUnapprovedId, + name: "Unapproved Repo", + slug: "unapproved-repo", + githubRepo: "TechNexusOrg/unapproved-repo", + description: "Unapproved repository", + primaryLanguage: "TypeScript", + isOfficial: false, + contributionEnabled: false, + firstPrEnabled: false, + approvedAt: null, + }, + ]); + }); + + it("returns zero metrics for a newly registered, non-onboarded user", async () => { + const db = await getDb(); + const metrics = await getContributorMetrics(userZeroId, db); + + expect(metrics.prsOpened).toBe(0); + expect(metrics.prsMerged).toBe(0); + expect(metrics.issuesResolved).toBe(0); + expect(metrics.reviewsCompleted).toBe(0); + expect(metrics.projectsContributedCount).toBe(0); + expect(metrics.isOnboarded).toBe(false); + + const progression = evaluateProgression("explorer", metrics); + expect(progression.eligibleLevel).toBe("explorer"); + expect(progression.canPromote).toBe(false); + }); + + it("excludes contributions and reviews from unapproved projects", async () => { + const db = await getDb(); + + // Add contribution on unapproved project + await db.insert(schema.contributions).values({ + id: "contrib_unapproved_1", + userId: userAliceId, + projectId: projectUnapprovedId, + githubPrNumber: 1, + prTitle: "fix: unauthorized contribution", + prUrl: "https://github.com/TechNexusOrg/unapproved-repo/pull/1", + state: "merged", + isFirstPr: true, + mergedAt: new Date(), + }); + + const metrics = await getContributorMetrics(userAliceId, db); + expect(metrics.prsMerged).toBe(0); + expect(metrics.projectsContributedCount).toBe(0); + }); + + it("evaluates contributor threshold upon legitimate merged PR on approved project", async () => { + const db = await getDb(); + + // Add legitimate contribution on official project 1 + await db.insert(schema.contributions).values({ + id: "contrib_official_1", + userId: userAliceId, + projectId: projectOfficial1Id, + githubPrNumber: 10, + prTitle: "feat: add robust metric pipeline", + prUrl: "https://github.com/TechNexusOrg/off-repo-1/pull/10", + state: "merged", + isFirstPr: true, + mergedAt: new Date(), + }); + + const metrics = await getContributorMetrics(userAliceId, db); + expect(metrics.prsMerged).toBe(1); + expect(metrics.projectsContributedCount).toBe(1); + expect(metrics.isOnboarded).toBe(true); + + const progression = evaluateProgression("explorer", metrics); + expect(progression.eligibleLevel).toBe("contributor"); + expect(progression.canPromote).toBe(true); + }); + + it("excludes self-reviews and dismissed reviews from reviewsCompleted count", async () => { + const db = await getDb(); + + // Create a PR authored by Alice + const alicePrId = "pr_authored_by_alice"; + await db.insert(schema.pullRequests).values({ + id: alicePrId, + userId: userAliceId, + projectId: projectOfficial1Id, + githubPrId: 1010, + githubPrNumber: 10, + title: "feat: add robust metric pipeline", + url: "https://github.com/TechNexusOrg/off-repo-1/pull/10", + state: "merged", + openedAt: new Date(), + }); + + // Alice reviews her own PR (self-review) + await db.insert(schema.pullRequestReviews).values({ + id: "review_alice_self", + pullRequestId: alicePrId, + reviewerGithubId: 889902, + reviewerUsername: "metrics_alice", + reviewState: "approved", + submittedAt: new Date(), + }); + + // Create a PR authored by someone else + const bobPrId = "pr_authored_by_bob"; + await db.insert(schema.pullRequests).values({ + id: bobPrId, + projectId: projectOfficial1Id, + githubPrId: 1011, + githubPrNumber: 11, + title: "fix: memory leak", + url: "https://github.com/TechNexusOrg/off-repo-1/pull/11", + state: "open", + openedAt: new Date(), + }); + + // Alice submits a review that is dismissed + await db.insert(schema.pullRequestReviews).values({ + id: "review_alice_dismissed", + pullRequestId: bobPrId, + reviewerGithubId: 889902, + reviewerUsername: "metrics_alice", + reviewState: "dismissed", + submittedAt: new Date(), + }); + + // Alice submits multiple legitimate reviews on the same PR (bobPrId) + await db.insert(schema.pullRequestReviews).values([ + { + id: "review_alice_valid_1", + pullRequestId: bobPrId, + reviewerGithubId: 889902, + reviewerUsername: "metrics_alice", + reviewState: "changes_requested", + submittedAt: new Date(), + }, + { + id: "review_alice_valid_2", + pullRequestId: bobPrId, + reviewerGithubId: 889902, + reviewerUsername: "metrics_alice", + reviewState: "approved", + submittedAt: new Date(), + }, + ]); + + const metrics = await getContributorMetrics(userAliceId, db); + // Even though there are 4 reviews in the table: + // - 1 is self-review (excluded) + // - 1 is dismissed (excluded) + // - 2 are on the same PR (deduplicated to 1 PR review) + expect(metrics.reviewsCompleted).toBe(1); + }); + + it("reaches core contributor threshold when all criteria (5 PRs, 2 reviews, 2 projects, 2 issues) are met", async () => { + const db = await getDb(); + + // 1. Add 4 more merged PRs across official repo 1 and official repo 2 (total 5 PRs, 2 projects) + await db.insert(schema.contributions).values([ + { + id: "contrib_official_2", + userId: userAliceId, + projectId: projectOfficial1Id, + githubPrNumber: 12, + prTitle: "feat: telemetry dashboard", + prUrl: "https://github.com/TechNexusOrg/off-repo-1/pull/12", + state: "merged", + mergedAt: new Date(), + }, + { + id: "contrib_official_3", + userId: userAliceId, + projectId: projectOfficial1Id, + githubPrNumber: 13, + prTitle: "fix: flaky timeout", + prUrl: "https://github.com/TechNexusOrg/off-repo-1/pull/13", + state: "merged", + mergedAt: new Date(), + }, + { + id: "contrib_official_4", + userId: userAliceId, + projectId: projectOfficial2Id, + githubPrNumber: 21, + prTitle: "feat: database connector", + prUrl: "https://github.com/TechNexusOrg/off-repo-2/pull/21", + state: "merged", + mergedAt: new Date(), + }, + { + id: "contrib_official_5", + userId: userAliceId, + projectId: projectOfficial2Id, + githubPrNumber: 22, + prTitle: "refactor: optimize cache", + prUrl: "https://github.com/TechNexusOrg/off-repo-2/pull/22", + state: "merged", + mergedAt: new Date(), + }, + ]); + + // 2. Add a second distinct PR review on official repo 2 + const carolPrId = "pr_authored_by_carol"; + await db.insert(schema.pullRequests).values({ + id: carolPrId, + projectId: projectOfficial2Id, + githubPrId: 2030, + githubPrNumber: 30, + title: "docs: add architecture spec", + url: "https://github.com/TechNexusOrg/off-repo-2/pull/30", + state: "merged", + openedAt: new Date(), + }); + + await db.insert(schema.pullRequestReviews).values({ + id: "review_alice_valid_3", + pullRequestId: carolPrId, + reviewerGithubId: 889902, + reviewerUsername: "metrics_alice", + reviewState: "approved", + submittedAt: new Date(), + }); + + // 3. Add 2 completed issue claims + // Seed issues first + await db.insert(schema.issues).values([ + { + id: "iss_metrics_1", + projectId: projectOfficial1Id, + githubIssueId: 771, + githubIssueNumber: 101, + title: "Test issue 1", + state: "closed", + htmlUrl: "https://github.com/TechNexusOrg/off-repo-1/issues/101", + }, + { + id: "iss_metrics_2", + projectId: projectOfficial2Id, + githubIssueId: 772, + githubIssueNumber: 201, + title: "Test issue 2", + state: "closed", + htmlUrl: "https://github.com/TechNexusOrg/off-repo-2/issues/201", + }, + ]); + + await db.insert(schema.issueClaims).values([ + { + id: "claim_alice_1", + issueId: "iss_metrics_1", + userId: userAliceId, + status: "completed", + claimedAt: new Date(), + expiresAt: new Date(), + }, + { + id: "claim_alice_2", + issueId: "iss_metrics_2", + userId: userAliceId, + status: "completed", + claimedAt: new Date(), + expiresAt: new Date(), + }, + ]); + + const metrics = await getContributorMetrics(userAliceId, db); + expect(metrics.prsMerged).toBe(5); + expect(metrics.reviewsCompleted).toBe(2); + expect(metrics.projectsContributedCount).toBe(2); + expect(metrics.issuesResolved).toBe(2); + + const progression = evaluateProgression("active_contributor", metrics); + expect(progression.eligibleLevel).toBe("core_contributor"); + expect(progression.canPromote).toBe(true); + }); +}); diff --git a/src/lib/metrics/contributor-metrics.ts b/src/lib/metrics/contributor-metrics.ts new file mode 100644 index 0000000..11499c4 --- /dev/null +++ b/src/lib/metrics/contributor-metrics.ts @@ -0,0 +1,149 @@ +import { getDb, schema } from "@/lib/db"; +import { eq, and } from "drizzle-orm"; +import type { ContributorMetrics } from "@/lib/progression/rules"; + +/** + * Calculates authoritative, server-verified contributor metrics for progression and dashboard display. + * Strictly verifies approved projects, eliminates duplicate reviews, ignores dismissed reviews, + * and excludes self-reviews. + */ +export async function getContributorMetrics( + userId: string, + customDb?: any +): Promise { + const db = customDb || (await getDb()); + + // 1. Fetch user record + const [user] = await db + .select({ + id: schema.users.id, + githubId: schema.users.githubId, + githubUsername: schema.users.githubUsername, + role: schema.users.role, + isOnboarded: schema.users.isOnboarded, + }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + + if (!user) { + return { + prsOpened: 0, + prsMerged: 0, + issuesResolved: 0, + reviewsCompleted: 0, + projectsContributedCount: 0, + isOnboarded: false, + }; + } + + // 2. Fetch contributions on official, approved projects + const contributions = await db + .select({ + id: schema.contributions.id, + projectId: schema.contributions.projectId, + prNumber: schema.contributions.githubPrNumber, + prUrl: schema.contributions.prUrl, + state: schema.contributions.state, + isOfficial: schema.projects.isOfficial, + contributionEnabled: schema.projects.contributionEnabled, + approvedAt: schema.projects.approvedAt, + }) + .from(schema.contributions) + .innerJoin(schema.projects, eq(schema.contributions.projectId, schema.projects.id)) + .where(eq(schema.contributions.userId, userId)); + + // Filter contributions to approved official projects only + const validContributions = contributions.filter( + (c: any) => c.isOfficial && c.contributionEnabled && c.approvedAt + ); + + // Deduplicate contributions by canonical prUrl + const seenPrUrls = new Set(); + const deduplicatedContributions: typeof validContributions = []; + for (const c of validContributions) { + const key = c.prUrl ? c.prUrl.toLowerCase() : `proj_${c.projectId}_pr_${c.prNumber}`; + if (!seenPrUrls.has(key)) { + seenPrUrls.add(key); + deduplicatedContributions.push(c); + } + } + + const mergedContributions = deduplicatedContributions.filter((c: any) => c.state === "merged"); + const uniqueOfficialProjects = new Set(mergedContributions.map((c: any) => c.projectId)); + + // 3. Fetch completed issue claims + const completedClaims = await db + .select({ id: schema.issueClaims.id }) + .from(schema.issueClaims) + .where( + and( + eq(schema.issueClaims.userId, userId), + eq(schema.issueClaims.status, "completed") + ) + ); + + // 4. Fetch code reviews completed by this user + // Avoid self-review, avoid dismissed reviews, avoid duplicate review events on the same PR + const reviews = await db + .select({ + id: schema.pullRequestReviews.id, + pullRequestId: schema.pullRequestReviews.pullRequestId, + reviewState: schema.pullRequestReviews.reviewState, + reviewerGithubId: schema.pullRequestReviews.reviewerGithubId, + reviewerUsername: schema.pullRequestReviews.reviewerUsername, + prAuthorUserId: schema.pullRequests.userId, + isOfficial: schema.projects.isOfficial, + contributionEnabled: schema.projects.contributionEnabled, + }) + .from(schema.pullRequestReviews) + .innerJoin( + schema.pullRequests, + eq(schema.pullRequestReviews.pullRequestId, schema.pullRequests.id) + ) + .innerJoin( + schema.projects, + eq(schema.pullRequests.projectId, schema.projects.id) + ) + .where( + eq(schema.pullRequestReviews.reviewerGithubId, user.githubId) + ); + + const eligibleReviewedPrIds = new Set(); + for (const r of reviews as any[]) { + // Only count reviews on official projects + if (!r.isOfficial || !r.contributionEnabled) { + continue; + } + + // Ignore dismissed reviews + if (r.reviewState === "dismissed") { + continue; + } + + // Only count legitimate review outcomes + if (r.reviewState !== "approved" && r.reviewState !== "changes_requested") { + continue; + } + + // Exclude self-reviews + if (r.prAuthorUserId && r.prAuthorUserId === userId) { + continue; + } + + // Add unique PR id + eligibleReviewedPrIds.add(r.pullRequestId); + } + + return { + prsOpened: deduplicatedContributions.length, + prsMerged: mergedContributions.length, + issuesResolved: completedClaims.length, + reviewsCompleted: eligibleReviewedPrIds.size, + projectsContributedCount: uniqueOfficialProjects.size, + isOnboarded: Boolean(user.isOnboarded), + isMaintainerAssigned: user.role === "maintainer", + isProjectLeadAssigned: user.role === "project_lead", + isMentorAssigned: user.role === "mentor", + }; +} diff --git a/src/lib/metrics/index.ts b/src/lib/metrics/index.ts index 3fa6bbc..2a185a5 100644 --- a/src/lib/metrics/index.ts +++ b/src/lib/metrics/index.ts @@ -48,3 +48,5 @@ export async function getLivePlatformMetrics(): Promise { }; } } + +export { getContributorMetrics } from "./contributor-metrics"; diff --git a/src/lib/progression/pipeline.test.ts b/src/lib/progression/pipeline.test.ts index bb09ae3..ad9d55c 100644 --- a/src/lib/progression/pipeline.test.ts +++ b/src/lib/progression/pipeline.test.ts @@ -36,6 +36,9 @@ describe("Contributor Progression Automation Pipeline", () => { primaryLanguage: "TypeScript", languages: ["TypeScript"], isOfficial: true, + contributionEnabled: true, + firstPrEnabled: true, + approvedAt: new Date(), }); }); diff --git a/src/lib/progression/pipeline.ts b/src/lib/progression/pipeline.ts index 16508bd..371362d 100644 --- a/src/lib/progression/pipeline.ts +++ b/src/lib/progression/pipeline.ts @@ -8,6 +8,7 @@ import { } from "@/lib/credentials/engine"; import { evaluateProgression, type ContributorLevel } from "@/lib/progression/rules"; import { claimFoundingMembership } from "@/lib/founding"; +import { getContributorMetrics } from "@/lib/metrics"; export interface ProcessContributionParams { userId: string; @@ -129,23 +130,9 @@ export async function processProgressionOnContribution( } } - // 3. Gather live user metrics to evaluate promotion - const userContributions = await db - .select() - .from(schema.contributions) - .where(eq(schema.contributions.userId, params.userId)); - - const mergedContributions = userContributions.filter((c: any) => c.state === "merged"); - const distinctProjects = new Set(userContributions.map((c: any) => c.projectId)).size; - - const progression = evaluateProgression(previousLevel, { - prsOpened: userContributions.length, - prsMerged: mergedContributions.length, - issuesResolved: 0, - reviewsCompleted: 0, - projectsContributedCount: distinctProjects, - isOnboarded: user.isOnboarded, - }); + // 3. Gather authoritative live user metrics to evaluate promotion + const metrics = await getContributorMetrics(params.userId, db); + const progression = evaluateProgression(previousLevel, metrics); let newLevel = previousLevel; @@ -171,8 +158,8 @@ export async function processProgressionOnContribution( metadata: { previousLevel, newLevel, - totalPrsMerged: mergedContributions.length, - projectsCount: distinctProjects, + totalPrsMerged: metrics.prsMerged, + projectsCount: metrics.projectsContributedCount, reason: "Met objective criteria for contributor advancement", }, }); @@ -200,7 +187,7 @@ export async function processProgressionOnContribution( prTitle: params.prTitle, mergedAt: params.mergedAt.toISOString(), verifiedAt: new Date().toISOString(), - additionalNotes: `Promoted to Active Contributor with ${mergedContributions.length} merged pull requests`, + additionalNotes: `Promoted to Active Contributor with ${metrics.prsMerged} merged pull requests`, }; const meta = buildCredentialMetadata("verified_contributor", evidence);