feat: 初始化 git-air AI 代码评审 CLI 工具

新增多模型 LLM 客户端、Git diff 提取与过滤、配置管理及 pre-commit 钩子支持。
This commit is contained in:
unclesam committed 2026-08-17 16:52:39 +08:00
commit 55f40e015d
18 files changed
+1315

No files matched your search

+101
View File
@@ -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
}
+97
View File
@@ -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, "")
}
+298
View File
@@ -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
}
+51
View File
@@ -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()
}
+40
View File
@@ -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)
}
+43
View File
@@ -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 ""
}
+66
View File
@@ -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)
}