Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ifab58fcb523549ca9cb83dc8467be51e6a6a6964
376 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|