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
同步内容: 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依赖
448 lines
16 KiB
Python
448 lines
16 KiB
Python
#!/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()
|