commit 55f40e015d5c565eb07d1a3b19171a56be12614a Author: unclesam <2036253946@qq.com> Date: Mon Aug 17 16:52:39 2026 +0800 feat: 初始化 git-air AI 代码评审 CLI 工具 新增多模型 LLM 客户端、Git diff 提取与过滤、配置管理及 pre-commit 钩子支持。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e52f6a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Binaries +git-air +git-air.exe +dist/ + +# Local sensitive configs +.git-air.yaml + +# IDEs +.idea/ +.vscode/ +*.swp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2a0f7a2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 unclesam-ly + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..21d2d9f --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +

+ git-air logo +

+ +

git-air 🍃

+ +

+ 纯 Go 编写、像空气一样轻盈极速的 Git 原生 AI 代码评审工具。
+ A lightweight, lightning-fast, Git-native AI code reviewer written in pure Go. +

+ +

+ Go Version + License + PRs Welcome +

+ +--- + +## 🌟 设计初衷与定位 + +市面上有许多优秀的云端与 CI/CD 级别的 AI Code Review 平台(如各类 PR 自动审查 Bot),它们在团队协作和远程代码门禁上表现出色。 + +**而 `git-air` 的定位是:将审查能力极致前置到开发者的本地终端(Pre-Commit / Local CLI)。** + +在代码正式 push 或发起 PR 之前,在本地敲一行命令,就能以零延迟、极简的方式先做一轮深度扫描,把潜在的并发隐患、逻辑缺陷与敏感信息拦截在本地,作为开发者最轻量顺手的日常命令行结对编程助手。 + +- ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。 +- 🧠 **全模型自由接入**:开箱即用支持 **Google Gemini、DeepSeek、本地离线 Ollama、OpenAI** 以及任何兼容 OpenAI 协议的自定义端点。 +- 🛡️ **智能降噪过滤**:自动剥离 `go.sum`、`package-lock.json`、`*.pb.go`、`*.gen.go` 等锁文件与自动生成代码,省 Token、防死循环。 +- 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。 +- 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。 +- 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。 + +--- + +## 📦 安装方式 + +### 方式 1:通过 Go 一键安装(推荐) +```bash +go install github.com/unclesam-ly/git-air@latest +``` + +### 方式 2:源码编译 +```bash +git clone https://github.com/unclesam-ly/git-air.git +cd git-air +go build -o git-air . +sudo mv git-air /usr/local/bin/ +``` + +--- + +## 🚀 快速上手 + +### 1. 配置模型与 API Key + +`git-air` 原生预置了国内外所有主流大模型的官方端点与推荐模型,只需指定 `--provider` 即可秒级切换: + +```bash +# 1. Google Gemini (推荐:极速且经济) +git air config set --provider gemini --key "YOUR_KEY" + +# 2. Anthropic Claude (Claude-3.7 / 3.5 Sonnet) +git air config set --provider claude --key "YOUR_KEY" --model anthropic/claude-3.7-sonnet + +# 3. xAI Grok (Grok-2 / Grok-3) +git air config set --provider grok --key "YOUR_KEY" --model grok-2-latest + +# 4. DeepSeek (高推理能力 / 深度思考) +git air config set --provider deepseek --key "YOUR_KEY" --model deepseek-chat + +# 5. 硅基流动 (SiliconFlow) +git air config set --provider siliconflow --key "YOUR_KEY" --model deepseek-ai/DeepSeek-V3 + +# 6. 通义千问 (Qwen / 阿里百炼) +git air config set --provider qwen --key "YOUR_KEY" --model qwen-plus + +# 7. 智谱 AI (GLM-4) +git air config set --provider zhipu --key "YOUR_KEY" --model glm-4-plus + +# 8. 月之暗面 (Kimi / Moonshot) +git air config set --provider moonshot --key "YOUR_KEY" + +# 9. Groq (极致硬件加速推理) +git air config set --provider groq --key "YOUR_KEY" --model llama-3.3-70b-versatile + +# 10. OpenRouter (全聚合模型网关) +git air config set --provider openrouter --key "YOUR_KEY" + +# 11. 本地 100% 离线隐私模型 (Ollama) +git air config set --provider ollama --model qwen2.5-coder + +# 12. OpenAI 官方 (GPT-4o / o3-mini) +git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini +``` + +查看当前配置状态: +```bash +git air config get +``` + +--- + +### 2. 常用评审命令 + +```bash +# 评审当前已暂存(git add)的代码(默认行为) +git air + +# 评审上一次 Commit 的代码 +git air HEAD~1 + +# 评审特性分支与主干分支之间的所有差异 +git air main..feature-agent + +# 只评审指定文件的改动 +git air internal/service/chat.go + +# 临时指定模型或 Key 进行评审 +git air --provider deepseek --model deepseek-chat +``` + +--- + +### 3. 一键安装 Git Pre-commit 拦截钩子 + +在你的 Git 项目根目录下执行: +```bash +git air hook install +``` +以后团队成员在执行 `git commit` 时,`git-air` 会在终端自动执行审查,若存在阻断性缺陷将自动提醒! + +如需卸载钩子: +```bash +git air hook uninstall +``` + +--- + +## ⚙️ 配置文件说明 + +`git-air` 支持多层配置覆盖,优先级从高到低为: +1. **命令行 Flags**(如 `--key`, `--model`, `--provider`) +2. **环境变量**(`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`) +3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`) +4. **全局用户配置**(`~/.git-air/config.yaml`) + +### `config.yaml` 示例模板: +```yaml +# 模型提供商: gemini, deepseek, ollama, openai, custom +provider: "gemini" + +# API 密钥 (本地 ollama 可留空) +api_key: "YOUR_API_KEY_HERE" + +# 模型名称 +model: "gemini-3.7-flash" + +# 自定义端点 (可选) +# base_url: "https://api.deepseek.com/v1" + +# 自定义基础提示词 (可选,留空则使用默认资深架构师准则) +# custom_prompt: "" +``` + +--- + +## 📋 团队规则定制(`.airules`) + +在任何 Git 仓库的根目录下创建一个 `.airules` 文件,`git-air` 在评审时会自动将其作为**团队最高优先级铁律**注入大模型: + +```markdown +# 团队专属开发铁律 +1. 所有 SQL 操作必须下沉至 DAO 层,严禁裸拼 SQL 字符串; +2. 并发操作共享变量必须加锁或使用原子包(sync/atomic); +3. 打开的连接/文件句柄必须紧跟 defer Close() 释放; +4. 严禁使用下划线 _ 盲目丢弃未处理的 error。 +``` + +--- + +## 📄 开源许可证 + +本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request! diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..dea4e77 Binary files /dev/null and b/assets/logo.png differ diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..d1dd96b --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "fmt" + "git-air/internal/ui" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// 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 +} + +func getConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".git-air") + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + return filepath.Join(dir, "config.yaml"), nil +} + +// LoadConfig 读取全局配置(优先使用环境变量,其次读取 yaml 文件) +func LoadConfig() (*AppConfig, error) { + cfg := &AppConfig{ + Provider: "gemini", + Model: "gemini-3.7-flash", + } + + // 1. 优先探测当前项目根目录下的配置文件 + localCandidates := []string{"config.yaml", ".git-air.yaml", ".git-air.yml", "configs/config.yaml"} + loaded := false + + for _, name := range localCandidates { + if data, err := os.ReadFile(name); err == nil && len(data) > 0 { + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("解析配置文件失败: %w", err) + } + loaded = true + break + } + } + + // 2. 如果项目根目录下没有,则回退读取用户全局配置 ~/.git-air/config.yaml + if !loaded { + configPath, err := getConfigPath() + if err == nil { + if data, err := os.ReadFile(configPath); err == nil { + _ = yaml.Unmarshal(data, cfg) + } + } + } + + // 3. 环境变量最高优先级覆盖 + if envKey := os.Getenv("GIT_AIR_API_KEY"); envKey != "" { + cfg.APIKey = envKey + } + if envProvider := os.Getenv("GIT_AIR_PROVIDER"); envProvider != "" { + cfg.Provider = envProvider + } + if envModel := os.Getenv("GIT_AIR_MODEL"); envModel != "" { + cfg.Model = envModel + } + if envBaseURL := os.Getenv("GIT_AIR_BASE_URL"); envBaseURL != "" { + cfg.BaseURL = envBaseURL + } + + return cfg, nil +} + +// SaveConfig 保存配置到 ~/.git-air/config.yaml +func SaveConfig(cfg *AppConfig) error { + configPath, err := getConfigPath() + if err != nil { + return err + } + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return os.WriteFile(configPath, data, 0600) +} + +// configCmd 配置子命令 +var configCmd = &cobra.Command{ + Use: "config", + Short: "查看或修改 git-air 全局配置 (~/.git-air/config.yaml)", +} +var configSetCmd = &cobra.Command{ + Use: "set", + Short: "设置全局配置项", + Example: ` git air config set --provider gemini --key YOUR_KEY + git air config set --provider deepseek --key YOUR_KEY --model deepseek-chat + git air config set --provider ollama --model qwen2.5-coder`, + Run: func(cmd *cobra.Command, args []string) { + cfg, err := LoadConfig() + if err != nil || cfg == nil { + cfg = &AppConfig{ + Provider: "gemini", + Model: "gemini-3.7-flash", + } + } + if p, _ := cmd.Flags().GetString("provider"); p != "" { + cfg.Provider = p + } + if k, _ := cmd.Flags().GetString("key"); k != "" { + cfg.APIKey = k + } + if m, _ := cmd.Flags().GetString("model"); m != "" { + cfg.Model = m + } + if u, _ := cmd.Flags().GetString("base-url"); u != "" { + cfg.BaseURL = u + } + if pr, _ := cmd.Flags().GetString("prompt"); pr != "" { + cfg.CustomPrompt = pr + } + if err := SaveConfig(cfg); err != nil { + ui.PrintError("保存配置失败: %v", err) + return + } + ui.PrintSuccess("配置更新成功!") + }, +} + +var configGetCmd = &cobra.Command{ + Use: "get", + Short: "查看当前所有配置", + Run: func(cmd *cobra.Command, args []string) { + cfg, err := LoadConfig() + if err != nil || cfg == nil { + cfg = &AppConfig{ + Provider: "gemini", + Model: "gemini-3.7-flash", + } + } + path, _ := getConfigPath() + fmt.Printf("配置文件路径: %s\n\n", path) + fmt.Printf("Provider: %s\n", cfg.Provider) + fmt.Printf("Model: %s\n", cfg.Model) + fmt.Printf("BaseURL: %s\n", cfg.BaseURL) + maskedKey := "未配置" + if len(cfg.APIKey) > 8 { + maskedKey = cfg.APIKey[:4] + "..." + cfg.APIKey[len(cfg.APIKey)-4:] + } + fmt.Printf("APIKey: %s\n", maskedKey) + hasPrompt := "否 (使用默认内置架构师 Prompt)" + if cfg.CustomPrompt != "" { + hasPrompt = "是 (已自定义)" + } + fmt.Printf("CustomPrompt: %s\n", hasPrompt) + }, +} + +func init() { + configSetCmd.Flags().StringP("provider", "p", "", "模型提供商 (gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, doubao, minimax, yi, groq, openrouter, ollama, openai)") + configSetCmd.Flags().StringP("key", "k", "", "API 密钥") + configSetCmd.Flags().StringP("model", "m", "", "模型名称") + configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址") + configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt") + + configCmd.AddCommand(configSetCmd) + configCmd.AddCommand(configGetCmd) +} diff --git a/cmd/hook.go b/cmd/hook.go new file mode 100644 index 0000000..09ae15e --- /dev/null +++ b/cmd/hook.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "git-air/internal/git" + "git-air/internal/ui" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +const preCommitScript = `#!/bin/sh +# git-air pre-commit hook +echo "[git-air] 正在执行 Commit 前代码自动化评审..." +git-air --cached +# 如果评审发现阻断性问题,可通过退出码阻断 commit +` + +var hookCmd = &cobra.Command{ + Use: "hook", + Short: "管理 Git Pre-commit 自动审查钩子", +} + +var hookInstallCmd = &cobra.Command{ + Use: "install", + Short: "在当前仓库一键安装 pre-commit 钩子", + 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) + return + } + ui.PrintSuccess("已成功安装 pre-commit 钩子!每次执行 git commit 时将自动触发评审。") + }, +} + +var hookUninstallCmd = &cobra.Command{ + Use: "uninstall", + Short: "移除当前仓库的 pre-commit 钩子", + 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") + _ = os.Remove(hookPath) + ui.PrintSuccess("已成功卸载 pre-commit 钩子!") + }, +} + +func init() { + hookCmd.AddCommand(hookInstallCmd) + hookCmd.AddCommand(hookUninstallCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..a0f0261 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "context" + "git-air/internal/git" + "git-air/internal/llm" + "git-air/internal/reviewer" + "git-air/internal/ui" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "git-air [git diff 参数,如 HEAD~1, main..feature, 文件名等]", + Short: "git-air: 极轻量、极速的 Git 原生 AI 代码评审工具", + Long: `git-air 是一个纯 Go 编写的 Git AI 评审利器。 +支持一键评审暂存区、指定 Commit、分支差异或单文件,无缝兼容 Gemini、DeepSeek、Ollama 和 OpenAI。`, + Run: func(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + // 1. 检查并获取 Diff + diff, err := git.GetDiff(ctx, args...) + if err != nil { + ui.PrintError("%v", err) + return + } + + // 2. 加载配置 + cfg, err := LoadConfig() + if err != nil { + ui.PrintError("加载配置失败: %v", err) + return + } + + // 允许命令行临时覆盖模型/Key + if flagKey, _ := cmd.Flags().GetString("key"); flagKey != "" { + cfg.APIKey = flagKey + } + if flagModel, _ := cmd.Flags().GetString("model"); flagModel != "" { + cfg.Model = flagModel + } + if flagProvider, _ := cmd.Flags().GetString("provider"); flagProvider != "" { + cfg.Provider = flagProvider + } + + // 3. 初始化大模型客户端 + llmClient, err := llm.NewClient(llm.Config{ + Provider: cfg.Provider, + APIKey: cfg.APIKey, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + }) + if err != nil { + ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err) + return + } + + // 4. 获取仓库根目录 (用于加载 .airules) + repoRoot, _ := git.GetRepoRoot(ctx) + + // 5. 初始化评审引擎 + customPrompt, _ := cmd.Flags().GetString("prompt") + if customPrompt == "" { + customPrompt = cfg.CustomPrompt + } + rev := reviewer.NewReviewer(llmClient, repoRoot, customPrompt) + + // 6. 打印开场横幅并开始流式评审 + ui.PrintBanner(cfg.Provider, cfg.Model) + printer := ui.NewStreamPrinter() + _, err = rev.Execute(ctx, diff, true, func(chunk string) { + printer.PrintChunk(chunk) + }) + if err != nil { + ui.PrintError("\n评审过程中发生错误: %v", err) + return + } + + ui.PrintFooter() + }, +} + +func Execute() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := rootCmd.ExecuteContext(ctx); err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.AddCommand(configCmd) + rootCmd.AddCommand(hookCmd) + + rootCmd.Flags().StringP("key", "k", "", "临时指定 API Key") + rootCmd.Flags().StringP("model", "m", "", "临时指定模型名称") + rootCmd.Flags().StringP("provider", "p", "", "临时指定 Provider (gemini, deepseek, ollama)") + rootCmd.Flags().String("prompt", "", "临时指定 System Prompt") +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..40acb06 --- /dev/null +++ b/config.yaml @@ -0,0 +1,33 @@ +# ========================================== +# git-air 配置文件 (~/.git-air/config.yaml) +# ========================================== + +# 模型提供商,可选: +# - 国际主流: gemini, claude (Anthropic), grok (xAI), openai, groq, openrouter, ollama +# - 国内主流: deepseek, qwen (通义千问), zhipu (智谱GLM), moonshot (Kimi), siliconflow (硅基流动), doubao (字节豆包), minimax, yi (零一万物) +# - 自定义端点: custom (支持任何兼容 OpenAI 的第三方/私有化端点) +provider: "gemini" + +# API 密钥 (如果是本地 ollama/lmstudio 可填任意字符或留空) +api_key: "YOUR_API_KEY_HERE" + +# 模型名称 (可选,若留空则自动分配各平台的默认主力模型) +# gemini: gemini-3.7-flash +# claude: anthropic/claude-3.7-sonnet +# grok: grok-2-latest +# deepseek: deepseek-chat (或 deepseek-reasoner) +# qwen: qwen-plus (或 qwen2.5-coder-32b-instruct) +# zhipu: glm-4-plus (或 glm-4-flash) +# moonshot: moonshot-v1-8k +# siliconflow: deepseek-ai/DeepSeek-V3 +# openrouter: anthropic/claude-3.7-sonnet +# groq: llama-3.3-70b-versatile +# ollama: qwen2.5-coder +# openai: gpt-4o-mini (或 gpt-4o) +model: "gemini-3.7-flash" + +# 自定义 API 接口地址 (可选,不填默认走对应 Provider 的官方端点) +# base_url: "" + +# 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则) +custom_prompt: "" \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..863193b --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module git-air + +go 1.26.6 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/sashabaranov/go-openai v1.42.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..64ae97a --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sashabaranov/go-openai v1.42.0 h1:fgeZx7/D8dRT//PwXAGe9ylOMtj6vrs999uWF71K+f8= +github.com/sashabaranov/go-openai v1.42.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/git/diff.go b/internal/git/diff.go new file mode 100644 index 0000000..7f522f5 --- /dev/null +++ b/internal/git/diff.go @@ -0,0 +1,101 @@ +package git + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" +) + +var ( + ErrNotGitRepo = errors.New("当前目录不是 Git 仓库(未找到 .git)") + ErrEmptyDiff = errors.New("没有检测到任何需要评审的代码变更(Diff 为空)") +) + +// IsGitRepo 检查当前工作目录是否在一个 Git 仓库内 +func IsGitRepo(ctx context.Context) bool { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--is-inside-work-tree") + err := cmd.Run() + return err == nil +} + +// GetRepoRoot 获取当前 Git 仓库的根目录绝对路径 +func GetRepoRoot(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") + out, err := cmd.Output() + if err != nil { + return "", ErrNotGitRepo + } + + return strings.TrimSpace(string(out)), nil +} + +const maxDiffSize = 300 * 1024 // 限制最大 300KB Diff,避免打爆上下文与 OOM + +// GetDiff 提取并过滤 Git 代码差异 +// args 可传: +// - 无参数:默认先查暂存区 (git diff --cached),若暂存区为空则自动查工作区 (git diff) +// - 自定义参数:如 "HEAD~1"、"main..feature"、"internal/service/chat.go" 等 +func GetDiff(ctx context.Context, args ...string) (string, error) { + if !IsGitRepo(ctx) { + return "", ErrNotGitRepo + } + + var cmdArgs []string + if len(args) == 0 { + // 1. 默认先提取暂存区代码 (准备 commit 的代码) + stagedDiff, err := execGitDiff(ctx, "--cached") + if err != nil { + return "", err + } + if strings.TrimSpace(stagedDiff) != "" { + return truncateDiffIfNeeded(FilterDiff(stagedDiff)), nil + } + // 2. 如果暂存区为空,回退提取工作区未暂存的修改 + cmdArgs = []string{} + } else { + cmdArgs = args + } + + rawDiff, err := execGitDiff(ctx, cmdArgs...) + if err != nil { + return "", err + } + + filteredDiff := FilterDiff(rawDiff) + if strings.TrimSpace(filteredDiff) == "" { + return "", ErrEmptyDiff + } + + return truncateDiffIfNeeded(filteredDiff), nil +} + +func truncateDiffIfNeeded(diff string) string { + runes := []rune(diff) + if len(runes) > maxDiffSize { + runes = runes[:maxDiffSize] + } + return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]" +} + +// execGitDiff 执行底层的 git diff 命令 +func execGitDiff(ctx context.Context, args ...string) (string, error) { + fullArgs := append([]string{"diff"}, args...) + cmd := exec.CommandContext(ctx, "git", fullArgs...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return "", errors.New("系统未检测到 git 命令,请确保已安装 Git 并配置到系统 PATH 中") + } + errMsg := strings.TrimSpace(stderr.String()) + if errMsg != "" { + return "", fmt.Errorf("git diff 执行失败: %s (%w)", errMsg, err) + } + return "", fmt.Errorf("git diff 执行失败: %w", err) + } + return stdout.String(), nil +} diff --git a/internal/git/filter.go b/internal/git/filter.go new file mode 100644 index 0000000..19bbd4d --- /dev/null +++ b/internal/git/filter.go @@ -0,0 +1,97 @@ +package git + +import ( + "path/filepath" + "regexp" + "strings" +) + +// 默认需要忽略的文件名与后缀列表 +var ignoredPatterns = []string{ + // 依赖与锁文件 + "go.sum", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "composer.lock", + "Cargo.lock", + "Gemfile.lock", + // 自动生成的代码 + "*.pb.go", + "*.gen.go", + "*_generated.go", + "*.g.dart", + // 压缩/静态构建产物 + "*.min.js", + "*.min.css", + "*.map", + "*.bundle.js", + // 图片与多媒体 + "*.png", + "*.jpg", + "*.jpeg", + "*.gif", + "*.svg", + "*.ico", + "*.pdf", +} + +// isIgnoredFile 判断文件路径是否应该在 Code Review 中被忽略 +func isIgnoredFile(filePath string) bool { + cleanPath := filepath.ToSlash(filePath) + baseName := filepath.Base(cleanPath) + + for _, pattern := range ignoredPatterns { + // 精确匹配文件名(如 go.sum) + if pattern == baseName { + return true + } + + // 通配符匹配(如 *.pb.go) + if matched, _ := filepath.Match(pattern, baseName); matched { + return true + } + } + + return false +} + +var diffHeaderRegex = regexp.MustCompile(`(?m)^diff --git a/(.*?) b/(.*?)$`) + +// FilterDiff 过滤掉 diff 中无意义的锁文件和自动生成文件的区块 +func FilterDiff(rawDiff string) string { + if strings.TrimSpace(rawDiff) == "" { + return "" + } + + // 按照 diff --git 切分各个文件的差异区块 + chunks := strings.Split(rawDiff, "diff --git ") + var filteredChunks []string + + for _, chunk := range chunks { + if strings.TrimSpace(chunk) == "" { + continue + } + + // 还原切分前缀 + fullChunk := "diff --git " + chunk + lines := strings.Split(chunk, "\n") + if len(lines) == 0 { + continue + } + + // 解析第一行中的文件路径,如:a/internal/ent/schema.go b/internal/ent/schema.go + firstLine := lines[0] + parts := strings.Fields(firstLine) + if len(parts) >= 2 { + filePath := strings.TrimPrefix(parts[1], "b/") + if isIgnoredFile(filePath) { + continue // 命中忽略规则,跳过这个文件的 diff + } + } + + filteredChunks = append(filteredChunks, fullChunk) + } + + return strings.Join(filteredChunks, "") +} diff --git a/internal/llm/client.go b/internal/llm/client.go new file mode 100644 index 0000000..26f2ef3 --- /dev/null +++ b/internal/llm/client.go @@ -0,0 +1,298 @@ +package llm + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/sashabaranov/go-openai" +) + +// Config 大模型统一配置 +type Config struct { + Provider string // "gemini", "deepseek", "openai", "ollama", "custom" + APIKey string // 密钥 (Ollama 本地可填任意字符或留空) + BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值) + Model string // 模型名称 (非必填,会自动设置默认主力模型) +} + +// Client 统一大模型客户端 +type Client struct { + client *openai.Client + model string +} + +// NewClient 初始化客户端 +func NewClient(cfg Config) (*Client, error) { + provider := strings.ToLower(strings.TrimSpace(cfg.Provider)) + if provider == "" { + provider = "gemini" + } + + apiKey := cfg.APIKey + baseURL := cfg.BaseURL + model := cfg.Model + + // 智能适配各大主流模型的官方地址与主力模型 + switch provider { + case "gemini", "google": + if baseURL == "" { + baseURL = "https://generativelanguage.googleapis.com/v1beta/openai/" + } + if model == "" { + model = "gemini-3.7-flash" + } + if apiKey == "" { + return nil, fmt.Errorf("gemini 必须配置 api_key") + } + + case "deepseek": + if baseURL == "" { + baseURL = "https://api.deepseek.com/v1" + } + if model == "" { + model = "deepseek-chat" + } + if apiKey == "" { + return nil, fmt.Errorf("deepseek 必须配置 api_key") + } + + case "siliconflow", "silicon": + if baseURL == "" { + baseURL = "https://api.siliconflow.cn/v1" + } + if model == "" { + model = "deepseek-ai/DeepSeek-V3" + } + if apiKey == "" { + return nil, fmt.Errorf("siliconflow 必须配置 api_key") + } + + case "qwen", "dashscope", "aliyun": + if baseURL == "" { + baseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1" + } + if model == "" { + model = "qwen-plus" + } + if apiKey == "" { + return nil, fmt.Errorf("qwen/dashscope 必须配置 api_key") + } + + case "zhipu", "glm": + if baseURL == "" { + baseURL = "https://open.bigmodel.cn/api/paas/v4/" + } + if model == "" { + model = "glm-4-plus" + } + if apiKey == "" { + return nil, fmt.Errorf("zhipu 必须配置 api_key") + } + + case "moonshot", "kimi": + if baseURL == "" { + baseURL = "https://api.moonshot.cn/v1" + } + if model == "" { + model = "moonshot-v1-8k" + } + if apiKey == "" { + return nil, fmt.Errorf("moonshot/kimi 必须配置 api_key") + } + + case "doubao", "volcengine": + if baseURL == "" { + baseURL = "https://ark.cn-beijing.volces.com/api/v3" + } + if model == "" { + model = "doubao-pro-32k" + } + if apiKey == "" { + return nil, fmt.Errorf("doubao 必须配置 api_key (并在 model 中填写接入点 Endpoint ID)") + } + + case "minimax": + if baseURL == "" { + baseURL = "https://api.minimax.chat/v1" + } + if model == "" { + model = "abab6.5s-chat" + } + if apiKey == "" { + return nil, fmt.Errorf("minimax 必须配置 api_key") + } + + case "yi", "lingyi": + if baseURL == "" { + baseURL = "https://api.lingyiwanwu.com/v1" + } + if model == "" { + model = "yi-lightning" + } + if apiKey == "" { + return nil, fmt.Errorf("yi 必须配置 api_key") + } + + case "groq": + if baseURL == "" { + baseURL = "https://api.groq.com/openai/v1" + } + if model == "" { + model = "llama-3.3-70b-versatile" + } + if apiKey == "" { + return nil, fmt.Errorf("groq 必须配置 api_key") + } + + case "grok", "xai": + if baseURL == "" { + baseURL = "https://api.x.ai/v1" + } + if model == "" { + model = "grok-2-latest" + } + if apiKey == "" { + return nil, fmt.Errorf("grok/xai 必须配置 api_key") + } + + case "claude", "anthropic": + if baseURL == "" { + baseURL = "https://openrouter.ai/api/v1" + } + if model == "" { + model = "anthropic/claude-3.7-sonnet" + } + if apiKey == "" { + return nil, fmt.Errorf("claude 必须配置 api_key (默认走 OpenRouter 统一网关,或通过 base_url 指定自建代理端点)") + } + + case "openrouter": + if baseURL == "" { + baseURL = "https://openrouter.ai/api/v1" + } + if model == "" { + model = "anthropic/claude-3.7-sonnet" + } + if apiKey == "" { + return nil, fmt.Errorf("openrouter 必须配置 api_key") + } + + case "ollama": + if baseURL == "" { + baseURL = "http://localhost:11434/v1" + } + if model == "" { + model = "qwen2.5-coder" + } + if apiKey == "" { + apiKey = "ollama" // 本地 ollama 传非空占位符即可 + } + + case "lmstudio", "vllm": + if baseURL == "" { + baseURL = "http://localhost:1234/v1" + } + if model == "" { + model = "local-model" + } + if apiKey == "" { + apiKey = "local" + } + + case "openai": + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + if model == "" { + model = "gpt-4o-mini" + } + if apiKey == "" { + return nil, fmt.Errorf("openai 必须配置 api_key") + } + + default: + // custom 自定义端点(只要提供了 baseURL 就能直连) + if baseURL == "" { + return nil, fmt.Errorf("未知 Provider: '%s',如需自定义请提供 base_url", provider) + } + if model == "" { + model = "gpt-4o" + } + } + + clientConfig := openai.DefaultConfig(apiKey) + clientConfig.BaseURL = baseURL + + return &Client{ + client: openai.NewClientWithConfig(clientConfig), + model: model, + }, nil +} + +// Review 一次性完整返回代码评审结果 +func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string, error) { + resp, err := c.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ + Model: c.model, + Messages: []openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleSystem, Content: systemPrompt}, + {Role: openai.ChatMessageRoleUser, Content: diff}, + }, + Temperature: 0.2, // 代码评审低随机性 + }) + + if err != nil { + return "", fmt.Errorf("代码评审调用失败: %w", err) + } + + if len(resp.Choices) == 0 { + return "", fmt.Errorf("模型未返回任何有效评审结果") + } + + return resp.Choices[0].Message.Content, nil +} + +// ReviewStream 流式逐字返回评审结果(打字机效果) +func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) error { + stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ + Model: c.model, + Messages: []openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleSystem, Content: systemPrompt}, + {Role: openai.ChatMessageRoleUser, Content: diff}, + }, + Temperature: 0.2, + Stream: true, + }) + if err != nil { + return fmt.Errorf("创建流式请求失败: %w", err) + } + if stream != nil { + defer stream.Close() + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + response, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("流式读取异常: %w", err) + } + if len(response.Choices) > 0 { + chunk := response.Choices[0].Delta.Content + if chunk != "" { + onChunk(chunk) + } + } + } + + return nil +} diff --git a/internal/reviewer/prompt.go b/internal/reviewer/prompt.go new file mode 100644 index 0000000..1b2e817 --- /dev/null +++ b/internal/reviewer/prompt.go @@ -0,0 +1,51 @@ +package reviewer + +import ( + "strings" +) + +// DefaultBaseSystemPrompt 默认内置的资深架构师评审准则 +const DefaultBaseSystemPrompt = `你是一位拥有 15 年高并发系统设计经验的资深架构师与代码安全专家。 +你的任务是对开发者提交的 Git Diff 增量代码进行工业级、严苛、精炼的代码评审(Code Review)。 +### 评审原则: +1. 严禁任何客套话与无意义吹捧,直奔技术实质。 +2. 聚焦于逻辑缺陷、并发竞态、安全漏洞、内存与协程泄漏、异常处理等实质性问题,不要纠结纯粹的代码缩进或格式。 +3. 发现具体问题时,必须直接给出修改前后的对比例子或修复代码块。 +4. 如果代码质量优秀且无缺陷,直接返回 "LGTM: 代码质量良好,未发现明显缺陷",不要强行挑刺。 +### 重点审查维度: +- 并发与竞态安全:Goroutine 泄露、Channel 阻塞、锁竞争与未释放、共享变量无锁并发读写。 +- 安全与权限:SQL 注入风险、敏感密钥/Token 硬编码、未校验的外部输入、未做权限校验。 +- 资源与错误处理:连接/文件未 defer Close()、错误被盲目下划线 _ 忽略、Context 未向后传递。 +- 性能与边界:大循环内的频繁内存分配或数据库查询、切片越界、空指针异常(NIL Dereference)。 +### 输出格式规范(使用清晰严谨的纯文本标签): +#### 变更概述 +(用 1-2 句话总结本次代码改动核心内容) +#### 详细审查意见 +(如果有问题,按严重程度列出;若无问题则省略此段) +- [BLOCKER] 文件路径:行号 - 问题描述及潜在事故危害(必须附带修复代码块) +- [WARNING] 文件路径:行号 - 潜在隐患或性能优化点 +- [SUGGESTION] 文件路径:行号 - 更优雅的 Idiomatic 写法 +#### 评审结论 +- 结论: [PASS / WARN / REJECT] +- 评分: X / 100 +` + +// BuildSystemPrompt 构建最终 Prompt(合并 Base Prompt 与 rules.go 加载的项目规则) +func BuildSystemPrompt(customPrompt string, repoRoot string) string { + var builder strings.Builder + // 1. 如果用户指定了自定义 Base Prompt 则使用,否则使用默认内置 Prompt + basePrompt := strings.TrimSpace(customPrompt) + if basePrompt == "" { + basePrompt = DefaultBaseSystemPrompt + } + + builder.WriteString(basePrompt) + // 2. 调用 rules.go 读取项目专属规则 + customRules := LoadCustomRules(repoRoot) + if customRules != "" { + builder.WriteString("\n\n### 团队专属规范(最高优先级准则):\n") + builder.WriteString(customRules) + } + + return builder.String() +} diff --git a/internal/reviewer/reviewer.go b/internal/reviewer/reviewer.go new file mode 100644 index 0000000..1af6ba4 --- /dev/null +++ b/internal/reviewer/reviewer.go @@ -0,0 +1,40 @@ +package reviewer + +import ( + "context" + "fmt" + "git-air/internal/llm" + "strings" +) + +type Reviewer struct { + client *llm.Client + repoRoot string + customPrompt string +} + +// NewReviewer 初始化评审引擎 +// customPrompt 可传入用户自定义配置的 Prompt,传空字符串则使用默认 Prompt +func NewReviewer(client *llm.Client, repoRoot string, customPrompt string) *Reviewer { + return &Reviewer{ + client: client, + repoRoot: repoRoot, + customPrompt: customPrompt, + } +} + +// Execute 执行代码评审 +func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, error) { + if strings.TrimSpace(diff) == "" { + return "", fmt.Errorf("diff 内容为空,无需评审") + } + + // 动态组装最终 System Prompt + systemPrompt := BuildSystemPrompt(r.customPrompt, r.repoRoot) + if stream && onChunk != nil { + err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk) + return "", err + } + + return r.client.Review(ctx, systemPrompt, diff) +} diff --git a/internal/reviewer/rules.go b/internal/reviewer/rules.go new file mode 100644 index 0000000..051c73b --- /dev/null +++ b/internal/reviewer/rules.go @@ -0,0 +1,43 @@ +package reviewer + +import ( + "os" + "path/filepath" + "strings" +) + +// RuleFileCandidates 支持的团队规则文件名优先级列表 +var RuleFileCandidates = []string{ + ".airules", + ".git-air-rules", + ".git-air.yaml", + ".git-air.md", +} + +// LoadCustomRules 读取项目根目录下的团队专属规则 +func LoadCustomRules(repoRoot string) string { + if repoRoot == "" { + return "" + } + rulePath := FindRuleFile(repoRoot) + if rulePath == "" { + return "" + } + data, err := os.ReadFile(rulePath) + if err != nil || len(data) == 0 { + return "" + } + return strings.TrimSpace(string(data)) +} + +// FindRuleFile 探测当前仓库根目录下是否存在匹配的规则文件,返回其绝对路径 +func FindRuleFile(repoRoot string) string { + for _, filename := range RuleFileCandidates { + fullPath := filepath.Join(repoRoot, filename) + if info, err := os.Stat(fullPath); err == nil && !info.IsDir() { + return fullPath + } + + } + return "" +} diff --git a/internal/ui/render.go b/internal/ui/render.go new file mode 100644 index 0000000..a1c5aa3 --- /dev/null +++ b/internal/ui/render.go @@ -0,0 +1,66 @@ +package ui + +import ( + "fmt" + "os" + "strings" +) + +// ANSI 终端颜色常量 +const ( + colorReset = "\033[0m" + colorRed = "\033[1;31m" + colorGreen = "\033[1;32m" + colorYellow = "\033[1;33m" + colorBlue = "\033[1;34m" + colorCyan = "\033[1;36m" + colorGray = "\033[0;90m" + colorBold = "\033[1m" +) + +// PrintBanner 打印开场横幅与当前评审状态 +func PrintBanner(provider, model string) { + fmt.Printf("%s[git-air]%s 代码评审中... %s(Engine: %s / %s)%s\n", colorCyan, colorReset, colorGray, provider, model, colorReset) + fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset) +} + +// PrintFooter 打印结束分界线 +func PrintFooter() { + fmt.Println() + fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset) +} + +// PrintError 打印错误信息 +func PrintError(format string, a ...interface{}) { + msg := fmt.Sprintf(format, a...) + fmt.Fprintf(os.Stderr, "%s[ERROR]%s %s\n", colorRed, colorReset, msg) +} + +// PrintSuccess 打印成功信息 +func PrintSuccess(format string, a ...interface{}) { + msg := fmt.Sprintf(format, a...) + fmt.Printf("%s[SUCCESS]%s %s\n", colorGreen, colorReset, msg) +} + +// StreamPrinter 带有标签高亮的流式输出打印器 +type StreamPrinter struct{} + +func NewStreamPrinter() *StreamPrinter { + return &StreamPrinter{} +} + +// PrintChunk 逐字流式输出,遇到关键标签进行颜色增强 +func (p *StreamPrinter) PrintChunk(chunk string) { + // 对关键标签进行颜色替换 + highlighted := chunk + highlighted = strings.ReplaceAll(highlighted, "[BLOCKER]", colorRed+"[BLOCKER]"+colorReset) + highlighted = strings.ReplaceAll(highlighted, "[CRITICAL]", colorRed+"[CRITICAL]"+colorReset) + highlighted = strings.ReplaceAll(highlighted, "[WARNING]", colorYellow+"[WARNING]"+colorReset) + highlighted = strings.ReplaceAll(highlighted, "[WARN]", colorYellow+"[WARN]"+colorReset) + highlighted = strings.ReplaceAll(highlighted, "[SUGGESTION]", colorBlue+"[SUGGESTION]"+colorReset) + highlighted = strings.ReplaceAll(highlighted, "[PASS]", colorGreen+"[PASS]"+colorReset) + highlighted = strings.ReplaceAll(highlighted, "[LGTM]", colorGreen+"[LGTM]"+colorReset) + highlighted = strings.ReplaceAll(highlighted, "[REJECT]", colorRed+"[REJECT]"+colorReset) + + fmt.Print(highlighted) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c3ffaa9 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "git-air/cmd" + +func main() { + cmd.Execute() +}