完成两条自动化工作流:社区运营自动化 + 代码质量看门人 #34

Merged
zzx-coder merged 1 commits from mc_branch into master 2026-07-10 11:10:02 +08:00
9 changed files with 1934 additions and 53 deletions

View File

@ -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++

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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

View File

@ -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()

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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}"