diff --git a/README.md b/README.md index 73e73d6..b6d6e85 100644 --- a/README.md +++ b/README.md @@ -3,34 +3,10 @@ Troutbot is the final solution to protecting the trout population. It's environmental protection incarnate! -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. +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. ## Quick Start @@ -41,17 +17,18 @@ $ npm install # Populate the environment config $ cp .env.example .env -# Set up application config +# Set up application confg cp config.example.ts config.ts -# 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 +# Edit .env and config.ts, then to start: +npm run build && npm start ``` ## How It Works -Troutbot has three analysis backends that analyze issues and PRs: +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. ### `checks` @@ -94,75 +71,6 @@ 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 @@ -181,7 +89,8 @@ 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. @@ -189,10 +98,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-production`) +3. Set a descriptive name (e.g., `troutbot-webhook`) 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 @@ -212,7 +121,19 @@ Set this as the `GITHUB_TOKEN` environment variable. > `repo` scope. Fine-grained tokens are recommended because they follow the > principle of least privilege. -## Configuring Troutbot +### 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 ### Environment Variables @@ -221,7 +142,7 @@ Set this as the `GITHUB_TOKEN` environment variable. | 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 (only for webhook mode) | +| `WEBHOOK_SECRET` | Secret for verifying webhook signatures | No (skips verification) | | `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 | @@ -235,11 +156,10 @@ 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 }, @@ -249,11 +169,6 @@ const config: Config = { weights: { checks: 0.4, diff: 0.3, quality: 0.3 }, confidenceThreshold: 0.1, }, - polling: { - enabled: true, - intervalMinutes: 5, - lookbackMinutes: 10, - }, // ... }; @@ -265,13 +180,28 @@ 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: -- **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. +- **`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. - **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. @@ -288,19 +218,22 @@ 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_..." -# Only needed for webhook mode: -# export WEBHOOK_SECRET="your-secret" +export WEBHOOK_SECRET="your-secret" npm start ``` -### Nix +
+ +
+Nix **Flake** (NixOS or flake-enabled systems): @@ -315,8 +248,8 @@ npm start { services.troutbot = { enable = true; - environmentFile = "/path/to/.env"; - configPath = "/path/to/config.ts"; + environmentFile = "/path/to/.env"; # use Agenix if possible + configPath = "/path/to/config.ts" # use Agenix if possible }; } ]; @@ -331,7 +264,10 @@ npm start nix run github:notashelf/troutbot ``` -### Docker +
+ +
+Docker ```bash docker build -t troutbot . @@ -339,6 +275,7 @@ 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 @@ -346,14 +283,17 @@ 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 @@ -365,17 +305,20 @@ 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 Bot +Description=Troutbot GitHub Webhook Bot After=network.target [Service] @@ -402,9 +345,10 @@ sudo systemctl daemon-reload sudo systemctl enable --now troutbot ``` -### Reverse Proxy (nginx) +
-Only needed for webhook mode: +
+Reverse Proxy (nginx) ```nginx server { @@ -425,7 +369,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Optional: nginx-level rate limiting for webhooks + # Optional: nginx-level rate limiting # limit_req_zone $binary_remote_addr zone=webhook:10m rate=10r/s; # location /webhook { # limit_req zone=webhook burst=20 nodelay; @@ -434,23 +378,21 @@ server { } ``` -## API Endpoints +
- +## API Endpoints | Method | Path | Description | | -------- | ------------- | ---------------------------------------------------------------------------------------- | | `GET` | `/health` | Health check - returns `status`, `uptime` (seconds), `version`, `dryRun`, and `backends` | -| `POST` | `/webhook` | GitHub webhook receiver (rate limited, webhook mode only) | +| `POST` | `/webhook` | GitHub webhook receiver (rate limited) | | `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 events from the in-memory ring buffer | +| `GET` | `/api/events` | Recent webhook 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 @@ -463,8 +405,9 @@ 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 events showing repo, PR/issue number, action, - impact rating, and confidence score. Keeps the last 100 events in memory. +- **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. - **Config editor** - read-only JSON view of the current runtime config with an "Edit" toggle that lets you modify and save changes without restarting. @@ -498,8 +441,8 @@ original config remains unchanged if validation fails. ### Event Buffer API -The event buffer stores the last 100 processed events in memory (from both -webhooks and polling). Events are lost on restart. +The event buffer stores the last 100 processed webhook events in memory. Events +are lost on restart. ```bash # List recent events diff --git a/config.example.ts b/config.example.ts index 513c8bc..28f5bb8 100644 --- a/config.example.ts +++ b/config.example.ts @@ -15,11 +15,9 @@ const config: Config = { include: [], exclude: ['bot-ignore'], }, - authors: { exclude: ['dependabot', 'renovate[bot]'], }, - branches: { include: [], // empty = all branches }, @@ -64,9 +62,7 @@ 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.', @@ -93,14 +89,6 @@ 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; diff --git a/src/dashboard.ts b/src/dashboard.ts index 64cd5fc..3dd7d99 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -9,7 +9,8 @@ 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) @@ -40,10 +41,7 @@ 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 f43b406..9c035ff 100644 --- a/src/engine/checks.ts +++ b/src/engine/checks.ts @@ -81,17 +81,20 @@ 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; @@ -114,20 +117,13 @@ 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 9c74cd5..c4ebba4 100644 --- a/src/engine/diff.ts +++ b/src/engine/diff.ts @@ -15,9 +15,7 @@ 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 = []; @@ -91,11 +89,7 @@ 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 --- @@ -104,17 +98,9 @@ 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 --- @@ -143,17 +129,10 @@ 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 --- @@ -201,11 +180,7 @@ 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 4aba1dd..6b069d3 100644 --- a/src/engine/quality.ts +++ b/src/engine/quality.ts @@ -44,11 +44,7 @@ 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) { @@ -72,11 +68,7 @@ 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 }); } } @@ -87,22 +79,14 @@ 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 --- @@ -116,9 +100,7 @@ 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 }); } @@ -158,11 +140,7 @@ 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 @@ -191,7 +169,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 13fe784..14348fa 100644 --- a/src/github.ts +++ b/src/github.ts @@ -16,7 +16,8 @@ export function isDryRun(): boolean { return octokit === null; } -// Comment operations +// --- Comment operations --- + export async function postComment( owner: string, repo: string, @@ -69,7 +70,8 @@ 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, @@ -144,74 +146,8 @@ export async function fetchPR( }; } -export async function fetchIssue( - owner: string, - repo: string, - issueNumber: number -): Promise<{ - title: string; - body: string; - author: string; - labels: string[]; -} | null> { - if (!octokit) return null; +// --- Comment formatting --- - 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 2e24cbb..e944a4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,6 @@ 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) { @@ -96,9 +95,7 @@ function serve() { ); } if (!process.env.WEBHOOK_SECRET) { - logger.warn( - 'No WEBHOOK_SECRET - webhook signature verification is disabled (not needed for polling-only mode)' - ); + logger.warn('No WEBHOOK_SECRET - webhook signature verification is disabled'); } const app = createApp(config); @@ -108,7 +105,7 @@ function serve() { .filter(([, v]) => v.enabled) .map(([k]) => k); - const server = app.listen(port, async () => { + const server = app.listen(port, () => { logger.info(`Troutbot listening on port ${port}`); logger.info(`Enabled backends: ${enabledBackends.join(', ')}`); @@ -141,9 +138,6 @@ 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 deleted file mode 100644 index 090dce6..0000000 --- a/src/polling.ts +++ /dev/null @@ -1,223 +0,0 @@ -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 b0cc53d..b97e3b0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,12 +1,11 @@ import crypto from 'node:crypto'; import express from 'express'; import rateLimit from 'express-rate-limit'; -import type { Config, WebhookEvent } from './types.js'; +import type { Config, WebhookEvent, AnalysisResult } from './types.js'; import { shouldProcess } from './filters.js'; import { createEngine } from './engine/index.js'; import { fetchPR, - fetchIssue, formatComment, hasExistingComment, postComment, @@ -97,21 +96,6 @@ 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; @@ -257,77 +241,6 @@ 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 ab07dff..a84bd30 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,13 +5,6 @@ export interface Config { engine: EngineConfig; response: ResponseConfig; logging: LoggingConfig; - polling?: PollingConfig; -} - -export interface PollingConfig { - enabled: boolean; - intervalMinutes: number; - lookbackMinutes: number; } export interface ServerConfig {