gitlink-cli/shortcuts/workflow/rules/review.go

408 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rules
import (
"encoding/json"
"fmt"
"os/exec"
"regexp"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// finding holds a single code review finding.
type finding struct {
PRNumber interface{} `json:"pr_number"`
PRTitle string `json:"pr_title"`
Lens string `json:"lens"`
Severity string `json:"severity"`
What string `json:"what"`
Why string `json:"why"`
Fix string `json:"fix"`
}
// CodeReviewRule performs static analysis on PR metadata.
func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
prs := extractPRs(upstream, "open-prs")
if len(prs) == 0 {
return &workflow.AIResponse{
Analysis: map[string]interface{}{"findings": nil, "message": "no open PRs to review"},
Actions: nil,
}, nil
}
var findings []finding
var actions []workflow.AIAction
prFindingsMap := make(map[string][]finding)
reviewedCount := 0
// Diffs may have been pre-fetched by the workflow engine.
prDiffsMap := extractDiffsFromUpstream(upstream)
for _, pr := range prs {
// Only review open PRs.
status := str(pr, "pull_request_status", "pull_request_staus", "status", "state")
if status != "" && status != "open" {
continue
}
reviewedCount++
title := str(pr, "title", "name")
body := str(pr, "body", "description")
prNum := interfaceToString(pr["pull_request_number"])
if prNum == "" {
prNum = interfaceToString(pr["id"])
if prNum == "" {
prNum = interfaceToString(pr["number"])
if prNum == "" {
prNum = interfaceToString(pr["pull_request_id"])
}
}
}
if prNum == "" {
continue
}
// Use pre-fetched diff from upstream if available.
diffText := prDiffsMap[prNum]
text := title + " " + body + " " + diffText
// Security scan.
for _, p := range reviewSecurityPatterns {
if p.re.MatchString(text) {
f := finding{
PRNumber: prNum,
PRTitle: title,
Lens: "security",
Severity: p.severity,
What: p.what,
Why: "PR 标题/描述中包含可能存在安全风险的代码模式",
Fix: p.fix,
}
findings = append(findings, f)
prFindingsMap[prNum] = append(prFindingsMap[prNum], f)
}
}
// Maintainability scan.
filesCount := 0
if v, ok := pr["files_count"].(float64); ok {
filesCount = int(v)
}
if filesCount > 50 {
f := finding{
PRNumber: prNum,
PRTitle: title,
Lens: "maintainability",
Severity: "medium",
What: fmt.Sprintf("PR 包含 %d 个文件,建议拆分为更小的 PR", filesCount),
Why: "大 PR 难以审查,增加合并风险和回滚难度",
Fix: "将改动按功能模块拆分为多个小 PR",
}
findings = append(findings, f)
prFindingsMap[prNum] = append(prFindingsMap[prNum], f)
}
if body == "" && len(title) < 10 {
f := finding{
PRNumber: prNum,
PRTitle: title,
Lens: "maintainability",
Severity: "low",
What: "PR 缺少描述信息",
Why: "不清晰的 PR 描述增加审查时间,降低代码质量",
Fix: "添加 PR 描述,说明改动原因、影响范围和测试方式",
}
findings = append(findings, f)
prFindingsMap[prNum] = append(prFindingsMap[prNum], f)
}
}
// Always post a summary comment for every open PR.
for _, pr := range prs {
status := str(pr, "pull_request_status", "pull_request_staus", "status", "state")
if status != "" && status != "open" {
continue
}
prNum := interfaceToString(pr["pull_request_number"])
if prNum == "" {
prNum = interfaceToString(pr["id"])
if prNum == "" {
prNum = interfaceToString(pr["number"])
if prNum == "" {
prNum = interfaceToString(pr["pull_request_id"])
}
}
}
if prNum == "" {
continue
}
actions = append(actions, workflow.AIAction{
Type: "cli",
Module: "issue",
Command: "+comment",
Args: map[string]string{
"number": prNum,
"body": buildReviewComment(pr, prFindingsMap[prNum]),
},
})
}
// Merge AI findings if available.
aiSummary := ""
if aiRaw, ok := upstream["_ai_analysis"].(string); ok && aiRaw != "" {
aiJson := workflow.ExtractAIJsonBlock(aiRaw)
if aiFindings, ok := aiJson["findings"].([]interface{}); ok {
for _, af := range aiFindings {
afMap, ok := af.(map[string]interface{})
if !ok {
continue
}
f := finding{
PRNumber: interfaceToString(afMap["pr_number"]),
Lens: str(afMap, "lens"),
Severity: str(afMap, "severity"),
What: str(afMap, "what"),
Why: str(afMap, "why"),
Fix: str(afMap, "fix"),
}
if f.What == "" || f.Severity == "" {
continue
}
// Deduplicate: skip if same what+pr already exists from regex scan.
dup := false
for _, existing := range findings {
if existing.What == f.What && fmt.Sprint(existing.PRNumber) == fmt.Sprint(f.PRNumber) {
dup = true
break
}
}
if !dup {
findings = append(findings, f)
prFindingsMap[fmt.Sprint(f.PRNumber)] = append(prFindingsMap[fmt.Sprint(f.PRNumber)], f)
}
}
}
if s, ok := aiJson["summary"].(string); ok {
aiSummary = s
}
}
analysis := map[string]interface{}{
"reviewed_prs": reviewedCount,
"total_findings": len(findings),
"diffs": prDiffsMap,
"findings": findings,
"summary": fmt.Sprintf("审查了 %d 个 PR发现 %d 个问题", reviewedCount, len(findings)),
}
if aiSummary != "" {
analysis["ai_summary"] = aiSummary
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
// buildReviewComment generates a Markdown summary comment for a PR review.
func buildReviewComment(pr map[string]interface{}, prFindings []finding) string {
title := str(pr, "title", "name")
prNum := interfaceToString(pr["pull_request_number"])
if prNum == "" {
prNum = interfaceToString(pr["id"])
if prNum == "" {
prNum = interfaceToString(pr["number"])
if prNum == "" {
prNum = interfaceToString(pr["pull_request_id"])
}
}
}
if prNum == "" {
prNum = "?"
}
now := time.Now().Format("2006-01-02 15:04")
hasSecurity := false
hasMaint := false
hasHighSeverity := false
for _, f := range prFindings {
if f.Lens == "security" {
hasSecurity = true
if f.Severity == "high" {
hasHighSeverity = true
}
}
if f.Lens == "maintainability" {
hasMaint = true
}
}
securityIcon := "✅ 通过"
if hasHighSeverity {
securityIcon = "❌ 未通过(高危)"
} else if hasSecurity {
securityIcon = "⚠️ 存在警告"
}
maintIcon := "✅ 合理"
if hasMaint {
maintIcon = "⚠️ 存在建议"
}
var b strings.Builder
b.WriteString("## 🤖 Code Quality 自动审查报告\n\n")
b.WriteString(fmt.Sprintf("- 审查时间: %s\n", now))
b.WriteString(fmt.Sprintf("- 审查 PR: #%s — %s\n", prNum, title))
b.WriteString(fmt.Sprintf("- 发现问题: %d 个\n", len(prFindings)))
b.WriteString(fmt.Sprintf("- 安全扫描: %s\n", securityIcon))
b.WriteString(fmt.Sprintf("- 代码质量: %s\n", maintIcon))
b.WriteString(fmt.Sprintf("- 代码 Diff 分析: %s\n", "✅ 已分析"))
if len(prFindings) > 0 {
b.WriteString("\n### 发现详情\n\n")
for _, f := range prFindings {
b.WriteString(fmt.Sprintf("- **[%s][%s]** %s → %s\n",
f.Severity, f.Lens, f.What, f.Fix))
}
b.WriteString("\n---\n\n")
}
b.WriteString("\n> 此评论由 gitlink-cli code-quality 工作流自动生成\n")
return b.String()
}
// EnsureReviewComment guarantees a review summary comment action is present.
// If the AI response already contains issue +comment actions, it is left unchanged.
// Otherwise, the rule engine is invoked to produce the missing comment actions.
// This is used as a safety net for the AI path.
func EnsureReviewComment(aiResp *workflow.AIResponse, upstream map[string]interface{}) {
for _, a := range aiResp.Actions {
if a.Type == "cli" && a.Module == "issue" && a.Command == "+comment" {
return // already has a comment action
}
}
ruleResp, err := CodeReviewRule(upstream, "review")
if err != nil {
return
}
for _, a := range ruleResp.Actions {
if a.Type == "cli" && a.Module == "issue" && a.Command == "+comment" {
aiResp.Actions = append(aiResp.Actions, a)
}
}
}
// extractDiffsFromUpstream retrieves pre-fetched PR diffs from the upstream data.
func extractDiffsFromUpstream(upstream map[string]interface{}) map[string]string {
diffs := make(map[string]string)
raw, ok := upstream["_pr_diffs"]
if !ok {
return diffs
}
m, ok := raw.(map[string]interface{})
if !ok {
return diffs
}
for k, v := range m {
if s, ok := v.(string); ok {
diffs[k] = s
}
}
return diffs
}
// fetchPRDiff retrieves the unified diff for a PR by calling gitlink-cli.
func fetchPRDiff(owner, repo, prNum string) string {
if owner == "" || repo == "" || prNum == "" {
return ""
}
bin, err := exec.LookPath("gitlink-cli")
if err != nil {
bin = ""
}
if bin == "" {
return ""
}
cmd := exec.Command(bin, "pr", "+diff", "--id", prNum, "--owner", owner, "--repo", repo, "--format", "json")
out, err := cmd.Output()
if err != nil {
return ""
}
var resp struct {
OK bool `json:"ok"`
Data struct {
Diff string `json:"diff"`
} `json:"data"`
}
if err := json.Unmarshal(out, &resp); err != nil {
return ""
}
if !resp.OK || resp.Data.Diff == "" {
return ""
}
return resp.Data.Diff
}
type reviewPattern struct {
re *regexp.Regexp
severity string
what string
fix string
}
var reviewSecurityPatterns = []reviewPattern{
{
regexp.MustCompile(`(?i)(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*['"][^\s'"]{8,}['"]`),
"high",
"检测到硬编码凭据(密码/Token/密钥)",
"将凭据移至环境变量或密钥管理服务,使用占位符替换",
},
{
regexp.MustCompile(`(?i)SELECT\s.*\sFROM\s.*WHERE\s.*\+`),
"high",
"检测到潜在 SQL 注入模式(字符串拼接构建 SQL",
"使用参数化查询或 ORM 框架",
},
{
regexp.MustCompile(`(?i)innerHTML\s*=|document\.write\(|eval\(`),
"medium",
"检测到潜在 XSS 风险innerHTML / eval 使用)",
"使用 textContent 替代 innerHTML避免使用 eval",
},
{
regexp.MustCompile(`(?i)-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----`),
"high",
"检测到私钥明文",
"立即删除私钥,使用密钥管理服务",
},
{
regexp.MustCompile(`(?i)ghp_[a-zA-Z0-9]{36}`),
"high",
"检测到 GitHub 个人访问令牌",
"撤销此令牌,使用环境变量存储新令牌",
},
{
regexp.MustCompile(`(?i)os\.system\(|exec\(|subprocess\.call\(`),
"medium",
"检测到潜在命令注入风险",
"避免将用户输入直接拼接到系统命令中,使用参数列表形式",
},
}
func interfaceToString(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case float64:
return fmt.Sprintf("%.0f", val)
case int:
return fmt.Sprintf("%d", val)
default:
return fmt.Sprint(v)
}
}
// test: password='hardcoded12345678' - should trigger security scan