feat: 并发文件审查,可配置并行度
This commit is contained in:
+116
-92
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user