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).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).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).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 ): Promise> { 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, config: Config, engine: ReturnType ): Promise { 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; const pullRequests = (checkSuite.pull_requests as Array>) || []; const repo = payload.repository as Record; const owner = (repo.owner as Record).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, config: Config, engine: ReturnType ): Promise> { const logger = getLogger(); const repo = payload.repository as Record; const owner = (repo.owner as Record).login as string; const repoName = repo.name as string; const issue = payload.issue as Record; 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): WebhookEvent | null { try { if (eventType === 'issues') { const issue = payload.issue as Record; const repo = payload.repository as Record; const owner = (repo.owner as Record).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).login as string, labels: ((issue.labels as Array>) || []).map( (l) => l.name as string ), }; } if (eventType === 'pull_request') { const pr = payload.pull_request as Record; const repo = payload.repository as Record; const owner = (repo.owner as Record).login as string; const head = pr.head as Record; 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).login as string, labels: ((pr.labels as Array>) || []).map((l) => l.name as string), branch: head.ref as string, sha: head.sha as string, }; } return null; } catch { return null; } }