Skip to content
Merged
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
32 changes: 32 additions & 0 deletions src/app/api/sync/contributions/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
95 changes: 16 additions & 79 deletions src/app/api/webhooks/github/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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) {
Expand Down
111 changes: 111 additions & 0 deletions src/app/dashboard/ProgressionPath.tsx
Original file line number Diff line number Diff line change
@@ -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<ContributorLevel, number> = {
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 (
<div className="space-y-6">
{/* Milestone Stepper */}
<div className="relative">
<div className="absolute top-4 left-6 right-6 h-0.5 bg-slate-800 -z-0" />
<div
className="absolute top-4 left-6 h-0.5 bg-gradient-to-r from-emerald-500 to-sky-400 transition-all duration-500 -z-0"
style={{
width: `${Math.min(100, (currentRank / (LEVELS.length - 1)) * 100)}%`,
}}
/>

<div className="relative z-10 flex items-center justify-between">
{LEVELS.map((lvl, idx) => {
const isCompleted = idx < currentRank;
const isCurrent = lvl.key === currentLevel;

return (
<div key={lvl.key} className="flex flex-col items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full border text-xs font-mono font-bold transition-all ${
isCurrent
? "border-sky-400 bg-sky-950 text-sky-300 ring-4 ring-sky-500/20"
: isCompleted
? "border-emerald-500 bg-emerald-950 text-emerald-400"
: "border-slate-800 bg-slate-900 text-slate-500"
}`}
>
{isCompleted ? "✓" : idx + 1}
</div>
<span
className={`text-[11px] font-mono capitalize ${
isCurrent
? "font-bold text-sky-400"
: isCompleted
? "text-slate-300"
: "text-slate-600"
}`}
>
{lvl.label}
</span>
</div>
);
})}
</div>
</div>

{/* Criteria Breakdown */}
<div className="rounded-lg border border-slate-800/80 bg-slate-950/40 p-4 space-y-3">
<div className="flex items-center justify-between text-xs font-mono">
<span className="text-slate-400">
Next Milestone:{" "}
<span className="font-bold text-sky-300 capitalize">
{progression.nextLevel ? progression.nextLevel.replace("_", " ") : "Maintainer / Max Tier"}
</span>
</span>
<span className="text-emerald-400">{mergedCount} PR(s) verified & merged</span>
</div>

<div>
<h4 className="text-[11px] font-mono uppercase tracking-wider text-slate-400">
Remaining Requirements:
</h4>
{progression.requirementsForNextLevel.length > 0 ? (
<ul className="mt-2 space-y-1.5">
{progression.requirementsForNextLevel.map((req, i) => (
<li key={i} className="flex items-center gap-2 text-xs text-slate-300">
<span className="flex h-1.5 w-1.5 rounded-full bg-sky-400" />
<span>{req}</span>
</li>
))}
</ul>
) : (
<p className="mt-1 text-xs text-emerald-400 font-mono">
All quantitative milestones satisfied for current evaluation cycle!
</p>
)}
</div>
</div>
</div>
);
}
68 changes: 68 additions & 0 deletions src/app/dashboard/SyncContributionsButton.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<button
onClick={handleSync}
disabled={loading}
className="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3.5 py-2 text-xs font-mono font-medium text-sky-400 hover:bg-sky-500/20 hover:text-sky-300 disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
<span className={loading ? "animate-spin" : ""}>⟳</span>
{loading ? "Syncing..." : "Sync PRs from GitHub"}
</button>

{statusMessage && (
<span className="text-[11px] font-mono text-emerald-400 animate-fade-in">
{statusMessage}
</span>
)}
</div>
);
}
Loading
Loading