codebee 0.1.68 → 0.1.69

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