forked from chroe/gitlink-cli
408 lines
11 KiB
Go
408 lines
11 KiB
Go
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
|