Files
PR-Helper/services/generate.go
T

152 lines
4.1 KiB
Go

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] + "..."
}