@hupan56/wlkj 3.3.13 → 3.3.14

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 (103) hide show
  1. package/bin/cli.js +1 -23
  2. package/package.json +29 -29
  3. package/templates/qoder/agents/design-agent.md +20 -0
  4. package/templates/qoder/agents/spec-generator.md +21 -0
  5. package/templates/qoder/commands/optional/wl-spec.md +2 -12
  6. package/templates/qoder/commands/optional/wl-status.md +0 -8
  7. package/templates/qoder/commands/wl-code.md +0 -8
  8. package/templates/qoder/commands/wl-commit.md +1 -11
  9. package/templates/qoder/commands/wl-init.md +129 -129
  10. package/templates/qoder/commands/wl-prd.md +0 -24
  11. package/templates/qoder/commands/wl-search.md +11 -26
  12. package/templates/qoder/commands/wl-task.md +1 -11
  13. package/templates/qoder/commands/wl-test.md +0 -8
  14. package/templates/qoder/config.yaml +8 -3
  15. package/templates/qoder/hooks/session-start.py +365 -384
  16. package/templates/qoder/hooks/stop-eval.py +254 -383
  17. package/templates/qoder/scripts/capability/adapters/mcp.py +314 -322
  18. package/templates/qoder/scripts/capability/registry.py +248 -245
  19. package/templates/qoder/scripts/capability/registry_mcp.py +4 -10
  20. package/templates/qoder/scripts/capability/smoke_test_report.json.new +94 -0
  21. package/templates/qoder/scripts/deployment/setup/init_doctor.py +2 -6
  22. package/templates/qoder/scripts/deployment/setup/install_qoderwork.py +14 -0
  23. package/templates/qoder/scripts/deployment/setup/setup.py +2 -16
  24. package/templates/qoder/scripts/domain/integration/return_to_platform.py +2 -4
  25. package/templates/qoder/scripts/domain/integration/spec_upload.py +4 -4
  26. package/templates/qoder/scripts/domain/kg/build/build_entity_registry.py +12 -12
  27. package/templates/qoder/scripts/domain/kg/build/build_relations.py +12 -12
  28. package/templates/qoder/scripts/domain/kg/extract/asset/__init__.py +10 -0
  29. package/templates/qoder/scripts/domain/kg/extract/asset/asset_tree.py +57 -0
  30. package/templates/qoder/scripts/domain/kg/extract/asset/discussion_importer.py +62 -0
  31. package/templates/qoder/scripts/domain/kg/extract/asset/prd_importer.py +146 -0
  32. package/templates/qoder/scripts/domain/kg/extract/asset/prototype_importer.py +64 -0
  33. package/templates/qoder/scripts/domain/kg/extract/asset/returns_importer.py +52 -0
  34. package/templates/qoder/scripts/domain/kg/extract/build_goal3.py +104 -0
  35. package/templates/qoder/scripts/domain/kg/extract/build_goal4.py +55 -0
  36. package/templates/qoder/scripts/domain/kg/extract/build_goal5.py +95 -0
  37. package/templates/qoder/scripts/domain/kg/extract/db/__init__.py +8 -0
  38. package/templates/qoder/scripts/domain/kg/extract/db/data_profile.py +22 -0
  39. package/templates/qoder/scripts/domain/kg/extract/db/fk_extractor.py +55 -0
  40. package/templates/qoder/scripts/domain/kg/extract/db/schema_extractor.py +90 -0
  41. package/templates/qoder/scripts/domain/kg/extract/inference/__init__.py +9 -0
  42. package/templates/qoder/scripts/domain/kg/extract/inference/community_summarizer.py +206 -0
  43. package/templates/qoder/scripts/domain/kg/extract/inference/embed_builder.py +132 -0
  44. package/templates/qoder/scripts/domain/kg/extract/inference/naming_matcher.py +80 -0
  45. package/templates/qoder/scripts/domain/kg/extract/inference/promote.py +59 -0
  46. package/templates/qoder/scripts/domain/kg/extract/inference/recompute.py +93 -0
  47. package/templates/qoder/scripts/domain/kg/extract/inference/weak_link.py +421 -0
  48. package/templates/qoder/scripts/domain/kg/extract/mybatis/__init__.py +9 -0
  49. package/templates/qoder/scripts/domain/kg/extract/mybatis/all.py +79 -0
  50. package/templates/qoder/scripts/domain/kg/extract/mybatis/mapper_parser.py +99 -0
  51. package/templates/qoder/scripts/domain/kg/extract/mybatis/relation_builder.py +69 -0
  52. package/templates/qoder/scripts/domain/kg/extract/mybatis/sql_extractor.py +78 -0
  53. package/templates/qoder/scripts/domain/kg/extract/prd/__init__.py +8 -0
  54. package/templates/qoder/scripts/domain/kg/extract/prd/prd_chunk_embed.py +105 -0
  55. package/templates/qoder/scripts/domain/kg/extract/prd/prd_llm_extract.py +153 -0
  56. package/templates/qoder/scripts/domain/kg/extract/prd/req_anchor.py +120 -0
  57. package/templates/qoder/scripts/domain/kg/graph/kg_link_db.py +235 -0
  58. package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +5 -41
  59. package/templates/qoder/scripts/domain/kg/kg.py +0 -14
  60. package/templates/qoder/scripts/domain/kg/search/enrich_prompt.py +238 -0
  61. package/templates/qoder/scripts/domain/kg/server/perf_bench.py +197 -0
  62. package/templates/qoder/scripts/domain/kg/switch_project.py +2 -2
  63. package/templates/qoder/scripts/domain/kg/sync_repowiki.py +109 -0
  64. package/templates/qoder/scripts/domain/task/wlkj_panel.py +1348 -1503
  65. package/templates/qoder/scripts/engine/poller.py +219 -0
  66. package/templates/qoder/scripts/foundation/bootstrap.py +145 -156
  67. package/templates/qoder/scripts/orchestration/wlkj.py +189 -306
  68. package/templates/qoder/settings.json +0 -8
  69. package/templates/qoder/skills/wl-spec/SKILL.md +1 -1
  70. package/templates/qoder/commands/wl-data.md +0 -46
  71. package/templates/qoder/commands/wl-fix.md +0 -47
  72. package/templates/qoder/commands/wl-knowledge.md +0 -41
  73. package/templates/qoder/commands/wl-review.md +0 -49
  74. package/templates/qoder/contracts/insight.md +0 -55
  75. package/templates/qoder/hooks/pre-tool-use-commit.py +0 -124
  76. package/templates/qoder/scripts/foundation/io/context_cache.py +0 -94
  77. package/templates/qoder/scripts/tool_guide.md +0 -70
  78. package/templates/qoder/scripts/validation/eval/alignment_matrix.py +0 -176
  79. package/templates/qoder/scripts/validation/eval/bf2_content_fidelity.py +0 -110
  80. package/templates/qoder/scripts/validation/eval/bf2_llmjudge.py +0 -104
  81. package/templates/qoder/scripts/validation/eval/bf_score.py +0 -218
  82. package/templates/qoder/scripts/validation/eval/code_flywheel.py +0 -150
  83. package/templates/qoder/scripts/validation/eval/dispatcher_ab.py +0 -156
  84. package/templates/qoder/scripts/validation/eval/dispatcher_ab_2026-07-21.json +0 -23
  85. package/templates/qoder/scripts/validation/eval/feature_fidelity_flywheel.py +0 -143
  86. package/templates/qoder/scripts/validation/eval/gradient_matrix.py +0 -261
  87. package/templates/qoder/scripts/validation/eval/gradient_matrix_baseline_2026-07-21.json +0 -33
  88. package/templates/qoder/scripts/validation/eval/metrics_dashboard.py +0 -105
  89. package/templates/qoder/scripts/validation/eval/multi_turn_flywheel.py +0 -118
  90. package/templates/qoder/scripts/validation/eval/prd_fidelity_flywheel.py +0 -128
  91. package/templates/qoder/scripts/validation/eval/prd_flywheel.py +0 -148
  92. package/templates/qoder/scripts/validation/eval/prototype_fidelity_flywheel.py +0 -166
  93. package/templates/qoder/scripts/validation/eval/recall_flywheel.py +0 -148
  94. package/templates/qoder/scripts/validation/eval/robustness_flywheel.py +0 -139
  95. package/templates/qoder/scripts/validation/eval/speed_accuracy_flywheel.py +0 -188
  96. package/templates/qoder/scripts/validation/eval/task_flywheel.py +0 -124
  97. package/templates/qoder/scripts/validation/eval/token_flywheel.py +0 -88
  98. package/templates/qoder/scripts/validation/metrics/eval_code_ac.py +0 -177
  99. package/templates/qoder/scripts/validation/metrics/lint_cases.py +0 -170
  100. package/templates/qoder/scripts/validation/test/test_context_cache.py +0 -78
  101. package/templates/qoder/scripts/validation/test/test_lint_cases.py +0 -60
  102. package/templates/qoder/scripts/validation/test/test_pre_tool_use_commit.py +0 -70
  103. package/templates/workspace/specs/REQ-TEST-CTX/.context-cache.json +0 -1
@@ -1,1503 +1,1348 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- wlkj_panel.py - 开发工作台数据接口(禅道任务 + 平台 spec/impact;供 VS Code 扩展 zentao-panel 调用)
5
-
6
- 目的: 给侧边栏面板一个稳定的 JSON 数据源, 一次返回"指派给我的【任务+需求+Bug】",
7
- 跨所有执行/产品全量聚合。逻辑与 my_workbench 工具一致 (只读), 但输出 JSON 而非文本。
8
-
9
- 为什么不直接调 MCP server:
10
- MCP server 的数据收集依赖 import-time 全局变量 (CLIENT / MY_ACCOUNT), 由 mcp_launcher
11
- 注入环境变量才生效。本脚本走独立进程 (child_process.spawn 启动), 没有 launcher 注入,
12
- 所以照搬 zentao_sync.py 的稳健模式: 自己 ZentaoClient.from_config() + 自己解析归属。
13
- 收集循环则与 zentao_mcp_server.py:_collect_my_* 逐字一致 (DRY 同款), 保证数据口径统一。
14
-
15
- 用法:
16
- python .qoder/scripts/domain/task/wlkj_panel.py --json # 三域活动项 JSON
17
- python .qoder/scripts/domain/task/wlkj_panel.py --json --include-closed # 含已关闭历史项
18
- python .qoder/scripts/domain/task/wlkj_panel.py --json --kind tasks # 只看任务
19
-
20
- 退出码 (对齐项目规范):
21
- 0 成功 (含空结果); 3 配置/归属错误; 5 网络不通。
22
- --json 模式下错误也输出 JSON ({"ok": false, "error": "..."}), 扩展端统一解析。
23
- """
24
- import os
25
- import sys
26
-
27
- # ── 路径自举 (复用 bootstrap 模式, 与 zentao_sync.py 完全一致) ──
28
- # 本脚本在 scripts/domain/task/ 下 (scripts 下 2 级), dirname 两次才到 scripts/。
29
- _HERE = os.path.dirname(os.path.abspath(__file__))
30
- _SCRIPTS = os.path.dirname(os.path.dirname(_HERE)) # .qoder/scripts (上两级)
31
- _COMMON = os.path.join(_SCRIPTS, 'foundation')
32
- for _p in (_SCRIPTS, _COMMON):
33
- if _p not in sys.path:
34
- sys.path.insert(0, _p)
35
- try:
36
- from bootstrap import setup; setup()
37
- except Exception:
38
- pass
39
-
40
- import json
41
- import argparse
42
- from datetime import datetime
43
-
44
- from foundation.core.paths import get_developer
45
- from foundation.identity.identity import get_member
46
- from foundation.integrations.zentao_client import ZentaoClient
47
-
48
-
49
- # ============================================================
50
- # 配置与归属解析 (照搬 zentao_sync.py, 唯一真相源)
51
- # ============================================================
52
-
53
- def _zentao_creds():
54
- """构建禅道客户端。返回 (client, preset_account)
55
-
56
- 凭据源优先级:
57
- ① 云平台 per-user 凭据(Option A:get_zentao_creds)—— account 已知(preset)
58
- ② 本地 .qoder/config.yaml + secrets/zentao.env —— account 走 member.json
59
- 云凭据优先:demo 机器在内网可达禅道,平台凭据比本地文件更可能是最新配的。
60
- 凭据全无 → (None, '')。
61
- """
62
- c = _cloud_zentao_creds()
63
- if c and c.get('ok') and c.get('url') and c.get('account') and c.get('password'):
64
- try:
65
- return ZentaoClient(c['url'], c['account'], c['password']), c['account']
66
- except Exception:
67
- pass
68
- return ZentaoClient.from_config(), ''
69
-
70
-
71
- def _cloud_my_panel():
72
- """云优先:调平台 get_my_zentao_panel(凭据走平台 per-user 配置,不出平台)。
73
-
74
- 直连云 /mcp JSON-RPC(读 .qoder/mcp_config.json token+url)。
75
- Returns: {ok:True, tasks, bugs, stories, account} 或 None(云不可达/工具失败)。
76
- 云返 {ok:False,error}(未配置/登录失败)→ 返该 dict(调用方据此提示+回退本地)。
77
- """
78
- import json as _json
79
- import urllib.request as _u
80
- import urllib.error as _ue
81
- # 定位 mcp_config.json(switch_project 写在 .qoder/)
82
- cfg_candidates = []
83
- try:
84
- from foundation.core.paths import get_repo_root
85
- cfg_candidates.append(os.path.join(str(get_repo_root()), '.qoder', 'mcp_config.json'))
86
- except Exception:
87
- pass
88
- cfg_candidates.append(os.path.join(_SCRIPTS, '..', 'mcp_config.json'))
89
- token, url = '', ''
90
- for cp in cfg_candidates:
91
- try:
92
- with open(cp, encoding='utf-8') as f:
93
- cfg = _json.load(f)
94
- srv = (cfg.get('mcpServers') or {}).get('wlinkj-knowledge') or {}
95
- token = srv.get('token', '')
96
- url = (srv.get('url') or '').rstrip('/')
97
- if token and url:
98
- break
99
- except Exception:
100
- continue
101
- if not token or not url:
102
- return None # 云未绑定(switch_project 没跑)→ 回退本地
103
- endpoint = url if url.endswith('/mcp') else url + '/mcp'
104
- body = _json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
105
- "params": {"name": "get_my_zentao_panel", "arguments": {}}}).encode('utf-8')
106
- req = _u.Request(endpoint, data=body, method='POST')
107
- req.add_header('Content-Type', 'application/json')
108
- req.add_header('X-MCP-Token', token)
109
- try:
110
- with _u.urlopen(req, timeout=30) as resp:
111
- r = _json.loads(resp.read().decode('utf-8', errors='replace'))
112
- except Exception:
113
- return None # 网络不通 回退本地
114
- try:
115
- text = r.get('result', {}).get('content', [{}])[0].get('text', '')
116
- data = _json.loads(text) if text else {}
117
- except Exception:
118
- return None
119
- return data # {ok:True/False, ...}
120
-
121
-
122
- def _cloud_zentao_creds():
123
- """Option A:从平台取禅道凭据(url/account/password 解密)。
124
-
125
- 用途:云到禅道被防火墙挡(my-panel 登录失败)时,本机(demo 机器,内网可达)
126
- 用平台凭据直连禅道拉真实数据。凭据随绑定 token 取本人配置(同信任级)。
127
- Returns: {ok:True, url, account, password} 或 None(云不可达/未配置)。
128
- """
129
- import json as _json
130
- import urllib.request as _u
131
- cfg_candidates = []
132
- try:
133
- from foundation.core.paths import get_repo_root
134
- cfg_candidates.append(os.path.join(str(get_repo_root()), '.qoder', 'mcp_config.json'))
135
- except Exception:
136
- pass
137
- cfg_candidates.append(os.path.join(_SCRIPTS, '..', 'mcp_config.json'))
138
- token, url = '', ''
139
- for cp in cfg_candidates:
140
- try:
141
- with open(cp, encoding='utf-8') as f:
142
- cfg = _json.load(f)
143
- srv = (cfg.get('mcpServers') or {}).get('wlinkj-knowledge') or {}
144
- token = srv.get('token', '')
145
- url = (srv.get('url') or '').rstrip('/')
146
- if token and url:
147
- break
148
- except Exception:
149
- continue
150
- if not token or not url:
151
- return None
152
- endpoint = url if url.endswith('/mcp') else url + '/mcp'
153
- body = _json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
154
- "params": {"name": "get_zentao_creds", "arguments": {}}}).encode('utf-8')
155
- req = _u.Request(endpoint, data=body, method='POST')
156
- req.add_header('Content-Type', 'application/json')
157
- req.add_header('X-MCP-Token', token)
158
- try:
159
- with _u.urlopen(req, timeout=15) as resp:
160
- r = _json.loads(resp.read().decode('utf-8', errors='replace'))
161
- text = r.get('result', {}).get('content', [{}])[0].get('text', '')
162
- data = _json.loads(text) if text else {}
163
- return data if data.get('ok') else None
164
- except Exception:
165
- return None
166
-
167
-
168
- def _resolve_my_account(developer):
169
- """解析当前 developer → 禅道 account。返回 (account, 错误原因)。"""
170
- mem = get_member(developer)
171
- if not mem:
172
- return None, '当前开发者 %s 未注册 (无 member.json)。请先跑 /wl-init。' % developer
173
- acc = (mem.get('zentao_account') or '').strip()
174
- if not acc:
175
- return None, ('%s 的 member.json 未配 zentao_account。\n'
176
- ' 编辑 workspace/members/%s/member.json 加 "zentao_account": "你的禅道账号"。'
177
- % (developer, developer))
178
- return acc, None
179
-
180
-
181
- # ============================================================
182
- # 收集循环 ( zentao_mcp_server.py:_collect_my_* 逐字一致)
183
- # 单独写成函数, 不依赖 self / 全局 CLIENT, 接收 client 做参数 → 可独立调用。
184
- # ============================================================
185
-
186
- def _paged_list(client, path, params, list_key, page_size=100, max_pages=20):
187
- """分页拉全量 (循环 page+limit, 按 id 去重)。照搬 mcp server 同名方法。
188
-
189
- Returns: (合并去重列表, 是否截断[读满 max_pages 仍未到尾])。
190
- """
191
- seen, all_items = set(), []
192
- truncated = False
193
- for _page in range(1, max_pages + 1):
194
- p = dict(params or {})
195
- p.update({'limit': page_size, 'page': _page})
196
- r = client.api('GET', path, p)
197
- if r.status_code not in (200, 201):
198
- break
199
- data = r.json()
200
- items = data.get(list_key) if isinstance(data, dict) else None
201
- if not isinstance(items, list) or not items:
202
- break
203
- new = 0
204
- for it in items:
205
- if not isinstance(it, dict):
206
- continue
207
- key = it.get('id', id(it))
208
- if key in seen:
209
- continue
210
- seen.add(key)
211
- all_items.append(it)
212
- new += 1
213
- if new == 0:
214
- break
215
- return all_items, truncated
216
-
217
-
218
- def _collect_my_tasks(client, my_account, status_filter=None):
219
- """扫所有执行, 收集指派给我的任务。返回 (任务列表, None) 或 (None, 错误)。
220
-
221
- 性能: 用线程池并行请求各 execution (61 个执行串行要 ~15s, 并行后 ~2-3s)。
222
- ZentaoClient 内部 token 有锁但 HTTP 请求本身是 IO, 线程并行安全。
223
- """
224
- from concurrent.futures import ThreadPoolExecutor
225
- execs, _ = _paged_list(client, '/executions', None, 'executions')
226
- if execs is None:
227
- return None, '查询执行列表失败, 稍后重试。'
228
-
229
- def fetch_one(e):
230
- eid = e.get('id')
231
- if eid is None:
232
- return []
233
- ename = str(e.get('name', ''))[:20]
234
- tasks, _ = _paged_list(client, '/executions/%s/tasks' % eid, None, 'tasks')
235
- out = []
236
- for t in tasks:
237
- if not isinstance(t, dict):
238
- continue
239
- if ZentaoClient.assignee_account(t) != my_account:
240
- continue
241
- if status_filter and t.get('status') != status_filter:
242
- continue
243
- t2 = dict(t)
244
- t2['execution'] = eid
245
- t2['execution_name'] = ename
246
- out.append(t2)
247
- return out
248
-
249
- all_t = []
250
- with ThreadPoolExecutor(max_workers=10) as pool:
251
- for batch in pool.map(fetch_one, execs):
252
- all_t.extend(batch)
253
- return all_t, None
254
-
255
-
256
- def _scan_products(client):
257
- """获取要扫描的产品 ID 列表。
258
-
259
- 关键修复: 禅道 /products 接口实测会漏产品 (团队踩坑: product=3 "ICS 2.0" 真实存在
260
- 且含 33 个 Bug, 但 /products 列表不返回它 → 逐产品扫描会漏掉全部这些 Bug)
261
- 所以这里采用「列表 + 连续 ID 补扫」双保险:
262
- 1. 先取 /products 列表 (官方口径);
263
- 2. 再补扫 1..MAX_PRODUCT_ID 范围里列表没返回但真实存在的 (用 /products/{id} 探活)。
264
- """
265
- listed, _ = _paged_list(client, '/products', None, 'products')
266
- listed_ids = set(p.get('id') for p in listed if isinstance(p, dict) and p.get('id') is not None)
267
- # 补扫: 列表没覆盖的连续 ID 段 (团队产品数实测 < 20, 给到 30 留余量)
268
- MAX_PID = 30
269
- for pid in range(1, MAX_PID + 1):
270
- if pid in listed_ids:
271
- continue
272
- r = client.api('GET', '/products/%s' % pid)
273
- if r.status_code == 200 and r.json().get('id'): # 真实存在
274
- listed_ids.add(pid)
275
- return sorted(listed_ids)
276
-
277
-
278
- def _collect_my_bugs(client, my_account):
279
- """扫所有产品 (含 /products 列表漏掉的), 收集**指派给我**的 Bug (并行)。
280
-
281
- v0.5 修复: 旧版只扫 /products 列表返回的产品, 漏掉了 product=3 (列表不返回但含
282
- 3 个我的 Bug)。改用 _scan_products 双保险覆盖所有真实产品。
283
- """
284
- from concurrent.futures import ThreadPoolExecutor
285
- pids = _scan_products(client)
286
-
287
- def fetch_one(pid):
288
- bugs, _ = _paged_list(client, '/products/%s/bugs' % pid, None, 'bugs')
289
- out = []
290
- for b in bugs:
291
- if not isinstance(b, dict):
292
- continue
293
- if ZentaoClient.assignee_account(b) != my_account:
294
- continue
295
- b2 = dict(b)
296
- b2['product_id'] = pid
297
- out.append(b2)
298
- return out
299
-
300
- mine = []
301
- with ThreadPoolExecutor(max_workers=10) as pool:
302
- for batch in pool.map(fetch_one, pids):
303
- mine.extend(batch)
304
- return mine, None
305
-
306
-
307
- def _collect_my_stories(client, my_account):
308
- """扫所有产品 (含 /products 列表漏掉的), 收集**指派给我**的需求 (并行)。"""
309
- from concurrent.futures import ThreadPoolExecutor
310
- pids = _scan_products(client)
311
-
312
- def fetch_one(pid):
313
- sts, _ = _paged_list(client, '/products/%s/stories' % pid, None, 'stories')
314
- out = []
315
- for s in sts:
316
- if not isinstance(s, dict):
317
- continue
318
- if ZentaoClient.assignee_account(s) != my_account:
319
- continue
320
- s2 = dict(s)
321
- s2['product_id'] = pid
322
- out.append(s2)
323
- return out
324
-
325
- mine = []
326
- with ThreadPoolExecutor(max_workers=10) as pool:
327
- for batch in pool.map(fetch_one, pids):
328
- mine.extend(batch)
329
- return mine, None
330
-
331
-
332
- # ============================================================
333
- # 归一化: 把禅道原始 dict 裁成面板需要的精简字段 (减体积 + 隐藏内部字段)
334
- # ============================================================
335
-
336
- def _detect_code_committed(item_id, title_keywords):
337
- """检测代码是否已提交 (git log 搜任务ID或标题关键词)。"""
338
- import subprocess
339
- try:
340
- # 搜任务ID 标题前几个关键词
341
- patterns = ['#%s' % item_id] + [k for k in title_keywords if len(k) >= 2][:2]
342
- for p in patterns:
343
- r = subprocess.run(
344
- ['git', 'log', '--oneline', '--all', '-1', '--grep=%s' % p],
345
- capture_output=True, text=True, timeout=5,
346
- cwd=str(_SCRIPTS.parent) if hasattr(_SCRIPTS, 'parent') else None)
347
- if r.returncode == 0 and r.stdout.strip():
348
- return True
349
- except Exception:
350
- pass
351
- return False
352
-
353
-
354
- def _detect_progress(client, kind, item_id, raw):
355
- """检测工作流进度: PRD / Spec / 代码 三节点。
356
-
357
- 返回 {'prd': bool, 'spec': str('none'|'draft'|'confirmed'), 'code': bool}
358
- - PRD: task 看关联需求spec非空; bug 看steps非空; story 看自身spec非空
359
- - Spec: find_spec 找到文件, 读 status
360
- - 代码: git log 搜任务ID/关键词
361
- """
362
- import re as _re
363
- from foundation.core.paths import find_spec
364
- progress = {'prd': False, 'spec': 'none', 'code': False}
365
-
366
- # PRD 检测
367
- try:
368
- if kind == 'task':
369
- sid = raw.get('story')
370
- if sid:
371
- sr = client.api('GET', '/stories/%s' % sid)
372
- if sr.status_code == 200:
373
- spec = _clean_html(sr.json().get('spec', ''))
374
- progress['prd'] = bool(spec.strip())
375
- elif kind == 'bug':
376
- steps = _clean_html(raw.get('steps', ''))
377
- progress['prd'] = bool(steps.strip())
378
- elif kind == 'story':
379
- spec = _clean_html(raw.get('spec', ''))
380
- progress['prd'] = bool(spec.strip())
381
- except Exception:
382
- pass
383
-
384
- # Spec 检测 (find_spec 需要 REQ-ID, 但禅道任务没有 REQ-ID, 用任务ID兜底)
385
- try:
386
- # 禅道任务没有 REQ-ID, find_spec 找不到 → 先查当前任务目录的 spec.md
387
- # (大多数情况 spec 在任务目录, 不在全局 specs/)
388
- hits = find_spec() # 查当前任务目录
389
- if hits:
390
- progress['spec'] = hits[0].get('status', 'draft')
391
- except Exception:
392
- pass
393
-
394
- # 代码检测 (git log)
395
- title = str(raw.get('name', '') or raw.get('title', ''))
396
- keywords = _re.split(r'[^\u4e00-\u9fa5a-zA-Z0-9]+', title)
397
- keywords = [k for k in keywords if len(k) >= 2][:3]
398
- progress['code'] = _detect_code_committed(item_id, keywords)
399
-
400
- return progress
401
-
402
-
403
- def _norm_task(t):
404
- return {
405
- 'id': t.get('id'),
406
- 'name': str(t.get('name', '')),
407
- 'status': t.get('status', ''),
408
- 'pri': t.get('pri'),
409
- 'left': t.get('left'), # 剩余工时
410
- 'estimate': t.get('estimate'),
411
- 'consumed': t.get('consumed'),
412
- 'deadline': t.get('deadline'),
413
- 'execution': t.get('execution'),
414
- 'execution_name': t.get('execution_name', ''),
415
- 'story': t.get('story'), # 关联需求ID (进度检测用)
416
- }
417
-
418
-
419
- def _norm_bug(b):
420
- return {
421
- 'id': b.get('id'),
422
- 'title': str(b.get('title', '')),
423
- 'status': b.get('status', ''),
424
- 'severity': b.get('severity'),
425
- 'pri': b.get('pri'),
426
- }
427
-
428
-
429
- def _norm_story(s):
430
- return {
431
- 'id': s.get('id'),
432
- 'title': str(s.get('title', '')),
433
- 'status': s.get('status', ''),
434
- 'pri': s.get('pri'),
435
- }
436
-
437
-
438
- # 活动态白名单 ( my_workbench 一致 + 修正: changed 也是活动态)
439
- # 说明: 禅道 task 有 changed 状态 (需求变更/任务被改动), 旧版 LIVE_TASK 漏掉它
440
- # → 用户实测"明明有个进行中的任务却看不到"。changed 不是终态, 应算活动项。
441
- LIVE_TASK = {'wait', 'doing', 'pause', 'changed'}
442
- LIVE_BUG = {'active'}
443
- LIVE_STORY = {'active', 'reviewing', 'changing'}
444
-
445
-
446
- def _filter_live(tasks, bugs, stories, include_closed):
447
- """过滤终态 (除非 include_closed)。"""
448
- if include_closed:
449
- return tasks, bugs, stories
450
- return ([t for t in tasks if t.get('status') in LIVE_TASK],
451
- [b for b in bugs if b.get('status') in LIVE_BUG],
452
- [s for s in stories if s.get('status') in LIVE_STORY])
453
-
454
-
455
- # ============================================================
456
- # 主流程
457
- # ============================================================
458
-
459
- def fetch_workbench(include_closed=False, kind=None):
460
- """收集三域并归一化。返回 (result_dict, exit_code)。
461
-
462
- Args:
463
- include_closed: True=含已关闭历史项
464
- kind: 'tasks'/'bugs'/'stories' 只取一域; None=全取
465
- Returns:
466
- ({"ok": True, "data": {...}, "meta": {...}}, 0) 或
467
- ({"ok": False, "error": "..."}, 3 或 5)
468
- """
469
- developer = get_developer()
470
-
471
- # ⓪ 云优先:调平台 get_my_zentao_panel(凭据走平台 per-user,不出平台)。
472
- # 成功 直接用云数据,跳过本地凭据/连通/归属/收集(① ~ ④)。
473
- # 未配置/登录失败 返回 {ok:False} 提示用户去平台配置,并回退本地(本地配了仍可用)。
474
- # 云不可达(返回 None) 静默回退本地全流程。
475
- cloud = _cloud_my_panel()
476
- if isinstance(cloud, dict):
477
- # 云可达 云的判断权威(平台是凭据源)。
478
- if cloud.get('ok'):
479
- tasks = cloud.get('tasks') or []
480
- bugs = cloud.get('bugs') or []
481
- stories = cloud.get('stories') or []
482
- # kind 裁剪(与本地一致)
483
- if kind == 'tasks':
484
- bugs, stories = [], []
485
- elif kind == 'bugs':
486
- tasks, stories = [], []
487
- elif kind == 'stories':
488
- tasks, bugs = [], []
489
- tasks, bugs, stories = _filter_live(tasks, bugs, stories, include_closed)
490
- # 云路径:数据已平台聚合,归一化即可(进度检测需本地 client,云模式跳过给默认值)
491
- norm_tasks = [_norm_task(t) for t in tasks]
492
- norm_bugs = [_norm_bug(b) for b in bugs]
493
- norm_stories = [_norm_story(s) for s in stories]
494
- for it in norm_tasks + norm_bugs + norm_stories:
495
- it['progress'] = {'prd': False, 'spec': 'none', 'code': False}
496
- data = {'account': cloud.get('account', ''), 'developer': developer,
497
- 'current_task': None, 'tasks': norm_tasks,
498
- 'bugs': norm_bugs, 'stories': norm_stories}
499
- meta = {'fetched_at': datetime.now().isoformat(timespec='seconds'),
500
- 'source': 'cloud',
501
- 'counts': {'tasks': len(norm_tasks), 'bugs': len(norm_bugs),
502
- 'stories': len(norm_stories)},
503
- 'include_closed': include_closed}
504
- return {'ok': True, 'data': data, 'meta': meta}, 0
505
- # 云可达但失败(未配置/登录失败/防火墙)→ 记下云诊断,回退本地。
506
- # 回退价值:云到禅道常被防火墙挡,但本机(demo 机器,内网)可能直连可达。
507
- # 本地也失败时,把云诊断一起带出(平台有凭据但云连不上 + 本地无凭据)。
508
- cloud_error = cloud.get('error', '禅道不可用')
509
- else:
510
- cloud_error = None # 云不可达(switch_project 没跑/平台宕机)→ 静默回退本地
511
-
512
- # 凭据(云平台优先 Option A,回退本地文件)
513
- client, preset_account = _zentao_creds()
514
- if client is None:
515
- base_msg = ('禅道凭据未配置。推荐在【平台设置 → 禅道】配置(panel 自动走云取凭据);'
516
- '或本地 .qoder/config.yaml 的 zentao.url + secrets/zentao.env 账号密码。')
517
- if cloud_error:
518
- base_msg += '(云 my-panel 失败:%s)' % cloud_error
519
- return {'ok': False, 'error': base_msg}, 3
520
-
521
- ok, msg = client.credentials_ok()
522
- if not ok:
523
- return {'ok': False, 'error': '禅道凭据不完整: %s' % msg}, 3
524
-
525
- # 内网连通(本机能否直连禅道——demo 机器在内网可达)
526
- ok, msg = client.check_intranet()
527
- if not ok:
528
- return {'ok': False, 'error': '本机连不上禅道内网: %s' % msg}, 5
529
-
530
- # 归属(云凭据带的 account 优先;否则走 member.json)
531
- if preset_account:
532
- my_account = preset_account
533
- else:
534
- my_account, err = _resolve_my_account(developer) if developer else (None, '未识别当前开发者身份。')
535
- if not my_account:
536
- return {'ok': False, 'error': err}, 3
537
-
538
- # 收集 (三域并行, 按 kind 决定取哪几域)
539
- from concurrent.futures import ThreadPoolExecutor
540
- tasks, bugs, stories = [], [], []
541
-
542
- def _gather():
543
- jobs = {}
544
- if kind in (None, 'tasks'):
545
- jobs['tasks'] = lambda: _collect_my_tasks(client, my_account)
546
- if kind in (None, 'bugs'):
547
- jobs['bugs'] = lambda: _collect_my_bugs(client, my_account)
548
- if kind in (None, 'stories'):
549
- jobs['stories'] = lambda: _collect_my_stories(client, my_account)
550
- results = {}
551
- with ThreadPoolExecutor(max_workers=3) as pool:
552
- futs = {pool.submit(fn): k for k, fn in jobs.items()}
553
- for fut in futs:
554
- results[futs[fut]] = fut.result()
555
- return results
556
-
557
- res = _gather()
558
- if 'tasks' in res:
559
- tasks, e = res['tasks']
560
- if e:
561
- return {'ok': False, 'error': e}, 5
562
- if 'bugs' in res:
563
- bugs, _ = res['bugs']
564
- if 'stories' in res:
565
- stories, _ = res['stories']
566
-
567
- # 过滤 + 归一 + 进度检测
568
- tasks, bugs, stories = _filter_live(tasks, bugs, stories, include_closed)
569
-
570
- # 进度检测 (并行, 只对前12个检测避免过多请求)
571
- from concurrent.futures import ThreadPoolExecutor
572
-
573
- def _add_progress(kind, items, raws):
574
- if not items:
575
- return
576
- # raws 和 items 按顺序对应 (都来自同一批过滤后数据)
577
- def detect_one(idx):
578
- try:
579
- p = _detect_progress(client, kind, items[idx]['id'], raws[idx])
580
- items[idx]['progress'] = p
581
- except Exception:
582
- items[idx]['progress'] = {'prd': False, 'spec': 'none', 'code': False}
583
- with ThreadPoolExecutor(max_workers=6) as pool:
584
- list(pool.map(detect_one, range(min(len(items), 12))))
585
- # 超过12个的不检测, 给默认值
586
- for i in range(12, len(items)):
587
- items[i]['progress'] = {'prd': False, 'spec': 'none', 'code': False}
588
-
589
- norm_tasks = [_norm_task(t) for t in tasks]
590
- norm_bugs = [_norm_bug(b) for b in bugs]
591
- norm_stories = [_norm_story(s) for s in stories]
592
- _add_progress('task', norm_tasks, tasks)
593
- _add_progress('bug', norm_bugs, bugs)
594
- _add_progress('story', norm_stories, stories)
595
-
596
- # 当前任务 (工作流的 .current-task 机制)
597
- current_task = None
598
- try:
599
- from foundation.core.paths import get_current_task
600
- ct = get_current_task()
601
- if ct:
602
- current_task = str(ct)
603
- except Exception:
604
- pass
605
-
606
- data = {
607
- 'account': my_account,
608
- 'developer': developer,
609
- 'current_task': current_task,
610
- 'tasks': norm_tasks,
611
- 'bugs': norm_bugs,
612
- 'stories': norm_stories,
613
- }
614
- meta = {
615
- 'fetched_at': datetime.now().isoformat(timespec='seconds'),
616
- 'counts': {
617
- 'tasks': len(data['tasks']),
618
- 'bugs': len(data['bugs']),
619
- 'stories': len(data['stories']),
620
- },
621
- 'include_closed': include_closed,
622
- }
623
- return {'ok': True, 'data': data, 'meta': meta}, 0
624
-
625
-
626
- # ============================================================
627
- # 写操作 (v0.3): 面板按钮触发的禅道状态流转
628
- # 设计对齐 MCP server 的 _assert_owner: 写前校验归属, 防误改队友。
629
- # 任务: start/pause/finish/log_effort; Bug: confirm/resolve
630
- # ============================================================
631
-
632
- def _check_owner(client, my_account, kind, item_id):
633
- """写前归属校验 (对齐 mcp server _assert_owner)。
634
- task 只认 assignedTo==我; bug assignedTo==我 OR openedBy==我。
635
- 返回 None=放行, 字符串=拒绝原因。"""
636
- r = client.api('GET', '/%ss/%s' % (kind, item_id))
637
- if r.status_code not in (200, 201):
638
- return None # 查询失败不阻塞 (禅道权限兜底)
639
- data = r.json()
640
- asg = ZentaoClient.assignee_account(data)
641
- allow = (asg == my_account)
642
- if kind == 'bug' and not allow:
643
- allow = (ZentaoClient.openedby_account(data) == my_account)
644
- if not allow:
645
- return '这条%s指派给 %s, 不是你(%s), 已拒绝改。' % (kind, asg or '空', my_account)
646
- return None
647
-
648
-
649
- def _do_confirm_spec(client, my_account, kind, item_id, params):
650
- """确认并上传 spec 到平台(禅道面板「确认 spec」按钮触发)。
651
-
652
- 流程: 定位 spec.md → 解析 zentao story id → 调 spec_upload 回流平台。
653
- spec 挂在 story 上(zentao_id = story 号),与 commit_pipeline 同套关联键。
654
- 幂等: spec.md 契约头有 platform_spec_id → update_spec;无 → create+confirm+回写。
655
-
656
- Args:
657
- client: ZentaoClient None(显式给 zentao_id 时不需禅道连接)。
658
- kind: task/bug/story(item_id 的类型,用于反查 story)。
659
- item_id: 禅道 task/bug/story id(仅展示用 + 反查 story)。
660
- params: spec_path / zentao_id / code_targets(均可选,缺则自动解析)。
661
- """
662
- # 定位 spec.md: 优先 --spec-path;否则 find_spec() 找当前任务目录的 spec
663
- spec_path = params.get('spec_path')
664
- if not spec_path:
665
- try:
666
- from foundation.core.paths import find_spec
667
- hits = find_spec() or []
668
- if hits:
669
- spec_path = hits[0].get('path')
670
- except Exception:
671
- pass
672
- if not spec_path:
673
- return {'ok': False, 'error': '没找到 spec.md。先用 /wl-spec 生成,或传 --spec-path <路径>。'}, 3
674
-
675
- # 解析 zentao story id: 优先 --zentao-id;否则从 item 反查(task/bug 读 story 字段, story 用自身 id)
676
- zentao_id = params.get('zentao_id')
677
- if not zentao_id and client:
678
- try:
679
- r = client.api('GET', '/%s/%s' % ({'task': 'tasks', 'bug': 'bugs', 'story': 'stories'}.get(kind, kind + 's'), item_id))
680
- if r.status_code == 200:
681
- d = r.json()
682
- zentao_id = str(d.get('id') if kind == 'story' else (d.get('story') or '') or '')
683
- except Exception:
684
- pass
685
- if not zentao_id:
686
- return {'ok': False, 'error': '未能从禅道 %s #%s 解析 story id(spec 挂 story 上)。传 --zentao-id 显式指定。' % (kind, item_id)}, 3
687
-
688
- # spec_upload(复用回流客户端)
689
- try:
690
- from domain.integration.spec_upload import upload_spec
691
- except Exception as e:
692
- return {'ok': False, 'error': 'spec_upload 客户端加载失败: %s' % e}, 3
693
- ct_raw = params.get('code_targets')
694
- code_targets = [c.strip() for c in ct_raw.split(',') if c.strip()] if ct_raw else None
695
- r = upload_spec(spec_path, zentao_id_override=zentao_id, code_targets=code_targets)
696
- if r.get('ok'):
697
- return {'ok': True, 'data': {
698
- 'action': 'confirm_spec', 'id': item_id, 'spec_id': r['platform_spec_id'],
699
- 'via': r.get('via'), 'status': r.get('status'), 'version': r.get('version'),
700
- 'message': '%s:%s(story=%s → platform spec %s, %s)' % (
701
- 'spec 已上传' if r.get('via') == 'create_spec' else 'spec 已更新',
702
- spec_path, zentao_id, r['platform_spec_id'], r.get('status'))}}, 0
703
- return {'ok': False, 'error': 'spec 上传失败: %s' % r.get('message')}, 5
704
-
705
-
706
- def _do_link_version(client, my_account, kind, item_id, params):
707
- """把 story/bug 关联到 build(禅道「关联版本」按钮触发)。
708
-
709
- Args:
710
- kind: story/bug(决定挂到 /builds/{id}/stories 还是 /bugs)
711
- item_id: 禅道 story/bug id
712
- params: build_id 必填
713
- """
714
- build_id = params.get('build_id')
715
- if not build_id:
716
- return {'ok': False, 'error': '关联版本必须指定 --build-id'}, 3
717
- obj_key = 'stories' if kind == 'story' else 'bugs'
718
- r = client.api('POST', '/builds/%s/%s' % (build_id, obj_key), {obj_key: [item_id]})
719
- if r.status_code in (200, 201):
720
- return {'ok': True, 'data': {
721
- 'action': 'link_version', 'id': item_id,
722
- 'message': '%s #%s 已关联到 build %s' % (kind, item_id, build_id)}}, 0
723
- return {'ok': False, 'error': '关联版本失败 (HTTP %s): %s' % (
724
- r.status_code, ZentaoClient.err_text(r))}, 5
725
-
726
-
727
- def _do_publish_story(client, my_account, kind, item_id, params):
728
- """建禅道 story(禅道「发布需求」按钮触发)。
729
-
730
- 新建需求挂产品;可选关联 plan / build。
731
- item_id 仅展示用(新建不依赖已有对象)。
732
-
733
- Args:
734
- params: product_id 必填;title/spec/pri/category/plan_id/build_id 可选
735
- """
736
- pid = params.get('product_id')
737
- if not pid:
738
- return {'ok': False, 'error': '建需求必须指定 --product-id'}, 3
739
- try:
740
- payload = {
741
- 'product': int(pid),
742
- 'title': params.get('title') or '',
743
- 'spec': params.get('spec', ''),
744
- 'pri': int(params.get('pri', 3)),
745
- 'category': params.get('category', 'feature'),
746
- }
747
- except (ValueError, TypeError):
748
- return {'ok': False, 'error': 'product-id/pri 必须是数字。'}, 3
749
- if not payload['title']:
750
- return {'ok': False, 'error': '建需求必须指定 --title'}, 3
751
-
752
- r = client.api('POST', '/stories', payload)
753
- if r.status_code not in (200, 201):
754
- return {'ok': False, 'error': '建需求失败 (HTTP %s): %s' % (
755
- r.status_code, ZentaoClient.err_text(r))}, 5
756
- try:
757
- story_id = r.json().get('id')
758
- except Exception:
759
- story_id = None
760
- if not story_id:
761
- return {'ok': False, 'error': '建需求响应无 id: %s' % ZentaoClient.err_text(r)}, 5
762
-
763
- # 可选关联 plan
764
- plan_id = params.get('plan_id')
765
- if plan_id:
766
- try:
767
- client.api('POST', '/plans/%s/stories' % plan_id, {'stories': [story_id]})
768
- except Exception:
769
- pass # 关联失败不阻塞主流程(需求已建好)
770
-
771
- # 可选关联 build
772
- build_id = params.get('build_id')
773
- if build_id:
774
- _do_link_version(client, my_account, 'story', story_id, {'build_id': build_id})
775
-
776
- return {'ok': True, 'data': {
777
- 'action': 'publish_story', 'story_id': story_id,
778
- 'message': '需求 #%s 已创建(产品 %s)%s' % (
779
- story_id, pid, (' 并关联 build %s' % build_id) if build_id else '')}}, 0
780
-
781
-
782
- # 禅道写操作的 HTTP 调度表: action -> (method, path_tpl, payload_builder, ok_msg)
783
- # payload_builder(args_dict) -> payload_dict
784
- def _do_action(client, my_account, action, item_id, params):
785
- """执行单个写操作。返回 (result_dict, exit_code)。"""
786
- developer = get_developer()
787
- my_account = my_account or (_resolve_my_account(developer)[0] if developer else None)
788
- if not my_account:
789
- return {'ok': False, 'error': '未识别禅道身份, 无法校验归属。请配 member.json 的 zentao_account。'}, 3
790
-
791
- # spec 确认上传(不走禅道 HTTP 写,调 spec_upload 回流平台)
792
- if action == 'confirm_spec':
793
- _k = params.get('kind') or 'task'
794
- _k = {'tasks': 'task', 'bugs': 'bug', 'stories': 'story'}.get(_k, _k)
795
- return _do_confirm_spec(client, my_account, _k, item_id, params)
796
-
797
- # story 域写操作(自定义逻辑,独立函数分支,同 confirm_spec)
798
- if action == 'publish_story':
799
- return _do_publish_story(client, my_account, 'story', item_id, params)
800
- if action == 'link_version':
801
- _k = params.get('kind') or 'story'
802
- _k = {'tasks': 'task', 'bugs': 'bug', 'stories': 'story'}.get(_k, _k)
803
- return _do_link_version(client, my_account, _k, item_id, params)
804
-
805
- # action 分派
806
- ACTIONS = {
807
- # 任务类 (kind=task)
808
- 'start_task': ('task', 'POST', '/tasks/%s/start', {'left': 'left'}),
809
- 'pause_task': ('task', 'POST', '/tasks/%s/pause', {}),
810
- 'finish_task': ('task', 'POST', '/tasks/%s/finish', {'left': 'left', 'consumed': 'consumed'}),
811
- 'log_effort': ('task', 'POST', '/tasks/%s/estimate', {'consumed': 'consumed', 'left': 'left'}),
812
- # Bug 类 (kind=bug)
813
- 'confirm_bug': ('bug', 'POST', '/bugs/%s/confirm', {}),
814
- 'resolve_bug': ('bug', 'POST', '/bugs/%s/resolve', {'resolution': 'resolution', 'comment': 'comment'}),
815
- }
816
- if action not in ACTIONS:
817
- return {'ok': False, 'error': '未知操作: %s。支持: %s' % (action, ', '.join(ACTIONS.keys()))}, 3
818
-
819
- kind, method, path_tpl, fields = ACTIONS[action]
820
- path = path_tpl % item_id
821
-
822
- # ① 归属校验 (防误改队友)
823
- guard = _check_owner(client, my_account, kind, item_id)
824
- if guard:
825
- return {'ok': False, 'error': guard}, 4
826
-
827
- # payload (只取该操作关心的字段)
828
- payload = {}
829
- for pkey, fkey in fields.items():
830
- v = params.get(fkey)
831
- if v is not None and v != '':
832
- try:
833
- payload[pkey] = float(v) if fkey in ('left', 'consumed') else v
834
- except (ValueError, TypeError):
835
- payload[pkey] = v
836
-
837
- # ③ 各操作的必填字段兜底 (实测禅道校验)
838
- today = datetime.now().strftime('%Y-%m-%d')
839
- if action == 'finish_task':
840
- # 实测: finish 必须带 finishedDate ("实际完成"日期), 否则 400
841
- payload.setdefault('left', 0)
842
- payload.setdefault('finishedDate', today)
843
- elif action == 'start_task':
844
- # 实测禅道: start 不允许 "总计消耗=0 且 预计剩余=0" (报 400)。
845
- # 策略: 用户填了 left 就用; 没填则查任务的 estimate(预计工时) 兜底;
846
- # estimate 也没有 → 返回友好错误让用户填, 不传 0 撞 400。
847
- if 'left' not in payload or payload.get('left') == 0:
848
- # 查任务详情拿 estimate
849
- try:
850
- td = client.api('GET', '/tasks/%s' % item_id).json()
851
- est = td.get('estimate') or 0
852
- except Exception:
853
- est = 0
854
- if est and est > 0:
855
- payload['left'] = float(est)
856
- elif not payload.get('consumed'):
857
- return {'ok': False, 'error': '开始任务需要填剩余工时(禅道不允许剩余=0)。请在弹框里填一个预估工时,如 8。'}, 3
858
- elif action == 'log_effort' and 'consumed' not in payload:
859
- return {'ok': False, 'error': '记工时必须填消耗工时。'}, 3
860
- elif action == 'resolve_bug' and 'resolution' not in payload:
861
- payload['resolution'] = 'fixed'
862
- elif action == 'confirm_bug':
863
- # 确认Bug 部分禅道版本要 assignedTo, 确保认领到自己头上
864
- payload.setdefault('assignedTo', my_account)
865
-
866
- # 调禅道
867
- r = client.api(method, path, payload)
868
- if r.status_code in (200, 201):
869
- label = {
870
- 'start_task': '已开始', 'pause_task': '已暂停', 'finish_task': '已完成',
871
- 'log_effort': '已记工时', 'confirm_bug': '已确认', 'resolve_bug': '已解决',
872
- }[action]
873
- return {'ok': True, 'data': {'action': action, 'id': item_id, 'message': '%s #%s %s' % (kind, item_id, label)}}, 0
874
- return {'ok': False, 'error': '操作失败 (HTTP %s): %s。可能权限不足或当前状态不允许。' % (
875
- r.status_code, ZentaoClient.err_text(r))}, 5
876
-
877
-
878
- def perform_action(action, item_id, params=None):
879
- """写操作入口。返回 (result_dict, exit_code)。"""
880
- params = params or {}
881
- # confirm_spec: 已显式给 spec_path + zentao_id 时不依赖禅道连接(直接回流平台)
882
- if action == 'confirm_spec' and params.get('spec_path') and params.get('zentao_id'):
883
- _k = params.get('kind') or 'task'
884
- _k = {'tasks': 'task', 'bugs': 'bug', 'stories': 'story'}.get(_k, _k)
885
- return _do_confirm_spec(None, None, _k, item_id, params)
886
- client = _zentao_creds()
887
- if client is None:
888
- return {'ok': False, 'error': '禅道凭据未配置。'}, 3
889
- ok, msg = client.credentials_ok()
890
- if not ok:
891
- return {'ok': False, 'error': '禅道凭据不完整: %s' % msg}, 3
892
- ok, msg = client.check_intranet()
893
- if not ok:
894
- return {'ok': False, 'error': '连不上禅道内网: %s' % msg}, 5
895
- developer = get_developer()
896
- my_account, err = _resolve_my_account(developer) if developer else (None, '未识别开发者身份')
897
- if err and not my_account:
898
- return {'ok': False, 'error': err}, 3
899
- return _do_action(client, my_account, action, item_id, params)
900
-
901
-
902
- # ============================================================
903
- # 提示词增强 (v0.6): 发AI前用知识图谱拉相关代码/API/字段
904
- # 策略: 调 context_pack.py 拿结构化上下文 → 拼进提示词。
905
- # 查不到/超时 → 静默降级返回基础提示词 (永远不阻塞「发AI」)。
906
- # ============================================================
907
-
908
- # 停用词: 任务标题里这些词不该拿去查知识图谱 (噪音太大)
909
- _STOPWORDS = set('的 了 和 与 或 及 及 以 在 为 对 从 给 把 被 让 用 : : ( ) ( ) - — , , . 。 / 新增 修改 优化 问题 异常 无法 不能 报错'.split())
910
-
911
-
912
- def _extract_keywords(title):
913
- """从任务/Bug 标题提取业务关键词。
914
-
915
- 策略: 按 、,。:等分隔符切块 → 去停用词 → 长串按2-4字滑窗拆出业务词。
916
- 例: "车辆年检、保险延期终止申请关联异常生成优化"
917
- → ['车辆年检','年检','保险','延期','终止','申请','关联','异常','生成']
918
- """
919
- if not title:
920
- return []
921
- import re
922
- # 去掉 #id、数字编号、【】
923
- clean = re.sub(r'#\d+|【.*?】|\d{4,}', ' ', title)
924
- # 按标点/符号切分成短语块
925
- chunks = re.split(r'[^\u4e00-\u9fa5a-zA-Z0-9]+', clean)
926
- kws = []
927
- for chunk in chunks:
928
- chunk = chunk.strip()
929
- if not chunk or chunk in _STOPWORDS or chunk.isdigit():
930
- continue
931
- if len(chunk) <= 4:
932
- # 短块直接用 (如 "保险" "年检")
933
- kws.append(chunk)
934
- elif re.match(r'^[\u4e00-\u9fa5]+$', chunk):
935
- # 纯中文长块: 2-4字滑窗拆出所有业务词 (如"保险延期终止"→保险/险延/延期/期终/终止)
936
- # 但只保留 2-4 字的常见业务词长度, 去掉无意义组合
937
- for size in (2, 3, 4):
938
- for i in range(len(chunk) - size + 1):
939
- w = chunk[i:i+size]
940
- if w not in _STOPWORDS:
941
- kws.append(w)
942
- else:
943
- kws.append(chunk)
944
- # 去重保序
945
- seen, out = set(), []
946
- for k in kws:
947
- if k not in seen:
948
- seen.add(k)
949
- out.append(k)
950
- return out[:6] # 多取一些, 合并查询时去重
951
-
952
-
953
- # 噪音文件: 这些通用文件命中了也不该推荐 (路由表/测试入口/通用配置, 不是业务实现)
954
- _NOISE_PATTERNS = [
955
- '/router/routes/', 'module/mList/', 'module/ips/main.vue',
956
- '/common/core/exception/', 'enum', 'constant.java', 'core.ts',
957
- 'Application.java', 'main.vue', 'List.vue', 'index.vue',
958
- ]
959
-
960
-
961
- def _is_noise(filepath):
962
- """判断代码文件是否是噪音 (路由/测试/通用文件, 非业务实现)。"""
963
- fp = filepath.lower()
964
- return any(p.lower() in fp for p in _NOISE_PATTERNS)
965
-
966
-
967
- def _guess_platform(scene_name):
968
- """从执行名/场景名推断 platform。
969
- execution 名形如 '20260702后端'/'20260702前端'/'20260702移动端'。
970
- 返回 'web'/'app' 或 None(后端代码不按端过滤)
971
- """
972
- if not scene_name:
973
- return None
974
- if '前端' in scene_name or 'PC' in scene_name or '管理端' in scene_name:
975
- return 'web'
976
- if '移动端' in scene_name or 'APP' in scene_name.upper() or 'H5' in scene_name.upper():
977
- return 'app'
978
- return None # 后端: 知识图谱不过滤 platform (fywl-ics 后端代码)
979
-
980
-
981
- def _query_kg_context(keyword, platform=None):
982
- """调 context_pack.py 拉单个关键词的上下文。带超时, 失败返回空dict。
983
- platform: 'web'/'app' 过滤代码库 (前端=fywl-ui, 移动端=Carmg-H5); None=不过滤(含后端)。
984
- """
985
- import subprocess
986
- script = os.path.join(_SCRIPTS, 'domain', 'kg', 'search', 'context_pack.py')
987
- if not os.path.isfile(script):
988
- return {}
989
- cmd = ['python', script, keyword]
990
- if platform in ('web', 'app'):
991
- cmd += ['--platform', platform]
992
- try:
993
- r = subprocess.run(
994
- cmd, capture_output=True, text=True, timeout=15, encoding='utf-8', errors='replace')
995
- return _parse_context_pack(r.stdout, keyword)
996
- except subprocess.TimeoutExpired:
997
- return {} # 知识图谱查询超时, 静默降级
998
- except Exception:
999
- return {}
1000
-
1001
-
1002
- def _parse_context_pack(text, keyword):
1003
- """从 context_pack 的文本输出里提取代码/API/字段三类信息 (精简版)。"""
1004
- if not text:
1005
- return {}
1006
- import re
1007
- result = {'code_files': [], 'apis': [], 'fields': []}
1008
- lines = text.split('\n')
1009
- section = None
1010
- for line in lines:
1011
- # 识别段落标题
1012
- if '相关代码文件' in line:
1013
- section = 'code'; continue
1014
- elif '相关 API' in line:
1015
- section = 'api'; continue
1016
- elif '相关字段' in line:
1017
- section = 'field'; continue
1018
- elif line.startswith('## '):
1019
- section = None; continue
1020
- # 提取条目
1021
- if not section:
1022
- continue
1023
- m = re.match(r'-\s+(?:\[[^\]]*\]\s*)?([^\s(]+)', line)
1024
- if not m:
1025
- continue
1026
- item = m.group(1).strip()
1027
- if section == 'code' and ('/' in item or '\\' in item):
1028
- if not _is_noise(item): # 过滤路由/测试/通用文件噪音
1029
- result['code_files'].append(item)
1030
- elif section == 'api' and item.startswith('/'):
1031
- if not _is_noise(item):
1032
- result['apis'].append(item)
1033
- elif section == 'field' and '->' in line:
1034
- result['fields'].append(line.strip('- ').strip())
1035
- # 精简: 代码取前8, API取前6, 字段取前6
1036
- result['code_files'] = result['code_files'][:8]
1037
- result['apis'] = result['apis'][:6]
1038
- result['fields'] = result['fields'][:6]
1039
- return result
1040
-
1041
-
1042
- def _query_kg_prefetch(title, platform=None):
1043
- """调 kg.py prefetch 一次性多词合并搜索 (知识图谱升级后的能力)
1044
-
1045
- _query_kg_context(单关键词) 强: prefetch 自动拆词+合并+按多词命中排序。
1046
- 输入完整标题, 一次拿到最全最准的上下文。
1047
- """
1048
- import subprocess
1049
- script = os.path.join(_SCRIPTS, 'domain', 'kg', 'kg.py')
1050
- if not os.path.isfile(script):
1051
- return {}
1052
- cmd = ['python', script, 'prefetch', title]
1053
- if platform in ('web', 'app'):
1054
- cmd += ['--platform', platform]
1055
- try:
1056
- r = subprocess.run(
1057
- cmd, capture_output=True, text=True, timeout=20, encoding='utf-8', errors='replace')
1058
- return _parse_prefetch(r.stdout)
1059
- except subprocess.TimeoutExpired:
1060
- return {}
1061
- except Exception:
1062
- return {}
1063
-
1064
-
1065
- def _parse_prefetch(text):
1066
- """从 kg.py prefetch 的输出提取代码/API/字段 (复用 _parse_context_pack 逻辑)。
1067
-
1068
- prefetch 输出格式和 context_pack 基本一致 (都有"相关代码文件/相关API/相关字段"段),
1069
- 只是 prefetch 带多词命中次数标注 (如 "4词命中")。
1070
- """
1071
- return _parse_context_pack(text, 'prefetch')
1072
-
1073
-
1074
- def _query_kg_semantic(query, top_k=5):
1075
- """调 kg.py semantic 语义搜索 (概念检索, 理解业务含义)
1076
-
1077
- 比 prefetch 的关键词匹配更强: 查"数据权限"能命中 permission/auth 等概念相关代码。
1078
- 用于补充 prefetch 可能漏掉的语义相关文件。带超时, daemon 未启动时冷启动~27s。
1079
- """
1080
- import subprocess
1081
- script = os.path.join(_SCRIPTS, 'domain', 'kg', 'kg.py')
1082
- if not os.path.isfile(script):
1083
- return []
1084
- try:
1085
- r = subprocess.run(
1086
- ['python', script, 'semantic', query, '--top', str(top_k)],
1087
- capture_output=True, text=True, timeout=35, encoding='utf-8', errors='replace')
1088
- if r.returncode != 0 or '[error]' in r.stdout:
1089
- return []
1090
- # 解析输出: " 0.849 数据权限 handlePermission"
1091
- import re
1092
- results = []
1093
- for line in r.stdout.split('\n'):
1094
- m = re.match(r'\s*([\d.]+)\s+(.+)', line)
1095
- if m:
1096
- results.append(m.group(2).strip())
1097
- return results[:top_k]
1098
- except Exception:
1099
- return []
1100
-
1101
-
1102
- def _query_kg_impact(keyword):
1103
- """调 kg.py impact 影响分析 (改某模块影响哪些端点/Controller)
1104
-
1105
- 用于 bug 提示词的"影响范围"——告诉 AI 这个改动会波及哪些接口。
1106
- """
1107
- import subprocess
1108
- script = os.path.join(_SCRIPTS, 'domain', 'kg', 'kg.py')
1109
- if not os.path.isfile(script):
1110
- return []
1111
- try:
1112
- r = subprocess.run(
1113
- ['python', script, 'impact', keyword],
1114
- capture_output=True, text=True, timeout=15, encoding='utf-8', errors='replace')
1115
- if r.returncode != 0 or '未找到' in r.stdout:
1116
- return []
1117
- # 解析: 提取 Controller 名和端点
1118
- import re
1119
- controllers = set()
1120
- endpoints = set()
1121
- for line in r.stdout.split('\n'):
1122
- m = re.match(r'\s+后端:\s*(\S+)', line)
1123
- if m:
1124
- controllers.add(m.group(1))
1125
- m2 = re.match(r'^###\s+(\S+)', line)
1126
- if m2 and m2.group(1).startswith('/'):
1127
- endpoints.add(m2.group(1))
1128
- return {'controllers': list(controllers)[:6], 'endpoints': list(endpoints)[:6]}
1129
- except Exception:
1130
- return {}
1131
-
1132
-
1133
- def _clean_html(s):
1134
- """去 HTML 标签 + 实体, 压缩空白。禅道详情字段是富文本 HTML。"""
1135
- if not s:
1136
- return ''
1137
- import re
1138
- s = re.sub(r'<br\s*/?>|</p>|</div>', '\n', s, flags=re.I)
1139
- s = re.sub(r'<[^>]+>', '', s)
1140
- s = re.sub(r'&nbsp;', ' ', s)
1141
- s = re.sub(r'&amp;', '&', s)
1142
- s = re.sub(r'&lt;', '<', s)
1143
- s = re.sub(r'&gt;', '>', s)
1144
- s = re.sub(r'[ \t]+', ' ', s)
1145
- s = re.sub(r'\n{3,}', '\n\n', s)
1146
- return s.strip()
1147
-
1148
-
1149
- def _fetch_zentao_detail(client, kind, item_id, max_len=600):
1150
- """从禅道拉任务/Bug/需求的详情 + 关联信息, 用于润色提示词。
1151
-
1152
- 返回 dict:
1153
- - detail: 自身描述 (任务desc / Bug的steps重现步骤 / 需求spec)
1154
- - story_title: 关联需求标题 (任务/bug 常挂在 story 下)
1155
- - story_spec: 关联需求规格 (业务背景, 可能很详细)
1156
- - scene: 场景名 (所属执行/产品)
1157
- 任何一项查不到就留空, 不报错。
1158
- """
1159
- import re
1160
- out = {'detail': '', 'story_id': 0, 'story_title': '', 'story_spec': '', 'scene': ''}
1161
-
1162
- # 自身详情 (zentao 路径: task→tasks, bug→bugs, story→stories — story +es 不是 +s)
1163
- _kind_path = {'task': 'tasks', 'bug': 'bugs', 'story': 'stories'}.get(kind, kind + 's')
1164
- path = '/%s/%s' % (_kind_path, item_id)
1165
- r = client.api('GET', path)
1166
- if r.status_code != 200:
1167
- return out
1168
- data = r.json()
1169
-
1170
- # 取详情正文 (不同 kind 字段名不同)
1171
- if kind == 'bug':
1172
- raw = data.get('steps', '')
1173
- elif kind == 'story':
1174
- raw = data.get('spec', '') # 故事详情=spec;spec 空则无详情(title 已在关联需求段,不重复)
1175
- else: # task
1176
- raw = data.get('desc', '')
1177
- detail = _clean_html(raw)
1178
- if detail and len(detail) > max_len:
1179
- detail = detail[:max_len] + '…'
1180
- out['detail'] = detail
1181
-
1182
- # 场景名: execution (任务) / product 名 (bug/story)
1183
- # kind=story: 故事本身就是需求,story_id 用自身 id(不找父 story,story 的 data.story 是父/None)
1184
- if kind == 'story':
1185
- story_id = item_id
1186
- else:
1187
- story_id = data.get('story') or 0
1188
-
1189
- if kind == 'task':
1190
- eid = data.get('execution')
1191
- if eid:
1192
- er = client.api('GET', '/executions/%s' % eid)
1193
- if er.status_code == 200:
1194
- out['scene'] = str(er.json().get('name', ''))
1195
- else:
1196
- pid = data.get('product')
1197
- if pid:
1198
- pr = client.api('GET', '/products/%s' % pid)
1199
- if pr.status_code == 200:
1200
- out['scene'] = str(pr.json().get('name', ''))
1201
-
1202
- # 关联需求 (业务背景, 最有价值的上下文)
1203
- if story_id:
1204
- out['story_id'] = story_id
1205
- if kind == 'story':
1206
- # 故事自身即需求,用已取的 data(不重复 fetch /stories/{自身})
1207
- out['story_title'] = str(data.get('title', ''))
1208
- out['story_spec'] = detail # 已 _clean_html 过(kind=story 时 raw=spec or title)
1209
- else:
1210
- sr = client.api('GET', '/stories/%s' % story_id)
1211
- if sr.status_code == 200:
1212
- sd = sr.json()
1213
- out['story_title'] = str(sd.get('title', ''))
1214
- spec = _clean_html(sd.get('spec', ''))
1215
- if spec and len(spec) > max_len:
1216
- spec = spec[:max_len] + '…'
1217
- out['story_spec'] = spec
1218
-
1219
- return out
1220
-
1221
-
1222
- def enrich_prompt(kind, item_id, title, client=None):
1223
- """构建增强提示词: 禅道详情(最高优先) + 知识图谱(代码定位)。
1224
-
1225
- 上下文优先级 (逐层取, 有就用, 没有就跳过):
1226
- 1. 禅道详情: 任务desc / Bug重现steps / 关联需求spec (最准, 但常为空)
1227
- 2. 场景名: 所属执行/产品
1228
- 3. 知识图谱: 相关代码/API/字段 (代码定位用)
1229
-
1230
- client 传入则拉禅道详情; 不传(None)则只做知识图谱增强。
1231
- 任何环节失败 静默跳过, 不阻塞。
1232
- """
1233
- kind_label = kind if kind in ('任务', 'Bug', '需求') else '任务'
1234
- # 禅道内部 kind (task/bug/story)
1235
- zt_kind = 'bug' if kind_label == 'Bug' else ('story' if kind_label == '需求' else 'task')
1236
-
1237
- # 角色化任务描述 (替代原来的"帮我推进禅道任务"废话)
1238
- if zt_kind == 'bug':
1239
- role_task = '定位这个 Bug 的根因并给出修复方案'
1240
- elif zt_kind == 'story':
1241
- role_task = '拆解这个需求并给出实现方案'
1242
- else:
1243
- role_task = '梳理这个任务的实现思路并定位代码'
1244
-
1245
- # ── 先拉禅道详情 (拿 scene 推断 platform) ──
1246
- detail_added = False
1247
- scene_name = ''
1248
- story_block = [] # 关联需求内容 (暂存, 后面拼)
1249
- detail_block = [] # 重现步骤/desc (暂存)
1250
- if client:
1251
- try:
1252
- d = _fetch_zentao_detail(client, zt_kind, item_id)
1253
- scene_name = d.get('scene', '')
1254
- if d['story_title']:
1255
- story_block.append('## 关联需求 #%s' % d['story_id'])
1256
- story_block.append(d['story_title'])
1257
- if d['story_spec']:
1258
- story_block.append(d['story_spec'])
1259
- detail_added = True
1260
- if d['detail']:
1261
- lbl = '重现步骤' if zt_kind == 'bug' else ('任务说明' if zt_kind == 'task' else '需求规格')
1262
- detail_block.append('## %s' % lbl)
1263
- detail_block.append(d['detail'])
1264
- detail_added = True
1265
- except Exception:
1266
- pass
1267
-
1268
- # 用 scene 推断 platform (前端/移动端/后端)
1269
- platform = _guess_platform(scene_name)
1270
- platform_label = {'web': '前端(Web)', 'app': '移动端(APP)'}.get(platform, '后端/全栈')
1271
-
1272
- # 构建 lines (此时 scene/platform 都已确定)
1273
- lines = [
1274
- '## 任务',
1275
- '%s「%s」(禅道%s #%s, %s方向)' % (role_task, title or '', kind_label, item_id, platform_label),
1276
- ]
1277
- if scene_name:
1278
- lines.append('- 迭代/场景:%s' % scene_name)
1279
-
1280
- # 拼入禅道详情
1281
- if story_block:
1282
- lines.append('')
1283
- lines.extend(story_block)
1284
- if detail_block:
1285
- lines.append('')
1286
- lines.extend(detail_block)
1287
-
1288
- # 平台上下文(按禅道 story 反查需求→spec + 代码变更史)
1289
- try:
1290
- if d.get('story_id'):
1291
- from domain.integration.spec_upload import _resolve_endpoint, _mcp_call
1292
- _base, _tok, _pid = _resolve_endpoint()
1293
- if _tok:
1294
- _sid = str(d['story_id'])
1295
- _ps = _mcp_call(_base, _tok, "find_specs_by_zentao",
1296
- {"zentao_id": _sid, "project_id": _pid})
1297
- _cc = _mcp_call(_base, _tok, "code_changes",
1298
- {"project_id": _pid, "req_key": "XQ" + _sid, "mode": "summary"})
1299
- if _ps.get("specs") or _cc.get("commits") or _cc.get("functions"):
1300
- lines.append('')
1301
- lines.append('## 平台上下文(禅道 #%s → %s)' % (
1302
- _sid, _ps.get("requirement_title") or ("XQ" + _sid)))
1303
- for _s in (_ps.get("specs") or []):
1304
- lines.append('- SPEC [%s] %s v%s:%s' % (
1305
- _s.get("status"), _s.get("title"), _s.get("current_version"),
1306
- str(_s.get("content_excerpt") or '')[:150].replace("\n", " ")))
1307
- if _cc.get("commits") or _cc.get("functions"):
1308
- _tf = ', '.join(f.get('fn', '') for f in (_cc.get('top_functions') or [])[:3])
1309
- lines.append('- 代码变更史:%s 函数 / %s 提交(top: %s)' % (
1310
- _cc.get('functions'), _cc.get('commits'), _tf or '—'))
1311
- except Exception:
1312
- pass # 平台上下文拉不到(无 token/无 story_id)不阻塞 enrich
1313
-
1314
- # 要做的事(任务化一句,替代通用 4 点 dump)
1315
- lines.append('')
1316
- lines.append('## 要做的事')
1317
- if zt_kind == 'bug':
1318
- lines.append('定位根因(数据/逻辑/接口/权限?)→ 修复方案(改哪个文件改什么)→ 回归点')
1319
- elif zt_kind == 'story':
1320
- lines.append('拆子功能 技术方案(接口/表/页面,新建还是改造)→ 实现步骤 → 风险点')
1321
- else:
1322
- lines.append('实现思路(用哪些现有代码)→ 改哪个类/方法 开发步骤 自检点')
1323
-
1324
- # ── 知识图谱增强 (代码定位) ──
1325
- # v0.18: 用 prefetch 替代多次 context_pack —— 一次多词合并查询, 更快更准。
1326
- kws = _extract_keywords(title)
1327
- if not kws:
1328
- return {'ok': True, 'data': {'prompt': '\n'.join(lines), 'enriched': detail_added, 'keyword': ''}}
1329
-
1330
- # prefetch 一次性多词搜索 (知识图谱升级后的能力)
1331
- ctx = _query_kg_prefetch(title, platform)
1332
- if not ctx or not (ctx.get('code_files') or ctx.get('apis') or ctx.get('fields')):
1333
- # prefetch 失败 → 回退到旧的多词 context_pack
1334
- from concurrent.futures import ThreadPoolExecutor
1335
- all_code, all_api, all_field = {}, [], []
1336
- with ThreadPoolExecutor(max_workers=4) as pool:
1337
- results = list(pool.map(lambda k: (k, _query_kg_context(k, platform)), kws[:4]))
1338
- for kw, r in results:
1339
- for f in r.get('code_files', []):
1340
- all_code[f] = all_code.get(f, 0) + 1
1341
- all_api.extend(r.get('apis', []))
1342
- all_field.extend(r.get('fields', []))
1343
- else:
1344
- # prefetch 成功: 它已经按多词命中次数排好序了
1345
- all_code = {f: 1 for f in ctx.get('code_files', [])}
1346
- all_api = ctx.get('apis', [])
1347
- all_field = ctx.get('fields', [])
1348
-
1349
- # 代码文件按命中次数排序 (次数多的排前面), API/字段去重
1350
- ranked_code = sorted(all_code.items(), key=lambda x: -x[1])[:8]
1351
- code_list = [f for f, _ in ranked_code]
1352
- seen = set()
1353
- api_list = [a for a in all_api if not (a in seen or seen.add(a))][:6]
1354
- # 字段过滤: 去掉明显跨业务域的假勤/薪资字段 (除非任务本身就是这些)
1355
- _IRRELEVANT_FIELDS = ('产检假', '年假', '年薪', '病假', '事假', '调休', '婚假', '丧假', '陪产假')
1356
- field_candidates = [fld for fld in all_field
1357
- if not (fld in seen or seen.add(fld))
1358
- and not any(ir in fld for ir in _IRRELEVANT_FIELDS)]
1359
- field_list = field_candidates[:6]
1360
-
1361
- if not (code_list or api_list or field_list):
1362
- return {'ok': True, 'data': {'prompt': '\n'.join(lines), 'enriched': detail_added, 'keyword': ','.join(kws[:3])}}
1363
-
1364
- # 代码定位:top-3 文件指针(不 dump API/字段/语义,让 agent 用 rag_search/get_impact/context_360 工具自取深入)
1365
- if code_list:
1366
- lines.append('')
1367
- lines.append('## 代码定位(top %d;细节用 rag_search / get_impact / context_360 工具取)' % min(3, len(ranked_code)))
1368
- for f, cnt in ranked_code[:3]:
1369
- mark = ' ←多词命中' if cnt >= 2 else ''
1370
- lines.append('- `%s`%s' % (f, mark))
1371
- # bug 影响范围(1 行,不展开)
1372
- if zt_kind == 'bug' and kws:
1373
- impact = _query_kg_impact(kws[0])
1374
- if impact and (impact.get('controllers') or impact.get('endpoints')):
1375
- hit = (impact.get('controllers') or [])[:3] + (impact.get('endpoints') or [])[:3]
1376
- lines.append('')
1377
- lines.append('**影响**:改「%s」波及 %s' % (kws[0], ', '.join(hit)))
1378
- lines.append('')
1379
- lines.append('先读上述文件理解现状再动手;细节走工具,别脱离现有架构另起炉灶。')
1380
- used_kws = ','.join(kws[:3])
1381
- return {'ok': True, 'data': {'prompt': '\n'.join(lines), 'enriched': True, 'keyword': used_kws,
1382
- 'counts': {'code': min(3, len(ranked_code)) if code_list else 0}}}
1383
-
1384
-
1385
- def main():
1386
- parser = argparse.ArgumentParser(description='禅道任务面板数据接口 (输出 JSON 供 VS Code 扩展消费)')
1387
- parser.add_argument('--json', action='store_true', help='JSON 输出 (面板默认用这个)')
1388
- parser.add_argument('--include-closed', action='store_true', help='包含已关闭/取消的历史项 (默认只看活动项)')
1389
- parser.add_argument('--pretty', action='store_true', help='JSON 美化缩进 (默认紧凑, 面板解析更快)')
1390
- # 写操作子命令 (v0.3)
1391
- parser.add_argument('--action', choices=['start_task', 'pause_task', 'finish_task',
1392
- 'log_effort', 'confirm_bug', 'resolve_bug', 'confirm_spec',
1393
- 'publish_story', 'link_version'],
1394
- help='执行禅道写操作 (面板按钮触发); confirm_spec=确认并上传 spec 到平台; '
1395
- 'publish_story=建需求; link_version=关联 build')
1396
- parser.add_argument('--id', type=int, help='写操作的目标 task/bug ID')
1397
- parser.add_argument('--left', help='剩余工时 (start/finish/log_effort)')
1398
- parser.add_argument('--consumed', help='消耗工时 (finish/log_effort)')
1399
- parser.add_argument('--resolution', help='Bug 解决方式 (resolve_bug): fixed/bydesign/duplicate/notrepro/postpone')
1400
- parser.add_argument('--comment', help='Bug 解决备注 (resolve_bug)')
1401
- # spec 确认上传 (confirm_spec)
1402
- parser.add_argument('--spec-path', help='本地 spec.md 路径 (confirm_spec; 不传则用当前任务 spec)')
1403
- parser.add_argument('--code-targets', help='代码目标实体逗号分隔 (confirm_spec, 可选)')
1404
- parser.add_argument('--zentao-id', help='禅道 story 号 (confirm_spec; 不传则从 --id 反查 story)')
1405
- # story 域写操作 (publish_story / link_version)
1406
- parser.add_argument('--product-id', help='产品 ID (publish_story 必填;需求挂产品)')
1407
- parser.add_argument('--spec', help='需求规格正文 (publish_story)')
1408
- parser.add_argument('--spec-file', help='需求规格正文文件路径 (publish_story; 优先于 --spec, 规避 shell 长度限制)')
1409
- parser.add_argument('--pri', type=int, default=3, help='需求优先级 1-4 (publish_story; 默认 3)')
1410
- parser.add_argument('--plan-id', help='关联计划 ID (publish_story 可选)')
1411
- parser.add_argument('--build-id', help='关联 build/版本 ID (publish_story 可选; link_version 必填)')
1412
- # 提示词增强 (v0.6)
1413
- parser.add_argument('--enrich', action='store_true', help='构建带知识图谱上下文的提示词 (发AI用)')
1414
- parser.add_argument('--title', help='任务/Bug 标题 (enrich 用)')
1415
- parser.add_argument('--kind-label', default='任务', help='类型标签 (enrich/detail 用): 任务/Bug/需求')
1416
- # 详情查看 (v0.9)
1417
- parser.add_argument('--detail', action='store_true', help='拉取任务/Bug/需求的完整详情 (卡片点击用)')
1418
- parser.add_argument('--kind', choices=['tasks', 'bugs', 'stories', 'task', 'bug', 'story'],
1419
- help='只取某一域 (默认三域全取); detail/enrich 用单数 task/bug/story 指定类型')
1420
- args = parser.parse_args()
1421
-
1422
- # 详情查看分支: 拉禅道详情展示
1423
- if args.detail:
1424
- zt_kind = args.kind if args.kind in ('task', 'bug', 'story') else 'task'
1425
- client = _zentao_creds()
1426
- d = {}
1427
- if client:
1428
- try:
1429
- ok, _ = client.credentials_ok()
1430
- if ok and client.check_intranet()[0]:
1431
- d = _fetch_zentao_detail(client, zt_kind, args.id or 0, max_len=1500)
1432
- # 给 detail_label
1433
- d['detail_label'] = {'bug': '重现步骤', 'task': '任务说明', 'story': '需求规格'}.get(zt_kind, '详情')
1434
- d['title'] = args.title or d.get('story_title', '')
1435
- except Exception as e:
1436
- print(json.dumps({'ok': False, 'error': '拉取详情失败: %s' % e})); sys.exit(3)
1437
- result = {'ok': True, 'data': d}
1438
- code = 0
1439
-
1440
- # 提示词增强分支: 禅道详情 + 知识图谱
1441
- elif args.enrich:
1442
- kind_label = args.kind_label if args.kind_label in ('任务', 'Bug', '需求') else '任务'
1443
- # 尝试用禅道 client 拉详情 (失败不影响, enrich_prompt 内部静默降级)
1444
- client = _zentao_creds()
1445
- if client:
1446
- ok, _ = client.credentials_ok()
1447
- if ok:
1448
- ok_inet, _ = client.check_intranet()
1449
- if not ok_inet:
1450
- client = None # 内网不通就不拉禅道详情, 只做知识图谱
1451
- else:
1452
- client = None
1453
- result = enrich_prompt(kind_label, args.id or 0, args.title or '', client=client)
1454
- code = 0
1455
- # 写操作分支
1456
- elif args.action:
1457
- if not args.id:
1458
- result = {'ok': False, 'error': '写操作必须提供 --id'}
1459
- code = 3
1460
- else:
1461
- # publish_story 的 spec 正文: --spec-file 优先于 --spec (读文件, 规避 shell 长度限制)
1462
- spec_text = args.spec or ''
1463
- if args.spec_file:
1464
- try:
1465
- with open(args.spec_file, encoding='utf-8') as fh:
1466
- spec_text = fh.read()
1467
- except Exception as e:
1468
- result = {'ok': False, 'error': '读 --spec-file 失败: %s' % e}
1469
- code = 3
1470
- spec_text = None
1471
- if spec_text is not None:
1472
- params = {'left': args.left, 'consumed': args.consumed,
1473
- 'resolution': args.resolution, 'comment': args.comment,
1474
- 'spec_path': args.spec_path, 'code_targets': args.code_targets,
1475
- 'zentao_id': args.zentao_id, 'kind': args.kind,
1476
- 'product_id': args.product_id, 'title': args.title,
1477
- 'spec': spec_text, 'pri': args.pri, 'plan_id': args.plan_id,
1478
- 'build_id': args.build_id}
1479
- result, code = perform_action(args.action, args.id, params)
1480
- else:
1481
- # workbench: kind 期望 tasks/bugs/stories (单数形式转复数)
1482
- wb_kind = args.kind if args.kind in ('tasks', 'bugs', 'stories') else None
1483
- result, code = fetch_workbench(include_closed=args.include_closed, kind=wb_kind)
1484
-
1485
- if args.json or not sys.stdout.isatty():
1486
- indent = 2 if args.pretty else None
1487
- print(json.dumps(result, ensure_ascii=False, default=str, indent=indent))
1488
- else:
1489
- if result.get('ok'):
1490
- if args.action:
1491
- print('✅ %s' % result['data'].get('message', '操作成功'))
1492
- else:
1493
- d, m = result['data'], result['meta']
1494
- print('📋 %s 的禅道工作台 — 任务 %d · 需求 %d · Bug %d (更新于 %s)' % (
1495
- d['account'], m['counts']['tasks'], m['counts']['stories'], m['counts']['bugs'],
1496
- m['fetched_at']))
1497
- else:
1498
- print('❌ %s' % result.get('error', '未知错误'))
1499
- sys.exit(code)
1500
-
1501
-
1502
- if __name__ == '__main__':
1503
- main()
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ wlkj_panel.py - 开发工作台数据接口(禅道任务 + 平台 spec/impact;供 VS Code 扩展 zentao-panel 调用)
5
+
6
+ 目的: 给侧边栏面板一个稳定的 JSON 数据源, 一次返回"指派给我的【任务+需求+Bug】",
7
+ 跨所有执行/产品全量聚合。逻辑与 my_workbench 工具一致 (只读), 但输出 JSON 而非文本。
8
+
9
+ 为什么不直接调 MCP server:
10
+ MCP server 的数据收集依赖 import-time 全局变量 (CLIENT / MY_ACCOUNT), 由 mcp_launcher
11
+ 注入环境变量才生效。本脚本走独立进程 (child_process.spawn 启动), 没有 launcher 注入,
12
+ 所以照搬 zentao_sync.py 的稳健模式: 自己 ZentaoClient.from_config() + 自己解析归属。
13
+ 收集循环则与 zentao_mcp_server.py:_collect_my_* 逐字一致 (DRY 同款), 保证数据口径统一。
14
+
15
+ 用法:
16
+ python .qoder/scripts/domain/task/wlkj_panel.py --json # 三域活动项 JSON
17
+ python .qoder/scripts/domain/task/wlkj_panel.py --json --include-closed # 含已关闭历史项
18
+ python .qoder/scripts/domain/task/wlkj_panel.py --json --kind tasks # 只看任务
19
+
20
+ 退出码 (对齐项目规范):
21
+ 0 成功 (含空结果); 3 配置/归属错误; 5 网络不通。
22
+ --json 模式下错误也输出 JSON ({"ok": false, "error": "..."}), 扩展端统一解析。
23
+ """
24
+ import os
25
+ import sys
26
+
27
+ # ── 路径自举 (复用 bootstrap 模式, 与 zentao_sync.py 完全一致) ──
28
+ # 本脚本在 scripts/domain/task/ 下 (scripts 下 2 级), dirname 两次才到 scripts/。
29
+ _HERE = os.path.dirname(os.path.abspath(__file__))
30
+ _SCRIPTS = os.path.dirname(os.path.dirname(_HERE)) # .qoder/scripts (上两级)
31
+ _COMMON = os.path.join(_SCRIPTS, 'foundation')
32
+ for _p in (_SCRIPTS, _COMMON):
33
+ if _p not in sys.path:
34
+ sys.path.insert(0, _p)
35
+ try:
36
+ from bootstrap import setup; setup()
37
+ except Exception:
38
+ pass
39
+
40
+ import json
41
+ import argparse
42
+ from datetime import datetime
43
+
44
+ from foundation.core.paths import get_developer
45
+ from foundation.identity.identity import get_member
46
+ from foundation.integrations.zentao_client import ZentaoClient
47
+
48
+
49
+ # ============================================================
50
+ # 配置与归属解析 (照搬 zentao_sync.py, 唯一真相源)
51
+ # ============================================================
52
+
53
+ def _zentao_creds():
54
+ """构建禅道客户端。凭据不全返回 None
55
+ url 来自 .qoder/config.yaml (团队共享); user/password 来自 secrets/zentao.env (个人)。"""
56
+ return ZentaoClient.from_config()
57
+
58
+
59
+ def _resolve_my_account(developer):
60
+ """解析当前 developer 禅道 account。返回 (account, 错误原因)。"""
61
+ mem = get_member(developer)
62
+ if not mem:
63
+ return None, '当前开发者 %s 未注册 ( member.json)。请先跑 /wl-init。' % developer
64
+ acc = (mem.get('zentao_account') or '').strip()
65
+ if not acc:
66
+ return None, ('%s 的 member.json 未配 zentao_account。\n'
67
+ ' 编辑 workspace/members/%s/member.json 加 "zentao_account": "你的禅道账号"。'
68
+ % (developer, developer))
69
+ return acc, None
70
+
71
+
72
+ # ============================================================
73
+ # 收集循环 (与 zentao_mcp_server.py:_collect_my_* 逐字一致)
74
+ # 单独写成函数, 不依赖 self / 全局 CLIENT, 接收 client 做参数 → 可独立调用。
75
+ # ============================================================
76
+
77
+ def _paged_list(client, path, params, list_key, page_size=100, max_pages=20):
78
+ """分页拉全量 (循环 page+limit, 按 id 去重)。照搬 mcp server 同名方法。
79
+
80
+ Returns: (合并去重列表, 是否截断[读满 max_pages 仍未到尾])。
81
+ """
82
+ seen, all_items = set(), []
83
+ truncated = False
84
+ for _page in range(1, max_pages + 1):
85
+ p = dict(params or {})
86
+ p.update({'limit': page_size, 'page': _page})
87
+ r = client.api('GET', path, p)
88
+ if r.status_code not in (200, 201):
89
+ break
90
+ data = r.json()
91
+ items = data.get(list_key) if isinstance(data, dict) else None
92
+ if not isinstance(items, list) or not items:
93
+ break
94
+ new = 0
95
+ for it in items:
96
+ if not isinstance(it, dict):
97
+ continue
98
+ key = it.get('id', id(it))
99
+ if key in seen:
100
+ continue
101
+ seen.add(key)
102
+ all_items.append(it)
103
+ new += 1
104
+ if new == 0:
105
+ break
106
+ return all_items, truncated
107
+
108
+
109
+ def _collect_my_tasks(client, my_account, status_filter=None):
110
+ """扫所有执行, 收集指派给我的任务。返回 (任务列表, None) (None, 错误)。
111
+
112
+ 性能: 用线程池并行请求各 execution (61 个执行串行要 ~15s, 并行后 ~2-3s)。
113
+ ZentaoClient 内部 token 有锁但 HTTP 请求本身是 IO, 线程并行安全。
114
+ """
115
+ from concurrent.futures import ThreadPoolExecutor
116
+ execs, _ = _paged_list(client, '/executions', None, 'executions')
117
+ if execs is None:
118
+ return None, '查询执行列表失败, 稍后重试。'
119
+
120
+ def fetch_one(e):
121
+ eid = e.get('id')
122
+ if eid is None:
123
+ return []
124
+ ename = str(e.get('name', ''))[:20]
125
+ tasks, _ = _paged_list(client, '/executions/%s/tasks' % eid, None, 'tasks')
126
+ out = []
127
+ for t in tasks:
128
+ if not isinstance(t, dict):
129
+ continue
130
+ if ZentaoClient.assignee_account(t) != my_account:
131
+ continue
132
+ if status_filter and t.get('status') != status_filter:
133
+ continue
134
+ t2 = dict(t)
135
+ t2['execution'] = eid
136
+ t2['execution_name'] = ename
137
+ out.append(t2)
138
+ return out
139
+
140
+ all_t = []
141
+ with ThreadPoolExecutor(max_workers=10) as pool:
142
+ for batch in pool.map(fetch_one, execs):
143
+ all_t.extend(batch)
144
+ return all_t, None
145
+
146
+
147
+ def _scan_products(client):
148
+ """获取要扫描的产品 ID 列表。
149
+
150
+ 关键修复: 禅道 /products 接口实测会漏产品 (团队踩坑: product=3 "ICS 2.0" 真实存在
151
+ 且含 33 个 Bug, 但 /products 列表不返回它 → 逐产品扫描会漏掉全部这些 Bug)。
152
+ 所以这里采用「列表 + 连续 ID 补扫」双保险:
153
+ 1. 先取 /products 列表 (官方口径);
154
+ 2. 再补扫 1..MAX_PRODUCT_ID 范围里列表没返回但真实存在的 (用 /products/{id} 探活)
155
+ """
156
+ listed, _ = _paged_list(client, '/products', None, 'products')
157
+ listed_ids = set(p.get('id') for p in listed if isinstance(p, dict) and p.get('id') is not None)
158
+ # 补扫: 列表没覆盖的连续 ID 段 (团队产品数实测 < 20, 给到 30 留余量)
159
+ MAX_PID = 30
160
+ for pid in range(1, MAX_PID + 1):
161
+ if pid in listed_ids:
162
+ continue
163
+ r = client.api('GET', '/products/%s' % pid)
164
+ if r.status_code == 200 and r.json().get('id'): # 真实存在
165
+ listed_ids.add(pid)
166
+ return sorted(listed_ids)
167
+
168
+
169
+ def _collect_my_bugs(client, my_account):
170
+ """扫所有产品 (含 /products 列表漏掉的), 收集**指派给我**的 Bug (并行)
171
+
172
+ v0.5 修复: 旧版只扫 /products 列表返回的产品, 漏掉了 product=3 (列表不返回但含
173
+ 3 个我的 Bug)。改用 _scan_products 双保险覆盖所有真实产品。
174
+ """
175
+ from concurrent.futures import ThreadPoolExecutor
176
+ pids = _scan_products(client)
177
+
178
+ def fetch_one(pid):
179
+ bugs, _ = _paged_list(client, '/products/%s/bugs' % pid, None, 'bugs')
180
+ out = []
181
+ for b in bugs:
182
+ if not isinstance(b, dict):
183
+ continue
184
+ if ZentaoClient.assignee_account(b) != my_account:
185
+ continue
186
+ b2 = dict(b)
187
+ b2['product_id'] = pid
188
+ out.append(b2)
189
+ return out
190
+
191
+ mine = []
192
+ with ThreadPoolExecutor(max_workers=10) as pool:
193
+ for batch in pool.map(fetch_one, pids):
194
+ mine.extend(batch)
195
+ return mine, None
196
+
197
+
198
+ def _collect_my_stories(client, my_account):
199
+ """扫所有产品 (含 /products 列表漏掉的), 收集**指派给我**的需求 (并行)。"""
200
+ from concurrent.futures import ThreadPoolExecutor
201
+ pids = _scan_products(client)
202
+
203
+ def fetch_one(pid):
204
+ sts, _ = _paged_list(client, '/products/%s/stories' % pid, None, 'stories')
205
+ out = []
206
+ for s in sts:
207
+ if not isinstance(s, dict):
208
+ continue
209
+ if ZentaoClient.assignee_account(s) != my_account:
210
+ continue
211
+ s2 = dict(s)
212
+ s2['product_id'] = pid
213
+ out.append(s2)
214
+ return out
215
+
216
+ mine = []
217
+ with ThreadPoolExecutor(max_workers=10) as pool:
218
+ for batch in pool.map(fetch_one, pids):
219
+ mine.extend(batch)
220
+ return mine, None
221
+
222
+
223
+ # ============================================================
224
+ # 归一化: 把禅道原始 dict 裁成面板需要的精简字段 (减体积 + 隐藏内部字段)
225
+ # ============================================================
226
+
227
+ def _detect_code_committed(item_id, title_keywords):
228
+ """检测代码是否已提交 (git log 搜任务ID或标题关键词)。"""
229
+ import subprocess
230
+ try:
231
+ # 搜任务ID 标题前几个关键词
232
+ patterns = ['#%s' % item_id] + [k for k in title_keywords if len(k) >= 2][:2]
233
+ for p in patterns:
234
+ r = subprocess.run(
235
+ ['git', 'log', '--oneline', '--all', '-1', '--grep=%s' % p],
236
+ capture_output=True, text=True, timeout=5,
237
+ cwd=str(_SCRIPTS.parent) if hasattr(_SCRIPTS, 'parent') else None)
238
+ if r.returncode == 0 and r.stdout.strip():
239
+ return True
240
+ except Exception:
241
+ pass
242
+ return False
243
+
244
+
245
+ def _detect_progress(client, kind, item_id, raw):
246
+ """检测工作流进度: PRD / Spec / 代码 三节点。
247
+
248
+ 返回 {'prd': bool, 'spec': str('none'|'draft'|'confirmed'), 'code': bool}
249
+ - PRD: task 看关联需求spec非空; bug 看steps非空; story 看自身spec非空
250
+ - Spec: find_spec 找到文件, 读 status
251
+ - 代码: git log 搜任务ID/关键词
252
+ """
253
+ import re as _re
254
+ from foundation.core.paths import find_spec
255
+ progress = {'prd': False, 'spec': 'none', 'code': False}
256
+
257
+ # PRD 检测
258
+ try:
259
+ if kind == 'task':
260
+ sid = raw.get('story')
261
+ if sid:
262
+ sr = client.api('GET', '/stories/%s' % sid)
263
+ if sr.status_code == 200:
264
+ spec = _clean_html(sr.json().get('spec', ''))
265
+ progress['prd'] = bool(spec.strip())
266
+ elif kind == 'bug':
267
+ steps = _clean_html(raw.get('steps', ''))
268
+ progress['prd'] = bool(steps.strip())
269
+ elif kind == 'story':
270
+ spec = _clean_html(raw.get('spec', ''))
271
+ progress['prd'] = bool(spec.strip())
272
+ except Exception:
273
+ pass
274
+
275
+ # Spec 检测 (find_spec 需要 REQ-ID, 但禅道任务没有 REQ-ID, 用任务ID兜底)
276
+ try:
277
+ # 禅道任务没有 REQ-ID, find_spec 找不到 → 先查当前任务目录的 spec.md
278
+ # (大多数情况 spec 在任务目录, 不在全局 specs/)
279
+ hits = find_spec() # 查当前任务目录
280
+ if hits:
281
+ progress['spec'] = hits[0].get('status', 'draft')
282
+ except Exception:
283
+ pass
284
+
285
+ # 代码检测 (git log)
286
+ title = str(raw.get('name', '') or raw.get('title', ''))
287
+ keywords = _re.split(r'[^\u4e00-\u9fa5a-zA-Z0-9]+', title)
288
+ keywords = [k for k in keywords if len(k) >= 2][:3]
289
+ progress['code'] = _detect_code_committed(item_id, keywords)
290
+
291
+ return progress
292
+
293
+
294
+ def _norm_task(t):
295
+ return {
296
+ 'id': t.get('id'),
297
+ 'name': str(t.get('name', '')),
298
+ 'status': t.get('status', ''),
299
+ 'pri': t.get('pri'),
300
+ 'left': t.get('left'), # 剩余工时
301
+ 'estimate': t.get('estimate'),
302
+ 'consumed': t.get('consumed'),
303
+ 'deadline': t.get('deadline'),
304
+ 'execution': t.get('execution'),
305
+ 'execution_name': t.get('execution_name', ''),
306
+ 'story': t.get('story'), # 关联需求ID (进度检测用)
307
+ }
308
+
309
+
310
+ def _norm_bug(b):
311
+ return {
312
+ 'id': b.get('id'),
313
+ 'title': str(b.get('title', '')),
314
+ 'status': b.get('status', ''),
315
+ 'severity': b.get('severity'),
316
+ 'pri': b.get('pri'),
317
+ }
318
+
319
+
320
+ def _norm_story(s):
321
+ return {
322
+ 'id': s.get('id'),
323
+ 'title': str(s.get('title', '')),
324
+ 'status': s.get('status', ''),
325
+ 'pri': s.get('pri'),
326
+ }
327
+
328
+
329
+ # 活动态白名单 (与 my_workbench 一致 + 修正: changed 也是活动态)。
330
+ # 说明: 禅道 task 有 changed 状态 (需求变更/任务被改动), 旧版 LIVE_TASK 漏掉它
331
+ # → 用户实测"明明有个进行中的任务却看不到"。changed 不是终态, 应算活动项。
332
+ LIVE_TASK = {'wait', 'doing', 'pause', 'changed'}
333
+ LIVE_BUG = {'active'}
334
+ LIVE_STORY = {'active', 'reviewing', 'changing'}
335
+
336
+
337
+ def _filter_live(tasks, bugs, stories, include_closed):
338
+ """过滤终态 (除非 include_closed)。"""
339
+ if include_closed:
340
+ return tasks, bugs, stories
341
+ return ([t for t in tasks if t.get('status') in LIVE_TASK],
342
+ [b for b in bugs if b.get('status') in LIVE_BUG],
343
+ [s for s in stories if s.get('status') in LIVE_STORY])
344
+
345
+
346
+ # ============================================================
347
+ # 主流程
348
+ # ============================================================
349
+
350
+ def fetch_workbench(include_closed=False, kind=None):
351
+ """收集三域并归一化。返回 (result_dict, exit_code)。
352
+
353
+ Args:
354
+ include_closed: True=含已关闭历史项
355
+ kind: 'tasks'/'bugs'/'stories' 只取一域; None=全取
356
+ Returns:
357
+ ({"ok": True, "data": {...}, "meta": {...}}, 0) 或
358
+ ({"ok": False, "error": "..."}, 3 5)
359
+ """
360
+ developer = get_developer()
361
+
362
+ # 凭据
363
+ client = _zentao_creds()
364
+ if client is None:
365
+ return {'ok': False,
366
+ 'error': '禅道凭据未配置。请检查 .qoder/config.yaml 的 zentao.url, '
367
+ '及 workspace/members/<我>/.private/secrets/zentao.env 的账号密码。'}, 3
368
+
369
+ ok, msg = client.credentials_ok()
370
+ if not ok:
371
+ return {'ok': False, 'error': '禅道凭据不完整: %s' % msg}, 3
372
+
373
+ # 内网连通
374
+ ok, msg = client.check_intranet()
375
+ if not ok:
376
+ return {'ok': False, 'error': '连不上禅道内网: %s' % msg}, 5
377
+
378
+ # 归属
379
+ my_account, err = _resolve_my_account(developer) if developer else (None, '未识别当前开发者身份。')
380
+ if not my_account:
381
+ return {'ok': False, 'error': err}, 3
382
+
383
+ # ④ 收集 (三域并行, 按 kind 决定取哪几域)
384
+ from concurrent.futures import ThreadPoolExecutor
385
+ tasks, bugs, stories = [], [], []
386
+
387
+ def _gather():
388
+ jobs = {}
389
+ if kind in (None, 'tasks'):
390
+ jobs['tasks'] = lambda: _collect_my_tasks(client, my_account)
391
+ if kind in (None, 'bugs'):
392
+ jobs['bugs'] = lambda: _collect_my_bugs(client, my_account)
393
+ if kind in (None, 'stories'):
394
+ jobs['stories'] = lambda: _collect_my_stories(client, my_account)
395
+ results = {}
396
+ with ThreadPoolExecutor(max_workers=3) as pool:
397
+ futs = {pool.submit(fn): k for k, fn in jobs.items()}
398
+ for fut in futs:
399
+ results[futs[fut]] = fut.result()
400
+ return results
401
+
402
+ res = _gather()
403
+ if 'tasks' in res:
404
+ tasks, e = res['tasks']
405
+ if e:
406
+ return {'ok': False, 'error': e}, 5
407
+ if 'bugs' in res:
408
+ bugs, _ = res['bugs']
409
+ if 'stories' in res:
410
+ stories, _ = res['stories']
411
+
412
+ # ⑤ 过滤 + 归一 + 进度检测
413
+ tasks, bugs, stories = _filter_live(tasks, bugs, stories, include_closed)
414
+
415
+ # 进度检测 (并行, 只对前12个检测避免过多请求)
416
+ from concurrent.futures import ThreadPoolExecutor
417
+
418
+ def _add_progress(kind, items, raws):
419
+ if not items:
420
+ return
421
+ # raws 和 items 按顺序对应 (都来自同一批过滤后数据)
422
+ def detect_one(idx):
423
+ try:
424
+ p = _detect_progress(client, kind, items[idx]['id'], raws[idx])
425
+ items[idx]['progress'] = p
426
+ except Exception:
427
+ items[idx]['progress'] = {'prd': False, 'spec': 'none', 'code': False}
428
+ with ThreadPoolExecutor(max_workers=6) as pool:
429
+ list(pool.map(detect_one, range(min(len(items), 12))))
430
+ # 超过12个的不检测, 给默认值
431
+ for i in range(12, len(items)):
432
+ items[i]['progress'] = {'prd': False, 'spec': 'none', 'code': False}
433
+
434
+ norm_tasks = [_norm_task(t) for t in tasks]
435
+ norm_bugs = [_norm_bug(b) for b in bugs]
436
+ norm_stories = [_norm_story(s) for s in stories]
437
+ _add_progress('task', norm_tasks, tasks)
438
+ _add_progress('bug', norm_bugs, bugs)
439
+ _add_progress('story', norm_stories, stories)
440
+
441
+ # 当前任务 (工作流的 .current-task 机制)
442
+ current_task = None
443
+ try:
444
+ from foundation.core.paths import get_current_task
445
+ ct = get_current_task()
446
+ if ct:
447
+ current_task = str(ct)
448
+ except Exception:
449
+ pass
450
+
451
+ data = {
452
+ 'account': my_account,
453
+ 'developer': developer,
454
+ 'current_task': current_task,
455
+ 'tasks': norm_tasks,
456
+ 'bugs': norm_bugs,
457
+ 'stories': norm_stories,
458
+ }
459
+ meta = {
460
+ 'fetched_at': datetime.now().isoformat(timespec='seconds'),
461
+ 'counts': {
462
+ 'tasks': len(data['tasks']),
463
+ 'bugs': len(data['bugs']),
464
+ 'stories': len(data['stories']),
465
+ },
466
+ 'include_closed': include_closed,
467
+ }
468
+ return {'ok': True, 'data': data, 'meta': meta}, 0
469
+
470
+
471
+ # ============================================================
472
+ # 写操作 (v0.3): 面板按钮触发的禅道状态流转
473
+ # 设计对齐 MCP server 的 _assert_owner: 写前校验归属, 防误改队友。
474
+ # 任务: start/pause/finish/log_effort; Bug: confirm/resolve
475
+ # ============================================================
476
+
477
+ def _check_owner(client, my_account, kind, item_id):
478
+ """写前归属校验 (对齐 mcp server _assert_owner)
479
+ task 只认 assignedTo==我; bug 认 assignedTo==我 OR openedBy==我。
480
+ 返回 None=放行, 字符串=拒绝原因。"""
481
+ r = client.api('GET', '/%ss/%s' % (kind, item_id))
482
+ if r.status_code not in (200, 201):
483
+ return None # 查询失败不阻塞 (禅道权限兜底)
484
+ data = r.json()
485
+ asg = ZentaoClient.assignee_account(data)
486
+ allow = (asg == my_account)
487
+ if kind == 'bug' and not allow:
488
+ allow = (ZentaoClient.openedby_account(data) == my_account)
489
+ if not allow:
490
+ return '这条%s指派给 %s, 不是你(%s), 已拒绝改。' % (kind, asg or '空', my_account)
491
+ return None
492
+
493
+
494
+ def _do_confirm_spec(client, my_account, kind, item_id, params):
495
+ """确认并上传 spec 到平台(禅道面板「确认 spec」按钮触发)。
496
+
497
+ 流程: 定位 spec.md → 解析 zentao story id → 调 spec_upload 回流平台。
498
+ spec 挂在 story 上(zentao_id = story 号),与 commit_pipeline 同套关联键。
499
+ 幂等: spec.md 契约头有 platform_spec_id → update_spec;无 → create+confirm+回写。
500
+
501
+ Args:
502
+ client: ZentaoClient 或 None(显式给 zentao_id 时不需禅道连接)。
503
+ kind: task/bug/story(item_id 的类型,用于反查 story)。
504
+ item_id: 禅道 task/bug/story id(仅展示用 + 反查 story)。
505
+ params: spec_path / zentao_id / code_targets(均可选,缺则自动解析)。
506
+ """
507
+ # 定位 spec.md: 优先 --spec-path;否则 find_spec() 找当前任务目录的 spec
508
+ spec_path = params.get('spec_path')
509
+ if not spec_path:
510
+ try:
511
+ from foundation.core.paths import find_spec
512
+ hits = find_spec() or []
513
+ if hits:
514
+ spec_path = hits[0].get('path')
515
+ except Exception:
516
+ pass
517
+ if not spec_path:
518
+ return {'ok': False, 'error': '没找到 spec.md。先用 /wl-spec 生成,或传 --spec-path <路径>。'}, 3
519
+
520
+ # ② 解析 zentao story id: 优先 --zentao-id;否则从 item 反查(task/bug 读 story 字段, story 用自身 id)
521
+ zentao_id = params.get('zentao_id')
522
+ if not zentao_id and client:
523
+ try:
524
+ r = client.api('GET', '/%s/%s' % ({'task': 'tasks', 'bug': 'bugs', 'story': 'stories'}.get(kind, kind + 's'), item_id))
525
+ if r.status_code == 200:
526
+ d = r.json()
527
+ zentao_id = str(d.get('id') if kind == 'story' else (d.get('story') or '') or '')
528
+ except Exception:
529
+ pass
530
+ if not zentao_id:
531
+ return {'ok': False, 'error': '未能从禅道 %s #%s 解析 story id(spec 挂 story 上)。传 --zentao-id 显式指定。' % (kind, item_id)}, 3
532
+
533
+ # ③ 调 spec_upload(复用回流客户端)
534
+ try:
535
+ from domain.integration.spec_upload import upload_spec
536
+ except Exception as e:
537
+ return {'ok': False, 'error': 'spec_upload 客户端加载失败: %s' % e}, 3
538
+ ct_raw = params.get('code_targets')
539
+ code_targets = [c.strip() for c in ct_raw.split(',') if c.strip()] if ct_raw else None
540
+ r = upload_spec(spec_path, zentao_id_override=zentao_id, code_targets=code_targets)
541
+ if r.get('ok'):
542
+ return {'ok': True, 'data': {
543
+ 'action': 'confirm_spec', 'id': item_id, 'spec_id': r['platform_spec_id'],
544
+ 'via': r.get('via'), 'status': r.get('status'), 'version': r.get('version'),
545
+ 'message': '%s:%s(story=%s platform spec %s, %s)' % (
546
+ 'spec 已上传' if r.get('via') == 'create_spec' else 'spec 已更新',
547
+ spec_path, zentao_id, r['platform_spec_id'], r.get('status'))}}, 0
548
+ return {'ok': False, 'error': 'spec 上传失败: %s' % r.get('message')}, 5
549
+
550
+
551
+ def _do_link_version(client, my_account, kind, item_id, params):
552
+ """把 story/bug 关联到 build(禅道「关联版本」按钮触发)。
553
+
554
+ Args:
555
+ kind: story/bug(决定挂到 /builds/{id}/stories 还是 /bugs)
556
+ item_id: 禅道 story/bug id
557
+ params: build_id 必填
558
+ """
559
+ build_id = params.get('build_id')
560
+ if not build_id:
561
+ return {'ok': False, 'error': '关联版本必须指定 --build-id'}, 3
562
+ obj_key = 'stories' if kind == 'story' else 'bugs'
563
+ r = client.api('POST', '/builds/%s/%s' % (build_id, obj_key), {obj_key: [item_id]})
564
+ if r.status_code in (200, 201):
565
+ return {'ok': True, 'data': {
566
+ 'action': 'link_version', 'id': item_id,
567
+ 'message': '%s #%s 已关联到 build %s' % (kind, item_id, build_id)}}, 0
568
+ return {'ok': False, 'error': '关联版本失败 (HTTP %s): %s' % (
569
+ r.status_code, ZentaoClient.err_text(r))}, 5
570
+
571
+
572
+ def _do_publish_story(client, my_account, kind, item_id, params):
573
+ """建禅道 story(禅道「发布需求」按钮触发)。
574
+
575
+ 新建需求挂产品;可选关联 plan / build。
576
+ item_id 仅展示用(新建不依赖已有对象)。
577
+
578
+ Args:
579
+ params: product_id 必填;title/spec/pri/category/plan_id/build_id 可选
580
+ """
581
+ pid = params.get('product_id')
582
+ if not pid:
583
+ return {'ok': False, 'error': '建需求必须指定 --product-id'}, 3
584
+ try:
585
+ payload = {
586
+ 'product': int(pid),
587
+ 'title': params.get('title') or '',
588
+ 'spec': params.get('spec', ''),
589
+ 'pri': int(params.get('pri', 3)),
590
+ 'category': params.get('category', 'feature'),
591
+ }
592
+ except (ValueError, TypeError):
593
+ return {'ok': False, 'error': 'product-id/pri 必须是数字。'}, 3
594
+ if not payload['title']:
595
+ return {'ok': False, 'error': '建需求必须指定 --title'}, 3
596
+
597
+ r = client.api('POST', '/stories', payload)
598
+ if r.status_code not in (200, 201):
599
+ return {'ok': False, 'error': '建需求失败 (HTTP %s): %s' % (
600
+ r.status_code, ZentaoClient.err_text(r))}, 5
601
+ try:
602
+ story_id = r.json().get('id')
603
+ except Exception:
604
+ story_id = None
605
+ if not story_id:
606
+ return {'ok': False, 'error': '建需求响应无 id: %s' % ZentaoClient.err_text(r)}, 5
607
+
608
+ # 可选关联 plan
609
+ plan_id = params.get('plan_id')
610
+ if plan_id:
611
+ try:
612
+ client.api('POST', '/plans/%s/stories' % plan_id, {'stories': [story_id]})
613
+ except Exception:
614
+ pass # 关联失败不阻塞主流程(需求已建好)
615
+
616
+ # 可选关联 build
617
+ build_id = params.get('build_id')
618
+ if build_id:
619
+ _do_link_version(client, my_account, 'story', story_id, {'build_id': build_id})
620
+
621
+ return {'ok': True, 'data': {
622
+ 'action': 'publish_story', 'story_id': story_id,
623
+ 'message': '需求 #%s 已创建(产品 %s)%s' % (
624
+ story_id, pid, (' 并关联 build %s' % build_id) if build_id else '')}}, 0
625
+
626
+
627
+ # 禅道写操作的 HTTP 调度表: action -> (method, path_tpl, payload_builder, ok_msg)
628
+ # payload_builder(args_dict) -> payload_dict
629
+ def _do_action(client, my_account, action, item_id, params):
630
+ """执行单个写操作。返回 (result_dict, exit_code)。"""
631
+ developer = get_developer()
632
+ my_account = my_account or (_resolve_my_account(developer)[0] if developer else None)
633
+ if not my_account:
634
+ return {'ok': False, 'error': '未识别禅道身份, 无法校验归属。请配 member.json 的 zentao_account。'}, 3
635
+
636
+ # spec 确认上传(不走禅道 HTTP 写,调 spec_upload 回流平台)
637
+ if action == 'confirm_spec':
638
+ _k = params.get('kind') or 'task'
639
+ _k = {'tasks': 'task', 'bugs': 'bug', 'stories': 'story'}.get(_k, _k)
640
+ return _do_confirm_spec(client, my_account, _k, item_id, params)
641
+
642
+ # story 域写操作(自定义逻辑,独立函数分支,同 confirm_spec)
643
+ if action == 'publish_story':
644
+ return _do_publish_story(client, my_account, 'story', item_id, params)
645
+ if action == 'link_version':
646
+ _k = params.get('kind') or 'story'
647
+ _k = {'tasks': 'task', 'bugs': 'bug', 'stories': 'story'}.get(_k, _k)
648
+ return _do_link_version(client, my_account, _k, item_id, params)
649
+
650
+ # action 分派
651
+ ACTIONS = {
652
+ # 任务类 (kind=task)
653
+ 'start_task': ('task', 'POST', '/tasks/%s/start', {'left': 'left'}),
654
+ 'pause_task': ('task', 'POST', '/tasks/%s/pause', {}),
655
+ 'finish_task': ('task', 'POST', '/tasks/%s/finish', {'left': 'left', 'consumed': 'consumed'}),
656
+ 'log_effort': ('task', 'POST', '/tasks/%s/estimate', {'consumed': 'consumed', 'left': 'left'}),
657
+ # Bug (kind=bug)
658
+ 'confirm_bug': ('bug', 'POST', '/bugs/%s/confirm', {}),
659
+ 'resolve_bug': ('bug', 'POST', '/bugs/%s/resolve', {'resolution': 'resolution', 'comment': 'comment'}),
660
+ }
661
+ if action not in ACTIONS:
662
+ return {'ok': False, 'error': '未知操作: %s。支持: %s' % (action, ', '.join(ACTIONS.keys()))}, 3
663
+
664
+ kind, method, path_tpl, fields = ACTIONS[action]
665
+ path = path_tpl % item_id
666
+
667
+ # 归属校验 (防误改队友)
668
+ guard = _check_owner(client, my_account, kind, item_id)
669
+ if guard:
670
+ return {'ok': False, 'error': guard}, 4
671
+
672
+ # 拼 payload (只取该操作关心的字段)
673
+ payload = {}
674
+ for pkey, fkey in fields.items():
675
+ v = params.get(fkey)
676
+ if v is not None and v != '':
677
+ try:
678
+ payload[pkey] = float(v) if fkey in ('left', 'consumed') else v
679
+ except (ValueError, TypeError):
680
+ payload[pkey] = v
681
+
682
+ # 各操作的必填字段兜底 (实测禅道校验)
683
+ today = datetime.now().strftime('%Y-%m-%d')
684
+ if action == 'finish_task':
685
+ # 实测: finish 必须带 finishedDate ("实际完成"日期), 否则 400
686
+ payload.setdefault('left', 0)
687
+ payload.setdefault('finishedDate', today)
688
+ elif action == 'start_task':
689
+ # 实测禅道: start 不允许 "总计消耗=0 且 预计剩余=0" (报 400)。
690
+ # 策略: 用户填了 left 就用; 没填则查任务的 estimate(预计工时) 兜底;
691
+ # estimate 也没有 → 返回友好错误让用户填, 不传 0 撞 400。
692
+ if 'left' not in payload or payload.get('left') == 0:
693
+ # 查任务详情拿 estimate
694
+ try:
695
+ td = client.api('GET', '/tasks/%s' % item_id).json()
696
+ est = td.get('estimate') or 0
697
+ except Exception:
698
+ est = 0
699
+ if est and est > 0:
700
+ payload['left'] = float(est)
701
+ elif not payload.get('consumed'):
702
+ return {'ok': False, 'error': '开始任务需要填剩余工时(禅道不允许剩余=0)。请在弹框里填一个预估工时,如 8。'}, 3
703
+ elif action == 'log_effort' and 'consumed' not in payload:
704
+ return {'ok': False, 'error': '记工时必须填消耗工时。'}, 3
705
+ elif action == 'resolve_bug' and 'resolution' not in payload:
706
+ payload['resolution'] = 'fixed'
707
+ elif action == 'confirm_bug':
708
+ # 确认Bug 部分禅道版本要 assignedTo, 确保认领到自己头上
709
+ payload.setdefault('assignedTo', my_account)
710
+
711
+ # 调禅道
712
+ r = client.api(method, path, payload)
713
+ if r.status_code in (200, 201):
714
+ label = {
715
+ 'start_task': '已开始', 'pause_task': '已暂停', 'finish_task': '已完成',
716
+ 'log_effort': '已记工时', 'confirm_bug': '已确认', 'resolve_bug': '已解决',
717
+ }[action]
718
+ return {'ok': True, 'data': {'action': action, 'id': item_id, 'message': '%s #%s %s' % (kind, item_id, label)}}, 0
719
+ return {'ok': False, 'error': '操作失败 (HTTP %s): %s。可能权限不足或当前状态不允许。' % (
720
+ r.status_code, ZentaoClient.err_text(r))}, 5
721
+
722
+
723
+ def perform_action(action, item_id, params=None):
724
+ """写操作入口。返回 (result_dict, exit_code)。"""
725
+ params = params or {}
726
+ # confirm_spec: 已显式给 spec_path + zentao_id 时不依赖禅道连接(直接回流平台)
727
+ if action == 'confirm_spec' and params.get('spec_path') and params.get('zentao_id'):
728
+ _k = params.get('kind') or 'task'
729
+ _k = {'tasks': 'task', 'bugs': 'bug', 'stories': 'story'}.get(_k, _k)
730
+ return _do_confirm_spec(None, None, _k, item_id, params)
731
+ client = _zentao_creds()
732
+ if client is None:
733
+ return {'ok': False, 'error': '禅道凭据未配置。'}, 3
734
+ ok, msg = client.credentials_ok()
735
+ if not ok:
736
+ return {'ok': False, 'error': '禅道凭据不完整: %s' % msg}, 3
737
+ ok, msg = client.check_intranet()
738
+ if not ok:
739
+ return {'ok': False, 'error': '连不上禅道内网: %s' % msg}, 5
740
+ developer = get_developer()
741
+ my_account, err = _resolve_my_account(developer) if developer else (None, '未识别开发者身份')
742
+ if err and not my_account:
743
+ return {'ok': False, 'error': err}, 3
744
+ return _do_action(client, my_account, action, item_id, params)
745
+
746
+
747
+ # ============================================================
748
+ # 提示词增强 (v0.6): 发AI前用知识图谱拉相关代码/API/字段
749
+ # 策略: 调 context_pack.py 拿结构化上下文 → 拼进提示词。
750
+ # 查不到/超时 静默降级返回基础提示词 (永远不阻塞「发AI」)。
751
+ # ============================================================
752
+
753
+ # 停用词: 任务标题里这些词不该拿去查知识图谱 (噪音太大)
754
+ _STOPWORDS = set(' 和 与 或 及 及 以 在 为 对 从 给 把 被 让 用 : ( ) ( ) - , , . 。 / 新增 修改 优化 问题 异常 无法 不能 报错'.split())
755
+
756
+
757
+ def _extract_keywords(title):
758
+ """从任务/Bug 标题提取业务关键词。
759
+
760
+ 策略: 、,。:等分隔符切块 → 去停用词 → 长串按2-4字滑窗拆出业务词。
761
+ 例: "车辆年检、保险延期终止申请关联异常生成优化"
762
+ → ['车辆年检','年检','保险','延期','终止','申请','关联','异常','生成']
763
+ """
764
+ if not title:
765
+ return []
766
+ import re
767
+ # 去掉 #id、数字编号、【】
768
+ clean = re.sub(r'#\d+|【.*?】|\d{4,}', ' ', title)
769
+ # 按标点/符号切分成短语块
770
+ chunks = re.split(r'[^\u4e00-\u9fa5a-zA-Z0-9]+', clean)
771
+ kws = []
772
+ for chunk in chunks:
773
+ chunk = chunk.strip()
774
+ if not chunk or chunk in _STOPWORDS or chunk.isdigit():
775
+ continue
776
+ if len(chunk) <= 4:
777
+ # 短块直接用 (如 "保险" "年检")
778
+ kws.append(chunk)
779
+ elif re.match(r'^[\u4e00-\u9fa5]+$', chunk):
780
+ # 纯中文长块: 2-4字滑窗拆出所有业务词 (如"保险延期终止"→保险/险延/延期/期终/终止)
781
+ # 但只保留 2-4 字的常见业务词长度, 去掉无意义组合
782
+ for size in (2, 3, 4):
783
+ for i in range(len(chunk) - size + 1):
784
+ w = chunk[i:i+size]
785
+ if w not in _STOPWORDS:
786
+ kws.append(w)
787
+ else:
788
+ kws.append(chunk)
789
+ # 去重保序
790
+ seen, out = set(), []
791
+ for k in kws:
792
+ if k not in seen:
793
+ seen.add(k)
794
+ out.append(k)
795
+ return out[:6] # 多取一些, 合并查询时去重
796
+
797
+
798
+ # 噪音文件: 这些通用文件命中了也不该推荐 (路由表/测试入口/通用配置, 不是业务实现)
799
+ _NOISE_PATTERNS = [
800
+ '/router/routes/', 'module/mList/', 'module/ips/main.vue',
801
+ '/common/core/exception/', 'enum', 'constant.java', 'core.ts',
802
+ 'Application.java', 'main.vue', 'List.vue', 'index.vue',
803
+ ]
804
+
805
+
806
+ def _is_noise(filepath):
807
+ """判断代码文件是否是噪音 (路由/测试/通用文件, 非业务实现)。"""
808
+ fp = filepath.lower()
809
+ return any(p.lower() in fp for p in _NOISE_PATTERNS)
810
+
811
+
812
+ def _guess_platform(scene_name):
813
+ """从执行名/场景名推断 platform。
814
+ execution 名形如 '20260702后端'/'20260702前端'/'20260702移动端'
815
+ 返回 'web'/'app' 或 None(后端代码不按端过滤)。
816
+ """
817
+ if not scene_name:
818
+ return None
819
+ if '前端' in scene_name or 'PC' in scene_name or '管理端' in scene_name:
820
+ return 'web'
821
+ if '移动端' in scene_name or 'APP' in scene_name.upper() or 'H5' in scene_name.upper():
822
+ return 'app'
823
+ return None # 后端: 知识图谱不过滤 platform (fywl-ics 后端代码)
824
+
825
+
826
+ def _query_kg_context(keyword, platform=None):
827
+ """调 context_pack.py 拉单个关键词的上下文。带超时, 失败返回空dict。
828
+ platform: 'web'/'app' 过滤代码库 (前端=fywl-ui, 移动端=Carmg-H5); None=不过滤(含后端)。
829
+ """
830
+ import subprocess
831
+ script = os.path.join(_SCRIPTS, 'domain', 'kg', 'search', 'context_pack.py')
832
+ if not os.path.isfile(script):
833
+ return {}
834
+ cmd = ['python', script, keyword]
835
+ if platform in ('web', 'app'):
836
+ cmd += ['--platform', platform]
837
+ try:
838
+ r = subprocess.run(
839
+ cmd, capture_output=True, text=True, timeout=15, encoding='utf-8', errors='replace')
840
+ return _parse_context_pack(r.stdout, keyword)
841
+ except subprocess.TimeoutExpired:
842
+ return {} # 知识图谱查询超时, 静默降级
843
+ except Exception:
844
+ return {}
845
+
846
+
847
+ def _parse_context_pack(text, keyword):
848
+ """从 context_pack 的文本输出里提取代码/API/字段三类信息 (精简版)。"""
849
+ if not text:
850
+ return {}
851
+ import re
852
+ result = {'code_files': [], 'apis': [], 'fields': []}
853
+ lines = text.split('\n')
854
+ section = None
855
+ for line in lines:
856
+ # 识别段落标题
857
+ if '相关代码文件' in line:
858
+ section = 'code'; continue
859
+ elif '相关 API' in line:
860
+ section = 'api'; continue
861
+ elif '相关字段' in line:
862
+ section = 'field'; continue
863
+ elif line.startswith('## '):
864
+ section = None; continue
865
+ # 提取条目
866
+ if not section:
867
+ continue
868
+ m = re.match(r'-\s+(?:\[[^\]]*\]\s*)?([^\s(]+)', line)
869
+ if not m:
870
+ continue
871
+ item = m.group(1).strip()
872
+ if section == 'code' and ('/' in item or '\\' in item):
873
+ if not _is_noise(item): # 过滤路由/测试/通用文件噪音
874
+ result['code_files'].append(item)
875
+ elif section == 'api' and item.startswith('/'):
876
+ if not _is_noise(item):
877
+ result['apis'].append(item)
878
+ elif section == 'field' and '->' in line:
879
+ result['fields'].append(line.strip('- ').strip())
880
+ # 精简: 代码取前8, API取前6, 字段取前6
881
+ result['code_files'] = result['code_files'][:8]
882
+ result['apis'] = result['apis'][:6]
883
+ result['fields'] = result['fields'][:6]
884
+ return result
885
+
886
+
887
+ def _query_kg_prefetch(title, platform=None):
888
+ """调 kg.py prefetch 一次性多词合并搜索 (知识图谱升级后的能力)。
889
+
890
+ _query_kg_context(单关键词) 强: prefetch 自动拆词+合并+按多词命中排序。
891
+ 输入完整标题, 一次拿到最全最准的上下文。
892
+ """
893
+ import subprocess
894
+ script = os.path.join(_SCRIPTS, 'domain', 'kg', 'kg.py')
895
+ if not os.path.isfile(script):
896
+ return {}
897
+ cmd = ['python', script, 'prefetch', title]
898
+ if platform in ('web', 'app'):
899
+ cmd += ['--platform', platform]
900
+ try:
901
+ r = subprocess.run(
902
+ cmd, capture_output=True, text=True, timeout=20, encoding='utf-8', errors='replace')
903
+ return _parse_prefetch(r.stdout)
904
+ except subprocess.TimeoutExpired:
905
+ return {}
906
+ except Exception:
907
+ return {}
908
+
909
+
910
+ def _parse_prefetch(text):
911
+ """从 kg.py prefetch 的输出提取代码/API/字段 (复用 _parse_context_pack 逻辑)。
912
+
913
+ prefetch 输出格式和 context_pack 基本一致 (都有"相关代码文件/相关API/相关字段"段),
914
+ 只是 prefetch 带多词命中次数标注 (如 "4词命中")。
915
+ """
916
+ return _parse_context_pack(text, 'prefetch')
917
+
918
+
919
+ def _query_kg_semantic(query, top_k=5):
920
+ """调 kg.py semantic 语义搜索 (概念检索, 理解业务含义)。
921
+
922
+ prefetch 的关键词匹配更强: 查"数据权限"能命中 permission/auth 等概念相关代码。
923
+ 用于补充 prefetch 可能漏掉的语义相关文件。带超时, daemon 未启动时冷启动~27s。
924
+ """
925
+ import subprocess
926
+ script = os.path.join(_SCRIPTS, 'domain', 'kg', 'kg.py')
927
+ if not os.path.isfile(script):
928
+ return []
929
+ try:
930
+ r = subprocess.run(
931
+ ['python', script, 'semantic', query, '--top', str(top_k)],
932
+ capture_output=True, text=True, timeout=35, encoding='utf-8', errors='replace')
933
+ if r.returncode != 0 or '[error]' in r.stdout:
934
+ return []
935
+ # 解析输出: " 0.849 数据权限 handlePermission"
936
+ import re
937
+ results = []
938
+ for line in r.stdout.split('\n'):
939
+ m = re.match(r'\s*([\d.]+)\s+(.+)', line)
940
+ if m:
941
+ results.append(m.group(2).strip())
942
+ return results[:top_k]
943
+ except Exception:
944
+ return []
945
+
946
+
947
+ def _query_kg_impact(keyword):
948
+ """调 kg.py impact 影响分析 (改某模块影响哪些端点/Controller)
949
+
950
+ 用于 bug 提示词的"影响范围"——告诉 AI 这个改动会波及哪些接口。
951
+ """
952
+ import subprocess
953
+ script = os.path.join(_SCRIPTS, 'domain', 'kg', 'kg.py')
954
+ if not os.path.isfile(script):
955
+ return []
956
+ try:
957
+ r = subprocess.run(
958
+ ['python', script, 'impact', keyword],
959
+ capture_output=True, text=True, timeout=15, encoding='utf-8', errors='replace')
960
+ if r.returncode != 0 or '未找到' in r.stdout:
961
+ return []
962
+ # 解析: 提取 Controller 名和端点
963
+ import re
964
+ controllers = set()
965
+ endpoints = set()
966
+ for line in r.stdout.split('\n'):
967
+ m = re.match(r'\s+后端:\s*(\S+)', line)
968
+ if m:
969
+ controllers.add(m.group(1))
970
+ m2 = re.match(r'^###\s+(\S+)', line)
971
+ if m2 and m2.group(1).startswith('/'):
972
+ endpoints.add(m2.group(1))
973
+ return {'controllers': list(controllers)[:6], 'endpoints': list(endpoints)[:6]}
974
+ except Exception:
975
+ return {}
976
+
977
+
978
+ def _clean_html(s):
979
+ """去 HTML 标签 + 实体, 压缩空白。禅道详情字段是富文本 HTML。"""
980
+ if not s:
981
+ return ''
982
+ import re
983
+ s = re.sub(r'<br\s*/?>|</p>|</div>', '\n', s, flags=re.I)
984
+ s = re.sub(r'<[^>]+>', '', s)
985
+ s = re.sub(r'&nbsp;', ' ', s)
986
+ s = re.sub(r'&amp;', '&', s)
987
+ s = re.sub(r'&lt;', '<', s)
988
+ s = re.sub(r'&gt;', '>', s)
989
+ s = re.sub(r'[ \t]+', ' ', s)
990
+ s = re.sub(r'\n{3,}', '\n\n', s)
991
+ return s.strip()
992
+
993
+
994
+ def _fetch_zentao_detail(client, kind, item_id, max_len=600):
995
+ """从禅道拉任务/Bug/需求的详情 + 关联信息, 用于润色提示词。
996
+
997
+ 返回 dict:
998
+ - detail: 自身描述 (任务desc / Bug的steps重现步骤 / 需求spec)
999
+ - story_title: 关联需求标题 (任务/bug 常挂在 story 下)
1000
+ - story_spec: 关联需求规格 (业务背景, 可能很详细)
1001
+ - scene: 场景名 (所属执行/产品)
1002
+ 任何一项查不到就留空, 不报错。
1003
+ """
1004
+ import re
1005
+ out = {'detail': '', 'story_id': 0, 'story_title': '', 'story_spec': '', 'scene': ''}
1006
+
1007
+ # 自身详情 (zentao 路径: task→tasks, bug→bugs, story→stories — story +es 不是 +s)
1008
+ _kind_path = {'task': 'tasks', 'bug': 'bugs', 'story': 'stories'}.get(kind, kind + 's')
1009
+ path = '/%s/%s' % (_kind_path, item_id)
1010
+ r = client.api('GET', path)
1011
+ if r.status_code != 200:
1012
+ return out
1013
+ data = r.json()
1014
+
1015
+ # 取详情正文 (不同 kind 字段名不同)
1016
+ if kind == 'bug':
1017
+ raw = data.get('steps', '')
1018
+ elif kind == 'story':
1019
+ raw = data.get('spec', '') # 故事详情=spec;spec 空则无详情(title 已在关联需求段,不重复)
1020
+ else: # task
1021
+ raw = data.get('desc', '')
1022
+ detail = _clean_html(raw)
1023
+ if detail and len(detail) > max_len:
1024
+ detail = detail[:max_len] + '…'
1025
+ out['detail'] = detail
1026
+
1027
+ # 场景名: execution (任务) / product (bug/story)
1028
+ # kind=story: 故事本身就是需求,story_id 用自身 id(不找父 story,story 的 data.story 是父/None)
1029
+ if kind == 'story':
1030
+ story_id = item_id
1031
+ else:
1032
+ story_id = data.get('story') or 0
1033
+
1034
+ if kind == 'task':
1035
+ eid = data.get('execution')
1036
+ if eid:
1037
+ er = client.api('GET', '/executions/%s' % eid)
1038
+ if er.status_code == 200:
1039
+ out['scene'] = str(er.json().get('name', ''))
1040
+ else:
1041
+ pid = data.get('product')
1042
+ if pid:
1043
+ pr = client.api('GET', '/products/%s' % pid)
1044
+ if pr.status_code == 200:
1045
+ out['scene'] = str(pr.json().get('name', ''))
1046
+
1047
+ # 关联需求 (业务背景, 最有价值的上下文)
1048
+ if story_id:
1049
+ out['story_id'] = story_id
1050
+ if kind == 'story':
1051
+ # 故事自身即需求,用已取的 data(不重复 fetch /stories/{自身}
1052
+ out['story_title'] = str(data.get('title', ''))
1053
+ out['story_spec'] = detail # _clean_html 过(kind=story 时 raw=spec or title)
1054
+ else:
1055
+ sr = client.api('GET', '/stories/%s' % story_id)
1056
+ if sr.status_code == 200:
1057
+ sd = sr.json()
1058
+ out['story_title'] = str(sd.get('title', ''))
1059
+ spec = _clean_html(sd.get('spec', ''))
1060
+ if spec and len(spec) > max_len:
1061
+ spec = spec[:max_len] + '…'
1062
+ out['story_spec'] = spec
1063
+
1064
+ return out
1065
+
1066
+
1067
+ def enrich_prompt(kind, item_id, title, client=None):
1068
+ """构建增强提示词: 禅道详情(最高优先) + 知识图谱(代码定位)
1069
+
1070
+ 上下文优先级 (逐层取, 有就用, 没有就跳过):
1071
+ 1. 禅道详情: 任务desc / Bug重现steps / 关联需求spec (最准, 但常为空)
1072
+ 2. 场景名: 所属执行/产品
1073
+ 3. 知识图谱: 相关代码/API/字段 (代码定位用)
1074
+
1075
+ client 传入则拉禅道详情; 不传(None)则只做知识图谱增强。
1076
+ 任何环节失败 → 静默跳过, 不阻塞。
1077
+ """
1078
+ kind_label = kind if kind in ('任务', 'Bug', '需求') else '任务'
1079
+ # 禅道内部 kind (task/bug/story)
1080
+ zt_kind = 'bug' if kind_label == 'Bug' else ('story' if kind_label == '需求' else 'task')
1081
+
1082
+ # 角色化任务描述 (替代原来的"帮我推进禅道任务"废话)
1083
+ if zt_kind == 'bug':
1084
+ role_task = '定位这个 Bug 的根因并给出修复方案'
1085
+ elif zt_kind == 'story':
1086
+ role_task = '拆解这个需求并给出实现方案'
1087
+ else:
1088
+ role_task = '梳理这个任务的实现思路并定位代码'
1089
+
1090
+ # ── 先拉禅道详情 (拿 scene 推断 platform) ──
1091
+ detail_added = False
1092
+ scene_name = ''
1093
+ story_block = [] # 关联需求内容 (暂存, 后面拼)
1094
+ detail_block = [] # 重现步骤/desc (暂存)
1095
+ if client:
1096
+ try:
1097
+ d = _fetch_zentao_detail(client, zt_kind, item_id)
1098
+ scene_name = d.get('scene', '')
1099
+ if d['story_title']:
1100
+ story_block.append('## 关联需求 #%s' % d['story_id'])
1101
+ story_block.append(d['story_title'])
1102
+ if d['story_spec']:
1103
+ story_block.append(d['story_spec'])
1104
+ detail_added = True
1105
+ if d['detail']:
1106
+ lbl = '重现步骤' if zt_kind == 'bug' else ('任务说明' if zt_kind == 'task' else '需求规格')
1107
+ detail_block.append('## %s' % lbl)
1108
+ detail_block.append(d['detail'])
1109
+ detail_added = True
1110
+ except Exception:
1111
+ pass
1112
+
1113
+ # scene 推断 platform (前端/移动端/后端)
1114
+ platform = _guess_platform(scene_name)
1115
+ platform_label = {'web': '前端(Web)', 'app': '移动端(APP)'}.get(platform, '后端/全栈')
1116
+
1117
+ # 构建 lines (此时 scene/platform 都已确定)
1118
+ lines = [
1119
+ '## 任务',
1120
+ '%s「%s」(禅道%s #%s, %s方向)' % (role_task, title or '', kind_label, item_id, platform_label),
1121
+ ]
1122
+ if scene_name:
1123
+ lines.append('- 迭代/场景:%s' % scene_name)
1124
+
1125
+ # 拼入禅道详情
1126
+ if story_block:
1127
+ lines.append('')
1128
+ lines.extend(story_block)
1129
+ if detail_block:
1130
+ lines.append('')
1131
+ lines.extend(detail_block)
1132
+
1133
+ # 平台上下文(按禅道 story 反查需求→spec + 代码变更史)
1134
+ try:
1135
+ if d.get('story_id'):
1136
+ from domain.integration.spec_upload import _resolve_endpoint, _mcp_call
1137
+ _base, _tok, _pid = _resolve_endpoint()
1138
+ if _tok:
1139
+ _sid = str(d['story_id'])
1140
+ _ps = _mcp_call(_base, _tok, "find_specs_by_zentao",
1141
+ {"zentao_id": _sid, "project_id": _pid})
1142
+ _cc = _mcp_call(_base, _tok, "code_changes",
1143
+ {"project_id": _pid, "req_key": "XQ" + _sid, "mode": "summary"})
1144
+ if _ps.get("specs") or _cc.get("commits") or _cc.get("functions"):
1145
+ lines.append('')
1146
+ lines.append('## 平台上下文(禅道 #%s → %s)' % (
1147
+ _sid, _ps.get("requirement_title") or ("XQ" + _sid)))
1148
+ for _s in (_ps.get("specs") or []):
1149
+ lines.append('- SPEC [%s] %s v%s:%s' % (
1150
+ _s.get("status"), _s.get("title"), _s.get("current_version"),
1151
+ str(_s.get("content_excerpt") or '')[:150].replace("\n", " ")))
1152
+ if _cc.get("commits") or _cc.get("functions"):
1153
+ _tf = ', '.join(f.get('fn', '') for f in (_cc.get('top_functions') or [])[:3])
1154
+ lines.append('- 代码变更史:%s 函数 / %s 提交(top: %s)' % (
1155
+ _cc.get('functions'), _cc.get('commits'), _tf or '—'))
1156
+ except Exception:
1157
+ pass # 平台上下文拉不到(无 token/无 story_id)不阻塞 enrich
1158
+
1159
+ # 要做的事(任务化一句,替代通用 4 点 dump)
1160
+ lines.append('')
1161
+ lines.append('## 要做的事')
1162
+ if zt_kind == 'bug':
1163
+ lines.append('定位根因(数据/逻辑/接口/权限?)→ 修复方案(改哪个文件改什么)→ 回归点')
1164
+ elif zt_kind == 'story':
1165
+ lines.append('拆子功能 → 技术方案(接口/表/页面,新建还是改造)→ 实现步骤 → 风险点')
1166
+ else:
1167
+ lines.append('实现思路(用哪些现有代码)→ 改哪个类/方法 → 开发步骤 → 自检点')
1168
+
1169
+ # ── ② 知识图谱增强 (代码定位) ──
1170
+ # v0.18: prefetch 替代多次 context_pack —— 一次多词合并查询, 更快更准。
1171
+ kws = _extract_keywords(title)
1172
+ if not kws:
1173
+ return {'ok': True, 'data': {'prompt': '\n'.join(lines), 'enriched': detail_added, 'keyword': ''}}
1174
+
1175
+ # prefetch 一次性多词搜索 (知识图谱升级后的能力)
1176
+ ctx = _query_kg_prefetch(title, platform)
1177
+ if not ctx or not (ctx.get('code_files') or ctx.get('apis') or ctx.get('fields')):
1178
+ # prefetch 失败 回退到旧的多词 context_pack
1179
+ from concurrent.futures import ThreadPoolExecutor
1180
+ all_code, all_api, all_field = {}, [], []
1181
+ with ThreadPoolExecutor(max_workers=4) as pool:
1182
+ results = list(pool.map(lambda k: (k, _query_kg_context(k, platform)), kws[:4]))
1183
+ for kw, r in results:
1184
+ for f in r.get('code_files', []):
1185
+ all_code[f] = all_code.get(f, 0) + 1
1186
+ all_api.extend(r.get('apis', []))
1187
+ all_field.extend(r.get('fields', []))
1188
+ else:
1189
+ # prefetch 成功: 它已经按多词命中次数排好序了
1190
+ all_code = {f: 1 for f in ctx.get('code_files', [])}
1191
+ all_api = ctx.get('apis', [])
1192
+ all_field = ctx.get('fields', [])
1193
+
1194
+ # 代码文件按命中次数排序 (次数多的排前面), API/字段去重
1195
+ ranked_code = sorted(all_code.items(), key=lambda x: -x[1])[:8]
1196
+ code_list = [f for f, _ in ranked_code]
1197
+ seen = set()
1198
+ api_list = [a for a in all_api if not (a in seen or seen.add(a))][:6]
1199
+ # 字段过滤: 去掉明显跨业务域的假勤/薪资字段 (除非任务本身就是这些)
1200
+ _IRRELEVANT_FIELDS = ('产检假', '年假', '年薪', '病假', '事假', '调休', '婚假', '丧假', '陪产假')
1201
+ field_candidates = [fld for fld in all_field
1202
+ if not (fld in seen or seen.add(fld))
1203
+ and not any(ir in fld for ir in _IRRELEVANT_FIELDS)]
1204
+ field_list = field_candidates[:6]
1205
+
1206
+ if not (code_list or api_list or field_list):
1207
+ return {'ok': True, 'data': {'prompt': '\n'.join(lines), 'enriched': detail_added, 'keyword': ','.join(kws[:3])}}
1208
+
1209
+ # 代码定位:top-3 文件指针(不 dump API/字段/语义,让 agent 用 rag_search/get_impact/context_360 工具自取深入)
1210
+ if code_list:
1211
+ lines.append('')
1212
+ lines.append('## 代码定位(top %d;细节用 rag_search / get_impact / context_360 工具取)' % min(3, len(ranked_code)))
1213
+ for f, cnt in ranked_code[:3]:
1214
+ mark = ' ←多词命中' if cnt >= 2 else ''
1215
+ lines.append('- `%s`%s' % (f, mark))
1216
+ # bug 影响范围(1 行,不展开)
1217
+ if zt_kind == 'bug' and kws:
1218
+ impact = _query_kg_impact(kws[0])
1219
+ if impact and (impact.get('controllers') or impact.get('endpoints')):
1220
+ hit = (impact.get('controllers') or [])[:3] + (impact.get('endpoints') or [])[:3]
1221
+ lines.append('')
1222
+ lines.append('**影响**:改「%s」波及 %s' % (kws[0], ', '.join(hit)))
1223
+ lines.append('')
1224
+ lines.append('先读上述文件理解现状再动手;细节走工具,别脱离现有架构另起炉灶。')
1225
+ used_kws = ','.join(kws[:3])
1226
+ return {'ok': True, 'data': {'prompt': '\n'.join(lines), 'enriched': True, 'keyword': used_kws,
1227
+ 'counts': {'code': min(3, len(ranked_code)) if code_list else 0}}}
1228
+
1229
+
1230
+ def main():
1231
+ parser = argparse.ArgumentParser(description='禅道任务面板数据接口 (输出 JSON 供 VS Code 扩展消费)')
1232
+ parser.add_argument('--json', action='store_true', help='JSON 输出 (面板默认用这个)')
1233
+ parser.add_argument('--include-closed', action='store_true', help='包含已关闭/取消的历史项 (默认只看活动项)')
1234
+ parser.add_argument('--pretty', action='store_true', help='JSON 美化缩进 (默认紧凑, 面板解析更快)')
1235
+ # 写操作子命令 (v0.3)
1236
+ parser.add_argument('--action', choices=['start_task', 'pause_task', 'finish_task',
1237
+ 'log_effort', 'confirm_bug', 'resolve_bug', 'confirm_spec',
1238
+ 'publish_story', 'link_version'],
1239
+ help='执行禅道写操作 (面板按钮触发); confirm_spec=确认并上传 spec 到平台; '
1240
+ 'publish_story=建需求; link_version=关联 build')
1241
+ parser.add_argument('--id', type=int, help='写操作的目标 task/bug ID')
1242
+ parser.add_argument('--left', help='剩余工时 (start/finish/log_effort)')
1243
+ parser.add_argument('--consumed', help='消耗工时 (finish/log_effort)')
1244
+ parser.add_argument('--resolution', help='Bug 解决方式 (resolve_bug): fixed/bydesign/duplicate/notrepro/postpone')
1245
+ parser.add_argument('--comment', help='Bug 解决备注 (resolve_bug)')
1246
+ # spec 确认上传 (confirm_spec)
1247
+ parser.add_argument('--spec-path', help='本地 spec.md 路径 (confirm_spec; 不传则用当前任务 spec)')
1248
+ parser.add_argument('--code-targets', help='代码目标实体逗号分隔 (confirm_spec, 可选)')
1249
+ parser.add_argument('--zentao-id', help='禅道 story (confirm_spec; 不传则从 --id 反查 story)')
1250
+ # story 域写操作 (publish_story / link_version)
1251
+ parser.add_argument('--product-id', help='产品 ID (publish_story 必填;需求挂产品)')
1252
+ parser.add_argument('--spec', help='需求规格正文 (publish_story)')
1253
+ parser.add_argument('--spec-file', help='需求规格正文文件路径 (publish_story; 优先于 --spec, 规避 shell 长度限制)')
1254
+ parser.add_argument('--pri', type=int, default=3, help='需求优先级 1-4 (publish_story; 默认 3)')
1255
+ parser.add_argument('--plan-id', help='关联计划 ID (publish_story 可选)')
1256
+ parser.add_argument('--build-id', help='关联 build/版本 ID (publish_story 可选; link_version 必填)')
1257
+ # 提示词增强 (v0.6)
1258
+ parser.add_argument('--enrich', action='store_true', help='构建带知识图谱上下文的提示词 (发AI用)')
1259
+ parser.add_argument('--title', help='任务/Bug 标题 (enrich 用)')
1260
+ parser.add_argument('--kind-label', default='任务', help='类型标签 (enrich/detail 用): 任务/Bug/需求')
1261
+ # 详情查看 (v0.9)
1262
+ parser.add_argument('--detail', action='store_true', help='拉取任务/Bug/需求的完整详情 (卡片点击用)')
1263
+ parser.add_argument('--kind', choices=['tasks', 'bugs', 'stories', 'task', 'bug', 'story'],
1264
+ help='只取某一域 (默认三域全取); detail/enrich 用单数 task/bug/story 指定类型')
1265
+ args = parser.parse_args()
1266
+
1267
+ # 详情查看分支: 拉禅道详情展示
1268
+ if args.detail:
1269
+ zt_kind = args.kind if args.kind in ('task', 'bug', 'story') else 'task'
1270
+ client = _zentao_creds()
1271
+ d = {}
1272
+ if client:
1273
+ try:
1274
+ ok, _ = client.credentials_ok()
1275
+ if ok and client.check_intranet()[0]:
1276
+ d = _fetch_zentao_detail(client, zt_kind, args.id or 0, max_len=1500)
1277
+ # 给 detail_label
1278
+ d['detail_label'] = {'bug': '重现步骤', 'task': '任务说明', 'story': '需求规格'}.get(zt_kind, '详情')
1279
+ d['title'] = args.title or d.get('story_title', '')
1280
+ except Exception as e:
1281
+ print(json.dumps({'ok': False, 'error': '拉取详情失败: %s' % e})); sys.exit(3)
1282
+ result = {'ok': True, 'data': d}
1283
+ code = 0
1284
+
1285
+ # 提示词增强分支: 禅道详情 + 知识图谱
1286
+ elif args.enrich:
1287
+ kind_label = args.kind_label if args.kind_label in ('任务', 'Bug', '需求') else '任务'
1288
+ # 尝试用禅道 client 拉详情 (失败不影响, enrich_prompt 内部静默降级)
1289
+ client = _zentao_creds()
1290
+ if client:
1291
+ ok, _ = client.credentials_ok()
1292
+ if ok:
1293
+ ok_inet, _ = client.check_intranet()
1294
+ if not ok_inet:
1295
+ client = None # 内网不通就不拉禅道详情, 只做知识图谱
1296
+ else:
1297
+ client = None
1298
+ result = enrich_prompt(kind_label, args.id or 0, args.title or '', client=client)
1299
+ code = 0
1300
+ # 写操作分支
1301
+ elif args.action:
1302
+ if not args.id:
1303
+ result = {'ok': False, 'error': '写操作必须提供 --id'}
1304
+ code = 3
1305
+ else:
1306
+ # publish_story 的 spec 正文: --spec-file 优先于 --spec (读文件, 规避 shell 长度限制)
1307
+ spec_text = args.spec or ''
1308
+ if args.spec_file:
1309
+ try:
1310
+ with open(args.spec_file, encoding='utf-8') as fh:
1311
+ spec_text = fh.read()
1312
+ except Exception as e:
1313
+ result = {'ok': False, 'error': '读 --spec-file 失败: %s' % e}
1314
+ code = 3
1315
+ spec_text = None
1316
+ if spec_text is not None:
1317
+ params = {'left': args.left, 'consumed': args.consumed,
1318
+ 'resolution': args.resolution, 'comment': args.comment,
1319
+ 'spec_path': args.spec_path, 'code_targets': args.code_targets,
1320
+ 'zentao_id': args.zentao_id, 'kind': args.kind,
1321
+ 'product_id': args.product_id, 'title': args.title,
1322
+ 'spec': spec_text, 'pri': args.pri, 'plan_id': args.plan_id,
1323
+ 'build_id': args.build_id}
1324
+ result, code = perform_action(args.action, args.id, params)
1325
+ else:
1326
+ # workbench: kind 期望 tasks/bugs/stories (单数形式转复数)
1327
+ wb_kind = args.kind if args.kind in ('tasks', 'bugs', 'stories') else None
1328
+ result, code = fetch_workbench(include_closed=args.include_closed, kind=wb_kind)
1329
+
1330
+ if args.json or not sys.stdout.isatty():
1331
+ indent = 2 if args.pretty else None
1332
+ print(json.dumps(result, ensure_ascii=False, default=str, indent=indent))
1333
+ else:
1334
+ if result.get('ok'):
1335
+ if args.action:
1336
+ print('✅ %s' % result['data'].get('message', '操作成功'))
1337
+ else:
1338
+ d, m = result['data'], result['meta']
1339
+ print('📋 %s 的禅道工作台 — 任务 %d · 需求 %d · Bug %d (更新于 %s)' % (
1340
+ d['account'], m['counts']['tasks'], m['counts']['stories'], m['counts']['bugs'],
1341
+ m['fetched_at']))
1342
+ else:
1343
+ print('❌ %s' % result.get('error', '未知错误'))
1344
+ sys.exit(code)
1345
+
1346
+
1347
+ if __name__ == '__main__':
1348
+ main()