修复列表页加载慢并支持 Gitea 1.27 一键停止

1. 状态同步加 20 秒节流、超时缩短到 8 秒;Gitea 改用 runs 列表接口一次批量取回全部任务状态,在途任务并发补查作业,消除逐任务串行 API 请求导致的后台/我的构建/详情页慢。

2. 停止时先区分 404:运行已删除则本地标记取消;Gitea 1.27 无取消 API 时,若后台配置了 Gitea 网页账号密码则模拟网页登录取消,否则回退手动链接提示。

3. 后台 Gitea 连接设置新增网页登录账号/密码两个可选项。
This commit is contained in:
naeeo
2026-09-30 01:04:16 +08:00
parent 5ff6bcfe51
commit c8f41a34c0
6 changed files with 349 additions and 35 deletions
+8
View File
@@ -61,6 +61,14 @@ class BuildBackend:
"""查询单次运行;网络异常或非 200 返回 None(调用方按「仍在进行」处理)。"""
raise NotImplementedError
def get_runs_batch(self, run_ids, *, timeout=12):
"""批量查询多个运行,返回 {str(run_id): RunInfo}。
列表页一次刷新多个在途任务时使用;平台不支持或查询失败返回 None,
调用方退化为逐个 get_run。返回字典中缺失的 id 由调用方逐个兜底。
"""
return None
def cancel_run(self, run_id, *, timeout=15):
"""请求平台取消一次运行;成功无返回,已结束时抛 BackendError(kind='finished')。"""
raise NotImplementedError
+216 -21
View File
@@ -8,6 +8,8 @@ API 与 GitHub Actions 几乎同形(已对照 Gitea 官方 Swagger 核实)
status/conclusion 字段仿 GitHub;
- 鉴权:Authorization: token <PAT>。
"""
import re
from .. import app_settings, ghnet
from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend
@@ -149,11 +151,10 @@ class GiteaBackend(BuildBackend):
# ---- 状态查询 ----
def get_run(self, run_id, *, timeout=12):
url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}')
token = app_settings.get_value('GITEA_TOKEN')
proxy = self._proxy()
try:
resp = ghnet.get(
url, token=app_settings.get_value('GITEA_TOKEN'),
timeout=timeout, flavor='gitea', proxy=self._proxy(),
)
resp = ghnet.get(url, token=token, timeout=timeout, flavor='gitea', proxy=proxy)
except Exception as e:
print(f'查询 Gitea 状态出错:{e}')
return None
@@ -176,23 +177,27 @@ class GiteaBackend(BuildBackend):
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)
# 仅在途运行才需要 jobs:识别「前置作业失败但父运行长期挂起」的 Gitea 已知行为
jobs_url = self._api(
f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}/jobs'
)
try:
jobs_resp = ghnet.get(
jobs_url, token=token, timeout=timeout, flavor='gitea', proxy=proxy)
except Exception as e:
print(f'查询 Gitea 作业状态出错:{e}')
jobs_resp = None
if jobs_resp is not None:
bad = self._bad_job_conclusion(jobs_resp)
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')
@staticmethod
def _bad_job_conclusion(resp):
"""从 jobs 响应中判定终态:有非 cleanup 作业失败/取消/超时时返回对应状态,否则 None。"""
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()
@@ -205,12 +210,98 @@ class GiteaBackend(BuildBackend):
if conclusion in _JOB_BAD_CONCLUSIONS:
return _CONCLUSION_MAP.get(conclusion, 'failure')
except Exception as e:
print(f'查询 Gitea 作业状态出错:{e}')
print(f'解析 Gitea 作业状态出错:{e}')
return None
# ---- 状态批量查询(列表页用,一次请求取回全部运行) ----
def get_runs_batch(self, run_ids, *, timeout=12):
"""一次 runs 列表请求批量同步状态,避免每个在途任务各发两个请求。
返回 {str(run_id): RunInfo};列表拿不到时返回 None 让调用方退化逐个查询。
列表中仍在途的运行,再查 jobs 以识别「前置作业失败但父运行挂起」的僵死;
列表中不存在的 id(已删除或超出最近窗口)不放进返回字典。
"""
want = {str(i) for i in run_ids if i}
if not want:
return {}
url = self._api(
f'/repos/{self._owner()}/{self._repo()}/actions/runs?limit=50'
)
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()
items = data.get('workflow_runs') if isinstance(data, dict) else data
except ValueError:
return None
if not items:
return {}
result = {}
active = [] # (id, html_url)
for item in items:
rid = item.get('id')
if rid is None or str(rid) not in want:
continue
html_url = item.get('html_url') or self.log_url(rid)
status = (item.get('status') or '').lower()
conclusion = (item.get('conclusion') or '').lower()
if status == 'completed' or status in _GITEA_TERMINAL:
result[str(rid)] = RunInfo(
finished=True,
status=_CONCLUSION_MAP.get(conclusion or status, 'failure'),
html_url=html_url,
)
else:
active.append((rid, html_url))
# 在途运行查 jobs 识别 Gitea 的作业级失败挂起(通常在途任务很少)
if active:
token = app_settings.get_value('GITEA_TOKEN')
proxy = self._proxy()
owner_repo = f'{self._owner()}/{self._repo()}'
def _probe(rid_html):
rid, html_url = rid_html
jobs_url = self._api(
f'/repos/{owner_repo}/actions/runs/{rid}/jobs'
)
try:
jobs_resp = ghnet.get(
jobs_url, token=token, timeout=timeout,
flavor='gitea', proxy=proxy,
)
except Exception:
return str(rid), RunInfo(False, 'in_progress', html_url)
bad = self._bad_job_conclusion(jobs_resp)
if bad:
return str(rid), RunInfo(True, bad, html_url)
return str(rid), RunInfo(False, 'in_progress', html_url)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=min(8, len(active))) as pool:
for key, info in pool.map(_probe, active):
result[key] = info
return result
# ---- 取消运行 ----
def cancel_run(self, run_id, *, timeout=15):
"""请求平台取消运行。成功无返回;409 表示运行已结束(调用方应改为拉取真实状态)。"""
"""请求平台取消运行。
- 2xx:已接受取消;
- 409:运行已结束(调用方应改为拉取真实状态);
- 运行在平台上已不存在(如已被手工删除):kind='missing';
- 旧版 Gitea(≤1.27)无取消 API:配置了 Web 账密则模拟网页登录取消,
否则 kind='unsupported',引导用户去运行页手动 Cancel。
"""
url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}/cancel')
try:
resp = ghnet.post(
@@ -224,9 +315,17 @@ class GiteaBackend(BuildBackend):
if resp.status_code == 409:
raise BackendError('该构建运行已经结束,无法取消。', kind='finished')
if resp.status_code == 404:
exists = self._run_exists(run_id, timeout=timeout)
if exists is False:
raise BackendError(
'该运行在 Gitea 上已不存在(可能已被删除)。', kind='missing')
# 运行存在但 cancel 端点 404:Gitea ≤ 1.27 没有取消 API
if self._cancel_via_web(run_id, timeout=timeout):
return
raise BackendError(
'当前 Gitea 版本(1.27 及更早)未开放取消运行的 API,'
'请在 Gitea 的运行页面手动点击 Cancel 停止;升级到 Gitea 1.28+ 后可在此一键停止。',
'当前 Gitea 版本(1.27 及更早)未开放取消运行的 API。'
'可在后台「Gitea 连接」填写 Gitea 网页登录账号以启用一键停止,'
'或在 Gitea 的运行页面手动点击 Cancel 停止;升级到 Gitea 1.28+ 后可直接一键停止。',
kind='unsupported',
)
detail = ''
@@ -236,6 +335,102 @@ class GiteaBackend(BuildBackend):
pass
raise BackendError(f'Gitea 拒绝了取消请求(HTTP {resp.status_code}):{detail}')
def _run_exists(self, run_id, *, timeout=10):
"""GET run:存在 True / 404 False / 无法确定 None。"""
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:
return None
if resp.status_code == 200:
return True
if resp.status_code == 404:
return False
return None
def _cancel_via_web(self, run_id, *, timeout=20):
"""Gitea ≤1.27 无取消 API 时,用网页会话方式取消(需后台配置 Web 账密)。
模拟浏览器:登录拿会话 → 打开运行页拿 CSRF → POST 网页取消路由。
注意:部分 1.27 实例的登录页本身不输出 CSRF(登录 POST 豁免),
但取消操作需要登录后页面里的令牌;未配置账密、登录失败
(含开启两步验证)时返回 False。
"""
import requests
username = app_settings.get_value('GITEA_WEB_USERNAME').strip()
password = app_settings.get_value('GITEA_WEB_PASSWORD')
if not username or not password:
return False
server = self._server()
owner, repo = self._owner(), self._repo()
proxies = ghnet.proxies(self._proxy())
sess = requests.Session()
sess.proxies.update(proxies)
ua = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 rdgen'
try:
# 1. 登录页(建立会话;CSRF 若页面有则带上,没有也无妨——该实例登录豁免)
r = sess.get(f'{server}/user/login', timeout=timeout,
headers={'User-Agent': ua}, allow_redirects=True)
csrf = self._extract_csrf(r.text)
login_data = {'user_name': username, 'password': password}
login_headers = {'User-Agent': ua, 'Referer': f'{server}/user/login'}
if csrf:
login_data['_csrf'] = csrf
login_headers['X-Csrf-Token'] = csrf
# 2. 提交登录(Gitea 用 user_name 字段;成功后 302 离开 /user/login)
r = sess.post(
f'{server}/user/login', data=login_data,
headers=login_headers, timeout=timeout, allow_redirects=True,
)
if '/user/login' in r.url or 'name="password"' in r.text:
print('Gitea 网页取消:登录失败(账号密码错误或启用了两步验证)')
return False
# 3. 打开运行页取 CSRF(登录后页面才输出令牌)
run_page = f'{server}/{owner}/{repo}/actions/runs/{run_id}'
r = sess.get(run_page, timeout=timeout, headers={'User-Agent': ua})
if r.status_code != 200:
print(f'Gitea 网页取消:运行页 HTTP {r.status_code}(账号可能无该仓库访问权限)')
return False
csrf = self._extract_csrf(r.text)
if not csrf:
print('Gitea 网页取消:登录后运行页仍未找到 CSRF 令牌(账号可能无该仓库写权限)')
return False
# 4. POST 网页取消路由(302/303 回运行页即成功)
r = sess.post(
f'{run_page}/cancel',
data={'_csrf': csrf},
headers={'User-Agent': ua, 'Referer': run_page,
'X-Csrf-Token': csrf},
timeout=timeout, allow_redirects=False,
)
if r.status_code in (200, 302, 303):
return True
print(f'Gitea 网页取消:取消请求被拒绝 HTTP {r.status_code}')
return False
except Exception as e:
print(f'Gitea 网页取消出错:{e}')
return False
@staticmethod
def _extract_csrf(html):
"""从 Gitea 页面提取 CSRF:优先 meta,其次隐藏域,最后前端 JSON 配置。"""
if not html:
return None
for pattern in (
r'name="_csrf"\s+content="([^"]+)"',
r'content="([^"]+)"\s+name="_csrf"',
r'name="_csrf"\s+value="([^"]+)"',
r'"(?:csrfToken|_csrf)"\s*:\s*"([^"]+)"',
):
m = re.search(pattern, html)
if m:
return m.group(1)
return None
# ---- 日志链接 ----
def log_url(self, run_id):
if not run_id: