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
+626
-34
No files matched your search
@@ -274,6 +274,9 @@ model: "gemini-3.7-flash"
|
|||||||
# base_url: "https://api.deepseek.com/v1"
|
# base_url: "https://api.deepseek.com/v1"
|
||||||
|
|
||||||
# 自定义基础提示词 (可选,留空则使用默认资深架构师准则)
|
# 自定义基础提示词 (可选,留空则使用默认资深架构师准则)
|
||||||
|
# custom_reviewer_prompt: ""
|
||||||
|
# custom_commit_msg_prompt: ""
|
||||||
|
# 旧版兼容字段(v1.x 仍可使用)
|
||||||
# custom_prompt: ""
|
# custom_prompt: ""
|
||||||
|
|
||||||
# 自定义模型单价 (可选,单位: 美元/1M Tokens)
|
# 自定义模型单价 (可选,单位: 美元/1M Tokens)
|
||||||
@@ -284,6 +287,32 @@ model: "gemini-3.7-flash"
|
|||||||
# commit_lang: "auto"
|
# 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`)
|
## 📋 团队规则定制(`.airules`)
|
||||||
@@ -323,4 +352,3 @@ secrets.yaml
|
|||||||
## 📄 开源许可证
|
## 📄 开源许可证
|
||||||
|
|
||||||
本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request!
|
本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request!
|
||||||
|
|
||||||
+31
-1
@@ -269,6 +269,9 @@ model: "gemini-3.7-flash"
|
|||||||
# base_url: "https://api.deepseek.com/v1"
|
# base_url: "https://api.deepseek.com/v1"
|
||||||
|
|
||||||
# Custom System Prompt (Optional)
|
# Custom System Prompt (Optional)
|
||||||
|
# custom_reviewer_prompt: ""
|
||||||
|
# custom_commit_msg_prompt: ""
|
||||||
|
# Legacy compatibility field (supported throughout v1.x)
|
||||||
# custom_prompt: ""
|
# custom_prompt: ""
|
||||||
|
|
||||||
# Custom Token Pricing (Optional, in USD per 1M tokens)
|
# Custom Token Pricing (Optional, in USD per 1M tokens)
|
||||||
@@ -279,6 +282,34 @@ model: "gemini-3.7-flash"
|
|||||||
# commit_lang: "auto"
|
# 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`)
|
## 📋 Custom Team Rules (`.airules`)
|
||||||
@@ -318,4 +349,3 @@ secrets.yaml
|
|||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome!
|
This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome!
|
||||||
|
|
||||||
@@ -310,6 +310,9 @@ model: "gemini-3.7-flash"
|
|||||||
# base_url: "https://api.deepseek.com/v1"
|
# base_url: "https://api.deepseek.com/v1"
|
||||||
|
|
||||||
# カスタム System Prompt (任意)
|
# カスタム System Prompt (任意)
|
||||||
|
# custom_reviewer_prompt: ""
|
||||||
|
# custom_commit_msg_prompt: ""
|
||||||
|
# 旧版互換フィールド(v1.x では引き続き使用可能)
|
||||||
# custom_prompt: ""
|
# custom_prompt: ""
|
||||||
|
|
||||||
# カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens)
|
# カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens)
|
||||||
@@ -320,6 +323,34 @@ model: "gemini-3.7-flash"
|
|||||||
# commit_lang: "auto"
|
# 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`)
|
## 📋 チーム規約のカスタマイズ(`.airules`)
|
||||||
|
|||||||
+28
-5
@@ -16,7 +16,10 @@ type AppConfig struct {
|
|||||||
APIKey string `yaml:"api_key"` // API Key
|
APIKey string `yaml:"api_key"` // API Key
|
||||||
BaseURL string `yaml:"base_url"` // 自定义请求地址
|
BaseURL string `yaml:"base_url"` // 自定义请求地址
|
||||||
Model string `yaml:"model"` // 模型名称
|
Model string `yaml:"model"` // 模型名称
|
||||||
CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt
|
// 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 美元,可选)
|
PriceInput float64 `yaml:"price_input,omitempty"` // 自定义输入单价 (每 1M tokens 美元,可选)
|
||||||
PriceOutput float64 `yaml:"price_output,omitempty"` // 自定义输出单价 (每 1M tokens 美元,可选)
|
PriceOutput float64 `yaml:"price_output,omitempty"` // 自定义输出单价 (每 1M tokens 美元,可选)
|
||||||
CommitLang string `yaml:"commit_lang,omitempty"` // Commit Message 生成语言 (auto, zh, en, ja, ko 等)
|
CommitLang string `yaml:"commit_lang,omitempty"` // Commit Message 生成语言 (auto, zh, en, ja, ko 等)
|
||||||
@@ -82,6 +85,11 @@ func LoadConfig() (*AppConfig, error) {
|
|||||||
cfg.CommitLang = envLang
|
cfg.CommitLang = envLang
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兼容旧版本:custom_prompt 等价于 reviewer 提示词。
|
||||||
|
if cfg.CustomReviewerPrompt == "" {
|
||||||
|
cfg.CustomReviewerPrompt = cfg.CustomPrompt
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +139,15 @@ var configSetCmd = &cobra.Command{
|
|||||||
cfg.BaseURL = u
|
cfg.BaseURL = u
|
||||||
}
|
}
|
||||||
if pr, _ := cmd.Flags().GetString("prompt"); pr != "" {
|
if pr, _ := cmd.Flags().GetString("prompt"); pr != "" {
|
||||||
|
// 旧版 --prompt 继续写入旧字段,同时同步新字段,保证 v1.x 行为兼容。
|
||||||
cfg.CustomPrompt = pr
|
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 {
|
if pi, _ := cmd.Flags().GetFloat64("price-input"); pi > 0 {
|
||||||
cfg.PriceInput = pi
|
cfg.PriceInput = pi
|
||||||
@@ -171,11 +187,16 @@ var configGetCmd = &cobra.Command{
|
|||||||
maskedKey = cfg.APIKey[:4] + "..." + cfg.APIKey[len(cfg.APIKey)-4:]
|
maskedKey = cfg.APIKey[:4] + "..." + cfg.APIKey[len(cfg.APIKey)-4:]
|
||||||
}
|
}
|
||||||
fmt.Printf("APIKey: %s\n", maskedKey)
|
fmt.Printf("APIKey: %s\n", maskedKey)
|
||||||
hasPrompt := "否 (使用默认内置架构师 Prompt)"
|
reviewerPrompt := "否 (使用默认内置架构师 Prompt)"
|
||||||
if cfg.CustomPrompt != "" {
|
if cfg.CustomReviewerPrompt != "" {
|
||||||
hasPrompt = "是 (已自定义)"
|
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 (自动识别系统语言)"
|
commitLang := "auto (自动识别系统语言)"
|
||||||
if cfg.CommitLang != "" {
|
if cfg.CommitLang != "" {
|
||||||
commitLang = cfg.CommitLang
|
commitLang = cfg.CommitLang
|
||||||
@@ -195,6 +216,8 @@ func init() {
|
|||||||
configSetCmd.Flags().StringP("model", "m", "", "模型名称")
|
configSetCmd.Flags().StringP("model", "m", "", "模型名称")
|
||||||
configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址")
|
configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址")
|
||||||
configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt")
|
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-input", 0, "自定义每 1M 输入 Token 美元价格")
|
||||||
configSetCmd.Flags().Float64("price-output", 0, "自定义每 1M 输出 Token 美元价格")
|
configSetCmd.Flags().Float64("price-output", 0, "自定义每 1M 输出 Token 美元价格")
|
||||||
configSetCmd.Flags().String("commit-lang", "", "Commit Message 语言 (auto, zh, en, ja, ko 等)")
|
configSetCmd.Flags().String("commit-lang", "", "Commit Message 语言 (auto, zh, en, ja, ko 等)")
|
||||||
|
|||||||
+144
-13
@@ -1,19 +1,142 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
"github.com/unclesam-ly/git-air/internal/git"
|
"github.com/unclesam-ly/git-air/internal/git"
|
||||||
"github.com/unclesam-ly/git-air/internal/ui"
|
"github.com/unclesam-ly/git-air/internal/ui"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const preCommitScript = `#!/bin/sh
|
const (
|
||||||
# git-air pre-commit hook
|
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 前代码自动化评审..."
|
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{
|
var hookCmd = &cobra.Command{
|
||||||
Use: "hook",
|
Use: "hook",
|
||||||
@@ -22,25 +145,30 @@ var hookCmd = &cobra.Command{
|
|||||||
|
|
||||||
var hookInstallCmd = &cobra.Command{
|
var hookInstallCmd = &cobra.Command{
|
||||||
Use: "install",
|
Use: "install",
|
||||||
Short: "在当前仓库一键安装 pre-commit 钩子",
|
Short: "在当前仓库安装 pre-commit 钩子并保留已有 Hook",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
repoRoot, err := git.GetRepoRoot(cmd.Context())
|
repoRoot, err := git.GetRepoRoot(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ui.PrintError("请在 Git 仓库根目录下执行此命令")
|
ui.PrintError("请在 Git 仓库根目录下执行此命令")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
|
hooksDir := filepath.Join(repoRoot, ".git", "hooks")
|
||||||
if err := os.WriteFile(hookPath, []byte(preCommitScript), 0755); err != nil {
|
if err := os.MkdirAll(hooksDir, 0755); err != nil {
|
||||||
ui.PrintError("写入 hook 文件失败: %v", err)
|
ui.PrintError("创建 hooks 目录失败: %v", err)
|
||||||
return
|
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{
|
var hookUninstallCmd = &cobra.Command{
|
||||||
Use: "uninstall",
|
Use: "uninstall",
|
||||||
Short: "移除当前仓库的 pre-commit 钩子",
|
Short: "移除 git-air Hook 并恢复已有 Hook",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
repoRoot, err := git.GetRepoRoot(cmd.Context())
|
repoRoot, err := git.GetRepoRoot(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,8 +176,11 @@ var hookUninstallCmd = &cobra.Command{
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
|
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
|
||||||
_ = os.Remove(hookPath)
|
if err := uninstallPreCommitHook(hookPath); err != nil {
|
||||||
ui.PrintSuccess("已成功卸载 pre-commit 钩子!")
|
ui.PrintError("卸载 Hook 失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.PrintSuccess("已卸载 git-air Hook;如原来存在 Hook,已恢复原文件。")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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.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")
|
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 {
|
if err != nil {
|
||||||
ui.PrintError("生成 Commit Message 失败: %v", err)
|
ui.PrintError("生成 Commit Message 失败: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
+47
-4
@@ -2,6 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -21,6 +22,11 @@ var rootCmd = &cobra.Command{
|
|||||||
Args: cobra.ArbitraryArgs,
|
Args: cobra.ArbitraryArgs,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
ctx := cmd.Context()
|
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()
|
updateCh := updater.CheckAsync()
|
||||||
@@ -76,17 +82,21 @@ var rootCmd = &cobra.Command{
|
|||||||
// 5. 初始化评审引擎
|
// 5. 初始化评审引擎
|
||||||
customPrompt, _ := cmd.Flags().GetString("prompt")
|
customPrompt, _ := cmd.Flags().GetString("prompt")
|
||||||
if customPrompt == "" {
|
if customPrompt == "" {
|
||||||
customPrompt = cfg.CustomPrompt
|
customPrompt = cfg.CustomReviewerPrompt
|
||||||
}
|
}
|
||||||
rev := reviewer.NewReviewer(llmClient, repoRoot, customPrompt)
|
rev := reviewer.NewReviewer(llmClient, repoRoot, customPrompt)
|
||||||
|
|
||||||
// 6. 打印开场横幅并开始流式评审
|
// 6. 打印开场横幅并开始流式评审
|
||||||
|
if outputFormat == "text" {
|
||||||
ui.PrintBanner(cfg.Provider, cfg.Model)
|
ui.PrintBanner(cfg.Provider, cfg.Model)
|
||||||
|
}
|
||||||
printer := ui.NewStreamPrinter()
|
printer := ui.NewStreamPrinter()
|
||||||
var reviewOutput strings.Builder
|
var reviewOutput strings.Builder
|
||||||
_, usage, err := rev.Execute(ctx, diff, true, func(chunk string) {
|
_, usage, err := rev.Execute(ctx, diff, true, func(chunk string) {
|
||||||
reviewOutput.WriteString(chunk)
|
reviewOutput.WriteString(chunk)
|
||||||
|
if outputFormat == "text" {
|
||||||
printer.PrintChunk(chunk)
|
printer.PrintChunk(chunk)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ui.PrintError("\n评审过程中发生错误: %v", err)
|
ui.PrintError("\n评审过程中发生错误: %v", err)
|
||||||
@@ -94,16 +104,48 @@ var rootCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. 打印 Token 消耗统计
|
// 7. 打印 Token 消耗统计
|
||||||
if usage != nil {
|
if outputFormat == "text" && usage != nil {
|
||||||
ui.PrintUsage(usage.InputTokens, usage.OutputTokens, usage.EstimateCost())
|
ui.PrintUsage(usage.InputTokens, usage.OutputTokens, usage.EstimateCost())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if outputFormat == "text" {
|
||||||
ui.PrintFooter()
|
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 {
|
select {
|
||||||
case info := <-updateCh:
|
case info := <-updateCh:
|
||||||
if info != nil {
|
if info != nil && outputFormat == "text" {
|
||||||
ui.PrintUpdateBanner(info.CurrentVersion, info.LatestVersion, info.ReleaseURL)
|
ui.PrintUpdateBanner(info.CurrentVersion, info.LatestVersion, info.ReleaseURL)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -153,4 +195,5 @@ func init() {
|
|||||||
rootCmd.Flags().Bool("staged", false, "显式审查暂存区(git add)代码")
|
rootCmd.Flags().Bool("staged", false, "显式审查暂存区(git add)代码")
|
||||||
rootCmd.Flags().Bool("strict", false, "严格模式:遇到 WARNING 也会阻断退出")
|
rootCmd.Flags().Bool("strict", false, "严格模式:遇到 WARNING 也会阻断退出")
|
||||||
rootCmd.Flags().Bool("no-block", false, "非阻断模式:即使发现 BLOCKER 也不以非零退出码阻断")
|
rootCmd.Flags().Bool("no-block", false, "非阻断模式:即使发现 BLOCKER 也不以非零退出码阻断")
|
||||||
|
rootCmd.Flags().String("format", "text", "输出格式: text 或 json")
|
||||||
}
|
}
|
||||||
+8
-2
@@ -29,8 +29,14 @@ model: "gemini-3.7-flash"
|
|||||||
# 自定义 API 接口地址 (可选,不填默认走对应 Provider 的官方端点)
|
# 自定义 API 接口地址 (可选,不填默认走对应 Provider 的官方端点)
|
||||||
# base_url: ""
|
# base_url: ""
|
||||||
|
|
||||||
# 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则)
|
# 自定义代码 Reviewer 提示词 (可选,留空则使用内置资深架构师准则)
|
||||||
custom_prompt: ""
|
# 旧字段 custom_prompt 仍兼容,但建议迁移为 custom_reviewer_prompt
|
||||||
|
custom_reviewer_prompt: ""
|
||||||
|
# 旧版兼容字段(v1.x 仍可使用)
|
||||||
|
# custom_prompt: ""
|
||||||
|
|
||||||
|
# 自定义 Commit Message 提示词 (可选,追加在内置 Conventional Commits 约束之前)
|
||||||
|
custom_commit_msg_prompt: ""
|
||||||
|
|
||||||
# 自定义模型 Token 计费费率 (可选,单位:美元 / 每 1M Tokens)
|
# 自定义模型 Token 计费费率 (可选,单位:美元 / 每 1M Tokens)
|
||||||
# 默认情况下 git-air 内置了各主流模型官方最新价格表;
|
# 默认情况下 git-air 内置了各主流模型官方最新价格表;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/unclesam-ly/git-air/internal/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -69,7 +71,8 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(stagedDiff) != "" {
|
if strings.TrimSpace(stagedDiff) != "" {
|
||||||
return truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...)), nil
|
truncated := truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...))
|
||||||
|
return security.RedactSecrets(truncated), nil
|
||||||
}
|
}
|
||||||
// 2. 如果暂存区为空,回退提取工作区未暂存的修改
|
// 2. 如果暂存区为空,回退提取工作区未暂存的修改
|
||||||
cmdArgs = []string{}
|
cmdArgs = []string{}
|
||||||
@@ -82,20 +85,22 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredDiff := FilterDiff(rawDiff, customIgnores...)
|
truncatedDiff := truncateDiffIfNeeded(FilterDiff(rawDiff, customIgnores...))
|
||||||
|
filteredDiff := security.RedactSecrets(truncatedDiff)
|
||||||
if strings.TrimSpace(filteredDiff) == "" {
|
if strings.TrimSpace(filteredDiff) == "" {
|
||||||
return "", ErrEmptyDiff
|
return "", ErrEmptyDiff
|
||||||
}
|
}
|
||||||
|
|
||||||
return truncateDiffIfNeeded(filteredDiff), nil
|
return filteredDiff, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateDiffIfNeeded(diff string) string {
|
func truncateDiffIfNeeded(diff string) string {
|
||||||
runes := []rune(diff)
|
runes := []rune(diff)
|
||||||
if len(runes) > maxDiffSize {
|
if len(runes) > maxDiffSize {
|
||||||
runes = runes[:maxDiffSize]
|
runes = runes[:maxDiffSize]
|
||||||
}
|
|
||||||
return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]"
|
return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]"
|
||||||
|
}
|
||||||
|
return diff
|
||||||
}
|
}
|
||||||
|
|
||||||
// execGitDiff 执行底层的 git diff 命令
|
// execGitDiff 执行底层的 git diff 命令
|
||||||
|
|||||||
@@ -95,3 +95,14 @@ func BuildCommitMsgPrompt(lang string) string {
|
|||||||
%s
|
%s
|
||||||
`, langName, exampleSummary, exampleBullet1, exampleBullet2)
|
`, 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 规范的提交信息
|
// 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) == "" {
|
if strings.TrimSpace(diff) == "" {
|
||||||
return "", fmt.Errorf("diff 内容为空,无法生成 Commit Message")
|
return "", fmt.Errorf("diff 内容为空,无法生成 Commit Message")
|
||||||
}
|
}
|
||||||
|
|
||||||
targetLang := DetectLanguage(lang)
|
targetLang := DetectLanguage(lang)
|
||||||
systemPrompt := BuildCommitMsgPrompt(targetLang)
|
systemPrompt := BuildCommitMsgPromptWithCustom(targetLang, customPrompt)
|
||||||
|
|
||||||
msg, err := r.client.Review(ctx, systemPrompt, diff)
|
msg, err := r.client.Review(ctx, systemPrompt, diff)
|
||||||
if err != nil {
|
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 当前编译版本号
|
// CurrentVersion 当前编译版本号
|
||||||
const CurrentVersion = "v1.0.6"
|
const CurrentVersion = "v1.0.7"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
githubAPIURL = "https://api.github.com/repos/unclesam-ly/git-air/releases/latest"
|
githubAPIURL = "https://api.github.com/repos/unclesam-ly/git-air/releases/latest"
|
||||||
|
|||||||
Reference in new issue
Block a user