diff --git a/src/app/api/sync/contributions/route.ts b/src/app/api/sync/contributions/route.ts new file mode 100644 index 0000000..8bac1b7 --- /dev/null +++ b/src/app/api/sync/contributions/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { syncUserContributions } from "@/lib/github/contributions"; + +export async function POST() { + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const session = await verifySessionToken(token); + if (!session) { + return NextResponse.json({ error: "Invalid session" }, { status: 401 }); + } + + try { + const result = await syncUserContributions(session.id); + return NextResponse.json({ + status: "success", + ...result, + }); + } catch (error: any) { + console.error("Contributions sync failed:", error); + return NextResponse.json( + { error: error?.message || "Failed to sync contributions" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/webhooks/github/route.ts b/src/app/api/webhooks/github/route.ts index 868e7f4..dba469f 100644 --- a/src/app/api/webhooks/github/route.ts +++ b/src/app/api/webhooks/github/route.ts @@ -2,13 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { verifyWebhookSignature, type WebhookPullRequestEvent } from "@/lib/github/webhooks"; import { getDb, schema } from "@/lib/db"; import { eq, and } from "drizzle-orm"; -import { - generateCredentialId, - buildCredentialMetadata, - formatVerificationUrl, - type CredentialEvidence, -} from "@/lib/credentials/engine"; -import { evaluateProgression } from "@/lib/progression/rules"; +import { processProgressionOnContribution } from "@/lib/progression/pipeline"; export async function POST(request: NextRequest) { const signature = request.headers.get("x-hub-signature-256"); @@ -99,84 +93,27 @@ export async function POST(request: NextRequest) { verificationSource: "github_webhook", }); - // 4. Issue verifiable credential for First PR if applicable - if (isFirstPr) { - const credId = generateCredentialId("first_pr_merged", user.id); - const evidence: CredentialEvidence = { - githubUsername: user.githubUsername, - repository: repoFullName, - prNumber: pr.number, - prUrl: pr.html_url, - prTitle: pr.title, - mergedAt: mergedDate.toISOString(), - verifiedAt: new Date().toISOString(), - }; - const meta = buildCredentialMetadata("first_pr_merged", evidence); - - await db.insert(schema.credentials).values({ - id: credId, - userId: user.id, - type: "first_pr_merged", - title: meta.title, - description: meta.description, - status: "active", - issuer: "TechNexusOrg", - issuedAt: new Date(), - evidenceData: evidence, - verificationUrl: formatVerificationUrl(credId), - }); - - // Check if user is in Founding 1,000 to verify - await db - .update(schema.foundingMembers) - .set({ - status: "active", - firstPrId: contributionId, - verifiedAt: new Date(), - }) - .where(eq(schema.foundingMembers.userId, user.id)); - } - - // 5. Evaluate and update contributor progression - const totalMerged = existingContributions.length + 1; - const progression = evaluateProgression(user.level as any, { - prsOpened: totalMerged, - prsMerged: totalMerged, - issuesResolved: 0, - reviewsCompleted: 0, - projectsContributedCount: 1, - isOnboarded: user.isOnboarded, + // 4. Process contributor progression, credentials, and Founding 1,000 cohort + const progressionResult = await processProgressionOnContribution(db, { + userId: user.id, + contributionId, + githubUsername: user.githubUsername, + repoFullName, + prNumber: pr.number, + prUrl: pr.html_url, + prTitle: pr.title, + mergedAt: mergedDate, + isFirstPr, }); - if (progression.canPromote) { - await db - .update(schema.users) - .set({ - level: progression.eligibleLevel, - updatedAt: new Date(), - }) - .where(eq(schema.users.id, user.id)); - - // Log promotion audit - await db.insert(schema.auditLogs).values({ - id: `audit_${crypto.randomUUID()}`, - actorId: user.id, - action: "user.promoted", - targetType: "user", - targetId: user.id, - metadata: { - previousLevel: user.level, - newLevel: progression.eligibleLevel, - reason: "Automated progression based on verified PR merge", - }, - }); - } - return NextResponse.json({ status: "success", contributionId, isFirstPr, - level: progression.eligibleLevel, + promoted: progressionResult.promoted, + level: progressionResult.newLevel, + credentialsIssued: progressionResult.credentialsIssued, + foundingMemberNumber: progressionResult.foundingMemberNumber, }); } } catch (err: any) { diff --git a/src/app/dashboard/ProgressionPath.tsx b/src/app/dashboard/ProgressionPath.tsx new file mode 100644 index 0000000..365d556 --- /dev/null +++ b/src/app/dashboard/ProgressionPath.tsx @@ -0,0 +1,111 @@ +import type { ContributorLevel, ProgressionResult } from "@/lib/progression/rules"; + +interface ProgressionPathProps { + currentLevel: ContributorLevel; + progression: ProgressionResult; + mergedCount: number; +} + +const LEVELS: Array<{ key: ContributorLevel; label: string }> = [ + { key: "explorer", label: "Explorer" }, + { key: "contributor", label: "Contributor" }, + { key: "active_contributor", label: "Active" }, + { key: "core_contributor", label: "Core" }, + { key: "maintainer", label: "Maintainer" }, +]; + +const LEVEL_RANK: Record = { + explorer: 0, + contributor: 1, + active_contributor: 2, + core_contributor: 3, + maintainer: 4, + project_lead: 5, + mentor: 4, +}; + +export function ProgressionPath({ currentLevel, progression, mergedCount }: ProgressionPathProps) { + const currentRank = LEVEL_RANK[currentLevel] ?? 0; + + return ( +
+ {/* Milestone Stepper */} +
+
+
+ +
+ {LEVELS.map((lvl, idx) => { + const isCompleted = idx < currentRank; + const isCurrent = lvl.key === currentLevel; + + return ( +
+
+ {isCompleted ? "✓" : idx + 1} +
+ + {lvl.label} + +
+ ); + })} +
+
+ + {/* Criteria Breakdown */} +
+
+ + Next Milestone:{" "} + + {progression.nextLevel ? progression.nextLevel.replace("_", " ") : "Maintainer / Max Tier"} + + + {mergedCount} PR(s) verified & merged +
+ +
+

+ Remaining Requirements: +

+ {progression.requirementsForNextLevel.length > 0 ? ( +
    + {progression.requirementsForNextLevel.map((req, i) => ( +
  • + + {req} +
  • + ))} +
+ ) : ( +

+ All quantitative milestones satisfied for current evaluation cycle! +

+ )} +
+
+
+ ); +} diff --git a/src/app/dashboard/SyncContributionsButton.tsx b/src/app/dashboard/SyncContributionsButton.tsx new file mode 100644 index 0000000..4e3084d --- /dev/null +++ b/src/app/dashboard/SyncContributionsButton.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export function SyncContributionsButton() { + const [loading, setLoading] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + const router = useRouter(); + + async function handleSync() { + setLoading(true); + setStatusMessage("Scanning official repositories on GitHub..."); + + try { + const res = await fetch("/api/sync/contributions", { + method: "POST", + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Sync failed"); + } + + if (data.newContributionsCount > 0) { + setStatusMessage( + `Verified ${data.newContributionsCount} new PR(s)! ${ + data.credentialsIssued?.length ? "Credentials minted!" : "" + }` + ); + } else { + setStatusMessage("All contributions are up to date."); + } + + router.refresh(); + setTimeout(() => { + setStatusMessage(null); + }, 5000); + } catch (err: any) { + setStatusMessage(`Error: ${err.message}`); + setTimeout(() => { + setStatusMessage(null); + }, 5000); + } finally { + setLoading(false); + } + } + + return ( +
+ + + {statusMessage && ( + + {statusMessage} + + )} +
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 3d38679..f99c788 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -4,8 +4,10 @@ import Link from "next/link"; import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; import { getDb, schema } from "@/lib/db"; import { eq } from "drizzle-orm"; -import { evaluateProgression } from "@/lib/progression/rules"; +import { evaluateProgression, type ContributorLevel } from "@/lib/progression/rules"; import { recommendIssues } from "@/lib/recommendations/engine"; +import { SyncContributionsButton } from "./SyncContributionsButton"; +import { ProgressionPath } from "./ProgressionPath"; export const metadata = { title: "Contributor Dashboard — TechNexusOrg", @@ -116,13 +118,18 @@ export default async function DashboardPage() { /> )}
-
+

{sessionUser.displayName || sessionUser.githubUsername}

{sessionUser.level.replace("_", " ")} + {sessionUser.foundingNumber && ( + + Founding Member #{String(sessionUser.foundingNumber).padStart(4, "0")} + + )}

@{sessionUser.githubUsername} • GitHub Verified Identity @@ -131,6 +138,7 @@ export default async function DashboardPage() {

+ {/* Progression & Requirements */} -
+

- Progression Pipeline + Contributor Progression Pipeline

- Next milestone:{" "} - - {progression.nextLevel ? progression.nextLevel.replace("_", " ") : "Max Rank Reached"} - + Verified proof of work drives automatic rank elevation. No fake counts or self-awarded titles.

-
- {mergedCount} merged PR(s) verified -
-
-

- Requirements for Next Level: -

- {progression.requirementsForNextLevel.length > 0 ? ( -
    - {progression.requirementsForNextLevel.map((req, i) => ( -
  • - - {req} -
  • - ))} -
- ) : ( -

- All criteria satisfied. Outstanding contribution history! -

- )} -
+
{/* Stats Cards */} diff --git a/src/app/first-pr/page.tsx b/src/app/first-pr/page.tsx index 2b54ad4..d0a7315 100644 --- a/src/app/first-pr/page.tsx +++ b/src/app/first-pr/page.tsx @@ -1,5 +1,7 @@ import Link from "next/link"; import { getLivePlatformMetrics } from "@/lib/metrics"; +import { getDb, schema } from "@/lib/db"; +import { eq, and, desc } from "drizzle-orm"; export const dynamic = "force-dynamic"; @@ -11,6 +13,26 @@ export const metadata = { export default async function FirstPRPage() { const metrics = await getLivePlatformMetrics(); + const db = await getDb(); + + const recentFirstPrs = await db + .select({ + id: schema.contributions.id, + prNumber: schema.contributions.githubPrNumber, + prTitle: schema.contributions.prTitle, + prUrl: schema.contributions.prUrl, + mergedAt: schema.contributions.mergedAt, + username: schema.users.githubUsername, + displayName: schema.users.displayName, + avatarUrl: schema.users.avatarUrl, + repo: schema.projects.githubRepo, + }) + .from(schema.contributions) + .innerJoin(schema.users, eq(schema.contributions.userId, schema.users.id)) + .innerJoin(schema.projects, eq(schema.contributions.projectId, schema.projects.id)) + .where(and(eq(schema.contributions.isFirstPr, true), eq(schema.contributions.state, "merged"))) + .orderBy(desc(schema.contributions.mergedAt)) + .limit(10); return (
@@ -96,6 +118,63 @@ export default async function FirstPRPage() {
+ + {/* Live Recent Graduates */} +
+

+ Recent #FirstPR Graduates +

+ + {recentFirstPrs.length === 0 ? ( +
+

+ No #FirstPR completions recorded yet. +

+

+ Be the first contributor to merge a pull request and claim permanent proof of work! +

+
+ ) : ( +
+ {recentFirstPrs.map((grad: any) => ( +
+
+ {grad.avatarUrl && ( + {grad.username} + )} +
+ + {grad.displayName || grad.username} + +
+ PR #{grad.prNumber} in {grad.repo} +
+
+
+ + + GitHub PR ↗ + +
+ ))} +
+ )} +
); } diff --git a/src/lib/founding/index.test.ts b/src/lib/founding/index.test.ts new file mode 100644 index 0000000..f0e3ef8 --- /dev/null +++ b/src/lib/founding/index.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { getDb, schema } from "@/lib/db"; +import { runMigrations } from "@/lib/db/migrate"; +import { claimFoundingMembership } from "./index"; +import { eq } from "drizzle-orm"; + +describe("Founding 1,000 Allocation Engine", () => { + beforeAll(async () => { + (process.env as Record).NODE_ENV = "test"; + await runMigrations(); + }); + + it("assigns sequential member numbers to contributors with verified first PRs", async () => { + const db = await getDb(); + + // Seed test project + await db.insert(schema.projects).values({ + id: "proj_founding_test", + name: "platform", + slug: "platform", + githubRepo: "TechNexusOrg/platform", + description: "Platform repo", + primaryLanguage: "TypeScript", + languages: ["TypeScript"], + isOfficial: true, + }); + + // Seed test user 1 + const userId1 = "usr_founding_test_001"; + await db.insert(schema.users).values({ + id: userId1, + githubId: 881101, + githubUsername: "founding_alpha", + displayName: "Alpha Founder", + role: "contributor", + level: "explorer", + isOnboarded: true, + }); + + // Seed test user 2 + const userId2 = "usr_founding_test_002"; + await db.insert(schema.users).values({ + id: userId2, + githubId: 881102, + githubUsername: "founding_beta", + displayName: "Beta Founder", + role: "contributor", + level: "explorer", + isOnboarded: true, + }); + + // Seed contributions for user 1 & 2 + await db.insert(schema.contributions).values([ + { + id: "contrib_001", + userId: userId1, + projectId: "proj_founding_test", + githubPrNumber: 101, + prTitle: "fix: initial setup", + prUrl: "https://github.com/TechNexusOrg/platform/pull/101", + state: "merged", + isFirstPr: true, + mergedAt: new Date(), + }, + { + id: "contrib_002", + userId: userId2, + projectId: "proj_founding_test", + githubPrNumber: 102, + prTitle: "feat: add cli parser", + prUrl: "https://github.com/TechNexusOrg/platform/pull/102", + state: "merged", + isFirstPr: true, + mergedAt: new Date(), + }, + { + id: "contrib_duplicate", + userId: userId1, + projectId: "proj_founding_test", + githubPrNumber: 999, + prTitle: "duplicate pr", + prUrl: "https://github.com/TechNexusOrg/platform/pull/999", + state: "merged", + isFirstPr: false, + mergedAt: new Date(), + }, + ]); + + // Claim for user 1 + const res1 = await claimFoundingMembership(db, { + userId: userId1, + contributionId: "contrib_001", + githubUsername: "founding_alpha", + repo: "TechNexusOrg/platform", + prNumber: 101, + prUrl: "https://github.com/TechNexusOrg/platform/pull/101", + prTitle: "fix: initial setup", + mergedAt: new Date(), + }); + + expect(res1.claimed).toBe(true); + expect(res1.memberNumber).toBeGreaterThanOrEqual(1); + + // Claim for user 2 + const res2 = await claimFoundingMembership(db, { + userId: userId2, + contributionId: "contrib_002", + githubUsername: "founding_beta", + repo: "TechNexusOrg/platform", + prNumber: 102, + prUrl: "https://github.com/TechNexusOrg/platform/pull/102", + prTitle: "feat: add cli parser", + mergedAt: new Date(), + }); + + expect(res2.claimed).toBe(true); + expect(res2.memberNumber).toBe((res1.memberNumber || 0) + 1); + + // Verify user record reflects the founding number + const [user1] = await db + .select() + .from(schema.users) + .where(eq(schema.users.id, userId1)); + expect(user1.foundingNumber).toBe(res1.memberNumber); + + // Verify founding_1000 credential was minted + const creds = await db + .select() + .from(schema.credentials) + .where(eq(schema.credentials.userId, userId1)); + + const foundingCred = creds.find((c: any) => c.type === "founding_1000"); + expect(foundingCred).toBeDefined(); + expect(foundingCred?.status).toBe("active"); + }); + + it("prevents duplicate founding claims for the same user", async () => { + const db = await getDb(); + const userId = "usr_founding_test_001"; + + const duplicateRes = await claimFoundingMembership(db, { + userId, + contributionId: "contrib_duplicate", + githubUsername: "founding_alpha", + repo: "TechNexusOrg/platform", + prNumber: 999, + prUrl: "https://github.com/TechNexusOrg/platform/pull/999", + prTitle: "duplicate pr", + mergedAt: new Date(), + }); + + expect(duplicateRes.claimed).toBe(true); + expect(duplicateRes.reason).toBe("already_claimed"); + }); +}); diff --git a/src/lib/founding/index.ts b/src/lib/founding/index.ts new file mode 100644 index 0000000..b52f9a7 --- /dev/null +++ b/src/lib/founding/index.ts @@ -0,0 +1,139 @@ +import { schema } from "@/lib/db"; +import { eq, sql } from "drizzle-orm"; +import { + generateCredentialId, + buildCredentialMetadata, + formatVerificationUrl, + type CredentialEvidence, +} from "@/lib/credentials/engine"; + +export interface ClaimFoundingParams { + userId: string; + contributionId: string; + githubUsername: string; + repo: string; + prNumber: number; + prUrl: string; + prTitle: string; + mergedAt: Date; +} + +export interface ClaimFoundingResult { + claimed: boolean; + memberNumber?: number; + credentialId?: string; + reason?: string; +} + +/** + * Claims a founding membership slot sequentially (1 to 1000). + * Rule: Registration alone NEVER awards a founding member slot. + * Only verified completion of a first legitimate merged pull request can claim it. + */ +export async function claimFoundingMembership( + db: any, + params: ClaimFoundingParams +): Promise { + // 1. Check if user is already a founding member + const existing = await db + .select() + .from(schema.foundingMembers) + .where(eq(schema.foundingMembers.userId, params.userId)) + .limit(1); + + if (existing.length > 0) { + return { + claimed: true, + memberNumber: existing[0].memberNumber, + reason: "already_claimed", + }; + } + + // 2. Determine the next sequential member number + const maxResult = await db + .select({ + maxNumber: sql`COALESCE(MAX(${schema.foundingMembers.memberNumber}), 0)`, + }) + .from(schema.foundingMembers); + + const nextNumber = Number(maxResult[0]?.maxNumber || 0) + 1; + + if (nextNumber > 1000) { + return { + claimed: false, + reason: "cohort_full", + }; + } + + const foundingId = `founding_${crypto.randomUUID()}`; + const now = new Date(); + + // 3. Insert into founding_members + await db.insert(schema.foundingMembers).values({ + id: foundingId, + userId: params.userId, + memberNumber: nextNumber, + status: "active", + firstPrId: params.contributionId, + verifiedAt: now, + createdAt: now, + }); + + // 4. Mirror foundingNumber to users table + await db + .update(schema.users) + .set({ + foundingNumber: nextNumber, + updatedAt: now, + }) + .where(eq(schema.users.id, params.userId)); + + // 5. Mint Founding 1,000 Verifiable Credential + const credId = generateCredentialId("founding_1000", params.userId); + const evidence: CredentialEvidence = { + githubUsername: params.githubUsername, + repository: params.repo, + prNumber: params.prNumber, + prUrl: params.prUrl, + prTitle: params.prTitle, + mergedAt: params.mergedAt.toISOString(), + verifiedAt: now.toISOString(), + additionalNotes: `Permanent Founding Member #${String(nextNumber).padStart(4, "0")} of 1,000`, + }; + + const meta = buildCredentialMetadata("founding_1000", evidence); + + await db.insert(schema.credentials).values({ + id: credId, + userId: params.userId, + type: "founding_1000", + title: meta.title, + description: meta.description, + status: "active", + issuer: "TechNexusOrg", + issuedAt: now, + evidenceData: evidence, + verificationUrl: formatVerificationUrl(credId), + }); + + // 6. Record audit log + await db.insert(schema.auditLogs).values({ + id: `audit_${crypto.randomUUID()}`, + actorId: params.userId, + action: "founding_member.claimed", + targetType: "founding_member", + targetId: foundingId, + metadata: { + userId: params.userId, + memberNumber: nextNumber, + contributionId: params.contributionId, + credentialId: credId, + }, + }); + + return { + claimed: true, + memberNumber: nextNumber, + credentialId: credId, + }; +} diff --git a/src/lib/github/contributions.ts b/src/lib/github/contributions.ts new file mode 100644 index 0000000..c44d130 --- /dev/null +++ b/src/lib/github/contributions.ts @@ -0,0 +1,159 @@ +import { getGitHubClient, checkRateLimit } from "./client"; +import { getDb, schema } from "@/lib/db"; +import { eq, and } from "drizzle-orm"; +import { processProgressionOnContribution } from "@/lib/progression/pipeline"; + +export interface SyncContributionsResult { + syncedCount: number; + newContributionsCount: number; + credentialsIssued: string[]; + newLevel?: string; + foundingMemberNumber?: number; + rateLimitRemaining?: number; +} + +export async function syncUserContributions( + userId: string, + userToken?: string +): Promise { + const db = await getDb(); + + // 1. Fetch user + const [user] = await db + .select() + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + + if (!user) { + throw new Error(`User not found: ${userId}`); + } + + const octokit = getGitHubClient(userToken); + const rateLimit = await checkRateLimit(octokit); + + if (rateLimit && rateLimit.remaining < 5) { + throw new Error( + `GitHub API rate limit exhausted. Resets at ${rateLimit.resetDate.toISOString()}` + ); + } + + // 2. Fetch all official projects + const officialProjects = await db + .select() + .from(schema.projects) + .where(eq(schema.projects.isOfficial, true)); + + let syncedCount = 0; + let newContributionsCount = 0; + const allCredentialsIssued: string[] = []; + let latestLevel: string = user.level; + let foundingMemberNumber: number | undefined; + + for (const project of officialProjects) { + const [owner, repo] = project.githubRepo.split("/"); + if (!owner || !repo) continue; + + try { + const { data: pulls } = await octokit.rest.pulls.list({ + owner, + repo, + state: "closed", + per_page: 50, + }); + + for (const pr of pulls) { + // Must be authored by the target user and merged + const authorMatch = + pr.user?.id === user.githubId || + pr.user?.login.toLowerCase() === user.githubUsername.toLowerCase(); + + if (!authorMatch || !pr.merged_at) { + continue; + } + + syncedCount++; + + // Check if contribution already recorded + const existingContrib = await db + .select() + .from(schema.contributions) + .where( + and( + eq(schema.contributions.userId, user.id), + eq(schema.contributions.prUrl, pr.html_url) + ) + ) + .limit(1); + + if (existingContrib.length > 0) { + continue; // Already processed + } + + // Count previous merged contributions to determine if this is the first PR + const priorMerged = await db + .select() + .from(schema.contributions) + .where( + and( + eq(schema.contributions.userId, user.id), + eq(schema.contributions.state, "merged") + ) + ); + + const isFirstPr = priorMerged.length === 0; + const contributionId = `contrib_${crypto.randomUUID()}`; + const mergedDate = new Date(pr.merged_at); + + // Record contribution + await db.insert(schema.contributions).values({ + id: contributionId, + userId: user.id, + projectId: project.id, + githubPrNumber: pr.number, + prTitle: pr.title, + prUrl: pr.html_url, + state: "merged", + isFirstPr, + mergedAt: mergedDate, + verifiedAt: new Date(), + verificationSource: "github_sync", + }); + + newContributionsCount++; + + // Process progression & credentials + const progressionResult = await processProgressionOnContribution(db, { + userId: user.id, + contributionId, + githubUsername: user.githubUsername, + repoFullName: project.githubRepo, + prNumber: pr.number, + prUrl: pr.html_url, + prTitle: pr.title, + mergedAt: mergedDate, + isFirstPr, + }); + + latestLevel = progressionResult.newLevel; + if (progressionResult.credentialsIssued.length > 0) { + allCredentialsIssued.push(...progressionResult.credentialsIssued); + } + if (progressionResult.foundingMemberNumber) { + foundingMemberNumber = progressionResult.foundingMemberNumber; + } + } + } catch (err: any) { + console.warn(`Could not sync pulls for ${project.githubRepo}:`, err?.message); + } + } + + return { + syncedCount, + newContributionsCount, + credentialsIssued: allCredentialsIssued, + newLevel: latestLevel, + foundingMemberNumber, + rateLimitRemaining: rateLimit?.remaining, + }; +} diff --git a/src/lib/progression/pipeline.test.ts b/src/lib/progression/pipeline.test.ts new file mode 100644 index 0000000..bb09ae3 --- /dev/null +++ b/src/lib/progression/pipeline.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { getDb, schema } from "@/lib/db"; +import { runMigrations } from "@/lib/db/migrate"; +import { processProgressionOnContribution } from "./pipeline"; +import { eq, and } from "drizzle-orm"; + +describe("Contributor Progression Automation Pipeline", () => { + const userId = "usr_progression_pipeline_test"; + const projectId = "proj_progression_test"; + const repoFullName = "TechNexusOrg/test-runner"; + + beforeAll(async () => { + (process.env as Record).NODE_ENV = "test"; + await runMigrations(); + + const db = await getDb(); + + // Seed test user as explorer + await db.insert(schema.users).values({ + id: userId, + githubId: 9922001, + githubUsername: "pipeline_explorer", + displayName: "Pipeline Explorer", + role: "contributor", + level: "explorer", + isOnboarded: true, + }); + + // Seed test official project + await db.insert(schema.projects).values({ + id: projectId, + name: "test-runner", + slug: "test-runner", + githubRepo: repoFullName, + description: "Automated test runner", + primaryLanguage: "TypeScript", + languages: ["TypeScript"], + isOfficial: true, + }); + }); + + it("promotes explorer to contributor and mints first_pr_merged and founding_1000 on first PR", async () => { + const db = await getDb(); + const contribId = "contrib_first_pr_001"; + const mergedAt = new Date(); + + // Insert first contribution record + await db.insert(schema.contributions).values({ + id: contribId, + userId, + projectId, + githubPrNumber: 1, + prTitle: "fix: solve memory leak in test suite", + prUrl: `https://github.com/${repoFullName}/pull/1`, + state: "merged", + isFirstPr: true, + mergedAt, + verifiedAt: new Date(), + verificationSource: "github_webhook", + }); + + const result = await processProgressionOnContribution(db, { + userId, + contributionId: contribId, + githubUsername: "pipeline_explorer", + repoFullName, + prNumber: 1, + prUrl: `https://github.com/${repoFullName}/pull/1`, + prTitle: "fix: solve memory leak in test suite", + mergedAt, + isFirstPr: true, + }); + + expect(result.promoted).toBe(true); + expect(result.previousLevel).toBe("explorer"); + expect(result.newLevel).toBe("contributor"); + expect(result.credentialsIssued.length).toBeGreaterThanOrEqual(1); + expect(result.foundingMemberNumber).toBeGreaterThanOrEqual(1); + + // Verify database user level updated + const [user] = await db + .select() + .from(schema.users) + .where(eq(schema.users.id, userId)); + expect(user.level).toBe("contributor"); + + // Verify first_pr_merged credential exists + const creds = await db + .select() + .from(schema.credentials) + .where( + and( + eq(schema.credentials.userId, userId), + eq(schema.credentials.type, "first_pr_merged") + ) + ); + expect(creds.length).toBe(1); + expect(creds[0].status).toBe("active"); + }); + + it("promotes contributor to active_contributor upon 3 merged PRs and mints verified_contributor credential", async () => { + const db = await getDb(); + + // Insert 2nd merged contribution + await db.insert(schema.contributions).values({ + id: "contrib_pipeline_002", + userId, + projectId, + githubPrNumber: 2, + prTitle: "feat: add colored test reporter", + prUrl: `https://github.com/${repoFullName}/pull/2`, + state: "merged", + isFirstPr: false, + mergedAt: new Date(), + verifiedAt: new Date(), + }); + + // Insert 3rd merged contribution + const contribId3 = "contrib_pipeline_003"; + const mergedAt3 = new Date(); + await db.insert(schema.contributions).values({ + id: contribId3, + userId, + projectId, + githubPrNumber: 3, + prTitle: "perf: cache test runner artifacts", + prUrl: `https://github.com/${repoFullName}/pull/3`, + state: "merged", + isFirstPr: false, + mergedAt: mergedAt3, + verifiedAt: new Date(), + }); + + // Run progression on 3rd contribution + const result = await processProgressionOnContribution(db, { + userId, + contributionId: contribId3, + githubUsername: "pipeline_explorer", + repoFullName, + prNumber: 3, + prUrl: `https://github.com/${repoFullName}/pull/3`, + prTitle: "perf: cache test runner artifacts", + mergedAt: mergedAt3, + isFirstPr: false, + }); + + expect(result.promoted).toBe(true); + expect(result.previousLevel).toBe("contributor"); + expect(result.newLevel).toBe("active_contributor"); + + // Verify verified_contributor credential exists + const verifiedCreds = await db + .select() + .from(schema.credentials) + .where( + and( + eq(schema.credentials.userId, userId), + eq(schema.credentials.type, "verified_contributor") + ) + ); + expect(verifiedCreds.length).toBe(1); + expect(verifiedCreds[0].status).toBe("active"); + }); +}); diff --git a/src/lib/progression/pipeline.ts b/src/lib/progression/pipeline.ts new file mode 100644 index 0000000..16508bd --- /dev/null +++ b/src/lib/progression/pipeline.ts @@ -0,0 +1,297 @@ +import { schema } from "@/lib/db"; +import { eq, and } from "drizzle-orm"; +import { + generateCredentialId, + buildCredentialMetadata, + formatVerificationUrl, + type CredentialEvidence, +} from "@/lib/credentials/engine"; +import { evaluateProgression, type ContributorLevel } from "@/lib/progression/rules"; +import { claimFoundingMembership } from "@/lib/founding"; + +export interface ProcessContributionParams { + userId: string; + contributionId: string; + githubUsername: string; + repoFullName: string; + prNumber: number; + prUrl: string; + prTitle: string; + mergedAt: Date; + isFirstPr: boolean; +} + +export interface ProcessProgressionResult { + promoted: boolean; + previousLevel: ContributorLevel; + newLevel: ContributorLevel; + credentialsIssued: string[]; + foundingMemberNumber?: number; +} + +/** + * Automates contributor level evaluation, credential minting, and founding cohort enrollment + * following verified pull request merge events. + */ +export async function processProgressionOnContribution( + db: any, + params: ProcessContributionParams +): Promise { + const credentialsIssued: string[] = []; + let foundingMemberNumber: number | undefined; + + // 1. Fetch user + const [user] = await db + .select() + .from(schema.users) + .where(eq(schema.users.id, params.userId)) + .limit(1); + + if (!user) { + throw new Error(`User not found: ${params.userId}`); + } + + const previousLevel = user.level as ContributorLevel; + + // 2. First PR Credential & Founding 1,000 claim + if (params.isFirstPr) { + // Check if first_pr_merged credential already exists + const existingFirstPrCred = await db + .select() + .from(schema.credentials) + .where( + and( + eq(schema.credentials.userId, params.userId), + eq(schema.credentials.type, "first_pr_merged") + ) + ) + .limit(1); + + if (existingFirstPrCred.length === 0) { + const credId = generateCredentialId("first_pr_merged", params.userId); + const evidence: CredentialEvidence = { + githubUsername: params.githubUsername, + repository: params.repoFullName, + prNumber: params.prNumber, + prUrl: params.prUrl, + prTitle: params.prTitle, + mergedAt: params.mergedAt.toISOString(), + verifiedAt: new Date().toISOString(), + }; + const meta = buildCredentialMetadata("first_pr_merged", evidence); + + await db.insert(schema.credentials).values({ + id: credId, + userId: params.userId, + type: "first_pr_merged", + title: meta.title, + description: meta.description, + status: "active", + issuer: "TechNexusOrg", + issuedAt: new Date(), + evidenceData: evidence, + verificationUrl: formatVerificationUrl(credId), + }); + + credentialsIssued.push(credId); + + await db.insert(schema.auditLogs).values({ + id: `audit_${crypto.randomUUID()}`, + actorId: params.userId, + action: "credential.issued", + targetType: "credential", + targetId: credId, + metadata: { + type: "first_pr_merged", + prNumber: params.prNumber, + repository: params.repoFullName, + }, + }); + } + + // Claim Founding 1,000 membership + const foundingResult = await claimFoundingMembership(db, { + userId: params.userId, + contributionId: params.contributionId, + githubUsername: params.githubUsername, + repo: params.repoFullName, + prNumber: params.prNumber, + prUrl: params.prUrl, + prTitle: params.prTitle, + mergedAt: params.mergedAt, + }); + + if (foundingResult.claimed && foundingResult.memberNumber) { + foundingMemberNumber = foundingResult.memberNumber; + if (foundingResult.credentialId) { + credentialsIssued.push(foundingResult.credentialId); + } + } + } + + // 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, + }); + + let newLevel = previousLevel; + + // 4. Perform promotion if criteria met + if (progression.canPromote) { + newLevel = progression.eligibleLevel; + + await db + .update(schema.users) + .set({ + level: newLevel, + updatedAt: new Date(), + }) + .where(eq(schema.users.id, params.userId)); + + // Log promotion audit entry + await db.insert(schema.auditLogs).values({ + id: `audit_${crypto.randomUUID()}`, + actorId: params.userId, + action: "user.promoted", + targetType: "user", + targetId: params.userId, + metadata: { + previousLevel, + newLevel, + totalPrsMerged: mergedContributions.length, + projectsCount: distinctProjects, + reason: "Met objective criteria for contributor advancement", + }, + }); + + // 5. Issue milestone credentials associated with new level + if (newLevel === "active_contributor") { + const existingVerifiedCred = await db + .select() + .from(schema.credentials) + .where( + and( + eq(schema.credentials.userId, params.userId), + eq(schema.credentials.type, "verified_contributor") + ) + ) + .limit(1); + + if (existingVerifiedCred.length === 0) { + const credId = generateCredentialId("verified_contributor", params.userId); + const evidence: CredentialEvidence = { + githubUsername: params.githubUsername, + repository: params.repoFullName, + prNumber: params.prNumber, + prUrl: params.prUrl, + prTitle: params.prTitle, + mergedAt: params.mergedAt.toISOString(), + verifiedAt: new Date().toISOString(), + additionalNotes: `Promoted to Active Contributor with ${mergedContributions.length} merged pull requests`, + }; + const meta = buildCredentialMetadata("verified_contributor", evidence); + + await db.insert(schema.credentials).values({ + id: credId, + userId: params.userId, + type: "verified_contributor", + title: meta.title, + description: meta.description, + status: "active", + issuer: "TechNexusOrg", + issuedAt: new Date(), + evidenceData: evidence, + verificationUrl: formatVerificationUrl(credId), + }); + + credentialsIssued.push(credId); + + await db.insert(schema.auditLogs).values({ + id: `audit_${crypto.randomUUID()}`, + actorId: params.userId, + action: "credential.issued", + targetType: "credential", + targetId: credId, + metadata: { + type: "verified_contributor", + level: newLevel, + }, + }); + } + } else if (newLevel === "core_contributor") { + const existingCoreCred = await db + .select() + .from(schema.credentials) + .where( + and( + eq(schema.credentials.userId, params.userId), + eq(schema.credentials.type, "core_contributor") + ) + ) + .limit(1); + + if (existingCoreCred.length === 0) { + const credId = generateCredentialId("core_contributor", params.userId); + const evidence: CredentialEvidence = { + githubUsername: params.githubUsername, + repository: params.repoFullName, + prNumber: params.prNumber, + prUrl: params.prUrl, + prTitle: params.prTitle, + mergedAt: params.mergedAt.toISOString(), + verifiedAt: new Date().toISOString(), + additionalNotes: `Elevated to Core Contributor with verified sustained contributions`, + }; + const meta = buildCredentialMetadata("core_contributor", evidence); + + await db.insert(schema.credentials).values({ + id: credId, + userId: params.userId, + type: "core_contributor", + title: meta.title, + description: meta.description, + status: "active", + issuer: "TechNexusOrg", + issuedAt: new Date(), + evidenceData: evidence, + verificationUrl: formatVerificationUrl(credId), + }); + + credentialsIssued.push(credId); + + await db.insert(schema.auditLogs).values({ + id: `audit_${crypto.randomUUID()}`, + actorId: params.userId, + action: "credential.issued", + targetType: "credential", + targetId: credId, + metadata: { + type: "core_contributor", + level: newLevel, + }, + }); + } + } + } + + return { + promoted: progression.canPromote, + previousLevel, + newLevel, + credentialsIssued, + foundingMemberNumber, + }; +}