6eac0b2cf2
Worker Base Image Build / Build Worker Base Images (worker-base-builder-cache, infra/docker/worker-base-builder.Dockerfile, worker-base-builder, builder) (push) Failing after 1m54s
Worker Base Image Build / Build Worker Base Images (worker-base-runtime-cache, infra/docker/worker-base-runtime.Dockerfile, worker-base-runtime, runtime) (push) Failing after 1m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m2s
CI/CD Pipeline / Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 44s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 8m14s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
同步内容: 1. CI流水线配置(ci-pipeline.yml)与develop对齐 2. PR构建脚本docker_build_only.sh增加buildx→docker build回退 3. pre-build步骤worker基础镜像构建增加buildx回退 4. 单元测试脚本全量覆盖率改为仅报告不阻塞 5. diff-cover依赖加入requirements-dev.txt 6. worker base builder/runtime Dockerfile同步 7. test_config_oss.py clear=False→clear=True修复OSS污染 8. Frontend Lint增加prettier依赖
391 lines
15 KiB
Python
Executable File
391 lines
15 KiB
Python
Executable File
#!/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())
|