feat: 初始化 git-air AI 代码评审 CLI 工具
新增多模型 LLM 客户端、Git diff 提取与过滤、配置管理及 pre-commit 钩子支持。
This commit is contained in:
commit
5a7af7259c
18 files changed
+1315
No files matched your search
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# Binaries
|
||||||
|
git-air
|
||||||
|
git-air.exe
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Local sensitive configs
|
||||||
|
.git-air.yaml
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="assets/logo.png" width="180" height="180" alt="git-air logo" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">git-air 🍃</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>纯 Go 编写、像空气一样轻盈极速的 Git 原生 AI 代码评审工具。</strong><br>
|
||||||
|
<em>A lightweight, lightning-fast, Git-native AI code reviewer written in pure Go.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.26%2B-00ADD8?style=flat&logo=go" alt="Go Version" /></a>
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
|
||||||
|
<a href="https://github.com/unclesam-ly/git-air/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 设计初衷与定位
|
||||||
|
|
||||||
|
市面上有许多优秀的云端与 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!
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1004 KiB |
+175
@@ -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)
|
||||||
|
}
|
||||||
+60
@@ -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)
|
||||||
|
}
|
||||||
+101
@@ -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")
|
||||||
|
}
|
||||||
+33
@@ -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: ""
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, "")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user