diff --git a/db/custom.db b/db/custom.db index eba0676..fe55c14 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/src/app/api/aos/webhooks/github/route.ts b/src/app/api/aos/webhooks/github/route.ts new file mode 100644 index 0000000..3109850 --- /dev/null +++ b/src/app/api/aos/webhooks/github/route.ts @@ -0,0 +1,159 @@ +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 }) + } +}