import { NextResponse } from 'next/server' import { db } from '@/lib/db' import { createHmac } from 'crypto' import { checkRateLimit } from '@/lib/rate-limit' export const dynamic = 'force-dynamic' // POST /api/aos/webhooks/github // GitHub/Gitea webhook receiver (PRD §6.3: "GitHub/Gitea webhooks (commits and PRs linked to tasks)") // // Receives push events and PR events from GitHub, extracts commit messages, // and links them to tasks by matching "#TASK-XXXX" references in commit messages. // // Security: verifies X-Hub-Signature-256 HMAC using GITHUB_WEBHOOK_SECRET env var. export async function POST(req: Request) { try { const rateLimited = checkRateLimit(req, 'webhook') if (rateLimited) return rateLimited const eventType = req.headers.get('x-github-event') ?? '' const signature = req.headers.get('x-hub-signature-256') ?? '' const body = await req.text() // Verify HMAC signature if secret is configured const secret = process.env.GITHUB_WEBHOOK_SECRET if (secret) { const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex') if (signature !== expected) { return NextResponse.json({ error: 'invalid signature' }, { status: 401 }) } } const payload = JSON.parse(body) const repo = payload.repository?.full_name ?? 'unknown' if (eventType === 'push') { // Push event: extract commits and link to tasks const commits = payload.commits ?? [] const linkedTasks: { commit: string; message: string; taskId: string; taskTitle: string }[] = [] for (const commit of commits) { const message = commit.message ?? '' const sha = (commit.id ?? '').slice(0, 7) // Match task references in commit message: #TKT-2026-XXXX or task: const taskMatches = message.match(/(?:#TKT-2026-|task:)([a-z0-9]+)/gi) ?? [] for (const match of taskMatches) { const taskId = match.replace(/#TKT-2026-|task:/i, '').trim() // Try to find the task by ID or by searching for the reference const task = await db.task.findFirst({ where: { id: { contains: taskId } }, include: { project: { select: { name: true } } }, }) if (task) { // Add a comment to the task with the commit reference await db.taskComment.create({ data: { taskId: task.id, author: `GitHub (${payload.pusher?.name ?? 'unknown'})`, body: `Commit ${sha} in ${repo}:\n${message.split('\n')[0]}\n\nLink: ${payload.repository?.html_url}/commit/${commit.id}`, }, }) linkedTasks.push({ commit: sha, message: message.split('\n')[0], taskId: task.id, taskTitle: task.title, }) } } } await db.auditLog.create({ data: { actor: 'system:github-webhook', action: 'webhook.github:push', entity: 'Task', entityId: 'batch', after: JSON.stringify({ repo, commits: commits.length, linked: linkedTasks.length }), hash: 'sha256:' + Math.random().toString(36).slice(2), }, }) return NextResponse.json({ ok: true, event: 'push', repo, commits: commits.length, linkedTasks: linkedTasks.length, links: linkedTasks, }) } if (eventType === 'pull_request') { // PR event: link PR to project const pr = payload.pull_request ?? {} const action = payload.action ?? 'opened' const prTitle = pr.title ?? 'Unknown PR' const prNumber = pr.number ?? 0 const prUrl = pr.html_url ?? '' const branch = pr.head?.ref ?? '' // Try to match project by branch name (e.g. "feature/intranet-portal" → Intranet-Portal) const project = await db.project.findFirst({ where: { name: { contains: branch.replace(/feature\/|fix\//g, '').replace(/-/g, ' ') } }, }) if (project) { await db.auditLog.create({ data: { actor: 'system:github-webhook', action: 'webhook.github:pr', entity: 'Project', entityId: project.id, after: JSON.stringify({ prNumber, prTitle, prUrl, action, branch, repo }), hash: 'sha256:' + Math.random().toString(36).slice(2), }, }) return NextResponse.json({ ok: true, event: 'pull_request', action, repo, prNumber, prTitle, branch, linkedProject: project.name, }) } return NextResponse.json({ ok: true, event: 'pull_request', action, repo, prNumber, prTitle, branch, linkedProject: null, note: 'No matching project found for this branch', }) } // Unknown event type return NextResponse.json({ ok: true, event: eventType, note: `Event type '${eventType}' received but not processed`, }) } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error' return NextResponse.json({ error: msg }, { status: 500 }) } }