#!/usr/bin/env python3 """ CI 状态查询模块 - 查询 run 列表、某分支/某 PR 的 CI 状态、失败详情 支持查询类型: - list_runs: 列出最近的 workflow runs - branch_status: 某分支最新 CI 状态 - pr_status: 某 PR 的 CI 状态 - failure_detail: 某次 run 的失败详情 用法: python3 scripts/ci/chatops/ci_query.py --branch develop python3 scripts/ci/chatops/ci_query.py --pr 123 python3 scripts/ci/chatops/ci_query.py --run-id 456 --detail 设计: - 与飞书机器人 /ci status 命令对接 - 返回结构化数据,上层负责格式化输出 """ import argparse import sys from . import config from .gitea_client import GiteaClient class CIQuery: """CI 状态查询器""" def __init__(self, gitea_client=None): self.gitea = gitea_client or GiteaClient() # ── 查询方法 ────────────────────────────────────── def get_branch_status(self, branch, limit=5): """获取指定分支最新的 CI 状态 Returns: dict: {branch, latest_run, recent_runs, overall_status} """ runs, total = self.gitea.list_runs(branch=branch, limit=limit) if not runs: return { "branch": branch, "latest_run": None, "recent_runs": [], "overall_status": "no_runs", "total_count": total, } latest = runs[0] overall = self._derive_overall_status(runs) return { "branch": branch, "latest_run": latest, "recent_runs": runs, "overall_status": overall, "total_count": total, } def get_pr_status(self, pr_number): """获取指定 PR 的 CI 状态 Returns: dict: {pr_number, pr_title, runs, overall_status} """ pr = self.gitea.get_pr(pr_number) if not pr: return { "pr_number": pr_number, "pr_title": "未知", "runs": [], "overall_status": "pr_not_found", } pr_title = pr.get("title", "") runs = self.gitea.get_pr_ci_runs(pr_number, limit=10) overall = self._derive_overall_status(runs) if runs else "no_runs" return { "pr_number": pr_number, "pr_title": pr_title, "runs": runs, "overall_status": overall, "head_sha": pr.get("head", {}).get("sha", ""), } def get_failure_detail(self, run_id): """获取某次 run 的失败详情 Returns: dict: {run_info, failed_jobs, summary} """ run = self.gitea.get_run(run_id) if not run: return {"run_info": None, "failed_jobs": [], "summary": "Run not found"} failed_jobs = self.gitea.get_failed_jobs_summary(run_id, max_lines_per_job=30) summary_parts = [] for job in failed_jobs: step = f"(步骤: {job['failed_step']})" if job["failed_step"] else "" summary_parts.append(f"• {job['name']}{step}") summary = "\n".join(summary_parts) if summary_parts else "无失败 job(可能还在运行中)" return { "run_info": run, "failed_jobs": failed_jobs, "summary": summary, "total_jobs": len(self.gitea.get_run_jobs(run_id)), } def list_recent_runs(self, status=None, branch=None, limit=10): """列出最近的 runs""" runs, total = self.gitea.list_runs(status=status, branch=branch, limit=limit) return {"runs": runs, "total_count": total} # ── 辅助方法 ──────────────────────────────────── @staticmethod def _derive_overall_status(runs): """根据最近 runs 推导整体状态 Returns: success: 最近一次成功 failing: 最近一次失败(连续失败) flaky: 有失败有成功(最近一次失败 running: 有正在运行的 unknown: 未知 """ if not runs: return "no_runs" # 检查是否有运行中的 running = [r for r in runs if r.get("status") != "completed"] if running: return "running" # 看最近一次 latest = runs[0] latest_conclusion = latest.get("conclusion", "unknown") if latest_conclusion == "success": return "success" if latest_conclusion == "failure": # 检查是否连续失败 consecutive_failures = 0 for r in runs: if r.get("conclusion") == "failure": consecutive_failures += 1 else: break # 看之前有没有成功 has_success = any(r.get("conclusion") == "success" for r in runs) if has_success: return "flaky" return "failing" return "unknown" # ── 格式化输出 ──────────────────────────────────── @staticmethod def format_branch_status(status_data): """格式化分支状态为人类可读文本""" branch = status_data["branch"] latest = status_data["latest_run"] overall = status_data["overall_status"] status_emoji = { "success": "✅", "failing": "🔴", "flaky": "🟡", "running": "🔄", "no_runs": "⚪", "unknown": "❓", }.get(overall, "❓") lines = [f"**CI 状态:{branch} 分支**", f"整体状态: {status_emoji} {overall}"] if latest: name = latest.get("name", "Unknown") conclusion = latest.get("conclusion", latest.get("status", "unknown")) run_id = latest.get("id", "") created = latest.get("created_at", "")[:16].replace("T", " ") run_url = f"{config.GITEA_URL}/{config.GITEA_REPO}/actions/runs/{run_id}" lines.append(f"最新: [{name} #{run_id}]({run_url}) - {conclusion} ({created})") recent = status_data["recent_runs"] if len(recent) > 1: lines.append(f"\n最近 {len(recent)} 次:") for r in recent[:5]: c = r.get("conclusion", r.get("status", "?")) emoji = {"success": "✅", "failure": "❌", "skipped": "⏭️"}.get(c, "🔄") lines.append(f" {emoji} #{r.get('id', '?')} {r.get('name', '?')[:30]} - {c}") return "\n".join(lines) @staticmethod def format_pr_status(status_data): """格式化 PR 状态为人类可读文本""" pr_num = status_data["pr_number"] pr_title = status_data["pr_title"] overall = status_data["overall_status"] status_emoji = { "success": "✅", "failing": "🔴", "flaky": "🟡", "running": "🔄", "no_runs": "⚪", "pr_not_found": "❓", "unknown": "❓", }.get(overall, "❓") pr_url = f"{config.GITEA_URL}/{config.GITEA_REPO}/pulls/{pr_num}" lines = [ f"**CI 状态:PR #{pr_num}**", f"标题: [{pr_title}]({pr_url})", f"状态: {status_emoji} {overall}", ] runs = status_data["runs"] if runs: lines.append(f"\nCI Runs ({len(runs)}):") for r in runs[:5]: c = r.get("conclusion", r.get("status", "?")) emoji = {"success": "✅", "failure": "❌", "skipped": "⏭️"}.get(c, "🔄") run_id = r.get("id", "?") run_url = f"{config.GITEA_URL}/{config.GITEA_REPO}/actions/runs/{run_id}" lines.append(f" {emoji} [{r.get('name', '?')[:30]} #{run_id}]({run_url}) - {c}") return "\n".join(lines) # ── CLI 入口 ────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="CI 状态查询") parser.add_argument("--branch", help="查询指定分支的 CI 状态") parser.add_argument("--pr", type=int, help="查询指定 PR 的 CI 状态") parser.add_argument("--run-id", help="查询指定 run 的详情") parser.add_argument("--detail", action="store_true", help="显示失败详情") parser.add_argument("--limit", type=int, default=5, help="返回数量限制") parser.add_argument("--status", help="按状态过滤: success/failure/running") args = parser.parse_args() query = CIQuery() if args.run_id: if args.detail: result = query.get_failure_detail(args.run_id) print(f"Run #{args.run_id} 失败详情:") print(result["summary"]) if result["failed_jobs"]: print("\n详细日志尾部:") for job in result["failed_jobs"]: print(f"\n--- {job['name']} ---") print(job["log_tail"][:500] if job["log_tail"] else "无日志") else: run = query.gitea.get_run(args.run_id) if run: print(f"Run #{args.run_id}: {run.get('name')} - {run.get('conclusion', run.get('status'))}") print(f"分支: {run.get('head_branch', '?')}") print(f"触发: {run.get('event', '?')}") else: print(f"Run {args.run_id} 不存在") elif args.pr: result = query.get_pr_status(args.pr) print(CIQuery.format_pr_status(result)) elif args.branch: result = query.get_branch_status(args.branch, limit=args.limit) print(CIQuery.format_branch_status(result)) elif args.status: result = query.list_recent_runs(status=args.status, limit=args.limit) for r in result["runs"]: print( f"#{r.get('id')} {r.get('name')[:40]} - {r.get('conclusion', r.get('status'))} ({r.get('head_branch', '?')})" ) else: parser.print_help() return 1 return 0 if __name__ == "__main__": sys.exit(main())