feat: 拆分评审与提交消息提示词并增加 hook 备份与 JSON 输出
- 新增 custom_reviewer_prompt 和 custom_commit_msg_prompt,兼容旧 custom_prompt - pre-commit hook 安装时备份已有 hook,卸载时恢复 - 支持 --format json 输出评审结果 - diff 发送前自动脱敏敏感信息
This commit is contained in:
1 parent
39c58316a5
commit
b04d70b99a
18 files changed
+637
-45
No files matched your search
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/unclesam-ly/git-air/internal/security"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -69,7 +71,8 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(stagedDiff) != "" {
|
||||
return truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...)), nil
|
||||
truncated := truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...))
|
||||
return security.RedactSecrets(truncated), nil
|
||||
}
|
||||
// 2. 如果暂存区为空,回退提取工作区未暂存的修改
|
||||
cmdArgs = []string{}
|
||||
@@ -82,20 +85,22 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filteredDiff := FilterDiff(rawDiff, customIgnores...)
|
||||
truncatedDiff := truncateDiffIfNeeded(FilterDiff(rawDiff, customIgnores...))
|
||||
filteredDiff := security.RedactSecrets(truncatedDiff)
|
||||
if strings.TrimSpace(filteredDiff) == "" {
|
||||
return "", ErrEmptyDiff
|
||||
}
|
||||
|
||||
return truncateDiffIfNeeded(filteredDiff), nil
|
||||
return filteredDiff, nil
|
||||
}
|
||||
|
||||
func truncateDiffIfNeeded(diff string) string {
|
||||
runes := []rune(diff)
|
||||
if len(runes) > maxDiffSize {
|
||||
runes = runes[:maxDiffSize]
|
||||
return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]"
|
||||
}
|
||||
return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]"
|
||||
return diff
|
||||
}
|
||||
|
||||
// execGitDiff 执行底层的 git diff 命令
|
||||
|
||||
@@ -95,3 +95,14 @@ func BuildCommitMsgPrompt(lang string) string {
|
||||
%s
|
||||
`, langName, exampleSummary, exampleBullet1, exampleBullet2)
|
||||
}
|
||||
|
||||
// BuildCommitMsgPromptWithCustom 在内置 Conventional Commits 约束基础上追加用户规则。
|
||||
// 保留内置约束,避免自定义提示词意外要求模型输出非法提交格式。
|
||||
func BuildCommitMsgPromptWithCustom(lang, customPrompt string) string {
|
||||
basePrompt := BuildCommitMsgPrompt(lang)
|
||||
customPrompt = strings.TrimSpace(customPrompt)
|
||||
if customPrompt == "" {
|
||||
return basePrompt
|
||||
}
|
||||
return customPrompt + "\n\n### git-air 内置提交信息约束(必须遵守):\n" + basePrompt
|
||||
}
|
||||
@@ -56,3 +56,13 @@ func TestBuildCommitMsgPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommitMsgPromptWithCustom(t *testing.T) {
|
||||
prompt := BuildCommitMsgPromptWithCustom("en", "Use a short imperative subject and mention breaking changes explicitly.")
|
||||
if !strings.Contains(prompt, "Use a short imperative subject") {
|
||||
t.Fatal("自定义 Commit Message Prompt 未被合并")
|
||||
}
|
||||
if !strings.Contains(prompt, "Conventional Commits") {
|
||||
t.Fatal("内置 Conventional Commits 约束不应丢失")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package reviewer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Finding struct {
|
||||
Severity string `json:"severity"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ReviewReport struct {
|
||||
Format string `json:"format"`
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
Score int `json:"score,omitempty"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
var (
|
||||
verdictPattern = regexp.MustCompile(`(?mi)(?:结论|verdict)\s*[::]\s*\[?(PASS|WARN|WARNING|REJECT|BLOCKER|CRITICAL)\]?`)
|
||||
scorePattern = regexp.MustCompile(`(?mi)(?:评分|score)\s*[::]\s*(\d{1,3})`)
|
||||
findingPattern = regexp.MustCompile(`(?m)^\s*-\s*\[(BLOCKER|CRITICAL|WARNING|WARN|SUGGESTION|INFO)\]\s+(.+?)\s*$`)
|
||||
)
|
||||
|
||||
// ParseReviewReport 将模型的 Markdown Review 转成稳定的 JSON 结构。
|
||||
// Raw 始终保留完整原文,解析失败时下游仍可自行处理。
|
||||
func ParseReviewReport(raw string) ReviewReport {
|
||||
report := ReviewReport{
|
||||
Format: "git-air-review/v1",
|
||||
Findings: []Finding{},
|
||||
Raw: strings.TrimSpace(raw),
|
||||
}
|
||||
|
||||
if match := verdictPattern.FindStringSubmatch(raw); len(match) > 1 {
|
||||
report.Verdict = strings.ToUpper(match[1])
|
||||
}
|
||||
if match := scorePattern.FindStringSubmatch(raw); len(match) > 1 {
|
||||
report.Score, _ = strconv.Atoi(match[1])
|
||||
if report.Score > 100 {
|
||||
report.Score = 100
|
||||
}
|
||||
}
|
||||
|
||||
for _, match := range findingPattern.FindAllStringSubmatch(raw, -1) {
|
||||
location, message := splitFindingContent(match[2])
|
||||
report.Findings = append(report.Findings, Finding{
|
||||
Severity: strings.ToUpper(match[1]),
|
||||
Location: location,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func splitFindingContent(content string) (string, string) {
|
||||
content = strings.TrimSpace(content)
|
||||
parts := strings.SplitN(content, " - ", 2)
|
||||
if len(parts) == 1 {
|
||||
return "", content
|
||||
}
|
||||
|
||||
location := strings.TrimSpace(parts[0])
|
||||
message := strings.TrimSpace(parts[1])
|
||||
// 没有行号/路径特征时,不要误把普通句子当成 location。
|
||||
if !strings.Contains(location, ":") {
|
||||
return "", content
|
||||
}
|
||||
return location, message
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package reviewer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseReviewReport(t *testing.T) {
|
||||
raw := `#### 详细审查意见
|
||||
- [BLOCKER] internal/user.go:45 - 存在 SQL 注入风险
|
||||
- [WARNING] internal/user.go:82 - Redis 错误被忽略
|
||||
- [WARNING] 缺少超时控制
|
||||
#### 评审结论
|
||||
- 结论: [REJECT]
|
||||
- 评分: 58 / 100
|
||||
`
|
||||
|
||||
report := ParseReviewReport(raw)
|
||||
if report.Verdict != "REJECT" || report.Score != 58 {
|
||||
t.Fatalf("结论解析错误: %+v", report)
|
||||
}
|
||||
if len(report.Findings) != 3 || report.Findings[0].Location != "internal/user.go:45" {
|
||||
t.Fatalf("问题解析错误: %+v", report.Findings)
|
||||
}
|
||||
if report.Findings[2].Location != "" || report.Findings[2].Message != "缺少超时控制" {
|
||||
t.Fatalf("无位置信息的问题解析错误: %+v", report.Findings[2])
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,13 @@ func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChun
|
||||
}
|
||||
|
||||
// GenerateCommitMsg 根据 Diff 智能生成符合 Conventional Commits 规范的提交信息
|
||||
func (r *Reviewer) GenerateCommitMsg(ctx context.Context, diff string, lang string) (string, error) {
|
||||
func (r *Reviewer) GenerateCommitMsg(ctx context.Context, diff string, lang string, customPrompt string) (string, error) {
|
||||
if strings.TrimSpace(diff) == "" {
|
||||
return "", fmt.Errorf("diff 内容为空,无法生成 Commit Message")
|
||||
}
|
||||
|
||||
targetLang := DetectLanguage(lang)
|
||||
systemPrompt := BuildCommitMsgPrompt(targetLang)
|
||||
systemPrompt := BuildCommitMsgPromptWithCustom(targetLang, customPrompt)
|
||||
|
||||
msg, err := r.client.Review(ctx, systemPrompt, diff)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package security
|
||||
|
||||
import "regexp"
|
||||
|
||||
type redactRule struct {
|
||||
pattern *regexp.Regexp
|
||||
replacement string
|
||||
}
|
||||
|
||||
var redactRules = []redactRule{
|
||||
{
|
||||
// PEM 私钥必须优先处理,避免后续规则只替换其中一部分。
|
||||
pattern: regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`),
|
||||
replacement: "[REDACTED_SECRET]",
|
||||
},
|
||||
{
|
||||
// 常见 Token 前缀:OpenAI/GitHub/AWS/Slack 等。
|
||||
pattern: regexp.MustCompile(`(?i)\b(?:sk-[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9_]{16,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})\b`),
|
||||
replacement: "[REDACTED_SECRET]",
|
||||
},
|
||||
{
|
||||
// Authorization: Bearer ...
|
||||
pattern: regexp.MustCompile(`(?i)(Authorization\s*:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+`),
|
||||
replacement: "${1}[REDACTED_BEARER_TOKEN]",
|
||||
},
|
||||
{
|
||||
// JWT。
|
||||
pattern: regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`),
|
||||
replacement: "[REDACTED_SECRET]",
|
||||
},
|
||||
{
|
||||
// 数据库连接串。
|
||||
pattern: regexp.MustCompile(`(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^\s"'<>]+`),
|
||||
replacement: "[REDACTED_SECRET]",
|
||||
},
|
||||
{
|
||||
// 常见密钥字段赋值。值至少 8 个字符,减少普通变量误报。
|
||||
pattern: regexp.MustCompile(`(?i)(\b(?:api[_-]?key|access[_-]?key|secret(?:[_-]?key)?|password|passwd|token)\b\s*[:=]\s*["']?)[^\s"' ,;]{8,}`),
|
||||
replacement: "${1}[REDACTED_SECRET]",
|
||||
},
|
||||
}
|
||||
|
||||
// RedactSecrets 在 Diff 发送给模型前替换常见敏感信息。
|
||||
// 脱敏只作用于发送给模型的字符串,不修改工作区文件或 Git 内容。
|
||||
func RedactSecrets(input string) string {
|
||||
output := input
|
||||
for _, rule := range redactRules {
|
||||
output = rule.pattern.ReplaceAllString(output, rule.replacement)
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactSecrets(t *testing.T) {
|
||||
input := `
|
||||
api_key = "sk-12345678901234567890"
|
||||
Authorization: Bearer my-secret-bearer-token-123
|
||||
password: "this-is-a-secret-password"
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
secret private key content
|
||||
-----END RSA PRIVATE KEY-----
|
||||
`
|
||||
if strings.Contains(input, "[REDACTED_") {
|
||||
t.Fatal("测试输入不应预先包含脱敏占位符")
|
||||
}
|
||||
|
||||
output := RedactSecrets(input)
|
||||
if output == input {
|
||||
t.Fatal("RedactSecrets 未改变包含敏感信息的输入")
|
||||
}
|
||||
for _, secret := range []string{
|
||||
"sk-12345678901234567890",
|
||||
"my-secret-bearer-token-123",
|
||||
"this-is-a-secret-password",
|
||||
"secret private key content",
|
||||
} {
|
||||
if strings.Contains(output, secret) {
|
||||
t.Fatalf("敏感信息未被脱敏: %q\noutput: %s", secret, output)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(output, "[REDACTED_SECRET]") || !strings.Contains(output, "[REDACTED_BEARER_TOKEN]") {
|
||||
t.Fatalf("脱敏占位符不完整: %s", output)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// CurrentVersion 当前编译版本号
|
||||
const CurrentVersion = "v1.0.6"
|
||||
const CurrentVersion = "v1.0.7"
|
||||
|
||||
const (
|
||||
githubAPIURL = "https://api.github.com/repos/unclesam-ly/git-air/releases/latest"
|
||||
|
||||
Reference in new issue
Block a user