package cc.unitmesh.devins.ui.compose.agent.codereview import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import cc.unitmesh.agent.CodeReviewAgent import cc.unitmesh.agent.Platform import cc.unitmesh.agent.codereview.ModifiedCodeRange import cc.unitmesh.agent.config.McpToolConfigService import cc.unitmesh.agent.config.ToolConfigFile import cc.unitmesh.agent.language.LanguageDetector import cc.unitmesh.agent.logging.AutoDevLogger import cc.unitmesh.agent.platform.GitOperations import cc.unitmesh.agent.diff.ChangeType import cc.unitmesh.devins.ui.compose.agent.ComposeRenderer import cc.unitmesh.devins.ui.compose.agent.codereview.analysis.CodeAnalyzer import cc.unitmesh.devins.ui.compose.agent.codereview.analysis.LintExecutor import cc.unitmesh.agent.diff.DiffParser import cc.unitmesh.agent.util.WalkthroughExtractor import cc.unitmesh.config.ConfigManager import cc.unitmesh.devins.ui.wasm.WasmGitManager import cc.unitmesh.devins.workspace.Workspace import cc.unitmesh.llm.LLMService import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch open class CodeReviewViewModel( val workspace: Workspace, private var codeReviewAgent: CodeReviewAgent? = null ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val gitOps = if (Platform.name == "WebAssembly") { WasmGitManager.getInstance() } else { GitOperations(workspace.rootPath ?: "") } private val analysisRepository = cc.unitmesh.devins.db.CodeReviewAnalysisRepository.getInstance() // Cache remote URL for repository operations private var cachedRemoteUrl: String? = null // Non-AI analysis components (extracted for testability) private val codeAnalyzer = CodeAnalyzer(workspace) private val lintExecutor = LintExecutor() // Issue tracker service private val issueService = IssueService(workspace.rootPath ?: "") // PR Review service (lazy initialized based on repository type) private var prReviewService: cc.unitmesh.agent.review.PRReviewService? = null // State private val _state = MutableStateFlow(CodeReviewState()) val state: StateFlow = _state.asStateFlow() var currentState by mutableStateOf(CodeReviewState()) internal set // Notification events private val _notificationEvent = kotlinx.coroutines.flow.MutableSharedFlow>() val notificationEvent = _notificationEvent.asSharedFlow() // Control execution private var currentJob: Job? = null // Performance optimization: Cache code content to avoid re-reading private var codeContentCache: Map? = null private var cacheTimestamp: Long = 0 private val CACHE_VALIDITY_MS = 30_000L // 30 seconds // Performance tracking private data class PerformanceMetrics( val startTime: Long, val phase: String ) private var currentMetrics: PerformanceMetrics? = null init { // Validate workspace has a valid root path if (workspace.rootPath.isNullOrEmpty()) { AutoDevLogger.warn("CodeReviewViewModel") { "Workspace root path is null or empty, skipping agent initialization" } updateState { it.copy(error = "No workspace path configured. Please open a project first.") } } else { CoroutineScope(Dispatchers.Default).launch { try { // Fetch and cache remote URL cachedRemoteUrl = try { if (gitOps.isSupported()) { gitOps.getRemoteUrl("origin") } else { null } } catch (e: Exception) { AutoDevLogger.warn("CodeReviewViewModel") { "Failed to get remote URL: ${e.message}" } null } AutoDevLogger.info("CodeReviewViewModel") { "Cached remote URL: ${cachedRemoteUrl ?: "(not available)"}" } codeReviewAgent = initializeCodingAgent() // Initialize issue service issueService.initialize(if (gitOps.isSupported()) gitOps else null) if (gitOps.isSupported()) { loadCommitHistory() } else { loadDiff() } } catch (e: Exception) { AutoDevLogger.error("CodeReviewViewModel") { "Failed to initialize: ${e.message}" } e.printStackTrace() updateState { it.copy(error = "Initialization failed: ${e.message}") } } } } } suspend fun initializeCodingAgent(): CodeReviewAgent { codeReviewAgent?.let { return it } val projectPath = workspace.rootPath if (projectPath.isNullOrEmpty()) { error("Cannot initialize coding agent: workspace root path is null or empty") } return createCodeReviewAgent(projectPath) } /** * Load recent git commits (initial load) */ suspend fun loadCommitHistory(count: Int = 50) { updateState { it.copy(isLoading = true, error = null) } try { val totalCount = gitOps.getTotalCommitCount() val gitCommits = gitOps.getRecentCommits(count) val hasMore = totalCount?.let { it > gitCommits.size } ?: false val commits = gitCommits.map { git -> CommitInfo( hash = git.hash, shortHash = git.shortHash, author = git.author, timestamp = git.date, date = formatDate(git.date), message = git.message ) } updateState { it.copy( isLoading = false, commitHistory = commits, selectedCommitIndices = if (commits.isNotEmpty()) setOf(0) else emptySet(), hasMoreCommits = hasMore, totalCommitCount = totalCount, error = null ) } if (commits.isNotEmpty()) { loadCommitDiffInternal(setOf(0)) // Load issue info for first commit asynchronously loadIssueForCommit(0) } } catch (e: Exception) { updateState { it.copy( isLoading = false, error = "Failed to load commits: ${e.message}" ) } } } suspend fun loadMoreCommits(batchSize: Int = 50) { if (currentState.isLoadingMore || !currentState.hasMoreCommits) { return } updateState { it.copy(isLoadingMore = true) } try { val currentCount = currentState.commitHistory.size val gitCommits = gitOps.getRecentCommits(currentCount + batchSize + 1) // Skip already loaded commits val newCommits = gitCommits.drop(currentCount) val hasMore = newCommits.size > batchSize val commitsToAdd = if (hasMore) newCommits.take(batchSize) else newCommits val additionalCommits = commitsToAdd.map { git -> CommitInfo( hash = git.hash, shortHash = git.shortHash, author = git.author, timestamp = git.date, date = formatDate(git.date), message = git.message ) } updateState { it.copy( commitHistory = it.commitHistory + additionalCommits, hasMoreCommits = hasMore, isLoadingMore = false ) } // Load issue info for newly loaded commits asynchronously val startIndex = currentCount additionalCommits.forEachIndexed { offset, _ -> loadIssueForCommit(startIndex + offset) } } catch (e: Exception) { updateState { it.copy( isLoadingMore = false, error = "Failed to load more commits: ${e.message}" ) } } } /** * Load diff for selected commits (supports range selection) */ private suspend fun loadCommitDiffInternal(selectedIndices: Set) { if (selectedIndices.isEmpty()) { updateState { it.copy( isLoadingDiff = false, selectedCommitIndices = emptySet(), diffFiles = emptyList(), error = null ) } return } updateState { it.copy( isLoadingDiff = true, selectedCommitIndices = selectedIndices, error = null ) } try { // Determine range: oldest to newest val sortedIndices = selectedIndices.sorted() val newestIndex = sortedIndices.first() // 0 is newest val oldestIndex = sortedIndices.last() val newestCommit = currentState.commitHistory[newestIndex] val oldestCommit = currentState.commitHistory[oldestIndex] val gitDiff = if (newestIndex == oldestIndex) { // Single commit gitOps.getCommitDiff(newestCommit.hash) } else { // Range diff: oldest^..newest // Check if oldest commit has a parent val hasParent = gitOps.hasParent(oldestCommit.hash) if (hasParent) { // Use parent notation for commits with parents gitOps.getDiff("${oldestCommit.hash}^", newestCommit.hash) } else { // For root commit, get diff from empty tree gitOps.getDiff("4b825dc642cb6eb9a060e54bf8d69288fbee4904", newestCommit.hash) } } if (gitDiff == null) { updateState { it.copy( isLoadingDiff = false, error = "No diff available for the selected range" ) } return } val diffFiles = gitDiff.files.map { file -> val parsedDiff = DiffParser.parse(file.diff) val hunks = parsedDiff.firstOrNull()?.hunks ?: emptyList() DiffFileInfo( path = file.path, oldPath = file.oldPath, changeType = when (file.status) { cc.unitmesh.devins.workspace.GitFileStatus.ADDED -> ChangeType.CREATE cc.unitmesh.devins.workspace.GitFileStatus.DELETED -> ChangeType.DELETE cc.unitmesh.devins.workspace.GitFileStatus.MODIFIED -> ChangeType.EDIT cc.unitmesh.devins.workspace.GitFileStatus.RENAMED -> ChangeType.RENAME cc.unitmesh.devins.workspace.GitFileStatus.COPIED -> ChangeType.EDIT }, hunks = hunks, language = LanguageDetector.detectLanguage(file.path), ) } updateState { it.copy( isLoadingDiff = false, diffFiles = diffFiles, selectedFileIndex = 0, error = null, originDiff = gitDiff.originDiff ) } // Find related tests findRelatedTests() // Auto-start analysis if agent is available (for automatic testing) if (codeReviewAgent != null && diffFiles.isNotEmpty()) { AutoDevLogger.info("CodeReviewViewModel") { "🤖 Auto-starting analysis with ${diffFiles.size} files" } } } catch (e: Exception) { updateState { it.copy( isLoadingDiff = false, error = "Failed to load diff: ${e.message}" ) } } } /** * Load git diff from workspace (fallback for platforms without git support) */ suspend fun loadDiff(request: DiffRequest = DiffRequest()) { updateState { it.copy(isLoading = true, error = null) } invalidateCodeCache() try { val gitDiff = gitOps.getDiff(request.baseBranch ?: "master", request.compareWith ?: "HEAD") if (gitDiff == null) { updateState { it.copy( isLoading = false, error = "No git diff available. Make sure you have uncommitted changes or specify a commit." ) } return } val diffFiles = gitDiff.files.map { file -> val parsedDiff = DiffParser.parse(file.diff) val hunks = parsedDiff.firstOrNull()?.hunks ?: emptyList() DiffFileInfo( path = file.path, oldPath = file.oldPath, changeType = when (file.status) { cc.unitmesh.devins.workspace.GitFileStatus.ADDED -> ChangeType.CREATE cc.unitmesh.devins.workspace.GitFileStatus.DELETED -> ChangeType.DELETE cc.unitmesh.devins.workspace.GitFileStatus.MODIFIED -> ChangeType.EDIT cc.unitmesh.devins.workspace.GitFileStatus.RENAMED -> ChangeType.RENAME cc.unitmesh.devins.workspace.GitFileStatus.COPIED -> ChangeType.EDIT }, hunks = hunks, language = LanguageDetector.detectLanguage(file.path) ) } updateState { it.copy( isLoading = false, diffFiles = diffFiles, error = null, originDiff = gitDiff.originDiff ) } // Find related tests findRelatedTests() } catch (e: Exception) { updateState { it.copy( isLoading = false, error = "Failed to load diff: ${e.message}" ) } } } open fun startAnalysis() { if (currentState.diffFiles.isEmpty()) { updateState { it.copy(error = "No files to analyze") } return } currentJob?.cancel() currentJob = CoroutineScope(Dispatchers.Default).launch { try { updateState { it.copy( aiProgress = AIAnalysisProgress(stage = AnalysisStage.RUNNING_LINT), error = null ) } // Step 1: Analyze modified code structure val modifiedCodeRanges = analyzeModifiedCode() // Step 2: Run lint on modified files val filePaths = currentState.diffFiles.map { it.path } runLint(filePaths, modifiedCodeRanges) updateState { it.copy( aiProgress = it.aiProgress.copy(stage = AnalysisStage.ANALYZING_LINT) ) } val agent = initializeCodingAgent() val additionalContext = buildString { val selectedCommits = currentState.selectedCommitIndices.mapNotNull { currentState.commitHistory.getOrNull(it) } if (selectedCommits.isNotEmpty()) { appendLine("## Selected Commits") selectedCommits.forEach { commit -> appendLine("- ${commit.shortHash}: ${commit.message.lines().firstOrNull()}") } appendLine() } val issues = selectedCommits.mapNotNull { it.issueInfo }.distinctBy { it.id } if (issues.isNotEmpty()) { appendLine("## Related Issue Information") issues.forEach { issue -> appendLine() appendLine("**Issue #${issue.id}**: ${issue.title}") appendLine("**Status**: ${issue.status}") if (issue.description.isNotBlank()) { appendLine() appendLine("**Description**:") appendLine(issue.description) } if (issue.labels.isNotEmpty()) { appendLine() appendLine("**Labels**: ${issue.labels.joinToString(", ")}") } } appendLine() } } val reviewTask = cc.unitmesh.agent.ReviewTask( filePaths = filePaths, reviewType = cc.unitmesh.agent.ReviewType.COMPREHENSIVE, projectPath = workspace.rootPath ?: "", patch = currentState.originDiff, lintResults = currentState.aiProgress.lintResults, additionalContext = additionalContext ) val analysisOutputBuilder = StringBuilder() updateState { it.copy(aiProgress = it.aiProgress.copy(analysisOutput = "🧠 Starting code review analysis...\n")) } try { analysisOutputBuilder.appendLine() val agentResult = agent.execute(reviewTask) { progressMessage -> analysisOutputBuilder.append(progressMessage) updateState { it.copy( aiProgress = it.aiProgress.copy( analysisOutput = analysisOutputBuilder.toString() ) ) } } } catch (e: Exception) { AutoDevLogger.error("CodeReviewViewModel") { "Failed to execute review task: ${e.message}" } analysisOutputBuilder.append("\n❌ Error: ${e.message}") updateState { it.copy( aiProgress = it.aiProgress.copy( analysisOutput = analysisOutputBuilder.toString() ) ) } } // Generate modification plan after analysis generateModificationPlan() _notificationEvent.emit("Analysis Complete" to "Code analysis finished. Please review the modification plan.") saveCurrentAnalysisResults() } catch (e: CancellationException) { throw e } catch (e: Exception) { updateState { it.copy( aiProgress = it.aiProgress.copy(stage = AnalysisStage.ERROR), error = "Analysis failed: ${e.message}" ) } saveCurrentAnalysisResults() } } } /** * Cancel current analysis */ open fun cancelAnalysis() { currentJob?.cancel() updateState { it.copy( aiProgress = AIAnalysisProgress(stage = AnalysisStage.IDLE) ) } } /** * Generate modification plan based on analysis results */ suspend fun generateModificationPlan() { try { updateState { it.copy( aiProgress = it.aiProgress.copy(stage = AnalysisStage.GENERATING_PLAN) ) } val planOutputBuilder = StringBuilder() updateState { it.copy(aiProgress = it.aiProgress.copy(planOutput = "💡 生成修改建议...\n")) } // Use CodeReviewAgentPromptRenderer to generate structured prompt val promptRenderer = cc.unitmesh.agent.CodeReviewAgentPromptRenderer() val planPrompt = promptRenderer.renderModificationPlanPrompt( lintResults = currentState.aiProgress.lintResults, analysisOutput = WalkthroughExtractor.extract(currentState.aiProgress.analysisOutput), modifiedCodeRanges = currentState.aiProgress.modifiedCodeRanges, language = "ZH" // Use Chinese for better user experience ) try { // Create a temporary LLM service for plan generation val configWrapper = ConfigManager.load() val modelConfig = configWrapper.getActiveModelConfig() ?: error("No active model configuration found") val llmService = LLMService.create(modelConfig) // Use LLM service to generate plan with streaming llmService.streamPrompt(planPrompt, compileDevIns = false).collect { chunk -> planOutputBuilder.append(chunk) updateState { it.copy( aiProgress = it.aiProgress.copy( planOutput = planOutputBuilder.toString() ) ) } } } catch (e: Exception) { AutoDevLogger.error("CodeReviewViewModel") { "LLM call failed during plan generation: ${e.message}" } planOutputBuilder.append("\n❌ Error: ${e.message}") updateState { it.copy( aiProgress = it.aiProgress.copy( planOutput = planOutputBuilder.toString() ) ) } } updateState { it.copy( aiProgress = it.aiProgress.copy(stage = AnalysisStage.WAITING_FOR_USER_INPUT) ) } } catch (e: Exception) { AutoDevLogger.error("CodeReviewViewModel") { "Failed to generate modification plan: ${e.message}" } updateState { it.copy( aiProgress = it.aiProgress.copy( planOutput = "\n❌ Error generating plan: ${e.message}", stage = AnalysisStage.WAITING_FOR_USER_INPUT ) ) } } } /** * Select a commit or toggle selection * @param index The index of the commit in the list * @param toggle Whether to toggle selection (multi-select) or replace it */ open fun selectCommit(index: Int, toggle: Boolean = false) { currentJob?.cancel() saveCurrentAnalysisResults() currentJob = CoroutineScope(Dispatchers.Default).launch { updateState { it.copy( aiProgress = AIAnalysisProgress(stage = AnalysisStage.IDLE) ) } val newSelection = if (toggle) { if (currentState.selectedCommitIndices.contains(index)) { currentState.selectedCommitIndices - index } else { currentState.selectedCommitIndices + index } } else { setOf(index) } // Ensure at least one commit is selected if we're not in a "deselect all" mode (which shouldn't happen usually) // But if user deselects the last one, maybe we should allow empty? For now let's allow empty. loadCommitDiffInternal(newSelection) // Load issue info for selected commits newSelection.forEach { idx -> loadIssueForCommit(idx) } // Restore analysis results only if single commit selected (complexity with multi-commit analysis storage) if (newSelection.size == 1) { val commit = currentState.commitHistory.getOrNull(newSelection.first()) if (commit != null) { restoreAnalysisResultsForCommit(commit.hash) } } } } open fun selectFile(index: Int) { if (index in currentState.diffFiles.indices) { updateState { it.copy(selectedFileIndex = index) } } } open fun getSelectedFile(): DiffFileInfo? { return currentState.diffFiles.getOrNull(currentState.selectedFileIndex) } open fun refresh() { scope.launch { if (gitOps.isSupported()) { // 如果支持 Git,就加载提交历史(无论当前是否有数据) loadCommitHistory() } else { loadDiff() } } } /** * Save current analysis results to database */ private fun saveCurrentAnalysisResults() { // Only save for single commit selection to avoid complexity if (currentState.selectedCommitIndices.size != 1) return val index = currentState.selectedCommitIndices.first() val currentCommit = currentState.commitHistory.getOrNull(index) val projectPath = workspace.rootPath ?: return if (currentCommit != null && currentState.aiProgress.stage != AnalysisStage.IDLE) { // Only save if there's actual analysis data if (currentState.aiProgress.lintResults.isNotEmpty() || currentState.aiProgress.analysisOutput.isNotBlank() || currentState.aiProgress.fixOutput.isNotBlank() ) { try { analysisRepository.saveAnalysisResult( remoteUrl = cachedRemoteUrl, projectPath = projectPath, commitHash = currentCommit.hash, progress = currentState.aiProgress ) AutoDevLogger.info("CodeReviewViewModel") { "Saved analysis results to database for commit ${currentCommit.shortHash} (remoteUrl: ${cachedRemoteUrl ?: "N/A"})" } } catch (e: Exception) { AutoDevLogger.error("CodeReviewViewModel") { "Failed to save analysis results: ${e.message}" } } } } } /** * Restore analysis results from database for a specific commit */ private fun restoreAnalysisResultsForCommit(commitHash: String) { val projectPath = workspace.rootPath ?: return try { val cachedProgress = analysisRepository.getAnalysisResult( remoteUrl = cachedRemoteUrl, projectPath = projectPath, commitHash = commitHash ) if (cachedProgress != null) { updateState { it.copy(aiProgress = cachedProgress) } AutoDevLogger.info("CodeReviewViewModel") { "Restored analysis results from database for commit ${commitHash.take(7)} (remoteUrl: ${cachedRemoteUrl ?: "N/A"})" } } } catch (e: Exception) { AutoDevLogger.error("CodeReviewViewModel") { "Failed to restore analysis results: ${e.message}" } } } /** * Analyze modified code to find which functions/classes were changed. * Delegates to CodeAnalyzer for the actual analysis logic. */ suspend fun analyzeModifiedCode(): Map> { val projectPath = workspace.rootPath ?: return emptyMap() val modifiedRanges = codeAnalyzer.analyzeModifiedCode( diffFiles = currentState.diffFiles, projectPath = projectPath, progressCallback = { progress -> updateState { it.copy( aiProgress = it.aiProgress.copy( lintOutput = it.aiProgress.lintOutput + progress ) ) } } ) // Update state with results updateState { it.copy( aiProgress = it.aiProgress.copy( modifiedCodeRanges = modifiedRanges ) ) } return modifiedRanges } suspend fun runLint( filePaths: List, modifiedCodeRanges: Map> = emptyMap() ) { val projectPath = workspace.rootPath ?: return // Delegate to LintExecutor with progress callback val lintResults = lintExecutor.runLint( filePaths = filePaths, projectPath = projectPath, modifiedCodeRanges = modifiedCodeRanges, progressCallback = { progress -> updateState { it.copy( aiProgress = it.aiProgress.copy( lintOutput = it.aiProgress.lintOutput + progress ) ) } } ) // Update state with results updateState { it.copy( aiProgress = it.aiProgress.copy( lintResults = lintResults ) ) } } /** * Find related test files for all changed files */ suspend fun findRelatedTests() { if (currentState.diffFiles.isEmpty()) { AutoDevLogger.info("CodeReviewViewModel") { "findRelatedTests: No diff files to process" } return } AutoDevLogger.info("CodeReviewViewModel") { "findRelatedTests: Starting test discovery for ${currentState.diffFiles.size} files" } updateState { it.copy(isLoadingTests = true) } try { val testMap = mutableMapOf>() for (diffFile in currentState.diffFiles) { if (diffFile.changeType == ChangeType.DELETE) continue val testFiles = TestFinderFactory.findTests( sourceFile = diffFile.path, language = diffFile.language, workspace = workspace ) if (testFiles.isNotEmpty()) { testMap[diffFile.path] = testFiles } } updateState { it.copy( relatedTests = testMap, isLoadingTests = false ) } AutoDevLogger.info("CodeReviewViewModel") { "findRelatedTests: Completed! Found ${testMap.values.sumOf { it.size }} test files for ${testMap.size} changed files" } } catch (e: Exception) { AutoDevLogger.error("CodeReviewViewModel") { "Failed to find related tests: ${e.message}" } e.printStackTrace() updateState { it.copy(isLoadingTests = false) } } } /** * Invalidate code content cache (call when files might have changed) */ private fun invalidateCodeCache() { codeContentCache = null cacheTimestamp = 0 } /** * Generate fixes with structured context * Creates CodingAgent directly with ComposeRenderer for timeline display */ suspend fun generateFixes() { try { val fixRenderer = ComposeRenderer() // Initialize the renderer in state updateState { it.copy( aiProgress = it.aiProgress.copy( fixRenderer = fixRenderer, fixOutput = "" ) ) } // Get git diff/patch for changed code context val patch = currentState.originDiff if (patch.isNullOrBlank()) { fixRenderer.renderError("No git diff available for fix generation") updateState { it.copy( aiProgress = it.aiProgress.copy( fixOutput = "❌ Error: No git diff available for fix generation" ) ) } return } // Build user feedback from selected plan items val selectedItemsFeedback = if (selectedPlanItems.isNotEmpty() && currentState.aiProgress.planOutput.isNotBlank()) { formatSelectedPlanItemsAsFeedback( planOutput = currentState.aiProgress.planOutput, selectedIndices = selectedPlanItems ) } else { "" } // Combine user feedback with selected plan items feedback var combinedUserFeedback = buildString { if (currentState.aiProgress.userFeedback.isNotBlank()) { appendLine(currentState.aiProgress.userFeedback) if (selectedItemsFeedback.isNotBlank()) { appendLine() } } if (selectedItemsFeedback.isNotBlank()) { append(selectedItemsFeedback) } }.trim() if (combinedUserFeedback.isEmpty() && currentState.aiProgress.planOutput.isNotEmpty()) { combinedUserFeedback = currentState.aiProgress.planOutput } AutoDevLogger.info("CodeReviewViewModel") { "Generating fixes with ${selectedPlanItems.size} selected plan items" } val agent = initializeCodingAgent() // Execute fix generation using the agent fixRenderer.addUserMessage("Generating fixes...") val result = agent.generateFixes( patch = patch, lintResults = currentState.aiProgress.lintResults, analysisOutput = currentState.aiProgress.analysisOutput, userFeedback = combinedUserFeedback, language = "ZH", renderer = fixRenderer ) { progress -> // Progress callback val currentOutput = currentState.aiProgress.fixOutput updateState { it.copy( aiProgress = it.aiProgress.copy( fixOutput = currentOutput + progress ) ) } } AutoDevLogger.info("CodeReviewViewModel") { "Fix generation completed - success: ${result.success}" } if (!result.success) { fixRenderer.renderError(result.content) updateState { it.copy( aiProgress = it.aiProgress.copy( fixOutput = currentState.aiProgress.fixOutput + "\n❌ Error: ${result.content}" ) ) } } } catch (e: Exception) { AutoDevLogger.error("CodeReviewViewModel") { "Failed to generate fixes: ${e.message}" } currentState.aiProgress.fixRenderer?.renderError("Error generating fixes: ${e.message}") updateState { it.copy( aiProgress = it.aiProgress.copy( fixOutput = "Error generating fixes: ${e.message}\n" ) ) } } } fun proceedToGenerateFixes(feedback: String) { // Cancel any existing job if needed, though usually we are in WAITING state currentJob?.cancel() updateState { it.copy( aiProgress = it.aiProgress.copy( userFeedback = feedback, stage = AnalysisStage.GENERATING_FIX ) ) } currentJob = CoroutineScope(Dispatchers.Default).launch { try { generateFixes() updateState { it.copy( aiProgress = it.aiProgress.copy(stage = AnalysisStage.COMPLETED) ) } saveCurrentAnalysisResults() } catch (e: Exception) { updateState { it.copy( aiProgress = it.aiProgress.copy(stage = AnalysisStage.ERROR), error = "Fix generation failed: ${e.message}" ) } } } } private fun updateState(update: (CodeReviewState) -> CodeReviewState) { currentState = update(currentState) _state.value = currentState } /** * Store selected plan items for use in fix generation */ private var selectedPlanItems: Set = emptySet() fun setSelectedPlanItems(items: Set) { selectedPlanItems = items AutoDevLogger.debug("CodeReviewViewModel") { "Selected plan items: ${items.joinToString()}" } } fun openFile(filePath: String, startLine: Int? = null, endLine: Int? = null) { // Get workspace root path and construct absolute path val root = workspace.rootPath val absolutePath = if (root != null && !filePath.startsWith("/") && !filePath.startsWith(root)) { "$root/$filePath" } else { filePath } AutoDevLogger.info("CodeReviewViewModel") { "Opening file: $absolutePath (original: $filePath, root: $root, lines: ${startLine?.let { "$it-$endLine" } ?: "all"})" } // Update state to show file viewer dialog updateState { it.copy( fileViewerPath = absolutePath, fileViewerStartLine = startLine, fileViewerEndLine = endLine ) } } /** * Close file viewer dialog */ fun closeFileViewer() { updateState { it.copy( fileViewerPath = null, fileViewerStartLine = null, fileViewerEndLine = null ) } } /** * Load issue information for a specific commit asynchronously * * @param commitIndex Index of the commit in commitHistory * @param forceRefresh If true, bypass cache and fetch fresh data */ private fun loadIssueForCommit(commitIndex: Int, forceRefresh: Boolean = false) { val commit = currentState.commitHistory.getOrNull(commitIndex) ?: return // Skip if already loaded or loading (unless force refresh) if (!forceRefresh && (commit.issueInfo != null || commit.isLoadingIssue)) { return } // Mark as loading updateCommitAtIndex(commitIndex) { it.copy(isLoadingIssue = true, issueLoadError = null) } // Load issue asynchronously scope.launch { try { val issueDeferred = issueService.getIssueAsync(commit.hash, commit.message, forceRefresh) val result = issueDeferred.await() // Update commit with issue info or error updateCommitAtIndex(commitIndex) { it.copy( issueInfo = result.issueInfo, isLoadingIssue = false, issueLoadError = result.error, issueFromCache = result.fromCache, issueCacheAge = result.cacheAgeDisplay ) } if (result.fromCache) { AutoDevLogger.debug("CodeReviewViewModel") { "Loaded cached issue for commit ${commit.shortHash} (cached ${result.cacheAgeDisplay})" } } } catch (e: Exception) { AutoDevLogger.error("CodeReviewViewModel") { "Failed to load issue for commit ${commit.shortHash}: ${e.message}" } updateCommitAtIndex(commitIndex) { it.copy( isLoadingIssue = false, issueLoadError = "Failed to load issue" ) } } } } /** * Force refresh issue for a specific commit (bypasses cache) */ fun refreshIssueForCommit(commitIndex: Int) { loadIssueForCommit(commitIndex, forceRefresh = true) } /** * Update a commit at a specific index */ private fun updateCommitAtIndex(index: Int, update: (CommitInfo) -> CommitInfo) { val commits = currentState.commitHistory if (index !in commits.indices) return val updatedCommits = commits.toMutableList() updatedCommits[index] = update(updatedCommits[index]) updateState { it.copy(commitHistory = updatedCommits) } } /** * Detect repository information from Git remote URL * @return Pair of (owner, repo) or null if not detected */ suspend fun detectRepositoryFromGit(): Pair? { return try { if (!gitOps.isSupported()) { return null } val remoteUrl = gitOps.getRemoteUrl("origin") if (remoteUrl != null) { cc.unitmesh.agent.tracker.GitHubIssueTracker.parseRepoUrl(remoteUrl) } else { null } } catch (e: Exception) { AutoDevLogger.warn("CodeReviewViewModel") { "Failed to detect repository from Git: ${e.message}" } null } } /** * Reload issue service (called when configuration changes) */ suspend fun reloadIssueService() { try { AutoDevLogger.info("CodeReviewViewModel") { "Reloading issue service..." } issueService.reload(if (gitOps.isSupported()) gitOps else null) // Reload issues for all commits currentState.commitHistory.forEachIndexed { index, commit -> // Reset issue info updateCommitAtIndex(index) { it.copy(issueInfo = null, isLoadingIssue = false, issueLoadError = null) } // Reload loadIssueForCommit(index) } AutoDevLogger.info("CodeReviewViewModel") { "Issue service reloaded successfully" } } catch (e: Exception) { AutoDevLogger.error("CodeReviewViewModel") { "Failed to reload issue service: ${e.message}" } } } /** * Cleanup when ViewModel is disposed */ open fun dispose() { currentJob?.cancel() scope.cancel() } companion object { suspend fun createCodeReviewAgent(projectPath: String): CodeReviewAgent { // Validate project path if (projectPath.isEmpty()) { throw IllegalArgumentException("Project path cannot be empty") } try { val toolConfig = ToolConfigFile.default() val configWrapper = ConfigManager.load() val modelConfig = configWrapper.getActiveModelConfig() ?: error("No active model configuration found. Please configure a model in settings.") val llmService = LLMService.create(modelConfig) val mcpToolConfigService = McpToolConfigService(toolConfig) // Create renderer val renderer = ComposeRenderer() val agent = CodeReviewAgent( projectPath = projectPath, llmService = llmService, maxIterations = 50, renderer = renderer, mcpToolConfigService = mcpToolConfigService, enableLLMStreaming = true ) return agent } catch (e: Exception) { AutoDevLogger.error("CodeReviewViewModel") { "Failed to create CodeReviewAgent: ${e.message}" } throw IllegalStateException("Failed to create CodeReviewAgent: ${e.message}", e) } } /** * Format selected plan items as user feedback for fix generation * This converts the selected plan items into a structured text that guides * the AI to focus on specific issues from the modification plan */ fun formatSelectedPlanItemsAsFeedback(planOutput: String, selectedIndices: Set): String { if (selectedIndices.isEmpty()) { return "" } // Parse plan items from the plan output val planItems = PlanParser.parse(planOutput) val selectedItems = planItems.filter { it.number in selectedIndices } if (selectedItems.isEmpty()) { AutoDevLogger.warn("CodeReviewViewModel") { "No plan items found for selected indices: ${selectedIndices.joinToString()}" } return "" } return buildString { appendLine("## 用户选择的修复项 (User Selected Items)") appendLine() appendLine("请优先修复以下选中的问题项:") appendLine() selectedItems.forEach { item -> appendLine("### ${item.number}. ${item.title} - ${item.priority}") // Include file paths from steps val filePaths = item.getAllFilePaths() if (filePaths.isNotEmpty()) { appendLine("**相关文件**:") filePaths.forEach { path -> appendLine("- $path") } appendLine() } // Include step details if (item.steps.isNotEmpty()) { appendLine("**修复步骤**:") item.steps.forEachIndexed { index, step -> val statusIcon = when (step.status) { StepStatus.COMPLETED -> "✓" StepStatus.FAILED -> "!" StepStatus.IN_PROGRESS -> "*" StepStatus.TODO -> "-" } appendLine("$statusIcon ${step.text}") } appendLine() } } appendLine("---") appendLine() appendLine("**注意**: 请只修复上述选中的问题项,其他未选中的项可以忽略。") } } } }