Compare commits
7 commits
70443c83ce
...
00d9c5c2d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
00d9c5c2d4 |
|||
|
4f4f67dc6c |
|||
|
cfb114e529 |
|||
|
773ea27295 |
|||
|
38105ec09c |
|||
|
c1104e2f67 |
|||
|
e854522ebe |
18 changed files with 3090 additions and 698 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.troutbot-context.json
|
||||||
|
|
|
||||||
|
|
@ -122,11 +122,17 @@ const config: Config = {
|
||||||
// Authorized users: Only these users can trigger on-demand analysis via @troutbot mentions
|
// Authorized users: Only these users can trigger on-demand analysis via @troutbot mentions
|
||||||
// Leave empty to allow all users (not recommended for public repos)
|
// Leave empty to allow all users (not recommended for public repos)
|
||||||
// authorizedUsers: ['trusted-user-1', 'trusted-user-2'],
|
// authorizedUsers: ['trusted-user-1', 'trusted-user-2'],
|
||||||
// Polling-specific repositories: Override global repositories list for polling only
|
// Polling-specific repository patterns: Control which repos to poll for @troutbot mentions
|
||||||
// If set, only these repos will be polled for @troutbot mentions
|
// Supports wildcards to match multiple repos
|
||||||
// Unauthorized repos will get a thumbsdown reaction and be ignored
|
// If empty/undefined: poll all repositories the bot can access
|
||||||
// repositories: [
|
// repositories: [
|
||||||
|
// // Single repo
|
||||||
// { owner: 'myorg', repo: 'myrepo' },
|
// { owner: 'myorg', repo: 'myrepo' },
|
||||||
|
// // All repos under an owner/organization
|
||||||
|
// { owner: 'myorg', repo: '*' },
|
||||||
|
// // Multiple specific repos
|
||||||
|
// { owner: 'owner1', repo: 'repo1' },
|
||||||
|
// { owner: 'owner2', repo: 'repo2' },
|
||||||
// ],
|
// ],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,6 @@ in {
|
||||||
};
|
};
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
port = mkOption {
|
|
||||||
type = port;
|
|
||||||
default = 3000;
|
|
||||||
};
|
|
||||||
|
|
||||||
user = mkOption {
|
user = mkOption {
|
||||||
type = str;
|
type = str;
|
||||||
default = "troutbot";
|
default = "troutbot";
|
||||||
|
|
@ -58,6 +53,12 @@ in {
|
||||||
default = "troutbot";
|
default = "troutbot";
|
||||||
description = "Group to run Troutbot under";
|
description = "Group to run Troutbot under";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
port = mkOption {
|
||||||
|
type = port;
|
||||||
|
default = 3000;
|
||||||
|
description = "Port to bind to";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -71,8 +72,7 @@ in {
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services.troutbot = {
|
systemd.services.troutbot = {
|
||||||
description = "Troutbot - GitHub PR/Issue Analysis Bot";
|
description = "Troutbot";
|
||||||
documentation = ["https://github.com/notashelf/troutbot"];
|
|
||||||
after = ["network-online.target"];
|
after = ["network-online.target"];
|
||||||
wants = ["network-online.target"];
|
wants = ["network-online.target"];
|
||||||
wantedBy = ["multi-user.target"];
|
wantedBy = ["multi-user.target"];
|
||||||
|
|
@ -80,94 +80,40 @@ in {
|
||||||
environment = {
|
environment = {
|
||||||
NODE_ENV = "production";
|
NODE_ENV = "production";
|
||||||
CONFIG_PATH = cfg.configPath;
|
CONFIG_PATH = cfg.configPath;
|
||||||
PORT = toString cfg.port;
|
PORT = toString cfg.settings.port;
|
||||||
};
|
};
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "simple";
|
Type = "simple";
|
||||||
User = cfg.user;
|
User = cfg.settings.user;
|
||||||
Group = cfg.group;
|
Group = cfg.settings.group;
|
||||||
ExecStart = "${lib.getExe cfg.package}";
|
ExecStart = "${lib.getExe cfg.package}";
|
||||||
|
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
|
||||||
|
|
||||||
# Restart policy with rate limiting
|
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = "5s";
|
RestartSec = "5s";
|
||||||
StartLimitInterval = "60s";
|
|
||||||
StartLimitBurst = 3;
|
|
||||||
|
|
||||||
# Timeouts
|
TimeoutStartSec = "10s";
|
||||||
TimeoutStartSec = "30s";
|
TimeoutStopSec = "10s";
|
||||||
TimeoutStopSec = "30s";
|
|
||||||
|
|
||||||
# Working directory and state
|
|
||||||
WorkingDirectory = "/var/lib/troutbot";
|
WorkingDirectory = "/var/lib/troutbot";
|
||||||
StateDirectory = "troutbot";
|
StateDirectory = "troutbot";
|
||||||
StateDirectoryMode = "0750";
|
StateDirectoryMode = "0750";
|
||||||
|
|
||||||
# Environment file for secrets
|
# Hardening
|
||||||
EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
|
|
||||||
|
|
||||||
# Security hardening
|
|
||||||
# Filesystem restrictions
|
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
ProtectHome = true;
|
ProtectHome = true;
|
||||||
PrivateTmp = true;
|
PrivateTmp = true;
|
||||||
PrivateDevices = true;
|
PrivateDevices = true;
|
||||||
|
|
||||||
# Process restrictions
|
|
||||||
NoNewPrivileges = true;
|
NoNewPrivileges = true;
|
||||||
|
|
||||||
ProtectKernelTunables = true;
|
ProtectKernelTunables = true;
|
||||||
ProtectKernelModules = true;
|
ProtectKernelModules = true;
|
||||||
ProtectControlGroups = true;
|
ProtectControlGroups = true;
|
||||||
ProtectClock = true;
|
ProtectClock = true;
|
||||||
|
|
||||||
# Memory and execution restrictions
|
|
||||||
MemoryDenyWriteExecute = true;
|
|
||||||
RestrictRealtime = true;
|
|
||||||
RestrictSUIDSGID = true;
|
|
||||||
LockPersonality = true;
|
LockPersonality = true;
|
||||||
|
|
||||||
# IPC cleanup
|
|
||||||
RemoveIPC = true;
|
|
||||||
|
|
||||||
# Capabilities
|
|
||||||
CapabilityBoundingSet = [""]; # no capabilities needed
|
|
||||||
AmbientCapabilities = []; # no ambient capabilities
|
|
||||||
|
|
||||||
# System call filtering
|
|
||||||
SystemCallFilter = [
|
|
||||||
"@system-service"
|
|
||||||
"~@privileged"
|
|
||||||
"~@resources"
|
|
||||||
];
|
|
||||||
SystemCallErrorNumber = "EPERM";
|
|
||||||
|
|
||||||
# Address families, only IPv4/IPv6 needed
|
|
||||||
RestrictAddressFamilies = [
|
|
||||||
"AF_INET"
|
|
||||||
"AF_INET6"
|
|
||||||
"AF_UNIX"
|
|
||||||
"AF_NETLINK" # for DNS resolution
|
|
||||||
];
|
|
||||||
|
|
||||||
UMask = "0027";
|
|
||||||
DeviceAllow = [];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "simple";
|
|
||||||
User = cfg.settings.user;
|
|
||||||
Group = cfg.settings.group;
|
|
||||||
ExecStart = "${lib.getExe cfg.package}";
|
|
||||||
Restart = "on-failure";
|
|
||||||
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
|
|
||||||
WorkingDirectory = "/var/lib/troutbot";
|
|
||||||
StateDirectory = "troutbot";
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ProtectHome = true;
|
|
||||||
PrivateTmp = true;
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
pnpmDeps = fetchPnpmDeps {
|
pnpmDeps = fetchPnpmDeps {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-y8LV1D+EgGcZ79lmxS20dqYBPEfk4atma+RWf7pJI30=";
|
hash = "sha256-96VRuwKes2FrOUFrlKjFBhdD7xC0W3vNNhT2TJWeJGc=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
|
|
|
||||||
12
package.json
12
package.json
|
|
@ -1,14 +1,18 @@
|
||||||
{
|
{
|
||||||
"name": "troutbot",
|
"name": "troutbot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "GitHub webhook bot that analyzes issues and PRs for impact on the trout population",
|
"description": "GitHub webhook bot that analyzes issues and PRs for impact on trout population",
|
||||||
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format cjs --dts --clean",
|
"build": "tsup src/index.ts --format cjs --dts --clean",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"fmt": "prettier --write ."
|
"fmt": "prettier --write .",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
|
|
@ -23,10 +27,12 @@
|
||||||
"@types/node": "^25.1.0",
|
"@types/node": "^25.1.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
"@typescript-eslint/parser": "^8.54.0",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2567
pnpm-lock.yaml
generated
2567
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,10 @@
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createJiti } from 'jiti';
|
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import type { Config } from './types.js';
|
import type { Config } from './types.js';
|
||||||
|
|
||||||
dotenv.config();
|
// Suppress dotenv warnings
|
||||||
|
dotenv.config({ quiet: true });
|
||||||
const jiti = createJiti(__filename, { interopDefault: true });
|
|
||||||
|
|
||||||
const defaults: Config = {
|
const defaults: Config = {
|
||||||
server: { port: 3000 },
|
server: { port: 3000 },
|
||||||
|
|
@ -83,14 +81,14 @@ export function deepMerge<T extends Record<string, unknown>>(target: T, source:
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(): 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 = jiti(resolvedPath) as Partial<Config> | { default: Partial<Config> };
|
const loaded = await import(resolvedPath);
|
||||||
fileConfig = 'default' in loaded ? loaded.default : loaded;
|
fileConfig = '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}`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
274
src/engine/context.ts
Normal file
274
src/engine/context.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
import type { AnalysisResult, WebhookEvent } from '../types.js';
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
|
import { getLogger } from '../logger.js';
|
||||||
|
|
||||||
|
// Author reputation tracking
|
||||||
|
interface AuthorStats {
|
||||||
|
totalContributions: number;
|
||||||
|
positiveImpacts: number;
|
||||||
|
negativeImpacts: number;
|
||||||
|
neutralImpacts: number;
|
||||||
|
averageConfidence: number;
|
||||||
|
lastSeen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository pattern tracking
|
||||||
|
interface RepoPatterns {
|
||||||
|
typicalPRSize: number;
|
||||||
|
typicalFileCount: number;
|
||||||
|
commonLabels: string[];
|
||||||
|
activeAuthors: string[];
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context data structure
|
||||||
|
interface ContextData {
|
||||||
|
authors: Record<string, AuthorStats>;
|
||||||
|
repositories: Record<string, RepoPatterns>;
|
||||||
|
globalStats: {
|
||||||
|
totalAnalyses: number;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTEXT_VERSION = 1;
|
||||||
|
const MAX_ACTIVE_AUTHORS = 100;
|
||||||
|
|
||||||
|
function createDefaultContext(): ContextData {
|
||||||
|
return {
|
||||||
|
authors: {},
|
||||||
|
repositories: {},
|
||||||
|
globalStats: {
|
||||||
|
totalAnalyses: 0,
|
||||||
|
version: CONTEXT_VERSION,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let contextData: ContextData = createDefaultContext();
|
||||||
|
let contextFile: string | null = null;
|
||||||
|
|
||||||
|
export function initContext(stateFile?: string): void {
|
||||||
|
contextFile = stateFile || '.troutbot-context.json';
|
||||||
|
loadContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadContext(): void {
|
||||||
|
if (!contextFile || !existsSync(contextFile)) {
|
||||||
|
contextData = createDefaultContext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = readFileSync(contextFile, 'utf-8');
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
|
||||||
|
// Validate structure
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === 'object' &&
|
||||||
|
parsed.globalStats?.version === CONTEXT_VERSION &&
|
||||||
|
typeof parsed.authors === 'object' &&
|
||||||
|
parsed.authors !== null &&
|
||||||
|
!Array.isArray(parsed.authors) &&
|
||||||
|
typeof parsed.repositories === 'object' &&
|
||||||
|
parsed.repositories !== null &&
|
||||||
|
!Array.isArray(parsed.repositories)
|
||||||
|
) {
|
||||||
|
contextData = parsed;
|
||||||
|
} else {
|
||||||
|
getLogger().warn('Invalid context format, resetting');
|
||||||
|
contextData = createDefaultContext();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
contextData = createDefaultContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveContext(): void {
|
||||||
|
if (!contextFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(contextFile, JSON.stringify(contextData, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
getLogger().warn('Failed to save context', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateContext(event: WebhookEvent, result: AnalysisResult): void {
|
||||||
|
const author = event.author;
|
||||||
|
const repo = `${event.owner}/${event.repo}`;
|
||||||
|
|
||||||
|
// Update author stats
|
||||||
|
if (!contextData.authors[author]) {
|
||||||
|
contextData.authors[author] = {
|
||||||
|
totalContributions: 0,
|
||||||
|
positiveImpacts: 0,
|
||||||
|
negativeImpacts: 0,
|
||||||
|
neutralImpacts: 0,
|
||||||
|
averageConfidence: 0,
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorStats = contextData.authors[author];
|
||||||
|
authorStats.totalContributions++;
|
||||||
|
authorStats.lastSeen = new Date().toISOString();
|
||||||
|
|
||||||
|
if (result.impact === 'positive') authorStats.positiveImpacts++;
|
||||||
|
else if (result.impact === 'negative') authorStats.negativeImpacts++;
|
||||||
|
else authorStats.neutralImpacts++;
|
||||||
|
|
||||||
|
// Update running average confidence
|
||||||
|
authorStats.averageConfidence =
|
||||||
|
(authorStats.averageConfidence * (authorStats.totalContributions - 1) + result.confidence) /
|
||||||
|
authorStats.totalContributions;
|
||||||
|
|
||||||
|
// Update repo patterns (simplified)
|
||||||
|
if (!contextData.repositories[repo]) {
|
||||||
|
contextData.repositories[repo] = {
|
||||||
|
typicalPRSize: 0,
|
||||||
|
typicalFileCount: 0,
|
||||||
|
commonLabels: [],
|
||||||
|
activeAuthors: [],
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoPatterns = contextData.repositories[repo];
|
||||||
|
if (!repoPatterns.activeAuthors.includes(author)) {
|
||||||
|
repoPatterns.activeAuthors.push(author);
|
||||||
|
// Enforce max size to prevent unbounded growth
|
||||||
|
if (repoPatterns.activeAuthors.length > MAX_ACTIVE_AUTHORS) {
|
||||||
|
repoPatterns.activeAuthors.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repoPatterns.lastUpdated = new Date().toISOString();
|
||||||
|
|
||||||
|
// Update global stats
|
||||||
|
contextData.globalStats.totalAnalyses++;
|
||||||
|
|
||||||
|
saveContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthorReputation(author: string): {
|
||||||
|
isTrusted: boolean;
|
||||||
|
isNew: boolean;
|
||||||
|
reputation: number;
|
||||||
|
history: string;
|
||||||
|
} {
|
||||||
|
const stats = contextData.authors[author];
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return {
|
||||||
|
isTrusted: false,
|
||||||
|
isNew: true,
|
||||||
|
reputation: 0,
|
||||||
|
history: 'First-time contributor',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const successRate =
|
||||||
|
stats.totalContributions > 0 ? stats.positiveImpacts / stats.totalContributions : 0;
|
||||||
|
|
||||||
|
const reputation = Math.min(
|
||||||
|
1,
|
||||||
|
successRate * 0.6 +
|
||||||
|
(Math.min(stats.totalContributions, 20) / 20) * 0.3 +
|
||||||
|
stats.averageConfidence * 0.1
|
||||||
|
);
|
||||||
|
|
||||||
|
let history: string;
|
||||||
|
if (stats.totalContributions === 1) {
|
||||||
|
history = '1 contribution';
|
||||||
|
} else if (stats.totalContributions < 5) {
|
||||||
|
history = `${stats.totalContributions} contributions, ${(successRate * 100).toFixed(0)}% positive`;
|
||||||
|
} else {
|
||||||
|
history = `${stats.totalContributions} contributions, ${stats.positiveImpacts}+/${stats.negativeImpacts}-/${stats.neutralImpacts}~`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTrusted: reputation > 0.7 && stats.totalContributions >= 5,
|
||||||
|
isNew: stats.totalContributions < 3,
|
||||||
|
reputation,
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepoContext(
|
||||||
|
owner: string,
|
||||||
|
repo: string
|
||||||
|
): {
|
||||||
|
isActive: boolean;
|
||||||
|
communitySize: number;
|
||||||
|
maturity: string;
|
||||||
|
} {
|
||||||
|
const repoKey = `${owner}/${repo}`;
|
||||||
|
const patterns = contextData.repositories[repoKey];
|
||||||
|
|
||||||
|
if (!patterns) {
|
||||||
|
return {
|
||||||
|
isActive: false,
|
||||||
|
communitySize: 0,
|
||||||
|
maturity: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const communitySize = patterns.activeAuthors.length;
|
||||||
|
let maturity: string;
|
||||||
|
|
||||||
|
if (contextData.globalStats.totalAnalyses < 10) {
|
||||||
|
maturity = 'new';
|
||||||
|
} else if (communitySize < 3) {
|
||||||
|
maturity = 'small-team';
|
||||||
|
} else if (communitySize < 10) {
|
||||||
|
maturity = 'growing';
|
||||||
|
} else {
|
||||||
|
maturity = 'established';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive: true,
|
||||||
|
communitySize,
|
||||||
|
maturity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContextualInsights(
|
||||||
|
event: WebhookEvent,
|
||||||
|
backendResults: Record<string, AnalysisResult>
|
||||||
|
): string[] {
|
||||||
|
const insights: string[] = [];
|
||||||
|
const authorRep = getAuthorReputation(event.author);
|
||||||
|
const repoCtx = getRepoContext(event.owner, event.repo);
|
||||||
|
|
||||||
|
// Author-based insights
|
||||||
|
if (authorRep.isNew) {
|
||||||
|
insights.push(`Welcome ${event.author}! This appears to be your first contribution.`);
|
||||||
|
} else if (authorRep.isTrusted) {
|
||||||
|
insights.push(`${event.author} is a trusted contributor with ${authorRep.history}.`);
|
||||||
|
} else if (authorRep.reputation < 0.3) {
|
||||||
|
insights.push(`${event.author} has had mixed results recently (${authorRep.history}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo-based insights
|
||||||
|
if (repoCtx.maturity === 'new') {
|
||||||
|
insights.push('This repository is still building up analysis history.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-backend pattern detection
|
||||||
|
const impacts = Object.values(backendResults).map((r) => r.impact);
|
||||||
|
const allPositive = impacts.every((i) => i === 'positive');
|
||||||
|
const allNegative = impacts.every((i) => i === 'negative');
|
||||||
|
const mixed = new Set(impacts).size > 1;
|
||||||
|
|
||||||
|
if (allPositive && impacts.length >= 2) {
|
||||||
|
insights.push('All analysis backends agree: this looks solid.');
|
||||||
|
} else if (allNegative && impacts.length >= 2) {
|
||||||
|
insights.push('Multiple concerns detected across different dimensions.');
|
||||||
|
} else if (mixed) {
|
||||||
|
insights.push('Mixed signals - some aspects look good, others need attention.');
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,25 @@ 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 { initContext, updateContext, getAuthorReputation, getRepoContext } from './context.js';
|
||||||
const impactToNumeric: Record<Impact, number> = {
|
|
||||||
positive: 1,
|
|
||||||
neutral: 0,
|
|
||||||
negative: -1,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface WeightedBackend {
|
interface WeightedBackend {
|
||||||
backend: EngineBackend;
|
backend: EngineBackend;
|
||||||
weight: number;
|
weight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BackendResult {
|
||||||
|
backend: string;
|
||||||
|
impact: Impact;
|
||||||
|
confidence: number;
|
||||||
|
reasoning: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class Engine {
|
export class Engine {
|
||||||
private backends: WeightedBackend[] = [];
|
private backends: WeightedBackend[] = [];
|
||||||
private confidenceThreshold: number;
|
private confidenceThreshold: number;
|
||||||
|
private contextInitialized = false;
|
||||||
|
|
||||||
constructor(config: EngineConfig) {
|
constructor(config: EngineConfig) {
|
||||||
this.confidenceThreshold = config.confidenceThreshold;
|
this.confidenceThreshold = config.confidenceThreshold;
|
||||||
|
|
@ -51,19 +55,42 @@ export class Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeContext(stateFile?: string): void {
|
||||||
|
if (!this.contextInitialized) {
|
||||||
|
initContext(stateFile);
|
||||||
|
this.contextInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async analyze(event: WebhookEvent): Promise<AnalysisResult> {
|
async analyze(event: WebhookEvent): Promise<AnalysisResult> {
|
||||||
const logger = getLogger();
|
const logger = getLogger();
|
||||||
const results = await Promise.all(
|
|
||||||
|
if (!this.contextInitialized) {
|
||||||
|
this.initializeContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorRep = getAuthorReputation(event.author);
|
||||||
|
const repoCtx = getRepoContext(event.owner, event.repo);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Analyzing ${event.type} #${event.number} by ${event.author} ` +
|
||||||
|
`(reputation: ${(authorRep.reputation * 100).toFixed(0)}%, repo: ${repoCtx.maturity})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run all backends
|
||||||
|
const backendResults: BackendResult[] = [];
|
||||||
|
const rawResults = await Promise.all(
|
||||||
this.backends.map(async ({ backend, weight }) => {
|
this.backends.map(async ({ backend, weight }) => {
|
||||||
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 { result, weight };
|
return { backend: backend.name, result, weight };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Backend "${backend.name}" threw unexpectedly`, err);
|
logger.error(`Backend "${backend.name}" threw unexpectedly`, err);
|
||||||
return {
|
return {
|
||||||
|
backend: backend.name,
|
||||||
result: {
|
result: {
|
||||||
impact: 'neutral' as Impact,
|
impact: 'neutral' as Impact,
|
||||||
confidence: 0,
|
confidence: 0,
|
||||||
|
|
@ -75,40 +102,363 @@ export class Engine {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter to backends that actually produced a signal (confidence > 0)
|
for (const r of rawResults) {
|
||||||
const active = results.filter((r) => r.result.confidence > 0);
|
backendResults.push({
|
||||||
if (active.length === 0) {
|
backend: r.backend,
|
||||||
return { impact: 'neutral', confidence: 0, reasoning: 'No backends produced a signal.' };
|
impact: r.result.impact,
|
||||||
|
confidence: r.result.confidence,
|
||||||
|
reasoning: r.result.reasoning,
|
||||||
|
weight: r.weight,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalWeight = active.reduce((s, r) => s + r.weight, 0);
|
const active = backendResults.filter((r) => r.confidence > 0);
|
||||||
const combinedScore =
|
if (active.length === 0) {
|
||||||
active.reduce(
|
return {
|
||||||
(s, r) => s + impactToNumeric[r.result.impact] * r.result.confidence * r.weight,
|
impact: 'neutral',
|
||||||
0
|
confidence: 0,
|
||||||
) / totalWeight;
|
reasoning: `Insufficient data: no analysis backends produced signals for ${event.type} #${event.number}.`,
|
||||||
const combinedConfidence =
|
};
|
||||||
active.reduce((s, r) => s + r.result.confidence * r.weight, 0) / totalWeight;
|
}
|
||||||
|
|
||||||
|
// Calculate multi-dimensional scores
|
||||||
|
const dimensions = this.calculateDimensions(active);
|
||||||
|
|
||||||
|
// Detect correlations and patterns
|
||||||
|
const correlations = this.detectCorrelations(backendResults, dimensions);
|
||||||
|
|
||||||
|
// Calculate overall score and confidence
|
||||||
|
const { score, confidence, uncertainty } = this.calculateOverall(
|
||||||
|
active,
|
||||||
|
dimensions,
|
||||||
|
correlations,
|
||||||
|
authorRep
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine impact
|
||||||
let impact: Impact;
|
let impact: Impact;
|
||||||
if (combinedScore > 0.1) {
|
if (score > 0.2) {
|
||||||
impact = 'positive';
|
impact = 'positive';
|
||||||
} else if (combinedScore < -0.1) {
|
} else if (score < -0.2) {
|
||||||
impact = 'negative';
|
impact = 'negative';
|
||||||
} else {
|
} else {
|
||||||
impact = 'neutral';
|
impact = 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (combinedConfidence < this.confidenceThreshold) {
|
if (confidence < this.confidenceThreshold) {
|
||||||
impact = 'neutral';
|
impact = 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
const reasoning = results
|
// Generate analytical reasoning
|
||||||
.filter((r) => r.result.confidence > 0)
|
const reasoning = this.generateAnalyticalReasoning(
|
||||||
.map((r) => r.result.reasoning)
|
event,
|
||||||
.join(' ');
|
backendResults,
|
||||||
|
active,
|
||||||
|
dimensions,
|
||||||
|
correlations,
|
||||||
|
score,
|
||||||
|
confidence,
|
||||||
|
uncertainty,
|
||||||
|
authorRep,
|
||||||
|
repoCtx
|
||||||
|
);
|
||||||
|
|
||||||
return { impact, confidence: combinedConfidence, reasoning };
|
const result: AnalysisResult = {
|
||||||
|
impact,
|
||||||
|
confidence,
|
||||||
|
reasoning,
|
||||||
|
dimensions,
|
||||||
|
correlations,
|
||||||
|
uncertainty,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateContext(event, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateDimensions(active: BackendResult[]): NonNullable<AnalysisResult['dimensions']> {
|
||||||
|
let correctnessScore = 0;
|
||||||
|
let correctnessWeight = 0;
|
||||||
|
let riskScore = 0;
|
||||||
|
let riskWeight = 0;
|
||||||
|
let maintainabilityScore = 0;
|
||||||
|
let maintainabilityWeight = 0;
|
||||||
|
let alignmentScore = 0;
|
||||||
|
let alignmentWeight = 0;
|
||||||
|
|
||||||
|
for (const r of active) {
|
||||||
|
const impactScore = r.impact === 'positive' ? 1 : r.impact === 'negative' ? -1 : 0;
|
||||||
|
const weightedImpact = impactScore * r.confidence * r.weight;
|
||||||
|
|
||||||
|
switch (r.backend) {
|
||||||
|
case 'checks':
|
||||||
|
correctnessScore += weightedImpact * 0.7;
|
||||||
|
correctnessWeight += r.weight * 0.7;
|
||||||
|
riskScore += weightedImpact * 0.3;
|
||||||
|
riskWeight += r.weight * 0.3;
|
||||||
|
break;
|
||||||
|
case 'diff':
|
||||||
|
maintainabilityScore += weightedImpact * 0.6;
|
||||||
|
maintainabilityWeight += r.weight * 0.6;
|
||||||
|
riskScore += weightedImpact * 0.4;
|
||||||
|
riskWeight += r.weight * 0.4;
|
||||||
|
break;
|
||||||
|
case 'quality':
|
||||||
|
alignmentScore += weightedImpact * 0.7;
|
||||||
|
alignmentWeight += r.weight * 0.7;
|
||||||
|
maintainabilityScore += weightedImpact * 0.3;
|
||||||
|
maintainabilityWeight += r.weight * 0.3;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
correctness: correctnessWeight > 0 ? correctnessScore / correctnessWeight : 0,
|
||||||
|
risk: riskWeight > 0 ? riskScore / riskWeight : 0,
|
||||||
|
maintainability: maintainabilityWeight > 0 ? maintainabilityScore / maintainabilityWeight : 0,
|
||||||
|
alignment: alignmentWeight > 0 ? alignmentScore / alignmentWeight : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectCorrelations(
|
||||||
|
allResults: BackendResult[],
|
||||||
|
dimensions: NonNullable<AnalysisResult['dimensions']>
|
||||||
|
): NonNullable<AnalysisResult['correlations']> {
|
||||||
|
const suspiciousPatterns: string[] = [];
|
||||||
|
const reinforcingSignals: string[] = [];
|
||||||
|
const contradictions: string[] = [];
|
||||||
|
|
||||||
|
const active = allResults.filter((r) => r.confidence > 0);
|
||||||
|
const hasChecks = active.some((r) => r.backend === 'checks');
|
||||||
|
const hasDiff = active.some((r) => r.backend === 'diff');
|
||||||
|
const hasQuality = active.some((r) => r.backend === 'quality');
|
||||||
|
|
||||||
|
// Check for suspicious patterns
|
||||||
|
if (hasChecks && hasDiff) {
|
||||||
|
const checksResult = active.find((r) => r.backend === 'checks');
|
||||||
|
const diffResult = active.find((r) => r.backend === 'diff');
|
||||||
|
|
||||||
|
if (checksResult?.impact === 'positive' && diffResult?.impact === 'negative') {
|
||||||
|
suspiciousPatterns.push('Checks pass but diff analysis shows concerns (untested changes?)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checksResult?.impact === 'negative' && diffResult?.impact === 'positive') {
|
||||||
|
suspiciousPatterns.push('Clean diff but failing checks (test failures?)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDiff && hasQuality) {
|
||||||
|
const diffResult = active.find((r) => r.backend === 'diff');
|
||||||
|
const qualityResult = active.find((r) => r.backend === 'quality');
|
||||||
|
|
||||||
|
if (diffResult?.impact === 'positive' && qualityResult?.impact === 'negative') {
|
||||||
|
suspiciousPatterns.push('Clean code changes but poor description (documentation debt)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reinforcing signals
|
||||||
|
if (dimensions.correctness > 0.5 && dimensions.maintainability > 0.5) {
|
||||||
|
reinforcingSignals.push('High correctness and maintainability scores align');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dimensions.risk < -0.5 && dimensions.alignment < -0.3) {
|
||||||
|
reinforcingSignals.push('Risk and misalignment indicators converge');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contradictions
|
||||||
|
const positiveCount = active.filter((r) => r.impact === 'positive').length;
|
||||||
|
const negativeCount = active.filter((r) => r.impact === 'negative').length;
|
||||||
|
|
||||||
|
if (positiveCount > 0 && negativeCount > 0) {
|
||||||
|
contradictions.push(
|
||||||
|
`Mixed backend signals: ${positiveCount} positive, ${negativeCount} negative`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dimensions.correctness > 0.3 && dimensions.risk < -0.3) {
|
||||||
|
contradictions.push('Correct implementation but high risk profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
suspiciousPatterns,
|
||||||
|
reinforcingSignals,
|
||||||
|
contradictions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateOverall(
|
||||||
|
active: BackendResult[],
|
||||||
|
dimensions: NonNullable<AnalysisResult['dimensions']>,
|
||||||
|
correlations: NonNullable<AnalysisResult['correlations']>,
|
||||||
|
authorRep: ReturnType<typeof getAuthorReputation>
|
||||||
|
): {
|
||||||
|
score: number;
|
||||||
|
confidence: number;
|
||||||
|
uncertainty: NonNullable<AnalysisResult['uncertainty']>;
|
||||||
|
} {
|
||||||
|
const totalWeight = active.reduce((s, r) => s + r.weight, 0);
|
||||||
|
|
||||||
|
// Calculate weighted average of individual backend scores
|
||||||
|
let baseScore = 0;
|
||||||
|
for (const r of active) {
|
||||||
|
const impactScore = r.impact === 'positive' ? 1 : r.impact === 'negative' ? -1 : 0;
|
||||||
|
baseScore += impactScore * r.confidence * r.weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against division by zero when all weights are 0
|
||||||
|
if (totalWeight === 0) {
|
||||||
|
baseScore = 0;
|
||||||
|
getLogger().debug('All backend weights are zero, defaulting baseScore to 0');
|
||||||
|
} else {
|
||||||
|
baseScore /= totalWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust score based on dimensions
|
||||||
|
const dimensionScore =
|
||||||
|
dimensions.correctness * 0.4 +
|
||||||
|
dimensions.risk * 0.2 +
|
||||||
|
dimensions.maintainability * 0.25 +
|
||||||
|
dimensions.alignment * 0.15;
|
||||||
|
|
||||||
|
// Blend backend score with dimension score
|
||||||
|
let finalScore = baseScore * 0.6 + dimensionScore * 0.4;
|
||||||
|
|
||||||
|
// Penalize for contradictions
|
||||||
|
if (correlations.contradictions.length > 0) {
|
||||||
|
finalScore *= 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate confidence
|
||||||
|
let baseConfidence = 0;
|
||||||
|
if (totalWeight === 0) {
|
||||||
|
baseConfidence = 0;
|
||||||
|
getLogger().debug('All backend weights are zero, defaulting baseConfidence to 0');
|
||||||
|
} else {
|
||||||
|
baseConfidence = active.reduce((s, r) => s + r.confidence * r.weight, 0) / totalWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust confidence based on various factors
|
||||||
|
const uniqueImpacts = new Set(active.map((r) => r.impact));
|
||||||
|
if (uniqueImpacts.size === 1) {
|
||||||
|
baseConfidence = Math.min(1, baseConfidence * 1.15);
|
||||||
|
} else if (uniqueImpacts.size === 3) {
|
||||||
|
baseConfidence *= 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorRep.isNew) {
|
||||||
|
baseConfidence *= 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (correlations.suspiciousPatterns.length > 0) {
|
||||||
|
baseConfidence *= 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate uncertainty interval
|
||||||
|
const uncertaintyRange = 1 - baseConfidence;
|
||||||
|
const lowerBound = Math.max(0, baseConfidence - uncertaintyRange * 0.5);
|
||||||
|
const upperBound = Math.min(1, baseConfidence + uncertaintyRange * 0.5);
|
||||||
|
|
||||||
|
// Determine primary uncertainty source
|
||||||
|
let primaryUncertaintySource = 'Backend confidence variance';
|
||||||
|
if (uniqueImpacts.size > 1) {
|
||||||
|
primaryUncertaintySource = 'Mixed backend signals';
|
||||||
|
} else if (authorRep.isNew) {
|
||||||
|
primaryUncertaintySource = 'Limited author history';
|
||||||
|
} else if (active.length < this.backends.length) {
|
||||||
|
primaryUncertaintySource = 'Partial backend coverage';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
score: finalScore,
|
||||||
|
confidence: baseConfidence,
|
||||||
|
uncertainty: {
|
||||||
|
confidenceInterval: [lowerBound, upperBound],
|
||||||
|
primaryUncertaintySource,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateAnalyticalReasoning(
|
||||||
|
event: WebhookEvent,
|
||||||
|
_allResults: BackendResult[],
|
||||||
|
activeResults: BackendResult[],
|
||||||
|
dimensions: NonNullable<AnalysisResult['dimensions']>,
|
||||||
|
correlations: NonNullable<AnalysisResult['correlations']>,
|
||||||
|
score: number,
|
||||||
|
confidence: number,
|
||||||
|
uncertainty: NonNullable<AnalysisResult['uncertainty']>,
|
||||||
|
authorRep: ReturnType<typeof getAuthorReputation>,
|
||||||
|
repoCtx: ReturnType<typeof getRepoContext>
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Summary of dimensional analysis
|
||||||
|
parts.push(`Dimensional Analysis:`);
|
||||||
|
parts.push(
|
||||||
|
` Correctness: ${(dimensions.correctness * 100).toFixed(0)}% | ` +
|
||||||
|
`Risk: ${(dimensions.risk * 100).toFixed(0)}% | ` +
|
||||||
|
`Maintainability: ${(dimensions.maintainability * 100).toFixed(0)}% | ` +
|
||||||
|
`Alignment: ${(dimensions.alignment * 100).toFixed(0)}%`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Backend breakdown
|
||||||
|
parts.push(`\nBackend Results:`);
|
||||||
|
for (const r of activeResults) {
|
||||||
|
const icon = r.impact === 'positive' ? '[+]' : r.impact === 'negative' ? '[-]' : '[~]';
|
||||||
|
parts.push(
|
||||||
|
` ${icon} ${r.backend}: ${r.impact} (${(r.confidence * 100).toFixed(0)}%) - ${r.reasoning}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correlation analysis
|
||||||
|
const hasPatterns =
|
||||||
|
correlations.suspiciousPatterns.length > 0 ||
|
||||||
|
correlations.reinforcingSignals.length > 0 ||
|
||||||
|
correlations.contradictions.length > 0;
|
||||||
|
|
||||||
|
if (hasPatterns) {
|
||||||
|
parts.push(`\nPattern Analysis:`);
|
||||||
|
|
||||||
|
if (correlations.reinforcingSignals.length > 0) {
|
||||||
|
for (const signal of correlations.reinforcingSignals) {
|
||||||
|
parts.push(` [^] ${signal}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (correlations.suspiciousPatterns.length > 0) {
|
||||||
|
for (const pattern of correlations.suspiciousPatterns) {
|
||||||
|
parts.push(` [!] ${pattern}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (correlations.contradictions.length > 0) {
|
||||||
|
for (const contradiction of correlations.contradictions) {
|
||||||
|
parts.push(` [x] ${contradiction}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context information
|
||||||
|
parts.push(`\nContext:`);
|
||||||
|
parts.push(
|
||||||
|
` Author: ${event.author} (${authorRep.isNew ? 'new' : `reputation: ${(authorRep.reputation * 100).toFixed(0)}%`})`
|
||||||
|
);
|
||||||
|
parts.push(` Repository: ${repoCtx.maturity} (${repoCtx.communitySize} active contributors)`);
|
||||||
|
|
||||||
|
// Confidence and uncertainty
|
||||||
|
parts.push(`\nConfidence Assessment:`);
|
||||||
|
parts.push(` Overall: ${(confidence * 100).toFixed(0)}%`);
|
||||||
|
parts.push(
|
||||||
|
` Interval: [${(uncertainty.confidenceInterval[0] * 100).toFixed(0)}%, ${(uncertainty.confidenceInterval[1] * 100).toFixed(0)}%]`
|
||||||
|
);
|
||||||
|
parts.push(` Primary uncertainty: ${uncertainty.primaryUncertaintySource}`);
|
||||||
|
|
||||||
|
// Final assessment
|
||||||
|
parts.push(
|
||||||
|
`\nAssessment: ${score > 0.2 ? 'POSITIVE' : score < -0.2 ? 'NEGATIVE' : 'NEUTRAL'} (score: ${score.toFixed(2)})`
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,17 @@ export function shouldProcess(
|
||||||
|
|
||||||
// Author filters
|
// Author filters
|
||||||
if (filters.authors.include && filters.authors.include.length > 0) {
|
if (filters.authors.include && filters.authors.include.length > 0) {
|
||||||
if (!filters.authors.include.includes(event.author)) {
|
const normalizedAuthor = event.author.toLowerCase();
|
||||||
|
const hasIncluded = filters.authors.include.some((a) => a.toLowerCase() === normalizedAuthor);
|
||||||
|
if (!hasIncluded) {
|
||||||
return { pass: false, reason: 'Author not in include list' };
|
return { pass: false, reason: 'Author not in include list' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.authors.exclude.length > 0) {
|
if (filters.authors.exclude.length > 0) {
|
||||||
if (filters.authors.exclude.includes(event.author)) {
|
const normalizedAuthor = event.author.toLowerCase();
|
||||||
|
const isExcluded = filters.authors.exclude.some((a) => a.toLowerCase() === normalizedAuthor);
|
||||||
|
if (isExcluded) {
|
||||||
return { pass: false, reason: 'Author is excluded' };
|
return { pass: false, reason: 'Author is excluded' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Octokit } from '@octokit/rest';
|
import { Octokit } from '@octokit/rest';
|
||||||
import { getLogger } from './logger.js';
|
import { getLogger } from './logger.js';
|
||||||
import type { CheckRun, PRFile, ResponseConfig } from './types.js';
|
import type { CheckRun, PRFile } from './types.js';
|
||||||
|
|
||||||
let octokit: Octokit | null = null;
|
let octokit: Octokit | null = null;
|
||||||
|
|
||||||
|
|
@ -27,7 +27,12 @@ export async function postComment(
|
||||||
getLogger().info(`[dry-run] Would post comment on ${owner}/${repo}#${issueNumber}:\n${body}`);
|
getLogger().info(`[dry-run] Would post comment on ${owner}/${repo}#${issueNumber}:\n${body}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await octokit.issues.createComment({ owner, repo, issue_number: issueNumber, body });
|
await octokit.issues.createComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
getLogger().info(`Posted comment on ${owner}/${repo}#${issueNumber}`);
|
getLogger().info(`Posted comment on ${owner}/${repo}#${issueNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,7 +70,12 @@ export async function updateComment(
|
||||||
getLogger().info(`[dry-run] Would update comment ${commentId}:\n${body}`);
|
getLogger().info(`[dry-run] Would update comment ${commentId}:\n${body}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await octokit.issues.updateComment({ owner, repo, comment_id: commentId, body });
|
await octokit.issues.updateComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
comment_id: commentId,
|
||||||
|
body,
|
||||||
|
});
|
||||||
getLogger().info(`Updated comment ${commentId} on ${owner}/${repo}`);
|
getLogger().info(`Updated comment ${commentId} on ${owner}/${repo}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,7 +173,11 @@ export async function fetchPR(
|
||||||
if (!octokit) return null;
|
if (!octokit) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await octokit.pulls.get({ owner, repo, pull_number: prNumber });
|
const { data } = await octokit.pulls.get({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: prNumber,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
body: data.body || '',
|
body: data.body || '',
|
||||||
|
|
@ -191,7 +205,11 @@ export async function fetchIssue(
|
||||||
if (!octokit) return null;
|
if (!octokit) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await octokit.issues.get({ owner, repo, issue_number: issueNumber });
|
const { data } = await octokit.issues.get({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
body: data.body || '',
|
body: data.body || '',
|
||||||
|
|
@ -204,6 +222,32 @@ export async function fetchIssue(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAccessibleRepositories(): Promise<
|
||||||
|
Array<{ owner: string; repo: string }>
|
||||||
|
> {
|
||||||
|
if (!octokit) {
|
||||||
|
getLogger().debug('[dry-run] Cannot fetch repositories without a token');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const repos: Array<{ owner: string; repo: string }> = [];
|
||||||
|
|
||||||
|
for await (const response of octokit.paginate.iterator(octokit.repos.listForAuthenticatedUser, {
|
||||||
|
per_page: 100,
|
||||||
|
sort: 'updated',
|
||||||
|
})) {
|
||||||
|
for (const repo of response.data) {
|
||||||
|
if (!repo.full_name || typeof repo.full_name !== 'string') continue;
|
||||||
|
const parts = repo.full_name.split('/');
|
||||||
|
if (parts.length < 2 || !parts[0] || !parts[1]) continue;
|
||||||
|
const [owner, repoName] = parts;
|
||||||
|
repos.push({ owner, repo: repoName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return repos;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecentComment {
|
export interface RecentComment {
|
||||||
id: number;
|
id: number;
|
||||||
body: string;
|
body: string;
|
||||||
|
|
@ -256,7 +300,16 @@ function pickRandom(list: string[]): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatComment(
|
export function formatComment(
|
||||||
responseConfig: ResponseConfig,
|
responseConfig: {
|
||||||
|
commentMarker: string;
|
||||||
|
includeConfidence: boolean;
|
||||||
|
includeReasoning: boolean;
|
||||||
|
messages: {
|
||||||
|
positive: string[];
|
||||||
|
negative: string[];
|
||||||
|
neutral: string[];
|
||||||
|
};
|
||||||
|
},
|
||||||
type: 'issue' | 'pull_request',
|
type: 'issue' | 'pull_request',
|
||||||
impact: string,
|
impact: string,
|
||||||
confidence: number,
|
confidence: number,
|
||||||
|
|
@ -283,7 +336,7 @@ export function formatComment(
|
||||||
body += `\n\n**Confidence:** ${(confidence * 100).toFixed(0)}%`;
|
body += `\n\n**Confidence:** ${(confidence * 100).toFixed(0)}%`;
|
||||||
}
|
}
|
||||||
if (responseConfig.includeReasoning) {
|
if (responseConfig.includeReasoning) {
|
||||||
body += `\n\n**Analysis:** ${reasoning}`;
|
body += `\n\n**Analysis:**\n\`\`\`\n${reasoning}\n\`\`\``;
|
||||||
}
|
}
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
|
|
|
||||||
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
src/polling.ts
104
src/polling.ts
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Config, WebhookEvent, RepoConfig } from './types.js';
|
import type { Config, WebhookEvent, RepoPattern } from './types.js';
|
||||||
import {
|
import {
|
||||||
listRecentComments,
|
listRecentComments,
|
||||||
|
listAccessibleRepositories,
|
||||||
fetchPR,
|
fetchPR,
|
||||||
fetchIssue,
|
fetchIssue,
|
||||||
hasExistingComment,
|
hasExistingComment,
|
||||||
|
|
@ -18,6 +19,7 @@ import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
interface ProcessedComment {
|
interface ProcessedComment {
|
||||||
id: number;
|
id: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
failures?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PollingState {
|
interface PollingState {
|
||||||
|
|
@ -26,6 +28,7 @@ interface PollingState {
|
||||||
|
|
||||||
const processedComments: Map<string, ProcessedComment> = new Map();
|
const processedComments: Map<string, ProcessedComment> = new Map();
|
||||||
const MAX_PROCESSED_CACHE = 1000;
|
const MAX_PROCESSED_CACHE = 1000;
|
||||||
|
const MAX_RETRY_ATTEMPTS = 3;
|
||||||
|
|
||||||
let pollingState: PollingState = { lastProcessedAt: {} };
|
let pollingState: PollingState = { lastProcessedAt: {} };
|
||||||
|
|
||||||
|
|
@ -86,7 +89,11 @@ function isProcessed(owner: string, repo: string, commentId: number): boolean {
|
||||||
|
|
||||||
function markProcessed(owner: string, repo: string, commentId: number): void {
|
function markProcessed(owner: string, repo: string, commentId: number): void {
|
||||||
const key = getCacheKey(owner, repo, commentId);
|
const key = getCacheKey(owner, repo, commentId);
|
||||||
processedComments.set(key, { id: commentId, timestamp: Date.now() });
|
processedComments.set(key, {
|
||||||
|
id: commentId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
failures: 0,
|
||||||
|
});
|
||||||
|
|
||||||
// Clean up old entries if cache is too large
|
// Clean up old entries if cache is too large
|
||||||
if (processedComments.size > MAX_PROCESSED_CACHE) {
|
if (processedComments.size > MAX_PROCESSED_CACHE) {
|
||||||
|
|
@ -99,6 +106,20 @@ function markProcessed(owner: string, repo: string, commentId: number): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordFailure(owner: string, repo: string, commentId: number): boolean {
|
||||||
|
const key = getCacheKey(owner, repo, commentId);
|
||||||
|
const existing = processedComments.get(key);
|
||||||
|
|
||||||
|
const failures = (existing?.failures || 0) + 1;
|
||||||
|
processedComments.set(key, {
|
||||||
|
id: commentId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
failures,
|
||||||
|
});
|
||||||
|
|
||||||
|
return failures >= MAX_RETRY_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
function containsMention(body: string): boolean {
|
function containsMention(body: string): boolean {
|
||||||
return body.includes('@troutbot');
|
return body.includes('@troutbot');
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +148,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,
|
||||||
|
|
@ -142,7 +164,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;
|
||||||
}
|
}
|
||||||
|
|
@ -155,11 +181,25 @@ function isAuthorized(username: string, authorizedUsers?: string[]): boolean {
|
||||||
return authorizedUsers.some((u) => u.toLowerCase() === normalizedUsername);
|
return authorizedUsers.some((u) => u.toLowerCase() === normalizedUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRepoAuthorized(owner: string, repo: string, pollingRepos?: RepoConfig[]): boolean {
|
function isRepoAuthorized(owner: string, repo: string, pollingPatterns?: RepoPattern[]): 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 processComment(
|
async function processComment(
|
||||||
|
|
@ -252,6 +292,14 @@ async function processComment(
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Failed to process mention in ${owner}/${repo}#${comment.issueNumber}`, err);
|
logger.error(`Failed to process mention in ${owner}/${repo}#${comment.issueNumber}`, err);
|
||||||
|
// Track failures and mark as processed after max retries
|
||||||
|
const shouldStop = recordFailure(owner, repo, comment.id);
|
||||||
|
if (shouldStop) {
|
||||||
|
logger.warn(
|
||||||
|
`Max retry attempts (${MAX_RETRY_ATTEMPTS}) reached for comment ${comment.id}, marking as processed`
|
||||||
|
);
|
||||||
|
markProcessed(owner, repo, comment.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,15 +345,47 @@ export async function startPolling(config: Config): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.repositories.length === 0) {
|
// Determine repos to poll
|
||||||
logger.warn('Polling enabled but no repositories configured');
|
let reposToPoll: Array<{ owner: string; repo: string }>;
|
||||||
|
const pollingPatterns = pollingConfig.repositories;
|
||||||
|
|
||||||
|
if (!pollingPatterns || pollingPatterns.length === 0) {
|
||||||
|
// No patterns configured - poll all accessible repos
|
||||||
|
reposToPoll = await listAccessibleRepositories();
|
||||||
|
logger.info(`Polling all accessible repositories (${reposToPoll.length} repos)`);
|
||||||
|
} else {
|
||||||
|
// Build repo list from patterns
|
||||||
|
reposToPoll = [];
|
||||||
|
|
||||||
|
// Start with explicitly configured global repos (for webhooks)
|
||||||
|
for (const repo of config.repositories) {
|
||||||
|
// Only include repos that match polling patterns
|
||||||
|
if (isRepoAuthorized(repo.owner, repo.repo, pollingPatterns)) {
|
||||||
|
reposToPoll.push({ owner: repo.owner, repo: repo.repo });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no explicit repos configured, fetch accessible ones and filter by patterns
|
||||||
|
if (config.repositories.length === 0) {
|
||||||
|
const accessibleRepos = await listAccessibleRepositories();
|
||||||
|
for (const repo of accessibleRepos) {
|
||||||
|
if (isRepoAuthorized(repo.owner, repo.repo, pollingPatterns)) {
|
||||||
|
reposToPoll.push({ owner: repo.owner, repo: repo.repo });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Polling ${reposToPoll.length} repositories matching patterns`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reposToPoll.length === 0) {
|
||||||
|
logger.warn('No repositories match polling patterns');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalMs = pollingConfig.intervalMinutes * 60 * 1000;
|
const intervalMs = pollingConfig.intervalMinutes * 60 * 1000;
|
||||||
const lookbackMs = pollingConfig.lookbackMinutes * 60 * 1000;
|
const lookbackMs = pollingConfig.lookbackMinutes * 60 * 1000;
|
||||||
|
|
||||||
logger.info(`Starting polling for ${config.repositories.length} repositories`);
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Poll interval: ${pollingConfig.intervalMinutes} minutes, lookback: ${pollingConfig.lookbackMinutes} minutes`
|
`Poll interval: ${pollingConfig.intervalMinutes} minutes, lookback: ${pollingConfig.lookbackMinutes} minutes`
|
||||||
);
|
);
|
||||||
|
|
@ -320,7 +400,7 @@ export async function startPolling(config: Config): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do an initial poll - use persisted timestamp if available, otherwise use lookback
|
// Do an initial poll - use persisted timestamp if available, otherwise use lookback
|
||||||
for (const repo of config.repositories) {
|
for (const repo of reposToPoll) {
|
||||||
const lastProcessed = getLastProcessedAt(repo.owner, repo.repo);
|
const lastProcessed = getLastProcessedAt(repo.owner, repo.repo);
|
||||||
const initialSince = lastProcessed || new Date(Date.now() - lookbackMs);
|
const initialSince = lastProcessed || new Date(Date.now() - lookbackMs);
|
||||||
if (lastProcessed) {
|
if (lastProcessed) {
|
||||||
|
|
@ -335,7 +415,7 @@ export async function startPolling(config: Config): Promise<void> {
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
const since = new Date(Date.now() - lookbackMs);
|
const since = new Date(Date.now() - lookbackMs);
|
||||||
|
|
||||||
for (const repo of config.repositories) {
|
for (const repo of reposToPoll) {
|
||||||
await pollRepository(repo.owner, repo.repo, config, since, stateFile);
|
await pollRepository(repo.owner, repo.repo, config, since, stateFile);
|
||||||
}
|
}
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
25
src/types.ts
25
src/types.ts
|
|
@ -16,7 +16,7 @@ export interface PollingConfig {
|
||||||
backfill?: boolean;
|
backfill?: boolean;
|
||||||
stateFile?: string;
|
stateFile?: string;
|
||||||
authorizedUsers?: string[];
|
authorizedUsers?: string[];
|
||||||
repositories?: RepoConfig[];
|
repositories?: RepoPattern[]; // Can include wildcards like [{ owner: 'NotAShelf', repo: '*' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
|
|
@ -42,6 +42,11 @@ export interface RepoConfig {
|
||||||
repo: string;
|
repo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RepoPattern {
|
||||||
|
owner: string;
|
||||||
|
repo?: string; // undefined means all repos for that owner
|
||||||
|
}
|
||||||
|
|
||||||
export interface FiltersConfig {
|
export interface FiltersConfig {
|
||||||
labels: {
|
labels: {
|
||||||
include: string[];
|
include: string[];
|
||||||
|
|
@ -112,6 +117,24 @@ export interface AnalysisResult {
|
||||||
impact: Impact;
|
impact: Impact;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
reasoning: string;
|
reasoning: string;
|
||||||
|
// Multi-dimensional scoring for sophisticated analysis
|
||||||
|
dimensions?: {
|
||||||
|
correctness: number; // -1 to 1: will this work correctly?
|
||||||
|
risk: number; // -1 to 1: could this break things? (negative = risky)
|
||||||
|
maintainability: number; // -1 to 1: will this be maintainable?
|
||||||
|
alignment: number; // -1 to 1: does this fit the project?
|
||||||
|
};
|
||||||
|
// Correlation analysis
|
||||||
|
correlations?: {
|
||||||
|
suspiciousPatterns: string[];
|
||||||
|
reinforcingSignals: string[];
|
||||||
|
contradictions: string[];
|
||||||
|
};
|
||||||
|
// Uncertainty quantification
|
||||||
|
uncertainty?: {
|
||||||
|
confidenceInterval: [number, number]; // [lower, upper]
|
||||||
|
primaryUncertaintySource: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EngineBackend {
|
export interface EngineBackend {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue