chore(ci): 同步main分支CI配置与scripts/ci脚本 - 与develop对齐
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依赖
This commit was merged in pull request #767.
This commit is contained in:
2026-07-24 10:36:29 +08:00
parent dfb2feef8a
commit 6eac0b2cf2
67 changed files with 13455 additions and 1538 deletions
+446
View File
@@ -0,0 +1,446 @@
#!/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.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())