codebee 0.1.14 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +21 -8
- package/app/core/automation.py +7 -0
- package/app/core/catalog.py +9 -1
- package/app/core/env_scrub.py +5 -3
- package/app/core/jobs.py +33 -11
- package/app/core/modelhub.py +4 -4
- package/app/core/paihang.py +62 -21
- package/app/core/pipeline.py +0 -3
- package/app/core/publish/browser.py +44 -1
- package/app/core/publish/fanqie.py +16 -0
- package/app/core/publish/flow.py +23 -1
- package/app/core/publish/flows-fanqie-calibrated.json +37 -0
- package/app/core/share_page.py +111 -0
- package/app/core/telemetry.py +3 -2
- package/app/core/tlsctx.py +16 -0
- package/app/core/zentao.py +1302 -0
- package/app/main.py +67 -4
- package/app/ui/app.js +142 -1
- package/app/ui/i18n.js +53 -0
- package/app/ui/index.html +49 -0
- package/app/ui/style.css +17 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1302 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""禅道 Bug 自动修复对接:多产品档案 + 排查定责路由 + 转派流转。
|
|
3
|
+
|
|
4
|
+
数据落盘 <data>/zentao.json(tmp + os.replace 原子写;TUTTI_DATA 环境变量感知)。
|
|
5
|
+
修复走与 /api/tasks 完全相同的链路(store.create_task → store.create_run →
|
|
6
|
+
jobs.enqueue,code 引擎=实现→验证→评审→修复),不自造运行器。调度挂在
|
|
7
|
+
automation._tick(同 publish/auto.fire_due 模式),内部按 interval_hours 节流。
|
|
8
|
+
|
|
9
|
+
禅道 REST API v1(开源版 15.x+;请求头 Token: <token>):
|
|
10
|
+
POST {base}/api.php/v1/tokens {account, password} → {token}
|
|
11
|
+
GET {base}/api.php/v1/products/{pid}/bugs 分页 {bugs:[...], page, total, limit}
|
|
12
|
+
GET {base}/api.php/v1/bugs/{id} 单查(回写前确认状态防谎报)
|
|
13
|
+
POST {base}/api.php/v1/bugs/{id}/resolve {resolution, resolvedBuild, comment, assignedTo}
|
|
14
|
+
PUT {base}/api.php/v1/bugs/{id} {assignedTo, comment}(转派/失败说明)
|
|
15
|
+
|
|
16
|
+
产品档案(product_profiles):每产品一份 {指派过滤, 严重度, 我方端 our_sides,
|
|
17
|
+
后端/前端仓库(workdir/git_rev/verify_command), repo_hints, 负责人 owners,
|
|
18
|
+
模块路由 module_routes}。老版扁平配置(products+单仓库)load 时自动迁移。
|
|
19
|
+
|
|
20
|
+
排查(_triage):模块路由按 bug.module 精确匹配优先 → AI 兜底(triage_ai 开时
|
|
21
|
+
modelhub 单次调用,bookmeta 同款配方)→ unknown。判定 side ∈
|
|
22
|
+
backend | frontend | both | not_ours | unknown。
|
|
23
|
+
|
|
24
|
+
流转规则(转派目标只认排查结论,与 bug 当前 assignedTo 无关——测试提错人也
|
|
25
|
+
照样改派):
|
|
26
|
+
我方端问题 → 建修复任务;成功=合并+resolve(fixed)+报告评论+指回报告人
|
|
27
|
+
双端/我方一端 → 我方端修完(合并落库)后转派另一端负责人+评论,不 resolve
|
|
28
|
+
纯对方端问题 → 不建任务,直接转派该端负责人+排查结论评论
|
|
29
|
+
非我方 → 转派报告人(或 owners.not_ours)+评论;只转派不解决
|
|
30
|
+
unknown → 不碰 bug,need_manual + 群通知(下轮扫描 bug 仍激活则重排查)
|
|
31
|
+
修复任务失败 → 评论尝试记录 + 转派该端负责人(模块路由 account > 端负责人)
|
|
32
|
+
|
|
33
|
+
出网边界(SSRF 防护,_guard_url):请求目标来自用户自配禅道地址——内网按设计
|
|
34
|
+
放行;强制 http(s)、解析主机并阻断云元数据/链路本地地址、禁跟随重定向。
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import html as _html
|
|
39
|
+
import ipaddress
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import socket
|
|
45
|
+
import threading
|
|
46
|
+
import time
|
|
47
|
+
import urllib.error
|
|
48
|
+
import urllib.request
|
|
49
|
+
from datetime import datetime, timedelta
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
from . import jobs, paths, settings, store, tlsctx
|
|
53
|
+
|
|
54
|
+
log = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
_LOCK = threading.RLock()
|
|
57
|
+
_FILE = paths.DATA_DIR / "zentao.json"
|
|
58
|
+
_STATE = {
|
|
59
|
+
"config": {}, # 持久配置(_CFG_DEFAULTS)
|
|
60
|
+
"claims": {}, # str(bug_id) → claim dict(v2:含 triage/tasks)
|
|
61
|
+
"last_scan": "",
|
|
62
|
+
"next_scan": "",
|
|
63
|
+
"last_error": "",
|
|
64
|
+
}
|
|
65
|
+
_LOADED = False
|
|
66
|
+
|
|
67
|
+
TOKEN_TTL = 23 * 3600
|
|
68
|
+
HTTP_TIMEOUT = 15
|
|
69
|
+
PAGE_LIMIT = 100
|
|
70
|
+
MAX_BUGS = 500
|
|
71
|
+
RESOLVE_MAX_ATTEMPTS = 3
|
|
72
|
+
RETRY_DELAY_MIN = 30
|
|
73
|
+
INTERVAL_MIN, INTERVAL_MAX = 1, 168
|
|
74
|
+
|
|
75
|
+
SIDES = ("backend", "frontend")
|
|
76
|
+
TRIAGE_SIDES = ("backend", "frontend", "both", "not_ours") # unknown 单列
|
|
77
|
+
SIDE_CN = {"backend": "后端", "frontend": "前端"}
|
|
78
|
+
|
|
79
|
+
_CFG_DEFAULTS = {
|
|
80
|
+
"base_url": "",
|
|
81
|
+
"account": "",
|
|
82
|
+
"password": "",
|
|
83
|
+
"product_profiles": [], # 产品档案列表(_norm_profile 形状)
|
|
84
|
+
"auto_resolve": True,
|
|
85
|
+
"auto_merge": True,
|
|
86
|
+
"triage_ai": True, # 模块路由未命中时用 AI 兜底排查
|
|
87
|
+
"poll_enabled": False,
|
|
88
|
+
"interval_hours": 2,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_REPO_DEFAULTS = {"workdir": "", "git_rev": "", "verify_command": ""}
|
|
92
|
+
|
|
93
|
+
_PROFILE_DEFAULTS = {
|
|
94
|
+
"product": 0, # 产品 ID(必填唯一)
|
|
95
|
+
"assigned_to": "", # 只认领指派给该账号的 bug;空=不按指派过滤
|
|
96
|
+
"severity_cap": 0, # 严重度上限(1 最严重);0=不限
|
|
97
|
+
"our_sides": ["backend"], # 我方端(CodeBee 自动修);空=纯排查转派不修
|
|
98
|
+
"repos": {"backend": dict(_REPO_DEFAULTS), "frontend": dict(_REPO_DEFAULTS)},
|
|
99
|
+
"repo_hints": {"backend": "", "frontend": ""}, # AI 排查时的一句仓库描述
|
|
100
|
+
"owners": {"backend": "", "frontend": "", "not_ours": ""},
|
|
101
|
+
"module_routes": [], # [{module:int, side:backend|frontend|both|not_ours, account:""}]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_UPDATABLE = ("base_url", "account", "password", "product_profiles",
|
|
105
|
+
"auto_resolve", "auto_merge", "triage_ai",
|
|
106
|
+
"poll_enabled", "interval_hours")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------- 出网边界(SSRF)
|
|
110
|
+
|
|
111
|
+
_META_HOSTS = {"metadata.google.internal", "metadata.goog"}
|
|
112
|
+
_NO_REDIRECT = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _no_redirect_opener():
|
|
116
|
+
"""HTTP 重定向一概不跟:目标必须就是用户配置的那台禅道。"""
|
|
117
|
+
global _NO_REDIRECT
|
|
118
|
+
if _NO_REDIRECT is None:
|
|
119
|
+
class _Stop(urllib.request.HTTPRedirectHandler):
|
|
120
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
121
|
+
return None
|
|
122
|
+
_NO_REDIRECT = urllib.request.build_opener(
|
|
123
|
+
_Stop(), urllib.request.HTTPSHandler(context=tlsctx.context()))
|
|
124
|
+
return _NO_REDIRECT
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _guard_url(url):
|
|
128
|
+
"""出网前的边界校验:协议白名单 + 主机解析阻断云元数据/链路本地地址。
|
|
129
|
+
|
|
130
|
+
私网与环回按设计放行(禅道自建在内网是主场景,配置者即本机用户)。
|
|
131
|
+
校验失败抛 ZenError。返回原 url。
|
|
132
|
+
"""
|
|
133
|
+
from urllib.parse import urlparse
|
|
134
|
+
u = urlparse(url)
|
|
135
|
+
if u.scheme not in ("http", "https"):
|
|
136
|
+
raise ZenError("禅道地址只允许 http/https 协议")
|
|
137
|
+
host = (u.hostname or "").lower()
|
|
138
|
+
if not host:
|
|
139
|
+
raise ZenError("禅道地址缺少主机名")
|
|
140
|
+
if host in _META_HOSTS:
|
|
141
|
+
raise ZenError("不允许访问云元数据地址")
|
|
142
|
+
try:
|
|
143
|
+
infos = socket.getaddrinfo(host, None)
|
|
144
|
+
except OSError:
|
|
145
|
+
raise ZenError("禅道主机解析失败:%s" % host)
|
|
146
|
+
for info in infos:
|
|
147
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
148
|
+
if ip.is_link_local or ip.is_reserved and not ip.is_private:
|
|
149
|
+
raise ZenError("不允许访问链路本地/保留地址:%s" % ip)
|
|
150
|
+
if ip.is_multicast or ip.is_unspecified:
|
|
151
|
+
raise ZenError("不允许访问多播/未指定地址:%s" % ip)
|
|
152
|
+
return url
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------- 持久化
|
|
156
|
+
|
|
157
|
+
def _save_locked():
|
|
158
|
+
_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
tmp = _FILE.with_suffix(".tmp")
|
|
160
|
+
tmp.write_text(json.dumps({"version": 2, **_STATE},
|
|
161
|
+
ensure_ascii=False, indent=2), encoding="utf-8")
|
|
162
|
+
os.replace(str(tmp), str(_FILE))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _normalize_claim(d):
|
|
166
|
+
"""claim 归一:v1 单任务形状(task_id/run_id)→ v2 tasks 数组。"""
|
|
167
|
+
c = dict(d) if isinstance(d, dict) else {}
|
|
168
|
+
if isinstance(c.get("tasks"), list) and c["tasks"]:
|
|
169
|
+
tasks = [dict(t) for t in c["tasks"] if isinstance(t, dict)]
|
|
170
|
+
else:
|
|
171
|
+
tasks = [{"side": "backend", "task_id": c.get("task_id") or "",
|
|
172
|
+
"run_id": c.get("run_id") or "", "state": "fixing"}] \
|
|
173
|
+
if (c.get("task_id") or c.get("run_id")) else []
|
|
174
|
+
c["tasks"] = tasks
|
|
175
|
+
c.setdefault("triage", {"side": "", "reason": "", "by": "", "account": ""})
|
|
176
|
+
if not isinstance(c.get("triage"), dict):
|
|
177
|
+
c["triage"] = {"side": "", "reason": "", "by": "", "account": ""}
|
|
178
|
+
c.setdefault("note", "")
|
|
179
|
+
c.setdefault("attempts", 0)
|
|
180
|
+
return c
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _migrate_legacy(cfg):
|
|
184
|
+
"""老扁平配置(products+单仓库)→ product_profiles。就地改写返回。"""
|
|
185
|
+
if cfg.get("product_profiles"):
|
|
186
|
+
return cfg
|
|
187
|
+
products = cfg.get("products") or []
|
|
188
|
+
if not isinstance(products, list) or not products:
|
|
189
|
+
return cfg
|
|
190
|
+
try:
|
|
191
|
+
pids = [int(p) for p in products if str(p).strip()]
|
|
192
|
+
except (TypeError, ValueError):
|
|
193
|
+
return cfg
|
|
194
|
+
backend = dict(_REPO_DEFAULTS)
|
|
195
|
+
for k in _REPO_DEFAULTS:
|
|
196
|
+
backend[k] = str(cfg.get(k) or "")
|
|
197
|
+
our = ["backend"]
|
|
198
|
+
if not backend["workdir"]:
|
|
199
|
+
our = [] # 老配置连工作目录都没配:纯路由
|
|
200
|
+
for pid in pids:
|
|
201
|
+
cfg.setdefault("product_profiles", []).append({
|
|
202
|
+
"product": pid,
|
|
203
|
+
"assigned_to": str(cfg.get("assigned_to") or ""),
|
|
204
|
+
"severity_cap": int(cfg.get("severity_cap") or 0),
|
|
205
|
+
"our_sides": list(our),
|
|
206
|
+
"repos": {"backend": backend, "frontend": dict(_REPO_DEFAULTS)},
|
|
207
|
+
"repo_hints": {"backend": "", "frontend": ""},
|
|
208
|
+
"owners": {"backend": "", "frontend": "", "not_ours": ""},
|
|
209
|
+
"module_routes": [],
|
|
210
|
+
})
|
|
211
|
+
return cfg
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load(force=False):
|
|
215
|
+
"""从磁盘加载状态(幂等)。返回 claims 数。测试可重绑 _FILE 后 force=True。"""
|
|
216
|
+
global _LOADED
|
|
217
|
+
with _LOCK:
|
|
218
|
+
if _LOADED and not force:
|
|
219
|
+
return len(_STATE["claims"])
|
|
220
|
+
try:
|
|
221
|
+
data = json.loads(_FILE.read_text(encoding="utf-8"))
|
|
222
|
+
except Exception:
|
|
223
|
+
data = {}
|
|
224
|
+
cfg = data.get("config") if isinstance(data, dict) else None
|
|
225
|
+
merged = dict(_CFG_DEFAULTS)
|
|
226
|
+
if isinstance(cfg, dict):
|
|
227
|
+
merged.update({k: v for k, v in cfg.items() if k in _CFG_DEFAULTS})
|
|
228
|
+
merged = _migrate_legacy(merged)
|
|
229
|
+
claims = data.get("claims") if isinstance(data, dict) else None
|
|
230
|
+
_STATE["config"] = merged
|
|
231
|
+
_STATE["claims"] = {str(k): _normalize_claim(v)
|
|
232
|
+
for k, v in (claims or {}).items()} \
|
|
233
|
+
if isinstance(claims, dict) else {}
|
|
234
|
+
_STATE["last_scan"] = str(data.get("last_scan") or "") if isinstance(data, dict) else ""
|
|
235
|
+
_STATE["next_scan"] = str(data.get("next_scan") or "") if isinstance(data, dict) else ""
|
|
236
|
+
_STATE["last_error"] = str(data.get("last_error") or "") if isinstance(data, dict) else ""
|
|
237
|
+
_LOADED = True
|
|
238
|
+
return len(_STATE["claims"])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _ensure_loaded():
|
|
242
|
+
if not _LOADED:
|
|
243
|
+
load()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _cfg():
|
|
247
|
+
cfg = dict(_CFG_DEFAULTS)
|
|
248
|
+
cfg.update({k: v for k, v in (_STATE.get("config") or {}).items()
|
|
249
|
+
if k in _CFG_DEFAULTS})
|
|
250
|
+
return cfg
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _profiles(cfg=None):
|
|
254
|
+
out = []
|
|
255
|
+
for p in (cfg or _cfg()).get("product_profiles") or []:
|
|
256
|
+
prof = dict(_PROFILE_DEFAULTS)
|
|
257
|
+
prof.update({k: v for k, v in (p or {}).items() if k in _PROFILE_DEFAULTS})
|
|
258
|
+
repos = dict(_PROFILE_DEFAULTS["repos"])
|
|
259
|
+
for side in SIDES:
|
|
260
|
+
r = dict(_REPO_DEFAULTS)
|
|
261
|
+
r.update({k: v for k, v in ((prof.get("repos") or {}).get(side) or {}).items()
|
|
262
|
+
if k in _REPO_DEFAULTS})
|
|
263
|
+
repos[side] = r
|
|
264
|
+
prof["repos"] = repos
|
|
265
|
+
hints = dict(_PROFILE_DEFAULTS["repo_hints"])
|
|
266
|
+
for side in SIDES:
|
|
267
|
+
hints[side] = str((prof.get("repo_hints") or {}).get(side) or "")
|
|
268
|
+
prof["repo_hints"] = hints
|
|
269
|
+
owners = dict(_PROFILE_DEFAULTS["owners"])
|
|
270
|
+
for k in owners:
|
|
271
|
+
owners[k] = str((prof.get("owners") or {}).get(k) or "").strip()
|
|
272
|
+
prof["owners"] = owners
|
|
273
|
+
prof["our_sides"] = [s for s in SIDES if s in (prof.get("our_sides") or [])]
|
|
274
|
+
prof["module_routes"] = [r for r in (prof.get("module_routes") or [])
|
|
275
|
+
if isinstance(r, dict)]
|
|
276
|
+
out.append(prof)
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _profile_for(cfg, product_id):
|
|
281
|
+
try:
|
|
282
|
+
pid = int(product_id or 0)
|
|
283
|
+
except (TypeError, ValueError):
|
|
284
|
+
return None
|
|
285
|
+
for p in _profiles(cfg):
|
|
286
|
+
if p["product"] == pid:
|
|
287
|
+
return p
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------- 禅道客户端
|
|
292
|
+
|
|
293
|
+
class ZenError(Exception):
|
|
294
|
+
"""禅道接口错误(message 人话,可直接展示)。"""
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
_TOKEN = {"v": "", "at": 0.0}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _reset_token():
|
|
301
|
+
_TOKEN["v"] = ""
|
|
302
|
+
_TOKEN["at"] = 0.0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _api_base(base_url):
|
|
306
|
+
b = str(base_url or "").strip().rstrip("/")
|
|
307
|
+
if not b:
|
|
308
|
+
raise ZenError("禅道地址未配置")
|
|
309
|
+
if not b.startswith(("http://", "https://")):
|
|
310
|
+
raise ZenError("禅道地址必须以 http:// 或 https:// 开头")
|
|
311
|
+
return b + "/api.php/v1"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _fetch_token(base_url, account, password):
|
|
315
|
+
"""获取新 token 并缓存。失败抛 ZenError。"""
|
|
316
|
+
url = _guard_url(_api_base(base_url) + "/tokens")
|
|
317
|
+
body = json.dumps({"account": str(account or ""), "password": str(password or "")},
|
|
318
|
+
ensure_ascii=False).encode("utf-8")
|
|
319
|
+
req = urllib.request.Request(url, data=body, method="POST", headers={
|
|
320
|
+
"Content-Type": "application/json", "Accept": "application/json"})
|
|
321
|
+
try:
|
|
322
|
+
with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
|
|
323
|
+
data = json.loads((r.read() or b"{}").decode("utf-8", "replace"))
|
|
324
|
+
except urllib.error.HTTPError as e:
|
|
325
|
+
try:
|
|
326
|
+
detail = json.loads((e.read() or b"").decode("utf-8", "replace"))
|
|
327
|
+
msg = (detail.get("error") or "") if isinstance(detail, dict) else ""
|
|
328
|
+
except Exception:
|
|
329
|
+
msg = ""
|
|
330
|
+
if e.code in (401, 403):
|
|
331
|
+
raise ZenError("禅道账号或密码不对(%s)%s" % (e.code, msg))
|
|
332
|
+
raise ZenError("禅道返回 %s:%s" % (e.code, msg or "获取令牌失败"))
|
|
333
|
+
except ZenError:
|
|
334
|
+
raise
|
|
335
|
+
except Exception as e:
|
|
336
|
+
raise ZenError("连不上禅道(%s)——请检查地址与网络" % e)
|
|
337
|
+
tok = ""
|
|
338
|
+
if isinstance(data, dict):
|
|
339
|
+
tok = str(data.get("token") or "")
|
|
340
|
+
if not tok and isinstance(data.get("data"), dict):
|
|
341
|
+
tok = str(data["data"].get("token") or "")
|
|
342
|
+
if not tok:
|
|
343
|
+
raise ZenError("禅道响应里没有 token——请确认版本 ≥15 且已开启 REST API")
|
|
344
|
+
_TOKEN["v"] = tok
|
|
345
|
+
_TOKEN["at"] = time.time()
|
|
346
|
+
return tok
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _token(cfg, force=False):
|
|
350
|
+
if force or not _TOKEN["v"] or time.time() - _TOKEN["at"] > TOKEN_TTL:
|
|
351
|
+
return _fetch_token(cfg.get("base_url"), cfg.get("account"), cfg.get("password"))
|
|
352
|
+
return _TOKEN["v"]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _api(method, path, cfg=None, body=None):
|
|
356
|
+
"""调禅道 API。返回 dict(非 dict 响应返回 {})。401/403 自动重取 token 重试一次。"""
|
|
357
|
+
c = cfg or _cfg()
|
|
358
|
+
for attempt in (1, 2):
|
|
359
|
+
headers = {"Accept": "application/json", "Token": _token(c, force=(attempt == 2))}
|
|
360
|
+
url = _guard_url(_api_base(c.get("base_url")) + path)
|
|
361
|
+
data = None
|
|
362
|
+
if body is not None:
|
|
363
|
+
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
364
|
+
headers["Content-Type"] = "application/json"
|
|
365
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
366
|
+
try:
|
|
367
|
+
with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
|
|
368
|
+
out = json.loads((r.read() or b"{}").decode("utf-8", "replace"))
|
|
369
|
+
return out if isinstance(out, dict) else {"_list": out}
|
|
370
|
+
except urllib.error.HTTPError as e:
|
|
371
|
+
raw = ""
|
|
372
|
+
try:
|
|
373
|
+
raw = (e.read() or b"").decode("utf-8", "replace")
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
detail = ""
|
|
377
|
+
try:
|
|
378
|
+
d = json.loads(raw)
|
|
379
|
+
if isinstance(d, dict):
|
|
380
|
+
detail = str(d.get("error") or d.get("message") or "")
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
if e.code in (401, 403) and attempt == 1:
|
|
384
|
+
_reset_token()
|
|
385
|
+
continue
|
|
386
|
+
raise ZenError("禅道接口 %s(%s)%s" % (path, e.code, detail or "调用失败"))
|
|
387
|
+
except ZenError:
|
|
388
|
+
raise
|
|
389
|
+
except Exception as e:
|
|
390
|
+
raise ZenError("连不上禅道(%s)" % e)
|
|
391
|
+
raise ZenError("禅道认证失败(token 两次获取后仍被拒绝,检查账号权限)")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _acct(v):
|
|
395
|
+
"""assignedTo/openedBy 兼容:新版返回 {account,...} 用户对象,老版直接是账号串。"""
|
|
396
|
+
if isinstance(v, dict):
|
|
397
|
+
return str(v.get("account") or "").strip()
|
|
398
|
+
return str(v or "").strip()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _strip_html(raw):
|
|
402
|
+
"""steps 字段是富文本 HTML:剥标签转纯文本。"""
|
|
403
|
+
txt = str(raw or "")
|
|
404
|
+
txt = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", txt)
|
|
405
|
+
txt = re.sub(r"(?i)<br\s*/?>|</p>|</li>|</div>|</tr>", "\n", txt)
|
|
406
|
+
txt = re.sub(r"<[^>]+>", " ", txt)
|
|
407
|
+
txt = _html.unescape(txt)
|
|
408
|
+
txt = re.sub(r"[ \t\r]+", " ", txt)
|
|
409
|
+
txt = re.sub(r"\n\s*\n+", "\n", txt)
|
|
410
|
+
return txt.strip()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _severity(bug):
|
|
414
|
+
try:
|
|
415
|
+
return int(bug.get("severity") or 0)
|
|
416
|
+
except (TypeError, ValueError):
|
|
417
|
+
return 0
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _claimable(bug, profile):
|
|
421
|
+
"""认领过滤:active + 指派 + 严重度(产品由档案本身界定)。"""
|
|
422
|
+
if str(bug.get("status") or "") != "active":
|
|
423
|
+
return False
|
|
424
|
+
assigned = str(profile.get("assigned_to") or "").strip()
|
|
425
|
+
cap = int(profile.get("severity_cap") or 0)
|
|
426
|
+
if assigned and _acct(bug.get("assignedTo")) != assigned:
|
|
427
|
+
return False
|
|
428
|
+
if cap and _severity(bug) > cap:
|
|
429
|
+
return False
|
|
430
|
+
return True
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def list_bugs(cfg, product_id):
|
|
434
|
+
"""拉一个产品下的 bug(分页,总量封顶 MAX_BUGS)。返回原始 bug dict 列表。"""
|
|
435
|
+
out = []
|
|
436
|
+
for page in range(1, 6):
|
|
437
|
+
d = _api("GET", "/products/%s/bugs?page=%d&limit=%d" % (product_id, page, PAGE_LIMIT),
|
|
438
|
+
cfg=cfg)
|
|
439
|
+
bugs = d.get("bugs") if isinstance(d, dict) else None
|
|
440
|
+
if not isinstance(bugs, list):
|
|
441
|
+
raise ZenError("禅道 bug 列表响应形状不对(预期 bugs 数组)")
|
|
442
|
+
out.extend(b for b in bugs if isinstance(b, dict))
|
|
443
|
+
total = 0
|
|
444
|
+
try:
|
|
445
|
+
total = int(d.get("total") or 0)
|
|
446
|
+
except (TypeError, ValueError):
|
|
447
|
+
pass
|
|
448
|
+
if len(out) >= MAX_BUGS or (total and len(out) >= total) or len(bugs) < PAGE_LIMIT:
|
|
449
|
+
break
|
|
450
|
+
return out[:MAX_BUGS]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def fetch_modules(product_id):
|
|
454
|
+
"""拉产品模块清单(模块路由配置辅助)。接口不存在/失败报人话,提示手填 ID。"""
|
|
455
|
+
_ensure_loaded()
|
|
456
|
+
cfg = _cfg()
|
|
457
|
+
try:
|
|
458
|
+
d = _api("GET", "/products/%s/modules" % product_id, cfg=cfg)
|
|
459
|
+
except ZenError as e:
|
|
460
|
+
return {"ok": False,
|
|
461
|
+
"error": "%s——也可能你的禅道没有该接口:请在禅道产品视图 URL 里查模块 ID 手工填写" % e}
|
|
462
|
+
items = d.get("_list") if isinstance(d.get("_list"), list) else d.get("modules")
|
|
463
|
+
out = []
|
|
464
|
+
for m in items or []:
|
|
465
|
+
if isinstance(m, dict) and m.get("id"):
|
|
466
|
+
out.append({"id": m.get("id"), "name": str(m.get("name") or "")})
|
|
467
|
+
elif isinstance(m, dict) and str(m.get("id") or "") == "" and m.get("name"):
|
|
468
|
+
continue
|
|
469
|
+
if not out:
|
|
470
|
+
return {"ok": False, "error": "模块清单为空或响应形状不认识——请手工填模块 ID"}
|
|
471
|
+
return {"ok": True, "modules": out[:200]}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def test_connection(base_url=None, account=None, password=None):
|
|
475
|
+
"""连接测试:取 token + 拉第一个产品的 bug 列表(有档案时)。返回 (ok, 人话结果)。"""
|
|
476
|
+
c = _cfg()
|
|
477
|
+
base_url = str(base_url if base_url is not None else c.get("base_url") or "").strip()
|
|
478
|
+
account = str(account if account is not None else c.get("account") or "").strip()
|
|
479
|
+
password = str(password if password is not None else c.get("password") or "").strip()
|
|
480
|
+
if not (base_url and account and password):
|
|
481
|
+
return False, "地址、账号、密码都要填全"
|
|
482
|
+
try:
|
|
483
|
+
_fetch_token(base_url, account, password)
|
|
484
|
+
except ZenError as e:
|
|
485
|
+
return False, str(e)
|
|
486
|
+
try:
|
|
487
|
+
profiles = _profiles(c)
|
|
488
|
+
if profiles:
|
|
489
|
+
bugs = list_bugs({"base_url": base_url, "account": account, "password": password},
|
|
490
|
+
profiles[0]["product"])
|
|
491
|
+
return True, "连接成功,产品 %s 可访问(当前 %d 条 bug 在列表里)" % (
|
|
492
|
+
profiles[0]["product"], len(bugs))
|
|
493
|
+
except ZenError as e:
|
|
494
|
+
return False, "令牌拿到了,但拉 bug 列表失败:%s" % e
|
|
495
|
+
return True, "连接成功(未配产品档案,跳过列表探测)"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------- 排查(triage)
|
|
499
|
+
|
|
500
|
+
def _ai_triage(bug, profile):
|
|
501
|
+
"""AI 兜底排查:单次 LLM 调用判端。不可用/解析失败返回 None(bookmeta 同款配方)。"""
|
|
502
|
+
try:
|
|
503
|
+
from . import modelhub, runner
|
|
504
|
+
orch = modelhub.resolve_orchestrator()
|
|
505
|
+
if not orch:
|
|
506
|
+
return None
|
|
507
|
+
prov, model = orch
|
|
508
|
+
repos = profile.get("repos") or {}
|
|
509
|
+
hints = profile.get("repo_hints") or {}
|
|
510
|
+
|
|
511
|
+
def _repo_desc(side):
|
|
512
|
+
hint = str(hints.get(side) or "").strip()
|
|
513
|
+
if hint:
|
|
514
|
+
return hint
|
|
515
|
+
wd = str((repos.get(side) or {}).get("workdir") or "").strip()
|
|
516
|
+
if wd:
|
|
517
|
+
return "目录 " + Path(wd).name
|
|
518
|
+
return "(未配置)"
|
|
519
|
+
|
|
520
|
+
lines = ["你是缺陷分诊员:根据缺陷描述判断问题属于哪个仓库端。", "",
|
|
521
|
+
"【缺陷】#%s %s" % (bug.get("id"), str(bug.get("title") or ""))]
|
|
522
|
+
steps = _strip_html(bug.get("steps"))
|
|
523
|
+
if steps:
|
|
524
|
+
lines.append(steps[:3000])
|
|
525
|
+
lines.append("")
|
|
526
|
+
lines.append("【仓库背景】后端仓库:%s;前端仓库:%s" % (_repo_desc("backend"),
|
|
527
|
+
_repo_desc("frontend")))
|
|
528
|
+
lines.append("")
|
|
529
|
+
lines.append('只输出 JSON(不要别的文字):{"side": "backend|frontend|both|not_ours", '
|
|
530
|
+
'"reason": "一句话依据"}')
|
|
531
|
+
lines.append("判定口径:backend=纯后端问题;frontend=纯前端问题;both=两端都要改;"
|
|
532
|
+
"not_ours=与这两个仓库无关(第三方服务/环境/需求变更/数据问题等)。")
|
|
533
|
+
res = modelhub.chat(prov["id"], model, "\n".join(lines), max_tokens=500,
|
|
534
|
+
timeout=90)
|
|
535
|
+
if not res.get("ok"):
|
|
536
|
+
log.warning("zentao: AI 排查失败:%s", res.get("error"))
|
|
537
|
+
return None
|
|
538
|
+
data = runner.extract_json(res.get("text") or "")
|
|
539
|
+
side = str((data or {}).get("side") or "").strip().lower()
|
|
540
|
+
if side not in TRIAGE_SIDES:
|
|
541
|
+
return None
|
|
542
|
+
return {"side": side, "reason": str((data or {}).get("reason") or "")[:300]}
|
|
543
|
+
except Exception:
|
|
544
|
+
log.debug("zentao: AI 排查异常", exc_info=True)
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _triage(bug, profile, cfg):
|
|
549
|
+
"""排查定责:模块路由 > AI > unknown。返回 {side, reason, by, account}。"""
|
|
550
|
+
mid = str(bug.get("module") or "")
|
|
551
|
+
if mid:
|
|
552
|
+
for r in profile.get("module_routes") or []:
|
|
553
|
+
try:
|
|
554
|
+
if int(r.get("module") or 0) != int(mid):
|
|
555
|
+
continue
|
|
556
|
+
except (TypeError, ValueError):
|
|
557
|
+
continue
|
|
558
|
+
side = str(r.get("side") or "")
|
|
559
|
+
if side in TRIAGE_SIDES:
|
|
560
|
+
return {"side": side, "reason": "模块 #%s 路由规则" % mid,
|
|
561
|
+
"by": "rule", "account": str(r.get("account") or "").strip()}
|
|
562
|
+
if cfg.get("triage_ai"):
|
|
563
|
+
res = _ai_triage(bug, profile)
|
|
564
|
+
if res:
|
|
565
|
+
res["by"] = "ai"
|
|
566
|
+
res["account"] = ""
|
|
567
|
+
return res
|
|
568
|
+
return {"side": "unknown", "reason": "模块未命中路由且 AI 排查不可用", "by": "fallback",
|
|
569
|
+
"account": ""}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# ---------------------------------------------------------------- 修复任务拉起
|
|
573
|
+
|
|
574
|
+
def _repo_of(profile, side):
|
|
575
|
+
return (profile.get("repos") or {}).get(side) or dict(_REPO_DEFAULTS)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _goal_text(bug, side=None):
|
|
579
|
+
"""bug → 修复目标提示词。side 给出时附端约束。"""
|
|
580
|
+
bid = bug.get("id")
|
|
581
|
+
lines = ["修复禅道 Bug #%s:%s" % (bid, str(bug.get("title") or "").strip())]
|
|
582
|
+
steps = _strip_html(bug.get("steps"))
|
|
583
|
+
if steps:
|
|
584
|
+
lines.append("")
|
|
585
|
+
lines.append("【重现步骤/问题描述】")
|
|
586
|
+
lines.append(steps[:4000])
|
|
587
|
+
sev, pri = _severity(bug), str(bug.get("pri") or "").strip()
|
|
588
|
+
meta = []
|
|
589
|
+
if sev:
|
|
590
|
+
meta.append("严重度 %s(1 最严重)" % sev)
|
|
591
|
+
if pri:
|
|
592
|
+
meta.append("优先级 %s" % pri)
|
|
593
|
+
if str(bug.get("module") or "").strip():
|
|
594
|
+
meta.append("模块 #%s" % bug["module"])
|
|
595
|
+
env = " / ".join(x for x in (str(bug.get("os") or "").strip(),
|
|
596
|
+
str(bug.get("browser") or "").strip()) if x)
|
|
597
|
+
if env:
|
|
598
|
+
meta.append("环境 " + env)
|
|
599
|
+
kw = str(bug.get("keywords") or "").strip()
|
|
600
|
+
if kw:
|
|
601
|
+
meta.append("关键字 " + kw)
|
|
602
|
+
if meta:
|
|
603
|
+
lines.append("")
|
|
604
|
+
lines.append("【元信息】" + ";".join(meta))
|
|
605
|
+
lines.append("")
|
|
606
|
+
lines.append("【要求】只修这个 bug,不做无关重构;改动最小化;修完自查不引入回归。")
|
|
607
|
+
if side in SIDES:
|
|
608
|
+
lines.append("【端约束】本任务只负责【%s】部分;%s部分由别人处理,不要越界改动。"
|
|
609
|
+
% (SIDE_CN[side],
|
|
610
|
+
SIDE_CN["frontend" if side == "backend" else "backend"]))
|
|
611
|
+
lines.append("完成后给出修改说明。")
|
|
612
|
+
return "\n".join(lines)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _launch_fix(bug, profile, side, cfg):
|
|
616
|
+
"""为一个 bug 的某一端建修复任务并入队。与 automation._launch_run 同一条链。"""
|
|
617
|
+
bid = bug.get("id")
|
|
618
|
+
repo = _repo_of(profile, side)
|
|
619
|
+
wd = str(repo.get("workdir") or "").strip() or settings.default_workdir()
|
|
620
|
+
title = ("[禅道#%s][%s] %s" % (bid, SIDE_CN.get(side, side),
|
|
621
|
+
str(bug.get("title") or "").strip())).strip()[:60]
|
|
622
|
+
payload = {"type": "code", "title": title, "goal": _goal_text(bug, side), "workdir": wd}
|
|
623
|
+
if str(repo.get("git_rev") or "").strip():
|
|
624
|
+
payload["git_rev"] = str(repo["git_rev"]).strip()
|
|
625
|
+
if str(repo.get("verify_command") or "").strip():
|
|
626
|
+
payload["verify_command"] = str(repo["verify_command"]).strip()
|
|
627
|
+
task = store.create_task(payload)
|
|
628
|
+
run = store.create_run("orchestration", task["title"], task_id=task["id"])
|
|
629
|
+
store.update_task_status(task["id"], "queued")
|
|
630
|
+
jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
|
|
631
|
+
return task, run
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ---------------------------------------------------------------- 回写文本
|
|
635
|
+
|
|
636
|
+
def _diffstat(workdir, task_id):
|
|
637
|
+
"""任务分支相对基线的改动统计(人话一行)。拿不到返回空串。"""
|
|
638
|
+
try:
|
|
639
|
+
from . import gitmod, runner
|
|
640
|
+
if task_id and workdir:
|
|
641
|
+
br = gitmod.branch_name(task_id)
|
|
642
|
+
r = runner.run_process(
|
|
643
|
+
argv=["git", "-C", workdir, "diff", "--shortstat", "HEAD..." + br],
|
|
644
|
+
timeout=20)
|
|
645
|
+
if r.get("ok"):
|
|
646
|
+
return (r.get("stdout") or "").strip()
|
|
647
|
+
except Exception:
|
|
648
|
+
log.debug("zentao: diffstat 失败", exc_info=True)
|
|
649
|
+
return ""
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _fix_summary(claim, run, profile, side):
|
|
653
|
+
"""我方某一端的修复摘要(转派评论与 resolve 报告共用的事实部分)。"""
|
|
654
|
+
task = store.get_task((claim.get("tasks") or [{}])[0].get("task_id") if not side
|
|
655
|
+
else _task_of(claim, side).get("task_id"))
|
|
656
|
+
lines = []
|
|
657
|
+
git = (run or {}).get("git") or {}
|
|
658
|
+
commit = str(git.get("commit") or "").strip()
|
|
659
|
+
from_branch = str(git.get("from_branch") or "").strip()
|
|
660
|
+
tid = (_task_of(claim, side).get("task_id") if side else "") or ""
|
|
661
|
+
if commit:
|
|
662
|
+
lines.append("%s修复提交 %s%s" % ("【%s】" % SIDE_CN[side] if side else "",
|
|
663
|
+
commit,
|
|
664
|
+
"(已合并回 %s)" % from_branch if from_branch else ""))
|
|
665
|
+
stat = _diffstat(_repo_of(profile, side).get("workdir") if profile else "", tid)
|
|
666
|
+
if stat:
|
|
667
|
+
lines.append("改动统计:%s" % stat)
|
|
668
|
+
v = (run or {}).get("verdict") or {}
|
|
669
|
+
if v:
|
|
670
|
+
lines.append("验证结论:%s" % ("通过(评审达标)" if v.get("pass") or v.get("publishable")
|
|
671
|
+
else "完成"))
|
|
672
|
+
return lines
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _task_of(claim, side):
|
|
676
|
+
for t in claim.get("tasks") or []:
|
|
677
|
+
if t.get("side") == side:
|
|
678
|
+
return t
|
|
679
|
+
return {}
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _report_text(claim, run, profile, cfg):
|
|
683
|
+
"""resolve 评论:确定性事实。"""
|
|
684
|
+
tri = claim.get("triage") or {}
|
|
685
|
+
lines = ["【CodeBee 自动修复报告】",
|
|
686
|
+
"Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or "")]
|
|
687
|
+
if tri.get("side"):
|
|
688
|
+
lines.append("排查结论:%s问题(%s)" % (SIDE_CN.get(tri["side"], tri["side"]),
|
|
689
|
+
tri.get("reason") or tri.get("by") or "按规则"))
|
|
690
|
+
lines.extend(_fix_summary(claim, run, profile, None))
|
|
691
|
+
lines.append("(本条由 CodeBee 禅道集成自动回写)")
|
|
692
|
+
return "\n".join(lines)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _transfer_text(claim, profile, fixed_runs, target_side):
|
|
696
|
+
"""转派评论:排查结论 + 我方已做工作 + 请对方继续。"""
|
|
697
|
+
tri = claim.get("triage") or {}
|
|
698
|
+
lines = ["【CodeBee 排查转派】",
|
|
699
|
+
"Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or ""),
|
|
700
|
+
"排查结论:%s问题——%s" % (SIDE_CN.get(target_side, target_side),
|
|
701
|
+
tri.get("reason") or tri.get("by") or "按规则")]
|
|
702
|
+
for side, run in (fixed_runs or []):
|
|
703
|
+
lines.extend(_fix_summary(claim, run, profile, side))
|
|
704
|
+
lines.append("请%s负责人接手处理;本 bug 保持激活,处理完请按正常流程解决。"
|
|
705
|
+
% SIDE_CN.get(target_side, target_side))
|
|
706
|
+
lines.append("(本条由 CodeBee 禅道集成自动回写)")
|
|
707
|
+
return "\n".join(lines)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _fail_text(claim, failed_tasks, runs):
|
|
711
|
+
tri = claim.get("triage") or {}
|
|
712
|
+
lines = ["【CodeBee 自动修复未成功】",
|
|
713
|
+
"Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or "")]
|
|
714
|
+
if tri.get("side"):
|
|
715
|
+
lines.append("排查结论:%s(%s)" % (tri.get("side"), tri.get("reason") or ""))
|
|
716
|
+
for t in failed_tasks:
|
|
717
|
+
r = runs.get(t.get("run_id")) or {}
|
|
718
|
+
why = str(r.get("error") or "").strip() or ("运行状态 " + str(r.get("status") or ""))
|
|
719
|
+
lines.append("【%s】失败:%s" % (SIDE_CN.get(t.get("side"), t.get("side")), why[:300]))
|
|
720
|
+
lines.append("CodeBee 修复任务:%s(可人工续跑或接管);本 bug 保持待处理。"
|
|
721
|
+
% "、".join(t.get("task_id") or "?" for t in claim.get("tasks") or []))
|
|
722
|
+
return "\n".join(lines)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
# ---------------------------------------------------------------- 禅道写回动作
|
|
726
|
+
|
|
727
|
+
def _bug_opened_by(cfg, bug_id):
|
|
728
|
+
try:
|
|
729
|
+
d = _api("GET", "/bugs/%s" % bug_id, cfg=cfg)
|
|
730
|
+
return _acct(d.get("openedBy"))
|
|
731
|
+
except ZenError:
|
|
732
|
+
return ""
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _ensure_resolved(cfg, bug_id, comment, assign_to):
|
|
736
|
+
"""resolve(幂等):已是 resolved/closed 视为成功。"""
|
|
737
|
+
cur = _api("GET", "/bugs/%s" % bug_id, cfg=cfg)
|
|
738
|
+
if str(cur.get("status") or "") in ("resolved", "closed"):
|
|
739
|
+
return True
|
|
740
|
+
body = {"resolution": "fixed", "resolvedBuild": "trunk", "comment": comment}
|
|
741
|
+
if assign_to:
|
|
742
|
+
body["assignedTo"] = assign_to
|
|
743
|
+
_api("POST", "/bugs/%s/resolve" % bug_id, cfg=cfg, body=body)
|
|
744
|
+
return True
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _transfer(cfg, bug_id, target, comment):
|
|
748
|
+
"""转派:PUT assignedTo+comment(bug 保持激活)。target 空=只评论。抛 ZenError。"""
|
|
749
|
+
body = {"comment": comment}
|
|
750
|
+
if target:
|
|
751
|
+
body["assignedTo"] = target
|
|
752
|
+
_api("PUT", "/bugs/%s" % bug_id, cfg=cfg, body=body)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _notify(text):
|
|
756
|
+
try:
|
|
757
|
+
from . import notify
|
|
758
|
+
notify.push_text(text)
|
|
759
|
+
except Exception:
|
|
760
|
+
log.debug("zentao: 群通知失败", exc_info=True)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _merge_branch(workdir, task):
|
|
764
|
+
try:
|
|
765
|
+
from . import gitmod
|
|
766
|
+
return gitmod.merge_task_branch(workdir, task)
|
|
767
|
+
except Exception as e:
|
|
768
|
+
return False, "合并异常:%s" % e, None
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _set_claim(bid, **patch):
|
|
772
|
+
with _LOCK:
|
|
773
|
+
c = _STATE["claims"].get(str(bid))
|
|
774
|
+
if c is None:
|
|
775
|
+
return
|
|
776
|
+
c.update(patch)
|
|
777
|
+
c["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
778
|
+
_save_locked()
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _route_account(profile, tri, side):
|
|
782
|
+
"""转派/升级目标:模块路由 account > 端负责人。"""
|
|
783
|
+
if tri and tri.get("account"):
|
|
784
|
+
return str(tri["account"]).strip()
|
|
785
|
+
return str((profile.get("owners") or {}).get(side) or "").strip()
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# ---------------------------------------------------------------- 对账回写
|
|
789
|
+
|
|
790
|
+
def _finish_ok(claim, cfg):
|
|
791
|
+
"""我方任务全部 done:合并 → (需要则)转派 → 否则 resolve。"""
|
|
792
|
+
bid = str(claim.get("bug_id"))
|
|
793
|
+
profile = _profile_for(cfg, claim.get("product")) or {}
|
|
794
|
+
tri = claim.get("triage") or {}
|
|
795
|
+
tasks = claim.get("tasks") or []
|
|
796
|
+
runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
|
|
797
|
+
# 1) 逐任务合并(任一失败即停:不转派不 resolve,绝不带着没落库的修复转派)
|
|
798
|
+
if cfg.get("auto_merge"):
|
|
799
|
+
for t in tasks:
|
|
800
|
+
task = store.get_task(t.get("task_id") or "")
|
|
801
|
+
if task is None or not task.get("git_rev"):
|
|
802
|
+
continue
|
|
803
|
+
ok, err, _info = _merge_branch(str(_repo_of(profile, t.get("side")).get("workdir")
|
|
804
|
+
or settings.default_workdir()), task)
|
|
805
|
+
if not ok:
|
|
806
|
+
_set_claim(bid, state="merge_failed",
|
|
807
|
+
note="【%s】任务分支合并失败:%s(不转派不 resolve,留人工)"
|
|
808
|
+
% (SIDE_CN.get(t.get("side"), t.get("side")), err))
|
|
809
|
+
_notify("🐛❌ 禅道 Bug #%s 的%s修复代码合并失败:%s\n修复任务:%s"
|
|
810
|
+
% (bid, SIDE_CN.get(t.get("side"), ""), err, t.get("task_id") or "?"))
|
|
811
|
+
return
|
|
812
|
+
# 2) 需要转派的端 = 判定端 - 我方端(both 且我方只管一端时非空)
|
|
813
|
+
verdict_sides = ([tri["side"]] if tri.get("side") in SIDES
|
|
814
|
+
else list(SIDES) if tri.get("side") == "both" else [])
|
|
815
|
+
other_sides = [s for s in verdict_sides if s not in (profile.get("our_sides") or [])]
|
|
816
|
+
if other_sides:
|
|
817
|
+
targets = {}
|
|
818
|
+
for s in other_sides:
|
|
819
|
+
tgt = _route_account(profile, tri, s)
|
|
820
|
+
if not tgt:
|
|
821
|
+
_set_claim(bid, state="need_manual",
|
|
822
|
+
note="我方已修完,但未配置%s负责人,无法转派——请人工转派" % SIDE_CN[s])
|
|
823
|
+
_notify("🐛⚠️ 禅道 Bug #%s 我方部分已修完,但未配置%s负责人,请人工转派"
|
|
824
|
+
% (bid, SIDE_CN[s]))
|
|
825
|
+
return
|
|
826
|
+
targets[s] = tgt
|
|
827
|
+
fixed_runs = [(t.get("side"), runs.get(t.get("run_id"))) for t in tasks]
|
|
828
|
+
for s in other_sides:
|
|
829
|
+
try:
|
|
830
|
+
_transfer(cfg, bid, targets[s], _transfer_text(claim, profile, fixed_runs, s))
|
|
831
|
+
except ZenError as e:
|
|
832
|
+
attempts = int(claim.get("attempts") or 0) + 1
|
|
833
|
+
if attempts >= RESOLVE_MAX_ATTEMPTS:
|
|
834
|
+
_set_claim(bid, attempts=attempts, state="resolve_failed",
|
|
835
|
+
note="转派连续 %d 次失败:%s" % (attempts, e))
|
|
836
|
+
_notify("🐛⚠️ 禅道 Bug #%s 修完但转派失败:%s" % (bid, e))
|
|
837
|
+
else:
|
|
838
|
+
_set_claim(bid, attempts=attempts, state="fixing",
|
|
839
|
+
note="转派第 %d 次失败,下轮重试:%s" % (attempts, e))
|
|
840
|
+
return
|
|
841
|
+
_set_claim(bid, state="transferred",
|
|
842
|
+
note="我方(%s)已修完并合并,转派给 %s"
|
|
843
|
+
% ("/".join(SIDE_CN.get(t.get("side"), "") for t in tasks),
|
|
844
|
+
"、".join(SIDE_CN[s] + ":" + targets[s] for s in other_sides)))
|
|
845
|
+
_notify("🐛🔁 禅道 Bug #%s 我方已修完,转派 %s\n%s"
|
|
846
|
+
% (bid, "、".join(SIDE_CN[s] + ":" + targets[s] for s in other_sides),
|
|
847
|
+
claim.get("title") or ""))
|
|
848
|
+
return
|
|
849
|
+
# 3) 全部我方端:resolve(幂等)+ 指回报告人
|
|
850
|
+
if not cfg.get("auto_resolve"):
|
|
851
|
+
_set_claim(bid, state="done_manual",
|
|
852
|
+
note="修复完成;auto_resolve 已关,请人工确认后到禅道解决 bug")
|
|
853
|
+
return
|
|
854
|
+
report = _report_text(claim, runs.get(tasks[0].get("run_id")), profile, cfg)
|
|
855
|
+
opened = _bug_opened_by(cfg, bid)
|
|
856
|
+
try:
|
|
857
|
+
_ensure_resolved(cfg, bid, report, opened)
|
|
858
|
+
except ZenError as e:
|
|
859
|
+
attempts = int(claim.get("attempts") or 0) + 1
|
|
860
|
+
if attempts >= RESOLVE_MAX_ATTEMPTS:
|
|
861
|
+
_set_claim(bid, attempts=attempts, state="resolve_failed",
|
|
862
|
+
note="resolve 连续 %d 次失败:%s" % (attempts, e))
|
|
863
|
+
_notify("🐛⚠️ 禅道 Bug #%s 修复完成但回写禅道失败:%s" % (bid, e))
|
|
864
|
+
else:
|
|
865
|
+
_set_claim(bid, attempts=attempts, state="fixing",
|
|
866
|
+
note="resolve 第 %d 次失败,下轮重试:%s" % (attempts, e))
|
|
867
|
+
return
|
|
868
|
+
_set_claim(bid, state="resolved", note="已 resolve(fixed)%s"
|
|
869
|
+
% (",指回报告人 " + opened if opened else ""))
|
|
870
|
+
_notify("🐛✅ 禅道 Bug #%s 已修复并 resolve\n%s\n修复任务:%s"
|
|
871
|
+
% (bid, claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _finish_failed(claim, cfg):
|
|
875
|
+
"""我方任一任务失败:评论尝试记录 + 转派该端负责人(有配则转)。"""
|
|
876
|
+
bid = str(claim.get("bug_id"))
|
|
877
|
+
profile = _profile_for(cfg, claim.get("product")) or {}
|
|
878
|
+
tasks = claim.get("tasks") or []
|
|
879
|
+
runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
|
|
880
|
+
failed = [t for t in tasks
|
|
881
|
+
if str((runs.get(t.get("run_id")) or {}).get("status") or "") not in
|
|
882
|
+
("queued", "running", "done")]
|
|
883
|
+
text = _fail_text(claim, failed, runs)
|
|
884
|
+
# 升级目标:取第一个失败端的路由账号/负责人
|
|
885
|
+
target = ""
|
|
886
|
+
for t in failed:
|
|
887
|
+
target = _route_account(profile, claim.get("triage") or {}, t.get("side"))
|
|
888
|
+
if target:
|
|
889
|
+
break
|
|
890
|
+
try:
|
|
891
|
+
_transfer(cfg, bid, target, text)
|
|
892
|
+
note = "已评论说明%s" % ("并转派 %s" % target if target else "")
|
|
893
|
+
state = "escalated" if target else "commented"
|
|
894
|
+
except ZenError as e:
|
|
895
|
+
log.warning("zentao: bug %s 失败评论未送达(群通知兜底):%s", bid, e)
|
|
896
|
+
note = "修复失败,评论未送达:%s" % e
|
|
897
|
+
state = "commented"
|
|
898
|
+
_set_claim(bid, state=state, note=note)
|
|
899
|
+
_notify("🐛❌ 禅道 Bug #%s 自动修复未成功%s\n%s\n修复任务:%s"
|
|
900
|
+
% (bid, (",已转派 " + target) if target else "",
|
|
901
|
+
claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _reconcile(cfg):
|
|
905
|
+
"""对账:fixing 中的 claim 查各 run 终态并回写。单条异常只跳过该条。"""
|
|
906
|
+
with _LOCK:
|
|
907
|
+
fixing = [dict(c) for c in _STATE["claims"].values() if c.get("state") == "fixing"]
|
|
908
|
+
for claim in fixing:
|
|
909
|
+
bid = str(claim.get("bug_id"))
|
|
910
|
+
try:
|
|
911
|
+
tasks = claim.get("tasks") or []
|
|
912
|
+
runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
|
|
913
|
+
if any(r is None for r in runs.values()):
|
|
914
|
+
_set_claim(bid, state="lost", note="运行记录不存在(可能被清理)")
|
|
915
|
+
continue
|
|
916
|
+
states = [str((r or {}).get("status") or "") for r in runs.values()]
|
|
917
|
+
if any(s in ("queued", "running") for s in states):
|
|
918
|
+
continue
|
|
919
|
+
if all(s == "done" for s in states):
|
|
920
|
+
_finish_ok(claim, cfg)
|
|
921
|
+
else:
|
|
922
|
+
_finish_failed(claim, cfg)
|
|
923
|
+
except Exception:
|
|
924
|
+
log.exception("zentao: claim %s 对账异常,跳过", bid)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
# ---------------------------------------------------------------- 扫描主流程
|
|
928
|
+
|
|
929
|
+
def _sides_to_fix(profile, tri):
|
|
930
|
+
"""判定端 ∩ 我方端。"""
|
|
931
|
+
tri_side = tri.get("side")
|
|
932
|
+
verdict_sides = [tri_side] if tri_side in SIDES else (list(SIDES) if tri_side == "both" else [])
|
|
933
|
+
return [s for s in verdict_sides if s in (profile.get("our_sides") or [])]
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _route_one(bug, profile, cfg, seen):
|
|
937
|
+
"""认领一个 bug:排查 → 分流(建任务/转派/留人工)。返回动作文案或 None。"""
|
|
938
|
+
bid = str(bug.get("id") or "")
|
|
939
|
+
tri = _triage(bug, profile, cfg)
|
|
940
|
+
base_claim = {
|
|
941
|
+
"bug_id": bug.get("id"), "product": profile.get("product"),
|
|
942
|
+
"title": str(bug.get("title") or "")[:120],
|
|
943
|
+
"triage": tri, "tasks": [], "state": "fixing", "note": "",
|
|
944
|
+
"attempts": 0, "claimed_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
945
|
+
}
|
|
946
|
+
opened = _acct(bug.get("openedBy"))
|
|
947
|
+
|
|
948
|
+
if tri["side"] == "unknown":
|
|
949
|
+
base_claim["state"] = "need_manual"
|
|
950
|
+
base_claim["note"] = tri.get("reason") or "排查失败,留人工"
|
|
951
|
+
with _LOCK:
|
|
952
|
+
_STATE["claims"][bid] = base_claim
|
|
953
|
+
_save_locked()
|
|
954
|
+
_notify("🐛❓ 禅道 Bug #%s 排查不出归属端(%s),留人工\n%s"
|
|
955
|
+
% (bid, tri.get("reason") or "", bug.get("title") or ""))
|
|
956
|
+
return "need_manual"
|
|
957
|
+
|
|
958
|
+
if tri["side"] == "not_ours":
|
|
959
|
+
target = tri.get("account") or str(profile.get("owners").get("not_ours") or "") \
|
|
960
|
+
or opened
|
|
961
|
+
text = ("【CodeBee 排查转派】\nBug:#%s %s\n排查结论:非我方两个仓库的问题——%s\n"
|
|
962
|
+
"转回 %s 核实处理。\n(本条由 CodeBee 禅道集成自动回写)"
|
|
963
|
+
% (bid, bug.get("title") or "", tri.get("reason") or "按规则",
|
|
964
|
+
target or "报告人"))
|
|
965
|
+
try:
|
|
966
|
+
_transfer(cfg, bid, target, text)
|
|
967
|
+
note = "非我方,已转派 %s" % (target or "(未指派,仅评论)")
|
|
968
|
+
except ZenError as e:
|
|
969
|
+
base_claim.update({"state": "need_manual", "note": "非我方转派失败:%s" % e})
|
|
970
|
+
with _LOCK:
|
|
971
|
+
_STATE["claims"][bid] = base_claim
|
|
972
|
+
_save_locked()
|
|
973
|
+
_notify("🐛⚠️ 禅道 Bug #%s 判定非我方但转派失败:%s" % (bid, e))
|
|
974
|
+
return "need_manual"
|
|
975
|
+
base_claim.update({"state": "transferred", "note": note})
|
|
976
|
+
with _LOCK:
|
|
977
|
+
_STATE["claims"][bid] = base_claim
|
|
978
|
+
_save_locked()
|
|
979
|
+
_notify("🐛↩️ 禅道 Bug #%s 判定非我方,已转派 %s\n%s"
|
|
980
|
+
% (bid, target or "报告人", bug.get("title") or ""))
|
|
981
|
+
return "transferred"
|
|
982
|
+
|
|
983
|
+
sides = _sides_to_fix(profile, tri)
|
|
984
|
+
if not sides:
|
|
985
|
+
# 纯对方端问题(含测试指错到我方账号的):直接转派正确负责人
|
|
986
|
+
other = tri["side"] if tri["side"] in SIDES else None
|
|
987
|
+
if other is None: # both 但我方两端都没配:整单转不了,留人工
|
|
988
|
+
base_claim["state"] = "need_manual"
|
|
989
|
+
base_claim["note"] = "双端问题但产品档案未配置我方端,请人工处理"
|
|
990
|
+
with _LOCK:
|
|
991
|
+
_STATE["claims"][bid] = base_claim
|
|
992
|
+
_save_locked()
|
|
993
|
+
_notify("🐛❓ 禅道 Bug #%s 为双端问题但未配我方端,留人工" % bid)
|
|
994
|
+
return "need_manual"
|
|
995
|
+
target = _route_account(profile, tri, other)
|
|
996
|
+
if not target:
|
|
997
|
+
base_claim["state"] = "need_manual"
|
|
998
|
+
base_claim["note"] = "判定为%s问题,但未配置%s负责人,无法转派" % (
|
|
999
|
+
SIDE_CN[other], SIDE_CN[other])
|
|
1000
|
+
with _LOCK:
|
|
1001
|
+
_STATE["claims"][bid] = base_claim
|
|
1002
|
+
_save_locked()
|
|
1003
|
+
_notify("🐛⚠️ 禅道 Bug #%s 判定为%s问题但未配负责人,请人工转派"
|
|
1004
|
+
% (bid, SIDE_CN[other]))
|
|
1005
|
+
return "need_manual"
|
|
1006
|
+
text = _transfer_text({"bug_id": bug.get("id"), "title": bug.get("title") or "",
|
|
1007
|
+
"triage": tri, "tasks": []}, profile, [], other)
|
|
1008
|
+
try:
|
|
1009
|
+
_transfer(cfg, bid, target, text)
|
|
1010
|
+
except ZenError as e:
|
|
1011
|
+
base_claim["state"] = "need_manual"
|
|
1012
|
+
base_claim["note"] = "%s转派失败:%s" % (SIDE_CN[other], e)
|
|
1013
|
+
with _LOCK:
|
|
1014
|
+
_STATE["claims"][bid] = base_claim
|
|
1015
|
+
_save_locked()
|
|
1016
|
+
_notify("🐛⚠️ 禅道 Bug #%s 判定%s问题但转派失败:%s" % (bid, SIDE_CN[other], e))
|
|
1017
|
+
return "need_manual"
|
|
1018
|
+
base_claim.update({"state": "transferred",
|
|
1019
|
+
"note": "%s问题(测试原指派 %s),已转派 %s"
|
|
1020
|
+
% (SIDE_CN[other], _acct(bug.get("assignedTo")) or "?", target)})
|
|
1021
|
+
with _LOCK:
|
|
1022
|
+
_STATE["claims"][bid] = base_claim
|
|
1023
|
+
_save_locked()
|
|
1024
|
+
_notify("🐛🔁 禅道 Bug #%s 判定%s问题,已转派 %s(原指派 %s)\n%s"
|
|
1025
|
+
% (bid, SIDE_CN[other], target, _acct(bug.get("assignedTo")) or "?",
|
|
1026
|
+
bug.get("title") or ""))
|
|
1027
|
+
return "transferred"
|
|
1028
|
+
|
|
1029
|
+
# 我方端:逐端建修复任务(任一端建不起来 → 整单留人工,不做半截修复)
|
|
1030
|
+
tasks = []
|
|
1031
|
+
try:
|
|
1032
|
+
for side in sides:
|
|
1033
|
+
task, run = _launch_fix(bug, profile, side, cfg)
|
|
1034
|
+
tasks.append({"side": side, "task_id": task["id"], "run_id": run["id"],
|
|
1035
|
+
"state": "fixing"})
|
|
1036
|
+
except Exception as e:
|
|
1037
|
+
log.warning("zentao: bug %s 建修复任务失败:%s", bid, e)
|
|
1038
|
+
base_claim["state"] = "need_manual"
|
|
1039
|
+
base_claim["note"] = "建修复任务失败:%s" % e
|
|
1040
|
+
with _LOCK:
|
|
1041
|
+
_STATE["claims"][bid] = base_claim
|
|
1042
|
+
_save_locked()
|
|
1043
|
+
_notify("🐛⚠️ 禅道 Bug #%s 建修复任务失败:%s" % (bid, e))
|
|
1044
|
+
return "need_manual"
|
|
1045
|
+
base_claim["tasks"] = tasks
|
|
1046
|
+
with _LOCK:
|
|
1047
|
+
_STATE["claims"][bid] = base_claim
|
|
1048
|
+
_save_locked()
|
|
1049
|
+
_notify("🐛🔍 认领禅道 Bug #%s(%s问题·%s):%s\n修复任务:%s"
|
|
1050
|
+
% (bid, tri["side"], "由规则" if tri.get("by") == "rule" else "AI 判定",
|
|
1051
|
+
bug.get("title") or "", "、".join(t["task_id"] for t in tasks)))
|
|
1052
|
+
return "fixing"
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _scan(cfg):
|
|
1056
|
+
"""按产品档案逐个拉 bug:认领新 bug + 重试 need_manual 的存量。返回认领数。"""
|
|
1057
|
+
claimed = 0
|
|
1058
|
+
profiles = _profiles(cfg)
|
|
1059
|
+
if not profiles:
|
|
1060
|
+
raise ZenError("未配置产品档案——请先在设置页添加产品并配置仓库")
|
|
1061
|
+
for profile in profiles:
|
|
1062
|
+
bugs = list_bugs(cfg, profile["product"])
|
|
1063
|
+
by_id = {str(b.get("id") or ""): b for b in bugs}
|
|
1064
|
+
with _LOCK:
|
|
1065
|
+
seen = set(_STATE["claims"].keys())
|
|
1066
|
+
retry_ids = [k for k, c in _STATE["claims"].items()
|
|
1067
|
+
if c.get("state") == "need_manual" and k in by_id
|
|
1068
|
+
and str(by_id[k].get("status") or "") == "active"]
|
|
1069
|
+
for bug in bugs:
|
|
1070
|
+
bid = str(bug.get("id") or "")
|
|
1071
|
+
if not bid or bid in seen or not _claimable(bug, profile):
|
|
1072
|
+
continue
|
|
1073
|
+
_route_one(bug, profile, cfg, seen)
|
|
1074
|
+
claimed += 1
|
|
1075
|
+
# need_manual 重排查(bug 仍激活才出现在列表里)
|
|
1076
|
+
for bid in retry_ids:
|
|
1077
|
+
with _LOCK:
|
|
1078
|
+
if _STATE["claims"].get(bid, {}).get("state") != "need_manual":
|
|
1079
|
+
continue
|
|
1080
|
+
_route_one(by_id[bid], profile, cfg, set())
|
|
1081
|
+
return claimed
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _poll(force=False):
|
|
1085
|
+
"""一次完整轮询:对账回写 + (到点/强制时)扫描认领。异常不外抛。"""
|
|
1086
|
+
_ensure_loaded()
|
|
1087
|
+
with _LOCK:
|
|
1088
|
+
cfg = _cfg()
|
|
1089
|
+
out = {"ok": True, "claimed": 0, "reconciled": True, "error": ""}
|
|
1090
|
+
try:
|
|
1091
|
+
_reconcile(cfg)
|
|
1092
|
+
except Exception:
|
|
1093
|
+
log.exception("zentao: 对账异常")
|
|
1094
|
+
if not force:
|
|
1095
|
+
if not cfg.get("poll_enabled"):
|
|
1096
|
+
return {"ok": True, "claimed": 0, "reconciled": True, "error": "",
|
|
1097
|
+
"skipped": "poll_disabled"}
|
|
1098
|
+
with _LOCK:
|
|
1099
|
+
nxt = _STATE.get("next_scan") or ""
|
|
1100
|
+
if nxt:
|
|
1101
|
+
try:
|
|
1102
|
+
if datetime.fromisoformat(nxt) > datetime.now():
|
|
1103
|
+
return {"ok": True, "claimed": 0, "reconciled": True,
|
|
1104
|
+
"error": "", "skipped": "not_due"}
|
|
1105
|
+
except ValueError:
|
|
1106
|
+
pass
|
|
1107
|
+
err = ""
|
|
1108
|
+
try:
|
|
1109
|
+
out["claimed"] = _scan(cfg)
|
|
1110
|
+
except ZenError as e:
|
|
1111
|
+
err = str(e)
|
|
1112
|
+
except Exception as e:
|
|
1113
|
+
err = "扫描失败:%s" % e
|
|
1114
|
+
log.warning("zentao: %s", err)
|
|
1115
|
+
now = datetime.now()
|
|
1116
|
+
delay = timedelta(hours=max(INTERVAL_MIN, int(cfg.get("interval_hours") or 2)))
|
|
1117
|
+
if err:
|
|
1118
|
+
delay = timedelta(minutes=RETRY_DELAY_MIN)
|
|
1119
|
+
with _LOCK:
|
|
1120
|
+
_STATE["last_scan"] = now.strftime("%Y-%m-%d %H:%M:%S")
|
|
1121
|
+
_STATE["next_scan"] = (now + delay).strftime("%Y-%m-%d %H:%M:%S")
|
|
1122
|
+
_STATE["last_error"] = err
|
|
1123
|
+
_save_locked()
|
|
1124
|
+
out["error"] = err
|
|
1125
|
+
out["ok"] = not err
|
|
1126
|
+
return out
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def fire_due():
|
|
1130
|
+
"""automation._tick 每拍调用:内部自节流,没到点/未启用时零开销返回。"""
|
|
1131
|
+
_ensure_loaded()
|
|
1132
|
+
with _LOCK:
|
|
1133
|
+
if not (_STATE.get("config") or {}).get("poll_enabled"):
|
|
1134
|
+
return None
|
|
1135
|
+
return _poll(force=False)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def scan_now():
|
|
1139
|
+
"""手动「立即扫描」:绕过轮询闸与节流。"""
|
|
1140
|
+
return _poll(force=True)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def start():
|
|
1144
|
+
"""服务启动接线:加载状态(含老配置迁移)。"""
|
|
1145
|
+
n = load()
|
|
1146
|
+
with _LOCK:
|
|
1147
|
+
cfg = _STATE.get("config") or {}
|
|
1148
|
+
if cfg.get("poll_enabled") and not _STATE.get("next_scan"):
|
|
1149
|
+
_STATE["next_scan"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
1150
|
+
_save_locked()
|
|
1151
|
+
return n
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
# ---------------------------------------------------------------- 配置管理
|
|
1155
|
+
|
|
1156
|
+
def _norm_profile(p, idx):
|
|
1157
|
+
if not isinstance(p, dict):
|
|
1158
|
+
raise ValueError("产品档案 #%d 必须是对象" % idx)
|
|
1159
|
+
try:
|
|
1160
|
+
pid = int(p.get("product") or 0)
|
|
1161
|
+
except (TypeError, ValueError):
|
|
1162
|
+
raise ValueError("产品档案 #%d 的产品 ID 必须是整数" % idx)
|
|
1163
|
+
if pid <= 0:
|
|
1164
|
+
raise ValueError("产品档案 #%d 缺产品 ID" % idx)
|
|
1165
|
+
prof = dict(_PROFILE_DEFAULTS)
|
|
1166
|
+
prof["product"] = pid
|
|
1167
|
+
prof["assigned_to"] = str(p.get("assigned_to") or "").strip()
|
|
1168
|
+
try:
|
|
1169
|
+
prof["severity_cap"] = max(0, min(4, int(p.get("severity_cap") or 0)))
|
|
1170
|
+
except (TypeError, ValueError):
|
|
1171
|
+
prof["severity_cap"] = 0
|
|
1172
|
+
our = [s for s in SIDES if s in (p.get("our_sides") or [])]
|
|
1173
|
+
prof["our_sides"] = our
|
|
1174
|
+
repos = {"backend": dict(_REPO_DEFAULTS), "frontend": dict(_REPO_DEFAULTS)}
|
|
1175
|
+
raw_repos = p.get("repos") if isinstance(p.get("repos"), dict) else {}
|
|
1176
|
+
for side in SIDES:
|
|
1177
|
+
r = raw_repos.get(side) if isinstance(raw_repos.get(side), dict) else {}
|
|
1178
|
+
wd = str(r.get("workdir") or "").strip()
|
|
1179
|
+
if wd:
|
|
1180
|
+
rp = Path(wd).expanduser()
|
|
1181
|
+
if not rp.is_absolute():
|
|
1182
|
+
raise ValueError("产品 %d 的%s工作目录必须是绝对路径" % (pid, SIDE_CN[side]))
|
|
1183
|
+
wd = str(rp)
|
|
1184
|
+
repos[side] = {"workdir": wd,
|
|
1185
|
+
"git_rev": str(r.get("git_rev") or "").strip(),
|
|
1186
|
+
"verify_command": str(r.get("verify_command") or "").strip()}
|
|
1187
|
+
# 我方端必须配仓库(our_sides 可为空=纯路由;但配了端没配目录到扫描时才暴露,
|
|
1188
|
+
# 这里只拦「配置了 our_sides 却两个仓库目录都没有」的明显失误)
|
|
1189
|
+
if our and not any(repos[s]["workdir"] for s in our):
|
|
1190
|
+
raise ValueError("产品 %d:我方端已勾选但没有配置任何仓库工作目录" % pid)
|
|
1191
|
+
prof["repos"] = repos
|
|
1192
|
+
hints = {}
|
|
1193
|
+
raw_hints = p.get("repo_hints") if isinstance(p.get("repo_hints"), dict) else {}
|
|
1194
|
+
for side in SIDES:
|
|
1195
|
+
hints[side] = str(raw_hints.get(side) or "").strip()
|
|
1196
|
+
prof["repo_hints"] = hints
|
|
1197
|
+
owners = {}
|
|
1198
|
+
raw_owners = p.get("owners") if isinstance(p.get("owners"), dict) else {}
|
|
1199
|
+
for k in ("backend", "frontend", "not_ours"):
|
|
1200
|
+
owners[k] = str(raw_owners.get(k) or "").strip()
|
|
1201
|
+
prof["owners"] = owners
|
|
1202
|
+
routes = []
|
|
1203
|
+
for r in (p.get("module_routes") or []):
|
|
1204
|
+
if not isinstance(r, dict):
|
|
1205
|
+
continue
|
|
1206
|
+
try:
|
|
1207
|
+
mid = int(r.get("module") or 0)
|
|
1208
|
+
except (TypeError, ValueError):
|
|
1209
|
+
raise ValueError("产品 %d 的模块路由:模块 ID 必须是整数" % pid)
|
|
1210
|
+
if mid <= 0:
|
|
1211
|
+
continue
|
|
1212
|
+
side = str(r.get("side") or "")
|
|
1213
|
+
if side not in TRIAGE_SIDES:
|
|
1214
|
+
raise ValueError("产品 %d 的模块路由 side 必须是 %s 之一"
|
|
1215
|
+
% (pid, "/".join(TRIAGE_SIDES)))
|
|
1216
|
+
routes.append({"module": mid, "side": side,
|
|
1217
|
+
"account": str(r.get("account") or "").strip()})
|
|
1218
|
+
prof["module_routes"] = routes
|
|
1219
|
+
return prof
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _norm_profiles(v):
|
|
1223
|
+
if v is None:
|
|
1224
|
+
return None
|
|
1225
|
+
if not isinstance(v, list):
|
|
1226
|
+
raise ValueError("product_profiles 必须是数组")
|
|
1227
|
+
out, seen = [], set()
|
|
1228
|
+
for i, p in enumerate(v):
|
|
1229
|
+
prof = _norm_profile(p, i)
|
|
1230
|
+
if prof["product"] in seen:
|
|
1231
|
+
raise ValueError("产品 %d 配置重复" % prof["product"])
|
|
1232
|
+
seen.add(prof["product"])
|
|
1233
|
+
out.append(prof)
|
|
1234
|
+
return out
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def save_config(patch):
|
|
1238
|
+
"""部分更新配置(password 缺省或空串=不改;product_profiles 整体替换)。
|
|
1239
|
+
校验失败抛 ValueError。返回脱敏视图的 config。"""
|
|
1240
|
+
_ensure_loaded()
|
|
1241
|
+
patch = patch if isinstance(patch, dict) else {}
|
|
1242
|
+
with _LOCK:
|
|
1243
|
+
cfg = _cfg()
|
|
1244
|
+
for k in _UPDATABLE:
|
|
1245
|
+
if k not in patch:
|
|
1246
|
+
continue
|
|
1247
|
+
v = patch[k]
|
|
1248
|
+
if k == "base_url":
|
|
1249
|
+
v = str(v or "").strip().rstrip("/")
|
|
1250
|
+
if v and not v.startswith(("http://", "https://")):
|
|
1251
|
+
raise ValueError("禅道地址必须以 http:// 或 https:// 开头")
|
|
1252
|
+
elif k == "account":
|
|
1253
|
+
v = str(v or "").strip()
|
|
1254
|
+
elif k == "password":
|
|
1255
|
+
v = str(v or "")
|
|
1256
|
+
if not v:
|
|
1257
|
+
continue
|
|
1258
|
+
elif k == "product_profiles":
|
|
1259
|
+
v = _norm_profiles(v)
|
|
1260
|
+
if v is None:
|
|
1261
|
+
continue
|
|
1262
|
+
elif k == "interval_hours":
|
|
1263
|
+
try:
|
|
1264
|
+
v = max(INTERVAL_MIN, min(INTERVAL_MAX, int(v)))
|
|
1265
|
+
except (TypeError, ValueError):
|
|
1266
|
+
raise ValueError("interval_hours 必须是 %d-%d 的整数"
|
|
1267
|
+
% (INTERVAL_MIN, INTERVAL_MAX))
|
|
1268
|
+
elif k in ("auto_resolve", "auto_merge", "triage_ai", "poll_enabled"):
|
|
1269
|
+
v = bool(v)
|
|
1270
|
+
cfg[k] = v
|
|
1271
|
+
_STATE["config"] = cfg
|
|
1272
|
+
if cfg.get("poll_enabled"):
|
|
1273
|
+
_STATE["next_scan"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
1274
|
+
_save_locked()
|
|
1275
|
+
return view()["config"]
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def view():
|
|
1279
|
+
"""前端视图:配置脱敏(password 只回是否已设)+ claims 列表(新在前)。"""
|
|
1280
|
+
_ensure_loaded()
|
|
1281
|
+
with _LOCK:
|
|
1282
|
+
cfg = dict(_cfg())
|
|
1283
|
+
has_pw = bool(cfg.get("password"))
|
|
1284
|
+
cfg["password"] = ""
|
|
1285
|
+
cfg["has_password"] = has_pw
|
|
1286
|
+
claims = sorted(_STATE["claims"].values(),
|
|
1287
|
+
key=lambda c: str(c.get("claimed_at") or ""), reverse=True)
|
|
1288
|
+
return {"config": cfg,
|
|
1289
|
+
"claims": [dict(c) for c in claims],
|
|
1290
|
+
"last_scan": _STATE.get("last_scan") or "",
|
|
1291
|
+
"next_scan": _STATE.get("next_scan") or "",
|
|
1292
|
+
"last_error": _STATE.get("last_error") or ""}
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _test_reset():
|
|
1296
|
+
"""测试钩子:清空内存状态(配合重绑 _FILE + TUTTI_DATA 临时目录用)。"""
|
|
1297
|
+
with _LOCK:
|
|
1298
|
+
_STATE["config"] = dict(_CFG_DEFAULTS)
|
|
1299
|
+
_STATE["claims"] = {}
|
|
1300
|
+
_STATE["last_scan"] = _STATE["next_scan"] = _STATE["last_error"] = ""
|
|
1301
|
+
_reset_token()
|
|
1302
|
+
globals()["_LOADED"] = True
|