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 {