diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 5fa4a2f..54aef1e 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -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++ diff --git a/workflows/01-community-ops.ps1 b/workflows/01-community-ops.ps1 index 4a968ed..f498864 100644 --- a/workflows/01-community-ops.ps1 +++ b/workflows/01-community-ops.ps1 @@ -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 diff --git a/workflows/01a-issue-triage.ps1 b/workflows/01a-issue-triage.ps1 index 4ed5b04..b93c285 100644 --- a/workflows/01a-issue-triage.ps1 +++ b/workflows/01a-issue-triage.ps1 @@ -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" + } } } diff --git a/workflows/01a-issue-triage.sh b/workflows/01a-issue-triage.sh index 989f539..2381e06 100644 --- a/workflows/01a-issue-triage.sh +++ b/workflows/01a-issue-triage.sh @@ -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 diff --git a/workflows/01a-webhook-listener.py b/workflows/01a-webhook-listener.py index 2a3da6f..4db54c6 100644 --- a/workflows/01a-webhook-listener.py +++ b/workflows/01a-webhook-listener.py @@ -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

GitLink Community Ops — Webhook Listener

✓ Running

-

Listening for issue events at /webhook

+

Listening for issue and pull_request events at /webhook

{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Port: {PORT} | HMAC: {'enabled' if SECRET else 'disabled'}

""" 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() diff --git a/workflows/02-code-quality-gatekeeper.ps1 b/workflows/02-code-quality-gatekeeper.ps1 new file mode 100644 index 0000000..2847a5d --- /dev/null +++ b/workflows/02-code-quality-gatekeeper.ps1 @@ -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 +} diff --git a/workflows/02-code-quality-gatekeeper.sh b/workflows/02-code-quality-gatekeeper.sh index 3555289..9e39f2d 100644 --- a/workflows/02-code-quality-gatekeeper.sh +++ b/workflows/02-code-quality-gatekeeper.sh @@ -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 diff --git a/workflows/02a-pr-gatekeeper.ps1 b/workflows/02a-pr-gatekeeper.ps1 new file mode 100644 index 0000000..d825e21 --- /dev/null +++ b/workflows/02a-pr-gatekeeper.ps1 @@ -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 diff --git a/workflows/02a-pr-gatekeeper.sh b/workflows/02a-pr-gatekeeper.sh new file mode 100644 index 0000000..0a97da3 --- /dev/null +++ b/workflows/02a-pr-gatekeeper.sh @@ -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}"