Files
rdgen/rdgenerator/build_backends/gitea.py
T
naeeo 4fd930aa0b feat: 同步构建真实状态并支持在列表停止在途任务
- Gitea 运行未结束时探测作业级结论,修复前置作业失败后可复用工作流父作业/等待标签作业长期挂起导致状态永远构建中

- 打开我的构建/管理仪表盘时主动向构建平台同步在途任务状态;创建超 6 小时仍在途的任务判超时

- 构建中任务列表增加停止按钮(Gitea 需 1.28+ 取消 API,旧版本提示去运行页手动 Cancel),终态任务保留删除

- GitHub/Gitea 后端统一 cancel_run 接口
2026-09-29 23:45:25 +08:00

301 lines
14 KiB
Python
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.
"""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 <PAT>。
"""
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}(请检查服务器地址与网络/代理配置)'}