codebee 0.1.17 → 0.1.19
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 +22 -0
- package/README.md +4 -3
- package/app/core/automation.py +19 -0
- package/app/core/backup.py +470 -0
- package/app/core/bookmeta.py +4 -1
- package/app/core/builtin_agent.py +33 -1
- package/app/core/cleanup.py +303 -0
- package/app/core/flows.py +1 -1
- package/app/core/jobs.py +22 -4
- package/app/core/knowledge.py +393 -0
- package/app/core/manager.py +25 -1
- package/app/core/market.py +14 -2
- package/app/core/modelhub.py +16 -0
- package/app/core/pipeline.py +62 -9
- package/app/core/planner.py +22 -4
- package/app/core/publish/manager.py +80 -3
- package/app/core/runner.py +127 -7
- package/app/core/selfupdate.py +80 -38
- package/app/core/settings.py +32 -1
- package/app/core/skill_scan.py +86 -0
- package/app/core/store.py +62 -8
- package/app/core/wxdigest.py +710 -0
- package/app/core/zentao.py +516 -50
- package/app/main.py +276 -1
- package/app/pet.py +1323 -0
- package/app/pet_bee.png +0 -0
- package/app/pet_bee_robot.png +0 -0
- package/app/pick_dialog.py +34 -2
- package/app/ui/app.js +11063 -10267
- package/app/ui/i18n.js +194 -4
- package/app/ui/index.html +148 -1
- package/app/ui/style.css +160 -2
- package/package.json +1 -1
package/app/core/zentao.py
CHANGED
|
@@ -12,6 +12,10 @@ automation._tick(同 publish/auto.fire_due 模式),内部按 interval_hour
|
|
|
12
12
|
GET {base}/api.php/v1/bugs/{id} 单查(回写前确认状态防谎报)
|
|
13
13
|
POST {base}/api.php/v1/bugs/{id}/resolve {resolution, resolvedBuild, comment, assignedTo}
|
|
14
14
|
PUT {base}/api.php/v1/bugs/{id} {assignedTo, comment}(转派/失败说明)
|
|
15
|
+
GET {base}/api.php/v1/products|users 产品/账号清单(前端下拉辅助)
|
|
16
|
+
|
|
17
|
+
地址自适应:根路径 tokens 404 时自动试 {base}/zentao/api.php/v1(官方一键安装包
|
|
18
|
+
默认子路径部署),探测成功缓存在 _APIBASE(key=用户填的原始地址)。
|
|
15
19
|
|
|
16
20
|
产品档案(product_profiles):每产品一份 {指派过滤, 严重度, 我方端 our_sides,
|
|
17
21
|
后端/前端仓库(workdir/git_rev/verify_command), repo_hints, 负责人 owners,
|
|
@@ -36,6 +40,7 @@ backend | frontend | both | not_ours | unknown。
|
|
|
36
40
|
from __future__ import annotations
|
|
37
41
|
|
|
38
42
|
import html as _html
|
|
43
|
+
import hashlib
|
|
39
44
|
import ipaddress
|
|
40
45
|
import json
|
|
41
46
|
import logging
|
|
@@ -45,6 +50,7 @@ import socket
|
|
|
45
50
|
import threading
|
|
46
51
|
import time
|
|
47
52
|
import urllib.error
|
|
53
|
+
import urllib.parse
|
|
48
54
|
import urllib.request
|
|
49
55
|
from datetime import datetime, timedelta
|
|
50
56
|
from pathlib import Path
|
|
@@ -298,6 +304,14 @@ class ZenError(Exception):
|
|
|
298
304
|
|
|
299
305
|
|
|
300
306
|
_TOKEN = {"v": "", "at": 0.0}
|
|
307
|
+
# 原始地址 → 探测成功的 api 基址(一键安装包常部署在 /zentao 子路径下,自动补探)
|
|
308
|
+
_APIBASE = {}
|
|
309
|
+
# 通道自适应:原始地址 → "rest"(≥15 REST v1)/ "old"(老版 module-method JSON 接口)
|
|
310
|
+
_MODE = {}
|
|
311
|
+
_OLD = {"api": "", "sid": "", "at": 0.0}
|
|
312
|
+
_OLD_FORM = {"form": ""} # 命中的密码形态 "plain" | "md5chain",缓存避免重复试
|
|
313
|
+
_REST_LAST_ERR = {"msg": ""}
|
|
314
|
+
_OLD_TTL = 20 * 60
|
|
301
315
|
|
|
302
316
|
|
|
303
317
|
def _reset_token():
|
|
@@ -305,53 +319,94 @@ def _reset_token():
|
|
|
305
319
|
_TOKEN["at"] = 0.0
|
|
306
320
|
|
|
307
321
|
|
|
308
|
-
def
|
|
322
|
+
def resolved_base_url(base_url):
|
|
323
|
+
"""探测成功后的有效地基(含自动补出的子路径);没探测过返回空串。"""
|
|
324
|
+
b = str(base_url or "").strip().rstrip("/")
|
|
325
|
+
hit = _APIBASE.get(b) or (_OLD.get("api") if _MODE.get(b) == "old" else "")
|
|
326
|
+
if not hit:
|
|
327
|
+
return ""
|
|
328
|
+
return re.sub(r"/api\.php/v1$", "", hit)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _raw_base(base_url):
|
|
309
332
|
b = str(base_url or "").strip().rstrip("/")
|
|
310
333
|
if not b:
|
|
311
334
|
raise ZenError("禅道地址未配置")
|
|
312
335
|
if not b.startswith(("http://", "https://")):
|
|
313
336
|
raise ZenError("禅道地址必须以 http:// 或 https:// 开头")
|
|
337
|
+
return b
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _api_base(base_url):
|
|
341
|
+
b = _raw_base(base_url)
|
|
342
|
+
hit = _APIBASE.get(b)
|
|
343
|
+
if hit:
|
|
344
|
+
return hit
|
|
314
345
|
return b + "/api.php/v1"
|
|
315
346
|
|
|
316
347
|
|
|
317
|
-
def
|
|
318
|
-
"""
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
348
|
+
def _base_candidates(b):
|
|
349
|
+
"""api 基址候选:用户填的根路径 → 官方一键安装包常见的 /zentao 子路径。"""
|
|
350
|
+
out, seen = [], set()
|
|
351
|
+
for c in (b + "/api.php/v1", b + "/zentao/api.php/v1"):
|
|
352
|
+
if c not in seen:
|
|
353
|
+
seen.add(c)
|
|
354
|
+
out.append(c)
|
|
355
|
+
return out
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _rest_login(base_url, account, password):
|
|
359
|
+
"""REST v1 通道取 token。成功返回 token(缓存基址);
|
|
360
|
+
通道不可用(404 / 只认应用 code 的 200 信封)返回 None;
|
|
361
|
+
账密被 REST 明确拒绝(401/403)或网络/守卫失败抛 ZenError。"""
|
|
362
|
+
b = _raw_base(base_url)
|
|
363
|
+
for api in _base_candidates(b):
|
|
364
|
+
url = _guard_url(api + "/tokens")
|
|
365
|
+
body = json.dumps({"account": str(account or ""), "password": str(password or "")},
|
|
366
|
+
ensure_ascii=False).encode("utf-8")
|
|
367
|
+
req = urllib.request.Request(url, data=body, method="POST", headers={
|
|
368
|
+
"Content-Type": "application/json", "Accept": "application/json"})
|
|
328
369
|
try:
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
except
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
370
|
+
with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
|
|
371
|
+
data = json.loads((r.read() or b"{}").decode("utf-8", "replace"))
|
|
372
|
+
except urllib.error.HTTPError as e:
|
|
373
|
+
try:
|
|
374
|
+
detail = json.loads((e.read() or b"").decode("utf-8", "replace"))
|
|
375
|
+
msg = (detail.get("error") or "") if isinstance(detail, dict) else ""
|
|
376
|
+
except Exception:
|
|
377
|
+
msg = ""
|
|
378
|
+
if e.code in (401, 403):
|
|
379
|
+
raise ZenError("禅道账号或密码不对(%s)%s" % (e.code, msg))
|
|
380
|
+
if e.code == 404:
|
|
381
|
+
_REST_LAST_ERR["msg"] = "REST 接口不存在(404)"
|
|
382
|
+
continue # 换下一个候选基址
|
|
383
|
+
raise ZenError("禅道返回 %s:%s" % (e.code, msg or "获取令牌失败"))
|
|
384
|
+
except ZenError:
|
|
385
|
+
raise
|
|
386
|
+
except Exception as e:
|
|
387
|
+
raise ZenError("连不上禅道(%s)——请检查地址与网络" % e)
|
|
388
|
+
tok = ""
|
|
389
|
+
if isinstance(data, dict):
|
|
390
|
+
tok = str(data.get("token") or "")
|
|
391
|
+
if not tok and isinstance(data.get("data"), dict):
|
|
392
|
+
tok = str(data["data"].get("token") or "")
|
|
393
|
+
if tok:
|
|
394
|
+
_APIBASE[b] = api
|
|
395
|
+
_TOKEN["v"] = tok
|
|
396
|
+
_TOKEN["at"] = time.time()
|
|
397
|
+
return tok
|
|
398
|
+
# HTTP 200 但没有 token:REST 在但不认账密(如只认应用 code 的部署)
|
|
399
|
+
_REST_LAST_ERR["msg"] = "REST 接口不认账密(%s)" % (
|
|
400
|
+
str(data.get("errmsg") or data.get("error") or "响应里没有 token"))
|
|
401
|
+
return None
|
|
350
402
|
|
|
351
403
|
|
|
352
404
|
def _token(cfg, force=False):
|
|
353
405
|
if force or not _TOKEN["v"] or time.time() - _TOKEN["at"] > TOKEN_TTL:
|
|
354
|
-
|
|
406
|
+
tok = _rest_login(cfg.get("base_url"), cfg.get("account"), cfg.get("password"))
|
|
407
|
+
if not tok:
|
|
408
|
+
raise ZenError(_REST_LAST_ERR.get("msg") or "REST 通道不可用")
|
|
409
|
+
return tok
|
|
355
410
|
return _TOKEN["v"]
|
|
356
411
|
|
|
357
412
|
|
|
@@ -394,10 +449,317 @@ def _api(method, path, cfg=None, body=None):
|
|
|
394
449
|
raise ZenError("禅道认证失败(token 两次获取后仍被拒绝,检查账号权限)")
|
|
395
450
|
|
|
396
451
|
|
|
452
|
+
# ---------------------------------------------------------------- 老版 JSON 接口适配
|
|
453
|
+
# 禅道 15 以前的经典入口:/zentao/api-getsessionid.json 取会话 → user-login.json
|
|
454
|
+
# 账密登录(Cookie zentaosid)→ {module}-{method}-{参数}.json 调业务。响应是双层
|
|
455
|
+
# 信封 {"status","data":"<json 字符串>"}。有的部署 REST 只认应用 code,账密只能
|
|
456
|
+
# 走这条通道,故在 _call 里自动探测分路。REST 路径在这里翻译成老接口形态。
|
|
457
|
+
|
|
458
|
+
def _old_parse(raw):
|
|
459
|
+
"""老信封解析。非 JSON(多半是登录重定向 HTML)返回 None = 会话死/不可用。"""
|
|
460
|
+
try:
|
|
461
|
+
outer = json.loads(raw.decode("utf-8", "replace"))
|
|
462
|
+
except Exception:
|
|
463
|
+
return None
|
|
464
|
+
if isinstance(outer, dict) and isinstance(outer.get("data"), str):
|
|
465
|
+
try:
|
|
466
|
+
outer["data"] = json.loads(outer["data"])
|
|
467
|
+
except Exception:
|
|
468
|
+
pass
|
|
469
|
+
return outer if isinstance(outer, dict) else None
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _old_fail(outer):
|
|
473
|
+
"""status=failed / result=fail → 人话错误文本;成功返回空串。"""
|
|
474
|
+
if not isinstance(outer, dict):
|
|
475
|
+
return ""
|
|
476
|
+
if outer.get("status") == "failed" or outer.get("result") == "fail":
|
|
477
|
+
return str(outer.get("reason") or outer.get("message")
|
|
478
|
+
or outer.get("error") or "调用失败")
|
|
479
|
+
return ""
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _old_get(api, path):
|
|
483
|
+
url = _guard_url(api + path)
|
|
484
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
485
|
+
with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
|
|
486
|
+
return r.read() or b""
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _old_post(api, path, form):
|
|
490
|
+
url = _guard_url(api + path)
|
|
491
|
+
data = urllib.parse.urlencode(form or {}).encode("utf-8")
|
|
492
|
+
req = urllib.request.Request(url, data=data, method="POST", headers={
|
|
493
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
494
|
+
"Accept": "application/json"})
|
|
495
|
+
with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
|
|
496
|
+
return r.read() or b""
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _old_session(api):
|
|
500
|
+
"""取新会话。返回 (sid, rand);通道不可用返回 None。"""
|
|
501
|
+
try:
|
|
502
|
+
raw = _old_get(api, "/api-getsessionid.json")
|
|
503
|
+
except urllib.error.HTTPError:
|
|
504
|
+
return None
|
|
505
|
+
except ZenError:
|
|
506
|
+
raise
|
|
507
|
+
except Exception as e:
|
|
508
|
+
raise ZenError("连不上禅道(%s)——请检查地址与网络" % e)
|
|
509
|
+
outer = _old_parse(raw)
|
|
510
|
+
if not outer or not isinstance(outer.get("data"), dict):
|
|
511
|
+
return None
|
|
512
|
+
d = outer["data"]
|
|
513
|
+
return str(d.get("sessionID") or ""), str(d.get("rand") or "")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _old_probe_authed(api, sid):
|
|
517
|
+
"""登录成功的兜底判据:带会话调需登录端点拿得到 JSON 信封(未登录会被
|
|
518
|
+
重定向到登录页 HTML,解析出来是 None)。"""
|
|
519
|
+
try:
|
|
520
|
+
raw = _old_get(api, "/my-index.json?zentaosid=" + sid)
|
|
521
|
+
except Exception:
|
|
522
|
+
return False
|
|
523
|
+
return _old_parse(raw) is not None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _old_identify(api, sid, rand, account, password, form):
|
|
527
|
+
"""老接口账密登录。返回 (ok, 人话错误)。form=plain|md5chain。"""
|
|
528
|
+
pw = str(password or "")
|
|
529
|
+
if form == "md5chain":
|
|
530
|
+
pw = hashlib.md5((hashlib.md5(pw.encode("utf-8")).hexdigest()
|
|
531
|
+
+ str(rand)).encode("utf-8")).hexdigest()
|
|
532
|
+
try:
|
|
533
|
+
raw = _old_post(api, "/user-login.json?zentaosid=" + sid,
|
|
534
|
+
{"account": str(account or ""), "password": pw})
|
|
535
|
+
except Exception as e:
|
|
536
|
+
return False, "连不上禅道(%s)" % e
|
|
537
|
+
outer = _old_parse(raw)
|
|
538
|
+
if outer is None:
|
|
539
|
+
return False, "登录响应不可识别"
|
|
540
|
+
err = _old_fail(outer)
|
|
541
|
+
if err:
|
|
542
|
+
return False, err
|
|
543
|
+
# 成功响应形状各版本不一:user 可能在 data 里(老)或顶层(实测某老版部署)
|
|
544
|
+
d = outer.get("data")
|
|
545
|
+
if (isinstance(d, dict) and isinstance(d.get("user"), dict)) \
|
|
546
|
+
or isinstance(outer.get("user"), dict) or _old_probe_authed(api, sid):
|
|
547
|
+
return True, ""
|
|
548
|
+
return False, "登录未被接受(账号或密码不对,或账号被锁)"
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _old_login(cfg, rest_err=""):
|
|
552
|
+
"""老接口通道登录(带 /zentao 子路径候选)。成功置 _OLD 缓存与密码形态。"""
|
|
553
|
+
b = _raw_base(cfg.get("base_url"))
|
|
554
|
+
account, password = cfg.get("account"), cfg.get("password")
|
|
555
|
+
cands, seen = [], set()
|
|
556
|
+
for c in (b + "/zentao", b):
|
|
557
|
+
if c not in seen:
|
|
558
|
+
seen.add(c)
|
|
559
|
+
cands.append(c)
|
|
560
|
+
last = ""
|
|
561
|
+
for api in cands:
|
|
562
|
+
sess = _old_session(api)
|
|
563
|
+
if not sess:
|
|
564
|
+
continue
|
|
565
|
+
sid, rand = sess
|
|
566
|
+
forms = [_OLD_FORM["form"] or "plain", "md5chain", "plain"]
|
|
567
|
+
tried = set()
|
|
568
|
+
for form in [f for f in forms if not (f in tried or tried.add(f))]:
|
|
569
|
+
ok, err = _old_identify(api, sid, rand, account, password, form)
|
|
570
|
+
if ok:
|
|
571
|
+
_OLD.update(api=api, sid=sid, at=time.time())
|
|
572
|
+
_OLD_FORM["form"] = form
|
|
573
|
+
_MODE[b] = "old"
|
|
574
|
+
return
|
|
575
|
+
last = err
|
|
576
|
+
break # 同一台服务,账密结果与子路径无关,别再烧尝试次数
|
|
577
|
+
if not last:
|
|
578
|
+
raise ZenError("禅道老版接口不可用(会话接口无响应——地址若是子目录部署要带上子目录)"
|
|
579
|
+
+ (";%s" % rest_err if rest_err else ""))
|
|
580
|
+
if "账号" in last or "密码" in last or "锁定" in last:
|
|
581
|
+
raise ZenError(last)
|
|
582
|
+
raise ZenError("禅道老接口登录失败:%s" % last)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _old_call(method, path, cfg, body=None):
|
|
586
|
+
"""老接口执行:翻译 REST 路径 → module-method.json,返回 REST 同形状的 dict。"""
|
|
587
|
+
b = _raw_base(cfg.get("base_url"))
|
|
588
|
+
if _MODE.get(b) != "old" or not _OLD["sid"] or time.time() - _OLD["at"] > _OLD_TTL:
|
|
589
|
+
_old_login(cfg)
|
|
590
|
+
parts = urllib.parse.urlsplit(path)
|
|
591
|
+
q = dict(urllib.parse.parse_qsl(parts.query))
|
|
592
|
+
p = parts.path
|
|
593
|
+
for attempt in (1, 2):
|
|
594
|
+
try:
|
|
595
|
+
return _old_route(_OLD["api"], method, p, q, body)
|
|
596
|
+
except _OldSessionDead:
|
|
597
|
+
if attempt == 2:
|
|
598
|
+
break
|
|
599
|
+
_OLD["sid"] = ""
|
|
600
|
+
_old_login(cfg)
|
|
601
|
+
except urllib.error.HTTPError as e:
|
|
602
|
+
raise ZenError("禅道老接口 %s(%s)调用失败" % (p, e.code))
|
|
603
|
+
raise ZenError("禅道会话两次登录后仍失效(检查账号权限)")
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
class _OldSessionDead(Exception):
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _pairs_to_list(pairs, id_key, name_key):
|
|
611
|
+
"""{id/name: 值} 形态的 pairs → [{id,name}] 列表(值可能为串或对象)。"""
|
|
612
|
+
out = []
|
|
613
|
+
for k, v in (pairs or {}).items():
|
|
614
|
+
if isinstance(v, dict):
|
|
615
|
+
item = dict(v)
|
|
616
|
+
item.setdefault(id_key, k)
|
|
617
|
+
else:
|
|
618
|
+
item = {id_key: k, name_key: v}
|
|
619
|
+
out.append(item)
|
|
620
|
+
return out
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _old_route(api, method, p, q, body):
|
|
624
|
+
"""路径翻译 + 请求 + 归一。会话死抛 _OldSessionDead。"""
|
|
625
|
+
|
|
626
|
+
def go(raw, translator=None):
|
|
627
|
+
outer = _old_parse(raw)
|
|
628
|
+
if outer is None:
|
|
629
|
+
txt = raw.decode("utf-8", "replace")
|
|
630
|
+
if "user-login" in txt:
|
|
631
|
+
raise _OldSessionDead() # 登录重定向 = 会话死
|
|
632
|
+
# 其余 HTML 是 js::locate/alert 回包(老禅道 POST 动作成功就回这种)
|
|
633
|
+
return {"_js": True}
|
|
634
|
+
err = _old_fail(outer)
|
|
635
|
+
if err:
|
|
636
|
+
raise ZenError("禅道老接口%s:%s" % (translator or "", err))
|
|
637
|
+
return outer.get("data")
|
|
638
|
+
|
|
639
|
+
def get(path):
|
|
640
|
+
try:
|
|
641
|
+
raw = _old_get(api, path + ("&" if "?" in path else "?")
|
|
642
|
+
+ "zentaosid=" + _OLD["sid"])
|
|
643
|
+
except urllib.error.HTTPError as e:
|
|
644
|
+
raw = e.read() or b""
|
|
645
|
+
if _old_parse(raw) is None:
|
|
646
|
+
raise _OldSessionDead() # 非 JSON:多半被重定向到登录页
|
|
647
|
+
return raw
|
|
648
|
+
|
|
649
|
+
def post(path, form):
|
|
650
|
+
try:
|
|
651
|
+
raw = _old_post(api, path + ("&" if "?" in path else "?")
|
|
652
|
+
+ "zentaosid=" + _OLD["sid"], form)
|
|
653
|
+
except urllib.error.HTTPError as e:
|
|
654
|
+
raw = e.read() or b""
|
|
655
|
+
if e.code in (401, 403) or _old_parse(raw) is None:
|
|
656
|
+
raise _OldSessionDead()
|
|
657
|
+
return raw
|
|
658
|
+
|
|
659
|
+
m = re.match(r"^/products/(\d+)/bugs$", p)
|
|
660
|
+
if m and method == "GET":
|
|
661
|
+
pid = m.group(1)
|
|
662
|
+
page = max(1, int(q.get("page") or 1))
|
|
663
|
+
out, total, seen_ids = [], 0, set()
|
|
664
|
+
# 翻页用路径参数形态(实测部分老版不认 query 翻页参数):带 branch 段;
|
|
665
|
+
# 若 recPerPage 没生效(老版本无 branch 段会错位解析)回落 query 形态。
|
|
666
|
+
branch_form = True
|
|
667
|
+
for pg in range(page, page + 6): # 去重兜底:翻页参数不被认时不会死循环
|
|
668
|
+
if branch_form:
|
|
669
|
+
pathq = "/bug-browse-%s-0-unclosed-0-id_desc-0-%d-%d.json" % (pid, PAGE_LIMIT, pg)
|
|
670
|
+
else:
|
|
671
|
+
pathq = "/bug-browse-%s.json?browseType=unclosed&orderBy=id_desc" \
|
|
672
|
+
"&recPerPage=%d&pagerID=%d" % (pid, PAGE_LIMIT, pg)
|
|
673
|
+
d = go(get(pathq), "(拉 bug 列表)")
|
|
674
|
+
if branch_form and isinstance(d, dict):
|
|
675
|
+
try:
|
|
676
|
+
got = int((d.get("pager") or {}).get("recPerPage") or 0)
|
|
677
|
+
except (TypeError, ValueError):
|
|
678
|
+
got = 0
|
|
679
|
+
if got != PAGE_LIMIT:
|
|
680
|
+
branch_form = False
|
|
681
|
+
continue # 形态没对上:换 query 形态重拉本页
|
|
682
|
+
if not isinstance(d, dict):
|
|
683
|
+
break
|
|
684
|
+
bugs = d.get("bugs") if isinstance(d.get("bugs"), list) else []
|
|
685
|
+
fresh = [x for x in bugs if isinstance(x, dict)
|
|
686
|
+
and str(x.get("id")) not in seen_ids]
|
|
687
|
+
for x in fresh:
|
|
688
|
+
seen_ids.add(str(x.get("id")))
|
|
689
|
+
out.extend(fresh)
|
|
690
|
+
try:
|
|
691
|
+
total = int((d.get("pager") or {}).get("recTotal") or 0)
|
|
692
|
+
except (TypeError, ValueError, AttributeError):
|
|
693
|
+
total = 0
|
|
694
|
+
if not fresh or (total and len(out) >= total) or len(out) >= MAX_BUGS:
|
|
695
|
+
break
|
|
696
|
+
return {"bugs": out[:MAX_BUGS], "total": total or len(out)}
|
|
697
|
+
|
|
698
|
+
m = re.match(r"^/products/(\d+)/modules$", p)
|
|
699
|
+
if m and method == "GET":
|
|
700
|
+
d = go(get("/tree-browse-%s-module.json" % m.group(1)), "(拉模块清单)")
|
|
701
|
+
items = []
|
|
702
|
+
if isinstance(d, dict):
|
|
703
|
+
items = d.get("sons") or d.get("modules") or []
|
|
704
|
+
elif isinstance(d, list):
|
|
705
|
+
items = d
|
|
706
|
+
return {"_list": [x for x in items if isinstance(x, dict) and x.get("id")]}
|
|
707
|
+
|
|
708
|
+
if p == "/products" and method == "GET":
|
|
709
|
+
d = go(get("/api-getmodel-product-getpairs.json"), "(拉产品清单)")
|
|
710
|
+
pairs = d.get("products") if isinstance(d, dict) and isinstance(d.get("products"), dict) else d
|
|
711
|
+
return {"_list": _pairs_to_list(pairs, "id", "name")}
|
|
712
|
+
|
|
713
|
+
if p == "/users" and method == "GET":
|
|
714
|
+
d = go(get("/api-getmodel-user-getpairs.json"), "(拉账号清单)")
|
|
715
|
+
return {"_list": _pairs_to_list(d, "account", "realname")}
|
|
716
|
+
|
|
717
|
+
m = re.match(r"^/bugs/(\d+)$", p)
|
|
718
|
+
if m and method == "GET":
|
|
719
|
+
d = go(get("/bug-view-%s.json" % m.group(1)), "(查 bug)")
|
|
720
|
+
return d.get("bug") if isinstance(d, dict) and isinstance(d.get("bug"), dict) else d
|
|
721
|
+
|
|
722
|
+
m = re.match(r"^/bugs/(\d+)/resolve$", p)
|
|
723
|
+
if m and method == "POST":
|
|
724
|
+
form = {k: str(v or "") for k, v in (body or {}).items()}
|
|
725
|
+
go(post("/bug-resolve-%s.json" % m.group(1), form), "(resolve)")
|
|
726
|
+
return {"ok": True}
|
|
727
|
+
|
|
728
|
+
m = re.match(r"^/bugs/(\d+)$", p)
|
|
729
|
+
if m and method == "PUT":
|
|
730
|
+
form = {k: str(v or "") for k, v in (body or {}).items() if k in ("assignedTo", "comment")}
|
|
731
|
+
go(post("/bug-assignTo-%s.json" % m.group(1), form), "(转派)")
|
|
732
|
+
return {"ok": True}
|
|
733
|
+
|
|
734
|
+
raise ZenError("禅道老接口不认识该调用:%s %s(请升级禅道到 ≥15 用 REST 接口)"
|
|
735
|
+
% (method, p))
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _call(method, path, cfg=None, body=None):
|
|
739
|
+
"""统一入口:REST v1 优先;通道探明后按模式分发(账密不被 REST 接受时自动
|
|
740
|
+
切老版 JSON 接口)。所有业务调用都走这里,拿到的都是 REST 形状。"""
|
|
741
|
+
c = cfg or _cfg()
|
|
742
|
+
b = _raw_base(c.get("base_url"))
|
|
743
|
+
mode = _MODE.get(b)
|
|
744
|
+
if not mode:
|
|
745
|
+
tok = _rest_login(b, c.get("account"), c.get("password"))
|
|
746
|
+
if tok:
|
|
747
|
+
_MODE[b] = mode = "rest"
|
|
748
|
+
else:
|
|
749
|
+
_old_login(c, _REST_LAST_ERR.get("msg", ""))
|
|
750
|
+
mode = "old"
|
|
751
|
+
if mode == "rest":
|
|
752
|
+
return _api(method, path, cfg=c, body=body)
|
|
753
|
+
return _old_call(method, path, c, body=body)
|
|
754
|
+
|
|
755
|
+
|
|
397
756
|
def _acct(v):
|
|
398
|
-
"""assignedTo/openedBy
|
|
757
|
+
"""assignedTo/openedBy 兼容:新版是 {account,...} 用户对象,老版可能是
|
|
758
|
+
[账号, 姓名] 数组或账号串。"""
|
|
399
759
|
if isinstance(v, dict):
|
|
400
760
|
return str(v.get("account") or "").strip()
|
|
761
|
+
if isinstance(v, (list, tuple)) and v:
|
|
762
|
+
return str(v[0] or "").strip()
|
|
401
763
|
return str(v or "").strip()
|
|
402
764
|
|
|
403
765
|
|
|
@@ -437,8 +799,8 @@ def list_bugs(cfg, product_id):
|
|
|
437
799
|
"""拉一个产品下的 bug(分页,总量封顶 MAX_BUGS)。返回原始 bug dict 列表。"""
|
|
438
800
|
out = []
|
|
439
801
|
for page in range(1, 6):
|
|
440
|
-
d =
|
|
441
|
-
|
|
802
|
+
d = _call("GET", "/products/%s/bugs?page=%d&limit=%d" % (product_id, page, PAGE_LIMIT),
|
|
803
|
+
cfg=cfg)
|
|
442
804
|
bugs = d.get("bugs") if isinstance(d, dict) else None
|
|
443
805
|
if not isinstance(bugs, list):
|
|
444
806
|
raise ZenError("禅道 bug 列表响应形状不对(预期 bugs 数组)")
|
|
@@ -458,7 +820,7 @@ def fetch_modules(product_id):
|
|
|
458
820
|
_ensure_loaded()
|
|
459
821
|
cfg = _cfg()
|
|
460
822
|
try:
|
|
461
|
-
d =
|
|
823
|
+
d = _call("GET", "/products/%s/modules" % product_id, cfg=cfg)
|
|
462
824
|
except ZenError as e:
|
|
463
825
|
return {"ok": False,
|
|
464
826
|
"error": "%s——也可能你的禅道没有该接口:请在禅道产品视图 URL 里查模块 ID 手工填写" % e}
|
|
@@ -474,28 +836,127 @@ def fetch_modules(product_id):
|
|
|
474
836
|
return {"ok": True, "modules": out[:200]}
|
|
475
837
|
|
|
476
838
|
|
|
839
|
+
def _list_items(d, key):
|
|
840
|
+
"""禅道 REST v1 清单响应形状兼容:{key:[...]} / 裸数组(_list) / {id:{...}} 字典。"""
|
|
841
|
+
if not isinstance(d, dict):
|
|
842
|
+
return None
|
|
843
|
+
if isinstance(d.get(key), list):
|
|
844
|
+
return d[key]
|
|
845
|
+
if isinstance(d.get("_list"), list):
|
|
846
|
+
return d["_list"]
|
|
847
|
+
vals = [v for v in d.values() if isinstance(v, dict) and v.get("id")]
|
|
848
|
+
return vals or None
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def fetch_products():
|
|
852
|
+
"""拉产品清单(产品 ID 下拉选择辅助)。接口不存在/失败返回 ok:False+人话。"""
|
|
853
|
+
_ensure_loaded()
|
|
854
|
+
cfg = _cfg()
|
|
855
|
+
out, total = [], 0
|
|
856
|
+
try:
|
|
857
|
+
page = 1
|
|
858
|
+
while page <= 3:
|
|
859
|
+
d = _call("GET", "/products?page=%d&limit=100" % page, cfg=cfg)
|
|
860
|
+
items = _list_items(d, "products")
|
|
861
|
+
if not items:
|
|
862
|
+
break
|
|
863
|
+
out.extend(it for it in items
|
|
864
|
+
if isinstance(it, dict) and str(it.get("id") or "") != "")
|
|
865
|
+
try:
|
|
866
|
+
total = int(d.get("total") or 0)
|
|
867
|
+
except (TypeError, ValueError, AttributeError):
|
|
868
|
+
total = 0
|
|
869
|
+
if not total or len(out) >= total:
|
|
870
|
+
break
|
|
871
|
+
page += 1
|
|
872
|
+
except ZenError as e:
|
|
873
|
+
return {"ok": False, "error": "%s——请先「测试连接」确认地址与账号可用" % e}
|
|
874
|
+
if not out:
|
|
875
|
+
return {"ok": False, "error": "禅道里没有产品,或响应形状不认识(REST API 需 ≥15)"}
|
|
876
|
+
seen, uniq = set(), []
|
|
877
|
+
for m in out:
|
|
878
|
+
try:
|
|
879
|
+
pid = int(m["id"])
|
|
880
|
+
except (TypeError, ValueError):
|
|
881
|
+
pid = m["id"]
|
|
882
|
+
if pid in seen:
|
|
883
|
+
continue
|
|
884
|
+
seen.add(pid)
|
|
885
|
+
uniq.append({"id": pid, "name": str(m.get("name") or ""),
|
|
886
|
+
"status": str(m.get("status") or "")})
|
|
887
|
+
uniq.sort(key=lambda x: str(x["id"]))
|
|
888
|
+
return {"ok": True, "products": uniq[:500]}
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def fetch_users():
|
|
892
|
+
"""拉禅道账号清单(负责人/转派下拉辅助)。失败返回 ok:False,前端仍可手填。"""
|
|
893
|
+
_ensure_loaded()
|
|
894
|
+
cfg = _cfg()
|
|
895
|
+
try:
|
|
896
|
+
d = _call("GET", "/users?limit=1000", cfg=cfg)
|
|
897
|
+
except ZenError as e:
|
|
898
|
+
return {"ok": False, "error": "%s——账号清单拉不到,仍可手填账号" % e}
|
|
899
|
+
items = None
|
|
900
|
+
if isinstance(d, dict):
|
|
901
|
+
if isinstance(d.get("users"), list):
|
|
902
|
+
items = d["users"]
|
|
903
|
+
elif isinstance(d.get("_list"), list):
|
|
904
|
+
items = d["_list"]
|
|
905
|
+
else:
|
|
906
|
+
items = []
|
|
907
|
+
for k, v in d.items(): # {账号: 用户} 字典形状
|
|
908
|
+
if not isinstance(v, dict):
|
|
909
|
+
continue
|
|
910
|
+
u = dict(v)
|
|
911
|
+
u.setdefault("account", str(k))
|
|
912
|
+
items.append(u)
|
|
913
|
+
out, seen = [], set()
|
|
914
|
+
for u in items or []:
|
|
915
|
+
if not isinstance(u, dict):
|
|
916
|
+
continue
|
|
917
|
+
acct = str(u.get("account") or "").strip()
|
|
918
|
+
if not acct or acct in seen:
|
|
919
|
+
continue
|
|
920
|
+
seen.add(acct)
|
|
921
|
+
out.append({"account": acct, "realname": str(u.get("realname") or "")})
|
|
922
|
+
if not out:
|
|
923
|
+
return {"ok": False, "error": "账号清单为空或响应形状不认识——仍可手填账号"}
|
|
924
|
+
out.sort(key=lambda x: x["account"])
|
|
925
|
+
return {"ok": True, "users": out[:500]}
|
|
926
|
+
|
|
927
|
+
|
|
477
928
|
def test_connection(base_url=None, account=None, password=None):
|
|
478
|
-
"""
|
|
929
|
+
"""连接测试:探明通道登录 + 拉第一个产品的 bug 列表(有档案时)。返回 (ok, 人话结果)。"""
|
|
479
930
|
c = _cfg()
|
|
480
931
|
base_url = str(base_url if base_url is not None else c.get("base_url") or "").strip()
|
|
481
932
|
account = str(account if account is not None else c.get("account") or "").strip()
|
|
482
933
|
password = str(password if password is not None else c.get("password") or "").strip()
|
|
483
934
|
if not (base_url and account and password):
|
|
484
935
|
return False, "地址、账号、密码都要填全"
|
|
936
|
+
b = base_url.strip().rstrip("/")
|
|
937
|
+
probe = {"base_url": b, "account": account, "password": password}
|
|
485
938
|
try:
|
|
486
|
-
|
|
939
|
+
tok = _rest_login(b, account, password)
|
|
487
940
|
except ZenError as e:
|
|
488
941
|
return False, str(e)
|
|
942
|
+
if tok:
|
|
943
|
+
_MODE[b] = "rest"
|
|
944
|
+
else:
|
|
945
|
+
try:
|
|
946
|
+
_old_login(probe, _REST_LAST_ERR.get("msg", ""))
|
|
947
|
+
_MODE[b] = "old"
|
|
948
|
+
except ZenError as e:
|
|
949
|
+
return False, str(e)
|
|
950
|
+
tag = "老版 JSON 接口" if _MODE.get(b) == "old" else "REST v1"
|
|
489
951
|
try:
|
|
490
952
|
profiles = _profiles(c)
|
|
491
953
|
if profiles:
|
|
492
|
-
bugs = list_bugs(
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
profiles[0]["product"], len(bugs))
|
|
954
|
+
bugs = list_bugs(probe, profiles[0]["product"])
|
|
955
|
+
return True, "连接成功(%s),产品 %s 可访问(当前 %d 条 bug 在列表里)" % (
|
|
956
|
+
tag, profiles[0]["product"], len(bugs))
|
|
496
957
|
except ZenError as e:
|
|
497
|
-
return False, "
|
|
498
|
-
return True, "
|
|
958
|
+
return False, "登录成功(%s),但拉 bug 列表失败:%s" % (tag, e)
|
|
959
|
+
return True, "连接成功(%s;未配产品档案,跳过列表探测)" % tag
|
|
499
960
|
|
|
500
961
|
|
|
501
962
|
# ---------------------------------------------------------------- 排查(triage)
|
|
@@ -727,7 +1188,7 @@ def _fail_text(claim, failed_tasks, runs):
|
|
|
727
1188
|
|
|
728
1189
|
def _bug_opened_by(cfg, bug_id):
|
|
729
1190
|
try:
|
|
730
|
-
d =
|
|
1191
|
+
d = _call("GET", "/bugs/%s" % bug_id, cfg=cfg)
|
|
731
1192
|
return _acct(d.get("openedBy"))
|
|
732
1193
|
except ZenError:
|
|
733
1194
|
return ""
|
|
@@ -735,13 +1196,13 @@ def _bug_opened_by(cfg, bug_id):
|
|
|
735
1196
|
|
|
736
1197
|
def _ensure_resolved(cfg, bug_id, comment, assign_to):
|
|
737
1198
|
"""resolve(幂等):已是 resolved/closed 视为成功。"""
|
|
738
|
-
cur =
|
|
1199
|
+
cur = _call("GET", "/bugs/%s" % bug_id, cfg=cfg)
|
|
739
1200
|
if str(cur.get("status") or "") in ("resolved", "closed"):
|
|
740
1201
|
return True
|
|
741
1202
|
body = {"resolution": "fixed", "resolvedBuild": "trunk", "comment": comment}
|
|
742
1203
|
if assign_to:
|
|
743
1204
|
body["assignedTo"] = assign_to
|
|
744
|
-
|
|
1205
|
+
_call("POST", "/bugs/%s/resolve" % bug_id, cfg=cfg, body=body)
|
|
745
1206
|
return True
|
|
746
1207
|
|
|
747
1208
|
|
|
@@ -750,7 +1211,7 @@ def _transfer(cfg, bug_id, target, comment):
|
|
|
750
1211
|
body = {"comment": comment}
|
|
751
1212
|
if target:
|
|
752
1213
|
body["assignedTo"] = target
|
|
753
|
-
|
|
1214
|
+
_call("PUT", "/bugs/%s" % bug_id, cfg=cfg, body=body)
|
|
754
1215
|
|
|
755
1216
|
|
|
756
1217
|
def _notify(text):
|
|
@@ -1299,4 +1760,9 @@ def _test_reset():
|
|
|
1299
1760
|
_STATE["claims"] = {}
|
|
1300
1761
|
_STATE["last_scan"] = _STATE["next_scan"] = _STATE["last_error"] = ""
|
|
1301
1762
|
_reset_token()
|
|
1763
|
+
_APIBASE.clear()
|
|
1764
|
+
_MODE.clear()
|
|
1765
|
+
_OLD.update(api="", sid="", at=0.0)
|
|
1766
|
+
_OLD_FORM["form"] = ""
|
|
1767
|
+
_REST_LAST_ERR["msg"] = ""
|
|
1302
1768
|
globals()["_LOADED"] = True
|