Files
boc/SIL/webhook-server.mjs
T

319 lines
11 KiB
JavaScript
Raw Normal View History

#!/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://<server>:${PORT}/github`);
console.log(`GitLab webhook URL: http://<server>:${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();