This commit is contained in:
Hua
2025-02-18 16:53:34 +08:00
parent 8b4b4b4181
commit 5cfdc92556
21 changed files with 3139 additions and 0 deletions

84
services/ai/ai.go Normal file
View File

@ -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
}

184
services/ai/client.go Normal file
View File

@ -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)<think>(.*?)</think>"
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)<think>(.*?)</think>"
reg := regexp.MustCompile(pattern)
matches := reg.ReplaceAllString(result.Choices[0].Message.Content, "")
return strings.TrimSpace(matches), nil
}

96
services/interfaces.go Normal file
View File

@ -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
}

View File

@ -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
}

189
services/platforms/gitea.go Normal file
View File

@ -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"
}

141
services/platforms/gitee.go Normal file
View File

@ -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"
}

View File

@ -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"
}

245
services/platforms/gogs.go Normal file
View File

@ -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
}
}

24
services/types/review.go Normal file
View File

@ -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"
)

37
services/types/webhook.go Normal file
View File

@ -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
}