UNPKG

@probelabs/visor

Version:

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

1,408 lines (1,387 loc) 505 kB
import { init_tracer_init, initializeTracer } from "./chunk-TUTOLSFV.mjs"; import { MemoryStore, createExtendedLiquid, createPermissionHelpers, detectLocalMode, init_logger, logger, logger_exports, resolveAssociationFromEvent } from "./chunk-B5QBV2QJ.mjs"; import { addEvent, addFailIfTriggered, context, emitNdjsonFallback, emitNdjsonSpanWithEvents, fallback_ndjson_exports, init_fallback_ndjson, trace, withActiveSpan } from "./chunk-33QVZ2D4.mjs"; import { __esm, __export, __require, __toCommonJS } from "./chunk-WMJKH4XE.mjs"; // 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 import("./tracer-init-WC75N5NW.mjs"); 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/github-comments.ts init_logger(); import { v4 as uuidv4 } from "uuid"; // 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"); } // src/github-comments.ts var 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 uuidv4().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((resolve4) => setTimeout(resolve4, 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/ai-review-service.ts init_session_registry(); init_logger(); init_tracer_init(); import { ProbeAgent } from "@probelabs/probe"; // src/utils/diff-processor.ts import { extract } from "@probelabs/probe"; import * as path from "path"; async function processDiffWithOutline(diffContent) { if (!diffContent || diffContent.trim().length === 0) { return diffContent; } try { const originalProbePath = process.env.PROBE_PATH; const fs7 = __require("fs"); const possiblePaths = [ // Relative to current working directory (most common in production) path.join(process.cwd(), "node_modules/@probelabs/probe/bin/probe-binary"), // Relative to __dirname (for unbundled development) path.join(__dirname, "../..", "node_modules/@probelabs/probe/bin/probe-binary"), // Relative to dist directory (for bundled CLI) path.join(__dirname, "node_modules/@probelabs/probe/bin/probe-binary") ]; let probeBinaryPath; for (const candidatePath of possiblePaths) { if (fs7.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 = 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; } } // src/ai-review-service.ts function log(...args) { logger.debug(args.join(" ")); } var 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>`; } } context2 += ` </pull_request>`; return context2; } /** * No longer escaping XML - returning text as-is */ escapeXml(text) { return text; } /** * Call ProbeAgent with an existing session */ async callProbeAgentWithExistingSession(agent, prompt, schema, debugInfo, _checkName) { if (this.config.model === "mock" || this.config.provider === "mock") { log("\u{1F3AD} Using mock AI model/provider for testing (session reuse)"); const response = await this.generateMockResponse(prompt); return { response, effectiveSchema: typeof schema === "object" ? "custom" : schema }; } log("\u{1F504} Reusing existing ProbeAgent session for AI review..."); log(`\u{1F4DD} Prompt length: ${prompt.length} characters`); log(`\u2699\uFE0F Model: ${this.config.model || "default"}, Provider: ${this.config.provider || "auto"}`); try { log("\u{1F680} Calling existing ProbeAgent with answer()..."); let schemaString = void 0; let effectiveSchema = typeof schema === "object" ? "custom" : schema; if (schema && schema !== "plain") { try { schemaString = await this.loadSchemaContent(schema); log(`\u{1F4CB} Loaded schema content for: ${schema}`); log(`\u{1F4C4} Raw schema JSON: ${schemaString}`); } catch (error) { log(`\u26A0\uFE0F Failed to load schema ${schema}, proceeding without schema:`, error); schemaString = void 0; effectiveSchema = void 0; if (debugInfo && debugInfo.errors) { debugInfo.errors.push(`Failed to load schema: ${error}`); } } } else if (schema === "plain") { log(`\u{1F4CB} Using plain schema - no JSON validation will be applied`); } const schemaOptions = schemaString ? { schema: schemaString } : void 0; if (debugInfo && schemaOptions) { debugInfo.schema = JSON.stringify(schemaOptions, null, 2); } if (schemaOptions) { log(`\u{1F3AF} Schema options passed to ProbeAgent.answer() (session reuse):`); log(JSON.stringify(schemaOptions, null, 2)); } if (process.env.VISOR_DEBUG_AI_SESSIONS === "true") { try { const fs7 = __require("fs"); const path9 = __require("path"); const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); const provider = this.config.provider || "auto"; const model = this.config.model || "default"; let conversationHistory = []; try { const agentAny2 = agent; if (agentAny2.history) { conversationHistory = agentAny2.history; } else if (agentAny2.messages) { conversationHistory = agentAny2.messages; } else if (agentAny2._messages) { conversationHistory = agentAny2._messages; } } catch { } const debugData = { timestamp, checkName: _checkName || "unknown", provider, model, schema: effectiveSchema, schemaOptions: schemaOptions || "none", sessionInfo: { isSessionReuse: true, historyMessageCount: conversationHistory.length }, currentPromptLength: prompt.length, currentPrompt: prompt, conversationHistory }; const debugJson = JSON.stringify(debugData, null, 2); let readableVersion = `============================================================= `; readableVersion += `VISOR DEBUG REPORT - SESSION REUSE `; readableVersion += `============================================================= `; readableVersion += `Timestamp: ${timestamp} `; readableVersion += `Check Name: ${_checkName || "unknown"} `; readableVersion += `Provider: ${provider} `; readableVersion += `Model: ${model} `; readableVersion += `Schema: ${effectiveSchema} `; readableVersion += `Schema Options: ${schemaOptions ? "provided" : "none"} `; readableVersion += `History Messages: ${conversationHistory.length} `; readableVersion += `============================================================= `; if (schemaOptions) { readableVersion += ` ${"=".repeat(60)} `; readableVersion += `SCHEMA CONFIGURATION `; readableVersion += `${"=".repeat(60)} `; readableVersion += JSON.stringify(schemaOptions, null, 2); readableVersion += ` `; } if (conversationHistory.length > 0) { readableVersion += ` ${"=".repeat(60)} `; readableVersion += `CONVERSATION HISTORY (${conversationHistory.length} messages) `; readableVersion += `${"=".repeat(60)} `; conversationHistory.forEach((msg, index) => { readableVersion += ` ${"-".repeat(60)} `; readableVersion += `MESSAGE #${index + 1} `; readableVersion += `Role: ${msg.role || "unknown"} `; if (msg.content) { const contentStr = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content, null, 2); readableVersion += `Length: ${contentStr.length} characters `; readableVersion += `${"-".repeat(60)} `; readableVersion += `${contentStr} `; } }); } readableVersion += ` ${"=".repeat(60)} `; readableVersion += `CURRENT PROMPT (NEW MESSAGE) `; readableVersion += `${"=".repeat(60)} `; readableVersion += `Length: ${prompt.length} characters `; readableVersion += `${"-".repeat(60)} `; readableVersion += `${prompt} `; readableVersion += ` ${"=".repeat(60)} `; readableVersion += `END OF DEBUG REPORT `; readableVersion += `${"=".repeat(60)} `; const debugArtifactsDir = process.env.VISOR_DEBUG_ARTIFACTS || path9.join(process.cwd(), "debug-artifacts"); if (!fs7.existsSync(debugArtifactsDir)) { fs7.mkdirSync(debugArtifactsDir, { recursive: true }); } const debugFile = path9.join( debugArtifactsDir, `prompt-${_checkName || "unknown"}-${timestamp}.json` ); fs7.writeFileSync(debugFile, debugJson, "utf-8"); const readableFile = path9.join( debugArtifactsDir, `prompt-${_checkName || "unknown"}-${timestamp}.txt` ); fs7.writeFileSync(readableFile, readableVersion, "utf-8"); log(` \u{1F4BE} Full debug info saved to:`); log(` JSON: ${debugFile}`); log(` TXT: ${readableFile}`); log(` - Includes: full conversation history, schema, current prompt`); } catch (error) { log(`\u26A0\uFE0F Could not save debug file: ${error}`); } } const agentAny = agent; let response; if (agentAny.tracer && typeof agentAny.tracer.withSpan === "function") { response = await agentAny.tracer.withSpan( "visor.ai_check_reuse", async () => { return await agent.answer(prompt, void 0, schemaOptions); }, { "check.name": _checkName || "unknown", "check.mode": "session_reuse", "prompt.length": prompt.length, "schema.type": effectiveSchema || "none" } ); } else { response = await agent.answer(prompt, void 0, schemaOptions); } log("\u2705 ProbeAgent session reuse completed successfully"); log(`\u{1F4E4} Response length: ${response.length} characters`); if (process.env.VISOR_DEBUG_AI_SESSIONS === "true") { try { const fs7 = __require("fs"); const path9 = __require("path"); const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); const agentAny2 = agent; let fullHistory = []; if (agentAny2.history) { fullHistory = agentAny2.history; } else if (agentAny2.messages) { fullHistory = agentAny2.messages; } else if (agentAny2._messages) { fullHistory = agentAny2._messages; } const debugArtifactsDir = process.env.VISOR_DEBUG_ARTIFACTS || path9.join(process.cwd(), "debug-artifacts"); const sessionBase = path9.join( debugArtifactsDir, `session-${_checkName || "unknown"}-${timestamp}` ); const sessionData = { timestamp, checkName: _checkName || "unknown", provider: this.config.provider || "auto", model: this.config.model || "default", schema: effectiveSchema, totalMessages: fullHistory.length }; fs7.writeFileSync(sessionBase + ".json", JSON.stringify(sessionData, null, 2), "utf-8"); let readable = `============================================================= `; readable += `COMPLETE AI SESSION HISTORY (AFTER RESPONSE) `; readable += `============================================================= `; readable += `Timestamp: ${timestamp} `; readable += `Check: ${_checkName || "unknown"} `; readable += `Total Messages: ${fullHistory.length} `; readable += `============================================================= `; fullHistory.forEach((msg, idx) => {