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:
Z User committed 2026-10-28 01:30:32 +00:00
1 parent de6573b15e
commit aa6365e373
2 files changed
+159

No files matched your search

+159
View File
@@ -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 })
}
}