4a8e43e698
Deploy PR-Helper / deploy (push) Successful in 26s
- 改进 LLM 提示词,明确要求用反引号包裹代码引用,避免输出 INLINECODE 占位符 - 后端新增 fixInlineCode 后处理,将残留 INLINECODE 占位符转为反引号代码 - 前端在 done 回调中从结构化字段组装完整 markdown 文本,修复复制按钮复制空内容 - 复制按钮增加空内容检查和错误提示 - Markdown 预览区域初始隐藏,生成完成后自动显示
170 lines
5.0 KiB
Go
170 lines
5.0 KiB
Go
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] + "..."
|
|
}
|