#!/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