/** * POST /api/webhooks/github * * GitHub Webhook Handler * * This endpoint receives push, pull_request, and issue events from GitHub. * It will eventually replace the 5-minute polling loop by triggering syncs on-demand. * * Webhook Setup: * 1. In GitHub repo settings > Webhooks > Add webhook * 2. Payload URL: https://yourdomain.com/api/webhooks/github * 3. Content type: application/json * 4. Events: push, pull_request, issues * 5. Secret: Set a secret in GitHub, store in env var GITHUB_WEBHOOK_SECRET * 6. Active: ✓ * * ROADMAP: * - Parse webhook payload * - Verify GitHub signature * - Queue a sync task for the affected repo * - Update issues/PRs in real-time without waiting for 5-min polling */ import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; import { db } from "@/db"; import { repositories } from "@/db/schema"; import { eq, and, sql, like } from "drizzle-orm"; import { syncSingleRepository } from "@/lib/sync/github-sync"; import { users } from "@/db/schema"; /** * Verify the GitHub webhook signature. * GitHub sends X-Hub-Signature-256 header with HMAC-SHA256 of the request body. */ function verifyGitHubSignature(payload: string, signature: string): boolean { const secret = process.env.GITHUB_WEBHOOK_SECRET; if (!secret) { console.warn("[Webhook] GITHUB_WEBHOOK_SECRET not set. Skipping signature verification."); return true; // In dev, allow unsigned webhooks if secret not configured } const hash = crypto .createHmac("sha256", secret) .update(payload) .digest("hex"); const expectedSignature = `sha256=${hash}`; return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); } /** * Extract repo info from GitHub webhook payload. */ interface WebhookPayload { action?: string; repository?: { id: number; name: string; full_name: string; owner: { login: string; type: string; }; html_url: string; }; pull_request?: { number: number; title: string; state: string; }; issue?: { number: number; title: string; state: string; }; pusher?: { name: string; }; ref?: string; // for push events } export async function POST(request: NextRequest) { try { // Read the raw body for signature verification const rawBody = await request.text(); const signature = request.headers.get("x-hub-signature-256") || ""; // ✅ Verify GitHub signature if (!verifyGitHubSignature(rawBody, signature)) { console.warn("[Webhook] Invalid GitHub signature. Rejecting webhook."); return NextResponse.json( { error: "Invalid signature" }, { status: 401 } ); } const payload: WebhookPayload = JSON.parse(rawBody); const repo = payload.repository; if (!repo) { return NextResponse.json( { error: "No repository in payload" }, { status: 400 } ); } const eventType = request.headers.get("x-github-event") || "unknown"; const repoName = repo.full_name; const [owner, repoShort] = repoName.split("/"); console.log(`[Webhook] Received ${eventType} event for ${repoName}`); // ============================================================================ // HANDLE DIFFERENT EVENT TYPES // ============================================================================ switch (eventType) { case "push": { // Push event: code was updated const ref = payload.ref || ""; const branch = ref.split("/").pop(); console.log(`[Webhook] Push to ${repoName}@${branch}`); // Only sync on pushes to main/master if (branch !== "main" && branch !== "master") { console.log(`[Webhook] Skipping non-main push to branch: ${branch}`); return NextResponse.json({ status: "skipped", reason: "non-main-branch" }); } // Queue a sync for this repo await queueRepoSync(owner, repoShort); break; } case "pull_request": { // PR event: opened, closed, synchronize, etc. const action = payload.action || ""; const prNumber = payload.pull_request?.number; console.log(`[Webhook] Pull request ${action}: #${prNumber}`); // Sync on relevant actions only if (["opened", "closed", "reopened", "synchronize"].includes(action)) { await queueRepoSync(owner, repoShort); } break; } case "issues": { // Issue event: opened, closed, etc. const action = payload.action || ""; const issueNumber = payload.issue?.number; console.log(`[Webhook] Issue ${action}: #${issueNumber}`); // Sync on relevant actions if (["opened", "closed", "reopened"].includes(action)) { await queueRepoSync(owner, repoShort); } break; } case "ping": { // GitHub sends a ping on webhook creation to verify it's reachable console.log("[Webhook] Ping from GitHub - webhook is reachable"); return NextResponse.json({ status: "pong" }); } default: { console.log(`[Webhook] Unhandled event type: ${eventType}`); return NextResponse.json({ status: "ignored", reason: "unhandled-event-type", }); } } return NextResponse.json({ status: "ok", repo: repoName }); } catch (error) { console.error("[Webhook] Error processing GitHub webhook:", error); return NextResponse.json( { error: "Failed to process webhook" }, { status: 500 } ); } } /** * Queue a sync for a repository. * * ROADMAP: * - Current: synchronous call to syncSingleRepository * - Future: queue to Bull/BullMQ job queue for async processing * - Eventually: trigger NextJS server action or external worker */ async function queueRepoSync(owner: string, repo: string): Promise { try { // Find the repository in our DB const dbRepo = await db.select() .from(repositories) .where( and( eq(repositories.owner, owner), like(repositories.name, `%${repo}%`) ) ) .limit(1); if (!dbRepo || dbRepo.length === 0) { console.warn( `[Webhook] Repository ${owner}/${repo} not tracked in DB. Skipping sync.` ); return; } const repository = dbRepo[0]; // Get the owner's GitHub token const user = await db.select() .from(users) .where(eq(users.id, repository.userId)) .limit(1); if (!user || user.length === 0 || !user[0].githubAccessToken) { console.warn( `[Webhook] No GitHub token for user ${repository.userId}. Skipping sync.` ); return; } const accessToken = user[0].githubAccessToken; // ✅ Trigger sync console.log( `[Webhook] Syncing ${owner}/${repo} (repoId: ${repository.id})` ); const result = await syncSingleRepository(accessToken, repository.id, owner, repo); console.log( `[Webhook] Sync complete: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted` ); // TODO: In production, queue this instead of awaiting synchronously // await syncQueue.add("syncRepo", { repoId: repository.id, owner, repo }, { attempts: 3 }); } catch (error) { console.error( `[Webhook] Error queuing sync for ${owner}/${repo}:`, error ); // Don't re-throw; webhook should return 200 even if sync fails // The next polling cycle will catch any missed updates } }