319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
|
|
#!/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();
|