#!/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()