From b04d70b99aa1035f3dd2a8cb92964f8bdae5d6d7 Mon Sep 17 00:00:00 2001 From: unclesam <2036253946@qq.com> Date: Sun, 23 Aug 2026 22:31:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8B=86=E5=88=86=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E4=B8=8E=E6=8F=90=E4=BA=A4=E6=B6=88=E6=81=AF=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E5=B9=B6=E5=A2=9E=E5=8A=A0=20hook=20=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E4=B8=8E=20JSON=20=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 custom_reviewer_prompt 和 custom_commit_msg_prompt,兼容旧 custom_prompt - pre-commit hook 安装时备份已有 hook,卸载时恢复 - 支持 --format json 输出评审结果 - diff 发送前自动脱敏敏感信息 --- README.md | 30 +++++- README_EN.md | 32 ++++++- README_JA.md | 31 ++++++ cmd/config.go | 47 ++++++--- cmd/hook.go | 157 ++++++++++++++++++++++++++++--- cmd/hook_test.go | 87 +++++++++++++++++ cmd/msg.go | 2 +- cmd/root.go | 57 +++++++++-- config.yaml | 12 ++- internal/git/diff.go | 13 ++- internal/reviewer/msg.go | 11 +++ internal/reviewer/msg_test.go | 10 ++ internal/reviewer/report.go | 73 ++++++++++++++ internal/reviewer/report_test.go | 25 +++++ internal/reviewer/reviewer.go | 4 +- internal/security/redact.go | 51 ++++++++++ internal/security/redact_test.go | 38 ++++++++ internal/updater/checker.go | 2 +- 18 files changed, 637 insertions(+), 45 deletions(-) create mode 100644 cmd/hook_test.go create mode 100644 internal/reviewer/report.go create mode 100644 internal/reviewer/report_test.go create mode 100644 internal/security/redact.go create mode 100644 internal/security/redact_test.go diff --git a/README.md b/README.md index 7fb4ca5..bf92c61 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,9 @@ model: "gemini-3.7-flash" # base_url: "https://api.deepseek.com/v1" # 自定义基础提示词 (可选,留空则使用默认资深架构师准则) +# custom_reviewer_prompt: "" +# custom_commit_msg_prompt: "" +# 旧版兼容字段(v1.x 仍可使用) # custom_prompt: "" # 自定义模型单价 (可选,单位: 美元/1M Tokens) @@ -284,6 +287,32 @@ model: "gemini-3.7-flash" # commit_lang: "auto" ``` +### 提示词字段迁移说明 + +旧版本只有一个通用的 `custom_prompt` 字段。随着 `git-air` 同时支持代码 Review 和 AI Commit Message,两个场景的目标不同,继续共用一个字段容易造成提示词混用和行为不明确。 + +因此从当前版本开始,建议分别使用: + +```yaml +custom_reviewer_prompt: "用于代码 Review 的提示词" +custom_commit_msg_prompt: "用于生成 Commit Message 的提示词" +``` + +`custom_prompt` 暂时仍然兼容,并会作为 `custom_reviewer_prompt` 使用。迁移方式: + +```bash +git air config set --reviewer-prompt "你的 Reviewer Prompt" +git air config set --commit-msg-prompt "你的 Commit Message Prompt" +``` + +生命周期安排: + +- `v1.x`:继续支持 `custom_prompt`; +- `v1.1.0`:标记为 deprecated,并提示用户迁移; +- `v2.0.0`:移除 `custom_prompt` 字段。 + +这是为了避免破坏现有用户配置,同时让两个 Prompt 的职责更加清晰。 + --- ## 📋 团队规则定制(`.airules`) @@ -323,4 +352,3 @@ secrets.yaml ## 📄 开源许可证 本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request! - diff --git a/README_EN.md b/README_EN.md index 613e9e4..3069c77 100644 --- a/README_EN.md +++ b/README_EN.md @@ -269,6 +269,9 @@ model: "gemini-3.7-flash" # base_url: "https://api.deepseek.com/v1" # Custom System Prompt (Optional) +# custom_reviewer_prompt: "" +# custom_commit_msg_prompt: "" +# Legacy compatibility field (supported throughout v1.x) # custom_prompt: "" # Custom Token Pricing (Optional, in USD per 1M tokens) @@ -279,6 +282,34 @@ model: "gemini-3.7-flash" # commit_lang: "auto" ``` +### Prompt Field Migration + +Older versions provided a single `custom_prompt` field. Since `git-air` supports both code review and AI commit-message generation, sharing one prompt can lead to mixed instructions and unclear behavior. + +Use separate fields instead: + +```yaml +custom_reviewer_prompt: "Prompt for code review" +custom_commit_msg_prompt: "Prompt for commit message generation" +``` + +The legacy `custom_prompt` field remains compatible for now and is treated as `custom_reviewer_prompt`. + +Migration commands: + +```bash +git air config set --reviewer-prompt "Your reviewer prompt" +git air config set --commit-msg-prompt "Your commit message prompt" +``` + +Deprecation timeline: + +- `v1.x`: `custom_prompt` remains supported; +- `v1.1.0`: the field is marked deprecated and migration is recommended; +- `v2.0.0`: `custom_prompt` will be removed. + +This keeps existing configurations working while making the two prompt responsibilities explicit. + --- ## 📋 Custom Team Rules (`.airules`) @@ -318,4 +349,3 @@ secrets.yaml ## 📄 License This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome! - diff --git a/README_JA.md b/README_JA.md index c7fec7f..2600cdf 100644 --- a/README_JA.md +++ b/README_JA.md @@ -310,6 +310,9 @@ model: "gemini-3.7-flash" # base_url: "https://api.deepseek.com/v1" # カスタム System Prompt (任意) +# custom_reviewer_prompt: "" +# custom_commit_msg_prompt: "" +# 旧版互換フィールド(v1.x では引き続き使用可能) # custom_prompt: "" # カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens) @@ -320,6 +323,34 @@ model: "gemini-3.7-flash" # commit_lang: "auto" ``` +### Prompt フィールドの移行について + +旧バージョンでは `custom_prompt` という共通フィールドのみを使用していました。しかし `git-air` はコード Review と AI Commit Message の両方を扱うため、同じ Prompt を共有すると指示が混在し、動作が分かりにくくなります。 + +今後は、用途ごとに次のフィールドを使用してください。 + +```yaml +custom_reviewer_prompt: "コード Review 用 Prompt" +custom_commit_msg_prompt: "Commit Message 生成用 Prompt" +``` + +旧 `custom_prompt` は当面互換性のためサポートされ、`custom_reviewer_prompt` として扱われます。 + +移行コマンド: + +```bash +git air config set --reviewer-prompt "Reviewer Prompt" +git air config set --commit-msg-prompt "Commit Message Prompt" +``` + +廃止予定: + +- `v1.x`:`custom_prompt` を引き続きサポート; +- `v1.1.0`:deprecated として案内; +- `v2.0.0`:`custom_prompt` を削除予定。 + +既存ユーザーの設定を壊さず、2 つの Prompt の役割を明確にするための変更です。 + --- ## 📋 チーム規約のカスタマイズ(`.airules`) diff --git a/cmd/config.go b/cmd/config.go index 484e646..26d4796 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -12,14 +12,17 @@ import ( // AppConfig 全局配置结构体 type AppConfig struct { - Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom - APIKey string `yaml:"api_key"` // API Key - BaseURL string `yaml:"base_url"` // 自定义请求地址 - Model string `yaml:"model"` // 模型名称 - CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt - PriceInput float64 `yaml:"price_input,omitempty"` // 自定义输入单价 (每 1M tokens 美元,可选) - PriceOutput float64 `yaml:"price_output,omitempty"` // 自定义输出单价 (每 1M tokens 美元,可选) - CommitLang string `yaml:"commit_lang,omitempty"` // Commit Message 生成语言 (auto, zh, en, ja, ko 等) + Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom + APIKey string `yaml:"api_key"` // API Key + BaseURL string `yaml:"base_url"` // 自定义请求地址 + Model string `yaml:"model"` // 模型名称 + // CustomPrompt 为兼容旧配置保留,读取时会回退到 CustomReviewerPrompt。 + CustomPrompt string `yaml:"custom_prompt,omitempty"` + CustomReviewerPrompt string `yaml:"custom_reviewer_prompt,omitempty"` + CustomCommitMsgPrompt string `yaml:"custom_commit_msg_prompt,omitempty"` + PriceInput float64 `yaml:"price_input,omitempty"` // 自定义输入单价 (每 1M tokens 美元,可选) + PriceOutput float64 `yaml:"price_output,omitempty"` // 自定义输出单价 (每 1M tokens 美元,可选) + CommitLang string `yaml:"commit_lang,omitempty"` // Commit Message 生成语言 (auto, zh, en, ja, ko 等) } func getConfigPath() (string, error) { @@ -82,6 +85,11 @@ func LoadConfig() (*AppConfig, error) { cfg.CommitLang = envLang } + // 兼容旧版本:custom_prompt 等价于 reviewer 提示词。 + if cfg.CustomReviewerPrompt == "" { + cfg.CustomReviewerPrompt = cfg.CustomPrompt + } + return cfg, nil } @@ -131,7 +139,15 @@ var configSetCmd = &cobra.Command{ cfg.BaseURL = u } if pr, _ := cmd.Flags().GetString("prompt"); pr != "" { + // 旧版 --prompt 继续写入旧字段,同时同步新字段,保证 v1.x 行为兼容。 cfg.CustomPrompt = pr + cfg.CustomReviewerPrompt = pr + } + if pr, _ := cmd.Flags().GetString("reviewer-prompt"); pr != "" { + cfg.CustomReviewerPrompt = pr + } + if pr, _ := cmd.Flags().GetString("commit-msg-prompt"); pr != "" { + cfg.CustomCommitMsgPrompt = pr } if pi, _ := cmd.Flags().GetFloat64("price-input"); pi > 0 { cfg.PriceInput = pi @@ -171,11 +187,16 @@ var configGetCmd = &cobra.Command{ maskedKey = cfg.APIKey[:4] + "..." + cfg.APIKey[len(cfg.APIKey)-4:] } fmt.Printf("APIKey: %s\n", maskedKey) - hasPrompt := "否 (使用默认内置架构师 Prompt)" - if cfg.CustomPrompt != "" { - hasPrompt = "是 (已自定义)" + reviewerPrompt := "否 (使用默认内置架构师 Prompt)" + if cfg.CustomReviewerPrompt != "" { + reviewerPrompt = "是 (已自定义)" } - fmt.Printf("CustomPrompt: %s\n", hasPrompt) + commitPrompt := "否 (使用默认 Commit Message Prompt)" + if cfg.CustomCommitMsgPrompt != "" { + commitPrompt = "是 (已自定义)" + } + fmt.Printf("ReviewerPrompt: %s\n", reviewerPrompt) + fmt.Printf("CommitMsgPrompt: %s\n", commitPrompt) commitLang := "auto (自动识别系统语言)" if cfg.CommitLang != "" { commitLang = cfg.CommitLang @@ -195,6 +216,8 @@ func init() { configSetCmd.Flags().StringP("model", "m", "", "模型名称") configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址") configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt") + configSetCmd.Flags().String("reviewer-prompt", "", "自定义代码 Reviewer Prompt") + configSetCmd.Flags().String("commit-msg-prompt", "", "自定义 Commit Message Prompt") configSetCmd.Flags().Float64("price-input", 0, "自定义每 1M 输入 Token 美元价格") configSetCmd.Flags().Float64("price-output", 0, "自定义每 1M 输出 Token 美元价格") configSetCmd.Flags().String("commit-lang", "", "Commit Message 语言 (auto, zh, en, ja, ko 等)") diff --git a/cmd/hook.go b/cmd/hook.go index 343c828..11496c9 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -1,19 +1,142 @@ package cmd import ( + "errors" + "fmt" "os" "path/filepath" + "strings" + "github.com/spf13/cobra" "github.com/unclesam-ly/git-air/internal/git" "github.com/unclesam-ly/git-air/internal/ui" - "github.com/spf13/cobra" ) -const preCommitScript = `#!/bin/sh -# git-air pre-commit hook +const ( + preCommitMarker = "# git-air-managed-pre-commit-v1" + preCommitBackupSuffix = ".git-air-backup" + preCommitBackupFileName = "pre-commit.git-air-backup" +) + +func preCommitScript(bin ...string) string { + binPath := "git-air" + if len(bin) > 0 && bin[0] != "" { + binPath = bin[0] + } + return fmt.Sprintf(`#!/bin/sh +%s echo "[git-air] 正在执行 Commit 前代码自动化评审..." -git-air --staged -` +"%s" --staged +git_air_status=$? +if [ "$git_air_status" -ne 0 ]; then + exit "$git_air_status" +fi + +hook_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +old_hook="$hook_dir/%s" +if [ -x "$old_hook" ]; then + exec "$old_hook" "$@" +fi +exit 0 +`, preCommitMarker, binPath, preCommitBackupFileName) +} + +func isGitAirHook(data []byte) bool { + content := string(data) + return strings.Contains(content, preCommitMarker) || + strings.Contains(content, "# git-air pre-commit hook") || + strings.Contains(content, "git-air --staged") +} + +func installPreCommitHook(hookPath string) error { + backupPath := hookPath + preCommitBackupSuffix + hookData, hookReadErr := os.ReadFile(hookPath) + hookExists := hookReadErr == nil + if hookReadErr != nil && !errors.Is(hookReadErr, os.ErrNotExist) { + return hookReadErr + } + // 幂等安装:已经是 git-air 包装 Hook 时直接返回,绝不把自己再次备份。 + if hookExists && isGitAirHook(hookData) { + return nil + } + + if _, err := os.Stat(backupPath); err == nil { + return fmt.Errorf("检测到已有 git-air 备份文件 %s,请先执行 hook uninstall", backupPath) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if hookExists { + // 保留原 Hook:先移动为备份,再安装包装 Hook。写入失败时尝试恢复。 + if err := os.Rename(hookPath, backupPath); err != nil { + return fmt.Errorf("备份已有 pre-commit Hook 失败: %w", err) + } + } + + binPath := "git-air" + if exe, err := os.Executable(); err == nil && exe != "" { + binPath = exe + } + + if err := os.WriteFile(hookPath, []byte(preCommitScript(binPath)), 0755); err != nil { + if hookExists { + _ = os.Remove(hookPath) + _ = os.Rename(backupPath, hookPath) + } + return fmt.Errorf("写入 git-air pre-commit Hook 失败: %w", err) + } + return nil +} + +func uninstallPreCommitHook(hookPath string) error { + data, err := os.ReadFile(hookPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("当前仓库没有 git-air pre-commit Hook") + } + return err + } + if !isGitAirHook(data) { + return fmt.Errorf("当前 pre-commit Hook 不是由 git-air 管理,已保留原文件") + } + + backupPath := hookPath + preCommitBackupSuffix + if _, err := os.Stat(backupPath); err == nil { + return restoreOriginalHook(hookPath, backupPath) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + return os.Remove(hookPath) +} + +// restoreOriginalHook 恢复备份 Hook。 +// POSIX 系统优先使用 Rename 的原子替换;Windows 目标文件存在时 Rename 可能失败, +// 此时先把包装 Hook 移到临时路径,恢复失败则尝试回滚,避免先删除导致数据丢失。 +func restoreOriginalHook(hookPath, backupPath string) error { + if err := os.Rename(backupPath, hookPath); err == nil { + return nil + } + + tempPath := hookPath + ".git-air-restore-temp" + if _, err := os.Stat(tempPath); err == nil { + return fmt.Errorf("恢复原 pre-commit Hook 失败:临时文件已存在 %s", tempPath) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if err := os.Rename(hookPath, tempPath); err != nil { + return fmt.Errorf("暂存 git-air Hook 失败: %w", err) + } + if err := os.Rename(backupPath, hookPath); err != nil { + _ = os.Rename(tempPath, hookPath) + return fmt.Errorf("恢复原 pre-commit Hook 失败: %w", err) + } + if err := os.Remove(tempPath); err != nil { + return fmt.Errorf("清理临时 Hook 文件失败: %w", err) + } + return nil +} var hookCmd = &cobra.Command{ Use: "hook", @@ -22,25 +145,30 @@ var hookCmd = &cobra.Command{ var hookInstallCmd = &cobra.Command{ Use: "install", - Short: "在当前仓库一键安装 pre-commit 钩子", + Short: "在当前仓库安装 pre-commit 钩子并保留已有 Hook", Run: func(cmd *cobra.Command, args []string) { repoRoot, err := git.GetRepoRoot(cmd.Context()) if err != nil { ui.PrintError("请在 Git 仓库根目录下执行此命令") return } - hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit") - if err := os.WriteFile(hookPath, []byte(preCommitScript), 0755); err != nil { - ui.PrintError("写入 hook 文件失败: %v", err) + hooksDir := filepath.Join(repoRoot, ".git", "hooks") + if err := os.MkdirAll(hooksDir, 0755); err != nil { + ui.PrintError("创建 hooks 目录失败: %v", err) return } - ui.PrintSuccess("已成功安装 pre-commit 钩子!每次执行 git commit 时将自动触发评审。") + hookPath := filepath.Join(hooksDir, "pre-commit") + if err := installPreCommitHook(hookPath); err != nil { + ui.PrintError("安装 Hook 失败: %v", err) + return + } + ui.PrintSuccess("已安装 git-air Hook;已有 Hook 已保存在 pre-commit.git-air-backup,提交通过后会继续执行。") }, } var hookUninstallCmd = &cobra.Command{ Use: "uninstall", - Short: "移除当前仓库的 pre-commit 钩子", + Short: "移除 git-air Hook 并恢复已有 Hook", Run: func(cmd *cobra.Command, args []string) { repoRoot, err := git.GetRepoRoot(cmd.Context()) if err != nil { @@ -48,8 +176,11 @@ var hookUninstallCmd = &cobra.Command{ return } hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit") - _ = os.Remove(hookPath) - ui.PrintSuccess("已成功卸载 pre-commit 钩子!") + if err := uninstallPreCommitHook(hookPath); err != nil { + ui.PrintError("卸载 Hook 失败: %v", err) + return + } + ui.PrintSuccess("已卸载 git-air Hook;如原来存在 Hook,已恢复原文件。") }, } diff --git a/cmd/hook_test.go b/cmd/hook_test.go new file mode 100644 index 0000000..68d597f --- /dev/null +++ b/cmd/hook_test.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInstallPreCommitHookIsIdempotent(t *testing.T) { + dir := t.TempDir() + hookPath := filepath.Join(dir, "pre-commit") + original := "#!/bin/sh\necho original\n" + if err := os.WriteFile(hookPath, []byte(original), 0755); err != nil { + t.Fatal(err) + } + + if err := installPreCommitHook(hookPath); err != nil { + t.Fatal(err) + } + backupPath := hookPath + preCommitBackupSuffix + backup, err := os.ReadFile(backupPath) + if err != nil { + t.Fatal(err) + } + if string(backup) != original { + t.Fatalf("原 Hook 备份内容被改变: %q", backup) + } + + first, err := os.ReadFile(hookPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(first), preCommitMarker) { + t.Fatal("安装后的 Hook 缺少 git-air 标记") + } + + if err := installPreCommitHook(hookPath); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(hookPath) + if err != nil { + t.Fatal(err) + } + if string(second) != string(first) { + t.Fatal("重复安装不应改写 git-air Hook") + } + backupAfter, err := os.ReadFile(backupPath) + if err != nil { + t.Fatal(err) + } + if string(backupAfter) != original { + t.Fatal("重复安装不应改变原 Hook 备份") + } +} + +func TestPreCommitScriptRunsOnlyExecutableBackup(t *testing.T) { + script := preCommitScript() + if !strings.Contains(script, "if [ -x \"$old_hook\" ]; then") { + t.Fatalf("Hook 没有检查备份文件的可执行权限:\n%s", script) + } +} + +func TestUninstallPreCommitHookRestoresOriginal(t *testing.T) { + dir := t.TempDir() + hookPath := filepath.Join(dir, "pre-commit") + original := "#!/bin/sh\necho original\n" + if err := os.WriteFile(hookPath, []byte(original), 0755); err != nil { + t.Fatal(err) + } + if err := installPreCommitHook(hookPath); err != nil { + t.Fatal(err) + } + if err := uninstallPreCommitHook(hookPath); err != nil { + t.Fatal(err) + } + restored, err := os.ReadFile(hookPath) + if err != nil { + t.Fatal(err) + } + if string(restored) != original { + t.Fatalf("原 Hook 未正确恢复: %q", restored) + } + if _, err := os.Stat(hookPath + preCommitBackupSuffix); !os.IsNotExist(err) { + t.Fatalf("备份文件未清理: %v", err) + } +} diff --git a/cmd/msg.go b/cmd/msg.go index 1a1a38d..557afc5 100644 --- a/cmd/msg.go +++ b/cmd/msg.go @@ -87,7 +87,7 @@ var msgCmd = &cobra.Command{ fmt.Printf("\033[1;36m[git-air]\033[0m 正在生成 Conventional Commit Message... \033[0;90m(Language: %s / Engine: %s)\033[0m\n", langDesc, cfg.Model) fmt.Println("\033[0;90m" + strings.Repeat("─", 65) + "\033[0m") - commitMsg, err := rev.GenerateCommitMsg(ctx, diff, lang) + commitMsg, err := rev.GenerateCommitMsg(ctx, diff, lang, cfg.CustomCommitMsgPrompt) if err != nil { ui.PrintError("生成 Commit Message 失败: %v", err) return diff --git a/cmd/root.go b/cmd/root.go index 241667b..c60a061 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "os" "strings" @@ -21,6 +22,11 @@ var rootCmd = &cobra.Command{ Args: cobra.ArbitraryArgs, Run: func(cmd *cobra.Command, args []string) { ctx := cmd.Context() + outputFormat, _ := cmd.Flags().GetString("format") + if outputFormat != "text" && outputFormat != "json" { + ui.PrintError("不支持的输出格式 %q,可选: text, json", outputFormat) + os.Exit(2) + } // 后台非阻塞异步检查新版本 updateCh := updater.CheckAsync() @@ -76,17 +82,21 @@ var rootCmd = &cobra.Command{ // 5. 初始化评审引擎 customPrompt, _ := cmd.Flags().GetString("prompt") if customPrompt == "" { - customPrompt = cfg.CustomPrompt + customPrompt = cfg.CustomReviewerPrompt } rev := reviewer.NewReviewer(llmClient, repoRoot, customPrompt) // 6. 打印开场横幅并开始流式评审 - ui.PrintBanner(cfg.Provider, cfg.Model) + if outputFormat == "text" { + ui.PrintBanner(cfg.Provider, cfg.Model) + } printer := ui.NewStreamPrinter() var reviewOutput strings.Builder _, usage, err := rev.Execute(ctx, diff, true, func(chunk string) { reviewOutput.WriteString(chunk) - printer.PrintChunk(chunk) + if outputFormat == "text" { + printer.PrintChunk(chunk) + } }) if err != nil { ui.PrintError("\n评审过程中发生错误: %v", err) @@ -94,16 +104,48 @@ var rootCmd = &cobra.Command{ } // 7. 打印 Token 消耗统计 - if usage != nil { + if outputFormat == "text" && usage != nil { ui.PrintUsage(usage.InputTokens, usage.OutputTokens, usage.EstimateCost()) } - ui.PrintFooter() + if outputFormat == "text" { + ui.PrintFooter() + } + if outputFormat == "json" { + report := reviewer.ParseReviewReport(reviewOutput.String()) + var reportUsage interface{} + if usage != nil { + reportUsage = struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` + CostUSD float64 `json:"cost_usd"` + }{ + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + Model: usage.Model, + Provider: cfg.Provider, + CostUSD: usage.EstimateCost(), + } + } - // 8. 检查新版本提醒(非阻塞) + finalReport := struct { + ReviewReport reviewer.ReviewReport `json:"review"` + Usage interface{} `json:"usage"` + }{ + ReviewReport: report, + Usage: reportUsage, + } + if err := json.NewEncoder(os.Stdout).Encode(finalReport); err != nil { + ui.PrintError("输出 JSON 失败: %v", err) + } + } + + // 8. 检查新版本提醒(非阻塞读取,仅在 text 模式下展示) select { case info := <-updateCh: - if info != nil { + if info != nil && outputFormat == "text" { ui.PrintUpdateBanner(info.CurrentVersion, info.LatestVersion, info.ReleaseURL) } default: @@ -153,4 +195,5 @@ func init() { rootCmd.Flags().Bool("staged", false, "显式审查暂存区(git add)代码") rootCmd.Flags().Bool("strict", false, "严格模式:遇到 WARNING 也会阻断退出") rootCmd.Flags().Bool("no-block", false, "非阻断模式:即使发现 BLOCKER 也不以非零退出码阻断") + rootCmd.Flags().String("format", "text", "输出格式: text 或 json") } diff --git a/config.yaml b/config.yaml index 07f0e77..01981b9 100644 --- a/config.yaml +++ b/config.yaml @@ -29,8 +29,14 @@ model: "gemini-3.7-flash" # 自定义 API 接口地址 (可选,不填默认走对应 Provider 的官方端点) # base_url: "" -# 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则) -custom_prompt: "" +# 自定义代码 Reviewer 提示词 (可选,留空则使用内置资深架构师准则) +# 旧字段 custom_prompt 仍兼容,但建议迁移为 custom_reviewer_prompt +custom_reviewer_prompt: "" +# 旧版兼容字段(v1.x 仍可使用) +# custom_prompt: "" + +# 自定义 Commit Message 提示词 (可选,追加在内置 Conventional Commits 约束之前) +custom_commit_msg_prompt: "" # 自定义模型 Token 计费费率 (可选,单位:美元 / 每 1M Tokens) # 默认情况下 git-air 内置了各主流模型官方最新价格表; @@ -40,4 +46,4 @@ custom_prompt: "" # price_output: 3.75 # 每 1M 输出 Token 美元单价 # AI Commit Message 生成语言 (可选,默认 "auto" 自动跟随系统语言,可选: zh, en, ja, ko) -commit_lang: "auto" \ No newline at end of file +commit_lang: "auto" diff --git a/internal/git/diff.go b/internal/git/diff.go index 2ba6c3f..7279cf8 100644 --- a/internal/git/diff.go +++ b/internal/git/diff.go @@ -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 命令 diff --git a/internal/reviewer/msg.go b/internal/reviewer/msg.go index e5d6812..c1aeea7 100644 --- a/internal/reviewer/msg.go +++ b/internal/reviewer/msg.go @@ -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 +} diff --git a/internal/reviewer/msg_test.go b/internal/reviewer/msg_test.go index 5cd0236..ed50670 100644 --- a/internal/reviewer/msg_test.go +++ b/internal/reviewer/msg_test.go @@ -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 约束不应丢失") + } +} diff --git a/internal/reviewer/report.go b/internal/reviewer/report.go new file mode 100644 index 0000000..ba73703 --- /dev/null +++ b/internal/reviewer/report.go @@ -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 +} diff --git a/internal/reviewer/report_test.go b/internal/reviewer/report_test.go new file mode 100644 index 0000000..869d930 --- /dev/null +++ b/internal/reviewer/report_test.go @@ -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]) + } +} diff --git a/internal/reviewer/reviewer.go b/internal/reviewer/reviewer.go index c751f98..f13fa1f 100644 --- a/internal/reviewer/reviewer.go +++ b/internal/reviewer/reviewer.go @@ -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 { diff --git a/internal/security/redact.go b/internal/security/redact.go new file mode 100644 index 0000000..209644f --- /dev/null +++ b/internal/security/redact.go @@ -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 +} diff --git a/internal/security/redact_test.go b/internal/security/redact_test.go new file mode 100644 index 0000000..2159124 --- /dev/null +++ b/internal/security/redact_test.go @@ -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) + } +} diff --git a/internal/updater/checker.go b/internal/updater/checker.go index 45db928..f6bf44c 100644 --- a/internal/updater/checker.go +++ b/internal/updater/checker.go @@ -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"