From 5cfdc92556ea5f6ba928b7a7881541f1aa61499a Mon Sep 17 00:00:00 2001 From: Hua Date: Tue, 18 Feb 2025 16:53:34 +0800 Subject: [PATCH] init --- .gitignore | 2 + config.yaml | 57 +++ config.yaml.bak | 56 +++ config/config.go | 338 +++++++++++++++++ go.mod | 55 +++ go.sum | 166 +++++++++ handlers/config.go | 155 ++++++++ handlers/webhook.go | 154 ++++++++ main.go | 66 ++++ services/ai/ai.go | 84 +++++ services/ai/client.go | 184 ++++++++++ services/interfaces.go | 96 +++++ services/platforms/client.go | 174 +++++++++ services/platforms/gitea.go | 189 ++++++++++ services/platforms/gitee.go | 141 ++++++++ services/platforms/gitlab.go | 147 ++++++++ services/platforms/gogs.go | 245 +++++++++++++ services/types/review.go | 24 ++ services/types/webhook.go | 37 ++ static/index.html | 680 +++++++++++++++++++++++++++++++++++ utils/utils.go | 89 +++++ 21 files changed, 3139 insertions(+) create mode 100644 config.yaml create mode 100644 config.yaml.bak create mode 100644 config/config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers/config.go create mode 100644 handlers/webhook.go create mode 100644 main.go create mode 100644 services/ai/ai.go create mode 100644 services/ai/client.go create mode 100644 services/interfaces.go create mode 100644 services/platforms/client.go create mode 100644 services/platforms/gitea.go create mode 100644 services/platforms/gitee.go create mode 100644 services/platforms/gitlab.go create mode 100644 services/platforms/gogs.go create mode 100644 services/types/review.go create mode 100644 services/types/webhook.go create mode 100644 static/index.html create mode 100644 utils/utils.go diff --git a/.gitignore b/.gitignore index 5b90e79..6c0433c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ go.work.sum # env file .env +.idea + diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..52f767b --- /dev/null +++ b/config.yaml @@ -0,0 +1,57 @@ +port: 53321 +admin_token: "token" # 管理页面访问令牌 + +auto_disable: + enabled: true + max_failures: 3 + reset_after: 30 # 分钟 + +ais: + - name: "ollama" + api_key: "" + type: "ollama" +# url: "http://192.168.0.106:11434" + url: "http://localhost:11434" + model: "deepseek-r1:1.5b" + temperature: 0 + stream: false + priority: 0 # 添加优先级配置 + system_msg: | + 你是一个代码审查员,你的职责是识别提交代码中的错误、性能问题和需要优化的地方。 + 你还负责提供建设性的反馈,并建议最佳实践来提高代码的整体质量。 + + 在审查代码时: + - 审查代码变更(差异)并提供反馈 + - 仔细检查是否真的存在错误或需要优化的空间,突出显示它们 + - 不要突出显示小问题和细节 + - 如果有多个评论,请使用要点符号 + - 你不需要解释代码的功能 + - 请使用中文给出反馈 + - 如果你认为不需要优化或修改,请只回复 666 + weight: 1 + enabled: true + auto_disable: true # 使用自己的自动禁用配置 + max_failures: 5 # 覆盖全局配置 + reset_after: 60 # 覆盖全局配置 + +git: + # gitee: + # token: your-gitee-token + # webhook_secret: your-webhook-secret + # api_base: https://gitee.com/api/v5 + # + - name: gitlab1 + type: gitlab + token: glpat-XxSURo3JGaxmbkbf-EU6 + webhook_secret: code + api_base: http://0.0.0.0:30080 + signature_header: X-Gitlab-Token + event_header: X-Gitlab-Event + + - name: gogs + type: gogs + token: 44315b70cb4f23ce1e35b24db666c253c09b4ca2 + webhook_secret: code + api_base: http://0.0.0.0:13000 + signature_header: X-Gogs-Signature + event_header: X-Gogs-Event \ No newline at end of file diff --git a/config.yaml.bak b/config.yaml.bak new file mode 100644 index 0000000..d3e6410 --- /dev/null +++ b/config.yaml.bak @@ -0,0 +1,56 @@ +port: 53321 +admin_token: "token" # 管理页面访问令牌 + +auto_disable: + enabled: true + max_failures: 3 + reset_after: 30 # 分钟 + +ais: + - name: "ollama" + api_key: "" + type: "ollama" + url: "http://localhost:11434" + model: "deepseek-coder:1.3b" + temperature: 0 + stream: false + priority: 0 # 添加优先级配置 + system_msg: | + 你是一个代码审查员,你的职责是识别提交代码中的错误、性能问题和需要优化的地方。 + 你还负责提供建设性的反馈,并建议最佳实践来提高代码的整体质量。 + + 在审查代码时: + - 审查代码变更(差异)并提供反馈 + - 仔细检查是否真的存在错误或需要优化的空间,突出显示它们 + - 不要突出显示小问题和细节 + - 如果有多个评论,请使用要点符号 + - 你不需要解释代码的功能 + - 请使用中文给出反馈 + - 如果你认为不需要优化或修改,请只回复 666 + weight: 1 + enabled: true + auto_disable: true # 使用自己的自动禁用配置 + max_failures: 5 # 覆盖全局配置 + reset_after: 60 # 覆盖全局配置 + +git: + # gitee: + # token: your-gitee-token + # webhook_secret: your-webhook-secret + # api_base: https://gitee.com/api/v5 + # + - name: gitlab + type: gitlab + token: glpat-XxSURo3JGaxmbkbf-EU6 + webhook_secret: code + api_base: http://0.0.0.0:30080 + signature_header: X-Gitlab-Token + event_header: X-Gitlab-Event + + - name: gogs + type: gogs + token: 44315b70cb4f23ce1e35b24db666c253c09b4ca2 + webhook_secret: code + api_base: http://0.0.0.0:13000 + signature_header: X-Gogs-Signature + event_header: X-Gogs-Event \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..15bf20b --- /dev/null +++ b/config/config.go @@ -0,0 +1,338 @@ +package config + +import ( + "fmt" + "log" + "math/rand" + "os" + "sort" + "sync" + + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +var ( + cfg *Config + once sync.Once + mu sync.RWMutex + + aiBalancer *AIBalancer +) + +type Config struct { + Port int `mapstructure:"port"` + AdminToken string `mapstructure:"admin_token"` // 管理令牌 + AIs []AIConfig `mapstructure:"ais"` + Git []GitConfig `mapstructure:"git"` + AutoDisableConfig `mapstructure:"auto_disable"` // 全局自动禁用配置 +} + +// AutoDisableConfig 自动禁用配置 +type AutoDisableConfig struct { + Enabled bool `mapstructure:"enabled"` // 是否启用自动禁用 + MaxFailures int `mapstructure:"max_failures"` // 最大失败次数 + ResetAfter int `mapstructure:"reset_after"` // 重置时间(分钟) +} + +type AIConfig struct { + Name string `mapstructure:"name"` + Type string `mapstructure:"type"` // "ollama" 或 "openai" + APIKey string `mapstructure:"api_key"` + APIBase string `mapstructure:"url"` + Model string `mapstructure:"model"` + SystemMsg string `mapstructure:"system_msg"` // 系统提示词 + Temperature float64 `mapstructure:"temperature"` // 温度 + Stream bool `mapstructure:"stream"` // 是否使用流式响应 + Weight int `mapstructure:"weight"` + Priority int `mapstructure:"priority"` // 优先级,数字越大优先级越高 + Enabled bool `mapstructure:"enabled"` // 是否启用 + AutoDisable bool `mapstructure:"auto_disable"` // 是否启用自动禁用(覆盖全局配置) + MaxFailures *int `mapstructure:"max_failures"` // 最大失败次数(覆盖全局配置) + ResetAfter *int `mapstructure:"reset_after"` // 重置时间(覆盖全局配置) +} + +type GitConfig struct { + Name string `mapstructure:"name"` // 平台名称 + Type string `mapstructure:"type"` // 平台类型 + Token string `mapstructure:"token"` // 访问令牌 + Secret string `mapstructure:"webhook_secret"` // webhook 密钥 + APIBase string `mapstructure:"api_base"` // API 基础 URL + SignatureHeader string `mapstructure:"signature_header"` // webhook 签名的 header 名称 + EventHeader string `mapstructure:"event_header"` // webhook 事件类型的 header 名称 +} + +// 添加一个新的结构体用于负载均衡 +type AIBalancer struct { + ais []AIConfig + current int + mu sync.Mutex +} + +func init() { + // 设置默认值 + viper.SetDefault("port", 53321) + viper.SetDefault("admin_token", "token") + viper.SetDefault("ais", []interface{}{}) + viper.SetDefault("git", []interface{}{}) + viper.SetDefault("auto_disable", map[string]interface{}{ + "enabled": true, + "max_failures": 3, + "reset_after": 30, + }) +} + +func Load() (*Config, error) { + var err error + once.Do(func() { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + + if err = viper.ReadInConfig(); err != nil { + return + } + + cfg = &Config{} + if err = viper.Unmarshal(cfg); err != nil { + return + } + + // 添加日志输出以便调试 + log.Printf("已加载配置: %+v", cfg) + log.Printf("使用的配置文件: %s", viper.ConfigFileUsed()) + }) + + if err != nil { + return nil, fmt.Errorf("加载配置失败: %w", err) + } + + // 确保配置不为空 + if cfg == nil { + return nil, fmt.Errorf("配置加载失败: 配置为空") + } + + return cfg, nil +} + +func GetConfig() *Config { + if cfg == nil { + panic("配置未初始化") + } + return cfg +} + +// Save 保存配置到文件 +func Save(newConfig *Config) error { + mu.Lock() + defer mu.Unlock() + + // 创建备份 + backupFile := "config.yaml.bak" + if _, err := os.Stat("config.yaml"); err == nil { + if err := os.Rename("config.yaml", backupFile); err != nil { + return fmt.Errorf("backup config failed: %w", err) + } + } + // 使用自定义的 YAML 编码器 + file, err := os.Create("config.yaml") + if err != nil { + return fmt.Errorf("创建配置文件失败: %w", err) + } + yamlEncoder := yaml.NewEncoder(file) + yamlEncoder.SetIndent(2) // 设置缩进 + + // 将配置转换为 map 并保存 + configMap := newConfig.ToMap() + if err := yamlEncoder.Encode(configMap); err != nil { + // 如果保存失败,恢复备份 + if _, err := os.Stat(backupFile); err == nil { + os.Rename(backupFile, "config.yaml") + } + return fmt.Errorf("save config failed: %w", err) + } + + // 更新内存中的配置 + cfg = newConfig + + return nil +} + +// ToMap 将配置转换为 map +func (c *Config) ToMap() map[string]interface{} { + ais := make([]map[string]interface{}, len(c.AIs)) + for i, ai := range c.AIs { + ais[i] = map[string]interface{}{ + "name": ai.Name, + "type": ai.Type, + "api_key": ai.APIKey, + "url": ai.APIBase, + "model": ai.Model, + "system_msg": ai.SystemMsg, + "temperature": ai.Temperature, + "stream": ai.Stream, + "weight": ai.Weight, + "priority": ai.Priority, + "enabled": ai.Enabled, + "auto_disable": ai.AutoDisable, + "max_failures": ai.MaxFailures, + "reset_after": ai.ResetAfter, + } + } + + // 修改 Git 平台配置的转换逻辑 + platforms := make([]map[string]interface{}, len(c.Git)) + for i, platform := range c.Git { + platforms[i] = map[string]interface{}{ + "name": platform.Name, + "type": platform.Type, + "token": platform.Token, + "webhook_secret": platform.Secret, + "api_base": platform.APIBase, + "signature_header": platform.SignatureHeader, + "event_header": platform.EventHeader, + } + } + + return map[string]interface{}{ + "port": c.Port, + "admin_token": c.AdminToken, + "ais": ais, + "git": platforms, + "auto_disable": map[string]interface{}{ + "enabled": c.AutoDisableConfig.Enabled, + "max_failures": c.AutoDisableConfig.MaxFailures, + "reset_after": c.AutoDisableConfig.ResetAfter, + }, + } +} + +// ToMapHtml 将配置转换为 map 页面展示 +func (c *Config) ToMapHtml() map[string]interface{} { + ais := make([]map[string]interface{}, len(c.AIs)) + for i, ai := range c.AIs { + ais[i] = map[string]interface{}{ + "name": ai.Name, + "type": ai.Type, + "api_key": ai.APIKey, + "url": ai.APIBase, + "model": ai.Model, + "system_msg": ai.SystemMsg, + "temperature": ai.Temperature, + "stream": ai.Stream, + "weight": ai.Weight, + "priority": ai.Priority, + "enabled": ai.Enabled, + "auto_disable": ai.AutoDisable, + "max_failures": ai.MaxFailures, + "reset_after": ai.ResetAfter, + } + } + + // 修改 Git 平台配置的转换逻辑 + platforms := make([]map[string]interface{}, len(c.Git)) + for i, platform := range c.Git { + platforms[i] = map[string]interface{}{ + "name": platform.Name, + "type": platform.Type, + "token": platform.Token, + "webhook_secret": platform.Secret, + "api_base": platform.APIBase, + "signature_header": platform.SignatureHeader, + "event_header": platform.EventHeader, + } + } + + return map[string]interface{}{ + "ais": ais, + "git": platforms, + "auto_disable": map[string]interface{}{ + "enabled": c.AutoDisableConfig.Enabled, + "max_failures": c.AutoDisableConfig.MaxFailures, + "reset_after": c.AutoDisableConfig.ResetAfter, + }, + } +} + +func LoadConfig(configFile string) error { + log.Printf("加载配置文件: file=%s", configFile) + + viper.SetConfigFile(configFile) + if err := viper.ReadInConfig(); err != nil { + log.Printf("读取配置文件失败: file=%s, error=%v", configFile, err) + return fmt.Errorf("读取配置文件失败: %w", err) + } + + mu.Lock() + defer mu.Unlock() + + if err := viper.Unmarshal(&cfg); err != nil { + log.Printf("解析配置文件失败: file=%s, error=%v", configFile, err) + return fmt.Errorf("解析配置文件失败: %w", err) + } + + log.Printf("配置文件加载成功: file=%s", configFile) + return nil +} + +// 添加新的方法 +func NewAIBalancer(ais []AIConfig) *AIBalancer { + // 过滤出已启用的 AI + enabledAIs := make([]AIConfig, 0) + for _, ai := range ais { + if ai.Enabled { + enabledAIs = append(enabledAIs, ai) + } + } + + // 按优先级排序(从高到低) + sort.Slice(enabledAIs, func(i, j int) bool { + return enabledAIs[i].Priority > enabledAIs[j].Priority + }) + + return &AIBalancer{ + ais: enabledAIs, + current: 0, + } +} + +// 获取下一个可用的 AI 配置 +func (b *AIBalancer) Next() (*AIConfig, error) { + b.mu.Lock() + defer b.mu.Unlock() + + if len(b.ais) == 0 { + return nil, fmt.Errorf("没有可用的 AI 配置") + } + + // 获取最高优先级 + highestPriority := b.ais[0].Priority + + // 收集具有最高优先级的 AI + highPriorityAIs := make([]AIConfig, 0) + totalWeight := 0 + for _, ai := range b.ais { + if ai.Priority == highestPriority { + highPriorityAIs = append(highPriorityAIs, ai) + totalWeight += ai.Weight + } + } + + // 在最高优先级的 AI 中按权重选择 + target := rand.Intn(totalWeight) + currentWeight := 0 + for i, ai := range highPriorityAIs { + currentWeight += ai.Weight + if currentWeight > target { + return &highPriorityAIs[i], nil + } + } + + // 如果没有选中任何一个(不应该发生),返回第一个最高优先级的 AI + return &highPriorityAIs[0], nil +} + +// 添加获取 AIBalancer 的方法 +func GetAIBalancer() *AIBalancer { + return aiBalancer +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d2389fc --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module code-review + +go 1.22.0 + +toolchain go1.23.6 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/spf13/viper v1.19.0 +) + +require ( + github.com/bytedance/sonic v1.12.8 // indirect + github.com/bytedance/sonic/loader v0.2.3 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.0.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.24.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.9 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/arch v0.14.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b9411c3 --- /dev/null +++ b/go.sum @@ -0,0 +1,166 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= +github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= +github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs= +github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0= +github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= +github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= +github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= +github.com/gin-gonic/gin v1.8.2/go.mod h1:qw5AYuDrzRTnhvusDsrov+fDIxp9Dleuu12h8nfB398= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/leodido/go-urn v1.3.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= +github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= +golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/handlers/config.go b/handlers/config.go new file mode 100644 index 0000000..879f232 --- /dev/null +++ b/handlers/config.go @@ -0,0 +1,155 @@ +package handlers + +import ( + "net/http" + + "code-review/config" + "code-review/utils" + + "log" + + "strings" + + "github.com/gin-gonic/gin" +) + +type ConfigHandler struct { + cfg *config.Config +} + +func NewConfigHandler(cfg *config.Config) *ConfigHandler { + return &ConfigHandler{ + cfg: cfg, + } +} + +// GetConfig 获取当前配置 +func (h *ConfigHandler) GetConfig(c *gin.Context) { + // 使用 ToMap 方法转换配置 + configMap := h.cfg.ToMapHtml() + c.JSON(http.StatusOK, configMap) +} + +// UpdateConfig 更新配置 +func (h *ConfigHandler) UpdateConfig(c *gin.Context) { + // 首先解析为 map + var configMap map[string]interface{} + if err := c.ShouldBindJSON(&configMap); err != nil { + log.Printf("解析配置 JSON 失败: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + log.Printf("开始更新配置") + + // 使用当前配置作为基础 + newConfig := *h.cfg + + // 处理自动禁用配置 + if autoDisable, ok := configMap["auto_disable"].(map[string]interface{}); ok { + log.Printf("更新自动禁用配置") + if enabled, exists := autoDisable["enabled"].(bool); exists { + newConfig.AutoDisableConfig.Enabled = enabled + log.Printf("自动禁用功能状态设置为: %v", enabled) + } + if maxFailures, ok := autoDisable["max_failures"].(float64); ok { + newConfig.AutoDisableConfig.MaxFailures = int(maxFailures) + } + if resetAfter, ok := autoDisable["reset_after"].(float64); ok { + newConfig.AutoDisableConfig.ResetAfter = int(resetAfter) + } + } + + // 处理 AI 配置 + newConfig.AIs = make([]config.AIConfig, 0) + for key, value := range configMap { + if strings.HasPrefix(key, "ais[") { + if aiMap, ok := value.(map[string]interface{}); ok { + aiConfig := config.AIConfig{ + Name: utils.GetString(aiMap, "name"), + Type: utils.GetString(aiMap, "type"), + APIKey: utils.GetString(aiMap, "api_key"), + APIBase: utils.GetString(aiMap, "url"), + Model: utils.GetString(aiMap, "model"), + SystemMsg: utils.GetString(aiMap, "system_msg"), + Temperature: utils.GetFloat64(aiMap, "temperature"), + Stream: utils.GetBool(aiMap, "stream"), + Weight: utils.GetInt(aiMap, "weight"), + Priority: utils.GetInt(aiMap, "priority"), + Enabled: utils.GetBool(aiMap, "enabled"), + AutoDisable: utils.GetBool(aiMap, "auto_disable"), + MaxFailures: utils.GetIntPtr(aiMap, "max_failures"), + ResetAfter: utils.GetIntPtr(aiMap, "reset_after"), + } + log.Printf("添加 AI 配置: %s, 类型: %s", aiConfig.Name, aiConfig.Type) + newConfig.AIs = append(newConfig.AIs, aiConfig) + } + } + } + + // 处理 Git 平台配置 + newConfig.Git = make([]config.GitConfig, 0) + for key, value := range configMap { + if strings.HasPrefix(key, "git[") { + if p, ok := value.(map[string]interface{}); ok { + platformType := utils.GetString(p, "type") + platformConfig := config.GitConfig{ + Name: utils.GetString(p, "name"), + Type: platformType, + Token: utils.GetString(p, "token"), + Secret: utils.GetString(p, "webhook_secret"), + APIBase: utils.GetString(p, "api_base"), + } + + // 根据平台类型设置对应的 header + switch platformType { + case "gitlab": + platformConfig.SignatureHeader = "X-Gitlab-Token" + platformConfig.EventHeader = "X-Gitlab-Event" + case "gogs": + platformConfig.SignatureHeader = "X-Gogs-Signature" + platformConfig.EventHeader = "X-Gogs-Event" + case "github": + platformConfig.SignatureHeader = "X-Hub-Signature" + platformConfig.EventHeader = "X-GitHub-Event" + case "gitee": + platformConfig.SignatureHeader = "X-Gitee-Token" + platformConfig.EventHeader = "X-Gitee-Event" + default: + // 如果前端传入了自定义的 header,则使用前端传入的值 + platformConfig.SignatureHeader = utils.GetString(p, "signature_header") + platformConfig.EventHeader = utils.GetString(p, "event_header") + } + + log.Printf("添加 Git 平台配置: %s, 类型: %s, SignatureHeader: %s, EventHeader: %s", + platformConfig.Name, platformConfig.Type, + platformConfig.SignatureHeader, platformConfig.EventHeader) + newConfig.Git = append(newConfig.Git, platformConfig) + } + } + } + + if err := config.Save(&newConfig); err != nil { + log.Printf("保存配置失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + log.Printf("配置更新成功") + h.cfg = &newConfig + c.JSON(http.StatusOK, gin.H{"message": "配置更新成功"}) +} + +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("X-Admin-Token") + cfg := config.GetConfig() + + if token == "" || token != cfg.AdminToken { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未授权访问"}) + return + } + + c.Next() + } +} diff --git a/handlers/webhook.go b/handlers/webhook.go new file mode 100644 index 0000000..753afca --- /dev/null +++ b/handlers/webhook.go @@ -0,0 +1,154 @@ +package handlers + +import ( + "bytes" + "code-review/config" + "code-review/services" + "code-review/services/platforms" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io/ioutil" + "log" + + "github.com/gin-gonic/gin" +) + +type WebhookHandler struct { + reviewService *services.ReviewService +} + +func NewWebhookHandler(reviewService *services.ReviewService) *WebhookHandler { + return &WebhookHandler{ + reviewService: reviewService, + } +} + +func (h *WebhookHandler) HandleWebhook(c *gin.Context) { + platform := c.Param("platform") + log.Printf("收到 webhook 请求: platform=%s", platform) + + // 验证 webhook 签名 + if !h.verifySignature(c, platform) { + log.Printf("webhook 签名验证失败: platform=%s", platform) + c.JSON(400, gin.H{"error": "无效的签名"}) + return + } + + // 解析请求体 + event, err := h.parseWebhookEvent(c, platform) + if err != nil { + log.Printf("解析 webhook 事件失败: platform=%s, error=%v", platform, err) + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + // 启动代码审查 + if err := h.reviewService.Review(event); err != nil { + log.Printf("代码审查失败: platform=%s, error=%v", platform, err) + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + log.Printf("代码审查完成: platform=%s", platform) + c.JSON(200, gin.H{"message": "success"}) +} + +func (h *WebhookHandler) verifySignature(c *gin.Context, platform string) bool { + body, err := c.GetRawData() + if err != nil { + return false + } + c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + + cfg := config.GetConfig() + // 查找对应的平台配置 + var gitConfig config.GitConfig + found := false + for _, p := range cfg.Git { + if p.Name == platform { + gitConfig = p + found = true + break + } + } + if !found { + return false + } + + // 获取签名 + signature := c.GetHeader(gitConfig.SignatureHeader) + if signature == "" { + return false + } + + mac := hmac.New(sha256.New, []byte(gitConfig.Secret)) + mac.Write(body) + expectedMAC := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(signature), []byte(expectedMAC)) || hmac.Equal([]byte(signature), []byte(gitConfig.Secret)) +} + +func (h *WebhookHandler) parseWebhookEvent(c *gin.Context, platform string) (services.WebhookEvent, error) { + cfg := config.GetConfig() + // 查找对应的平台配置 + var platformConfig config.GitConfig + found := false + for _, p := range cfg.Git { + if p.Name == platform { + platformConfig = p + found = true + break + } + } + if !found { + return nil, fmt.Errorf("不支持的平台: %s", platform) + } + + // 获取事件类型 + eventType := c.GetHeader(platformConfig.EventHeader) + if eventType == "" { + return nil, fmt.Errorf("未找到事件类型 header: %s", platformConfig.EventHeader) + } + + var event services.WebhookEvent + switch platformConfig.Type { + case "gogs": + event = platforms.NewGogsEvent( + platformConfig.APIBase, + platformConfig.Token, + eventType, + ) + case "gitea": + event = platforms.NewGiteaEvent( + platformConfig.APIBase, + platformConfig.Token, + eventType, + ) + //case "gitee": + // event = &platforms.GiteeEvent{ + // ApiBase: platformConfig.APIBase, + // Token: platformConfig.Token, + // } + case "gitlab": + event = platforms.NewGitlabEvent( + platformConfig.APIBase, + platformConfig.Token, + eventType, + ) + default: + return nil, fmt.Errorf("不支持的平台: %s", platform) + } + + // 只处理 push 和 pull_request 事件 + //if eventType != "push" && eventType != "pull_request" { + // return nil, fmt.Errorf("不支持的事件类型: %s", eventType) + //} + + if err := c.ShouldBindJSON(event); err != nil { + return nil, err + } + + return event, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6998649 --- /dev/null +++ b/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "code-review/config" + "code-review/handlers" + "code-review/services" + "code-review/services/ai" + "fmt" + "github.com/gin-gonic/gin" + "log" +) + +func main() { + if err := config.LoadConfig("config.yaml"); err != nil { + log.Fatalf("加载配置失败: %v", err) + } + cfg := config.GetConfig() + + // 检查是否有可用的 AI 配置 + if len(cfg.AIs) == 0 { + log.Fatalf("未找到有效的 AI 配置") + } + + // 初始化 AI 负载均衡器 + aiBalancer := config.NewAIBalancer(cfg.AIs) + + // 获取一个 AI 配置 + aiConfig, err := aiBalancer.Next() + if err != nil { + log.Fatalf("获取 AI 配置失败: %v", err) + } + + // 创建 AI 客户端和审查器 + client := ai.NewClient( + aiConfig.APIBase, + aiConfig.APIKey, + aiConfig.Model, + aiConfig.Type, + aiConfig.Temperature, + ) + reviewer := ai.NewAI(aiConfig.Model, aiConfig.SystemMsg, client) + + // 创建服务和处理器 + reviewService := services.NewReviewService(reviewer) + webhookHandler := handlers.NewWebhookHandler(reviewService) + configHandler := handlers.NewConfigHandler(cfg) + + // 初始化路由 + r := gin.Default() + r.Static("/static", "./static") + r.StaticFile("/", "./static/index.html") + + api := r.Group("/api") + configAPI := api.Group("/config") + configAPI.Use(handlers.AuthRequired()) + { + configAPI.GET("", configHandler.GetConfig) + configAPI.POST("", configHandler.UpdateConfig) + } + api.POST("/webhook/:platform", webhookHandler.HandleWebhook) + + log.Printf("服务启动在 :%d", cfg.Port) + if err := r.Run(fmt.Sprintf(":%d", cfg.Port)); err != nil { + log.Fatalf("服务器启动失败: %v", err) + } +} diff --git a/services/ai/ai.go b/services/ai/ai.go new file mode 100644 index 0000000..09ff055 --- /dev/null +++ b/services/ai/ai.go @@ -0,0 +1,84 @@ +package ai + +import ( + "code-review/services" + "code-review/services/types" + "fmt" + "log" + "strings" +) + +// AI 实现了 CodeReviewer 和 AIPool 接口 +type AI struct { + model string + systemMsg string + client AIClient +} + +// NewAI 创建新的 AI 实例 +func NewAI(model, systemMsg string, client AIClient) *AI { + return &AI{ + model: model, + systemMsg: systemMsg, + client: client, + } +} + +// GetAI 实现 AIPool 接口 +func (a *AI) GetAI() services.CodeReviewer { + return a +} + +// Review 实现 CodeReviewer 接口 +func (a *AI) Review(changes *types.CodeChanges) (*types.ReviewResult, error) { + log.Printf("AI 开始审查代码: model=%s", a.model) + + // 构建审查请求 + prompt := a.buildPrompt(changes) + + // 调用 AI API + response, err := a.client.Chat(a.systemMsg, prompt) + if err != nil { + log.Printf("AI API 调用失败: model=%s, error=%v", a.model, err) + return nil, fmt.Errorf("AI API 调用失败: %w", err) + } + + // 解析审查结果 + result, err := a.parseResponse(response) + if err != nil { + log.Printf("解析 AI 响应失败: model=%s, error=%v", a.model, err) + return nil, fmt.Errorf("解析 AI 响应失败: %w", err) + } + + log.Printf("AI 代码审查完成: model=%s", a.model) + return result, nil +} + +// buildPrompt 构建代码审查的提示词 +func (a *AI) buildPrompt(changes *types.CodeChanges) string { + var prompt strings.Builder + + prompt.WriteString("请审查以下代码变更:\n\n") + + for _, file := range changes.Files { + prompt.WriteString(fmt.Sprintf("文件: %s\n", file.Path)) + prompt.WriteString("diff:\n") + prompt.WriteString(file.Content) + prompt.WriteString("\n---\n\n") + } + + return prompt.String() +} + +// parseResponse 解析 AI 的响应 +func (a *AI) parseResponse(response string) (*types.ReviewResult, error) { + return &types.ReviewResult{ + Comments: []types.Comment{ + { + Path: "全局", + Content: response, + }, + }, + Summary: "代码审查完成", + }, nil +} diff --git a/services/ai/client.go b/services/ai/client.go new file mode 100644 index 0000000..cb30c10 --- /dev/null +++ b/services/ai/client.go @@ -0,0 +1,184 @@ +package ai + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "regexp" + "strings" +) + +// AIClient 定义了与 AI 服务交互的接口 +type AIClient interface { + Chat(systemMsg, prompt string) (string, error) +} + +// Client 实现了 AIClient 接口 +type Client struct { + apiBase string + apiKey string + model string + aiType string + stream bool + temperature float64 + client *http.Client +} + +// NewClient 创建新的 AI 客户端 +func NewClient(apiBase, apiKey, model, aiType string, temperature float64) *Client { + return &Client{ + apiBase: apiBase, + apiKey: apiKey, + model: model, + aiType: aiType, + temperature: temperature, + client: &http.Client{}, + } +} + +// Chat 发送聊天请求到 AI 服务 +func (c *Client) Chat(systemMsg, prompt string) (string, error) { + // 根据配置的类型判断使用哪个服务 + var response string + var err error + + if c.aiType == "ollama" { + response, err = c.ollamaChat(systemMsg, prompt) + } else { + response, err = c.openAIChat(systemMsg, prompt) + } + + if err != nil { + log.Printf("AI 聊天请求失败: error=%v", err) + return "", fmt.Errorf("AI 聊天请求失败: %w", err) + } + + return response, nil +} + +// ollamaChat 发送请求到 Ollama API +func (c *Client) ollamaChat(systemMsg, prompt string) (string, error) { + // 组合系统提示词和用户提示词 + fullPrompt := fmt.Sprintf("%s\n\n%s", systemMsg, prompt) + + reqBody := map[string]interface{}{ + "model": c.model, + "prompt": fullPrompt, + "stream": c.stream, + "temperature": c.temperature, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("序列化请求失败: %w", err) + } + + req, err := http.NewRequest("POST", c.apiBase+"/api/generate", bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("创建请求失败: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return "", fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("API 请求失败: status=%d, body=%s", resp.StatusCode, string(body)) + } + + var result struct { + Response string `json:"response"` + Done bool `json:"done"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + body, _ := io.ReadAll(resp.Body) + log.Printf("响应内容: %s", string(body)) + return "", fmt.Errorf("解析响应失败: %w", err) + } + + if !result.Done { + return "", fmt.Errorf("AI 响应未完成") + } + + pattern := "(?s)(.*?)" + + reg := regexp.MustCompile(pattern) + matches := reg.ReplaceAllString(result.Response, "") + + return strings.TrimSpace(matches), nil +} + +// openAIChat 发送请求到 OpenAI API +func (c *Client) openAIChat(systemMsg, prompt string) (string, error) { + reqBody := map[string]interface{}{ + "model": c.model, + "messages": []map[string]string{ + { + "role": "system", + "content": systemMsg, + }, + { + "role": "user", + "content": prompt, + }, + }, + "stream": false, + "temperature": c.temperature, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("序列化请求失败: %w", err) + } + + req, err := http.NewRequest("POST", c.apiBase+"/v1/chat/completions", bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("创建请求失败: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.client.Do(req) + if err != nil { + return "", fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("API 请求失败: status=%d, body=%s", resp.StatusCode, string(body)) + } + + var result struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("解析响应失败: %w", err) + } + + if len(result.Choices) == 0 { + return "", fmt.Errorf("AI 响应为空") + } + + pattern := "(?s)(.*?)" + + reg := regexp.MustCompile(pattern) + matches := reg.ReplaceAllString(result.Choices[0].Message.Content, "") + + return strings.TrimSpace(matches), nil +} diff --git a/services/interfaces.go b/services/interfaces.go new file mode 100644 index 0000000..5b566b6 --- /dev/null +++ b/services/interfaces.go @@ -0,0 +1,96 @@ +package services + +import ( + "code-review/services/types" + "fmt" + "log" +) + +// CodeReviewer 定义代码审查的接口 +type CodeReviewer interface { + Review(changes *types.CodeChanges) (*types.ReviewResult, error) +} + +// WebhookEvent 定义 webhook 事件的接口 +type WebhookEvent interface { + ExtractChanges() (*types.CodeChanges, error) + PostComments(result *types.ReviewResult) error + GetPlatform() string +} + +// AIPool 定义 AI 池接口 +type AIPool interface { + GetAI() CodeReviewer +} + +// ReviewService 代码审查服务 +type ReviewService struct { + aiPool AIPool +} + +// NewReviewService 创建代码审查服务 +func NewReviewService(aiPool AIPool) *ReviewService { + return &ReviewService{ + aiPool: aiPool, + } +} + +// GetCodeChanges 从 webhook 事件中提取代码变更 +func (s *ReviewService) GetCodeChanges(event WebhookEvent) (*types.CodeChanges, error) { + return event.ExtractChanges() +} + +// ReviewCode 使用配置的审查器进行代码审查 +func (s *ReviewService) ReviewCode(changes *types.CodeChanges) (*types.ReviewResult, error) { + if s.aiPool == nil { + return nil, fmt.Errorf("未配置 AI 池") + } + ai := s.aiPool.GetAI() + if ai == nil { + log.Printf("没有可用的 AI") + return nil, fmt.Errorf("没有可用的 AI") + } + return ai.Review(changes) +} + +// PostReviewComments 将审查结果发送到代码托管平台 +func (s *ReviewService) PostReviewComments(event WebhookEvent, result *types.ReviewResult) error { + return event.PostComments(result) +} + +// Review 执行代码审查流程 +func (s *ReviewService) Review(event WebhookEvent) error { + log.Printf("开始代码审查: platform=%s", event.GetPlatform()) + + changes, err := event.ExtractChanges() + if err != nil { + log.Printf("提取代码变更失败: error=%v", err) + return fmt.Errorf("提取代码变更失败: %w", err) + } + + // 如果没有变更或者跳过审查,直接返回 + if changes == nil || len(changes.Files) == 0 { + log.Printf("没有需要审查的代码变更") + return nil + } + + ai := s.aiPool.GetAI() + if ai == nil { + log.Printf("没有可用的 AI") + return fmt.Errorf("没有可用的 AI") + } + + result, err := ai.Review(changes) + if err != nil { + log.Printf("AI 代码审查失败: error=%v", err) + return fmt.Errorf("AI 代码审查失败: %w", err) + } + + if err := event.PostComments(result); err != nil { + log.Printf("发送审查结果失败: error=%v", err) + return fmt.Errorf("发送审查结果失败: %w", err) + } + + log.Printf("代码审查完成") + return nil +} diff --git a/services/platforms/client.go b/services/platforms/client.go new file mode 100644 index 0000000..e1f738c --- /dev/null +++ b/services/platforms/client.go @@ -0,0 +1,174 @@ +package platforms + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" +) + +type httpClient struct { + url string + token string + client *http.Client +} + +func newHTTPClient(baseURL, token string) *httpClient { + return &httpClient{ + url: baseURL, + token: token, + client: &http.Client{}, + } +} + +func (c *httpClient) get(path string, result interface{}) error { + url := fmt.Sprintf("%s%s", c.url, path) + log.Printf("发送 GET 请求: url=%s", url) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + log.Printf("创建 GET 请求失败: url=%s, error=%v", url, err) + return fmt.Errorf("创建请求失败: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + + resp, err := c.client.Do(req) + if err != nil { + log.Printf("发送 GET 请求失败: url=%s, error=%v", url, err) + return fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("GET 请求返回错误状态码: url=%s, status=%d, response=%s", url, resp.StatusCode, string(body)) + return fmt.Errorf("请求失败,状态码: %d,响应: %s", resp.StatusCode, string(body)) + } + + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + log.Printf("解析 GET 响应失败: url=%s, error=%v", url, err) + return fmt.Errorf("解析响应失败: %w", err) + } + + log.Printf("GET 请求成功: url=%s", url) + return nil +} + +func (c *httpClient) post(path string, data interface{}) error { + url := fmt.Sprintf("%s%s", c.url, path) + log.Printf("发送 POST 请求: url=%s", url) + + jsonData, err := json.Marshal(data) + if err != nil { + log.Printf("序列化 POST 数据失败: url=%s, error=%v", url, err) + return fmt.Errorf("序列化请求数据失败: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + log.Printf("创建 POST 请求失败: url=%s, error=%v", url, err) + return fmt.Errorf("创建请求失败: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + + resp, err := c.client.Do(req) + if err != nil { + log.Printf("发送 POST 请求失败: url=%s, error=%v", url, err) + return fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + log.Printf("POST 请求返回错误状态码: url=%s, status=%d, response=%s", url, resp.StatusCode, string(body)) + return fmt.Errorf("请求失败,状态码: %d,响应: %s", resp.StatusCode, string(body)) + } + + log.Printf("POST 请求成功: url=%s", url) + return nil +} + +func (c *httpClient) getWithHeaders(path string, result interface{}, headers map[string]string) error { + url := fmt.Sprintf("%s%s", c.url, path) + log.Printf("发送 GET 请求: url=%s", url) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + log.Printf("创建 GET 请求失败: url=%s, error=%v", url, err) + return fmt.Errorf("创建请求失败: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := c.client.Do(req) + if err != nil { + log.Printf("发送 GET 请求失败: url=%s, error=%v", url, err) + return fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("GET 请求返回错误状态码: url=%s, status=%d, response=%s", url, resp.StatusCode, string(body)) + return fmt.Errorf("请求失败,状态码: %d,响应: %s", resp.StatusCode, string(body)) + } + + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + log.Printf("解析 GET 响应失败: url=%s, error=%v", url, err) + return fmt.Errorf("解析响应失败: %w", err) + } + + log.Printf("GET 请求成功: url=%s", url) + return nil +} + +func (c *httpClient) postWithHeaders(path string, data interface{}, headers map[string]string) error { + url := fmt.Sprintf("%s%s", c.url, path) + log.Printf("发送 POST 请求: url=%s", url) + + jsonData, err := json.Marshal(data) + if err != nil { + log.Printf("序列化 POST 数据失败: url=%s, error=%v", url, err) + return fmt.Errorf("序列化请求数据失败: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + log.Printf("创建 POST 请求失败: url=%s, error=%v", url, err) + return fmt.Errorf("创建请求失败: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := c.client.Do(req) + if err != nil { + log.Printf("发送 POST 请求失败: url=%s, error=%v", url, err) + return fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + log.Printf("POST 请求返回错误状态码: url=%s, status=%d, response=%s", url, resp.StatusCode, string(body)) + return fmt.Errorf("请求失败,状态码: %d,响应: %s", resp.StatusCode, string(body)) + } + + log.Printf("POST 请求成功: url=%s", url) + return nil +} diff --git a/services/platforms/gitea.go b/services/platforms/gitea.go new file mode 100644 index 0000000..e4b4404 --- /dev/null +++ b/services/platforms/gitea.go @@ -0,0 +1,189 @@ +package platforms + +import ( + "code-review/services/types" + "code-review/utils" + "fmt" + "log" + "strings" +) + +type GiteaEvent struct { + apiBase string + token string + Event string + client *httpClient + + Ref string `json:"ref"` + Before string `json:"before"` + After string `json:"after"` + CompareURL string `json:"compare_url"` + Commits []struct { + ID string `json:"id"` + Message string `json:"message"` + URL string `json:"url"` + Author Author `json:"author"` + Committer Author `json:"committer"` + Timestamp string `json:"timestamp"` + } `json:"commits"` + Repository struct { + ID int `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + Private bool `json:"private"` + } `json:"repository"` +} + +// NewGiteaEvent 创建 Gitea 事件 +func NewGiteaEvent(apiBase, token string, event string) *GiteaEvent { + return &GiteaEvent{ + apiBase: apiBase, + token: token, + Event: event, + } +} + +func (e *GiteaEvent) ExtractChanges() (*types.CodeChanges, error) { + // 检查是否有提交记录 + if len(e.Commits) == 0 { + log.Printf("没有提交记录,跳过代码审查") + return nil, nil + } + + // 检查是否跳过代码审查 + for _, commit := range e.Commits { + if strings.Contains(commit.Message, "[skip codereview]") { + log.Printf("跳过代码审查: commit=%s", commit.ID) + return nil, nil + } + } + + if e.client == nil { + e.client = newHTTPClient(e.apiBase, e.token) + log.Printf("初始化 HTTP 客户端: url=%s", e.apiBase) + } + + changes := &types.CodeChanges{ + Repository: e.Repository.FullName, + Branch: e.Ref, + CommitID: e.After, + Files: make([]types.FileChange, 0), + } + + for _, commit := range e.Commits { + // 直接获取 diff 内容 + apiPath := fmt.Sprintf("/api/v1/repos/%s/git/commits/%s.diff?access_token=%s", e.Repository.FullName, commit.ID, e.token) + var diffContent string + + if err := e.client.get(apiPath, &diffContent); err != nil { + log.Printf("获取提交详情失败: commit=%s, error=%v", commit.ID, err) + continue + } + + // 去除首尾空白 + diffContent = strings.TrimSpace(diffContent) + if diffContent == "" { + log.Printf("提交没有变更内容: commit=%s", commit.ID) + continue + } + + // 解析 diff 内容 + diffBlocks := strings.Split(diffContent, "diff --git ") + // 去除空块 + for _, block := range diffBlocks { + if strings.TrimSpace(block) == "" { + continue + } + + // 移除 'diff --git ' 前缀 + block = strings.TrimPrefix(block, "diff --git ") + + // 解析文件名和变更类型 + lines := strings.Split(block, "\n") + if len(lines) < 2 { + continue + } + + // 获取文件名 + filename := "" + status := "modified" + for _, line := range lines { + if strings.HasPrefix(line, "--- a/") { + filename = strings.TrimPrefix(line, "--- a/") + break + } else if strings.HasPrefix(line, "+++ b/") { + filename = strings.TrimPrefix(line, "+++ b/") + break + } + } + + // 如果没有找到文件名,跳过 + if filename == "" { + continue + } + + // 确定变更类型 + if strings.Contains(block, "new file mode") { + status = "added" + } else if strings.Contains(block, "deleted file mode") { + status = "deleted" + } else if strings.Contains(block, "rename from") { + status = "renamed" + } + + var content strings.Builder + content.WriteString(fmt.Sprintf("### 变更说明\n")) + content.WriteString(fmt.Sprintf("提交信息: %s\n\n", commit.Message)) + + switch status { + case "added": + content.WriteString(fmt.Sprintf("新增文件: %s\n\n", filename)) + case "modified": + content.WriteString(fmt.Sprintf("修改文件: %s\n\n", filename)) + case "deleted": + content.WriteString(fmt.Sprintf("删除文件: %s\n\n", filename)) + case "renamed": + content.WriteString(fmt.Sprintf("重命名文件: %s\n\n", filename)) + } + + content.WriteString("### 变更内容\n") + content.WriteString("```diff\n") + content.WriteString(block) + content.WriteString("\n```\n") + + changes.Files = append(changes.Files, types.FileChange{ + Path: filename, + Content: content.String(), + Type: parseFileType(status), + }) + } + } + + return changes, nil +} + +func (e *GiteaEvent) PostComments(result *types.ReviewResult) error { + if e.client == nil { + e.client = newHTTPClient(e.apiBase, e.token) + } + + // 创建 issue + path := fmt.Sprintf("/api/v1/repos/%s/issues", e.Repository.FullName) + issueData := map[string]interface{}{ + "title": fmt.Sprintf("AI 代码审查 - %s", e.After[:7]), + "body": utils.FormatReviewResult(result), + } + + if err := e.client.post(path, issueData); err != nil { + log.Printf("创建 issue 失败: path=%s, error=%v", path, err) + return fmt.Errorf("创建 issue 失败: %w", err) + } + + log.Printf("成功创建 issue: path=%s, commitID=%s", path, e.After[:7]) + return nil +} + +func (e *GiteaEvent) GetPlatform() string { + return "gitea" +} diff --git a/services/platforms/gitee.go b/services/platforms/gitee.go new file mode 100644 index 0000000..aead45e --- /dev/null +++ b/services/platforms/gitee.go @@ -0,0 +1,141 @@ +package platforms + +import ( + "code-review/services/types" + "fmt" +) + +// GiteeEvent Gitee 平台的 webhook 事件 +type GiteeEvent struct { + client *httpClient + Action string `json:"action"` + ActionDesc string `json:"action_desc"` + Hook struct { + Password string `json:"password"` // webhook 密码 + } `json:"hook"` + Password string `json:"password"` // 兼容旧版本 + Project struct { + ID int `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + FullName string `json:"full_name"` + WebURL string `json:"web_url"` + Description string `json:"description"` + } `json:"project"` + PullRequest struct { + ID int `json:"id"` + Number int `json:"number"` + State string `json:"state"` + Title string `json:"title"` + Body string `json:"body"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Commits []struct { + ID string `json:"id"` + Message string `json:"message"` + } `json:"commits"` + Changes []struct { + Path string `json:"path"` + Content string `json:"content"` + Type string `json:"type"` // added, modified, deleted, renamed + OldPath string `json:"old_path,omitempty"` + } `json:"changes"` + } `json:"pull_request"` +} + +func NewGiteeEvent(baseURL, token string) *GiteeEvent { + return &GiteeEvent{ + client: newHTTPClient(baseURL, token), + } +} + +func (e *GiteeEvent) ExtractChanges() (*types.CodeChanges, error) { + changes := &types.CodeChanges{ + Repository: e.Project.FullName, + Branch: e.PullRequest.SourceBranch, + CommitID: e.PullRequest.Commits[len(e.PullRequest.Commits)-1].ID, + Files: make([]types.FileChange, 0, len(e.PullRequest.Changes)), + PullRequest: &types.PullRequest{ + ID: e.PullRequest.ID, + Title: e.PullRequest.Title, + Description: e.PullRequest.Body, + SourceBranch: e.PullRequest.SourceBranch, + TargetBranch: e.PullRequest.TargetBranch, + }, + } + + for _, change := range e.PullRequest.Changes { + fileChange := types.FileChange{ + Path: change.Path, + Content: change.Content, + OldPath: change.OldPath, + } + + switch change.Type { + case "added": + fileChange.Type = types.Added + case "modified": + fileChange.Type = types.Modified + case "deleted": + fileChange.Type = types.Deleted + case "renamed": + fileChange.Type = types.Renamed + } + + changes.Files = append(changes.Files, fileChange) + } + + return changes, nil +} + +func (e *GiteeEvent) PostComments(result *types.ReviewResult) error { + if e.client == nil { + return fmt.Errorf("client not initialized") + } + + for _, comment := range result.Comments { + body := map[string]interface{}{ + "access_token": e.client.token, + "body": fmt.Sprintf("**Code Review Comment**\n\nFile: %s\nLine: %d\nSeverity: %s\n\n%s", + comment.Path, + comment.Line, + comment.Severity, + comment.Content, + ), + "position_line": comment.Line, + "path": comment.Path, + } + + path := fmt.Sprintf("/v5/repos/%s/pulls/%d/comments", + e.Project.FullName, + e.PullRequest.Number, + ) + + if err := e.client.post(path, body); err != nil { + return fmt.Errorf("post comment failed: %w", err) + } + } + + // 发送总结评论 + if result.Summary != "" { + body := map[string]interface{}{ + "access_token": e.client.token, + "body": fmt.Sprintf("**Code Review Summary**\n\n%s", result.Summary), + } + + path := fmt.Sprintf("/v5/repos/%s/pulls/%d/comments", + e.Project.FullName, + e.PullRequest.Number, + ) + + if err := e.client.post(path, body); err != nil { + return fmt.Errorf("post summary failed: %w", err) + } + } + + return nil +} + +func (e *GiteeEvent) GetPlatform() string { + return "gitee" +} diff --git a/services/platforms/gitlab.go b/services/platforms/gitlab.go new file mode 100644 index 0000000..1d00a8f --- /dev/null +++ b/services/platforms/gitlab.go @@ -0,0 +1,147 @@ +package platforms + +import ( + "code-review/services/types" + "code-review/utils" + "fmt" + "log" + "strings" +) + +// GitlabEvent Gitlab 平台的 webhook 事件 +type GitlabEvent struct { + apiBase string + token string + Event string + client *httpClient + + ObjectKind string `json:"object_kind"` // "push", "merge_request" 等 + Before string `json:"before"` + After string `json:"after"` + Ref string `json:"ref"` + Project struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + WebURL string `json:"web_url"` + PathWithNamespace string `json:"path_with_namespace"` + } `json:"project"` + Commits []struct { + ID string `json:"id"` + Message string `json:"message"` + Title string `json:"title"` + } `json:"commits"` +} + +// NewGitlabEvent 创建 GitLab 事件实例 +func NewGitlabEvent(baseURL, token string, event string) *GitlabEvent { + return &GitlabEvent{ + apiBase: baseURL, + token: token, + Event: event, + } +} + +// 定义 GitLab diff 响应的结构 +type gitlabDiff struct { + Diff string `json:"diff"` + NewPath string `json:"new_path"` + OldPath string `json:"old_path"` + AMode string `json:"a_mode"` + BMode string `json:"b_mode"` + NewFile bool `json:"new_file"` + RenamedFile bool `json:"renamed_file"` + DeletedFile bool `json:"deleted_file"` +} + +func (e *GitlabEvent) ExtractChanges() (*types.CodeChanges, error) { + // 检查是否有提交记录 + if len(e.Commits) == 0 { + log.Printf("没有提交记录,跳过代码审查") + return nil, nil + } + + // 检查是否跳过代码审查 + for _, commit := range e.Commits { + if strings.Contains(commit.Message, "[skip codereview]") { + log.Printf("跳过代码审查: commit=%s", commit.ID) + return nil, nil + } + } + + if e.client == nil { + e.client = newHTTPClient(e.apiBase, e.token) + log.Printf("初始化 HTTP 客户端: url=%s", e.apiBase) + } + + changes := &types.CodeChanges{ + Repository: e.Project.PathWithNamespace, + Branch: e.Ref, + CommitID: e.After, + Files: make([]types.FileChange, 0), + } + + for _, commit := range e.Commits { + // 移除 URL 中的 token + apiPath := fmt.Sprintf("/api/v4/projects/%d/repository/commits/%s/diff", + e.Project.ID, commit.ID) + + headers := map[string]string{ + "PRIVATE-TOKEN": e.token, + } + + var diffs []gitlabDiff + if err := e.client.getWithHeaders(apiPath, &diffs, headers); err != nil { + log.Printf("获取提交详情失败: commit=%s, error=%v", commit.ID, err) + continue + } + + for _, diff := range diffs { + status := "modified" + if diff.NewFile { + status = "added" + } else if diff.DeletedFile { + status = "deleted" + } else if diff.RenamedFile { + status = "renamed" + } + + changes.Files = append(changes.Files, types.FileChange{ + Path: diff.NewPath, + Content: diff.Diff, + Type: parseFileType(status), + }) + } + } + + return changes, nil +} + +func (e *GitlabEvent) PostComments(result *types.ReviewResult) error { + if e.client == nil { + e.client = newHTTPClient(e.apiBase, e.token) + } + + // 创建 issue + path := fmt.Sprintf("/api/v4/projects/%d/issues", e.Project.ID) + issueData := map[string]interface{}{ + "title": fmt.Sprintf("AI 代码审查 - %s", e.After[:7]), + "description": utils.FormatReviewResult(result), + } + + headers := map[string]string{ + "PRIVATE-TOKEN": e.token, + } + + if err := e.client.postWithHeaders(path, issueData, headers); err != nil { + log.Printf("创建 issue 失败: path=%s, error=%v", path, err) + return fmt.Errorf("创建 issue 失败: %w", err) + } + + log.Printf("成功创建 issue: path=%s, commitID=%s", path, e.After[:7]) + return nil +} + +func (e *GitlabEvent) GetPlatform() string { + return "gitlab" +} diff --git a/services/platforms/gogs.go b/services/platforms/gogs.go new file mode 100644 index 0000000..2652c64 --- /dev/null +++ b/services/platforms/gogs.go @@ -0,0 +1,245 @@ +package platforms + +import ( + "code-review/services/types" + "fmt" + "log" + "strings" +) + +// GogsEvent Gogs 平台的 webhook 事件 +type GogsEvent struct { + apiBase string + token string + Event string // 事件类型 + client *httpClient + Ref string `json:"ref"` + Before string `json:"before"` + After string `json:"after"` + CompareURL string `json:"compare_url"` + Commits []struct { + ID string `json:"id"` + Message string `json:"message"` + URL string `json:"url"` + Author Author `json:"author"` + Committer Author `json:"committer"` + Timestamp string `json:"timestamp"` + } `json:"commits"` + CommitDetail struct { + Files []struct { + Filename string `json:"filename"` + Status string `json:"status"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Patch string `json:"patch"` + } `json:"files"` + } + Repository struct { + ID int `json:"id"` + Owner Author `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Private bool `json:"private"` + Fork bool `json:"fork"` + HTMLURL string `json:"html_url"` + SSHURL string `json:"ssh_url"` + CloneURL string `json:"clone_url"` + Website string `json:"website"` + StarsCount int `json:"stars_count"` + ForksCount int `json:"forks_count"` + WatchersCount int `json:"watchers_count"` + OpenIssuesCount int `json:"open_issues_count"` + DefaultBranch string `json:"default_branch"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } `json:"repository"` + Pusher Author `json:"pusher"` + Sender Author `json:"sender"` +} + +type Author struct { + ID int `json:"id"` + Login string `json:"login"` + FullName string `json:"full_name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + Username string `json:"username"` +} + +// NewGogsEvent 创建 Gogs 事件 +func NewGogsEvent(apiBase, token string, event string) *GogsEvent { + return &GogsEvent{ + apiBase: apiBase, + token: token, + Event: event, + } +} + +func (e *GogsEvent) ExtractChanges() (*types.CodeChanges, error) { + // 检查是否有提交记录 + if len(e.Commits) == 0 { + log.Printf("没有提交记录,跳过代码审查") + return nil, nil + } + + // 检查是否跳过代码审查 + for _, commit := range e.Commits { + if strings.Contains(commit.Message, "[skip codereview]") { + log.Printf("跳过代码审查: commit=%s", commit.ID) + return nil, nil + } + } + + if e.client == nil { + e.client = newHTTPClient(e.apiBase, e.token) + log.Printf("初始化 HTTP 客户端: url=%s", e.apiBase) + } + + changes := &types.CodeChanges{ + Repository: e.Repository.FullName, + Branch: e.Ref, + CommitID: e.After, + Files: make([]types.FileChange, 0), + } + + for _, commit := range e.Commits { + // 直接获取 diff 内容 + apiPath := fmt.Sprintf("/api/v1/repos/%s/git/commits/%s.diff?access_token=%s", e.Repository.FullName, commit.ID, e.token) + var diffContent string + + if err := e.client.get(apiPath, &diffContent); err != nil { + log.Printf("获取提交详情失败: commit=%s, error=%v", commit.ID, err) + continue + } + + // 去除首尾空白 + diffContent = strings.TrimSpace(diffContent) + if diffContent == "" { + log.Printf("提交没有变更内容: commit=%s", commit.ID) + continue + } + + // 解析 diff 内容 + diffBlocks := strings.Split(diffContent, "diff --git ") + // 去除空块 + for _, block := range diffBlocks { + if strings.TrimSpace(block) == "" { + continue + } + + // 移除 'diff --git ' 前缀 + block = strings.TrimPrefix(block, "diff --git ") + + // 解析文件名和变更类型 + lines := strings.Split(block, "\n") + if len(lines) < 2 { + continue + } + + // 获取文件名 + filename := "" + status := "modified" + for _, line := range lines { + if strings.HasPrefix(line, "--- a/") { + filename = strings.TrimPrefix(line, "--- a/") + break + } else if strings.HasPrefix(line, "+++ b/") { + filename = strings.TrimPrefix(line, "+++ b/") + break + } + } + + // 如果没有找到文件名,跳过 + if filename == "" { + continue + } + + // 确定变更类型 + if strings.Contains(block, "new file mode") { + status = "added" + } else if strings.Contains(block, "deleted file mode") { + status = "deleted" + } else if strings.Contains(block, "rename from") { + status = "renamed" + } + + var content strings.Builder + content.WriteString(fmt.Sprintf("### 变更说明\n")) + content.WriteString(fmt.Sprintf("提交信息: %s\n\n", commit.Message)) + + switch status { + case "added": + content.WriteString(fmt.Sprintf("新增文件: %s\n\n", filename)) + case "modified": + content.WriteString(fmt.Sprintf("修改文件: %s\n\n", filename)) + case "deleted": + content.WriteString(fmt.Sprintf("删除文件: %s\n\n", filename)) + case "renamed": + content.WriteString(fmt.Sprintf("重命名文件: %s\n\n", filename)) + } + + content.WriteString("### 变更内容\n") + content.WriteString("```diff\n") + content.WriteString(block) + content.WriteString("\n```\n") + + changes.Files = append(changes.Files, types.FileChange{ + Path: filename, + Content: content.String(), + Type: parseFileType(status), + }) + } + } + + return changes, nil +} + +func (e *GogsEvent) PostComments(result *types.ReviewResult) error { + if e.client == nil { + e.client = newHTTPClient(e.apiBase, e.token) + log.Printf("初始化 HTTP 客户端: url=%s", e.apiBase) + } + + // 创建 issue 评论 + issueBody := fmt.Sprintf("**代码审查报告**\n\n提交: %s\n\n", e.After) + for _, comment := range result.Comments { + issueBody += fmt.Sprintf("### 文件: %s\n\n%s\n\n", comment.Path, comment.Content) + } + if result.Summary != "" { + issueBody += fmt.Sprintf("\n### 总结\n\n%s", result.Summary) + } + + // 创建 issue + createIssuePath := fmt.Sprintf("/api/v1/repos/%s/issues", e.Repository.FullName) + issueData := map[string]interface{}{ + "title": fmt.Sprintf("代码审查: %s", e.After[:7]), + "body": issueBody, + } + + if err := e.client.post(createIssuePath, issueData); err != nil { + log.Printf("创建 issue 失败: path=%s, error=%v", createIssuePath, err) + return fmt.Errorf("创建 issue 失败: %w", err) + } + + log.Printf("成功创建 issue: path=%s, commitID=%s", createIssuePath, e.After[:7]) + + return nil +} + +func (e *GogsEvent) GetPlatform() string { + return "gogs" +} + +func parseFileType(t string) types.ChangeType { + switch t { + case "add": + return types.Added + case "modify": + return types.Modified + case "delete": + return types.Deleted + default: + return types.Modified + } +} diff --git a/services/types/review.go b/services/types/review.go new file mode 100644 index 0000000..e4909f4 --- /dev/null +++ b/services/types/review.go @@ -0,0 +1,24 @@ +package types + +// ReviewResult 代表代码审查结果 +type ReviewResult struct { + Comments []Comment + Summary string +} + +// Comment 代表代码审查评论 +type Comment struct { + Path string + Line int + Content string + Severity Severity +} + +// Severity 代表问题严重程度 +type Severity string + +const ( + Error Severity = "error" + Warning Severity = "warning" + Info Severity = "info" +) diff --git a/services/types/webhook.go b/services/types/webhook.go new file mode 100644 index 0000000..c92747f --- /dev/null +++ b/services/types/webhook.go @@ -0,0 +1,37 @@ +package types + +// CodeChanges 代表代码变更信息 +type CodeChanges struct { + Repository string + Branch string + CommitID string + Files []FileChange + PullRequest *PullRequest +} + +// FileChange 代表单个文件的变更 +type FileChange struct { + Path string + Content string + OldPath string // 用于重命名场景 + Type ChangeType +} + +// ChangeType 代表变更类型 +type ChangeType string + +const ( + Added ChangeType = "added" + Modified ChangeType = "modified" + Deleted ChangeType = "deleted" + Renamed ChangeType = "renamed" +) + +// PullRequest 代表 PR/MR 信息 +type PullRequest struct { + ID int + Title string + Description string + SourceBranch string + TargetBranch string +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..cd7e722 --- /dev/null +++ b/static/index.html @@ -0,0 +1,680 @@ + + + + Code Review 配置 + + + + + + + +
+
+
+
+ + +
+ +
+
+ + +
+ + + + + + + \ No newline at end of file diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..20ca9b2 --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,89 @@ +package utils + +import ( + "code-review/services/types" + "fmt" + "strings" +) + +// GetString 从 map 中安全获取字符串值 +func GetString(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +// GetFloat64 从 map 中安全获取 float64 值 +func GetFloat64(m map[string]interface{}, key string) float64 { + if v, ok := m[key].(float64); ok { + return v + } + return 0 +} + +// GetBool 从 map 中安全获取布尔值 +func GetBool(m map[string]interface{}, key string) bool { + if v, ok := m[key].(bool); ok { + return v + } + return false +} + +// GetInt 从 map 中安全获取整数值 +func GetInt(m map[string]interface{}, key string) int { + if v, ok := m[key].(float64); ok { + return int(v) + } + return 0 +} + +// GetIntPtr 从 map 中安全获取整数指针值 +func GetIntPtr(m map[string]interface{}, key string) *int { + if v, ok := m[key].(float64); ok { + intVal := int(v) + return &intVal + } + return nil +} + +// GetIntOk 从 map 中获取 int 值,并返回是否存在 +func GetIntOk(m map[string]interface{}, key string) (int, bool) { + if val, ok := m[key]; ok { + switch v := val.(type) { + case float64: + return int(v), true + case int: + return v, true + case int64: + return int(v), true + } + } + return 0, false +} + +// FormatReviewResult 将审查结果格式化为 Markdown 格式 +func FormatReviewResult(result *types.ReviewResult) string { + var body strings.Builder + + // 添加摘要 + if result.Summary != "" { + body.WriteString("## 审查摘要\n\n") + body.WriteString(result.Summary) + body.WriteString("\n\n") + } + + // 添加详细评论 + if len(result.Comments) > 0 { + body.WriteString("## 详细评论\n\n") + for _, comment := range result.Comments { + if comment.Path != "全局" { + body.WriteString(fmt.Sprintf("### %s\n\n", comment.Path)) + } + body.WriteString(comment.Content) + body.WriteString("\n\n") + } + } + + return body.String() +}