Files
xiaoxia-saas/scripts/ci/chatops/ci_query.py
xiaoxia a117158168
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 3m15s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1436h43m52s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1436h43m53s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1436h43m54s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1436h43m55s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1436h43m55s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1436h47m2s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
feat(ci): P2-2 ChatOps集成 - 飞书机器人对接Gitea Actions (#533)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:42:36 +08:00

297 lines
10 KiB
Python
Executable File
Raw Permalink 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 状态查询模块 - 查询 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())