#!/usr/bin/env python3 """ 飞书通知模块 - CI 关键事件推送 支持通知类型: - branch_failure: main/develop 分支 CI 失败 - branch_recovery: main/develop 分支 CI 从失败恢复(绿色恢复) - e2e_failure: E2E 测试失败摘要 - pr_failure: PR CI 失败(可选) 用法: # 命令行直接调用(供 CI workflow 使用) python3 -m scripts.ci.chatops.feishu_notify --mode failure --run-id 12345 python3 scripts/ci/chatops/feishu_notify.py --mode recovery --run-id 12345 # Python 模块调用 from scripts.ci.chatops.feishu_notify import FeishuNotifier notifier = FeishuNotifier() notifier.notify_branch_failure(run_id=12345, branch="develop") 设计原则: 1. 通知失败永远不阻断主流程(返回 0) 2. 卡片信息丰富,一键跳转 Gitea 详情页 3. 失败通知包含错误摘要,不用点进去就能判断严重程度 """ import argparse import json import sys import urllib.request from datetime import datetime from . import config from .gitea_client import GiteaClient class FeishuNotifier: """飞书通知发送器""" def __init__(self, webhook_url=None, gitea_client=None): self.webhook_url = webhook_url or config.FEISHU_WEBHOOK_URL self.gitea = gitea_client or GiteaClient() def _send_card(self, card_payload): """发送飞书卡片消息 Returns: True 表示发送成功(飞书返回 code=0) """ if not self.webhook_url: print("[INFO] 未配置 FEISHU_WEBHOOK_URL,跳过飞书通知") return False payload = {"msg_type": "interactive", "card": card_payload} data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( self.webhook_url, data=data, headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=10) as resp: resp_body = resp.read().decode("utf-8") result = json.loads(resp_body) if result.get("code", 0) != 0: print( f"[WARN] 飞书通知返回错误: {result.get('msg', resp_body)}", file=sys.stderr, ) return False return True except Exception as e: print(f"[WARN] 飞书通知发送失败: {e}", file=sys.stderr) return False # ── 通知模板 ────────────────────────────────────── def _run_url(self, run_id): return f"{config.GITEA_URL}/{config.GITEA_REPO}/actions/runs/{run_id}" def _pr_url(self, pr_number): return f"{config.GITEA_URL}/{config.GITEA_REPO}/pulls/{pr_number}" def notify_branch_failure(self, run_id, branch, run_data=None): """main/develop 分支 CI 失败通知 包含: 失败 job 列表、错误摘要、一键重跑链接 """ run = run_data or self.gitea.get_run(run_id) if not run: print(f"[WARN] 无法获取 run {run_id} 详情", file=sys.stderr) return False workflow_name = run.get("name", "Unknown Workflow") commit_msg = run.get("head_commit", {}).get("message", "未知").splitlines()[0][:60] commit_sha = run.get("head_sha", "")[:8] actor = ( run.get("trigger_event", {}).get("actor", {}).get("login", "unknown") if isinstance(run.get("trigger_event"), dict) else run.get("actor", "unknown") ) run_url = self._run_url(run_id) # 获取失败 job 摘要 failed_jobs = self.gitea.get_failed_jobs_summary(run_id, max_lines_per_job=15) # 构建失败摘要 failure_summary = "" if failed_jobs: job_lines = [] for job in failed_jobs[:3]: # 最多显示 3 个 step_info = f"({job['failed_step']})" if job["failed_step"] else "" job_lines.append(f"• **{job['name']}**{step_info}") if job["log_tail"]: # 取最后 3 行日志 tail_lines = job["log_tail"].strip().splitlines()[-3:] for line in tail_lines: clean_line = line.strip()[:100] if clean_line: job_lines.append(f" `{clean_line}`") failure_summary = "\n".join(job_lines) else: failure_summary = "(获取失败详情中,点击查看日志)" # 字段 fields = [ {"is_short": True, "text": {"tag": "lark_md", "content": f"**分支**\n{branch}"}}, {"is_short": True, "text": {"tag": "lark_md", "content": f"**Workflow**\n{workflow_name}"}}, {"is_short": True, "text": {"tag": "lark_md", "content": f"**提交**\n`{commit_sha}`"}}, {"is_short": True, "text": {"tag": "lark_md", "content": f"**触发者**\n{actor}"}}, {"is_short": False, "text": {"tag": "lark_md", "content": f"**提交信息**\n{commit_msg}"}}, {"is_short": False, "text": {"tag": "lark_md", "content": f"**失败详情**\n{failure_summary}"}}, ] card = { "header": { "title": { "tag": "plain_text", "content": f"❌ CI告警:{branch} 分支构建失败", }, "status": "red", }, "elements": [ {"tag": "div", "fields": fields}, { "tag": "action", "actions": [ { "tag": "button", "text": {"tag": "plain_text", "content": "查看失败日志"}, "url": run_url, "type": "danger", }, { "tag": "button", "text": {"tag": "plain_text", "content": "重跑失败Job"}, "url": f"{run_url}/rerun-failed-jobs", "type": "default", }, ], }, ], } result = self._send_card(card) print(f"[INFO] 分支失败通知已发送: {branch} run={run_id}") return result def notify_branch_recovery(self, run_id, branch, previous_failure_run_id=None): """分支 CI 恢复通知(从失败变成功)""" run = self.gitea.get_run(run_id) if not run: print(f"[WARN] 无法获取 run {run_id} 详情", file=sys.stderr) return False workflow_name = run.get("name", "Unknown Workflow") commit_sha = run.get("head_sha", "")[:8] run_url = self._run_url(run_id) # 计算恢复耗时 duration_text = "已恢复" if previous_failure_run_id: prev_run = self.gitea.get_run(previous_failure_run_id) if prev_run: # 简单计算两个 run 的时间差 prev_time = prev_run.get("created_at", "") cur_time = run.get("created_at", "") if prev_time and cur_time: try: t1 = datetime.fromisoformat(prev_time.replace("Z", "+00:00")) t2 = datetime.fromisoformat(cur_time.replace("Z", "+00:00")) diff = (t2 - t1).total_seconds() / 60 duration_text = f"故障时长约 {diff:.0f} 分钟" except Exception: pass fields = [ {"is_short": True, "text": {"tag": "lark_md", "content": f"**分支**\n{branch}"}}, {"is_short": True, "text": {"tag": "lark_md", "content": f"**Workflow**\n{workflow_name}"}}, {"is_short": True, "text": {"tag": "lark_md", "content": f"**提交**\n`{commit_sha}`"}}, {"is_short": True, "text": {"tag": "lark_md", "content": "**状态**\n✅ 已恢复"}}, {"is_short": False, "text": {"tag": "lark_md", "content": f"**说明**\n{duration_text}"}}, ] card = { "header": { "title": { "tag": "plain_text", "content": f"✅ CI通知:{branch} 分支构建已恢复", }, "status": "green", }, "elements": [ {"tag": "div", "fields": fields}, { "tag": "action", "actions": [ { "tag": "button", "text": {"tag": "plain_text", "content": "查看详情"}, "url": run_url, "type": "primary", } ], }, ], } result = self._send_card(card) print(f"[INFO] 分支恢复通知已发送: {branch} run={run_id}") return result def notify_e2e_failure(self, run_id, branch="develop", pr_number=None): """E2E 测试失败摘要通知""" failed_jobs = self.gitea.get_failed_jobs_summary(run_id, max_lines_per_job=50) e2e_jobs = [j for j in failed_jobs if "e2e" in j["name"].lower() or "test" in j["name"].lower()] if not e2e_jobs: # 没有明确的 e2e job,取所有失败的 e2e_jobs = failed_jobs run_url = self._run_url(run_id) title_suffix = f"PR #{pr_number}" if pr_number else f"{branch} 分支" # 构建失败用例摘要 case_summary = "" for job in e2e_jobs[:3]: case_summary += f"**{job['name']}**\n" if job["log_tail"]: # 尝试提取 FAIL 行 fail_lines = [ line.strip() for line in job["log_tail"].splitlines() if "FAIL" in line or "fail" in line.lower() or "✗" in line or "●" in line ][:5] if fail_lines: for line in fail_lines: case_summary += f" • {line[:120]}\n" else: tail = job["log_tail"].strip().splitlines()[-5:] for line in tail: case_summary += f" `{line.strip()[:100]}`\n" case_summary += "\n" if not case_summary: case_summary = "(点击查看完整测试报告)" fields = [ {"is_short": True, "text": {"tag": "lark_md", "content": f"**来源**\n{title_suffix}"}}, {"is_short": True, "text": {"tag": "lark_md", "content": f"**失败Job数**\n{len(e2e_jobs)}"}}, {"is_short": False, "text": {"tag": "lark_md", "content": f"**失败摘要**\n{case_summary}"}}, ] card = { "header": { "title": { "tag": "plain_text", "content": f"🧪 CI告警:E2E 测试失败 - {title_suffix}", }, "status": "orange", }, "elements": [ {"tag": "div", "fields": fields}, { "tag": "action", "actions": [ { "tag": "button", "text": {"tag": "plain_text", "content": "查看完整报告"}, "url": run_url, "type": "danger", } ], }, ], } result = self._send_card(card) print(f"[INFO] E2E失败通知已发送: run={run_id}") return result def notify_pr_failure(self, run_id, pr_number, pr_title=""): """PR CI 失败通知(轻量版,可选开启)""" run_url = self._run_url(run_id) pr_url = self._pr_url(pr_number) failed_jobs = self.gitea.get_failed_jobs_summary(run_id, max_lines_per_job=10) failure_names = [j["name"] for j in failed_jobs[:3]] failure_text = "、".join(failure_names) if failure_names else "未知" fields = [ { "is_short": False, "text": {"tag": "lark_md", "content": f"**PR**\n[#{pr_number} {pr_title[:50]}]({pr_url})"}, }, {"is_short": False, "text": {"tag": "lark_md", "content": f"**失败任务**\n{failure_text}"}}, ] card = { "header": { "title": { "tag": "plain_text", "content": f"⚠️ CI通知:PR #{pr_number} 构建失败", }, "status": "yellow", }, "elements": [ {"tag": "div", "fields": fields}, { "tag": "action", "actions": [ { "tag": "button", "text": {"tag": "plain_text", "content": "查看日志"}, "url": run_url, "type": "default", }, { "tag": "button", "text": {"tag": "plain_text", "content": "查看PR"}, "url": pr_url, "type": "default", }, ], }, ], } result = self._send_card(card) print(f"[INFO] PR失败通知已发送: PR #{pr_number} run={run_id}") return result # ── CLI 入口 ────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="飞书 CI 通知") parser.add_argument( "--mode", required=True, choices=["failure", "recovery", "e2e_failure", "pr_failure"], help="通知模式", ) parser.add_argument("--run-id", required=True, help="Workflow Run ID") parser.add_argument("--branch", default="develop", help="分支名") parser.add_argument("--pr-number", type=int, help="PR 编号") parser.add_argument("--pr-title", default="", help="PR 标题") parser.add_argument("--prev-run-id", help="上一个失败的 run ID(恢复通知用)") parser.add_argument("--webhook", help="飞书 webhook URL(覆盖环境变量)") args = parser.parse_args() notifier = FeishuNotifier(webhook_url=args.webhook) if args.mode == "failure": notifier.notify_branch_failure(args.run_id, args.branch) elif args.mode == "recovery": notifier.notify_branch_recovery(args.run_id, args.branch, previous_failure_run_id=args.prev_run_id) elif args.mode == "e2e_failure": notifier.notify_e2e_failure(args.run_id, branch=args.branch, pr_number=args.pr_number) elif args.mode == "pr_failure": notifier.notify_pr_failure(args.run_id, args.pr_number, pr_title=args.pr_title) return 0 if __name__ == "__main__": sys.exit(main())