@cnbmtech/dsh-zentao-session 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js ADDED
@@ -0,0 +1,816 @@
1
+ /**
2
+ * ZenTao (禅道) session-based client.
3
+ *
4
+ * Uses the officially documented "session" integration path (zentao.net/book/extension-dev/1341.html)
5
+ * which works on legacy ZenTao versions that do NOT expose REST API v1:
6
+ * 1. POST /user-login.html -> zentaosid session cookie
7
+ * 2. GET /my-bug.json, /my-task.json, /bug-view-{id}.json (t=json envelope)
8
+ * 3. POST /bug-confirmBug-{id}.html (confirm a bug; uid taken from page JS)
9
+ * 4. POST /action-comment-bug-{id}.html (add a remark)
10
+ */
11
+
12
+ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'
13
+ const LOGIN_PATH = '/user-login.html'
14
+
15
+ function trimSlash(value) {
16
+ return String(value || '').replace(/\/+$/, '')
17
+ }
18
+
19
+ export class ZentaoSessionError extends Error {}
20
+
21
+ export function createZentaoSessionClient(options = {}) {
22
+ const baseUrl = trimSlash(options.baseUrl)
23
+ const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 30000
24
+ const readAccount = typeof options.resolveAccount === 'function' ? options.resolveAccount : async () => options.account
25
+ const readPassword = typeof options.resolvePassword === 'function' ? options.resolvePassword : async () => options.password
26
+
27
+ if (!baseUrl) throw new ZentaoSessionError('baseUrl 不能为空')
28
+
29
+ const jar = new Map()
30
+ let loggedIn = false
31
+
32
+ function cookieHeader() {
33
+ return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ')
34
+ }
35
+
36
+ function absorbCookies(res) {
37
+ let list = []
38
+ try {
39
+ list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
40
+ } catch {
41
+ list = []
42
+ }
43
+ for (const raw of list) {
44
+ const first = String(raw).split(';')[0]
45
+ const idx = first.indexOf('=')
46
+ if (idx > 0) jar.set(first.slice(0, idx).trim(), first.slice(idx + 1).trim())
47
+ }
48
+ }
49
+
50
+ async function raw(path, { method = 'GET', form, signal } = {}) {
51
+ const url = /^https?:/i.test(path) ? path : baseUrl + path
52
+ const headers = { 'User-Agent': UA, Accept: '*/*' }
53
+ const cookies = cookieHeader()
54
+ if (cookies) headers.Cookie = cookies
55
+ let body
56
+ if (form !== undefined) {
57
+ // 数组必须展开成重复键(mailto[]=a&mailto[]=b):直接 new URLSearchParams(form)
58
+ // 会把数组逗号拼成单值(mailto[]=a%2Cb),禅道只会收到一个错误账号。
59
+ const params = new URLSearchParams()
60
+ for (const [key, value] of Object.entries(form)) {
61
+ if (value === undefined || value === null) continue
62
+ if (Array.isArray(value)) { for (const item of value) params.append(key, String(item)) }
63
+ else params.append(key, String(value))
64
+ }
65
+ body = params.toString()
66
+ headers['Content-Type'] = 'application/x-www-form-urlencoded'
67
+ }
68
+ // 超时/取消必须覆盖整个响应生命周期(只在响应头前生效的话,body 卡住就永久挂起);
69
+ // 同时兼容「传进来的 signal 已经 abort」的情况——旧写法对已 abort 的信号不会触发回调。
70
+ if (signal && signal.aborted) throw new ZentaoSessionError('请求已取消')
71
+ const signals = [AbortSignal.timeout(timeoutMs)]
72
+ if (signal) signals.push(signal)
73
+ const combined = typeof AbortSignal.any === 'function' ? AbortSignal.any(signals) : signals[0]
74
+ const res = await fetch(url, { method, headers, body, redirect: 'manual', signal: combined })
75
+ absorbCookies(res)
76
+ return res
77
+ }
78
+
79
+ async function login(signal) {
80
+ const account = await readAccount()
81
+ const password = await readPassword()
82
+ if (!account || !password) {
83
+ throw new ZentaoSessionError('缺少禅道账号或密码(config.account/password、凭据 ZENTAO_ACCOUNT/ZENTAO_PASSWORD 或环境变量)')
84
+ }
85
+ // 不再先 jar.clear():并发下会把别的请求刚建立的会话抹掉;新会话的 zentaosid 会覆盖旧的
86
+ const res = await raw(LOGIN_PATH, { method: 'POST', form: { account, password, keepLogin: '0' }, signal })
87
+ const body = await res.text()
88
+ if (!jar.has('zentaosid')) {
89
+ throw new ZentaoSessionError(`禅道登录失败:未取得会话 Cookie(HTTP ${res.status})`)
90
+ }
91
+ if (/账号或密码错误|密码错误|登录失败/.test(body)) {
92
+ throw new ZentaoSessionError('禅道登录失败:账号或密码不正确')
93
+ }
94
+ loggedIn = true
95
+ return { account }
96
+ }
97
+
98
+ /** 单飞登录:并发调用只发一次登录 POST(否则每个调用都新建会话并互相覆盖 Cookie)。 */
99
+ let loginFlight = null
100
+ async function ensure(signal) {
101
+ if (loggedIn && jar.has('zentaosid')) return
102
+ if (!loginFlight) loginFlight = login(signal).finally(() => { loginFlight = null })
103
+ return loginFlight
104
+ }
105
+
106
+ function looksLikeLoginPage(body) {
107
+ return /user-login\.html/.test(body) && /name=['"]?account['"]?/.test(body)
108
+ }
109
+
110
+ async function html(path, { signal, retry = true } = {}) {
111
+ await ensure(signal)
112
+ let res = await raw(path, { signal })
113
+ if (res.status === 302 || res.status === 401) {
114
+ if (!retry) throw new ZentaoSessionError(`禅道会话已失效(HTTP ${res.status})`)
115
+ loggedIn = false
116
+ await ensure(signal)
117
+ res = await raw(path, { signal })
118
+ }
119
+ const body = await res.text()
120
+ if (looksLikeLoginPage(body)) {
121
+ if (!retry) throw new ZentaoSessionError('禅道会话已失效(被重定向到登录页)')
122
+ loggedIn = false
123
+ await ensure(signal)
124
+ return html(path, { signal, retry: false })
125
+ }
126
+ return body
127
+ }
128
+
129
+ async function jsonPage(path, { signal } = {}) {
130
+ const body = await html(path, { signal })
131
+ let outer
132
+ try {
133
+ outer = JSON.parse(body)
134
+ } catch {
135
+ throw new ZentaoSessionError(`禅道返回非 JSON(${path}):${body.slice(0, 160)}`)
136
+ }
137
+ if (!outer || typeof outer !== 'object') {
138
+ throw new ZentaoSessionError(`禅道返回结构异常(${path}):期望对象,实际 ${typeof outer}`)
139
+ }
140
+ // 必须显式成功:缺 status 或 result=fail 时旧写法会静默返回空列表,
141
+ // 最终渲染成「你没有 BUG」这种**错误事实**喂给模型。
142
+ const ok = outer.status === 'success' || outer.result === 'success'
143
+ if (!ok) {
144
+ const reason = outer.message || outer.reason || JSON.stringify(outer).slice(0, 200)
145
+ throw new ZentaoSessionError(`禅道接口失败(${path}):${reason}`)
146
+ }
147
+ if (outer.data === undefined) {
148
+ throw new ZentaoSessionError(`禅道返回缺少 data 字段(${path}):${JSON.stringify(outer).slice(0, 200)}`)
149
+ }
150
+ if (typeof outer.data !== 'string') return outer.data
151
+ try {
152
+ return JSON.parse(outer.data)
153
+ } catch {
154
+ throw new ZentaoSessionError(`禅道 data 字段不是合法 JSON(${path}):${String(outer.data).slice(0, 160)}`)
155
+ }
156
+ }
157
+
158
+ async function postForm(path, form, { signal } = {}) {
159
+ await ensure(signal)
160
+ const res = await raw(path, { method: 'POST', form, signal })
161
+ const body = await res.text()
162
+ const location = res.headers.get('location') || ''
163
+ // 302 本身是禅道表单提交后的正常跳转,**不能**当成会话失效;
164
+ // 只有落到登录页才算失效(而且非幂等写操作绝不自动重试)。
165
+ const loginPage = looksLikeLoginPage(body) || /user-login/i.test(location)
166
+ if (loginPage || res.status === 401) loggedIn = false
167
+ const failed = /alert\(\s*(['"])([\s\S]*?)\1\s*\)/i.exec(body)
168
+ return {
169
+ status: res.status,
170
+ location,
171
+ body,
172
+ alert: failed ? failed[2] : '',
173
+ loginPage,
174
+ // posted 只代表「禅道没有报错」,不代表服务端确实改了数据;要证实必须回读
175
+ posted: !loginPage && !failed && res.status < 400,
176
+ }
177
+ }
178
+
179
+ /** 取页面 JS 变量:兼容 var/let/const 与单双引号(禅道各版本写法不同)。 */
180
+ function pageVar(source, name) {
181
+ const m = new RegExp(`(?:var|let|const)\\s+${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`).exec(String(source))
182
+ return m ? m[2] : ''
183
+ }
184
+
185
+ /** 取令牌(uid):取不到直接失败——带着空令牌写入只会静默不生效。 */
186
+ function requireUid(source) {
187
+ const uid = pageVar(source, 'kuid') || pageVar(source, 'uid')
188
+ if (!uid) throw new ZentaoSessionError('未取到禅道表单令牌(kuid),已中止写入以免产生无效操作')
189
+ return uid
190
+ }
191
+
192
+ /** 取下拉框当前选中值:属性引号单双皆可(禅道不同版本/模板渲染可能不一致)。 */
193
+ function selectedValue(source, name) {
194
+ const sel = new RegExp(`<select[^>]*name=['"]${name}['"][^>]*>([\\s\\S]*?)</select>`).exec(source)
195
+ if (!sel) return ''
196
+ const m = /<option[^>]*selected['"]?[^>]*value=['"]([^'"]*)['"]/.exec(sel[1])
197
+ || /<option[^>]*value=['"]([^'"]*)['"][^>]*selected/.exec(sel[1])
198
+ return m ? m[1] : ''
199
+ }
200
+
201
+ /** 取隐藏域值:同样兼容单双引号。 */
202
+ function hiddenValue(source, name) {
203
+ const m = new RegExp(`<input[^>]*name=['"]${name}['"][^>]*value=['"]([^'"]*)['"]`).exec(source)
204
+ return m ? m[1] : ''
205
+ }
206
+
207
+ /** 表单里是否存在该控件:用于「解析不到就拒绝写入」的硬判断。 */
208
+ function hasControl(source, name) {
209
+ return new RegExp(`<(?:select|input|textarea)[^>]*name=['"]${name}['"]`).test(source)
210
+ }
211
+
212
+ /**
213
+ * 取「我的地盘」某一类条目的某一浏览范围。
214
+ * 路径必须写成 /my-{kind}-{scope}.json:?type= 查询参数禅道**不认**(实测返回默认范围)。
215
+ */
216
+ async function myList(kind, type, signal) {
217
+ const scope = resolveBrowse(kind, type)
218
+ const data = await jsonPage(`/my-${kind}-${scope}.json`, { signal })
219
+ const key = kind === 'bug' ? 'bugs' : kind === 'task' ? 'tasks' : 'stories'
220
+ return { data, rows: listRows(data, key), scope }
221
+ }
222
+
223
+ return {
224
+ baseUrl,
225
+
226
+ async ping({ signal } = {}) {
227
+ await login(signal)
228
+ const [bugPage, taskPage] = await Promise.all([
229
+ jsonPage('/my-bug-assignedTo.json', { signal }),
230
+ jsonPage('/my-task-assignedTo.json', { signal }),
231
+ ])
232
+ // 需求接口在老版本禅道上可能不存在:取不到时给 null("不可用"),不要伪造成 0 条
233
+ let myStoryCount = null
234
+ try {
235
+ myStoryCount = listRows(await jsonPage('/my-story-assignedTo.json', { signal }), 'stories').length
236
+ } catch { myStoryCount = null }
237
+ return {
238
+ connected: true,
239
+ baseUrl,
240
+ myBugCount: listRows(bugPage, 'bugs').length,
241
+ myTaskCount: listRows(taskPage, 'tasks').length,
242
+ myStoryCount,
243
+ }
244
+ },
245
+
246
+ /** 需求(story)列表,与 BUG/任务同一套范围语义。 */
247
+ async myStories({ type, limit, signal } = {}) {
248
+ const { data, rows } = await myList('story', type, signal)
249
+ const mapped = rows.map((s) => ({
250
+ id: String(s.id),
251
+ title: s.title,
252
+ status: s.status,
253
+ stage: s.stage,
254
+ pri: s.pri,
255
+ estimate: s.estimate,
256
+ version: s.version,
257
+ openedBy: s.openedBy,
258
+ openedDate: s.openedDate,
259
+ assignedTo: s.assignedTo,
260
+ assignedDate: s.assignedDate,
261
+ reviewedBy: s.reviewedBy,
262
+ reviewedDate: s.reviewedDate,
263
+ closedBy: s.closedBy,
264
+ closedDate: s.closedDate,
265
+ product: s.product,
266
+ productTitle: s.productTitle,
267
+ planTitle: s.planTitle,
268
+ lastEditedDate: s.lastEditedDate,
269
+ }))
270
+ return {
271
+ total: mapped.length,
272
+ recTotal: pageTotal(data, mapped.length),
273
+ type: resolveBrowse('story', type),
274
+ stories: sliceLimit(mapped, limit),
275
+ }
276
+ },
277
+
278
+ /** 需求完整详情:全字段 + 模块路径 + 相关人真名 + 操作历史。 */
279
+ async storyFull({ id, signal } = {}) {
280
+ const data = await jsonPage(`/story-view-${encodeURIComponent(id)}.json`, { signal })
281
+ const story = data?.story || {}
282
+ const users = data?.users || {}
283
+ return {
284
+ id: String(story.id ?? id),
285
+ baseUrl,
286
+ productName: story.productName || data?.productName || '',
287
+ modulePath: moduleNames(data?.modulePath),
288
+ story: normalizeFields(story),
289
+ users: referencedUsers(users, story, data?.actions),
290
+ actions: normalizeActions(data?.actions, users),
291
+ }
292
+ },
293
+
294
+ async myBugs({ type, limit, signal } = {}) {
295
+ const { data, rows } = await myList('bug', type, signal)
296
+ const all = rows
297
+ const mapped = all.map((b) => ({
298
+ id: String(b.id),
299
+ title: b.title,
300
+ status: b.status,
301
+ confirmed: b.confirmed,
302
+ pri: b.pri,
303
+ type: b.type,
304
+ assignedTo: b.assignedTo,
305
+ openedBy: b.openedBy,
306
+ openedDate: b.openedDate,
307
+ product: b.product,
308
+ project: b.project,
309
+ }))
310
+ return {
311
+ total: mapped.length,
312
+ recTotal: pageTotal(data, mapped.length),
313
+ type: resolveBrowse('bug', type),
314
+ bugs: sliceLimit(mapped, limit),
315
+ }
316
+ },
317
+
318
+ async myTasks({ type, limit, signal } = {}) {
319
+ const { data, rows } = await myList('task', type, signal)
320
+ const mapped = rows.map((t) => ({
321
+ id: String(t.id),
322
+ name: t.name,
323
+ status: t.status,
324
+ pri: t.pri,
325
+ estimate: t.estimate,
326
+ consumed: t.consumed,
327
+ left: t.left,
328
+ deadline: t.deadline,
329
+ project: t.project,
330
+ execution: t.execution,
331
+ }))
332
+ return {
333
+ total: mapped.length,
334
+ recTotal: pageTotal(data, mapped.length),
335
+ type: resolveBrowse('task', type),
336
+ tasks: sliceLimit(mapped, limit),
337
+ }
338
+ },
339
+
340
+ async bugView({ id, signal } = {}) {
341
+ const data = await jsonPage(`/bug-view-${encodeURIComponent(id)}.json`, { signal })
342
+ const bug = data?.bug || {}
343
+ return {
344
+ id: String(bug.id ?? id),
345
+ title: bug.title,
346
+ status: bug.status,
347
+ confirmed: bug.confirmed,
348
+ type: bug.type,
349
+ pri: bug.pri,
350
+ severity: bug.severity,
351
+ assignedTo: bug.assignedTo,
352
+ openedBy: bug.openedBy,
353
+ openedDate: bug.openedDate,
354
+ resolvedBy: bug.resolvedBy,
355
+ resolvedDate: bug.resolvedDate,
356
+ resolution: bug.resolution,
357
+ steps: bug.steps,
358
+ product: bug.product,
359
+ project: bug.project,
360
+ execution: bug.execution,
361
+ }
362
+ },
363
+
364
+ async bugConfirm({ id, comment, signal } = {}) {
365
+ const path = `/bug-confirmBug-${encodeURIComponent(id)}.html`
366
+ const source = await html(path, { signal })
367
+ const form = {
368
+ assignedTo: selectedValue(source, 'assignedTo'),
369
+ type: selectedValue(source, 'type'),
370
+ pri: selectedValue(source, 'pri'),
371
+ status: hiddenValue(source, 'status') || 'active',
372
+ }
373
+ const uid = requireUid(source)
374
+ // 解析不到「指派给」就中止:把空值发出去等于把指派人清空(静默的数据污染)
375
+ if (hasControl(source, 'assignedTo') && !form.assignedTo) {
376
+ throw new ZentaoSessionError('确认表单解析失败(未取到指派给),已中止写入以免清空字段')
377
+ }
378
+ form.uid = uid
379
+ if (comment) form.comment = comment
380
+ const result = await postForm(path, form, { signal })
381
+ // posted 只说明禅道没报错;再回读一次证实 confirmed 真的变了
382
+ let verified = null
383
+ if (result.posted) {
384
+ try {
385
+ const after = await jsonPage(`/bug-view-${encodeURIComponent(id)}.json`, { signal })
386
+ verified = String(after?.bug?.confirmed) === '1'
387
+ } catch { verified = null }
388
+ }
389
+ return {
390
+ id: String(id),
391
+ posted: result.posted,
392
+ verified,
393
+ httpStatus: result.status,
394
+ location: result.location,
395
+ alert: result.alert,
396
+ sentFields: Object.keys(form),
397
+ note: '禅道「确认」只置 confirmed=1;status 仍为 active(激活/待解决)属正常。verified=true 表示已回读证实。',
398
+ }
399
+ },
400
+
401
+ async bugComment({ id, comment, signal } = {}) {
402
+ if (!comment || !String(comment).trim()) throw new ZentaoSessionError('comment 不能为空')
403
+ const viewPath = `/bug-view-${encodeURIComponent(id)}.html`
404
+ const source = await html(viewPath, { signal })
405
+ const form = { comment }
406
+ form.uid = requireUid(source)
407
+ const result = await postForm(`/action-comment-bug-${encodeURIComponent(id)}.html`, form, { signal })
408
+ // 回读操作历史证实备注真的进去了(避免把「禅道没报错」当成「已写入」)
409
+ let verified = null
410
+ if (result.posted) {
411
+ try {
412
+ const data = await jsonPage(`/bug-view-${encodeURIComponent(id)}.json`, { signal })
413
+ const actions = Array.isArray(data?.actions) ? data.actions : []
414
+ const head = String(comment).trim().slice(0, 40)
415
+ verified = actions.some((item) => String(item?.comment || '').trim().includes(head))
416
+ } catch { verified = null }
417
+ }
418
+ return {
419
+ id: String(id),
420
+ posted: result.posted,
421
+ verified,
422
+ httpStatus: result.status,
423
+ location: result.location,
424
+ alert: result.alert,
425
+ note: 'verified=true 表示已回读操作历史确认写入;本版本禅道不支持删除备注。',
426
+ }
427
+ },
428
+
429
+ /** 缺陷完整详情:全字段 + 模块路径 + 相关人真名 + 操作历史(含备注)。 */
430
+ async bugFull({ id, signal } = {}) {
431
+ const data = await jsonPage(`/bug-view-${encodeURIComponent(id)}.json`, { signal })
432
+ const bug = data?.bug || {}
433
+ const users = data?.users || {}
434
+ return {
435
+ id: String(bug.id ?? id),
436
+ baseUrl,
437
+ productName: data?.productName || '',
438
+ projectName: bug.projectName || '',
439
+ modulePath: moduleNames(data?.modulePath),
440
+ bug: normalizeFields(bug),
441
+ users: referencedUsers(users, bug, data?.actions),
442
+ actions: normalizeActions(data?.actions, users),
443
+ }
444
+ },
445
+
446
+ /** 任务完整详情:全字段 + 相关人真名 + 操作历史。 */
447
+ async taskFull({ id, signal } = {}) {
448
+ const data = await jsonPage(`/task-view-${encodeURIComponent(id)}.json`, { signal })
449
+ const task = data?.task || {}
450
+ const users = data?.users || {}
451
+ return {
452
+ id: String(task.id ?? id),
453
+ baseUrl,
454
+ projectName: task.projectName || data?.project?.name || '',
455
+ modulePath: moduleNames(data?.modulePath),
456
+ task: normalizeFields(task),
457
+ users: referencedUsers(users, task, data?.actions),
458
+ actions: normalizeActions(data?.actions, users),
459
+ }
460
+ },
461
+
462
+ /** 取禅道文件(缺陷截图/附件)。仅允许 /file-read-* 与 /file-download-* 白名单路径。 */
463
+ async fetchFile({ path, signal } = {}) {
464
+ const target = String(path || '')
465
+ // 规范化后再校验:否则 /file-read-1/../../index.php 之类的点段会被上游解析到别处
466
+ let resolved
467
+ try {
468
+ resolved = new URL(target, baseUrl)
469
+ } catch {
470
+ throw new ZentaoSessionError(`文件路径无法解析:${target.slice(0, 80)}`)
471
+ }
472
+ const base = new URL(baseUrl)
473
+ if (resolved.origin !== base.origin) throw new ZentaoSessionError(`文件路径越界(跨源):${target.slice(0, 80)}`)
474
+ if (!FILE_PATH_PATTERN.test(resolved.pathname)) {
475
+ throw new ZentaoSessionError(`不允许的文件路径:${target.slice(0, 80)}`)
476
+ }
477
+ await ensure(signal)
478
+ const res = await raw(resolved.pathname, { signal })
479
+ if (res.status !== 200) throw new ZentaoSessionError(`取文件失败:HTTP ${res.status}`)
480
+ const contentType = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
481
+ // 先看 content-length,避免把超大文件整块读进内存
482
+ const declared = Number(res.headers.get('content-length') || 0)
483
+ if (Number.isFinite(declared) && declared > MAX_FILE_BYTES) {
484
+ throw new ZentaoSessionError(`文件过大(声明 ${declared} 字节,上限 ${MAX_FILE_BYTES})`)
485
+ }
486
+ const buffer = Buffer.from(await res.arrayBuffer())
487
+ if (buffer.length > MAX_FILE_BYTES) throw new ZentaoSessionError(`文件过大(${buffer.length} 字节)`)
488
+ return { contentType, buffer }
489
+ },
490
+
491
+ /** 用给定账号密码试登录(不改动当前会话),用于首次安装的登录校验。 */
492
+ /** 当前会话 id:用于在浏览器里以 ?zentaosid= 免登录打开禅道页面。 */
493
+ async currentSessionId({ signal } = {}) {
494
+ await ensure(signal)
495
+ return jar.get('zentaosid') || ''
496
+ },
497
+
498
+ async login({ baseUrl: targetBase, account, password, signal } = {}) {
499
+ const probe = createZentaoSessionClient({
500
+ baseUrl: trimSlash(targetBase),
501
+ account: String(account || ''),
502
+ password: String(password || ''),
503
+ timeoutMs,
504
+ })
505
+ const info = await probe.ping({ signal })
506
+ return { baseUrl: trimSlash(targetBase), account: String(account || ''), ...info }
507
+ },
508
+
509
+ /** 当前 BUG 在禅道里可执行的操作(解析详情页里的 /bug-{op}-{id}.html 链接)。 */
510
+ async bugOps({ id, signal } = {}) {
511
+ const source = await html(`/bug-view-${encodeURIComponent(id)}.html`, { signal })
512
+ const found = new Set()
513
+ const pattern = new RegExp(`/bug-([a-zA-Z][a-zA-Z0-9]*)-${String(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.html`, 'g')
514
+ for (const match of source.matchAll(pattern)) found.add(match[1])
515
+ for (const skip of ['view', 'create', 'browse', 'export', 'report']) found.delete(skip)
516
+ // 保险:破坏性操作不暴露给面板。实测本实例只会出现 confirmBug/assignTo/resolve/edit,
517
+ // 但 ops 是从详情页链接抓来的,别的实例/权限若列出删除类操作,这里先挡住。
518
+ for (const op of [...found]) {
519
+ if (/^(delete|remove|erase|trash|batch)/i.test(op)) found.delete(op)
520
+ }
521
+ return { id: String(id), ops: [...found] }
522
+ },
523
+
524
+ /** 取某个操作的表单定义(字段、选项、默认值),与禅道弹窗同源。 */
525
+ async bugForm({ id, op, signal } = {}) {
526
+ const path = `/bug-${String(op).replace(/[^a-zA-Z0-9]/g, '')}-${encodeURIComponent(id)}.html`
527
+ const source = await html(path, { signal })
528
+ const parsed = parseZentaoForm(source)
529
+ return { id: String(id), op, path, uid: pageVar(source, 'kuid'), ...parsed }
530
+ },
531
+
532
+ /** 按禅道表单提交操作(确认/解决/关闭/激活/指派/编辑)。 */
533
+ async bugAction({ id, op, values, signal } = {}) {
534
+ const safeOp = String(op || '').replace(/[^a-zA-Z0-9]/g, '')
535
+ if (!safeOp) throw new ZentaoSessionError('缺少操作名')
536
+ // 不再用清洗后的名字静默改写端点:名字里有非法字符就直接拒绝
537
+ if (safeOp !== String(op || '')) throw new ZentaoSessionError(`操作名含非法字符,已拒绝:${String(op).slice(0, 40)}`)
538
+ const path = `/bug-${safeOp}-${encodeURIComponent(id)}.html`
539
+ const source = await html(path, { signal })
540
+ const form = {}
541
+ for (const [key, value] of Object.entries(values || {})) {
542
+ if (key === 'uid') continue
543
+ if (Array.isArray(value)) form[key] = value.map((item) => String(item))
544
+ else if (value !== undefined && value !== null) form[key] = String(value)
545
+ }
546
+ form.uid = requireUid(source)
547
+ const result = await postForm(path, form, { signal })
548
+ return {
549
+ id: String(id),
550
+ op: safeOp,
551
+ posted: result.posted,
552
+ httpStatus: result.status,
553
+ location: result.location,
554
+ alert: result.alert,
555
+ sentFields: Object.keys(form),
556
+ }
557
+ },
558
+ }
559
+ }
560
+
561
+ /**
562
+ * 「我的地盘」列表的浏览范围白名单。
563
+ * 值必须与禅道自己 tab 链接里的写法逐字一致:写错时禅道**不报错**,
564
+ * 而是静默退化成未过滤的全量列表(实测把 reviewedBy 写成 reviewBy,
565
+ * recTotal 从 137 变成 27428),所以未知值一律抛错、绝不发出去。
566
+ */
567
+ const BROWSE_TYPES = {
568
+ bug: ['assignedTo', 'openedBy', 'resolvedBy', 'closedBy'],
569
+ task: ['assignedTo', 'openedBy', 'finishedBy', 'closedBy', 'canceledBy'],
570
+ story: ['assignedTo', 'openedBy', 'reviewedBy', 'closedBy'],
571
+ }
572
+
573
+ /** 校验并返回浏览范围;缺省 assignedTo(= 指派给我,与禅道 tab 默认一致)。 */
574
+ function resolveBrowse(kind, type) {
575
+ const allowed = BROWSE_TYPES[kind]
576
+ if (!allowed) throw new ZentaoSessionError(`未知条目类型:${kind}`)
577
+ const wanted = type === undefined || type === null || type === '' ? 'assignedTo' : String(type)
578
+ if (!allowed.includes(wanted)) {
579
+ throw new ZentaoSessionError(`未知范围 ${wanted}(${kind} 支持:${allowed.join('/')})`)
580
+ }
581
+ return wanted
582
+ }
583
+
584
+ /** 列表容器归一化:BUG/任务返回数组,需求返回 id→对象的 map(实测差异),两种都要吃。 */
585
+ function listRows(data, key) {
586
+ const raw = data ? data[key] : undefined
587
+ if (Array.isArray(raw)) return raw
588
+ if (raw && typeof raw === 'object') return Object.values(raw)
589
+ return []
590
+ }
591
+
592
+ /** 服务端声明的总条数(本页只有前 N 条时,界面要能说清"共多少条")。 */
593
+ function pageTotal(data, fallback) {
594
+ const value = Number(data && data.pager && data.pager.recTotal)
595
+ return Number.isFinite(value) && value > 0 ? value : fallback
596
+ }
597
+
598
+ /** limit<=0/未给 时返回全部(工具默认条数由调用方决定)。 */
599
+ function sliceLimit(rows, limit) {
600
+ return typeof limit === 'number' && limit > 0 ? rows.slice(0, limit) : rows
601
+ }
602
+
603
+ /** 只允许禅道自身的文件存取路径:带 $ 锚点,避免 /file-read-1/../../x 这类穿越。 */
604
+ const FILE_PATH_PATTERN = /^\/file-(read|download)-\d+(?:\.[a-z0-9]+)?$/i
605
+ const MAX_FILE_BYTES = 8 * 1024 * 1024
606
+
607
+ /** 把模块链拼成 "投资企业侧应用 / 危险作业管理"。 */
608
+ function moduleNames(modulePath) {
609
+ if (!Array.isArray(modulePath)) return ''
610
+ return modulePath.map((item) => item && item.name).filter(Boolean).join(' / ')
611
+ }
612
+
613
+ /** 去掉空值字段,保证返回的是 lossless JSON。 */
614
+ function normalizeFields(source) {
615
+ const out = {}
616
+ for (const [key, value] of Object.entries(source || {})) {
617
+ if (value === null || value === undefined || value === '') continue
618
+ if (Array.isArray(value)) {
619
+ out[key] = value.length === 0 ? [] : value
620
+ continue
621
+ }
622
+ out[key] = typeof value === 'object' ? JSON.parse(JSON.stringify(value)) : value
623
+ }
624
+ return out
625
+ }
626
+
627
+ /** 汇总本次详情涉及到的账号真名(禅道 users 表有 700+ 条,只带相关的)。 */
628
+ function referencedUsers(users, record, actions) {
629
+ const accounts = new Set()
630
+ for (const key of ['openedBy', 'assignedTo', 'resolvedBy', 'closedBy', 'finishedBy', 'canceledBy', 'lastEditedBy', 'startedBy']) {
631
+ if (record && typeof record[key] === 'string' && record[key]) accounts.add(record[key])
632
+ }
633
+ if (record && typeof record.assignedToRealName === 'string' && record.assignedToRealName) accounts.add(record.assignedTo)
634
+ for (const action of Object.values(actions || {})) {
635
+ if (action && typeof action.actor === 'string' && action.actor) accounts.add(action.actor)
636
+ }
637
+ const out = {}
638
+ for (const account of accounts) {
639
+ const realname = users && users[account]
640
+ out[account] = typeof realname === 'string' && realname ? realname : account
641
+ }
642
+ return out
643
+ }
644
+
645
+ /** 操作历史:按时间倒序,附上操作人真名。 */
646
+ function normalizeActions(actions, users) {
647
+ return Object.values(actions || {})
648
+ .filter((action) => action && typeof action === 'object')
649
+ .map((action) => ({
650
+ id: String(action.id || ''),
651
+ action: String(action.action || ''),
652
+ actor: String(action.actor || ''),
653
+ actorName: (users && users[action.actor]) || String(action.actor || ''),
654
+ date: String(action.date || ''),
655
+ comment: stripTags(String(action.comment || '')),
656
+ history: Array.isArray(action.history) ? action.history.length : 0,
657
+ }))
658
+ .sort((left, right) => String(right.date).localeCompare(String(left.date)))
659
+ }
660
+
661
+ /** 把 HTML 片段压成纯文本(操作历史里的备注可能是富文本)。 */
662
+ function stripTags(html) {
663
+ return String(html)
664
+ .replace(/<br\s*\/?>/gi, '\n')
665
+ .replace(/<\/p>/gi, '\n')
666
+ .replace(/<[^>]+>/g, '')
667
+ .replace(/&nbsp;/g, ' ')
668
+ .replace(/&lt;/g, '<')
669
+ .replace(/&gt;/g, '>')
670
+ .replace(/&amp;/g, '&')
671
+ .replace(/\n{3,}/g, '\n\n')
672
+ .trim()
673
+ }
674
+
675
+ /** 禅道必填字段(服务端以 JS 校验收尾,HTML 上没有标记,按标签兜底)。 */
676
+ const REQUIRED_FIELDS = new Set(['解决方案'])
677
+ /** 条件显示的行:行 id → 依赖字段与取值(与禅道 JS 的显隐逻辑一致)。
678
+ * 实测该实例:解决版本默认可见,选「重复bug」时隐藏;重复ID 仅在「重复bug」时出现。 */
679
+ const SHOW_WHEN = {
680
+ duplicateBugBox: { field: 'resolution', in: ['duplicate'] },
681
+ newBuildProjectBox: { field: 'resolution', notIn: ['duplicate'] },
682
+ }
683
+
684
+ /**
685
+ * 解析禅道表单页,得到字段定义(名称/标签/类型/默认值/下拉选项/是否宽行/是否条件显示)。
686
+ * 用于在面板里渲染与禅道弹窗一致的确认、解决、关闭等操作界面。
687
+ */
688
+ function parseZentaoForm(source) {
689
+ const main = /<div id='mainContent'[\s\S]*?<form([^>]*)>([\s\S]*?)<\/form>/i.exec(String(source))
690
+ if (!main) return { title: '', fields: [] }
691
+ const titleMatch = /<h2>([\s\S]*?)<\/h2>/i.exec(String(source))
692
+ const title = titleMatch ? stripTags(titleMatch[1]).replace(/\s+/g, ' ').slice(0, 120) : ''
693
+ const body = main[2]
694
+ const fields = []
695
+
696
+ // 隐藏域:除 uid(每次请求现取)外全部保留,禅道表单依赖它们
697
+ for (const tag of body.matchAll(/<input[^>]*type=['"]hidden['"][^>]*>/gi)) {
698
+ const name = /name=['"]([^'"]+)['"]/.exec(tag[0])
699
+ if (!name || name[1] === 'uid') continue
700
+ fields.push({
701
+ name: name[1],
702
+ label: name[1],
703
+ type: 'hidden',
704
+ value: (/value=['"]([^'"]*)['"]/.exec(tag[0]) || [])[1] || '',
705
+ })
706
+ }
707
+
708
+ let rowIndex = 0
709
+ for (const row of body.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)) {
710
+ if (/form-actions/i.test(row[0])) continue
711
+ const th = /<th[^>]*>([\s\S]*?)<\/th>/i.exec(row[1])
712
+ if (!th) continue
713
+ const label = stripTags(th[1]).replace(/\s+/g, ' ').trim()
714
+ const cell = row[1].slice(row[1].indexOf('</th>'))
715
+ // 行属性:条件显示(禅道把 id/class 放在 tr 或 td 上)与整行隐藏
716
+ const rowAttrs = row[0].slice(0, row[0].indexOf('>') + 1)
717
+ const rowId = (/id=['"]([^'"]+)['"]/.exec(row[0]) || [])[1] || ''
718
+ const rowHidden = /\bclass=['"][^'"]*\bhide\b/i.test(rowAttrs)
719
+ // 整行只有隐藏域时(如「Bug状态」行):不渲染,值已由隐藏域循环带走
720
+ const onlyHiddenInput = /<input[^>]*type=['"]hidden['"][^>]*>/i.test(cell)
721
+ && !/<select|<textarea|<input(?![^>]*type=['"]hidden['"])/i.test(cell)
722
+ if (onlyHiddenInput) continue
723
+ if (/file-input-list|type=['"]file['"]/i.test(cell)) continue // 附件:插件不支持上传,整行不渲染
724
+
725
+ const wide = /<td[^>]*colspan=['"]?([2-9])/i.test(cell)
726
+ const required = /\bclass=['"][^'"]*\brequired\b/i.test(cell) || REQUIRED_FIELDS.has(label)
727
+ const meta = {
728
+ ...(wide ? { wide: true } : {}),
729
+ ...(rowHidden ? { hidden: true } : {}),
730
+ ...(SHOW_WHEN[rowId] ? { showWhen: SHOW_WHEN[rowId] } : {}),
731
+ }
732
+
733
+ // 收集该行内所有控件(禅道一行可能有多个:所属项目 + 解决版本 + 创建 + 新版本名称)
734
+ rowIndex++
735
+ const rowKey = `r${rowIndex}`
736
+ const controls = []
737
+ const escapeRe = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
738
+ /** 控件是否位于「勾选创建后才出现」的容器里(td/div id 以 newBuild 开头:所属项目、新版本名称)。 */
739
+ const isCreateOnly = (name) => new RegExp(`<(?:div|td)[^>]*id='newBuild[^']*'[^>]*>(?:(?!</(?:div|td)>)[\\s\\S])*?name='${escapeRe(name)}'`).test(cell)
740
+ /** 控件前的 input-group 前缀文字(如「所属项目」)。 */
741
+ const addonOf = (name) => {
742
+ const hit = new RegExp(`<span class='input-group-addon'>([^<]*)</span>[\\s\\S]{0,240}?name='${escapeRe(name)}'`).exec(cell)
743
+ return hit ? hit[1].trim() : ''
744
+ }
745
+
746
+ for (const select of cell.matchAll(/<select[^>]*name=['"]([^'"]+)['"]([^>]*)>([\s\S]*?)<\/select>/gi)) {
747
+ const options = [...select[3].matchAll(/<option[^>]*value=['"]([^'"]*)['"]([^>]*)>([\s\S]*?)<\/option>/gi)]
748
+ .map((option) => ({
749
+ value: option[1],
750
+ label: stripTags(option[3]).replace(/\s+/g, ' ').trim(),
751
+ selected: /selected/i.test(option[2]),
752
+ }))
753
+ controls.push({
754
+ name: select[1],
755
+ type: 'select',
756
+ multiple: /multiple/i.test(select[2]),
757
+ options,
758
+ value: (options.find((option) => option.selected) || {}).value || '',
759
+ })
760
+ }
761
+ for (const textarea of cell.matchAll(/<textarea[^>]*name=['"]([^'"]+)['"]([^>]*)>([\s\S]*?)<\/textarea>/gi)) {
762
+ controls.push({
763
+ name: textarea[1],
764
+ type: 'textarea',
765
+ rows: Number((/rows=['"]?(\d+)/i.exec(textarea[2]) || [])[1] || 6),
766
+ value: textarea[3].replace(/\s+$/, ''),
767
+ })
768
+ }
769
+ for (const input of cell.matchAll(/<input[^>]*>/gi)) {
770
+ const tag = input[0]
771
+ if (/type=['"]hidden['"]/i.test(tag) || /type=['"]file['"]/i.test(tag)) continue
772
+ const name = /name=['"]([^'"]+)['"]/.exec(tag)
773
+ if (!name) continue
774
+ const isCheckbox = /type=['"]checkbox['"]/i.test(tag)
775
+ const around = cell.slice(Math.max(0, cell.indexOf(tag) + tag.length), cell.indexOf(tag) + tag.length + 80)
776
+ controls.push({
777
+ name: name[1],
778
+ type: isCheckbox ? 'checkbox' : (/type=['"]date['"]/.test(tag) ? 'date' : 'text'),
779
+ value: (/value=['"]([^'"]*)['"]/.exec(tag) || [])[1] || '',
780
+ checked: isCheckbox ? /\bchecked\b/i.test(tag) : undefined,
781
+ placeholder: (/placeholder=['"]([^'"]*)['"]/.exec(tag) || [])[1] || '',
782
+ // 勾选框文案:禅道写成 <input ...><label for='x'>创建</label>
783
+ inlineText: isCheckbox ? (stripTags(around).replace(/\s+/g, ' ').trim().split(/[<]/)[0] || '') : '',
784
+ })
785
+ }
786
+ if (controls.length === 0) continue
787
+
788
+ // 主控件:优先取「非勾选创建才出现」的第一个控件(避免把「所属项目」当成主字段)
789
+ const plainControls = controls
790
+ .map((control, index) => ({ control, index }))
791
+ .filter((item) => item.control.type !== 'checkbox')
792
+ const mainEntry = plainControls.find((item) => !isCreateOnly(item.control.name)) || plainControls[0]
793
+ const mainIndex = mainEntry ? mainEntry.index : 0
794
+ controls.forEach((control, index) => {
795
+ const isMain = index === mainIndex
796
+ const createOnly = control.type !== 'checkbox' && isCreateOnly(control.name)
797
+ && controls.some((item) => item.name === 'createBuild')
798
+ fields.push({
799
+ ...control,
800
+ label: isMain ? label : '',
801
+ rowKey,
802
+ primary: isMain,
803
+ addon: isMain ? '' : addonOf(control.name),
804
+ required: isMain ? required : false,
805
+ ...meta,
806
+ ...(createOnly ? { showWhenChecked: 'createBuild' } : {}),
807
+ ...(control.type === 'checkbox' ? {
808
+ inline: true,
809
+ label: control.inlineText || control.name,
810
+ defaultChecked: control.checked === true,
811
+ } : {}),
812
+ })
813
+ })
814
+ }
815
+ return { title, fields }
816
+ }