From 374408834b7a50f45c447ec4434cad1d9e5f7fcc Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 14:38:58 +0300 Subject: [PATCH 1/3] treewide: make less webhook-centric Signed-off-by: NotAShelf Change-Id: Ifab58fcb523549ca9cb83dc8467be51e6a6a6964 --- src/dashboard.ts | 8 +- src/engine/checks.ts | 32 +++--- src/engine/diff.ts | 39 ++++++-- src/engine/quality.ts | 36 +++++-- src/github.ts | 74 +++++++++++++- src/index.ts | 10 +- src/polling.ts | 223 ++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 89 ++++++++++++++++- src/types.ts | 7 ++ 9 files changed, 479 insertions(+), 39 deletions(-) create mode 100644 src/polling.ts diff --git a/src/dashboard.ts b/src/dashboard.ts index 3dd7d99..64cd5fc 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -9,8 +9,7 @@ export function createDashboardRouter(config: Config): express.Router { router.use(express.json()); - // --- API routes --- - + // API routes router.get('/api/status', (_req, res) => { const enabledBackends = Object.entries(config.engine.backends) .filter(([, v]) => v.enabled) @@ -41,7 +40,10 @@ export function createDashboardRouter(config: Config): express.Router { router.put('/api/config', (req, res) => { try { const partial = req.body as Partial; - const merged = deepMerge(config as Record, partial as Record) as Config; + const merged = deepMerge( + config as Record, + partial as Record + ) as Config; validate(merged); // Apply in-place diff --git a/src/engine/checks.ts b/src/engine/checks.ts index 9c035ff..f43b406 100644 --- a/src/engine/checks.ts +++ b/src/engine/checks.ts @@ -81,20 +81,17 @@ export class ChecksBackend implements EngineBackend { // Classify failures by severity const criticalFailures = failed.filter((r) => classifyCheck(r.name) === 'critical'); const advisoryFailures = failed.filter((r) => classifyCheck(r.name) === 'advisory'); - const standardFailures = failed.filter( - (r) => classifyCheck(r.name) === 'standard' - ); + const standardFailures = failed.filter((r) => classifyCheck(r.name) === 'standard'); // Weighted scoring: critical failures count 3x, advisory 0.5x const failureScore = criticalFailures.length * 3 + standardFailures.length * 1 + advisoryFailures.length * 0.5; - const totalWeight = - completed - .filter((r) => !skipped.includes(r)) - .reduce((s, r) => { - const cls = classifyCheck(r.name); - return s + (cls === 'critical' ? 3 : cls === 'advisory' ? 0.5 : 1); - }, 0); + const totalWeight = completed + .filter((r) => !skipped.includes(r)) + .reduce((s, r) => { + const cls = classifyCheck(r.name); + return s + (cls === 'critical' ? 3 : cls === 'advisory' ? 0.5 : 1); + }, 0); const weightedPassRate = totalWeight > 0 ? 1 - failureScore / totalWeight : 0; @@ -117,13 +114,20 @@ export class ChecksBackend implements EngineBackend { // Build detailed reasoning const parts: string[] = []; - if (passed.length > 0) parts.push(`${passed.length} passed (${passed.map((r) => r.name).join(', ')})`); + if (passed.length > 0) + parts.push(`${passed.length} passed (${passed.map((r) => r.name).join(', ')})`); if (criticalFailures.length > 0) - parts.push(`${criticalFailures.length} critical failure(s) (${criticalFailures.map((r) => r.name).join(', ')})`); + parts.push( + `${criticalFailures.length} critical failure(s) (${criticalFailures.map((r) => r.name).join(', ')})` + ); if (advisoryFailures.length > 0) - parts.push(`${advisoryFailures.length} advisory failure(s) (${advisoryFailures.map((r) => r.name).join(', ')})`); + parts.push( + `${advisoryFailures.length} advisory failure(s) (${advisoryFailures.map((r) => r.name).join(', ')})` + ); if (standardFailures.length > 0) - parts.push(`${standardFailures.length} other failure(s) (${standardFailures.map((r) => r.name).join(', ')})`); + parts.push( + `${standardFailures.length} other failure(s) (${standardFailures.map((r) => r.name).join(', ')})` + ); if (skipped.length > 0) parts.push(`${skipped.length} skipped`); if (pending.length > 0) parts.push(`${pending.length} still running`); diff --git a/src/engine/diff.ts b/src/engine/diff.ts index c4ebba4..9c74cd5 100644 --- a/src/engine/diff.ts +++ b/src/engine/diff.ts @@ -15,7 +15,9 @@ const RISKY_FILE_PATTERN = const DOC_FILE_PATTERN = /\.(md|mdx|txt|rst|adoc)$|^(README|CHANGELOG|LICENSE|CONTRIBUTING)/i; -function categorizeFiles(files: { filename: string; additions: number; deletions: number; changes: number }[]) { +function categorizeFiles( + files: { filename: string; additions: number; deletions: number; changes: number }[] +) { const src: typeof files = []; const tests: typeof files = []; const generated: typeof files = []; @@ -89,7 +91,11 @@ export class DiffBackend implements EngineBackend { } else if (totalChanges <= this.config.maxChanges) { signals.push({ name: `large PR (${totalChanges} lines)`, positive: false, weight: 0.8 }); } else { - signals.push({ name: `very large PR (${totalChanges} lines, exceeds limit)`, positive: false, weight: 1.5 }); + signals.push({ + name: `very large PR (${totalChanges} lines, exceeds limit)`, + positive: false, + weight: 1.5, + }); } // --- Focus signals --- @@ -98,9 +104,17 @@ export class DiffBackend implements EngineBackend { } else if (meaningful.length <= 10) { signals.push({ name: 'focused changeset', positive: true, weight: 0.8 }); } else if (meaningful.length > 30) { - signals.push({ name: `sprawling changeset (${meaningful.length} files)`, positive: false, weight: 1.2 }); + signals.push({ + name: `sprawling changeset (${meaningful.length} files)`, + positive: false, + weight: 1.2, + }); } else if (meaningful.length > 20) { - signals.push({ name: `broad changeset (${meaningful.length} files)`, positive: false, weight: 0.6 }); + signals.push({ + name: `broad changeset (${meaningful.length} files)`, + positive: false, + weight: 0.6, + }); } // --- Test coverage --- @@ -129,10 +143,17 @@ export class DiffBackend implements EngineBackend { // --- Churn detection (files with high add+delete suggesting rewrites) --- const highChurnFiles = src.filter( - (f) => f.additions > 50 && f.deletions > 50 && Math.min(f.additions, f.deletions) / Math.max(f.additions, f.deletions) > 0.6 + (f) => + f.additions > 50 && + f.deletions > 50 && + Math.min(f.additions, f.deletions) / Math.max(f.additions, f.deletions) > 0.6 ); if (highChurnFiles.length >= 3) { - signals.push({ name: `high churn in ${highChurnFiles.length} files (possible refactor)`, positive: false, weight: 0.5 }); + signals.push({ + name: `high churn in ${highChurnFiles.length} files (possible refactor)`, + positive: false, + weight: 0.5, + }); } // --- Risky files --- @@ -180,7 +201,11 @@ export class DiffBackend implements EngineBackend { const totalSignalWeight = positiveWeight + negativeWeight; const confidence = signals.length > 0 - ? Math.min(1, Math.abs(positiveWeight - negativeWeight) / Math.max(totalSignalWeight, 1) * 0.6 + 0.25) + ? Math.min( + 1, + (Math.abs(positiveWeight - negativeWeight) / Math.max(totalSignalWeight, 1)) * 0.6 + + 0.25 + ) : 0; // Build reasoning diff --git a/src/engine/quality.ts b/src/engine/quality.ts index 6b069d3..4aba1dd 100644 --- a/src/engine/quality.ts +++ b/src/engine/quality.ts @@ -44,7 +44,11 @@ export class QualityBackend implements EngineBackend { if (body.length === 0) { signals.push({ name: 'empty description', positive: false, weight: 2 }); } else if (body.length < this.config.minBodyLength) { - signals.push({ name: `short description (${body.length} chars)`, positive: false, weight: 1.2 }); + signals.push({ + name: `short description (${body.length} chars)`, + positive: false, + weight: 1.2, + }); } else if (body.length >= this.config.minBodyLength) { signals.push({ name: 'adequate description', positive: true, weight: 1 }); if (body.length > 300) { @@ -68,7 +72,11 @@ export class QualityBackend implements EngineBackend { if (total > 0 && checked === total) { signals.push({ name: `checklist complete (${total}/${total})`, positive: true, weight: 1 }); } else if (total > 0) { - signals.push({ name: `checklist incomplete (${checked}/${total})`, positive: false, weight: 0.8 }); + signals.push({ + name: `checklist incomplete (${checked}/${total})`, + positive: false, + weight: 0.8, + }); } } @@ -79,14 +87,22 @@ export class QualityBackend implements EngineBackend { if (body.length > 100 && BREAKING_PATTERN.test(body)) { signals.push({ name: 'breaking change documented', positive: true, weight: 0.8 }); } else { - signals.push({ name: 'breaking change mentioned but not detailed', positive: false, weight: 0.8 }); + signals.push({ + name: 'breaking change mentioned but not detailed', + positive: false, + weight: 0.8, + }); } } // TODOs/FIXMEs in description suggest unfinished work const todoMatches = body.match(TODO_PATTERN); if (todoMatches) { - signals.push({ name: `unfinished markers in description (${todoMatches.length})`, positive: false, weight: 0.6 }); + signals.push({ + name: `unfinished markers in description (${todoMatches.length})`, + positive: false, + weight: 0.6, + }); } // --- Type-specific signals --- @@ -100,7 +116,9 @@ export class QualityBackend implements EngineBackend { signals.push({ name: 'has expected/actual behavior', positive: true, weight: 1.2 }); } - if (/\b(version|environment|os|platform|browser|node|python|java|rust|go)\s*[:\d]/i.test(body)) { + if ( + /\b(version|environment|os|platform|browser|node|python|java|rust|go)\s*[:\d]/i.test(body) + ) { signals.push({ name: 'has environment details', positive: true, weight: 1 }); } @@ -140,7 +158,11 @@ export class QualityBackend implements EngineBackend { // Shared: references to other issues/PRs const refs = body.match(/#\d+/g); if (refs && refs.length > 0) { - signals.push({ name: `references ${refs.length} issue(s)/PR(s)`, positive: true, weight: 0.6 }); + signals.push({ + name: `references ${refs.length} issue(s)/PR(s)`, + positive: true, + weight: 0.6, + }); } // Screenshots or images @@ -169,7 +191,7 @@ export class QualityBackend implements EngineBackend { const totalWeight = positiveWeight + negativeWeight; const confidence = Math.min( 1, - Math.abs(positiveWeight - negativeWeight) / Math.max(totalWeight, 1) * 0.5 + 0.2 + (Math.abs(positiveWeight - negativeWeight) / Math.max(totalWeight, 1)) * 0.5 + 0.2 ); const reasoning = `Quality: ${signals.map((s) => `${s.positive ? '+' : '-'} ${s.name}`).join(', ')}.`; diff --git a/src/github.ts b/src/github.ts index 14348fa..13fe784 100644 --- a/src/github.ts +++ b/src/github.ts @@ -16,8 +16,7 @@ export function isDryRun(): boolean { return octokit === null; } -// --- Comment operations --- - +// Comment operations export async function postComment( owner: string, repo: string, @@ -70,8 +69,7 @@ export async function updateComment( getLogger().info(`Updated comment ${commentId} on ${owner}/${repo}`); } -// --- Data fetching for engine backends --- - +// Data fetching for engine backends export async function fetchCheckRuns( owner: string, repo: string, @@ -146,8 +144,74 @@ export async function fetchPR( }; } -// --- Comment formatting --- +export async function fetchIssue( + owner: string, + repo: string, + issueNumber: number +): Promise<{ + title: string; + body: string; + author: string; + labels: string[]; +} | null> { + if (!octokit) return null; + const { data } = await octokit.issues.get({ owner, repo, issue_number: issueNumber }); + return { + title: data.title, + body: data.body || '', + author: data.user?.login || '', + labels: (data.labels || []).map((l) => (typeof l === 'string' ? l : l.name || '')), + }; +} + +export interface RecentComment { + id: number; + body: string; + author: string; + createdAt: string; + issueNumber: number; + isPullRequest: boolean; +} + +export async function listRecentComments( + owner: string, + repo: string, + since: Date +): Promise { + if (!octokit) { + getLogger().debug('[dry-run] Cannot fetch comments without a token'); + return []; + } + + const sinceIso = since.toISOString(); + const comments: RecentComment[] = []; + + // Fetch recent issue comments + const issueComments = await octokit.paginate(octokit.issues.listCommentsForRepo, { + owner, + repo, + since: sinceIso, + per_page: 100, + }); + + for (const comment of issueComments) { + if (!comment.body) continue; + + comments.push({ + id: comment.id, + body: comment.body, + author: comment.user?.login || '', + createdAt: comment.created_at, + issueNumber: comment.issue_url ? parseInt(comment.issue_url.split('/').pop() || '0', 10) : 0, + isPullRequest: false, // we'll determine this by fetching the issue + }); + } + + return comments; +} + +// Comment formatting function pickRandom(list: string[]): string { return list[Math.floor(Math.random() * list.length)]; } diff --git a/src/index.ts b/src/index.ts index e944a4f..2e24cbb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { } from './github.js'; import { createApp } from './server.js'; import { createEngine } from './engine/index.js'; +import { startPolling } from './polling.js'; import type { WebhookEvent } from './types.js'; async function analyzeOne(target: string) { @@ -95,7 +96,9 @@ function serve() { ); } if (!process.env.WEBHOOK_SECRET) { - logger.warn('No WEBHOOK_SECRET - webhook signature verification is disabled'); + logger.warn( + 'No WEBHOOK_SECRET - webhook signature verification is disabled (not needed for polling-only mode)' + ); } const app = createApp(config); @@ -105,7 +108,7 @@ function serve() { .filter(([, v]) => v.enabled) .map(([k]) => k); - const server = app.listen(port, () => { + const server = app.listen(port, async () => { logger.info(`Troutbot listening on port ${port}`); logger.info(`Enabled backends: ${enabledBackends.join(', ')}`); @@ -138,6 +141,9 @@ function serve() { logger.info(`Comment updates: ${config.response.allowUpdates ? 'enabled' : 'disabled'}`); logger.info(`Dashboard available at http://localhost:${port}/dashboard`); + + // Start polling if enabled + await startPolling(config); }); function shutdown(signal: string) { diff --git a/src/polling.ts b/src/polling.ts new file mode 100644 index 0000000..090dce6 --- /dev/null +++ b/src/polling.ts @@ -0,0 +1,223 @@ +import type { Config, WebhookEvent } from './types.js'; +import { + listRecentComments, + fetchPR, + fetchIssue, + hasExistingComment, + postComment, + updateComment, + formatComment, + type RecentComment, +} from './github.js'; +import { createEngine } from './engine/index.js'; +import { getLogger } from './logger.js'; +import { recordEvent } from './events.js'; + +interface ProcessedComment { + id: number; + timestamp: number; +} + +const processedComments: Map = new Map(); +const MAX_PROCESSED_CACHE = 1000; + +function getCacheKey(owner: string, repo: string, commentId: number): string { + return `${owner}/${repo}#${commentId}`; +} + +function isProcessed(owner: string, repo: string, commentId: number): boolean { + return processedComments.has(getCacheKey(owner, repo, commentId)); +} + +function markProcessed(owner: string, repo: string, commentId: number): void { + const key = getCacheKey(owner, repo, commentId); + processedComments.set(key, { id: commentId, timestamp: Date.now() }); + + // Clean up old entries if cache is too large + if (processedComments.size > MAX_PROCESSED_CACHE) { + const entries = Array.from(processedComments.entries()); + entries.sort((a, b) => a[1].timestamp - b[1].timestamp); + const toRemove = entries.slice(0, entries.length - MAX_PROCESSED_CACHE); + for (const [k] of toRemove) { + processedComments.delete(k); + } + } +} + +function containsMention(body: string): boolean { + return body.includes('@troutbot'); +} + +async function analyzeAndComment( + event: WebhookEvent, + config: Config +): Promise> { + const logger = getLogger(); + const engine = createEngine(config.engine); + + // 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) { + logger.info(`Updating existing comment on ${event.owner}/${event.repo}#${event.number}`); + 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 processComment( + comment: RecentComment, + owner: string, + repo: string, + config: Config +): Promise { + const logger = getLogger(); + + if (!containsMention(comment.body)) { + return; + } + + if (isProcessed(owner, repo, comment.id)) { + logger.debug(`Comment ${owner}/${repo}#${comment.id} already processed, skipping`); + return; + } + + logger.info(`Found @troutbot mention in ${owner}/${repo}#${comment.issueNumber}`); + + try { + // First, try to fetch as a PR to check if it's a pull request + const prData = await fetchPR(owner, repo, comment.issueNumber); + + let event: WebhookEvent; + + if (prData) { + // It's a pull request + event = { + action: 'on_demand', + type: 'pull_request', + number: comment.issueNumber, + title: prData.title, + body: prData.body, + owner, + repo, + author: prData.author, + labels: prData.labels, + branch: prData.branch, + sha: prData.sha, + }; + } else { + // It's an issue + const issueData = await fetchIssue(owner, repo, comment.issueNumber); + if (!issueData) { + logger.warn(`Could not fetch issue ${owner}/${repo}#${comment.issueNumber}`); + return; + } + + event = { + action: 'on_demand', + type: 'issue', + number: comment.issueNumber, + title: issueData.title, + body: issueData.body, + owner, + repo, + author: issueData.author, + labels: issueData.labels, + }; + } + + await analyzeAndComment(event, config); + markProcessed(owner, repo, comment.id); + + logger.info( + `Successfully processed on-demand analysis for ${owner}/${repo}#${comment.issueNumber}` + ); + } catch (err) { + logger.error(`Failed to process mention in ${owner}/${repo}#${comment.issueNumber}`, err); + } +} + +async function pollRepository( + owner: string, + repo: string, + config: Config, + since: Date +): Promise { + const logger = getLogger(); + + try { + const comments = await listRecentComments(owner, repo, since); + logger.debug(`Fetched ${comments.length} recent comments from ${owner}/${repo}`); + + for (const comment of comments) { + await processComment(comment, owner, repo, config); + } + } catch (err) { + logger.error(`Failed to poll ${owner}/${repo}`, err); + } +} + +export async function startPolling(config: Config): Promise { + const logger = getLogger(); + const pollingConfig = config.polling; + + if (!pollingConfig || !pollingConfig.enabled) { + logger.info('Polling is disabled'); + return; + } + + if (config.repositories.length === 0) { + logger.warn('Polling enabled but no repositories configured'); + return; + } + + const intervalMs = pollingConfig.intervalMinutes * 60 * 1000; + const lookbackMs = pollingConfig.lookbackMinutes * 60 * 1000; + + logger.info(`Starting polling for ${config.repositories.length} repositories`); + logger.info( + `Poll interval: ${pollingConfig.intervalMinutes} minutes, lookback: ${pollingConfig.lookbackMinutes} minutes` + ); + + // Do an initial poll + const initialSince = new Date(Date.now() - lookbackMs); + for (const repo of config.repositories) { + await pollRepository(repo.owner, repo.repo, config, initialSince); + } + + // Set up recurring polling + setInterval(async () => { + const since = new Date(Date.now() - lookbackMs); + + for (const repo of config.repositories) { + await pollRepository(repo.owner, repo.repo, config, since); + } + }, intervalMs); +} diff --git a/src/server.ts b/src/server.ts index b97e3b0..b0cc53d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,11 +1,12 @@ import crypto from 'node:crypto'; import express from 'express'; import rateLimit from 'express-rate-limit'; -import type { Config, WebhookEvent, AnalysisResult } from './types.js'; +import type { Config, WebhookEvent } from './types.js'; import { shouldProcess } from './filters.js'; import { createEngine } from './engine/index.js'; import { fetchPR, + fetchIssue, formatComment, hasExistingComment, postComment, @@ -96,6 +97,21 @@ export function createApp(config: Config): express.Express { 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; @@ -241,6 +257,77 @@ async function handleCheckSuiteCompleted( } } +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') { diff --git a/src/types.ts b/src/types.ts index a84bd30..ab07dff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,13 @@ export interface Config { engine: EngineConfig; response: ResponseConfig; logging: LoggingConfig; + polling?: PollingConfig; +} + +export interface PollingConfig { + enabled: boolean; + intervalMinutes: number; + lookbackMinutes: number; } export interface ServerConfig { From 2facb2a1e21afc838d56dbd007012b9e71bf2a51 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 15:35:05 +0300 Subject: [PATCH 2/3] config: add polling section to sample config Signed-off-by: NotAShelf Change-Id: I882b8ce0657f0f92dc31f7fc9713e9256a6a6964 --- config.example.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/config.example.ts b/config.example.ts index 28f5bb8..513c8bc 100644 --- a/config.example.ts +++ b/config.example.ts @@ -15,9 +15,11 @@ const config: Config = { include: [], exclude: ['bot-ignore'], }, + authors: { exclude: ['dependabot', 'renovate[bot]'], }, + branches: { include: [], // empty = all branches }, @@ -62,7 +64,9 @@ const config: Config = { includeReasoning: true, // One message is picked at random from the list matching the impact. - // Placeholders: {type} (issue/pull request), {impact} (positive/negative/neutral) + // Placeholders: + // - {type} (issue/pull request), + // - {impact} (positive/negative/neutral) messages: { positive: [ 'This {type} looks great for the trout! All signals point upstream.', @@ -89,6 +93,14 @@ const config: Config = { level: 'info', file: 'troutbot.log', }, + + // Polling mode: Watch for @troutbot mentions without webhooks. + // Useful for monitoring multiple repos without needing webhook configuration. + polling: { + enabled: false, + intervalMinutes: 5, // how often to check for new comments + lookbackMinutes: 10, // how far back to look for comments on each poll + }, }; export default config; From d952b973a839c2d1f7b05e24ee0593a567db2692 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 15:36:28 +0300 Subject: [PATCH 3/3] docs: rewrite README to be less webhook centric Signed-off-by: NotAShelf Change-Id: I4a46610262c629f22bc61b8581a4a0336a6a6964 --- README.md | 231 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 144 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index b6d6e85..73e73d6 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,34 @@ Troutbot is the final solution to protecting the trout population. It's environmental protection incarnate! -Well in reality, it's a GitHub webhook bot that analyzes issues and pull -requests using real signals such as CI check results, diff quality, and body -structure and then posts trout-themed comments about the findings. Now you know -whether your changes hurt or help the trout population. +Well, in reality, it is a GitHub bot that analyzes issues and pull requests +using real signals such as CI check results, diff quality, and body structure +and then posts trout-themed comments about the findings. Now you know whether +your changes hurt or help the trout population. + +## Operation Modes + +Troutbot supports two operation modes: + +### Webhook Mode (Real-time) + +GitHub sends webhook events to troutbot when issues/PRs are opened or updated. +Troutbot responds immediately. Best for: + +- Single or few repositories +- You have admin access to configure webhooks +- You can expose a public endpoint + +### Polling Mode (Periodic) + +Troutbot periodically polls configured repositories for `@troutbot` mentions in +comments. Best for: + +- Monitoring dozens of repositories without webhook setup +- Running behind a firewall or on dynamic IPs +- Simplified deployment without webhook secrets + +Both modes use the same analysis engine and produce the same results. ## Quick Start @@ -17,18 +41,17 @@ $ npm install # Populate the environment config $ cp .env.example .env -# Set up application confg +# Set up application config cp config.example.ts config.ts -# Edit .env and config.ts, then to start: -npm run build && npm start +# Edit .env and config.ts, then build and start. +# If `.env` is not populated, Troutbot will start in dry-run mode. +pnpm run build && pnpm start ``` ## How It Works -Troutbot has three analysis backends ran against each incoming webhook event. -They are the primary decisionmaking logic behind whether your changes affect the -trout population negatively, or positively. +Troutbot has three analysis backends that analyze issues and PRs: ### `checks` @@ -71,6 +94,75 @@ checks 0.4, diff 0.3, quality 0.3). Backends that return zero confidence (e.g., no CI checks found yet) are excluded from the average. If combined confidence falls below `confidenceThreshold`, the result is forced to neutral. +## Webhook Mode + +In webhook mode, troutbot receives real-time events from GitHub. + +### GitHub Webhook Setup + +1. Go to your repository's **Settings > Webhooks > Add webhook** +2. **Payload URL**: `https://your-host/webhook` +3. **Content type**: `application/json` +4. **Secret**: Generate with `openssl rand -hex 32` and set as `WEBHOOK_SECRET` +5. **Events**: Select **Issues**, **Pull requests**, and optionally **Check + suites** (for re-analysis when CI finishes) + +If you enable **Check suites** and set `response.allowUpdates: true` in your +config, troutbot will update its comment on a PR once CI results are available. + +### Webhook Security + +- **`WEBHOOK_SECRET` is strongly recommended.** Without it, anyone who can reach + the `/webhook` endpoint can trigger analysis and post comments. Always set a + secret and configure the same value in your GitHub webhook settings. + +## Polling Mode + +In polling mode, troutbot periodically checks configured repositories for +`@troutbot` mentions in comments. + +### Configuration + +Enable polling in your `config.ts`: + +```typescript +polling: { + enabled: true, + intervalMinutes: 5, // Check every 5 minutes + lookbackMinutes: 10, // Look back 10 minutes for new comments +} +``` + +### How It Works + +1. On startup, troutbot fetches recent comments from all configured repositories +2. It scans each comment for `@troutbot` mentions +3. When found, it analyzes the associated issue/PR and posts a response +4. Processed comments are tracked to avoid duplicate responses +5. The cycle repeats every `intervalMinutes` + +### On-Demand Analysis + +Users can trigger analysis by mentioning `@troutbot` in any comment: + +```plaintext +Hey @troutbot, can you take a look at this? +``` + +The bot will analyze the issue/PR and respond with a trout-themed assessment. + +### Rate Limiting + +Polling uses the GitHub REST API and respects rate limits. The default settings +(5 min interval, 10 min lookback) are conservative and work well within GitHub's +5000 requests/hour limit for personal access tokens. + +### Requirements + +- `GITHUB_TOKEN` with read access to all watched repositories +- Repositories configured in `config.repositories` +- Write access to post comments + ## GitHub Account & Token Setup Troutbot is designed to run as a dedicated bot account on GitHub. Create a @@ -89,8 +181,7 @@ The bot account needs access to every repository it will comment on: - **For organization repos**: Invite the bot account as a collaborator with **Write** access, or add it to a team with write permissions. - **For personal repos**: Add the bot account as a collaborator under - \*\*Settings - > Collaborators\*\*. + `Settings > Collaborators`. The bot needs write access to post comments. Read access alone is not enough. @@ -98,10 +189,10 @@ The bot needs write access to post comments. Read access alone is not enough. Log in as the bot account and create a fine-grained PAT: -1. Go to **Settings > Developer settings > Personal access tokens > Fine-grained - tokens** +1. Go to + `Settings > Developer settings > Personal access tokens > Fine-grained tokens` 2. Click **Generate new token** -3. Set a descriptive name (e.g., `troutbot-webhook`) +3. Set a descriptive name (e.g., `troutbot-production`) 4. Set **Expiration** - pick a long-lived duration or no expiration, since this runs unattended 5. Under **Repository access**, select the specific repositories the bot will @@ -121,19 +212,7 @@ Set this as the `GITHUB_TOKEN` environment variable. > `repo` scope. Fine-grained tokens are recommended because they follow the > principle of least privilege. -### 4. Generate a webhook secret - -Generate a random secret to verify webhook payloads: - -```bash -openssl rand -hex 32 -``` - -Set this as the `WEBHOOK_SECRET` environment variable, and use the same value -when configuring the webhook in GitHub (see -[GitHub Webhook Setup](#github-webhook-setup)). - -## Configuration +## Configuring Troutbot ### Environment Variables @@ -142,7 +221,7 @@ when configuring the webhook in GitHub (see | Variable | Description | Required | | ---------------- | ----------------------------------------------------- | ---------------------------- | | `GITHUB_TOKEN` | Fine-grained PAT from the bot account (see above) | No (dry-run without it) | -| `WEBHOOK_SECRET` | Secret for verifying webhook signatures | No (skips verification) | +| `WEBHOOK_SECRET` | Secret for verifying webhook signatures | No (only for webhook mode) | | `PORT` | Server port (overrides `server.port` in config) | No | | `CONFIG_PATH` | Path to config file | No (defaults to `config.ts`) | | `LOG_LEVEL` | Log level override (`debug`, `info`, `warn`, `error`) | No | @@ -156,10 +235,11 @@ default-exports a `Config` object - full type checking and autocompletion in your editor. ```typescript -import type { Config } from "./src/types"; +import type { Config } from './src/types'; const config: Config = { server: { port: 3000 }, + repositories: [{ owner: 'myorg', repo: 'myrepo' }], engine: { backends: { checks: { enabled: true }, @@ -169,6 +249,11 @@ const config: Config = { weights: { checks: 0.4, diff: 0.3, quality: 0.3 }, confidenceThreshold: 0.1, }, + polling: { + enabled: true, + intervalMinutes: 5, + lookbackMinutes: 10, + }, // ... }; @@ -180,28 +265,13 @@ pre-compilation needed. See `config.example.ts` for the full annotated reference. -## GitHub Webhook Setup - -1. Go to your repository's **Settings > Webhooks > Add webhook** -2. **Payload URL**: `https://your-host/webhook` -3. **Content type**: `application/json` -4. **Secret**: Must match your `WEBHOOK_SECRET` env var -5. **Events**: Select **Issues**, **Pull requests**, and optionally **Check - suites** (for re-analysis when CI finishes) - -If you enable **Check suites** and set `response.allowUpdates: true` in your -config, troutbot will update its comment on a PR once CI results are available. - ## Production Configuration When deploying troutbot to production, keep the following in mind: -- **`WEBHOOK_SECRET` is strongly recommended.** Without it, anyone who can reach - the `/webhook` endpoint can trigger analysis and post comments. Always set a - secret and configure the same value in your GitHub webhook settings. -- **Use a reverse proxy with TLS.** GitHub sends webhook payloads over HTTPS. - Put nginx, Caddy, or a cloud load balancer in front of troutbot and terminate - TLS there. +- **Use a reverse proxy with TLS.** If using webhook mode, GitHub sends payloads + over HTTPS. Put nginx, Caddy, or a cloud load balancer in front of troutbot + and terminate TLS there. Polling mode doesn't require a public endpoint. - **Set `NODE_ENV=production`.** This is set automatically in the Docker image. For standalone deployments, export it in your environment. Express uses this to enable performance optimizations. @@ -218,22 +288,19 @@ When deploying troutbot to production, keep the following in mind: ## Deployment -
-Standalone (Node.js) +### Standalone (Node.js) ```bash npm ci npm run build export NODE_ENV=production export GITHUB_TOKEN="ghp_..." -export WEBHOOK_SECRET="your-secret" +# Only needed for webhook mode: +# export WEBHOOK_SECRET="your-secret" npm start ``` -
- -
-Nix +### Nix **Flake** (NixOS or flake-enabled systems): @@ -248,8 +315,8 @@ npm start { services.troutbot = { enable = true; - environmentFile = "/path/to/.env"; # use Agenix if possible - configPath = "/path/to/config.ts" # use Agenix if possible + environmentFile = "/path/to/.env"; + configPath = "/path/to/config.ts"; }; } ]; @@ -264,10 +331,7 @@ npm start nix run github:notashelf/troutbot ``` -
- -
-Docker +### Docker ```bash docker build -t troutbot . @@ -275,7 +339,6 @@ docker run -d \ --name troutbot \ -p 127.0.0.1:3000:3000 \ -e GITHUB_TOKEN="ghp_..." \ - -e WEBHOOK_SECRET="your-secret" \ -v $(pwd)/config.ts:/app/config.ts:ro \ --restart unless-stopped \ troutbot @@ -283,17 +346,14 @@ docker run -d \ Multi-stage build, non-root user, built-in health check, `STOPSIGNAL SIGTERM`. -
- -
-Docker Compose +### Docker Compose ```yaml services: troutbot: build: . ports: - - "127.0.0.1:3000:3000" + - '127.0.0.1:3000:3000' env_file: .env volumes: - ./config.ts:/app/config.ts:ro @@ -305,20 +365,17 @@ services: logging: driver: json-file options: - max-size: "10m" - max-file: "3" + max-size: '10m' + max-file: '3' ``` -
- -
-systemd +### Systemd Create `/etc/systemd/system/troutbot.service`: ```ini [Unit] -Description=Troutbot GitHub Webhook Bot +Description=Troutbot GitHub Bot After=network.target [Service] @@ -345,10 +402,9 @@ sudo systemctl daemon-reload sudo systemctl enable --now troutbot ``` -
+### Reverse Proxy (nginx) -
-Reverse Proxy (nginx) +Only needed for webhook mode: ```nginx server { @@ -369,7 +425,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Optional: nginx-level rate limiting + # Optional: nginx-level rate limiting for webhooks # limit_req_zone $binary_remote_addr zone=webhook:10m rate=10r/s; # location /webhook { # limit_req zone=webhook burst=20 nodelay; @@ -378,21 +434,23 @@ server { } ``` -
- ## API Endpoints + + | Method | Path | Description | | -------- | ------------- | ---------------------------------------------------------------------------------------- | | `GET` | `/health` | Health check - returns `status`, `uptime` (seconds), `version`, `dryRun`, and `backends` | -| `POST` | `/webhook` | GitHub webhook receiver (rate limited) | +| `POST` | `/webhook` | GitHub webhook receiver (rate limited, webhook mode only) | | `GET` | `/dashboard` | Web UI dashboard with status, events, and config editor | | `GET` | `/api/status` | JSON status: uptime, version, dry-run, backends, repo count | -| `GET` | `/api/events` | Recent webhook events from the in-memory ring buffer | +| `GET` | `/api/events` | Recent events from the in-memory ring buffer | | `DELETE` | `/api/events` | Clear the event ring buffer | | `GET` | `/api/config` | Current runtime configuration as JSON | | `PUT` | `/api/config` | Partial config update: deep-merges, validates, and applies in-place | + + ## Dashboard & Runtime API Troutbot ships with a built-in web dashboard and JSON API for monitoring and @@ -405,9 +463,8 @@ running). The dashboard provides: - **Status card** - uptime, version, dry-run state, active backends, and repo count. Auto-refreshes every 30 seconds. -- **Event log** - table of recent webhook events showing repo, PR/issue number, - action, impact rating, and confidence score. Keeps the last 100 events in - memory. +- **Event log** - table of recent events showing repo, PR/issue number, action, + impact rating, and confidence score. Keeps the last 100 events in memory. - **Config editor** - read-only JSON view of the current runtime config with an "Edit" toggle that lets you modify and save changes without restarting. @@ -441,8 +498,8 @@ original config remains unchanged if validation fails. ### Event Buffer API -The event buffer stores the last 100 processed webhook events in memory. Events -are lost on restart. +The event buffer stores the last 100 processed events in memory (from both +webhooks and polling). Events are lost on restart. ```bash # List recent events