feat: Phase 4 — LLM 集成,PR 描述生成与 AI 代码审查

This commit is contained in:
2026-06-19 19:43:18 +08:00
parent 7ac3a3726b
commit afb270721c
8 changed files with 783 additions and 29 deletions
+151
View File
@@ -0,0 +1,151 @@
package services
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
goopenai "github.com/sashabaranov/go-openai"
)
// PRDescription is the structured output from PR description generation.
type PRDescription struct {
Title string `json:"title"`
Type string `json:"type"`
Summary string `json:"summary"`
Details string `json:"details"`
Impact string `json:"impact"`
}
// GeneratePR generates a structured PR description from commit history and diff.
// It streams progress via the callback and returns the parsed PR description.
func GeneratePR(db *sql.DB, repoPath, base, head string, callback StreamCallback) (*PRDescription, error) {
// Read LLM config
config, err := GetLLMConfig(db)
if err != nil {
return nil, err
}
// Open repo
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, fmt.Errorf("open repo: %w", err)
}
// Get commits between base and head
commits, err := GetCommitLog(repo, head, 100)
if err != nil {
return nil, fmt.Errorf("get commits: %w", err)
}
// Filter commits to only those reachable from head but not from base
baseHash, err := repo.ResolveRevision(plumbing.Revision(base))
if err == nil {
baseCommit, bcErr := repo.CommitObject(*baseHash)
if bcErr == nil {
baseSet := make(map[string]bool)
baseQueue := []*object.Commit{baseCommit}
for len(baseQueue) > 0 {
c := baseQueue[0]
baseQueue = baseQueue[1:]
if baseSet[c.Hash.String()] {
continue
}
baseSet[c.Hash.String()] = true
for _, p := range c.ParentHashes {
pc, err := repo.CommitObject(p)
if err == nil {
baseQueue = append(baseQueue, pc)
}
}
}
var filtered []CommitInfo
for _, ci := range commits {
if !baseSet[ci.Hash] {
filtered = append(filtered, ci)
}
}
if len(filtered) > 0 {
commits = filtered
}
}
}
// Get diff
diff, err := GetDiff(repo, base, head)
if err != nil {
return nil, fmt.Errorf("get diff: %w", err)
}
// Build prompt
var commitLines []string
for _, c := range commits {
commitLines = append(commitLines, fmt.Sprintf("- %s %s", c.ShortHash, c.Message))
}
commitStr := strings.Join(commitLines, "\n")
if commitStr == "" {
commitStr = "(no commits)"
}
// Truncate diff if too large (approx 60k chars to stay within token limits)
if len(diff) > 60000 {
diff = diff[:60000] + "\n\n... [diff truncated due to size]"
}
prompt := fmt.Sprintf(`你是一个专业的技术文档撰写助手。根据以下 Git 变更信息,生成一份结构化的 PR 描述。
## Commit 记录
%s
## 代码变更 (Diff)
%s
请按以下 JSON 格式输出(直接输出 JSON,不要包含 markdown 代码块标记):
{
"title": "简洁的 PR 标题",
"type": "变更类型: feat|fix|refactor|docs|chore|style|test|perf",
"summary": "一段话概述变更内容",
"details": "详细的变更说明,按模块分组,使用 Markdown 格式",
"impact": "影响范围说明"
}`, commitStr, diff)
messages := []goopenai.ChatCompletionMessage{
{Role: goopenai.ChatMessageRoleUser, Content: prompt},
}
// Call LLM with streaming
fullResponse, err := ChatStream(config, messages, callback)
if err != nil {
return nil, fmt.Errorf("LLM call: %w", err)
}
// Parse JSON response
jsonStr := extractJSON(fullResponse)
var pr PRDescription
if err := json.Unmarshal([]byte(jsonStr), &pr); err != nil {
return nil, fmt.Errorf("parse PR description: %w (raw: %s)", err, truncateString(fullResponse, 200))
}
// Send structured events
if callback != nil {
callback("title", map[string]interface{}{"content": pr.Title})
callback("type", map[string]interface{}{"content": pr.Type})
callback("summary", map[string]interface{}{"content": pr.Summary})
callback("detail", map[string]interface{}{"content": pr.Details})
callback("impact", map[string]interface{}{"content": pr.Impact})
callback("done", map[string]interface{}{"content": ""})
}
return &pr, nil
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
+152
View File
@@ -0,0 +1,152 @@
package services
import (
"context"
"database/sql"
"fmt"
"io"
"strings"
goopenai "github.com/sashabaranov/go-openai"
)
// LLMConfig holds LLM API configuration.
type LLMConfig struct {
Endpoint string
APIKey string
Model string
}
// GetLLMConfig reads LLM settings from the database.
func GetLLMConfig(db *sql.DB) (LLMConfig, error) {
config := LLMConfig{}
rows, err := db.Query(`SELECT key, value FROM settings WHERE key IN ('llm.endpoint', 'llm.api_key', 'llm.model')`)
if err != nil {
return config, fmt.Errorf("read settings: %w", err)
}
defer rows.Close()
for rows.Next() {
var key, value string
if rows.Scan(&key, &value) == nil {
switch key {
case "llm.endpoint":
config.Endpoint = value
case "llm.api_key":
config.APIKey = value
case "llm.model":
config.Model = value
}
}
}
if config.APIKey == "" {
return config, fmt.Errorf("LLM API key not configured — please set it in the settings page")
}
if config.Model == "" {
config.Model = "gpt-4o"
}
return config, nil
}
// StreamCallback is called for each SSE event during LLM streaming.
type StreamCallback func(event string, data interface{})
// ChatStream sends a streaming chat completion request to an OpenAI-compatible API.
// It calls callback with "content" events for each chunk received.
// Returns the full concatenated response text.
func ChatStream(config LLMConfig, messages []goopenai.ChatCompletionMessage, callback StreamCallback) (string, error) {
clientConfig := goopenai.DefaultConfig(config.APIKey)
if config.Endpoint != "" {
clientConfig.BaseURL = config.Endpoint
}
client := goopenai.NewClientWithConfig(clientConfig)
ctx := context.Background()
stream, err := client.CreateChatCompletionStream(ctx, goopenai.ChatCompletionRequest{
Model: config.Model,
Messages: messages,
Stream: true,
})
if err != nil {
return "", fmt.Errorf("create stream: %w", err)
}
defer stream.Close()
var fullResponse strings.Builder
for {
response, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return fullResponse.String(), fmt.Errorf("stream recv: %w", err)
}
if len(response.Choices) > 0 {
content := response.Choices[0].Delta.Content
if content != "" {
fullResponse.WriteString(content)
if callback != nil {
callback("content", map[string]interface{}{"content": content})
}
}
}
}
return fullResponse.String(), nil
}
// extractJSON attempts to extract a JSON object or array from a string
// that may contain markdown code blocks or extra text.
func extractJSON(s string) string {
// Try to find JSON in markdown code block
if idx := strings.Index(s, "```json"); idx >= 0 {
start := idx + 7
if end := strings.Index(s[start:], "```"); end >= 0 {
return strings.TrimSpace(s[start : start+end])
}
}
if idx := strings.Index(s, "```"); idx >= 0 {
start := idx + 3
if nl := strings.Index(s[start:], "\n"); nl >= 0 {
start += nl + 1
}
if end := strings.Index(s[start:], "```"); end >= 0 {
return strings.TrimSpace(s[start : start+end])
}
}
// Find first { or [
startObj := strings.Index(s, "{")
startArr := strings.Index(s, "[")
var start int
var endChar byte
if startObj >= 0 && (startArr < 0 || startObj < startArr) {
start = startObj
endChar = '}'
} else if startArr >= 0 {
start = startArr
endChar = ']'
} else {
return s
}
// Find matching closing bracket
depth := 0
for i := start; i < len(s); i++ {
if s[i] == '{' || s[i] == '[' {
depth++
} else if s[i] == '}' || s[i] == ']' {
depth--
if depth == 0 && s[i] == endChar {
return s[start : i+1]
}
}
}
return s[start:]
}
+298
View File
@@ -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
}