Files
xiaoxia-saas/scripts/ci/ci_failure_diagnosis.py
T
xiaoxia 6eac0b2cf2
Worker Base Image Build / Build Worker Base Images (worker-base-builder-cache, infra/docker/worker-base-builder.Dockerfile, worker-base-builder, builder) (push) Failing after 1m54s
Worker Base Image Build / Build Worker Base Images (worker-base-runtime-cache, infra/docker/worker-base-runtime.Dockerfile, worker-base-runtime, runtime) (push) Failing after 1m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m2s
CI/CD Pipeline / Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 44s
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 / Build Staging API Image (push) Failing after 2m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 8m14s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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
chore(ci): 同步main分支CI配置与scripts/ci脚本 - 与develop对齐
同步内容:
1. CI流水线配置(ci-pipeline.yml)与develop对齐
2. PR构建脚本docker_build_only.sh增加buildx→docker build回退
3. pre-build步骤worker基础镜像构建增加buildx回退
4. 单元测试脚本全量覆盖率改为仅报告不阻塞
5. diff-cover依赖加入requirements-dev.txt
6. worker base builder/runtime Dockerfile同步
7. test_config_oss.py clear=False→clear=True修复OSS污染
8. Frontend Lint增加prettier依赖
2026-07-24 10:36:29 +08:00

448 lines
16 KiB
Python
Raw 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
"""CI失败诊断增强脚本:自动分类失败原因 + 提取关键错误 + 给出修复建议。
# Trigger CI after auto-format fix
支持的失败类型:
1. Lint/格式问题 (ruff/black/eslint/prettier)
2. 单元测试失败
3. Docker构建失败
4. 依赖安装失败 (pip/npm)
5. 超时
6. 缓存问题
7. 数据库/迁移问题
8. 网络问题
9. 其他
用法:
python3 scripts/ci/ci_failure_diagnosis.py [--job-name "Job Name"] [--log-file /path/to/log]
如果不传--log-file,会尝试从Gitea API获取失败job的日志。
"""
import json
import os
import re
import sys
import urllib.request
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class FailureDiagnosis:
"""失败诊断结果"""
category: str # 失败分类
category_cn: str # 中文分类名
severity: str # 严重程度: high / medium / low
summary: str # 一句话摘要
error_lines: List[str] = field(default_factory=list) # 关键错误行
suggestions: List[str] = field(default_factory=list) # 修复建议
auto_fixable: bool = False # 是否可以自动修复
related_docs: str = "" # 相关文档链接
# ============================================================
# 失败模式定义
# ============================================================
FAILURE_PATTERNS = [
# ===== Lint / 格式问题 =====
{
"pattern": r"(ruff|black|isort)\b.*(error|failed|Error)",
"category": "lint_python",
"category_cn": "Python代码质量检查",
"severity": "low",
"summary_contains": ["ruff", "black", "isort"],
"suggestions": [
"本地运行 `black . && isort . && ruff check --fix .` 自动修复",
"使用 `scripts/agent-commit.sh` 提交(自动格式化)",
"如确认无误,可加 `# noqa: xxx` 忽略特定规则",
],
"auto_fixable": True,
},
{
"pattern": r"ESLint|prettier|eslint",
"category": "lint_frontend",
"category_cn": "前端代码检查",
"severity": "low",
"summary_contains": ["eslint", "prettier"],
"suggestions": [
"本地运行 `cd apps/web && npm run lint:fix` 自动修复",
"Prettier问题: `cd apps/web && npx prettier --write .`",
],
"auto_fixable": True,
},
{
"pattern": r"F\d{3}|E\d{3}|W\d{3}.*ruff|ruff.*F\d{3}",
"category": "lint_python",
"category_cn": "Python代码质量检查",
"severity": "low",
"suggestions": [
"F401: 删除未使用的import",
"F841: 删除未使用的变量或加下划线前缀",
"E501: 行超长,加 `# noqa: E501`",
"F811: 删重复import",
"运行 `ruff check --fix .` 自动修复大部分问题",
],
"auto_fixable": True,
},
# ===== 单元测试失败 =====
{
"pattern": r"FAILED|assert.*Error|AssertionError",
"category": "unit_test",
"category_cn": "单元测试失败",
"severity": "high",
"suggestions": [
"检查相关测试文件,确认是代码问题还是测试用例问题",
"本地运行对应测试:`pytest path/to/test.py -v`",
"如测试依赖外部服务,检查mock是否正确",
],
"auto_fixable": False,
},
{
"pattern": r"pytest.*failed|\d+ failed.*\d+ passed",
"category": "unit_test",
"category_cn": "单元测试失败",
"severity": "high",
"suggestions": [
"查看上方日志中的FAILED测试用例",
"检查失败断言的期望值 vs 实际值",
"新代码影响了现有测试行为,确认是预期内变更吗?",
],
"auto_fixable": False,
},
# ===== Docker 构建失败 =====
{
"pattern": r"Dockerfile.*not found|docker build.*failed|ERROR: failed to solve",
"category": "docker_build",
"category_cn": "Docker构建失败",
"severity": "high",
"suggestions": [
"检查Dockerfile语法是否正确",
"检查引用的基础镜像是否存在",
"本地运行 `docker build -f path/to/Dockerfile .` 复现",
],
"auto_fixable": False,
},
{
"pattern": r"manifest.*not found|no such image|image.*not found",
"category": "docker_build",
"category_cn": "镜像不存在",
"severity": "medium",
"suggestions": [
"检查基础镜像名称和tag是否正确",
"确认镜像仓库可访问,登录是否有效",
"如为新基础镜像,需先手动构建一次基础镜像",
],
"auto_fixable": False,
},
{
"pattern": r"ETXTBSY|text file busy",
"category": "docker_build",
"category_cn": "文件锁冲突(ETXTBSY",
"severity": "low",
"summary": "esbuild并发构建冲突,重试即可",
"suggestions": ["偶发问题,点击Rerun重新运行即可", "如频繁出现,检查是否有多个job并发写入同一文件"],
"auto_fixable": True,
},
# ===== 依赖安装失败 =====
{
"pattern": r"pip install.*error|Could not find a version|No matching distribution",
"category": "dependency",
"category_cn": "pip依赖安装失败",
"severity": "medium",
"suggestions": [
"检查requirements.txt中的版本号是否正确",
"如为新版本刚发布,可能源还没同步,稍后重试",
"检查网络连接,可尝试切换pip镜像源",
],
"auto_fixable": False,
},
{
"pattern": r"npm.*ERR|npm install.*failed|E404|ECONNREFUSED.*npm",
"category": "dependency",
"category_cn": "npm依赖安装失败",
"severity": "medium",
"suggestions": [
"检查package.json中的版本号是否存在",
"网络问题:检查npm registry是否可访问",
"国内网络建议配置npmmirror镜像源",
],
"auto_fixable": False,
},
{
"pattern": r"Connection refused|timed out|network.*unreachable",
"category": "network",
"category_cn": "网络问题",
"severity": "medium",
"summary": "网络连接失败,可能是源站问题或DNS问题",
"suggestions": [
"点击Rerun重试,网络问题通常是临时的",
"如持续失败,检查对应服务是否正常",
"检查Runner网络配置",
],
"auto_fixable": True,
},
# ===== 超时 =====
{
"pattern": r"timeout|timed out|exceeded.*time limit|job.*cancelled.*timeout",
"category": "timeout",
"category_cn": "执行超时",
"severity": "medium",
"suggestions": [
"如首次出现:重试一次,可能是临时性能波动",
"频繁出现:检查构建是否变慢了,最近是否加了新依赖",
"可适当增加timeout-minutes配置",
],
"auto_fixable": False,
},
# ===== 数据库/迁移 =====
{
"pattern": r"alembic.*error|migration.*failed|relation.*does not exist|column.*does not exist",
"category": "migration",
"category_cn": "数据库迁移失败",
"severity": "high",
"suggestions": [
"检查迁移脚本是否正确,down_revision是否对",
"确认数据库中是否有脏数据或残留表",
"迁移脚本合并冲突时,重新生成迁移文件",
],
"auto_fixable": False,
},
# ===== 缓存问题 =====
{
"pattern": r"cache.*corrupt|cache.*invalid|snapshot.*not found|failed to compute cache key",
"category": "cache",
"category_cn": "缓存损坏",
"severity": "low",
"suggestions": ["构建系统会自动清理损坏缓存并重试,通常无需干预", "如持续失败,手动清理Runner上的缓存目录"],
"auto_fixable": True,
},
# ===== Checkout 失败 =====
{
"pattern": r"Could not resolve host|fatal:.*repository|SSL.*problem",
"category": "checkout",
"category_cn": "代码拉取失败",
"severity": "low",
"suggestions": ["临时网络问题,点击Rerun重试", "如持续失败,检查Gitea服务状态"],
"auto_fixable": True,
},
]
def analyze_log(log_text: str, job_name: str = "") -> FailureDiagnosis:
"""分析日志,返回诊断结果"""
lines = log_text.strip().split("\n")
# 收集所有匹配的模式
matched = []
error_lines = []
for line in lines:
line_stripped = line.strip()
# 收集ERROR/FAILED/Failed等错误行(最多20行)
if re.search(r"(ERROR|FAILED|Error|error:|FAIL:|Traceback)", line_stripped):
if len(error_lines) < 20:
error_lines.append(line_stripped)
for pattern_info in FAILURE_PATTERNS:
if re.search(pattern_info["pattern"], line_stripped, re.IGNORECASE):
matched.append(pattern_info)
break # 一行只匹配一个模式
if not matched:
# 未识别的失败类型
return FailureDiagnosis(
category="unknown",
category_cn="未知错误",
severity="medium",
summary="未识别的失败类型,需要人工查看日志",
error_lines=error_lines[:10],
suggestions=[
"点击'查看失败日志'查看完整日志",
"如为偶发问题,可先重试一次",
"常见原因:环境问题、配置问题、新增逻辑引入的bug",
],
auto_fixable=False,
)
# 选最严重、最具体的那个
severity_order = {"high": 3, "medium": 2, "low": 1}
matched.sort(key=lambda x: severity_order.get(x["severity"], 0), reverse=True)
best_match = matched[0]
# 生成摘要
if "summary" in best_match:
summary = best_match["summary"]
else:
summary = f"{best_match['category_cn']}检查失败"
if job_name:
summary = f"[{job_name}] {summary}"
# 从error_lines中过滤出与该分类相关的
relevant_errors = error_lines[:10]
return FailureDiagnosis(
category=best_match["category"],
category_cn=best_match["category_cn"],
severity=best_match["severity"],
summary=summary,
error_lines=relevant_errors,
suggestions=best_match["suggestions"],
auto_fixable=best_match.get("auto_fixable", False),
)
def fetch_failed_job_log(run_id: str, job_id: str, token: str, repo: str) -> Optional[str]:
"""从Gitea API获取失败job的日志"""
api_base = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}"
# 尝试获取job的日志
url = f"{api_base}/actions/runs/{run_id}/jobs/{job_id}/log"
req = urllib.request.Request(url)
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode("utf-8", errors="replace")
except Exception as e:
print(f"获取日志失败: {e}", file=sys.stderr)
return None
def format_diagnosis_markdown(d: FailureDiagnosis, job_name: str = "", run_url: str = "") -> str:
"""将诊断结果格式化为飞书卡片markdown"""
severity_emoji = {"high": "🔴", "medium": "🟡", "low": "🟢"}
emoji = severity_emoji.get(d.severity, "⚪")
lines = []
lines.append(f"**分类**: {emoji} {d.category_cn}")
lines.append(f"**问题**: {d.summary}")
if d.error_lines:
lines.append("")
lines.append("**关键错误行**:")
for err in d.error_lines[:5]:
# 截断过长的行
if len(err) > 150:
err = err[:147] + "..."
lines.append(f" `{err}`")
lines.append("")
lines.append("**修复建议**:")
for i, s in enumerate(d.suggestions[:5], 1):
lines.append(f" {i}. {s}")
if d.auto_fixable:
lines.append("")
lines.append("💡 **可自动修复**:如格式问题,可尝试点击Rerun让auto-fix自动处理")
if run_url:
lines.append("")
lines.append(f"[查看完整日志]({run_url})")
return "\n".join(lines)
def main():
job_name = os.environ.get("FAILED_JOB", "")
run_id = os.environ.get("GITHUB_RUN_ID", "")
repo = os.environ.get("GITHUB_REPOSITORY", "xiaoxia/xiaoxia-saas")
token = os.environ.get("GITHUB_TOKEN", "")
# 1. 尝试获取日志
log_text = ""
# 优先从环境变量或文件读取
log_file = os.environ.get("CI_LOG_FILE", "")
if log_file and os.path.exists(log_file):
with open(log_file) as f:
log_text = f.read()
elif run_id and token:
# 尝试从API获取(需要job_id,这里简化处理)
pass
# 如果没有日志,用job_name做粗略分类
if not log_text:
# 基于job名做初始判断
if any(k in job_name.lower() for k in ["validate", "lint", "quality"]):
d = FailureDiagnosis(
category="lint_general",
category_cn="代码质量检查",
severity="low",
summary=f"{job_name} 检查失败(日志不可用,基于job名初步诊断)",
suggestions=["点击查看日志获取具体错误信息", "格式类问题通常可自动修复"],
auto_fixable=True,
)
elif "build" in job_name.lower():
d = FailureDiagnosis(
category="build_general",
category_cn="构建失败",
severity="high",
summary=f"{job_name} 构建失败(日志不可用)",
suggestions=["点击查看日志获取具体构建错误", "常见原因:Dockerfile错误、依赖安装失败、网络问题"],
auto_fixable=False,
)
elif "test" in job_name.lower():
d = FailureDiagnosis(
category="test_general",
category_cn="测试失败",
severity="high",
summary=f"{job_name} 测试失败(日志不可用)",
suggestions=["点击查看日志获取具体失败的测试用例", "检查最近代码改动是否影响了测试"],
auto_fixable=False,
)
elif "deploy" in job_name.lower():
d = FailureDiagnosis(
category="deploy_general",
category_cn="部署失败",
severity="high",
summary=f"{job_name} 部署失败(日志不可用)",
suggestions=["检查目标服务器状态和网络", "检查镜像是否正确推送", "查看服务器上的容器日志"],
auto_fixable=False,
)
else:
d = FailureDiagnosis(
category="unknown",
category_cn="未知错误",
severity="medium",
summary=f"{job_name} 失败",
suggestions=["点击查看日志获取详细信息"],
auto_fixable=False,
)
else:
d = analyze_log(log_text, job_name)
# 输出诊断结果
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" if run_id else ""
print("=" * 60)
print(" CI 失败诊断报告")
print("=" * 60)
print()
print(format_diagnosis_markdown(d, job_name, run_url))
print()
print("=" * 60)
# 将诊断结果写入文件(供通知脚本读取)
output_file = os.environ.get("DIAGNOSIS_OUTPUT", "/tmp/ci_diagnosis.json")
result = {
"category": d.category,
"category_cn": d.category_cn,
"severity": d.severity,
"summary": d.summary,
"error_lines": d.error_lines,
"suggestions": d.suggestions,
"auto_fixable": d.auto_fixable,
}
with open(output_file, "w") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"\n诊断结果已保存到: {output_file}")
if __name__ == "__main__":
main()