#!/usr/bin/env python3 """ CI 健康度快速检查脚本 - 统计最近 N 条 run 的成功率(按 workflow 分类) - 列出失败的 run 和失败的 job/step - 区分基础设施问题 vs 业务代码问题 - 输出简洁的健康度报告 用法: python3 scripts/ci/ci_health_check.py [--limit 20] [--workflow ci-pipeline.yml] [--json] 环境变量: GITEA_TOKEN API token(必需) GITEA_API_URL Gitea API 地址,默认 https://git.xiaoxiajianji.com/api/v1 GITEA_REPO 仓库,默认 xiaoxia/xiaoxia-saas """ import argparse import json import os import sys import urllib.request from datetime import datetime, timedelta, timezone # ---- 基础设施问题关键词(命中即判定为基础设施问题)---- INFRA_KEYWORDS = [ # 网络/连接 "Couldn't connect to server", "Connection refused", "Connection reset", "Connection timed out", "Failed to connect to", "network is unreachable", "TLS handshake timeout", "SSL certificate problem", # 容器/Runner "No such container", "container already exists", "docker: not found", "no space left on device", "out of memory", "OOMKilled", "pull access denied", "manifest unknown", "Error response from daemon", "runner", "runner is not online", "no matching runners", # Checkout/Git "Could not resolve host", "fatal: unable to access", "The remote end hung up unexpectedly", "early EOF", "index-pack failed", "git fetch", "checkout failed", "ETXTBSY", "text file busy", # 镜像/环境 "No module named pip", "pip: not found", "command not found: python", "python3: not found", "node: not found", "npm: not found", "exec format error", "standard_init_linux.go", # 系统/资源 "Input/output error", "device or resource busy", "No space left on device", "Disk full", # 鉴权/配置 "401 Unauthorized", "403 Forbidden", "404 Not Found", "identity_sign: private key", "Permission denied", ] def api_get(path: str) -> dict: base = os.environ.get("GITEA_API_URL", "https://git.xiaoxiajianji.com/api/v1") repo = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas") token = os.environ.get("GITEA_TOKEN", "") url = f"{base}/repos/{repo}/{path}" req = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode()) def get_run_jobs(run_id: int) -> list: return api_get(f"actions/runs/{run_id}/jobs").get("jobs", []) def get_job_log(job_id: int) -> str: try: return api_get(f"actions/jobs/{job_id}/logs") except Exception: return "" def classify_failure(job: dict) -> str: """判断失败原因类型: infra / business / unknown""" name = job.get("name", "") # 仅根据 job 名称做初步分类(更精确需读日志,但代价高) infra_jobs = ["Checkout", "Build", "Deploy", "Cleanup"] business_jobs = [ "Unit Tests", "Integration Tests", "Frontend Lint", "Frontend Unit Tests", "Staging E2E", "E2E", "Validate Code Quality", ] name_lower = name.lower() if ( any(k.lower() in name_lower for k in infra_jobs) and "Test" not in name and "Lint" not in name and "Validate" not in name ): return "infra" if any(k.lower() in name_lower for k in business_jobs): return "business" return "unknown" def analyze_with_log(job_id: int) -> str: """通过日志关键词精确分类""" log = get_job_log(job_id) log_lower = log.lower() for kw in INFRA_KEYWORDS: if kw.lower() in log_lower: return "infra" return "business" def fmt_time(t: str) -> str: if not t or t.startswith("1970"): return "-" try: dt = datetime.fromisoformat(t.replace("Z", "+00:00")) bj = dt.astimezone(timezone(timedelta(hours=8))) return bj.strftime("%m-%d %H:%M") except Exception: return t[:16] def main(): parser = argparse.ArgumentParser(description="CI 健康度快速检查") parser.add_argument("--limit", type=int, default=20, help="最近多少条 run") parser.add_argument("--workflow", type=str, default="", help="只看某个 workflow") parser.add_argument("--json", action="store_true", help="JSON 输出") parser.add_argument("--deep", action="store_true", help="深度检查(读日志,较慢)") args = parser.parse_args() if not os.environ.get("GITEA_TOKEN"): print("错误: 请设置 GITEA_TOKEN 环境变量", file=sys.stderr) sys.exit(1) # 1. 获取最近 run runs = api_get(f"actions/runs?limit={args.limit}").get("workflow_runs", []) if args.workflow: runs = [r for r in runs if args.workflow in r.get("path", "")] if not runs: print("没有找到匹配的 run") return # 按 workflow 分组统计 wf_stats = {} failed_runs = [] for r in runs: path = r.get("path", "unknown") # 提取 workflow 文件名,兼容各种 path 格式 if ".yml" in path or ".yaml" in path: # ci-pipeline.yml@refs/heads/develop -> ci-pipeline.yml wf = path.split("@")[0].split("/")[-1] else: wf = path.split("/")[-1] if "/" in path else path if wf not in wf_stats: wf_stats[wf] = {"total": 0, "success": 0, "failure": 0, "cancelled": 0, "others": 0} wf_stats[wf]["total"] += 1 status = r.get("status", "") conc = r.get("conclusion", "") if status != "completed": wf_stats[wf]["others"] += 1 continue if conc == "success": wf_stats[wf]["success"] += 1 elif conc == "failure": wf_stats[wf]["failure"] += 1 failed_runs.append(r) elif conc == "cancelled": wf_stats[wf]["cancelled"] += 1 else: wf_stats[wf]["others"] += 1 # 2. 失败 run 详情 failed_details = [] for r in failed_runs[:10]: # 最多看10个失败的 jobs = get_run_jobs(r["id"]) failed_jobs = [j for j in jobs if j.get("conclusion") == "failure"] job_infos = [] for j in failed_jobs: cat = classify_failure(j) if args.deep and cat == "unknown": cat = analyze_with_log(j["id"]) # 找失败的 step failed_steps = [] for step in j.get("steps", []): if step.get("conclusion") == "failure": failed_steps.append(step.get("name", "?")) job_infos.append( { "name": j.get("name", ""), "category": cat, "failed_steps": failed_steps, "runner": j.get("runner_name", ""), } ) failed_details.append( { "id": r["id"], "title": r.get("display_title", ""), "branch": r.get("head_branch", ""), "time": fmt_time(r.get("updated_at", "")), "jobs": job_infos, } ) # 3. 输出 if args.json: result = {"workflows": wf_stats, "failed_runs": failed_details} print(json.dumps(result, ensure_ascii=False, indent=2)) return # 文本报告 print("=" * 60) print(" CI 健康度报告") print("=" * 60) print(f"统计范围: 最近 {len(runs)} 条 run") print(f"时间: {datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')}") print() print("📊 各 Workflow 成功率:") print("-" * 60) for wf, s in sorted(wf_stats.items()): total = s["total"] succ = s["success"] rate = (succ / total * 100) if total > 0 else 0 bar = "█" * int(rate / 5) + "░" * (20 - int(rate / 5)) icon = "🟢" if rate >= 90 else ("🟡" if rate >= 70 else "🔴") print(f" {icon} {wf:35s} {rate:5.1f}% {bar} ({succ}/{total})") if s["failure"]: print(f" 失败: {s['failure']} 取消: {s['cancelled']} 进行中: {s['others']}") if failed_details: print() print("❌ 失败详情:") print("-" * 60) for d in failed_details: print(f" #{d['id']} [{d['time']}] {d['title'][:45]}") print(f" 分支: {d['branch']}") for j in d["jobs"]: cat_icon = "🏗️" if j["category"] == "infra" else ("🐛" if j["category"] == "business" else "❓") steps = ", ".join(j["failed_steps"][:3]) if j["failed_steps"] else "未知" print(f" {cat_icon} {j['name'][:30]:30s} 失败步骤: {steps}") if j["runner"]: print(f" runner: {j['runner']}") else: print() print("✅ 最近没有失败的 run") # 总结 total_all = sum(s["total"] for s in wf_stats.values()) succ_all = sum(s["success"] for s in wf_stats.values()) fail_all = sum(s["failure"] for s in wf_stats.values()) infra_fail = sum(1 for d in failed_details for j in d["jobs"] if j["category"] == "infra") biz_fail = sum(1 for d in failed_details for j in d["jobs"] if j["category"] == "business") rate_all = (succ_all / total_all * 100) if total_all > 0 else 0 print() print("=" * 60) print(f" 总结: 总成功率 {rate_all:.1f}% ({succ_all}/{total_all})") if fail_all > 0: print(f" 失败job分类: 基础设施 {infra_fail} 个 | 业务代码 {biz_fail} 个") if infra_fail > biz_fail: print(" ⚠️ 主要是基础设施问题,建议优先排查 CI 环境") else: print(" 💡 主要是业务代码问题,建议关注业务侧修复") print("=" * 60) if __name__ == "__main__": main()