02e3246f5a
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 55s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m3s
CI/CD Pipeline / Build Production API Image (push) Failing after 27s
CI/CD Pipeline / Build Production Web Image (push) Failing after 20s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m25s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m18s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 23s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m4s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m3s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 3m26s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been skipped
fix(ci): 修复auto_fix_formatting防循环索引错误 + AI审查fail-open未生效 1. 防循环索引bug:Gitea API返回commits倒序,commits[-1]取到最旧commit,改为commits[0] 2. fail-open bug:LLM调用失败和未捕获异常都是exit 1,改为exit 0不阻塞合并
389 lines
14 KiB
Python
Executable File
389 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""CI中自动修复代码格式(Python: black + isort | Frontend: prettier),并推送回原分支。
|
||
|
||
- PR事件:所有PR只要Code Quality因格式问题失败,自动修复并push回源分支
|
||
- Push事件(develop/main):自动修复并push回原分支,保持主干格式永远正确
|
||
- 防循环:修复commit带 [skip ci-format-check] 标记,检测到该标记则跳过修复
|
||
- 只修格式(black/isort/prettier),ruff逻辑类错误不动
|
||
当code quality检查因格式问题失败时触发。
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.request
|
||
|
||
|
||
def run(cmd, check=True, capture=True, cwd=None):
|
||
"""运行shell命令"""
|
||
result = subprocess.run(cmd, shell=True, capture_output=capture, text=True, cwd=cwd)
|
||
if check and result.returncode != 0:
|
||
print(f"命令失败: {cmd}", file=sys.stderr)
|
||
if result.stderr:
|
||
print(result.stderr, file=sys.stderr)
|
||
sys.exit(1)
|
||
return result
|
||
|
||
|
||
def ensure_git_repo(api_url, repo, token, pr_number):
|
||
"""确保当前目录是git仓库,并切换到PR源分支。
|
||
|
||
checkout脚本用tarball方式下载代码(PR merge后的commit),没有.git目录。
|
||
这里自动初始化git仓库,fetch PR源分支并强制checkout,
|
||
使工作区变为PR源分支的代码,确保后续格式化修复基于源分支。
|
||
"""
|
||
if os.path.exists(".git"):
|
||
return
|
||
|
||
print("检测到tarball checkout(无.git目录),自动初始化git仓库...")
|
||
|
||
# 构造带认证的远端URL
|
||
server_url = api_url.rsplit("/api/v1", 1)[0]
|
||
remote_url = f"{server_url.replace('https://', f'https://x-access-token:{token}@')}/{repo}.git"
|
||
|
||
# 获取PR的源分支
|
||
pr_api_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
|
||
req_obj = urllib.request.Request(pr_api_url, headers={"Authorization": f"token {token}"})
|
||
with urllib.request.urlopen(req_obj) as resp:
|
||
pr = json.loads(resp.read())
|
||
head_branch = pr["head"]["ref"]
|
||
|
||
print(f"PR源分支: {head_branch}")
|
||
|
||
# 初始化git
|
||
run("git init -q")
|
||
run(f"git remote add origin {remote_url}")
|
||
run('git config user.name "CI Bot"')
|
||
run('git config user.email "ci-bot@xiaoxiajianji.com"')
|
||
|
||
# fetch源分支(浅克隆,只要最新commit)
|
||
print("fetch源分支...")
|
||
run(f"git fetch --depth=1 origin {head_branch}")
|
||
|
||
# 强制checkout到源分支(覆盖tarball内容)
|
||
# tarball是merge后的commit,源分支才是我们要修改并推送的目标
|
||
print("切换到源分支...")
|
||
run(f"git checkout -f -B {head_branch} FETCH_HEAD")
|
||
|
||
result = run("git status --porcelain")
|
||
if result.stdout.strip():
|
||
n = len(result.stdout.strip().splitlines())
|
||
print(f"⚠️ 工作区有 {n} 个未追踪文件")
|
||
else:
|
||
print("✅ git仓库就绪,工作区clean")
|
||
|
||
return head_branch
|
||
|
||
|
||
def ensure_git_repo_for_push(api_url, repo, token, branch_name):
|
||
"""push事件下确保git仓库可用,并切换到目标分支。
|
||
|
||
checkout脚本用tarball方式下载代码,没有.git目录。
|
||
这里自动初始化git仓库,fetch目标分支并checkout。
|
||
"""
|
||
if os.path.exists(".git"):
|
||
# 已有git,确认在正确分支
|
||
result = run("git rev-parse --abbrev-ref HEAD", check=False)
|
||
if result.stdout.strip() == branch_name:
|
||
return
|
||
# 不在目标分支,切换
|
||
run(f"git checkout {branch_name}", check=False)
|
||
return
|
||
|
||
print(f"检测到tarball checkout(无.git目录),初始化git仓库(push模式,分支: {branch_name})...")
|
||
|
||
server_url = api_url.rsplit("/api/v1", 1)[0]
|
||
remote_url = f"{server_url.replace('https://', f'https://x-access-token:{token}@')}/{repo}.git"
|
||
|
||
run("git init -q")
|
||
run(f"git remote add origin {remote_url}")
|
||
run('git config user.name "CI Bot"')
|
||
run('git config user.email "ci-bot@xiaoxiajianji.com"')
|
||
|
||
print(f"fetch {branch_name} 分支...")
|
||
run(f"git fetch --depth=1 origin {branch_name}")
|
||
|
||
print(f"切换到 {branch_name} 分支...")
|
||
run(f"git checkout -f -B {branch_name} FETCH_HEAD")
|
||
|
||
result = run("git status --porcelain")
|
||
if result.stdout.strip():
|
||
n = len(result.stdout.strip().splitlines())
|
||
print(f"⚠️ 工作区有 {n} 个未追踪文件")
|
||
else:
|
||
print("✅ git仓库就绪,工作区clean")
|
||
|
||
|
||
def get_changed_files(pr_number, api_url, token):
|
||
"""获取PR中变更的文件列表"""
|
||
url = f"{api_url}/pulls/{pr_number}/files?limit=100"
|
||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||
with urllib.request.urlopen(req) as resp:
|
||
files = json.loads(resp.read())
|
||
return [f["filename"] for f in files if f["status"] != "removed"]
|
||
|
||
|
||
def get_pr_head_branch(pr_number, api_url, token):
|
||
"""获取PR的来源分支名"""
|
||
url = f"{api_url}/pulls/{pr_number}"
|
||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||
with urllib.request.urlopen(req) as resp:
|
||
pr = json.loads(resp.read())
|
||
return pr["head"]["ref"]
|
||
|
||
|
||
def fix_python(target_py_files, scan_mode):
|
||
"""修复 Python 文件格式 (black + isort)"""
|
||
if not target_py_files:
|
||
print("没有需要修复的 Python 文件,跳过")
|
||
return
|
||
|
||
target_str = " ".join(target_py_files)
|
||
print()
|
||
print("--- black 格式化 ---")
|
||
result = run(f"python3 -m black {target_str}", check=False)
|
||
print(result.stdout[-500:] if result.stdout else "")
|
||
if result.returncode != 0:
|
||
print("black执行失败,但继续尝试isort", file=sys.stderr)
|
||
|
||
print()
|
||
print("--- isort 排序 ---")
|
||
result = run(f"python3 -m isort {target_str}", check=False)
|
||
print(result.stdout[-500:] if result.stdout else "")
|
||
if result.returncode != 0:
|
||
print("isort执行失败", file=sys.stderr)
|
||
|
||
|
||
def fix_frontend(target_fe_files, scan_mode, repo_root):
|
||
"""修复前端文件格式 (prettier)"""
|
||
if not target_fe_files:
|
||
print("没有需要修复的前端文件,跳过")
|
||
return
|
||
|
||
# 检查 prettier 是否可用
|
||
web_dir = os.path.join(repo_root, "apps", "web")
|
||
prettier_bin = os.path.join(web_dir, "node_modules", ".bin", "prettier")
|
||
|
||
if not os.path.exists(prettier_bin):
|
||
print()
|
||
print("--- 安装前端依赖 (prettier) ---")
|
||
result = run("npm install --no-audit --no-fund --prefer-offline", check=False, cwd=web_dir)
|
||
if result.returncode != 0:
|
||
print("npm install 失败,跳过 prettier 修复", file=sys.stderr)
|
||
return
|
||
print("依赖安装完成")
|
||
|
||
if not os.path.exists(prettier_bin):
|
||
print("prettier 仍不可用,跳过", file=sys.stderr)
|
||
return
|
||
|
||
print()
|
||
print("--- prettier 格式化 ---")
|
||
|
||
if scan_mode == "incremental":
|
||
# 增量模式:只格式化变更的前端文件
|
||
target_str = " ".join(target_fe_files)
|
||
cmd = f"{prettier_bin} --write {target_str}"
|
||
else:
|
||
# 全量模式:格式化整个前端目录
|
||
cmd = f"{prettier_bin} --write ."
|
||
|
||
result = run(cmd, check=False, cwd=web_dir if scan_mode != "incremental" else repo_root)
|
||
print(result.stdout[-800:] if result.stdout else "")
|
||
if result.stderr:
|
||
print(result.stderr[-500:], file=sys.stderr)
|
||
|
||
|
||
def main():
|
||
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
|
||
github_ref = os.environ.get("GITHUB_REF", "")
|
||
api_url = os.environ.get("GITHUB_API_URL", "")
|
||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||
token = os.environ.get("REVIEW_TOKEN", "") or os.environ.get("GITHUB_TOKEN", "")
|
||
scan_mode = os.environ.get("SCAN_MODE", "full")
|
||
changed_files_env = os.environ.get("CHANGED_FILES", "")
|
||
|
||
if not token:
|
||
print("缺少REVIEW_TOKEN或GITHUB_TOKEN,无法推送修复", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
repo_root = os.getcwd()
|
||
|
||
# ====== Push事件处理(develop/main等受保护分支) ======
|
||
if event_name == "push":
|
||
# 从 refs/heads/xxx 提取分支名
|
||
if not github_ref.startswith("refs/heads/"):
|
||
print(f"push事件但refs格式异常: {github_ref},跳过")
|
||
return
|
||
branch_name = github_ref.replace("refs/heads/", "")
|
||
|
||
# 只在受保护分支(develop/main)上自动修复并推送
|
||
protected_branches = {"develop", "main", "master"}
|
||
if branch_name not in protected_branches:
|
||
print(f"push事件,分支 {branch_name} 不是受保护分支,跳过自动修复")
|
||
return
|
||
|
||
print("=== Push事件:检测到格式问题,自动修复并推送回原分支 ===")
|
||
print(f"分支: {branch_name}")
|
||
print(f"扫描模式: {scan_mode}")
|
||
|
||
# 初始化git仓库
|
||
ensure_git_repo_for_push(api_url, repo, token, branch_name)
|
||
head_branch = branch_name
|
||
fix_mode = "auto_fix_and_push"
|
||
|
||
# ====== PR事件处理 ======
|
||
elif event_name == "pull_request":
|
||
pr_number = github_ref.split("/")[2] if github_ref.startswith("refs/pull/") else ""
|
||
if not pr_number:
|
||
print("无法获取PR号,跳过自动修复")
|
||
return
|
||
|
||
# 获取PR信息
|
||
pr_info_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
|
||
req_pr = urllib.request.Request(pr_info_url, headers={"Authorization": f"token {token}"})
|
||
with urllib.request.urlopen(req_pr) as resp:
|
||
pr_info = json.loads(resp.read())
|
||
pr_author = pr_info.get("user", {}).get("login", "")
|
||
print(f"PR作者: {pr_author}")
|
||
|
||
# 防循环检测:检查最新commit是否已经是格式修复commit
|
||
# 修复commit message 带 [skip ci-format-check] 标记,检测到则跳过
|
||
head_branch_tmp = pr_info.get("head", {}).get("ref", "")
|
||
skip_marker = "[skip ci-format-check]"
|
||
try:
|
||
commits_url = f"{api_url}/repos/{repo}/pulls/{pr_number}/commits?limit=3"
|
||
req_commits = urllib.request.Request(commits_url, headers={"Authorization": f"token {token}"})
|
||
with urllib.request.urlopen(req_commits) as resp_commits:
|
||
commits = json.loads(resp_commits.read())
|
||
latest_msg = commits[0].get("commit", {}).get("message", "") if commits else ""
|
||
if skip_marker in latest_msg:
|
||
print(f"检测到最新commit包含 {skip_marker} 标记,跳过格式修复(防循环)")
|
||
print("本次格式检查失败是格式修复commit触发的CI回跑,属正常现象")
|
||
sys.exit(0)
|
||
except Exception as e:
|
||
print(f"⚠️ 防循环检测失败,继续执行: {e}")
|
||
|
||
# 所有PR都自动修复格式(不再区分人/Agent)
|
||
print("检测到格式问题,将自动修复并推送回分支")
|
||
fix_mode = "auto_fix_and_push"
|
||
|
||
print("=== 检测到代码格式问题,尝试自动修复 ===")
|
||
print(f"PR #{pr_number}")
|
||
print(f"扫描模式: {scan_mode}")
|
||
|
||
# 确保git仓库可用(tarball checkout模式下自动初始化)
|
||
head_branch = ensure_git_repo(api_url, repo, token, pr_number)
|
||
|
||
# ====== 其他事件跳过 ======
|
||
else:
|
||
print(f"事件 {event_name} 不支持自动修复,跳过")
|
||
return
|
||
|
||
# 前端文件扩展名
|
||
fe_extensions = (
|
||
".ts",
|
||
".tsx",
|
||
".js",
|
||
".jsx",
|
||
".css",
|
||
".scss",
|
||
".less",
|
||
".json",
|
||
".html",
|
||
".md",
|
||
".yaml",
|
||
".yml",
|
||
)
|
||
py_extensions = (".py",)
|
||
|
||
# 确定要修复的文件范围
|
||
if scan_mode == "incremental" and changed_files_env:
|
||
all_changed = changed_files_env.split()
|
||
target_py_files = [f for f in all_changed if f.endswith(py_extensions)]
|
||
target_fe_files = [f for f in all_changed if f.endswith(fe_extensions)]
|
||
print(f"增量模式: {len(target_py_files)} 个Python文件, {len(target_fe_files)} 个前端文件")
|
||
else:
|
||
target_py_files = ["alembic", "apps", "packages", "tests", "scripts"]
|
||
target_fe_files = ["apps/web"]
|
||
print("全量模式,修复所有文件")
|
||
|
||
# Python 格式化
|
||
fix_python(target_py_files, scan_mode)
|
||
|
||
# 前端格式化
|
||
if scan_mode != "incremental":
|
||
fix_frontend(["apps/web"], scan_mode, repo_root)
|
||
else:
|
||
fix_frontend(target_fe_files, scan_mode, repo_root)
|
||
|
||
# 检查是否有改动
|
||
result = run("git status --porcelain")
|
||
if not result.stdout.strip():
|
||
print()
|
||
print("没有需要提交的格式改动")
|
||
return
|
||
|
||
print()
|
||
print("变更文件:")
|
||
for line in result.stdout.strip().split("\n"):
|
||
print(f" {line}")
|
||
|
||
# 提交修复
|
||
run("git add -A")
|
||
run('git commit -m "style: auto-format with black + isort + prettier [skip ci-format-check]"')
|
||
|
||
# 推送(head_branch已从ensure_git_repo获取)
|
||
print(f"\nPR来源分支: {head_branch}")
|
||
print("推送格式修复到远端...")
|
||
|
||
# 推送前先 rebase 拉取远端最新,避免快进冲突
|
||
# 最多重试 3 次:rebase → push,失败则重新拉取再试
|
||
max_retries = 3
|
||
push_success = False
|
||
last_error = ""
|
||
|
||
for attempt in range(1, max_retries + 1):
|
||
print(f" 尝试 {attempt}/{max_retries}: 拉取最新代码并推送...")
|
||
|
||
# 先拉取远端最新 commit 并 rebase
|
||
fetch_result = run(f"git fetch origin {head_branch}", check=False)
|
||
if fetch_result.returncode != 0:
|
||
last_error = f"git fetch 失败: {fetch_result.stderr.strip()}"
|
||
print(f" {last_error}")
|
||
time.sleep(2)
|
||
continue
|
||
|
||
rebase_result = run(f"git rebase origin/{head_branch}", check=False)
|
||
if rebase_result.returncode != 0:
|
||
last_error = f"git rebase 失败,中止并重置: {rebase_result.stderr.strip()[:200]}"
|
||
print(f" {last_error}")
|
||
run("git rebase --abort", check=False)
|
||
# rebase 失败通常是冲突,重试没用,直接跳出
|
||
break
|
||
|
||
# 推送
|
||
push_result = run(f'git push origin "HEAD:{head_branch}"', check=False)
|
||
if push_result.returncode == 0:
|
||
push_success = True
|
||
break
|
||
|
||
last_error = push_result.stderr.strip() or push_result.stdout.strip()
|
||
print(f" push 失败: {last_error[:200]}")
|
||
time.sleep(3)
|
||
|
||
if not push_success:
|
||
print(f"\n❌ 推送失败(已重试 {max_retries} 次)", file=sys.stderr)
|
||
print(f"最后错误: {last_error}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
print()
|
||
print("✅ 格式已自动修复并推送回分支")
|
||
print("新的commit会重新触发CI检查")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|