package services import ( "database/sql" "encoding/json" "fmt" "regexp" "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, userID int64, callback StreamCallback) (*PRDescription, 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 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": "影响范围说明" } 重要格式要求: - details 字段中引用文件名、函数名、变量名等代码标识时,必须用反引号包裹,例如:`+"`services/generate.go`"+`, `+"`GeneratePR()`"+` - 直接写出实际的代码名称,不要用任何占位符替代`, 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)) } // Post-process: convert INLINECODE placeholders back to backtick-enclosed code pr.Details = fixInlineCode(pr.Details) // 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 } // fixInlineCode converts LLM-generated INLINECODE placeholders back to backtick-enclosed inline code. // Some LLMs output INLINECODE0, INLINECODE1, etc. instead of `code` in JSON string values. // This wraps them in backticks so the frontend markdown renderer displays them as inline code. func fixInlineCode(text string) string { re := regexp.MustCompile(`(?i)INLINECODE[_]?(\d+)`) return re.ReplaceAllStringFunc(text, func(match string) string { return "`" + match + "`" }) } func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." }