#!/usr/bin/env node // ═══════════════════════════════════════════════════════════════════════════ // SIL Webhook Server — Event-driven PR-analys (ersätter cron-pollning) // Erik-krav: "Om Git-plattformen kan skicka webhooks... är det bättre att // trigga analysen direkt på dessa händelser" // ═══════════════════════════════════════════════════════════════════════════ import { createServer } from 'http'; import { execSync, spawn } from 'child_process'; import { readFileSync, writeFileSync, appendFileSync } from 'fs'; import { createHmac } from 'crypto'; const PORT = process.env.SIL_WEBHOOK_PORT || 8765; const SECRET = process.env.SIL_WEBHOOK_SECRET || ''; // Set this! const LOG_PATH = '/home/bernt/.openclaw/workspace/SIL/webhook-events.jsonl'; // ── Webhook Handler ─────────────────────────────────────────────────────── class WebhookServer { constructor() { this.pendingAnalyses = new Map(); } verifySignature(payload, signature) { if (!SECRET) { console.warn('⚠️ Ingen WEBHOOK_SECRET satt — accepterar alla requests'); return true; } const hmac = createHmac('sha256', SECRET); hmac.update(payload); const expected = `sha256=${hmac.digest('hex')}`; return signature === expected; } handleGitHubWebhook(req, body) { const event = req.headers['x-github-event']; const delivery = req.headers['x-github-delivery']; console.log(`📥 GitHub event: ${event} (delivery: ${delivery})`); const payload = JSON.parse(body); switch (event) { case 'pull_request': return this.handlePullRequest(payload); case 'push': return this.handlePush(payload); case 'pull_request_review': return this.handlePRReview(payload); default: return { status: 'ignored', reason: `Event ${event} hanteras inte` }; } } handleGitLabWebhook(req, body) { const event = req.headers['x-gitlab-event']; console.log(`📥 GitLab event: ${event}`); const payload = JSON.parse(body); switch (event) { case 'Merge Request Hook': return this.handleMergeRequest(payload); case 'Push Hook': return this.handleGitLabPush(payload); default: return { status: 'ignored', reason: `Event ${event} hanteras inte` }; } } handlePullRequest(payload) { const action = payload.action; const pr = payload.pull_request; if (!pr) return { status: 'error', reason: 'Ingen PR-data' }; // Vi bryr oss om: opened, synchronize (nya commits), reopened if (!['opened', 'synchronize', 'reopened'].includes(action)) { return { status: 'ignored', reason: `Action ${action} triggar inte analys` }; } const analysis = { id: `pr-${pr.number}-${Date.now()}`, type: 'github_pr', prNumber: pr.number, title: pr.title, branch: pr.head.ref, base: pr.base.ref, repo: payload.repository?.full_name || 'unknown', action, author: pr.user?.login, url: pr.html_url, timestamp: new Date().toISOString() }; this.queueAnalysis(analysis); return { status: 'queued', analysisId: analysis.id }; } handleMergeRequest(payload) { const mr = payload.object_attributes; if (!mr) return { status: 'error', reason: 'Ingen MR-data' }; const analysis = { id: `mr-${mr.iid}-${Date.now()}`, type: 'gitlab_mr', mrNumber: mr.iid, title: mr.title, branch: mr.source_branch, base: mr.target_branch, repo: payload.project?.path_with_namespace || 'unknown', action: mr.action, author: mr.author_id, url: mr.url, timestamp: new Date().toISOString() }; this.queueAnalysis(analysis); return { status: 'queued', analysisId: analysis.id }; } handlePush(payload) { const ref = payload.ref; // Bara intresserad av branch pushes (inte tags) if (!ref.startsWith('refs/heads/')) { return { status: 'ignored', reason: 'Inte en branch-push' }; } const branch = ref.replace('refs/heads/', ''); // Om det finns en öppen PR för denna branch, analysera den const analysis = { id: `push-${branch}-${Date.now()}`, type: 'push', branch, repo: payload.repository?.full_name || 'unknown', commits: payload.commits?.length || 0, timestamp: new Date().toISOString() }; this.queueAnalysis(analysis); return { status: 'queued', analysisId: analysis.id }; } handleGitLabPush(payload) { const ref = payload.ref; if (!ref.startsWith('refs/heads/')) { return { status: 'ignored', reason: 'Inte en branch-push' }; } const branch = ref.replace('refs/heads/', ''); const analysis = { id: `push-${branch}-${Date.now()}`, type: 'gitlab_push', branch, repo: payload.project?.path_with_namespace || 'unknown', commits: payload.commits?.length || 0, timestamp: new Date().toISOString() }; this.queueAnalysis(analysis); return { status: 'queued', analysisId: analysis.id }; } handlePRReview(payload) { // När en review submit:as, kör analys igen (kan ha ändrats) const pr = payload.pull_request; const analysis = { id: `review-${pr.number}-${Date.now()}`, type: 'pr_review', prNumber: pr.number, title: pr.title, branch: pr.head.ref, base: pr.base.ref, state: payload.review?.state, timestamp: new Date().toISOString() }; this.queueAnalysis(analysis); return { status: 'queued', analysisId: analysis.id }; } // ── Analysis Queue ────────────────────────────────────────────────────── queueAnalysis(analysis) { this.pendingAnalyses.set(analysis.id, analysis); // Logga event appendFileSync(LOG_PATH, JSON.stringify({ ...analysis, status: 'queued' }) + '\n', 'utf8'); console.log(`🔄 Köad analys: ${analysis.id}`); console.log(` PR/MR: ${analysis.title || analysis.branch}`); console.log(` Branch: ${analysis.branch} → ${analysis.base}`); // Kör analys asynkront this.runAnalysis(analysis).then(result => { console.log(`✅ Analys klar: ${analysis.id}`); this.pendingAnalyses.delete(analysis.id); }).catch(err => { console.error(`❌ Analys fel: ${analysis.id}`, err.message); this.pendingAnalyses.delete(analysis.id); }); } async runAnalysis(analysis) { const repoPath = '/home/bernt/repos/quixzoom.com'; // Konfigurerbart // Kör pr-analyzer return new Promise((resolve, reject) => { const child = spawn('node', [ '/home/bernt/.openclaw/workspace/SIL/pr-analyzer.mjs', repoPath, analysis.base || 'main', analysis.branch || 'HEAD' ], { cwd: repoPath, stdio: 'pipe' }); let output = ''; child.stdout.on('data', data => { output += data; }); child.stderr.on('data', data => { console.error(data.toString()); }); child.on('close', code => { // Logga resultat appendFileSync(LOG_PATH, JSON.stringify({ ...analysis, status: 'completed', exitCode: code, completedAt: new Date().toISOString() }) + '\n', 'utf8'); if (code === 0) { resolve(output); } else { reject(new Error(`Exit code ${code}`)); } }); }); } // ── HTTP Server ───────────────────────────────────────────────────────── start() { const server = createServer((req, res) => { // Endast POST if (req.method !== 'POST') { res.writeHead(405); res.end('Method not allowed'); return; } let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { try { // Verifiera signatur om konfigurerat const signature = req.headers['x-hub-signature-256'] || req.headers['x-gitlab-token']; if (!this.verifySignature(body, signature)) { res.writeHead(401); res.end('Unauthorized'); return; } // Route till rätt handler let result; if (req.headers['x-github-event']) { result = this.handleGitHubWebhook(req, body); } else if (req.headers['x-gitlab-event']) { result = this.handleGitLabWebhook(req, body); } else { result = { status: 'error', reason: 'Okänd git-plattform' }; } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (e) { console.error('Webhook-fel:', e); res.writeHead(500); res.end(JSON.stringify({ status: 'error', message: e.message })); } }); }); server.listen(PORT, () => { console.log('═══════════════════════════════════════════════════════════════'); console.log(' SIL WEBHOOK SERVER'); console.log(' (Event-driven PR-analys)'); console.log('═══════════════════════════════════════════════════════════════\n'); console.log(`🌐 Lyssnar på port ${PORT}`); console.log(`📁 Event-log: ${LOG_PATH}`); console.log(`\nGitHub webhook URL: http://:${PORT}/github`); console.log(`GitLab webhook URL: http://:${PORT}/gitlab`); console.log(`\n⚙️ Konfigurera i din Git-plattform:`); console.log(' - Content type: application/json'); console.log(' - Events: Pull requests, Pushes'); if (!SECRET) { console.log('\n⚠️ VARNING: Sätt SIL_WEBHOOK_SECRET för säkerhet'); } console.log('\nTryck Ctrl+C för att avsluta\n'); }); return server; } } // ── Main ────────────────────────────────────────────────────────────────── const server = new WebhookServer(); server.start();