package services import ( "database/sql" "encoding/json" "fmt" "sort" "strings" "sync" goopenai "github.com/sashabaranov/go-openai" ) // ReviewSuggestion is a single review finding for a file. type ReviewSuggestion struct { Severity string `json:"severity"` Description string `json:"description"` Suggestion string `json:"suggestion"` CodeExample string `json:"code_example,omitempty"` } // FileReview holds the review results for one file. type FileReview struct { FileName string `json:"file_name"` ChangeLines int `json:"change_lines"` Suggestions []ReviewSuggestion `json:"suggestions"` RawReview string `json:"raw_review"` } // ReviewSummary is the overall assessment after reviewing all files. type ReviewSummary struct { Score int `json:"score"` Overall string `json:"overall"` Findings string `json:"findings"` Recommendations string `json:"recommendations"` } // ReviewResult holds the complete review output for persistence. type ReviewResult struct { FileReviews []FileReview `json:"file_reviews"` Summary ReviewSummary `json:"summary"` TopN int `json:"top_n"` } // countDiffLines counts the number of added/removed lines in a diff patch. func countDiffLines(patch string) int { count := 0 for _, line := range strings.Split(patch, "\n") { if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") { if !strings.HasPrefix(line, "+++") && !strings.HasPrefix(line, "---") { count++ } } } return count } // GenerateReview performs AI code review on diff files with Top-N strategy. // It streams events (file_start, suggestion, file_end, summary, done) via callback // and returns the complete ReviewResult for persistence. func GenerateReview(db *sql.DB, repoPath, base, head string, topN, concurrency int, userID int64, callback StreamCallback) (*ReviewResult, error) { // Read LLM config (per-user) config, err := GetLLMConfig(db, userID) if err != nil { return nil, err } // Open repo repo, err := OpenRepo(repoPath) if err != nil { return nil, fmt.Errorf("open repo: %w", err) } // Get diff files files, err := GetDiffFiles(repo, base, head) if err != nil { return nil, fmt.Errorf("get diff files: %w", err) } if len(files) == 0 { if callback != nil { callback("summary", map[string]interface{}{"content": "没有检测到代码变更。"}) callback("done", map[string]interface{}{"content": ""}) } return &ReviewResult{TopN: topN}, nil } // Sort by change size (descending) sort.Slice(files, func(i, j int) bool { return countDiffLines(files[i].Patch) > countDiffLines(files[j].Patch) }) totalFiles := len(files) // Apply Top-N (0 means analyze all) if topN > 0 && topN < len(files) { files = files[:topN] } reviewedFiles := len(files) // Notify frontend of file count info if callback != nil { callback("start", map[string]interface{}{ "total_files": totalFiles, "reviewed_files": reviewedFiles, "top_n": topN, }) } // Review each file concurrently (max 5 workers) fileReviews := make([]FileReview, reviewedFiles) var mu sync.Mutex if concurrency < 1 { concurrency = 1 } sem := make(chan struct{}, concurrency) var wg sync.WaitGroup // Thread-safe callback wrapper — all SSE writes go through this safeCallback := callback if callback != nil { safeCallback = func(event string, data interface{}) { mu.Lock() defer mu.Unlock() callback(event, data) } } for i, file := range files { wg.Add(1) go func(idx int, f FileDiff) { defer wg.Done() sem <- struct{}{} // acquire slot defer func() { <-sem }() // release slot // Send file_start after acquiring semaphore — this means the file is actually being reviewed now if safeCallback != nil { safeCallback("file_start", map[string]interface{}{ "file": f.Filename, "index": idx + 1, "total": reviewedFiles, }) } changeLines := countDiffLines(f.Patch) // Truncate per-file diff if too large patch := f.Patch if len(patch) > 30000 { patch = patch[:30000] + "\n\n... [diff truncated due to size]" } // Build review prompt prompt := fmt.Sprintf(`你是一个资深代码审查专家。请审查以下代码变更,给出专业的 Review 意见。 ## 文件: %s ## 变更行数: +%d / -%d ## Diff %s 请按以下 JSON 格式输出审查意见(直接输出 JSON 数组,不要包含 markdown 代码块标记): [ { "severity": "critical 或 warning 或 info", "description": "问题描述", "suggestion": "建议的修改方案", "code_example": "建议的代码(如有)" } ] 严重程度说明: - critical: 严重问题(安全漏洞、数据丢失风险、崩溃风险) - warning: 建议改进(性能问题、代码规范、可维护性) - info: 提示信息(最佳实践、可选优化) 如果代码没有问题,输出空数组 []。 请用中文回复。`, f.Filename, changeLines/2+changeLines%2, changeLines/2, patch) messages := []goopenai.ChatCompletionMessage{ {Role: goopenai.ChatMessageRoleUser, Content: prompt}, } // Call LLM fullResponse, err := ChatStream(config, messages, safeCallback) if err != nil { if safeCallback != nil { safeCallback("error", map[string]interface{}{ "file": f.Filename, "message": err.Error(), }) } fileReviews[idx] = FileReview{ FileName: f.Filename, ChangeLines: changeLines, Suggestions: nil, RawReview: fmt.Sprintf("Error: %s", err.Error()), } return } // Parse suggestions jsonStr := extractJSON(fullResponse) var suggestions []ReviewSuggestion if err := json.Unmarshal([]byte(jsonStr), &suggestions); err != nil { var single ReviewSuggestion if err2 := json.Unmarshal([]byte(jsonStr), &single); err2 == nil { suggestions = []ReviewSuggestion{single} } else { suggestions = []ReviewSuggestion{{ Severity: "info", Description: fullResponse, }} } } // Validate severity values for j := range suggestions { switch suggestions[j].Severity { case "critical", "warning", "info": default: suggestions[j].Severity = "info" } } // Send suggestion events for _, s := range suggestions { content := s.Description if s.Suggestion != "" { content += "\n\n**建议修改:** " + s.Suggestion } if s.CodeExample != "" { content += "\n\n```\n" + s.CodeExample + "\n```" } if safeCallback != nil { safeCallback("suggestion", map[string]interface{}{ "file": f.Filename, "severity": s.Severity, "content": content, }) } } fileReviews[idx] = FileReview{ FileName: f.Filename, ChangeLines: changeLines, Suggestions: suggestions, RawReview: fullResponse, } if safeCallback != nil { safeCallback("file_end", map[string]interface{}{"file": f.Filename}) } }(i, file) } wg.Wait() // Generate summary var summary ReviewSummary if len(fileReviews) > 0 { if callback != nil { callback("progress", map[string]interface{}{"step": "generating_summary"}) } var reviewParts []string for _, fr := range fileReviews { suggestionText := "没有发现问题" if len(fr.Suggestions) > 0 { var parts []string for _, s := range fr.Suggestions { parts = append(parts, fmt.Sprintf("[%s] %s", s.Severity, s.Description)) } suggestionText = strings.Join(parts, "\n") } reviewParts = append(reviewParts, fmt.Sprintf("### %s (%d 行变更)\n%s", fr.FileName, fr.ChangeLines, suggestionText)) } summaryPrompt := fmt.Sprintf(`以下是多个文件的代码审查结果,请给出整体评估: %s 请按以下 JSON 格式输出(直接输出 JSON,不要包含 markdown 代码块标记): { "score": 7, "overall": "总体评价(2-3 句话)", "findings": "按严重程度排序的主要发现汇总", "recommendations": "改进建议,用换行分隔多条建议" } 注意:所有字段必须是字符串类型,不要使用数组。请用中文回复。`, strings.Join(reviewParts, "\n\n")) messages := []goopenai.ChatCompletionMessage{ {Role: goopenai.ChatMessageRoleUser, Content: summaryPrompt}, } summaryResponse, err := ChatStream(config, messages, callback) if err == nil { jsonStr := extractJSON(summaryResponse) if json.Unmarshal([]byte(jsonStr), &summary) == nil { if callback != nil { callback("summary", map[string]interface{}{ "score": summary.Score, "overall": summary.Overall, "findings": summary.Findings, "recommendations": summary.Recommendations, }) } } else { // Try flexible parsing (handle recommendations as array) var raw map[string]json.RawMessage if json.Unmarshal([]byte(jsonStr), &raw) == nil { var flexible struct { Score int `json:"score"` Overall string `json:"overall"` Findings string `json:"findings"` } json.Unmarshal(raw["score"], &flexible.Score) json.Unmarshal(raw["overall"], &flexible.Overall) json.Unmarshal(raw["findings"], &flexible.Findings) recommendations := "" if rec, ok := raw["recommendations"]; ok { var arr []string if json.Unmarshal(rec, &arr) == nil { recommendations = strings.Join(arr, "\n") } else { var s string json.Unmarshal(rec, &s) recommendations = s } } if callback != nil { callback("summary", map[string]interface{}{ "score": flexible.Score, "overall": flexible.Overall, "findings": flexible.Findings, "recommendations": recommendations, }) } } else { // If all parsing fails, send raw response as summary if callback != nil { callback("summary", map[string]interface{}{"content": summaryResponse}) } } } } else { if callback != nil { callback("error", map[string]interface{}{"message": "生成汇总失败: " + err.Error()}) } } } if callback != nil { callback("done", map[string]interface{}{"content": ""}) } return &ReviewResult{ FileReviews: fileReviews, Summary: summary, TopN: topN, }, nil }