/** * POST /api/webhooks/gitea?projectId={projectId} * * Receives push, pull_request, issues, and issue_comment events from Gitea. * Verifies the HMAC signature, then updates the project's contextSnapshot * in Postgres so the AI always has fresh context at the start of new chats. */ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db-postgres'; import { verifyWebhookSignature } from '@/lib/gitea'; const GITEA_WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET ?? 'vibn-webhook-secret'; // ────────────────────────────────────────────────── // Gitea payload shapes (minimal — we only read what we need) // ────────────────────────────────────────────────── interface GitCommit { id: string; message: string; timestamp: string; author: { name: string; email: string }; url: string; } interface PushPayload { ref: string; after: string; commits: GitCommit[]; repository: { full_name: string; html_url: string }; pusher: { login: string }; } interface PullRequestPayload { action: string; // opened, closed, reopened, synchronized number: number; pull_request: { title: string; html_url: string; state: string; merged: boolean; head: { label: string }; base: { label: string }; }; repository: { full_name: string }; } interface IssuePayload { action: string; // opened, closed, reopened issue: { number: number; title: string; html_url: string; state: string; body?: string; labels?: { name: string }[]; }; repository: { full_name: string }; } // ────────────────────────────────────────────────── // Handler // ────────────────────────────────────────────────── export async function POST(request: NextRequest) { const projectId = request.nextUrl.searchParams.get('projectId'); if (!projectId) { return NextResponse.json({ error: 'Missing projectId' }, { status: 400 }); } const rawBody = await request.text(); const signature = request.headers.get('x-gitea-signature-256') ?? ''; const event = request.headers.get('x-gitea-event') ?? 'unknown'; // Verify HMAC signature const valid = await verifyWebhookSignature(rawBody, signature, GITEA_WEBHOOK_SECRET); if (!valid) { console.warn(`[webhook/gitea] Invalid signature for project ${projectId}`); return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); } let payload: any; try { payload = JSON.parse(rawBody); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } // Fetch existing project snapshot const rows = await query<{ id: string; data: any }>( `SELECT id, data FROM fs_projects WHERE id = $1 LIMIT 1`, [projectId] ); if (rows.length === 0) { console.warn(`[webhook/gitea] Project not found: ${projectId}`); return NextResponse.json({ error: 'Project not found' }, { status: 404 }); } const project = rows[0]; const existingSnapshot = project.data?.contextSnapshot ?? {}; // Build updated snapshot based on event type const updatedAt = new Date().toISOString(); let snapshotPatch: Record = { updatedAt }; if (event === 'push') { const push = payload as PushPayload; const branch = push.ref?.replace('refs/heads/', '') ?? 'unknown'; const latestCommit = push.commits?.[0]; snapshotPatch.lastCommit = latestCommit ? { sha: latestCommit.id, message: latestCommit.message, author: latestCommit.author?.name, timestamp: latestCommit.timestamp, url: latestCommit.url, } : existingSnapshot.lastCommit; snapshotPatch.currentBranch = branch; snapshotPatch.recentCommits = [ ...(push.commits ?? []).slice(0, 5).map(c => ({ sha: c.id.slice(0, 8), message: c.message.split('\n')[0], author: c.author?.name, timestamp: c.timestamp, })), ...(existingSnapshot.recentCommits ?? []), ].slice(0, 10); console.log(`[webhook/gitea] push to ${branch} on project ${projectId}`); } else if (event === 'pull_request') { const pr = payload as PullRequestPayload; const openPRs: any[] = existingSnapshot.openPRs ?? []; if (pr.action === 'opened' || pr.action === 'reopened') { // Add or update const existing = openPRs.findIndex((p: any) => p.number === pr.number); const entry = { number: pr.number, title: pr.pull_request.title, url: pr.pull_request.html_url, state: pr.pull_request.state, from: pr.pull_request.head.label, into: pr.pull_request.base.label, }; if (existing >= 0) openPRs[existing] = entry; else openPRs.push(entry); } else if (pr.action === 'closed') { // Remove closed/merged PR from open list const idx = openPRs.findIndex((p: any) => p.number === pr.number); if (idx >= 0) openPRs.splice(idx, 1); } snapshotPatch.openPRs = openPRs; console.log(`[webhook/gitea] PR #${pr.number} ${pr.action} on project ${projectId}`); } else if (event === 'issues') { const iss = payload as IssuePayload; const openIssues: any[] = existingSnapshot.openIssues ?? []; if (iss.action === 'opened' || iss.action === 'reopened') { const existing = openIssues.findIndex((i: any) => i.number === iss.issue.number); const entry = { number: iss.issue.number, title: iss.issue.title, url: iss.issue.html_url, state: iss.issue.state, labels: (iss.issue.labels ?? []).map(l => l.name), }; if (existing >= 0) openIssues[existing] = entry; else openIssues.push(entry); } else if (iss.action === 'closed') { const idx = openIssues.findIndex((i: any) => i.number === iss.issue.number); if (idx >= 0) openIssues.splice(idx, 1); } snapshotPatch.openIssues = openIssues; console.log(`[webhook/gitea] issue #${iss.issue.number} ${iss.action} on project ${projectId}`); } // Merge patch into existing snapshot and persist const newSnapshot = { ...existingSnapshot, ...snapshotPatch }; await query(` UPDATE fs_projects SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb) WHERE id = $2 `, [JSON.stringify(newSnapshot), projectId]); return NextResponse.json({ ok: true, event, projectId }); }