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依赖
244 lines
8.3 KiB
Python
Executable File
244 lines
8.3 KiB
Python
Executable File
#!/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
|