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
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;
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 {