initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ic08e7c4b5b4f4072de9e2f9a701e977b6a6a6964
This commit is contained in:
commit
f8db097ba9
21 changed files with 4924 additions and 0 deletions
289
src/server.ts
Normal file
289
src/server.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import crypto from 'node:crypto';
|
||||
import express from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import type { Config, WebhookEvent, AnalysisResult } from './types.js';
|
||||
import { shouldProcess } from './filters.js';
|
||||
import { createEngine } from './engine/index.js';
|
||||
import {
|
||||
fetchPR,
|
||||
formatComment,
|
||||
hasExistingComment,
|
||||
postComment,
|
||||
updateComment,
|
||||
} from './github.js';
|
||||
import { getLogger } from './logger.js';
|
||||
import { recordEvent } from './events.js';
|
||||
import { createDashboardRouter } from './dashboard.js';
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
export function createApp(config: Config): express.Express {
|
||||
const app = express();
|
||||
const logger = getLogger();
|
||||
const engine = createEngine(config.engine);
|
||||
|
||||
app.use(
|
||||
express.json({
|
||||
limit: '1mb',
|
||||
verify: (req, _res, buf) => {
|
||||
(req as unknown as Record<string, Buffer>).rawBody = buf;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.use((_req, res, next) => {
|
||||
res.setTimeout(30_000, () => {
|
||||
logger.warn('Response timeout reached (30s)');
|
||||
if (!res.headersSent) {
|
||||
res.status(504).json({ error: 'Response timeout' });
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
const webhookLimiter = rateLimit({
|
||||
windowMs: 60_000,
|
||||
limit: config.server.rateLimit ?? 120,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later' },
|
||||
});
|
||||
|
||||
const enabledBackends = Object.entries(config.engine.backends)
|
||||
.filter(([, v]) => v.enabled)
|
||||
.map(([k]) => k);
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
version: process.env.npm_package_version ?? 'unknown',
|
||||
dryRun: !process.env.GITHUB_TOKEN,
|
||||
backends: enabledBackends,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/webhook', webhookLimiter, async (req, res) => {
|
||||
try {
|
||||
// Signature verification
|
||||
const secret = process.env.WEBHOOK_SECRET;
|
||||
if (secret) {
|
||||
const signature = req.headers['x-hub-signature-256'] as string | undefined;
|
||||
if (!signature) {
|
||||
logger.warn('Missing webhook signature');
|
||||
res.status(401).json({ error: 'Missing signature' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = (req as unknown as Record<string, Buffer>).rawBody;
|
||||
const expected =
|
||||
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
|
||||
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
|
||||
logger.warn('Invalid webhook signature');
|
||||
res.status(401).json({ error: 'Invalid signature' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const eventType = req.headers['x-github-event'] as string;
|
||||
const payload = req.body;
|
||||
|
||||
// Handle check_suite completion - re-analyze associated PRs
|
||||
if (eventType === 'check_suite' && payload.action === 'completed') {
|
||||
await handleCheckSuiteCompleted(payload, config, engine);
|
||||
res.json({ processed: true, event: 'check_suite' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType !== 'issues' && eventType !== 'pull_request') {
|
||||
res.json({ skipped: true, reason: `Unhandled event: ${eventType}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const action = payload.action as string;
|
||||
if (!['opened', 'edited', 'synchronize'].includes(action)) {
|
||||
res.json({ skipped: true, reason: `Unhandled action: ${action}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const event = parseEvent(eventType, payload);
|
||||
if (!event) {
|
||||
res.json({ skipped: true, reason: 'Could not parse event' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await analyzeAndComment(event, config, engine);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
logger.error('Error processing webhook', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.use(createDashboardRouter(config));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function analyzeAndComment(
|
||||
event: WebhookEvent,
|
||||
config: Config,
|
||||
engine: ReturnType<typeof createEngine>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const logger = getLogger();
|
||||
|
||||
// Check if repo is configured
|
||||
if (config.repositories.length > 0) {
|
||||
const repoMatch = config.repositories.some(
|
||||
(r) => r.owner === event.owner && r.repo === event.repo
|
||||
);
|
||||
if (!repoMatch) {
|
||||
logger.debug(`Ignoring event for unconfigured repo ${event.owner}/${event.repo}`);
|
||||
const result = { skipped: true, reason: 'Repository not configured' };
|
||||
recordEvent(event, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
const filterResult = shouldProcess(event, config.filters);
|
||||
if (!filterResult.pass) {
|
||||
logger.debug(`Filtered out: ${filterResult.reason}`);
|
||||
const result = { skipped: true, reason: filterResult.reason };
|
||||
recordEvent(event, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Run analysis
|
||||
const analysis = await engine.analyze(event);
|
||||
logger.info(
|
||||
`Analyzed ${event.owner}/${event.repo}#${event.number}: impact=${analysis.impact}, confidence=${analysis.confidence.toFixed(2)}`
|
||||
);
|
||||
|
||||
// Check for existing comment
|
||||
const { commentMarker, allowUpdates } = config.response;
|
||||
const existing = await hasExistingComment(event.owner, event.repo, event.number, commentMarker);
|
||||
|
||||
if (existing.exists && !allowUpdates) {
|
||||
logger.info(`Already commented on ${event.owner}/${event.repo}#${event.number}, skipping`);
|
||||
const result = { skipped: true, reason: 'Already commented' };
|
||||
recordEvent(event, result, analysis);
|
||||
return result;
|
||||
}
|
||||
|
||||
const body = formatComment(
|
||||
config.response,
|
||||
event.type,
|
||||
analysis.impact,
|
||||
analysis.confidence,
|
||||
analysis.reasoning
|
||||
);
|
||||
|
||||
if (existing.exists && allowUpdates && existing.commentId) {
|
||||
await updateComment(event.owner, event.repo, existing.commentId, body);
|
||||
} else {
|
||||
await postComment(event.owner, event.repo, event.number, body);
|
||||
}
|
||||
|
||||
const result = { processed: true, impact: analysis.impact, confidence: analysis.confidence };
|
||||
recordEvent(event, result, analysis);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleCheckSuiteCompleted(
|
||||
payload: Record<string, unknown>,
|
||||
config: Config,
|
||||
engine: ReturnType<typeof createEngine>
|
||||
): Promise<void> {
|
||||
const logger = getLogger();
|
||||
|
||||
if (!config.response.allowUpdates) {
|
||||
logger.debug('check_suite received but allowUpdates is false, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const checkSuite = payload.check_suite as Record<string, unknown>;
|
||||
const pullRequests = (checkSuite.pull_requests as Array<Record<string, unknown>>) || [];
|
||||
const repo = payload.repository as Record<string, unknown>;
|
||||
const owner = (repo.owner as Record<string, unknown>).login as string;
|
||||
const repoName = repo.name as string;
|
||||
|
||||
for (const pr of pullRequests) {
|
||||
const prNumber = pr.number as number;
|
||||
logger.info(`Re-analyzing ${owner}/${repoName}#${prNumber} after check_suite completed`);
|
||||
|
||||
try {
|
||||
const prData = await fetchPR(owner, repoName, prNumber);
|
||||
if (!prData) {
|
||||
logger.warn(`Could not fetch PR ${owner}/${repoName}#${prNumber}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const event: WebhookEvent = {
|
||||
action: 'check_suite_completed',
|
||||
type: 'pull_request',
|
||||
number: prNumber,
|
||||
title: prData.title,
|
||||
body: prData.body,
|
||||
owner,
|
||||
repo: repoName,
|
||||
author: prData.author,
|
||||
labels: prData.labels,
|
||||
branch: prData.branch,
|
||||
sha: prData.sha,
|
||||
};
|
||||
|
||||
await analyzeAndComment(event, config, engine);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to re-analyze PR ${owner}/${repoName}#${prNumber}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseEvent(eventType: string, payload: Record<string, unknown>): WebhookEvent | null {
|
||||
try {
|
||||
if (eventType === 'issues') {
|
||||
const issue = payload.issue as Record<string, unknown>;
|
||||
const repo = payload.repository as Record<string, unknown>;
|
||||
const owner = (repo.owner as Record<string, unknown>).login as string;
|
||||
return {
|
||||
action: payload.action as string,
|
||||
type: 'issue',
|
||||
number: issue.number as number,
|
||||
title: (issue.title as string) || '',
|
||||
body: (issue.body as string) || '',
|
||||
owner,
|
||||
repo: repo.name as string,
|
||||
author: (issue.user as Record<string, unknown>).login as string,
|
||||
labels: ((issue.labels as Array<Record<string, unknown>>) || []).map(
|
||||
(l) => l.name as string
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (eventType === 'pull_request') {
|
||||
const pr = payload.pull_request as Record<string, unknown>;
|
||||
const repo = payload.repository as Record<string, unknown>;
|
||||
const owner = (repo.owner as Record<string, unknown>).login as string;
|
||||
const head = pr.head as Record<string, unknown>;
|
||||
return {
|
||||
action: payload.action as string,
|
||||
type: 'pull_request',
|
||||
number: pr.number as number,
|
||||
title: (pr.title as string) || '',
|
||||
body: (pr.body as string) || '',
|
||||
owner,
|
||||
repo: repo.name as string,
|
||||
author: (pr.user as Record<string, unknown>).login as string,
|
||||
labels: ((pr.labels as Array<Record<string, unknown>>) || []).map((l) => l.name as string),
|
||||
branch: head.ref as string,
|
||||
sha: head.sha as string,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue