chore: format with prettier
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ib25c8bc022433f1dff87e9f6aeff4a726a6a6964
This commit is contained in:
parent
773ea27295
commit
cfb114e529
12 changed files with 437 additions and 462 deletions
|
|
@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
138
src/github.ts
138
src/github.ts
|
|
@ -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,
|
per_page: 100,
|
||||||
sort: "updated",
|
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,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
since: sinceIso,
|
since: sinceIso,
|
||||||
per_page: 100,
|
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)}%`;
|
||||||
|
|
|
||||||
11
src/index.ts
11
src/index.ts
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
src/polling.ts
132
src/polling.ts
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue