gitlink-cli/workflows/02-code-quality-gatekeeper.ps1

568 lines
21 KiB
PowerShell

# ----------------------------------------------------------------
# Scenario 2: Code Quality Gatekeeper
# Flow: PR submit -> AI Review -> Check CI -> Auto-merge if pass
#
# Commands chained:
# 1. pr +list -- list open PRs
# 2. pr +view -- get PR details
# 3. pr +files -- get changed files
# 4. pr +diff -- get diff content
# 5. gitlink-code-review -- AI code review (claude CLI or keyword fallback)
# 6. api POST .../reviews -- post review comment
# 7. ci +builds -- check CI status
# 8. pr +merge -- auto-merge if quality threshold met
# ----------------------------------------------------------------
#Requires -Version 5.1
param(
[string]$Owner = "",
[string]$Repo = "",
[string]$PrId = "",
[int]$Threshold = 80,
[switch]$DryRun,
[switch]$Help
)
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
if ($Help) {
Write-Host "Usage: powershell 02-code-quality-gatekeeper.ps1 -Owner OWNER -Repo REPO [-PrId ID] [-Threshold SCORE] [-DryRun]"
Write-Host ""
Write-Host " -Owner OWNER Repository owner"
Write-Host " -Repo REPO Repository name"
Write-Host " -PrId ID Specific PR to review (default: all open PRs)"
Write-Host " -Threshold SCORE Min quality score to auto-merge (default: 80)"
Write-Host " -DryRun Preview actions without executing"
exit 0
}
Check-Auth
$r = Resolve-OwnerRepo $Owner $Repo
$Owner = $r.Owner; $Repo = $r.Repo
# ----------------------------------------------------------------
# Default fallback strings (defined at script scope to avoid indented here-string issue)
$DefaultSkillDimensions = "1. 代码质量: 复杂度、命名、注释、格式`n2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证`n3. 性能: 循环效率、资源泄漏、N+1查询、内存`n4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖"
$DefaultReviewPrompt = @'
你是代码审查专家。请按 gitlink-code-review skill 的审查维度分析以下 PR。
## 审查维度与检查项
{SKILL_DIMENSIONS}
## 评分标准
- 90-100: 优秀,可直接合并
- 75-89: 良好,建议合并
- 60-74: 一般,需要改进
- <60: 较差,不建议合并
## 问题严重级别
- CRITICAL: 阻止合并
- HIGH: 强烈建议修复
- MEDIUM: 建议修复
- LOW: 可选修复
## PR 数据
PR 标题: {PR_TITLE}
变更文件:
{FILE_LIST}
代码差异:
{DIFF_CONTENT}
## 输出要求
请严格按以下 JSON 格式输出,不要输出其他内容:
{"total": <0-100>, "quality": <0-25>, "security": <0-25>, "performance": <0-25>, "maintainability": <0-25>, "issues": [{"severity": "HIGH/MEDIUM/LOW", "category": "quality/security/performance/maintainability", "file": "文件路径", "rule": "规则名", "description": "问题描述", "suggestion": "修复建议"}], "positive_notes": [{"description": "优秀实践描述"}], "recommendations": ["改进建议1"], "verdict": "PASS或FAIL"}
'@
function Review-PR {
param([string]$PrId)
Log-Title "Reviewing PR #$PrId"
# -- Step 1: Get PR details --
Log-Step "Fetching PR details..."
$prJson = Invoke-GLCheck pr,+view,--owner,$Owner,--repo,$Repo,--id,$PrId
if (-not $prJson) {
Log-Warn "Failed to fetch PR #$PrId, skipping"
return
}
$prData = $prJson.data
$prTitle = if ($prData.title) { $prData.title }
elseif ($prData.subject) { $prData.subject }
elseif ($prData.issue.subject) { $prData.issue.subject }
else { "N/A" }
$prState = if ($prData.state) { $prData.state }
elseif ($prData.status) { $prData.status }
else { "N/A" }
$prAuthor = if ($prData.author.login) { $prData.author.login }
elseif ($prData.author.username) { $prData.author.username }
elseif ($prData.issue.author.login) { $prData.issue.author.login }
else { "N/A" }
Log-Ok "PR #${PrId}: `"$prTitle`" by @$prAuthor (state: $prState)"
# -- Step 2: Get changed files --
Log-Step "Fetching changed files..."
$filesJson = Invoke-GL pr,+files,--owner,$Owner,--repo,$Repo,--id,$PrId
$fileNames = @()
$fileCount = 0
if ($filesJson) {
try {
$filesData = $filesJson | ConvertFrom-Json
if ($filesData.data.files) {
$fileNames = @($filesData.data.files | ForEach-Object {
if ($_.name) { $_.name } elseif ($_.filename) { $_.filename } else { "unknown" }
})
$fileCount = $fileNames.Count
}
} catch { }
}
Log-Ok "Changed files: $fileCount"
foreach ($f in $fileNames) { Write-Host " $f" }
# -- Step 3: Get diff --
Log-Step "Fetching diff..."
$diffContent = ""
$diffLines = 0
$diffJson = Invoke-GL pr,+diff,--owner,$Owner,--repo,$Repo,--id,$PrId
if ($diffJson) {
try {
$diffData = $diffJson | ConvertFrom-Json
$diffLines_arr = @()
if ($diffData.data.files) {
foreach ($f in $diffData.data.files) {
if ($f.sections) {
foreach ($s in $f.sections) {
if ($s.lines) {
foreach ($l in $s.lines) {
if ($l.content) { $diffLines_arr += $l.content }
}
}
}
}
}
}
$diffContent = ($diffLines_arr -join "`n")
if ($diffContent.Length -gt 5000) { $diffContent = $diffContent.Substring(0, 5000) }
$diffLines = $diffLines_arr.Count
} catch { }
}
Log-Ok "Diff: $diffLines lines"
# -- Step 4: AI-powered code review --
Log-Step "AI analyzing code quality..."
$fileListText = ""
foreach ($f in $fileNames) { $fileListText += "- $f`n" }
$diffTruncated = $diffContent
if ($diffTruncated.Length -gt 4000) { $diffTruncated = $diffTruncated.Substring(0, 4000) }
# Load skill dimensions from SKILL.md
$skillDir = "$PSScriptRoot/../skills/gitlink-code-review"
$skillDimensions = ""
if (Test-Path "$skillDir/SKILL.md") {
$skillMd = Get-Content "$skillDir/SKILL.md" -Raw -Encoding UTF8
if ($skillMd -match '(?s)## 📊 审查维度(.+?)## 🔧 使用方式') {
$section = $Matches[1]
$dimLines = ($section -split "`n" | Where-Object { $_ -match '^- \*\*' } | Select-Object -First 20)
$skillDimensions = $dimLines -join "`n"
}
}
if (-not $skillDimensions) {
$skillDimensions = $DefaultSkillDimensions
}
$reviewPrompt = $DefaultReviewPrompt -replace '\{PR_TITLE\}', $prTitle -replace '\{FILE_LIST\}', $fileListText -replace '\{DIFF_CONTENT\}', $diffTruncated -replace '\{SKILL_DIMENSIONS\}', $skillDimensions
$aiAvailable = $false
$totalScore = 0
$scoreQuality = 25
$scoreSecurity = 25
$scorePerformance = 25
$scoreMaintainability = 25
$issuesFound = @()
$aiPositive = @()
$aiRecommendations = @()
$aiVerdict = "PASS"
$claudePath = (Get-Command claude -ErrorAction SilentlyContinue).Source
if ($claudePath) {
Log-Info "Calling AI agent for code review (may take 30-60s)..."
try {
$promptFile = [System.IO.Path]::GetTempFileName()
$outFile = [System.IO.Path]::GetTempFileName()
[System.IO.File]::WriteAllText($promptFile, $reviewPrompt, [System.Text.Encoding]::UTF8)
$aiResult = $null
$proc = Start-Process -FilePath $claudePath `
-ArgumentList @("-p", "--output-format", "json") `
-RedirectStandardInput $promptFile `
-RedirectStandardOutput $outFile `
-NoNewWindow -Wait -PassThru
if ($proc.ExitCode -eq 0 -and (Test-Path $outFile)) {
$aiOutput = [System.IO.File]::ReadAllText($outFile, [System.Text.Encoding]::UTF8)
try {
$aiOutputJson = $aiOutput | ConvertFrom-Json
$aiResult = $aiOutputJson.result
} catch {
$aiResult = $aiOutput
}
}
Remove-Item $promptFile -Force -ErrorAction SilentlyContinue
Remove-Item $outFile -Force -ErrorAction SilentlyContinue
if ($aiResult) {
# Extract JSON from AI response (may contain markdown wrapping)
$aiJson = $null
$jsonCandidate = Extract-JsonBlock $aiResult
if ($jsonCandidate) {
try {
$testJson = $jsonCandidate | ConvertFrom-Json
if ($testJson.total -ne $null -and $testJson.verdict) {
$aiJson = $testJson
}
} catch { }
}
# Fallback: try direct parse
if (-not $aiJson) {
try {
$testJson = $aiResult | ConvertFrom-Json
if ($testJson.total -ne $null -and $testJson.verdict) {
$aiJson = $testJson
}
} catch { }
}
if ($aiJson) {
$totalScore = [int]($aiJson.total -as [int])
$scoreQuality = [int]($aiJson.quality -as [int])
$scoreSecurity = [int]($aiJson.security -as [int])
$scorePerformance = [int]($aiJson.performance -as [int])
$scoreMaintainability = [int]($aiJson.maintainability -as [int])
$aiVerdict = if ($aiJson.verdict) { $aiJson.verdict } else { "PASS" }
if ($aiJson.issues) {
foreach ($issue in $aiJson.issues) {
if ($issue -is [string]) {
$issuesFound += $issue
} else {
$sev = if ($issue.severity) { $issue.severity } else { "?" }
$cat = if ($issue.category) { $issue.category } else { "?" }
$desc = if ($issue.description) { $issue.description }
elseif ($issue.rule) { $issue.rule } else { "unknown" }
$file = if ($issue.file) { " ($($issue.file))" } else { "" }
$sug = if ($issue.suggestion) { " -> $($issue.suggestion)" } else { "" }
$issuesFound += "[$sev] ${cat}: $desc${file}${sug}"
}
}
}
if ($aiJson.positive_notes) {
foreach ($note in $aiJson.positive_notes) {
if ($note.description) { $aiPositive += $note.description }
elseif ($note -is [string]) { $aiPositive += $note }
}
}
if ($aiJson.recommendations) {
foreach ($rec in $aiJson.recommendations) {
if ($rec -is [string]) { $aiRecommendations += $rec }
}
}
$aiAvailable = $true
Log-Ok "AI review complete (verdict: $aiVerdict)"
} else {
Log-Warn "Could not parse AI response JSON, falling back to keyword-based"
}
}
} catch {
Log-Warn "AI call failed: $_"
}
}
# -- Fallback: keyword-based heuristics --
if (-not $aiAvailable) {
Log-Warn "AI not available, falling back to keyword-based analysis"
$scoreQuality = 25
$scoreSecurity = 25
$scorePerformance = 25
$scoreMaintainability = 25
$issuesFound = @()
if ($diffContent -match '(?i)password|secret|token|api_key|apikey|private_key') {
$scoreSecurity -= 15
$issuesFound += "SECURITY: 检测到可能的硬编码凭证"
}
if ($diffContent -match '(?i)eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call') {
$scoreSecurity -= 10
$issuesFound += "SECURITY: 检测到危险函数调用"
}
if ($diffContent -match '(?i)TODO|FIXME|HACK|XXX') {
$scoreQuality -= 5
$issuesFound += "QUALITY: 存在 TODO/FIXME/HACK 注释"
}
if ($diffContent -match '(?i)SELECT \*|\.findAll\(\)|\.all\(\)') {
$scorePerformance -= 10
$issuesFound += "PERFORMANCE: 可能的全表查询"
}
if ($diffContent -match '(?i)sleep\(|time\.sleep|Thread\.sleep') {
$scorePerformance -= 5
$issuesFound += "PERFORMANCE: 检测到阻塞式 sleep"
}
if ($fileCount -gt 20) {
$scoreMaintainability -= 10
$issuesFound += "MAINTAINABILITY: 变更文件数量过多 ($fileCount)"
}
$totalScore = [Math]::Max(0, $scoreQuality + $scoreSecurity + $scorePerformance + $scoreMaintainability)
}
# -- Print review report --
Divider
if ($aiAvailable) {
Log-Info "AI Review Report for PR #$PrId"
} else {
Log-Info "Review Report for PR #$PrId (keyword-based)"
}
Write-Host ""
Write-Host " Overall Score: $totalScore / 100"
Write-Host " Code Quality: $scoreQuality / 25"
Write-Host " Security: $scoreSecurity / 25"
Write-Host " Performance: $scorePerformance / 25"
Write-Host " Maintainability: $scoreMaintainability / 25"
Write-Host ""
if ($issuesFound.Count -gt 0) {
Write-Host " Issues Found:"
foreach ($issue in $issuesFound) {
Write-Host " - $issue"
}
Write-Host ""
}
if ($aiPositive.Count -gt 0) {
Write-Host " Positive Notes:"
foreach ($note in $aiPositive) {
Write-Host " + $note"
}
Write-Host ""
}
if ($aiRecommendations.Count -gt 0) {
Write-Host " Recommendations:"
foreach ($rec in $aiRecommendations) {
Write-Host " > $rec"
}
Write-Host ""
}
# -- Step 5: Post review comment --
if ($aiAvailable) {
$reviewHeader = "## AI Code Quality Review - PR #$PrId"
} else {
$reviewHeader = "## Code Quality Review - PR #$PrId (keyword-based)"
}
$reviewBody = "$reviewHeader`n`n### Scores`n"
$reviewBody += "| Dimension | Score | Max |`n"
$reviewBody += "|-----------|-------|-----|`n"
$reviewBody += "| Code Quality | $scoreQuality | 25 |`n"
$reviewBody += "| Security | $scoreSecurity | 25 |`n"
$reviewBody += "| Performance | $scorePerformance | 25 |`n"
$reviewBody += "| Maintainability | $scoreMaintainability | 25 |`n"
$reviewBody += "| **Total** | **$totalScore** | **100** |`n`n"
$reviewBody += "### Issues Found`n"
if ($issuesFound.Count -gt 0) {
foreach ($issue in $issuesFound) {
$reviewBody += "- $issue`n"
}
} else {
$reviewBody += "No issues found.`n"
}
if ($aiPositive.Count -gt 0) {
$reviewBody += "`n### Positive Notes`n"
foreach ($note in $aiPositive) {
$reviewBody += "- $note`n"
}
}
if ($aiRecommendations.Count -gt 0) {
$reviewBody += "`n### Recommendations`n"
foreach ($rec in $aiRecommendations) {
$reviewBody += "- $rec`n"
}
}
$verdictText = if ($totalScore -ge $Threshold) {
"**PASS** - Score $totalScore >= threshold $Threshold. Ready to merge."
} else {
"**FAIL** - Score $totalScore < threshold $Threshold. Please address the issues above."
}
$reviewBody += "`n### Verdict`n${verdictText}`n`n---`n*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*"
Log-Step "Posting review comment..."
$reviewEvent = if ($totalScore -ge $Threshold) { "APPROVE" } else { "COMMENT" }
$reviewPayload = @{
body = $reviewBody
event = $reviewEvent
} | ConvertTo-Json -Compress
$reviewResult = Invoke-GL api,POST,"/$Owner/$Repo/pulls/$PrId/reviews",--body,$reviewPayload
if ($reviewResult) {
try {
$reviewOk = (($reviewResult | ConvertFrom-Json).ok -eq $true)
} catch { $reviewOk = $false }
if ($reviewOk) {
Log-Ok "Review posted"
} else {
Log-Warn "Review post may have failed (review API might not be available)"
}
} else {
Log-Warn "Review post may have failed"
}
# -- Step 6: Check CI --
Log-Step "Checking CI build status..."
$ciPresent = $false
$ciPassed = $true
$ciJson = Invoke-GL ci,+builds,--owner,$Owner,--repo,$Repo
if ($ciJson) {
try {
$ciData = $ciJson | ConvertFrom-Json
$builds = @()
if ($ciData.data.builds) { $builds = @($ciData.data.builds) }
elseif ($ciData.data -is [array]) { $builds = @($ciData.data) }
if ($builds.Count -gt 0) {
$ciPresent = $true
foreach ($b in $builds) {
$status = if ($b.status) { $b.status } elseif ($b.state) { $b.state } else { "unknown" }
$name = if ($b.name) { $b.name } else { "build" }
if ($status -notin @("success", "passed", "completed")) {
$ciPassed = $false
Log-Warn "CI '$name' status: $status"
} else {
Log-Ok "CI '$name' status: $status"
}
}
}
} catch { }
}
if (-not $ciPresent) { Log-Info "No CI builds found" }
# -- Step 7: Auto-merge --
if ($totalScore -ge $Threshold -and $ciPassed) {
Log-Step "Quality score $totalScore >= $Threshold and CI passed"
if ($DryRun) {
Log-Warn "[DRY RUN] Would auto-merge PR #$PrId"
} else {
Log-Step "Auto-merging PR #$PrId..."
$mergeResult = Invoke-GL pr,+merge,--owner,$Owner,--repo,$Repo,--id,$PrId,--method,merge
if ($mergeResult) {
try {
$mergeOk = (($mergeResult | ConvertFrom-Json).ok -eq $true)
} catch { $mergeOk = $false }
if ($mergeOk) {
Log-Ok "PR #$PrId merged successfully!"
} else {
Log-Err "Auto-merge failed"
}
} else {
Log-Err "Auto-merge failed"
}
}
} else {
Log-Warn "PR #$PrId not auto-merged (score: $totalScore, threshold: $Threshold, CI passed: $ciPassed)"
}
Write-Host ""
}
# ----------------------------------------------------------------
function Extract-JsonBlock {
param([string]$Text)
# Find JSON by balanced brace matching
$depth = 0
$start = -1
$results = @()
for ($i = 0; $i -lt $Text.Length; $i++) {
$c = $Text[$i]
if ($c -eq '{') {
if ($depth -eq 0) { $start = $i }
$depth++
} elseif ($c -eq '}') {
$depth--
if ($depth -eq 0 -and $start -ge 0) {
$results += $Text.Substring($start, $i - $start + 1)
$start = -1
}
}
}
# Return the last valid JSON object (usually the most complete)
for ($i = $results.Count - 1; $i -ge 0; $i--) {
try {
$obj = $results[$i] | ConvertFrom-Json
if ($obj.total -ne $null -and $obj.verdict) {
return $results[$i]
}
} catch { }
}
return $null
}
# ================================================================
# Main
# ================================================================
Log-Title "Code Quality Gatekeeper"
if ($PrId) {
Review-PR $PrId
} else {
Log-Step "Fetching open PRs..."
$prsResult = Invoke-GL pr,+list,--owner,$Owner,--repo,$Repo,--state,open,--limit,50
$prList = @()
if ($prsResult) {
try {
$prsJson = $prsResult | ConvertFrom-Json
$prData = $prsJson.data
if ($prData.issues) { $prList = @($prData.issues) }
elseif ($prData.pulls) { $prList = @($prData.pulls) }
elseif ($prData -is [array]) { $prList = $prData }
} catch { }
}
$prCount = $prList.Count
Log-Ok "Found $prCount open PRs"
if ($prCount -eq 0) {
Log-Info "No open PRs to review"
exit 0
}
$reviewed = 0
$passed = 0
$failed = 0
foreach ($pr in $prList) {
$prNum = if ($pr.pull_request_number) { $pr.pull_request_number }
elseif ($pr.number) { $pr.number }
elseif ($pr.id) { $pr.id }
else { $null }
if (-not $prNum) { continue }
Review-PR $prNum
$reviewed++
}
Log-Title "Gatekeeper Summary"
Write-Host " PRs Reviewed: $reviewed" -ForegroundColor Green
Write-Host " Threshold: $Threshold" -ForegroundColor Green
}