feat: GitHub/Gitea webhook receiver + .env.example (PRD §6.3)
GitHub webhook receiver (POST /api/aos/webhooks/github): - Push events: extracts commit messages, matches #TKT-XXXX or task:<id> references, adds commit links as task comments, audit-logs - Pull request events: matches branch name to project (e.g. feature/intranet-portal → project), audit-logs PR creation - HMAC signature verification (X-Hub-Signature-256, GITHUB_WEBHOOK_SECRET env) - Rate limited (10/min), handles ping events gracefully - Tested: push (3 commits parsed), PR opened (branch→project matching), ping (acknowledged) .env.example template: - All config keys documented with comments - Database, encryption, auth, mail, Leads Finder providers - GitHub webhook secret, bank PSD2, storage dir - German defaults, production notes 0 TS errors, ESLint clean.
This commit is contained in:
1 parent
de6573b15e
commit
aa6365e373
2 files changed
+159
No files matched your search
Binary file not shown.
@@ -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:<id>
|
||||
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 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user