forked from Gitlink/gitlink-cli
414 lines
17 KiB
PowerShell
414 lines
17 KiB
PowerShell
# ----------------------------------------------------------------
|
|
# Scenario 2a: Real-Time PR Gatekeeper (Webhook 触发)
|
|
# Flow: Webhook 推送 PR → 拉取详情 → AI Review → 查 CI → 发评论 → 达标自动合并
|
|
# ----------------------------------------------------------------
|
|
#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 02a-pr-gatekeeper.ps1 -PrId ID [-Owner OWNER] [-Repo REPO] [-Threshold SCORE] [-DryRun]"
|
|
exit 0
|
|
}
|
|
|
|
if (-not $PrId) { Log-Err "PrId is required (use -Help for usage)"; exit 1 }
|
|
|
|
Check-Auth
|
|
$r = Resolve-OwnerRepo $Owner $Repo
|
|
$Owner = $r.Owner; $Repo = $r.Repo
|
|
|
|
# Default fallback strings
|
|
$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 Extract-JsonBlock {
|
|
param([string]$Text)
|
|
$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
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
# ================================================================
|
|
Log-Title "PR Gatekeeper: #$PrId ($Owner/$Repo)"
|
|
|
|
# -- Step 1: 拉取 PR 详情 --
|
|
Log-Step "Fetching PR details..."
|
|
$prJson = Invoke-GLCheck pr,+view,--owner,$Owner,--repo,$Repo,--id,$PrId
|
|
if (-not $prJson) { Log-Err "Failed to fetch PR #$PrId"; exit 1 }
|
|
|
|
$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: 获取变更文件列表 --
|
|
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: 获取 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 代码审查 --
|
|
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) }
|
|
|
|
$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)
|
|
|
|
$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 { $aiResult = ($aiOutput | ConvertFrom-Json).result } catch { $aiResult = $aiOutput }
|
|
}
|
|
|
|
Remove-Item $promptFile -Force -ErrorAction SilentlyContinue
|
|
Remove-Item $outFile -Force -ErrorAction SilentlyContinue
|
|
|
|
if ($aiResult) {
|
|
$aiJson = $null
|
|
$jsonCandidate = Extract-JsonBlock $aiResult
|
|
if ($jsonCandidate) {
|
|
try {
|
|
$testJson = $jsonCandidate | ConvertFrom-Json
|
|
if ($testJson.total -ne $null -and $testJson.verdict) { $aiJson = $testJson }
|
|
} catch { }
|
|
}
|
|
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 --
|
|
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)
|
|
}
|
|
|
|
# -- 打印审查报告 --
|
|
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: 发布审查评论 --
|
|
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: 检查 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: 质量达标 → 自动合并 --
|
|
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)"
|
|
}
|
|
|
|
# ── Complete ──────────────────────────────────────────────────────
|
|
Log-Title "Gatekeeper Complete"
|
|
Write-Host " PR: #$PrId - $prTitle" -ForegroundColor Green
|
|
Write-Host " Author: @$prAuthor" -ForegroundColor Green
|
|
Write-Host " Score: $totalScore / 100" -ForegroundColor Green
|
|
Write-Host " CI Passed: $ciPassed" -ForegroundColor Green
|