chore: format with prettier

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib25c8bc022433f1dff87e9f6aeff4a726a6a6964
This commit is contained in:
raf 2026-02-07 16:14:47 +03:00
commit cfb114e529
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
12 changed files with 437 additions and 462 deletions

View file

@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from 'node:fs';
import path from "node:path"; import path from 'node:path';
import dotenv from "dotenv"; import dotenv from 'dotenv';
import type { Config } from "./types.js"; import type { Config } from './types.js';
// Suppress dotenv warnings // Suppress dotenv warnings
dotenv.config({ quiet: true }); dotenv.config({ quiet: true });
@ -32,34 +32,31 @@ const defaults: Config = {
includeReasoning: true, includeReasoning: true,
messages: { messages: {
positive: [ positive: [
"This {type} looks great for the trout! All signals point upstream.", 'This {type} looks great for the trout! All signals point upstream.',
"The trout approve of this {type}. Swim on!", 'The trout approve of this {type}. Swim on!',
"Splashing good news - this {type} is looking healthy.", 'Splashing good news - this {type} is looking healthy.',
], ],
negative: [ negative: [
"This {type} is muddying the waters. The trout are concerned.", 'This {type} is muddying the waters. The trout are concerned.',
"Warning: the trout sense trouble in this {type}.", 'Warning: the trout sense trouble in this {type}.',
"Something smells fishy about this {type}. Please review.", 'Something smells fishy about this {type}. Please review.',
], ],
neutral: [ neutral: [
"The trout have no strong feelings about this {type}.", 'The trout have no strong feelings about this {type}.',
"This {type} is neither upstream nor downstream. Neutral waters.", 'This {type} is neither upstream nor downstream. Neutral waters.',
"The trout are watching this {type} with mild interest.", 'The trout are watching this {type} with mild interest.',
], ],
}, },
commentMarker: "<!-- troutbot -->", commentMarker: '<!-- troutbot -->',
allowUpdates: false, allowUpdates: false,
}, },
logging: { logging: {
level: "info", level: 'info',
file: "troutbot.log", file: 'troutbot.log',
}, },
}; };
export function deepMerge<T extends Record<string, unknown>>( export function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
target: T,
source: Partial<T>,
): T {
const result = { ...target }; const result = { ...target };
for (const key of Object.keys(source) as (keyof T)[]) { for (const key of Object.keys(source) as (keyof T)[]) {
const sourceVal = source[key]; const sourceVal = source[key];
@ -67,15 +64,15 @@ export function deepMerge<T extends Record<string, unknown>>(
if ( if (
sourceVal !== null && sourceVal !== null &&
sourceVal !== undefined && sourceVal !== undefined &&
typeof sourceVal === "object" && typeof sourceVal === 'object' &&
!Array.isArray(sourceVal) && !Array.isArray(sourceVal) &&
typeof targetVal === "object" && typeof targetVal === 'object' &&
!Array.isArray(targetVal) && !Array.isArray(targetVal) &&
targetVal !== null targetVal !== null
) { ) {
result[key] = deepMerge( result[key] = deepMerge(
targetVal as Record<string, unknown>, targetVal as Record<string, unknown>,
sourceVal as Record<string, unknown>, sourceVal as Record<string, unknown>
) as T[keyof T]; ) as T[keyof T];
} else if (sourceVal !== undefined) { } else if (sourceVal !== undefined) {
result[key] = sourceVal as T[keyof T]; result[key] = sourceVal as T[keyof T];
@ -85,34 +82,29 @@ export function deepMerge<T extends Record<string, unknown>>(
} }
export async function loadConfig(): Promise<Config> { export async function loadConfig(): Promise<Config> {
const configPath = process.env.CONFIG_PATH || "config.ts"; const configPath = process.env.CONFIG_PATH || 'config.ts';
const resolvedPath = path.resolve(configPath); const resolvedPath = path.resolve(configPath);
let fileConfig: Partial<Config> = {}; let fileConfig: Partial<Config> = {};
if (fs.existsSync(resolvedPath)) { if (fs.existsSync(resolvedPath)) {
const loaded = await import(resolvedPath); const loaded = await import(resolvedPath);
fileConfig = fileConfig = 'default' in loaded ? loaded.default : (loaded as unknown as Partial<Config>);
"default" in loaded
? loaded.default
: (loaded as unknown as Partial<Config>);
} else if (process.env.CONFIG_PATH) { } else if (process.env.CONFIG_PATH) {
console.warn( console.warn(
`Warning: CONFIG_PATH is set to "${process.env.CONFIG_PATH}" but file not found at ${resolvedPath}`, `Warning: CONFIG_PATH is set to "${process.env.CONFIG_PATH}" but file not found at ${resolvedPath}`
); );
} }
const config = deepMerge( const config = deepMerge(
defaults as unknown as Record<string, unknown>, defaults as unknown as Record<string, unknown>,
fileConfig as unknown as Record<string, unknown>, fileConfig as unknown as Record<string, unknown>
) as unknown as Config; ) as unknown as Config;
// Environment variable overrides // Environment variable overrides
if (process.env.PORT) { if (process.env.PORT) {
const parsed = parseInt(process.env.PORT, 10); const parsed = parseInt(process.env.PORT, 10);
if (Number.isNaN(parsed)) { if (Number.isNaN(parsed)) {
throw new Error( throw new Error(`Invalid PORT value: "${process.env.PORT}" is not a number`);
`Invalid PORT value: "${process.env.PORT}" is not a number`,
);
} }
config.server.port = parsed; config.server.port = parsed;
} }
@ -125,7 +117,7 @@ export async function loadConfig(): Promise<Config> {
config.dashboard = { config.dashboard = {
...(config.dashboard || { enabled: true }), ...(config.dashboard || { enabled: true }),
auth: { auth: {
type: "token", type: 'token',
token: process.env.DASHBOARD_TOKEN, token: process.env.DASHBOARD_TOKEN,
}, },
}; };
@ -135,18 +127,18 @@ export async function loadConfig(): Promise<Config> {
config.dashboard = { config.dashboard = {
...(config.dashboard || { enabled: true }), ...(config.dashboard || { enabled: true }),
auth: { auth: {
type: "basic", type: 'basic',
username: process.env.DASHBOARD_USERNAME, username: process.env.DASHBOARD_USERNAME,
password: process.env.DASHBOARD_PASSWORD, password: process.env.DASHBOARD_PASSWORD,
}, },
}; };
} }
const validLogLevels = ["debug", "info", "warn", "error"]; const validLogLevels = ['debug', 'info', 'warn', 'error'];
if (process.env.LOG_LEVEL) { if (process.env.LOG_LEVEL) {
if (!validLogLevels.includes(process.env.LOG_LEVEL)) { if (!validLogLevels.includes(process.env.LOG_LEVEL)) {
throw new Error( throw new Error(
`Invalid LOG_LEVEL: "${process.env.LOG_LEVEL}". Must be one of: ${validLogLevels.join(", ")}`, `Invalid LOG_LEVEL: "${process.env.LOG_LEVEL}". Must be one of: ${validLogLevels.join(', ')}`
); );
} }
config.logging.level = process.env.LOG_LEVEL; config.logging.level = process.env.LOG_LEVEL;
@ -157,36 +149,23 @@ export async function loadConfig(): Promise<Config> {
} }
export function validate(config: Config): void { export function validate(config: Config): void {
if ( if (!config.server.port || config.server.port < 1 || config.server.port > 65535) {
!config.server.port || throw new Error('Invalid server port');
config.server.port < 1 ||
config.server.port > 65535
) {
throw new Error("Invalid server port");
} }
const { backends } = config.engine; const { backends } = config.engine;
if ( if (!backends.checks.enabled && !backends.diff.enabled && !backends.quality.enabled) {
!backends.checks.enabled && throw new Error('At least one engine backend must be enabled');
!backends.diff.enabled &&
!backends.quality.enabled
) {
throw new Error("At least one engine backend must be enabled");
} }
const { weights } = config.engine; const { weights } = config.engine;
for (const [key, value] of Object.entries(weights)) { for (const [key, value] of Object.entries(weights)) {
if (value < 0) { if (value < 0) {
throw new Error( throw new Error(`Backend weight "${key}" must be non-negative, got ${value}`);
`Backend weight "${key}" must be non-negative, got ${value}`,
);
} }
} }
if ( if (config.engine.confidenceThreshold < 0 || config.engine.confidenceThreshold > 1) {
config.engine.confidenceThreshold < 0 || throw new Error('confidenceThreshold must be between 0 and 1');
config.engine.confidenceThreshold > 1
) {
throw new Error("confidenceThreshold must be between 0 and 1");
} }
} }

View file

@ -35,7 +35,11 @@ export class ChecksBackend implements EngineBackend {
async analyze(event: WebhookEvent): Promise<AnalysisResult> { async analyze(event: WebhookEvent): Promise<AnalysisResult> {
if (event.type !== 'pull_request' || !event.sha) { if (event.type !== 'pull_request' || !event.sha) {
return { impact: 'neutral', confidence: 0, reasoning: 'Not a PR or no SHA available.' }; return {
impact: 'neutral',
confidence: 0,
reasoning: 'Not a PR or no SHA available.',
};
} }
let runs; let runs;
@ -46,11 +50,19 @@ export class ChecksBackend implements EngineBackend {
`Failed to fetch check runs for ${event.owner}/${event.repo}@${event.sha}`, `Failed to fetch check runs for ${event.owner}/${event.repo}@${event.sha}`,
err err
); );
return { impact: 'neutral', confidence: 0, reasoning: 'Could not fetch CI check results.' }; return {
impact: 'neutral',
confidence: 0,
reasoning: 'Could not fetch CI check results.',
};
} }
if (runs.length === 0) { if (runs.length === 0) {
return { impact: 'neutral', confidence: 0, reasoning: 'No CI checks found.' }; return {
impact: 'neutral',
confidence: 0,
reasoning: 'No CI checks found.',
};
} }
const completed = runs.filter((r) => r.status === 'completed'); const completed = runs.filter((r) => r.status === 'completed');
@ -75,7 +87,11 @@ export class ChecksBackend implements EngineBackend {
const actionable = completed.length - skipped.length; const actionable = completed.length - skipped.length;
if (actionable === 0) { if (actionable === 0) {
return { impact: 'neutral', confidence: 0.2, reasoning: 'All CI checks were skipped.' }; return {
impact: 'neutral',
confidence: 0.2,
reasoning: 'All CI checks were skipped.',
};
} }
// Classify failures by severity // Classify failures by severity

View file

@ -1,6 +1,6 @@
import type { AnalysisResult, WebhookEvent } from "../types.js"; import type { AnalysisResult, WebhookEvent } from '../types.js';
import { readFileSync, writeFileSync, existsSync } from "fs"; import { readFileSync, writeFileSync, existsSync } from 'fs';
import { getLogger } from "../logger.js"; import { getLogger } from '../logger.js';
// Author reputation tracking // Author reputation tracking
interface AuthorStats { interface AuthorStats {
@ -49,7 +49,7 @@ let contextData: ContextData = createDefaultContext();
let contextFile: string | null = null; let contextFile: string | null = null;
export function initContext(stateFile?: string): void { export function initContext(stateFile?: string): void {
contextFile = stateFile || ".troutbot-context.json"; contextFile = stateFile || '.troutbot-context.json';
loadContext(); loadContext();
} }
@ -60,24 +60,24 @@ function loadContext(): void {
} }
try { try {
const data = readFileSync(contextFile, "utf-8"); const data = readFileSync(contextFile, 'utf-8');
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
// Validate structure // Validate structure
if ( if (
parsed && parsed &&
typeof parsed === "object" && typeof parsed === 'object' &&
parsed.globalStats?.version === CONTEXT_VERSION && parsed.globalStats?.version === CONTEXT_VERSION &&
typeof parsed.authors === "object" && typeof parsed.authors === 'object' &&
parsed.authors !== null && parsed.authors !== null &&
!Array.isArray(parsed.authors) && !Array.isArray(parsed.authors) &&
typeof parsed.repositories === "object" && typeof parsed.repositories === 'object' &&
parsed.repositories !== null && parsed.repositories !== null &&
!Array.isArray(parsed.repositories) !Array.isArray(parsed.repositories)
) { ) {
contextData = parsed; contextData = parsed;
} else { } else {
getLogger().warn("Invalid context format, resetting"); getLogger().warn('Invalid context format, resetting');
contextData = createDefaultContext(); contextData = createDefaultContext();
} }
} catch { } catch {
@ -91,14 +91,11 @@ function saveContext(): void {
try { try {
writeFileSync(contextFile, JSON.stringify(contextData, null, 2)); writeFileSync(contextFile, JSON.stringify(contextData, null, 2));
} catch (err) { } catch (err) {
getLogger().warn("Failed to save context", err); getLogger().warn('Failed to save context', err);
} }
} }
export function updateContext( export function updateContext(event: WebhookEvent, result: AnalysisResult): void {
event: WebhookEvent,
result: AnalysisResult,
): void {
const author = event.author; const author = event.author;
const repo = `${event.owner}/${event.repo}`; const repo = `${event.owner}/${event.repo}`;
@ -118,14 +115,13 @@ export function updateContext(
authorStats.totalContributions++; authorStats.totalContributions++;
authorStats.lastSeen = new Date().toISOString(); authorStats.lastSeen = new Date().toISOString();
if (result.impact === "positive") authorStats.positiveImpacts++; if (result.impact === 'positive') authorStats.positiveImpacts++;
else if (result.impact === "negative") authorStats.negativeImpacts++; else if (result.impact === 'negative') authorStats.negativeImpacts++;
else authorStats.neutralImpacts++; else authorStats.neutralImpacts++;
// Update running average confidence // Update running average confidence
authorStats.averageConfidence = authorStats.averageConfidence =
(authorStats.averageConfidence * (authorStats.totalContributions - 1) + (authorStats.averageConfidence * (authorStats.totalContributions - 1) + result.confidence) /
result.confidence) /
authorStats.totalContributions; authorStats.totalContributions;
// Update repo patterns (simplified) // Update repo patterns (simplified)
@ -168,25 +164,23 @@ export function getAuthorReputation(author: string): {
isTrusted: false, isTrusted: false,
isNew: true, isNew: true,
reputation: 0, reputation: 0,
history: "First-time contributor", history: 'First-time contributor',
}; };
} }
const successRate = const successRate =
stats.totalContributions > 0 stats.totalContributions > 0 ? stats.positiveImpacts / stats.totalContributions : 0;
? stats.positiveImpacts / stats.totalContributions
: 0;
const reputation = Math.min( const reputation = Math.min(
1, 1,
successRate * 0.6 + successRate * 0.6 +
(Math.min(stats.totalContributions, 20) / 20) * 0.3 + (Math.min(stats.totalContributions, 20) / 20) * 0.3 +
stats.averageConfidence * 0.1, stats.averageConfidence * 0.1
); );
let history: string; let history: string;
if (stats.totalContributions === 1) { if (stats.totalContributions === 1) {
history = "1 contribution"; history = '1 contribution';
} else if (stats.totalContributions < 5) { } else if (stats.totalContributions < 5) {
history = `${stats.totalContributions} contributions, ${(successRate * 100).toFixed(0)}% positive`; history = `${stats.totalContributions} contributions, ${(successRate * 100).toFixed(0)}% positive`;
} else { } else {
@ -203,7 +197,7 @@ export function getAuthorReputation(author: string): {
export function getRepoContext( export function getRepoContext(
owner: string, owner: string,
repo: string, repo: string
): { ): {
isActive: boolean; isActive: boolean;
communitySize: number; communitySize: number;
@ -216,7 +210,7 @@ export function getRepoContext(
return { return {
isActive: false, isActive: false,
communitySize: 0, communitySize: 0,
maturity: "unknown", maturity: 'unknown',
}; };
} }
@ -224,13 +218,13 @@ export function getRepoContext(
let maturity: string; let maturity: string;
if (contextData.globalStats.totalAnalyses < 10) { if (contextData.globalStats.totalAnalyses < 10) {
maturity = "new"; maturity = 'new';
} else if (communitySize < 3) { } else if (communitySize < 3) {
maturity = "small-team"; maturity = 'small-team';
} else if (communitySize < 10) { } else if (communitySize < 10) {
maturity = "growing"; maturity = 'growing';
} else { } else {
maturity = "established"; maturity = 'established';
} }
return { return {
@ -242,7 +236,7 @@ export function getRepoContext(
export function getContextualInsights( export function getContextualInsights(
event: WebhookEvent, event: WebhookEvent,
backendResults: Record<string, AnalysisResult>, backendResults: Record<string, AnalysisResult>
): string[] { ): string[] {
const insights: string[] = []; const insights: string[] = [];
const authorRep = getAuthorReputation(event.author); const authorRep = getAuthorReputation(event.author);
@ -250,38 +244,30 @@ export function getContextualInsights(
// Author-based insights // Author-based insights
if (authorRep.isNew) { if (authorRep.isNew) {
insights.push( insights.push(`Welcome ${event.author}! This appears to be your first contribution.`);
`Welcome ${event.author}! This appears to be your first contribution.`,
);
} else if (authorRep.isTrusted) { } else if (authorRep.isTrusted) {
insights.push( insights.push(`${event.author} is a trusted contributor with ${authorRep.history}.`);
`${event.author} is a trusted contributor with ${authorRep.history}.`,
);
} else if (authorRep.reputation < 0.3) { } else if (authorRep.reputation < 0.3) {
insights.push( insights.push(`${event.author} has had mixed results recently (${authorRep.history}).`);
`${event.author} has had mixed results recently (${authorRep.history}).`,
);
} }
// Repo-based insights // Repo-based insights
if (repoCtx.maturity === "new") { if (repoCtx.maturity === 'new') {
insights.push("This repository is still building up analysis history."); insights.push('This repository is still building up analysis history.');
} }
// Cross-backend pattern detection // Cross-backend pattern detection
const impacts = Object.values(backendResults).map((r) => r.impact); const impacts = Object.values(backendResults).map((r) => r.impact);
const allPositive = impacts.every((i) => i === "positive"); const allPositive = impacts.every((i) => i === 'positive');
const allNegative = impacts.every((i) => i === "negative"); const allNegative = impacts.every((i) => i === 'negative');
const mixed = new Set(impacts).size > 1; const mixed = new Set(impacts).size > 1;
if (allPositive && impacts.length >= 2) { if (allPositive && impacts.length >= 2) {
insights.push("All analysis backends agree: this looks solid."); insights.push('All analysis backends agree: this looks solid.');
} else if (allNegative && impacts.length >= 2) { } else if (allNegative && impacts.length >= 2) {
insights.push("Multiple concerns detected across different dimensions."); insights.push('Multiple concerns detected across different dimensions.');
} else if (mixed) { } else if (mixed) {
insights.push( insights.push('Mixed signals - some aspects look good, others need attention.');
"Mixed signals - some aspects look good, others need attention.",
);
} }
return insights; return insights;

View file

@ -16,7 +16,12 @@ const RISKY_FILE_PATTERN =
const DOC_FILE_PATTERN = /\.(md|mdx|txt|rst|adoc)$|^(README|CHANGELOG|LICENSE|CONTRIBUTING)/i; const DOC_FILE_PATTERN = /\.(md|mdx|txt|rst|adoc)$|^(README|CHANGELOG|LICENSE|CONTRIBUTING)/i;
function categorizeFiles( function categorizeFiles(
files: { filename: string; additions: number; deletions: number; changes: number }[] files: {
filename: string;
additions: number;
deletions: number;
changes: number;
}[]
) { ) {
const src: typeof files = []; const src: typeof files = [];
const tests: typeof files = []; const tests: typeof files = [];
@ -64,7 +69,11 @@ export class DiffBackend implements EngineBackend {
`Failed to fetch PR files for ${event.owner}/${event.repo}#${event.number}`, `Failed to fetch PR files for ${event.owner}/${event.repo}#${event.number}`,
err err
); );
return { impact: 'neutral', confidence: 0, reasoning: 'Could not fetch PR diff.' }; return {
impact: 'neutral',
confidence: 0,
reasoning: 'Could not fetch PR diff.',
};
} }
if (files.length === 0) { if (files.length === 0) {
@ -89,7 +98,11 @@ export class DiffBackend implements EngineBackend {
} else if (totalChanges <= 500) { } else if (totalChanges <= 500) {
// medium - no signal either way // medium - no signal either way
} else if (totalChanges <= this.config.maxChanges) { } else if (totalChanges <= this.config.maxChanges) {
signals.push({ name: `large PR (${totalChanges} lines)`, positive: false, weight: 0.8 }); signals.push({
name: `large PR (${totalChanges} lines)`,
positive: false,
weight: 0.8,
});
} else { } else {
signals.push({ signals.push({
name: `very large PR (${totalChanges} lines, exceeds limit)`, name: `very large PR (${totalChanges} lines, exceeds limit)`,
@ -121,21 +134,33 @@ export class DiffBackend implements EngineBackend {
if (tests.length > 0 && src.length > 0) { if (tests.length > 0 && src.length > 0) {
const testRatio = tests.length / src.length; const testRatio = tests.length / src.length;
if (testRatio >= 0.5) { if (testRatio >= 0.5) {
signals.push({ name: 'good test coverage in diff', positive: true, weight: 1.5 }); signals.push({
name: 'good test coverage in diff',
positive: true,
weight: 1.5,
});
} else { } else {
signals.push({ name: 'includes tests', positive: true, weight: 1 }); signals.push({ name: 'includes tests', positive: true, weight: 1 });
} }
} else if (tests.length > 0 && src.length === 0) { } else if (tests.length > 0 && src.length === 0) {
signals.push({ name: 'test-only change', positive: true, weight: 1.2 }); signals.push({ name: 'test-only change', positive: true, weight: 1.2 });
} else if (this.config.requireTests && src.length > 0 && totalChanges > 50) { } else if (this.config.requireTests && src.length > 0 && totalChanges > 50) {
signals.push({ name: 'no test changes for non-trivial PR', positive: false, weight: 1.3 }); signals.push({
name: 'no test changes for non-trivial PR',
positive: false,
weight: 1.3,
});
} }
// --- Net deletion --- // --- Net deletion ---
if (totalDeletions > totalAdditions && totalDeletions > 10) { if (totalDeletions > totalAdditions && totalDeletions > 10) {
const ratio = totalDeletions / Math.max(totalAdditions, 1); const ratio = totalDeletions / Math.max(totalAdditions, 1);
if (ratio > 3) { if (ratio > 3) {
signals.push({ name: 'significant code removal', positive: true, weight: 1.3 }); signals.push({
name: 'significant code removal',
positive: true,
weight: 1.3,
});
} else { } else {
signals.push({ name: 'net code removal', positive: true, weight: 1 }); signals.push({ name: 'net code removal', positive: true, weight: 1 });
} }
@ -167,7 +192,11 @@ export class DiffBackend implements EngineBackend {
// --- Documentation --- // --- Documentation ---
if (docs.length > 0 && src.length > 0) { if (docs.length > 0 && src.length > 0) {
signals.push({ name: 'includes docs updates', positive: true, weight: 0.6 }); signals.push({
name: 'includes docs updates',
positive: true,
weight: 0.6,
});
} else if (docs.length > 0 && src.length === 0) { } else if (docs.length > 0 && src.length === 0) {
signals.push({ name: 'docs-only change', positive: true, weight: 1 }); signals.push({ name: 'docs-only change', positive: true, weight: 1 });
} }
@ -181,7 +210,11 @@ export class DiffBackend implements EngineBackend {
if (generated.length > 0) { if (generated.length > 0) {
const genChanges = generated.reduce((s, f) => s + f.changes, 0); const genChanges = generated.reduce((s, f) => s + f.changes, 0);
if (genChanges > totalChanges * 2) { if (genChanges > totalChanges * 2) {
signals.push({ name: 'dominated by generated file changes', positive: false, weight: 0.4 }); signals.push({
name: 'dominated by generated file changes',
positive: false,
weight: 0.4,
});
} }
} }

View file

@ -4,17 +4,12 @@ import type {
EngineConfig, EngineConfig,
Impact, Impact,
WebhookEvent, WebhookEvent,
} from "../types.js"; } from '../types.js';
import { ChecksBackend } from "./checks.js"; import { ChecksBackend } from './checks.js';
import { DiffBackend } from "./diff.js"; import { DiffBackend } from './diff.js';
import { QualityBackend } from "./quality.js"; import { QualityBackend } from './quality.js';
import { getLogger } from "../logger.js"; import { getLogger } from '../logger.js';
import { import { initContext, updateContext, getAuthorReputation, getRepoContext } from './context.js';
initContext,
updateContext,
getAuthorReputation,
getRepoContext,
} from "./context.js";
interface WeightedBackend { interface WeightedBackend {
backend: EngineBackend; backend: EngineBackend;
@ -56,7 +51,7 @@ export class Engine {
} }
if (this.backends.length === 0) { if (this.backends.length === 0) {
throw new Error("No engine backends enabled"); throw new Error('No engine backends enabled');
} }
} }
@ -79,7 +74,7 @@ export class Engine {
logger.debug( logger.debug(
`Analyzing ${event.type} #${event.number} by ${event.author} ` + `Analyzing ${event.type} #${event.number} by ${event.author} ` +
`(reputation: ${(authorRep.reputation * 100).toFixed(0)}%, repo: ${repoCtx.maturity})`, `(reputation: ${(authorRep.reputation * 100).toFixed(0)}%, repo: ${repoCtx.maturity})`
); );
// Run all backends // Run all backends
@ -89,7 +84,7 @@ export class Engine {
try { try {
const result = await backend.analyze(event); const result = await backend.analyze(event);
logger.debug( logger.debug(
`Backend "${backend.name}": impact=${result.impact}, confidence=${result.confidence.toFixed(2)}`, `Backend "${backend.name}": impact=${result.impact}, confidence=${result.confidence.toFixed(2)}`
); );
return { backend: backend.name, result, weight }; return { backend: backend.name, result, weight };
} catch (err) { } catch (err) {
@ -97,14 +92,14 @@ export class Engine {
return { return {
backend: backend.name, backend: backend.name,
result: { result: {
impact: "neutral" as Impact, impact: 'neutral' as Impact,
confidence: 0, confidence: 0,
reasoning: `${backend.name}: error`, reasoning: `${backend.name}: error`,
}, },
weight, weight,
}; };
} }
}), })
); );
for (const r of rawResults) { for (const r of rawResults) {
@ -120,7 +115,7 @@ export class Engine {
const active = backendResults.filter((r) => r.confidence > 0); const active = backendResults.filter((r) => r.confidence > 0);
if (active.length === 0) { if (active.length === 0) {
return { return {
impact: "neutral", impact: 'neutral',
confidence: 0, confidence: 0,
reasoning: `Insufficient data: no analysis backends produced signals for ${event.type} #${event.number}.`, reasoning: `Insufficient data: no analysis backends produced signals for ${event.type} #${event.number}.`,
}; };
@ -137,21 +132,21 @@ export class Engine {
active, active,
dimensions, dimensions,
correlations, correlations,
authorRep, authorRep
); );
// Determine impact // Determine impact
let impact: Impact; let impact: Impact;
if (score > 0.2) { if (score > 0.2) {
impact = "positive"; impact = 'positive';
} else if (score < -0.2) { } else if (score < -0.2) {
impact = "negative"; impact = 'negative';
} else { } else {
impact = "neutral"; impact = 'neutral';
} }
if (confidence < this.confidenceThreshold) { if (confidence < this.confidenceThreshold) {
impact = "neutral"; impact = 'neutral';
} }
// Generate analytical reasoning // Generate analytical reasoning
@ -165,7 +160,7 @@ export class Engine {
confidence, confidence,
uncertainty, uncertainty,
authorRep, authorRep,
repoCtx, repoCtx
); );
const result: AnalysisResult = { const result: AnalysisResult = {
@ -181,9 +176,7 @@ export class Engine {
return result; return result;
} }
private calculateDimensions( private calculateDimensions(active: BackendResult[]): NonNullable<AnalysisResult['dimensions']> {
active: BackendResult[],
): NonNullable<AnalysisResult["dimensions"]> {
let correctnessScore = 0; let correctnessScore = 0;
let correctnessWeight = 0; let correctnessWeight = 0;
let riskScore = 0; let riskScore = 0;
@ -194,24 +187,23 @@ export class Engine {
let alignmentWeight = 0; let alignmentWeight = 0;
for (const r of active) { for (const r of active) {
const impactScore = const impactScore = r.impact === 'positive' ? 1 : r.impact === 'negative' ? -1 : 0;
r.impact === "positive" ? 1 : r.impact === "negative" ? -1 : 0;
const weightedImpact = impactScore * r.confidence * r.weight; const weightedImpact = impactScore * r.confidence * r.weight;
switch (r.backend) { switch (r.backend) {
case "checks": case 'checks':
correctnessScore += weightedImpact * 0.7; correctnessScore += weightedImpact * 0.7;
correctnessWeight += r.weight * 0.7; correctnessWeight += r.weight * 0.7;
riskScore += weightedImpact * 0.3; riskScore += weightedImpact * 0.3;
riskWeight += r.weight * 0.3; riskWeight += r.weight * 0.3;
break; break;
case "diff": case 'diff':
maintainabilityScore += weightedImpact * 0.6; maintainabilityScore += weightedImpact * 0.6;
maintainabilityWeight += r.weight * 0.6; maintainabilityWeight += r.weight * 0.6;
riskScore += weightedImpact * 0.4; riskScore += weightedImpact * 0.4;
riskWeight += r.weight * 0.4; riskWeight += r.weight * 0.4;
break; break;
case "quality": case 'quality':
alignmentScore += weightedImpact * 0.7; alignmentScore += weightedImpact * 0.7;
alignmentWeight += r.weight * 0.7; alignmentWeight += r.weight * 0.7;
maintainabilityScore += weightedImpact * 0.3; maintainabilityScore += weightedImpact * 0.3;
@ -221,91 +213,70 @@ export class Engine {
} }
return { return {
correctness: correctness: correctnessWeight > 0 ? correctnessScore / correctnessWeight : 0,
correctnessWeight > 0 ? correctnessScore / correctnessWeight : 0,
risk: riskWeight > 0 ? riskScore / riskWeight : 0, risk: riskWeight > 0 ? riskScore / riskWeight : 0,
maintainability: maintainability: maintainabilityWeight > 0 ? maintainabilityScore / maintainabilityWeight : 0,
maintainabilityWeight > 0
? maintainabilityScore / maintainabilityWeight
: 0,
alignment: alignmentWeight > 0 ? alignmentScore / alignmentWeight : 0, alignment: alignmentWeight > 0 ? alignmentScore / alignmentWeight : 0,
}; };
} }
private detectCorrelations( private detectCorrelations(
allResults: BackendResult[], allResults: BackendResult[],
dimensions: NonNullable<AnalysisResult["dimensions"]>, dimensions: NonNullable<AnalysisResult['dimensions']>
): NonNullable<AnalysisResult["correlations"]> { ): NonNullable<AnalysisResult['correlations']> {
const suspiciousPatterns: string[] = []; const suspiciousPatterns: string[] = [];
const reinforcingSignals: string[] = []; const reinforcingSignals: string[] = [];
const contradictions: string[] = []; const contradictions: string[] = [];
const active = allResults.filter((r) => r.confidence > 0); const active = allResults.filter((r) => r.confidence > 0);
const hasChecks = active.some((r) => r.backend === "checks"); const hasChecks = active.some((r) => r.backend === 'checks');
const hasDiff = active.some((r) => r.backend === "diff"); const hasDiff = active.some((r) => r.backend === 'diff');
const hasQuality = active.some((r) => r.backend === "quality"); const hasQuality = active.some((r) => r.backend === 'quality');
// Check for suspicious patterns // Check for suspicious patterns
if (hasChecks && hasDiff) { if (hasChecks && hasDiff) {
const checksResult = active.find((r) => r.backend === "checks"); const checksResult = active.find((r) => r.backend === 'checks');
const diffResult = active.find((r) => r.backend === "diff"); const diffResult = active.find((r) => r.backend === 'diff');
if ( if (checksResult?.impact === 'positive' && diffResult?.impact === 'negative') {
checksResult?.impact === "positive" && suspiciousPatterns.push('Checks pass but diff analysis shows concerns (untested changes?)');
diffResult?.impact === "negative"
) {
suspiciousPatterns.push(
"Checks pass but diff analysis shows concerns (untested changes?)",
);
} }
if ( if (checksResult?.impact === 'negative' && diffResult?.impact === 'positive') {
checksResult?.impact === "negative" && suspiciousPatterns.push('Clean diff but failing checks (test failures?)');
diffResult?.impact === "positive"
) {
suspiciousPatterns.push(
"Clean diff but failing checks (test failures?)",
);
} }
} }
if (hasDiff && hasQuality) { if (hasDiff && hasQuality) {
const diffResult = active.find((r) => r.backend === "diff"); const diffResult = active.find((r) => r.backend === 'diff');
const qualityResult = active.find((r) => r.backend === "quality"); const qualityResult = active.find((r) => r.backend === 'quality');
if ( if (diffResult?.impact === 'positive' && qualityResult?.impact === 'negative') {
diffResult?.impact === "positive" && suspiciousPatterns.push('Clean code changes but poor description (documentation debt)');
qualityResult?.impact === "negative"
) {
suspiciousPatterns.push(
"Clean code changes but poor description (documentation debt)",
);
} }
} }
// Check for reinforcing signals // Check for reinforcing signals
if (dimensions.correctness > 0.5 && dimensions.maintainability > 0.5) { if (dimensions.correctness > 0.5 && dimensions.maintainability > 0.5) {
reinforcingSignals.push( reinforcingSignals.push('High correctness and maintainability scores align');
"High correctness and maintainability scores align",
);
} }
if (dimensions.risk < -0.5 && dimensions.alignment < -0.3) { if (dimensions.risk < -0.5 && dimensions.alignment < -0.3) {
reinforcingSignals.push("Risk and misalignment indicators converge"); reinforcingSignals.push('Risk and misalignment indicators converge');
} }
// Check for contradictions // Check for contradictions
const positiveCount = active.filter((r) => r.impact === "positive").length; const positiveCount = active.filter((r) => r.impact === 'positive').length;
const negativeCount = active.filter((r) => r.impact === "negative").length; const negativeCount = active.filter((r) => r.impact === 'negative').length;
if (positiveCount > 0 && negativeCount > 0) { if (positiveCount > 0 && negativeCount > 0) {
contradictions.push( contradictions.push(
`Mixed backend signals: ${positiveCount} positive, ${negativeCount} negative`, `Mixed backend signals: ${positiveCount} positive, ${negativeCount} negative`
); );
} }
if (dimensions.correctness > 0.3 && dimensions.risk < -0.3) { if (dimensions.correctness > 0.3 && dimensions.risk < -0.3) {
contradictions.push("Correct implementation but high risk profile"); contradictions.push('Correct implementation but high risk profile');
} }
return { return {
@ -317,30 +288,27 @@ export class Engine {
private calculateOverall( private calculateOverall(
active: BackendResult[], active: BackendResult[],
dimensions: NonNullable<AnalysisResult["dimensions"]>, dimensions: NonNullable<AnalysisResult['dimensions']>,
correlations: NonNullable<AnalysisResult["correlations"]>, correlations: NonNullable<AnalysisResult['correlations']>,
authorRep: ReturnType<typeof getAuthorReputation>, authorRep: ReturnType<typeof getAuthorReputation>
): { ): {
score: number; score: number;
confidence: number; confidence: number;
uncertainty: NonNullable<AnalysisResult["uncertainty"]>; uncertainty: NonNullable<AnalysisResult['uncertainty']>;
} { } {
const totalWeight = active.reduce((s, r) => s + r.weight, 0); const totalWeight = active.reduce((s, r) => s + r.weight, 0);
// Calculate weighted average of individual backend scores // Calculate weighted average of individual backend scores
let baseScore = 0; let baseScore = 0;
for (const r of active) { for (const r of active) {
const impactScore = const impactScore = r.impact === 'positive' ? 1 : r.impact === 'negative' ? -1 : 0;
r.impact === "positive" ? 1 : r.impact === "negative" ? -1 : 0;
baseScore += impactScore * r.confidence * r.weight; baseScore += impactScore * r.confidence * r.weight;
} }
// Guard against division by zero when all weights are 0 // Guard against division by zero when all weights are 0
if (totalWeight === 0) { if (totalWeight === 0) {
baseScore = 0; baseScore = 0;
getLogger().debug( getLogger().debug('All backend weights are zero, defaulting baseScore to 0');
"All backend weights are zero, defaulting baseScore to 0",
);
} else { } else {
baseScore /= totalWeight; baseScore /= totalWeight;
} }
@ -364,12 +332,9 @@ export class Engine {
let baseConfidence = 0; let baseConfidence = 0;
if (totalWeight === 0) { if (totalWeight === 0) {
baseConfidence = 0; baseConfidence = 0;
getLogger().debug( getLogger().debug('All backend weights are zero, defaulting baseConfidence to 0');
"All backend weights are zero, defaulting baseConfidence to 0",
);
} else { } else {
baseConfidence = baseConfidence = active.reduce((s, r) => s + r.confidence * r.weight, 0) / totalWeight;
active.reduce((s, r) => s + r.confidence * r.weight, 0) / totalWeight;
} }
// Adjust confidence based on various factors // Adjust confidence based on various factors
@ -394,13 +359,13 @@ export class Engine {
const upperBound = Math.min(1, baseConfidence + uncertaintyRange * 0.5); const upperBound = Math.min(1, baseConfidence + uncertaintyRange * 0.5);
// Determine primary uncertainty source // Determine primary uncertainty source
let primaryUncertaintySource = "Backend confidence variance"; let primaryUncertaintySource = 'Backend confidence variance';
if (uniqueImpacts.size > 1) { if (uniqueImpacts.size > 1) {
primaryUncertaintySource = "Mixed backend signals"; primaryUncertaintySource = 'Mixed backend signals';
} else if (authorRep.isNew) { } else if (authorRep.isNew) {
primaryUncertaintySource = "Limited author history"; primaryUncertaintySource = 'Limited author history';
} else if (active.length < this.backends.length) { } else if (active.length < this.backends.length) {
primaryUncertaintySource = "Partial backend coverage"; primaryUncertaintySource = 'Partial backend coverage';
} }
return { return {
@ -417,13 +382,13 @@ export class Engine {
event: WebhookEvent, event: WebhookEvent,
_allResults: BackendResult[], _allResults: BackendResult[],
activeResults: BackendResult[], activeResults: BackendResult[],
dimensions: NonNullable<AnalysisResult["dimensions"]>, dimensions: NonNullable<AnalysisResult['dimensions']>,
correlations: NonNullable<AnalysisResult["correlations"]>, correlations: NonNullable<AnalysisResult['correlations']>,
score: number, score: number,
confidence: number, confidence: number,
uncertainty: NonNullable<AnalysisResult["uncertainty"]>, uncertainty: NonNullable<AnalysisResult['uncertainty']>,
authorRep: ReturnType<typeof getAuthorReputation>, authorRep: ReturnType<typeof getAuthorReputation>,
repoCtx: ReturnType<typeof getRepoContext>, repoCtx: ReturnType<typeof getRepoContext>
): string { ): string {
const parts: string[] = []; const parts: string[] = [];
@ -433,20 +398,15 @@ export class Engine {
` Correctness: ${(dimensions.correctness * 100).toFixed(0)}% | ` + ` Correctness: ${(dimensions.correctness * 100).toFixed(0)}% | ` +
`Risk: ${(dimensions.risk * 100).toFixed(0)}% | ` + `Risk: ${(dimensions.risk * 100).toFixed(0)}% | ` +
`Maintainability: ${(dimensions.maintainability * 100).toFixed(0)}% | ` + `Maintainability: ${(dimensions.maintainability * 100).toFixed(0)}% | ` +
`Alignment: ${(dimensions.alignment * 100).toFixed(0)}%`, `Alignment: ${(dimensions.alignment * 100).toFixed(0)}%`
); );
// Backend breakdown // Backend breakdown
parts.push(`\nBackend Results:`); parts.push(`\nBackend Results:`);
for (const r of activeResults) { for (const r of activeResults) {
const icon = const icon = r.impact === 'positive' ? '[+]' : r.impact === 'negative' ? '[-]' : '[~]';
r.impact === "positive"
? "[+]"
: r.impact === "negative"
? "[-]"
: "[~]";
parts.push( parts.push(
` ${icon} ${r.backend}: ${r.impact} (${(r.confidence * 100).toFixed(0)}%) - ${r.reasoning}`, ` ${icon} ${r.backend}: ${r.impact} (${(r.confidence * 100).toFixed(0)}%) - ${r.reasoning}`
); );
} }
@ -481,28 +441,24 @@ export class Engine {
// Context information // Context information
parts.push(`\nContext:`); parts.push(`\nContext:`);
parts.push( parts.push(
` Author: ${event.author} (${authorRep.isNew ? "new" : `reputation: ${(authorRep.reputation * 100).toFixed(0)}%`})`, ` Author: ${event.author} (${authorRep.isNew ? 'new' : `reputation: ${(authorRep.reputation * 100).toFixed(0)}%`})`
);
parts.push(
` Repository: ${repoCtx.maturity} (${repoCtx.communitySize} active contributors)`,
); );
parts.push(` Repository: ${repoCtx.maturity} (${repoCtx.communitySize} active contributors)`);
// Confidence and uncertainty // Confidence and uncertainty
parts.push(`\nConfidence Assessment:`); parts.push(`\nConfidence Assessment:`);
parts.push(` Overall: ${(confidence * 100).toFixed(0)}%`); parts.push(` Overall: ${(confidence * 100).toFixed(0)}%`);
parts.push( parts.push(
` Interval: [${(uncertainty.confidenceInterval[0] * 100).toFixed(0)}%, ${(uncertainty.confidenceInterval[1] * 100).toFixed(0)}%]`, ` Interval: [${(uncertainty.confidenceInterval[0] * 100).toFixed(0)}%, ${(uncertainty.confidenceInterval[1] * 100).toFixed(0)}%]`
);
parts.push(
` Primary uncertainty: ${uncertainty.primaryUncertaintySource}`,
); );
parts.push(` Primary uncertainty: ${uncertainty.primaryUncertaintySource}`);
// Final assessment // Final assessment
parts.push( parts.push(
`\nAssessment: ${score > 0.2 ? "POSITIVE" : score < -0.2 ? "NEGATIVE" : "NEUTRAL"} (score: ${score.toFixed(2)})`, `\nAssessment: ${score > 0.2 ? 'POSITIVE' : score < -0.2 ? 'NEGATIVE' : 'NEUTRAL'} (score: ${score.toFixed(2)})`
); );
return parts.join("\n"); return parts.join('\n');
} }
} }

View file

@ -28,15 +28,27 @@ export class QualityBackend implements EngineBackend {
if (title.length < 10) { if (title.length < 10) {
signals.push({ name: 'very short title', positive: false, weight: 1.2 }); signals.push({ name: 'very short title', positive: false, weight: 1.2 });
} else if (title.length > 200) { } else if (title.length > 200) {
signals.push({ name: 'excessively long title', positive: false, weight: 0.5 }); signals.push({
name: 'excessively long title',
positive: false,
weight: 0.5,
});
} }
if (CONVENTIONAL_COMMIT.test(title)) { if (CONVENTIONAL_COMMIT.test(title)) {
signals.push({ name: 'conventional commit format', positive: true, weight: 1 }); signals.push({
name: 'conventional commit format',
positive: true,
weight: 1,
});
} }
if (WIP_PATTERN.test(title) || WIP_PATTERN.test(body)) { if (WIP_PATTERN.test(title) || WIP_PATTERN.test(body)) {
signals.push({ name: 'marked as work-in-progress', positive: false, weight: 1.5 }); signals.push({
name: 'marked as work-in-progress',
positive: false,
weight: 1.5,
});
} }
// --- Body analysis --- // --- Body analysis ---
@ -52,7 +64,11 @@ export class QualityBackend implements EngineBackend {
} else if (body.length >= this.config.minBodyLength) { } else if (body.length >= this.config.minBodyLength) {
signals.push({ name: 'adequate description', positive: true, weight: 1 }); signals.push({ name: 'adequate description', positive: true, weight: 1 });
if (body.length > 300) { if (body.length > 300) {
signals.push({ name: 'thorough description', positive: true, weight: 0.5 }); signals.push({
name: 'thorough description',
positive: true,
weight: 0.5,
});
} }
} }
@ -61,7 +77,11 @@ export class QualityBackend implements EngineBackend {
} }
if (/^#{1,6}\s/m.test(body)) { if (/^#{1,6}\s/m.test(body)) {
signals.push({ name: 'has section headers', positive: true, weight: 0.8 }); signals.push({
name: 'has section headers',
positive: true,
weight: 0.8,
});
} }
// Checklists // Checklists
@ -70,7 +90,11 @@ export class QualityBackend implements EngineBackend {
const checked = checklistItems.filter((i) => /\[x\]/i.test(i)).length; const checked = checklistItems.filter((i) => /\[x\]/i.test(i)).length;
const total = checklistItems.length; const total = checklistItems.length;
if (total > 0 && checked === total) { if (total > 0 && checked === total) {
signals.push({ name: `checklist complete (${total}/${total})`, positive: true, weight: 1 }); signals.push({
name: `checklist complete (${total}/${total})`,
positive: true,
weight: 1,
});
} else if (total > 0) { } else if (total > 0) {
signals.push({ signals.push({
name: `checklist incomplete (${checked}/${total})`, name: `checklist incomplete (${checked}/${total})`,
@ -85,7 +109,11 @@ export class QualityBackend implements EngineBackend {
// Not inherently positive or negative, but we flag it for visibility. // Not inherently positive or negative, but we flag it for visibility.
// If there's a description of the breaking change, it's better. // If there's a description of the breaking change, it's better.
if (body.length > 100 && BREAKING_PATTERN.test(body)) { if (body.length > 100 && BREAKING_PATTERN.test(body)) {
signals.push({ name: 'breaking change documented', positive: true, weight: 0.8 }); signals.push({
name: 'breaking change documented',
positive: true,
weight: 0.8,
});
} else { } else {
signals.push({ signals.push({
name: 'breaking change mentioned but not detailed', name: 'breaking change mentioned but not detailed',
@ -109,26 +137,46 @@ export class QualityBackend implements EngineBackend {
if (event.type === 'issue') { if (event.type === 'issue') {
if (/\b(steps?\s+to\s+reproduce|reproduction|repro\s+steps?)\b/i.test(body)) { if (/\b(steps?\s+to\s+reproduce|reproduction|repro\s+steps?)\b/i.test(body)) {
signals.push({ name: 'has reproduction steps', positive: true, weight: 1.3 }); signals.push({
name: 'has reproduction steps',
positive: true,
weight: 1.3,
});
} }
if (/\b(expected|actual)\s+(behavior|behaviour|result|output)\b/i.test(body)) { if (/\b(expected|actual)\s+(behavior|behaviour|result|output)\b/i.test(body)) {
signals.push({ name: 'has expected/actual behavior', positive: true, weight: 1.2 }); signals.push({
name: 'has expected/actual behavior',
positive: true,
weight: 1.2,
});
} }
if ( if (
/\b(version|environment|os|platform|browser|node|python|java|rust|go)\s*[:\d]/i.test(body) /\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 }); signals.push({
name: 'has environment details',
positive: true,
weight: 1,
});
} }
if (/\b(stack\s*trace|traceback|error|exception|panic)\b/i.test(body)) { if (/\b(stack\s*trace|traceback|error|exception|panic)\b/i.test(body)) {
signals.push({ name: 'includes error output', positive: true, weight: 0.8 }); signals.push({
name: 'includes error output',
positive: true,
weight: 0.8,
});
} }
// Template usage detection (common issue template markers) // Template usage detection (common issue template markers)
if (/\b(describe the bug|feature request|is your feature request related to)\b/i.test(body)) { if (/\b(describe the bug|feature request|is your feature request related to)\b/i.test(body)) {
signals.push({ name: 'uses issue template', positive: true, weight: 0.6 }); signals.push({
name: 'uses issue template',
positive: true,
weight: 0.6,
});
} }
} }
@ -143,14 +191,22 @@ export class QualityBackend implements EngineBackend {
// Migration or upgrade guide // Migration or upgrade guide
if (/\b(migration|upgrade|breaking).*(guide|instruction|step)/i.test(body)) { if (/\b(migration|upgrade|breaking).*(guide|instruction|step)/i.test(body)) {
signals.push({ name: 'has migration guide', positive: true, weight: 1 }); signals.push({
name: 'has migration guide',
positive: true,
weight: 1,
});
} }
// Before/after comparison // Before/after comparison
if (/\b(before|after)\b/i.test(body) && /\b(before|after)\b/gi.test(body)) { if (/\b(before|after)\b/i.test(body) && /\b(before|after)\b/gi.test(body)) {
const beforeAfter = body.match(/\b(before|after)\b/gi); const beforeAfter = body.match(/\b(before|after)\b/gi);
if (beforeAfter && beforeAfter.length >= 2) { if (beforeAfter && beforeAfter.length >= 2) {
signals.push({ name: 'has before/after comparison', positive: true, weight: 0.7 }); signals.push({
name: 'has before/after comparison',
positive: true,
weight: 0.7,
});
} }
} }
} }
@ -167,13 +223,21 @@ export class QualityBackend implements EngineBackend {
// Screenshots or images // Screenshots or images
if (/!\[.*\]\(.*\)/.test(body) || /<img\s/i.test(body)) { if (/!\[.*\]\(.*\)/.test(body) || /<img\s/i.test(body)) {
signals.push({ name: 'has images/screenshots', positive: true, weight: 0.8 }); signals.push({
name: 'has images/screenshots',
positive: true,
weight: 0.8,
});
} }
// --- Weighted scoring --- // --- Weighted scoring ---
if (signals.length === 0) { if (signals.length === 0) {
return { impact: 'neutral', confidence: 0.1, reasoning: 'No quality signals detected.' }; return {
impact: 'neutral',
confidence: 0.1,
reasoning: 'No quality signals detected.',
};
} }
const positiveWeight = signals.filter((s) => s.positive).reduce((s, x) => s + x.weight, 0); const positiveWeight = signals.filter((s) => s.positive).reduce((s, x) => s + x.weight, 0);

View file

@ -1,53 +1,45 @@
import type { FiltersConfig, WebhookEvent } from "./types.js"; import type { FiltersConfig, WebhookEvent } from './types.js';
export function shouldProcess( export function shouldProcess(
event: WebhookEvent, event: WebhookEvent,
filters: FiltersConfig, filters: FiltersConfig
): { pass: boolean; reason?: string } { ): { pass: boolean; reason?: string } {
// Label filters // Label filters
if (filters.labels.include.length > 0) { if (filters.labels.include.length > 0) {
const hasRequired = event.labels.some((l) => const hasRequired = event.labels.some((l) => filters.labels.include.includes(l));
filters.labels.include.includes(l),
);
if (!hasRequired) { if (!hasRequired) {
return { pass: false, reason: "Missing required label" }; return { pass: false, reason: 'Missing required label' };
} }
} }
if (filters.labels.exclude.length > 0) { if (filters.labels.exclude.length > 0) {
const hasExcluded = event.labels.some((l) => const hasExcluded = event.labels.some((l) => filters.labels.exclude.includes(l));
filters.labels.exclude.includes(l),
);
if (hasExcluded) { if (hasExcluded) {
return { pass: false, reason: "Has excluded label" }; return { pass: false, reason: 'Has excluded label' };
} }
} }
// Author filters // Author filters
if (filters.authors.include && filters.authors.include.length > 0) { if (filters.authors.include && filters.authors.include.length > 0) {
const normalizedAuthor = event.author.toLowerCase(); const normalizedAuthor = event.author.toLowerCase();
const hasIncluded = filters.authors.include.some( const hasIncluded = filters.authors.include.some((a) => a.toLowerCase() === normalizedAuthor);
(a) => a.toLowerCase() === normalizedAuthor,
);
if (!hasIncluded) { if (!hasIncluded) {
return { pass: false, reason: "Author not in include list" }; return { pass: false, reason: 'Author not in include list' };
} }
} }
if (filters.authors.exclude.length > 0) { if (filters.authors.exclude.length > 0) {
const normalizedAuthor = event.author.toLowerCase(); const normalizedAuthor = event.author.toLowerCase();
const isExcluded = filters.authors.exclude.some( const isExcluded = filters.authors.exclude.some((a) => a.toLowerCase() === normalizedAuthor);
(a) => a.toLowerCase() === normalizedAuthor,
);
if (isExcluded) { if (isExcluded) {
return { pass: false, reason: "Author is excluded" }; return { pass: false, reason: 'Author is excluded' };
} }
} }
// Branch filters (PRs only) // Branch filters (PRs only)
if (event.branch && filters.branches.include.length > 0) { if (event.branch && filters.branches.include.length > 0) {
if (!filters.branches.include.includes(event.branch)) { if (!filters.branches.include.includes(event.branch)) {
return { pass: false, reason: "Branch not in include list" }; return { pass: false, reason: 'Branch not in include list' };
} }
} }

View file

@ -1,14 +1,12 @@
import { Octokit } from "@octokit/rest"; import { Octokit } from '@octokit/rest';
import { getLogger } from "./logger.js"; import { getLogger } from './logger.js';
import type { CheckRun, PRFile } from "./types.js"; import type { CheckRun, PRFile } from './types.js';
let octokit: Octokit | null = null; let octokit: Octokit | null = null;
export function initGitHub(token?: string): void { export function initGitHub(token?: string): void {
if (!token) { if (!token) {
getLogger().warn( getLogger().warn('No GITHUB_TOKEN set - running in dry-run mode, comments will not be posted');
"No GITHUB_TOKEN set - running in dry-run mode, comments will not be posted",
);
return; return;
} }
octokit = new Octokit({ auth: token }); octokit = new Octokit({ auth: token });
@ -23,12 +21,10 @@ export async function postComment(
owner: string, owner: string,
repo: string, repo: string,
issueNumber: number, issueNumber: number,
body: string, body: string
): Promise<void> { ): Promise<void> {
if (!octokit) { if (!octokit) {
getLogger().info( getLogger().info(`[dry-run] Would post comment on ${owner}/${repo}#${issueNumber}:\n${body}`);
`[dry-run] Would post comment on ${owner}/${repo}#${issueNumber}:\n${body}`,
);
return; return;
} }
await octokit.issues.createComment({ await octokit.issues.createComment({
@ -44,7 +40,7 @@ export async function hasExistingComment(
owner: string, owner: string,
repo: string, repo: string,
issueNumber: number, issueNumber: number,
marker: string, marker: string
): Promise<{ exists: boolean; commentId?: number }> { ): Promise<{ exists: boolean; commentId?: number }> {
if (!octokit) { if (!octokit) {
return { exists: false }; return { exists: false };
@ -68,7 +64,7 @@ export async function updateComment(
owner: string, owner: string,
repo: string, repo: string,
commentId: number, commentId: number,
body: string, body: string
): Promise<void> { ): Promise<void> {
if (!octokit) { if (!octokit) {
getLogger().info(`[dry-run] Would update comment ${commentId}:\n${body}`); getLogger().info(`[dry-run] Would update comment ${commentId}:\n${body}`);
@ -88,41 +84,26 @@ export async function createReaction(
repo: string, repo: string,
commentId: number, commentId: number,
reaction: reaction:
| "thumbs_up" | 'thumbs_up'
| "thumbs_down" | 'thumbs_down'
| "laugh" | 'laugh'
| "confused" | 'confused'
| "heart" | 'heart'
| "hooray" | 'hooray'
| "eyes" | 'eyes'
| "rocket", | 'rocket'
): Promise<void> { ): Promise<void> {
if (!octokit) { if (!octokit) {
getLogger().info( getLogger().info(`[dry-run] Would add ${reaction} reaction to comment ${commentId}`);
`[dry-run] Would add ${reaction} reaction to comment ${commentId}`,
);
return; return;
} }
// Map thumbs_up/thumbs_down to GitHub API format (+1/-1) // Map thumbs_up/thumbs_down to GitHub API format (+1/-1)
const content = const content = reaction === 'thumbs_up' ? '+1' : reaction === 'thumbs_down' ? '-1' : reaction;
reaction === "thumbs_up"
? "+1"
: reaction === "thumbs_down"
? "-1"
: reaction;
await octokit.reactions.createForIssueComment({ await octokit.reactions.createForIssueComment({
owner, owner,
repo, repo,
comment_id: commentId, comment_id: commentId,
content: content as content: content as '+1' | '-1' | 'laugh' | 'confused' | 'heart' | 'hooray' | 'eyes' | 'rocket',
| "+1"
| "-1"
| "laugh"
| "confused"
| "heart"
| "hooray"
| "eyes"
| "rocket",
}); });
getLogger().info(`Added ${reaction} reaction to comment ${commentId}`); getLogger().info(`Added ${reaction} reaction to comment ${commentId}`);
} }
@ -131,10 +112,10 @@ export async function createReaction(
export async function fetchCheckRuns( export async function fetchCheckRuns(
owner: string, owner: string,
repo: string, repo: string,
ref: string, ref: string
): Promise<CheckRun[]> { ): Promise<CheckRun[]> {
if (!octokit) { if (!octokit) {
getLogger().debug("[dry-run] Cannot fetch check runs without a token"); getLogger().debug('[dry-run] Cannot fetch check runs without a token');
return []; return [];
} }
@ -155,10 +136,10 @@ export async function fetchCheckRuns(
export async function fetchPRFiles( export async function fetchPRFiles(
owner: string, owner: string,
repo: string, repo: string,
prNumber: number, prNumber: number
): Promise<PRFile[]> { ): Promise<PRFile[]> {
if (!octokit) { if (!octokit) {
getLogger().debug("[dry-run] Cannot fetch PR files without a token"); getLogger().debug('[dry-run] Cannot fetch PR files without a token');
return []; return [];
} }
@ -180,7 +161,7 @@ export async function fetchPRFiles(
export async function fetchPR( export async function fetchPR(
owner: string, owner: string,
repo: string, repo: string,
prNumber: number, prNumber: number
): Promise<{ ): Promise<{
title: string; title: string;
body: string; body: string;
@ -199,11 +180,9 @@ export async function fetchPR(
}); });
return { return {
title: data.title, title: data.title,
body: data.body || "", body: data.body || '',
author: data.user?.login || "", author: data.user?.login || '',
labels: (data.labels || []).map((l) => labels: (data.labels || []).map((l) => (typeof l === 'string' ? l : l.name || '')),
typeof l === "string" ? l : l.name || "",
),
branch: data.head.ref, branch: data.head.ref,
sha: data.head.sha, sha: data.head.sha,
}; };
@ -216,7 +195,7 @@ export async function fetchPR(
export async function fetchIssue( export async function fetchIssue(
owner: string, owner: string,
repo: string, repo: string,
issueNumber: number, issueNumber: number
): Promise<{ ): Promise<{
title: string; title: string;
body: string; body: string;
@ -233,17 +212,12 @@ export async function fetchIssue(
}); });
return { return {
title: data.title, title: data.title,
body: data.body || "", body: data.body || '',
author: data.user?.login || "", author: data.user?.login || '',
labels: (data.labels || []).map((l) => labels: (data.labels || []).map((l) => (typeof l === 'string' ? l : l.name || '')),
typeof l === "string" ? l : l.name || "",
),
}; };
} catch (err) { } catch (err) {
getLogger().debug( getLogger().debug(`Failed to fetch issue ${owner}/${repo}#${issueNumber}`, err);
`Failed to fetch issue ${owner}/${repo}#${issueNumber}`,
err,
);
return null; return null;
} }
} }
@ -252,22 +226,19 @@ export async function listAccessibleRepositories(): Promise<
Array<{ owner: string; repo: string }> Array<{ owner: string; repo: string }>
> { > {
if (!octokit) { if (!octokit) {
getLogger().debug("[dry-run] Cannot fetch repositories without a token"); getLogger().debug('[dry-run] Cannot fetch repositories without a token');
return []; return [];
} }
const repos: Array<{ owner: string; repo: string }> = []; const repos: Array<{ owner: string; repo: string }> = [];
for await (const response of octokit.paginate.iterator( for await (const response of octokit.paginate.iterator(octokit.repos.listForAuthenticatedUser, {
octokit.repos.listForAuthenticatedUser, per_page: 100,
{ sort: 'updated',
per_page: 100, })) {
sort: "updated",
},
)) {
for (const repo of response.data) { for (const repo of response.data) {
if (!repo.full_name || typeof repo.full_name !== "string") continue; if (!repo.full_name || typeof repo.full_name !== 'string') continue;
const parts = repo.full_name.split("/"); const parts = repo.full_name.split('/');
if (parts.length < 2 || !parts[0] || !parts[1]) continue; if (parts.length < 2 || !parts[0] || !parts[1]) continue;
const [owner, repoName] = parts; const [owner, repoName] = parts;
repos.push({ owner, repo: repoName }); repos.push({ owner, repo: repoName });
@ -289,10 +260,10 @@ export interface RecentComment {
export async function listRecentComments( export async function listRecentComments(
owner: string, owner: string,
repo: string, repo: string,
since: Date, since: Date
): Promise<RecentComment[]> { ): Promise<RecentComment[]> {
if (!octokit) { if (!octokit) {
getLogger().debug("[dry-run] Cannot fetch comments without a token"); getLogger().debug('[dry-run] Cannot fetch comments without a token');
return []; return [];
} }
@ -300,15 +271,12 @@ export async function listRecentComments(
const comments: RecentComment[] = []; const comments: RecentComment[] = [];
// Fetch recent issue comments // Fetch recent issue comments
const issueComments = await octokit.paginate( const issueComments = await octokit.paginate(octokit.issues.listCommentsForRepo, {
octokit.issues.listCommentsForRepo, owner,
{ repo,
owner, since: sinceIso,
repo, per_page: 100,
since: sinceIso, });
per_page: 100,
},
);
for (const comment of issueComments) { for (const comment of issueComments) {
if (!comment.body) continue; if (!comment.body) continue;
@ -316,11 +284,9 @@ export async function listRecentComments(
comments.push({ comments.push({
id: comment.id, id: comment.id,
body: comment.body, body: comment.body,
author: comment.user?.login || "", author: comment.user?.login || '',
createdAt: comment.created_at, createdAt: comment.created_at,
issueNumber: comment.issue_url issueNumber: comment.issue_url ? parseInt(comment.issue_url.split('/').pop() || '0', 10) : 0,
? parseInt(comment.issue_url.split("/").pop() || "0", 10)
: 0,
isPullRequest: false, // we'll determine this by fetching the issue isPullRequest: false, // we'll determine this by fetching the issue
}); });
} }
@ -344,18 +310,18 @@ export function formatComment(
neutral: string[]; neutral: string[];
}; };
}, },
type: "issue" | "pull_request", type: 'issue' | 'pull_request',
impact: string, impact: string,
confidence: number, confidence: number,
reasoning: string, reasoning: string
): string { ): string {
const typeLabel = type === "pull_request" ? "pull request" : "issue"; const typeLabel = type === 'pull_request' ? 'pull request' : 'issue';
const { messages } = responseConfig; const { messages } = responseConfig;
let messageList: string[]; let messageList: string[];
if (impact === "positive") { if (impact === 'positive') {
messageList = messages.positive; messageList = messages.positive;
} else if (impact === "negative") { } else if (impact === 'negative') {
messageList = messages.negative; messageList = messages.negative;
} else { } else {
messageList = messages.neutral; messageList = messages.neutral;
@ -363,10 +329,8 @@ export function formatComment(
const template = pickRandom(messageList); const template = pickRandom(messageList);
let body = responseConfig.commentMarker + "\n\n"; let body = responseConfig.commentMarker + '\n\n';
body += template body += template.replace(/\{type\}/g, typeLabel).replace(/\{impact\}/g, impact);
.replace(/\{type\}/g, typeLabel)
.replace(/\{impact\}/g, impact);
if (responseConfig.includeConfidence) { if (responseConfig.includeConfidence) {
body += `\n\n**Confidence:** ${(confidence * 100).toFixed(0)}%`; body += `\n\n**Confidence:** ${(confidence * 100).toFixed(0)}%`;

View file

@ -24,7 +24,7 @@ async function analyzeOne(target: string) {
const [, owner, repo, numStr] = match; const [, owner, repo, numStr] = match;
const prNumber = parseInt(numStr, 10); const prNumber = parseInt(numStr, 10);
const config = loadConfig(); const config = await loadConfig();
initLogger(config.logging); initLogger(config.logging);
const logger = getLogger(); const logger = getLogger();
@ -104,8 +104,8 @@ async function analyzeOne(target: string) {
} }
} }
function serve() { async function serve() {
const config = loadConfig(); const config = await loadConfig();
initLogger(config.logging); initLogger(config.logging);
const logger = getLogger(); const logger = getLogger();
@ -192,5 +192,8 @@ if (args[0] === 'analyze' && args[1]) {
process.exit(1); process.exit(1);
}); });
} else { } else {
serve(); serve().catch((err) => {
console.error(err);
process.exit(1);
});
} }

View file

@ -1,4 +1,4 @@
import type { Config, WebhookEvent, RepoPattern } from "./types.js"; import type { Config, WebhookEvent, RepoPattern } from './types.js';
import { import {
listRecentComments, listRecentComments,
listAccessibleRepositories, listAccessibleRepositories,
@ -10,11 +10,11 @@ import {
formatComment, formatComment,
createReaction, createReaction,
type RecentComment, type RecentComment,
} from "./github.js"; } from './github.js';
import { createEngine } from "./engine/index.js"; import { createEngine } from './engine/index.js';
import { getLogger } from "./logger.js"; import { getLogger } from './logger.js';
import { recordEvent } from "./events.js"; import { recordEvent } from './events.js';
import { readFileSync, writeFileSync, existsSync } from "fs"; import { readFileSync, writeFileSync, existsSync } from 'fs';
interface ProcessedComment { interface ProcessedComment {
id: number; id: number;
@ -35,20 +35,18 @@ let pollingState: PollingState = { lastProcessedAt: {} };
function loadPollingState(stateFile: string): void { function loadPollingState(stateFile: string): void {
if (existsSync(stateFile)) { if (existsSync(stateFile)) {
try { try {
const data = readFileSync(stateFile, "utf-8"); const data = readFileSync(stateFile, 'utf-8');
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
// Validate that parsed data has expected structure // Validate that parsed data has expected structure
if ( if (
parsed && parsed &&
typeof parsed === "object" && typeof parsed === 'object' &&
parsed.lastProcessedAt && parsed.lastProcessedAt &&
typeof parsed.lastProcessedAt === "object" typeof parsed.lastProcessedAt === 'object'
) { ) {
pollingState = parsed; pollingState = parsed;
} else { } else {
getLogger().warn( getLogger().warn('Invalid polling state format, resetting to empty state');
"Invalid polling state format, resetting to empty state",
);
pollingState = { lastProcessedAt: {} }; pollingState = { lastProcessedAt: {} };
} }
} catch { } catch {
@ -62,7 +60,7 @@ function savePollingState(stateFile: string): void {
try { try {
writeFileSync(stateFile, JSON.stringify(pollingState, null, 2)); writeFileSync(stateFile, JSON.stringify(pollingState, null, 2));
} catch (err) { } catch (err) {
getLogger().warn("Failed to save polling state", err); getLogger().warn('Failed to save polling state', err);
} }
} }
@ -108,11 +106,7 @@ function markProcessed(owner: string, repo: string, commentId: number): void {
} }
} }
function recordFailure( function recordFailure(owner: string, repo: string, commentId: number): boolean {
owner: string,
repo: string,
commentId: number,
): boolean {
const key = getCacheKey(owner, repo, commentId); const key = getCacheKey(owner, repo, commentId);
const existing = processedComments.get(key); const existing = processedComments.get(key);
@ -127,12 +121,12 @@ function recordFailure(
} }
function containsMention(body: string): boolean { function containsMention(body: string): boolean {
return body.includes("@troutbot"); return body.includes('@troutbot');
} }
async function analyzeAndComment( async function analyzeAndComment(
event: WebhookEvent, event: WebhookEvent,
config: Config, config: Config
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const logger = getLogger(); const logger = getLogger();
const engine = createEngine(config.engine); const engine = createEngine(config.engine);
@ -140,23 +134,16 @@ async function analyzeAndComment(
// Run analysis // Run analysis
const analysis = await engine.analyze(event); const analysis = await engine.analyze(event);
logger.info( logger.info(
`Analyzed ${event.owner}/${event.repo}#${event.number}: impact=${analysis.impact}, confidence=${analysis.confidence.toFixed(2)}`, `Analyzed ${event.owner}/${event.repo}#${event.number}: impact=${analysis.impact}, confidence=${analysis.confidence.toFixed(2)}`
); );
// Check for existing comment // Check for existing comment
const { commentMarker, allowUpdates } = config.response; const { commentMarker, allowUpdates } = config.response;
const existing = await hasExistingComment( const existing = await hasExistingComment(event.owner, event.repo, event.number, commentMarker);
event.owner,
event.repo,
event.number,
commentMarker,
);
if (existing.exists && !allowUpdates) { if (existing.exists && !allowUpdates) {
logger.info( logger.info(`Already commented on ${event.owner}/${event.repo}#${event.number}, skipping`);
`Already commented on ${event.owner}/${event.repo}#${event.number}, skipping`, const result = { skipped: true, reason: 'Already commented' };
);
const result = { skipped: true, reason: "Already commented" };
recordEvent(event, result, analysis); recordEvent(event, result, analysis);
return result; return result;
} }
@ -167,13 +154,11 @@ async function analyzeAndComment(
event.type, event.type,
analysis.impact, analysis.impact,
analysis.confidence, analysis.confidence,
analysis.reasoning, analysis.reasoning
); );
if (existing.exists && allowUpdates && existing.commentId) { if (existing.exists && allowUpdates && existing.commentId) {
logger.info( logger.info(`Updating existing comment on ${event.owner}/${event.repo}#${event.number}`);
`Updating existing comment on ${event.owner}/${event.repo}#${event.number}`,
);
await updateComment(event.owner, event.repo, existing.commentId, body); await updateComment(event.owner, event.repo, existing.commentId, body);
} else { } else {
await postComment(event.owner, event.repo, event.number, body); await postComment(event.owner, event.repo, event.number, body);
@ -196,22 +181,16 @@ function isAuthorized(username: string, authorizedUsers?: string[]): boolean {
return authorizedUsers.some((u) => u.toLowerCase() === normalizedUsername); return authorizedUsers.some((u) => u.toLowerCase() === normalizedUsername);
} }
function isRepoAuthorized( function isRepoAuthorized(owner: string, repo: string, pollingPatterns?: RepoPattern[]): boolean {
owner: string,
repo: string,
pollingPatterns?: RepoPattern[],
): boolean {
if (!pollingPatterns || pollingPatterns.length === 0) { if (!pollingPatterns || pollingPatterns.length === 0) {
return true; // No restrictions, accept all repos return true; // No restrictions, accept all repos
} }
// Check if repo matches any pattern // Check if repo matches any pattern
for (const pattern of pollingPatterns) { for (const pattern of pollingPatterns) {
const ownerMatch = const ownerMatch = pattern.owner === '*' || pattern.owner.toLowerCase() === owner.toLowerCase();
pattern.owner === "*" ||
pattern.owner.toLowerCase() === owner.toLowerCase();
const repoMatch = const repoMatch =
pattern.repo === "*" || pattern.repo === '*' ||
pattern.repo === undefined || pattern.repo === undefined ||
pattern.repo.toLowerCase() === repo.toLowerCase(); pattern.repo.toLowerCase() === repo.toLowerCase();
@ -227,7 +206,7 @@ async function processComment(
comment: RecentComment, comment: RecentComment,
owner: string, owner: string,
repo: string, repo: string,
config: Config, config: Config
): Promise<void> { ): Promise<void> {
const logger = getLogger(); const logger = getLogger();
@ -236,9 +215,7 @@ async function processComment(
} }
if (isProcessed(owner, repo, comment.id)) { if (isProcessed(owner, repo, comment.id)) {
logger.debug( logger.debug(`Comment ${owner}/${repo}#${comment.id} already processed, skipping`);
`Comment ${owner}/${repo}#${comment.id} already processed, skipping`,
);
return; return;
} }
@ -246,9 +223,9 @@ async function processComment(
const pollingRepos = config.polling?.repositories; const pollingRepos = config.polling?.repositories;
if (!isRepoAuthorized(owner, repo, pollingRepos)) { if (!isRepoAuthorized(owner, repo, pollingRepos)) {
logger.info( logger.info(
`Unauthorized repo ${owner}/${repo} for polling, ignoring mention from ${comment.author}`, `Unauthorized repo ${owner}/${repo} for polling, ignoring mention from ${comment.author}`
); );
await createReaction(owner, repo, comment.id, "thumbs_down"); await createReaction(owner, repo, comment.id, 'thumbs_down');
markProcessed(owner, repo, comment.id); markProcessed(owner, repo, comment.id);
return; return;
} }
@ -257,15 +234,13 @@ async function processComment(
const authorizedUsers = config.polling?.authorizedUsers; const authorizedUsers = config.polling?.authorizedUsers;
if (!isAuthorized(comment.author, authorizedUsers)) { if (!isAuthorized(comment.author, authorizedUsers)) {
logger.info( logger.info(
`Unauthorized user ${comment.author} attempted on-demand analysis in ${owner}/${repo}#${comment.issueNumber}`, `Unauthorized user ${comment.author} attempted on-demand analysis in ${owner}/${repo}#${comment.issueNumber}`
); );
markProcessed(owner, repo, comment.id); markProcessed(owner, repo, comment.id);
return; return;
} }
logger.info( logger.info(`Found @troutbot mention in ${owner}/${repo}#${comment.issueNumber}`);
`Found @troutbot mention in ${owner}/${repo}#${comment.issueNumber}`,
);
try { try {
// First, try to fetch as a PR to check if it's a pull request // First, try to fetch as a PR to check if it's a pull request
@ -276,8 +251,8 @@ async function processComment(
if (prData) { if (prData) {
// It's a pull request // It's a pull request
event = { event = {
action: "on_demand", action: 'on_demand',
type: "pull_request", type: 'pull_request',
number: comment.issueNumber, number: comment.issueNumber,
title: prData.title, title: prData.title,
body: prData.body, body: prData.body,
@ -292,15 +267,13 @@ async function processComment(
// It's an issue // It's an issue
const issueData = await fetchIssue(owner, repo, comment.issueNumber); const issueData = await fetchIssue(owner, repo, comment.issueNumber);
if (!issueData) { if (!issueData) {
logger.warn( logger.warn(`Could not fetch issue ${owner}/${repo}#${comment.issueNumber}`);
`Could not fetch issue ${owner}/${repo}#${comment.issueNumber}`,
);
return; return;
} }
event = { event = {
action: "on_demand", action: 'on_demand',
type: "issue", type: 'issue',
number: comment.issueNumber, number: comment.issueNumber,
title: issueData.title, title: issueData.title,
body: issueData.body, body: issueData.body,
@ -315,18 +288,15 @@ async function processComment(
markProcessed(owner, repo, comment.id); markProcessed(owner, repo, comment.id);
logger.info( logger.info(
`Successfully processed on-demand analysis for ${owner}/${repo}#${comment.issueNumber}`, `Successfully processed on-demand analysis for ${owner}/${repo}#${comment.issueNumber}`
); );
} catch (err) { } catch (err) {
logger.error( logger.error(`Failed to process mention in ${owner}/${repo}#${comment.issueNumber}`, err);
`Failed to process mention in ${owner}/${repo}#${comment.issueNumber}`,
err,
);
// Track failures and mark as processed after max retries // Track failures and mark as processed after max retries
const shouldStop = recordFailure(owner, repo, comment.id); const shouldStop = recordFailure(owner, repo, comment.id);
if (shouldStop) { if (shouldStop) {
logger.warn( logger.warn(
`Max retry attempts (${MAX_RETRY_ATTEMPTS}) reached for comment ${comment.id}, marking as processed`, `Max retry attempts (${MAX_RETRY_ATTEMPTS}) reached for comment ${comment.id}, marking as processed`
); );
markProcessed(owner, repo, comment.id); markProcessed(owner, repo, comment.id);
} }
@ -338,15 +308,13 @@ async function pollRepository(
repo: string, repo: string,
config: Config, config: Config,
since: Date, since: Date,
stateFile?: string, stateFile?: string
): Promise<void> { ): Promise<void> {
const logger = getLogger(); const logger = getLogger();
try { try {
const comments = await listRecentComments(owner, repo, since); const comments = await listRecentComments(owner, repo, since);
logger.debug( logger.debug(`Fetched ${comments.length} recent comments from ${owner}/${repo}`);
`Fetched ${comments.length} recent comments from ${owner}/${repo}`,
);
let latestCommentDate = since; let latestCommentDate = since;
@ -373,7 +341,7 @@ export async function startPolling(config: Config): Promise<void> {
const pollingConfig = config.polling; const pollingConfig = config.polling;
if (!pollingConfig || !pollingConfig.enabled) { if (!pollingConfig || !pollingConfig.enabled) {
logger.info("Polling is disabled"); logger.info('Polling is disabled');
return; return;
} }
@ -384,9 +352,7 @@ export async function startPolling(config: Config): Promise<void> {
if (!pollingPatterns || pollingPatterns.length === 0) { if (!pollingPatterns || pollingPatterns.length === 0) {
// No patterns configured - poll all accessible repos // No patterns configured - poll all accessible repos
reposToPoll = await listAccessibleRepositories(); reposToPoll = await listAccessibleRepositories();
logger.info( logger.info(`Polling all accessible repositories (${reposToPoll.length} repos)`);
`Polling all accessible repositories (${reposToPoll.length} repos)`,
);
} else { } else {
// Build repo list from patterns // Build repo list from patterns
reposToPoll = []; reposToPoll = [];
@ -413,7 +379,7 @@ export async function startPolling(config: Config): Promise<void> {
} }
if (reposToPoll.length === 0) { if (reposToPoll.length === 0) {
logger.warn("No repositories match polling patterns"); logger.warn('No repositories match polling patterns');
return; return;
} }
@ -421,12 +387,12 @@ export async function startPolling(config: Config): Promise<void> {
const lookbackMs = pollingConfig.lookbackMinutes * 60 * 1000; const lookbackMs = pollingConfig.lookbackMinutes * 60 * 1000;
logger.info( logger.info(
`Poll interval: ${pollingConfig.intervalMinutes} minutes, lookback: ${pollingConfig.lookbackMinutes} minutes`, `Poll interval: ${pollingConfig.intervalMinutes} minutes, lookback: ${pollingConfig.lookbackMinutes} minutes`
); );
// Load persisted state if backfill is enabled // Load persisted state if backfill is enabled
const stateFile = pollingConfig.backfill const stateFile = pollingConfig.backfill
? pollingConfig.stateFile || ".troutbot-polling-state.json" ? pollingConfig.stateFile || '.troutbot-polling-state.json'
: undefined; : undefined;
if (stateFile) { if (stateFile) {
loadPollingState(stateFile); loadPollingState(stateFile);
@ -439,16 +405,10 @@ export async function startPolling(config: Config): Promise<void> {
const initialSince = lastProcessed || new Date(Date.now() - lookbackMs); const initialSince = lastProcessed || new Date(Date.now() - lookbackMs);
if (lastProcessed) { if (lastProcessed) {
logger.info( logger.info(
`Resuming polling for ${repo.owner}/${repo.repo} from ${lastProcessed.toISOString()}`, `Resuming polling for ${repo.owner}/${repo.repo} from ${lastProcessed.toISOString()}`
); );
} }
await pollRepository( await pollRepository(repo.owner, repo.repo, config, initialSince, stateFile);
repo.owner,
repo.repo,
config,
initialSince,
stateFile,
);
} }
// Set up recurring polling // Set up recurring polling

View file

@ -1,7 +1,7 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import express from 'express'; import express from 'express';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import type { Config, WebhookEvent, RepoConfig } from './types.js'; import type { Config, WebhookEvent, RepoPattern } from './types.js';
import { shouldProcess } from './filters.js'; import { shouldProcess } from './filters.js';
import { createEngine } from './engine/index.js'; import { createEngine } from './engine/index.js';
import { import {
@ -109,7 +109,10 @@ export function createApp(config: Config): express.Express {
res.json(result); res.json(result);
return; return;
} }
res.json({ skipped: true, reason: 'Comment does not mention @troutbot' }); res.json({
skipped: true,
reason: 'Comment does not mention @troutbot',
});
return; return;
} }
@ -189,6 +192,7 @@ async function analyzeAndComment(
return result; return result;
} }
// Generate comment with analysis
const body = formatComment( const body = formatComment(
config.response, config.response,
event.type, event.type,
@ -203,7 +207,11 @@ async function analyzeAndComment(
await postComment(event.owner, event.repo, event.number, body); await postComment(event.owner, event.repo, event.number, body);
} }
const result = { processed: true, impact: analysis.impact, confidence: analysis.confidence }; const result = {
processed: true,
impact: analysis.impact,
confidence: analysis.confidence,
};
recordEvent(event, result, analysis); recordEvent(event, result, analysis);
return result; return result;
} }
@ -269,12 +277,26 @@ function isAuthorized(username: string, authorizedUsers?: string[]): boolean {
function isRepoAuthorizedForPolling( function isRepoAuthorizedForPolling(
owner: string, owner: string,
repo: string, repo: string,
pollingRepos?: RepoConfig[] pollingPatterns?: RepoPattern[]
): boolean { ): boolean {
if (!pollingRepos || pollingRepos.length === 0) { if (!pollingPatterns || pollingPatterns.length === 0) {
return true; // no restrictions, use global repos return true; // No restrictions, accept all repos
} }
return pollingRepos.some((r) => r.owner === owner && r.repo === repo);
// Check if repo matches any pattern
for (const pattern of pollingPatterns) {
const ownerMatch = pattern.owner === '*' || pattern.owner.toLowerCase() === owner.toLowerCase();
const repoMatch =
pattern.repo === '*' ||
pattern.repo === undefined ||
pattern.repo.toLowerCase() === repo.toLowerCase();
if (ownerMatch && repoMatch) {
return true;
}
}
return false;
} }
async function handleOnDemandAnalysis( async function handleOnDemandAnalysis(

View file

@ -31,7 +31,7 @@ export interface DashboardConfig {
} }
export interface DashboardAuthConfig { export interface DashboardAuthConfig {
type: "basic" | "token"; type: 'basic' | 'token';
username?: string; username?: string;
password?: string; password?: string;
token?: string; token?: string;
@ -111,7 +111,7 @@ export interface LoggingConfig {
file: string; file: string;
} }
export type Impact = "positive" | "negative" | "neutral"; export type Impact = 'positive' | 'negative' | 'neutral';
export interface AnalysisResult { export interface AnalysisResult {
impact: Impact; impact: Impact;
@ -144,7 +144,7 @@ export interface EngineBackend {
export interface WebhookEvent { export interface WebhookEvent {
action: string; action: string;
type: "issue" | "pull_request"; type: 'issue' | 'pull_request';
number: number; number: number;
title: string; title: string;
body: string; body: string;