完成两条自动化工作流:社区运营自动化 + 代码质量看门人
工作流一(社区运营自动化): - Webhook 监听 Issue 创建 → 自动关键词分类 → 打标签 + 分配责任人 - 修复 assigner_ids 字段名(原 assigned_to_id 已被后端静默忽略) - 修复标签 ID 从项目 issue_tags API 动态获取(原 tracker ID 与 issue_tag ID 不匹配) - 新增 Windows PowerShell 版本脚本 工作流二(代码质量看门人): - Webhook 监听 PR 创建 → 数据采集 → AI 代码审查 → 本地 CI → 冲突检测 → 自动合并 - 新增 02a-pr-gatekeeper.sh 核心编排脚本(603 行) - 新增 Windows PowerShell 版本 - Webhook 监听器增加 pull_request 事件路由
This commit is contained in:
parent
b476963fde
commit
9f0973f80a
|
|
@ -372,7 +372,7 @@ func runBatchAssign(ctx *common.RuntimeContext) error {
|
|||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"assigned_to_id": assigneeID}); err != nil {
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"assigner_ids": []interface{}{assigneeID}}); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
|
|
@ -413,7 +413,12 @@ func runBatchLabel(ctx *common.RuntimeContext) error {
|
|||
return err
|
||||
}
|
||||
label := ctx.Arg("label")
|
||||
trackerID, err := parseTracker(label)
|
||||
|
||||
tags, err := resolveIssueTags(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve issue tags: %w", err)
|
||||
}
|
||||
tagID, err := parseLabel(label, tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -444,7 +449,7 @@ func runBatchLabel(ctx *common.RuntimeContext) error {
|
|||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{trackerID}}); err != nil {
|
||||
if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{tagID}}); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
|
|
|
|||
|
|
@ -191,6 +191,190 @@ $docSection = if ($docLines.Count -gt 0) { ($docLines -join "`n") } else { "_无
|
|||
$otherSection = if ($otherLines.Count -gt 0) { ($otherLines -join "`n") } else { "_无_" }
|
||||
Log-Ok "Issues in period: $issCount"
|
||||
|
||||
# -- 动态收集标签 ID 映射 (与 01a-issue-triage.ps1 一致) --
|
||||
Log-Step "Discovering label IDs..."
|
||||
$tagIdMap = @{}
|
||||
|
||||
# 优先从项目 issue_tags API 获取
|
||||
$tagsApi = Invoke-GL api,GET,"/v1/$Owner/$Repo/issue_tags"
|
||||
if ($tagsApi) {
|
||||
try {
|
||||
$tData = ($tagsApi | ConvertFrom-Json).data
|
||||
$tagList = if ($tData.issue_tags) { @($tData.issue_tags) } elseif ($tData -is [array]) { @($tData) } else { @() }
|
||||
foreach ($t in $tagList) {
|
||||
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# 补充:从已有 issue 的 tags 字段收集
|
||||
foreach ($state in @("open","closed")) {
|
||||
$sample = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,50
|
||||
if ($sample) {
|
||||
try {
|
||||
$sData = ($sample | ConvertFrom-Json).data
|
||||
$sIssues = if ($sData.issues) { @($sData.issues) } elseif ($sData -is [array]) { @($sData) } else { @() }
|
||||
foreach ($iss in $sIssues) {
|
||||
if ($iss.tags) {
|
||||
foreach ($t in $iss.tags) {
|
||||
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
$tagFound = @()
|
||||
foreach ($k in $tagIdMap.Keys) { $tagFound += "$k($($tagIdMap[$k]))" }
|
||||
if ($tagFound.Count -gt 0) { Log-Ok "Found tags: $($tagFound -join ' ')" } else { Log-Warn "No tags found in repo" }
|
||||
|
||||
# -- 打标签 & 分配责任人 (自动分类后的实际写入) --
|
||||
# 收集需要处理的 Issue 信息 (编号, issue ID, 标签名, 作者)
|
||||
$triageItems = @()
|
||||
$seenTriage = @{}
|
||||
|
||||
foreach ($state in @("open","closed")) {
|
||||
$issJson2 = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,100
|
||||
if (-not $issJson2) { continue }
|
||||
try {
|
||||
$issData2 = ($issJson2 | ConvertFrom-Json).data
|
||||
$issues2 = if ($issData2.issues) { @($issData2.issues) } elseif ($issData2 -is [array]) { @($issData2) } else { @() }
|
||||
foreach ($iss in $issues2) {
|
||||
$num = if ($iss.project_issues_index) { $iss.project_issues_index } elseif ($iss.number) { $iss.number } else { "?" }
|
||||
if ($num -eq "?" -or $seenTriage.ContainsKey($num)) { continue }
|
||||
$created = if ($iss.created_at) { $iss.created_at } else { "" }
|
||||
$closed = if ($iss.closed_at) { $iss.closed_at } else { "" }
|
||||
$inPeriod = $false
|
||||
if ($created) { try { if (([DateTime]$created) -ge $periodStart) { $inPeriod = $true } } catch {} }
|
||||
if (-not $inPeriod -and $closed) { try { if (([DateTime]$closed) -ge $periodStart) { $inPeriod = $true } } catch {} }
|
||||
if (-not $inPeriod) { continue }
|
||||
|
||||
$seenTriage[$num] = $true
|
||||
$title2 = if ($iss.subject) { $iss.subject } elseif ($iss.title) { $iss.title } else { "" }
|
||||
$desc2 = if ($iss.description) { $iss.description } else { "" }
|
||||
$author2 = if ($iss.author.login) { $iss.author.login } elseif ($iss.author.username) { $iss.author.username } else { "" }
|
||||
$issueId2 = if ($iss.id) { $iss.id } else { $num }
|
||||
|
||||
# 检查是否已有标签
|
||||
$hasLabel = $false
|
||||
if ($iss.tags -and @($iss.tags).Count -gt 0) { $hasLabel = $true }
|
||||
|
||||
# 关键词分类
|
||||
$combined2 = "$title2 $desc2".ToLower()
|
||||
$chosenLabel = ""
|
||||
if ($combined2 -match '(?i)bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障') {
|
||||
$chosenLabel = "缺陷"
|
||||
} elseif ($combined2 -match '(?i)feature|enhancement|add|新增|建议|功能|特性|新功能|支持|request') {
|
||||
$chosenLabel = "功能"
|
||||
} elseif ($combined2 -match '(?i)doc|readme|guide|wiki|tutorial|文档|说明|教程|手册') {
|
||||
$chosenLabel = "文档"
|
||||
} elseif ($combined2 -match '(?i)test|测试|用例|覆盖|验证') {
|
||||
$chosenLabel = "测试"
|
||||
} elseif ($combined2 -match '(?i)duplicate|重复|重复的') {
|
||||
$chosenLabel = "重复"
|
||||
} elseif ($combined2 -match '(?i)question|疑问|不确定|讨论|澄清|是否|可否') {
|
||||
$chosenLabel = "疑问"
|
||||
} elseif ($combined2 -match '(?i)help|协助|帮助|协作|请求帮助|互助') {
|
||||
$chosenLabel = "协助"
|
||||
} elseif ($combined2 -match '(?i)postpone|wontfix|暂缓|搁置|低优|不重要|不紧急|暂不|delay') {
|
||||
$chosenLabel = "搁置"
|
||||
} elseif ($combined2 -match '(?i)task|todo|任务|待办|计划|安排') {
|
||||
$chosenLabel = "任务"
|
||||
} elseif ($combined2 -match '(?i)support|兼容|环境|依赖|平台|适配') {
|
||||
$chosenLabel = "支持"
|
||||
}
|
||||
|
||||
# 检查是否已有负责人
|
||||
$hasAssignee = $false
|
||||
if ($iss.assigned_to_id -and "$($iss.assigned_to_id)" -ne "0" -and "$($iss.assigned_to_id)" -ne "") { $hasAssignee = $true }
|
||||
if ($iss.assigned_to -and $iss.assigned_to.login) { $hasAssignee = $true }
|
||||
|
||||
if ($chosenLabel -or -not $hasAssignee) {
|
||||
$triageItems += @{ num=$num; id=$issueId2; title=$title2; desc=$desc2; author=$author2; label=$chosenLabel; hasLabel=$hasLabel; hasAssignee=$hasAssignee }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$labelOk = 0; $labelFail = 0; $labelSkip = 0
|
||||
$assignOk = 0; $assignFail = 0; $assignSkip = 0
|
||||
|
||||
if ($triageItems.Count -gt 0) {
|
||||
Log-Step "Applying labels and assignees to $($triageItems.Count) issue(s)..."
|
||||
foreach ($item in $triageItems) {
|
||||
# -- 打标签 --
|
||||
if (-not $item.label) {
|
||||
$labelSkip++
|
||||
} elseif ($item.hasLabel) {
|
||||
Log-Info " #$($item.num) already has label, skipping"
|
||||
$labelSkip++
|
||||
} elseif ($DryRun) {
|
||||
Log-Warn " [DRY RUN] Would tag #$($item.num) with '$($item.label)'"
|
||||
$labelSkip++
|
||||
} else {
|
||||
$tgtId = $tagIdMap[$item.label]
|
||||
if (-not $tgtId) {
|
||||
Log-Warn " #$($item.num): label '$($item.label)' not found in repo tags — create on website first"
|
||||
$labelFail++
|
||||
} else {
|
||||
$bodyJson = "{`"issue_tag_ids`":[$tgtId]}"
|
||||
$tagResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$($item.id)",--body,$bodyJson
|
||||
if ($tagResult) {
|
||||
try {
|
||||
$tagOkFlag = (($tagResult | ConvertFrom-Json).ok -eq $true)
|
||||
} catch { $tagOkFlag = $false }
|
||||
if ($tagOkFlag) { Log-Ok " #$($item.num) tagged: $($item.label)"; $labelOk++ } else { Log-Warn " #$($item.num) tag failed"; $labelFail++ }
|
||||
} else { Log-Warn " #$($item.num) tag failed (no response)"; $labelFail++ }
|
||||
}
|
||||
}
|
||||
|
||||
# -- 分配责任人 (默认分配给 Issue 作者) --
|
||||
if ($item.hasAssignee) {
|
||||
$assignSkip++
|
||||
} elseif (-not $item.author -or $item.author -eq "?") {
|
||||
Log-Info " #$($item.num): no author info, skipping assign"
|
||||
$assignSkip++
|
||||
} elseif ($DryRun) {
|
||||
Log-Warn " [DRY RUN] Would assign #$($item.num) to @$($item.author)"
|
||||
$assignSkip++
|
||||
} else {
|
||||
# Resolve login name to numeric user ID
|
||||
$authorID = $null
|
||||
$isNum = $false
|
||||
try { [void][int]$item.author; $isNum = $true } catch {}
|
||||
if ($isNum) {
|
||||
$authorID = $item.author
|
||||
} else {
|
||||
$userJson = Invoke-GL api,GET,"/users/$($item.author)"
|
||||
if ($userJson) {
|
||||
try {
|
||||
$userData = ($userJson | ConvertFrom-Json).data
|
||||
if ($userData.id) { $authorID = [int]$userData.id }
|
||||
elseif ($userData.user_id) { $authorID = [int]$userData.user_id }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $authorID) {
|
||||
Log-Warn " #$($item.num): cannot resolve user ID for '@$($item.author)'"
|
||||
$assignFail++
|
||||
} else {
|
||||
$bodyJson = "{`"assigner_ids`":[$authorID]}"
|
||||
$assignResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$($item.id)",--body,$bodyJson
|
||||
if ($assignResult) {
|
||||
try {
|
||||
$assignOkFlag = (($assignResult | ConvertFrom-Json).ok -eq $true)
|
||||
} catch { $assignOkFlag = $false }
|
||||
if ($assignOkFlag) { Log-Ok " #$($item.num) assigned to @$($item.author) (ID:$authorID)"; $assignOk++ } else { Log-Warn " #$($item.num) assign failed"; $assignFail++ }
|
||||
} else { Log-Warn " #$($item.num) assign failed (no response)"; $assignFail++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Log-Ok "Labels: $labelOk applied, $labelFail failed, $labelSkip skipped"
|
||||
Log-Ok "Assign: $assignOk applied, $assignFail failed, $assignSkip skipped"
|
||||
} else {
|
||||
Log-Info "No new issues require triage in this period"
|
||||
}
|
||||
|
||||
# -- 贡献者汇总 --
|
||||
$allContribs = @($prContributors.Keys; $issContributors.Keys) | Select-Object -Unique | Sort-Object
|
||||
$contribText = if ($allContribs.Count -gt 0) { ($allContribs | ForEach-Object { "- @$_" }) -join "`n" } else { "(无活跃贡献者)" }
|
||||
|
|
@ -311,5 +495,7 @@ Log-Title "Complete"
|
|||
Write-Host " Release: $newVersion" -ForegroundColor Green
|
||||
Write-Host " PRs merged: $prCount" -ForegroundColor Green
|
||||
Write-Host " Issues: $issCount" -ForegroundColor Green
|
||||
Write-Host " Labels: $labelOk applied / $labelFail failed / $labelSkip skipped" -ForegroundColor Green
|
||||
Write-Host " Assigned: $assignOk applied / $assignFail failed / $assignSkip skipped" -ForegroundColor Green
|
||||
Write-Host " Contributors: $($allContribs.Count)" -ForegroundColor Green
|
||||
Write-Host " Wiki: $wikiTitle" -ForegroundColor Green
|
||||
|
|
|
|||
|
|
@ -56,6 +56,19 @@ Log-Ok "Issue `"$issueTitle`" by @$issueAuthor"
|
|||
Log-Step "Discovering label IDs..."
|
||||
$tagIdMap = @{}
|
||||
|
||||
# 优先从项目 issue_tags API 获取
|
||||
$tagsApi = Invoke-GL api,GET,"/v1/$Owner/$Repo/issue_tags"
|
||||
if ($tagsApi) {
|
||||
try {
|
||||
$tData = ($tagsApi | ConvertFrom-Json).data
|
||||
$tagList = if ($tData.issue_tags) { @($tData.issue_tags) } elseif ($tData -is [array]) { @($tData) } else { @() }
|
||||
foreach ($t in $tagList) {
|
||||
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# 补充:从已有 issue 的 tags 字段收集
|
||||
foreach ($state in @("open","closed")) {
|
||||
$sample = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,50
|
||||
if ($sample) {
|
||||
|
|
@ -127,11 +140,8 @@ if (-not $chosenLabel) {
|
|||
} else {
|
||||
Log-Step "Tagging with '$chosenLabel' (ID:$tgtId)..."
|
||||
|
||||
$curTagsJson = if ($issueData.tags) {
|
||||
($issueData.tags | ForEach-Object { "{`"id`":$($_.id),`"name`":`"$($_.name)`"" }) -join "," | ForEach-Object { "[$_]" }
|
||||
} else { "[]" }
|
||||
|
||||
$bodyJson = "{`"tags`":[{`"id`":$tgtId,`"name`":`"$chosenLabel`"}]}"
|
||||
# GitLink v1 API 使用 issue_tag_ids (整数ID数组),不是 tags (对象数组)
|
||||
$bodyJson = "{`"issue_tag_ids`":[$tgtId]}"
|
||||
|
||||
$tagResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson
|
||||
if ($tagResult) {
|
||||
|
|
@ -152,12 +162,40 @@ if (-not $targetAssignee) {
|
|||
} elseif ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would assign @$targetAssignee"
|
||||
} else {
|
||||
$bodyJson = "{`"subject`":`"$($issueTitle -replace '"','\"')`",`"description`":`"$($issueDesc -replace '"','\"')`",`"assigned_to_id`":`"$targetAssignee`"}"
|
||||
$result = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson
|
||||
if ($result -and ((($result | ConvertFrom-Json).ok -eq $true))) {
|
||||
Log-Ok "Assigned: @$targetAssignee"
|
||||
# assigned_to_id 需要数字用户 ID,不是登录名
|
||||
# 先尝试把输入当纯数字;否则通过 /users/{login} 查询
|
||||
$assigneeID = $null
|
||||
$isNumeric = $false
|
||||
try { [void][int]$targetAssignee; $isNumeric = $true } catch {}
|
||||
if ($isNumeric) {
|
||||
$assigneeID = $targetAssignee
|
||||
} else {
|
||||
Log-Warn "Assign failed"
|
||||
Log-Info "Resolving user ID for '$targetAssignee'..."
|
||||
$userJson = Invoke-GL api,GET,"/users/$targetAssignee"
|
||||
if ($userJson) {
|
||||
try {
|
||||
$userData = ($userJson | ConvertFrom-Json).data
|
||||
if ($userData.id) {
|
||||
$assigneeID = [int]$userData.id
|
||||
} elseif ($userData.user_id) {
|
||||
$assigneeID = [int]$userData.user_id
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $assigneeID) {
|
||||
Log-Warn "Cannot resolve user ID for '$targetAssignee' — skipping assign"
|
||||
} else {
|
||||
$escTitle = $issueTitle -replace '"','\"'
|
||||
$escDesc = $issueDesc -replace '"','\"'
|
||||
$bodyJson = "{`"subject`":`"$escTitle`",`"description`":`"$escDesc`",`"assigner_ids`":[$assigneeID]}"
|
||||
$result = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson
|
||||
if ($result -and ((($result | ConvertFrom-Json).ok -eq $true))) {
|
||||
Log-Ok "Assigned: @$targetAssignee (ID:$assigneeID)"
|
||||
} else {
|
||||
Log-Warn "Assign failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,8 +60,15 @@ log_ok "Issue \"$ISSUE_TITLE\" by @$ISSUE_AUTHOR"
|
|||
# ── Phase 2: 动态收集标签 ID 映射 ─────────────────────────────────
|
||||
log_step "Discovering label IDs..."
|
||||
|
||||
# 从已有 issue 的 tags 字段收集 name→ID 映射
|
||||
ID_MAP_FILE=$(mktemp)
|
||||
|
||||
# 优先从项目 issue_tags API 获取
|
||||
TAGS_JSON=$(gl_run api GET "/v1/$OWNER/$REPO/issue_tags" 2>/dev/null || true)
|
||||
if [[ -n "$TAGS_JSON" ]]; then
|
||||
echo "$TAGS_JSON" | jq -r '(.data.issue_tags // .data // [])[]? | "\(.id)|\(.name)"' 2>/dev/null >> "$ID_MAP_FILE" || true
|
||||
fi
|
||||
|
||||
# 补充:从已有 issue 的 tags 字段收集(覆盖 issue_tags API 未返回的情况)
|
||||
for state in open closed; do
|
||||
SAMPLE=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state "$state" --limit 50 2>/dev/null || true)
|
||||
echo "$SAMPLE" | jq -r '(.data.issues // .data // [])[]?.tags[]? | "\(.id)|\(.name)"' 2>/dev/null >> "$ID_MAP_FILE" || true
|
||||
|
|
@ -136,13 +143,9 @@ else
|
|||
else
|
||||
log_step "Tagging with '$CHOSEN_LABEL' (ID:$TGT_ID)..."
|
||||
|
||||
# 获取当前 tags,追加新标签(去重)
|
||||
CUR_TAGS=$(echo "$ISSUE_JSON" | jq -c '[.data.tags[]? | {id:.id, name:.name}]' 2>/dev/null || echo "[]")
|
||||
NEW_TAGS=$(echo "$CUR_TAGS" | jq -c --argjson nt "{\"id\":$TGT_ID,\"name\":\"$CHOSEN_LABEL\"}" \
|
||||
'. + [$nt] | unique_by(.id)' 2>/dev/null)
|
||||
|
||||
TAG_RESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||
--body "{\"tags\":$NEW_TAGS}" 2>&1) || true
|
||||
--body "{\"issue_tag_ids\":[$TGT_ID]}" 2>&1) || true
|
||||
|
||||
if echo "$TAG_RESP" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Tagged: $CHOSEN_LABEL"
|
||||
|
|
@ -162,13 +165,28 @@ if [[ -z "$TARGET_ASSIGNEE" ]]; then
|
|||
elif [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would assign @$TARGET_ASSIGNEE"
|
||||
else
|
||||
BODY="{\"assigned_to_id\":\"$TARGET_ASSIGNEE\"}"
|
||||
ARESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" --body "$BODY" 2>&1) || true
|
||||
if echo "$ARESP" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Assigned: @$TARGET_ASSIGNEE"
|
||||
# Resolve login name to numeric user ID
|
||||
ASSIGNEE_ID=""
|
||||
if [[ "$TARGET_ASSIGNEE" =~ ^[0-9]+$ ]]; then
|
||||
ASSIGNEE_ID="$TARGET_ASSIGNEE"
|
||||
else
|
||||
ERR2=$(echo "$ARESP" | jq -r '.error.message // "unknown"' 2>/dev/null || echo "unknown")
|
||||
log_warn "Assign failed: $ERR2"
|
||||
USER_JSON=$(gl_run api GET "/users/$TARGET_ASSIGNEE" 2>/dev/null || true)
|
||||
if [[ -n "$USER_JSON" ]]; then
|
||||
ASSIGNEE_ID=$(echo "$USER_JSON" | jq -r '.data.id // .data.user_id // ""' 2>/dev/null || echo "")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$ASSIGNEE_ID" ]]; then
|
||||
log_warn "Cannot resolve user ID for '$TARGET_ASSIGNEE' — skipping assign"
|
||||
else
|
||||
BODY="{\"assigner_ids\":[$ASSIGNEE_ID]}"
|
||||
ARESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" --body "$BODY" 2>&1) || true
|
||||
if echo "$ARESP" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Assigned: @$TARGET_ASSIGNEE (ID:$ASSIGNEE_ID)"
|
||||
else
|
||||
ERR2=$(echo "$ARESP" | jq -r '.error.message // "unknown"' 2>/dev/null || echo "unknown")
|
||||
log_warn "Assign failed: $ERR2"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ PORT = int(os.environ.get("WEBHOOK_PORT", "8080"))
|
|||
SECRET = os.environ.get("WEBHOOK_SECRET", "")
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
TRIAGE_SCRIPT = os.path.join(SCRIPT_DIR, "01a-issue-triage.sh")
|
||||
GATEKEEPER_SCRIPT = os.path.join(SCRIPT_DIR, "02a-pr-gatekeeper.sh")
|
||||
LOG_DIR = os.path.join(SCRIPT_DIR, "webhook-logs")
|
||||
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
|
@ -60,24 +61,34 @@ def verify_signature(body: bytes, signature_header: str) -> bool:
|
|||
|
||||
def extract_issue_number(payload: dict) -> str:
|
||||
"""从 webhook payload 提取 Issue 编号"""
|
||||
# GitLink 格式
|
||||
issue = payload.get("issue", {})
|
||||
if issue:
|
||||
num = issue.get("project_issues_index") or issue.get("number") or issue.get("id")
|
||||
if num:
|
||||
log(f"Extracted issue number: #{num}", "OK")
|
||||
return str(num)
|
||||
|
||||
# 直接格式
|
||||
if "number" in payload:
|
||||
return str(payload["number"])
|
||||
if "id" in payload:
|
||||
return str(payload["id"])
|
||||
|
||||
log("Could not extract issue number from payload", "WARN")
|
||||
return None
|
||||
|
||||
|
||||
def extract_pr_number(payload: dict) -> str:
|
||||
"""从 webhook payload 提取 PR 编号"""
|
||||
pr = payload.get("pull_request", {}) or payload.get("pullrequest", {})
|
||||
if pr:
|
||||
num = pr.get("number") or pr.get("pull_request_number") or pr.get("id")
|
||||
if num:
|
||||
log(f"Extracted PR number: #{num}", "OK")
|
||||
return str(num)
|
||||
if "pull_request_number" in payload:
|
||||
return str(payload["pull_request_number"])
|
||||
log("Could not extract PR number from payload", "WARN")
|
||||
return None
|
||||
|
||||
|
||||
def extract_repo_info(payload: dict):
|
||||
"""从 payload 提取 owner/repo"""
|
||||
repo = payload.get("repository", {})
|
||||
|
|
@ -116,6 +127,34 @@ def run_triage_async(issue_number: str, owner: str = "", repo: str = ""):
|
|||
t.start()
|
||||
|
||||
|
||||
def run_gatekeeper_async(pr_number: str, owner: str = "", repo: str = ""):
|
||||
"""异步调用 PR gatekeeper 脚本"""
|
||||
def _run():
|
||||
cmd = ["bash", GATEKEEPER_SCRIPT, "--pr-id", pr_number]
|
||||
if owner:
|
||||
cmd += ["--owner", owner]
|
||||
if repo:
|
||||
cmd += ["--repo", repo]
|
||||
|
||||
log(f"Dispatching gatekeeper: {' '.join(cmd)}", "INFO")
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, cwd=SCRIPT_DIR)
|
||||
outfile = os.path.join(LOG_DIR, f"gatekeeper-{pr_number}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log")
|
||||
with open(outfile, "w", encoding="utf-8") as f:
|
||||
f.write(f"=== STDOUT ===\n{result.stdout}\n=== STDERR ===\n{result.stderr}\n")
|
||||
if result.returncode == 0:
|
||||
log(f"Gatekeeper PR #{pr_number} completed successfully -> {outfile}", "OK")
|
||||
else:
|
||||
log(f"Gatekeeper PR #{pr_number} failed (exit={result.returncode}) -> {outfile}", "ERR")
|
||||
except subprocess.TimeoutExpired:
|
||||
log(f"Gatekeeper PR #{pr_number} TIMEOUT after 300s", "ERR")
|
||||
except Exception as e:
|
||||
log(f"Gatekeeper PR #{pr_number} error: {e}", "ERR")
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
class WebhookHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
log(f"{self.client_address[0]} - {format % args}", "INFO")
|
||||
|
|
@ -129,7 +168,7 @@ h1{{color:#333}}.ok{{color:green;font-weight:bold}}code{{background:#f0f0f0;padd
|
|||
</head><body>
|
||||
<h1>GitLink Community Ops — Webhook Listener</h1>
|
||||
<p class="ok">✓ Running</p>
|
||||
<p>Listening for <code>issue</code> events at <code>/webhook</code></p>
|
||||
<p>Listening for <code>issue</code> and <code>pull_request</code> events at <code>/webhook</code></p>
|
||||
<p><small>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Port: {PORT} | HMAC: {'enabled' if SECRET else 'disabled'}</small></p>
|
||||
</body></html>"""
|
||||
self._respond(200, html, "text/html")
|
||||
|
|
@ -149,12 +188,6 @@ h1{{color:#333}}.ok{{color:green;font-weight:bold}}code{{background:#f0f0f0;padd
|
|||
self._respond(403, '{"error":"Invalid signature"}')
|
||||
return
|
||||
|
||||
# 只处理 issue 事件
|
||||
if "issue" not in event_type.lower():
|
||||
log(f"Ignoring non-issue event: {event_type}", "INFO")
|
||||
self._respond(200, '{"status":"ignored","reason":"non-issue event"}')
|
||||
return
|
||||
|
||||
# 解析 payload
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
|
|
@ -163,28 +196,45 @@ h1{{color:#333}}.ok{{color:green;font-weight:bold}}code{{background:#f0f0f0;padd
|
|||
self._respond(400, '{"error":"Invalid JSON"}')
|
||||
return
|
||||
|
||||
# 只处理 "opened" 动作
|
||||
action = payload.get("action", "")
|
||||
if action and action != "opened":
|
||||
log(f"Ignoring issue event with action: {action}", "INFO")
|
||||
self._respond(200, f'{{"status":"ignored","reason":"action={action}"}}')
|
||||
is_issue = "issue" in event_type.lower()
|
||||
is_pr = "pull_request" in event_type.lower() or "pullrequest" in event_type.lower()
|
||||
|
||||
if not is_issue and not is_pr:
|
||||
log(f"Ignoring unsupported event: {event_type}", "INFO")
|
||||
self._respond(200, '{"status":"ignored","reason":"unsupported event type"}')
|
||||
return
|
||||
|
||||
# 提取 Issue 编号
|
||||
issue_number = extract_issue_number(payload)
|
||||
if not issue_number:
|
||||
self._respond(400, '{"error":"Cannot extract issue number"}')
|
||||
# 只处理 "opened" 动作
|
||||
if action and action != "opened":
|
||||
log(f"Ignoring {event_type} event with action: {action}", "INFO")
|
||||
self._respond(200, f'{{"status":"ignored","reason":"action={action}"}}')
|
||||
return
|
||||
|
||||
# 提取 owner/repo
|
||||
owner, repo = extract_repo_info(payload)
|
||||
|
||||
log(f"=== New Issue #{issue_number} — dispatching to triage ===", "OK")
|
||||
# ── Issue 事件 → 调起分类 ──
|
||||
if is_issue:
|
||||
issue_number = extract_issue_number(payload)
|
||||
if not issue_number:
|
||||
self._respond(400, '{"error":"Cannot extract issue number"}')
|
||||
return
|
||||
log(f"=== New Issue #{issue_number} — dispatching to triage ===", "OK")
|
||||
run_triage_async(issue_number, owner or "", repo or "")
|
||||
self._respond(200, f'{{"status":"accepted","issue_number":{issue_number}}}')
|
||||
return
|
||||
|
||||
# 异步调起分类
|
||||
run_triage_async(issue_number, owner or "", repo or "")
|
||||
|
||||
self._respond(200, f'{{"status":"accepted","issue_number":{issue_number}}}')
|
||||
# ── PR 事件 → 调起 gatekeeper ──
|
||||
if is_pr:
|
||||
pr_number = extract_pr_number(payload)
|
||||
if not pr_number:
|
||||
self._respond(400, '{"error":"Cannot extract PR number"}')
|
||||
return
|
||||
log(f"=== New PR #{pr_number} — dispatching to gatekeeper ===", "OK")
|
||||
run_gatekeeper_async(pr_number, owner or "", repo or "")
|
||||
self._respond(200, f'{{"status":"accepted","pr_number":{pr_number}}}')
|
||||
return
|
||||
|
||||
def _respond(self, code, body, content_type="application/json"):
|
||||
self.send_response(code)
|
||||
|
|
@ -202,7 +252,8 @@ def main():
|
|||
print()
|
||||
|
||||
log(f"Starting on port {PORT}")
|
||||
log(f"Triage script: {TRIAGE_SCRIPT}")
|
||||
log(f" Issue -> {os.path.basename(TRIAGE_SCRIPT)}")
|
||||
log(f" PR -> {os.path.basename(GATEKEEPER_SCRIPT)}")
|
||||
log(f"HMAC verification: {'ENABLED' if SECRET else 'DISABLED (set WEBHOOK_SECRET env var)'}")
|
||||
print()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,567 @@
|
|||
# ----------------------------------------------------------------
|
||||
# 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
|
||||
}
|
||||
|
|
@ -403,9 +403,9 @@ $(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "**PASS** - Score $TOTAL_SCORE
|
|||
*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*"
|
||||
|
||||
log_step "Posting review comment..."
|
||||
REVIEW_EVENT=$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "APPROVE"; else echo "COMMENT"; fi)
|
||||
REVIEW_JSON=$(jq -n --arg body "$REVIEW_BODY" --arg event "$REVIEW_EVENT" \
|
||||
'{body: $body, event: $event}')
|
||||
REVIEW_STATE=$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "approved"; else echo "commented"; fi)
|
||||
REVIEW_JSON=$(jq -n --arg body "$REVIEW_BODY" --arg state "$REVIEW_STATE" \
|
||||
'{body: $body, state: $state}')
|
||||
REVIEW_RESULT=$(gl_run api POST "/$OWNER/$REPO/pulls/$pr_id/reviews" \
|
||||
--body "$REVIEW_JSON" 2>&1) || true
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,413 @@
|
|||
# ----------------------------------------------------------------
|
||||
# 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
|
||||
|
|
@ -0,0 +1,603 @@
|
|||
#!/usr/bin/env bash
|
||||
# ================================================================
|
||||
# Scenario 2a: Real-Time PR Gatekeeper (Webhook 触发)
|
||||
# Flow: Webhook 推送 PR → 拉取详情 → AI Review → 查 CI → 发评论 → 达标自动合并
|
||||
# ================================================================
|
||||
set -eo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
PR_ID=""
|
||||
OWNER=""
|
||||
REPO=""
|
||||
THRESHOLD=80
|
||||
DRY_RUN=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --pr-id N [--owner OWNER] [--repo REPO] [--threshold SCORE] [--dry-run]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pr-id) PR_ID="$2"; shift 2 ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--threshold) THRESHOLD="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$PR_ID" ]] && { log_err "--pr-id is required"; usage; }
|
||||
|
||||
check_auth
|
||||
require_owner_repo
|
||||
|
||||
# ================================================================
|
||||
log_title "PR Gatekeeper: #$PR_ID ($OWNER/$REPO)"
|
||||
|
||||
# ── Step 1: 拉取 PR 详情 ─────────────────────────────────────────
|
||||
log_step "Fetching PR details..."
|
||||
PR_JSON=$(gl_check pr +view --owner "$OWNER" --repo "$REPO" --id "$PR_ID")
|
||||
[[ -z "$PR_JSON" ]] && { log_err "Failed to fetch PR #$PR_ID"; exit 1; }
|
||||
|
||||
PR_TITLE=$(echo "$PR_JSON" | jq -r '.data.title // .data.subject // .data.issue.subject // "N/A"')
|
||||
PR_STATE=$(echo "$PR_JSON" | jq -r '.data.state // .data.status // "N/A"')
|
||||
PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.data.author.login // .data.author.username // .data.issue.author.login // "N/A"')
|
||||
ISSUE_ID=$(echo "$PR_JSON" | jq -r '.data.id // .data.issue.id // empty')
|
||||
PR_HEAD=$(echo "$PR_JSON" | jq -r '.data.pull_request.head // empty')
|
||||
log_ok "PR #$PR_ID: \"$PR_TITLE\" by @$PR_AUTHOR (state: $PR_STATE, branch: $PR_HEAD)"
|
||||
|
||||
# ── Step 2: 获取变更文件列表 ──────────────────────────────────────
|
||||
log_step "Fetching changed files..."
|
||||
FILES_JSON=$(gl_run pr +files --owner "$OWNER" --repo "$REPO" --id "$PR_ID")
|
||||
FILE_COUNT=0
|
||||
if echo "$FILES_JSON" | jq empty 2>/dev/null; then
|
||||
FILE_COUNT=$(echo "$FILES_JSON" | jq '.data.files | length' 2>/dev/null || echo "0")
|
||||
fi
|
||||
log_ok "Changed files: $FILE_COUNT"
|
||||
|
||||
FILE_LIST=""
|
||||
if [[ "$FILE_COUNT" -gt 0 ]] && [[ "$FILE_COUNT" != "null" ]]; then
|
||||
for i in $(seq 0 $((FILE_COUNT - 1))); do
|
||||
FNAME=$(echo "$FILES_JSON" | jq -r ".data.files[$i].name // .data.files[$i].filename // \"unknown\"" 2>/dev/null || echo "unknown")
|
||||
echo " $FNAME"
|
||||
FILE_LIST+="- $FNAME"$'\n'
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Step 3: 获取 diff ────────────────────────────────────────────
|
||||
log_step "Fetching diff..."
|
||||
DIFF_JSON=$(gl_run pr +diff --owner "$OWNER" --repo "$REPO" --id "$PR_ID")
|
||||
DIFF_CONTENT=""
|
||||
if echo "$DIFF_JSON" | jq empty 2>/dev/null; then
|
||||
DIFF_CONTENT=$(echo "$DIFF_JSON" | jq -r '
|
||||
[.data.files[]?.sections[]?.lines[]?.content // empty] | join("\n")
|
||||
' 2>/dev/null | head -c 5000 || true)
|
||||
fi
|
||||
DIFF_LINES=$(echo "$DIFF_CONTENT" | wc -l)
|
||||
log_ok "Diff: $DIFF_LINES lines"
|
||||
|
||||
# ── Step 4: AI 代码审查 ───────────────────────────────────────────
|
||||
log_step "AI analyzing code quality..."
|
||||
|
||||
DIFF_TRUNCATED=$(echo "$DIFF_CONTENT" | head -c 4000)
|
||||
|
||||
# Load skill dimensions from SKILL.md
|
||||
SKILL_DIR="$SCRIPT_DIR/../skills/gitlink-code-review"
|
||||
SKILL_DIMENSIONS=""
|
||||
if [[ -f "$SKILL_DIR/SKILL.md" ]]; then
|
||||
SKILL_DIMENSIONS=$(sed -n '/^## 📊 审查维度/,/^## 🔧 使用方式/p' "$SKILL_DIR/SKILL.md" | grep '^\- \*\*' | head -20)
|
||||
fi
|
||||
|
||||
REVIEW_PROMPT="你是代码审查专家。请按 gitlink-code-review skill 的审查维度分析以下 PR。
|
||||
|
||||
## 审查维度与检查项
|
||||
|
||||
${SKILL_DIMENSIONS:-1. 代码质量: 复杂度、命名、注释、格式
|
||||
2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证
|
||||
3. 性能: 循环效率、资源泄漏、N+1查询、内存
|
||||
4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖}
|
||||
|
||||
## 评分标准
|
||||
- 90-100: 优秀,可直接合并
|
||||
- 75-89: 良好,建议合并
|
||||
- 60-74: 一般,需要改进
|
||||
- <60: 较差,不建议合并
|
||||
|
||||
## 问题严重级别
|
||||
- CRITICAL: 阻止合并
|
||||
- HIGH: 强烈建议修复
|
||||
- MEDIUM: 建议修复
|
||||
- LOW: 可选修复
|
||||
|
||||
## PR 数据
|
||||
|
||||
PR 标题: $PR_TITLE
|
||||
变更文件:
|
||||
$FILE_LIST
|
||||
代码差异:
|
||||
$DIFF_TRUNCATED
|
||||
|
||||
## 输出要求
|
||||
|
||||
请严格按以下 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\"}"
|
||||
|
||||
AI_AVAILABLE=false
|
||||
SCORE_QUALITY=25
|
||||
SCORE_SECURITY=25
|
||||
SCORE_PERFORMANCE=25
|
||||
SCORE_MAINTAINABILITY=25
|
||||
ISSUES_FOUND=()
|
||||
AI_POSITIVE=()
|
||||
AI_RECOMMENDATIONS=()
|
||||
TOTAL_SCORE=0
|
||||
|
||||
if command -v claude &>/dev/null; then
|
||||
log_info "Calling AI agent for code review (may take 30-60s)..."
|
||||
PROMPT_FILE=$(mktemp)
|
||||
AI_OUT_FILE=$(mktemp)
|
||||
echo "$REVIEW_PROMPT" > "$PROMPT_FILE"
|
||||
|
||||
if [[ -z "${CLAUDE_CODE_GIT_BASH_PATH:-}" ]] && command -v cygpath &>/dev/null; then
|
||||
export CLAUDE_CODE_GIT_BASH_PATH="$(cygpath -w "$(which bash)")"
|
||||
fi
|
||||
|
||||
AI_EXIT=0
|
||||
(cat "$PROMPT_FILE" | timeout 300 claude -p --output-format json > "$AI_OUT_FILE" 2>/dev/null) || AI_EXIT=$?
|
||||
|
||||
if [[ $AI_EXIT -eq 0 ]] && [[ -s "$AI_OUT_FILE" ]]; then
|
||||
AI_RESULT=$(jq -r '.result // empty' "$AI_OUT_FILE" 2>/dev/null)
|
||||
else
|
||||
log_warn "AI call failed (exit: $AI_EXIT), falling back to keyword-based"
|
||||
AI_RESULT=""
|
||||
fi
|
||||
rm -f "$PROMPT_FILE" "$AI_OUT_FILE"
|
||||
|
||||
if [[ -n "$AI_RESULT" ]]; then
|
||||
AI_JSON=""
|
||||
if command -v python3 &>/dev/null; then
|
||||
AI_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
text = sys.stdin.read()
|
||||
depth = 0
|
||||
start = -1
|
||||
results = []
|
||||
for i, c in enumerate(text):
|
||||
if c == '{':
|
||||
if depth == 0: start = i
|
||||
depth += 1
|
||||
elif c == '}':
|
||||
depth -= 1
|
||||
if depth == 0 and start >= 0:
|
||||
results.append(text[start:i+1])
|
||||
start = -1
|
||||
for m in reversed(results):
|
||||
try:
|
||||
obj = json.loads(m)
|
||||
if 'total' in obj and 'verdict' in obj:
|
||||
print(json.dumps(obj))
|
||||
break
|
||||
except: pass
|
||||
" <<< "$AI_RESULT" 2>/dev/null)
|
||||
fi
|
||||
if [[ -z "$AI_JSON" ]]; then
|
||||
AI_JSON=$(echo "$AI_RESULT" | grep -oP '\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}' | tail -1)
|
||||
fi
|
||||
|
||||
if [[ -n "$AI_JSON" ]] && echo "$AI_JSON" | jq empty 2>/dev/null; then
|
||||
TOTAL_SCORE=$(echo "$AI_JSON" | jq -r '.total // 0' 2>/dev/null)
|
||||
SCORE_QUALITY=$(echo "$AI_JSON" | jq -r '.quality // 0' 2>/dev/null)
|
||||
SCORE_SECURITY=$(echo "$AI_JSON" | jq -r '.security // 0' 2>/dev/null)
|
||||
SCORE_PERFORMANCE=$(echo "$AI_JSON" | jq -r '.performance // 0' 2>/dev/null)
|
||||
SCORE_MAINTAINABILITY=$(echo "$AI_JSON" | jq -r '.maintainability // 0' 2>/dev/null)
|
||||
AI_VERDICT=$(echo "$AI_JSON" | jq -r '.verdict // "PASS"' 2>/dev/null)
|
||||
|
||||
ISSUE_COUNT=$(echo "$AI_JSON" | jq '.issues | length' 2>/dev/null || echo "0")
|
||||
if [[ "$ISSUE_COUNT" -gt 0 ]] && [[ "$ISSUE_COUNT" != "null" ]]; then
|
||||
for idx in $(seq 0 $((ISSUE_COUNT - 1))); do
|
||||
issue=$(echo "$AI_JSON" | jq -r '
|
||||
if .issues['"$idx"'] | type == "object" then
|
||||
"[" + (.issues['"$idx"'].severity // "?") + "] " +
|
||||
(.issues['"$idx"'].category // "?") + ": " +
|
||||
(.issues['"$idx"'].description // .issues['"$idx"'].rule // "unknown") +
|
||||
(if .issues['"$idx"'].file then " (" + .issues['"$idx"'].file + ")" else "" end) +
|
||||
(if .issues['"$idx"'].suggestion then " -> " + .issues['"$idx"'].suggestion else "" end)
|
||||
else .issues['"$idx"'] // empty end' 2>/dev/null)
|
||||
[[ -n "$issue" ]] && ISSUES_FOUND+=("$issue")
|
||||
done
|
||||
fi
|
||||
|
||||
POS_COUNT=$(echo "$AI_JSON" | jq '.positive_notes | length' 2>/dev/null || echo "0")
|
||||
if [[ "$POS_COUNT" -gt 0 ]] && [[ "$POS_COUNT" != "null" ]]; then
|
||||
for idx in $(seq 0 $((POS_COUNT - 1))); do
|
||||
note=$(echo "$AI_JSON" | jq -r '.positive_notes['"$idx"'].description // empty' 2>/dev/null)
|
||||
[[ -n "$note" ]] && AI_POSITIVE+=("$note")
|
||||
done
|
||||
fi
|
||||
|
||||
REC_COUNT=$(echo "$AI_JSON" | jq '.recommendations | length' 2>/dev/null || echo "0")
|
||||
if [[ "$REC_COUNT" -gt 0 ]] && [[ "$REC_COUNT" != "null" ]]; then
|
||||
for idx in $(seq 0 $((REC_COUNT - 1))); do
|
||||
rec=$(echo "$AI_JSON" | jq -r '.recommendations['"$idx"'] // empty' 2>/dev/null)
|
||||
[[ -n "$rec" ]] && AI_RECOMMENDATIONS+=("$rec")
|
||||
done
|
||||
fi
|
||||
|
||||
AI_AVAILABLE=true
|
||||
log_ok "AI review complete (verdict: $AI_VERDICT)"
|
||||
else
|
||||
log_warn "Could not parse AI response JSON, falling back to keyword-based"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: keyword-based heuristics
|
||||
if [[ "$AI_AVAILABLE" != "true" ]]; then
|
||||
log_warn "AI not available, falling back to keyword-based analysis"
|
||||
|
||||
SCORE_QUALITY=25
|
||||
SCORE_SECURITY=25
|
||||
SCORE_PERFORMANCE=25
|
||||
SCORE_MAINTAINABILITY=25
|
||||
ISSUES_FOUND=()
|
||||
|
||||
if echo "$DIFF_CONTENT" | grep -qiE 'password|secret|token|api_key|apikey|private_key'; then
|
||||
SCORE_SECURITY=$((SCORE_SECURITY - 15))
|
||||
ISSUES_FOUND+=("SECURITY: 检测到可能的硬编码凭证")
|
||||
fi
|
||||
if echo "$DIFF_CONTENT" | grep -qiE 'eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call'; then
|
||||
SCORE_SECURITY=$((SCORE_SECURITY - 10))
|
||||
ISSUES_FOUND+=("SECURITY: 检测到危险函数调用")
|
||||
fi
|
||||
if echo "$DIFF_CONTENT" | grep -qiE 'TODO|FIXME|HACK|XXX'; then
|
||||
SCORE_QUALITY=$((SCORE_QUALITY - 5))
|
||||
ISSUES_FOUND+=("QUALITY: 存在 TODO/FIXME/HACK 注释")
|
||||
fi
|
||||
if echo "$DIFF_CONTENT" | grep -qiE 'SELECT \*|\.findAll\(\)|\.all\(\)'; then
|
||||
SCORE_PERFORMANCE=$((SCORE_PERFORMANCE - 10))
|
||||
ISSUES_FOUND+=("PERFORMANCE: 可能的全表查询")
|
||||
fi
|
||||
if echo "$DIFF_CONTENT" | grep -qiE 'sleep\(|time\.sleep|Thread\.sleep'; then
|
||||
SCORE_PERFORMANCE=$((SCORE_PERFORMANCE - 5))
|
||||
ISSUES_FOUND+=("PERFORMANCE: 检测到阻塞式 sleep")
|
||||
fi
|
||||
if [[ "$FILE_COUNT" -gt 20 ]]; then
|
||||
SCORE_MAINTAINABILITY=$((SCORE_MAINTAINABILITY - 10))
|
||||
ISSUES_FOUND+=("MAINTAINABILITY: 变更文件数量过多 ($FILE_COUNT)")
|
||||
fi
|
||||
|
||||
TOTAL_SCORE=$((SCORE_QUALITY + SCORE_SECURITY + SCORE_PERFORMANCE + SCORE_MAINTAINABILITY))
|
||||
TOTAL_SCORE=$((TOTAL_SCORE < 0 ? 0 : TOTAL_SCORE))
|
||||
fi
|
||||
|
||||
# ── 打印审查报告 ──────────────────────────────────────────────────
|
||||
divider
|
||||
if [[ "$AI_AVAILABLE" == "true" ]]; then
|
||||
log_info "AI Review Report for PR #$PR_ID"
|
||||
else
|
||||
log_info "Review Report for PR #$PR_ID (keyword-based)"
|
||||
fi
|
||||
echo ""
|
||||
echo " Overall Score: $TOTAL_SCORE / 100"
|
||||
echo " Code Quality: $SCORE_QUALITY / 25"
|
||||
echo " Security: $SCORE_SECURITY / 25"
|
||||
echo " Performance: $SCORE_PERFORMANCE / 25"
|
||||
echo " Maintainability: $SCORE_MAINTAINABILITY / 25"
|
||||
echo ""
|
||||
|
||||
if [[ ${#ISSUES_FOUND[@]} -gt 0 ]]; then
|
||||
echo " Issues Found:"
|
||||
for issue in "${ISSUES_FOUND[@]}"; do
|
||||
echo " - $issue"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "${#AI_POSITIVE[@]}" -gt 0 ]]; then
|
||||
echo " Positive Notes:"
|
||||
for note in "${AI_POSITIVE[@]}"; do
|
||||
echo " + $note"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "${#AI_RECOMMENDATIONS[@]}" -gt 0 ]]; then
|
||||
echo " Recommendations:"
|
||||
for rec in "${AI_RECOMMENDATIONS[@]}"; do
|
||||
echo " > $rec"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Step 5: 发布审查评论 ──────────────────────────────────────────
|
||||
if [[ "$AI_AVAILABLE" == "true" ]]; then
|
||||
REVIEW_HEADER="## AI Code Quality Review - PR #$PR_ID"
|
||||
else
|
||||
REVIEW_HEADER="## Code Quality Review - PR #$PR_ID (keyword-based)"
|
||||
fi
|
||||
|
||||
REVIEW_BODY="$REVIEW_HEADER
|
||||
|
||||
### Scores
|
||||
| Dimension | Score | Max |
|
||||
|-----------|-------|-----|
|
||||
| Code Quality | $SCORE_QUALITY | 25 |
|
||||
| Security | $SCORE_SECURITY | 25 |
|
||||
| Performance | $SCORE_PERFORMANCE | 25 |
|
||||
| Maintainability | $SCORE_MAINTAINABILITY | 25 |
|
||||
| **Total** | **$TOTAL_SCORE** | **100** |
|
||||
|
||||
### Issues Found"
|
||||
|
||||
if [[ ${#ISSUES_FOUND[@]} -gt 0 ]]; then
|
||||
for issue in "${ISSUES_FOUND[@]}"; do
|
||||
REVIEW_BODY+=$'\n'"- $issue"
|
||||
done
|
||||
else
|
||||
REVIEW_BODY+=$'\n'"No issues found."
|
||||
fi
|
||||
|
||||
if [[ "${#AI_POSITIVE[@]}" -gt 0 ]]; then
|
||||
REVIEW_BODY+=$'\n'$'\n'"### Positive Notes"
|
||||
for note in "${AI_POSITIVE[@]}"; do
|
||||
REVIEW_BODY+=$'\n'"- $note"
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "${#AI_RECOMMENDATIONS[@]}" -gt 0 ]]; then
|
||||
REVIEW_BODY+=$'\n'$'\n'"### Recommendations"
|
||||
for rec in "${AI_RECOMMENDATIONS[@]}"; do
|
||||
REVIEW_BODY+=$'\n'"- $rec"
|
||||
done
|
||||
fi
|
||||
|
||||
REVIEW_BODY+="
|
||||
|
||||
### Verdict
|
||||
$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "**PASS** - Score $TOTAL_SCORE >= threshold $THRESHOLD. Ready to merge."; else echo "**FAIL** - Score $TOTAL_SCORE < threshold $THRESHOLD. Please address the issues above."; fi)
|
||||
|
||||
---
|
||||
*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*"
|
||||
|
||||
log_step "Posting review comment..."
|
||||
REVIEW_JSON=$(jq -n --arg notes "$REVIEW_BODY" '{notes: $notes}')
|
||||
REVIEW_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \
|
||||
--body "$REVIEW_JSON" 2>&1) || true
|
||||
|
||||
if [[ "$(json_ok "$REVIEW_RESULT")" == "true" ]]; then
|
||||
log_ok "Review comment posted"
|
||||
else
|
||||
log_warn "Review comment may have failed"
|
||||
fi
|
||||
|
||||
# ── Step 6: 本地 CI (编译 + 测试) ─────────────────────────────────
|
||||
log_step "Running local CI: build & test..."
|
||||
|
||||
REPOS_DIR="/opt/gitlink-webhook/repos"
|
||||
REPO_CLONE_DIR="$REPOS_DIR/$REPO"
|
||||
GIT_URL="https://www.gitlink.org.cn/${OWNER}/${REPO}.git"
|
||||
|
||||
BUILD_LOG=$(mktemp)
|
||||
TEST_LOG=$(mktemp)
|
||||
CI_BUILD_PASSED=false
|
||||
CI_TEST_PASSED=false
|
||||
CI_PASSED=false
|
||||
|
||||
if [[ -z "$PR_HEAD" ]]; then
|
||||
log_warn "Cannot determine PR head branch, skipping CI"
|
||||
CI_BUILD_OUTPUT="CI skipped: unknown PR branch"
|
||||
CI_TEST_OUTPUT="CI skipped: unknown PR branch"
|
||||
else
|
||||
# Clone repo if not exists, otherwise fetch latest
|
||||
if [[ ! -d "$REPO_CLONE_DIR/.git" ]]; then
|
||||
mkdir -p "$REPOS_DIR"
|
||||
log_info "Cloning $GIT_URL..."
|
||||
git clone "$GIT_URL" "$REPO_CLONE_DIR" 2>&1 || true
|
||||
fi
|
||||
|
||||
{
|
||||
cd "$REPO_CLONE_DIR"
|
||||
log_info "Fetching and checking out branch '$PR_HEAD'..."
|
||||
git fetch origin 2>&1 || true
|
||||
git checkout "$PR_HEAD" 2>&1 || true
|
||||
git pull origin "$PR_HEAD" 2>&1 || true
|
||||
|
||||
log_info "Running: go build ./..."
|
||||
if go build ./... >> "$BUILD_LOG" 2>&1; then
|
||||
CI_BUILD_PASSED=true
|
||||
log_ok "Build passed"
|
||||
else
|
||||
log_err "Build failed"
|
||||
fi
|
||||
|
||||
log_info "Running: go test ./..."
|
||||
if go test ./... >> "$TEST_LOG" 2>&1; then
|
||||
CI_TEST_PASSED=true
|
||||
log_ok "Tests passed"
|
||||
else
|
||||
log_err "Tests failed"
|
||||
fi
|
||||
}
|
||||
|
||||
CI_BUILD_OUTPUT=$(tail -c 3000 "$BUILD_LOG" 2>/dev/null || echo "no output")
|
||||
CI_TEST_OUTPUT=$(tail -c 3000 "$TEST_LOG" 2>/dev/null || echo "no output")
|
||||
fi
|
||||
|
||||
if $CI_BUILD_PASSED && $CI_TEST_PASSED; then
|
||||
CI_PASSED=true
|
||||
CI_SUMMARY="| Build | PASSED | | |\n| Tests | PASSED | | |"
|
||||
elif $CI_BUILD_PASSED && ! $CI_TEST_PASSED; then
|
||||
CI_SUMMARY="| Build | PASSED | | |\n| Tests | **FAILED** | | |"
|
||||
elif ! $CI_BUILD_PASSED && $CI_TEST_PASSED; then
|
||||
CI_SUMMARY="| Build | **FAILED** | | |\n| Tests | PASSED | | |"
|
||||
else
|
||||
CI_SUMMARY="| Build | **FAILED** | | |\n| Tests | **FAILED** | | |"
|
||||
fi
|
||||
|
||||
# Post CI results as a follow-up comment
|
||||
log_step "Posting CI results..."
|
||||
CI_BODY="## CI Results - PR #$PR_ID
|
||||
|
||||
### Build & Test
|
||||
| Stage | Status |
|
||||
|-------|--------|
|
||||
$CI_SUMMARY
|
||||
|
||||
### Build Output
|
||||
\`\`\`
|
||||
$(echo "$CI_BUILD_OUTPUT" | tail -20)
|
||||
\`\`\`
|
||||
|
||||
### Test Output
|
||||
\`\`\`
|
||||
$(echo "$CI_TEST_OUTPUT" | tail -20)
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
*Local CI executed on server*"
|
||||
|
||||
CI_COMMENT_JSON=$(jq -n --arg notes "$CI_BODY" '{notes: $notes}')
|
||||
CI_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \
|
||||
--body "$CI_COMMENT_JSON" 2>&1) || true
|
||||
|
||||
if [[ "$(json_ok "$CI_RESULT")" == "true" ]]; then
|
||||
log_ok "CI results posted"
|
||||
else
|
||||
log_warn "CI results post may have failed"
|
||||
fi
|
||||
|
||||
rm -f "$BUILD_LOG" "$TEST_LOG"
|
||||
|
||||
# ── Step 7: 冲突检测 + 自动合并 ───────────────────────────────────
|
||||
MERGED=false
|
||||
MERGE_RESULT_MSG=""
|
||||
CONFLICT_DETECTED=false
|
||||
|
||||
# Build merge summary: determine why merge can/cannot proceed
|
||||
MERGE_BLOCK_REASONS=()
|
||||
if [[ $TOTAL_SCORE -lt $THRESHOLD ]]; then
|
||||
MERGE_BLOCK_REASONS+=("score $TOTAL_SCORE < threshold $THRESHOLD")
|
||||
fi
|
||||
if [[ "$CI_PASSED" != "true" ]]; then
|
||||
MERGE_BLOCK_REASONS+=("CI not passed")
|
||||
fi
|
||||
|
||||
if [[ ${#MERGE_BLOCK_REASONS[@]} -gt 0 ]]; then
|
||||
# Cannot merge: log reasons
|
||||
BLOCK_LIST=$(printf '; %s' "${MERGE_BLOCK_REASONS[@]}")
|
||||
BLOCK_LIST="${BLOCK_LIST:2}"
|
||||
log_warn "Merge blocked: $BLOCK_LIST"
|
||||
else
|
||||
log_step "Score $TOTAL_SCORE >= $THRESHOLD and CI passed — checking mergeability..."
|
||||
|
||||
# Check for merge conflicts
|
||||
if [[ -d "$REPO_CLONE_DIR/.git" ]]; then
|
||||
log_info "Checking merge conflicts with base branch..."
|
||||
(
|
||||
cd "$REPO_CLONE_DIR"
|
||||
# Ensure git identity is set for merge test
|
||||
git config user.email "gatekeeper@gitlink-cli.local" 2>&1 || true
|
||||
git config user.name "gitlink-gatekeeper" 2>&1 || true
|
||||
git fetch origin 2>&1 || true
|
||||
# Determine base branch from PR data
|
||||
BASE_BRANCH=$(echo "$PR_JSON" | jq -r '.data.pull_request.base // .data.base // "master"')
|
||||
git checkout "$BASE_BRANCH" 2>&1 || true
|
||||
git pull origin "$BASE_BRANCH" 2>&1 || true
|
||||
# Test merge without committing
|
||||
if git merge --no-commit --no-ff "$PR_HEAD" 2>&1; then
|
||||
log_ok "No merge conflicts detected"
|
||||
git merge --abort 2>&1 || true
|
||||
CONFLICT_DETECTED=false
|
||||
else
|
||||
CONFLICT_DETECTED=true
|
||||
CONFLICT_FILES=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "unknown")
|
||||
log_err "Merge conflict detected in: $CONFLICT_FILES"
|
||||
git merge --abort 2>&1 || true
|
||||
fi
|
||||
git checkout "$PR_HEAD" 2>&1 || true
|
||||
)
|
||||
else
|
||||
log_info "Skipping conflict check (repo not cloned)"
|
||||
fi
|
||||
|
||||
if [[ "$CONFLICT_DETECTED" == "true" ]]; then
|
||||
MERGE_RESULT_MSG="Merge blocked: conflicts detected"
|
||||
log_err "$MERGE_RESULT_MSG"
|
||||
elif [[ "$DRY_RUN" == "true" ]]; then
|
||||
MERGE_RESULT_MSG="[DRY RUN] Would auto-merge"
|
||||
log_warn "$MERGE_RESULT_MSG"
|
||||
else
|
||||
log_step "Auto-merging PR #$PR_ID..."
|
||||
MERGE_OUT=$(gl_run pr +merge --owner "$OWNER" --repo "$REPO" --id "$PR_ID" --method merge 2>&1) || true
|
||||
if [[ "$(json_ok "$MERGE_OUT")" == "true" ]]; then
|
||||
MERGED=true
|
||||
MERGE_RESULT_MSG="Merge succeeded"
|
||||
log_ok "PR #$PR_ID merged successfully!"
|
||||
else
|
||||
MERGE_RESULT_MSG="Merge failed: $(json_error "$MERGE_OUT")"
|
||||
log_err "$MERGE_RESULT_MSG"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Step 8: 发布最终汇总评论 ──────────────────────────────────────
|
||||
log_step "Posting final summary..."
|
||||
|
||||
FINAL_BODY="## Gatekeeper Summary - PR #$PR_ID
|
||||
|
||||
| Item | Detail |
|
||||
|------|--------|
|
||||
| Score | **$TOTAL_SCORE / 100** (threshold: $THRESHOLD) |
|
||||
| CI Build | $([[ "$CI_BUILD_PASSED" == "true" ]] && echo "PASSED" || echo "FAILED") |
|
||||
| CI Test | $([[ "$CI_TEST_PASSED" == "true" ]] && echo "PASSED" || echo "FAILED") |
|
||||
| Merge Conflict | $([[ "$CONFLICT_DETECTED" == "true" ]] && echo "**DETECTED**" || echo "None") |
|
||||
| Result | $([[ "$MERGED" == "true" ]] && echo "**MERGED**" || echo "$MERGE_RESULT_MSG") |
|
||||
"
|
||||
|
||||
if [[ ${#MERGE_BLOCK_REASONS[@]} -gt 0 ]]; then
|
||||
FINAL_BODY+="
|
||||
### Blocked Reasons
|
||||
"
|
||||
for reason in "${MERGE_BLOCK_REASONS[@]}"; do
|
||||
FINAL_BODY+="- $reason
|
||||
"
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "$CONFLICT_DETECTED" == "true" ]]; then
|
||||
FINAL_BODY+="
|
||||
### Conflict Files
|
||||
\`\`\`
|
||||
$CONFLICT_FILES
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
FINAL_BODY+="
|
||||
---
|
||||
*Gatekeeper workflow completed at $(date '+%Y-%m-%d %H:%M:%S')*"
|
||||
|
||||
FINAL_JSON=$(jq -n --arg notes "$FINAL_BODY" '{notes: $notes}')
|
||||
FINAL_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \
|
||||
--body "$FINAL_JSON" 2>&1) || true
|
||||
|
||||
if [[ "$(json_ok "$FINAL_RESULT")" == "true" ]]; then
|
||||
log_ok "Final summary posted"
|
||||
else
|
||||
log_warn "Final summary post may have failed"
|
||||
fi
|
||||
|
||||
# ── Complete ──────────────────────────────────────────────────────
|
||||
log_title "Gatekeeper Complete"
|
||||
echo -e "${GREEN} PR: #$PR_ID - $PR_TITLE${NC}"
|
||||
echo -e "${GREEN} Author: @$PR_AUTHOR${NC}"
|
||||
echo -e "${GREEN} Score: $TOTAL_SCORE / 100${NC}"
|
||||
echo -e "${GREEN} CI Passed: $CI_PASSED${NC}"
|
||||
echo -e "${GREEN} Conflict: $CONFLICT_DETECTED${NC}"
|
||||
echo -e "${GREEN} Merged: $( [[ "$MERGED" == "true" ]] && echo 'YES' || echo 'NO')${NC}"
|
||||
Loading…
Reference in New Issue