feat: 初始化 git-air AI 代码评审 CLI 工具
新增多模型 LLM 客户端、Git diff 提取与过滤、配置管理及 pre-commit 钩子支持。
This commit is contained in:
commit
55f40e015d
18 files changed
+1315
No files matched your search
@@ -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