codebee 0.1.18 → 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.
@@ -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 _api_base(base_url):
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 _fetch_token(base_url, account, password):
318
- """获取新 token 并缓存。失败抛 ZenError。"""
319
- url = _guard_url(_api_base(base_url) + "/tokens")
320
- body = json.dumps({"account": str(account or ""), "password": str(password or "")},
321
- ensure_ascii=False).encode("utf-8")
322
- req = urllib.request.Request(url, data=body, method="POST", headers={
323
- "Content-Type": "application/json", "Accept": "application/json"})
324
- try:
325
- with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
326
- data = json.loads((r.read() or b"{}").decode("utf-8", "replace"))
327
- except urllib.error.HTTPError as e:
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
- detail = json.loads((e.read() or b"").decode("utf-8", "replace"))
330
- msg = (detail.get("error") or "") if isinstance(detail, dict) else ""
331
- except Exception:
332
- msg = ""
333
- if e.code in (401, 403):
334
- raise ZenError("禅道账号或密码不对(%s)%s" % (e.code, msg))
335
- raise ZenError("禅道返回 %s:%s" % (e.code, msg or "获取令牌失败"))
336
- except ZenError:
337
- raise
338
- except Exception as e:
339
- raise ZenError("连不上禅道(%s)——请检查地址与网络" % e)
340
- tok = ""
341
- if isinstance(data, dict):
342
- tok = str(data.get("token") or "")
343
- if not tok and isinstance(data.get("data"), dict):
344
- tok = str(data["data"].get("token") or "")
345
- if not tok:
346
- raise ZenError("禅道响应里没有 token——请确认版本 ≥15 且已开启 REST API")
347
- _TOKEN["v"] = tok
348
- _TOKEN["at"] = time.time()
349
- return tok
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
- return _fetch_token(cfg.get("base_url"), cfg.get("account"), cfg.get("password"))
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 兼容:新版返回 {account,...} 用户对象,老版直接是账号串。"""
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 = _api("GET", "/products/%s/bugs?page=%d&limit=%d" % (product_id, page, PAGE_LIMIT),
441
- cfg=cfg)
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 = _api("GET", "/products/%s/modules" % product_id, cfg=cfg)
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
- """连接测试:取 token + 拉第一个产品的 bug 列表(有档案时)。返回 (ok, 人话结果)。"""
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
- _fetch_token(base_url, account, password)
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({"base_url": base_url, "account": account, "password": password},
493
- profiles[0]["product"])
494
- return True, "连接成功,产品 %s 可访问(当前 %d 条 bug 在列表里)" % (
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, "令牌拿到了,但拉 bug 列表失败:%s" % e
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 = _api("GET", "/bugs/%s" % bug_id, cfg=cfg)
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 = _api("GET", "/bugs/%s" % bug_id, cfg=cfg)
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
- _api("POST", "/bugs/%s/resolve" % bug_id, cfg=cfg, body=body)
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
- _api("PUT", "/bugs/%s" % bug_id, cfg=cfg, body=body)
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