From cfb114e5299d431a715a382f2c026f7420063254 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 7 Feb 2026 16:14:47 +0300 Subject: [PATCH] chore: format with prettier Signed-off-by: NotAShelf Change-Id: Ib25c8bc022433f1dff87e9f6aeff4a726a6a6964 --- src/config.ts | 93 ++++++++------------- src/engine/checks.ts | 24 +++++- src/engine/context.ts | 84 ++++++++----------- src/engine/diff.ts | 49 +++++++++-- src/engine/index.ts | 186 ++++++++++++++++-------------------------- src/engine/quality.ts | 96 ++++++++++++++++++---- src/filters.ts | 30 +++---- src/github.ts | 148 +++++++++++++-------------------- src/index.ts | 11 ++- src/polling.ts | 132 +++++++++++------------------- src/server.ts | 36 ++++++-- src/types.ts | 6 +- 12 files changed, 435 insertions(+), 460 deletions(-) diff --git a/src/config.ts b/src/config.ts index 863ac93..cb82a79 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ -import fs from "node:fs"; -import path from "node:path"; -import dotenv from "dotenv"; -import type { Config } from "./types.js"; +import fs from 'node:fs'; +import path from 'node:path'; +import dotenv from 'dotenv'; +import type { Config } from './types.js'; // Suppress dotenv warnings dotenv.config({ quiet: true }); @@ -32,34 +32,31 @@ const defaults: Config = { includeReasoning: true, messages: { positive: [ - "This {type} looks great for the trout! All signals point upstream.", - "The trout approve of this {type}. Swim on!", - "Splashing good news - this {type} is looking healthy.", + 'This {type} looks great for the trout! All signals point upstream.', + 'The trout approve of this {type}. Swim on!', + 'Splashing good news - this {type} is looking healthy.', ], negative: [ - "This {type} is muddying the waters. The trout are concerned.", - "Warning: the trout sense trouble in this {type}.", - "Something smells fishy about this {type}. Please review.", + 'This {type} is muddying the waters. The trout are concerned.', + 'Warning: the trout sense trouble in this {type}.', + 'Something smells fishy about this {type}. Please review.', ], neutral: [ - "The trout have no strong feelings about this {type}.", - "This {type} is neither upstream nor downstream. Neutral waters.", - "The trout are watching this {type} with mild interest.", + 'The trout have no strong feelings about this {type}.', + 'This {type} is neither upstream nor downstream. Neutral waters.', + 'The trout are watching this {type} with mild interest.', ], }, - commentMarker: "", + commentMarker: '', allowUpdates: false, }, logging: { - level: "info", - file: "troutbot.log", + level: 'info', + file: 'troutbot.log', }, }; -export function deepMerge>( - target: T, - source: Partial, -): T { +export function deepMerge>(target: T, source: Partial): T { const result = { ...target }; for (const key of Object.keys(source) as (keyof T)[]) { const sourceVal = source[key]; @@ -67,15 +64,15 @@ export function deepMerge>( if ( sourceVal !== null && sourceVal !== undefined && - typeof sourceVal === "object" && + typeof sourceVal === 'object' && !Array.isArray(sourceVal) && - typeof targetVal === "object" && + typeof targetVal === 'object' && !Array.isArray(targetVal) && targetVal !== null ) { result[key] = deepMerge( targetVal as Record, - sourceVal as Record, + sourceVal as Record ) as T[keyof T]; } else if (sourceVal !== undefined) { result[key] = sourceVal as T[keyof T]; @@ -85,34 +82,29 @@ export function deepMerge>( } export async function loadConfig(): Promise { - const configPath = process.env.CONFIG_PATH || "config.ts"; + const configPath = process.env.CONFIG_PATH || 'config.ts'; const resolvedPath = path.resolve(configPath); let fileConfig: Partial = {}; if (fs.existsSync(resolvedPath)) { const loaded = await import(resolvedPath); - fileConfig = - "default" in loaded - ? loaded.default - : (loaded as unknown as Partial); + fileConfig = 'default' in loaded ? loaded.default : (loaded as unknown as Partial); } else if (process.env.CONFIG_PATH) { 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( defaults as unknown as Record, - fileConfig as unknown as Record, + fileConfig as unknown as Record ) as unknown as Config; // Environment variable overrides if (process.env.PORT) { const parsed = parseInt(process.env.PORT, 10); if (Number.isNaN(parsed)) { - throw new Error( - `Invalid PORT value: "${process.env.PORT}" is not a number`, - ); + throw new Error(`Invalid PORT value: "${process.env.PORT}" is not a number`); } config.server.port = parsed; } @@ -125,7 +117,7 @@ export async function loadConfig(): Promise { config.dashboard = { ...(config.dashboard || { enabled: true }), auth: { - type: "token", + type: 'token', token: process.env.DASHBOARD_TOKEN, }, }; @@ -135,18 +127,18 @@ export async function loadConfig(): Promise { config.dashboard = { ...(config.dashboard || { enabled: true }), auth: { - type: "basic", + type: 'basic', username: process.env.DASHBOARD_USERNAME, password: process.env.DASHBOARD_PASSWORD, }, }; } - const validLogLevels = ["debug", "info", "warn", "error"]; + const validLogLevels = ['debug', 'info', 'warn', 'error']; if (process.env.LOG_LEVEL) { if (!validLogLevels.includes(process.env.LOG_LEVEL)) { 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; @@ -157,36 +149,23 @@ export async function loadConfig(): Promise { } export function validate(config: Config): void { - if ( - !config.server.port || - config.server.port < 1 || - config.server.port > 65535 - ) { - throw new Error("Invalid server port"); + if (!config.server.port || config.server.port < 1 || config.server.port > 65535) { + throw new Error('Invalid server port'); } const { backends } = config.engine; - if ( - !backends.checks.enabled && - !backends.diff.enabled && - !backends.quality.enabled - ) { - throw new Error("At least one engine backend must be enabled"); + if (!backends.checks.enabled && !backends.diff.enabled && !backends.quality.enabled) { + throw new Error('At least one engine backend must be enabled'); } const { weights } = config.engine; for (const [key, value] of Object.entries(weights)) { if (value < 0) { - throw new Error( - `Backend weight "${key}" must be non-negative, got ${value}`, - ); + throw new Error(`Backend weight "${key}" must be non-negative, got ${value}`); } } - if ( - config.engine.confidenceThreshold < 0 || - config.engine.confidenceThreshold > 1 - ) { - throw new Error("confidenceThreshold must be between 0 and 1"); + if (config.engine.confidenceThreshold < 0 || config.engine.confidenceThreshold > 1) { + throw new Error('confidenceThreshold must be between 0 and 1'); } } diff --git a/src/engine/checks.ts b/src/engine/checks.ts index f43b406..def772c 100644 --- a/src/engine/checks.ts +++ b/src/engine/checks.ts @@ -35,7 +35,11 @@ export class ChecksBackend implements EngineBackend { async analyze(event: WebhookEvent): Promise { 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; @@ -46,11 +50,19 @@ export class ChecksBackend implements EngineBackend { `Failed to fetch check runs for ${event.owner}/${event.repo}@${event.sha}`, 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) { - 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'); @@ -75,7 +87,11 @@ export class ChecksBackend implements EngineBackend { const actionable = completed.length - skipped.length; 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 diff --git a/src/engine/context.ts b/src/engine/context.ts index aa04373..edfc7db 100644 --- a/src/engine/context.ts +++ b/src/engine/context.ts @@ -1,6 +1,6 @@ -import type { AnalysisResult, WebhookEvent } from "../types.js"; -import { readFileSync, writeFileSync, existsSync } from "fs"; -import { getLogger } from "../logger.js"; +import type { AnalysisResult, WebhookEvent } from '../types.js'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { getLogger } from '../logger.js'; // Author reputation tracking interface AuthorStats { @@ -49,7 +49,7 @@ let contextData: ContextData = createDefaultContext(); let contextFile: string | null = null; export function initContext(stateFile?: string): void { - contextFile = stateFile || ".troutbot-context.json"; + contextFile = stateFile || '.troutbot-context.json'; loadContext(); } @@ -60,24 +60,24 @@ function loadContext(): void { } try { - const data = readFileSync(contextFile, "utf-8"); + const data = readFileSync(contextFile, 'utf-8'); const parsed = JSON.parse(data); // Validate structure if ( parsed && - typeof parsed === "object" && + typeof parsed === 'object' && parsed.globalStats?.version === CONTEXT_VERSION && - typeof parsed.authors === "object" && + typeof parsed.authors === 'object' && parsed.authors !== null && !Array.isArray(parsed.authors) && - typeof parsed.repositories === "object" && + typeof parsed.repositories === 'object' && parsed.repositories !== null && !Array.isArray(parsed.repositories) ) { contextData = parsed; } else { - getLogger().warn("Invalid context format, resetting"); + getLogger().warn('Invalid context format, resetting'); contextData = createDefaultContext(); } } catch { @@ -91,14 +91,11 @@ function saveContext(): void { try { writeFileSync(contextFile, JSON.stringify(contextData, null, 2)); } catch (err) { - getLogger().warn("Failed to save context", err); + getLogger().warn('Failed to save context', err); } } -export function updateContext( - event: WebhookEvent, - result: AnalysisResult, -): void { +export function updateContext(event: WebhookEvent, result: AnalysisResult): void { const author = event.author; const repo = `${event.owner}/${event.repo}`; @@ -118,14 +115,13 @@ export function updateContext( authorStats.totalContributions++; authorStats.lastSeen = new Date().toISOString(); - if (result.impact === "positive") authorStats.positiveImpacts++; - else if (result.impact === "negative") authorStats.negativeImpacts++; + if (result.impact === 'positive') authorStats.positiveImpacts++; + else if (result.impact === 'negative') authorStats.negativeImpacts++; else authorStats.neutralImpacts++; // Update running average confidence authorStats.averageConfidence = - (authorStats.averageConfidence * (authorStats.totalContributions - 1) + - result.confidence) / + (authorStats.averageConfidence * (authorStats.totalContributions - 1) + result.confidence) / authorStats.totalContributions; // Update repo patterns (simplified) @@ -168,25 +164,23 @@ export function getAuthorReputation(author: string): { isTrusted: false, isNew: true, reputation: 0, - history: "First-time contributor", + history: 'First-time contributor', }; } const successRate = - stats.totalContributions > 0 - ? stats.positiveImpacts / stats.totalContributions - : 0; + stats.totalContributions > 0 ? stats.positiveImpacts / stats.totalContributions : 0; const reputation = Math.min( 1, successRate * 0.6 + (Math.min(stats.totalContributions, 20) / 20) * 0.3 + - stats.averageConfidence * 0.1, + stats.averageConfidence * 0.1 ); let history: string; if (stats.totalContributions === 1) { - history = "1 contribution"; + history = '1 contribution'; } else if (stats.totalContributions < 5) { history = `${stats.totalContributions} contributions, ${(successRate * 100).toFixed(0)}% positive`; } else { @@ -203,7 +197,7 @@ export function getAuthorReputation(author: string): { export function getRepoContext( owner: string, - repo: string, + repo: string ): { isActive: boolean; communitySize: number; @@ -216,7 +210,7 @@ export function getRepoContext( return { isActive: false, communitySize: 0, - maturity: "unknown", + maturity: 'unknown', }; } @@ -224,13 +218,13 @@ export function getRepoContext( let maturity: string; if (contextData.globalStats.totalAnalyses < 10) { - maturity = "new"; + maturity = 'new'; } else if (communitySize < 3) { - maturity = "small-team"; + maturity = 'small-team'; } else if (communitySize < 10) { - maturity = "growing"; + maturity = 'growing'; } else { - maturity = "established"; + maturity = 'established'; } return { @@ -242,7 +236,7 @@ export function getRepoContext( export function getContextualInsights( event: WebhookEvent, - backendResults: Record, + backendResults: Record ): string[] { const insights: string[] = []; const authorRep = getAuthorReputation(event.author); @@ -250,38 +244,30 @@ export function getContextualInsights( // Author-based insights if (authorRep.isNew) { - insights.push( - `Welcome ${event.author}! This appears to be your first contribution.`, - ); + insights.push(`Welcome ${event.author}! This appears to be your first contribution.`); } else if (authorRep.isTrusted) { - insights.push( - `${event.author} is a trusted contributor with ${authorRep.history}.`, - ); + insights.push(`${event.author} is a trusted contributor with ${authorRep.history}.`); } else if (authorRep.reputation < 0.3) { - insights.push( - `${event.author} has had mixed results recently (${authorRep.history}).`, - ); + insights.push(`${event.author} has had mixed results recently (${authorRep.history}).`); } // Repo-based insights - if (repoCtx.maturity === "new") { - insights.push("This repository is still building up analysis history."); + if (repoCtx.maturity === 'new') { + insights.push('This repository is still building up analysis history.'); } // Cross-backend pattern detection const impacts = Object.values(backendResults).map((r) => r.impact); - const allPositive = impacts.every((i) => i === "positive"); - const allNegative = impacts.every((i) => i === "negative"); + const allPositive = impacts.every((i) => i === 'positive'); + const allNegative = impacts.every((i) => i === 'negative'); const mixed = new Set(impacts).size > 1; 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) { - insights.push("Multiple concerns detected across different dimensions."); + insights.push('Multiple concerns detected across different dimensions.'); } else if (mixed) { - insights.push( - "Mixed signals - some aspects look good, others need attention.", - ); + insights.push('Mixed signals - some aspects look good, others need attention.'); } return insights; diff --git a/src/engine/diff.ts b/src/engine/diff.ts index 9c74cd5..6be08e9 100644 --- a/src/engine/diff.ts +++ b/src/engine/diff.ts @@ -16,7 +16,12 @@ 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 }[] + files: { + filename: string; + additions: number; + deletions: number; + changes: number; + }[] ) { const src: 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}`, 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) { @@ -89,7 +98,11 @@ export class DiffBackend implements EngineBackend { } else if (totalChanges <= 500) { // medium - no signal either way } 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 { signals.push({ name: `very large PR (${totalChanges} lines, exceeds limit)`, @@ -121,21 +134,33 @@ export class DiffBackend implements EngineBackend { if (tests.length > 0 && src.length > 0) { const testRatio = tests.length / src.length; 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 { signals.push({ name: 'includes tests', positive: true, weight: 1 }); } } else if (tests.length > 0 && src.length === 0) { signals.push({ name: 'test-only change', positive: true, weight: 1.2 }); } 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 --- if (totalDeletions > totalAdditions && totalDeletions > 10) { const ratio = totalDeletions / Math.max(totalAdditions, 1); 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 { signals.push({ name: 'net code removal', positive: true, weight: 1 }); } @@ -167,7 +192,11 @@ export class DiffBackend implements EngineBackend { // --- Documentation --- 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) { signals.push({ name: 'docs-only change', positive: true, weight: 1 }); } @@ -181,7 +210,11 @@ export class DiffBackend implements EngineBackend { if (generated.length > 0) { const genChanges = generated.reduce((s, f) => s + f.changes, 0); 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, + }); } } diff --git a/src/engine/index.ts b/src/engine/index.ts index 4371c33..3fb1642 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -4,17 +4,12 @@ import type { EngineConfig, Impact, WebhookEvent, -} from "../types.js"; -import { ChecksBackend } from "./checks.js"; -import { DiffBackend } from "./diff.js"; -import { QualityBackend } from "./quality.js"; -import { getLogger } from "../logger.js"; -import { - initContext, - updateContext, - getAuthorReputation, - getRepoContext, -} from "./context.js"; +} from '../types.js'; +import { ChecksBackend } from './checks.js'; +import { DiffBackend } from './diff.js'; +import { QualityBackend } from './quality.js'; +import { getLogger } from '../logger.js'; +import { initContext, updateContext, getAuthorReputation, getRepoContext } from './context.js'; interface WeightedBackend { backend: EngineBackend; @@ -56,7 +51,7 @@ export class Engine { } 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( `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 @@ -89,7 +84,7 @@ export class Engine { try { const result = await backend.analyze(event); 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 }; } catch (err) { @@ -97,14 +92,14 @@ export class Engine { return { backend: backend.name, result: { - impact: "neutral" as Impact, + impact: 'neutral' as Impact, confidence: 0, reasoning: `${backend.name}: error`, }, weight, }; } - }), + }) ); for (const r of rawResults) { @@ -120,7 +115,7 @@ export class Engine { const active = backendResults.filter((r) => r.confidence > 0); if (active.length === 0) { return { - impact: "neutral", + impact: 'neutral', confidence: 0, reasoning: `Insufficient data: no analysis backends produced signals for ${event.type} #${event.number}.`, }; @@ -137,21 +132,21 @@ export class Engine { active, dimensions, correlations, - authorRep, + authorRep ); // Determine impact let impact: Impact; if (score > 0.2) { - impact = "positive"; + impact = 'positive'; } else if (score < -0.2) { - impact = "negative"; + impact = 'negative'; } else { - impact = "neutral"; + impact = 'neutral'; } if (confidence < this.confidenceThreshold) { - impact = "neutral"; + impact = 'neutral'; } // Generate analytical reasoning @@ -165,7 +160,7 @@ export class Engine { confidence, uncertainty, authorRep, - repoCtx, + repoCtx ); const result: AnalysisResult = { @@ -181,9 +176,7 @@ export class Engine { return result; } - private calculateDimensions( - active: BackendResult[], - ): NonNullable { + private calculateDimensions(active: BackendResult[]): NonNullable { let correctnessScore = 0; let correctnessWeight = 0; let riskScore = 0; @@ -194,24 +187,23 @@ export class Engine { let alignmentWeight = 0; for (const r of active) { - const impactScore = - r.impact === "positive" ? 1 : r.impact === "negative" ? -1 : 0; + const impactScore = r.impact === 'positive' ? 1 : r.impact === 'negative' ? -1 : 0; const weightedImpact = impactScore * r.confidence * r.weight; switch (r.backend) { - case "checks": + case 'checks': correctnessScore += weightedImpact * 0.7; correctnessWeight += r.weight * 0.7; riskScore += weightedImpact * 0.3; riskWeight += r.weight * 0.3; break; - case "diff": + case 'diff': maintainabilityScore += weightedImpact * 0.6; maintainabilityWeight += r.weight * 0.6; riskScore += weightedImpact * 0.4; riskWeight += r.weight * 0.4; break; - case "quality": + case 'quality': alignmentScore += weightedImpact * 0.7; alignmentWeight += r.weight * 0.7; maintainabilityScore += weightedImpact * 0.3; @@ -221,91 +213,70 @@ export class Engine { } return { - correctness: - correctnessWeight > 0 ? correctnessScore / correctnessWeight : 0, + correctness: correctnessWeight > 0 ? correctnessScore / correctnessWeight : 0, risk: riskWeight > 0 ? riskScore / riskWeight : 0, - maintainability: - maintainabilityWeight > 0 - ? maintainabilityScore / maintainabilityWeight - : 0, + maintainability: maintainabilityWeight > 0 ? maintainabilityScore / maintainabilityWeight : 0, alignment: alignmentWeight > 0 ? alignmentScore / alignmentWeight : 0, }; } private detectCorrelations( allResults: BackendResult[], - dimensions: NonNullable, - ): NonNullable { + dimensions: NonNullable + ): NonNullable { const suspiciousPatterns: string[] = []; const reinforcingSignals: string[] = []; const contradictions: string[] = []; const active = allResults.filter((r) => r.confidence > 0); - const hasChecks = active.some((r) => r.backend === "checks"); - const hasDiff = active.some((r) => r.backend === "diff"); - const hasQuality = active.some((r) => r.backend === "quality"); + const hasChecks = active.some((r) => r.backend === 'checks'); + const hasDiff = active.some((r) => r.backend === 'diff'); + const hasQuality = active.some((r) => r.backend === 'quality'); // Check for suspicious patterns if (hasChecks && hasDiff) { - const checksResult = active.find((r) => r.backend === "checks"); - const diffResult = active.find((r) => r.backend === "diff"); + const checksResult = active.find((r) => r.backend === 'checks'); + const diffResult = active.find((r) => r.backend === 'diff'); - if ( - checksResult?.impact === "positive" && - diffResult?.impact === "negative" - ) { - suspiciousPatterns.push( - "Checks pass but diff analysis shows concerns (untested changes?)", - ); + if (checksResult?.impact === 'positive' && diffResult?.impact === 'negative') { + suspiciousPatterns.push('Checks pass but diff analysis shows concerns (untested changes?)'); } - if ( - checksResult?.impact === "negative" && - diffResult?.impact === "positive" - ) { - suspiciousPatterns.push( - "Clean diff but failing checks (test failures?)", - ); + if (checksResult?.impact === 'negative' && diffResult?.impact === 'positive') { + suspiciousPatterns.push('Clean diff but failing checks (test failures?)'); } } if (hasDiff && hasQuality) { - const diffResult = active.find((r) => r.backend === "diff"); - const qualityResult = active.find((r) => r.backend === "quality"); + const diffResult = active.find((r) => r.backend === 'diff'); + const qualityResult = active.find((r) => r.backend === 'quality'); - if ( - diffResult?.impact === "positive" && - qualityResult?.impact === "negative" - ) { - suspiciousPatterns.push( - "Clean code changes but poor description (documentation debt)", - ); + if (diffResult?.impact === 'positive' && qualityResult?.impact === 'negative') { + suspiciousPatterns.push('Clean code changes but poor description (documentation debt)'); } } // Check for reinforcing signals if (dimensions.correctness > 0.5 && dimensions.maintainability > 0.5) { - reinforcingSignals.push( - "High correctness and maintainability scores align", - ); + reinforcingSignals.push('High correctness and maintainability scores align'); } 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 - const positiveCount = active.filter((r) => r.impact === "positive").length; - const negativeCount = active.filter((r) => r.impact === "negative").length; + const positiveCount = active.filter((r) => r.impact === 'positive').length; + const negativeCount = active.filter((r) => r.impact === 'negative').length; if (positiveCount > 0 && negativeCount > 0) { 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) { - contradictions.push("Correct implementation but high risk profile"); + contradictions.push('Correct implementation but high risk profile'); } return { @@ -317,30 +288,27 @@ export class Engine { private calculateOverall( active: BackendResult[], - dimensions: NonNullable, - correlations: NonNullable, - authorRep: ReturnType, + dimensions: NonNullable, + correlations: NonNullable, + authorRep: ReturnType ): { score: number; confidence: number; - uncertainty: NonNullable; + uncertainty: NonNullable; } { const totalWeight = active.reduce((s, r) => s + r.weight, 0); // Calculate weighted average of individual backend scores let baseScore = 0; for (const r of active) { - const impactScore = - r.impact === "positive" ? 1 : r.impact === "negative" ? -1 : 0; + const impactScore = r.impact === 'positive' ? 1 : r.impact === 'negative' ? -1 : 0; baseScore += impactScore * r.confidence * r.weight; } // Guard against division by zero when all weights are 0 if (totalWeight === 0) { baseScore = 0; - getLogger().debug( - "All backend weights are zero, defaulting baseScore to 0", - ); + getLogger().debug('All backend weights are zero, defaulting baseScore to 0'); } else { baseScore /= totalWeight; } @@ -364,12 +332,9 @@ export class Engine { let baseConfidence = 0; if (totalWeight === 0) { baseConfidence = 0; - getLogger().debug( - "All backend weights are zero, defaulting baseConfidence to 0", - ); + getLogger().debug('All backend weights are zero, defaulting baseConfidence to 0'); } else { - baseConfidence = - active.reduce((s, r) => s + r.confidence * r.weight, 0) / totalWeight; + baseConfidence = active.reduce((s, r) => s + r.confidence * r.weight, 0) / totalWeight; } // Adjust confidence based on various factors @@ -394,13 +359,13 @@ export class Engine { const upperBound = Math.min(1, baseConfidence + uncertaintyRange * 0.5); // Determine primary uncertainty source - let primaryUncertaintySource = "Backend confidence variance"; + let primaryUncertaintySource = 'Backend confidence variance'; if (uniqueImpacts.size > 1) { - primaryUncertaintySource = "Mixed backend signals"; + primaryUncertaintySource = 'Mixed backend signals'; } else if (authorRep.isNew) { - primaryUncertaintySource = "Limited author history"; + primaryUncertaintySource = 'Limited author history'; } else if (active.length < this.backends.length) { - primaryUncertaintySource = "Partial backend coverage"; + primaryUncertaintySource = 'Partial backend coverage'; } return { @@ -417,13 +382,13 @@ export class Engine { event: WebhookEvent, _allResults: BackendResult[], activeResults: BackendResult[], - dimensions: NonNullable, - correlations: NonNullable, + dimensions: NonNullable, + correlations: NonNullable, score: number, confidence: number, - uncertainty: NonNullable, + uncertainty: NonNullable, authorRep: ReturnType, - repoCtx: ReturnType, + repoCtx: ReturnType ): string { const parts: string[] = []; @@ -433,20 +398,15 @@ export class Engine { ` Correctness: ${(dimensions.correctness * 100).toFixed(0)}% | ` + `Risk: ${(dimensions.risk * 100).toFixed(0)}% | ` + `Maintainability: ${(dimensions.maintainability * 100).toFixed(0)}% | ` + - `Alignment: ${(dimensions.alignment * 100).toFixed(0)}%`, + `Alignment: ${(dimensions.alignment * 100).toFixed(0)}%` ); // Backend breakdown parts.push(`\nBackend Results:`); for (const r of activeResults) { - const icon = - r.impact === "positive" - ? "[+]" - : r.impact === "negative" - ? "[-]" - : "[~]"; + const icon = r.impact === 'positive' ? '[+]' : r.impact === 'negative' ? '[-]' : '[~]'; 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 parts.push(`\nContext:`); parts.push( - ` Author: ${event.author} (${authorRep.isNew ? "new" : `reputation: ${(authorRep.reputation * 100).toFixed(0)}%`})`, - ); - parts.push( - ` Repository: ${repoCtx.maturity} (${repoCtx.communitySize} active contributors)`, + ` Author: ${event.author} (${authorRep.isNew ? 'new' : `reputation: ${(authorRep.reputation * 100).toFixed(0)}%`})` ); + parts.push(` Repository: ${repoCtx.maturity} (${repoCtx.communitySize} active contributors)`); // Confidence and uncertainty parts.push(`\nConfidence Assessment:`); parts.push(` Overall: ${(confidence * 100).toFixed(0)}%`); parts.push( - ` Interval: [${(uncertainty.confidenceInterval[0] * 100).toFixed(0)}%, ${(uncertainty.confidenceInterval[1] * 100).toFixed(0)}%]`, - ); - parts.push( - ` Primary uncertainty: ${uncertainty.primaryUncertaintySource}`, + ` Interval: [${(uncertainty.confidenceInterval[0] * 100).toFixed(0)}%, ${(uncertainty.confidenceInterval[1] * 100).toFixed(0)}%]` ); + parts.push(` Primary uncertainty: ${uncertainty.primaryUncertaintySource}`); // Final assessment 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'); } } diff --git a/src/engine/quality.ts b/src/engine/quality.ts index 4aba1dd..3630044 100644 --- a/src/engine/quality.ts +++ b/src/engine/quality.ts @@ -28,15 +28,27 @@ export class QualityBackend implements EngineBackend { if (title.length < 10) { signals.push({ name: 'very short title', positive: false, weight: 1.2 }); } 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)) { - 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)) { - 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 --- @@ -52,7 +64,11 @@ export class QualityBackend implements EngineBackend { } else if (body.length >= this.config.minBodyLength) { signals.push({ name: 'adequate description', positive: true, weight: 1 }); 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)) { - signals.push({ name: 'has section headers', positive: true, weight: 0.8 }); + signals.push({ + name: 'has section headers', + positive: true, + weight: 0.8, + }); } // Checklists @@ -70,7 +90,11 @@ export class QualityBackend implements EngineBackend { const checked = checklistItems.filter((i) => /\[x\]/i.test(i)).length; const total = checklistItems.length; 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) { signals.push({ 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. // If there's a description of the breaking change, it's better. 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 { signals.push({ name: 'breaking change mentioned but not detailed', @@ -109,26 +137,46 @@ export class QualityBackend implements EngineBackend { if (event.type === 'issue') { 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)) { - 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 ( /\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)) { - 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) 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 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 if (/\b(before|after)\b/i.test(body) && /\b(before|after)\b/gi.test(body)) { const beforeAfter = body.match(/\b(before|after)\b/gi); 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 if (/!\[.*\]\(.*\)/.test(body) || / s.positive).reduce((s, x) => s + x.weight, 0); diff --git a/src/filters.ts b/src/filters.ts index 3d6f36a..f14793c 100644 --- a/src/filters.ts +++ b/src/filters.ts @@ -1,53 +1,45 @@ -import type { FiltersConfig, WebhookEvent } from "./types.js"; +import type { FiltersConfig, WebhookEvent } from './types.js'; export function shouldProcess( event: WebhookEvent, - filters: FiltersConfig, + filters: FiltersConfig ): { pass: boolean; reason?: string } { // Label filters if (filters.labels.include.length > 0) { - const hasRequired = event.labels.some((l) => - filters.labels.include.includes(l), - ); + const hasRequired = event.labels.some((l) => filters.labels.include.includes(l)); if (!hasRequired) { - return { pass: false, reason: "Missing required label" }; + return { pass: false, reason: 'Missing required label' }; } } if (filters.labels.exclude.length > 0) { - const hasExcluded = event.labels.some((l) => - filters.labels.exclude.includes(l), - ); + const hasExcluded = event.labels.some((l) => filters.labels.exclude.includes(l)); if (hasExcluded) { - return { pass: false, reason: "Has excluded label" }; + return { pass: false, reason: 'Has excluded label' }; } } // Author filters if (filters.authors.include && filters.authors.include.length > 0) { const normalizedAuthor = event.author.toLowerCase(); - const hasIncluded = filters.authors.include.some( - (a) => a.toLowerCase() === normalizedAuthor, - ); + const hasIncluded = filters.authors.include.some((a) => a.toLowerCase() === normalizedAuthor); 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) { const normalizedAuthor = event.author.toLowerCase(); - const isExcluded = filters.authors.exclude.some( - (a) => a.toLowerCase() === normalizedAuthor, - ); + const isExcluded = filters.authors.exclude.some((a) => a.toLowerCase() === normalizedAuthor); if (isExcluded) { - return { pass: false, reason: "Author is excluded" }; + return { pass: false, reason: 'Author is excluded' }; } } // Branch filters (PRs only) if (event.branch && filters.branches.include.length > 0) { 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' }; } } diff --git a/src/github.ts b/src/github.ts index 29dc69e..6b6a8da 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,14 +1,12 @@ -import { Octokit } from "@octokit/rest"; -import { getLogger } from "./logger.js"; -import type { CheckRun, PRFile } from "./types.js"; +import { Octokit } from '@octokit/rest'; +import { getLogger } from './logger.js'; +import type { CheckRun, PRFile } from './types.js'; let octokit: Octokit | null = null; export function initGitHub(token?: string): void { if (!token) { - getLogger().warn( - "No GITHUB_TOKEN set - running in dry-run mode, comments will not be posted", - ); + getLogger().warn('No GITHUB_TOKEN set - running in dry-run mode, comments will not be posted'); return; } octokit = new Octokit({ auth: token }); @@ -23,12 +21,10 @@ export async function postComment( owner: string, repo: string, issueNumber: number, - body: string, + body: string ): Promise { if (!octokit) { - getLogger().info( - `[dry-run] Would post comment on ${owner}/${repo}#${issueNumber}:\n${body}`, - ); + getLogger().info(`[dry-run] Would post comment on ${owner}/${repo}#${issueNumber}:\n${body}`); return; } await octokit.issues.createComment({ @@ -44,7 +40,7 @@ export async function hasExistingComment( owner: string, repo: string, issueNumber: number, - marker: string, + marker: string ): Promise<{ exists: boolean; commentId?: number }> { if (!octokit) { return { exists: false }; @@ -68,7 +64,7 @@ export async function updateComment( owner: string, repo: string, commentId: number, - body: string, + body: string ): Promise { if (!octokit) { getLogger().info(`[dry-run] Would update comment ${commentId}:\n${body}`); @@ -88,41 +84,26 @@ export async function createReaction( repo: string, commentId: number, reaction: - | "thumbs_up" - | "thumbs_down" - | "laugh" - | "confused" - | "heart" - | "hooray" - | "eyes" - | "rocket", + | 'thumbs_up' + | 'thumbs_down' + | 'laugh' + | 'confused' + | 'heart' + | 'hooray' + | 'eyes' + | 'rocket' ): Promise { if (!octokit) { - getLogger().info( - `[dry-run] Would add ${reaction} reaction to comment ${commentId}`, - ); + getLogger().info(`[dry-run] Would add ${reaction} reaction to comment ${commentId}`); return; } // Map thumbs_up/thumbs_down to GitHub API format (+1/-1) - const content = - reaction === "thumbs_up" - ? "+1" - : reaction === "thumbs_down" - ? "-1" - : reaction; + const content = reaction === 'thumbs_up' ? '+1' : reaction === 'thumbs_down' ? '-1' : reaction; await octokit.reactions.createForIssueComment({ owner, repo, comment_id: commentId, - content: content as - | "+1" - | "-1" - | "laugh" - | "confused" - | "heart" - | "hooray" - | "eyes" - | "rocket", + content: content as '+1' | '-1' | 'laugh' | 'confused' | 'heart' | 'hooray' | 'eyes' | 'rocket', }); getLogger().info(`Added ${reaction} reaction to comment ${commentId}`); } @@ -131,10 +112,10 @@ export async function createReaction( export async function fetchCheckRuns( owner: string, repo: string, - ref: string, + ref: string ): Promise { 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 []; } @@ -155,10 +136,10 @@ export async function fetchCheckRuns( export async function fetchPRFiles( owner: string, repo: string, - prNumber: number, + prNumber: number ): Promise { 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 []; } @@ -180,7 +161,7 @@ export async function fetchPRFiles( export async function fetchPR( owner: string, repo: string, - prNumber: number, + prNumber: number ): Promise<{ title: string; body: string; @@ -199,11 +180,9 @@ export async function fetchPR( }); return { title: data.title, - body: data.body || "", - author: data.user?.login || "", - labels: (data.labels || []).map((l) => - typeof l === "string" ? l : l.name || "", - ), + body: data.body || '', + author: data.user?.login || '', + labels: (data.labels || []).map((l) => (typeof l === 'string' ? l : l.name || '')), branch: data.head.ref, sha: data.head.sha, }; @@ -216,7 +195,7 @@ export async function fetchPR( export async function fetchIssue( owner: string, repo: string, - issueNumber: number, + issueNumber: number ): Promise<{ title: string; body: string; @@ -233,17 +212,12 @@ export async function fetchIssue( }); return { title: data.title, - body: data.body || "", - author: data.user?.login || "", - labels: (data.labels || []).map((l) => - typeof l === "string" ? l : l.name || "", - ), + body: data.body || '', + author: data.user?.login || '', + labels: (data.labels || []).map((l) => (typeof l === 'string' ? l : l.name || '')), }; } catch (err) { - getLogger().debug( - `Failed to fetch issue ${owner}/${repo}#${issueNumber}`, - err, - ); + getLogger().debug(`Failed to fetch issue ${owner}/${repo}#${issueNumber}`, err); return null; } } @@ -252,22 +226,19 @@ export async function listAccessibleRepositories(): Promise< Array<{ owner: string; repo: string }> > { if (!octokit) { - getLogger().debug("[dry-run] Cannot fetch repositories without a token"); + getLogger().debug('[dry-run] Cannot fetch repositories without a token'); return []; } const repos: Array<{ owner: string; repo: string }> = []; - for await (const response of octokit.paginate.iterator( - octokit.repos.listForAuthenticatedUser, - { - per_page: 100, - sort: "updated", - }, - )) { + for await (const response of octokit.paginate.iterator(octokit.repos.listForAuthenticatedUser, { + per_page: 100, + sort: 'updated', + })) { for (const repo of response.data) { - if (!repo.full_name || typeof repo.full_name !== "string") continue; - const parts = repo.full_name.split("/"); + if (!repo.full_name || typeof repo.full_name !== 'string') continue; + const parts = repo.full_name.split('/'); if (parts.length < 2 || !parts[0] || !parts[1]) continue; const [owner, repoName] = parts; repos.push({ owner, repo: repoName }); @@ -289,10 +260,10 @@ export interface RecentComment { export async function listRecentComments( owner: string, repo: string, - since: Date, + since: Date ): Promise { if (!octokit) { - getLogger().debug("[dry-run] Cannot fetch comments without a token"); + getLogger().debug('[dry-run] Cannot fetch comments without a token'); return []; } @@ -300,15 +271,12 @@ export async function listRecentComments( const comments: RecentComment[] = []; // Fetch recent issue comments - const issueComments = await octokit.paginate( - octokit.issues.listCommentsForRepo, - { - owner, - repo, - since: sinceIso, - per_page: 100, - }, - ); + const issueComments = await octokit.paginate(octokit.issues.listCommentsForRepo, { + owner, + repo, + since: sinceIso, + per_page: 100, + }); for (const comment of issueComments) { if (!comment.body) continue; @@ -316,11 +284,9 @@ export async function listRecentComments( comments.push({ id: comment.id, body: comment.body, - author: comment.user?.login || "", + author: comment.user?.login || '', createdAt: comment.created_at, - issueNumber: comment.issue_url - ? parseInt(comment.issue_url.split("/").pop() || "0", 10) - : 0, + issueNumber: comment.issue_url ? parseInt(comment.issue_url.split('/').pop() || '0', 10) : 0, isPullRequest: false, // we'll determine this by fetching the issue }); } @@ -344,18 +310,18 @@ export function formatComment( neutral: string[]; }; }, - type: "issue" | "pull_request", + type: 'issue' | 'pull_request', impact: string, confidence: number, - reasoning: string, + reasoning: string ): string { - const typeLabel = type === "pull_request" ? "pull request" : "issue"; + const typeLabel = type === 'pull_request' ? 'pull request' : 'issue'; const { messages } = responseConfig; let messageList: string[]; - if (impact === "positive") { + if (impact === 'positive') { messageList = messages.positive; - } else if (impact === "negative") { + } else if (impact === 'negative') { messageList = messages.negative; } else { messageList = messages.neutral; @@ -363,10 +329,8 @@ export function formatComment( const template = pickRandom(messageList); - let body = responseConfig.commentMarker + "\n\n"; - body += template - .replace(/\{type\}/g, typeLabel) - .replace(/\{impact\}/g, impact); + let body = responseConfig.commentMarker + '\n\n'; + body += template.replace(/\{type\}/g, typeLabel).replace(/\{impact\}/g, impact); if (responseConfig.includeConfidence) { body += `\n\n**Confidence:** ${(confidence * 100).toFixed(0)}%`; diff --git a/src/index.ts b/src/index.ts index a59a62a..aacebae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ async function analyzeOne(target: string) { const [, owner, repo, numStr] = match; const prNumber = parseInt(numStr, 10); - const config = loadConfig(); + const config = await loadConfig(); initLogger(config.logging); const logger = getLogger(); @@ -104,8 +104,8 @@ async function analyzeOne(target: string) { } } -function serve() { - const config = loadConfig(); +async function serve() { + const config = await loadConfig(); initLogger(config.logging); const logger = getLogger(); @@ -192,5 +192,8 @@ if (args[0] === 'analyze' && args[1]) { process.exit(1); }); } else { - serve(); + serve().catch((err) => { + console.error(err); + process.exit(1); + }); } diff --git a/src/polling.ts b/src/polling.ts index 2d4edc1..6859d00 100644 --- a/src/polling.ts +++ b/src/polling.ts @@ -1,4 +1,4 @@ -import type { Config, WebhookEvent, RepoPattern } from "./types.js"; +import type { Config, WebhookEvent, RepoPattern } from './types.js'; import { listRecentComments, listAccessibleRepositories, @@ -10,11 +10,11 @@ import { formatComment, createReaction, type RecentComment, -} from "./github.js"; -import { createEngine } from "./engine/index.js"; -import { getLogger } from "./logger.js"; -import { recordEvent } from "./events.js"; -import { readFileSync, writeFileSync, existsSync } from "fs"; +} from './github.js'; +import { createEngine } from './engine/index.js'; +import { getLogger } from './logger.js'; +import { recordEvent } from './events.js'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; interface ProcessedComment { id: number; @@ -35,20 +35,18 @@ let pollingState: PollingState = { lastProcessedAt: {} }; function loadPollingState(stateFile: string): void { if (existsSync(stateFile)) { try { - const data = readFileSync(stateFile, "utf-8"); + const data = readFileSync(stateFile, 'utf-8'); const parsed = JSON.parse(data); // Validate that parsed data has expected structure if ( parsed && - typeof parsed === "object" && + typeof parsed === 'object' && parsed.lastProcessedAt && - typeof parsed.lastProcessedAt === "object" + typeof parsed.lastProcessedAt === 'object' ) { pollingState = parsed; } else { - getLogger().warn( - "Invalid polling state format, resetting to empty state", - ); + getLogger().warn('Invalid polling state format, resetting to empty state'); pollingState = { lastProcessedAt: {} }; } } catch { @@ -62,7 +60,7 @@ function savePollingState(stateFile: string): void { try { writeFileSync(stateFile, JSON.stringify(pollingState, null, 2)); } 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( - owner: string, - repo: string, - commentId: number, -): boolean { +function recordFailure(owner: string, repo: string, commentId: number): boolean { const key = getCacheKey(owner, repo, commentId); const existing = processedComments.get(key); @@ -127,12 +121,12 @@ function recordFailure( } function containsMention(body: string): boolean { - return body.includes("@troutbot"); + return body.includes('@troutbot'); } async function analyzeAndComment( event: WebhookEvent, - config: Config, + config: Config ): Promise> { const logger = getLogger(); const engine = createEngine(config.engine); @@ -140,23 +134,16 @@ async function analyzeAndComment( // 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)}`, + `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, - ); + 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" }; + 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; } @@ -167,13 +154,11 @@ async function analyzeAndComment( event.type, analysis.impact, analysis.confidence, - analysis.reasoning, + analysis.reasoning ); if (existing.exists && allowUpdates && existing.commentId) { - logger.info( - `Updating existing comment on ${event.owner}/${event.repo}#${event.number}`, - ); + 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); @@ -196,22 +181,16 @@ function isAuthorized(username: string, authorizedUsers?: string[]): boolean { return authorizedUsers.some((u) => u.toLowerCase() === normalizedUsername); } -function isRepoAuthorized( - owner: string, - repo: string, - pollingPatterns?: RepoPattern[], -): boolean { +function isRepoAuthorized(owner: string, repo: string, pollingPatterns?: RepoPattern[]): boolean { if (!pollingPatterns || pollingPatterns.length === 0) { return true; // No restrictions, accept all repos } // Check if repo matches any pattern for (const pattern of pollingPatterns) { - const ownerMatch = - pattern.owner === "*" || - pattern.owner.toLowerCase() === owner.toLowerCase(); + const ownerMatch = pattern.owner === '*' || pattern.owner.toLowerCase() === owner.toLowerCase(); const repoMatch = - pattern.repo === "*" || + pattern.repo === '*' || pattern.repo === undefined || pattern.repo.toLowerCase() === repo.toLowerCase(); @@ -227,7 +206,7 @@ async function processComment( comment: RecentComment, owner: string, repo: string, - config: Config, + config: Config ): Promise { const logger = getLogger(); @@ -236,9 +215,7 @@ async function processComment( } if (isProcessed(owner, repo, comment.id)) { - logger.debug( - `Comment ${owner}/${repo}#${comment.id} already processed, skipping`, - ); + logger.debug(`Comment ${owner}/${repo}#${comment.id} already processed, skipping`); return; } @@ -246,9 +223,9 @@ async function processComment( const pollingRepos = config.polling?.repositories; if (!isRepoAuthorized(owner, repo, pollingRepos)) { 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); return; } @@ -257,15 +234,13 @@ async function processComment( const authorizedUsers = config.polling?.authorizedUsers; if (!isAuthorized(comment.author, authorizedUsers)) { 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); return; } - logger.info( - `Found @troutbot mention in ${owner}/${repo}#${comment.issueNumber}`, - ); + 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 @@ -276,8 +251,8 @@ async function processComment( if (prData) { // It's a pull request event = { - action: "on_demand", - type: "pull_request", + action: 'on_demand', + type: 'pull_request', number: comment.issueNumber, title: prData.title, body: prData.body, @@ -292,15 +267,13 @@ async function processComment( // It's an issue const issueData = await fetchIssue(owner, repo, comment.issueNumber); if (!issueData) { - logger.warn( - `Could not fetch issue ${owner}/${repo}#${comment.issueNumber}`, - ); + logger.warn(`Could not fetch issue ${owner}/${repo}#${comment.issueNumber}`); return; } event = { - action: "on_demand", - type: "issue", + action: 'on_demand', + type: 'issue', number: comment.issueNumber, title: issueData.title, body: issueData.body, @@ -315,18 +288,15 @@ async function processComment( markProcessed(owner, repo, comment.id); logger.info( - `Successfully processed on-demand analysis for ${owner}/${repo}#${comment.issueNumber}`, + `Successfully processed on-demand analysis for ${owner}/${repo}#${comment.issueNumber}` ); } catch (err) { - logger.error( - `Failed to process mention in ${owner}/${repo}#${comment.issueNumber}`, - err, - ); + logger.error(`Failed to process mention in ${owner}/${repo}#${comment.issueNumber}`, err); // Track failures and mark as processed after max retries const shouldStop = recordFailure(owner, repo, comment.id); if (shouldStop) { 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); } @@ -338,15 +308,13 @@ async function pollRepository( repo: string, config: Config, since: Date, - stateFile?: string, + stateFile?: string ): Promise { const logger = getLogger(); try { const comments = await listRecentComments(owner, repo, since); - logger.debug( - `Fetched ${comments.length} recent comments from ${owner}/${repo}`, - ); + logger.debug(`Fetched ${comments.length} recent comments from ${owner}/${repo}`); let latestCommentDate = since; @@ -373,7 +341,7 @@ export async function startPolling(config: Config): Promise { const pollingConfig = config.polling; if (!pollingConfig || !pollingConfig.enabled) { - logger.info("Polling is disabled"); + logger.info('Polling is disabled'); return; } @@ -384,9 +352,7 @@ export async function startPolling(config: Config): Promise { if (!pollingPatterns || pollingPatterns.length === 0) { // No patterns configured - poll all accessible repos reposToPoll = await listAccessibleRepositories(); - logger.info( - `Polling all accessible repositories (${reposToPoll.length} repos)`, - ); + logger.info(`Polling all accessible repositories (${reposToPoll.length} repos)`); } else { // Build repo list from patterns reposToPoll = []; @@ -413,7 +379,7 @@ export async function startPolling(config: Config): Promise { } if (reposToPoll.length === 0) { - logger.warn("No repositories match polling patterns"); + logger.warn('No repositories match polling patterns'); return; } @@ -421,12 +387,12 @@ export async function startPolling(config: Config): Promise { const lookbackMs = pollingConfig.lookbackMinutes * 60 * 1000; 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 const stateFile = pollingConfig.backfill - ? pollingConfig.stateFile || ".troutbot-polling-state.json" + ? pollingConfig.stateFile || '.troutbot-polling-state.json' : undefined; if (stateFile) { loadPollingState(stateFile); @@ -439,16 +405,10 @@ export async function startPolling(config: Config): Promise { const initialSince = lastProcessed || new Date(Date.now() - lookbackMs); if (lastProcessed) { logger.info( - `Resuming polling for ${repo.owner}/${repo.repo} from ${lastProcessed.toISOString()}`, + `Resuming polling for ${repo.owner}/${repo.repo} from ${lastProcessed.toISOString()}` ); } - await pollRepository( - repo.owner, - repo.repo, - config, - initialSince, - stateFile, - ); + await pollRepository(repo.owner, repo.repo, config, initialSince, stateFile); } // Set up recurring polling diff --git a/src/server.ts b/src/server.ts index 17ad3d3..31e5898 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import express from 'express'; 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 { createEngine } from './engine/index.js'; import { @@ -109,7 +109,10 @@ export function createApp(config: Config): express.Express { res.json(result); return; } - res.json({ skipped: true, reason: 'Comment does not mention @troutbot' }); + res.json({ + skipped: true, + reason: 'Comment does not mention @troutbot', + }); return; } @@ -189,6 +192,7 @@ async function analyzeAndComment( return result; } + // Generate comment with analysis const body = formatComment( config.response, event.type, @@ -203,7 +207,11 @@ async function analyzeAndComment( 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); return result; } @@ -269,12 +277,26 @@ function isAuthorized(username: string, authorizedUsers?: string[]): boolean { function isRepoAuthorizedForPolling( owner: string, repo: string, - pollingRepos?: RepoConfig[] + pollingPatterns?: RepoPattern[] ): boolean { - if (!pollingRepos || pollingRepos.length === 0) { - return true; // no restrictions, use global repos + if (!pollingPatterns || pollingPatterns.length === 0) { + 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( diff --git a/src/types.ts b/src/types.ts index 77a8680..50dd658 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,7 +31,7 @@ export interface DashboardConfig { } export interface DashboardAuthConfig { - type: "basic" | "token"; + type: 'basic' | 'token'; username?: string; password?: string; token?: string; @@ -111,7 +111,7 @@ export interface LoggingConfig { file: string; } -export type Impact = "positive" | "negative" | "neutral"; +export type Impact = 'positive' | 'negative' | 'neutral'; export interface AnalysisResult { impact: Impact; @@ -144,7 +144,7 @@ export interface EngineBackend { export interface WebhookEvent { action: string; - type: "issue" | "pull_request"; + type: 'issue' | 'pull_request'; number: number; title: string; body: string;