Files
xiaoxia-saas/scripts/ci/pr_auto_scan.py
xiaoxia 974ca188b3
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Check push changed paths (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
fix(ci): sync REQUIRED_CONTEXTS_APPROVE with CI optimized job names
2026-09-01 10:30:23 +08:00

440 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
PR自动扫描器:扫描所有open PR,对CI全绿的进行自动审批/合并
作为短作业模式的兜底机制,每5分钟运行一次
新增:AI审查联动 - AI代码审查发现严重问题时,不自动审批
"""
import argparse
import json
import re
import socket
import time
import urllib.error
import urllib.request
# 单次HTTP请求超时(秒),防止网络异常时永久阻塞占住runner
HTTP_TIMEOUT = 15
# 整次扫描墙钟上限(秒),到点主动退出(Gitea 1.26的timeout-minutes不可靠,脚本自保)
DEFAULT_WALL_SECONDS = 240
def api_request(token, repo, endpoint, method="GET", data=None):
"""Gitea API请求"""
url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/{endpoint}"
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
# 跳过SSL验证
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
resp = urllib.request.urlopen(req, context=ctx, timeout=HTTP_TIMEOUT)
return json.loads(resp.read().decode()), resp.status
except urllib.error.HTTPError as e:
body = e.read().decode()
if body:
try:
return json.loads(body), e.code
except json.JSONDecodeError:
return {"error": body}, e.code
return {"error": str(e)}, e.code
except (urllib.error.URLError, socket.timeout, TimeoutError, OSError) as e:
# 网络不可达/超时:返回599让调用方按失败处理,绝不永久挂起
return {"error": f"request-failed: {e}"}, 599
def get_open_prs(token, repo, base="develop"):
"""获取所有open的PR"""
prs = []
page = 1
while True:
data, code = api_request(token, repo, f"pulls?state=open&base={base}&sort=recentupdate&per_page=50&page={page}")
if code != 200 or not isinstance(data, list) or len(data) == 0:
break
prs.extend(data)
if len(data) < 50:
break
page += 1
return prs
def get_commit_status(token, repo, sha):
"""获取commit的CI状态汇总"""
data, code = api_request(token, repo, f"commits/{sha}/status")
if code != 200:
return {}, "error"
return data, data.get("state", "unknown")
def check_required_contexts(token, repo, sha, contexts):
"""检查指定的context是否都通过"""
data, _ = get_commit_status(token, repo, sha)
statuses = {s["context"]: s["status"] for s in data.get("statuses", [])}
all_success = True
any_pending = False
any_failed = False
for ctx in contexts:
state = statuses.get(ctx, "pending")
if state not in ("success", "skipped"):
all_success = False
if state == "pending":
any_pending = True
if state in ("failure", "error"):
any_failed = True
return all_success, any_pending, any_failed, statuses
def get_pr_files(token, repo, pr_number):
"""获取PR变更文件"""
files = []
page = 1
while True:
data, code = api_request(token, repo, f"pulls/{pr_number}/files?per_page=300&page={page}")
if code != 200 or not isinstance(data, list) or len(data) == 0:
break
files.extend(data)
if len(data) < 300:
break
page += 1
return [f["filename"] for f in files]
def is_frontend_only(files):
"""判断是否纯前端改动"""
if not files:
return False
frontend_count = sum(1 for f in files if f.startswith("apps/web/"))
backend_count = len(files) - frontend_count
return backend_count == 0 and frontend_count > 0
def has_approval(token, repo, pr_number):
"""检查PR是否已有审批"""
reviews, code = api_request(token, repo, f"pulls/{pr_number}/reviews")
if code != 200:
return False
return any(r.get("state") == "APPROVED" for r in reviews if isinstance(r, dict))
def get_ai_review_result(token, repo, pr_number):
"""
检查AI代码审查结果,返回 (has_critical, review_body)
has_critical: 是否有严重问题(需修改的问题 > 0)
review_body: 最新的AI审查评论文本
"""
# AI审查评论标记
AI_REVIEW_MARKER = "AI_CODE_REVIEW_AUTO_COMMENT"
comments, code = api_request(token, repo, f"issues/{pr_number}/comments")
if code != 200:
return False, None
# 找最新的AI审查评论
ai_comments = [c for c in comments if isinstance(c, dict) and AI_REVIEW_MARKER in c.get("body", "")]
if not ai_comments:
return False, None
# 按时间排序,取最新的
latest = max(ai_comments, key=lambda c: c.get("created_at", ""))
body = latest.get("body", "")
# 解析严重问题数量
# 匹配 "严重问题数量:X 个" 或 "需修改的问题(严重)" 下的列表
critical_count = 0
# 方式1:直接匹配数字
match = re.search(r"严重问题数量[:]\s*(\d+)\s*个", body)
if match:
critical_count = int(match.group(1))
else:
# 方式2:数 "需修改的问题" 章节下的条目数
critical_section = re.search(
r"###\s*[❌⚠️].*?(?:需修改|问题).*?\n(.*?)(?=\n###|\Z)",
body,
re.DOTALL,
)
if critical_section:
section_text = critical_section.group(1)
# 数编号条目 1. 2. 3.
items = re.findall(r"^\d+\.\s+\*\*", section_text, re.MULTILINE)
critical_count = len(items)
has_critical = critical_count > 0
return has_critical, body
def approve_pr(token, repo, pr_number, reason="CI全绿,自动审批通过。"):
"""审批PR"""
# 创建review
data, code = api_request(
token,
repo,
f"pulls/{pr_number}/reviews",
method="POST",
data={"event": "PENDING", "body": reason},
)
if code not in (200, 201):
return False, f"创建review失败: HTTP {code}"
review_id = data.get("id")
if data.get("state") == "APPROVED":
return True, "直接创建APPROVED成功"
if not review_id:
return False, "未获取到review ID"
# submit为APPROVED
data2, code2 = api_request(
token,
repo,
f"pulls/{pr_number}/reviews/{review_id}/events",
method="POST",
data={"event": "APPROVED", "body": reason},
)
if code2 in (200, 201):
return True, "审批提交成功"
else:
# 尝试另一个端点
data3, code3 = api_request(
token,
repo,
f"pulls/{pr_number}/reviews/{review_id}",
method="POST",
data={"event": "APPROVED", "body": reason},
)
if code3 in (200, 201):
return True, "审批提交成功(备用端点)"
return False, f"审批提交失败: HTTP {code2}/{code3}"
def add_pr_label(token, repo, pr_number, label):
"""给PR添加标签"""
data, code = api_request(
token,
repo,
f"issues/{pr_number}/labels",
method="POST",
data={"labels": [label]},
)
return code in (200, 201)
def merge_pr(token, repo, pr_number, deadline=None):
"""合并PRsquash merge"""
# 等待几秒让状态同步(可被墙钟上限打断,最多等30秒)
wait_end = min(time.monotonic() + 30, deadline) if deadline else time.monotonic() + 30
while time.monotonic() < wait_end:
time.sleep(2)
# 检查PR状态
pr_data, code = api_request(token, repo, f"pulls/{pr_number}")
if code != 200:
return False, f"获取PR状态失败: HTTP {code}"
if pr_data.get("state") != "open":
return False, f"PR状态不是open: {pr_data.get('state')}"
# 执行squash merge
data, code = api_request(
token,
repo,
f"pulls/{pr_number}/merge",
method="POST",
data={
"do": "squash",
"merge_title_field": "",
"merge_message_field": "",
"delete_branch_after_merge": True,
"force_merge": False,
},
)
if code == 200:
return True, "合并成功"
elif code == 405:
return False, "合并返回405(门禁未满足或冲突)"
else:
return False, f"合并失败: HTTP {code}"
def main():
parser = argparse.ArgumentParser(description="PR自动扫描器")
parser.add_argument("--token", required=True, help="Gitea API token")
parser.add_argument("--repo", default="xiaoxia/xiaoxia-saas", help="仓库")
parser.add_argument("--base", default="develop", help="目标分支")
parser.add_argument("--approve", action="store_true", help="执行自动审批")
parser.add_argument("--merge", action="store_true", help="执行自动合并")
parser.add_argument("--dry-run", default="false", help="试运行模式")
parser.add_argument("--max-prs", type=int, default=20, help="最多处理的PR数")
parser.add_argument("--skip-ai-review", action="store_true", help="跳过AI审查检查(强制审批)")
parser.add_argument(
"--max-wall-seconds",
type=int,
default=DEFAULT_WALL_SECONDS,
help="整次扫描墙钟上限(秒),到点主动退出,默认240",
)
args = parser.parse_args()
dry_run = args.dry_run.lower() == "true"
# 墙钟自保:Gitea 1.26 的 timeout-minutes 对卡死 job 不生效,脚本自己兜底
wall_deadline = time.monotonic() + args.max_wall_seconds
def wall_expired():
return time.monotonic() >= wall_deadline
# required contexts(与分支保护一致)
REQUIRED_CONTEXTS_FULL = [
# 统一使用CI Gate作为合并门禁(与pr-automation和分支保护保持一致)
# CI Gate内部已包含: 代码质量/类型检查/迁移检查/单测/集成测试/前端Lint/前端单测/构建/AI审查
"CI/CD Pipeline / CI Gate (pull_request)",
]
REQUIRED_CONTEXTS_APPROVE = [
# CI 优化后 job 名称(2026-08):Code Quality 拆分为 Style+SecurityType Check+Migration 合并为 Python
"CI/CD Pipeline / Validate - Style (pull_request)",
"CI/CD Pipeline / Validate - Security (pull_request)",
"CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request)",
"CI/CD Pipeline / Frontend Lint (pull_request)",
]
FRONTEND_ONLY_CONTEXT = [
# 纯前端PR也用CI Gate统一判断,内部自动跳过后端相关检查
"CI/CD Pipeline / CI Gate (pull_request)",
]
# 获取所有open PR
print(f"获取 {args.base} 分支的open PR...")
prs = get_open_prs(args.token, args.repo, args.base)
print(f"找到 {len(prs)} 个open PR")
approved_count = 0
merged_count = 0
skipped_count = 0
ai_blocked_count = 0
for pr in prs[: args.max_prs]:
if wall_expired():
print(f"\n⏰ 达到墙钟上限 {args.max_wall_seconds}s,停止处理剩余PR(下次调度继续)")
break
pr_num = pr["number"]
pr_title = pr["title"]
head_sha = pr["head"]["sha"]
base_ref = pr.get("base", {}).get("ref", "")
# 跳过draft
if pr.get("draft"):
print(f"\n⏭️ #{pr_num} {pr_title[:50]} - draft,跳过")
skipped_count += 1
continue
# 跳过目标分支不对的
if base_ref != args.base:
skipped_count += 1
continue
print(f"\n--- #{pr_num} {pr_title[:60]} ---")
# 判断是否纯前端
files = get_pr_files(args.token, args.repo, pr_num)
frontend_only = is_frontend_only(files)
if frontend_only:
approve_contexts = FRONTEND_ONLY_CONTEXT
merge_contexts = FRONTEND_ONLY_CONTEXT
print(f" 类型: 纯前端改动 ({len(files)}个文件)")
else:
approve_contexts = REQUIRED_CONTEXTS_APPROVE
merge_contexts = REQUIRED_CONTEXTS_FULL
print(f" 类型: 全栈/后端改动 ({len(files)}个文件)")
# 检查审批用的CI状态
all_ok, pending, failed, _ = check_required_contexts(args.token, args.repo, head_sha, approve_contexts)
# === AI审查检查 ===
ai_has_critical = False
if not args.skip_ai_review and all_ok and not failed and args.approve:
ai_has_critical, ai_body = get_ai_review_result(args.token, args.repo, pr_num)
if ai_has_critical:
print(" ⚠️ AI审查发现严重问题,阻止自动审批")
ai_blocked_count += 1
# 给PR打标签便于人工识别
if not dry_run:
add_pr_label(args.token, args.repo, pr_num, "ai-review/需修改")
# === 自动审批 ===
if args.approve and all_ok and not failed and not ai_has_critical:
if has_approval(args.token, args.repo, pr_num):
print(" ✅ 已有审批,跳过")
else:
if dry_run:
print(" 🎯 [DRY-RUN] 将自动审批")
else:
print(" 🎯 执行自动审批...")
ok, msg = approve_pr(args.token, args.repo, pr_num)
if ok:
print(f" ✅ 审批成功: {msg}")
approved_count += 1
else:
print(f" ❌ 审批失败: {msg}")
elif ai_has_critical:
print(" 🚫 AI审查阻止审批(人工可手动审批覆盖)")
elif failed:
print(" ❌ CI有失败项,跳过审批")
elif pending:
print(" ⏳ CI仍在运行,跳过")
# === 自动合并 ===
if args.merge:
# 检查合并用的CI状态
merge_ok, merge_pending, merge_failed, _ = check_required_contexts(
args.token, args.repo, head_sha, merge_contexts
)
# 检查审批
approved = has_approval(args.token, args.repo, pr_num)
if merge_ok and approved and not merge_failed:
if wall_expired():
print("⏰ 达到墙钟上限,跳过本次合并(下次调度继续)")
break
if dry_run:
print(" 🎯 [DRY-RUN] 将自动合并")
else:
print(" 🎯 执行自动合并...")
ok, msg = merge_pr(args.token, args.repo, pr_num, deadline=wall_deadline)
if ok:
print(f" ✅ 合并成功: {msg}")
merged_count += 1
else:
print(f" ⚠️ 合并失败: {msg}")
elif merge_pending:
print(" ⏳ 合并条件未满足: CI运行中")
elif merge_failed:
print(" ❌ 合并条件未满足: CI有失败")
elif not approved:
print(" ⏳ 合并条件未满足: 无审批")
print("\n=== 扫描结果 ===")
print(f" 处理PR数: {min(len(prs), args.max_prs)}")
print(f" 自动审批: {approved_count} 个")
print(f" 自动合并: {merged_count} 个")
print(f" AI审查阻止: {ai_blocked_count} 个")
print(f" 跳过: {skipped_count} 个")
print(" 模式: {'DRY-RUN' if dry_run else '正式执行'}")
if __name__ == "__main__":
main()