545ff0fab8
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 24s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m17s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m24s
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Successful in 1m35s
AI Code Review / AI Code Review (pull_request) Failing after 2m12s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 2m22s
CI/CD Pipeline / PR Build Worker Image (Backend) (pull_request) Failing after 2m23s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m30s
CI/CD Pipeline / AI Code Review (pull_request) Failing after 3m56s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m59s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m42s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 8s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
209 lines
7.7 KiB
Python
Executable File
209 lines
7.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
统一CI通知脚本 - 发送飞书卡片通知
|
||
支持三种模式: start / success / failure
|
||
包含: PR链接、耗时、失败阶段、分支、提交者、Run链接、Runner信息
|
||
|
||
用法:
|
||
NOTIFY_MODE=start JOB_NAME="xxx" python3 scripts/ci_notify.py
|
||
NOTIFY_MODE=success JOB_NAME="xxx" JOB_DURATION="2m30s" python3 scripts/ci_notify.py
|
||
NOTIFY_MODE=failure JOB_NAME="xxx" FAILED_STEP="xxx" JOB_DURATION="2m30s" python3 scripts/ci_notify.py
|
||
|
||
环境变量:
|
||
CI_NOTIFY_WEBHOOK - 飞书webhook地址 (必填)
|
||
NOTIFY_MODE - 通知模式: start / success / failure (必填)
|
||
JOB_NAME - Job名称 (必填)
|
||
JOB_DURATION - 耗时,如"2m30s" (成功/失败时建议传)
|
||
FAILED_STEP - 失败的步骤名 (失败时建议传)
|
||
GITHUB_REF_NAME - 分支名
|
||
GITHUB_SHA - commit SHA
|
||
GITHUB_ACTOR - 提交者
|
||
GITHUB_RUN_ID - Run ID
|
||
GITHUB_REPOSITORY - 仓库路径
|
||
GITHUB_EVENT_NAME - 事件类型 (pull_request / push / ...)
|
||
GITHUB_PR_NUMBER - PR编号 (PR事件时)
|
||
GITHUB_PR_TITLE - PR标题 (PR事件时)
|
||
RUNNER_NAME - Runner名称 (可选,自动获取)
|
||
|
||
设计原则:
|
||
1. 通知失败永远不阻断CI主流程(返回exit code 0)
|
||
2. 标题包含"CI通知"/"CI告警"关键词,适配飞书webhook关键词校验
|
||
3. 卡片信息尽量丰富,方便快速定位问题
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import urllib.request
|
||
|
||
|
||
def get_env(name, default=""):
|
||
"""读取环境变量"""
|
||
return os.environ.get(name, default)
|
||
|
||
|
||
def format_duration(seconds_str):
|
||
"""将秒数格式化为易读形式"""
|
||
try:
|
||
seconds = int(float(seconds_str))
|
||
mins = seconds // 60
|
||
secs = seconds % 60
|
||
if mins > 0:
|
||
return f"{mins}m{secs}s"
|
||
return f"{secs}s"
|
||
except (ValueError, TypeError):
|
||
return seconds_str or "未知"
|
||
|
||
|
||
def classify_job(job_name):
|
||
"""根据Job名称判断所属阶段"""
|
||
name = job_name.lower()
|
||
if any(k in name for k in ["validate", "lint", "unit test", "integration test"]):
|
||
return "门禁检查"
|
||
if any(k in name for k in ["build", "image"]):
|
||
return "镜像构建"
|
||
if any(k in name for k in ["deploy", "staging", "production"]):
|
||
return "部署发布"
|
||
if any(k in name for k in ["e2e", "test", "smoke"]):
|
||
return "测试验证"
|
||
return "其他"
|
||
|
||
|
||
def main() -> int:
|
||
webhook = get_env("CI_NOTIFY_WEBHOOK")
|
||
if not webhook:
|
||
print("未配置 CI_NOTIFY_WEBHOOK,跳过通知")
|
||
print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK")
|
||
return 0
|
||
|
||
mode = get_env("NOTIFY_MODE", "failure").lower()
|
||
job_name = get_env("JOB_NAME", "Unknown Job")
|
||
duration = get_env("JOB_DURATION")
|
||
if not duration:
|
||
duration_sec = get_env("JOB_DURATION_SECONDS")
|
||
duration = format_duration(duration_sec) if duration_sec else "计算中..."
|
||
|
||
failed_step = get_env("FAILED_STEP", "")
|
||
branch = get_env("GITHUB_REF_NAME", "unknown")
|
||
commit = get_env("GITHUB_SHA", "unknown")[:8]
|
||
actor = get_env("GITHUB_ACTOR", "unknown")
|
||
run_id = get_env("GITHUB_RUN_ID", "unknown")
|
||
repo = get_env("GITHUB_REPOSITORY", "unknown")
|
||
event_name = get_env("GITHUB_EVENT_NAME", "")
|
||
pr_number = get_env("GITHUB_PR_NUMBER", "")
|
||
pr_title = get_env("GITHUB_PR_TITLE", "")
|
||
runner_name = get_env("RUNNER_NAME", "")
|
||
|
||
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
|
||
job_stage = classify_job(job_name)
|
||
|
||
# 根据模式设置标题、状态、颜色
|
||
# 注意:标题中必须包含飞书webhook配置的关键词,否则会报"Key Words Not Found"
|
||
# 这里加入"CI通知"/"CI告警"关键词提高命中率
|
||
if mode == "start":
|
||
title = f"🔄 CI通知:{job_name} 开始构建"
|
||
status = "blue"
|
||
button_text = "查看进度"
|
||
button_type = "primary"
|
||
elif mode == "success":
|
||
title = f"✅ CI通知:{job_name} 构建成功"
|
||
status = "green"
|
||
button_text = "查看详情"
|
||
button_type = "primary"
|
||
else: # failure
|
||
title = f"❌ CI告警:{job_name} 构建失败"
|
||
status = "red"
|
||
button_text = "查看失败日志"
|
||
button_type = "danger"
|
||
|
||
# 构建卡片内容 - 左侧标签+右侧值的结构化展示
|
||
fields = []
|
||
|
||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**阶段**\n{job_stage}"}})
|
||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**任务**\n{job_name}"}})
|
||
|
||
if mode != "start":
|
||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**耗时**\n{duration}"}})
|
||
else:
|
||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": "**状态**\n进行中"}})
|
||
|
||
if runner_name:
|
||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**Runner**\n{runner_name}"}})
|
||
|
||
if mode == "failure" and failed_step:
|
||
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**失败步骤**\n{failed_step}"}})
|
||
|
||
# PR/分支信息
|
||
if event_name == "pull_request" and pr_number:
|
||
pr_url = f"https://git.xiaoxiajianji.com/{repo}/pulls/{pr_number}"
|
||
pr_display = f"#{pr_number}"
|
||
if pr_title:
|
||
pr_display += f" {pr_title[:30]}"
|
||
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**PR**\n[{pr_display}]({pr_url})"}})
|
||
elif event_name == "push":
|
||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**分支**\n{branch}"}})
|
||
|
||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**提交**\n`{commit}`"}})
|
||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**提交者**\n{actor}"}})
|
||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**Run ID**\n{run_id}"}})
|
||
|
||
payload = {
|
||
"msg_type": "interactive",
|
||
"card": {
|
||
"header": {
|
||
"title": {
|
||
"tag": "plain_text",
|
||
"content": title,
|
||
},
|
||
"status": status,
|
||
},
|
||
"elements": [
|
||
{
|
||
"tag": "div",
|
||
"fields": fields,
|
||
},
|
||
{
|
||
"tag": "action",
|
||
"actions": [
|
||
{
|
||
"tag": "button",
|
||
"text": {"tag": "plain_text", "content": button_text},
|
||
"url": run_url,
|
||
"type": button_type,
|
||
}
|
||
],
|
||
},
|
||
],
|
||
},
|
||
}
|
||
|
||
data = json.dumps(payload).encode("utf-8")
|
||
req = urllib.request.Request(
|
||
webhook,
|
||
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")
|
||
# 飞书返回code=0表示成功
|
||
try:
|
||
result = json.loads(resp_body)
|
||
if result.get("code", 0) != 0:
|
||
print(f"通知发送告警: 飞书返回错误 - {result.get('msg', resp_body)}", file=sys.stderr)
|
||
print(f"通知已发送 ({mode}) - 飞书返回非0,但不阻断CI流程")
|
||
else:
|
||
print(f"通知已发送 ({mode})")
|
||
except json.JSONDecodeError:
|
||
print(f"通知已发送 ({mode})")
|
||
except Exception as e:
|
||
print(f"通知发送告警: {e}", file=sys.stderr)
|
||
|
||
# 通知无论成功失败都不阻断CI主流程,统一返回0
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|