@comate/zulu 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +8 -0
  2. package/comate-engine/assets/skills/auto-commit/SKILL.md +386 -0
  3. package/comate-engine/assets/skills/auto-commit/references/issue_type_mapping.json +19 -0
  4. package/comate-engine/assets/skills/auto-commit/references/new_version_instruction.md +196 -0
  5. package/comate-engine/assets/skills/auto-commit/references/old_version_instruction.md +189 -0
  6. package/comate-engine/assets/skills/auto-commit/references/query_reference.md +176 -0
  7. package/comate-engine/assets/skills/auto-commit/scripts/compat.py +86 -0
  8. package/comate-engine/assets/skills/auto-commit/scripts/create_card_cli.py +67 -0
  9. package/comate-engine/assets/skills/auto-commit/scripts/git_diff_cli.py +195 -0
  10. package/comate-engine/assets/skills/auto-commit/scripts/git_utils.py +225 -0
  11. package/comate-engine/assets/skills/auto-commit/scripts/icafe/__init__.py +66 -0
  12. package/comate-engine/assets/skills/auto-commit/scripts/icafe/client.py +444 -0
  13. package/comate-engine/assets/skills/auto-commit/scripts/icafe/farseer.py +53 -0
  14. package/comate-engine/assets/skills/auto-commit/scripts/icafe/matching.py +778 -0
  15. package/comate-engine/assets/skills/auto-commit/scripts/logger.py +32 -0
  16. package/comate-engine/assets/skills/auto-commit/scripts/recognize_card_cli.py +63 -0
  17. package/comate-engine/assets/skills/automation-browser-comate/SKILL.md +193 -90
  18. package/comate-engine/assets/skills/figma2code-comate/SKILL.md +2 -2
  19. package/comate-engine/assets/skills/figma2code-comate/references/codeConnect.md +7 -10
  20. package/comate-engine/assets/skills/smart-commit/SKILL.md +646 -0
  21. package/comate-engine/assets/skills/smart-commit/references/issue_type_mapping.json +19 -0
  22. package/comate-engine/assets/skills/smart-commit/references/query_reference.md +176 -0
  23. package/comate-engine/assets/skills/smart-commit/scripts/compat.py +86 -0
  24. package/comate-engine/assets/skills/smart-commit/scripts/create_card_cli.py +67 -0
  25. package/comate-engine/assets/skills/smart-commit/scripts/git_utils.py +220 -0
  26. package/comate-engine/assets/skills/smart-commit/scripts/icafe/__init__.py +66 -0
  27. package/comate-engine/assets/skills/smart-commit/scripts/icafe/client.py +444 -0
  28. package/comate-engine/assets/skills/smart-commit/scripts/icafe/farseer.py +53 -0
  29. package/comate-engine/assets/skills/smart-commit/scripts/icafe/matching.py +728 -0
  30. package/comate-engine/assets/skills/smart-commit/scripts/logger.py +32 -0
  31. package/comate-engine/assets/skills/smart-commit/scripts/recognize_card_cli.py +63 -0
  32. package/comate-engine/node_modules/@comate/plugin-engine/dist/index.js +7 -7
  33. package/comate-engine/node_modules/@comate/plugin-host/dist/index.js +1 -1
  34. package/comate-engine/node_modules/@comate/plugin-host/dist/main.js +1 -1
  35. package/comate-engine/node_modules/@comate/plugin-shared-internals/dist/index.js +8 -8
  36. package/comate-engine/node_modules/@comate/preview-proxy/package.json +2 -2
  37. package/comate-engine/node_modules/better-sqlite3/build/Release/better_sqlite3.node +0 -0
  38. package/comate-engine/package.json +2 -2
  39. package/comate-engine/server.js +61 -44
  40. package/dist/bundle/index.js +8 -8
  41. package/package.json +1 -1
  42. package/comate-engine/node_modules/@comate/plugin-engine/dist/index.d.ts +0 -188
  43. package/comate-engine/node_modules/@comate/plugin-host/dist/main.d.ts +0 -14
  44. package/comate-engine/node_modules/@comate/plugin-shared-internals/dist/index.d.ts +0 -4817
  45. package/comate-engine/node_modules/better-sqlite3/README.md +0 -99
  46. package/comate-engine/node_modules/bindings/LICENSE.md +0 -22
  47. package/comate-engine/node_modules/bindings/README.md +0 -98
  48. package/comate-engine/node_modules/compare-versions/README.md +0 -133
  49. package/comate-engine/node_modules/compare-versions/lib/esm/compare.d.ts +0 -19
  50. package/comate-engine/node_modules/compare-versions/lib/esm/compareVersions.d.ts +0 -8
  51. package/comate-engine/node_modules/compare-versions/lib/esm/index.d.ts +0 -5
  52. package/comate-engine/node_modules/compare-versions/lib/esm/satisfies.d.ts +0 -14
  53. package/comate-engine/node_modules/compare-versions/lib/esm/utils.d.ts +0 -7
  54. package/comate-engine/node_modules/compare-versions/lib/esm/validate.d.ts +0 -28
  55. package/comate-engine/node_modules/file-uri-to-path/History.md +0 -21
  56. package/comate-engine/node_modules/file-uri-to-path/README.md +0 -74
  57. package/comate-engine/node_modules/file-uri-to-path/index.d.ts +0 -2
  58. package/comate-engine/node_modules/pkce-challenge/README.md +0 -55
  59. package/comate-engine/node_modules/pkce-challenge/dist/index.browser.d.ts +0 -19
  60. package/comate-engine/node_modules/pkce-challenge/dist/index.node.d.ts +0 -19
  61. package/comate-engine/node_modules/sqlite-vec/README.md +0 -1
  62. package/comate-engine/node_modules/sqlite-vec/index.d.ts +0 -17
  63. package/comate-engine/node_modules/sqlite-vec-darwin-arm64/README.md +0 -1
  64. package/comate-engine/node_modules/sqlite-vec-darwin-x64/README.md +0 -1
  65. package/comate-engine/node_modules/sqlite-vec-linux-arm64/README.md +0 -1
  66. package/comate-engine/node_modules/sqlite-vec-linux-x64/README.md +0 -1
  67. package/comate-engine/node_modules/sqlite-vec-windows-x64/README.md +0 -1
  68. package/comate-engine/node_modules/tree-sitter-bash/README.md +0 -44
  69. package/comate-engine/node_modules/tree-sitter-bash/bindings/node/binding_test.js +0 -9
  70. package/comate-engine/node_modules/tree-sitter-bash/bindings/node/index.d.ts +0 -28
  71. package/comate-engine/node_modules/tree-sitter-bash/bindings/node/index.js +0 -11
  72. package/comate-engine/node_modules/tree-sitter-bash/prebuilds/darwin-arm64/tree-sitter-bash.node +0 -0
  73. package/comate-engine/node_modules/tree-sitter-bash/prebuilds/darwin-x64/tree-sitter-bash.node +0 -0
  74. package/comate-engine/node_modules/tree-sitter-bash/prebuilds/linux-arm64/tree-sitter-bash.node +0 -0
  75. package/comate-engine/node_modules/tree-sitter-bash/prebuilds/linux-x64/tree-sitter-bash.node +0 -0
  76. package/comate-engine/node_modules/tree-sitter-bash/prebuilds/win32-arm64/tree-sitter-bash.node +0 -0
  77. package/comate-engine/node_modules/tree-sitter-bash/prebuilds/win32-x64/tree-sitter-bash.node +0 -0
  78. package/comate-engine/node_modules/tree-sitter-bash/src/grammar.json +0 -7145
  79. package/comate-engine/node_modules/tree-sitter-bash/src/node-types.json +0 -2894
  80. package/comate-engine/node_modules/web-streams-polyfill/README.md +0 -119
  81. package/comate-engine/node_modules/web-streams-polyfill/types/polyfill.d.ts +0 -28
  82. package/comate-engine/node_modules/web-streams-polyfill/types/ponyfill.d.ts +0 -809
  83. package/comate-engine/node_modules/web-tree-sitter/README.md +0 -269
  84. package/comate-engine/node_modules/web-tree-sitter/debug/tree-sitter.cjs +0 -4558
  85. package/comate-engine/node_modules/web-tree-sitter/debug/tree-sitter.js +0 -4516
  86. package/comate-engine/node_modules/web-tree-sitter/debug/tree-sitter.wasm +0 -0
  87. package/comate-engine/node_modules/web-tree-sitter/web-tree-sitter.d.ts +0 -1030
  88. package/comate-engine/node_modules/win-ca/README.md +0 -648
@@ -0,0 +1,728 @@
1
+ """iCafe 自动检测和匹配逻辑
2
+
3
+ 包含卡片匹配、自动检测和格式化输出功能。
4
+ """
5
+
6
+ import datetime
7
+ import json
8
+ import os
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Dict, List, Any, Optional, Tuple
12
+ from concurrent.futures import ThreadPoolExecutor
13
+
14
+ from .client import ICafeQueryClient, DEFAULT_LOOKBACK_DAYS, DEFAULT_MAX_RECORDS
15
+ from git_utils import get_current_user, extract_space_ids_from_git_log
16
+ from .farseer import get_binding_card_types
17
+ from logger import get_logger
18
+
19
+ logger = get_logger()
20
+
21
+
22
+ def is_smart_commit_enabled() -> bool:
23
+ """检查绑卡提交功能是否启用
24
+
25
+ 读取 ~/.comate/settings.json 中的 enableSmartCommit 字段。
26
+ - 值为 false 时返回 False(功能关闭)
27
+ - 值为 true 或字段不存在时返回 True(默认开启)
28
+ """
29
+ settings_path = Path.home() / ".comate" / "settings.json"
30
+ try:
31
+ with open(settings_path, "r", encoding="utf-8") as f:
32
+ config = json.load(f)
33
+ return config.get("enableSmartCommit", True) is not False
34
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
35
+ return True # 文件不存在或解析失败,默认开启
36
+
37
+
38
+ # ========================================
39
+ # 自动检测工具函数
40
+ # ========================================
41
+
42
+ def parse_card_id_from_link(link: str) -> Optional[Dict[str, str]]:
43
+ """从 iCafe 卡片链接或卡片 ID 中解析卡片 ID 和空间前缀
44
+
45
+ Args:
46
+ link: iCafe 卡片链接或卡片 ID,支持以下格式:
47
+ - 完整链接: "https://console.cloud.baidu-int.com/devops/icafe/issue/DevOps-iScan-35835/show"
48
+ - 卡片 ID: "DevOps-iScan-35835"
49
+
50
+ Returns:
51
+ 包含 space_prefix 和 card_id 的字典,解析失败返回 None
52
+ 示例:{"space_prefix": "DevOps-iScan", "card_id": "35835"}
53
+ """
54
+ # 先尝试直接解析卡片 ID 格式(如 "DevOps-iScan-35835")
55
+ card_id_match = re.match(r'^([A-Za-z][A-Za-z0-9_-]*)-(\d+)$', link.strip())
56
+ if card_id_match:
57
+ space_prefix = card_id_match.group(1)
58
+ card_id = card_id_match.group(2)
59
+ return {"space_prefix": space_prefix, "card_id": card_id}
60
+
61
+ # 匹配格式: https://console.cloud.baidu-int.com/devops/icafe/issue/DevOps-iScan-35835/show
62
+ match = re.match(r'.*/issue/([^/]+)(?:/show)?$', link)
63
+ if match:
64
+ full_card_id = match.group(1)
65
+ # 尝试解析为 "SpacePrefix-ID" 格式
66
+ card_match = re.match(r'^(.+)-(\d+)$', full_card_id)
67
+ if card_match:
68
+ space_prefix = card_match.group(1)
69
+ card_id = card_match.group(2)
70
+ return {"space_prefix": space_prefix, "card_id": card_id}
71
+ # 如果不是标准格式,直接返回完整的 ID
72
+ return {"space_prefix": "", "card_id": full_card_id}
73
+ return None
74
+
75
+
76
+ def auto_detect_space(
77
+ client: 'ICafeQueryClient',
78
+ current_user: Optional[str] = None
79
+ ) -> Optional[Dict[str, Any]]:
80
+ """自动检测当前项目对应的 iCafe 空间
81
+
82
+ 交叉匹配 git 提交历史中的卡片引用与用户最近访问的空间,
83
+ 返回最近提交过且用户有访问记录的空间信息。
84
+
85
+ Args:
86
+ client: ICafeQueryClient 实例
87
+ current_user: 用户 ID。未提供时自动从 git config 获取。
88
+
89
+ Returns:
90
+ 检测到的空间信息字典,包含:
91
+ - prefix_code: 空间前缀(如 "dkx"),用于 IQL 查询
92
+ - space_id: 数字 ID(如 52862),用于 storyRule 等接口
93
+ - name: 空间名称
94
+ 检测失败时返回 None
95
+ """
96
+ # 1. 获取用户 ID
97
+ user = current_user or get_current_user()
98
+ if not user:
99
+ return None
100
+
101
+ # 2. 获取最近访问空间
102
+ try:
103
+ api_response = client.get_latest_spaces(user)
104
+ except Exception:
105
+ return None
106
+
107
+ # API 返回格式: {"status": 200, "message": "OK.", "result": [...]}
108
+ if isinstance(api_response, dict):
109
+ spaces = api_response.get('result', [])
110
+ elif isinstance(api_response, list):
111
+ spaces = api_response
112
+ else:
113
+ return None
114
+
115
+ if not spaces:
116
+ return None
117
+
118
+ # 构建 prefixCode -> 完整空间信息的映射(小写归一化)
119
+ prefix_to_space = {}
120
+ for space in spaces:
121
+ prefix = space.get('prefixCode', '')
122
+ if prefix:
123
+ prefix_to_space[prefix.lower()] = space
124
+
125
+ # 3. 用已知前缀从 git log 提取空间引用,然后交叉匹配
126
+ known_prefixes = [s.get('prefixCode', '') for s in spaces if s.get('prefixCode')]
127
+ git_space_ids = extract_space_ids_from_git_log(known_prefixes=known_prefixes)
128
+
129
+ if git_space_ids:
130
+ for git_prefix in git_space_ids:
131
+ key = git_prefix.lower()
132
+ if key in prefix_to_space:
133
+ matched = prefix_to_space[key]
134
+ return {
135
+ 'prefix_code': matched.get('prefixCode', git_prefix),
136
+ 'space_id': matched.get('id'),
137
+ 'name': matched.get('name', ''),
138
+ }
139
+
140
+ # 4. 回退:git log 无结果或交叉匹配失败,取最近访问的第一个空间
141
+ first = spaces[0]
142
+ return {
143
+ 'prefix_code': first.get('prefixCode', ''),
144
+ 'space_id': first.get('id'),
145
+ 'name': first.get('name', ''),
146
+ }
147
+
148
+
149
+ def _quote_iql_value(value: str) -> str:
150
+ """对含 IQL 特殊字符的值加双引号,安全值保持原样"""
151
+ if re.search(r'[(),\s"]', value):
152
+ return f'"{value}"'
153
+ return value
154
+
155
+
156
+ def build_type_filter_iql(type_names: List[str]) -> str:
157
+ """根据卡片类型名称列表构建 IQL 类型过滤条件
158
+
159
+ Args:
160
+ type_names: 类型名称列表,如 ["Bug", "Task", "Story"]
161
+
162
+ Returns:
163
+ IQL 类型过滤表达式,如 '类型 in (Bug, Task, Story)'。
164
+ 含特殊字符的类型名称会自动加双引号。
165
+ 列表为空时返回空字符串。
166
+ """
167
+ if not type_names:
168
+ return ""
169
+ quoted = [_quote_iql_value(name) for name in type_names]
170
+ if len(quoted) == 1:
171
+ return f"类型 = {quoted[0]}"
172
+ return f"类型 in ({', '.join(quoted)})"
173
+
174
+
175
+ # ========================================
176
+ # 空间匹配相关函数
177
+ # ========================================
178
+
179
+ def fetch_space_issue_types(
180
+ client: 'ICafeQueryClient',
181
+ space_prefix: str
182
+ ) -> List[Dict[str, Any]]:
183
+ """获取单个空间的卡片类型列表
184
+
185
+ Args:
186
+ client: ICafeQueryClient 实例
187
+ space_prefix: 空间前缀
188
+
189
+ Returns:
190
+ 格式化的卡片类型列表
191
+ """
192
+ try:
193
+ raw_response = client.get_space_issue_types(space_prefix)
194
+ formatted_types = []
195
+ issue_types = None
196
+ if isinstance(raw_response, dict):
197
+ if 'result' in raw_response and isinstance(raw_response['result'], dict):
198
+ issue_types = raw_response['result'].get('issueTypes', [])
199
+ elif 'issueTypes' in raw_response:
200
+ issue_types = raw_response['issueTypes']
201
+ elif 'data' in raw_response and isinstance(raw_response['data'], dict):
202
+ issue_types = raw_response['data'].get('issueTypes', [])
203
+ elif isinstance(raw_response, list):
204
+ issue_types = raw_response
205
+
206
+ if issue_types and isinstance(issue_types, list):
207
+ for t in issue_types:
208
+ if isinstance(t, dict):
209
+ type_id = t.get('id', t.get('issueTypeId', ''))
210
+ type_name = t.get('name', t.get('type', ''))
211
+ if type_id or type_name:
212
+ formatted_types.append({
213
+ 'id': str(type_id),
214
+ 'name': str(type_name)
215
+ })
216
+ return formatted_types
217
+ except Exception:
218
+ return []
219
+
220
+
221
+ def fetch_all_space_types(
222
+ client: 'ICafeQueryClient',
223
+ space_code_map: Dict[str, Dict[str, Any]]
224
+ ) -> Dict[str, List[Dict[str, Any]]]:
225
+ """并行获取所有空间的卡片类型
226
+
227
+ Args:
228
+ client: ICafeQueryClient 实例
229
+ space_code_map: 空间前缀 -> 空间信息的映射
230
+
231
+ Returns:
232
+ 空间前缀 -> 卡片类型列表的映射
233
+ """
234
+ space_types_map: Dict[str, List[Dict[str, Any]]] = {}
235
+ with ThreadPoolExecutor(max_workers=5) as pool:
236
+ # 同时提交所有任务并记录对应的前缀
237
+ future_to_prefix = {}
238
+ for prefix_key, space_info in space_code_map.items():
239
+ original_prefix = space_info.get('prefixCode', prefix_key)
240
+ future = pool.submit(fetch_space_issue_types, client, original_prefix)
241
+ future_to_prefix[future] = prefix_key
242
+
243
+ # 收集结果
244
+ for future in future_to_prefix:
245
+ types_list = future.result()
246
+ prefix_key = future_to_prefix[future]
247
+ space_types_map[prefix_key] = types_list
248
+
249
+ return space_types_map
250
+
251
+
252
+ def resolve_space(
253
+ client: 'ICafeQueryClient',
254
+ user: str,
255
+ space_prefix: Optional[str],
256
+ spaces_future,
257
+ git_log_future
258
+ ) -> Tuple[str, Optional[int], str, Dict[str, Dict[str, Any]]]:
259
+ """解析并匹配空间
260
+
261
+ Args:
262
+ client: ICafeQueryClient 实例
263
+ user: 用户 ID
264
+ space_prefix: 指定的空间前缀
265
+ spaces_future: 并行获取的空间列表 Future
266
+ git_log_future: 并行获取的 git log Future
267
+
268
+ Returns:
269
+ (space_prefix, space_id_num, space_name, space_code_map)
270
+ """
271
+ space_id_num: Optional[int] = None
272
+ space_name: str = ""
273
+ space_code_map: Dict[str, Dict[str, Any]] = {}
274
+
275
+ try:
276
+ api_response = spaces_future.result()
277
+ spaces_list = (api_response.get('result', [])
278
+ if isinstance(api_response, dict) else api_response)
279
+ for s in (spaces_list or []):
280
+ if isinstance(s, dict):
281
+ code = s.get('prefixCode', '')
282
+ if code:
283
+ space_code_map[code.lower()] = s
284
+ logger.info("获取到 %d 个最近访问空间: %s", len(space_code_map), list(space_code_map.keys()))
285
+ except Exception as e:
286
+ logger.warning("获取空间列表失败: %s", e)
287
+
288
+ # 匹配空间
289
+ if space_prefix:
290
+ matched = space_code_map.get(space_prefix.lower())
291
+ if matched:
292
+ space_id_num = matched.get('id')
293
+ space_name = matched.get('name', '')
294
+ space_prefix = matched.get('prefixCode', space_prefix)
295
+ logger.info("空间精确匹配成功: prefix=%s, id=%s, name=%s", space_prefix, space_id_num, space_name)
296
+ else:
297
+ # 用已知前缀重新跑 git log 精确匹配
298
+ known_prefixes = [s.get('prefixCode', '') for s in space_code_map.values() if s.get('prefixCode')]
299
+ git_space_ids = git_log_future.result()
300
+
301
+ # 如果有已知前缀但 git_log 返回的是旧的正则结果,需要重新精确匹配
302
+ if known_prefixes:
303
+ git_space_ids = extract_space_ids_from_git_log(known_prefixes=known_prefixes)
304
+
305
+ logger.info("git log 提取的前缀: %s", git_space_ids)
306
+
307
+ matched_space = None
308
+ if git_space_ids:
309
+ for git_prefix in git_space_ids:
310
+ if git_prefix.lower() in space_code_map:
311
+ matched_space = space_code_map[git_prefix.lower()]
312
+ logger.info("交叉匹配成功: prefix=%s", git_prefix)
313
+ break
314
+
315
+ if not matched_space and space_code_map:
316
+ matched_space = next(iter(space_code_map.values()))
317
+ logger.warning("交叉匹配无结果,回退到最近访问空间: %s", matched_space.get('prefixCode', ''))
318
+
319
+ if matched_space:
320
+ space_prefix = matched_space.get('prefixCode', '')
321
+ space_id_num = matched_space.get('id')
322
+ space_name = matched_space.get('name', '')
323
+
324
+ if not space_prefix:
325
+ logger.error("无法自动检测空间")
326
+ raise ValueError("无法自动检测空间,请通过 space_prefix 参数指定空间前缀")
327
+
328
+ return space_prefix, space_id_num, space_name, space_code_map
329
+
330
+
331
+ def build_type_filter(
332
+ space_id_num: Optional[int],
333
+ include_type_filter: bool
334
+ ) -> Tuple[str, List[Dict[str, Any]]]:
335
+ """构建类型过滤 IQL
336
+
337
+ Args:
338
+ space_id_num: 空间数字 ID
339
+ include_type_filter: 是否启用类型过滤
340
+
341
+ Returns:
342
+ (type_filter, available_types)
343
+ """
344
+ type_filter = ""
345
+ available_types = []
346
+
347
+ if include_type_filter and space_id_num is not None:
348
+ type_items = get_binding_card_types(space_id_num)
349
+ available_types = type_items
350
+ type_names = [t['name'] for t in type_items]
351
+ type_filter = build_type_filter_iql(type_names)
352
+
353
+ return type_filter, available_types
354
+
355
+
356
+ def build_iql_and_query(
357
+ client: 'ICafeQueryClient',
358
+ space_prefix: str,
359
+ space_id_num: Optional[int],
360
+ lookback_days: int,
361
+ type_filter: str,
362
+ max_records: int
363
+ ) -> Dict[str, Any]:
364
+ """构建 IQL 并查询卡片
365
+
366
+ Args:
367
+ client: ICafeQueryClient 实例
368
+ space_prefix: 空间前缀
369
+ space_id_num: 空间数字 ID
370
+ lookback_days: 回溯天数
371
+ type_filter: 类型过滤 IQL
372
+ max_records: 最大记录数
373
+
374
+ Returns:
375
+ 查询结果
376
+ """
377
+ cutoff_date = (datetime.date.today() - datetime.timedelta(days=lookback_days)).isoformat()
378
+ active_iql = (
379
+ '负责人 = currentUser'
380
+ ' AND 流程状态 in (开发中, 进行中, 待开发, 新建, open)'
381
+ f' AND 最后修改时间 > "{cutoff_date}"'
382
+ )
383
+
384
+ if type_filter:
385
+ final_iql = f"{type_filter} AND ({active_iql})"
386
+ else:
387
+ final_iql = active_iql
388
+
389
+ try:
390
+ result = client.query_cards(
391
+ space_id=space_prefix,
392
+ iql=final_iql,
393
+ max_records=max_records,
394
+ is_desc=True,
395
+ order="最后修改时间",
396
+ )
397
+ except Exception as e:
398
+ logger.error("IQL 查询异常: %s", e)
399
+ result = {"cards": []}
400
+
401
+ # 提取简化卡片列表
402
+ raw_cards = result.get("cards", [])
403
+ cards = []
404
+ for card in raw_cards:
405
+ raw_type = card.get("type", "")
406
+ card_type = (raw_type.get("name", str(raw_type))
407
+ if isinstance(raw_type, dict) else str(raw_type))
408
+ raw_status = card.get("status", "")
409
+ card_status = (raw_status.get("name", str(raw_status))
410
+ if isinstance(raw_status, dict) else str(raw_status))
411
+ cards.append({
412
+ "sequence": card.get("sequence", ""),
413
+ "title": card.get("title", ""),
414
+ "type": card_type,
415
+ "status": card_status,
416
+ })
417
+
418
+ return {"cards": cards, "final_iql": final_iql}
419
+
420
+
421
+ def build_output(
422
+ space_prefix: str,
423
+ space_id_num: Optional[int],
424
+ space_name: str,
425
+ cards: List[Dict],
426
+ final_iql: str,
427
+ available_types: List[Dict],
428
+ space_code_map: Dict[str, Dict[str, Any]],
429
+ space_types_map: Dict[str, List[Dict]],
430
+ ) -> Dict[str, Any]:
431
+ """构建最终输出
432
+
433
+ Args:
434
+ space_prefix: 空间前缀
435
+ space_id_num: 空间数字 ID
436
+ space_name: 空间名称
437
+ cards: 卡片列表
438
+ final_iql: 最终 IQL
439
+ available_types: 可用类型列表
440
+ space_code_map: 空间映射
441
+ space_types_map: 空间类型映射
442
+
443
+ Returns:
444
+ 完整的输出字典
445
+ """
446
+ # 构建可用空间列表(包含每个空间的卡片类型)
447
+ available_spaces = []
448
+ for s in space_code_map.values():
449
+ prefix = s.get('prefixCode', '')
450
+ available_spaces.append({
451
+ "id": s.get('id'),
452
+ "prefix": prefix,
453
+ "name": s.get('name', ''),
454
+ "types": space_types_map.get(prefix.lower(), [])
455
+ })
456
+
457
+ # 构建默认值
458
+ defaults = {
459
+ "title": "",
460
+ "type_id": "",
461
+ "space_id": space_id_num
462
+ }
463
+ # 从当前匹配空间的 types 中取第一个类型作为默认值
464
+ current_space_types = space_types_map.get(space_prefix.lower(), []) if space_prefix else []
465
+ if current_space_types:
466
+ defaults["type_id"] = current_space_types[0]["id"]
467
+ elif available_types:
468
+ # 兜底使用 farseer 的 available_types
469
+ defaults["type_id"] = available_types[0]["id"]
470
+
471
+ output: Dict[str, Any] = {
472
+ "cards": cards,
473
+ "space_prefix": space_prefix,
474
+ "space_id": space_id_num,
475
+ "space_name": space_name,
476
+ "final_iql": final_iql,
477
+ "available_types": available_types,
478
+ "available_spaces": available_spaces,
479
+ "defaults": defaults,
480
+ }
481
+
482
+ return output
483
+
484
+
485
+ def build_match_prompt(
486
+ diff_summary: Dict[str, Any],
487
+ cards: List[Dict[str, str]],
488
+ space_prefix: str,
489
+ ) -> str:
490
+ """构建卡片匹配的 LLM 提示词(内部函数)"""
491
+ files_list = "\n".join(f" - {f}" for f in diff_summary["changed_files"])
492
+
493
+ card_lines = []
494
+ for card in cards:
495
+ seq = card["sequence"]
496
+ card_id = f"{space_prefix}-{seq}" if seq else "N/A"
497
+ card_lines.append(
498
+ f" - [{card_id}] ({card['type']}) {card['title']} [状态: {card['status']}]"
499
+ )
500
+ cards_text = "\n".join(card_lines) if card_lines else " (无匹配卡片)"
501
+
502
+ truncation_note = ""
503
+ if diff_summary.get("truncated"):
504
+ truncation_note = "\n注意:diff 内容因过长已被截断,请基于已展示部分进行判断。\n"
505
+
506
+ return f"""请根据以下 git diff 变更内容,对每张候选 iCafe 卡片进行语义匹配度评分,并生成 commit message。
507
+
508
+ ## 变更文件
509
+ {files_list}
510
+
511
+ ## 变更统计
512
+ {diff_summary["stat_summary"]}
513
+
514
+ ## Git Diff 内容
515
+ ```
516
+ {diff_summary["diff_content"]}
517
+ ```
518
+ {truncation_note}
519
+ ## 候选卡片列表
520
+ {cards_text}
521
+
522
+ ## 评分要求
523
+
524
+ 请对**每张**候选卡片进行匹配度评分(0-100 分),评分维度:
525
+ - **语义相关性**(主要):diff 修改的模块、函数、业务逻辑是否与卡片标题描述的任务相关
526
+ - **类型匹配度**(次要):diff 的变更性质(修复 bug、新增功能、重构等)是否与卡片类型吻合
527
+ - 60 分及以上视为"匹配",低于 60 分视为"不匹配"
528
+
529
+ ## 输出格式
530
+
531
+ 请严格按以下 JSON 格式输出,不要输出其他内容:
532
+
533
+ ```json
534
+ {{
535
+ "ranked_cards": [
536
+ {{"sequence": "<卡片序号>", "score": <0-100>, "reason": "<一句话匹配理由>"}},
537
+ ...
538
+ ],
539
+ "has_good_match": <true|false>,
540
+ "recommended_commit_message": "<{space_prefix}-<序号> <简要描述>>"
541
+ }}
542
+ ```
543
+
544
+ 说明:
545
+ - `ranked_cards`:所有候选卡片按 score 降序排列,每张卡片都必须包含
546
+ - `has_good_match`:是否存在 score >= 60 的卡片
547
+ - `recommended_commit_message`:如果有匹配卡片,基于最佳匹配卡片生成;如果无匹配卡片,基于 diff 内容生成(卡片 ID 部分留空为 `{space_prefix}-???`)
548
+ - **低于 60 分的卡片将被过滤,不会展示给用户**"""
549
+
550
+
551
+ # ========================================
552
+ # 主入口函数
553
+ # ========================================
554
+
555
+ def find_matching_card(
556
+ client: Optional['ICafeQueryClient'] = None,
557
+ space_prefix: Optional[str] = None,
558
+ current_user: Optional[str] = None,
559
+ max_records: int = DEFAULT_MAX_RECORDS,
560
+ lookback_days: int = DEFAULT_LOOKBACK_DAYS,
561
+ include_type_filter: bool = True,
562
+ ) -> Dict[str, Any]:
563
+ """查询当前用户的活跃 iCafe 卡片
564
+
565
+ 封装完整流程:
566
+ 1. 并行获取:当前用户 / 最近访问空间 / git log
567
+ 2. 交叉匹配确定 iCafe 空间
568
+ 3. 构建 IQL 查询卡片
569
+
570
+ 注意:git diff 获取已移至 Agent 侧执行,以支持多 workspace 场景。
571
+ 脚本仅负责 iCafe 数据获取。
572
+
573
+ Args:
574
+ client: ICafeQueryClient 实例。未提供时自动创建。
575
+ space_prefix: 空间前缀(如 "dkx")。未提供时自动检测。
576
+ current_user: 用户 ID。未提供时自动获取。
577
+ max_records: 查询卡片的最大数量,默认 20。
578
+ lookback_days: 回溯天数,默认 14。
579
+ include_type_filter: 是否调用 farseer 获取卡片类型过滤,默认 True。
580
+
581
+ Returns:
582
+ {
583
+ "cards": [ { sequence, title, type, status }, ... ],
584
+ "space_prefix": "...",
585
+ "space_id": 12345,
586
+ "space_name": "空间名称",
587
+ "final_iql": "...",
588
+ "available_types": [{"id": "5009", "name": "Bug"}, ...],
589
+ "available_spaces": [{"id": 12345, "prefix": "dkx", "name": "空间名称"}, ...],
590
+ "defaults": {
591
+ "title": "", # 由 Agent 根据 diff 生成
592
+ "type_id": "5009", # 默认卡片类型 ID
593
+ "space_id": 12345 # 默认空间 ID
594
+ }
595
+ }
596
+
597
+ Raises:
598
+ ValueError: 无法确定空间或用户时
599
+ """
600
+ # 前置检查:绑卡提交开关
601
+ if not is_smart_commit_enabled():
602
+ logger.info("绑卡提交功能已关闭,跳过执行")
603
+ return {"disabled": True, "message": "绑卡提交功能已关闭(enableSmartCommit: false)"}
604
+
605
+ logger.info("find_matching_card 开始执行, include_type_filter=%s", include_type_filter)
606
+
607
+ if client is None:
608
+ client: ICafeQueryClient = ICafeQueryClient()
609
+
610
+ # ---- 阶段 1:并行获取本地 git 数据 + API 空间列表 ----
611
+ user = current_user or get_current_user()
612
+ if not user:
613
+ logger.error("无法获取当前用户 ID")
614
+ raise ValueError("无法获取当前用户 ID,请通过 current_user 参数指定")
615
+
616
+ logger.info("提交并行任务: get_latest_spaces(user=%s), extract_git_log", user)
617
+ with ThreadPoolExecutor(max_workers=2) as pool:
618
+ spaces_future = pool.submit(client.get_latest_spaces, user)
619
+ git_log_future = pool.submit(extract_space_ids_from_git_log)
620
+
621
+ # ---- 阶段 2:解析空间列表 + 匹配空间 + 获取各空间卡片类型 ----
622
+ try:
623
+ space_prefix, space_id_num, space_name, space_code_map = resolve_space(
624
+ client, user, space_prefix, spaces_future, git_log_future
625
+ )
626
+ logger.info("空间匹配结果: prefix=%s, id=%s, name=%s", space_prefix, space_id_num, space_name)
627
+ except Exception as e:
628
+ logger.error("空间解析失败: %s", e)
629
+ raise
630
+
631
+ # 并行获取所有空间的卡片类型
632
+ space_types_map = fetch_all_space_types(client, space_code_map)
633
+ logger.info("获取到 %d 个空间的卡片类型", len(space_types_map))
634
+
635
+ # ---- 阶段 3:构建 IQL 并查询卡片 ----
636
+ type_filter, available_types = build_type_filter(space_id_num, include_type_filter)
637
+ result = build_iql_and_query(client, space_prefix, space_id_num, lookback_days, type_filter, max_records)
638
+ logger.info("IQL 查询完成, 获得 %d 张卡片, iql=%s", len(result["cards"]), result["final_iql"])
639
+
640
+ # ---- 阶段 4:构建输出 ----
641
+ output = build_output(
642
+ space_prefix=space_prefix,
643
+ space_id_num=space_id_num,
644
+ space_name=space_name,
645
+ cards=result["cards"],
646
+ final_iql=result["final_iql"],
647
+ available_types=available_types,
648
+ space_code_map=space_code_map,
649
+ space_types_map=space_types_map,
650
+ )
651
+ logger.info("find_matching_card 执行完成, 返回 %d 张卡片", len(output.get("cards", [])))
652
+ return output
653
+
654
+
655
+ def match_diff_to_cards(
656
+ client: Optional['ICafeQueryClient'] = None,
657
+ space_prefix: Optional[str] = None,
658
+ current_user: Optional[str] = None,
659
+ max_records: int = DEFAULT_MAX_RECORDS,
660
+ ) -> Dict[str, Any]:
661
+ """向后兼容包装器,委托给 find_matching_card()"""
662
+ return find_matching_card(
663
+ client=client,
664
+ space_prefix=space_prefix,
665
+ current_user=current_user,
666
+ max_records=max_records,
667
+ include_type_filter=True,
668
+ )
669
+
670
+
671
+ # ========================================
672
+ # 格式化输出函数
673
+ # ========================================
674
+
675
+ def format_card_summary(card: Dict[str, Any], space_id: str) -> str:
676
+ """将卡片数据格式化为易读的摘要文本
677
+
678
+ Args:
679
+ card: 卡片数据字典
680
+ space_id: 空间 ID(用于生成链接)
681
+
682
+ Returns:
683
+ 格式化的卡片摘要字符串
684
+ """
685
+ card_id = card.get('sequence') or card.get('id', 'N/A')
686
+ title = card.get('title', '无标题')
687
+ status = card.get('status', 'N/A')
688
+ assignee = card.get('assignee', 'N/A')
689
+ card_type = card.get('type', 'N/A')
690
+ priority = card.get('priority', 'N/A')
691
+ created = card.get('createdAt', card.get('created_at', 'N/A'))
692
+ updated = card.get('updatedAt', card.get('updated_at', 'N/A'))
693
+
694
+ url = f"https://console.cloud.baidu-int.com/devops/icafe/issue/{space_id}-{card_id}/show"
695
+
696
+ lines = [
697
+ f"[{card_id}] {title}",
698
+ f" 类型: {card_type} | 状态: {status} | 优先级: {priority}",
699
+ f" 负责人: {assignee}",
700
+ f" 创建时间: {created} | 更新时间: {updated}",
701
+ f" 链接: {url}",
702
+ ]
703
+ return "\n".join(lines)
704
+
705
+
706
+ def format_query_result(result: Dict[str, Any], space_id: str) -> str:
707
+ """将查询结果格式化为易读的文本
708
+
709
+ Args:
710
+ result: query_cards 返回的结果字典
711
+ space_id: 空间 ID
712
+
713
+ Returns:
714
+ 格式化的查询结果字符串
715
+ """
716
+ cards = result.get('cards', result.get('data', []))
717
+ total = result.get('total', result.get('totalCount', len(cards)))
718
+
719
+ if not cards:
720
+ return "未查询到符合条件的卡片。"
721
+
722
+ lines = [f"共查询到 {total} 张卡片:\n"]
723
+ for i, card in enumerate(cards, 1):
724
+ lines.append(f"--- 卡片 {i} ---")
725
+ lines.append(format_card_summary(card, space_id))
726
+ lines.append("")
727
+
728
+ return "\n".join(lines)