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
1 change: 1 addition & 0 deletions drizzle/0003_pr_nullable_user.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "pull_requests" ALTER COLUMN "user_id" DROP NOT NULL;
153 changes: 52 additions & 101 deletions src/app/api/webhooks/github/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
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 { processProgressionOnContribution } from "@/lib/progression/pipeline";
import {
verifyWebhookSignature,
handlePullRequestWebhook,
handlePullRequestReviewWebhook,
isWebhookDeliveryProcessed,
recordWebhookDelivery,
markWebhookDeliveryCompleted,
markWebhookDeliveryFailed,
type WebhookPullRequestEvent,
type WebhookPullRequestReviewEvent,
} from "@/lib/github/webhooks";

export async function POST(request: NextRequest) {
const signature = request.headers.get("x-hub-signature-256");
const event = request.headers.get("x-github-event");
const deliveryId = request.headers.get("x-github-delivery");
const rawBody = await request.text();

// Validate webhook secret signature
Expand All @@ -18,109 +26,52 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ message: "PONG" });
}

if (event === "pull_request") {
try {
const payload = JSON.parse(rawBody) as WebhookPullRequestEvent;

if (payload.action === "closed" && payload.pull_request.merged) {
const db = await getDb();
const pr = payload.pull_request;
const repoFullName = payload.repository.full_name;

// 1. Look up user in database
const matchingUsers = await db
.select()
.from(schema.users)
.where(eq(schema.users.githubId, pr.user.id))
.limit(1);

if (matchingUsers.length === 0) {
return NextResponse.json({ message: "PR author not registered on platform, skipped" });
}

const user = matchingUsers[0];

// 2. Find or create project entry
const matchingProjects = await db
.select()
.from(schema.projects)
.where(eq(schema.projects.githubRepo, repoFullName))
.limit(1);
// Idempotency: Ignore duplicate webhook deliveries
if (deliveryId) {
const alreadyProcessed = await isWebhookDeliveryProcessed(deliveryId);
if (alreadyProcessed) {
return NextResponse.json({
message: "Duplicate webhook delivery ignored",
deliveryId,
});
}

let projectId: string;
if (matchingProjects.length === 0) {
projectId = `proj_${crypto.randomUUID()}`;
await db.insert(schema.projects).values({
id: projectId,
name: payload.repository.name,
slug: payload.repository.name.toLowerCase(),
githubRepo: repoFullName,
description: `Official repository: ${repoFullName}`,
primaryLanguage: "TypeScript",
languages: [],
isOfficial: true,
});
} else {
projectId = matchingProjects[0].id;
}
let repoName: string | undefined;
try {
const parsed = JSON.parse(rawBody);
repoName = parsed.repository?.full_name;
} catch {
// ignore
}

// 3. Check existing merged contributions for this user
const existingContributions = await db
.select()
.from(schema.contributions)
.where(
and(
eq(schema.contributions.userId, user.id),
eq(schema.contributions.state, "merged")
)
);
await recordWebhookDelivery({
deliveryId,
eventType: event || "unknown",
repository: repoName,
});
}

const isFirstPr = existingContributions.length === 0;
const contributionId = `contrib_${crypto.randomUUID()}`;
const mergedDate = pr.merged_at ? new Date(pr.merged_at) : new Date();
try {
let result: any = { received: true, event };

await db.insert(schema.contributions).values({
id: contributionId,
userId: user.id,
projectId,
githubPrNumber: pr.number,
prTitle: pr.title,
prUrl: pr.html_url,
state: "merged",
isFirstPr,
mergedAt: mergedDate,
verifiedAt: new Date(),
verificationSource: "github_webhook",
});
if (event === "pull_request") {
const payload = JSON.parse(rawBody) as WebhookPullRequestEvent;
result = await handlePullRequestWebhook(payload);
} else if (event === "pull_request_review") {
const payload = JSON.parse(rawBody) as WebhookPullRequestReviewEvent;
result = await handlePullRequestReviewWebhook(payload);
}

// 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 (deliveryId) {
await markWebhookDeliveryCompleted(deliveryId);
}

return NextResponse.json({
status: "success",
contributionId,
isFirstPr,
promoted: progressionResult.promoted,
level: progressionResult.newLevel,
credentialsIssued: progressionResult.credentialsIssued,
foundingMemberNumber: progressionResult.foundingMemberNumber,
});
}
} catch (err: any) {
console.error("Webhook processing error:", err);
return NextResponse.json({ error: err.message }, { status: 500 });
return NextResponse.json(result);
} catch (err: any) {
console.error("Webhook processing error:", err);
if (deliveryId) {
await markWebhookDeliveryFailed(deliveryId, err.message);
}
return NextResponse.json({ error: err.message }, { status: 500 });
}

return NextResponse.json({ received: true });
}
1 change: 0 additions & 1 deletion src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,6 @@ export const pullRequests = pgTable(
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
projectId: text("project_id")
.notNull()
Expand Down
Loading
Loading