Files
xiaoxia-saas/scripts/ci/chatops/feishu_notify.py
T
CI Bot 55347f962c
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 16s
AI Code Review / AI Code Review (pull_request) Failing after 23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 49s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m55s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m52s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 3m17s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m31s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 3m56s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 20s
feat(ci): P2-2 ChatOps集成 - 飞书机器人对接Gitea Actions
新增 scripts/ci/chatops/ 目录,Python实现,纯工具链扩展不动业务代码。

3个核心模块:
- ci_query.py    - 查询CI状态(分支/PR/失败详情)
- ci_trigger.py  - 触发重跑(rerun失败job/整个workflow/取消)
- feishu_notify.py - CI事件飞书通知(失败/恢复/E2E摘要)

配套基础设施:
- config.py      - 配置化,所有token/webhook走环境变量
- gitea_client.py - Gitea API统一封装(urllib实现,无第三方依赖)
- webhook_server.py - FastAPI webhook接收服务(可选依赖)

通知规则(最高频使用场景):
- main/develop分支CI失败 → 飞书告警卡片(含失败摘要+一键重跑)
- 分支从失败恢复 → 绿色恢复通知(含故障时长)
- E2E测试失败 → 单独摘要通知
- 连续失败不重复告警(状态缓存去重)

飞书交互命令(第一版已实现解析+执行):
- /ci status [branch]   - 查询分支CI状态
- /ci rerun <run-id>    - 重跑失败jobs
- /ci rerun latest [branch] - 重跑最近一次失败的run
- /ci help              - 帮助

black+ruff全绿,与现有ci脚本风格一致。
2026-07-18 18:38:11 +08:00

391 lines
15 KiB
Python
Executable File
Raw 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())