"""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; - 鉴权:Authorization: token 。 注意:本平台不主动取消 Gitea 上的运行,停止/删除只作用于本平台本地记录。 构建状态以工作流结束时的回调(update_github_run)为第一来源;get_run 仅做 只读终态核对,兜底回调丢失(脚本未跑到收尾或 GENURL 不可达)的情况。 """ from .. import app_settings, ghnet from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend # 部分 Gitea 版本 run.status 直接返回终态(无 completed+conclusion 包裹) _GITEA_TERMINAL = {'success', 'failure', 'cancelled', 'skipped', 'timed_out', 'stopped', 'dead'} # Gitea 结论 -> rdgen 内部终态 _GITEA_CONCLUSION_MAP = { 'success': 'success', 'failure': 'failure', 'cancelled': 'cancelled', 'skipped': 'skipped', 'timed_out': 'timed_out', 'stopped': 'cancelled', 'dead': 'failure', } 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=8): 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 try: data = resp.json() except ValueError: return None 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=_GITEA_CONCLUSION_MAP.get(conclusion, 'failure'), html_url=html_url, ) # 部分 Gitea 版本直接在 status 返回终态(无 completed 包裹) if status in _GITEA_TERMINAL: return RunInfo( finished=True, status=_GITEA_CONCLUSION_MAP.get(status, 'failure'), html_url=html_url, ) # queued/in_progress/waiting/blocked 等在途状态返回 None,交给回调与下次核对 return None # ---- 日志链接 ---- 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}(请检查服务器地址与网络/代理配置)'}