"""Gitea Actions 构建后端(自建 Gitea 1.23+,建议 1.27+)。 API 与 GitHub Actions 几乎同形(已对照 Gitea 官方 Swagger 核实): - 触发:POST /api/v1/repos/{owner}/{repo}/actions/workflows/{file}/dispatches ?return_run_details=true,body {ref, inputs},200 返回 {workflow_run_id, html_url, run_url},不传该参数为 204; - 状态:GET /api/v1/repos/{owner}/{repo}/actions/runs/{id}, status/conclusion 字段仿 GitHub; - 鉴权:Authorization: token 。 """ from .. import app_settings, ghnet from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend # Gitea 未结束状态(blocked=等待审批,waiting=等待 runner) _GITEA_ACTIVE = {'queued', 'in_progress', 'waiting', 'blocked', 'requested', 'pending', 'running'} # Gitea 原生终态(部分版本 run.status 直接返回结论,不走 completed+conclusion) _GITEA_TERMINAL = {'success', 'failure', 'cancelled', 'skipped', 'timed_out', 'stopped', 'dead'} # Gitea 结论 -> rdgen 内部状态 _CONCLUSION_MAP = { 'success': 'success', 'failure': 'failure', 'cancelled': 'cancelled', 'skipped': 'skipped', 'timed_out': 'timed_out', 'stopped': 'cancelled', 'dead': 'failure', } # 作业出现这些结论时,整个运行最终必然失败(cleanup 等收尾作业除外) _JOB_BAD_CONCLUSIONS = {'failure', 'cancelled', 'timed_out', 'dead'} class GiteaBackend(BuildBackend): name = 'gitea' label = 'Gitea' # ---- 取值辅助 ---- def _server(self): return app_settings.get_value('GITEA_SERVER_URL').strip().rstrip('/') def _owner(self): return app_settings.get_value('GITEA_OWNER').strip() def _repo(self): return app_settings.get_value('GITEA_REPO').strip() or 'rdgen' def _branch(self): return app_settings.get_value('GITEA_BRANCH').strip() or 'master' def _ref(self): branch = self._branch() return branch if branch.startswith('refs/') else f'refs/heads/{branch}' def _proxy(self): return app_settings.get_value('GITEA_PROXY').strip() def _api(self, path): return f'{self._server()}/api/v1{path}' # ---- 配置 ---- def configured(self): return bool(self._server() and self._owner() and app_settings.get_value('GITEA_TOKEN').strip()) def warnings(self): warnings = [] if not self._server(): warnings.append('Gitea 服务器地址未配置,无法触发构建。') if not self._owner(): warnings.append('Gitea 仓库属主未配置,无法触发构建。') if not app_settings.get_value('GITEA_TOKEN').strip(): warnings.append('Gitea 访问令牌未配置,无法触发构建。') warnings.extend(self._shared_warnings()) return warnings # ---- 工作流选择 ---- def workflow_file_for(self, platform, selfhosted=False): # Gitea 的 runner 全部为自建注册,无需 sh- 变体,统一用标准工作流 return WORKFLOW_FILES.get(platform, 'generator-windows.yml') # ---- 触发 ---- def dispatch(self, workflow_file, inputs, *, timeout=20): if not self.configured(): raise BackendError('Gitea 构建服务未完成配置(服务器地址 / 属主 / 令牌缺失)。', kind='config') url = self._api( f'/repos/{self._owner()}/{self._repo()}' f'/actions/workflows/{workflow_file}/dispatches?return_run_details=true' ) payload = {'ref': self._ref(), 'inputs': inputs} try: resp = ghnet.post( url, json=payload, token=app_settings.get_value('GITEA_TOKEN'), timeout=timeout, flavor='gitea', proxy=self._proxy(), ) except Exception as e: raise BackendError(f'无法连接 Gitea 构建服务:{e}', kind='network') if resp.status_code in (200, 204): data = {} try: data = resp.json() except ValueError: pass run_id = data.get('workflow_run_id') html_url = data.get('html_url') or '' # 旧版 Gitea 或未回传详情时,按工作流最新一条运行兜底取 run id if not run_id and resp.status_code == 204: fallback = self._latest_run_id(workflow_file) if fallback: run_id, html_url = fallback return DispatchResult(run_id=run_id, html_url=html_url or self.log_url(run_id)) detail = '' try: detail = (resp.text or '')[:200] except Exception: pass if resp.status_code == 404: msg = f'Gitea 仓库或工作流 {workflow_file} 不存在(请确认仓库已启用 Repository Actions)。' elif resp.status_code in (401, 403): msg = 'Gitea 拒绝访问:请检查令牌是否有效且具备该仓库 Actions 读写权限。' elif resp.status_code == 422: msg = f'Gitea 校验失败(分支或工作流参数有误):{detail}' else: msg = f'Gitea 构建服务拒绝了启动请求(HTTP {resp.status_code}):{detail}' raise BackendError(msg, kind='rejected') def _latest_run_id(self, workflow_file, *, timeout=10): """204 兜底:查该工作流最新一条运行(仅在刚触发后调用)。""" url = self._api( f'/repos/{self._owner()}/{self._repo()}' f'/actions/workflows/{workflow_file}/runs?limit=1' ) try: resp = ghnet.get( url, token=app_settings.get_value('GITEA_TOKEN'), timeout=timeout, flavor='gitea', proxy=self._proxy(), ) if resp.status_code != 200: return None data = resp.json() runs = data.get('workflow_runs') if isinstance(data, dict) else data if runs: run = runs[0] return run.get('id'), run.get('html_url') or '' except Exception as e: print(f'查询 Gitea 最新运行失败:{e}') return None # ---- 状态查询 ---- def get_run(self, run_id, *, timeout=12): url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}') try: resp = ghnet.get( url, token=app_settings.get_value('GITEA_TOKEN'), timeout=timeout, flavor='gitea', proxy=self._proxy(), ) except Exception as e: print(f'查询 Gitea 状态出错:{e}') return None if resp.status_code != 200: return None data = resp.json() html_url = data.get('html_url') or self.log_url(run_id) status = (data.get('status') or '').lower() conclusion = (data.get('conclusion') or '').lower() if status == 'completed': return RunInfo( finished=True, status=_CONCLUSION_MAP.get(conclusion, 'failure'), html_url=html_url, ) # 部分 Gitea 版本直接在 status 返回终态(无 completed 包裹) if status in _GITEA_TERMINAL: return RunInfo( finished=True, status=_CONCLUSION_MAP.get(status, 'failure'), html_url=html_url, ) # Gitea 已知行为:前置作业失败后,可复用工作流的父作业/等待匹配标签的 # 后续作业可能长期停留在 in_progress/waiting,运行永远不进入 completed。 # 这里检查作业级结论:只要非收尾作业已失败,即可判定整个运行失败。 bad = self._detect_failed_job(run_id) if bad: return RunInfo(finished=True, status=bad, html_url=html_url) # blocked(等待审批)等也算进行中 return RunInfo(finished=False, status=status or 'in_progress', html_url=html_url) def _detect_failed_job(self, run_id, *, timeout=10): """检查运行下各作业:有非 cleanup 作业失败/取消/超时时返回对应终态,否则 None。""" url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}/jobs') try: resp = ghnet.get( url, token=app_settings.get_value('GITEA_TOKEN'), timeout=timeout, flavor='gitea', proxy=self._proxy(), ) if resp.status_code != 200: return None data = resp.json() jobs = data.get('jobs', data if isinstance(data, list) else []) for job in jobs: name = (job.get('name') or '').lower() if name.startswith('cleanup'): continue conclusion = (job.get('conclusion') or '').lower() if conclusion in _JOB_BAD_CONCLUSIONS: return _CONCLUSION_MAP.get(conclusion, 'failure') except Exception as e: print(f'查询 Gitea 作业状态出错:{e}') return None # ---- 取消运行 ---- def cancel_run(self, run_id, *, timeout=15): """请求平台取消运行。成功无返回;409 表示运行已结束(调用方应改为拉取真实状态)。""" url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}/cancel') try: resp = ghnet.post( url, token=app_settings.get_value('GITEA_TOKEN'), timeout=timeout, flavor='gitea', proxy=self._proxy(), ) except Exception as e: raise BackendError(f'无法连接 Gitea 构建服务:{e}', kind='network') if 200 <= resp.status_code < 300: return if resp.status_code == 409: raise BackendError('该构建运行已经结束,无法取消。', kind='finished') if resp.status_code == 404: raise BackendError( '当前 Gitea 版本(1.27 及更早)未开放取消运行的 API,' '请在 Gitea 的运行页面手动点击 Cancel 停止;升级到 Gitea 1.28+ 后可在此一键停止。', kind='unsupported', ) detail = '' try: detail = (resp.text or '')[:200] except Exception: pass raise BackendError(f'Gitea 拒绝了取消请求(HTTP {resp.status_code}):{detail}') # ---- 日志链接 ---- def log_url(self, run_id): if not run_id: return '' return f'{self._server()}/{self._owner()}/{self._repo()}/actions/runs/{run_id}' # ---- 连接测试 ---- def test_connection(self): server = self._server() owner = self._owner() token = app_settings.get_value('GITEA_TOKEN').strip() repo = self._repo() if not server or not owner or not token: return {'ok': False, 'message': 'Gitea 服务器地址、仓库属主或访问令牌为空,请先保存配置。'} if not (server.startswith('http://') or server.startswith('https://')): return {'ok': False, 'message': 'Gitea 服务器地址需以 http:// 或 https:// 开头。'} try: resp = ghnet.get( self._api(f'/repos/{owner}/{repo}'), token=token, timeout=10, flavor='gitea', proxy=self._proxy(), ) if resp.status_code == 401: return {'ok': False, 'message': '令牌无效或已过期(Gitea 返回 401)。'} if resp.status_code == 404: return {'ok': False, 'message': f'找不到 Gitea 仓库 {owner}/{repo},或令牌无权访问该仓库。'} if resp.status_code != 200: return {'ok': False, 'message': f'Gitea 返回异常状态码:{resp.status_code}。'} act = ghnet.get( self._api(f'/repos/{owner}/{repo}/actions/workflows'), token=token, timeout=10, flavor='gitea', proxy=self._proxy(), ) if act.status_code != 200: return {'ok': False, 'message': '可以访问仓库,但无法读取 Actions 工作流;请在仓库设置中启用 Repository Actions。'} # 版本检查:1.23 起支持 workflow_dispatch;建议 1.27+(return_run_details 与缓存兼容性更好) version_msg = '' ver = ghnet.get( self._api('/version'), token=token, timeout=10, flavor='gitea', proxy=self._proxy(), ) if ver.status_code == 200: raw = (ver.json().get('version') or '').strip() parts = raw.replace('+dev', '').split('.') try: major, minor = int(parts[0]), int(parts[1]) if (major, minor) < (1, 23): return {'ok': False, 'message': f'Gitea 版本为 {raw},低于 1.23,不支持 workflow_dispatch 远程触发,请先升级。'} if (major, minor) < (1, 27): version_msg = f'当前 Gitea 版本为 {raw},可触发构建,但建议升级到 1.27+(运行详情回传与缓存兼容性更好)。' except (ValueError, IndexError): pass msg = f'连接正常:令牌可访问仓库 {owner}/{repo},Actions 已启用。' if version_msg: msg += version_msg return {'ok': True, 'message': msg} except Exception as e: return {'ok': False, 'message': f'无法连接 Gitea API:{e}(请检查服务器地址与网络/代理配置)'}