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