Files
PR-Helper/services/review.go
T
wonder c7b03303e4
Deploy PR-Helper / deploy (push) Successful in 28s
fix: 修复代码审查进度条逻辑
- 后端: file_start 事件改为在获取信号量之后发送,避免并发时所有文件同时'开始'导致进度瞬间跳到90%
- 前端: 进度追踪改为基于 file_end 事件(完成)而非 file_start(开始),进度条平滑反映实际审查进度
2026-06-21 15:17:51 +08:00

367 lines
10 KiB
Go

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
}