@comate/zulu 1.3.3-internal.3 → 1.3.3-internal.4

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.
@@ -0,0 +1,309 @@
1
+ """共享 payload 验证和转换函数
2
+
3
+ 为 build_icafe_cards_payload.py 和 build_git_commit_payload.py 提供验证和转换逻辑。
4
+ 每个验证函数返回错误列表(list[str]),空列表表示验证通过。
5
+ """
6
+
7
+ import json
8
+
9
+
10
+ def _check_type(value, expected_type, path):
11
+ """检查值的类型,返回错误列表。"""
12
+ if not isinstance(value, expected_type):
13
+ type_name = expected_type.__name__ if isinstance(expected_type, type) else str(expected_type)
14
+ actual_name = type(value).__name__
15
+ if expected_type is str and isinstance(value, (int, float)):
16
+ actual_name = "数字"
17
+ return [f"'{path}' 应为 {type_name},实际为 {actual_name}"]
18
+ return []
19
+
20
+
21
+ def convert_icafe_cards_payload(raw):
22
+ """将模型传入的原始数据转换为 camelCase payload。
23
+
24
+ 接受 snake_case 或 camelCase 的混合输入,完成转换和合并。
25
+ 模型只需要把 match_card_cli.py 的输出原封传入,加上 viewMode 和 cards(排序后)。
26
+
27
+ Args:
28
+ raw: 模型传入的原始 dict
29
+
30
+ Returns:
31
+ (converted_data, errors)
32
+ """
33
+ errors = []
34
+
35
+ if not isinstance(raw, dict):
36
+ return None, ["输入必须是一个 JSON 对象"]
37
+
38
+ # 从 raw 中提取字段,兼容 snake_case 和 camelCase
39
+ cards = raw.get("cards", [])
40
+ space_prefix = raw.get("spacePrefix") or raw.get("space_prefix", "")
41
+ space_id = raw.get("spaceId") or raw.get("space_id", 0)
42
+ space_name = raw.get("spaceName") or raw.get("space_name", "")
43
+ available_spaces = raw.get("availableSpaces") or raw.get("available_spaces", [])
44
+ view_mode = raw.get("viewMode") or raw.get("view_mode", "")
45
+ defaults_raw = raw.get("defaults", {})
46
+
47
+ # 构建 defaults
48
+ if isinstance(defaults_raw, dict):
49
+ defaults = {
50
+ "title": defaults_raw.get("title", ""),
51
+ "typeId": defaults_raw.get("typeId") or defaults_raw.get("type_id", ""),
52
+ "typeName": defaults_raw.get("typeName") or defaults_raw.get("type_name", ""),
53
+ "spaceId": defaults_raw.get("spaceId") or defaults_raw.get("space_id", 0),
54
+ }
55
+ else:
56
+ defaults = {"title": "", "typeId": "", "typeName": "", "spaceId": 0}
57
+
58
+ # 如果模型没传 defaults 但 raw 里有顶层字段,从顶层取
59
+ if not defaults.get("typeId") and raw.get("available_types"):
60
+ defaults["typeId"] = str(raw["available_types"][0]["id"]) if raw["available_types"] else ""
61
+ defaults["typeName"] = raw["available_types"][0]["name"] if raw["available_types"] else ""
62
+ if not defaults.get("spaceId") and space_id:
63
+ defaults["spaceId"] = space_id
64
+
65
+ data = {
66
+ "cards": cards,
67
+ "spacePrefix": space_prefix,
68
+ "spaceId": space_id,
69
+ "spaceName": space_name,
70
+ "availableSpaces": available_spaces,
71
+ "viewMode": view_mode,
72
+ "defaults": defaults,
73
+ }
74
+
75
+ # 验证
76
+ errors.extend(_validate_icafe_cards(data))
77
+
78
+ return data, errors
79
+
80
+
81
+ def _validate_icafe_cards(data):
82
+ """验证转换后的 icafe-cards payload。"""
83
+ errors = []
84
+
85
+ # cards
86
+ if not isinstance(data["cards"], list):
87
+ errors.append("'cards' 应为数组")
88
+ else:
89
+ for i, card in enumerate(data["cards"]):
90
+ if not isinstance(card, dict):
91
+ errors.append(f"'cards[{i}]' 应为对象")
92
+ continue
93
+ for key in ("sequence", "title", "type", "status"):
94
+ if key not in card:
95
+ errors.append(f"'cards[{i}].{key}' 缺失")
96
+ elif not isinstance(card[key], str):
97
+ # sequence 可能是数字,转成字符串
98
+ if key == "sequence":
99
+ card[key] = str(card[key])
100
+ else:
101
+ errors.append(f"'cards[{i}].{key}' 应为字符串")
102
+
103
+ # spacePrefix
104
+ if not isinstance(data["spacePrefix"], str):
105
+ errors.append("'spacePrefix' 应为字符串")
106
+ elif not data["spacePrefix"]:
107
+ errors.append("'spacePrefix' 不能为空字符串")
108
+
109
+ # spaceId
110
+ if not isinstance(data["spaceId"], (int, float)):
111
+ errors.append("'spaceId' 应为数字")
112
+
113
+ # spaceName
114
+ if not isinstance(data["spaceName"], str):
115
+ errors.append("'spaceName' 应为字符串")
116
+
117
+ # availableSpaces
118
+ if not isinstance(data["availableSpaces"], list):
119
+ errors.append("'availableSpaces' 应为数组")
120
+ else:
121
+ for i, space in enumerate(data["availableSpaces"]):
122
+ if not isinstance(space, dict):
123
+ errors.append(f"'availableSpaces[{i}]' 应为对象")
124
+ continue
125
+ for key in ("id", "prefix", "name", "types"):
126
+ if key not in space:
127
+ errors.append(f"'availableSpaces[{i}].{key}' 缺失")
128
+ # types 检查(但允许为空)
129
+ if "types" in space and isinstance(space["types"], list):
130
+ for j, t in enumerate(space["types"]):
131
+ if not isinstance(t, dict):
132
+ errors.append(f"'availableSpaces[{i}].types[{j}]' 应为对象")
133
+ continue
134
+ if "id" not in t:
135
+ errors.append(f"'availableSpaces[{i}].types[{j}].id' 缺失")
136
+ if "name" not in t:
137
+ errors.append(f"'availableSpaces[{i}].types[{j}].name' 缺失")
138
+
139
+ # viewMode
140
+ valid_modes = ("list", "create")
141
+ if not isinstance(data["viewMode"], str):
142
+ errors.append(f"'viewMode' 应为字符串,取值范围为 {valid_modes}")
143
+ elif data["viewMode"] not in valid_modes:
144
+ errors.append(f"'viewMode' 应为 {valid_modes} 之一,实际为 '{data['viewMode']}'")
145
+
146
+ # defaults
147
+ if not isinstance(data["defaults"], dict):
148
+ errors.append("'defaults' 应为对象")
149
+ else:
150
+ defaults = data["defaults"]
151
+ for key in ("title", "typeId", "typeName", "spaceId"):
152
+ if key not in defaults:
153
+ errors.append(f"'defaults.{key}' 缺失")
154
+ if "title" in defaults:
155
+ errors.extend(_check_type(defaults["title"], str, "defaults.title"))
156
+ if "typeId" in defaults:
157
+ errors.extend(_check_type(defaults["typeId"], str, "defaults.typeId"))
158
+ if "typeName" in defaults:
159
+ errors.extend(_check_type(defaults["typeName"], str, "defaults.typeName"))
160
+ if "spaceId" in defaults:
161
+ if not isinstance(defaults["spaceId"], (int, float)):
162
+ errors.append("'defaults.spaceId' 应为数字")
163
+ else:
164
+ defaults["spaceId"] = int(defaults["spaceId"])
165
+
166
+ # spaceId 转为 int
167
+ if isinstance(data["spaceId"], float):
168
+ data["spaceId"] = int(data["spaceId"])
169
+
170
+ return errors
171
+
172
+
173
+ def convert_git_commit_payload(raw):
174
+ """将模型传入的原始数据转换为并验证 git-commit payload。
175
+
176
+ 模型需要传入 commitMessage、boundCard、workspaces。
177
+ workspaces 中的 diffSummary 保持 snake_case(与 git_diff_cli.py 输出一致)。
178
+
179
+ Args:
180
+ raw: 模型传入的原始 dict
181
+
182
+ Returns:
183
+ (converted_data, errors)
184
+ """
185
+ errors = []
186
+
187
+ if not isinstance(raw, dict):
188
+ return None, ["输入必须是一个 JSON 对象"]
189
+
190
+ commit_message = raw.get("commitMessage") or raw.get("commit_message", "")
191
+ bound_card = raw.get("boundCard") or raw.get("boundCard") or raw.get("icafe_card")
192
+ workspaces = raw.get("workspaces", [])
193
+
194
+ data = {
195
+ "commitMessage": commit_message,
196
+ "boundCard": bound_card,
197
+ "workspaces": workspaces,
198
+ }
199
+
200
+ errors.extend(_validate_git_commit(data))
201
+
202
+ return data, errors
203
+
204
+
205
+ def _validate_git_commit(data):
206
+ """验证转换后的 git-commit payload。"""
207
+ errors = []
208
+
209
+ # commitMessage
210
+ if not isinstance(data["commitMessage"], str):
211
+ errors.append("'commitMessage' 应为字符串")
212
+ elif not data["commitMessage"]:
213
+ errors.append("'commitMessage' 不能为空字符串")
214
+
215
+ # boundCard
216
+ bound_card = data["boundCard"]
217
+ if bound_card is not None and not isinstance(bound_card, dict):
218
+ errors.append("'boundCard' 应为对象或 null")
219
+ elif isinstance(bound_card, dict):
220
+ for key in ("sequence", "title", "type", "status", "spacePrefix"):
221
+ if key not in bound_card:
222
+ errors.append(f"'boundCard.{key}' 缺失")
223
+ for key in ("sequence", "title", "type", "status", "spacePrefix"):
224
+ if key in bound_card and not isinstance(bound_card[key], str):
225
+ # sequence 可能是数字
226
+ if key == "sequence":
227
+ bound_card[key] = str(bound_card[key])
228
+ else:
229
+ errors.append(f"'boundCard.{key}' 应为字符串")
230
+
231
+ # workspaces
232
+ if not isinstance(data["workspaces"], list):
233
+ if isinstance(data["workspaces"], str):
234
+ errors.append("'workspaces' 应为数组,不能是字符串")
235
+ else:
236
+ errors.append("'workspaces' 应为数组")
237
+ elif len(data["workspaces"]) == 0:
238
+ errors.append("'workspaces' 数组不能为空")
239
+ else:
240
+ for i, ws in enumerate(data["workspaces"]):
241
+ p = f"workspaces[{i}]"
242
+ if not isinstance(ws, dict):
243
+ errors.append(f"'{p}' 应为对象")
244
+ continue
245
+
246
+ ws_required = {"workspace", "repoName", "currentBranch",
247
+ "remoteBranches", "hasChanges", "diffSummary"}
248
+ for key in ws_required:
249
+ if key not in ws:
250
+ errors.append(f"'{p}.{key}' 缺失")
251
+
252
+ if "workspace" in ws:
253
+ errors.extend(_check_type(ws["workspace"], str, f"{p}.workspace"))
254
+ if "repoName" in ws:
255
+ errors.extend(_check_type(ws["repoName"], str, f"{p}.repoName"))
256
+ if "currentBranch" in ws:
257
+ errors.extend(_check_type(ws["currentBranch"], str, f"{p}.currentBranch"))
258
+
259
+ # remoteBranches
260
+ if "remoteBranches" in ws:
261
+ if not isinstance(ws["remoteBranches"], list):
262
+ errors.append(f"'{p}.remoteBranches' 应为数组")
263
+ else:
264
+ for j, branch in enumerate(ws["remoteBranches"]):
265
+ bp = f"{p}.remoteBranches[{j}]"
266
+ if not isinstance(branch, dict):
267
+ errors.append(f"'{bp}' 应为对象")
268
+ continue
269
+ if "name" not in branch:
270
+ errors.append(f"'{bp}.name' 缺失")
271
+ elif not isinstance(branch["name"], str):
272
+ errors.append(f"'{bp}.name' 应为字符串")
273
+ if "isCurrent" not in branch:
274
+ errors.append(f"'{bp}.isCurrent' 缺失")
275
+ elif not isinstance(branch["isCurrent"], bool):
276
+ errors.append(f"'{bp}.isCurrent' 应为布尔值")
277
+
278
+ # hasChanges + diffSummary 一致性
279
+ if "hasChanges" in ws:
280
+ if not isinstance(ws["hasChanges"], bool):
281
+ errors.append(f"'{p}.hasChanges' 应为布尔值")
282
+ else:
283
+ ds = ws.get("diffSummary")
284
+ if not ws["hasChanges"] and ds is not None:
285
+ errors.append(f"'{p}.hasChanges' 为 false 时,'{p}.diffSummary' 必须为 null")
286
+ elif ws["hasChanges"] and ds is None:
287
+ errors.append(f"'{p}.hasChanges' 为 true 时,'{p}.diffSummary' 不能为 null")
288
+ elif ws["hasChanges"] and isinstance(ds, dict):
289
+ if "changed_files" not in ds:
290
+ errors.append(f"'{p}.diffSummary.changed_files' 缺失")
291
+ elif not isinstance(ds["changed_files"], list):
292
+ errors.append(f"'{p}.diffSummary.changed_files' 应为数组")
293
+ else:
294
+ for k, cf in enumerate(ds["changed_files"]):
295
+ cfp = f"{p}.diffSummary.changed_files[{k}]"
296
+ if not isinstance(cf, dict):
297
+ errors.append(f"'{cfp}' 应为对象")
298
+ continue
299
+ for fkey, ftype in [("file", str), ("insertions", int), ("deletions", int)]:
300
+ if fkey not in cf:
301
+ errors.append(f"'{cfp}.{fkey}' 缺失")
302
+ elif not isinstance(cf[fkey], ftype):
303
+ errors.append(f"'{cfp}.{fkey}' 应为 {ftype.__name__}")
304
+ if "stat_summary" not in ds:
305
+ errors.append(f"'{p}.diffSummary.stat_summary' 缺失")
306
+ elif not isinstance(ds["stat_summary"], str):
307
+ errors.append(f"'{p}.diffSummary.stat_summary' 应为字符串")
308
+
309
+ return errors