Files
xiaoxia a117158168
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 45s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 3m15s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m36s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m2s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 5m15s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 14s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m9s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
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

391 lines
15 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 关键事件推送
支持通知类型:
- 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())