@hupan56/wlkj 3.1.28 → 3.1.30

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 (38) hide show
  1. package/bin/cli.js +9 -1
  2. package/package.json +1 -1
  3. package/templates/qoder/commands/wl-task.md +11 -4
  4. package/templates/qoder/scripts/capability/smoke_test_report.json +12 -12
  5. package/templates/qoder/scripts/capability/smoke_test_report.json.new +94 -0
  6. package/templates/qoder/scripts/domain/report/report_snapshot.py +87 -6
  7. package/templates/qoder/scripts/domain/task/team_sync.py +34 -2
  8. package/templates/qoder/scripts/domain/task/zentao_panel.py +331 -0
  9. package/templates/qoder/scripts/domain/task/zentao_sync.py +6 -5
  10. package/templates/qoder/scripts/foundation/integrations/zentao_client.py +38 -0
  11. package/templates/qoder/scripts/protocol/mcp/mcp_doctor.py +14 -2
  12. package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +211 -92
  13. package/templates/root/AGENTS.md +12 -12
  14. package/templates/qoder/contracts/CHANGELOG.md +0 -418
  15. package/templates/qoder/contracts/README.md +0 -184
  16. package/templates/qoder/contracts/code.md +0 -81
  17. package/templates/qoder/contracts/commit.md +0 -86
  18. package/templates/qoder/contracts/contract-header.md +0 -76
  19. package/templates/qoder/contracts/design.md +0 -106
  20. package/templates/qoder/contracts/fallback.md +0 -126
  21. package/templates/qoder/contracts/isolation.md +0 -119
  22. package/templates/qoder/contracts/prd.md +0 -118
  23. package/templates/qoder/contracts/schemas/design-spec.schema.json +0 -46
  24. package/templates/qoder/contracts/schemas/prd.schema.json +0 -36
  25. package/templates/qoder/contracts/schemas/test-cases.schema.json +0 -40
  26. package/templates/qoder/contracts/spec.md +0 -81
  27. package/templates/qoder/contracts/task.md +0 -125
  28. package/templates/qoder/contracts/test.md +0 -112
  29. package/templates/qoder/scripts/domain/task/__pycache__/syncgate.cpython-39.pyc +0 -0
  30. package/templates/qoder/scripts/foundation/__pycache__/__init__.cpython-39.pyc +0 -0
  31. package/templates/qoder/scripts/foundation/__pycache__/bootstrap.cpython-39.pyc +0 -0
  32. package/templates/qoder/scripts/foundation/core/__pycache__/__init__.cpython-39.pyc +0 -0
  33. package/templates/qoder/scripts/foundation/core/__pycache__/paths.cpython-39.pyc +0 -0
  34. package/templates/qoder/scripts/foundation/identity/__pycache__/__init__.cpython-39.pyc +0 -0
  35. package/templates/qoder/scripts/foundation/identity/__pycache__/identity.cpython-39.pyc +0 -0
  36. package/templates/qoder/scripts/foundation/io/__pycache__/__init__.cpython-39.pyc +0 -0
  37. package/templates/qoder/scripts/foundation/io/__pycache__/atomicio.cpython-39.pyc +0 -0
  38. package/templates/qoder/scripts/foundation/io/__pycache__/filelock.cpython-39.pyc +0 -0
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ zentao_panel.py - 禅道任务面板数据接口 (供 VS Code 扩展 zentao-panel 调用)
5
+
6
+ 目的: 给侧边栏面板一个稳定的 JSON 数据源, 一次返回"指派给我的【任务+需求+Bug】",
7
+ 跨所有执行/产品全量聚合。逻辑与 my_workbench 工具一致 (只读), 但输出 JSON 而非文本。
8
+
9
+ 为什么不直接调 MCP server:
10
+ MCP server 的数据收集依赖 import-time 全局变量 (CLIENT / MY_ACCOUNT), 由 mcp_launcher
11
+ 注入环境变量才生效。本脚本走独立进程 (child_process.spawn 启动), 没有 launcher 注入,
12
+ 所以照搬 zentao_sync.py 的稳健模式: 自己 ZentaoClient.from_config() + 自己解析归属。
13
+ 收集循环则与 zentao_mcp_server.py:_collect_my_* 逐字一致 (DRY 同款), 保证数据口径统一。
14
+
15
+ 用法:
16
+ python .qoder/scripts/domain/task/zentao_panel.py --json # 三域活动项 JSON
17
+ python .qoder/scripts/domain/task/zentao_panel.py --json --include-closed # 含已关闭历史项
18
+ python .qoder/scripts/domain/task/zentao_panel.py --json --kind tasks # 只看任务
19
+
20
+ 退出码 (对齐项目规范):
21
+ 0 成功 (含空结果); 3 配置/归属错误; 5 网络不通。
22
+ --json 模式下错误也输出 JSON ({"ok": false, "error": "..."}), 扩展端统一解析。
23
+ """
24
+ import os
25
+ import sys
26
+
27
+ # ── 路径自举 (复用 bootstrap 模式, 与 zentao_sync.py 完全一致) ──
28
+ # 本脚本在 scripts/domain/task/ 下 (scripts 下 2 级), dirname 两次才到 scripts/。
29
+ _HERE = os.path.dirname(os.path.abspath(__file__))
30
+ _SCRIPTS = os.path.dirname(os.path.dirname(_HERE)) # .qoder/scripts (上两级)
31
+ _COMMON = os.path.join(_SCRIPTS, 'foundation')
32
+ for _p in (_SCRIPTS, _COMMON):
33
+ if _p not in sys.path:
34
+ sys.path.insert(0, _p)
35
+ try:
36
+ from bootstrap import setup; setup()
37
+ except Exception:
38
+ pass
39
+
40
+ import json
41
+ import argparse
42
+ from datetime import datetime
43
+
44
+ from foundation.core.paths import get_developer
45
+ from foundation.identity.identity import get_member
46
+ from foundation.integrations.zentao_client import ZentaoClient
47
+
48
+
49
+ # ============================================================
50
+ # 配置与归属解析 (照搬 zentao_sync.py, 唯一真相源)
51
+ # ============================================================
52
+
53
+ def _zentao_creds():
54
+ """构建禅道客户端。凭据不全返回 None。
55
+ url 来自 .qoder/config.yaml (团队共享); user/password 来自 secrets/zentao.env (个人)。"""
56
+ return ZentaoClient.from_config()
57
+
58
+
59
+ def _resolve_my_account(developer):
60
+ """解析当前 developer → 禅道 account。返回 (account, 错误原因)。"""
61
+ mem = get_member(developer)
62
+ if not mem:
63
+ return None, '当前开发者 %s 未注册 (无 member.json)。请先跑 /wl-init。' % developer
64
+ acc = (mem.get('zentao_account') or '').strip()
65
+ if not acc:
66
+ return None, ('%s 的 member.json 未配 zentao_account。\n'
67
+ ' 编辑 workspace/members/%s/member.json 加 "zentao_account": "你的禅道账号"。'
68
+ % (developer, developer))
69
+ return acc, None
70
+
71
+
72
+ # ============================================================
73
+ # 收集循环 (与 zentao_mcp_server.py:_collect_my_* 逐字一致)
74
+ # 单独写成函数, 不依赖 self / 全局 CLIENT, 接收 client 做参数 → 可独立调用。
75
+ # ============================================================
76
+
77
+ def _paged_list(client, path, params, list_key, page_size=100, max_pages=20):
78
+ """分页拉全量 (循环 page+limit, 按 id 去重)。照搬 mcp server 同名方法。
79
+
80
+ Returns: (合并去重列表, 是否截断[读满 max_pages 仍未到尾])。
81
+ """
82
+ seen, all_items = set(), []
83
+ truncated = False
84
+ for _page in range(1, max_pages + 1):
85
+ p = dict(params or {})
86
+ p.update({'limit': page_size, 'page': _page})
87
+ r = client.api('GET', path, p)
88
+ if r.status_code not in (200, 201):
89
+ break
90
+ data = r.json()
91
+ items = data.get(list_key) if isinstance(data, dict) else None
92
+ if not isinstance(items, list) or not items:
93
+ break
94
+ new = 0
95
+ for it in items:
96
+ if not isinstance(it, dict):
97
+ continue
98
+ key = it.get('id', id(it))
99
+ if key in seen:
100
+ continue
101
+ seen.add(key)
102
+ all_items.append(it)
103
+ new += 1
104
+ if new == 0:
105
+ break
106
+ return all_items, truncated
107
+
108
+
109
+ def _collect_my_tasks(client, my_account, status_filter=None):
110
+ """扫所有执行, 收集指派给我的任务。返回 (任务列表, None) 或 (None, 错误)。"""
111
+ execs, _ = _paged_list(client, '/executions', None, 'executions')
112
+ if execs is None:
113
+ return None, '查询执行列表失败, 稍后重试。'
114
+ all_t = []
115
+ for e in execs:
116
+ eid = e.get('id')
117
+ if eid is None:
118
+ continue
119
+ ename = str(e.get('name', ''))[:20]
120
+ tasks, _ = _paged_list(client, '/executions/%s/tasks' % eid, None, 'tasks')
121
+ for t in tasks:
122
+ if not isinstance(t, dict):
123
+ continue
124
+ if ZentaoClient.assignee_account(t) != my_account:
125
+ continue
126
+ if status_filter and t.get('status') != status_filter:
127
+ continue
128
+ t2 = dict(t)
129
+ t2['execution'] = eid
130
+ t2['execution_name'] = ename
131
+ all_t.append(t2)
132
+ return all_t, None
133
+
134
+
135
+ def _collect_my_bugs(client, my_account):
136
+ """扫活跃产品, 收集我的 Bug (指派给我 OR 我提的)。"""
137
+ prods, _ = _paged_list(client, '/products', None, 'products')
138
+ mine = []
139
+ for p in prods:
140
+ pid = p.get('id')
141
+ bugs, _ = _paged_list(client, '/products/%s/bugs' % pid, None, 'bugs')
142
+ for b in bugs:
143
+ if not isinstance(b, dict):
144
+ continue
145
+ asg = ZentaoClient.assignee_account(b)
146
+ opn = ZentaoClient.openedby_account(b)
147
+ why = '指派给我' if asg == my_account else ('我提的' if opn == my_account else None)
148
+ if why:
149
+ b2 = dict(b)
150
+ b2['product_id'] = pid
151
+ b2['_why'] = why
152
+ mine.append(b2)
153
+ return mine, None
154
+
155
+
156
+ def _collect_my_stories(client, my_account):
157
+ """扫活跃产品, 收集我的需求 (指派给我 OR 我提的)。"""
158
+ prods, _ = _paged_list(client, '/products', None, 'products')
159
+ mine = []
160
+ for p in prods:
161
+ pid = p.get('id')
162
+ sts, _ = _paged_list(client, '/products/%s/stories' % pid, None, 'stories')
163
+ for s in sts:
164
+ if not isinstance(s, dict):
165
+ continue
166
+ asg = ZentaoClient.assignee_account(s)
167
+ opn = ZentaoClient.openedby_account(s)
168
+ why = '指派给我' if asg == my_account else ('我提的' if opn == my_account else None)
169
+ if why:
170
+ s2 = dict(s)
171
+ s2['product_id'] = pid
172
+ s2['_why'] = why
173
+ mine.append(s2)
174
+ return mine, None
175
+
176
+
177
+ # ============================================================
178
+ # 归一化: 把禅道原始 dict 裁成面板需要的精简字段 (减体积 + 隐藏内部字段)
179
+ # ============================================================
180
+
181
+ def _norm_task(t):
182
+ return {
183
+ 'id': t.get('id'),
184
+ 'name': str(t.get('name', '')),
185
+ 'status': t.get('status', ''),
186
+ 'pri': t.get('pri'),
187
+ 'left': t.get('left'), # 剩余工时
188
+ 'estimate': t.get('estimate'),
189
+ 'consumed': t.get('consumed'),
190
+ 'deadline': t.get('deadline'),
191
+ 'execution': t.get('execution'),
192
+ 'execution_name': t.get('execution_name', ''),
193
+ }
194
+
195
+
196
+ def _norm_bug(b):
197
+ return {
198
+ 'id': b.get('id'),
199
+ 'title': str(b.get('title', '')),
200
+ 'status': b.get('status', ''),
201
+ 'severity': b.get('severity'),
202
+ 'pri': b.get('pri'),
203
+ 'why': b.get('_why', ''), # 指派给我 / 我提的
204
+ }
205
+
206
+
207
+ def _norm_story(s):
208
+ return {
209
+ 'id': s.get('id'),
210
+ 'title': str(s.get('title', '')),
211
+ 'status': s.get('status', ''),
212
+ 'pri': s.get('pri'),
213
+ 'why': s.get('_why', ''),
214
+ }
215
+
216
+
217
+ # 活动态白名单 (与 my_workbench 一致 + 修正: changed 也是活动态)。
218
+ # 说明: 禅道 task 有 changed 状态 (需求变更/任务被改动), 旧版 LIVE_TASK 漏掉它
219
+ # → 用户实测"明明有个进行中的任务却看不到"。changed 不是终态, 应算活动项。
220
+ LIVE_TASK = {'wait', 'doing', 'pause', 'changed'}
221
+ LIVE_BUG = {'active'}
222
+ LIVE_STORY = {'active', 'reviewing', 'changing'}
223
+
224
+
225
+ def _filter_live(tasks, bugs, stories, include_closed):
226
+ """过滤终态 (除非 include_closed)。"""
227
+ if include_closed:
228
+ return tasks, bugs, stories
229
+ return ([t for t in tasks if t.get('status') in LIVE_TASK],
230
+ [b for b in bugs if b.get('status') in LIVE_BUG],
231
+ [s for s in stories if s.get('status') in LIVE_STORY])
232
+
233
+
234
+ # ============================================================
235
+ # 主流程
236
+ # ============================================================
237
+
238
+ def fetch_workbench(include_closed=False, kind=None):
239
+ """收集三域并归一化。返回 (result_dict, exit_code)。
240
+
241
+ Args:
242
+ include_closed: True=含已关闭历史项
243
+ kind: 'tasks'/'bugs'/'stories' 只取一域; None=全取
244
+ Returns:
245
+ ({"ok": True, "data": {...}, "meta": {...}}, 0) 或
246
+ ({"ok": False, "error": "..."}, 3 或 5)
247
+ """
248
+ developer = get_developer()
249
+
250
+ # ① 凭据
251
+ client = _zentao_creds()
252
+ if client is None:
253
+ return {'ok': False,
254
+ 'error': '禅道凭据未配置。请检查 .qoder/config.yaml 的 zentao.url, '
255
+ '及 workspace/members/<我>/.private/secrets/zentao.env 的账号密码。'}, 3
256
+
257
+ ok, msg = client.credentials_ok()
258
+ if not ok:
259
+ return {'ok': False, 'error': '禅道凭据不完整: %s' % msg}, 3
260
+
261
+ # ② 内网连通
262
+ ok, msg = client.check_intranet()
263
+ if not ok:
264
+ return {'ok': False, 'error': '连不上禅道内网: %s' % msg}, 5
265
+
266
+ # ③ 归属
267
+ my_account, err = _resolve_my_account(developer) if developer else (None, '未识别当前开发者身份。')
268
+ if not my_account:
269
+ return {'ok': False, 'error': err}, 3
270
+
271
+ # ④ 收集 (按 kind 决定取哪几域)
272
+ tasks, bugs, stories = [], [], []
273
+ if kind in (None, 'tasks'):
274
+ tasks, e = _collect_my_tasks(client, my_account)
275
+ if e:
276
+ return {'ok': False, 'error': e}, 5
277
+ if kind in (None, 'bugs'):
278
+ bugs, _ = _collect_my_bugs(client, my_account)
279
+ if kind in (None, 'stories'):
280
+ stories, _ = _collect_my_stories(client, my_account)
281
+
282
+ # ⑤ 过滤 + 归一
283
+ tasks, bugs, stories = _filter_live(tasks, bugs, stories, include_closed)
284
+ data = {
285
+ 'account': my_account,
286
+ 'developer': developer,
287
+ 'tasks': [_norm_task(t) for t in tasks],
288
+ 'bugs': [_norm_bug(b) for b in bugs],
289
+ 'stories': [_norm_story(s) for s in stories],
290
+ }
291
+ meta = {
292
+ 'fetched_at': datetime.now().isoformat(timespec='seconds'),
293
+ 'counts': {
294
+ 'tasks': len(data['tasks']),
295
+ 'bugs': len(data['bugs']),
296
+ 'stories': len(data['stories']),
297
+ },
298
+ 'include_closed': include_closed,
299
+ }
300
+ return {'ok': True, 'data': data, 'meta': meta}, 0
301
+
302
+
303
+ def main():
304
+ parser = argparse.ArgumentParser(description='禅道任务面板数据接口 (输出 JSON 供 VS Code 扩展消费)')
305
+ parser.add_argument('--json', action='store_true', help='JSON 输出 (面板默认用这个)')
306
+ parser.add_argument('--include-closed', action='store_true', help='包含已关闭/取消的历史项 (默认只看活动项)')
307
+ parser.add_argument('--kind', choices=['tasks', 'bugs', 'stories'],
308
+ help='只取某一域 (默认三域全取)')
309
+ parser.add_argument('--pretty', action='store_true', help='JSON 美化缩进 (默认紧凑, 面板解析更快)')
310
+ args = parser.parse_args()
311
+
312
+ result, code = fetch_workbench(include_closed=args.include_closed, kind=args.kind)
313
+
314
+ if args.json or not sys.stdout.isatty():
315
+ # 面板/管道场景: 一律 JSON (成功/失败统一结构, 扩展端好解析)
316
+ indent = 2 if args.pretty else None
317
+ print(json.dumps(result, ensure_ascii=False, default=str, indent=indent))
318
+ else:
319
+ # 人类可读 (终端直接跑时, 兜底; 正常都用 --json)
320
+ if result.get('ok'):
321
+ d, m = result['data'], result['meta']
322
+ print('📋 %s 的禅道工作台 — 任务 %d · 需求 %d · Bug %d (更新于 %s)' % (
323
+ d['account'], m['counts']['tasks'], m['counts']['stories'], m['counts']['bugs'],
324
+ m['fetched_at']))
325
+ else:
326
+ print('❌ %s' % result.get('error', '未知错误'))
327
+ sys.exit(code)
328
+
329
+
330
+ if __name__ == '__main__':
331
+ main()
@@ -137,7 +137,7 @@ def cmd_pull(args, creds, my_account):
137
137
  print('[DRY-RUN] 仅预览, 不写本地。加 --apply 才真写。\n')
138
138
 
139
139
  r = creds.api('GET', '/executions/%s/tasks' % eid, {'limit': 100})
140
- if r.status_code != 200:
140
+ if r.status_code not in (200, 201):
141
141
  print('查询失败: %s %s' % (r.status_code, r.text[:120]))
142
142
  return 3
143
143
  data = r.json()
@@ -146,9 +146,10 @@ def cmd_pull(args, creds, my_account):
146
146
  print('响应格式异常 (无 tasks 列表)。')
147
147
  return 3
148
148
 
149
- # ── 归属硬过滤: 只取 assignedTo == 我 ──
149
+ # ── 归属硬过滤: 只取 assignedTo == 我 (用 ZentaoClient.assignee_account,
150
+ # 兼容禅道 assignedTo 返回 dict 的格式; 旧版 str(dict)==account 永远不匹配 → 拉到 0 个) ──
150
151
  mine = [t for t in tasks if isinstance(t, dict)
151
- and str(t.get('assignedTo', '')) == my_account]
152
+ and ZentaoClient.assignee_account(t) == my_account]
152
153
  skipped = len(tasks) - len(mine)
153
154
  print('执行 #%s 共 %d 个任务, 归属我的 %d 个 (跳过队友 %d 个, 不拉)。\n'
154
155
  % (eid, len(tasks), len(mine), skipped))
@@ -225,8 +226,8 @@ def cmd_push(args, creds, my_account, developer):
225
226
  if zid:
226
227
  # 已有 zentao_id → 更新前校验归属一致性
227
228
  chk = creds.api('GET', '/tasks/%s' % zid)
228
- if chk.status_code == 200:
229
- remote_assign = str(chk.json().get('assignedTo', ''))
229
+ if chk.status_code in (200, 201):
230
+ remote_assign = ZentaoClient.assignee_account(chk.json())
230
231
  if remote_assign != my_account:
231
232
  print(' 🚫 拒绝更新 #%s「%s」: 禅道 assignedTo=%s ≠ 你(%s)'
232
233
  ' (防误改队友任务)' % (zid, title, remote_assign, my_account))
@@ -241,3 +241,41 @@ class ZentaoClient:
241
241
  return r.json().get('error', r.text[:120])
242
242
  except Exception:
243
243
  return r.text[:120]
244
+
245
+ # ------------------------------------------------------------------
246
+ # 归属解析 (纯数据提取, sync + mcp 共用 —— 消除 str(dict)==account bug 根因)
247
+ # ------------------------------------------------------------------
248
+
249
+ @staticmethod
250
+ def _account_field(item, field: str) -> str:
251
+ """从 item 提取账号字段 (兼容禅道两种返回格式)。
252
+
253
+ 禅道 API 的 assignedTo/openedBy 可能是:
254
+ - dict: {'id':14,'account':'zhengkang','realname':'郑康'} (列表接口常见)
255
+ - str: 'zhengkang' (详情接口常见)
256
+ - None/其它: 返回空串
257
+
258
+ 历史 bug: 旧代码用 ``str(dict)==account`` 比较, dict 被转成
259
+ ``"{'account':'zhengkang'}"`` 永远 != 'zhengkang' → 归属过滤全失效
260
+ (sync pull 永远拉 0 个; push 永远误判归属不一致而拒绝更新)。
261
+ 本方法统一取 account 子字段, 是归属判定的唯一真相源。
262
+ """
263
+ if not isinstance(item, dict):
264
+ return ''
265
+ v = item.get(field)
266
+ if isinstance(v, dict):
267
+ return str(v.get('account', ''))
268
+ if isinstance(v, str):
269
+ return v
270
+ return ''
271
+
272
+ @staticmethod
273
+ def assignee_account(item) -> str:
274
+ """指派人账号 (assignedTo)。task/bug/story 的归属主字段。"""
275
+ return ZentaoClient._account_field(item, 'assignedTo')
276
+
277
+ @staticmethod
278
+ def openedby_account(item) -> str:
279
+ """创建人账号 (openedBy)。bug/story 归属的放宽字段
280
+ (团队习惯提了 bug 不指派, 留在创建人头上)。"""
281
+ return ZentaoClient._account_field(item, 'openedBy')
@@ -381,10 +381,22 @@ def find_python_with(mod):
381
381
  return None
382
382
 
383
383
 
384
+ def _find_launcher():
385
+ """定位 mcp_launcher.py 真实位置。
386
+
387
+ launcher 由 QoderWork 安装到用户级 ~/.qoderwork/ (不在仓库内),
388
+ 兼容少数仓库内就近部署的情况。返回 (路径, 是否存在)。
389
+ """
390
+ for cand in (QODERWORK_DIR / 'mcp_launcher.py', SCRIPTS_DIR / 'mcp_launcher.py'):
391
+ if cand.is_file():
392
+ return cand, True
393
+ return QODERWORK_DIR / 'mcp_launcher.py', False
394
+
395
+
384
396
  def test_mcp(name):
385
397
  """测试一个 MCP 能否正常响应 initialize."""
386
- launcher = SCRIPTS_DIR / 'mcp_launcher.py'
387
- if not launcher.is_file():
398
+ launcher, exists = _find_launcher()
399
+ if not exists:
388
400
  return {'name': name, 'status': 'ERROR',
389
401
  'detail': f'launcher 不存在: {launcher}'}
390
402