Files
xiaoxia-saas/scripts/ci/chatops/gitea_client.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

244 lines
8.3 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
"""
Gitea API 客户端封装 - Actions + PR + Webhook 相关接口
基于 urllib 实现,无第三方依赖,与 ci_dashboard.py 风格一致。
支持 token 和 basic auth 两种认证方式。
"""
import base64
import json
import sys
import urllib.error
import urllib.request
from . import config
class GiteaClient:
"""Gitea API 客户端"""
def __init__(
self,
base_url=None,
repo=None,
token=None,
username=None,
password=None,
):
self.base_url = (base_url or config.GITEA_URL).rstrip("/")
self.repo = repo or config.GITEA_REPO
self.token = token or config.GITEA_TOKEN
self.username = username or config.GITEA_USERNAME
self.password = password or config.GITEA_PASSWORD
self.api_base = f"{self.base_url}/api/v1/repos/{self.repo}"
def _request(self, path, method="GET", data=None):
"""通用 HTTP 请求
Args:
path: API 路径(相对于 /api/v1/repos/{repo}/
method: HTTP 方法
data: 请求体(dict 或 bytes
Returns:
解析后的 JSON 数据,失败返回 None
"""
url = f"{self.api_base}/{path}"
body = None
if data is not None:
if isinstance(data, (dict, list)):
body = json.dumps(data).encode("utf-8")
else:
body = data if isinstance(data, bytes) else str(data).encode()
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Content-Type", "application/json")
if self.token:
req.add_header("Authorization", f"token {self.token}")
elif self.username and self.password:
auth = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
req.add_header("Authorization", f"Basic {auth}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_body = resp.read().decode()
if not resp_body:
return {}
return json.loads(resp_body)
except urllib.error.HTTPError as e:
err_body = ""
try:
err_body = e.read().decode()
except Exception:
pass
print(
f"[WARN] HTTP {e.code}: {url} - {err_body[:200]}",
file=sys.stderr,
)
return None
except Exception as e:
print(f"[WARN] 请求失败 {url}: {e}", file=sys.stderr)
return None
# ── Actions: Workflow Runs ────────────────────────
def list_runs(
self,
status=None,
branch=None,
event=None,
workflow_id=None,
page=1,
limit=config.PAGE_LIMIT,
):
"""获取 workflow runs 列表
Returns:
(runs列表, 总数)
"""
params = []
if status:
params.append(f"status={status}")
if branch:
params.append(f"branch={branch}")
if event:
params.append(f"event={event}")
if workflow_id:
params.append(f"workflow_id={workflow_id}")
params.append(f"page={page}")
params.append(f"limit={limit}")
path = f"actions/runs?{'&'.join(params)}"
data = self._request(path)
if not data:
return [], 0
runs = data.get("workflow_runs", [])
total = data.get("total_count", 0)
return runs, total
def get_run(self, run_id):
"""获取单个 run 详情"""
return self._request(f"actions/runs/{run_id}")
def get_run_jobs(self, run_id):
"""获取 run 的 jobs 列表"""
data = self._request(f"actions/runs/{run_id}/jobs")
if not data:
return []
return data.get("jobs", [])
def get_job_log(self, run_id, job_id):
"""获取 job 日志(纯文本)"""
url = f"{self.api_base}/actions/runs/{run_id}/jobs/{job_id}/logs"
req = urllib.request.Request(url)
if self.token:
req.add_header("Authorization", f"token {self.token}")
elif self.username and self.password:
auth = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
req.add_header("Authorization", f"Basic {auth}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode("utf-8", errors="replace")
except Exception as e:
print(f"[WARN] 获取日志失败 job={job_id}: {e}", file=sys.stderr)
return ""
def rerun_run(self, run_id):
"""重新运行整个 workflow run"""
return self._request(f"actions/runs/{run_id}/rerun", method="POST")
def rerun_failed_jobs(self, run_id):
"""重新运行失败的 jobs"""
return self._request(f"actions/runs/{run_id}/rerun-failed-jobs", method="POST")
def cancel_run(self, run_id):
"""取消 run"""
return self._request(f"actions/runs/{run_id}/cancel", method="POST")
# ── Actions: Workflows ────────────────────────────
def list_workflows(self):
"""获取 workflow 列表"""
data = self._request("actions/workflows")
if not data:
return []
return data.get("workflows", [])
def get_workflow(self, workflow_id):
"""获取单个 workflow 详情"""
return self._request(f"actions/workflows/{workflow_id}")
# ── Pull Requests ─────────────────────────────────
def get_pr(self, pr_number):
"""获取 PR 详情"""
return self._request(f"pulls/{pr_number}")
def get_pr_ci_runs(self, pr_number, limit=20):
"""获取 PR 关联的 CI runs(通过 head_sha 查询)"""
pr = self.get_pr(pr_number)
if not pr:
return []
head_sha = pr.get("head", {}).get("sha", "")
if not head_sha:
return []
# 用 head_sha 过滤 runs
runs, _ = self.list_runs(limit=limit)
return [r for r in runs if r.get("head_sha", "") == head_sha]
# ── 便捷方法 ──────────────────────────────────────
def get_latest_run(self, branch, workflow_id=None, status=None):
"""获取指定分支最新的 run"""
runs, _ = self.list_runs(branch=branch, workflow_id=workflow_id, status=status, limit=5)
return runs[0] if runs else None
def get_failed_jobs_summary(self, run_id, max_lines_per_job=30):
"""获取失败 job 的摘要信息(用于通知)
Returns:
list[dict]: 每个失败 job 的 {name, conclusion, failed_step, log_tail}
"""
jobs = self.get_run_jobs(run_id)
if not jobs:
return []
failed = [j for j in jobs if j.get("status") == "completed" and j.get("conclusion") == "failure"]
if not failed:
# 运行中的也返回,方便定位
failed = [j for j in jobs if j.get("status") != "completed"]
result = []
for job in failed[:5]: # 最多取 5 个失败 job
job_id = job.get("id", "")
name = job.get("name", "Unknown")
conclusion = job.get("conclusion", job.get("status", "unknown"))
# 找失败的 step
failed_step = ""
steps = job.get("steps", [])
for step in steps:
if step.get("conclusion") == "failure":
failed_step = step.get("name", "")
break
# 取日志尾部
log_tail = ""
if job_id:
log = self.get_job_log(run_id, job_id)
if log:
lines = log.strip().splitlines()
log_tail = "\n".join(lines[-max_lines_per_job:])
result.append(
{
"name": name,
"conclusion": conclusion,
"failed_step": failed_step,
"log_tail": log_tail,
"job_id": job_id,
}
)
return result