Files
xiaoxia 6e70a7ad29
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
chore(ci): 端口变量命名统一 (#799) (#845)
2026-07-24 21:29:35 +08:00

447 lines
14 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
"""
Gitea Webhook 接收服务 - FastAPI 实现
功能:
1. 接收 Gitea Actions webhook 事件,触发飞书通知
2. 接收飞书机器人回调消息,处理 /ci 交互命令
3. 维护简单的状态缓存,检测分支恢复等状态变化
部署:
部署到构建服务器,监听 8090 端口(可配置)
Gitea webhook 指向: http://<server>:8090/webhook/gitea
飞书消息回调指向: http://<server>:8090/webhook/feishu
依赖:
fastapi + uvicorn(可选,未安装时仅模块可用,服务不可启动)
注意:
本文件为第一版骨架,通知逻辑已实现,飞书交互命令待后续完善。
"""
import hashlib
import hmac
import json
import sys
import threading
import time
from typing import Optional
from . import config
from .gitea_client import GiteaClient
# FastAPI 是可选依赖,未安装时仅导出类不启动服务
try:
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
FASTAPI_AVAILABLE = True
except ImportError:
FASTAPI_AVAILABLE = False
FastAPI = None # type: ignore
# ── 状态缓存 ──────────────────────────────────────────
class StateCache:
"""简单的内存状态缓存,用于检测状态变化
记录每个分支最后一次 run 的状态,用于判断:
- 是否从失败变成功(恢复通知)
- 是否连续失败(避免重复告警)
"""
def __init__(self, max_entries=100):
self._cache = {} # {branch: {last_status, last_run_id, last_notified_failure}}
self._lock = threading.Lock()
self._max = max_entries
def get(self, key):
with self._lock:
return self._cache.get(key)
def set(self, key, value):
with self._lock:
self._cache[key] = value
# 简单的淘汰策略
if len(self._cache) > self._max:
oldest_key = next(iter(self._cache))
del self._cache[oldest_key]
def check_and_update(self, branch, run_id, conclusion):
"""检查状态变化并更新缓存
Returns:
dict: {is_new_failure, is_recovery, previous_status, previous_run_id}
"""
prev = self.get(branch) or {}
prev_status = prev.get("last_status", "unknown")
prev_run_id = prev.get("last_run_id")
is_new_failure = False
is_recovery = False
if conclusion == "failure" and prev_status != "failure":
is_new_failure = True
if conclusion == "success" and prev_status == "failure":
is_recovery = True
self.set(
branch,
{
"last_status": conclusion,
"last_run_id": run_id,
"last_updated": time.time(),
"last_notified_failure": run_id if is_new_failure else prev.get("last_notified_failure"),
},
)
return {
"is_new_failure": is_new_failure,
"is_recovery": is_recovery,
"previous_status": prev_status,
"previous_run_id": prev_run_id,
}
state_cache = StateCache()
# ── Gitea Webhook 处理 ────────────────────────────────
def verify_gitea_signature(payload: bytes, signature: str) -> bool:
"""校验 Gitea webhook 签名(X-Gitea-Signature
Gitea 使用 HMAC-SHA256 签名,格式: sha256=xxx
"""
if not config.WEBHOOK_SECRET:
return True # 未配置密钥则跳过校验
if not signature:
return False
try:
algo, sig_hex = signature.split("=", 1)
if algo != "sha256":
return False
expected = hmac.new(config.WEBHOOK_SECRET.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig_hex)
except Exception:
return False
def handle_gitea_webhook(payload: dict, event_type: str) -> dict:
"""处理 Gitea webhook 事件
Args:
payload: webhook 请求体
event_type: X-Gitea-Event 头
Returns:
dict: {handled, notifications_sent, message}
"""
if event_type != "create" and event_type != "push":
# 我们主要关心 push 和 actions 事件
# Gitea Actions 的 webhook 事件类型可能是 "push" 或专门的 actions 事件
pass
# 尝试提取 run 信息
run_info = _extract_run_info(payload)
if not run_info:
return {"handled": False, "notifications_sent": 0, "message": "非 CI 事件,跳过"}
branch = run_info["branch"]
run_id = run_info["run_id"]
status = run_info["status"]
conclusion = run_info.get("conclusion", "")
# 只处理已完成的 run
if status != "completed":
return {"handled": True, "notifications_sent": 0, "message": f"Run {run_id} 仍在运行中 ({status})"}
# 检查是否在通知分支列表中
if branch not in config.NOTIFY_BRANCHES:
return {
"handled": True,
"notifications_sent": 0,
"message": f"分支 {branch} 不在通知列表中",
}
# 检查状态变化
change_info = state_cache.check_and_update(branch, run_id, conclusion)
notifications = 0
# 延迟导入,避免循环依赖
from .feishu_notify import FeishuNotifier
notifier = FeishuNotifier()
if conclusion == "failure" and change_info["is_new_failure"]:
# 新失败 → 发失败通知
notifier.notify_branch_failure(run_id, branch)
notifications += 1
# 检查是否是 E2E 失败
gitea = GiteaClient()
failed_jobs = gitea.get_failed_jobs_summary(run_id)
has_e2e = any("e2e" in j["name"].lower() for j in failed_jobs)
if has_e2e:
notifier.notify_e2e_failure(run_id, branch=branch)
notifications += 1
elif conclusion == "success" and change_info["is_recovery"]:
# 从失败恢复 → 发恢复通知
prev_run_id = change_info.get("previous_run_id")
notifier.notify_branch_recovery(run_id, branch, previous_failure_run_id=prev_run_id)
notifications += 1
return {
"handled": True,
"notifications_sent": notifications,
"message": f"分支 {branch} run {run_id} {conclusion}",
}
def _extract_run_info(payload: dict) -> Optional[dict]:
"""从 webhook payload 中提取 run 信息
Gitea Actions webhook 的 payload 结构可能不同,这里做兼容处理。
如果 payload 不是 run 事件,返回 None。
"""
# 尝试多种可能的结构
if "workflow_run" in payload:
wr = payload["workflow_run"]
return {
"run_id": wr.get("id"),
"branch": wr.get("head_branch", ""),
"status": wr.get("status", ""),
"conclusion": wr.get("conclusion", ""),
"name": wr.get("name", ""),
}
if "action" in payload and "pull_request" in payload:
# PR 事件,暂不处理
return None
if "ref" in payload and "head_commit" in payload:
# push 事件,不是 run 事件
return None
return None
# ── 飞书消息处理 ──────────────────────────────────────
def handle_feishu_message(payload: dict) -> dict:
"""处理飞书机器人回调消息
支持命令:
/ci status [branch] - 查询分支 CI 状态
/ci rerun <run-id> - 重跑失败的 jobs
/ci help - 帮助
注意: 第一版骨架,仅解析命令,实际执行逻辑待完善。
"""
# 飞书消息回调格式
header = payload.get("header", {})
event_type = header.get("event_type", "")
if event_type == "url_verification":
# 飞书 URL 验证
return {"challenge": payload.get("challenge", "")}
if event_type != "im.message.receive_v1":
return {"handled": False, "message": f"非消息事件: {event_type}"}
event = payload.get("event", {})
message = event.get("message", {})
content_str = message.get("content", "{}")
try:
content = json.loads(content_str)
except json.JSONDecodeError:
content = {}
text = content.get("text", "")
if not text:
return {"handled": False, "message": "空消息"}
# 解析命令
text = text.strip()
if not text.startswith("/ci"):
return {"handled": False, "message": "非 CI 命令"}
parts = text.split()
if len(parts) < 2:
return _help_response()
cmd = parts[1].lower()
if cmd == "status":
branch = parts[2] if len(parts) > 2 else "develop"
return _handle_status_command(branch)
elif cmd == "rerun":
if len(parts) < 3:
return {"text": "用法: /ci rerun <run-id> 或 /ci rerun latest [branch]"}
arg = parts[2]
if arg == "latest":
branch = parts[3] if len(parts) > 3 else "develop"
return _handle_rerun_latest(branch)
return _handle_rerun_command(arg)
elif cmd == "help":
return _help_response()
else:
return {"text": f"未知命令: {cmd}\n输入 /ci help 查看帮助"}
def _handle_status_command(branch: str) -> dict:
"""处理 /ci status 命令"""
from .ci_query import CIQuery
query = CIQuery()
result = query.get_branch_status(branch)
reply = CIQuery.format_branch_status(result)
return {"text": reply}
def _handle_rerun_command(run_id: str) -> dict:
"""处理 /ci rerun 命令"""
from .ci_trigger import CITrigger
trigger = CITrigger()
try:
result = trigger.rerun_failed(int(run_id))
except ValueError:
return {"text": f"无效的 run id: {run_id}"}
if result["success"]:
return {"text": f"✅ {result['message']}\n{result.get('run_url', '')}"}
return {"text": f"❌ {result['message']}"}
def _handle_rerun_latest(branch: str) -> dict:
"""处理 /ci rerun latest 命令"""
from .ci_trigger import CITrigger
trigger = CITrigger()
result = trigger.rerun_latest_failed(branch=branch)
if result["success"]:
return {"text": f"✅ {result['message']}\n{result.get('run_url', '')}"}
return {"text": f"❌ {result['message']}"}
def _help_response() -> dict:
"""返回帮助信息"""
help_text = """**CI ChatOps 命令帮助**
`/ci status [branch]` 查询分支 CI 状态(默认 develop
`/ci rerun <run-id>` 重跑指定 run 的失败 jobs
`/ci rerun latest [branch]` 重跑分支最近一次失败的 run
`/ci help` 显示此帮助
**环境变量配置:**
`GITEA_TOKEN` / `GITEA_USERNAME + GITEA_PASSWORD`
`FEISHU_WEBHOOK_URL`
`CHATOPS_NOTIFY_BRANCHES=main,develop`
"""
return {"text": help_text}
# ── FastAPI 应用 ──────────────────────────────────────
def create_app():
"""创建 FastAPI 应用
如果 FastAPI 未安装,返回 None
"""
if not FASTAPI_AVAILABLE:
print(
"[WARN] FastAPI 未安装,无法启动 webhook 服务。" " 请运行: pip install fastapi uvicorn",
file=sys.stderr,
)
return None
app = FastAPI(title="CI ChatOps Webhook", version="0.1.0")
@app.post("/webhook/gitea")
async def gitea_webhook(
request: Request,
x_gitea_event: str = Header(default=""),
x_gitea_signature: str = Header(default=""),
):
body = await request.body()
# 签名校验
if not verify_gitea_signature(body, x_gitea_signature):
raise HTTPException(status_code=401, detail="Invalid signature")
try:
payload = json.loads(body.decode())
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail="Invalid JSON") from e
result = handle_gitea_webhook(payload, x_gitea_event)
return JSONResponse(content=result)
@app.post("/webhook/feishu")
async def feishu_webhook(request: Request):
body = await request.body()
try:
payload = json.loads(body.decode())
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail="Invalid JSON") from e
result = handle_feishu_message(payload)
return JSONResponse(content=result)
@app.get("/health")
async def health():
return {"status": "ok", "service": "ci-chatops"}
return app
# ── CLI 入口 ──────────────────────────────────────────
def main():
"""启动 webhook 服务"""
import argparse
parser = argparse.ArgumentParser(description="CI ChatOps Webhook 服务")
parser.add_argument("--port", type=int, default=config.CHATOPS_WEBHOOK_PORT, help="监听端口")
parser.add_argument("--host", default="0.0.0.0", help="监听地址")
args = parser.parse_args()
app = create_app()
if not app:
print("[ERROR] FastAPI 不可用,请先安装: pip install fastapi uvicorn")
return 1
try:
import uvicorn
except ImportError:
print("[ERROR] uvicorn 未安装,请先安装: pip install uvicorn")
return 1
print(f"[INFO] CI ChatOps Webhook 服务启动: http://{args.host}:{args.port}")
print("[INFO] Gitea webhook: POST /webhook/gitea")
print("[INFO] 飞书 webhook: POST /webhook/feishu")
print("[INFO] 健康检查: GET /health")
print(f"[INFO] 通知分支: {', '.join(config.NOTIFY_BRANCHES)}")
uvicorn.run(app, host=args.host, port=args.port)
return 0
if __name__ == "__main__":
sys.exit(main())