troutbot/src/server.ts
NotAShelf 374408834b
treewide: make less webhook-centric
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ifab58fcb523549ca9cb83dc8467be51e6a6a6964
2026-02-01 15:36:56 +03:00

376 lines
12 KiB
TypeScript

import crypto from 'node:crypto';
import express from 'express';
import rateLimit from 'express-rate-limit';
import type { Config, WebhookEvent } from './types.js';
import { shouldProcess } from './filters.js';
import { createEngine } from './engine/index.js';
import {
fetchPR,
fetchIssue,
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;
}
// Handle issue_comment with @troutbot mention - on-demand analysis
if (
eventType === 'issue_comment' &&
['created', 'edited'].includes(payload.action as string)
) {
const commentBody = (payload.comment as Record<string, unknown>).body as string;
if (commentBody && commentBody.includes('@troutbot')) {
const result = await handleOnDemandAnalysis(payload, config, engine);
res.json(result);
return;
}
res.json({ skipped: true, reason: 'Comment does not mention @troutbot' });
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);
}
}
}
async function handleOnDemandAnalysis(
payload: Record<string, unknown>,
config: Config,
engine: ReturnType<typeof createEngine>
): Promise<Record<string, unknown>> {
const logger = getLogger();
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;
const issue = payload.issue as Record<string, unknown>;
const issueNumber = issue.number as number;
const isPullRequest = issue.pull_request !== undefined;
logger.info(
`On-demand analysis triggered for ${owner}/${repoName}#${issueNumber} (${isPullRequest ? 'PR' : 'issue'})`
);
try {
let event: WebhookEvent;
if (isPullRequest) {
const prData = await fetchPR(owner, repoName, issueNumber);
if (!prData) {
logger.warn(`Could not fetch PR ${owner}/${repoName}#${issueNumber}`);
return { skipped: true, reason: 'Could not fetch PR data' };
}
event = {
action: 'on_demand',
type: 'pull_request',
number: issueNumber,
title: prData.title,
body: prData.body,
owner,
repo: repoName,
author: prData.author,
labels: prData.labels,
branch: prData.branch,
sha: prData.sha,
};
} else {
const issueData = await fetchIssue(owner, repoName, issueNumber);
if (!issueData) {
logger.warn(`Could not fetch issue ${owner}/${repoName}#${issueNumber}`);
return { skipped: true, reason: 'Could not fetch issue data' };
}
event = {
action: 'on_demand',
type: 'issue',
number: issueNumber,
title: issueData.title,
body: issueData.body,
owner,
repo: repoName,
author: issueData.author,
labels: issueData.labels,
};
}
return await analyzeAndComment(event, config, engine);
} catch (err) {
logger.error(
`Failed to process on-demand analysis for ${owner}/${repoName}#${issueNumber}`,
err
);
return { error: 'Internal server error' };
}
}
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;
}
}