codebee 0.1.62 → 0.1.63

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +499 -490
  2. package/LICENSE +21 -21
  3. package/README.md +484 -486
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/aiflavor.py +197 -197
  12. package/app/core/attachments.py +532 -532
  13. package/app/core/automation.py +758 -758
  14. package/app/core/backup.py +470 -470
  15. package/app/core/beekeeper.py +183 -183
  16. package/app/core/board.py +209 -209
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +1191 -1191
  20. package/app/core/capability.py +144 -144
  21. package/app/core/catalog.py +19 -14
  22. package/app/core/chaptersafe.py +32 -32
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +186 -186
  25. package/app/core/covergen.py +356 -356
  26. package/app/core/diagnostics.py +114 -114
  27. package/app/core/dispatch.py +173 -173
  28. package/app/core/dispatch_log.py +113 -113
  29. package/app/core/env_scrub.py +85 -85
  30. package/app/core/error_codes.py +64 -64
  31. package/app/core/errorlog.py +137 -137
  32. package/app/core/flows.py +355 -355
  33. package/app/core/goal_service.py +167 -167
  34. package/app/core/health.py +341 -341
  35. package/app/core/history.py +38 -38
  36. package/app/core/hooks.py +156 -156
  37. package/app/core/jobs.py +1022 -1022
  38. package/app/core/manager.py +1996 -1996
  39. package/app/core/market.py +362 -362
  40. package/app/core/market_remote.py +66 -66
  41. package/app/core/mocks.py +64 -64
  42. package/app/core/modelhub.py +3266 -3266
  43. package/app/core/notify.py +106 -106
  44. package/app/core/paihang.py +109 -109
  45. package/app/core/paths.py +2 -2
  46. package/app/core/pipeline.py +4444 -4426
  47. package/app/core/planner.py +27 -27
  48. package/app/core/portguard.py +111 -111
  49. package/app/core/preview.py +293 -293
  50. package/app/core/publish/__init__.py +7 -7
  51. package/app/core/publish/auto.py +367 -367
  52. package/app/core/publish/browser.py +533 -533
  53. package/app/core/publish/fanqie.py +128 -128
  54. package/app/core/publish/flow.py +406 -406
  55. package/app/core/publish/ledger.py +198 -198
  56. package/app/core/publish/manager.py +590 -590
  57. package/app/core/publish/qimao.py +141 -141
  58. package/app/core/publish/ws.py +139 -139
  59. package/app/core/redact.py +38 -38
  60. package/app/core/repeat_guard.py +124 -124
  61. package/app/core/router.py +20 -3
  62. package/app/core/runner.py +2014 -1991
  63. package/app/core/selfupdate.py +384 -384
  64. package/app/core/session_log.py +161 -161
  65. package/app/core/sessions.py +312 -312
  66. package/app/core/settings.py +149 -149
  67. package/app/core/settings_schema.py +262 -262
  68. package/app/core/share_page.py +114 -114
  69. package/app/core/skill_scan.py +106 -106
  70. package/app/core/skillpacks/fanqie-novel.md +80 -80
  71. package/app/core/skillpacks/market/character-bible.md +66 -66
  72. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  73. package/app/core/skillpacks/market/git-workflow.md +57 -57
  74. package/app/core/skillpacks/market/release-notes.md +72 -72
  75. package/app/core/skillpacks/market/weekly-report.md +71 -71
  76. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  77. package/app/core/step_runner.py +132 -132
  78. package/app/core/task_compile.py +199 -199
  79. package/app/core/telemetry.py +292 -292
  80. package/app/core/tlsctx.py +64 -64
  81. package/app/core/token_meter.py +147 -147
  82. package/app/core/volumes.py +355 -355
  83. package/app/core/wxdigest.py +858 -858
  84. package/app/core/zentao.py +2090 -2090
  85. package/app/main.py +2826 -2826
  86. package/app/pick_dialog.py +110 -110
  87. package/app/ui/app.js +13977 -13977
  88. package/app/ui/board.html +162 -162
  89. package/app/ui/board.js +305 -305
  90. package/app/ui/i18n.js +2711 -2711
  91. package/app/ui/icons/bee.svg +79 -79
  92. package/app/ui/index.html +1 -1
  93. package/app/ui/manifest.json +16 -16
  94. package/app/ui/qrcode.js +2297 -2297
  95. package/app/ui/style.css +6125 -6125
  96. package/bin/tutti.js +147 -147
  97. package/package.json +41 -41
  98. package/tools/wx_reader.py +261 -261
@@ -1,2090 +1,2090 @@
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_minutes 节流。
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
- GET {base}/api.php/v1/products|users 产品/账号清单(前端下拉辅助)
16
-
17
- 地址自适应:根路径 tokens 404 时自动试 {base}/zentao/api.php/v1(官方一键安装包
18
- 默认子路径部署),探测成功缓存在 _APIBASE(key=用户填的原始地址)。
19
-
20
- 产品档案(product_profiles):每产品一份 {指派过滤, 严重度, 我方端 our_sides,
21
- 后端/前端仓库(workdir/git_rev/verify_command), repo_hints, 负责人 owners,
22
- 模块路由 module_routes}。老版扁平配置(products+单仓库)load 时自动迁移。
23
-
24
- 排查(_triage):模块路由按 bug.module 精确匹配优先 → AI 兜底(triage_ai 开时
25
- modelhub 单次调用,bookmeta 同款配方)→ unknown。判定 side ∈
26
- backend | frontend | both | not_ours | unknown。
27
-
28
- 流转规则(转派目标只认排查结论,与 bug 当前 assignedTo 无关——测试提错人也
29
- 照样改派):
30
- 我方端问题 → 建修复任务;成功=合并+resolve(fixed)+报告评论+指回报告人
31
- 双端/我方一端 → 我方端修完(合并落库)后转派另一端负责人+评论,不 resolve
32
- 纯对方端问题 → 不建任务,直接转派该端负责人+排查结论评论
33
- 非我方 → 转派报告人(或 owners.not_ours)+评论;只转派不解决
34
- unknown → 不碰 bug,need_manual + 群通知(下轮扫描 bug 仍激活则重排查)
35
- 修复任务失败 → 评论尝试记录 + 转派该端负责人(模块路由 account > 端负责人)
36
-
37
- 落库纪律:修复任务落单即带基线(档案配了用档案,没配取工作目录当前 HEAD)
38
- 走任务分支隔离,修完的代码自动提交在任务分支上,对账合并后才算落库;
39
- resolve 前再验一遍——没基线的任务改动只在工作区,有未提交的已跟踪改动
40
- 不 resolve(转 done_manual 人工收口),auto_merge 关闭同理。
41
- 对账(_reconcile)认任务最新一次运行:人工重试换 run 不影响回写判断。
42
-
43
- 出网边界(SSRF 防护,_guard_url):请求目标来自用户自配禅道地址——内网按设计
44
- 放行;强制 http(s)、解析主机并阻断云元数据/链路本地地址、禁跟随重定向。
45
- """
46
- from __future__ import annotations
47
-
48
- import html as _html
49
- import hashlib
50
- import ipaddress
51
- import json
52
- import logging
53
- import os
54
- import re
55
- import socket
56
- import threading
57
- import time
58
- import urllib.error
59
- import urllib.parse
60
- import urllib.request
61
- from datetime import datetime, timedelta
62
- from pathlib import Path
63
-
64
- from . import jobs, paths, settings, store, tlsctx
65
-
66
- log = logging.getLogger(__name__)
67
-
68
- _LOCK = threading.RLock()
69
- _SCAN_LOCK = threading.Lock() # 手动/定时扫描单飞,避免重复拉取和重复认领
70
- _FILE = paths.DATA_DIR / "zentao.json"
71
- _STATE = {
72
- "config": {}, # 持久配置(_CFG_DEFAULTS)
73
- "claims": {}, # str(bug_id) → claim dict(v2:含 triage/tasks)
74
- "last_scan": "",
75
- "next_scan": "",
76
- "last_error": "",
77
- }
78
- _LOADED = False
79
- _BOOT_TIMER = None # 启动补对账的兜底 Timer(测试里要能拿到并取消)
80
-
81
- TOKEN_TTL = 23 * 3600
82
- HTTP_TIMEOUT = 15
83
- PAGE_LIMIT = 100
84
- MAX_BUGS = 500
85
- RESOLVE_MAX_ATTEMPTS = 3
86
- RETRY_DELAY_MIN = 30
87
- INTERVAL_MIN, INTERVAL_MAX = 5, 10080 # 扫描间隔(分钟):5 分钟 ~ 7 天
88
- INTERVAL_DEFAULT = 5
89
- _LEGACY_INTERVAL_HOURS_DEFAULT = 2 # 老配置 interval_hours 缺省值(迁移用)
90
-
91
- SIDES = ("backend", "frontend")
92
- TRIAGE_SIDES = ("backend", "frontend", "both", "not_ours") # unknown 单列
93
- SIDE_CN = {"backend": "后端", "frontend": "前端"}
94
-
95
- _CFG_DEFAULTS = {
96
- "base_url": "",
97
- "account": "",
98
- "password": "",
99
- "product_profiles": [], # 产品档案列表(_norm_profile 形状)
100
- "auto_resolve": True,
101
- "auto_merge": True,
102
- "triage_ai": True, # 模块路由未命中时用 AI 兜底排查
103
- "poll_enabled": False,
104
- "interval_minutes": INTERVAL_DEFAULT,
105
- }
106
-
107
- _REPO_DEFAULTS = {"workdir": "", "git_rev": "", "verify_command": ""}
108
-
109
- _PROFILE_DEFAULTS = {
110
- "product": 0, # 产品 ID(必填唯一)
111
- "assigned_to": "", # 只认领指派给该账号的 bug;空=不按指派过滤
112
- "severity_cap": 0, # 严重度上限(1 最严重);0=不限
113
- "our_sides": ["backend"], # 我方端(CodeBee 自动修);空=纯排查转派不修
114
- "repos": {"backend": dict(_REPO_DEFAULTS), "frontend": dict(_REPO_DEFAULTS)},
115
- "repo_hints": {"backend": "", "frontend": ""}, # AI 排查时的一句仓库描述
116
- "owners": {"backend": "", "frontend": "", "not_ours": ""},
117
- "module_routes": [], # [{module:int, side:backend|frontend|both|not_ours, account:""}]
118
- }
119
-
120
- _UPDATABLE = ("base_url", "account", "password", "product_profiles",
121
- "auto_resolve", "auto_merge", "triage_ai",
122
- "poll_enabled", "interval_minutes")
123
-
124
-
125
- # ---------------------------------------------------------------- 出网边界(SSRF)
126
-
127
- _META_HOSTS = {"metadata.google.internal", "metadata.goog"}
128
- _NO_REDIRECT = None
129
-
130
-
131
- def _no_redirect_opener():
132
- """HTTP 重定向一概不跟:目标必须就是用户配置的那台禅道。"""
133
- global _NO_REDIRECT
134
- if _NO_REDIRECT is None:
135
- class _Stop(urllib.request.HTTPRedirectHandler):
136
- def redirect_request(self, req, fp, code, msg, headers, newurl):
137
- return None
138
- _NO_REDIRECT = urllib.request.build_opener(
139
- _Stop(), urllib.request.HTTPSHandler(context=tlsctx.context()))
140
- return _NO_REDIRECT
141
-
142
-
143
- def _guard_url(url):
144
- """出网前的边界校验:协议白名单 + 主机解析阻断云元数据/链路本地地址。
145
-
146
- 私网与环回按设计放行(禅道自建在内网是主场景,配置者即本机用户)。
147
- 校验失败抛 ZenError。返回原 url。
148
- """
149
- from urllib.parse import urlparse
150
- u = urlparse(url)
151
- if u.scheme not in ("http", "https"):
152
- raise ZenError("禅道地址只允许 http/https 协议")
153
- host = (u.hostname or "").lower()
154
- if not host:
155
- raise ZenError("禅道地址缺少主机名")
156
- if host in _META_HOSTS:
157
- raise ZenError("不允许访问云元数据地址")
158
- try:
159
- infos = socket.getaddrinfo(host, None)
160
- except OSError:
161
- raise ZenError("禅道主机解析失败:%s" % host)
162
- for info in infos:
163
- ip = ipaddress.ip_address(info[4][0])
164
- if ip.is_link_local or ip.is_reserved and not ip.is_private:
165
- raise ZenError("不允许访问链路本地/保留地址:%s" % ip)
166
- if ip.is_multicast or ip.is_unspecified:
167
- raise ZenError("不允许访问多播/未指定地址:%s" % ip)
168
- return url
169
-
170
-
171
- # ---------------------------------------------------------------- 持久化
172
-
173
- def _save_locked():
174
- _FILE.parent.mkdir(parents=True, exist_ok=True)
175
- tmp = _FILE.with_suffix(".tmp")
176
- tmp.write_text(json.dumps({"version": 2, **_STATE},
177
- ensure_ascii=False, indent=2), encoding="utf-8")
178
- os.replace(str(tmp), str(_FILE))
179
-
180
-
181
- def _normalize_claim(d):
182
- """claim 归一:v1 单任务形状(task_id/run_id)→ v2 tasks 数组。"""
183
- c = dict(d) if isinstance(d, dict) else {}
184
- if isinstance(c.get("tasks"), list) and c["tasks"]:
185
- tasks = [dict(t) for t in c["tasks"] if isinstance(t, dict)]
186
- else:
187
- tasks = [{"side": "backend", "task_id": c.get("task_id") or "",
188
- "run_id": c.get("run_id") or "", "state": "fixing"}] \
189
- if (c.get("task_id") or c.get("run_id")) else []
190
- c["tasks"] = tasks
191
- c.setdefault("triage", {"side": "", "reason": "", "by": "", "account": ""})
192
- if not isinstance(c.get("triage"), dict):
193
- c["triage"] = {"side": "", "reason": "", "by": "", "account": ""}
194
- c.setdefault("note", "")
195
- c.setdefault("attempts", 0)
196
- return c
197
-
198
-
199
- def _migrate_legacy(cfg):
200
- """老扁平配置(products+单仓库)→ product_profiles。就地改写返回。"""
201
- if cfg.get("product_profiles"):
202
- return cfg
203
- products = cfg.get("products") or []
204
- if not isinstance(products, list) or not products:
205
- return cfg
206
- try:
207
- pids = [int(p) for p in products if str(p).strip()]
208
- except (TypeError, ValueError):
209
- return cfg
210
- backend = dict(_REPO_DEFAULTS)
211
- for k in _REPO_DEFAULTS:
212
- backend[k] = str(cfg.get(k) or "")
213
- our = ["backend"]
214
- if not backend["workdir"]:
215
- our = [] # 老配置连工作目录都没配:纯路由
216
- for pid in pids:
217
- cfg.setdefault("product_profiles", []).append({
218
- "product": pid,
219
- "assigned_to": str(cfg.get("assigned_to") or ""),
220
- "severity_cap": int(cfg.get("severity_cap") or 0),
221
- "our_sides": list(our),
222
- "repos": {"backend": backend, "frontend": dict(_REPO_DEFAULTS)},
223
- "repo_hints": {"backend": "", "frontend": ""},
224
- "owners": {"backend": "", "frontend": "", "not_ours": ""},
225
- "module_routes": [],
226
- })
227
- return cfg
228
-
229
-
230
- def load(force=False):
231
- """从磁盘加载状态(幂等)。返回 claims 数。测试可重绑 _FILE 后 force=True。"""
232
- global _LOADED
233
- with _LOCK:
234
- if _LOADED and not force:
235
- return len(_STATE["claims"])
236
- try:
237
- data = json.loads(_FILE.read_text(encoding="utf-8"))
238
- except Exception:
239
- data = {}
240
- cfg = data.get("config") if isinstance(data, dict) else None
241
- # 老扁平配置先迁移成产品档案(老键 products/workdir/... 不在新 defaults 里,
242
- # 必须在按 _CFG_DEFAULTS 过滤之前完成迁移)
243
- if isinstance(cfg, dict) and not cfg.get("product_profiles"):
244
- cfg = _migrate_legacy(dict(cfg))
245
- # 老配置用 interval_hours(小时),新配置改用 interval_minutes(分钟):
246
- # 用户没动过间隔(还是老默认 2 小时)就跟随新默认 5 分钟;真改过的按小时换算保留。
247
- if isinstance(cfg, dict) and "interval_minutes" not in cfg \
248
- and "interval_hours" in cfg:
249
- try:
250
- hours = int(cfg.get("interval_hours") or _LEGACY_INTERVAL_HOURS_DEFAULT)
251
- except (TypeError, ValueError):
252
- hours = _LEGACY_INTERVAL_HOURS_DEFAULT
253
- cfg = dict(cfg)
254
- mins = INTERVAL_DEFAULT if hours == _LEGACY_INTERVAL_HOURS_DEFAULT else hours * 60
255
- cfg["interval_minutes"] = max(INTERVAL_MIN, min(INTERVAL_MAX, mins))
256
- merged = dict(_CFG_DEFAULTS)
257
- if isinstance(cfg, dict):
258
- merged.update({k: v for k, v in cfg.items() if k in _CFG_DEFAULTS})
259
- claims = data.get("claims") if isinstance(data, dict) else None
260
- _STATE["config"] = merged
261
- _STATE["claims"] = {str(k): _normalize_claim(v)
262
- for k, v in (claims or {}).items()} \
263
- if isinstance(claims, dict) else {}
264
- _STATE["last_scan"] = str(data.get("last_scan") or "") if isinstance(data, dict) else ""
265
- _STATE["next_scan"] = str(data.get("next_scan") or "") if isinstance(data, dict) else ""
266
- _STATE["last_error"] = str(data.get("last_error") or "") if isinstance(data, dict) else ""
267
- _LOADED = True
268
- return len(_STATE["claims"])
269
-
270
-
271
- def _ensure_loaded():
272
- if not _LOADED:
273
- load()
274
-
275
-
276
- def _cfg():
277
- cfg = dict(_CFG_DEFAULTS)
278
- cfg.update({k: v for k, v in (_STATE.get("config") or {}).items()
279
- if k in _CFG_DEFAULTS})
280
- return cfg
281
-
282
-
283
- def _profiles(cfg=None):
284
- out = []
285
- for p in (cfg or _cfg()).get("product_profiles") or []:
286
- prof = dict(_PROFILE_DEFAULTS)
287
- prof.update({k: v for k, v in (p or {}).items() if k in _PROFILE_DEFAULTS})
288
- repos = dict(_PROFILE_DEFAULTS["repos"])
289
- for side in SIDES:
290
- r = dict(_REPO_DEFAULTS)
291
- r.update({k: v for k, v in ((prof.get("repos") or {}).get(side) or {}).items()
292
- if k in _REPO_DEFAULTS})
293
- repos[side] = r
294
- prof["repos"] = repos
295
- hints = dict(_PROFILE_DEFAULTS["repo_hints"])
296
- for side in SIDES:
297
- hints[side] = str((prof.get("repo_hints") or {}).get(side) or "")
298
- prof["repo_hints"] = hints
299
- owners = dict(_PROFILE_DEFAULTS["owners"])
300
- for k in owners:
301
- owners[k] = str((prof.get("owners") or {}).get(k) or "").strip()
302
- prof["owners"] = owners
303
- prof["our_sides"] = [s for s in SIDES if s in (prof.get("our_sides") or [])]
304
- prof["module_routes"] = [r for r in (prof.get("module_routes") or [])
305
- if isinstance(r, dict)]
306
- out.append(prof)
307
- return out
308
-
309
-
310
- def _profile_for(cfg, product_id):
311
- try:
312
- pid = int(product_id or 0)
313
- except (TypeError, ValueError):
314
- return None
315
- for p in _profiles(cfg):
316
- if p["product"] == pid:
317
- return p
318
- return None
319
-
320
-
321
- # ---------------------------------------------------------------- 禅道客户端
322
-
323
- class ZenError(Exception):
324
- """禅道接口错误(message 人话,可直接展示)。"""
325
-
326
-
327
- _TOKEN = {"v": "", "at": 0.0}
328
- # 原始地址 → 探测成功的 api 基址(一键安装包常部署在 /zentao 子路径下,自动补探)
329
- _APIBASE = {}
330
- # 通道自适应:原始地址 → "rest"(≥15 REST v1)/ "old"(老版 module-method JSON 接口)
331
- _MODE = {}
332
- # web 端路由形态:原始地址 → "pathinfo"(伪静态 bug-view-N.html,官方默认)
333
- # / "get"(index.php?m=bug&f=view&bugID=N)。bug 详情链接用(2026-09-23 真机实证
334
- # 伪静态部署对 GET 式链接不路由)。
335
- _WEBSTYLE = {}
336
- _OLD = {"api": "", "sid": "", "at": 0.0}
337
- _OLD_FORM = {"form": ""} # 命中的密码形态 "plain" | "md5chain",缓存避免重复试
338
- _REST_LAST_ERR = {"msg": ""}
339
- _OLD_TTL = 20 * 60
340
-
341
-
342
- def _reset_token():
343
- _TOKEN["v"] = ""
344
- _TOKEN["at"] = 0.0
345
-
346
-
347
- def resolved_base_url(base_url):
348
- """探测成功后的有效地基(含自动补出的子路径);没探测过返回空串。"""
349
- b = str(base_url or "").strip().rstrip("/")
350
- hit = _APIBASE.get(b) or (_OLD.get("api") if _MODE.get(b) == "old" else "")
351
- if not hit:
352
- return ""
353
- return re.sub(r"/api\.php/v1$", "", hit)
354
-
355
-
356
- def probe_web_style(base_url):
357
- """探测禅道 web 端路由形态(bug 详情链接用):GET web 根(裸根 + 常见 /zentao
358
- 子路径都试),看登录跳转里是伪静态路由(user-login-xxx.html)还是 GET 式
359
- (index.php?m=user)。尽力而为:任何网络/守卫异常回空串,绝不影响登录与
360
- 扫描主流程;探出即缓存。"""
361
- try:
362
- b = _raw_base(base_url)
363
- except ZenError:
364
- return ""
365
- if _WEBSTYLE.get(b):
366
- return _WEBSTYLE[b]
367
- style = ""
368
- for root in (b + "/", b + "/zentao/"):
369
- body = ""
370
- try:
371
- req = urllib.request.Request(_guard_url(root), headers={"Accept": "text/html"})
372
- with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
373
- body = (r.read(65536) or b"").decode("utf-8", "replace")
374
- except Exception:
375
- body = ""
376
- if "user-login-" in body or re.search(r"[a-z]+-view-\d+[^\"]*\.html", body):
377
- style = "pathinfo"
378
- elif "index.php?m=" in body:
379
- style = "get"
380
- if style:
381
- break
382
- if style:
383
- _WEBSTYLE[b] = style
384
- return style
385
-
386
-
387
- def _raw_base(base_url):
388
- b = str(base_url or "").strip().rstrip("/")
389
- if not b:
390
- raise ZenError("禅道地址未配置")
391
- if not b.startswith(("http://", "https://")):
392
- raise ZenError("禅道地址必须以 http:// 或 https:// 开头")
393
- return b
394
-
395
-
396
- def _api_base(base_url):
397
- b = _raw_base(base_url)
398
- hit = _APIBASE.get(b)
399
- if hit:
400
- return hit
401
- return b + "/api.php/v1"
402
-
403
-
404
- def _base_candidates(b):
405
- """api 基址候选:用户填的根路径 → 官方一键安装包常见的 /zentao 子路径。"""
406
- out, seen = [], set()
407
- for c in (b + "/api.php/v1", b + "/zentao/api.php/v1"):
408
- if c not in seen:
409
- seen.add(c)
410
- out.append(c)
411
- return out
412
-
413
-
414
- def _rest_login(base_url, account, password):
415
- """REST v1 通道取 token。成功返回 token(缓存基址);
416
- 通道不可用(404 / 只认应用 code 的 200 信封)返回 None;
417
- 账密被 REST 明确拒绝(401/403)或网络/守卫失败抛 ZenError。"""
418
- b = _raw_base(base_url)
419
- for api in _base_candidates(b):
420
- url = _guard_url(api + "/tokens")
421
- body = json.dumps({"account": str(account or ""), "password": str(password or "")},
422
- ensure_ascii=False).encode("utf-8")
423
- req = urllib.request.Request(url, data=body, method="POST", headers={
424
- "Content-Type": "application/json", "Accept": "application/json"})
425
- try:
426
- with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
427
- data = json.loads((r.read() or b"{}").decode("utf-8", "replace"))
428
- except urllib.error.HTTPError as e:
429
- try:
430
- detail = json.loads((e.read() or b"").decode("utf-8", "replace"))
431
- msg = (detail.get("error") or "") if isinstance(detail, dict) else ""
432
- except Exception:
433
- msg = ""
434
- if e.code in (401, 403):
435
- raise ZenError("禅道账号或密码不对(%s)%s" % (e.code, msg))
436
- if e.code == 404:
437
- _REST_LAST_ERR["msg"] = "REST 接口不存在(404)"
438
- continue # 换下一个候选基址
439
- raise ZenError("禅道返回 %s:%s" % (e.code, msg or "获取令牌失败"))
440
- except ZenError:
441
- raise
442
- except Exception as e:
443
- raise ZenError("连不上禅道(%s)——请检查地址与网络" % e)
444
- tok = ""
445
- if isinstance(data, dict):
446
- tok = str(data.get("token") or "")
447
- if not tok and isinstance(data.get("data"), dict):
448
- tok = str(data["data"].get("token") or "")
449
- if tok:
450
- _APIBASE[b] = api
451
- _TOKEN["v"] = tok
452
- _TOKEN["at"] = time.time()
453
- probe_web_style(b) # bug 链接形态探测,失败不影响登录
454
- return tok
455
- # HTTP 200 但没有 token:REST 在但不认账密(如只认应用 code 的部署)
456
- _REST_LAST_ERR["msg"] = "REST 接口不认账密(%s)" % (
457
- str(data.get("errmsg") or data.get("error") or "响应里没有 token"))
458
- return None
459
-
460
-
461
- def _token(cfg, force=False):
462
- if force or not _TOKEN["v"] or time.time() - _TOKEN["at"] > TOKEN_TTL:
463
- tok = _rest_login(cfg.get("base_url"), cfg.get("account"), cfg.get("password"))
464
- if not tok:
465
- raise ZenError(_REST_LAST_ERR.get("msg") or "REST 通道不可用")
466
- return tok
467
- return _TOKEN["v"]
468
-
469
-
470
- def _api(method, path, cfg=None, body=None):
471
- """调禅道 API。返回 dict(非 dict 响应返回 {})。401/403 自动重取 token 重试一次。"""
472
- c = cfg or _cfg()
473
- for attempt in (1, 2):
474
- headers = {"Accept": "application/json", "Token": _token(c, force=(attempt == 2))}
475
- url = _guard_url(_api_base(c.get("base_url")) + path)
476
- data = None
477
- if body is not None:
478
- data = json.dumps(body, ensure_ascii=False).encode("utf-8")
479
- headers["Content-Type"] = "application/json"
480
- req = urllib.request.Request(url, data=data, method=method, headers=headers)
481
- try:
482
- with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
483
- out = json.loads((r.read() or b"{}").decode("utf-8", "replace"))
484
- return out if isinstance(out, dict) else {"_list": out}
485
- except urllib.error.HTTPError as e:
486
- raw = ""
487
- try:
488
- raw = (e.read() or b"").decode("utf-8", "replace")
489
- except Exception:
490
- pass
491
- detail = ""
492
- try:
493
- d = json.loads(raw)
494
- if isinstance(d, dict):
495
- detail = str(d.get("error") or d.get("message") or "")
496
- except Exception:
497
- pass
498
- if e.code in (401, 403) and attempt == 1:
499
- _reset_token()
500
- continue
501
- raise ZenError("禅道接口 %s(%s)%s" % (path, e.code, detail or "调用失败"))
502
- except ZenError:
503
- raise
504
- except Exception as e:
505
- raise ZenError("连不上禅道(%s)" % e)
506
- raise ZenError("禅道认证失败(token 两次获取后仍被拒绝,检查账号权限)")
507
-
508
-
509
- # ---------------------------------------------------------------- 老版 JSON 接口适配
510
- # 禅道 15 以前的经典入口:/zentao/api-getsessionid.json 取会话 → user-login.json
511
- # 账密登录(Cookie zentaosid)→ {module}-{method}-{参数}.json 调业务。响应是双层
512
- # 信封 {"status","data":"<json 字符串>"}。有的部署 REST 只认应用 code,账密只能
513
- # 走这条通道,故在 _call 里自动探测分路。REST 路径在这里翻译成老接口形态。
514
-
515
- def _old_parse(raw):
516
- """老信封解析。非 JSON(多半是登录重定向 HTML)返回 None = 会话死/不可用。"""
517
- try:
518
- outer = json.loads(raw.decode("utf-8", "replace"))
519
- except Exception:
520
- return None
521
- if isinstance(outer, dict) and isinstance(outer.get("data"), str):
522
- try:
523
- outer["data"] = json.loads(outer["data"])
524
- except Exception:
525
- pass
526
- return outer if isinstance(outer, dict) else None
527
-
528
-
529
- def _old_fail(outer):
530
- """status=failed / result=fail → 人话错误文本;成功返回空串。"""
531
- if not isinstance(outer, dict):
532
- return ""
533
- if outer.get("status") == "failed" or outer.get("result") == "fail":
534
- return str(outer.get("reason") or outer.get("message")
535
- or outer.get("error") or "调用失败")
536
- return ""
537
-
538
-
539
- def _old_get(api, path):
540
- url = _guard_url(api + path)
541
- req = urllib.request.Request(url, headers={"Accept": "application/json"})
542
- with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
543
- return r.read() or b""
544
-
545
-
546
- def _old_post(api, path, form):
547
- url = _guard_url(api + path)
548
- data = urllib.parse.urlencode(form or {}).encode("utf-8")
549
- req = urllib.request.Request(url, data=data, method="POST", headers={
550
- "Content-Type": "application/x-www-form-urlencoded",
551
- "Accept": "application/json"})
552
- with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
553
- return r.read() or b""
554
-
555
-
556
- def _old_session(api):
557
- """取新会话。返回 (sid, rand);通道不可用返回 None。"""
558
- try:
559
- raw = _old_get(api, "/api-getsessionid.json")
560
- except urllib.error.HTTPError:
561
- return None
562
- except ZenError:
563
- raise
564
- except Exception as e:
565
- raise ZenError("连不上禅道(%s)——请检查地址与网络" % e)
566
- outer = _old_parse(raw)
567
- if not outer or not isinstance(outer.get("data"), dict):
568
- return None
569
- d = outer["data"]
570
- return str(d.get("sessionID") or ""), str(d.get("rand") or "")
571
-
572
-
573
- def _old_probe_authed(api, sid):
574
- """登录成功的兜底判据:带会话调需登录端点拿得到 JSON 信封(未登录会被
575
- 重定向到登录页 HTML,解析出来是 None)。"""
576
- try:
577
- raw = _old_get(api, "/my-index.json?zentaosid=" + sid)
578
- except Exception:
579
- return False
580
- return _old_parse(raw) is not None
581
-
582
-
583
- def _old_identify(api, sid, rand, account, password, form):
584
- """老接口账密登录。返回 (ok, 人话错误)。form=plain|md5chain。"""
585
- pw = str(password or "")
586
- if form == "md5chain":
587
- pw = hashlib.md5((hashlib.md5(pw.encode("utf-8")).hexdigest()
588
- + str(rand)).encode("utf-8")).hexdigest()
589
- try:
590
- raw = _old_post(api, "/user-login.json?zentaosid=" + sid,
591
- {"account": str(account or ""), "password": pw})
592
- except Exception as e:
593
- return False, "连不上禅道(%s)" % e
594
- outer = _old_parse(raw)
595
- if outer is None:
596
- return False, "登录响应不可识别"
597
- err = _old_fail(outer)
598
- if err:
599
- return False, err
600
- # 成功响应形状各版本不一:user 可能在 data 里(老)或顶层(实测某老版部署)
601
- d = outer.get("data")
602
- if (isinstance(d, dict) and isinstance(d.get("user"), dict)) \
603
- or isinstance(outer.get("user"), dict) or _old_probe_authed(api, sid):
604
- return True, ""
605
- return False, "登录未被接受(账号或密码不对,或账号被锁)"
606
-
607
-
608
- def _old_login(cfg, rest_err=""):
609
- """老接口通道登录(带 /zentao 子路径候选)。成功置 _OLD 缓存与密码形态。"""
610
- b = _raw_base(cfg.get("base_url"))
611
- account, password = cfg.get("account"), cfg.get("password")
612
- cands, seen = [], set()
613
- for c in (b + "/zentao", b):
614
- if c not in seen:
615
- seen.add(c)
616
- cands.append(c)
617
- last = ""
618
- for api in cands:
619
- sess = _old_session(api)
620
- if not sess:
621
- continue
622
- sid, rand = sess
623
- forms = [_OLD_FORM["form"] or "plain", "md5chain", "plain"]
624
- tried = set()
625
- for form in [f for f in forms if not (f in tried or tried.add(f))]:
626
- ok, err = _old_identify(api, sid, rand, account, password, form)
627
- if ok:
628
- _OLD.update(api=api, sid=sid, at=time.time())
629
- _OLD_FORM["form"] = form
630
- _MODE[b] = "old"
631
- probe_web_style(b) # bug 链接形态探测,失败不影响登录
632
- return
633
- last = err
634
- break # 同一台服务,账密结果与子路径无关,别再烧尝试次数
635
- if not last:
636
- raise ZenError("禅道老版接口不可用(会话接口无响应——地址若是子目录部署要带上子目录)"
637
- + (";%s" % rest_err if rest_err else ""))
638
- if "账号" in last or "密码" in last or "锁定" in last:
639
- raise ZenError(last)
640
- raise ZenError("禅道老接口登录失败:%s" % last)
641
-
642
-
643
- def _old_call(method, path, cfg, body=None):
644
- """老接口执行:翻译 REST 路径 → module-method.json,返回 REST 同形状的 dict。"""
645
- b = _raw_base(cfg.get("base_url"))
646
- if _MODE.get(b) != "old" or not _OLD["sid"] or time.time() - _OLD["at"] > _OLD_TTL:
647
- _old_login(cfg)
648
- parts = urllib.parse.urlsplit(path)
649
- q = dict(urllib.parse.parse_qsl(parts.query))
650
- p = parts.path
651
- for attempt in (1, 2):
652
- try:
653
- return _old_route(_OLD["api"], method, p, q, body)
654
- except _OldSessionDead:
655
- if attempt == 2:
656
- break
657
- _OLD["sid"] = ""
658
- _old_login(cfg)
659
- except urllib.error.HTTPError as e:
660
- raise ZenError("禅道老接口 %s(%s)调用失败" % (p, e.code))
661
- raise ZenError("禅道会话两次登录后仍失效(检查账号权限)")
662
-
663
-
664
- class _OldSessionDead(Exception):
665
- pass
666
-
667
-
668
- def _pairs_to_list(pairs, id_key, name_key):
669
- """{id/name: 值} 形态的 pairs → [{id,name}] 列表(值可能为串或对象)。"""
670
- out = []
671
- for k, v in (pairs or {}).items():
672
- if isinstance(v, dict):
673
- item = dict(v)
674
- item.setdefault(id_key, k)
675
- else:
676
- item = {id_key: k, name_key: v}
677
- out.append(item)
678
- return out
679
-
680
-
681
- def _module_items(d):
682
- """老版模块树形状兼容(2026-09-21 真机反馈「响应形状不认识」):
683
- sons/modules 数组、{id: {name,...}} 字典、children/sons 嵌套树,统一
684
- 递归展开成 [{id,name}] 平铺清单(id=0 是树根容器,跳过);全数字键的
685
- {id: 名字} 扁平映射也认(部分版本的模块摘要形状)。"""
686
- out = []
687
-
688
- def walk(node):
689
- if isinstance(node, list):
690
- for x in node:
691
- walk(x)
692
- elif isinstance(node, dict):
693
- if node.get("id") not in (None, "", 0):
694
- out.append(node)
695
- for k in ("children", "sons"):
696
- if isinstance(node.get(k), (list, dict)):
697
- walk(node[k])
698
- else:
699
- if node and all(str(k).isdigit() for k in node.keys()):
700
- flat = [{"id": k, "name": str(v)}
701
- for k, v in node.items() if str(v or "").strip()]
702
- if flat:
703
- out.extend(flat)
704
- return
705
- for v in node.values():
706
- walk(v)
707
-
708
- if isinstance(d, dict):
709
- # tree 优先:真机 tree=带 children 的完整嵌套树,sons 只有顶层扁平表
710
- # (走 sons 会漏子模块)
711
- walk(d.get("tree") or d.get("sons") or d.get("modules") or d)
712
- elif isinstance(d, list):
713
- walk(d)
714
- return out
715
-
716
-
717
- def _old_route(api, method, p, q, body):
718
- """路径翻译 + 请求 + 归一。会话死抛 _OldSessionDead。"""
719
-
720
- def go(raw, translator=None):
721
- outer = _old_parse(raw)
722
- if outer is None:
723
- txt = raw.decode("utf-8", "replace")
724
- if "user-login" in txt:
725
- raise _OldSessionDead() # 登录重定向 = 会话死
726
- # 其余 HTML 是 js::locate/alert 回包(老禅道 POST 动作成功就回这种)
727
- return {"_js": True}
728
- err = _old_fail(outer)
729
- if err:
730
- raise ZenError("禅道老接口%s:%s" % (translator or "", err))
731
- return outer.get("data")
732
-
733
- def get(path):
734
- try:
735
- raw = _old_get(api, path + ("&" if "?" in path else "?")
736
- + "zentaosid=" + _OLD["sid"])
737
- except urllib.error.HTTPError as e:
738
- raw = e.read() or b""
739
- if _old_parse(raw) is None:
740
- raise _OldSessionDead() # 非 JSON:多半被重定向到登录页
741
- return raw
742
-
743
- def post(path, form):
744
- try:
745
- raw = _old_post(api, path + ("&" if "?" in path else "?")
746
- + "zentaosid=" + _OLD["sid"], form)
747
- except urllib.error.HTTPError as e:
748
- raw = e.read() or b""
749
- if e.code in (401, 403) or _old_parse(raw) is None:
750
- raise _OldSessionDead()
751
- return raw
752
-
753
- m = re.match(r"^/products/(\d+)/bugs$", p)
754
- if m and method == "GET":
755
- pid = m.group(1)
756
- page = max(1, int(q.get("page") or 1))
757
- out, total, seen_ids = [], 0, set()
758
- # 翻页用路径参数形态(实测部分老版不认 query 翻页参数):带 branch 段;
759
- # 若 recPerPage 没生效(老版本无 branch 段会错位解析)回落 query 形态。
760
- branch_form = True
761
- for pg in range(page, page + 6): # 去重兜底:翻页参数不被认时不会死循环
762
- if branch_form:
763
- pathq = "/bug-browse-%s-0-unclosed-0-id_desc-0-%d-%d.json" % (pid, PAGE_LIMIT, pg)
764
- else:
765
- pathq = "/bug-browse-%s.json?browseType=unclosed&orderBy=id_desc" \
766
- "&recPerPage=%d&pagerID=%d" % (pid, PAGE_LIMIT, pg)
767
- d = go(get(pathq), "(拉 bug 列表)")
768
- if branch_form and isinstance(d, dict):
769
- try:
770
- got = int((d.get("pager") or {}).get("recPerPage") or 0)
771
- except (TypeError, ValueError):
772
- got = 0
773
- if got != PAGE_LIMIT:
774
- branch_form = False
775
- continue # 形态没对上:换 query 形态重拉本页
776
- if not isinstance(d, dict):
777
- break
778
- bugs = d.get("bugs") if isinstance(d.get("bugs"), list) else []
779
- fresh = [x for x in bugs if isinstance(x, dict)
780
- and str(x.get("id")) not in seen_ids]
781
- for x in fresh:
782
- seen_ids.add(str(x.get("id")))
783
- out.extend(fresh)
784
- try:
785
- total = int((d.get("pager") or {}).get("recTotal") or 0)
786
- except (TypeError, ValueError, AttributeError):
787
- total = 0
788
- if not fresh or (total and len(out) >= total) or len(out) >= MAX_BUGS:
789
- break
790
- return {"bugs": out[:MAX_BUGS], "total": total or len(out)}
791
-
792
- m = re.match(r"^/products/(\d+)/modules$", p)
793
- if m and method == "GET":
794
- # 2026-09-22 真机实探:该版模块行 type=story,viewType=module 查出空树
795
- # (status 仍 success)——「响应形状不认识」的真根因。改按 bug 视图拉
796
- # (bug 表单模块下拉用的就是这棵树),story/case 兜底。
797
- pid = m.group(1)
798
- last, saw_js = None, False
799
- for vt in ("bug", "story", "case"):
800
- d = go(get("/tree-browse-%s-%s.json" % (pid, vt)), "(拉模块清单)")
801
- last = d
802
- if isinstance(d, dict) and d.get("_js"):
803
- saw_js = True # 接口回了页面脚本:换视图再试
804
- continue
805
- items = [x for x in _module_items(d) if isinstance(x, dict) and x.get("id")]
806
- if items:
807
- return {"_list": items}
808
- if saw_js:
809
- raise ZenError("禅道老接口(拉模块清单):回了页面脚本而不是数据"
810
- "(模块树接口不在或被重定向)——请在禅道产品视图 URL 里查模块 ID 手工填写")
811
- keys = ",".join(sorted(str(k) for k in last.keys())) \
812
- if isinstance(last, dict) else type(last).__name__
813
- raise ZenError("该产品模块树为空(bug/story/case 三个视图都没拉到模块,"
814
- "响应顶层键:%s)——请到禅道确认产品下建过模块,或手工填模块 ID" % keys)
815
-
816
- if p == "/products" and method == "GET":
817
- d = go(get("/api-getmodel-product-getpairs.json"), "(拉产品清单)")
818
- prods = d.get("products") if isinstance(d, dict) else None
819
- if isinstance(prods, list): # 部分老版回数组形状 [{id,name,...}]
820
- return {"_list": [x for x in prods if isinstance(x, dict) and x.get("id")]}
821
- pairs = prods if isinstance(prods, dict) else d
822
- return {"_list": _pairs_to_list(pairs, "id", "name")}
823
-
824
- if p == "/users" and method == "GET":
825
- d = go(get("/api-getmodel-user-getpairs.json"), "(拉账号清单)")
826
- return {"_list": _pairs_to_list(d, "account", "realname")}
827
-
828
- m = re.match(r"^/bugs/(\d+)$", p)
829
- if m and method == "GET":
830
- d = go(get("/bug-view-%s.json" % m.group(1)), "(查 bug)")
831
- return d.get("bug") if isinstance(d, dict) and isinstance(d.get("bug"), dict) else d
832
-
833
- m = re.match(r"^/bugs/(\d+)/resolve$", p)
834
- if m and method == "POST":
835
- form = {k: str(v or "") for k, v in (body or {}).items()}
836
- go(post("/bug-resolve-%s.json" % m.group(1), form), "(resolve)")
837
- return {"ok": True}
838
-
839
- m = re.match(r"^/bugs/(\d+)$", p)
840
- if m and method == "PUT":
841
- form = {k: str(v or "") for k, v in (body or {}).items() if k in ("assignedTo", "comment")}
842
- go(post("/bug-assignTo-%s.json" % m.group(1), form), "(转派)")
843
- return {"ok": True}
844
-
845
- raise ZenError("禅道老接口不认识该调用:%s %s(请升级禅道到 ≥15 用 REST 接口)"
846
- % (method, p))
847
-
848
-
849
- def _call(method, path, cfg=None, body=None):
850
- """统一入口:REST v1 优先;通道探明后按模式分发(账密不被 REST 接受时自动
851
- 切老版 JSON 接口)。所有业务调用都走这里,拿到的都是 REST 形状。"""
852
- c = cfg or _cfg()
853
- b = _raw_base(c.get("base_url"))
854
- mode = _MODE.get(b)
855
- if not mode:
856
- tok = _rest_login(b, c.get("account"), c.get("password"))
857
- if tok:
858
- _MODE[b] = mode = "rest"
859
- else:
860
- _old_login(c, _REST_LAST_ERR.get("msg", ""))
861
- mode = "old"
862
- if mode == "rest":
863
- return _api(method, path, cfg=c, body=body)
864
- return _old_call(method, path, c, body=body)
865
-
866
-
867
- def _acct(v):
868
- """assignedTo/openedBy 兼容:新版是 {account,...} 用户对象,老版可能是
869
- [账号, 姓名] 数组或账号串。"""
870
- if isinstance(v, dict):
871
- return str(v.get("account") or "").strip()
872
- if isinstance(v, (list, tuple)) and v:
873
- return str(v[0] or "").strip()
874
- return str(v or "").strip()
875
-
876
-
877
- def _strip_html(raw):
878
- """steps 字段是富文本 HTML:剥标签转纯文本。"""
879
- txt = str(raw or "")
880
- txt = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", txt)
881
- txt = re.sub(r"(?i)<br\s*/?>|</p>|</li>|</div>|</tr>", "\n", txt)
882
- txt = re.sub(r"<[^>]+>", " ", txt)
883
- txt = _html.unescape(txt)
884
- txt = re.sub(r"[ \t\r]+", " ", txt)
885
- txt = re.sub(r"\n\s*\n+", "\n", txt)
886
- return txt.strip()
887
-
888
-
889
- def _severity(bug):
890
- try:
891
- return int(bug.get("severity") or 0)
892
- except (TypeError, ValueError):
893
- return 0
894
-
895
-
896
- def _claimable(bug, profile):
897
- """认领过滤:active + 指派 + 严重度(产品由档案本身界定)。"""
898
- if str(bug.get("status") or "") != "active":
899
- return False
900
- assigned = str(profile.get("assigned_to") or "").strip()
901
- cap = int(profile.get("severity_cap") or 0)
902
- if assigned and _acct(bug.get("assignedTo")) != assigned:
903
- return False
904
- if cap and _severity(bug) > cap:
905
- return False
906
- return True
907
-
908
-
909
- def list_bugs(cfg, product_id, profile=None):
910
- """拉一个产品下的激活 bug(分页,总量封顶 MAX_BUGS)。
911
-
912
- REST v1 支持时把状态/指派人过滤下推给禅道,减少无关 bug 的分页传输;
913
- 本地的 _claimable 仍保留,兼容老接口或禅道忽略未知查询参数的情况。
914
- """
915
- out = []
916
- for page in range(1, 6):
917
- query = {"page": page, "limit": PAGE_LIMIT, "status": "active"}
918
- assigned = str((profile or {}).get("assigned_to") or "").strip()
919
- if assigned:
920
- query["assignedTo"] = assigned
921
- d = _call("GET", "/products/%s/bugs?%s" % (
922
- product_id, urllib.parse.urlencode(query)), cfg=cfg)
923
- bugs = d.get("bugs") if isinstance(d, dict) else None
924
- if not isinstance(bugs, list):
925
- raise ZenError("禅道 bug 列表响应形状不对(预期 bugs 数组)")
926
- out.extend(b for b in bugs if isinstance(b, dict))
927
- total = 0
928
- try:
929
- total = int(d.get("total") or 0)
930
- except (TypeError, ValueError):
931
- pass
932
- if len(out) >= MAX_BUGS or (total and len(out) >= total) or len(bugs) < PAGE_LIMIT:
933
- break
934
- return out[:MAX_BUGS]
935
-
936
-
937
- def weekly_brief(limit=12):
938
- """本周工作素材(工作汇报起草注入用,2026-09-23 Weekly Report Generator 借鉴
939
- ——「从工作系统取数入素材」):分配给我的活跃任务 + 指派给我的活跃 Bug 摘要。
940
- 尽力而为:未配置/不可达/响应形状不对一律返回空串,绝不阻塞汇报起草。
941
- 多产品档案逐个聚合(同 _scan 口径),条目总量封顶 limit*2。"""
942
- lines = []
943
- try:
944
- cfg = _cfg()
945
- for profile in _profiles(cfg):
946
- assigned = str(profile.get("assigned_to") or "").strip()
947
- if not assigned:
948
- continue
949
- try:
950
- d = _call("GET", "/tasks?%s" % urllib.parse.urlencode(
951
- {"assignedTo": assigned, "limit": limit}), cfg=cfg)
952
- tasks = d.get("tasks") if isinstance(d, dict) else None
953
- for t in (tasks or [])[:limit]:
954
- if isinstance(t, dict) and t.get("status") in ("wait", "doing"):
955
- lines.append("- [任务#%s] %s(%s)" % (
956
- t.get("id"), t.get("name") or "", t.get("status") or ""))
957
- except Exception:
958
- pass
959
- try:
960
- for b in list_bugs(cfg, profile.get("product"), profile)[:limit]:
961
- lines.append("- [Bug#%s] %s(严重级 %s)" % (
962
- b.get("id"), b.get("title") or "",
963
- b.get("severity") or b.get("status") or ""))
964
- except Exception:
965
- pass
966
- except Exception:
967
- return ""
968
- return "\n".join(lines[:limit * 2])
969
-
970
-
971
- def fetch_modules(product_id):
972
- """拉产品模块清单(模块路由配置辅助)。接口不存在/失败报人话,提示手填 ID。"""
973
- _ensure_loaded()
974
- cfg = _cfg()
975
- try:
976
- d = _call("GET", "/products/%s/modules" % product_id, cfg=cfg)
977
- except ZenError as e:
978
- msg = str(e)
979
- # 只有「接口不存在」类错误才补「没有该接口」提示;老通道自己报的
980
- # 空树/脚本回包已带人话结论,别再叠一层误导
981
- if "404" in msg or "不认识" in msg:
982
- msg += "——也可能你的禅道没有该接口:请在禅道产品视图 URL 里查模块 ID 手工填写"
983
- return {"ok": False, "error": msg}
984
- # 形状兼容器({_list} / {modules:[...]} / {id:{...}} 字典 / 裸数组)
985
- items = _list_items(d, "modules")
986
- if items is None:
987
- items = d.get("_list") if isinstance(d.get("_list"), list) else None
988
- out = []
989
- for m in items or []:
990
- if isinstance(m, dict) and m.get("id"):
991
- out.append({"id": m.get("id"), "name": str(m.get("name") or "")})
992
- if not out:
993
- if isinstance(d, dict) and set(d.keys()) == {"_list"}:
994
- # 自家归一包装(裸数组进 _api/_old_route 时打的包):空清单=
995
- # 产品真没模块,不是形状问题,别拿顶层键吓人
996
- return {"ok": False,
997
- "error": "禅道回了空模块清单(该产品可能没建模块)——请手工填模块 ID"}
998
- # 带响应形状摘要帮排查(顶层键名,不吐正文——用户实测反馈
999
- # 「响应形状不认识」却看不到真实形状,没法报修)
1000
- keys = ",".join(sorted(str(k) for k in d.keys())) if isinstance(d, dict) else type(d).__name__
1001
- return {"ok": False,
1002
- "error": "模块清单为空或响应形状不认识(响应顶层键:%s)——请手工填模块 ID" % (keys or "非字典")}
1003
- return {"ok": True, "modules": out[:200]}
1004
-
1005
-
1006
- def _list_items(d, key):
1007
- """禅道 REST v1 清单响应形状兼容:{key:[...]} / 裸数组(_list) / {id:{...}} 字典。"""
1008
- if not isinstance(d, dict):
1009
- return None
1010
- if isinstance(d.get(key), list):
1011
- return d[key]
1012
- if isinstance(d.get("_list"), list):
1013
- return d["_list"]
1014
- vals = [v for v in d.values() if isinstance(v, dict) and v.get("id")]
1015
- return vals or None
1016
-
1017
-
1018
- def fetch_products():
1019
- """拉产品清单(产品 ID 下拉选择辅助)。接口不存在/失败返回 ok:False+人话。"""
1020
- _ensure_loaded()
1021
- cfg = _cfg()
1022
- out, total = [], 0
1023
- try:
1024
- page = 1
1025
- while page <= 3:
1026
- d = _call("GET", "/products?page=%d&limit=100" % page, cfg=cfg)
1027
- items = _list_items(d, "products")
1028
- if not items:
1029
- break
1030
- out.extend(it for it in items
1031
- if isinstance(it, dict) and str(it.get("id") or "") != "")
1032
- try:
1033
- total = int(d.get("total") or 0)
1034
- except (TypeError, ValueError, AttributeError):
1035
- total = 0
1036
- if not total or len(out) >= total:
1037
- break
1038
- page += 1
1039
- except ZenError as e:
1040
- return {"ok": False, "error": "%s——请先「测试连接」确认地址与账号可用" % e}
1041
- if not out:
1042
- return {"ok": False, "error": "禅道里没有产品,或响应形状不认识(REST API 需 ≥15)"}
1043
- seen, uniq = set(), []
1044
- for m in out:
1045
- try:
1046
- pid = int(m["id"])
1047
- except (TypeError, ValueError):
1048
- pid = m["id"]
1049
- if pid in seen:
1050
- continue
1051
- seen.add(pid)
1052
- uniq.append({"id": pid, "name": str(m.get("name") or ""),
1053
- "status": str(m.get("status") or "")})
1054
- uniq.sort(key=lambda x: str(x["id"]))
1055
- return {"ok": True, "products": uniq[:500]}
1056
-
1057
-
1058
- def fetch_users():
1059
- """拉禅道账号清单(负责人/转派下拉辅助)。失败返回 ok:False,前端仍可手填。"""
1060
- _ensure_loaded()
1061
- cfg = _cfg()
1062
- try:
1063
- d = _call("GET", "/users?limit=1000", cfg=cfg)
1064
- except ZenError as e:
1065
- return {"ok": False, "error": "%s——账号清单拉不到,仍可手填账号" % e}
1066
- items = None
1067
- if isinstance(d, dict):
1068
- if isinstance(d.get("users"), list):
1069
- items = d["users"]
1070
- elif isinstance(d.get("_list"), list):
1071
- items = d["_list"]
1072
- else:
1073
- items = []
1074
- for k, v in d.items(): # {账号: 用户} 字典形状
1075
- if not isinstance(v, dict):
1076
- continue
1077
- u = dict(v)
1078
- u.setdefault("account", str(k))
1079
- items.append(u)
1080
- out, seen = [], set()
1081
- for u in items or []:
1082
- if not isinstance(u, dict):
1083
- continue
1084
- acct = str(u.get("account") or "").strip()
1085
- if not acct or acct in seen:
1086
- continue
1087
- seen.add(acct)
1088
- out.append({"account": acct, "realname": str(u.get("realname") or "")})
1089
- if not out:
1090
- return {"ok": False, "error": "账号清单为空或响应形状不认识——仍可手填账号"}
1091
- out.sort(key=lambda x: x["account"])
1092
- return {"ok": True, "users": out[:500]}
1093
-
1094
-
1095
- def test_connection(base_url=None, account=None, password=None):
1096
- """连接测试:探明通道登录 + 拉第一个产品的 bug 列表(有档案时)。返回 (ok, 人话结果)。"""
1097
- c = _cfg()
1098
- base_url = str(base_url if base_url is not None else c.get("base_url") or "").strip()
1099
- account = str(account if account is not None else c.get("account") or "").strip()
1100
- password = str(password if password is not None else c.get("password") or "").strip()
1101
- if not (base_url and account and password):
1102
- return False, "地址、账号、密码都要填全"
1103
- b = base_url.strip().rstrip("/")
1104
- probe = {"base_url": b, "account": account, "password": password}
1105
- try:
1106
- tok = _rest_login(b, account, password)
1107
- except ZenError as e:
1108
- return False, str(e)
1109
- if tok:
1110
- _MODE[b] = "rest"
1111
- else:
1112
- try:
1113
- _old_login(probe, _REST_LAST_ERR.get("msg", ""))
1114
- _MODE[b] = "old"
1115
- except ZenError as e:
1116
- return False, str(e)
1117
- tag = "老版 JSON 接口" if _MODE.get(b) == "old" else "REST v1"
1118
- try:
1119
- profiles = _profiles(c)
1120
- if profiles:
1121
- bugs = list_bugs(probe, profiles[0]["product"], profiles[0])
1122
- return True, "连接成功(%s),产品 %s 可访问(当前 %d 条 bug 在列表里)" % (
1123
- tag, profiles[0]["product"], len(bugs))
1124
- except ZenError as e:
1125
- return False, "登录成功(%s),但拉 bug 列表失败:%s" % (tag, e)
1126
- return True, "连接成功(%s;未配产品档案,跳过列表探测)" % tag
1127
-
1128
-
1129
- # ---------------------------------------------------------------- 排查(triage)
1130
-
1131
- def _ai_triage(bug, profile):
1132
- """AI 兜底排查:单次 LLM 调用判端。不可用/解析失败返回 None(bookmeta 同款配方)。"""
1133
- try:
1134
- from . import modelhub, runner
1135
- orch = modelhub.resolve_orchestrator()
1136
- if not orch:
1137
- return None
1138
- prov, model = orch
1139
- repos = profile.get("repos") or {}
1140
- hints = profile.get("repo_hints") or {}
1141
-
1142
- def _repo_desc(side):
1143
- hint = str(hints.get(side) or "").strip()
1144
- if hint:
1145
- return hint
1146
- wd = str((repos.get(side) or {}).get("workdir") or "").strip()
1147
- if wd:
1148
- return "目录 " + Path(wd).name
1149
- return "(未配置)"
1150
-
1151
- lines = ["你是缺陷分诊员:根据缺陷描述判断问题属于哪个仓库端。", "",
1152
- "【缺陷】#%s %s" % (bug.get("id"), str(bug.get("title") or ""))]
1153
- steps = _strip_html(bug.get("steps"))
1154
- if steps:
1155
- lines.append(steps[:3000])
1156
- lines.append("")
1157
- lines.append("【仓库背景】后端仓库:%s;前端仓库:%s" % (_repo_desc("backend"),
1158
- _repo_desc("frontend")))
1159
- lines.append("")
1160
- lines.append('只输出 JSON(不要别的文字):{"side": "backend|frontend|both|not_ours", '
1161
- '"reason": "一句话依据"}')
1162
- lines.append("判定口径:backend=纯后端问题;frontend=纯前端问题;both=两端都要改;"
1163
- "not_ours=与这两个仓库无关(第三方服务/环境/需求变更/数据问题等)。")
1164
- res = modelhub.chat(prov["id"], model, "\n".join(lines), max_tokens=500,
1165
- timeout=90)
1166
- if not res.get("ok"):
1167
- log.warning("zentao: AI 排查失败:%s", res.get("error"))
1168
- return None
1169
- data = runner.extract_json(res.get("text") or "")
1170
- side = str((data or {}).get("side") or "").strip().lower()
1171
- if side not in TRIAGE_SIDES:
1172
- return None
1173
- return {"side": side, "reason": str((data or {}).get("reason") or "")[:300]}
1174
- except Exception:
1175
- log.debug("zentao: AI 排查异常", exc_info=True)
1176
- return None
1177
-
1178
-
1179
- def _triage(bug, profile, cfg):
1180
- """排查定责:模块路由 > AI > unknown。返回 {side, reason, by, account}。"""
1181
- mid = str(bug.get("module") or "")
1182
- if mid:
1183
- for r in profile.get("module_routes") or []:
1184
- try:
1185
- if int(r.get("module") or 0) != int(mid):
1186
- continue
1187
- except (TypeError, ValueError):
1188
- continue
1189
- side = str(r.get("side") or "")
1190
- if side in TRIAGE_SIDES:
1191
- return {"side": side, "reason": "模块 #%s 路由规则" % mid,
1192
- "by": "rule", "account": str(r.get("account") or "").strip()}
1193
- if cfg.get("triage_ai"):
1194
- res = _ai_triage(bug, profile)
1195
- if res:
1196
- res["by"] = "ai"
1197
- res["account"] = ""
1198
- return res
1199
- return {"side": "unknown", "reason": "模块未命中路由且 AI 排查不可用", "by": "fallback",
1200
- "account": ""}
1201
-
1202
-
1203
- # ---------------------------------------------------------------- 修复任务拉起
1204
-
1205
- def _repo_of(profile, side):
1206
- return (profile.get("repos") or {}).get(side) or dict(_REPO_DEFAULTS)
1207
-
1208
-
1209
- def _goal_text(bug, side=None):
1210
- """bug → 修复目标提示词。side 给出时附端约束。"""
1211
- bid = bug.get("id")
1212
- lines = ["修复禅道 Bug #%s:%s" % (bid, str(bug.get("title") or "").strip())]
1213
- steps = _strip_html(bug.get("steps"))
1214
- if steps:
1215
- lines.append("")
1216
- lines.append("【重现步骤/问题描述】")
1217
- lines.append(steps[:4000])
1218
- sev, pri = _severity(bug), str(bug.get("pri") or "").strip()
1219
- meta = []
1220
- if sev:
1221
- meta.append("严重度 %s(1 最严重)" % sev)
1222
- if pri:
1223
- meta.append("优先级 %s" % pri)
1224
- if str(bug.get("module") or "").strip():
1225
- meta.append("模块 #%s" % bug["module"])
1226
- env = " / ".join(x for x in (str(bug.get("os") or "").strip(),
1227
- str(bug.get("browser") or "").strip()) if x)
1228
- if env:
1229
- meta.append("环境 " + env)
1230
- kw = str(bug.get("keywords") or "").strip()
1231
- if kw:
1232
- meta.append("关键字 " + kw)
1233
- if meta:
1234
- lines.append("")
1235
- lines.append("【元信息】" + ";".join(meta))
1236
- lines.append("")
1237
- lines.append("【要求】只修这个 bug,不做无关重构;改动最小化;修完自查不引入回归。")
1238
- if side in SIDES:
1239
- lines.append("【端约束】本任务只负责【%s】部分;%s部分由别人处理,不要越界改动。"
1240
- % (SIDE_CN[side],
1241
- SIDE_CN["frontend" if side == "backend" else "backend"]))
1242
- lines.append("完成后给出修改说明。")
1243
- return "\n".join(lines)
1244
-
1245
-
1246
- def _launch_fix(bug, profile, side, cfg):
1247
- """为一个 bug 的某一端建修复任务并立即启动。"""
1248
- bid = bug.get("id")
1249
- repo = _repo_of(profile, side)
1250
- wd = str(repo.get("workdir") or "").strip() or settings.default_workdir()
1251
- title = ("[禅道#%s][%s] %s" % (bid, SIDE_CN.get(side, side),
1252
- str(bug.get("title") or "").strip())).strip()[:60]
1253
- payload = {"type": "code", "title": title, "goal": _goal_text(bug, side), "workdir": wd}
1254
- base = str(repo.get("git_rev") or "").strip()
1255
- if not base:
1256
- # 档案没配基线就取落单时点的 HEAD:让修复走任务分支隔离——修完自动
1257
- # 提交在任务分支上,对账合并才真正落库,resolve 才站得住(#27697 案:
1258
- # 无基线任务修完只躺在工作区,闭环永远不会替你提交)。
1259
- base = _head_rev(wd)
1260
- if base:
1261
- payload["git_rev"] = base
1262
- if str(repo.get("verify_command") or "").strip():
1263
- payload["verify_command"] = str(repo["verify_command"]).strip()
1264
- task = None
1265
- run = None
1266
- try:
1267
- task = store.create_task(payload)
1268
- run = store.create_run("orchestration", task["title"], task_id=task["id"])
1269
- store.update_task_status(task["id"], "queued")
1270
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"],
1271
- "task_id": task["id"]})
1272
- return task, run
1273
- except Exception:
1274
- log.exception("zentao: 修复任务启动失败 bug=%s side=%s task=%s run=%s",
1275
- bid, side, (task or {}).get("id"), (run or {}).get("id"))
1276
- if run:
1277
- try:
1278
- store.update_run(run["id"], status="failed",
1279
- error="禅道修复任务启动失败,本次未排队,请稍后重试",
1280
- ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1281
- except Exception:
1282
- log.exception("zentao: 修复运行失败收口失败 run=%s", run.get("id"))
1283
- if task:
1284
- try:
1285
- store.update_task_status(task["id"], "failed")
1286
- except Exception:
1287
- log.exception("zentao: 修复任务失败收口失败 task=%s", task.get("id"))
1288
- raise
1289
-
1290
-
1291
- # ---------------------------------------------------------------- 回写文本
1292
-
1293
- def _diffstat(workdir, task_id):
1294
- """任务分支相对基线的改动统计(人话一行)。拿不到返回空串。"""
1295
- try:
1296
- from . import gitmod, runner
1297
- if task_id and workdir:
1298
- br = gitmod.branch_name(task_id)
1299
- r = runner.run_process(
1300
- argv=["git", "-C", workdir, "diff", "--shortstat", "HEAD..." + br],
1301
- timeout=20)
1302
- if r.get("ok"):
1303
- return (r.get("stdout") or "").strip()
1304
- except Exception:
1305
- log.debug("zentao: diffstat 失败", exc_info=True)
1306
- return ""
1307
-
1308
-
1309
- def _fix_summary(claim, run, profile, side):
1310
- """我方某一端的修复摘要(转派评论与 resolve 报告共用的事实部分)。"""
1311
- lines = []
1312
- git = (run or {}).get("git") or {}
1313
- commit = str(git.get("commit") or "").strip()
1314
- from_branch = str(git.get("from_branch") or "").strip()
1315
- tid = _task_of(claim, side).get("task_id") or ""
1316
- if commit:
1317
- lines.append("【%s】修复提交 %s%s" % (SIDE_CN.get(side, side), commit,
1318
- "(已合并回 %s)" % from_branch if from_branch else ""))
1319
- stat = _diffstat(str(_repo_of(profile, side).get("workdir") or ""), tid)
1320
- if stat:
1321
- lines.append("【%s】改动统计:%s" % (SIDE_CN.get(side, side), stat))
1322
- v = (run or {}).get("verdict") or {}
1323
- if v:
1324
- lines.append("【%s】验证结论:%s" % (SIDE_CN.get(side, side),
1325
- "通过(评审达标)" if v.get("pass") or v.get("publishable") else "完成"))
1326
- return lines
1327
-
1328
-
1329
- def _task_of(claim, side):
1330
- for t in claim.get("tasks") or []:
1331
- if t.get("side") == side:
1332
- return t
1333
- return {}
1334
-
1335
-
1336
- def _report_text(claim, runs, profile, cfg):
1337
- """resolve 评论:确定性事实(逐端汇总)。"""
1338
- tri = claim.get("triage") or {}
1339
- lines = ["【CodeBee 自动修复报告】",
1340
- "Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or "")]
1341
- if tri.get("side"):
1342
- lines.append("排查结论:%s问题(%s)" % (tri.get("side"),
1343
- tri.get("reason") or tri.get("by") or "按规则"))
1344
- for t in claim.get("tasks") or []:
1345
- lines.extend(_fix_summary(claim, runs.get(t.get("run_id")), profile, t.get("side")))
1346
- lines.append("(本条由 CodeBee 禅道集成自动回写)")
1347
- return "\n".join(lines)
1348
-
1349
-
1350
- def _transfer_text(claim, profile, fixed_runs, target_side):
1351
- """转派评论:排查结论 + 我方已做工作 + 请对方继续。"""
1352
- tri = claim.get("triage") or {}
1353
- lines = ["【CodeBee 排查转派】",
1354
- "Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or ""),
1355
- "排查结论:%s问题——%s" % (SIDE_CN.get(target_side, target_side),
1356
- tri.get("reason") or tri.get("by") or "按规则")]
1357
- for side, run in (fixed_runs or []):
1358
- lines.extend(_fix_summary(claim, run, profile, side))
1359
- lines.append("请%s负责人接手处理;本 bug 保持激活,处理完请按正常流程解决。"
1360
- % SIDE_CN.get(target_side, target_side))
1361
- lines.append("(本条由 CodeBee 禅道集成自动回写)")
1362
- return "\n".join(lines)
1363
-
1364
-
1365
- def _fail_text(claim, failed_tasks, runs):
1366
- tri = claim.get("triage") or {}
1367
- lines = ["【CodeBee 自动修复未成功】",
1368
- "Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or "")]
1369
- if tri.get("side"):
1370
- lines.append("排查结论:%s(%s)" % (tri.get("side"), tri.get("reason") or ""))
1371
- for t in failed_tasks:
1372
- r = runs.get(t.get("run_id")) or {}
1373
- why = str(r.get("error") or "").strip() or ("运行状态 " + str(r.get("status") or ""))
1374
- lines.append("【%s】失败:%s" % (SIDE_CN.get(t.get("side"), t.get("side")), why[:300]))
1375
- lines.append("CodeBee 修复任务:%s(可人工续跑或接管);本 bug 保持待处理。"
1376
- % "、".join(t.get("task_id") or "?" for t in claim.get("tasks") or []))
1377
- return "\n".join(lines)
1378
-
1379
-
1380
- # ---------------------------------------------------------------- 禅道写回动作
1381
-
1382
- def _bug_opened_by(cfg, bug_id):
1383
- try:
1384
- d = _call("GET", "/bugs/%s" % bug_id, cfg=cfg)
1385
- return _acct(d.get("openedBy"))
1386
- except ZenError:
1387
- return ""
1388
-
1389
-
1390
- def _ensure_resolved(cfg, bug_id, comment, assign_to):
1391
- """resolve(幂等):已是 resolved/closed 视为成功。"""
1392
- cur = _call("GET", "/bugs/%s" % bug_id, cfg=cfg)
1393
- if str(cur.get("status") or "") in ("resolved", "closed"):
1394
- return True
1395
- body = {"resolution": "fixed", "resolvedBuild": "trunk", "comment": comment}
1396
- if assign_to:
1397
- body["assignedTo"] = assign_to
1398
- _call("POST", "/bugs/%s/resolve" % bug_id, cfg=cfg, body=body)
1399
- return True
1400
-
1401
-
1402
- def _transfer(cfg, bug_id, target, comment):
1403
- """转派:PUT assignedTo+comment(bug 保持激活)。target 空=只评论。抛 ZenError。"""
1404
- body = {"comment": comment}
1405
- if target:
1406
- body["assignedTo"] = target
1407
- _call("PUT", "/bugs/%s" % bug_id, cfg=cfg, body=body)
1408
-
1409
-
1410
- def _notify(text):
1411
- try:
1412
- from . import notify
1413
- notify.push_text(text)
1414
- except Exception:
1415
- log.debug("zentao: 群通知失败", exc_info=True)
1416
-
1417
-
1418
- def _merge_branch(workdir, task):
1419
- try:
1420
- from . import gitmod
1421
- return gitmod.merge_task_branch(workdir, task)
1422
- except Exception as e:
1423
- return False, "合并异常:%s" % e, None
1424
-
1425
-
1426
- def _head_rev(workdir):
1427
- """工作目录当前 HEAD 短哈希(作任务隔离基线)。非仓库/拿不到返回空串。"""
1428
- try:
1429
- from . import gitmod, runner
1430
- r = runner.run_process(
1431
- argv=["git", "-C", str(workdir), "rev-parse", "--short", "HEAD"], timeout=20)
1432
- if r.get("ok"):
1433
- rev = (r.get("stdout") or "").strip()
1434
- if rev and gitmod.valid_rev(rev):
1435
- return rev
1436
- except Exception:
1437
- log.debug("zentao: 读取 HEAD 失败", exc_info=True)
1438
- return ""
1439
-
1440
-
1441
- def _uncommitted(workdir):
1442
- """工作目录里有没有未提交的已跟踪改动(untracked 不算——CLI 噪音文件太多)。
1443
-
1444
- 返回 (是否脏, 人话明细)。目录不是 git 仓库视为不脏(无提交语义,放行)。
1445
- """
1446
- try:
1447
- from . import runner
1448
- if not workdir:
1449
- return False, ""
1450
- r = runner.run_process(
1451
- argv=["git", "-C", str(workdir), "status", "--porcelain", "-uno", "--", "."],
1452
- timeout=20)
1453
- if r.get("ok"):
1454
- dirty = [ln.strip() for ln in (r.get("stdout") or "").splitlines() if ln.strip()]
1455
- if dirty:
1456
- return True, "未提交改动 %d 处(如 %s)" % (len(dirty), dirty[0][:80])
1457
- except Exception:
1458
- log.debug("zentao: 工作区状态检查失败", exc_info=True)
1459
- return False, ""
1460
-
1461
-
1462
- def _set_claim(bid, **patch):
1463
- with _LOCK:
1464
- c = _STATE["claims"].get(str(bid))
1465
- if c is None:
1466
- return
1467
- c.update(patch)
1468
- c["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
1469
- _save_locked()
1470
-
1471
-
1472
- def _route_account(profile, tri, side):
1473
- """转派/升级目标:模块路由 account > 端负责人。"""
1474
- if tri and tri.get("account"):
1475
- return str(tri["account"]).strip()
1476
- return str((profile.get("owners") or {}).get(side) or "").strip()
1477
-
1478
-
1479
- # ---------------------------------------------------------------- 对账回写
1480
-
1481
- def _finish_ok(claim, cfg):
1482
- """我方任务全部 done:合并 → (需要则)转派 → 否则 resolve。"""
1483
- bid = str(claim.get("bug_id"))
1484
- profile = _profile_for(cfg, claim.get("product")) or {}
1485
- tri = claim.get("triage") or {}
1486
- tasks = claim.get("tasks") or []
1487
- runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
1488
- # 1) 逐任务合并(任一失败即停:不转派不 resolve,绝不带着没落库的修复转派)
1489
- if not cfg.get("auto_merge"):
1490
- _set_claim(bid, state="done_manual",
1491
- note="修复完成;auto_merge 已关,请人工合并落库后到禅道解决 bug")
1492
- return
1493
- for t in tasks:
1494
- task = store.get_task(t.get("task_id") or "")
1495
- if task is None or not task.get("git_rev"):
1496
- continue
1497
- ok, err, _info = _merge_branch(str(_repo_of(profile, t.get("side")).get("workdir")
1498
- or settings.default_workdir()), task)
1499
- if not ok:
1500
- _set_claim(bid, state="merge_failed",
1501
- note="【%s】任务分支合并失败:%s(不转派不 resolve,留人工)"
1502
- % (SIDE_CN.get(t.get("side"), t.get("side")), err))
1503
- _notify("🐛❌ 禅道 Bug #%s 的%s修复代码合并失败:%s\n修复任务:%s"
1504
- % (bid, SIDE_CN.get(t.get("side"), ""), err, t.get("task_id") or "?"))
1505
- return
1506
- # 1.5) 落库闸:没基线的任务不走任务分支隔离,改动只躺在工作区——带着
1507
- # 未提交的已跟踪改动点 resolve 是对禅道撒谎(#27697 案),拦下转人工。
1508
- for t in tasks:
1509
- task = store.get_task(t.get("task_id") or "")
1510
- if task is None or task.get("git_rev"):
1511
- continue
1512
- dirty, detail = _uncommitted(str(_repo_of(profile, t.get("side")).get("workdir")
1513
- or settings.default_workdir()))
1514
- if dirty:
1515
- _set_claim(bid, state="done_manual",
1516
- note="修复完成但工作区有未提交改动(%s),已阻止自动 resolve"
1517
- "——请人工提交后到禅道解决" % detail)
1518
- _notify("🐛⚠️ 禅道 Bug #%s 修复完成但未提交落库,已阻止自动 resolve\n%s"
1519
- % (bid, claim.get("title") or ""))
1520
- return
1521
- # 2) 需要转派的端 = 判定端 - 我方端(both 且我方只管一端时非空)
1522
- verdict_sides = ([tri["side"]] if tri.get("side") in SIDES
1523
- else list(SIDES) if tri.get("side") == "both" else [])
1524
- other_sides = [s for s in verdict_sides if s not in (profile.get("our_sides") or [])]
1525
- if other_sides:
1526
- targets = {}
1527
- for s in other_sides:
1528
- tgt = _route_account(profile, tri, s)
1529
- if not tgt:
1530
- _set_claim(bid, state="need_manual",
1531
- note="我方已修完,但未配置%s负责人,无法转派——请人工转派" % SIDE_CN[s])
1532
- _notify("🐛⚠️ 禅道 Bug #%s 我方部分已修完,但未配置%s负责人,请人工转派"
1533
- % (bid, SIDE_CN[s]))
1534
- return
1535
- targets[s] = tgt
1536
- fixed_runs = [(t.get("side"), runs.get(t.get("run_id"))) for t in tasks]
1537
- for s in other_sides:
1538
- try:
1539
- _transfer(cfg, bid, targets[s], _transfer_text(claim, profile, fixed_runs, s))
1540
- except ZenError as e:
1541
- attempts = int(claim.get("attempts") or 0) + 1
1542
- if attempts >= RESOLVE_MAX_ATTEMPTS:
1543
- _set_claim(bid, attempts=attempts, state="resolve_failed",
1544
- note="转派连续 %d 次失败:%s" % (attempts, e))
1545
- _notify("🐛⚠️ 禅道 Bug #%s 修完但转派失败:%s" % (bid, e))
1546
- else:
1547
- _set_claim(bid, attempts=attempts, state="fixing",
1548
- note="转派第 %d 次失败,下轮重试:%s" % (attempts, e))
1549
- return
1550
- _set_claim(bid, state="transferred",
1551
- note="我方(%s)已修完并合并,转派给 %s"
1552
- % ("/".join(SIDE_CN.get(t.get("side"), "") for t in tasks),
1553
- "、".join(SIDE_CN[s] + ":" + targets[s] for s in other_sides)))
1554
- _notify("🐛🔁 禅道 Bug #%s 我方已修完,转派 %s\n%s"
1555
- % (bid, "、".join(SIDE_CN[s] + ":" + targets[s] for s in other_sides),
1556
- claim.get("title") or ""))
1557
- return
1558
- # 3) 全部我方端:resolve(幂等)+ 指回报告人
1559
- if not cfg.get("auto_resolve"):
1560
- _set_claim(bid, state="done_manual",
1561
- note="修复完成;auto_resolve 已关,请人工确认后到禅道解决 bug")
1562
- return
1563
- report = _report_text(claim, runs, profile, cfg)
1564
- opened = _bug_opened_by(cfg, bid)
1565
- try:
1566
- _ensure_resolved(cfg, bid, report, opened)
1567
- except ZenError as e:
1568
- attempts = int(claim.get("attempts") or 0) + 1
1569
- if attempts >= RESOLVE_MAX_ATTEMPTS:
1570
- _set_claim(bid, attempts=attempts, state="resolve_failed",
1571
- note="resolve 连续 %d 次失败:%s" % (attempts, e))
1572
- _notify("🐛⚠️ 禅道 Bug #%s 修复完成但回写禅道失败:%s" % (bid, e))
1573
- else:
1574
- _set_claim(bid, attempts=attempts, state="fixing",
1575
- note="resolve 第 %d 次失败,下轮重试:%s" % (attempts, e))
1576
- return
1577
- _set_claim(bid, state="resolved", note="已 resolve(fixed)%s"
1578
- % (",指回报告人 " + opened if opened else ""))
1579
- _notify("🐛✅ 禅道 Bug #%s 已修复并 resolve\n%s\n修复任务:%s"
1580
- % (bid, claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
1581
-
1582
-
1583
- def _finish_failed(claim, cfg):
1584
- """我方任一任务失败:评论尝试记录 + 转派该端负责人(有配则转)。"""
1585
- bid = str(claim.get("bug_id"))
1586
- profile = _profile_for(cfg, claim.get("product")) or {}
1587
- tasks = claim.get("tasks") or []
1588
- runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
1589
- failed = [t for t in tasks
1590
- if str((runs.get(t.get("run_id")) or {}).get("status") or "") not in
1591
- ("queued", "running", "done")]
1592
- text = _fail_text(claim, failed, runs)
1593
- # 升级目标:取第一个失败端的路由账号/负责人
1594
- target = ""
1595
- for t in failed:
1596
- target = _route_account(profile, claim.get("triage") or {}, t.get("side"))
1597
- if target:
1598
- break
1599
- try:
1600
- _transfer(cfg, bid, target, text)
1601
- note = "已评论说明%s" % ("并转派 %s" % target if target else "")
1602
- state = "escalated" if target else "commented"
1603
- except ZenError as e:
1604
- log.warning("zentao: bug %s 失败评论未送达(群通知兜底):%s", bid, e)
1605
- note = "修复失败,评论未送达:%s" % e
1606
- state = "commented"
1607
- _set_claim(bid, state=state, note=note)
1608
- _notify("🐛❌ 禅道 Bug #%s 自动修复未成功%s\n%s\n修复任务:%s"
1609
- % (bid, (",已转派 " + target) if target else "",
1610
- claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
1611
-
1612
-
1613
- def _refresh_claim_runs(claim):
1614
- """把 claim.tasks 的 run_id 对齐到各任务最新一次运行,返回 (tasks, 是否换绑)。
1615
-
1616
- 领单时钉住的 run 失败后,人工重试会换新 run——对账只认旧 run 会把已经
1617
- 成功的修复判成失败(2026-09-22 #27697 误报失败评论案)。
1618
- """
1619
- fresh, changed = [], False
1620
- for t in claim.get("tasks") or []:
1621
- tid = str(t.get("task_id") or "")
1622
- if tid:
1623
- runs = store.task_runs(tid)
1624
- rid = str((runs[0].get("id") if runs else "") or "")
1625
- if rid and rid != t.get("run_id"):
1626
- t = dict(t, run_id=rid)
1627
- changed = True
1628
- fresh.append(t)
1629
- return fresh, changed
1630
-
1631
-
1632
- def _reconcile(cfg):
1633
- """对账:fixing 中的 claim 查各 run 终态并回写。单条异常只跳过该条。"""
1634
- with _LOCK:
1635
- fixing = [dict(c) for c in _STATE["claims"].values() if c.get("state") == "fixing"]
1636
- for claim in fixing:
1637
- bid = str(claim.get("bug_id"))
1638
- try:
1639
- tasks, moved = _refresh_claim_runs(claim)
1640
- if moved:
1641
- _set_claim(bid, tasks=tasks)
1642
- claim = dict(claim, tasks=tasks)
1643
- runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
1644
- if any(r is None for r in runs.values()):
1645
- _set_claim(bid, state="lost", note="运行记录不存在(可能被清理)")
1646
- continue
1647
- states = [str((r or {}).get("status") or "") for r in runs.values()]
1648
- if any(s in ("queued", "running") for s in states):
1649
- continue
1650
- if all(s == "done" for s in states):
1651
- _finish_ok(claim, cfg)
1652
- else:
1653
- _finish_failed(claim, cfg)
1654
- except Exception:
1655
- log.exception("zentao: claim %s 对账异常,跳过", bid)
1656
-
1657
-
1658
- # ---------------------------------------------------------------- 扫描主流程
1659
-
1660
- def _sides_to_fix(profile, tri):
1661
- """判定端 ∩ 我方端。"""
1662
- tri_side = tri.get("side")
1663
- verdict_sides = [tri_side] if tri_side in SIDES else (list(SIDES) if tri_side == "both" else [])
1664
- return [s for s in verdict_sides if s in (profile.get("our_sides") or [])]
1665
-
1666
-
1667
- def _route_one(bug, profile, cfg, notify=True):
1668
- """认领一个 bug:排查 → 分流(建任务/转派/留人工)。notify=False 用于
1669
- need_manual 存量的重排查(仍无解时不重复群通知)。返回动作文案或 None。"""
1670
- bid = str(bug.get("id") or "")
1671
- tri = _triage(bug, profile, cfg)
1672
- base_claim = {
1673
- "bug_id": bug.get("id"), "product": profile.get("product"),
1674
- "title": str(bug.get("title") or "")[:120],
1675
- "triage": tri, "tasks": [], "state": "fixing", "note": "",
1676
- "attempts": 0, "claimed_at": time.strftime("%Y-%m-%d %H:%M:%S"),
1677
- }
1678
- opened = _acct(bug.get("openedBy"))
1679
-
1680
- if tri["side"] == "unknown":
1681
- base_claim["state"] = "need_manual"
1682
- base_claim["note"] = tri.get("reason") or "排查失败,留人工"
1683
- with _LOCK:
1684
- _STATE["claims"][bid] = base_claim
1685
- _save_locked()
1686
- if notify:
1687
- _notify("🐛❓ 禅道 Bug #%s 排查不出归属端(%s),留人工\n%s"
1688
- % (bid, tri.get("reason") or "", bug.get("title") or ""))
1689
- return "need_manual"
1690
-
1691
- if tri["side"] == "not_ours":
1692
- target = tri.get("account") or str(profile.get("owners").get("not_ours") or "") \
1693
- or opened
1694
- text = ("【CodeBee 排查转派】\nBug:#%s %s\n排查结论:非我方两个仓库的问题——%s\n"
1695
- "转回 %s 核实处理。\n(本条由 CodeBee 禅道集成自动回写)"
1696
- % (bid, bug.get("title") or "", tri.get("reason") or "按规则",
1697
- target or "报告人"))
1698
- try:
1699
- _transfer(cfg, bid, target, text)
1700
- note = "非我方,已转派 %s" % (target or "(未指派,仅评论)")
1701
- except ZenError as e:
1702
- base_claim.update({"state": "need_manual", "note": "非我方转派失败:%s" % e})
1703
- with _LOCK:
1704
- _STATE["claims"][bid] = base_claim
1705
- _save_locked()
1706
- _notify("🐛⚠️ 禅道 Bug #%s 判定非我方但转派失败:%s" % (bid, e))
1707
- return "need_manual"
1708
- base_claim.update({"state": "transferred", "note": note})
1709
- with _LOCK:
1710
- _STATE["claims"][bid] = base_claim
1711
- _save_locked()
1712
- _notify("🐛↩️ 禅道 Bug #%s 判定非我方,已转派 %s\n%s"
1713
- % (bid, target or "报告人", bug.get("title") or ""))
1714
- return "transferred"
1715
-
1716
- sides = _sides_to_fix(profile, tri)
1717
- if not sides:
1718
- # 纯对方端问题(含测试指错到我方账号的):直接转派正确负责人
1719
- other = tri["side"] if tri["side"] in SIDES else None
1720
- if other is None: # both 但我方两端都没配:整单转不了,留人工
1721
- base_claim["state"] = "need_manual"
1722
- base_claim["note"] = "双端问题但产品档案未配置我方端,请人工处理"
1723
- with _LOCK:
1724
- _STATE["claims"][bid] = base_claim
1725
- _save_locked()
1726
- _notify("🐛❓ 禅道 Bug #%s 为双端问题但未配我方端,留人工" % bid)
1727
- return "need_manual"
1728
- target = _route_account(profile, tri, other)
1729
- if not target:
1730
- base_claim["state"] = "need_manual"
1731
- base_claim["note"] = "判定为%s问题,但未配置%s负责人,无法转派" % (
1732
- SIDE_CN[other], SIDE_CN[other])
1733
- with _LOCK:
1734
- _STATE["claims"][bid] = base_claim
1735
- _save_locked()
1736
- _notify("🐛⚠️ 禅道 Bug #%s 判定为%s问题但未配负责人,请人工转派"
1737
- % (bid, SIDE_CN[other]))
1738
- return "need_manual"
1739
- text = _transfer_text({"bug_id": bug.get("id"), "title": bug.get("title") or "",
1740
- "triage": tri, "tasks": []}, profile, [], other)
1741
- try:
1742
- _transfer(cfg, bid, target, text)
1743
- except ZenError as e:
1744
- base_claim["state"] = "need_manual"
1745
- base_claim["note"] = "%s转派失败:%s" % (SIDE_CN[other], e)
1746
- with _LOCK:
1747
- _STATE["claims"][bid] = base_claim
1748
- _save_locked()
1749
- _notify("🐛⚠️ 禅道 Bug #%s 判定%s问题但转派失败:%s" % (bid, SIDE_CN[other], e))
1750
- return "need_manual"
1751
- base_claim.update({"state": "transferred",
1752
- "note": "%s问题(测试原指派 %s),已转派 %s"
1753
- % (SIDE_CN[other], _acct(bug.get("assignedTo")) or "?", target)})
1754
- with _LOCK:
1755
- _STATE["claims"][bid] = base_claim
1756
- _save_locked()
1757
- _notify("🐛🔁 禅道 Bug #%s 判定%s问题,已转派 %s(原指派 %s)\n%s"
1758
- % (bid, SIDE_CN[other], target, _acct(bug.get("assignedTo")) or "?",
1759
- bug.get("title") or ""))
1760
- return "transferred"
1761
-
1762
- # 我方端:逐端建修复任务(任一端建不起来 → 整单留人工,不做半截修复)
1763
- tasks = []
1764
- try:
1765
- for side in sides:
1766
- task, run = _launch_fix(bug, profile, side, cfg)
1767
- tasks.append({"side": side, "task_id": task["id"], "run_id": run["id"],
1768
- "state": "fixing"})
1769
- except Exception as e:
1770
- log.warning("zentao: bug %s 建修复任务失败:%s", bid, e)
1771
- base_claim["state"] = "need_manual"
1772
- base_claim["note"] = "建修复任务失败:%s" % e
1773
- with _LOCK:
1774
- _STATE["claims"][bid] = base_claim
1775
- _save_locked()
1776
- _notify("🐛⚠️ 禅道 Bug #%s 建修复任务失败:%s" % (bid, e))
1777
- return "need_manual"
1778
- base_claim["tasks"] = tasks
1779
- with _LOCK:
1780
- _STATE["claims"][bid] = base_claim
1781
- _save_locked()
1782
- _notify("🐛🔍 认领禅道 Bug #%s(%s问题·%s):%s\n修复任务:%s"
1783
- % (bid, tri["side"], "由规则" if tri.get("by") == "rule" else "AI 判定",
1784
- bug.get("title") or "", "、".join(t["task_id"] for t in tasks)))
1785
- return "fixing"
1786
-
1787
-
1788
- def _scan(cfg):
1789
- """按产品档案逐个拉 bug:认领新 bug + 重试 need_manual 的存量。返回认领数。"""
1790
- claimed = 0
1791
- profiles = _profiles(cfg)
1792
- if not profiles:
1793
- raise ZenError("未配置产品档案——请先在设置页添加产品并配置仓库")
1794
- for profile in profiles:
1795
- bugs = list_bugs(cfg, profile["product"], profile)
1796
- by_id = {str(b.get("id") or ""): b for b in bugs}
1797
- with _LOCK:
1798
- seen = set(_STATE["claims"].keys())
1799
- retry_ids = [k for k, c in _STATE["claims"].items()
1800
- if c.get("state") == "need_manual" and k in by_id
1801
- and str(by_id[k].get("status") or "") == "active"]
1802
- for bug in bugs:
1803
- bid = str(bug.get("id") or "")
1804
- if not bid or bid in seen or not _claimable(bug, profile):
1805
- continue
1806
- _route_one(bug, profile, cfg)
1807
- claimed += 1
1808
- # need_manual 重排查(bug 仍激活才出现在列表里)
1809
- for bid in retry_ids:
1810
- with _LOCK:
1811
- if _STATE["claims"].get(bid, {}).get("state") != "need_manual":
1812
- continue
1813
- _route_one(by_id[bid], profile, cfg, notify=False)
1814
- return claimed
1815
-
1816
-
1817
- def _poll(force=False):
1818
- """执行一次扫描;同一进程内只允许一个扫描实例。"""
1819
- if not _SCAN_LOCK.acquire(blocking=False):
1820
- return {"ok": False, "claimed": 0, "reconciled": False,
1821
- "error": "扫描正在进行,请稍候", "skipped": "in_progress"}
1822
- try:
1823
- return _poll_unlocked(force=force)
1824
- finally:
1825
- _SCAN_LOCK.release()
1826
-
1827
-
1828
- def _poll_unlocked(force=False):
1829
- """一次完整轮询:对账回写 + (到点/强制时)扫描认领。异常不外抛。"""
1830
- _ensure_loaded()
1831
- with _LOCK:
1832
- cfg = _cfg()
1833
- out = {"ok": True, "claimed": 0, "reconciled": True, "error": ""}
1834
- try:
1835
- _reconcile(cfg)
1836
- except Exception:
1837
- log.exception("zentao: 对账异常")
1838
- if not force:
1839
- if not cfg.get("poll_enabled"):
1840
- return {"ok": True, "claimed": 0, "reconciled": True, "error": "",
1841
- "skipped": "poll_disabled"}
1842
- with _LOCK:
1843
- nxt = _STATE.get("next_scan") or ""
1844
- if nxt:
1845
- try:
1846
- if datetime.fromisoformat(nxt) > datetime.now():
1847
- return {"ok": True, "claimed": 0, "reconciled": True,
1848
- "error": "", "skipped": "not_due"}
1849
- except ValueError:
1850
- pass
1851
- err = ""
1852
- try:
1853
- out["claimed"] = _scan(cfg)
1854
- except ZenError as e:
1855
- err = str(e)
1856
- except Exception as e:
1857
- err = "扫描失败:%s" % e
1858
- log.warning("zentao: %s", err)
1859
- now = datetime.now()
1860
- delay = timedelta(minutes=max(INTERVAL_MIN, int(cfg.get("interval_minutes") or INTERVAL_DEFAULT)))
1861
- if err:
1862
- delay = timedelta(minutes=RETRY_DELAY_MIN)
1863
- with _LOCK:
1864
- _STATE["last_scan"] = now.strftime("%Y-%m-%d %H:%M:%S")
1865
- _STATE["next_scan"] = (now + delay).strftime("%Y-%m-%d %H:%M:%S")
1866
- _STATE["last_error"] = err
1867
- _save_locked()
1868
- out["error"] = err
1869
- out["ok"] = not err
1870
- return out
1871
-
1872
-
1873
- def fire_due():
1874
- """automation._tick 每拍调用:内部自节流,没到点/未启用时零开销返回。"""
1875
- _ensure_loaded()
1876
- with _LOCK:
1877
- if not (_STATE.get("config") or {}).get("poll_enabled"):
1878
- return None
1879
- return _poll(force=False)
1880
-
1881
-
1882
- def scan_now():
1883
- """手动「立即扫描」:绕过轮询闸与节流。"""
1884
- return _poll(force=True)
1885
-
1886
-
1887
- def _boot_reconcile():
1888
- """服务重启后补一次对账:上个进程死亡窗口里的终态变化别等下个 tick
1889
- (#27697 案:服务死窗 7 小时,失败转派被拖到重启后才发现)。"""
1890
- try:
1891
- _ensure_loaded()
1892
- with _LOCK:
1893
- cfg = _cfg()
1894
- _reconcile(cfg)
1895
- except Exception:
1896
- log.exception("zentao: 启动补对账失败")
1897
-
1898
-
1899
- def start():
1900
- """服务启动接线:加载状态(含老配置迁移)。"""
1901
- global _BOOT_TIMER
1902
- n = load()
1903
- with _LOCK:
1904
- cfg = _STATE.get("config") or {}
1905
- if cfg.get("poll_enabled") and not _STATE.get("next_scan"):
1906
- _STATE["next_scan"] = time.strftime("%Y-%m-%d %H:%M:%S")
1907
- _save_locked()
1908
- pending = any(c.get("state") == "fixing" for c in _STATE["claims"].values())
1909
- if pending:
1910
- if _BOOT_TIMER is not None:
1911
- _BOOT_TIMER.cancel()
1912
- _BOOT_TIMER = threading.Timer(3.0, _boot_reconcile)
1913
- _BOOT_TIMER.daemon = True
1914
- _BOOT_TIMER.start()
1915
- return n
1916
-
1917
-
1918
- # ---------------------------------------------------------------- 配置管理
1919
-
1920
- def _norm_profile(p, idx):
1921
- if not isinstance(p, dict):
1922
- raise ValueError("产品档案 #%d 必须是对象" % idx)
1923
- try:
1924
- pid = int(p.get("product") or 0)
1925
- except (TypeError, ValueError):
1926
- raise ValueError("产品档案 #%d 的产品 ID 必须是整数" % idx)
1927
- if pid <= 0:
1928
- raise ValueError("产品档案 #%d 缺产品 ID" % idx)
1929
- prof = dict(_PROFILE_DEFAULTS)
1930
- prof["product"] = pid
1931
- prof["assigned_to"] = str(p.get("assigned_to") or "").strip()
1932
- try:
1933
- prof["severity_cap"] = max(0, min(4, int(p.get("severity_cap") or 0)))
1934
- except (TypeError, ValueError):
1935
- prof["severity_cap"] = 0
1936
- our = [s for s in SIDES if s in (p.get("our_sides") or [])]
1937
- prof["our_sides"] = our
1938
- repos = {"backend": dict(_REPO_DEFAULTS), "frontend": dict(_REPO_DEFAULTS)}
1939
- raw_repos = p.get("repos") if isinstance(p.get("repos"), dict) else {}
1940
- for side in SIDES:
1941
- r = raw_repos.get(side) if isinstance(raw_repos.get(side), dict) else {}
1942
- wd = str(r.get("workdir") or "").strip()
1943
- if wd:
1944
- rp = Path(wd).expanduser()
1945
- if not rp.is_absolute():
1946
- raise ValueError("产品 %d 的%s工作目录必须是绝对路径" % (pid, SIDE_CN[side]))
1947
- wd = str(rp)
1948
- repos[side] = {"workdir": wd,
1949
- "git_rev": str(r.get("git_rev") or "").strip(),
1950
- "verify_command": str(r.get("verify_command") or "").strip()}
1951
- # 工作目录留空合法:扫描时回落「默认保存路径」(_launch_fix 同口径),不在此拦截
1952
- prof["repos"] = repos
1953
- hints = {}
1954
- raw_hints = p.get("repo_hints") if isinstance(p.get("repo_hints"), dict) else {}
1955
- for side in SIDES:
1956
- hints[side] = str(raw_hints.get(side) or "").strip()
1957
- prof["repo_hints"] = hints
1958
- owners = {}
1959
- raw_owners = p.get("owners") if isinstance(p.get("owners"), dict) else {}
1960
- for k in ("backend", "frontend", "not_ours"):
1961
- owners[k] = str(raw_owners.get(k) or "").strip()
1962
- prof["owners"] = owners
1963
- routes = []
1964
- for r in (p.get("module_routes") or []):
1965
- if not isinstance(r, dict):
1966
- continue
1967
- try:
1968
- mid = int(r.get("module") or 0)
1969
- except (TypeError, ValueError):
1970
- raise ValueError("产品 %d 的模块路由:模块 ID 必须是整数" % pid)
1971
- if mid <= 0:
1972
- continue
1973
- side = str(r.get("side") or "")
1974
- if side not in TRIAGE_SIDES:
1975
- raise ValueError("产品 %d 的模块路由 side 必须是 %s 之一"
1976
- % (pid, "/".join(TRIAGE_SIDES)))
1977
- routes.append({"module": mid, "side": side,
1978
- "account": str(r.get("account") or "").strip()})
1979
- prof["module_routes"] = routes
1980
- return prof
1981
-
1982
-
1983
- def _norm_profiles(v):
1984
- if v is None:
1985
- return None
1986
- if not isinstance(v, list):
1987
- raise ValueError("product_profiles 必须是数组")
1988
- out, seen = [], set()
1989
- for i, p in enumerate(v):
1990
- prof = _norm_profile(p, i)
1991
- if prof["product"] in seen:
1992
- raise ValueError("产品 %d 配置重复" % prof["product"])
1993
- seen.add(prof["product"])
1994
- out.append(prof)
1995
- return out
1996
-
1997
-
1998
- def save_config(patch):
1999
- """部分更新配置(password 缺省或空串=不改;product_profiles 整体替换)。
2000
- 校验失败抛 ValueError。返回脱敏视图的 config。"""
2001
- _ensure_loaded()
2002
- patch = patch if isinstance(patch, dict) else {}
2003
- with _LOCK:
2004
- cfg = _cfg()
2005
- for k in _UPDATABLE:
2006
- if k not in patch:
2007
- continue
2008
- v = patch[k]
2009
- if k == "base_url":
2010
- v = str(v or "").strip().rstrip("/")
2011
- if v and not v.startswith(("http://", "https://")):
2012
- raise ValueError("禅道地址必须以 http:// 或 https:// 开头")
2013
- elif k == "account":
2014
- v = str(v or "").strip()
2015
- elif k == "password":
2016
- v = str(v or "")
2017
- if not v:
2018
- continue
2019
- elif k == "product_profiles":
2020
- v = _norm_profiles(v)
2021
- if v is None:
2022
- continue
2023
- elif k == "interval_minutes":
2024
- try:
2025
- v = max(INTERVAL_MIN, min(INTERVAL_MAX, int(v)))
2026
- except (TypeError, ValueError):
2027
- raise ValueError("interval_minutes 必须是 %d-%d 的整数"
2028
- % (INTERVAL_MIN, INTERVAL_MAX))
2029
- elif k in ("auto_resolve", "auto_merge", "triage_ai", "poll_enabled"):
2030
- v = bool(v)
2031
- cfg[k] = v
2032
- _STATE["config"] = cfg
2033
- if cfg.get("poll_enabled"):
2034
- _STATE["next_scan"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
2035
- _save_locked()
2036
- return view()["config"]
2037
-
2038
-
2039
- def bug_web_base(cfg=None):
2040
- """给前端拼 bug 详情页用的 web 根:探测出的有效地基(含自动补的 /zentao 子路径)
2041
- 优先,没探测过回落用户填的原始地址。返回 stripped 根或空串。"""
2042
- c = dict(cfg) if cfg else _cfg()
2043
- hit = resolved_base_url(c.get("base_url"))
2044
- if hit:
2045
- return hit
2046
- b = re.sub(r"/api\.php/v1$", "", str(c.get("base_url") or "").strip().rstrip("/"))
2047
- return b if b.startswith(("http://", "https://")) else ""
2048
-
2049
-
2050
- def bug_link_style(cfg=None):
2051
- """bug 详情链接形态:探测缓存优先,没探出来按伪静态(官方默认形态)。"""
2052
- c = dict(cfg) if cfg else _cfg()
2053
- b = str(c.get("base_url") or "").strip().rstrip("/")
2054
- return _WEBSTYLE.get(b) or "pathinfo"
2055
-
2056
-
2057
- def view():
2058
- """前端视图:配置脱敏(password 只回是否已设)+ claims 列表(新在前)。"""
2059
- _ensure_loaded()
2060
- with _LOCK:
2061
- cfg = dict(_cfg())
2062
- has_pw = bool(cfg.get("password"))
2063
- cfg["password"] = ""
2064
- cfg["has_password"] = has_pw
2065
- bug_base = bug_web_base(cfg)
2066
- claims = sorted(_STATE["claims"].values(),
2067
- key=lambda c: str(c.get("claimed_at") or ""), reverse=True)
2068
- return {"config": cfg,
2069
- "bug_base": bug_base,
2070
- "bug_style": bug_link_style(cfg),
2071
- "claims": [dict(c) for c in claims],
2072
- "last_scan": _STATE.get("last_scan") or "",
2073
- "next_scan": _STATE.get("next_scan") or "",
2074
- "last_error": _STATE.get("last_error") or ""}
2075
-
2076
-
2077
- def _test_reset():
2078
- """测试钩子:清空内存状态(配合重绑 _FILE + TUTTI_DATA 临时目录用)。"""
2079
- with _LOCK:
2080
- _STATE["config"] = dict(_CFG_DEFAULTS)
2081
- _STATE["claims"] = {}
2082
- _STATE["last_scan"] = _STATE["next_scan"] = _STATE["last_error"] = ""
2083
- _reset_token()
2084
- _APIBASE.clear()
2085
- _WEBSTYLE.clear()
2086
- _MODE.clear()
2087
- _OLD.update(api="", sid="", at=0.0)
2088
- _OLD_FORM["form"] = ""
2089
- _REST_LAST_ERR["msg"] = ""
2090
- globals()["_LOADED"] = True
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_minutes 节流。
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
+ GET {base}/api.php/v1/products|users 产品/账号清单(前端下拉辅助)
16
+
17
+ 地址自适应:根路径 tokens 404 时自动试 {base}/zentao/api.php/v1(官方一键安装包
18
+ 默认子路径部署),探测成功缓存在 _APIBASE(key=用户填的原始地址)。
19
+
20
+ 产品档案(product_profiles):每产品一份 {指派过滤, 严重度, 我方端 our_sides,
21
+ 后端/前端仓库(workdir/git_rev/verify_command), repo_hints, 负责人 owners,
22
+ 模块路由 module_routes}。老版扁平配置(products+单仓库)load 时自动迁移。
23
+
24
+ 排查(_triage):模块路由按 bug.module 精确匹配优先 → AI 兜底(triage_ai 开时
25
+ modelhub 单次调用,bookmeta 同款配方)→ unknown。判定 side ∈
26
+ backend | frontend | both | not_ours | unknown。
27
+
28
+ 流转规则(转派目标只认排查结论,与 bug 当前 assignedTo 无关——测试提错人也
29
+ 照样改派):
30
+ 我方端问题 → 建修复任务;成功=合并+resolve(fixed)+报告评论+指回报告人
31
+ 双端/我方一端 → 我方端修完(合并落库)后转派另一端负责人+评论,不 resolve
32
+ 纯对方端问题 → 不建任务,直接转派该端负责人+排查结论评论
33
+ 非我方 → 转派报告人(或 owners.not_ours)+评论;只转派不解决
34
+ unknown → 不碰 bug,need_manual + 群通知(下轮扫描 bug 仍激活则重排查)
35
+ 修复任务失败 → 评论尝试记录 + 转派该端负责人(模块路由 account > 端负责人)
36
+
37
+ 落库纪律:修复任务落单即带基线(档案配了用档案,没配取工作目录当前 HEAD)
38
+ 走任务分支隔离,修完的代码自动提交在任务分支上,对账合并后才算落库;
39
+ resolve 前再验一遍——没基线的任务改动只在工作区,有未提交的已跟踪改动
40
+ 不 resolve(转 done_manual 人工收口),auto_merge 关闭同理。
41
+ 对账(_reconcile)认任务最新一次运行:人工重试换 run 不影响回写判断。
42
+
43
+ 出网边界(SSRF 防护,_guard_url):请求目标来自用户自配禅道地址——内网按设计
44
+ 放行;强制 http(s)、解析主机并阻断云元数据/链路本地地址、禁跟随重定向。
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import html as _html
49
+ import hashlib
50
+ import ipaddress
51
+ import json
52
+ import logging
53
+ import os
54
+ import re
55
+ import socket
56
+ import threading
57
+ import time
58
+ import urllib.error
59
+ import urllib.parse
60
+ import urllib.request
61
+ from datetime import datetime, timedelta
62
+ from pathlib import Path
63
+
64
+ from . import jobs, paths, settings, store, tlsctx
65
+
66
+ log = logging.getLogger(__name__)
67
+
68
+ _LOCK = threading.RLock()
69
+ _SCAN_LOCK = threading.Lock() # 手动/定时扫描单飞,避免重复拉取和重复认领
70
+ _FILE = paths.DATA_DIR / "zentao.json"
71
+ _STATE = {
72
+ "config": {}, # 持久配置(_CFG_DEFAULTS)
73
+ "claims": {}, # str(bug_id) → claim dict(v2:含 triage/tasks)
74
+ "last_scan": "",
75
+ "next_scan": "",
76
+ "last_error": "",
77
+ }
78
+ _LOADED = False
79
+ _BOOT_TIMER = None # 启动补对账的兜底 Timer(测试里要能拿到并取消)
80
+
81
+ TOKEN_TTL = 23 * 3600
82
+ HTTP_TIMEOUT = 15
83
+ PAGE_LIMIT = 100
84
+ MAX_BUGS = 500
85
+ RESOLVE_MAX_ATTEMPTS = 3
86
+ RETRY_DELAY_MIN = 30
87
+ INTERVAL_MIN, INTERVAL_MAX = 5, 10080 # 扫描间隔(分钟):5 分钟 ~ 7 天
88
+ INTERVAL_DEFAULT = 5
89
+ _LEGACY_INTERVAL_HOURS_DEFAULT = 2 # 老配置 interval_hours 缺省值(迁移用)
90
+
91
+ SIDES = ("backend", "frontend")
92
+ TRIAGE_SIDES = ("backend", "frontend", "both", "not_ours") # unknown 单列
93
+ SIDE_CN = {"backend": "后端", "frontend": "前端"}
94
+
95
+ _CFG_DEFAULTS = {
96
+ "base_url": "",
97
+ "account": "",
98
+ "password": "",
99
+ "product_profiles": [], # 产品档案列表(_norm_profile 形状)
100
+ "auto_resolve": True,
101
+ "auto_merge": True,
102
+ "triage_ai": True, # 模块路由未命中时用 AI 兜底排查
103
+ "poll_enabled": False,
104
+ "interval_minutes": INTERVAL_DEFAULT,
105
+ }
106
+
107
+ _REPO_DEFAULTS = {"workdir": "", "git_rev": "", "verify_command": ""}
108
+
109
+ _PROFILE_DEFAULTS = {
110
+ "product": 0, # 产品 ID(必填唯一)
111
+ "assigned_to": "", # 只认领指派给该账号的 bug;空=不按指派过滤
112
+ "severity_cap": 0, # 严重度上限(1 最严重);0=不限
113
+ "our_sides": ["backend"], # 我方端(CodeBee 自动修);空=纯排查转派不修
114
+ "repos": {"backend": dict(_REPO_DEFAULTS), "frontend": dict(_REPO_DEFAULTS)},
115
+ "repo_hints": {"backend": "", "frontend": ""}, # AI 排查时的一句仓库描述
116
+ "owners": {"backend": "", "frontend": "", "not_ours": ""},
117
+ "module_routes": [], # [{module:int, side:backend|frontend|both|not_ours, account:""}]
118
+ }
119
+
120
+ _UPDATABLE = ("base_url", "account", "password", "product_profiles",
121
+ "auto_resolve", "auto_merge", "triage_ai",
122
+ "poll_enabled", "interval_minutes")
123
+
124
+
125
+ # ---------------------------------------------------------------- 出网边界(SSRF)
126
+
127
+ _META_HOSTS = {"metadata.google.internal", "metadata.goog"}
128
+ _NO_REDIRECT = None
129
+
130
+
131
+ def _no_redirect_opener():
132
+ """HTTP 重定向一概不跟:目标必须就是用户配置的那台禅道。"""
133
+ global _NO_REDIRECT
134
+ if _NO_REDIRECT is None:
135
+ class _Stop(urllib.request.HTTPRedirectHandler):
136
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
137
+ return None
138
+ _NO_REDIRECT = urllib.request.build_opener(
139
+ _Stop(), urllib.request.HTTPSHandler(context=tlsctx.context()))
140
+ return _NO_REDIRECT
141
+
142
+
143
+ def _guard_url(url):
144
+ """出网前的边界校验:协议白名单 + 主机解析阻断云元数据/链路本地地址。
145
+
146
+ 私网与环回按设计放行(禅道自建在内网是主场景,配置者即本机用户)。
147
+ 校验失败抛 ZenError。返回原 url。
148
+ """
149
+ from urllib.parse import urlparse
150
+ u = urlparse(url)
151
+ if u.scheme not in ("http", "https"):
152
+ raise ZenError("禅道地址只允许 http/https 协议")
153
+ host = (u.hostname or "").lower()
154
+ if not host:
155
+ raise ZenError("禅道地址缺少主机名")
156
+ if host in _META_HOSTS:
157
+ raise ZenError("不允许访问云元数据地址")
158
+ try:
159
+ infos = socket.getaddrinfo(host, None)
160
+ except OSError:
161
+ raise ZenError("禅道主机解析失败:%s" % host)
162
+ for info in infos:
163
+ ip = ipaddress.ip_address(info[4][0])
164
+ if ip.is_link_local or ip.is_reserved and not ip.is_private:
165
+ raise ZenError("不允许访问链路本地/保留地址:%s" % ip)
166
+ if ip.is_multicast or ip.is_unspecified:
167
+ raise ZenError("不允许访问多播/未指定地址:%s" % ip)
168
+ return url
169
+
170
+
171
+ # ---------------------------------------------------------------- 持久化
172
+
173
+ def _save_locked():
174
+ _FILE.parent.mkdir(parents=True, exist_ok=True)
175
+ tmp = _FILE.with_suffix(".tmp")
176
+ tmp.write_text(json.dumps({"version": 2, **_STATE},
177
+ ensure_ascii=False, indent=2), encoding="utf-8")
178
+ os.replace(str(tmp), str(_FILE))
179
+
180
+
181
+ def _normalize_claim(d):
182
+ """claim 归一:v1 单任务形状(task_id/run_id)→ v2 tasks 数组。"""
183
+ c = dict(d) if isinstance(d, dict) else {}
184
+ if isinstance(c.get("tasks"), list) and c["tasks"]:
185
+ tasks = [dict(t) for t in c["tasks"] if isinstance(t, dict)]
186
+ else:
187
+ tasks = [{"side": "backend", "task_id": c.get("task_id") or "",
188
+ "run_id": c.get("run_id") or "", "state": "fixing"}] \
189
+ if (c.get("task_id") or c.get("run_id")) else []
190
+ c["tasks"] = tasks
191
+ c.setdefault("triage", {"side": "", "reason": "", "by": "", "account": ""})
192
+ if not isinstance(c.get("triage"), dict):
193
+ c["triage"] = {"side": "", "reason": "", "by": "", "account": ""}
194
+ c.setdefault("note", "")
195
+ c.setdefault("attempts", 0)
196
+ return c
197
+
198
+
199
+ def _migrate_legacy(cfg):
200
+ """老扁平配置(products+单仓库)→ product_profiles。就地改写返回。"""
201
+ if cfg.get("product_profiles"):
202
+ return cfg
203
+ products = cfg.get("products") or []
204
+ if not isinstance(products, list) or not products:
205
+ return cfg
206
+ try:
207
+ pids = [int(p) for p in products if str(p).strip()]
208
+ except (TypeError, ValueError):
209
+ return cfg
210
+ backend = dict(_REPO_DEFAULTS)
211
+ for k in _REPO_DEFAULTS:
212
+ backend[k] = str(cfg.get(k) or "")
213
+ our = ["backend"]
214
+ if not backend["workdir"]:
215
+ our = [] # 老配置连工作目录都没配:纯路由
216
+ for pid in pids:
217
+ cfg.setdefault("product_profiles", []).append({
218
+ "product": pid,
219
+ "assigned_to": str(cfg.get("assigned_to") or ""),
220
+ "severity_cap": int(cfg.get("severity_cap") or 0),
221
+ "our_sides": list(our),
222
+ "repos": {"backend": backend, "frontend": dict(_REPO_DEFAULTS)},
223
+ "repo_hints": {"backend": "", "frontend": ""},
224
+ "owners": {"backend": "", "frontend": "", "not_ours": ""},
225
+ "module_routes": [],
226
+ })
227
+ return cfg
228
+
229
+
230
+ def load(force=False):
231
+ """从磁盘加载状态(幂等)。返回 claims 数。测试可重绑 _FILE 后 force=True。"""
232
+ global _LOADED
233
+ with _LOCK:
234
+ if _LOADED and not force:
235
+ return len(_STATE["claims"])
236
+ try:
237
+ data = json.loads(_FILE.read_text(encoding="utf-8"))
238
+ except Exception:
239
+ data = {}
240
+ cfg = data.get("config") if isinstance(data, dict) else None
241
+ # 老扁平配置先迁移成产品档案(老键 products/workdir/... 不在新 defaults 里,
242
+ # 必须在按 _CFG_DEFAULTS 过滤之前完成迁移)
243
+ if isinstance(cfg, dict) and not cfg.get("product_profiles"):
244
+ cfg = _migrate_legacy(dict(cfg))
245
+ # 老配置用 interval_hours(小时),新配置改用 interval_minutes(分钟):
246
+ # 用户没动过间隔(还是老默认 2 小时)就跟随新默认 5 分钟;真改过的按小时换算保留。
247
+ if isinstance(cfg, dict) and "interval_minutes" not in cfg \
248
+ and "interval_hours" in cfg:
249
+ try:
250
+ hours = int(cfg.get("interval_hours") or _LEGACY_INTERVAL_HOURS_DEFAULT)
251
+ except (TypeError, ValueError):
252
+ hours = _LEGACY_INTERVAL_HOURS_DEFAULT
253
+ cfg = dict(cfg)
254
+ mins = INTERVAL_DEFAULT if hours == _LEGACY_INTERVAL_HOURS_DEFAULT else hours * 60
255
+ cfg["interval_minutes"] = max(INTERVAL_MIN, min(INTERVAL_MAX, mins))
256
+ merged = dict(_CFG_DEFAULTS)
257
+ if isinstance(cfg, dict):
258
+ merged.update({k: v for k, v in cfg.items() if k in _CFG_DEFAULTS})
259
+ claims = data.get("claims") if isinstance(data, dict) else None
260
+ _STATE["config"] = merged
261
+ _STATE["claims"] = {str(k): _normalize_claim(v)
262
+ for k, v in (claims or {}).items()} \
263
+ if isinstance(claims, dict) else {}
264
+ _STATE["last_scan"] = str(data.get("last_scan") or "") if isinstance(data, dict) else ""
265
+ _STATE["next_scan"] = str(data.get("next_scan") or "") if isinstance(data, dict) else ""
266
+ _STATE["last_error"] = str(data.get("last_error") or "") if isinstance(data, dict) else ""
267
+ _LOADED = True
268
+ return len(_STATE["claims"])
269
+
270
+
271
+ def _ensure_loaded():
272
+ if not _LOADED:
273
+ load()
274
+
275
+
276
+ def _cfg():
277
+ cfg = dict(_CFG_DEFAULTS)
278
+ cfg.update({k: v for k, v in (_STATE.get("config") or {}).items()
279
+ if k in _CFG_DEFAULTS})
280
+ return cfg
281
+
282
+
283
+ def _profiles(cfg=None):
284
+ out = []
285
+ for p in (cfg or _cfg()).get("product_profiles") or []:
286
+ prof = dict(_PROFILE_DEFAULTS)
287
+ prof.update({k: v for k, v in (p or {}).items() if k in _PROFILE_DEFAULTS})
288
+ repos = dict(_PROFILE_DEFAULTS["repos"])
289
+ for side in SIDES:
290
+ r = dict(_REPO_DEFAULTS)
291
+ r.update({k: v for k, v in ((prof.get("repos") or {}).get(side) or {}).items()
292
+ if k in _REPO_DEFAULTS})
293
+ repos[side] = r
294
+ prof["repos"] = repos
295
+ hints = dict(_PROFILE_DEFAULTS["repo_hints"])
296
+ for side in SIDES:
297
+ hints[side] = str((prof.get("repo_hints") or {}).get(side) or "")
298
+ prof["repo_hints"] = hints
299
+ owners = dict(_PROFILE_DEFAULTS["owners"])
300
+ for k in owners:
301
+ owners[k] = str((prof.get("owners") or {}).get(k) or "").strip()
302
+ prof["owners"] = owners
303
+ prof["our_sides"] = [s for s in SIDES if s in (prof.get("our_sides") or [])]
304
+ prof["module_routes"] = [r for r in (prof.get("module_routes") or [])
305
+ if isinstance(r, dict)]
306
+ out.append(prof)
307
+ return out
308
+
309
+
310
+ def _profile_for(cfg, product_id):
311
+ try:
312
+ pid = int(product_id or 0)
313
+ except (TypeError, ValueError):
314
+ return None
315
+ for p in _profiles(cfg):
316
+ if p["product"] == pid:
317
+ return p
318
+ return None
319
+
320
+
321
+ # ---------------------------------------------------------------- 禅道客户端
322
+
323
+ class ZenError(Exception):
324
+ """禅道接口错误(message 人话,可直接展示)。"""
325
+
326
+
327
+ _TOKEN = {"v": "", "at": 0.0}
328
+ # 原始地址 → 探测成功的 api 基址(一键安装包常部署在 /zentao 子路径下,自动补探)
329
+ _APIBASE = {}
330
+ # 通道自适应:原始地址 → "rest"(≥15 REST v1)/ "old"(老版 module-method JSON 接口)
331
+ _MODE = {}
332
+ # web 端路由形态:原始地址 → "pathinfo"(伪静态 bug-view-N.html,官方默认)
333
+ # / "get"(index.php?m=bug&f=view&bugID=N)。bug 详情链接用(2026-09-23 真机实证
334
+ # 伪静态部署对 GET 式链接不路由)。
335
+ _WEBSTYLE = {}
336
+ _OLD = {"api": "", "sid": "", "at": 0.0}
337
+ _OLD_FORM = {"form": ""} # 命中的密码形态 "plain" | "md5chain",缓存避免重复试
338
+ _REST_LAST_ERR = {"msg": ""}
339
+ _OLD_TTL = 20 * 60
340
+
341
+
342
+ def _reset_token():
343
+ _TOKEN["v"] = ""
344
+ _TOKEN["at"] = 0.0
345
+
346
+
347
+ def resolved_base_url(base_url):
348
+ """探测成功后的有效地基(含自动补出的子路径);没探测过返回空串。"""
349
+ b = str(base_url or "").strip().rstrip("/")
350
+ hit = _APIBASE.get(b) or (_OLD.get("api") if _MODE.get(b) == "old" else "")
351
+ if not hit:
352
+ return ""
353
+ return re.sub(r"/api\.php/v1$", "", hit)
354
+
355
+
356
+ def probe_web_style(base_url):
357
+ """探测禅道 web 端路由形态(bug 详情链接用):GET web 根(裸根 + 常见 /zentao
358
+ 子路径都试),看登录跳转里是伪静态路由(user-login-xxx.html)还是 GET 式
359
+ (index.php?m=user)。尽力而为:任何网络/守卫异常回空串,绝不影响登录与
360
+ 扫描主流程;探出即缓存。"""
361
+ try:
362
+ b = _raw_base(base_url)
363
+ except ZenError:
364
+ return ""
365
+ if _WEBSTYLE.get(b):
366
+ return _WEBSTYLE[b]
367
+ style = ""
368
+ for root in (b + "/", b + "/zentao/"):
369
+ body = ""
370
+ try:
371
+ req = urllib.request.Request(_guard_url(root), headers={"Accept": "text/html"})
372
+ with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
373
+ body = (r.read(65536) or b"").decode("utf-8", "replace")
374
+ except Exception:
375
+ body = ""
376
+ if "user-login-" in body or re.search(r"[a-z]+-view-\d+[^\"]*\.html", body):
377
+ style = "pathinfo"
378
+ elif "index.php?m=" in body:
379
+ style = "get"
380
+ if style:
381
+ break
382
+ if style:
383
+ _WEBSTYLE[b] = style
384
+ return style
385
+
386
+
387
+ def _raw_base(base_url):
388
+ b = str(base_url or "").strip().rstrip("/")
389
+ if not b:
390
+ raise ZenError("禅道地址未配置")
391
+ if not b.startswith(("http://", "https://")):
392
+ raise ZenError("禅道地址必须以 http:// 或 https:// 开头")
393
+ return b
394
+
395
+
396
+ def _api_base(base_url):
397
+ b = _raw_base(base_url)
398
+ hit = _APIBASE.get(b)
399
+ if hit:
400
+ return hit
401
+ return b + "/api.php/v1"
402
+
403
+
404
+ def _base_candidates(b):
405
+ """api 基址候选:用户填的根路径 → 官方一键安装包常见的 /zentao 子路径。"""
406
+ out, seen = [], set()
407
+ for c in (b + "/api.php/v1", b + "/zentao/api.php/v1"):
408
+ if c not in seen:
409
+ seen.add(c)
410
+ out.append(c)
411
+ return out
412
+
413
+
414
+ def _rest_login(base_url, account, password):
415
+ """REST v1 通道取 token。成功返回 token(缓存基址);
416
+ 通道不可用(404 / 只认应用 code 的 200 信封)返回 None;
417
+ 账密被 REST 明确拒绝(401/403)或网络/守卫失败抛 ZenError。"""
418
+ b = _raw_base(base_url)
419
+ for api in _base_candidates(b):
420
+ url = _guard_url(api + "/tokens")
421
+ body = json.dumps({"account": str(account or ""), "password": str(password or "")},
422
+ ensure_ascii=False).encode("utf-8")
423
+ req = urllib.request.Request(url, data=body, method="POST", headers={
424
+ "Content-Type": "application/json", "Accept": "application/json"})
425
+ try:
426
+ with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
427
+ data = json.loads((r.read() or b"{}").decode("utf-8", "replace"))
428
+ except urllib.error.HTTPError as e:
429
+ try:
430
+ detail = json.loads((e.read() or b"").decode("utf-8", "replace"))
431
+ msg = (detail.get("error") or "") if isinstance(detail, dict) else ""
432
+ except Exception:
433
+ msg = ""
434
+ if e.code in (401, 403):
435
+ raise ZenError("禅道账号或密码不对(%s)%s" % (e.code, msg))
436
+ if e.code == 404:
437
+ _REST_LAST_ERR["msg"] = "REST 接口不存在(404)"
438
+ continue # 换下一个候选基址
439
+ raise ZenError("禅道返回 %s:%s" % (e.code, msg or "获取令牌失败"))
440
+ except ZenError:
441
+ raise
442
+ except Exception as e:
443
+ raise ZenError("连不上禅道(%s)——请检查地址与网络" % e)
444
+ tok = ""
445
+ if isinstance(data, dict):
446
+ tok = str(data.get("token") or "")
447
+ if not tok and isinstance(data.get("data"), dict):
448
+ tok = str(data["data"].get("token") or "")
449
+ if tok:
450
+ _APIBASE[b] = api
451
+ _TOKEN["v"] = tok
452
+ _TOKEN["at"] = time.time()
453
+ probe_web_style(b) # bug 链接形态探测,失败不影响登录
454
+ return tok
455
+ # HTTP 200 但没有 token:REST 在但不认账密(如只认应用 code 的部署)
456
+ _REST_LAST_ERR["msg"] = "REST 接口不认账密(%s)" % (
457
+ str(data.get("errmsg") or data.get("error") or "响应里没有 token"))
458
+ return None
459
+
460
+
461
+ def _token(cfg, force=False):
462
+ if force or not _TOKEN["v"] or time.time() - _TOKEN["at"] > TOKEN_TTL:
463
+ tok = _rest_login(cfg.get("base_url"), cfg.get("account"), cfg.get("password"))
464
+ if not tok:
465
+ raise ZenError(_REST_LAST_ERR.get("msg") or "REST 通道不可用")
466
+ return tok
467
+ return _TOKEN["v"]
468
+
469
+
470
+ def _api(method, path, cfg=None, body=None):
471
+ """调禅道 API。返回 dict(非 dict 响应返回 {})。401/403 自动重取 token 重试一次。"""
472
+ c = cfg or _cfg()
473
+ for attempt in (1, 2):
474
+ headers = {"Accept": "application/json", "Token": _token(c, force=(attempt == 2))}
475
+ url = _guard_url(_api_base(c.get("base_url")) + path)
476
+ data = None
477
+ if body is not None:
478
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
479
+ headers["Content-Type"] = "application/json"
480
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
481
+ try:
482
+ with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
483
+ out = json.loads((r.read() or b"{}").decode("utf-8", "replace"))
484
+ return out if isinstance(out, dict) else {"_list": out}
485
+ except urllib.error.HTTPError as e:
486
+ raw = ""
487
+ try:
488
+ raw = (e.read() or b"").decode("utf-8", "replace")
489
+ except Exception:
490
+ pass
491
+ detail = ""
492
+ try:
493
+ d = json.loads(raw)
494
+ if isinstance(d, dict):
495
+ detail = str(d.get("error") or d.get("message") or "")
496
+ except Exception:
497
+ pass
498
+ if e.code in (401, 403) and attempt == 1:
499
+ _reset_token()
500
+ continue
501
+ raise ZenError("禅道接口 %s(%s)%s" % (path, e.code, detail or "调用失败"))
502
+ except ZenError:
503
+ raise
504
+ except Exception as e:
505
+ raise ZenError("连不上禅道(%s)" % e)
506
+ raise ZenError("禅道认证失败(token 两次获取后仍被拒绝,检查账号权限)")
507
+
508
+
509
+ # ---------------------------------------------------------------- 老版 JSON 接口适配
510
+ # 禅道 15 以前的经典入口:/zentao/api-getsessionid.json 取会话 → user-login.json
511
+ # 账密登录(Cookie zentaosid)→ {module}-{method}-{参数}.json 调业务。响应是双层
512
+ # 信封 {"status","data":"<json 字符串>"}。有的部署 REST 只认应用 code,账密只能
513
+ # 走这条通道,故在 _call 里自动探测分路。REST 路径在这里翻译成老接口形态。
514
+
515
+ def _old_parse(raw):
516
+ """老信封解析。非 JSON(多半是登录重定向 HTML)返回 None = 会话死/不可用。"""
517
+ try:
518
+ outer = json.loads(raw.decode("utf-8", "replace"))
519
+ except Exception:
520
+ return None
521
+ if isinstance(outer, dict) and isinstance(outer.get("data"), str):
522
+ try:
523
+ outer["data"] = json.loads(outer["data"])
524
+ except Exception:
525
+ pass
526
+ return outer if isinstance(outer, dict) else None
527
+
528
+
529
+ def _old_fail(outer):
530
+ """status=failed / result=fail → 人话错误文本;成功返回空串。"""
531
+ if not isinstance(outer, dict):
532
+ return ""
533
+ if outer.get("status") == "failed" or outer.get("result") == "fail":
534
+ return str(outer.get("reason") or outer.get("message")
535
+ or outer.get("error") or "调用失败")
536
+ return ""
537
+
538
+
539
+ def _old_get(api, path):
540
+ url = _guard_url(api + path)
541
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
542
+ with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
543
+ return r.read() or b""
544
+
545
+
546
+ def _old_post(api, path, form):
547
+ url = _guard_url(api + path)
548
+ data = urllib.parse.urlencode(form or {}).encode("utf-8")
549
+ req = urllib.request.Request(url, data=data, method="POST", headers={
550
+ "Content-Type": "application/x-www-form-urlencoded",
551
+ "Accept": "application/json"})
552
+ with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
553
+ return r.read() or b""
554
+
555
+
556
+ def _old_session(api):
557
+ """取新会话。返回 (sid, rand);通道不可用返回 None。"""
558
+ try:
559
+ raw = _old_get(api, "/api-getsessionid.json")
560
+ except urllib.error.HTTPError:
561
+ return None
562
+ except ZenError:
563
+ raise
564
+ except Exception as e:
565
+ raise ZenError("连不上禅道(%s)——请检查地址与网络" % e)
566
+ outer = _old_parse(raw)
567
+ if not outer or not isinstance(outer.get("data"), dict):
568
+ return None
569
+ d = outer["data"]
570
+ return str(d.get("sessionID") or ""), str(d.get("rand") or "")
571
+
572
+
573
+ def _old_probe_authed(api, sid):
574
+ """登录成功的兜底判据:带会话调需登录端点拿得到 JSON 信封(未登录会被
575
+ 重定向到登录页 HTML,解析出来是 None)。"""
576
+ try:
577
+ raw = _old_get(api, "/my-index.json?zentaosid=" + sid)
578
+ except Exception:
579
+ return False
580
+ return _old_parse(raw) is not None
581
+
582
+
583
+ def _old_identify(api, sid, rand, account, password, form):
584
+ """老接口账密登录。返回 (ok, 人话错误)。form=plain|md5chain。"""
585
+ pw = str(password or "")
586
+ if form == "md5chain":
587
+ pw = hashlib.md5((hashlib.md5(pw.encode("utf-8")).hexdigest()
588
+ + str(rand)).encode("utf-8")).hexdigest()
589
+ try:
590
+ raw = _old_post(api, "/user-login.json?zentaosid=" + sid,
591
+ {"account": str(account or ""), "password": pw})
592
+ except Exception as e:
593
+ return False, "连不上禅道(%s)" % e
594
+ outer = _old_parse(raw)
595
+ if outer is None:
596
+ return False, "登录响应不可识别"
597
+ err = _old_fail(outer)
598
+ if err:
599
+ return False, err
600
+ # 成功响应形状各版本不一:user 可能在 data 里(老)或顶层(实测某老版部署)
601
+ d = outer.get("data")
602
+ if (isinstance(d, dict) and isinstance(d.get("user"), dict)) \
603
+ or isinstance(outer.get("user"), dict) or _old_probe_authed(api, sid):
604
+ return True, ""
605
+ return False, "登录未被接受(账号或密码不对,或账号被锁)"
606
+
607
+
608
+ def _old_login(cfg, rest_err=""):
609
+ """老接口通道登录(带 /zentao 子路径候选)。成功置 _OLD 缓存与密码形态。"""
610
+ b = _raw_base(cfg.get("base_url"))
611
+ account, password = cfg.get("account"), cfg.get("password")
612
+ cands, seen = [], set()
613
+ for c in (b + "/zentao", b):
614
+ if c not in seen:
615
+ seen.add(c)
616
+ cands.append(c)
617
+ last = ""
618
+ for api in cands:
619
+ sess = _old_session(api)
620
+ if not sess:
621
+ continue
622
+ sid, rand = sess
623
+ forms = [_OLD_FORM["form"] or "plain", "md5chain", "plain"]
624
+ tried = set()
625
+ for form in [f for f in forms if not (f in tried or tried.add(f))]:
626
+ ok, err = _old_identify(api, sid, rand, account, password, form)
627
+ if ok:
628
+ _OLD.update(api=api, sid=sid, at=time.time())
629
+ _OLD_FORM["form"] = form
630
+ _MODE[b] = "old"
631
+ probe_web_style(b) # bug 链接形态探测,失败不影响登录
632
+ return
633
+ last = err
634
+ break # 同一台服务,账密结果与子路径无关,别再烧尝试次数
635
+ if not last:
636
+ raise ZenError("禅道老版接口不可用(会话接口无响应——地址若是子目录部署要带上子目录)"
637
+ + (";%s" % rest_err if rest_err else ""))
638
+ if "账号" in last or "密码" in last or "锁定" in last:
639
+ raise ZenError(last)
640
+ raise ZenError("禅道老接口登录失败:%s" % last)
641
+
642
+
643
+ def _old_call(method, path, cfg, body=None):
644
+ """老接口执行:翻译 REST 路径 → module-method.json,返回 REST 同形状的 dict。"""
645
+ b = _raw_base(cfg.get("base_url"))
646
+ if _MODE.get(b) != "old" or not _OLD["sid"] or time.time() - _OLD["at"] > _OLD_TTL:
647
+ _old_login(cfg)
648
+ parts = urllib.parse.urlsplit(path)
649
+ q = dict(urllib.parse.parse_qsl(parts.query))
650
+ p = parts.path
651
+ for attempt in (1, 2):
652
+ try:
653
+ return _old_route(_OLD["api"], method, p, q, body)
654
+ except _OldSessionDead:
655
+ if attempt == 2:
656
+ break
657
+ _OLD["sid"] = ""
658
+ _old_login(cfg)
659
+ except urllib.error.HTTPError as e:
660
+ raise ZenError("禅道老接口 %s(%s)调用失败" % (p, e.code))
661
+ raise ZenError("禅道会话两次登录后仍失效(检查账号权限)")
662
+
663
+
664
+ class _OldSessionDead(Exception):
665
+ pass
666
+
667
+
668
+ def _pairs_to_list(pairs, id_key, name_key):
669
+ """{id/name: 值} 形态的 pairs → [{id,name}] 列表(值可能为串或对象)。"""
670
+ out = []
671
+ for k, v in (pairs or {}).items():
672
+ if isinstance(v, dict):
673
+ item = dict(v)
674
+ item.setdefault(id_key, k)
675
+ else:
676
+ item = {id_key: k, name_key: v}
677
+ out.append(item)
678
+ return out
679
+
680
+
681
+ def _module_items(d):
682
+ """老版模块树形状兼容(2026-09-21 真机反馈「响应形状不认识」):
683
+ sons/modules 数组、{id: {name,...}} 字典、children/sons 嵌套树,统一
684
+ 递归展开成 [{id,name}] 平铺清单(id=0 是树根容器,跳过);全数字键的
685
+ {id: 名字} 扁平映射也认(部分版本的模块摘要形状)。"""
686
+ out = []
687
+
688
+ def walk(node):
689
+ if isinstance(node, list):
690
+ for x in node:
691
+ walk(x)
692
+ elif isinstance(node, dict):
693
+ if node.get("id") not in (None, "", 0):
694
+ out.append(node)
695
+ for k in ("children", "sons"):
696
+ if isinstance(node.get(k), (list, dict)):
697
+ walk(node[k])
698
+ else:
699
+ if node and all(str(k).isdigit() for k in node.keys()):
700
+ flat = [{"id": k, "name": str(v)}
701
+ for k, v in node.items() if str(v or "").strip()]
702
+ if flat:
703
+ out.extend(flat)
704
+ return
705
+ for v in node.values():
706
+ walk(v)
707
+
708
+ if isinstance(d, dict):
709
+ # tree 优先:真机 tree=带 children 的完整嵌套树,sons 只有顶层扁平表
710
+ # (走 sons 会漏子模块)
711
+ walk(d.get("tree") or d.get("sons") or d.get("modules") or d)
712
+ elif isinstance(d, list):
713
+ walk(d)
714
+ return out
715
+
716
+
717
+ def _old_route(api, method, p, q, body):
718
+ """路径翻译 + 请求 + 归一。会话死抛 _OldSessionDead。"""
719
+
720
+ def go(raw, translator=None):
721
+ outer = _old_parse(raw)
722
+ if outer is None:
723
+ txt = raw.decode("utf-8", "replace")
724
+ if "user-login" in txt:
725
+ raise _OldSessionDead() # 登录重定向 = 会话死
726
+ # 其余 HTML 是 js::locate/alert 回包(老禅道 POST 动作成功就回这种)
727
+ return {"_js": True}
728
+ err = _old_fail(outer)
729
+ if err:
730
+ raise ZenError("禅道老接口%s:%s" % (translator or "", err))
731
+ return outer.get("data")
732
+
733
+ def get(path):
734
+ try:
735
+ raw = _old_get(api, path + ("&" if "?" in path else "?")
736
+ + "zentaosid=" + _OLD["sid"])
737
+ except urllib.error.HTTPError as e:
738
+ raw = e.read() or b""
739
+ if _old_parse(raw) is None:
740
+ raise _OldSessionDead() # 非 JSON:多半被重定向到登录页
741
+ return raw
742
+
743
+ def post(path, form):
744
+ try:
745
+ raw = _old_post(api, path + ("&" if "?" in path else "?")
746
+ + "zentaosid=" + _OLD["sid"], form)
747
+ except urllib.error.HTTPError as e:
748
+ raw = e.read() or b""
749
+ if e.code in (401, 403) or _old_parse(raw) is None:
750
+ raise _OldSessionDead()
751
+ return raw
752
+
753
+ m = re.match(r"^/products/(\d+)/bugs$", p)
754
+ if m and method == "GET":
755
+ pid = m.group(1)
756
+ page = max(1, int(q.get("page") or 1))
757
+ out, total, seen_ids = [], 0, set()
758
+ # 翻页用路径参数形态(实测部分老版不认 query 翻页参数):带 branch 段;
759
+ # 若 recPerPage 没生效(老版本无 branch 段会错位解析)回落 query 形态。
760
+ branch_form = True
761
+ for pg in range(page, page + 6): # 去重兜底:翻页参数不被认时不会死循环
762
+ if branch_form:
763
+ pathq = "/bug-browse-%s-0-unclosed-0-id_desc-0-%d-%d.json" % (pid, PAGE_LIMIT, pg)
764
+ else:
765
+ pathq = "/bug-browse-%s.json?browseType=unclosed&orderBy=id_desc" \
766
+ "&recPerPage=%d&pagerID=%d" % (pid, PAGE_LIMIT, pg)
767
+ d = go(get(pathq), "(拉 bug 列表)")
768
+ if branch_form and isinstance(d, dict):
769
+ try:
770
+ got = int((d.get("pager") or {}).get("recPerPage") or 0)
771
+ except (TypeError, ValueError):
772
+ got = 0
773
+ if got != PAGE_LIMIT:
774
+ branch_form = False
775
+ continue # 形态没对上:换 query 形态重拉本页
776
+ if not isinstance(d, dict):
777
+ break
778
+ bugs = d.get("bugs") if isinstance(d.get("bugs"), list) else []
779
+ fresh = [x for x in bugs if isinstance(x, dict)
780
+ and str(x.get("id")) not in seen_ids]
781
+ for x in fresh:
782
+ seen_ids.add(str(x.get("id")))
783
+ out.extend(fresh)
784
+ try:
785
+ total = int((d.get("pager") or {}).get("recTotal") or 0)
786
+ except (TypeError, ValueError, AttributeError):
787
+ total = 0
788
+ if not fresh or (total and len(out) >= total) or len(out) >= MAX_BUGS:
789
+ break
790
+ return {"bugs": out[:MAX_BUGS], "total": total or len(out)}
791
+
792
+ m = re.match(r"^/products/(\d+)/modules$", p)
793
+ if m and method == "GET":
794
+ # 2026-09-22 真机实探:该版模块行 type=story,viewType=module 查出空树
795
+ # (status 仍 success)——「响应形状不认识」的真根因。改按 bug 视图拉
796
+ # (bug 表单模块下拉用的就是这棵树),story/case 兜底。
797
+ pid = m.group(1)
798
+ last, saw_js = None, False
799
+ for vt in ("bug", "story", "case"):
800
+ d = go(get("/tree-browse-%s-%s.json" % (pid, vt)), "(拉模块清单)")
801
+ last = d
802
+ if isinstance(d, dict) and d.get("_js"):
803
+ saw_js = True # 接口回了页面脚本:换视图再试
804
+ continue
805
+ items = [x for x in _module_items(d) if isinstance(x, dict) and x.get("id")]
806
+ if items:
807
+ return {"_list": items}
808
+ if saw_js:
809
+ raise ZenError("禅道老接口(拉模块清单):回了页面脚本而不是数据"
810
+ "(模块树接口不在或被重定向)——请在禅道产品视图 URL 里查模块 ID 手工填写")
811
+ keys = ",".join(sorted(str(k) for k in last.keys())) \
812
+ if isinstance(last, dict) else type(last).__name__
813
+ raise ZenError("该产品模块树为空(bug/story/case 三个视图都没拉到模块,"
814
+ "响应顶层键:%s)——请到禅道确认产品下建过模块,或手工填模块 ID" % keys)
815
+
816
+ if p == "/products" and method == "GET":
817
+ d = go(get("/api-getmodel-product-getpairs.json"), "(拉产品清单)")
818
+ prods = d.get("products") if isinstance(d, dict) else None
819
+ if isinstance(prods, list): # 部分老版回数组形状 [{id,name,...}]
820
+ return {"_list": [x for x in prods if isinstance(x, dict) and x.get("id")]}
821
+ pairs = prods if isinstance(prods, dict) else d
822
+ return {"_list": _pairs_to_list(pairs, "id", "name")}
823
+
824
+ if p == "/users" and method == "GET":
825
+ d = go(get("/api-getmodel-user-getpairs.json"), "(拉账号清单)")
826
+ return {"_list": _pairs_to_list(d, "account", "realname")}
827
+
828
+ m = re.match(r"^/bugs/(\d+)$", p)
829
+ if m and method == "GET":
830
+ d = go(get("/bug-view-%s.json" % m.group(1)), "(查 bug)")
831
+ return d.get("bug") if isinstance(d, dict) and isinstance(d.get("bug"), dict) else d
832
+
833
+ m = re.match(r"^/bugs/(\d+)/resolve$", p)
834
+ if m and method == "POST":
835
+ form = {k: str(v or "") for k, v in (body or {}).items()}
836
+ go(post("/bug-resolve-%s.json" % m.group(1), form), "(resolve)")
837
+ return {"ok": True}
838
+
839
+ m = re.match(r"^/bugs/(\d+)$", p)
840
+ if m and method == "PUT":
841
+ form = {k: str(v or "") for k, v in (body or {}).items() if k in ("assignedTo", "comment")}
842
+ go(post("/bug-assignTo-%s.json" % m.group(1), form), "(转派)")
843
+ return {"ok": True}
844
+
845
+ raise ZenError("禅道老接口不认识该调用:%s %s(请升级禅道到 ≥15 用 REST 接口)"
846
+ % (method, p))
847
+
848
+
849
+ def _call(method, path, cfg=None, body=None):
850
+ """统一入口:REST v1 优先;通道探明后按模式分发(账密不被 REST 接受时自动
851
+ 切老版 JSON 接口)。所有业务调用都走这里,拿到的都是 REST 形状。"""
852
+ c = cfg or _cfg()
853
+ b = _raw_base(c.get("base_url"))
854
+ mode = _MODE.get(b)
855
+ if not mode:
856
+ tok = _rest_login(b, c.get("account"), c.get("password"))
857
+ if tok:
858
+ _MODE[b] = mode = "rest"
859
+ else:
860
+ _old_login(c, _REST_LAST_ERR.get("msg", ""))
861
+ mode = "old"
862
+ if mode == "rest":
863
+ return _api(method, path, cfg=c, body=body)
864
+ return _old_call(method, path, c, body=body)
865
+
866
+
867
+ def _acct(v):
868
+ """assignedTo/openedBy 兼容:新版是 {account,...} 用户对象,老版可能是
869
+ [账号, 姓名] 数组或账号串。"""
870
+ if isinstance(v, dict):
871
+ return str(v.get("account") or "").strip()
872
+ if isinstance(v, (list, tuple)) and v:
873
+ return str(v[0] or "").strip()
874
+ return str(v or "").strip()
875
+
876
+
877
+ def _strip_html(raw):
878
+ """steps 字段是富文本 HTML:剥标签转纯文本。"""
879
+ txt = str(raw or "")
880
+ txt = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", txt)
881
+ txt = re.sub(r"(?i)<br\s*/?>|</p>|</li>|</div>|</tr>", "\n", txt)
882
+ txt = re.sub(r"<[^>]+>", " ", txt)
883
+ txt = _html.unescape(txt)
884
+ txt = re.sub(r"[ \t\r]+", " ", txt)
885
+ txt = re.sub(r"\n\s*\n+", "\n", txt)
886
+ return txt.strip()
887
+
888
+
889
+ def _severity(bug):
890
+ try:
891
+ return int(bug.get("severity") or 0)
892
+ except (TypeError, ValueError):
893
+ return 0
894
+
895
+
896
+ def _claimable(bug, profile):
897
+ """认领过滤:active + 指派 + 严重度(产品由档案本身界定)。"""
898
+ if str(bug.get("status") or "") != "active":
899
+ return False
900
+ assigned = str(profile.get("assigned_to") or "").strip()
901
+ cap = int(profile.get("severity_cap") or 0)
902
+ if assigned and _acct(bug.get("assignedTo")) != assigned:
903
+ return False
904
+ if cap and _severity(bug) > cap:
905
+ return False
906
+ return True
907
+
908
+
909
+ def list_bugs(cfg, product_id, profile=None):
910
+ """拉一个产品下的激活 bug(分页,总量封顶 MAX_BUGS)。
911
+
912
+ REST v1 支持时把状态/指派人过滤下推给禅道,减少无关 bug 的分页传输;
913
+ 本地的 _claimable 仍保留,兼容老接口或禅道忽略未知查询参数的情况。
914
+ """
915
+ out = []
916
+ for page in range(1, 6):
917
+ query = {"page": page, "limit": PAGE_LIMIT, "status": "active"}
918
+ assigned = str((profile or {}).get("assigned_to") or "").strip()
919
+ if assigned:
920
+ query["assignedTo"] = assigned
921
+ d = _call("GET", "/products/%s/bugs?%s" % (
922
+ product_id, urllib.parse.urlencode(query)), cfg=cfg)
923
+ bugs = d.get("bugs") if isinstance(d, dict) else None
924
+ if not isinstance(bugs, list):
925
+ raise ZenError("禅道 bug 列表响应形状不对(预期 bugs 数组)")
926
+ out.extend(b for b in bugs if isinstance(b, dict))
927
+ total = 0
928
+ try:
929
+ total = int(d.get("total") or 0)
930
+ except (TypeError, ValueError):
931
+ pass
932
+ if len(out) >= MAX_BUGS or (total and len(out) >= total) or len(bugs) < PAGE_LIMIT:
933
+ break
934
+ return out[:MAX_BUGS]
935
+
936
+
937
+ def weekly_brief(limit=12):
938
+ """本周工作素材(工作汇报起草注入用,2026-09-23 Weekly Report Generator 借鉴
939
+ ——「从工作系统取数入素材」):分配给我的活跃任务 + 指派给我的活跃 Bug 摘要。
940
+ 尽力而为:未配置/不可达/响应形状不对一律返回空串,绝不阻塞汇报起草。
941
+ 多产品档案逐个聚合(同 _scan 口径),条目总量封顶 limit*2。"""
942
+ lines = []
943
+ try:
944
+ cfg = _cfg()
945
+ for profile in _profiles(cfg):
946
+ assigned = str(profile.get("assigned_to") or "").strip()
947
+ if not assigned:
948
+ continue
949
+ try:
950
+ d = _call("GET", "/tasks?%s" % urllib.parse.urlencode(
951
+ {"assignedTo": assigned, "limit": limit}), cfg=cfg)
952
+ tasks = d.get("tasks") if isinstance(d, dict) else None
953
+ for t in (tasks or [])[:limit]:
954
+ if isinstance(t, dict) and t.get("status") in ("wait", "doing"):
955
+ lines.append("- [任务#%s] %s(%s)" % (
956
+ t.get("id"), t.get("name") or "", t.get("status") or ""))
957
+ except Exception:
958
+ pass
959
+ try:
960
+ for b in list_bugs(cfg, profile.get("product"), profile)[:limit]:
961
+ lines.append("- [Bug#%s] %s(严重级 %s)" % (
962
+ b.get("id"), b.get("title") or "",
963
+ b.get("severity") or b.get("status") or ""))
964
+ except Exception:
965
+ pass
966
+ except Exception:
967
+ return ""
968
+ return "\n".join(lines[:limit * 2])
969
+
970
+
971
+ def fetch_modules(product_id):
972
+ """拉产品模块清单(模块路由配置辅助)。接口不存在/失败报人话,提示手填 ID。"""
973
+ _ensure_loaded()
974
+ cfg = _cfg()
975
+ try:
976
+ d = _call("GET", "/products/%s/modules" % product_id, cfg=cfg)
977
+ except ZenError as e:
978
+ msg = str(e)
979
+ # 只有「接口不存在」类错误才补「没有该接口」提示;老通道自己报的
980
+ # 空树/脚本回包已带人话结论,别再叠一层误导
981
+ if "404" in msg or "不认识" in msg:
982
+ msg += "——也可能你的禅道没有该接口:请在禅道产品视图 URL 里查模块 ID 手工填写"
983
+ return {"ok": False, "error": msg}
984
+ # 形状兼容器({_list} / {modules:[...]} / {id:{...}} 字典 / 裸数组)
985
+ items = _list_items(d, "modules")
986
+ if items is None:
987
+ items = d.get("_list") if isinstance(d.get("_list"), list) else None
988
+ out = []
989
+ for m in items or []:
990
+ if isinstance(m, dict) and m.get("id"):
991
+ out.append({"id": m.get("id"), "name": str(m.get("name") or "")})
992
+ if not out:
993
+ if isinstance(d, dict) and set(d.keys()) == {"_list"}:
994
+ # 自家归一包装(裸数组进 _api/_old_route 时打的包):空清单=
995
+ # 产品真没模块,不是形状问题,别拿顶层键吓人
996
+ return {"ok": False,
997
+ "error": "禅道回了空模块清单(该产品可能没建模块)——请手工填模块 ID"}
998
+ # 带响应形状摘要帮排查(顶层键名,不吐正文——用户实测反馈
999
+ # 「响应形状不认识」却看不到真实形状,没法报修)
1000
+ keys = ",".join(sorted(str(k) for k in d.keys())) if isinstance(d, dict) else type(d).__name__
1001
+ return {"ok": False,
1002
+ "error": "模块清单为空或响应形状不认识(响应顶层键:%s)——请手工填模块 ID" % (keys or "非字典")}
1003
+ return {"ok": True, "modules": out[:200]}
1004
+
1005
+
1006
+ def _list_items(d, key):
1007
+ """禅道 REST v1 清单响应形状兼容:{key:[...]} / 裸数组(_list) / {id:{...}} 字典。"""
1008
+ if not isinstance(d, dict):
1009
+ return None
1010
+ if isinstance(d.get(key), list):
1011
+ return d[key]
1012
+ if isinstance(d.get("_list"), list):
1013
+ return d["_list"]
1014
+ vals = [v for v in d.values() if isinstance(v, dict) and v.get("id")]
1015
+ return vals or None
1016
+
1017
+
1018
+ def fetch_products():
1019
+ """拉产品清单(产品 ID 下拉选择辅助)。接口不存在/失败返回 ok:False+人话。"""
1020
+ _ensure_loaded()
1021
+ cfg = _cfg()
1022
+ out, total = [], 0
1023
+ try:
1024
+ page = 1
1025
+ while page <= 3:
1026
+ d = _call("GET", "/products?page=%d&limit=100" % page, cfg=cfg)
1027
+ items = _list_items(d, "products")
1028
+ if not items:
1029
+ break
1030
+ out.extend(it for it in items
1031
+ if isinstance(it, dict) and str(it.get("id") or "") != "")
1032
+ try:
1033
+ total = int(d.get("total") or 0)
1034
+ except (TypeError, ValueError, AttributeError):
1035
+ total = 0
1036
+ if not total or len(out) >= total:
1037
+ break
1038
+ page += 1
1039
+ except ZenError as e:
1040
+ return {"ok": False, "error": "%s——请先「测试连接」确认地址与账号可用" % e}
1041
+ if not out:
1042
+ return {"ok": False, "error": "禅道里没有产品,或响应形状不认识(REST API 需 ≥15)"}
1043
+ seen, uniq = set(), []
1044
+ for m in out:
1045
+ try:
1046
+ pid = int(m["id"])
1047
+ except (TypeError, ValueError):
1048
+ pid = m["id"]
1049
+ if pid in seen:
1050
+ continue
1051
+ seen.add(pid)
1052
+ uniq.append({"id": pid, "name": str(m.get("name") or ""),
1053
+ "status": str(m.get("status") or "")})
1054
+ uniq.sort(key=lambda x: str(x["id"]))
1055
+ return {"ok": True, "products": uniq[:500]}
1056
+
1057
+
1058
+ def fetch_users():
1059
+ """拉禅道账号清单(负责人/转派下拉辅助)。失败返回 ok:False,前端仍可手填。"""
1060
+ _ensure_loaded()
1061
+ cfg = _cfg()
1062
+ try:
1063
+ d = _call("GET", "/users?limit=1000", cfg=cfg)
1064
+ except ZenError as e:
1065
+ return {"ok": False, "error": "%s——账号清单拉不到,仍可手填账号" % e}
1066
+ items = None
1067
+ if isinstance(d, dict):
1068
+ if isinstance(d.get("users"), list):
1069
+ items = d["users"]
1070
+ elif isinstance(d.get("_list"), list):
1071
+ items = d["_list"]
1072
+ else:
1073
+ items = []
1074
+ for k, v in d.items(): # {账号: 用户} 字典形状
1075
+ if not isinstance(v, dict):
1076
+ continue
1077
+ u = dict(v)
1078
+ u.setdefault("account", str(k))
1079
+ items.append(u)
1080
+ out, seen = [], set()
1081
+ for u in items or []:
1082
+ if not isinstance(u, dict):
1083
+ continue
1084
+ acct = str(u.get("account") or "").strip()
1085
+ if not acct or acct in seen:
1086
+ continue
1087
+ seen.add(acct)
1088
+ out.append({"account": acct, "realname": str(u.get("realname") or "")})
1089
+ if not out:
1090
+ return {"ok": False, "error": "账号清单为空或响应形状不认识——仍可手填账号"}
1091
+ out.sort(key=lambda x: x["account"])
1092
+ return {"ok": True, "users": out[:500]}
1093
+
1094
+
1095
+ def test_connection(base_url=None, account=None, password=None):
1096
+ """连接测试:探明通道登录 + 拉第一个产品的 bug 列表(有档案时)。返回 (ok, 人话结果)。"""
1097
+ c = _cfg()
1098
+ base_url = str(base_url if base_url is not None else c.get("base_url") or "").strip()
1099
+ account = str(account if account is not None else c.get("account") or "").strip()
1100
+ password = str(password if password is not None else c.get("password") or "").strip()
1101
+ if not (base_url and account and password):
1102
+ return False, "地址、账号、密码都要填全"
1103
+ b = base_url.strip().rstrip("/")
1104
+ probe = {"base_url": b, "account": account, "password": password}
1105
+ try:
1106
+ tok = _rest_login(b, account, password)
1107
+ except ZenError as e:
1108
+ return False, str(e)
1109
+ if tok:
1110
+ _MODE[b] = "rest"
1111
+ else:
1112
+ try:
1113
+ _old_login(probe, _REST_LAST_ERR.get("msg", ""))
1114
+ _MODE[b] = "old"
1115
+ except ZenError as e:
1116
+ return False, str(e)
1117
+ tag = "老版 JSON 接口" if _MODE.get(b) == "old" else "REST v1"
1118
+ try:
1119
+ profiles = _profiles(c)
1120
+ if profiles:
1121
+ bugs = list_bugs(probe, profiles[0]["product"], profiles[0])
1122
+ return True, "连接成功(%s),产品 %s 可访问(当前 %d 条 bug 在列表里)" % (
1123
+ tag, profiles[0]["product"], len(bugs))
1124
+ except ZenError as e:
1125
+ return False, "登录成功(%s),但拉 bug 列表失败:%s" % (tag, e)
1126
+ return True, "连接成功(%s;未配产品档案,跳过列表探测)" % tag
1127
+
1128
+
1129
+ # ---------------------------------------------------------------- 排查(triage)
1130
+
1131
+ def _ai_triage(bug, profile):
1132
+ """AI 兜底排查:单次 LLM 调用判端。不可用/解析失败返回 None(bookmeta 同款配方)。"""
1133
+ try:
1134
+ from . import modelhub, runner
1135
+ orch = modelhub.resolve_orchestrator()
1136
+ if not orch:
1137
+ return None
1138
+ prov, model = orch
1139
+ repos = profile.get("repos") or {}
1140
+ hints = profile.get("repo_hints") or {}
1141
+
1142
+ def _repo_desc(side):
1143
+ hint = str(hints.get(side) or "").strip()
1144
+ if hint:
1145
+ return hint
1146
+ wd = str((repos.get(side) or {}).get("workdir") or "").strip()
1147
+ if wd:
1148
+ return "目录 " + Path(wd).name
1149
+ return "(未配置)"
1150
+
1151
+ lines = ["你是缺陷分诊员:根据缺陷描述判断问题属于哪个仓库端。", "",
1152
+ "【缺陷】#%s %s" % (bug.get("id"), str(bug.get("title") or ""))]
1153
+ steps = _strip_html(bug.get("steps"))
1154
+ if steps:
1155
+ lines.append(steps[:3000])
1156
+ lines.append("")
1157
+ lines.append("【仓库背景】后端仓库:%s;前端仓库:%s" % (_repo_desc("backend"),
1158
+ _repo_desc("frontend")))
1159
+ lines.append("")
1160
+ lines.append('只输出 JSON(不要别的文字):{"side": "backend|frontend|both|not_ours", '
1161
+ '"reason": "一句话依据"}')
1162
+ lines.append("判定口径:backend=纯后端问题;frontend=纯前端问题;both=两端都要改;"
1163
+ "not_ours=与这两个仓库无关(第三方服务/环境/需求变更/数据问题等)。")
1164
+ res = modelhub.chat(prov["id"], model, "\n".join(lines), max_tokens=500,
1165
+ timeout=90)
1166
+ if not res.get("ok"):
1167
+ log.warning("zentao: AI 排查失败:%s", res.get("error"))
1168
+ return None
1169
+ data = runner.extract_json(res.get("text") or "")
1170
+ side = str((data or {}).get("side") or "").strip().lower()
1171
+ if side not in TRIAGE_SIDES:
1172
+ return None
1173
+ return {"side": side, "reason": str((data or {}).get("reason") or "")[:300]}
1174
+ except Exception:
1175
+ log.debug("zentao: AI 排查异常", exc_info=True)
1176
+ return None
1177
+
1178
+
1179
+ def _triage(bug, profile, cfg):
1180
+ """排查定责:模块路由 > AI > unknown。返回 {side, reason, by, account}。"""
1181
+ mid = str(bug.get("module") or "")
1182
+ if mid:
1183
+ for r in profile.get("module_routes") or []:
1184
+ try:
1185
+ if int(r.get("module") or 0) != int(mid):
1186
+ continue
1187
+ except (TypeError, ValueError):
1188
+ continue
1189
+ side = str(r.get("side") or "")
1190
+ if side in TRIAGE_SIDES:
1191
+ return {"side": side, "reason": "模块 #%s 路由规则" % mid,
1192
+ "by": "rule", "account": str(r.get("account") or "").strip()}
1193
+ if cfg.get("triage_ai"):
1194
+ res = _ai_triage(bug, profile)
1195
+ if res:
1196
+ res["by"] = "ai"
1197
+ res["account"] = ""
1198
+ return res
1199
+ return {"side": "unknown", "reason": "模块未命中路由且 AI 排查不可用", "by": "fallback",
1200
+ "account": ""}
1201
+
1202
+
1203
+ # ---------------------------------------------------------------- 修复任务拉起
1204
+
1205
+ def _repo_of(profile, side):
1206
+ return (profile.get("repos") or {}).get(side) or dict(_REPO_DEFAULTS)
1207
+
1208
+
1209
+ def _goal_text(bug, side=None):
1210
+ """bug → 修复目标提示词。side 给出时附端约束。"""
1211
+ bid = bug.get("id")
1212
+ lines = ["修复禅道 Bug #%s:%s" % (bid, str(bug.get("title") or "").strip())]
1213
+ steps = _strip_html(bug.get("steps"))
1214
+ if steps:
1215
+ lines.append("")
1216
+ lines.append("【重现步骤/问题描述】")
1217
+ lines.append(steps[:4000])
1218
+ sev, pri = _severity(bug), str(bug.get("pri") or "").strip()
1219
+ meta = []
1220
+ if sev:
1221
+ meta.append("严重度 %s(1 最严重)" % sev)
1222
+ if pri:
1223
+ meta.append("优先级 %s" % pri)
1224
+ if str(bug.get("module") or "").strip():
1225
+ meta.append("模块 #%s" % bug["module"])
1226
+ env = " / ".join(x for x in (str(bug.get("os") or "").strip(),
1227
+ str(bug.get("browser") or "").strip()) if x)
1228
+ if env:
1229
+ meta.append("环境 " + env)
1230
+ kw = str(bug.get("keywords") or "").strip()
1231
+ if kw:
1232
+ meta.append("关键字 " + kw)
1233
+ if meta:
1234
+ lines.append("")
1235
+ lines.append("【元信息】" + ";".join(meta))
1236
+ lines.append("")
1237
+ lines.append("【要求】只修这个 bug,不做无关重构;改动最小化;修完自查不引入回归。")
1238
+ if side in SIDES:
1239
+ lines.append("【端约束】本任务只负责【%s】部分;%s部分由别人处理,不要越界改动。"
1240
+ % (SIDE_CN[side],
1241
+ SIDE_CN["frontend" if side == "backend" else "backend"]))
1242
+ lines.append("完成后给出修改说明。")
1243
+ return "\n".join(lines)
1244
+
1245
+
1246
+ def _launch_fix(bug, profile, side, cfg):
1247
+ """为一个 bug 的某一端建修复任务并立即启动。"""
1248
+ bid = bug.get("id")
1249
+ repo = _repo_of(profile, side)
1250
+ wd = str(repo.get("workdir") or "").strip() or settings.default_workdir()
1251
+ title = ("[禅道#%s][%s] %s" % (bid, SIDE_CN.get(side, side),
1252
+ str(bug.get("title") or "").strip())).strip()[:60]
1253
+ payload = {"type": "code", "title": title, "goal": _goal_text(bug, side), "workdir": wd}
1254
+ base = str(repo.get("git_rev") or "").strip()
1255
+ if not base:
1256
+ # 档案没配基线就取落单时点的 HEAD:让修复走任务分支隔离——修完自动
1257
+ # 提交在任务分支上,对账合并才真正落库,resolve 才站得住(#27697 案:
1258
+ # 无基线任务修完只躺在工作区,闭环永远不会替你提交)。
1259
+ base = _head_rev(wd)
1260
+ if base:
1261
+ payload["git_rev"] = base
1262
+ if str(repo.get("verify_command") or "").strip():
1263
+ payload["verify_command"] = str(repo["verify_command"]).strip()
1264
+ task = None
1265
+ run = None
1266
+ try:
1267
+ task = store.create_task(payload)
1268
+ run = store.create_run("orchestration", task["title"], task_id=task["id"])
1269
+ store.update_task_status(task["id"], "queued")
1270
+ jobs.enqueue({"kind": "orchestration", "run_id": run["id"],
1271
+ "task_id": task["id"]})
1272
+ return task, run
1273
+ except Exception:
1274
+ log.exception("zentao: 修复任务启动失败 bug=%s side=%s task=%s run=%s",
1275
+ bid, side, (task or {}).get("id"), (run or {}).get("id"))
1276
+ if run:
1277
+ try:
1278
+ store.update_run(run["id"], status="failed",
1279
+ error="禅道修复任务启动失败,本次未排队,请稍后重试",
1280
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1281
+ except Exception:
1282
+ log.exception("zentao: 修复运行失败收口失败 run=%s", run.get("id"))
1283
+ if task:
1284
+ try:
1285
+ store.update_task_status(task["id"], "failed")
1286
+ except Exception:
1287
+ log.exception("zentao: 修复任务失败收口失败 task=%s", task.get("id"))
1288
+ raise
1289
+
1290
+
1291
+ # ---------------------------------------------------------------- 回写文本
1292
+
1293
+ def _diffstat(workdir, task_id):
1294
+ """任务分支相对基线的改动统计(人话一行)。拿不到返回空串。"""
1295
+ try:
1296
+ from . import gitmod, runner
1297
+ if task_id and workdir:
1298
+ br = gitmod.branch_name(task_id)
1299
+ r = runner.run_process(
1300
+ argv=["git", "-C", workdir, "diff", "--shortstat", "HEAD..." + br],
1301
+ timeout=20)
1302
+ if r.get("ok"):
1303
+ return (r.get("stdout") or "").strip()
1304
+ except Exception:
1305
+ log.debug("zentao: diffstat 失败", exc_info=True)
1306
+ return ""
1307
+
1308
+
1309
+ def _fix_summary(claim, run, profile, side):
1310
+ """我方某一端的修复摘要(转派评论与 resolve 报告共用的事实部分)。"""
1311
+ lines = []
1312
+ git = (run or {}).get("git") or {}
1313
+ commit = str(git.get("commit") or "").strip()
1314
+ from_branch = str(git.get("from_branch") or "").strip()
1315
+ tid = _task_of(claim, side).get("task_id") or ""
1316
+ if commit:
1317
+ lines.append("【%s】修复提交 %s%s" % (SIDE_CN.get(side, side), commit,
1318
+ "(已合并回 %s)" % from_branch if from_branch else ""))
1319
+ stat = _diffstat(str(_repo_of(profile, side).get("workdir") or ""), tid)
1320
+ if stat:
1321
+ lines.append("【%s】改动统计:%s" % (SIDE_CN.get(side, side), stat))
1322
+ v = (run or {}).get("verdict") or {}
1323
+ if v:
1324
+ lines.append("【%s】验证结论:%s" % (SIDE_CN.get(side, side),
1325
+ "通过(评审达标)" if v.get("pass") or v.get("publishable") else "完成"))
1326
+ return lines
1327
+
1328
+
1329
+ def _task_of(claim, side):
1330
+ for t in claim.get("tasks") or []:
1331
+ if t.get("side") == side:
1332
+ return t
1333
+ return {}
1334
+
1335
+
1336
+ def _report_text(claim, runs, profile, cfg):
1337
+ """resolve 评论:确定性事实(逐端汇总)。"""
1338
+ tri = claim.get("triage") or {}
1339
+ lines = ["【CodeBee 自动修复报告】",
1340
+ "Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or "")]
1341
+ if tri.get("side"):
1342
+ lines.append("排查结论:%s问题(%s)" % (tri.get("side"),
1343
+ tri.get("reason") or tri.get("by") or "按规则"))
1344
+ for t in claim.get("tasks") or []:
1345
+ lines.extend(_fix_summary(claim, runs.get(t.get("run_id")), profile, t.get("side")))
1346
+ lines.append("(本条由 CodeBee 禅道集成自动回写)")
1347
+ return "\n".join(lines)
1348
+
1349
+
1350
+ def _transfer_text(claim, profile, fixed_runs, target_side):
1351
+ """转派评论:排查结论 + 我方已做工作 + 请对方继续。"""
1352
+ tri = claim.get("triage") or {}
1353
+ lines = ["【CodeBee 排查转派】",
1354
+ "Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or ""),
1355
+ "排查结论:%s问题——%s" % (SIDE_CN.get(target_side, target_side),
1356
+ tri.get("reason") or tri.get("by") or "按规则")]
1357
+ for side, run in (fixed_runs or []):
1358
+ lines.extend(_fix_summary(claim, run, profile, side))
1359
+ lines.append("请%s负责人接手处理;本 bug 保持激活,处理完请按正常流程解决。"
1360
+ % SIDE_CN.get(target_side, target_side))
1361
+ lines.append("(本条由 CodeBee 禅道集成自动回写)")
1362
+ return "\n".join(lines)
1363
+
1364
+
1365
+ def _fail_text(claim, failed_tasks, runs):
1366
+ tri = claim.get("triage") or {}
1367
+ lines = ["【CodeBee 自动修复未成功】",
1368
+ "Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or "")]
1369
+ if tri.get("side"):
1370
+ lines.append("排查结论:%s(%s)" % (tri.get("side"), tri.get("reason") or ""))
1371
+ for t in failed_tasks:
1372
+ r = runs.get(t.get("run_id")) or {}
1373
+ why = str(r.get("error") or "").strip() or ("运行状态 " + str(r.get("status") or ""))
1374
+ lines.append("【%s】失败:%s" % (SIDE_CN.get(t.get("side"), t.get("side")), why[:300]))
1375
+ lines.append("CodeBee 修复任务:%s(可人工续跑或接管);本 bug 保持待处理。"
1376
+ % "、".join(t.get("task_id") or "?" for t in claim.get("tasks") or []))
1377
+ return "\n".join(lines)
1378
+
1379
+
1380
+ # ---------------------------------------------------------------- 禅道写回动作
1381
+
1382
+ def _bug_opened_by(cfg, bug_id):
1383
+ try:
1384
+ d = _call("GET", "/bugs/%s" % bug_id, cfg=cfg)
1385
+ return _acct(d.get("openedBy"))
1386
+ except ZenError:
1387
+ return ""
1388
+
1389
+
1390
+ def _ensure_resolved(cfg, bug_id, comment, assign_to):
1391
+ """resolve(幂等):已是 resolved/closed 视为成功。"""
1392
+ cur = _call("GET", "/bugs/%s" % bug_id, cfg=cfg)
1393
+ if str(cur.get("status") or "") in ("resolved", "closed"):
1394
+ return True
1395
+ body = {"resolution": "fixed", "resolvedBuild": "trunk", "comment": comment}
1396
+ if assign_to:
1397
+ body["assignedTo"] = assign_to
1398
+ _call("POST", "/bugs/%s/resolve" % bug_id, cfg=cfg, body=body)
1399
+ return True
1400
+
1401
+
1402
+ def _transfer(cfg, bug_id, target, comment):
1403
+ """转派:PUT assignedTo+comment(bug 保持激活)。target 空=只评论。抛 ZenError。"""
1404
+ body = {"comment": comment}
1405
+ if target:
1406
+ body["assignedTo"] = target
1407
+ _call("PUT", "/bugs/%s" % bug_id, cfg=cfg, body=body)
1408
+
1409
+
1410
+ def _notify(text):
1411
+ try:
1412
+ from . import notify
1413
+ notify.push_text(text)
1414
+ except Exception:
1415
+ log.debug("zentao: 群通知失败", exc_info=True)
1416
+
1417
+
1418
+ def _merge_branch(workdir, task):
1419
+ try:
1420
+ from . import gitmod
1421
+ return gitmod.merge_task_branch(workdir, task)
1422
+ except Exception as e:
1423
+ return False, "合并异常:%s" % e, None
1424
+
1425
+
1426
+ def _head_rev(workdir):
1427
+ """工作目录当前 HEAD 短哈希(作任务隔离基线)。非仓库/拿不到返回空串。"""
1428
+ try:
1429
+ from . import gitmod, runner
1430
+ r = runner.run_process(
1431
+ argv=["git", "-C", str(workdir), "rev-parse", "--short", "HEAD"], timeout=20)
1432
+ if r.get("ok"):
1433
+ rev = (r.get("stdout") or "").strip()
1434
+ if rev and gitmod.valid_rev(rev):
1435
+ return rev
1436
+ except Exception:
1437
+ log.debug("zentao: 读取 HEAD 失败", exc_info=True)
1438
+ return ""
1439
+
1440
+
1441
+ def _uncommitted(workdir):
1442
+ """工作目录里有没有未提交的已跟踪改动(untracked 不算——CLI 噪音文件太多)。
1443
+
1444
+ 返回 (是否脏, 人话明细)。目录不是 git 仓库视为不脏(无提交语义,放行)。
1445
+ """
1446
+ try:
1447
+ from . import runner
1448
+ if not workdir:
1449
+ return False, ""
1450
+ r = runner.run_process(
1451
+ argv=["git", "-C", str(workdir), "status", "--porcelain", "-uno", "--", "."],
1452
+ timeout=20)
1453
+ if r.get("ok"):
1454
+ dirty = [ln.strip() for ln in (r.get("stdout") or "").splitlines() if ln.strip()]
1455
+ if dirty:
1456
+ return True, "未提交改动 %d 处(如 %s)" % (len(dirty), dirty[0][:80])
1457
+ except Exception:
1458
+ log.debug("zentao: 工作区状态检查失败", exc_info=True)
1459
+ return False, ""
1460
+
1461
+
1462
+ def _set_claim(bid, **patch):
1463
+ with _LOCK:
1464
+ c = _STATE["claims"].get(str(bid))
1465
+ if c is None:
1466
+ return
1467
+ c.update(patch)
1468
+ c["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
1469
+ _save_locked()
1470
+
1471
+
1472
+ def _route_account(profile, tri, side):
1473
+ """转派/升级目标:模块路由 account > 端负责人。"""
1474
+ if tri and tri.get("account"):
1475
+ return str(tri["account"]).strip()
1476
+ return str((profile.get("owners") or {}).get(side) or "").strip()
1477
+
1478
+
1479
+ # ---------------------------------------------------------------- 对账回写
1480
+
1481
+ def _finish_ok(claim, cfg):
1482
+ """我方任务全部 done:合并 → (需要则)转派 → 否则 resolve。"""
1483
+ bid = str(claim.get("bug_id"))
1484
+ profile = _profile_for(cfg, claim.get("product")) or {}
1485
+ tri = claim.get("triage") or {}
1486
+ tasks = claim.get("tasks") or []
1487
+ runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
1488
+ # 1) 逐任务合并(任一失败即停:不转派不 resolve,绝不带着没落库的修复转派)
1489
+ if not cfg.get("auto_merge"):
1490
+ _set_claim(bid, state="done_manual",
1491
+ note="修复完成;auto_merge 已关,请人工合并落库后到禅道解决 bug")
1492
+ return
1493
+ for t in tasks:
1494
+ task = store.get_task(t.get("task_id") or "")
1495
+ if task is None or not task.get("git_rev"):
1496
+ continue
1497
+ ok, err, _info = _merge_branch(str(_repo_of(profile, t.get("side")).get("workdir")
1498
+ or settings.default_workdir()), task)
1499
+ if not ok:
1500
+ _set_claim(bid, state="merge_failed",
1501
+ note="【%s】任务分支合并失败:%s(不转派不 resolve,留人工)"
1502
+ % (SIDE_CN.get(t.get("side"), t.get("side")), err))
1503
+ _notify("🐛❌ 禅道 Bug #%s 的%s修复代码合并失败:%s\n修复任务:%s"
1504
+ % (bid, SIDE_CN.get(t.get("side"), ""), err, t.get("task_id") or "?"))
1505
+ return
1506
+ # 1.5) 落库闸:没基线的任务不走任务分支隔离,改动只躺在工作区——带着
1507
+ # 未提交的已跟踪改动点 resolve 是对禅道撒谎(#27697 案),拦下转人工。
1508
+ for t in tasks:
1509
+ task = store.get_task(t.get("task_id") or "")
1510
+ if task is None or task.get("git_rev"):
1511
+ continue
1512
+ dirty, detail = _uncommitted(str(_repo_of(profile, t.get("side")).get("workdir")
1513
+ or settings.default_workdir()))
1514
+ if dirty:
1515
+ _set_claim(bid, state="done_manual",
1516
+ note="修复完成但工作区有未提交改动(%s),已阻止自动 resolve"
1517
+ "——请人工提交后到禅道解决" % detail)
1518
+ _notify("🐛⚠️ 禅道 Bug #%s 修复完成但未提交落库,已阻止自动 resolve\n%s"
1519
+ % (bid, claim.get("title") or ""))
1520
+ return
1521
+ # 2) 需要转派的端 = 判定端 - 我方端(both 且我方只管一端时非空)
1522
+ verdict_sides = ([tri["side"]] if tri.get("side") in SIDES
1523
+ else list(SIDES) if tri.get("side") == "both" else [])
1524
+ other_sides = [s for s in verdict_sides if s not in (profile.get("our_sides") or [])]
1525
+ if other_sides:
1526
+ targets = {}
1527
+ for s in other_sides:
1528
+ tgt = _route_account(profile, tri, s)
1529
+ if not tgt:
1530
+ _set_claim(bid, state="need_manual",
1531
+ note="我方已修完,但未配置%s负责人,无法转派——请人工转派" % SIDE_CN[s])
1532
+ _notify("🐛⚠️ 禅道 Bug #%s 我方部分已修完,但未配置%s负责人,请人工转派"
1533
+ % (bid, SIDE_CN[s]))
1534
+ return
1535
+ targets[s] = tgt
1536
+ fixed_runs = [(t.get("side"), runs.get(t.get("run_id"))) for t in tasks]
1537
+ for s in other_sides:
1538
+ try:
1539
+ _transfer(cfg, bid, targets[s], _transfer_text(claim, profile, fixed_runs, s))
1540
+ except ZenError as e:
1541
+ attempts = int(claim.get("attempts") or 0) + 1
1542
+ if attempts >= RESOLVE_MAX_ATTEMPTS:
1543
+ _set_claim(bid, attempts=attempts, state="resolve_failed",
1544
+ note="转派连续 %d 次失败:%s" % (attempts, e))
1545
+ _notify("🐛⚠️ 禅道 Bug #%s 修完但转派失败:%s" % (bid, e))
1546
+ else:
1547
+ _set_claim(bid, attempts=attempts, state="fixing",
1548
+ note="转派第 %d 次失败,下轮重试:%s" % (attempts, e))
1549
+ return
1550
+ _set_claim(bid, state="transferred",
1551
+ note="我方(%s)已修完并合并,转派给 %s"
1552
+ % ("/".join(SIDE_CN.get(t.get("side"), "") for t in tasks),
1553
+ "、".join(SIDE_CN[s] + ":" + targets[s] for s in other_sides)))
1554
+ _notify("🐛🔁 禅道 Bug #%s 我方已修完,转派 %s\n%s"
1555
+ % (bid, "、".join(SIDE_CN[s] + ":" + targets[s] for s in other_sides),
1556
+ claim.get("title") or ""))
1557
+ return
1558
+ # 3) 全部我方端:resolve(幂等)+ 指回报告人
1559
+ if not cfg.get("auto_resolve"):
1560
+ _set_claim(bid, state="done_manual",
1561
+ note="修复完成;auto_resolve 已关,请人工确认后到禅道解决 bug")
1562
+ return
1563
+ report = _report_text(claim, runs, profile, cfg)
1564
+ opened = _bug_opened_by(cfg, bid)
1565
+ try:
1566
+ _ensure_resolved(cfg, bid, report, opened)
1567
+ except ZenError as e:
1568
+ attempts = int(claim.get("attempts") or 0) + 1
1569
+ if attempts >= RESOLVE_MAX_ATTEMPTS:
1570
+ _set_claim(bid, attempts=attempts, state="resolve_failed",
1571
+ note="resolve 连续 %d 次失败:%s" % (attempts, e))
1572
+ _notify("🐛⚠️ 禅道 Bug #%s 修复完成但回写禅道失败:%s" % (bid, e))
1573
+ else:
1574
+ _set_claim(bid, attempts=attempts, state="fixing",
1575
+ note="resolve 第 %d 次失败,下轮重试:%s" % (attempts, e))
1576
+ return
1577
+ _set_claim(bid, state="resolved", note="已 resolve(fixed)%s"
1578
+ % (",指回报告人 " + opened if opened else ""))
1579
+ _notify("🐛✅ 禅道 Bug #%s 已修复并 resolve\n%s\n修复任务:%s"
1580
+ % (bid, claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
1581
+
1582
+
1583
+ def _finish_failed(claim, cfg):
1584
+ """我方任一任务失败:评论尝试记录 + 转派该端负责人(有配则转)。"""
1585
+ bid = str(claim.get("bug_id"))
1586
+ profile = _profile_for(cfg, claim.get("product")) or {}
1587
+ tasks = claim.get("tasks") or []
1588
+ runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
1589
+ failed = [t for t in tasks
1590
+ if str((runs.get(t.get("run_id")) or {}).get("status") or "") not in
1591
+ ("queued", "running", "done")]
1592
+ text = _fail_text(claim, failed, runs)
1593
+ # 升级目标:取第一个失败端的路由账号/负责人
1594
+ target = ""
1595
+ for t in failed:
1596
+ target = _route_account(profile, claim.get("triage") or {}, t.get("side"))
1597
+ if target:
1598
+ break
1599
+ try:
1600
+ _transfer(cfg, bid, target, text)
1601
+ note = "已评论说明%s" % ("并转派 %s" % target if target else "")
1602
+ state = "escalated" if target else "commented"
1603
+ except ZenError as e:
1604
+ log.warning("zentao: bug %s 失败评论未送达(群通知兜底):%s", bid, e)
1605
+ note = "修复失败,评论未送达:%s" % e
1606
+ state = "commented"
1607
+ _set_claim(bid, state=state, note=note)
1608
+ _notify("🐛❌ 禅道 Bug #%s 自动修复未成功%s\n%s\n修复任务:%s"
1609
+ % (bid, (",已转派 " + target) if target else "",
1610
+ claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
1611
+
1612
+
1613
+ def _refresh_claim_runs(claim):
1614
+ """把 claim.tasks 的 run_id 对齐到各任务最新一次运行,返回 (tasks, 是否换绑)。
1615
+
1616
+ 领单时钉住的 run 失败后,人工重试会换新 run——对账只认旧 run 会把已经
1617
+ 成功的修复判成失败(2026-09-22 #27697 误报失败评论案)。
1618
+ """
1619
+ fresh, changed = [], False
1620
+ for t in claim.get("tasks") or []:
1621
+ tid = str(t.get("task_id") or "")
1622
+ if tid:
1623
+ runs = store.task_runs(tid)
1624
+ rid = str((runs[0].get("id") if runs else "") or "")
1625
+ if rid and rid != t.get("run_id"):
1626
+ t = dict(t, run_id=rid)
1627
+ changed = True
1628
+ fresh.append(t)
1629
+ return fresh, changed
1630
+
1631
+
1632
+ def _reconcile(cfg):
1633
+ """对账:fixing 中的 claim 查各 run 终态并回写。单条异常只跳过该条。"""
1634
+ with _LOCK:
1635
+ fixing = [dict(c) for c in _STATE["claims"].values() if c.get("state") == "fixing"]
1636
+ for claim in fixing:
1637
+ bid = str(claim.get("bug_id"))
1638
+ try:
1639
+ tasks, moved = _refresh_claim_runs(claim)
1640
+ if moved:
1641
+ _set_claim(bid, tasks=tasks)
1642
+ claim = dict(claim, tasks=tasks)
1643
+ runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
1644
+ if any(r is None for r in runs.values()):
1645
+ _set_claim(bid, state="lost", note="运行记录不存在(可能被清理)")
1646
+ continue
1647
+ states = [str((r or {}).get("status") or "") for r in runs.values()]
1648
+ if any(s in ("queued", "running") for s in states):
1649
+ continue
1650
+ if all(s == "done" for s in states):
1651
+ _finish_ok(claim, cfg)
1652
+ else:
1653
+ _finish_failed(claim, cfg)
1654
+ except Exception:
1655
+ log.exception("zentao: claim %s 对账异常,跳过", bid)
1656
+
1657
+
1658
+ # ---------------------------------------------------------------- 扫描主流程
1659
+
1660
+ def _sides_to_fix(profile, tri):
1661
+ """判定端 ∩ 我方端。"""
1662
+ tri_side = tri.get("side")
1663
+ verdict_sides = [tri_side] if tri_side in SIDES else (list(SIDES) if tri_side == "both" else [])
1664
+ return [s for s in verdict_sides if s in (profile.get("our_sides") or [])]
1665
+
1666
+
1667
+ def _route_one(bug, profile, cfg, notify=True):
1668
+ """认领一个 bug:排查 → 分流(建任务/转派/留人工)。notify=False 用于
1669
+ need_manual 存量的重排查(仍无解时不重复群通知)。返回动作文案或 None。"""
1670
+ bid = str(bug.get("id") or "")
1671
+ tri = _triage(bug, profile, cfg)
1672
+ base_claim = {
1673
+ "bug_id": bug.get("id"), "product": profile.get("product"),
1674
+ "title": str(bug.get("title") or "")[:120],
1675
+ "triage": tri, "tasks": [], "state": "fixing", "note": "",
1676
+ "attempts": 0, "claimed_at": time.strftime("%Y-%m-%d %H:%M:%S"),
1677
+ }
1678
+ opened = _acct(bug.get("openedBy"))
1679
+
1680
+ if tri["side"] == "unknown":
1681
+ base_claim["state"] = "need_manual"
1682
+ base_claim["note"] = tri.get("reason") or "排查失败,留人工"
1683
+ with _LOCK:
1684
+ _STATE["claims"][bid] = base_claim
1685
+ _save_locked()
1686
+ if notify:
1687
+ _notify("🐛❓ 禅道 Bug #%s 排查不出归属端(%s),留人工\n%s"
1688
+ % (bid, tri.get("reason") or "", bug.get("title") or ""))
1689
+ return "need_manual"
1690
+
1691
+ if tri["side"] == "not_ours":
1692
+ target = tri.get("account") or str(profile.get("owners").get("not_ours") or "") \
1693
+ or opened
1694
+ text = ("【CodeBee 排查转派】\nBug:#%s %s\n排查结论:非我方两个仓库的问题——%s\n"
1695
+ "转回 %s 核实处理。\n(本条由 CodeBee 禅道集成自动回写)"
1696
+ % (bid, bug.get("title") or "", tri.get("reason") or "按规则",
1697
+ target or "报告人"))
1698
+ try:
1699
+ _transfer(cfg, bid, target, text)
1700
+ note = "非我方,已转派 %s" % (target or "(未指派,仅评论)")
1701
+ except ZenError as e:
1702
+ base_claim.update({"state": "need_manual", "note": "非我方转派失败:%s" % e})
1703
+ with _LOCK:
1704
+ _STATE["claims"][bid] = base_claim
1705
+ _save_locked()
1706
+ _notify("🐛⚠️ 禅道 Bug #%s 判定非我方但转派失败:%s" % (bid, e))
1707
+ return "need_manual"
1708
+ base_claim.update({"state": "transferred", "note": note})
1709
+ with _LOCK:
1710
+ _STATE["claims"][bid] = base_claim
1711
+ _save_locked()
1712
+ _notify("🐛↩️ 禅道 Bug #%s 判定非我方,已转派 %s\n%s"
1713
+ % (bid, target or "报告人", bug.get("title") or ""))
1714
+ return "transferred"
1715
+
1716
+ sides = _sides_to_fix(profile, tri)
1717
+ if not sides:
1718
+ # 纯对方端问题(含测试指错到我方账号的):直接转派正确负责人
1719
+ other = tri["side"] if tri["side"] in SIDES else None
1720
+ if other is None: # both 但我方两端都没配:整单转不了,留人工
1721
+ base_claim["state"] = "need_manual"
1722
+ base_claim["note"] = "双端问题但产品档案未配置我方端,请人工处理"
1723
+ with _LOCK:
1724
+ _STATE["claims"][bid] = base_claim
1725
+ _save_locked()
1726
+ _notify("🐛❓ 禅道 Bug #%s 为双端问题但未配我方端,留人工" % bid)
1727
+ return "need_manual"
1728
+ target = _route_account(profile, tri, other)
1729
+ if not target:
1730
+ base_claim["state"] = "need_manual"
1731
+ base_claim["note"] = "判定为%s问题,但未配置%s负责人,无法转派" % (
1732
+ SIDE_CN[other], SIDE_CN[other])
1733
+ with _LOCK:
1734
+ _STATE["claims"][bid] = base_claim
1735
+ _save_locked()
1736
+ _notify("🐛⚠️ 禅道 Bug #%s 判定为%s问题但未配负责人,请人工转派"
1737
+ % (bid, SIDE_CN[other]))
1738
+ return "need_manual"
1739
+ text = _transfer_text({"bug_id": bug.get("id"), "title": bug.get("title") or "",
1740
+ "triage": tri, "tasks": []}, profile, [], other)
1741
+ try:
1742
+ _transfer(cfg, bid, target, text)
1743
+ except ZenError as e:
1744
+ base_claim["state"] = "need_manual"
1745
+ base_claim["note"] = "%s转派失败:%s" % (SIDE_CN[other], e)
1746
+ with _LOCK:
1747
+ _STATE["claims"][bid] = base_claim
1748
+ _save_locked()
1749
+ _notify("🐛⚠️ 禅道 Bug #%s 判定%s问题但转派失败:%s" % (bid, SIDE_CN[other], e))
1750
+ return "need_manual"
1751
+ base_claim.update({"state": "transferred",
1752
+ "note": "%s问题(测试原指派 %s),已转派 %s"
1753
+ % (SIDE_CN[other], _acct(bug.get("assignedTo")) or "?", target)})
1754
+ with _LOCK:
1755
+ _STATE["claims"][bid] = base_claim
1756
+ _save_locked()
1757
+ _notify("🐛🔁 禅道 Bug #%s 判定%s问题,已转派 %s(原指派 %s)\n%s"
1758
+ % (bid, SIDE_CN[other], target, _acct(bug.get("assignedTo")) or "?",
1759
+ bug.get("title") or ""))
1760
+ return "transferred"
1761
+
1762
+ # 我方端:逐端建修复任务(任一端建不起来 → 整单留人工,不做半截修复)
1763
+ tasks = []
1764
+ try:
1765
+ for side in sides:
1766
+ task, run = _launch_fix(bug, profile, side, cfg)
1767
+ tasks.append({"side": side, "task_id": task["id"], "run_id": run["id"],
1768
+ "state": "fixing"})
1769
+ except Exception as e:
1770
+ log.warning("zentao: bug %s 建修复任务失败:%s", bid, e)
1771
+ base_claim["state"] = "need_manual"
1772
+ base_claim["note"] = "建修复任务失败:%s" % e
1773
+ with _LOCK:
1774
+ _STATE["claims"][bid] = base_claim
1775
+ _save_locked()
1776
+ _notify("🐛⚠️ 禅道 Bug #%s 建修复任务失败:%s" % (bid, e))
1777
+ return "need_manual"
1778
+ base_claim["tasks"] = tasks
1779
+ with _LOCK:
1780
+ _STATE["claims"][bid] = base_claim
1781
+ _save_locked()
1782
+ _notify("🐛🔍 认领禅道 Bug #%s(%s问题·%s):%s\n修复任务:%s"
1783
+ % (bid, tri["side"], "由规则" if tri.get("by") == "rule" else "AI 判定",
1784
+ bug.get("title") or "", "、".join(t["task_id"] for t in tasks)))
1785
+ return "fixing"
1786
+
1787
+
1788
+ def _scan(cfg):
1789
+ """按产品档案逐个拉 bug:认领新 bug + 重试 need_manual 的存量。返回认领数。"""
1790
+ claimed = 0
1791
+ profiles = _profiles(cfg)
1792
+ if not profiles:
1793
+ raise ZenError("未配置产品档案——请先在设置页添加产品并配置仓库")
1794
+ for profile in profiles:
1795
+ bugs = list_bugs(cfg, profile["product"], profile)
1796
+ by_id = {str(b.get("id") or ""): b for b in bugs}
1797
+ with _LOCK:
1798
+ seen = set(_STATE["claims"].keys())
1799
+ retry_ids = [k for k, c in _STATE["claims"].items()
1800
+ if c.get("state") == "need_manual" and k in by_id
1801
+ and str(by_id[k].get("status") or "") == "active"]
1802
+ for bug in bugs:
1803
+ bid = str(bug.get("id") or "")
1804
+ if not bid or bid in seen or not _claimable(bug, profile):
1805
+ continue
1806
+ _route_one(bug, profile, cfg)
1807
+ claimed += 1
1808
+ # need_manual 重排查(bug 仍激活才出现在列表里)
1809
+ for bid in retry_ids:
1810
+ with _LOCK:
1811
+ if _STATE["claims"].get(bid, {}).get("state") != "need_manual":
1812
+ continue
1813
+ _route_one(by_id[bid], profile, cfg, notify=False)
1814
+ return claimed
1815
+
1816
+
1817
+ def _poll(force=False):
1818
+ """执行一次扫描;同一进程内只允许一个扫描实例。"""
1819
+ if not _SCAN_LOCK.acquire(blocking=False):
1820
+ return {"ok": False, "claimed": 0, "reconciled": False,
1821
+ "error": "扫描正在进行,请稍候", "skipped": "in_progress"}
1822
+ try:
1823
+ return _poll_unlocked(force=force)
1824
+ finally:
1825
+ _SCAN_LOCK.release()
1826
+
1827
+
1828
+ def _poll_unlocked(force=False):
1829
+ """一次完整轮询:对账回写 + (到点/强制时)扫描认领。异常不外抛。"""
1830
+ _ensure_loaded()
1831
+ with _LOCK:
1832
+ cfg = _cfg()
1833
+ out = {"ok": True, "claimed": 0, "reconciled": True, "error": ""}
1834
+ try:
1835
+ _reconcile(cfg)
1836
+ except Exception:
1837
+ log.exception("zentao: 对账异常")
1838
+ if not force:
1839
+ if not cfg.get("poll_enabled"):
1840
+ return {"ok": True, "claimed": 0, "reconciled": True, "error": "",
1841
+ "skipped": "poll_disabled"}
1842
+ with _LOCK:
1843
+ nxt = _STATE.get("next_scan") or ""
1844
+ if nxt:
1845
+ try:
1846
+ if datetime.fromisoformat(nxt) > datetime.now():
1847
+ return {"ok": True, "claimed": 0, "reconciled": True,
1848
+ "error": "", "skipped": "not_due"}
1849
+ except ValueError:
1850
+ pass
1851
+ err = ""
1852
+ try:
1853
+ out["claimed"] = _scan(cfg)
1854
+ except ZenError as e:
1855
+ err = str(e)
1856
+ except Exception as e:
1857
+ err = "扫描失败:%s" % e
1858
+ log.warning("zentao: %s", err)
1859
+ now = datetime.now()
1860
+ delay = timedelta(minutes=max(INTERVAL_MIN, int(cfg.get("interval_minutes") or INTERVAL_DEFAULT)))
1861
+ if err:
1862
+ delay = timedelta(minutes=RETRY_DELAY_MIN)
1863
+ with _LOCK:
1864
+ _STATE["last_scan"] = now.strftime("%Y-%m-%d %H:%M:%S")
1865
+ _STATE["next_scan"] = (now + delay).strftime("%Y-%m-%d %H:%M:%S")
1866
+ _STATE["last_error"] = err
1867
+ _save_locked()
1868
+ out["error"] = err
1869
+ out["ok"] = not err
1870
+ return out
1871
+
1872
+
1873
+ def fire_due():
1874
+ """automation._tick 每拍调用:内部自节流,没到点/未启用时零开销返回。"""
1875
+ _ensure_loaded()
1876
+ with _LOCK:
1877
+ if not (_STATE.get("config") or {}).get("poll_enabled"):
1878
+ return None
1879
+ return _poll(force=False)
1880
+
1881
+
1882
+ def scan_now():
1883
+ """手动「立即扫描」:绕过轮询闸与节流。"""
1884
+ return _poll(force=True)
1885
+
1886
+
1887
+ def _boot_reconcile():
1888
+ """服务重启后补一次对账:上个进程死亡窗口里的终态变化别等下个 tick
1889
+ (#27697 案:服务死窗 7 小时,失败转派被拖到重启后才发现)。"""
1890
+ try:
1891
+ _ensure_loaded()
1892
+ with _LOCK:
1893
+ cfg = _cfg()
1894
+ _reconcile(cfg)
1895
+ except Exception:
1896
+ log.exception("zentao: 启动补对账失败")
1897
+
1898
+
1899
+ def start():
1900
+ """服务启动接线:加载状态(含老配置迁移)。"""
1901
+ global _BOOT_TIMER
1902
+ n = load()
1903
+ with _LOCK:
1904
+ cfg = _STATE.get("config") or {}
1905
+ if cfg.get("poll_enabled") and not _STATE.get("next_scan"):
1906
+ _STATE["next_scan"] = time.strftime("%Y-%m-%d %H:%M:%S")
1907
+ _save_locked()
1908
+ pending = any(c.get("state") == "fixing" for c in _STATE["claims"].values())
1909
+ if pending:
1910
+ if _BOOT_TIMER is not None:
1911
+ _BOOT_TIMER.cancel()
1912
+ _BOOT_TIMER = threading.Timer(3.0, _boot_reconcile)
1913
+ _BOOT_TIMER.daemon = True
1914
+ _BOOT_TIMER.start()
1915
+ return n
1916
+
1917
+
1918
+ # ---------------------------------------------------------------- 配置管理
1919
+
1920
+ def _norm_profile(p, idx):
1921
+ if not isinstance(p, dict):
1922
+ raise ValueError("产品档案 #%d 必须是对象" % idx)
1923
+ try:
1924
+ pid = int(p.get("product") or 0)
1925
+ except (TypeError, ValueError):
1926
+ raise ValueError("产品档案 #%d 的产品 ID 必须是整数" % idx)
1927
+ if pid <= 0:
1928
+ raise ValueError("产品档案 #%d 缺产品 ID" % idx)
1929
+ prof = dict(_PROFILE_DEFAULTS)
1930
+ prof["product"] = pid
1931
+ prof["assigned_to"] = str(p.get("assigned_to") or "").strip()
1932
+ try:
1933
+ prof["severity_cap"] = max(0, min(4, int(p.get("severity_cap") or 0)))
1934
+ except (TypeError, ValueError):
1935
+ prof["severity_cap"] = 0
1936
+ our = [s for s in SIDES if s in (p.get("our_sides") or [])]
1937
+ prof["our_sides"] = our
1938
+ repos = {"backend": dict(_REPO_DEFAULTS), "frontend": dict(_REPO_DEFAULTS)}
1939
+ raw_repos = p.get("repos") if isinstance(p.get("repos"), dict) else {}
1940
+ for side in SIDES:
1941
+ r = raw_repos.get(side) if isinstance(raw_repos.get(side), dict) else {}
1942
+ wd = str(r.get("workdir") or "").strip()
1943
+ if wd:
1944
+ rp = Path(wd).expanduser()
1945
+ if not rp.is_absolute():
1946
+ raise ValueError("产品 %d 的%s工作目录必须是绝对路径" % (pid, SIDE_CN[side]))
1947
+ wd = str(rp)
1948
+ repos[side] = {"workdir": wd,
1949
+ "git_rev": str(r.get("git_rev") or "").strip(),
1950
+ "verify_command": str(r.get("verify_command") or "").strip()}
1951
+ # 工作目录留空合法:扫描时回落「默认保存路径」(_launch_fix 同口径),不在此拦截
1952
+ prof["repos"] = repos
1953
+ hints = {}
1954
+ raw_hints = p.get("repo_hints") if isinstance(p.get("repo_hints"), dict) else {}
1955
+ for side in SIDES:
1956
+ hints[side] = str(raw_hints.get(side) or "").strip()
1957
+ prof["repo_hints"] = hints
1958
+ owners = {}
1959
+ raw_owners = p.get("owners") if isinstance(p.get("owners"), dict) else {}
1960
+ for k in ("backend", "frontend", "not_ours"):
1961
+ owners[k] = str(raw_owners.get(k) or "").strip()
1962
+ prof["owners"] = owners
1963
+ routes = []
1964
+ for r in (p.get("module_routes") or []):
1965
+ if not isinstance(r, dict):
1966
+ continue
1967
+ try:
1968
+ mid = int(r.get("module") or 0)
1969
+ except (TypeError, ValueError):
1970
+ raise ValueError("产品 %d 的模块路由:模块 ID 必须是整数" % pid)
1971
+ if mid <= 0:
1972
+ continue
1973
+ side = str(r.get("side") or "")
1974
+ if side not in TRIAGE_SIDES:
1975
+ raise ValueError("产品 %d 的模块路由 side 必须是 %s 之一"
1976
+ % (pid, "/".join(TRIAGE_SIDES)))
1977
+ routes.append({"module": mid, "side": side,
1978
+ "account": str(r.get("account") or "").strip()})
1979
+ prof["module_routes"] = routes
1980
+ return prof
1981
+
1982
+
1983
+ def _norm_profiles(v):
1984
+ if v is None:
1985
+ return None
1986
+ if not isinstance(v, list):
1987
+ raise ValueError("product_profiles 必须是数组")
1988
+ out, seen = [], set()
1989
+ for i, p in enumerate(v):
1990
+ prof = _norm_profile(p, i)
1991
+ if prof["product"] in seen:
1992
+ raise ValueError("产品 %d 配置重复" % prof["product"])
1993
+ seen.add(prof["product"])
1994
+ out.append(prof)
1995
+ return out
1996
+
1997
+
1998
+ def save_config(patch):
1999
+ """部分更新配置(password 缺省或空串=不改;product_profiles 整体替换)。
2000
+ 校验失败抛 ValueError。返回脱敏视图的 config。"""
2001
+ _ensure_loaded()
2002
+ patch = patch if isinstance(patch, dict) else {}
2003
+ with _LOCK:
2004
+ cfg = _cfg()
2005
+ for k in _UPDATABLE:
2006
+ if k not in patch:
2007
+ continue
2008
+ v = patch[k]
2009
+ if k == "base_url":
2010
+ v = str(v or "").strip().rstrip("/")
2011
+ if v and not v.startswith(("http://", "https://")):
2012
+ raise ValueError("禅道地址必须以 http:// 或 https:// 开头")
2013
+ elif k == "account":
2014
+ v = str(v or "").strip()
2015
+ elif k == "password":
2016
+ v = str(v or "")
2017
+ if not v:
2018
+ continue
2019
+ elif k == "product_profiles":
2020
+ v = _norm_profiles(v)
2021
+ if v is None:
2022
+ continue
2023
+ elif k == "interval_minutes":
2024
+ try:
2025
+ v = max(INTERVAL_MIN, min(INTERVAL_MAX, int(v)))
2026
+ except (TypeError, ValueError):
2027
+ raise ValueError("interval_minutes 必须是 %d-%d 的整数"
2028
+ % (INTERVAL_MIN, INTERVAL_MAX))
2029
+ elif k in ("auto_resolve", "auto_merge", "triage_ai", "poll_enabled"):
2030
+ v = bool(v)
2031
+ cfg[k] = v
2032
+ _STATE["config"] = cfg
2033
+ if cfg.get("poll_enabled"):
2034
+ _STATE["next_scan"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
2035
+ _save_locked()
2036
+ return view()["config"]
2037
+
2038
+
2039
+ def bug_web_base(cfg=None):
2040
+ """给前端拼 bug 详情页用的 web 根:探测出的有效地基(含自动补的 /zentao 子路径)
2041
+ 优先,没探测过回落用户填的原始地址。返回 stripped 根或空串。"""
2042
+ c = dict(cfg) if cfg else _cfg()
2043
+ hit = resolved_base_url(c.get("base_url"))
2044
+ if hit:
2045
+ return hit
2046
+ b = re.sub(r"/api\.php/v1$", "", str(c.get("base_url") or "").strip().rstrip("/"))
2047
+ return b if b.startswith(("http://", "https://")) else ""
2048
+
2049
+
2050
+ def bug_link_style(cfg=None):
2051
+ """bug 详情链接形态:探测缓存优先,没探出来按伪静态(官方默认形态)。"""
2052
+ c = dict(cfg) if cfg else _cfg()
2053
+ b = str(c.get("base_url") or "").strip().rstrip("/")
2054
+ return _WEBSTYLE.get(b) or "pathinfo"
2055
+
2056
+
2057
+ def view():
2058
+ """前端视图:配置脱敏(password 只回是否已设)+ claims 列表(新在前)。"""
2059
+ _ensure_loaded()
2060
+ with _LOCK:
2061
+ cfg = dict(_cfg())
2062
+ has_pw = bool(cfg.get("password"))
2063
+ cfg["password"] = ""
2064
+ cfg["has_password"] = has_pw
2065
+ bug_base = bug_web_base(cfg)
2066
+ claims = sorted(_STATE["claims"].values(),
2067
+ key=lambda c: str(c.get("claimed_at") or ""), reverse=True)
2068
+ return {"config": cfg,
2069
+ "bug_base": bug_base,
2070
+ "bug_style": bug_link_style(cfg),
2071
+ "claims": [dict(c) for c in claims],
2072
+ "last_scan": _STATE.get("last_scan") or "",
2073
+ "next_scan": _STATE.get("next_scan") or "",
2074
+ "last_error": _STATE.get("last_error") or ""}
2075
+
2076
+
2077
+ def _test_reset():
2078
+ """测试钩子:清空内存状态(配合重绑 _FILE + TUTTI_DATA 临时目录用)。"""
2079
+ with _LOCK:
2080
+ _STATE["config"] = dict(_CFG_DEFAULTS)
2081
+ _STATE["claims"] = {}
2082
+ _STATE["last_scan"] = _STATE["next_scan"] = _STATE["last_error"] = ""
2083
+ _reset_token()
2084
+ _APIBASE.clear()
2085
+ _WEBSTYLE.clear()
2086
+ _MODE.clear()
2087
+ _OLD.update(api="", sid="", at=0.0)
2088
+ _OLD_FORM["form"] = ""
2089
+ _REST_LAST_ERR["msg"] = ""
2090
+ globals()["_LOADED"] = True