@hupan56/wlkj 3.0.0 → 3.1.3

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 (247) hide show
  1. package/bin/cli.js +1435 -1373
  2. package/package.json +1 -1
  3. package/templates/qoder/commands/optional/wl-insight.md +6 -4
  4. package/templates/qoder/commands/optional/wl-report.md +20 -39
  5. package/templates/qoder/commands/optional/wl-status.md +4 -4
  6. package/templates/qoder/commands/wl-code.md +2 -2
  7. package/templates/qoder/commands/wl-design.md +5 -5
  8. package/templates/qoder/commands/wl-init.md +3 -3
  9. package/templates/qoder/commands/wl-prd.md +26 -13
  10. package/templates/qoder/commands/wl-req.md +3 -3
  11. package/templates/qoder/commands/wl-search.md +78 -47
  12. package/templates/qoder/commands/wl-task.md +343 -40
  13. package/templates/qoder/commands/wl-test.md +10 -10
  14. package/templates/qoder/config.yaml +35 -13
  15. package/templates/qoder/hooks/post-tool-use.py +181 -0
  16. package/templates/qoder/hooks/session-start.py +82 -1
  17. package/templates/qoder/hooks/stop-eval.py +207 -0
  18. package/templates/qoder/hooks/user-prompt-submit.py +139 -0
  19. package/templates/qoder/rules/wl-pipeline.md +70 -8
  20. package/templates/qoder/scripts/README.md +78 -115
  21. package/templates/qoder/scripts/capability/__init__.py +26 -0
  22. package/templates/qoder/scripts/capability/__main__.py +72 -0
  23. package/templates/qoder/scripts/capability/adapters/__init__.py +29 -0
  24. package/templates/qoder/scripts/capability/adapters/cli.py +316 -0
  25. package/templates/qoder/scripts/capability/adapters/mcp.py +334 -0
  26. package/templates/qoder/scripts/capability/adapters/qw.py +271 -0
  27. package/templates/qoder/scripts/capability/caps/__init__.py +27 -0
  28. package/templates/qoder/scripts/capability/caps/context.py +36 -0
  29. package/templates/qoder/scripts/capability/caps/cron.py +50 -0
  30. package/templates/qoder/scripts/capability/caps/identity.py +43 -0
  31. package/templates/qoder/scripts/capability/caps/memory.py +71 -0
  32. package/templates/qoder/scripts/capability/caps/notify.py +41 -0
  33. package/templates/qoder/scripts/capability/caps/present.py +48 -0
  34. package/templates/qoder/scripts/capability/caps/repo.py +31 -0
  35. package/templates/qoder/scripts/capability/caps/sandbox.py +43 -0
  36. package/templates/qoder/scripts/capability/chain.py +92 -0
  37. package/templates/qoder/scripts/capability/memory_chain.py +41 -0
  38. package/templates/qoder/scripts/capability/registry.py +246 -0
  39. package/templates/qoder/scripts/capability/registry_mcp.py +250 -0
  40. package/templates/qoder/scripts/capability/smoke_test.py +211 -0
  41. package/templates/qoder/scripts/capability/smoke_test_report.json +94 -0
  42. package/templates/qoder/scripts/{report → deployment/setup}/__init__.py +11 -11
  43. package/templates/qoder/scripts/{setup → deployment/setup}/carriers.py +33 -26
  44. package/templates/qoder/scripts/deployment/setup/carriers_verify.py +148 -0
  45. package/templates/qoder/scripts/{setup → deployment/setup}/init_doctor.py +55 -23
  46. package/templates/qoder/scripts/{setup → deployment/setup}/install_qoderwork.py +20 -12
  47. package/templates/qoder/scripts/{setup → deployment/setup}/platform_doctor.py +8 -2
  48. package/templates/qoder/scripts/{setup → deployment/setup}/repo_root.py +113 -112
  49. package/templates/qoder/scripts/{setup → deployment/setup}/setup.py +35 -12
  50. package/templates/qoder/scripts/{setup → deployment/setup}/setup_lanhu.py +13 -7
  51. package/templates/qoder/scripts/domain/__init__.py +0 -0
  52. package/templates/qoder/scripts/domain/design/__init__.py +0 -0
  53. package/templates/qoder/scripts/{report → domain/design}/fill_prototype.py +12 -6
  54. package/templates/qoder/scripts/{report → domain/design}/gen_design_doc.py +8 -2
  55. package/templates/qoder/scripts/{setup → domain/kg}/__init__.py +11 -11
  56. package/templates/qoder/scripts/domain/kg/build/__init__.py +0 -0
  57. package/templates/qoder/scripts/{kg → domain/kg/build}/build_entity_registry.py +7 -2
  58. package/templates/qoder/scripts/{kg → domain/kg/build}/build_relations.py +7 -2
  59. package/templates/qoder/scripts/{kg → domain/kg/build}/build_style_index.py +13 -7
  60. package/templates/qoder/scripts/{kg → domain/kg/build}/build_workflows.py +7 -2
  61. package/templates/qoder/scripts/{kg → domain/kg/build}/kg_build.py +76 -9
  62. package/templates/qoder/scripts/{kg → domain/kg/build}/kg_build_db.py +7 -2
  63. package/templates/qoder/scripts/{kg → domain/kg/build}/kg_incremental.py +11 -6
  64. package/templates/qoder/scripts/{report → domain/kg/build}/learn_aggregate.py +9 -4
  65. package/templates/qoder/scripts/domain/kg/extract/__init__.py +0 -0
  66. package/templates/qoder/scripts/{common → domain/kg/extract}/extract.py +430 -419
  67. package/templates/qoder/scripts/{common → domain/kg/extract}/test_extract.py +115 -115
  68. package/templates/qoder/scripts/{common → domain/kg/extract}/ts_extract.py +614 -536
  69. package/templates/qoder/scripts/domain/kg/graph/__init__.py +0 -0
  70. package/templates/qoder/scripts/{kg → domain/kg/graph}/kg_link_db.py +7 -2
  71. package/templates/qoder/scripts/domain/kg/graph/kg_semantic.py +683 -0
  72. package/templates/qoder/scripts/{kg → domain/kg}/kg.py +263 -30
  73. package/templates/qoder/scripts/{common → domain/kg}/kg_capabilities.py +182 -182
  74. package/templates/qoder/scripts/domain/kg/search/__init__.py +0 -0
  75. package/templates/qoder/scripts/{kg → domain/kg/search}/context_pack.py +30 -9
  76. package/templates/qoder/scripts/{kg → domain/kg/search}/enrich_prompt.py +10 -4
  77. package/templates/qoder/scripts/{kg → domain/kg/search}/prefetch.py +33 -8
  78. package/templates/qoder/scripts/{common → domain/kg/search}/search_engine.py +207 -205
  79. package/templates/qoder/scripts/{kg → domain/kg/search}/search_index.py +143 -77
  80. package/templates/qoder/scripts/domain/kg/server/__init__.py +0 -0
  81. package/templates/qoder/scripts/domain/kg/server/kgd.py +549 -0
  82. package/templates/qoder/scripts/domain/kg/server/perf_bench.py +197 -0
  83. package/templates/qoder/scripts/domain/kg/storage/__init__.py +0 -0
  84. package/templates/qoder/scripts/{kg → domain/kg/storage}/kg_duckdb.py +38 -14
  85. package/templates/qoder/scripts/domain/learning/__init__.py +0 -0
  86. package/templates/qoder/scripts/{report → domain/learning}/learn.py +8 -3
  87. package/templates/qoder/scripts/{mcp → domain/report}/__init__.py +11 -11
  88. package/templates/qoder/scripts/{report → domain/report}/add_session.py +9 -3
  89. package/templates/qoder/scripts/{report → domain/report}/export.py +10 -5
  90. package/templates/qoder/scripts/{report → domain/report}/report.py +9 -4
  91. package/templates/qoder/scripts/domain/report/report_snapshot.py +261 -0
  92. package/templates/qoder/scripts/{report → domain/report}/role.py +10 -4
  93. package/templates/qoder/scripts/{report → domain/report}/status.py +112 -9
  94. package/templates/qoder/scripts/domain/report/status_snapshot.py +191 -0
  95. package/templates/qoder/scripts/domain/requirement/__init__.py +0 -0
  96. package/templates/qoder/scripts/{report → domain/requirement}/archive_prd.py +8 -2
  97. package/templates/qoder/scripts/{report → domain/requirement}/req.py +228 -222
  98. package/templates/qoder/scripts/{kg → domain/task}/__init__.py +11 -11
  99. package/templates/qoder/scripts/domain/task/__pycache__/syncgate.cpython-39.pyc +0 -0
  100. package/templates/qoder/scripts/{task → domain/task}/git_sync.py +40 -21
  101. package/templates/qoder/scripts/{task → domain/task}/syncgate.py +13 -8
  102. package/templates/qoder/scripts/{task → domain/task}/task.py +13 -5
  103. package/templates/qoder/scripts/{task → domain/task}/task_lifecycle.py +606 -596
  104. package/templates/qoder/scripts/{task → domain/task}/task_query.py +162 -161
  105. package/templates/qoder/scripts/{task → domain/task}/task_relations.py +425 -424
  106. package/templates/qoder/scripts/{task → domain/task}/team_sync.py +9 -4
  107. package/templates/qoder/scripts/domain/task/zentao_sync.py +370 -0
  108. package/templates/qoder/scripts/foundation/__init__.py +0 -0
  109. package/templates/qoder/scripts/foundation/__pycache__/__init__.cpython-39.pyc +0 -0
  110. package/templates/qoder/scripts/foundation/__pycache__/bootstrap.cpython-39.pyc +0 -0
  111. package/templates/qoder/scripts/{common → foundation}/bootstrap.py +10 -10
  112. package/templates/qoder/scripts/foundation/core/__init__.py +0 -0
  113. package/templates/qoder/scripts/foundation/core/__pycache__/__init__.cpython-39.pyc +0 -0
  114. package/templates/qoder/scripts/foundation/core/__pycache__/paths.cpython-39.pyc +0 -0
  115. package/templates/qoder/scripts/{common → foundation/core}/cmd_registry.py +1 -1
  116. package/templates/qoder/scripts/{common → foundation/core}/config.py +1 -1
  117. package/templates/qoder/scripts/{common → foundation/core}/paths.py +706 -586
  118. package/templates/qoder/scripts/foundation/data/__init__.py +0 -0
  119. package/templates/qoder/scripts/{common → foundation/data}/contract.py +2 -2
  120. package/templates/qoder/scripts/{common → foundation/data}/events.py +2 -2
  121. package/templates/qoder/scripts/{common → foundation/data}/result.py +1 -1
  122. package/templates/qoder/scripts/{common → foundation/data}/task_utils.py +427 -404
  123. package/templates/qoder/scripts/foundation/identity/__init__.py +0 -0
  124. package/templates/qoder/scripts/foundation/identity/__pycache__/__init__.cpython-39.pyc +0 -0
  125. package/templates/qoder/scripts/foundation/identity/__pycache__/identity.cpython-39.pyc +0 -0
  126. package/templates/qoder/scripts/{common → foundation/identity}/check_publish.py +103 -98
  127. package/templates/qoder/scripts/{common → foundation/identity}/developer.py +239 -239
  128. package/templates/qoder/scripts/{common → foundation/identity}/guard.py +2 -2
  129. package/templates/qoder/scripts/{common → foundation/identity}/identity.py +14 -10
  130. package/templates/qoder/scripts/{common → foundation/identity}/roles.py +1 -1
  131. package/templates/qoder/scripts/foundation/integrations/__init__.py +0 -0
  132. package/templates/qoder/scripts/{common → foundation/integrations}/active_task.py +30 -15
  133. package/templates/qoder/scripts/{common → foundation/integrations}/feishu.py +2 -2
  134. package/templates/qoder/scripts/{common → foundation/integrations}/terms.py +3 -3
  135. package/templates/qoder/scripts/foundation/integrations/zentao_client.py +220 -0
  136. package/templates/qoder/scripts/foundation/io/__init__.py +0 -0
  137. package/templates/qoder/scripts/foundation/io/__pycache__/__init__.cpython-39.pyc +0 -0
  138. package/templates/qoder/scripts/foundation/io/__pycache__/atomicio.cpython-39.pyc +0 -0
  139. package/templates/qoder/scripts/foundation/io/__pycache__/filelock.cpython-39.pyc +0 -0
  140. package/templates/qoder/scripts/{common → foundation/io}/filelock.py +1 -1
  141. package/templates/qoder/scripts/{common → foundation/io}/reqid.py +1 -1
  142. package/templates/qoder/scripts/foundation/protocol/__init__.py +0 -0
  143. package/templates/qoder/scripts/foundation/protocol/duckdb_conn.py +39 -0
  144. package/templates/qoder/scripts/{common → foundation/protocol}/mcp_base.py +1 -1
  145. package/templates/qoder/scripts/foundation/utils/__init__.py +0 -0
  146. package/templates/qoder/scripts/{common → foundation/utils}/eval_api.py +12 -1
  147. package/templates/qoder/scripts/{common → foundation/utils}/pip_install.py +23 -6
  148. package/templates/qoder/scripts/{common → foundation/utils}/platform_guard.py +1 -1
  149. package/templates/qoder/scripts/orchestration/__init__.py +0 -0
  150. package/templates/qoder/scripts/orchestration/wlkj.py +185 -0
  151. package/templates/qoder/scripts/protocol/__init__.py +0 -0
  152. package/templates/qoder/scripts/protocol/browser/README.md +207 -0
  153. package/templates/qoder/scripts/protocol/browser/SKILL.md +265 -0
  154. package/templates/qoder/scripts/protocol/browser/config.env +9 -0
  155. package/templates/qoder/scripts/protocol/browser/references/cdp-api.md +110 -0
  156. package/templates/qoder/scripts/protocol/browser/references/migration-2.5.3.md +72 -0
  157. package/templates/qoder/scripts/protocol/browser/references/site-patterns/.gitkeep +0 -0
  158. package/templates/qoder/scripts/protocol/browser/scripts/browser-discovery.mjs +138 -0
  159. package/templates/qoder/scripts/protocol/browser/scripts/cdp-proxy.mjs +672 -0
  160. package/templates/qoder/scripts/protocol/browser/scripts/check-deps.mjs +206 -0
  161. package/templates/qoder/scripts/protocol/browser/scripts/find-url.mjs +253 -0
  162. package/templates/qoder/scripts/protocol/browser/scripts/match-site.mjs +46 -0
  163. package/templates/qoder/scripts/protocol/browser/templates/config.env.template +9 -0
  164. package/templates/qoder/scripts/protocol/mcp/__init__.py +11 -0
  165. package/templates/qoder/scripts/protocol/mcp/kg_mcp_server.py +478 -0
  166. package/templates/qoder/scripts/{mcp → protocol/mcp}/lanhu_stdio_wrapper.py +11 -5
  167. package/templates/qoder/scripts/{mcp → protocol/mcp}/mcp_doctor.py +47 -14
  168. package/templates/qoder/scripts/protocol/mcp/mcp_launcher.py +600 -0
  169. package/templates/qoder/scripts/{mcp → protocol/mcp}/mysql_mcp_server.py +181 -67
  170. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +1920 -0
  171. package/templates/qoder/scripts/protocol/transports/__init__.py +56 -0
  172. package/templates/qoder/scripts/protocol/transports/base.py +87 -0
  173. package/templates/qoder/scripts/protocol/transports/cli.py +132 -0
  174. package/templates/qoder/scripts/protocol/transports/http.py +141 -0
  175. package/templates/qoder/scripts/protocol/transports/stdio.py +293 -0
  176. package/templates/qoder/scripts/run_weekly_update.bat +27 -18
  177. package/templates/qoder/scripts/run_weekly_update.sh +22 -13
  178. package/templates/qoder/scripts/validation/__init__.py +0 -0
  179. package/templates/qoder/scripts/validation/eval/__init__.py +0 -0
  180. package/templates/qoder/scripts/validation/eval/perf-report-2026-06-24.md +139 -0
  181. package/templates/qoder/scripts/validation/eval/qwork_harness.py +617 -0
  182. package/templates/qoder/scripts/validation/eval/report-commands.md +220 -0
  183. package/templates/qoder/scripts/validation/eval/transcript_timing.py +386 -0
  184. package/templates/qoder/scripts/validation/metrics/__init__.py +0 -0
  185. package/templates/qoder/scripts/{report → validation/metrics}/eval_prd.py +17 -10
  186. package/templates/qoder/scripts/validation/metrics/usability_score.py +750 -0
  187. package/templates/qoder/scripts/validation/test/__init__.py +11 -0
  188. package/templates/qoder/scripts/validation/test/assertion_gen.py +551 -0
  189. package/templates/qoder/scripts/{test → validation/test}/autotest.py +64 -4
  190. package/templates/qoder/scripts/{common → validation/test}/autotest_auth.py +1 -1
  191. package/templates/qoder/scripts/{test → validation/test}/autotest_batch.py +16 -3
  192. package/templates/qoder/scripts/{test → validation/test}/autotest_data.py +17 -12
  193. package/templates/qoder/scripts/validation/test/autotest_opencli.py +646 -0
  194. package/templates/qoder/scripts/{test → validation/test}/autotest_run.py +17 -3
  195. package/templates/qoder/scripts/validation/test/autotest_webaccess.py +571 -0
  196. package/templates/qoder/scripts/{test → validation/test}/benchmark.py +12 -6
  197. package/templates/qoder/scripts/{test → validation/test}/kg_auto_login.py +8 -2
  198. package/templates/qoder/scripts/{test → validation/test}/kg_test_runner.py +7 -2
  199. package/templates/qoder/scripts/{test → validation/test}/page_probe.py +8 -3
  200. package/templates/qoder/scripts/validation/test/smoke_all_commands.py +170 -0
  201. package/templates/qoder/settings.json +30 -0
  202. package/templates/qoder/skills/design-import/SKILL.md +22 -13
  203. package/templates/qoder/skills/design-review/SKILL.md +19 -10
  204. package/templates/qoder/skills/prd-generator/SKILL.md +22 -18
  205. package/templates/qoder/skills/prd-review/SKILL.md +6 -4
  206. package/templates/qoder/skills/prototype-generator/SKILL.md +63 -14
  207. package/templates/qoder/skills/spec-coder/SKILL.md +22 -13
  208. package/templates/qoder/skills/spec-generator/SKILL.md +22 -10
  209. package/templates/qoder/skills/test-generator/SKILL.md +7 -5
  210. package/templates/qoder/skills/wl-code/SKILL.md +23 -11
  211. package/templates/qoder/skills/wl-commit/SKILL.md +9 -8
  212. package/templates/qoder/skills/wl-design/SKILL.md +5 -3
  213. package/templates/qoder/skills/wl-init/SKILL.md +6 -6
  214. package/templates/qoder/skills/wl-insight/SKILL.md +23 -10
  215. package/templates/qoder/skills/wl-prd-full/SKILL.md +57 -6
  216. package/templates/qoder/skills/wl-prd-quick/SKILL.md +3 -3
  217. package/templates/qoder/skills/wl-prd-review/SKILL.md +16 -4
  218. package/templates/qoder/skills/wl-report/SKILL.md +25 -23
  219. package/templates/qoder/skills/wl-search/SKILL.md +149 -78
  220. package/templates/qoder/skills/wl-spec/SKILL.md +7 -5
  221. package/templates/qoder/skills/wl-status/SKILL.md +48 -16
  222. package/templates/qoder/skills/wl-task/SKILL.md +92 -10
  223. package/templates/qoder/skills/wl-test/SKILL.md +75 -29
  224. package/templates/qoder/templates/prd-full-template.md +7 -0
  225. package/templates/root/AGENTS.md +275 -236
  226. package/templates/root/requirements.txt +3 -0
  227. package/templates/root//344/275/277/347/224/250/350/257/264/346/230/216.md +3 -3
  228. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +2 -2
  229. package/templates/.qoder/.runtime/ctx-cache-5660152f1d6dd819.md +0 -23
  230. package/templates/.qoder/.runtime/ctx-cache-afdce0dac06b25b0.md +0 -23
  231. package/templates/.qoder/.runtime/search-cache-eae7644e7b122f35.txt +0 -1
  232. package/templates/.qoder/learning/eval-history.jsonl +0 -28
  233. package/templates/data/index/wiki-index.json +0 -8
  234. package/templates/qoder/nul +0 -4
  235. package/templates/qoder/scripts/kg/extract.py +0 -398
  236. package/templates/qoder/scripts/kg/kg_semantic.py +0 -156
  237. package/templates/qoder/scripts/mcp/kg_mcp_server.py +0 -606
  238. package/templates/qoder/scripts/mcp/mcp_launcher.py +0 -442
  239. package/templates/qoder/scripts/mcp/zentao_mcp_server.py +0 -384
  240. package/templates/qoder/scripts/task/__init__.py +0 -11
  241. package/templates/qoder/scripts/test/__init__.py +0 -11
  242. package/templates/qoder/scripts/wlkj.py +0 -116
  243. /package/templates/qoder/scripts/{common → deployment}/__init__.py +0 -0
  244. /package/templates/qoder/scripts/{common → domain/kg/graph}/graph_traverse.py +0 -0
  245. /package/templates/qoder/scripts/{common → domain/kg/server}/repowiki.py +0 -0
  246. /package/templates/qoder/scripts/{common → foundation/io}/atomicio.py +0 -0
  247. /package/templates/qoder/scripts/{test → validation/test}/secure-ls.js +0 -0
@@ -0,0 +1,1920 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ # v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
4
+ import os as _o, sys as _s
5
+ _f = _o.path.abspath(__file__)
6
+ for _ in range(10):
7
+ _f = _o.path.dirname(_f)
8
+ _cp = _o.path.join(_f, 'foundation')
9
+ if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
10
+ break
11
+ if _cp not in _s.path: _s.path.insert(0, _cp)
12
+ from bootstrap import setup; setup()
13
+
14
+ """ZenTao (禅道) MCP Server - 对接禅道项目管理, 替代本地 task。
15
+
16
+ 禅道自建内网开源版 REST API (v1, 开源版 16.5+)。
17
+ 禅道为唯一事实源; /wl-task 的底层走禅道, 本地 task.json 仅作离线缓存。
18
+
19
+ 工具按禅道资源分组 (产品/计划/版本/需求/项目/执行/任务/Bug/用户):
20
+ - 产品: list_products, get_product, create_product, update_product, delete_product
21
+ - 计划: list_plans, get_plan, create_plan, link_story_to_plan, unlink_story_from_plan, ...
22
+ - 版本: list_builds, get_build, create_build, link_story_to_build, link_bug_to_build
23
+ - 需求: list_stories, get_story_detail, create_story, update_story, assign_story, ...
24
+ - 任务: list_tasks, get_task, create_task, start/pause/restart/finish/close_task, log_effort
25
+ - Bug: list_bugs, get_bug, create_bug, confirm/resolve/activate/close_bug, ...
26
+ - 用户: list_users, get_user
27
+ - 辅助: check_zentao_status, get_zentao_url
28
+
29
+ v3.1: 迁移到 @tool 装饰器模式 (元类自动收集 TOOLS + 自动分发), 消除 if-elif 链。
30
+
31
+ 认证: 用户名密码 → token (config.yaml zentao 段配, 团队共享; 真值不进源码)
32
+ """
33
+ import os
34
+ import sys
35
+ import json
36
+
37
+ try:
38
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
39
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
40
+ except Exception:
41
+ pass
42
+
43
+ # 配置 (真值只在 config.yaml, 由 mcp_launcher 注入 env; 此处不留明文 fallback)
44
+ # 对齐 mysql_mcp_server 风格: 未配置就报清晰错误, 不硬编码凭据(避免随 npm 包泄露)
45
+ # ZENTAO_USER/PASSWORD 由 ZentaoClient.from_env() 直接从 env 读取, 不留模块级变量。
46
+ ZENTAO_URL = os.environ.get('ZENTAO_URL', '') # 部分 tool (get_zentao_url) 直接引用
47
+ # 当前开发者的禅道账号 (mcp_launcher 从 member.json 注入)。禅道是共享账号,
48
+ # 靠此区分"谁在操作" → list 默认只看我的, 写操作可校验归属。未注入则为空
49
+ # (兼容独立运行/旧环境), 此时 list 不过滤, 退回旧行为。
50
+ MY_ACCOUNT = os.environ.get('ZENTAO_MY_ACCOUNT', '')
51
+
52
+ # ★ 自修复: launcher 没注入 ZENTAO_MY_ACCOUNT 时, server 自己从 developer 身份读。
53
+ # 原因: 旧版 launcher / 缓存的 launcher 进程可能不注入此变量 → 归属过滤全失效
54
+ # (list 把全队任务都拉回来, 标记 [我]/[队友] 也错乱)。本段让 server 自给自足。
55
+ if not MY_ACCOUNT:
56
+ try:
57
+ from foundation.core.paths import get_developer
58
+ from foundation.identity.identity import get_member
59
+ _dev = get_developer()
60
+ if _dev:
61
+ _mem = get_member(_dev)
62
+ if _mem:
63
+ _acc = (_mem.get('zentao_account') or '').strip()
64
+ if _acc:
65
+ MY_ACCOUNT = _acc
66
+ os.environ['ZENTAO_MY_ACCOUNT'] = _acc
67
+ except Exception:
68
+ pass # 读不到就保持空, 退回旧行为(不过滤)
69
+
70
+ # ── 禅道客户端 (HTTP 逻辑收敛在 common.zentao_client.ZentaoClient,与 zentao_sync 共享同一份) ──
71
+ # ZENTAO_URL / MY_ACCOUNT 保留为模块级常量:部分 tool 直接引用 (get_zentao_url / 归属过滤)。
72
+ # PING_HOST/PING_PORT 不再需要(client 内部解析)。
73
+ # stdout 重定向 + 协议层由 BaseMCPServer 接管 (v3.0 重构)。
74
+ from foundation.integrations.zentao_client import ZentaoClient
75
+
76
+ CLIENT = ZentaoClient.from_env()
77
+
78
+
79
+ def check_intranet():
80
+ """内网连通性 (委托 ZentaoClient)。CLIENT 未配置时直接判不通。"""
81
+ if CLIENT is None:
82
+ return False, '禅道凭据未配置,无法探测连通性。'
83
+ return CLIENT.check_intranet()
84
+
85
+
86
+ def _check_credentials():
87
+ """凭据完整性 (委托 ZentaoClient)。"""
88
+ if CLIENT is None:
89
+ return False, ('禅道凭据未注入 (ZENTAO_URL/USER/PASSWORD)。'
90
+ '请检查 mcp_launcher / .qoder/config.yaml 的 zentao 段,重启 MCP 生效。')
91
+ ok, msg = CLIENT.credentials_ok()
92
+ if not ok:
93
+ return False, msg + ' 重启 MCP 生效。'
94
+ return True, ''
95
+
96
+
97
+ def _api(method, path, data=None):
98
+ """调禅道 API (委托 ZentaoClient,含 401 自动重登)。_gate 已挡 CLIENT=None。"""
99
+ return CLIENT.api(method, path, data)
100
+
101
+
102
+ def _upload_file(file_path, title='', extra_fields=None):
103
+ """上传文件到禅道 (multipart)。_gate 已挡 CLIENT=None。"""
104
+ return CLIENT.upload_file(file_path, title=title, extra_fields=extra_fields)
105
+
106
+
107
+ def _err_text(r):
108
+ """从 response 提取错误文本 (委托 ZentaoClient)。"""
109
+ return ZentaoClient.err_text(r)
110
+
111
+
112
+ # ── MCP Server (继承 BaseMCPServer, @tool 装饰器自动分发) ──────────────
113
+
114
+ from foundation.protocol.mcp_base import BaseMCPServer, tool
115
+
116
+
117
+ def _assignee_account(item):
118
+ """从任务/Bug 提取指派人账号 (兼容禅道两种返回格式)。
119
+
120
+ 禅道 API 的 assignedTo 可能是:
121
+ - dict: {'id':14,'account':'zhengkang','realname':'郑康'} (列表接口常见)
122
+ - str: 'zhengkang' (详情接口常见)
123
+ - None/其它: 返回空串
124
+ 旧代码用 str(dict)==account 比较, 永远匹配不上 → 过滤全失效。本函数统一取 account。
125
+ """
126
+ a = item.get('assignedTo') if isinstance(item, dict) else None
127
+ if isinstance(a, dict):
128
+ return str(a.get('account', ''))
129
+ if isinstance(a, str):
130
+ return a
131
+ return ''
132
+
133
+
134
+ def _openedby_account(item):
135
+ """从任务/Bug 提取创建人账号 (openedBy 字段, 同 _assignee_account 的兼容逻辑)。
136
+
137
+ 用途: Bug 的归属判定 —— 团队习惯提了 bug 不指派 (assignedTo=None), bug 留在创建人头上。
138
+ "我的 bug" 应包含 openedBy==我 (我提的), 不能只认 assignedTo。
139
+ openedBy 在禅道里通常是字符串 (账号), 兼容 dict 格式。
140
+ """
141
+ o = item.get('openedBy') if isinstance(item, dict) else None
142
+ if isinstance(o, dict):
143
+ return str(o.get('account', ''))
144
+ if isinstance(o, str):
145
+ return o
146
+ return ''
147
+
148
+
149
+ class ZentaoMCPServer(BaseMCPServer):
150
+ """禅道 MCP Server。
151
+
152
+ v3.1: 每个工具用 @tool 装饰, 元类自动收集 TOOLS + 自动 handle_tool 分发。
153
+ 新增工具 = 加一个 @tool 方法, 无需改 TOOLS 列表或 if 链。
154
+
155
+ 健壮性三道门 (复用现有模式, 推广到全部工具):
156
+ ① 内网不通 → check_intranet 友好提示, 不崩
157
+ ② 凭据未配 → _check_credentials 友好提示
158
+ ③ 写操作失败 → 返回 ⚠️ 不抛异常 (权限不足/字段缺失不影响其它操作)
159
+ """
160
+
161
+ SERVER_NAME = "qoder-zentao"
162
+ SERVER_VERSION = "1.1.0"
163
+
164
+ # ── 复用辅助方法 (消除工具样板重复, 仿 mysql _execute_query) ───────
165
+
166
+ def _gate(self):
167
+ """三道门: 内网 + 凭据。不通返回提示字符串, 通返回 None。"""
168
+ ok, msg = check_intranet()
169
+ if not ok:
170
+ return msg
171
+ ok, msg = _check_credentials()
172
+ if not ok:
173
+ return msg
174
+ return None
175
+
176
+ def _require_my_account(self):
177
+ """归属铁门: "看我的"类工具必须先过此门 (v3.1.5 起从静默降级改为直接拒绝)。
178
+
179
+ 历史: 旧版 MY_ACCOUNT 为空时 list 工具静默返回全队 + 全标[队友], 用户无法察觉
180
+ 归属失效 → 看错数据(用户实测踩坑: 本以为看的是自己的, 实际是全队的)。
181
+ 现改为: 读不到我的禅道账号 → 直接拒绝并给修复指引, 不静默降级。
182
+
183
+ 返回 None=放行, 字符串=拒绝原因(直接 return 给用户)。
184
+ """
185
+ gate = self._gate()
186
+ if gate:
187
+ return gate
188
+ if not MY_ACCOUNT:
189
+ return ('🚫 未识别禅道身份: member.json 缺 zentao_account 字段, 无法区分"我的/队友的"。\n'
190
+ ' 修复: 跑 /wl-init 配置当前开发者的禅道账号, 或编辑\n'
191
+ ' workspace/members/<我>/member.json 加 "zentao_account": "<禅道账号>"\n'
192
+ ' (账号可在禅道→用户列表查到, 如 hupan) 后重启 MCP。')
193
+ return None
194
+
195
+ def _assert_owner(self, kind, item_id):
196
+ """归属校验 (写操作前调用): 查 item 详情, 校验归属。
197
+
198
+ 防止误改队友任务/Bug。返回 None=放行, 字符串=拒绝原因。
199
+ - 未配置 MY_ACCOUNT (兼容旧环境): 不校验, 返回 None (放行, 退回旧行为)。
200
+ - 查询失败: 放行 (不因网络抖动阻塞用户, 禅道侧权限仍兜底)。
201
+ - 归属语义 (v3.1.6): task 只认 assignedTo==我; bug/story 认 assignedTo==我 OR openedBy==我
202
+ (原因: bug/story 创建后 assignedTo 常为空, 用户提的自己能 resolve/close 是合理的;
203
+ task 则不放宽 —— task 的 openedBy 是组长拆的, 归属以 assignedTo 为准)。
204
+ - 归属不匹配: 返回拒绝提示 (含归属人, 提示用户确认)。
205
+
206
+ Args:
207
+ kind: 'task' / 'bug' (决定查 /tasks/N 或 /bugs/N; 也决定是否放宽 openedBy)
208
+ item_id: 任务/Bug ID
209
+ """
210
+ if not MY_ACCOUNT:
211
+ return None # 未配置归属 → 不强制 (兼容)
212
+ r = _api('GET', '/%ss/%s' % (kind, item_id))
213
+ if r.status_code not in (200, 201):
214
+ return None # 查询失败不阻塞 (禅道权限兜底)
215
+ data = r.json()
216
+ asg = _assignee_account(data)
217
+ # task: 只认 assignedTo; bug/story: 认 assignedTo OR openedBy
218
+ allow = (asg == MY_ACCOUNT)
219
+ if kind in ('bug', 'story') and not allow:
220
+ allow = (_openedby_account(data) == MY_ACCOUNT)
221
+ if not allow:
222
+ owner = asg or '空'
223
+ return ('🚫 拒绝: 这条%s指派给 %s, 不是你(%s)。共享账号下不静默改队友的。'
224
+ '\n 确实要改? 请用户明确确认, 或先 assign 给你再操作。'
225
+ % (kind, owner, MY_ACCOUNT))
226
+ return None
227
+
228
+ # ── 聚合辅助: "我的"全景查询 (跨执行/产品全量扫描) ──────────────────
229
+ # 背景: 禅道无 "assignedTo=我的全部任务" 单接口, 只能逐执行/产品拉。
230
+ # 旧工作流只查最近几个执行 → 漏掉早期任务 (用户实测: 61 执行只查 12 个,
231
+ # 漏 9 个未开始任务)。这些辅助方法封装全量扫描, 供 my_workbench 等工具复用。
232
+
233
+ def _fetch_all_executions(self, limit=200):
234
+ """全量拉执行列表 (默认 200, 覆盖团队历史执行量)。返回 [(id,name,status),...] 或 None。"""
235
+ r = _api('GET', '/executions', {'limit': limit})
236
+ if r.status_code not in (200, 201):
237
+ return None
238
+ data = r.json()
239
+ execs = data.get('executions') if isinstance(data, dict) else None
240
+ if not isinstance(execs, list):
241
+ return None
242
+ return [(e.get('id'), str(e.get('name', ''))[:20], e.get('status', '')) for e in execs]
243
+
244
+ def _collect_my_tasks(self, status_filter=None):
245
+ """全量扫所有执行, 收集指派给我(MY_ACCOUNT)的任务。
246
+
247
+ Args:
248
+ status_filter: 可选, 只保留该状态 (如 'wait'/'doing'); None=全部非终态。
249
+ Returns:
250
+ (任务列表, 执行映射) 或 (None, 错误提示)。任务为统一 dict:
251
+ {id,name,status,pri,execution,execution_name,estimate,left,deadline,story}
252
+ """
253
+ execs = self._fetch_all_executions()
254
+ if execs is None:
255
+ return None, '查询执行列表失败, 稍后重试。'
256
+ emap = {eid: ename for eid, ename, _ in execs}
257
+ mine = []
258
+ for eid, ename, _ in execs:
259
+ r = _api('GET', '/executions/%s/tasks' % eid, {'limit': 200})
260
+ if r.status_code not in (200, 201):
261
+ continue
262
+ data = r.json()
263
+ tasks = data.get('tasks') if isinstance(data, dict) else None
264
+ if not isinstance(tasks, list):
265
+ continue
266
+ for t in tasks:
267
+ if not isinstance(t, dict):
268
+ continue
269
+ if _assignee_account(t) != MY_ACCOUNT:
270
+ continue
271
+ if status_filter and t.get('status') != status_filter:
272
+ continue
273
+ t2 = dict(t)
274
+ t2['execution'] = eid
275
+ t2['execution_name'] = ename
276
+ mine.append(t2)
277
+ return mine, None
278
+
279
+ def _collect_my_bugs(self):
280
+ """扫活跃产品, 收集"我的"Bug。
281
+
282
+ 归属语义 (v3.1.6 修复): 认 assignedTo==我 **或** openedBy==我。
283
+ 原因: 实测团队习惯——提了 bug 不指派 (assignedTo=None), bug 留在创建人头上。
284
+ 只认 assignedTo 会让用户提的 bug 永远查不到 (实测: hupan 提了 2 个 bug 全部
285
+ assignedTo=None → 旧逻辑 Bug=0, 用户"明明有 BUG"却查不到)。
286
+ 返回 (bug列表, None) 或 (None, 错误)。bug dict 加 _why 字段标记归属来源。
287
+ """
288
+ r = _api('GET', '/products', None)
289
+ if r.status_code not in (200, 201):
290
+ return None, '查询产品列表失败。'
291
+ data = r.json()
292
+ prods = data.get('products') if isinstance(data, dict) else None
293
+ if not isinstance(prods, list):
294
+ return None, '产品列表响应异常。'
295
+ mine = []
296
+ for p in prods:
297
+ pid = p.get('id')
298
+ r = _api('GET', '/products/%s/bugs' % pid, {'limit': 500})
299
+ if r.status_code not in (200, 201):
300
+ continue
301
+ data = r.json()
302
+ bugs = data.get('bugs') if isinstance(data, dict) else None
303
+ if not isinstance(bugs, list):
304
+ continue
305
+ for b in bugs:
306
+ if not isinstance(b, dict):
307
+ continue
308
+ asg = _assignee_account(b)
309
+ opn = _openedby_account(b)
310
+ why = None
311
+ if asg == MY_ACCOUNT:
312
+ why = '指派给我'
313
+ elif opn == MY_ACCOUNT:
314
+ why = '我提的'
315
+ if why:
316
+ b2 = dict(b)
317
+ b2['product_id'] = pid
318
+ b2['_why'] = why
319
+ mine.append(b2)
320
+ return mine, None
321
+
322
+ def _collect_my_stories(self):
323
+ """扫活跃产品, 收集"我的"需求 (指派给我 OR 我提的, 同 Bug 语义)。
324
+
325
+ 归属语义 (v3.1.6): 认 assignedTo==我 OR openedBy==我。需求创建后 assignedTo 常为空,
326
+ 只认 assignedTo 会让用户提的需求查不到 (同 Bug 的踩坑)。
327
+ 返回 (story列表, None) 或 (None, 错误)。story dict 加 _why 字段标记来源。
328
+ """
329
+ r = _api('GET', '/products', None)
330
+ if r.status_code not in (200, 201):
331
+ return None, '查询产品列表失败。'
332
+ data = r.json()
333
+ prods = data.get('products') if isinstance(data, dict) else None
334
+ if not isinstance(prods, list):
335
+ return None, '产品列表响应异常。'
336
+ mine = []
337
+ for p in prods:
338
+ pid = p.get('id')
339
+ r = _api('GET', '/products/%s/stories' % pid, {'limit': 500})
340
+ # ★ 禅道列表接口可能返回 201 (Created), 不能只认 200
341
+ if r.status_code not in (200, 201):
342
+ continue
343
+ data = r.json()
344
+ sts = data.get('stories') if isinstance(data, dict) else None
345
+ if not isinstance(sts, list):
346
+ continue
347
+ for s in sts:
348
+ if not isinstance(s, dict):
349
+ continue
350
+ asg = _assignee_account(s)
351
+ opn = _openedby_account(s)
352
+ why = '指派给我' if asg == MY_ACCOUNT else ('我提的' if opn == MY_ACCOUNT else None)
353
+ if why:
354
+ s2 = dict(s)
355
+ s2['product_id'] = pid
356
+ s2['_why'] = why
357
+ mine.append(s2)
358
+ return mine, None
359
+
360
+ def _safe_get(self, path, params, title, formatter, empty_msg=None):
361
+ """列表/详情类读取的统一封装。
362
+
363
+ Args:
364
+ path: API 路径 (如 '/products')
365
+ params: GET 参数 dict
366
+ title: 结果标题 (如 '禅道产品 (3 个):')
367
+ formatter: 函数 (item) -> 一行字符串
368
+ empty_msg: 列表为空时的提示 (None 则用 title)
369
+ """
370
+ gate = self._gate()
371
+ if gate:
372
+ return gate
373
+ r = _api('GET', path, params)
374
+ if r.status_code not in (200, 201):
375
+ return '查询失败: %s' % _err_text(r)
376
+ data = r.json()
377
+ # 响应可能是 {key: [...]} 或直接是 dict(详情)
378
+ items = None
379
+ key = None
380
+ for k in ('products', 'stories', 'plans', 'executions', 'tasks',
381
+ 'bugs', 'builds', 'releases', 'projects', 'users'):
382
+ if isinstance(data.get(k), list):
383
+ items = data[k]
384
+ key = k
385
+ break
386
+ if items is None:
387
+ # 单对象详情, 原样交给 formatter
388
+ return formatter(data)
389
+ if not items:
390
+ return empty_msg or title
391
+ lines = [title % len(items) if '%' in title else title]
392
+ for it in items:
393
+ lines.append(formatter(it))
394
+ return '\n'.join(lines)
395
+
396
+ def _safe_write(self, method, path, payload, ok_msg, fail_hint, ok_codes=(200, 201)):
397
+ """写操作 (POST/PUT/DELETE) 的统一封装。
398
+
399
+ 成功返回 ✅ ok_msg; 失败返回 ⚠️ 不抛异常 (权限不足/字段缺失不影响其它操作)。
400
+ """
401
+ gate = self._gate()
402
+ if gate:
403
+ return gate
404
+ r = _api(method, path, payload)
405
+ if r.status_code in ok_codes:
406
+ try:
407
+ d = r.json()
408
+ sid = d.get('id', '')
409
+ return ok_msg(d) if callable(ok_msg) else ok_msg
410
+ except Exception:
411
+ return ok_msg if isinstance(ok_msg, str) else ok_msg(d) if callable(ok_msg) else '✅ 操作成功'
412
+ return '⚠️ %s\n(%s)' % (_err_text(r), fail_hint)
413
+
414
+ # ── 通用辅助 (2) ──────────────────────────────────────────────────
415
+
416
+ @tool("check_zentao_status",
417
+ "禅道健康检查: 探测内网连通 + 凭据是否配好。给交互壳子/启动诊断用。例: check_zentao_status()",
418
+ {"type": "object", "properties": {}})
419
+ def _t_check_status(self, args):
420
+ ok, msg = check_intranet()
421
+ if not ok:
422
+ return '❌ ' + msg
423
+ ok2, msg2 = _check_credentials()
424
+ if not ok2:
425
+ return '❌ ' + msg2
426
+ return '✅ 禅道可达, 凭据已配置 (%s)' % ZENTAO_URL
427
+
428
+ @tool("get_zentao_url",
429
+ "返回禅道前端地址(给'在禅道打开'链接用)。例: get_zentao_url()",
430
+ {"type": "object", "properties": {}})
431
+ def _t_get_url(self, args):
432
+ if not ZENTAO_URL:
433
+ return '禅道地址未配置。'
434
+ return ZENTAO_URL
435
+
436
+ # ── 产品 Product (5) ──────────────────────────────────────────────
437
+
438
+ @tool("list_products",
439
+ "列禅道产品(找到需求归属)。例: list_products()",
440
+ {"type": "object", "properties": {}})
441
+ def _t_list_products(self, args):
442
+ return self._safe_get(
443
+ '/products', None,
444
+ '禅道产品 (%d 个):',
445
+ lambda p: ' [%s] %s' % (p.get('id'), str(p.get('name', ''))[:20]),
446
+ empty_msg='禅道暂无产品。',
447
+ )
448
+
449
+ @tool("get_product",
450
+ "查产品详情。例: get_product(product_id=1)",
451
+ {"type": "object", "properties": {
452
+ "product_id": {"type": "integer", "description": "产品ID"},
453
+ }, "required": ["product_id"]})
454
+ def _t_get_product(self, args):
455
+ pid = args.get('product_id', 0)
456
+ gate = self._gate()
457
+ if gate:
458
+ return gate
459
+ r = _api('GET', '/products/%s' % pid)
460
+ if r.status_code not in (200, 201):
461
+ return '查询失败: %s' % _err_text(r)
462
+ p = r.json()
463
+ return '产品 #%s: %s\n状态: %s\n类型: %s' % (
464
+ pid, p.get('name', ''), p.get('status', ''), p.get('type', ''))
465
+
466
+ @tool("create_product",
467
+ "创建产品。例: create_product(name='车险管理', code='cxgl', type='normal')",
468
+ {"type": "object", "properties": {
469
+ "name": {"type": "string", "description": "产品名称"},
470
+ "code": {"type": "string", "description": "产品代号"},
471
+ "type": {"type": "string", "description": "类型: normal(正常)/branch(多分支)/platform(平台), 默认normal"},
472
+ }, "required": ["name", "code"]})
473
+ def _t_create_product(self, args):
474
+ name = args.get('name', '')
475
+ code = args.get('code', '')
476
+ if not name or not code:
477
+ return '⚠️ 缺少必填字段: name 和 code 不能为空。'
478
+ payload = {'name': name, 'code': code, 'type': args.get('type', 'normal')}
479
+ return self._safe_write(
480
+ 'POST', '/products', payload,
481
+ lambda d: '✅ 产品已创建! ID=%s' % d.get('id', '?'),
482
+ '可能权限不足或字段缺失')
483
+
484
+ @tool("update_product",
485
+ "修改产品(名称等)。权限不足返回提示不报错。例: update_product(product_id=1, name='车险管理(改)')",
486
+ {"type": "object", "properties": {
487
+ "product_id": {"type": "integer", "description": "产品ID"},
488
+ "name": {"type": "string", "description": "新名称(可选)"},
489
+ }, "required": ["product_id"]})
490
+ def _t_update_product(self, args):
491
+ pid = args.get('product_id', 0)
492
+ payload = {}
493
+ if args.get('name'):
494
+ payload['name'] = args['name']
495
+ if not payload:
496
+ return '⚠️ 没有要更新的字段(可选: name)。'
497
+ return self._safe_write(
498
+ 'PUT', '/products/%s' % pid, payload,
499
+ '✅ 产品 #%s 已更新' % pid,
500
+ '可能权限不足或字段缺失')
501
+
502
+ @tool("delete_product",
503
+ "删除产品。权限不足返回提示不报错。例: delete_product(product_id=1)",
504
+ {"type": "object", "properties": {
505
+ "product_id": {"type": "integer", "description": "产品ID"},
506
+ }, "required": ["product_id"]})
507
+ def _t_delete_product(self, args):
508
+ pid = args.get('product_id', 0)
509
+ return self._safe_write(
510
+ 'DELETE', '/products/%s' % pid, None,
511
+ '✅ 产品 #%s 已删除' % pid,
512
+ '可能权限不足或有关联需求/任务无法删除')
513
+
514
+ # ── 计划 Plan (7) ─────────────────────────────────────────────────
515
+
516
+ @tool("list_plans",
517
+ "查某产品的计划/迭代列表(让用户选归属)。例: list_plans(product_id=1)",
518
+ {"type": "object", "properties": {
519
+ "product_id": {"type": "integer", "description": "产品ID"},
520
+ }, "required": ["product_id"]})
521
+ def _t_list_plans(self, args):
522
+ pid = args.get('product_id', 1)
523
+ return self._safe_get(
524
+ '/products/%s/plans' % pid, None,
525
+ '产品 %s 的计划 (%%d 个):' % pid,
526
+ lambda p: ' [%s] %s (%s~%s)' % (
527
+ p.get('id'), str(p.get('title', ''))[:20], p.get('begin', ''), p.get('end', '')),
528
+ empty_msg='产品 %s 暂无计划。可在禅道里创建计划/迭代。' % pid,
529
+ )
530
+
531
+ @tool("get_plan",
532
+ "查计划详情(含关联的需求)。例: get_plan(plan_id=12)",
533
+ {"type": "object", "properties": {
534
+ "plan_id": {"type": "integer", "description": "计划ID"},
535
+ }, "required": ["plan_id"]})
536
+ def _t_get_plan(self, args):
537
+ pid = args.get('plan_id', 0)
538
+ gate = self._gate()
539
+ if gate:
540
+ return gate
541
+ r = _api('GET', '/plans/%s' % pid)
542
+ if r.status_code not in (200, 201):
543
+ return '查询失败: %s' % _err_text(r)
544
+ p = r.json()
545
+ return '计划 #%s: %s\n周期: %s ~ %s\n描述: %s' % (
546
+ pid, p.get('title', ''), p.get('begin', ''), p.get('end', ''),
547
+ str(p.get('desc', ''))[:200])
548
+
549
+ @tool("create_plan",
550
+ "创建计划(产品规划用)。例: create_plan(product_id=1, title='2026Q3车险v2', begin='2026-07-01', end='2026-09-30')",
551
+ {"type": "object", "properties": {
552
+ "product_id": {"type": "integer", "description": "产品ID"},
553
+ "title": {"type": "string", "description": "计划标题"},
554
+ "begin": {"type": "string", "description": "开始日期 YYYY-MM-DD"},
555
+ "end": {"type": "string", "description": "结束日期 YYYY-MM-DD"},
556
+ "desc": {"type": "string", "description": "计划描述(可选)"},
557
+ }, "required": ["product_id", "title", "begin", "end"]})
558
+ def _t_create_plan(self, args):
559
+ pid = args.get('product_id', 1)
560
+ title = args.get('title', '')
561
+ begin = args.get('begin', '')
562
+ end = args.get('end', '')
563
+ if not title or not begin or not end:
564
+ return '⚠️ 缺少必填字段: title/begin/end 都不能为空。'
565
+ payload = {'title': title, 'begin': begin, 'end': end}
566
+ if args.get('desc'):
567
+ payload['desc'] = args['desc']
568
+ return self._safe_write(
569
+ 'POST', '/products/%s/plans' % pid, payload,
570
+ lambda d: '✅ 计划已创建! ID=%s' % d.get('id', '?'),
571
+ '可能权限不足或字段缺失')
572
+
573
+ @tool("link_story_to_plan",
574
+ "把需求关联到计划(产品把需求归入计划)。权限不足返回提示不报错。例: link_story_to_plan(plan_id=12, stories=[761,762])",
575
+ {"type": "object", "properties": {
576
+ "plan_id": {"type": "integer", "description": "计划ID"},
577
+ "stories": {"type": "array", "items": {"type": "integer"}, "description": "需求ID列表"},
578
+ }, "required": ["plan_id", "stories"]})
579
+ def _t_link_story_plan(self, args):
580
+ pid = args.get('plan_id', 0)
581
+ stories = args.get('stories', [])
582
+ if not stories:
583
+ return '⚠️ 缺少必填字段: stories(需求ID列表)不能为空。'
584
+ return self._safe_write(
585
+ 'POST', '/plans/%s/linkStory' % pid, {'stories': stories},
586
+ '✅ %d 个需求已关联到计划 #%s' % (len(stories), pid),
587
+ '可能权限不足, 需在禅道手动关联')
588
+
589
+ @tool("unlink_story_from_plan",
590
+ "从计划移除需求。权限不足返回提示不报错。例: unlink_story_from_plan(plan_id=12, stories=[761])",
591
+ {"type": "object", "properties": {
592
+ "plan_id": {"type": "integer", "description": "计划ID"},
593
+ "stories": {"type": "array", "items": {"type": "integer"}, "description": "需求ID列表"},
594
+ }, "required": ["plan_id", "stories"]})
595
+ def _t_unlink_story_plan(self, args):
596
+ pid = args.get('plan_id', 0)
597
+ stories = args.get('stories', [])
598
+ if not stories:
599
+ return '⚠️ 缺少必填字段: stories(需求ID列表)不能为空。'
600
+ return self._safe_write(
601
+ 'POST', '/plans/%s/unlinkStory' % pid, {'stories': stories},
602
+ '✅ %d 个需求已从计划 #%s 移除' % (len(stories), pid),
603
+ '可能权限不足, 需在禅道手动移除')
604
+
605
+ @tool("link_bug_to_plan",
606
+ "把Bug关联到计划。权限不足返回提示不报错。例: link_bug_to_plan(plan_id=12, bugs=[101])",
607
+ {"type": "object", "properties": {
608
+ "plan_id": {"type": "integer", "description": "计划ID"},
609
+ "bugs": {"type": "array", "items": {"type": "integer"}, "description": "Bug ID列表"},
610
+ }, "required": ["plan_id", "bugs"]})
611
+ def _t_link_bug_plan(self, args):
612
+ pid = args.get('plan_id', 0)
613
+ bugs = args.get('bugs', [])
614
+ if not bugs:
615
+ return '⚠️ 缺少必填字段: bugs(Bug ID列表)不能为空。'
616
+ return self._safe_write(
617
+ 'POST', '/plans/%s/linkBug' % pid, {'bugs': bugs},
618
+ '✅ %d 个Bug已关联到计划 #%s' % (len(bugs), pid),
619
+ '可能权限不足, 需在禅道手动关联')
620
+
621
+ @tool("unlink_bug_from_plan",
622
+ "从计划移除Bug。权限不足返回提示不报错。例: unlink_bug_from_plan(plan_id=12, bugs=[101])",
623
+ {"type": "object", "properties": {
624
+ "plan_id": {"type": "integer", "description": "计划ID"},
625
+ "bugs": {"type": "array", "items": {"type": "integer"}, "description": "Bug ID列表"},
626
+ }, "required": ["plan_id", "bugs"]})
627
+ def _t_unlink_bug_plan(self, args):
628
+ pid = args.get('plan_id', 0)
629
+ bugs = args.get('bugs', [])
630
+ if not bugs:
631
+ return '⚠️ 缺少必填字段: bugs(Bug ID列表)不能为空。'
632
+ return self._safe_write(
633
+ 'POST', '/plans/%s/unlinkBug' % pid, {'bugs': bugs},
634
+ '✅ %d 个Bug已从计划 #%s 移除' % (len(bugs), pid),
635
+ '可能权限不足, 需在禅道手动移除')
636
+
637
+ # ── 发布 Release (2) ──────────────────────────────────────────────
638
+
639
+ @tool("list_releases",
640
+ "查某产品的发布列表(已发布的版本里程碑)。例: list_releases(product_id=1)",
641
+ {"type": "object", "properties": {
642
+ "product_id": {"type": "integer", "description": "产品ID"},
643
+ }, "required": ["product_id"]})
644
+ def _t_list_releases(self, args):
645
+ pid = args.get('product_id', 1)
646
+ return self._safe_get(
647
+ '/products/%s/releases' % pid, None,
648
+ '产品 %s 的发布 (%%d 个):' % pid,
649
+ lambda rel: ' [%s] %s (date=%s build=%s)' % (
650
+ rel.get('id'), str(rel.get('name', ''))[:20],
651
+ rel.get('date', ''), rel.get('build', '')),
652
+ empty_msg='产品 %s 暂无发布。' % pid,
653
+ )
654
+
655
+ @tool("get_release",
656
+ "查发布详情。例: get_release(release_id=5)",
657
+ {"type": "object", "properties": {
658
+ "release_id": {"type": "integer", "description": "发布ID"},
659
+ }, "required": ["release_id"]})
660
+ def _t_get_release(self, args):
661
+ rid = args.get('release_id', 0)
662
+ gate = self._gate()
663
+ if gate:
664
+ return gate
665
+ r = _api('GET', '/releases/%s' % rid)
666
+ if r.status_code not in (200, 201):
667
+ return '查询失败: %s' % _err_text(r)
668
+ d = r.json()
669
+ return '发布 #%s: %s\n日期: %s\nbuild: %s' % (
670
+ rid, d.get('name', ''), d.get('date', ''), d.get('build', ''))
671
+
672
+ # ── 版本 Build (5) ── ★"把需求放进版本" ────────────────────────────
673
+
674
+ @tool("list_builds",
675
+ "查版本/构建列表(项目/产品/执行维度, 三选一)。例: list_builds(project_id=9) 或 list_builds(product_id=1) 或 list_builds(execution_id=73)",
676
+ {"type": "object", "properties": {
677
+ "project_id": {"type": "integer", "description": "项目ID(★最常用, 看项目级按周版本, 三选一优先)"},
678
+ "product_id": {"type": "integer", "description": "产品ID(与project_id/execution_id三选一)"},
679
+ "execution_id": {"type": "integer", "description": "执行ID(与project_id/product_id三选一)"},
680
+ }})
681
+ def _t_list_builds(self, args):
682
+ # 维度优先级: project > execution > product(默认)
683
+ if args.get('project_id'):
684
+ path = '/projects/%s/builds' % args['project_id']
685
+ scope = '项目 %s' % args['project_id']
686
+ elif args.get('execution_id'):
687
+ path = '/executions/%s/builds' % args['execution_id']
688
+ scope = '执行 %s' % args['execution_id']
689
+ else:
690
+ pid = args.get('product_id', 1)
691
+ path = '/products/%s/builds' % pid
692
+ scope = '产品 %s' % pid
693
+ return self._safe_get(
694
+ path, None,
695
+ '%s 的版本 (%%d 个):' % scope,
696
+ lambda b: ' [%s] %s (date=%s)' % (
697
+ b.get('id'), str(b.get('name', ''))[:20], b.get('date', '')),
698
+ empty_msg='%s 暂无版本(build)。' % scope,
699
+ )
700
+
701
+ @tool("get_build",
702
+ "查版本/构建详情。例: get_build(build_id=8)",
703
+ {"type": "object", "properties": {
704
+ "build_id": {"type": "integer", "description": "版本ID"},
705
+ }, "required": ["build_id"]})
706
+ def _t_get_build(self, args):
707
+ bid = args.get('build_id', 0)
708
+ gate = self._gate()
709
+ if gate:
710
+ return gate
711
+ r = _api('GET', '/builds/%s' % bid)
712
+ if r.status_code not in (200, 201):
713
+ return '查询失败: %s' % _err_text(r)
714
+ b = r.json()
715
+ return '版本 #%s: %s\n日期: %s\n执行: %s' % (
716
+ bid, b.get('name', ''), b.get('date', ''), b.get('execution', ''))
717
+
718
+ @tool("create_build",
719
+ "创建版本/构建(发版里程碑)。例: create_build(product_id=1, name='2.5.0', build='build2')",
720
+ {"type": "object", "properties": {
721
+ "product_id": {"type": "integer", "description": "产品ID"},
722
+ "name": {"type": "string", "description": "版本名称"},
723
+ "build": {"type": "string", "description": "构建标识"},
724
+ "execution_id": {"type": "integer", "description": "所属执行ID(可选)"},
725
+ }, "required": ["product_id", "name"]})
726
+ def _t_create_build(self, args):
727
+ pid = args.get('product_id', 1)
728
+ name = args.get('name', '')
729
+ if not name:
730
+ return '⚠️ 缺少必填字段: name(版本名称)不能为空。'
731
+ payload = {'name': name}
732
+ if args.get('build'):
733
+ payload['build'] = args['build']
734
+ if args.get('execution_id'):
735
+ payload['execution'] = args['execution_id']
736
+ return self._safe_write(
737
+ 'POST', '/products/%s/builds' % pid, payload,
738
+ lambda d: '✅ 版本已创建! ID=%s' % d.get('id', '?'),
739
+ '可能权限不足或字段缺失')
740
+
741
+ @tool("link_story_to_build",
742
+ "把需求关联到版本(需求定版本/收进发布)。权限不足返回提示不报错。例: link_story_to_build(build_id=8, stories=[761])",
743
+ {"type": "object", "properties": {
744
+ "build_id": {"type": "integer", "description": "版本ID"},
745
+ "stories": {"type": "array", "items": {"type": "integer"}, "description": "需求ID列表"},
746
+ }, "required": ["build_id", "stories"]})
747
+ def _t_link_story_build(self, args):
748
+ bid = args.get('build_id', 0)
749
+ stories = args.get('stories', [])
750
+ if not stories:
751
+ return '⚠️ 缺少必填字段: stories(需求ID列表)不能为空。'
752
+ return self._safe_write(
753
+ 'POST', '/builds/%s/linkStory' % bid, {'stories': stories},
754
+ '✅ %d 个需求已关联到版本 #%s' % (len(stories), bid),
755
+ '可能权限不足, 需在禅道手动关联')
756
+
757
+ @tool("link_bug_to_build",
758
+ "把Bug关联到版本。权限不足返回提示不报错。例: link_bug_to_build(build_id=8, bugs=[101])",
759
+ {"type": "object", "properties": {
760
+ "build_id": {"type": "integer", "description": "版本ID"},
761
+ "bugs": {"type": "array", "items": {"type": "integer"}, "description": "Bug ID列表"},
762
+ }, "required": ["build_id", "bugs"]})
763
+ def _t_link_bug_build(self, args):
764
+ bid = args.get('build_id', 0)
765
+ bugs = args.get('bugs', [])
766
+ if not bugs:
767
+ return '⚠️ 缺少必填字段: bugs(Bug ID列表)不能为空。'
768
+ return self._safe_write(
769
+ 'POST', '/builds/%s/linkBug' % bid, {'bugs': bugs},
770
+ '✅ %d 个Bug已关联到版本 #%s' % (len(bugs), bid),
771
+ '可能权限不足, 需在禅道手动关联')
772
+
773
+ # ── 项目 Project (3) ──────────────────────────────────────────────
774
+
775
+ @tool("list_projects",
776
+ "查项目列表(执行/任务的父级)。例: list_projects()",
777
+ {"type": "object", "properties": {}})
778
+ def _t_list_projects(self, args):
779
+ return self._safe_get(
780
+ '/projects', None,
781
+ '禅道项目 (%d 个):',
782
+ lambda p: ' [%s] %s (status=%s)' % (
783
+ p.get('id'), str(p.get('name', ''))[:20], p.get('status', '')),
784
+ empty_msg='禅道暂无项目。',
785
+ )
786
+
787
+ @tool("get_project",
788
+ "查项目详情。例: get_project(project_id=11)",
789
+ {"type": "object", "properties": {
790
+ "project_id": {"type": "integer", "description": "项目ID"},
791
+ }, "required": ["project_id"]})
792
+ def _t_get_project(self, args):
793
+ pid = args.get('project_id', 0)
794
+ gate = self._gate()
795
+ if gate:
796
+ return gate
797
+ r = _api('GET', '/projects/%s' % pid)
798
+ if r.status_code not in (200, 201):
799
+ return '查询失败: %s' % _err_text(r)
800
+ p = r.json()
801
+ return '项目 #%s: %s\n状态: %s\n周期: %s ~ %s' % (
802
+ pid, p.get('name', ''), p.get('status', ''),
803
+ p.get('begin', ''), p.get('end', ''))
804
+
805
+ @tool("create_project",
806
+ "创建项目。例: create_project(name='车险v2', code='cxv2', begin='2026-07-01', end='2026-09-30')",
807
+ {"type": "object", "properties": {
808
+ "name": {"type": "string", "description": "项目名称"},
809
+ "code": {"type": "string", "description": "项目代号"},
810
+ "begin": {"type": "string", "description": "开始日期 YYYY-MM-DD"},
811
+ "end": {"type": "string", "description": "结束日期 YYYY-MM-DD"},
812
+ "project_id": {"type": "integer", "description": "所属项目集ID(可选)"},
813
+ }, "required": ["name", "code"]})
814
+ def _t_create_project(self, args):
815
+ name = args.get('name', '')
816
+ code = args.get('code', '')
817
+ if not name or not code:
818
+ return '⚠️ 缺少必填字段: name 和 code 不能为空。'
819
+ payload = {'name': name, 'code': code}
820
+ if args.get('begin'):
821
+ payload['begin'] = args['begin']
822
+ if args.get('end'):
823
+ payload['end'] = args['end']
824
+ return self._safe_write(
825
+ 'POST', '/projects', payload,
826
+ lambda d: '✅ 项目已创建! ID=%s' % d.get('id', '?'),
827
+ '可能权限不足或字段缺失')
828
+
829
+ # ── 执行 Execution (5) ────────────────────────────────────────────
830
+
831
+ @tool("list_executions",
832
+ "查活跃的执行/迭代(按周划分的后端/前端/移动端)。默认全量(100), 查'我的全部任务'靠这个拿全执行别漏。例: list_executions() / list_executions(limit=5)",
833
+ {"type": "object", "properties": {
834
+ "limit": {"type": "integer", "description": "返回数量(默认100, 团队历史执行多, 旧版默认10会漏早期执行里的任务)"},
835
+ }})
836
+ def _t_list_executions(self, args):
837
+ limit = args.get('limit', 100)
838
+ return self._safe_get(
839
+ '/executions', {'limit': limit},
840
+ '活跃执行/迭代 (%d 个):',
841
+ lambda ex: ' [%s] %s (%s~%s project=%s)' % (
842
+ ex.get('id'), str(ex.get('name', ''))[:15],
843
+ ex.get('begin', ''), ex.get('end', ''), ex.get('project', '')),
844
+ )
845
+
846
+ @tool("get_execution",
847
+ "查执行详情。例: get_execution(execution_id=73)",
848
+ {"type": "object", "properties": {
849
+ "execution_id": {"type": "integer", "description": "执行ID"},
850
+ }, "required": ["execution_id"]})
851
+ def _t_get_execution(self, args):
852
+ eid = args.get('execution_id', 0)
853
+ gate = self._gate()
854
+ if gate:
855
+ return gate
856
+ r = _api('GET', '/executions/%s' % eid)
857
+ if r.status_code not in (200, 201):
858
+ return '查询失败: %s' % _err_text(r)
859
+ e = r.json()
860
+ return '执行 #%s: %s\n状态: %s\n周期: %s ~ %s\n项目: %s' % (
861
+ eid, e.get('name', ''), e.get('status', ''),
862
+ e.get('begin', ''), e.get('end', ''), e.get('project', ''))
863
+
864
+ @tool("create_execution",
865
+ "创建执行/迭代。例: create_execution(project_id=11, name='车险v2后端第1周', code='cxv2be1', begin='2026-07-01', end='2026-07-07')",
866
+ {"type": "object", "properties": {
867
+ "project_id": {"type": "integer", "description": "所属项目ID"},
868
+ "name": {"type": "string", "description": "执行名称"},
869
+ "code": {"type": "string", "description": "执行代号"},
870
+ "begin": {"type": "string", "description": "开始日期 YYYY-MM-DD"},
871
+ "end": {"type": "string", "description": "结束日期 YYYY-MM-DD"},
872
+ }, "required": ["project_id", "name", "code"]})
873
+ def _t_create_execution(self, args):
874
+ pid = args.get('project_id', 0)
875
+ name = args.get('name', '')
876
+ code = args.get('code', '')
877
+ if not pid or not name or not code:
878
+ return '⚠️ 缺少必填字段: project_id/name/code 都不能为空。'
879
+ payload = {'project': pid, 'name': name, 'code': code}
880
+ if args.get('begin'):
881
+ payload['begin'] = args['begin']
882
+ if args.get('end'):
883
+ payload['end'] = args['end']
884
+ return self._safe_write(
885
+ 'POST', '/executions', payload,
886
+ lambda d: '✅ 执行已创建! ID=%s' % d.get('id', '?'),
887
+ '可能权限不足或字段缺失')
888
+
889
+ @tool("update_execution",
890
+ "修改执行(改名称/周期等)。权限不足返回提示不报错。例: update_execution(execution_id=73, name='车险v2后端第1周(改)')",
891
+ {"type": "object", "properties": {
892
+ "execution_id": {"type": "integer", "description": "执行ID"},
893
+ "name": {"type": "string", "description": "新名称(可选)"},
894
+ "begin": {"type": "string", "description": "新开始日期(可选)"},
895
+ "end": {"type": "string", "description": "新结束日期(可选)"},
896
+ }, "required": ["execution_id"]})
897
+ def _t_update_execution(self, args):
898
+ eid = args.get('execution_id', 0)
899
+ payload = {}
900
+ for k in ('name', 'begin', 'end'):
901
+ if args.get(k):
902
+ payload[k] = args[k]
903
+ if not payload:
904
+ return '⚠️ 没有要更新的字段(可选: name/begin/end)。'
905
+ return self._safe_write(
906
+ 'PUT', '/executions/%s' % eid, payload,
907
+ '✅ 执行 #%s 已更新' % eid,
908
+ '可能权限不足或字段缺失')
909
+
910
+ @tool("delete_execution",
911
+ "删除执行。权限不足返回提示不报错。例: delete_execution(execution_id=73)",
912
+ {"type": "object", "properties": {
913
+ "execution_id": {"type": "integer", "description": "执行ID"},
914
+ }, "required": ["execution_id"]})
915
+ def _t_delete_execution(self, args):
916
+ eid = args.get('execution_id', 0)
917
+ return self._safe_write(
918
+ 'DELETE', '/executions/%s' % eid, None,
919
+ '✅ 执行 #%s 已删除' % eid,
920
+ '可能权限不足或有任务关联无法删除')
921
+
922
+ @tool("unlink_story_from_execution",
923
+ "从执行移除需求。权限不足返回提示不报错。例: unlink_story_from_execution(execution_id=73, stories=[761])",
924
+ {"type": "object", "properties": {
925
+ "execution_id": {"type": "integer", "description": "执行ID"},
926
+ "stories": {"type": "array", "items": {"type": "integer"}, "description": "需求ID列表"},
927
+ }, "required": ["execution_id", "stories"]})
928
+ def _t_unlink_story_exec(self, args):
929
+ eid = args.get('execution_id', 0)
930
+ stories = args.get('stories', [])
931
+ if not stories:
932
+ return '⚠️ 缺少必填字段: stories(需求ID列表)不能为空。'
933
+ return self._safe_write(
934
+ 'POST', '/executions/%s/unlinkStory' % eid, {'stories': stories},
935
+ '✅ %d 个需求已从执行 #%s 移除' % (len(stories), eid),
936
+ '可能权限不足, 需在禅道手动移除')
937
+
938
+ # ── 需求 Story (8) ────────────────────────────────────────────────
939
+
940
+ @tool("list_stories",
941
+ "查某产品的需求列表。默认只看指派给我的(assignedTo=我), 防看错队友; 要看全队加 all=true。"
942
+ "★查'我的禅道需求'直接用这个, 不要逐个 get_story_detail 枚举。"
943
+ "例: list_stories(product_id=1) / list_stories(product_id=1, all=true)",
944
+ {"type": "object", "properties": {
945
+ "product_id": {"type": "integer", "description": "产品ID"},
946
+ "limit": {"type": "integer", "description": "返回数量(默认50, 过滤后)"},
947
+ "all": {"type": "boolean", "description": "true=看全队(默认false只看我的)"},
948
+ }, "required": ["product_id"]})
949
+ def _t_list_stories(self, args):
950
+ pid = args.get('product_id', 1)
951
+ limit = args.get('limit', 50)
952
+ see_all = bool(args.get('all', False))
953
+ filter_mine = not see_all # 默认只看我的
954
+ # 归属铁门: 看我的就必须有账号 (v3.1.5 起拒绝而非静默降级)
955
+ if filter_mine:
956
+ guard = self._require_my_account()
957
+ if guard:
958
+ return guard
959
+ else:
960
+ gate = self._gate()
961
+ if gate:
962
+ return gate
963
+ resp = _api('GET', '/products/%s/stories' % pid, {'limit': limit})
964
+ if resp.status_code not in (200, 201):
965
+ return '查询失败: %s' % _err_text(resp)
966
+ data = resp.json()
967
+ items = data.get('stories') if isinstance(data, dict) else None
968
+ if items is None:
969
+ # 未知结构 → 回显顶层 key 便于排障 (不再笼统报"异常")
970
+ topkeys = list(data.keys()) if isinstance(data, dict) else type(data).__name__
971
+ return '产品 %s 的需求响应无 stories 字段 (顶层 keys: %s)。' % (pid, topkeys)
972
+ if not items:
973
+ return '产品 %s 暂无需求。' % pid
974
+ if filter_mine:
975
+ total = len(items)
976
+ # v3.1.6: "我的需求" = 指派给我 OR 我提的 (同 Bug 语义, 需求创建后 assignedTo 常为空)
977
+ items = [s for s in items if isinstance(s, dict) and (
978
+ _assignee_account(s) == MY_ACCOUNT or _openedby_account(s) == MY_ACCOUNT)]
979
+ scope = '我的(指派给我或我提的, %s)' % MY_ACCOUNT
980
+ if not items:
981
+ return '产品 %s 没有指派给你或你提的需求。\n(全队 %d 个, 要看全队加 all=true)' % (pid, MY_ACCOUNT, total)
982
+ else:
983
+ scope = '全队' if see_all else '全部'
984
+ lines = ['产品 %s 的需求 [%s, %d 个]:' % (pid, scope, len(items))]
985
+ for s in items:
986
+ if isinstance(s, dict):
987
+ acc = _assignee_account(s)
988
+ opn = _openedby_account(s)
989
+ if filter_mine:
990
+ tag = ''
991
+ else:
992
+ tag = (' [指派给我]' if acc == MY_ACCOUNT else
993
+ (' [我提的]' if opn == MY_ACCOUNT else ' [队友]'))
994
+ acc_show = acc or ('(空,我提的)' if opn == MY_ACCOUNT and not acc else acc)
995
+ lines.append(' [#%s] %s (pri=%s status=%s assignedTo=%s)%s' % (
996
+ s.get('id'), str(s.get('title', ''))[:30], s.get('pri'),
997
+ s.get('status', ''), acc_show, tag))
998
+ else:
999
+ lines.append(' ' + str(s)[:60])
1000
+ return '\n'.join(lines)
1001
+
1002
+ @tool("get_story_detail",
1003
+ "查需求详情(完整描述+状态+关联)。例: get_story_detail(story_id=761)",
1004
+ {"type": "object", "properties": {
1005
+ "story_id": {"type": "integer", "description": "需求ID"},
1006
+ }, "required": ["story_id"]})
1007
+ def _t_get_story_detail(self, args):
1008
+ sid = args.get('story_id', 0)
1009
+ gate = self._gate()
1010
+ if gate:
1011
+ return gate
1012
+ r = _api('GET', '/stories/%s' % sid)
1013
+ if r.status_code not in (200, 201):
1014
+ return '查询失败: %s' % _err_text(r)
1015
+ s = r.json()
1016
+ return '需求 #%s: %s\n状态: %s\n优先级: %s\n描述: %s' % (
1017
+ sid, s.get('title', ''), s.get('status', ''),
1018
+ s.get('pri', ''), str(s.get('spec', ''))[:200])
1019
+
1020
+ @tool("create_story",
1021
+ "在禅道创建需求(PRD发布→禅道需求)。权限不足会返回友好提示不报错。例: create_story(product_id=1, title='品质案件催办', spec='需求详情', pri=3, module=104)",
1022
+ {"type": "object", "properties": {
1023
+ "product_id": {"type": "integer", "description": "产品ID"},
1024
+ "title": {"type": "string", "description": "需求标题"},
1025
+ "spec": {"type": "string", "description": "需求描述"},
1026
+ "pri": {"type": "integer", "description": "优先级 1-4(1最高, 默认3)"},
1027
+ "module": {"type": "integer", "description": "模块ID(可选)"},
1028
+ "category": {"type": "string", "description": "类别(feature/interface/optimization, 默认feature)"},
1029
+ }, "required": ["product_id", "title", "spec"]})
1030
+ def _t_create_story(self, args):
1031
+ pid = args.get('product_id', 1)
1032
+ title = args.get('title', '')
1033
+ spec = args.get('spec', '')
1034
+ if not title or not spec:
1035
+ return '⚠️ 缺少必填字段: title(标题) 和 spec(描述) 都不能为空。'
1036
+ payload = {
1037
+ 'title': title, 'spec': spec,
1038
+ 'category': args.get('category', 'feature'),
1039
+ 'pri': args.get('pri', 3),
1040
+ }
1041
+ if args.get('module'):
1042
+ payload['module'] = args['module']
1043
+
1044
+ def ok_msg(d):
1045
+ sid = d.get('id', '?')
1046
+ return '✅ 需求已创建! ID=%s\n禅道地址: %s/story-view-%s.html' % (
1047
+ sid, ZENTAO_URL.rstrip('/'), sid)
1048
+
1049
+ return self._safe_write(
1050
+ 'POST', '/products/%s/stories' % pid, payload, ok_msg,
1051
+ '可能权限不足或字段缺失, 不影响其它操作')
1052
+
1053
+ @tool("assign_story",
1054
+ "指派需求给某人(设负责人)。权限不足返回提示不报错。例: assign_story(story_id=761, account='hupan')",
1055
+ {"type": "object", "properties": {
1056
+ "story_id": {"type": "integer", "description": "需求ID"},
1057
+ "account": {"type": "string", "description": "禅道账号"},
1058
+ }, "required": ["story_id", "account"]})
1059
+ def _t_assign_story(self, args):
1060
+ sid = args.get('story_id', 0)
1061
+ account = args.get('account', '')
1062
+ return self._safe_write(
1063
+ 'POST', '/stories/%s/assign' % sid, {'assignedTo': account},
1064
+ '✅ 需求 #%s 已指派给 %s' % (sid, account),
1065
+ '可能权限不足, 需在禅道手动指派')
1066
+
1067
+ @tool("change_story_status",
1068
+ "变更需求状态(draft→reviewing→active→closed)。权限不足返回提示不报错。例: change_story_status(story_id=761, status='active')",
1069
+ {"type": "object", "properties": {
1070
+ "story_id": {"type": "integer", "description": "需求ID"},
1071
+ "status": {"type": "string", "description": "目标状态(draft/reviewing/active/closed)"},
1072
+ }, "required": ["story_id", "status"]})
1073
+ def _t_change_story_status(self, args):
1074
+ sid = args.get('story_id', 0)
1075
+ status = args.get('status', '')
1076
+ return self._safe_write(
1077
+ 'POST', '/stories/%s/change' % sid, {'status': status},
1078
+ '✅ 需求 #%s 状态变更为 %s' % (sid, status),
1079
+ '可能权限不足或状态转换不合法')
1080
+
1081
+ @tool("link_story_to_execution",
1082
+ "把需求关联到执行/迭代(让开发能看到)。权限不足返回提示不报错。例: link_story_to_execution(story_id=761, execution_id=73)",
1083
+ {"type": "object", "properties": {
1084
+ "story_id": {"type": "integer", "description": "需求ID"},
1085
+ "execution_id": {"type": "integer", "description": "执行ID"},
1086
+ }, "required": ["story_id", "execution_id"]})
1087
+ def _t_link_story_exec(self, args):
1088
+ sid = args.get('story_id', 0)
1089
+ eid = args.get('execution_id', 0)
1090
+ return self._safe_write(
1091
+ 'POST', '/executions/%s/stories' % eid, {'stories': [sid]},
1092
+ '✅ 需求 #%s 已关联到执行 #%s' % (sid, eid),
1093
+ '可能权限不足, 需在禅道手动关联')
1094
+
1095
+ @tool("upload_file",
1096
+ "上传文件到禅道附件(原型图/PRD等)。注意: 禅道开源版 REST API 的文件上传支持有限, "
1097
+ "HTML/PNG/MD 等常被拒('格式不在范围')。失败时本工具会返回明确提示+降级方案, 不闷头失败。"
1098
+ "例: upload_file(file_path='/abs/prototype-x.png', object_type='story', object_id=812)",
1099
+ {"type": "object", "properties": {
1100
+ "file_path": {"type": "string", "description": "本地文件绝对路径"},
1101
+ "title": {"type": "string", "description": "文件标题(可选, 默认取文件名)"},
1102
+ "object_type": {"type": "string", "description": "关联对象类型: story/task/bug(可选)"},
1103
+ "object_id": {"type": "integer", "description": "关联对象ID(可选, 配合object_type)"},
1104
+ }, "required": ["file_path"]})
1105
+ def _t_upload_file(self, args):
1106
+ import os
1107
+ fpath = args.get('file_path', '')
1108
+ if not fpath:
1109
+ return '⚠️ 缺少必填字段 file_path。'
1110
+ if not os.path.isfile(fpath):
1111
+ return '⚠️ 文件不存在: %s' % fpath
1112
+ gate = self._gate()
1113
+ if gate:
1114
+ return gate
1115
+ extra = None
1116
+ ot = args.get('object_type')
1117
+ oid = args.get('object_id')
1118
+ if ot and oid:
1119
+ extra = {'objectType': ot, 'objectID': str(oid)}
1120
+ r = _upload_file(fpath, title=args.get('title', ''), extra_fields=extra)
1121
+ if r.status_code in (200, 201):
1122
+ d = r.json() if r.text else {}
1123
+ return '✅ 文件已上传! ID=%s (%s)\n禅道附件可在需求/任务详情的"附件"区查看' % (
1124
+ d.get('id', '?'), os.path.basename(fpath))
1125
+ # 上传失败 → 明确降级指引 (禅道开源版 REST 上传常被拒, 不闷头失败)
1126
+ err = _err_text(r)
1127
+ return ('⚠️ 禅道文件上传失败 (%s): %s\n'
1128
+ ' 这通常是禅道开源版 REST API 不支持文件上传 (限制: 格式/端点)。\n'
1129
+ ' ★降级方案(二选一):\n'
1130
+ ' 1. 手动上传: 打开禅道需求 → 附件标签 → 拖入文件 (web 端支持所有格式)\n'
1131
+ ' 2. 嵌入截图: 截取原型图关键界面 → 粘贴到需求描述 (禅道 web 端支持图片粘贴)\n'
1132
+ ' 本地原型文件仍可用浏览器打开预览: %s'
1133
+ % (r.status_code, err, os.path.basename(fpath)))
1134
+
1135
+ @tool("update_story",
1136
+ "修改需求(标题/描述/优先级等)。权限不足返回提示不报错。例: update_story(story_id=761, title='新标题', pri=2)",
1137
+ {"type": "object", "properties": {
1138
+ "story_id": {"type": "integer", "description": "需求ID"},
1139
+ "title": {"type": "string", "description": "新标题(可选)"},
1140
+ "spec": {"type": "string", "description": "新描述(可选)"},
1141
+ "pri": {"type": "integer", "description": "新优先级 1-4(可选)"},
1142
+ }, "required": ["story_id"]})
1143
+ def _t_update_story(self, args):
1144
+ sid = args.get('story_id', 0)
1145
+ payload = {}
1146
+ for k in ('title', 'spec', 'pri'):
1147
+ if args.get(k) is not None:
1148
+ payload[k] = args[k]
1149
+ if not payload:
1150
+ return '⚠️ 没有要更新的字段(可选: title/spec/pri)。'
1151
+ return self._safe_write(
1152
+ 'PUT', '/stories/%s' % sid, payload,
1153
+ '✅ 需求 #%s 已更新' % sid,
1154
+ '可能权限不足或字段缺失')
1155
+
1156
+ @tool("delete_story",
1157
+ "删除需求。权限不足返回提示不报错。例: delete_story(story_id=761)",
1158
+ {"type": "object", "properties": {
1159
+ "story_id": {"type": "integer", "description": "需求ID"},
1160
+ }, "required": ["story_id"]})
1161
+ def _t_delete_story(self, args):
1162
+ sid = args.get('story_id', 0)
1163
+ return self._safe_write(
1164
+ 'DELETE', '/stories/%s' % sid, None,
1165
+ '✅ 需求 #%s 已删除' % sid,
1166
+ '可能权限不足或有关联任务无法删除')
1167
+
1168
+ # ── 任务 Task (10) ── ★"组长拆任务" ───────────────────────────────
1169
+
1170
+ @tool("list_execution_tasks",
1171
+ "查某执行的任务列表。默认只看指派给我的(assignedTo=我), 防看错队友; "
1172
+ "要看全队加 all=true。★查'我的全部任务(跨执行)'请用 list_my_tasks 或 my_workbench, 别逐执行查会漏。"
1173
+ "例: list_execution_tasks(execution_id=73) / list_execution_tasks(execution_id=73, all=true)",
1174
+ {"type": "object", "properties": {
1175
+ "execution_id": {"type": "integer", "description": "执行ID"},
1176
+ "limit": {"type": "integer", "description": "返回数量"},
1177
+ "all": {"type": "boolean", "description": "true=看全队(默认false只看我的)"},
1178
+ }, "required": ["execution_id"]})
1179
+ def _t_list_exec_tasks(self, args):
1180
+ eid = args.get('execution_id', 1)
1181
+ limit = args.get('limit', 20)
1182
+ see_all = bool(args.get('all', False))
1183
+ filter_mine = not see_all # 默认只看我的
1184
+ # 归属铁门 (v3.1.5 起拒绝而非静默降级, 堵"看错队友"且无察觉)
1185
+ if filter_mine:
1186
+ guard = self._require_my_account()
1187
+ if guard:
1188
+ return guard
1189
+ else:
1190
+ r = self._gate()
1191
+ if r:
1192
+ return r
1193
+ resp = _api('GET', '/executions/%s/tasks' % eid, {'limit': limit})
1194
+ if resp.status_code not in (200, 201):
1195
+ return '查询失败: %s' % _err_text(resp)
1196
+ data = resp.json()
1197
+ items = data.get('tasks') if isinstance(data, dict) else None
1198
+ if items is None:
1199
+ topkeys = list(data.keys()) if isinstance(data, dict) else type(data).__name__
1200
+ return '执行 %s 的任务响应无 tasks 字段 (顶层 keys: %s)。' % (eid, topkeys)
1201
+ if not items:
1202
+ return '执行 %s 暂无任务。' % eid
1203
+ if filter_mine:
1204
+ total = len(items)
1205
+ items = [t for t in items if isinstance(t, dict)
1206
+ and _assignee_account(t) == MY_ACCOUNT]
1207
+ scope = '指派给我(%s)' % MY_ACCOUNT
1208
+ if not items:
1209
+ return '执行 %s 没有指派给你(%s)的任务。\n(全队 %d 个, 要看全队加 all=true)' % (eid, MY_ACCOUNT, total)
1210
+ else:
1211
+ scope = '全队' if see_all else '全部'
1212
+ lines = ['执行 %s 的任务 [%s, %d 个]:' % (eid, scope, len(items))]
1213
+ for t in items:
1214
+ if isinstance(t, dict):
1215
+ acc = _assignee_account(t)
1216
+ mine_flag = '' if filter_mine else (' [我]' if acc == MY_ACCOUNT else ' [队友]')
1217
+ lines.append(' [#%s] %s (assignedTo=%s status=%s)%s' % (
1218
+ t.get('id'), str(t.get('name', ''))[:25],
1219
+ acc, t.get('status', ''), mine_flag))
1220
+ else:
1221
+ lines.append(' ' + str(t)[:60])
1222
+ return '\n'.join(lines)
1223
+
1224
+ @tool("get_task",
1225
+ "查任务详情(含工时:预计/消耗/剩余)。例: get_task(task_id=301)",
1226
+ {"type": "object", "properties": {
1227
+ "task_id": {"type": "integer", "description": "任务ID"},
1228
+ }, "required": ["task_id"]})
1229
+ def _t_get_task(self, args):
1230
+ tid = args.get('task_id', 0)
1231
+ gate = self._gate()
1232
+ if gate:
1233
+ return gate
1234
+ r = _api('GET', '/tasks/%s' % tid)
1235
+ if r.status_code not in (200, 201):
1236
+ return '查询失败: %s' % _err_text(r)
1237
+ t = r.json()
1238
+ asg = _assignee_account(t) or '空'
1239
+ opn = _openedby_account(t) or '空'
1240
+ return ('任务 #%s: %s\n状态: %s | 类型: %s | 优先级: %s\n'
1241
+ '指派: %s | 创建: %s\n'
1242
+ '工时: 预计=%s 消耗=%s 剩余=%s\n截止: %s\n需求: %s 执行: %s') % (
1243
+ tid, t.get('name', ''), t.get('status', ''), t.get('type', ''),
1244
+ t.get('pri', ''), asg, opn,
1245
+ t.get('estimate', ''), t.get('consumed', ''), t.get('left', ''),
1246
+ t.get('deadline', ''), t.get('story', ''), t.get('execution', ''))
1247
+
1248
+ @tool("create_task",
1249
+ "创建任务(组长拆需求为任务)。必填: execution, name, assignedTo。可选 type(devel/test/design等), pri, estimate(预计工时h), deadline, story(关联需求)。权限不足返回提示不报错。例: create_task(execution_id=73, name='导出PDF接口', assignedTo='zhangsan', type='devel', estimate=6, story=761)",
1250
+ {"type": "object", "properties": {
1251
+ "execution_id": {"type": "integer", "description": "执行ID"},
1252
+ "name": {"type": "string", "description": "任务名称"},
1253
+ "assignedTo": {"type": "string", "description": "指派给(禅道账号, 不填默认指派给当前开发者自己)"},
1254
+ "type": {"type": "string", "description": "类型: devel(开发)/test(测试)/design(设计)/discuss(讨论)/study(研究)等, 默认devel"},
1255
+ "pri": {"type": "integer", "description": "优先级 1-4(默认3)"},
1256
+ "estimate": {"type": "number", "description": "预计工时(小时)"},
1257
+ "deadline": {"type": "string", "description": "截止日期 YYYY-MM-DD(可选)"},
1258
+ "story": {"type": "integer", "description": "关联需求ID(可选)"},
1259
+ "desc": {"type": "string", "description": "任务描述(可选)"},
1260
+ }, "required": ["execution_id", "name"]})
1261
+ def _t_create_task(self, args):
1262
+ eid = args.get('execution_id', 0)
1263
+ name = args.get('name', '')
1264
+ # 默认指派给当前开发者 (防共享账号误派给别人); 显式传 assignedTo 才派别人
1265
+ assignedTo = args.get('assignedTo') or MY_ACCOUNT
1266
+ if not eid or not name or not assignedTo:
1267
+ return '⚠️ 缺少必填字段: execution_id/name 必填, assignedTo 不填则需先配 member.json 的 zentao_account。'
1268
+ payload = {'execution': eid, 'name': name, 'assignedTo': assignedTo,
1269
+ 'type': args.get('type', 'devel'), 'pri': args.get('pri', 3)}
1270
+ for k in ('estimate', 'deadline', 'story', 'desc'):
1271
+ if args.get(k) is not None:
1272
+ payload[k] = args[k]
1273
+ return self._safe_write(
1274
+ 'POST', '/tasks', payload,
1275
+ lambda d: '✅ 任务已创建! ID=%s\n禅道地址: %s/task-view-%s.html' % (
1276
+ d.get('id', '?'), ZENTAO_URL.rstrip('/'), d.get('id', '?')),
1277
+ '可能权限不足或字段缺失')
1278
+
1279
+ @tool("update_task",
1280
+ "修改任务(名称/指派/工时/截止等)。权限不足返回提示不报错。例: update_task(task_id=301, name='导出PDF接口(改)', left=3)",
1281
+ {"type": "object", "properties": {
1282
+ "task_id": {"type": "integer", "description": "任务ID"},
1283
+ "name": {"type": "string", "description": "新名称(可选)"},
1284
+ "assignedTo": {"type": "string", "description": "新指派(可选)"},
1285
+ "estimate": {"type": "number", "description": "预计工时(可选)"},
1286
+ "left": {"type": "number", "description": "剩余工时(可选)"},
1287
+ "deadline": {"type": "string", "description": "截止日期(可选)"},
1288
+ }, "required": ["task_id"]})
1289
+ def _t_update_task(self, args):
1290
+ tid = args.get('task_id', 0)
1291
+ guard = self._assert_owner('task', tid)
1292
+ if guard:
1293
+ return guard
1294
+ payload = {}
1295
+ for k in ('name', 'assignedTo', 'estimate', 'left', 'deadline'):
1296
+ if args.get(k) is not None:
1297
+ payload[k] = args[k]
1298
+ if not payload:
1299
+ return '⚠️ 没有要更新的字段(可选: name/assignedTo/estimate/left/deadline)。'
1300
+ return self._safe_write(
1301
+ 'PUT', '/tasks/%s' % tid, payload,
1302
+ '✅ 任务 #%s 已更新' % tid,
1303
+ '可能权限不足或字段缺失')
1304
+
1305
+ @tool("delete_task",
1306
+ "删除任务。权限不足返回提示不报错。例: delete_task(task_id=301)",
1307
+ {"type": "object", "properties": {
1308
+ "task_id": {"type": "integer", "description": "任务ID"},
1309
+ }, "required": ["task_id"]})
1310
+ def _t_delete_task(self, args):
1311
+ tid = args.get('task_id', 0)
1312
+ guard = self._assert_owner('task', tid)
1313
+ if guard:
1314
+ return guard
1315
+ return self._safe_write(
1316
+ 'DELETE', '/tasks/%s' % tid, None,
1317
+ '✅ 任务 #%s 已删除' % tid,
1318
+ '可能权限不足或状态不允许删除')
1319
+
1320
+ @tool("assign_task",
1321
+ "指派任务给某人。权限不足返回提示不报错。例: assign_task(task_id=301, account='lisi')",
1322
+ {"type": "object", "properties": {
1323
+ "task_id": {"type": "integer", "description": "任务ID"},
1324
+ "account": {"type": "string", "description": "禅道账号"},
1325
+ }, "required": ["task_id", "account"]})
1326
+ def _t_assign_task(self, args):
1327
+ tid = args.get('task_id', 0)
1328
+ account = args.get('account', '')
1329
+ guard = self._assert_owner('task', tid)
1330
+ if guard:
1331
+ return guard
1332
+ return self._safe_write(
1333
+ 'POST', '/tasks/%s/assign' % tid, {'assignedTo': account},
1334
+ '✅ 任务 #%s 已指派给 %s' % (tid, account),
1335
+ '可能权限不足, 需在禅道手动指派')
1336
+
1337
+ @tool("start_task",
1338
+ "开始任务(未开始→进行中)。需填剩余工时left。权限不足返回提示不报错。例: start_task(task_id=301, left=6)",
1339
+ {"type": "object", "properties": {
1340
+ "task_id": {"type": "integer", "description": "任务ID"},
1341
+ "left": {"type": "number", "description": "剩余工时(小时, 默认取预计)"},
1342
+ "consumed": {"type": "number", "description": "已消耗工时(默认0)"},
1343
+ }, "required": ["task_id"]})
1344
+ def _t_start_task(self, args):
1345
+ tid = args.get('task_id', 0)
1346
+ guard = self._assert_owner('task', tid)
1347
+ if guard:
1348
+ return guard
1349
+ payload = {}
1350
+ if args.get('left') is not None:
1351
+ payload['left'] = args['left']
1352
+ if args.get('consumed') is not None:
1353
+ payload['consumed'] = args['consumed']
1354
+ return self._safe_write(
1355
+ 'POST', '/tasks/%s/start' % tid, payload,
1356
+ '✅ 任务 #%s 已开始' % tid,
1357
+ '可能权限不足或当前状态不允许开始')
1358
+
1359
+ @tool("pause_task",
1360
+ "暂停任务(进行中→已暂停)。权限不足返回提示不报错。例: pause_task(task_id=301)",
1361
+ {"type": "object", "properties": {
1362
+ "task_id": {"type": "integer", "description": "任务ID"},
1363
+ }, "required": ["task_id"]})
1364
+ def _t_pause_task(self, args):
1365
+ tid = args.get('task_id', 0)
1366
+ guard = self._assert_owner('task', tid)
1367
+ if guard:
1368
+ return guard
1369
+ return self._safe_write(
1370
+ 'POST', '/tasks/%s/pause' % tid, {},
1371
+ '✅ 任务 #%s 已暂停' % tid,
1372
+ '可能权限不足或当前状态不允许暂停')
1373
+
1374
+ @tool("restart_task",
1375
+ "继续任务(已暂停→进行中)。可填剩余工时。权限不足返回提示不报错。例: restart_task(task_id=301, left=4)",
1376
+ {"type": "object", "properties": {
1377
+ "task_id": {"type": "integer", "description": "任务ID"},
1378
+ "left": {"type": "number", "description": "剩余工时(可选)"},
1379
+ }, "required": ["task_id"]})
1380
+ def _t_restart_task(self, args):
1381
+ tid = args.get('task_id', 0)
1382
+ guard = self._assert_owner('task', tid)
1383
+ if guard:
1384
+ return guard
1385
+ payload = {}
1386
+ if args.get('left') is not None:
1387
+ payload['left'] = args['left']
1388
+ return self._safe_write(
1389
+ 'POST', '/tasks/%s/restart' % tid, payload,
1390
+ '✅ 任务 #%s 已继续' % tid,
1391
+ '可能权限不足或当前状态不允许继续')
1392
+
1393
+ @tool("finish_task",
1394
+ "完成任务(进行中→已完成)。需填消耗工时consumed和剩余0。权限不足返回提示不报错。例: finish_task(task_id=301, consumed=6)",
1395
+ {"type": "object", "properties": {
1396
+ "task_id": {"type": "integer", "description": "任务ID"},
1397
+ "consumed": {"type": "number", "description": "本次消耗工时(小时)"},
1398
+ "left": {"type": "number", "description": "剩余工时(完成时填0, 默认0)"},
1399
+ }, "required": ["task_id"]})
1400
+ def _t_finish_task(self, args):
1401
+ tid = args.get('task_id', 0)
1402
+ guard = self._assert_owner('task', tid)
1403
+ if guard:
1404
+ return guard
1405
+ payload = {'left': args.get('left', 0)}
1406
+ if args.get('consumed') is not None:
1407
+ payload['consumed'] = args['consumed']
1408
+ return self._safe_write(
1409
+ 'POST', '/tasks/%s/finish' % tid, payload,
1410
+ '✅ 任务 #%s 已完成' % tid,
1411
+ '可能权限不足或当前状态不允许完成')
1412
+
1413
+ @tool("close_task",
1414
+ "关闭任务(已完成→已关闭, 通常由负责人确认)。权限不足返回提示不报错。例: close_task(task_id=301)",
1415
+ {"type": "object", "properties": {
1416
+ "task_id": {"type": "integer", "description": "任务ID"},
1417
+ }, "required": ["task_id"]})
1418
+ def _t_close_task(self, args):
1419
+ tid = args.get('task_id', 0)
1420
+ guard = self._assert_owner('task', tid)
1421
+ if guard:
1422
+ return guard
1423
+ return self._safe_write(
1424
+ 'PUT', '/tasks/%s/close' % tid, {},
1425
+ '✅ 任务 #%s 已关闭' % tid,
1426
+ '可能权限不足或当前状态不允许关闭')
1427
+
1428
+ @tool("log_effort",
1429
+ "记录任务工时(每日填写消耗/剩余)。权限不足返回提示不报错。例: log_effort(task_id=301, consumed=2, left=4)",
1430
+ {"type": "object", "properties": {
1431
+ "task_id": {"type": "integer", "description": "任务ID"},
1432
+ "consumed": {"type": "number", "description": "本次消耗工时(小时)"},
1433
+ "left": {"type": "number", "description": "剩余工时(小时)"},
1434
+ }, "required": ["task_id", "consumed", "left"]})
1435
+ def _t_log_effort(self, args):
1436
+ tid = args.get('task_id', 0)
1437
+ guard = self._assert_owner('task', tid)
1438
+ if guard:
1439
+ return guard
1440
+ payload = {'consumed': args.get('consumed', 0), 'left': args.get('left', 0)}
1441
+ return self._safe_write(
1442
+ 'POST', '/tasks/%s/estimate' % tid, payload,
1443
+ '✅ 任务 #%s 已记录工时 (消耗%s 剩余%s)' % (tid, payload['consumed'], payload['left']),
1444
+ '可能权限不足')
1445
+
1446
+ @tool("list_effort",
1447
+ "查任务的工时记录。例: list_effort(task_id=301)",
1448
+ {"type": "object", "properties": {
1449
+ "task_id": {"type": "integer", "description": "任务ID"},
1450
+ }, "required": ["task_id"]})
1451
+ def _t_list_effort(self, args):
1452
+ tid = args.get('task_id', 0)
1453
+ gate = self._gate()
1454
+ if gate:
1455
+ return gate
1456
+ r = _api('GET', '/tasks/%s/estimate' % tid)
1457
+ if r.status_code not in (200, 201):
1458
+ return '查询失败: %s' % _err_text(r)
1459
+ data = r.json()
1460
+ efforts = []
1461
+ for k in ('efforts', 'estimates', 'records'):
1462
+ if isinstance(data.get(k), list):
1463
+ efforts = data[k]
1464
+ break
1465
+ if not efforts:
1466
+ return '任务 #%s 暂无工时记录。' % tid
1467
+ lines = ['任务 #%s 的工时 (%d 条):' % (tid, len(efforts))]
1468
+ for e in efforts:
1469
+ lines.append(' %s 消耗=%s 剩余=%s (%s)' % (
1470
+ e.get('date', ''), e.get('consumed', ''), e.get('left', ''),
1471
+ e.get('account', '')))
1472
+ return '\n'.join(lines)
1473
+
1474
+ # ── Bug (8) ── ★"加BUG" ───────────────────────────────────────────
1475
+
1476
+ @tool("list_bugs",
1477
+ "查Bug列表(产品或执行维度)。默认只看指派给我的(assignedTo=我), 防看错队友; 要看全队加 all=true。★查'我的全部Bug(跨产品)'请用 list_my_bugs 或 my_workbench。例: list_bugs(product_id=1) / list_bugs(execution_id=73, all=true)",
1478
+ {"type": "object", "properties": {
1479
+ "product_id": {"type": "integer", "description": "产品ID(与execution_id二选一)"},
1480
+ "execution_id": {"type": "integer", "description": "执行ID(与product_id二选一)"},
1481
+ "limit": {"type": "integer", "description": "返回数量(默认20)"},
1482
+ "all": {"type": "boolean", "description": "true=看全队(默认false只看我的)"},
1483
+ }})
1484
+ def _t_list_bugs(self, args):
1485
+ limit = args.get('limit', 20)
1486
+ see_all = bool(args.get('all', False))
1487
+ filter_mine = not see_all
1488
+ if args.get('execution_id'):
1489
+ path = '/executions/%s/bugs' % args['execution_id']
1490
+ scope = '执行 %s' % args['execution_id']
1491
+ else:
1492
+ pid = args.get('product_id', 1)
1493
+ path = '/products/%s/bugs' % pid
1494
+ scope = '产品 %s' % pid
1495
+ # 归属铁门 (v3.1.5 起拒绝而非静默降级)
1496
+ if filter_mine:
1497
+ guard = self._require_my_account()
1498
+ if guard:
1499
+ return guard
1500
+ else:
1501
+ r = self._gate()
1502
+ if r:
1503
+ return r
1504
+ resp = _api('GET', path, {'limit': limit})
1505
+ if resp.status_code not in (200, 201):
1506
+ return '查询失败: %s' % _err_text(resp)
1507
+ data = resp.json()
1508
+ items = data.get('bugs') if isinstance(data, dict) else data
1509
+ if not isinstance(items, list):
1510
+ topkeys = list(data.keys()) if isinstance(data, dict) else type(data).__name__
1511
+ return '%s 的Bug响应无 bugs 字段 (顶层 keys: %s)。' % (scope, topkeys)
1512
+ if not items:
1513
+ return '%s 暂无Bug。' % scope
1514
+ if filter_mine:
1515
+ total = len(items)
1516
+ # v3.1.6: "我的Bug" = 指派给我 OR 我提的 (团队习惯提了不指派, assignedTo 常为空)
1517
+ items = [b for b in items if isinstance(b, dict) and (
1518
+ _assignee_account(b) == MY_ACCOUNT or _openedby_account(b) == MY_ACCOUNT)]
1519
+ scope = '%s [我的(指派给我或我提的, %s)]' % (scope, MY_ACCOUNT)
1520
+ if not items:
1521
+ return '%s 没有指派给你或你提的Bug。\n(全队 %d 个, 要看全队加 all=true)' % (scope, MY_ACCOUNT, total)
1522
+ else:
1523
+ scope = '%s [%s]' % (scope, '全队' if see_all else '全部')
1524
+ lines = ['%s 的Bug (%d 个):' % (scope, len(items))]
1525
+ for b in items:
1526
+ if isinstance(b, dict):
1527
+ asg = _assignee_account(b)
1528
+ opn = _openedby_account(b)
1529
+ # 归属来源标记: 区分"指派给我"和"我提的"
1530
+ if filter_mine:
1531
+ tag = ' [指派给我]' if asg == MY_ACCOUNT else (' [我提的]' if opn == MY_ACCOUNT else '')
1532
+ else:
1533
+ tag = (' [指派给我]' if asg == MY_ACCOUNT else
1534
+ (' [我提的]' if opn == MY_ACCOUNT else ' [队友]'))
1535
+ asg_show = asg or ('(空,我提的)' if opn == MY_ACCOUNT and not asg else asg)
1536
+ lines.append(' [#%s] %s (severity=%s status=%s assignedTo=%s)%s' % (
1537
+ b.get('id'), str(b.get('title', ''))[:25],
1538
+ b.get('severity', ''), b.get('status', ''), asg_show, tag))
1539
+ return '\n'.join(lines) if items else '%s 暂无Bug。' % scope
1540
+
1541
+ @tool("get_bug",
1542
+ "查Bug详情。例: get_bug(bug_id=101)",
1543
+ {"type": "object", "properties": {
1544
+ "bug_id": {"type": "integer", "description": "Bug ID"},
1545
+ }, "required": ["bug_id"]})
1546
+ def _t_get_bug(self, args):
1547
+ bid = args.get('bug_id', 0)
1548
+ gate = self._gate()
1549
+ if gate:
1550
+ return gate
1551
+ r = _api('GET', '/bugs/%s' % bid)
1552
+ if r.status_code not in (200, 201):
1553
+ return '查询失败: %s' % _err_text(r)
1554
+ b = r.json()
1555
+ asg = _assignee_account(b) or '空'
1556
+ opn = _openedby_account(b) or '空'
1557
+ return ('Bug #%s: %s\n状态: %s | 严重度: %s | 优先级: %s | 解决方案: %s\n'
1558
+ '指派: %s | 创建: %s | 影响版本: %s | 解决版本: %s\n'
1559
+ '关联需求: %s | 类型: %s\n描述: %s') % (
1560
+ bid, b.get('title', ''), b.get('status', ''),
1561
+ b.get('severity', ''), b.get('pri', ''), b.get('resolution', ''),
1562
+ asg, opn, b.get('openedBuild', ''), b.get('resolvedBuild', ''),
1563
+ b.get('story', ''),
1564
+ b.get('type', ''), str(b.get('steps', ''))[:200])
1565
+
1566
+ @tool("create_bug",
1567
+ "提Bug。必填: product, title, openedBuild(影响版本)。可选 severity(1-4,1最严重), pri(1-4), type, story(关联需求), assignedTo。权限不足返回提示不报错。例: create_bug(product_id=1, title='导出PDF乱码', openedBuild='8', severity=2, story=761)",
1568
+ {"type": "object", "properties": {
1569
+ "product_id": {"type": "integer", "description": "产品ID"},
1570
+ "title": {"type": "string", "description": "Bug标题"},
1571
+ "openedBuild": {"type": "string", "description": "影响版本(build ID 或名称)"},
1572
+ "steps": {"type": "string", "description": "重现步骤(可选)"},
1573
+ "severity": {"type": "integer", "description": "严重度 1-4(1最严重, 默认3)"},
1574
+ "pri": {"type": "integer", "description": "优先级 1-4(默认3)"},
1575
+ "type": {"type": "string", "description": "类型: codegen/interface/config/security等, 默认codeerror"},
1576
+ "story": {"type": "integer", "description": "关联需求ID(可选)"},
1577
+ "assignedTo": {"type": "string", "description": "指派给(可选)"},
1578
+ }, "required": ["product_id", "title", "openedBuild"]})
1579
+ def _t_create_bug(self, args):
1580
+ pid = args.get('product_id', 1)
1581
+ title = args.get('title', '')
1582
+ openedBuild = args.get('openedBuild', '')
1583
+ if not title or not openedBuild:
1584
+ return '⚠️ 缺少必填字段: title 和 openedBuild(影响版本) 都不能为空。'
1585
+ # v3.1.6: 默认指派给提交人 (当前开发者), 防提交后 assignedTo=None 查不到自己的 bug
1586
+ # (历史踩坑: 不传 assignedTo → 禅道默认空 → 用户"明明提了 bug"却查不到)
1587
+ assignedTo = args.get('assignedTo') or MY_ACCOUNT
1588
+ payload = {'title': title, 'openedBuild': openedBuild,
1589
+ 'severity': args.get('severity', 3), 'pri': args.get('pri', 3),
1590
+ 'type': args.get('type', 'codeerror'), 'assignedTo': assignedTo}
1591
+ if args.get('steps'):
1592
+ payload['steps'] = args['steps']
1593
+ if args.get('story'):
1594
+ payload['story'] = args['story']
1595
+ return self._safe_write(
1596
+ 'POST', '/products/%s/bugs' % pid, payload,
1597
+ lambda d: '✅ Bug已提交! ID=%s (指派给 %s)\n禅道地址: %s/bug-view-%s.html' % (
1598
+ d.get('id', '?'), assignedTo, ZENTAO_URL.rstrip('/'), d.get('id', '?')),
1599
+ '可能权限不足或字段缺失')
1600
+
1601
+ @tool("update_bug",
1602
+ "修改Bug(标题/严重度/影响版本等)。权限不足返回提示不报错。例: update_bug(bug_id=101, severity=2)",
1603
+ {"type": "object", "properties": {
1604
+ "bug_id": {"type": "integer", "description": "Bug ID"},
1605
+ "title": {"type": "string", "description": "新标题(可选)"},
1606
+ "severity": {"type": "integer", "description": "新严重度 1-4(可选)"},
1607
+ "pri": {"type": "integer", "description": "新优先级 1-4(可选)"},
1608
+ "openedBuild": {"type": "string", "description": "新影响版本(可选)"},
1609
+ }, "required": ["bug_id"]})
1610
+ def _t_update_bug(self, args):
1611
+ bid = args.get('bug_id', 0)
1612
+ payload = {}
1613
+ for k in ('title', 'severity', 'pri', 'openedBuild'):
1614
+ if args.get(k) is not None:
1615
+ payload[k] = args[k]
1616
+ if not payload:
1617
+ return '⚠️ 没有要更新的字段(可选: title/severity/pri/openedBuild)。'
1618
+ return self._safe_write(
1619
+ 'PUT', '/bugs/%s' % bid, payload,
1620
+ '✅ Bug #%s 已更新' % bid,
1621
+ '可能权限不足或字段缺失')
1622
+
1623
+ @tool("delete_bug",
1624
+ "删除Bug。权限不足返回提示不报错。例: delete_bug(bug_id=101)",
1625
+ {"type": "object", "properties": {
1626
+ "bug_id": {"type": "integer", "description": "Bug ID"},
1627
+ }, "required": ["bug_id"]})
1628
+ def _t_delete_bug(self, args):
1629
+ bid = args.get('bug_id', 0)
1630
+ return self._safe_write(
1631
+ 'DELETE', '/bugs/%s' % bid, None,
1632
+ '✅ Bug #%s 已删除' % bid,
1633
+ '可能权限不足')
1634
+
1635
+ @tool("assign_bug",
1636
+ "指派Bug给某人。权限不足返回提示不报错。例: assign_bug(bug_id=101, account='zhangsan')",
1637
+ {"type": "object", "properties": {
1638
+ "bug_id": {"type": "integer", "description": "Bug ID"},
1639
+ "account": {"type": "string", "description": "禅道账号"},
1640
+ }, "required": ["bug_id", "account"]})
1641
+ def _t_assign_bug(self, args):
1642
+ bid = args.get('bug_id', 0)
1643
+ account = args.get('account', '')
1644
+ guard = self._assert_owner('bug', bid)
1645
+ if guard:
1646
+ return guard
1647
+ return self._safe_write(
1648
+ 'POST', '/bugs/%s/assign' % bid, {'assignedTo': account},
1649
+ '✅ Bug #%s 已指派给 %s' % (bid, account),
1650
+ '可能权限不足, 需在禅道手动指派')
1651
+
1652
+ @tool("confirm_bug",
1653
+ "确认Bug(开发认领)。权限不足返回提示不报错。例: confirm_bug(bug_id=101)",
1654
+ {"type": "object", "properties": {
1655
+ "bug_id": {"type": "integer", "description": "Bug ID"},
1656
+ }, "required": ["bug_id"]})
1657
+ def _t_confirm_bug(self, args):
1658
+ bid = args.get('bug_id', 0)
1659
+ guard = self._assert_owner('bug', bid)
1660
+ if guard:
1661
+ return guard
1662
+ return self._safe_write(
1663
+ 'POST', '/bugs/%s/confirm' % bid, {},
1664
+ '✅ Bug #%s 已确认' % bid,
1665
+ '可能权限不足或当前状态不允许确认')
1666
+
1667
+ @tool("resolve_bug",
1668
+ "解决Bug(开发标记已修复)。可选 resolution(bydesign/duplicate/notrepro/postponed/willnotfix/codeerror), resolvedBuild(解决版本)。权限不足返回提示不报错。例: resolve_bug(bug_id=101, resolution='fixed', resolvedBuild='9')",
1669
+ {"type": "object", "properties": {
1670
+ "bug_id": {"type": "integer", "description": "Bug ID"},
1671
+ "resolution": {"type": "string", "description": "解决方案: fixed(已解决,默认)/bydesign/duplicate/notrepro/postponed/willnotfix"},
1672
+ "resolvedBuild": {"type": "string", "description": "解决版本(build ID或名称)"},
1673
+ "comment": {"type": "string", "description": "解决说明(可选)"},
1674
+ }, "required": ["bug_id"]})
1675
+ def _t_resolve_bug(self, args):
1676
+ bid = args.get('bug_id', 0)
1677
+ guard = self._assert_owner('bug', bid)
1678
+ if guard:
1679
+ return guard
1680
+ payload = {'resolution': args.get('resolution', 'fixed')}
1681
+ if args.get('resolvedBuild'):
1682
+ payload['resolvedBuild'] = args['resolvedBuild']
1683
+ if args.get('comment'):
1684
+ payload['comment'] = args['comment']
1685
+ return self._safe_write(
1686
+ 'POST', '/bugs/%s/resolve' % bid, payload,
1687
+ '✅ Bug #%s 已解决' % bid,
1688
+ '可能权限不足或当前状态不允许解决')
1689
+
1690
+ @tool("activate_bug",
1691
+ "激活Bug(验证未通过,重新打开)。权限不足返回提示不报错。例: activate_bug(bug_id=101)",
1692
+ {"type": "object", "properties": {
1693
+ "bug_id": {"type": "integer", "description": "Bug ID"},
1694
+ }, "required": ["bug_id"]})
1695
+ def _t_activate_bug(self, args):
1696
+ bid = args.get('bug_id', 0)
1697
+ guard = self._assert_owner('bug', bid)
1698
+ if guard:
1699
+ return guard
1700
+ return self._safe_write(
1701
+ 'POST', '/bugs/%s/activate' % bid, {},
1702
+ '✅ Bug #%s 已激活' % bid,
1703
+ '可能权限不足或当前状态不允许激活')
1704
+
1705
+ @tool("close_bug",
1706
+ "关闭Bug(测试验证通过后关闭)。权限不足返回提示不报错。例: close_bug(bug_id=101)",
1707
+ {"type": "object", "properties": {
1708
+ "bug_id": {"type": "integer", "description": "Bug ID"},
1709
+ }, "required": ["bug_id"]})
1710
+ def _t_close_bug(self, args):
1711
+ bid = args.get('bug_id', 0)
1712
+ guard = self._assert_owner('bug', bid)
1713
+ if guard:
1714
+ return guard
1715
+ return self._safe_write(
1716
+ 'PUT', '/bugs/%s/close' % bid, {},
1717
+ '✅ Bug #%s 已关闭' % bid,
1718
+ '可能权限不足或当前状态不允许关闭')
1719
+
1720
+ # ── 我的全景 (3) ── ★"看我的活" 跨执行/产品全量聚合 ──────────────────
1721
+ # 禅道无 "assignedTo=我的全部任务" 单接口, 这里全量扫描聚合。
1722
+ # 旧工作流逐执行查会漏 (用户实测: 61 执行只查 12 个, 漏 9 个未开始任务)。
1723
+
1724
+ @tool("my_workbench",
1725
+ "★我的禅道工作台: 一次返回指派给我的【任务+需求+Bug】, 跨所有执行/产品全量聚合, 按紧急度排序。"
1726
+ "回答'我手头有什么活/我的任务/这周干啥/我的禅道'直接用这个, 不要逐执行查。"
1727
+ "例: my_workbench()",
1728
+ {"type": "object", "properties": {
1729
+ "include_closed": {"type": "boolean", "description": "true=含已关闭/取消历史项(默认false只看活的)"},
1730
+ }})
1731
+ def _t_my_workbench(self, args):
1732
+ guard = self._require_my_account()
1733
+ if guard:
1734
+ return guard
1735
+ include_closed = bool(args.get('include_closed', False))
1736
+ # 并行收集三域 (内部串行调 _api, 但每个都是必要查询)
1737
+ tasks, terr = self._collect_my_tasks()
1738
+ if terr:
1739
+ return terr
1740
+ bugs, berr = self._collect_my_bugs()
1741
+ if berr:
1742
+ return berr
1743
+ stories, serr = self._collect_my_stories()
1744
+ if serr:
1745
+ return serr
1746
+
1747
+ # 过滤掉终态 (除非显式要看历史)
1748
+ LIVE_TASK = {'wait', 'doing', 'pause'}
1749
+ LIVE_BUG = {'active', 'resolved'}
1750
+ if not include_closed:
1751
+ tasks = [t for t in tasks if t.get('status') in LIVE_TASK]
1752
+ bugs = [b for b in bugs if b.get('status') in LIVE_BUG]
1753
+ stories = [s for s in stories if s.get('status') not in ('closed',)]
1754
+
1755
+ lines = ['📋 我(%s)的禅道工作台 — 任务 %d · 需求 %d · Bug %d' % (
1756
+ MY_ACCOUNT, len(tasks), len(stories), len(bugs))]
1757
+
1758
+ # ── 任务 (按状态分组: 进行中 > 未开始) ──
1759
+ if tasks:
1760
+ doing = [t for t in tasks if t.get('status') == 'doing']
1761
+ waiting = [t for t in tasks if t.get('status') in ('wait', 'pause')]
1762
+ if doing:
1763
+ lines.append('\n🔴 进行中 (%d):' % len(doing))
1764
+ for t in doing:
1765
+ lines.append(' ' + self._fmt_task_line(t))
1766
+ if waiting:
1767
+ lines.append('\n🟡 未开始 (%d):' % len(waiting))
1768
+ for t in waiting:
1769
+ lines.append(' ' + self._fmt_task_line(t))
1770
+ else:
1771
+ lines.append('\n✅ 没有指派给你的活动任务。')
1772
+
1773
+ # ── 需求 ──
1774
+ if stories:
1775
+ lines.append('\n🔵 指派给我的需求 (%d):' % len(stories))
1776
+ for s in stories:
1777
+ lines.append(' [#%s] %s (status=%s pri=%s)' % (
1778
+ s.get('id'), str(s.get('title', ''))[:32], s.get('status'), s.get('pri')))
1779
+ # 需求为空不单独提示 (任务段已给总览)
1780
+
1781
+ # ── Bug ──
1782
+ if bugs:
1783
+ lines.append('\n🐛 我的Bug (%d, 指派给我或我提的):' % len(bugs))
1784
+ for b in bugs:
1785
+ lines.append(' [#%s] %s (severity=%s status=%s) [%s]' % (
1786
+ b.get('id'), str(b.get('title', ''))[:32], b.get('severity'),
1787
+ b.get('status'), b.get('_why', '')))
1788
+
1789
+ # 提示下一步
1790
+ n_active = len([t for t in tasks if t.get('status') in ('doing', 'wait')])
1791
+ lines.append('\n💡 下一步: 说"看任务#XXXX详情" / "开始做#XXXX" / "看需求#XXXX"')
1792
+ if not include_closed:
1793
+ lines.append(' (以上只列活动项; 看含已取消的历史加 include_closed=true)')
1794
+ return '\n'.join(lines)
1795
+
1796
+ def _fmt_task_line(self, t):
1797
+ """格式化任务为一行 (带工时/截止/执行提示)。"""
1798
+ extras = []
1799
+ if t.get('pri'):
1800
+ extras.append('P%s' % t.get('pri'))
1801
+ if t.get('left'):
1802
+ extras.append('余%s h' % t.get('left'))
1803
+ if t.get('deadline'):
1804
+ extras.append('截止%s' % t.get('deadline'))
1805
+ suffix = (' (' + ' '.join(extras) + ')') if extras else ''
1806
+ ename = str(t.get('execution_name', ''))[:12]
1807
+ return '[#%s] %s%s ←执行%s' % (
1808
+ t.get('id'), str(t.get('name', ''))[:34], suffix, ename)
1809
+
1810
+ @tool("list_my_tasks",
1811
+ "查指派给我的全部任务(跨所有执行, 全量不漏)。支持按状态过滤。"
1812
+ "★回答'我的全部任务'用这个; 只看某执行用 list_execution_tasks。"
1813
+ "例: list_my_tasks() / list_my_tasks(status='wait') / list_my_tasks(status='doing')",
1814
+ {"type": "object", "properties": {
1815
+ "status": {"type": "string", "description": "只看某状态: wait(未开始)/doing(进行中)/pause/done/closed/cancel。不填=全部"},
1816
+ }})
1817
+ def _t_list_my_tasks(self, args):
1818
+ guard = self._require_my_account()
1819
+ if guard:
1820
+ return guard
1821
+ status_filter = args.get('status')
1822
+ tasks, err = self._collect_my_tasks(status_filter=status_filter)
1823
+ if err:
1824
+ return err
1825
+ if not tasks:
1826
+ hint = '状态=%s' % status_filter if status_filter else '任何状态'
1827
+ return '没有指派给你(%s)的任务(%s)。' % (MY_ACCOUNT, hint)
1828
+ # 按状态分组打印
1829
+ from collections import OrderedDict
1830
+ order = ['doing', 'wait', 'pause', 'done', 'closed', 'cancel']
1831
+ grouped = OrderedDict((s, []) for s in order)
1832
+ for t in tasks:
1833
+ grouped.setdefault(t.get('status'), []).append(t)
1834
+ lines = ['我的任务(%s) 共 %d 个:' % (MY_ACCOUNT, len(tasks))]
1835
+ for st, arr in grouped.items():
1836
+ if not arr:
1837
+ continue
1838
+ label = {'doing': '🔴 进行中', 'wait': '🟡 未开始', 'pause': '⏸ 暂停',
1839
+ 'done': '✅ 已完成', 'closed': '🔒 已关闭', 'cancel': '⚫ 已取消'}.get(st, st)
1840
+ lines.append('\n%s (%d):' % (label, len(arr)))
1841
+ for t in arr:
1842
+ lines.append(' ' + self._fmt_task_line(t))
1843
+ lines.append('\n💡 说"看任务#XXXX详情"或"开始做#XXXX"')
1844
+ return '\n'.join(lines)
1845
+
1846
+ @tool("list_my_bugs",
1847
+ "查'我的'全部Bug(跨所有产品, 全量不漏)——指派给我的 或 我提的都算。"
1848
+ "★回答'我的全部Bug'用这个; 只看某产品用 list_bugs。例: list_my_bugs()",
1849
+ {"type": "object", "properties": {}})
1850
+ def _t_list_my_bugs(self, args):
1851
+ guard = self._require_my_account()
1852
+ if guard:
1853
+ return guard
1854
+ bugs, err = self._collect_my_bugs()
1855
+ if err:
1856
+ return err
1857
+ if not bugs:
1858
+ return '没有指派给你(%s)或你提的Bug。' % MY_ACCOUNT
1859
+ # 按状态分组
1860
+ from collections import OrderedDict
1861
+ order = ['active', 'resolved', 'closed']
1862
+ grouped = OrderedDict((s, []) for s in order)
1863
+ for b in bugs:
1864
+ grouped.setdefault(b.get('status'), []).append(b)
1865
+ lines = ['我的Bug(%s) 共 %d 个 (指派给我 或 我提的):' % (MY_ACCOUNT, len(bugs))]
1866
+ for st, arr in grouped.items():
1867
+ if not arr:
1868
+ continue
1869
+ label = {'active': '🐛 待解决', 'resolved': '🛠 已解决待验证',
1870
+ 'closed': '✅ 已关闭'}.get(st, st)
1871
+ lines.append('\n%s (%d):' % (label, len(arr)))
1872
+ for b in arr:
1873
+ why = b.get('_why', '')
1874
+ lines.append(' [#%s] %s (severity=%s 产品%s) [%s]' % (
1875
+ b.get('id'), str(b.get('title', ''))[:32], b.get('severity'),
1876
+ b.get('product_id'), why))
1877
+ lines.append('\n💡 说"看Bug#XXXX"或"Bug解决了"')
1878
+ return '\n'.join(lines)
1879
+
1880
+ # ── 用户 User (2) ── (指派任务/Bug 必须知道有哪些账号) ─────────────
1881
+
1882
+ @tool("list_users",
1883
+ "查禅道用户列表(指派任务/Bug时选账号)。例: list_users()",
1884
+ {"type": "object", "properties": {}})
1885
+ def _t_list_users(self, args):
1886
+ return self._safe_get(
1887
+ '/users', None,
1888
+ '禅道用户 (%d 个):',
1889
+ lambda u: ' %s (%s role=%s)' % (
1890
+ u.get('account', ''), str(u.get('realname', ''))[:10], u.get('role', '')),
1891
+ empty_msg='禅道暂无用户。',
1892
+ )
1893
+
1894
+ @tool("get_user",
1895
+ "查用户详情。例: get_user(account='hupan')",
1896
+ {"type": "object", "properties": {
1897
+ "account": {"type": "string", "description": "禅道账号"},
1898
+ }, "required": ["account"]})
1899
+ def _t_get_user(self, args):
1900
+ account = args.get('account', '')
1901
+ gate = self._gate()
1902
+ if gate:
1903
+ return gate
1904
+ r = _api('GET', '/users/%s' % account)
1905
+ if r.status_code not in (200, 201):
1906
+ return '查询失败: %s' % _err_text(r)
1907
+ u = r.json()
1908
+ return '用户 %s: %s\n角色: %s\n部门: %s\n邮箱: %s' % (
1909
+ account, u.get('realname', ''), u.get('role', ''),
1910
+ u.get('dept', ''), u.get('email', ''))
1911
+
1912
+ def on_initialize_hook(self, info):
1913
+ # 启动时内网校验, 不通塞 warning (与旧版行为一致)
1914
+ ok, msg = check_intranet()
1915
+ if not ok:
1916
+ info['warning'] = msg
1917
+
1918
+
1919
+ if __name__ == '__main__':
1920
+ ZentaoMCPServer().run()