UNPKG

@probelabs/visor

Version:

AI-powered code review tool for GitHub Pull Requests - CLI and GitHub Action

1,343 lines (1,325 loc) 694 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/logger.ts var logger_exports = {}; __export(logger_exports, { configureLoggerFromCli: () => configureLoggerFromCli, logger: () => logger }); function levelToNumber(level) { switch (level) { case "silent": return 0; case "error": return 10; case "warn": return 20; case "info": return 30; case "verbose": return 40; case "debug": return 50; } } function configureLoggerFromCli(options) { logger.configure({ outputFormat: options.output, debug: options.debug, verbose: options.verbose, quiet: options.quiet }); try { if (options.output) process.env.VISOR_OUTPUT_FORMAT = String(options.output); if (typeof options.debug === "boolean") { process.env.VISOR_DEBUG = options.debug ? "true" : "false"; } } catch { } } var Logger, logger; var init_logger = __esm({ "src/logger.ts"() { "use strict"; Logger = class { level = "info"; isJsonLike = false; isTTY = typeof process !== "undefined" ? !!process.stderr.isTTY : false; configure(opts = {}) { let lvl = "info"; if (opts.debug || process.env.VISOR_DEBUG === "true") { lvl = "debug"; } else if (opts.verbose || process.env.VISOR_LOG_LEVEL === "verbose") { lvl = "verbose"; } else if (opts.quiet || process.env.VISOR_LOG_LEVEL === "quiet") { lvl = "warn"; } else if (opts.level) { lvl = opts.level; } else if (process.env.VISOR_LOG_LEVEL) { const envLvl = process.env.VISOR_LOG_LEVEL; if (["silent", "error", "warn", "info", "verbose", "debug"].includes(envLvl)) { lvl = envLvl; } } this.level = lvl; const output = opts.outputFormat || process.env.VISOR_OUTPUT_FORMAT || "table"; this.isJsonLike = output === "json" || output === "sarif"; } shouldLog(level) { const desired = levelToNumber(level); const current = levelToNumber(this.level); if (desired > current) return false; if (this.isJsonLike && desired < levelToNumber("error") && this.level !== "debug" && this.level !== "verbose") { return false; } return true; } write(msg) { try { process.stderr.write(msg + "\n"); } catch { } } info(msg) { if (this.shouldLog("info")) this.write(msg); } warn(msg) { if (this.shouldLog("warn")) this.write(msg); } error(msg) { if (this.shouldLog("error")) this.write(msg); } verbose(msg) { if (this.shouldLog("verbose")) this.write(msg); } debug(msg) { if (this.shouldLog("debug")) this.write(msg); } step(msg) { if (this.shouldLog("info")) this.write(`\u25B6 ${msg}`); } success(msg) { if (this.shouldLog("info")) this.write(`\u2714 ${msg}`); } }; logger = new Logger(); } }); // src/footer.ts function generateFooter(options = {}) { const { includeMetadata, includeSeparator = true } = options; const parts = []; if (includeSeparator) { parts.push("---"); parts.push(""); } parts.push( "*Powered by [Visor](https://probelabs.com/visor) from [Probelabs](https://probelabs.com)*" ); if (includeMetadata) { const { lastUpdated, triggeredBy, commitSha } = includeMetadata; const commitInfo = commitSha ? ` | Commit: ${commitSha.substring(0, 7)}` : ""; parts.push(""); parts.push(`*Last updated: ${lastUpdated} | Triggered by: ${triggeredBy}${commitInfo}*`); } parts.push(""); parts.push("\u{1F4A1} **TIP:** You can chat with Visor using `/visor ask <your question>`"); return parts.join("\n"); } var init_footer = __esm({ "src/footer.ts"() { "use strict"; } }); // src/github-comments.ts var import_uuid, CommentManager; var init_github_comments = __esm({ "src/github-comments.ts"() { "use strict"; import_uuid = require("uuid"); init_logger(); init_footer(); CommentManager = class { octokit; retryConfig; constructor(octokit, retryConfig) { this.octokit = octokit; this.retryConfig = { maxRetries: 3, baseDelay: 1e3, maxDelay: 1e4, backoffFactor: 2, ...retryConfig }; } /** * Find existing Visor comment by comment ID marker */ async findVisorComment(owner, repo, prNumber, commentId) { try { const comments = await this.octokit.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page: 100 // GitHub default max }); for (const comment of comments.data) { if (comment.body && this.isVisorComment(comment.body, commentId)) { return comment; } } return null; } catch (error) { if (this.isRateLimitError( error )) { await this.handleRateLimit(error); return this.findVisorComment(owner, repo, prNumber, commentId); } throw error; } } /** * Update existing comment or create new one with collision detection */ async updateOrCreateComment(owner, repo, prNumber, content, options = {}) { const { commentId = this.generateCommentId(), triggeredBy = "unknown", allowConcurrentUpdates = false, commitSha } = options; return this.withRetry(async () => { const existingComment = await this.findVisorComment(owner, repo, prNumber, commentId); const formattedContent = this.formatCommentWithMetadata(content, { commentId, lastUpdated: (/* @__PURE__ */ new Date()).toISOString(), triggeredBy, commitSha }); if (existingComment) { if (!allowConcurrentUpdates) { const currentComment = await this.octokit.rest.issues.getComment({ owner, repo, comment_id: existingComment.id }); if (currentComment.data.updated_at !== existingComment.updated_at) { throw new Error( `Comment collision detected for comment ${commentId}. Another process may have updated it.` ); } } const updatedComment = await this.octokit.rest.issues.updateComment({ owner, repo, comment_id: existingComment.id, body: formattedContent }); logger.info( `\u2705 Successfully updated comment (ID: ${commentId}, GitHub ID: ${existingComment.id}) on PR #${prNumber} in ${owner}/${repo}` ); return updatedComment.data; } else { const newComment = await this.octokit.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: formattedContent }); logger.info( `\u2705 Successfully created comment (ID: ${commentId}, GitHub ID: ${newComment.data.id}) on PR #${prNumber} in ${owner}/${repo}` ); return newComment.data; } }); } /** * Format comment content with metadata markers */ formatCommentWithMetadata(content, metadata) { const { commentId, lastUpdated, triggeredBy, commitSha } = metadata; const footer = generateFooter({ includeMetadata: { lastUpdated, triggeredBy, commitSha } }); return `<!-- visor-comment-id:${commentId} --> ${content} ${footer} <!-- /visor-comment-id:${commentId} -->`; } /** * Create collapsible sections for comment content */ createCollapsibleSection(title, content, isExpanded = false) { const openAttribute = isExpanded ? " open" : ""; return `<details${openAttribute}> <summary>${title}</summary> ${content} </details>`; } /** * Group review results by check type with collapsible sections */ formatGroupedResults(results, groupBy = "check") { const grouped = this.groupResults(results, groupBy); const sections = []; for (const [groupKey, items] of Object.entries(grouped)) { const totalScore = items.reduce((sum, item) => sum + (item.score || 0), 0) / items.length; const totalIssues = items.reduce((sum, item) => sum + (item.issuesFound || 0), 0); const emoji = this.getCheckTypeEmoji(groupKey); const title = `${emoji} ${this.formatGroupTitle(groupKey, totalScore, totalIssues)}`; const sectionContent = items.map((item) => item.content).join("\n\n"); sections.push(this.createCollapsibleSection(title, sectionContent, totalIssues > 0)); } return sections.join("\n\n"); } /** * Generate unique comment ID */ generateCommentId() { return (0, import_uuid.v4)().substring(0, 8); } /** * Check if comment is a Visor comment */ isVisorComment(body, commentId) { if (commentId) { if (body.includes(`visor-comment-id:${commentId} `) || body.includes(`visor-comment-id:${commentId} -->`)) { return true; } if (commentId.startsWith("pr-review-") && body.includes("visor-review-")) { return true; } return false; } return body.includes("visor-comment-id:") && body.includes("<!-- /visor-comment-id:") || body.includes("visor-review-"); } /** * Extract comment ID from comment body */ extractCommentId(body) { const match = body.match(/visor-comment-id:([a-f0-9-]+)/); return match ? match[1] : null; } /** * Handle rate limiting with exponential backoff */ async handleRateLimit(error) { const resetTime = error.response?.headers?.["x-ratelimit-reset"]; if (resetTime) { const resetDate = new Date(parseInt(resetTime) * 1e3); const waitTime = Math.max(resetDate.getTime() - Date.now(), this.retryConfig.baseDelay); console.log(`Rate limit exceeded. Waiting ${Math.round(waitTime / 1e3)}s until reset...`); await this.sleep(Math.min(waitTime, this.retryConfig.maxDelay)); } else { await this.sleep(this.retryConfig.baseDelay); } } /** * Check if error is a rate limit error */ isRateLimitError(error) { return error.status === 403 && (error.response?.data?.message?.includes("rate limit") ?? false); } /** * Check if error should not be retried (auth errors, not found, etc.) */ isNonRetryableError(error) { const nonRetryableStatuses = [401, 404, 422]; const status = error.status || error.response?.status; if (status === 403) { return !this.isRateLimitError(error); } return status !== void 0 && nonRetryableStatuses.includes(status); } /** * Retry wrapper with exponential backoff */ async withRetry(operation) { let lastError = new Error("Unknown error"); for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) { try { return await operation(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt === this.retryConfig.maxRetries) { break; } if (this.isRateLimitError( error )) { await this.handleRateLimit(error); } else if (this.isNonRetryableError(error)) { throw error; } else { const delay = Math.min( this.retryConfig.baseDelay * Math.pow(this.retryConfig.backoffFactor, attempt), this.retryConfig.maxDelay ); await this.sleep(delay); } } } throw lastError; } /** * Sleep utility */ sleep(ms) { return new Promise((resolve7) => setTimeout(resolve7, ms)); } /** * Group results by specified criteria */ groupResults(results, groupBy) { const grouped = {}; for (const result of results) { const key = groupBy === "check" ? result.checkType : this.getSeverityGroup(result.score); if (!grouped[key]) { grouped[key] = []; } grouped[key].push(result); } return grouped; } /** * Get severity group based on score */ getSeverityGroup(score) { if (!score) return "Unknown"; if (score >= 90) return "Excellent"; if (score >= 75) return "Good"; if (score >= 50) return "Needs Improvement"; return "Critical Issues"; } /** * Get emoji for check type */ getCheckTypeEmoji(checkType) { const emojiMap = { performance: "\u{1F4C8}", security: "\u{1F512}", architecture: "\u{1F3D7}\uFE0F", style: "\u{1F3A8}", all: "\u{1F50D}", Excellent: "\u2705", Good: "\u{1F44D}", "Needs Improvement": "\u26A0\uFE0F", "Critical Issues": "\u{1F6A8}", Unknown: "\u2753" }; return emojiMap[checkType] || "\u{1F4DD}"; } /** * Format group title with score and issue count */ formatGroupTitle(groupKey, score, issuesFound) { const formattedScore = Math.round(score); return `${groupKey} Review (Score: ${formattedScore}/100)${issuesFound > 0 ? ` - ${issuesFound} issues found` : ""}`; } }; } }); // src/utils/tracer-init.ts var tracer_init_exports = {}; __export(tracer_init_exports, { initializeTracer: () => initializeTracer }); async function initializeTracer(sessionId, checkName) { try { let ProbeLib; try { ProbeLib = await import("@probelabs/probe"); } catch { try { ProbeLib = require("@probelabs/probe"); } catch { ProbeLib = {}; } } const SimpleTelemetry = ProbeLib?.SimpleTelemetry; const SimpleAppTracer = ProbeLib?.SimpleAppTracer; if (SimpleTelemetry && SimpleAppTracer) { const sanitizedCheckName = checkName ? path.basename(checkName) : "check"; const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); const traceDir = process.env.GITHUB_WORKSPACE ? path.join(process.env.GITHUB_WORKSPACE, "debug-artifacts") : path.join(process.cwd(), "debug-artifacts"); if (!fs.existsSync(traceDir)) { fs.mkdirSync(traceDir, { recursive: true }); } const traceFilePath = path.join(traceDir, `trace-${sanitizedCheckName}-${timestamp}.jsonl`); const resolvedTracePath = path.resolve(traceFilePath); const resolvedTraceDir = path.resolve(traceDir); if (!resolvedTracePath.startsWith(resolvedTraceDir)) { console.error( `\u26A0\uFE0F Security: Attempted path traversal detected. Check name: ${checkName}, resolved path: ${resolvedTracePath}` ); return null; } const telemetry = new SimpleTelemetry({ enableFile: true, filePath: traceFilePath, enableConsole: false }); const tracer = new SimpleAppTracer(telemetry, sessionId); console.error(`\u{1F4CA} Simple tracing enabled, will save to: ${traceFilePath}`); if (process.env.GITHUB_ACTIONS) { console.log(`::notice title=AI Trace::Trace will be saved to ${traceFilePath}`); console.log(`::set-output name=trace-path::${traceFilePath}`); } return { tracer, telemetryConfig: telemetry, filePath: traceFilePath }; } console.error("\u26A0\uFE0F Telemetry classes not available in ProbeAgent, skipping tracing"); return null; } catch (error) { console.error("\u26A0\uFE0F Warning: Failed to initialize tracing:", error); return null; } } var path, fs; var init_tracer_init = __esm({ "src/utils/tracer-init.ts"() { "use strict"; path = __toESM(require("path")); fs = __toESM(require("fs")); } }); // src/session-registry.ts var session_registry_exports = {}; __export(session_registry_exports, { SessionRegistry: () => SessionRegistry }); var SessionRegistry; var init_session_registry = __esm({ "src/session-registry.ts"() { "use strict"; SessionRegistry = class _SessionRegistry { static instance; sessions = /* @__PURE__ */ new Map(); exitHandlerRegistered = false; constructor() { this.registerExitHandlers(); } /** * Get the singleton instance of SessionRegistry */ static getInstance() { if (!_SessionRegistry.instance) { _SessionRegistry.instance = new _SessionRegistry(); } return _SessionRegistry.instance; } /** * Register a ProbeAgent session */ registerSession(sessionId, agent) { console.error(`\u{1F504} Registering AI session: ${sessionId}`); this.sessions.set(sessionId, agent); } /** * Get an existing ProbeAgent session */ getSession(sessionId) { const agent = this.sessions.get(sessionId); if (agent) { console.error(`\u267B\uFE0F Reusing AI session: ${sessionId}`); } return agent; } /** * Remove a session from the registry */ unregisterSession(sessionId) { if (this.sessions.has(sessionId)) { console.error(`\u{1F5D1}\uFE0F Unregistering AI session: ${sessionId}`); const agent = this.sessions.get(sessionId); this.sessions.delete(sessionId); if (agent && typeof agent.cleanup === "function") { try { agent.cleanup(); } catch (error) { console.error(`\u26A0\uFE0F Warning: Failed to cleanup ProbeAgent: ${error}`); } } } } /** * Clear all sessions (useful for cleanup) */ clearAllSessions() { console.error(`\u{1F9F9} Clearing all AI sessions (${this.sessions.size} sessions)`); for (const [, agent] of this.sessions.entries()) { if (agent && typeof agent.cleanup === "function") { try { agent.cleanup(); } catch { } } } this.sessions.clear(); } /** * Get all active session IDs */ getActiveSessionIds() { return Array.from(this.sessions.keys()); } /** * Check if a session exists */ hasSession(sessionId) { return this.sessions.has(sessionId); } /** * Clone a session with a new session ID using ProbeAgent's official clone() method * This uses ProbeAgent's built-in cloning which automatically handles: * - Intelligent filtering of internal messages (schema reminders, tool prompts, etc.) * - Preserving system message for cache efficiency * - Deep copying conversation history * - Copying agent configuration */ async cloneSession(sourceSessionId, newSessionId, checkName) { const sourceAgent = this.sessions.get(sourceSessionId); if (!sourceAgent) { console.error(`\u26A0\uFE0F Cannot clone session: ${sourceSessionId} not found`); return void 0; } try { const clonedAgent = sourceAgent.clone({ sessionId: newSessionId, stripInternalMessages: true, // Remove schema reminders, tool prompts, etc. keepSystemMessage: true, // Keep for cache efficiency deepCopy: true // Safe deep copy of history }); if (sourceAgent.debug && checkName) { try { const { initializeTracer: initializeTracer2 } = await Promise.resolve().then(() => (init_tracer_init(), tracer_init_exports)); const tracerResult = await initializeTracer2(newSessionId, checkName); if (tracerResult) { clonedAgent.tracer = tracerResult.tracer; clonedAgent._telemetryConfig = tracerResult.telemetryConfig; clonedAgent._traceFilePath = tracerResult.filePath; } } catch (traceError) { console.error( "\u26A0\uFE0F Warning: Failed to initialize tracing for cloned session:", traceError ); } } if (sourceAgent._mcpInitialized && typeof clonedAgent.initialize === "function") { try { await clonedAgent.initialize(); console.error(`\u{1F527} Initialized MCP tools for cloned session`); } catch (initError) { console.error(`\u26A0\uFE0F Warning: Failed to initialize cloned agent: ${initError}`); } } const historyLength = clonedAgent.history?.length || 0; console.error( `\u{1F4CB} Cloned session ${sourceSessionId} \u2192 ${newSessionId} using ProbeAgent.clone() (${historyLength} messages, internal messages filtered)` ); this.registerSession(newSessionId, clonedAgent); return clonedAgent; } catch (error) { console.error(`\u26A0\uFE0F Failed to clone session ${sourceSessionId}:`, error); return void 0; } } /** * Register process exit handlers to cleanup sessions on exit */ registerExitHandlers() { if (this.exitHandlerRegistered) { return; } const cleanupAndExit = (signal) => { if (this.sessions.size > 0) { console.error(` \u{1F9F9} [${signal}] Cleaning up ${this.sessions.size} active AI sessions...`); this.clearAllSessions(); } }; process.on("exit", () => { if (this.sessions.size > 0) { console.error(`\u{1F9F9} [exit] Cleaning up ${this.sessions.size} active AI sessions...`); for (const [, agent] of this.sessions.entries()) { if (agent && typeof agent.cleanup === "function") { try { agent.cleanup(); } catch { } } } this.sessions.clear(); } }); process.on("SIGINT", () => { cleanupAndExit("SIGINT"); process.exit(0); }); process.on("SIGTERM", () => { cleanupAndExit("SIGTERM"); process.exit(0); }); this.exitHandlerRegistered = true; } }; } }); // src/utils/diff-processor.ts async function processDiffWithOutline(diffContent) { if (!diffContent || diffContent.trim().length === 0) { return diffContent; } try { const originalProbePath = process.env.PROBE_PATH; const fs14 = require("fs"); const possiblePaths = [ // Relative to current working directory (most common in production) path2.join(process.cwd(), "node_modules/@probelabs/probe/bin/probe-binary"), // Relative to __dirname (for unbundled development) path2.join(__dirname, "../..", "node_modules/@probelabs/probe/bin/probe-binary"), // Relative to dist directory (for bundled CLI) path2.join(__dirname, "node_modules/@probelabs/probe/bin/probe-binary") ]; let probeBinaryPath; for (const candidatePath of possiblePaths) { if (fs14.existsSync(candidatePath)) { probeBinaryPath = candidatePath; break; } } if (!probeBinaryPath) { if (process.env.DEBUG === "1" || process.env.VERBOSE === "1") { console.error("Probe binary not found. Tried:", possiblePaths); } return diffContent; } process.env.PROBE_PATH = probeBinaryPath; const extractPromise = (0, import_probe.extract)({ content: diffContent, format: "outline-diff", allowTests: true // Allow test files and test code blocks in extraction results }); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error("Extract timeout after 30s")), 3e4); }); const result = await Promise.race([extractPromise, timeoutPromise]); if (originalProbePath !== void 0) { process.env.PROBE_PATH = originalProbePath; } else { delete process.env.PROBE_PATH; } return typeof result === "string" ? result : JSON.stringify(result); } catch (error) { if (process.env.DEBUG === "1" || process.env.VERBOSE === "1") { console.error("Failed to process diff with outline-diff format:", error); } return diffContent; } } var import_probe, path2; var init_diff_processor = __esm({ "src/utils/diff-processor.ts"() { "use strict"; import_probe = require("@probelabs/probe"); path2 = __toESM(require("path")); } }); // src/ai-review-service.ts function log(...args) { logger.debug(args.join(" ")); } var import_probe2, AIReviewService; var init_ai_review_service = __esm({ "src/ai-review-service.ts"() { "use strict"; import_probe2 = require("@probelabs/probe"); init_session_registry(); init_logger(); init_tracer_init(); init_diff_processor(); AIReviewService = class { config; sessionRegistry; constructor(config = {}) { this.config = { timeout: 6e5, // Increased timeout to 10 minutes for AI responses ...config }; this.sessionRegistry = SessionRegistry.getInstance(); if (!this.config.apiKey) { if (process.env.CLAUDE_CODE_API_KEY) { this.config.apiKey = process.env.CLAUDE_CODE_API_KEY; this.config.provider = "claude-code"; } else if (process.env.GOOGLE_API_KEY) { this.config.apiKey = process.env.GOOGLE_API_KEY; this.config.provider = "google"; } else if (process.env.ANTHROPIC_API_KEY) { this.config.apiKey = process.env.ANTHROPIC_API_KEY; this.config.provider = "anthropic"; } else if (process.env.OPENAI_API_KEY) { this.config.apiKey = process.env.OPENAI_API_KEY; this.config.provider = "openai"; } else if ( // Check for AWS Bedrock credentials process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY || process.env.AWS_BEDROCK_API_KEY ) { this.config.provider = "bedrock"; this.config.apiKey = "AWS_CREDENTIALS"; } } if (!this.config.model && process.env.MODEL_NAME) { this.config.model = process.env.MODEL_NAME; } } // NOTE: per request, no additional redaction/encryption helpers are used. /** * Execute AI review using probe agent */ async executeReview(prInfo, customPrompt, schema, checkName, sessionId) { const startTime = Date.now(); const timestamp = (/* @__PURE__ */ new Date()).toISOString(); const prompt = await this.buildCustomPrompt(prInfo, customPrompt, schema); log(`Executing AI review with ${this.config.provider} provider...`); log(`\u{1F527} Debug: Raw schema parameter: ${JSON.stringify(schema)} (type: ${typeof schema})`); log(`Schema type: ${schema || "none (no schema)"}`); let debugInfo; if (this.config.debug) { debugInfo = { prompt, rawResponse: "", provider: this.config.provider || "unknown", model: this.config.model || "default", apiKeySource: this.getApiKeySource(), processingTime: 0, promptLength: prompt.length, responseLength: 0, errors: [], jsonParseSuccess: false, timestamp, schemaName: typeof schema === "object" ? "custom" : schema, schema: void 0 // Will be populated when schema is loaded }; } if (this.config.model === "mock" || this.config.provider === "mock") { log("\u{1F3AD} Using mock AI model/provider for testing - skipping API key validation"); } else { if (!this.config.apiKey) { const errorMessage = "No API key configured. Please set GOOGLE_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY environment variable, or configure AWS credentials for Bedrock (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY)."; if (debugInfo) { debugInfo.errors = [errorMessage]; debugInfo.processingTime = Date.now() - startTime; debugInfo.rawResponse = "API call not attempted - no API key configured"; return { issues: [ { file: "system", line: 0, ruleId: "system/api-key-missing", message: errorMessage, severity: "error", category: "logic" } ], debug: debugInfo }; } throw new Error(errorMessage); } } try { const { response, effectiveSchema } = await this.callProbeAgent( prompt, schema, debugInfo, checkName, sessionId ); const processingTime = Date.now() - startTime; if (debugInfo) { debugInfo.rawResponse = response; debugInfo.responseLength = response.length; debugInfo.processingTime = processingTime; } const result = this.parseAIResponse(response, debugInfo, effectiveSchema); if (debugInfo) { result.debug = debugInfo; } return result; } catch (error) { if (debugInfo) { debugInfo.errors = [error instanceof Error ? error.message : String(error)]; debugInfo.processingTime = Date.now() - startTime; return { issues: [ { file: "system", line: 0, ruleId: "system/ai-execution-error", message: error instanceof Error ? error.message : String(error), severity: "error", category: "logic" } ], debug: debugInfo }; } throw error; } } /** * Execute AI review using session reuse - reuses an existing ProbeAgent session * @param sessionMode - 'clone' (default) clones history, 'append' shares history */ async executeReviewWithSessionReuse(prInfo, customPrompt, parentSessionId, schema, checkName, sessionMode = "clone") { const startTime = Date.now(); const timestamp = (/* @__PURE__ */ new Date()).toISOString(); const existingAgent = this.sessionRegistry.getSession(parentSessionId); if (!existingAgent) { throw new Error( `Session not found for reuse: ${parentSessionId}. Ensure the parent check completed successfully.` ); } const prompt = await this.buildCustomPrompt(prInfo, customPrompt, schema, { skipPRContext: true }); let agentToUse; let currentSessionId; if (sessionMode === "clone") { currentSessionId = `${checkName}-session-${Date.now()}`; log( `\u{1F4CB} Cloning AI session ${parentSessionId} \u2192 ${currentSessionId} for ${checkName} check...` ); const clonedAgent = await this.sessionRegistry.cloneSession( parentSessionId, currentSessionId, checkName // Pass checkName for tracing ); if (!clonedAgent) { throw new Error(`Failed to clone session ${parentSessionId}`); } agentToUse = clonedAgent; } else { log(`\u{1F504} Appending to AI session ${parentSessionId} (shared history)...`); agentToUse = existingAgent; currentSessionId = parentSessionId; } log(`\u{1F527} Debug: Raw schema parameter: ${JSON.stringify(schema)} (type: ${typeof schema})`); log(`\u{1F4CB} Schema for this check: ${schema || "none (no schema)"}`); if (sessionMode === "clone") { log(`\u2705 Cloned agent will use NEW schema (${schema}) - parent schema does not persist`); log(`\u{1F504} Clone operation ensures fresh agent with copied history but new configuration`); } else { log(`\u{1F504} Append mode - using existing agent instance with shared history and configuration`); } let debugInfo; if (this.config.debug) { debugInfo = { prompt, rawResponse: "", provider: this.config.provider || "unknown", model: this.config.model || "default", apiKeySource: this.getApiKeySource(), processingTime: 0, promptLength: prompt.length, responseLength: 0, errors: [], jsonParseSuccess: false, timestamp, schemaName: typeof schema === "object" ? "custom" : schema, schema: void 0 // Will be populated when schema is loaded }; } try { const { response, effectiveSchema } = await this.callProbeAgentWithExistingSession( agentToUse, prompt, schema, debugInfo, checkName ); const processingTime = Date.now() - startTime; if (debugInfo) { debugInfo.rawResponse = response; debugInfo.responseLength = response.length; debugInfo.processingTime = processingTime; } const result = this.parseAIResponse(response, debugInfo, effectiveSchema); if (debugInfo) { result.debug = debugInfo; } if (sessionMode === "clone" && currentSessionId !== parentSessionId) { result.sessionId = currentSessionId; } return result; } catch (error) { if (debugInfo) { debugInfo.errors = [error instanceof Error ? error.message : String(error)]; debugInfo.processingTime = Date.now() - startTime; return { issues: [ { file: "system", line: 0, ruleId: "system/ai-session-reuse-error", message: error instanceof Error ? error.message : String(error), severity: "error", category: "logic" } ], debug: debugInfo }; } throw error; } } /** * Register a new AI session in the session registry */ registerSession(sessionId, agent) { this.sessionRegistry.registerSession(sessionId, agent); } /** * Cleanup a session from the registry */ cleanupSession(sessionId) { this.sessionRegistry.unregisterSession(sessionId); } /** * Build a custom prompt for AI review with XML-formatted data */ async buildCustomPrompt(prInfo, customInstructions, schema, options) { const skipPRContext = options?.skipPRContext === true; const isCodeReviewSchema = schema === "code-review"; const prContext = skipPRContext ? "" : await this.formatPRContext(prInfo, isCodeReviewSchema); const isIssue = prInfo.isIssue === true; if (isIssue) { if (skipPRContext) { return `<instructions> ${customInstructions} </instructions>`; } return `<review_request> <instructions> ${customInstructions} </instructions> <context> ${prContext} </context> <rules> <rule>Understand the issue context and requirements from the XML data structure</rule> <rule>Provide helpful, actionable guidance based on the issue details</rule> <rule>Be constructive and supportive in your analysis</rule> <rule>Consider project conventions and patterns when making recommendations</rule> <rule>Suggest practical solutions or next steps that address the specific concern</rule> <rule>Focus on addressing the specific concern raised in the issue</rule> <rule>Reference relevant XML elements like metadata, description, labels, assignees when providing context</rule> </rules> </review_request>`; } if (isCodeReviewSchema) { const analysisType = prInfo.isIncremental ? "INCREMENTAL" : "FULL"; if (skipPRContext) { return `<instructions> ${customInstructions} </instructions> <reminder> <rule>The code context and diff were provided in the previous message</rule> <rule>Focus on the new analysis instructions above</rule> <rule>Only analyze code that appears with + (additions) or - (deletions) in the diff sections</rule> <rule>STRICT OUTPUT POLICY: Report only actual problems, risks, or deficiencies</rule> <rule>SEVERITY ASSIGNMENT: Assign severity ONLY to problems introduced or left unresolved by this change</rule> </reminder>`; } return `<review_request> <analysis_type>${analysisType}</analysis_type> <analysis_focus> ${analysisType === "INCREMENTAL" ? "You are analyzing a NEW COMMIT added to an existing PR. Focus on the changes in the commit_diff section for this specific commit." : "You are analyzing the COMPLETE PR. Review all changes in the full_diff section."} </analysis_focus> <instructions> ${customInstructions} </instructions> <context> ${prContext} </context> <rules> <rule>Only analyze code that appears with + (additions) or - (deletions) in the diff sections</rule> <rule>Ignore unchanged code unless directly relevant to understanding a change</rule> <rule>Line numbers in your response should match actual file line numbers from the diff</rule> <rule>Focus on real issues, not nitpicks or cosmetic concerns</rule> <rule>Provide actionable, specific feedback with clear remediation steps</rule> <rule>For INCREMENTAL analysis, ONLY review changes in commit_diff section</rule> <rule>For FULL analysis, review all changes in full_diff section</rule> <rule>Reference specific XML elements like files_summary, metadata when providing context</rule> <rule>STRICT OUTPUT POLICY: Report only actual problems, risks, or deficiencies. Do not write praise, congratulations, or celebratory text. Do not create issues that merely restate improvements or say "no action needed".</rule> <rule>SEVERITY ASSIGNMENT: Assign severity ONLY to problems introduced or left unresolved by this change (critical/error/warning/info as appropriate). Do NOT create issue entries solely to acknowledge improvements; if no problems exist, return zero issues.</rule> </rules> </review_request>`; } if (skipPRContext) { return `<instructions> ${customInstructions} </instructions>`; } return `<instructions> ${customInstructions} </instructions> <context> ${prContext} </context>`; } // REMOVED: Built-in prompts - only use custom prompts from .visor.yaml // REMOVED: getFocusInstructions - only use custom prompts from .visor.yaml /** * Format PR or Issue context for the AI using XML structure */ async formatPRContext(prInfo, isCodeReviewSchema) { const prContextInfo = prInfo; const isIssue = prContextInfo.isIssue === true; const isPRContext = prContextInfo.isPRContext === true; const includeCodeContext = isPRContext || prContextInfo.includeCodeContext !== false; if (isPRContext) { log("\u{1F50D} Including full code diffs in AI context (PR mode)"); } else if (!includeCodeContext) { log("\u{1F4CA} Including only file summary in AI context (no diffs)"); } else { log("\u{1F50D} Including code diffs in AI context"); } if (isIssue) { let context3 = `<issue> <!-- Core issue metadata including identification, status, and timeline information --> <metadata> <number>${prInfo.number}</number> <title>${this.escapeXml(prInfo.title)}</title> <author>${prInfo.author}</author> <state>${prInfo.eventContext?.issue?.state || "open"}</state> <created_at>${prInfo.eventContext?.issue?.created_at || ""}</created_at> <updated_at>${prInfo.eventContext?.issue?.updated_at || ""}</updated_at> <comments_count>${prInfo.eventContext?.issue?.comments || 0}</comments_count> </metadata>`; if (prInfo.body) { context3 += ` <!-- Full issue description and body text provided by the issue author --> <description> ${this.escapeXml(prInfo.body)} </description>`; } const eventContext = prInfo; const labels = eventContext.eventContext?.issue?.labels; if (labels && labels.length > 0) { context3 += ` <!-- Applied labels for issue categorization and organization --> <labels>`; labels.forEach((label) => { const labelName = typeof label === "string" ? label : label.name || "unknown"; context3 += ` <label>${this.escapeXml(labelName)}</label>`; }); context3 += ` </labels>`; } const assignees = prInfo.eventContext?.issue?.assignees; if (assignees && assignees.length > 0) { context3 += ` <!-- Users assigned to work on this issue --> <assignees>`; assignees.forEach((assignee) => { const assigneeName = typeof assignee === "string" ? assignee : assignee.login || "unknown"; context3 += ` <assignee>${this.escapeXml(assigneeName)}</assignee>`; }); context3 += ` </assignees>`; } const milestone = prInfo.eventContext?.issue?.milestone; if (milestone) { context3 += ` <!-- Associated project milestone information --> <milestone> <title>${this.escapeXml(milestone.title || "")}</title> <state>${milestone.state || "open"}</state> <due_on>${milestone.due_on || ""}</due_on> </milestone>`; } const triggeringComment2 = prInfo.eventContext?.comment; if (triggeringComment2) { context3 += ` <!-- The comment that triggered this analysis --> <triggering_comment> <author>${this.escapeXml(triggeringComment2.user?.login || "unknown")}</author> <created_at>${triggeringComment2.created_at || ""}</created_at> <body>${this.escapeXml(triggeringComment2.body || "")}</body> </triggering_comment>`; } const issueComments = prInfo.comments; if (issueComments && issueComments.length > 0) { let historicalComments = triggeringComment2 ? issueComments.filter((c) => c.id !== triggeringComment2.id) : issueComments; if (isCodeReviewSchema) { historicalComments = historicalComments.filter( (c) => !c.body || !c.body.includes("visor-comment-id:pr-review-") ); } if (historicalComments.length > 0) { context3 += ` <!-- Previous comments in chronological order (excluding triggering comment) --> <comment_history>`; historicalComments.forEach((comment) => { context3 += ` <comment> <author>${this.escapeXml(comment.author || "unknown")}</author> <created_at>${comment.createdAt || ""}</created_at> <body>${this.escapeXml(comment.body || "")}</body> </comment>`; }); context3 += ` </comment_history>`; } } context3 += ` </issue>`; return context3; } let context2 = `<pull_request> <!-- Core pull request metadata including identification, branches, and change statistics --> <metadata> <number>${prInfo.number}</number> <title>${this.escapeXml(prInfo.title)}</title> <author>${prInfo.author}</author> <base_branch>${prInfo.base}</base_branch> <target_branch>${prInfo.head}</target_branch> <total_additions>${prInfo.totalAdditions}</total_additions> <total_deletions>${prInfo.totalDeletions}</total_deletions> <files_changed_count>${prInfo.files.length}</files_changed_count> </metadata>`; if (prInfo.body) { context2 += ` <!-- Full pull request description provided by the author --> <description> ${this.escapeXml(prInfo.body)} </description>`; } if (includeCodeContext) { if (prInfo.fullDiff) { const processedFullDiff = await processDiffWithOutline(prInfo.fullDiff); context2 += ` <!-- Complete unified diff showing all changes in the pull request (processed with outline-diff) --> <full_diff> ${this.escapeXml(processedFullDiff)} </full_diff>`; } if (prInfo.isIncremental) { if (prInfo.commitDiff && prInfo.commitDiff.length > 0) { const processedCommitDiff = await processDiffWithOutline(prInfo.commitDiff); context2 += ` <!-- Diff of only the latest commit for incremental analysis (processed with outline-diff) --> <commit_diff> ${this.escapeXml(processedCommitDiff)} </commit_diff>`; } else { const processedFallbackDiff = prInfo.fullDiff ? await processDiffWithOutline(prInfo.fullDiff) : ""; context2 += ` <!-- Commit diff could not be retrieved - falling back to full diff analysis (processed with outline-diff) --> <commit_diff> ${this.escapeXml(processedFallbackDiff)} </commit_diff>`; } } } else { context2 += ` <!-- Code diffs excluded to reduce token usage (no code-review schema detected or disabled by flag) -->`; } if (prInfo.files.length > 0) { context2 += ` <!-- Summary of all files changed with statistics --> <files_summary>`; prInfo.files.forEach((file) => { context2 += ` <file> <filename>${this.escapeXml(file.filename)}</filename> <status>${file.status}</status> <additions>${file.additions}</additions> <deletions>${file.deletions}</deletions> </file>`; }); context2 += ` </files_summary>`; } const triggeringComment = prInfo.eventContext?.comment; if (triggeringComment) { context2 += ` <!-- The comment that triggered this analysis --> <triggering_comment> <author>${this.escapeXml(triggeringComment.user?.login || "unknown")}</author> <created_at>${triggeringComment.created_at || ""}</created_at> <body>${this.escapeXml(triggeringComment.body || "")}</body> </triggering_comment>`; } const prComments = prInfo.comments; if (prComments && prComments.length > 0) { let historicalComments = triggeringComment ? prComments.filter((c) => c.id !== triggeringComment.id) : prComments; if (isCodeReviewSchema) { historicalComments = historicalComments.filter( (c) => !c.body || !c.body.includes("visor-comment-id:pr-review-") ); } if (historicalComments.length > 0) { context2 += ` <!-- Previous PR comments in chronological order (excluding triggering comment) --> <comment_history>`; historicalComments.forEach((comment) => { context2 += ` <comment> <author>${this.escapeXml(comment.author || "unknown")}</author> <created_at>${comment.createdAt || ""}</created_at> <body>${this.escapeXml(comment.body || "")}</body> </comment>`; }); context2 += ` </comment_history>