c7b03303e4
Deploy PR-Helper / deploy (push) Successful in 28s
- 后端: file_start 事件改为在获取信号量之后发送,避免并发时所有文件同时'开始'导致进度瞬间跳到90% - 前端: 进度追踪改为基于 file_end 事件(完成)而非 file_start(开始),进度条平滑反映实际审查进度
367 lines
10 KiB
Go
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
|
|
}
|