feat: Phase 4 — LLM 集成,PR 描述生成与 AI 代码审查
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback StreamCallback) error {
|
||||
// Read LLM config
|
||||
config, err := GetLLMConfig(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Open repo
|
||||
repo, err := OpenRepo(repoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open repo: %w", err)
|
||||
}
|
||||
|
||||
// Get diff files
|
||||
files, err := GetDiffFiles(repo, base, head)
|
||||
if err != nil {
|
||||
return 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 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("progress", map[string]interface{}{
|
||||
"total_files": totalFiles,
|
||||
"reviewed_files": reviewedFiles,
|
||||
"top_n": topN,
|
||||
})
|
||||
}
|
||||
|
||||
// Review each file
|
||||
var fileReviews []FileReview
|
||||
for i, file := range files {
|
||||
if callback != nil {
|
||||
callback("file_start", map[string]interface{}{
|
||||
"file": file.Filename,
|
||||
"index": i + 1,
|
||||
"total": reviewedFiles,
|
||||
})
|
||||
}
|
||||
|
||||
changeLines := countDiffLines(file.Patch)
|
||||
|
||||
// Truncate per-file diff if too large
|
||||
patch := file.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: 提示信息(最佳实践、可选优化)
|
||||
|
||||
如果代码没有问题,输出空数组 []。
|
||||
请用中文回复。`, file.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(),
|
||||
})
|
||||
}
|
||||
fileReviews = append(fileReviews, FileReview{
|
||||
FileName: file.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,
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate severity values
|
||||
for i := range suggestions {
|
||||
switch suggestions[i].Severity {
|
||||
case "critical", "warning", "info":
|
||||
// valid
|
||||
default:
|
||||
suggestions[i].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 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})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
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)
|
||||
var summary ReviewSummary
|
||||
if json.Unmarshal([]byte(jsonStr), &summary) == nil {
|
||||
summaryText := fmt.Sprintf("整体评分: %d/10\n\n%s", summary.Score, summary.Overall)
|
||||
if summary.Findings != "" {
|
||||
summaryText += "\n\n**主要发现:**\n" + summary.Findings
|
||||
}
|
||||
if summary.Recommendations != "" {
|
||||
summaryText += "\n\n**改进建议:**\n" + summary.Recommendations
|
||||
}
|
||||
if callback != nil {
|
||||
callback("summary", map[string]interface{}{"content": summaryText})
|
||||
}
|
||||
} else {
|
||||
// If 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 nil
|
||||
}
|
||||
Reference in New Issue
Block a user