feat: 并发文件审查,可配置并行度

This commit is contained in:
2026-06-19 23:40:13 +08:00
parent e4df6487ea
commit a66074e49c
6 changed files with 164 additions and 104 deletions
+116 -92
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"sort"
"strings"
"sync"
goopenai "github.com/sashabaranov/go-openai"
)
@@ -57,7 +58,7 @@ func countDiffLines(patch string) int {
// 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 int, callback StreamCallback) (*ReviewResult, error) {
func GenerateReview(db *sql.DB, repoPath, base, head string, topN, concurrency int, callback StreamCallback) (*ReviewResult, error) {
// Read LLM config
config, err := GetLLMConfig(db)
if err != nil {
@@ -107,27 +108,51 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
})
}
// Review each file
var fileReviews []FileReview
// 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 {
if callback != nil {
callback("file_start", map[string]interface{}{
"file": file.Filename,
"index": i + 1,
"total": reviewedFiles,
})
}
wg.Add(1)
go func(idx int, f FileDiff) {
defer wg.Done()
sem <- struct{}{} // acquire slot
defer func() { <-sem }() // release slot
changeLines := countDiffLines(file.Patch)
// Send file_start (order doesn't matter, frontend tracks by filename)
if safeCallback != nil {
safeCallback("file_start", map[string]interface{}{
"file": f.Filename,
"index": idx + 1,
"total": reviewedFiles,
})
}
// Truncate per-file diff if too large
patch := file.Patch
if len(patch) > 30000 {
patch = patch[:30000] + "\n\n... [diff truncated due to size]"
}
changeLines := countDiffLines(f.Patch)
// Build review prompt
prompt := fmt.Sprintf(`你是一个资深代码审查专家。请审查以下代码变更,给出专业的 Review 意见。
// 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
@@ -151,88 +176,87 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
- info: 提示信息(最佳实践、可选优化)
如果代码没有问题,输出空数组 []。
请用中文回复。`, file.Filename, changeLines/2+changeLines%2, changeLines/2, patch)
请用中文回复。`, f.Filename, changeLines/2+changeLines%2, changeLines/2, patch)
messages := []goopenai.ChatCompletionMessage{
{Role: goopenai.ChatMessageRoleUser, Content: prompt},
}
// Call LLM
fullResponse, err := ChatStream(config, messages, callback)
if err != nil {
// Continue with other files on error
if callback != nil {
callback("error", map[string]interface{}{
"file": file.Filename,
"message": err.Error(),
})
messages := []goopenai.ChatCompletionMessage{
{Role: goopenai.ChatMessageRoleUser, Content: prompt},
}
fileReviews = append(fileReviews, FileReview{
FileName: file.Filename,
// 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: nil,
RawReview: fmt.Sprintf("Error: %s", err.Error()),
})
continue
}
// Parse suggestions
jsonStr := extractJSON(fullResponse)
var suggestions []ReviewSuggestion
if err := json.Unmarshal([]byte(jsonStr), &suggestions); err != nil {
// If parsing fails, try single object
var single ReviewSuggestion
if err2 := json.Unmarshal([]byte(jsonStr), &single); err2 == nil {
suggestions = []ReviewSuggestion{single}
} else {
// Fall back to raw text as info suggestion
suggestions = []ReviewSuggestion{{
Severity: "info",
Description: fullResponse,
}}
Suggestions: suggestions,
RawReview: fullResponse,
}
}
// Validate severity values
for i := range suggestions {
switch suggestions[i].Severity {
case "critical", "warning", "info":
// valid
default:
suggestions[i].Severity = "info"
if safeCallback != nil {
safeCallback("file_end", map[string]interface{}{"file": f.Filename})
}
}
// 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 callback != nil {
callback("suggestion", map[string]interface{}{
"file": file.Filename,
"severity": s.Severity,
"content": content,
})
}
}
fileReviews = append(fileReviews, FileReview{
FileName: file.Filename,
ChangeLines: changeLines,
Suggestions: suggestions,
RawReview: fullResponse,
})
if callback != nil {
callback("file_end", map[string]interface{}{"file": file.Filename})
}
}(i, file)
}
wg.Wait()
// Generate summary
var summary ReviewSummary
if len(fileReviews) > 0 {