aitable-workflow-cli 0.1.28 → 0.1.29-beta.1790736949347

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 (60) hide show
  1. package/dist/assistant/assets/index-BA6il19d.css +1 -0
  2. package/dist/assistant/assets/{index-B3LvYfRW.js → index-BMzAOp70.js} +31 -31
  3. package/dist/assistant/index.html +2 -2
  4. package/dist/chunk-BBP5RVFU.js +15 -0
  5. package/dist/cli.js +180 -73
  6. package/dist/{config-ui-server-4SVD42Z3.js → config-ui-server-UWELHUH4.js} +1 -1
  7. package/dist/web/assets/index-Bnmnydwk.css +1 -0
  8. package/dist/web/assets/index-Dbs1xWB9.js +331 -0
  9. package/dist/web/index.html +2 -2
  10. package/dist/web/install.ps1 +173 -0
  11. package/dist/web/install.sh +188 -0
  12. package/package.json +3 -3
  13. package/templates/ai-native-sdlc/.env.example +11 -0
  14. package/templates/ai-native-sdlc/aitable-workflow.config.yml +47 -0
  15. package/templates/ai-native-sdlc/coding-agents.config.json +17 -0
  16. package/templates/ai-native-sdlc/docs/plans/2026-09-15-ai-native-sdlc-dual-staff-implementation-plan.md +150 -0
  17. package/templates/ai-native-sdlc/docs/plans/2026-09-15-ai-native-sdlc-dual-staff.md +213 -0
  18. package/templates/ai-native-sdlc/docs/plans/2026-09-16-domain-role-notification-routing.md +36 -0
  19. package/templates/ai-native-sdlc/docs/plans/2026-09-16-execution-resume-and-cr-loop.md +29 -0
  20. package/templates/ai-native-sdlc/docs/plans/2026-09-16-project-handoff.md +148 -0
  21. package/templates/ai-native-sdlc/docs/plans/2026-09-17-prd-code-context-and-readable-prompts.md +37 -0
  22. package/templates/ai-native-sdlc/docs/plans/2026-09-21-plan-merge-sync-and-agent-source.md +19 -0
  23. package/templates/ai-native-sdlc/docs/plans/2026-09-21-sdlc-base-ref-and-review-notification.md +18 -0
  24. package/templates/ai-native-sdlc/docs/plans/2026-09-29-borrow-from-joe-design.md +943 -0
  25. package/templates/ai-native-sdlc/lib/artifacts.ts +126 -0
  26. package/templates/ai-native-sdlc/lib/recipe-config.ts +34 -0
  27. package/templates/ai-native-sdlc/lib/recipe-helpers.ts +595 -0
  28. package/templates/ai-native-sdlc/npmrc +1 -0
  29. package/templates/ai-native-sdlc/package.json +19 -0
  30. package/templates/ai-native-sdlc/profile/profile.yml +115 -0
  31. package/templates/ai-native-sdlc/setup/module-config.seed.json +37 -0
  32. package/templates/ai-native-sdlc/test/artifacts.test.ts +72 -0
  33. package/templates/ai-native-sdlc/test/recipe-helpers.test.ts +931 -0
  34. package/templates/ai-native-sdlc/tsconfig.json +17 -0
  35. package/templates/ai-native-sdlc/workflows/.gitkeep +3 -0
  36. package/templates/ai-native-sdlc/workflows/execution/automations/blocked-questions-dev.json +91 -0
  37. package/templates/ai-native-sdlc/workflows/execution/automations/blocked-questions.json +91 -0
  38. package/templates/ai-native-sdlc/workflows/execution/automations/cr-review-dev.json +80 -0
  39. package/templates/ai-native-sdlc/workflows/execution/automations/cr-review.json +81 -0
  40. package/templates/ai-native-sdlc/workflows/execution/automations/resume-answers.json +42 -0
  41. package/templates/ai-native-sdlc/workflows/execution/automations/spec-review-dev.json +138 -0
  42. package/templates/ai-native-sdlc/workflows/execution/recipes/coding.recipe.ts +161 -0
  43. package/templates/ai-native-sdlc/workflows/execution/recipes/prd-review.recipe.ts +200 -0
  44. package/templates/ai-native-sdlc/workflows/execution/recipes/prd.recipe.ts +163 -0
  45. package/templates/ai-native-sdlc/workflows/execution/recipes/review.recipe.ts +257 -0
  46. package/templates/ai-native-sdlc/workflows/execution/recipes/spec-review.recipe.ts +242 -0
  47. package/templates/ai-native-sdlc/workflows/execution/recipes/spec.recipe.ts +173 -0
  48. package/templates/ai-native-sdlc/workflows/execution/workflow.yml +79 -0
  49. package/templates/ai-native-sdlc/workflows/intake/automations/accept-intake.json +101 -0
  50. package/templates/ai-native-sdlc/workflows/intake/recipes/classify-module.recipe.ts +133 -0
  51. package/templates/ai-native-sdlc/workflows/intake/recipes/create-change.recipe.ts +94 -0
  52. package/templates/ai-native-sdlc/workflows/intake/recipes/mechanical-deduplicate.recipe.ts +33 -0
  53. package/templates/ai-native-sdlc/workflows/intake/workflow.yml +62 -0
  54. package/templates/ai-native-sdlc/workspaces/dev/AGENTS.md +69 -0
  55. package/templates/ai-native-sdlc/workspaces/pm/AGENTS.md +48 -0
  56. package/templates/ai-native-sdlc/workspaces/reviewer/AGENTS.md +79 -0
  57. package/dist/assistant/assets/index-CCEHOkkJ.css +0 -1
  58. package/dist/chunk-YDJTCQW4.js +0 -15
  59. package/dist/web/assets/index-DPc2WOdp.css +0 -1
  60. package/dist/web/assets/index-HTw8K38o.js +0 -332
@@ -0,0 +1,931 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { parse } from "yaml";
6
+ import {
7
+ agentQuestions,
8
+ answersForQuestions,
9
+ changeTitle,
10
+ commentRepairNotice,
11
+ consumeAnswersOrBlock,
12
+ crId,
13
+ crEntries,
14
+ formatCrEntries,
15
+ hasHandledComments,
16
+ handleCodingFailure,
17
+ isTestDemand,
18
+ appendHandledComments,
19
+ persistCodingSession,
20
+ parseAnswers,
21
+ questionsText,
22
+ repoPathFromUrl,
23
+ testDemandInstruction,
24
+ commitOptions,
25
+ worktreeOptions,
26
+ buildChangeDetails,
27
+ unhandledComments,
28
+ waitForExternalProgress,
29
+ unansweredQuestions,
30
+ } from "../lib/recipe-helpers.js";
31
+
32
+ test("构建改动详情只取 build 工件的指定章节", () => {
33
+ assert.equal(
34
+ buildChangeDetails("# 构建\n\n## 改动内容\n\n- 新增导出\n\n## 测试命令与结果\n\npnpm test"),
35
+ "- 新增导出",
36
+ );
37
+ assert.equal(buildChangeDetails("## 测试命令与结果\n\npnpm test"), undefined);
38
+ assert.equal(buildChangeDetails("## 改动内容\n\n \n## CI 证据\n通过"), undefined);
39
+ });
40
+
41
+ test("仅精确测试标题注入收窄代码库调研的提示", () => {
42
+ assert.match(testDemandInstruction({ 标题: "【测试】新增冒烟工件" }), /不得调研/);
43
+ assert.equal(testDemandInstruction({ 标题: "新增冒烟工件" }), "");
44
+ assert.equal(isTestDemand({ 需求: "【测试】兼容旧标题字段" }), true);
45
+ assert.equal(isTestDemand({ 标题: "测试】错误标记" }), false);
46
+ assert.deepEqual(commitOptions({ 标题: "【测试】新增冒烟工件" }), { noVerify: true });
47
+ assert.equal(commitOptions({ 标题: "新增冒烟工件" }), undefined);
48
+ assert.deepEqual(
49
+ worktreeOptions({ url: "git@example.test:repo.git", default_branch: "master" }),
50
+ { baseRef: "master" },
51
+ );
52
+ });
53
+
54
+ test("仓库在 profile YAML anchor 中只定义一次,steps 使用完整结构 alias", () => {
55
+ const source = readFileSync(resolve("profile/profile.yml"), "utf8");
56
+ const profile = parse(source) as {
57
+ workflows?: { execution?: { steps?: Record<string, Record<string, unknown>> } };
58
+ };
59
+ const steps = profile.workflows?.execution?.steps;
60
+
61
+ assert.match(source, /we_notable:\s*&we_notable/);
62
+ assert.match(source, /dev_repositories:\s*\[\*we_notable, \*lippi_notable\]/);
63
+ assert.deepEqual(steps?.spec?.dev_repositories, [
64
+ {
65
+ url: "git@gitlab.alibaba-inc.com:alidocs/we-notable.git",
66
+ default_branch: "origin/master",
67
+ commands: [{ bin: "pnpm", args: ["install", "--frozen-lockfile"], timeout_ms: 1200000 }],
68
+ },
69
+ {
70
+ url: "git@gitlab.alibaba-inc.com:alidocs/lippi-doc-notable.git",
71
+ default_branch: "origin/master",
72
+ },
73
+ ]);
74
+ });
75
+
76
+ test("执行流正常走完时首个终态为 shipped", () => {
77
+ const workflow = parse(readFileSync(resolve("workflows/execution/workflow.yml"), "utf8")) as {
78
+ tracker?: { states?: { terminal?: string[] } };
79
+ };
80
+
81
+ assert.equal(workflow.tracker?.states?.terminal?.[0], "shipped");
82
+ });
83
+
84
+ test("执行流将角色路由和阶段幂等字段明确命名", () => {
85
+ const workflow = parse(readFileSync(resolve("workflows/execution/workflow.yml"), "utf8")) as {
86
+ tracker?: { field_definitions?: Array<{ name: string }> };
87
+ };
88
+ const names = workflow.tracker?.field_definitions?.map((field) => field.name) ?? [];
89
+
90
+ assert.ok(names.includes("当前角色"));
91
+ assert.ok(names.includes("审计日志"));
92
+ assert.ok(!names.includes("属主"));
93
+ assert.ok(!names.includes("阶段跃迁审计"));
94
+ });
95
+ import mechanicalDeduplicate from "../workflows/intake/recipes/mechanical-deduplicate.recipe.js";
96
+ import createChange from "../workflows/intake/recipes/create-change.recipe.js";
97
+ import classifyModule from "../workflows/intake/recipes/classify-module.recipe.js";
98
+ import review from "../workflows/execution/recipes/review.recipe.js";
99
+
100
+ test("问题必须按编号逐项获得人工答复", () => {
101
+ const questions = JSON.stringify([
102
+ { id: "plan-Q1", question: "需要支持哪些客户端?" },
103
+ { id: "plan-Q2", question: "是否允许灰度?" },
104
+ ]);
105
+ const answers = JSON.stringify([{ id: "plan-Q1", answer: "Web 和移动端" }]);
106
+
107
+ assert.deepEqual(unansweredQuestions(questions, answers), [
108
+ { id: "plan-Q2", question: "是否允许灰度?" },
109
+ ]);
110
+ assert.equal(answersForQuestions(questions, answers), answers);
111
+ });
112
+
113
+ test("Agent 问题保留显式编号并兼容旧字符串格式", () => {
114
+ assert.deepEqual(
115
+ agentQuestions('SDLC_QUESTIONS=[{"id":"build-Q9","question":"需要开关吗?"}]', "build"),
116
+ [{ id: "build-Q9", question: "需要开关吗?" }],
117
+ );
118
+ assert.deepEqual(agentQuestions('SDLC_QUESTIONS=["是否兼容旧接口?"]', "build"), [
119
+ { id: "build-Q1", question: "是否兼容旧接口?" },
120
+ ]);
121
+ });
122
+
123
+ test("AI 问题以人可读的 Q 编号写入,人工答复可按相同编号恢复", () => {
124
+ const questions = questionsText([
125
+ { id: "prd-Q9", question: "是否需要灰度发布?" },
126
+ { id: "prd-Q10", question: "是否保留旧入口?" },
127
+ ]);
128
+
129
+ assert.equal(questions, "Q1: 是否需要灰度发布?\nQ2: 是否保留旧入口?");
130
+ assert.deepEqual(unansweredQuestions(questions, "Q1: 需要灰度发布"), [
131
+ { id: "Q2", question: "是否保留旧入口?" },
132
+ ]);
133
+ });
134
+
135
+ test("Agent 问题文本含 ] 时不被截断丢弃", () => {
136
+ // 旧非贪婪正则会在第一个 ] 处截断,JSON.parse 失败并静默丢掉全部问题。
137
+ assert.deepEqual(
138
+ agentQuestions(
139
+ 'SDLC_QUESTIONS=["需覆盖哪些表格产品?请确认是否包含 [+ viewId] 形态","第二个问题"]',
140
+ "prd",
141
+ ),
142
+ [
143
+ { id: "prd-Q1", question: "需覆盖哪些表格产品?请确认是否包含 [+ viewId] 形态" },
144
+ { id: "prd-Q2", question: "第二个问题" },
145
+ ],
146
+ );
147
+ assert.deepEqual(
148
+ agentQuestions(
149
+ '前置说明 SDLC_QUESTIONS=[{"id":"prd-Q9","question":"选项 (a)/(b) [见附录] 如何取舍?"}] 收尾散文',
150
+ "prd",
151
+ ),
152
+ [{ id: "prd-Q9", question: "选项 (a)/(b) [见附录] 如何取舍?" }],
153
+ );
154
+ });
155
+
156
+ test("评论按 ID 去重,不受行号漂移或同批评论增减影响", () => {
157
+ const first = { id: 7, note: "补测试", author: "reviewer", path: "a.ts", line: 12, createdAt: "" };
158
+ const second = { id: 8, note: "补文档", author: "reviewer", path: "README.md", line: 3, createdAt: "" };
159
+ const previous = appendHandledComments("", "review", "42", [first]);
160
+
161
+ assert.equal(hasHandledComments(previous, "review", "42", [{ ...first, line: 4 }]), true);
162
+ assert.deepEqual(unhandledComments(previous, "review", "42", [{ ...first, line: 4 }, second]), [second]);
163
+ assert.deepEqual(unhandledComments(previous, "review", "42", [second]), [second]);
164
+ });
165
+
166
+ test("当前 CR 链接同时兼容纯详情链接与历史 ID 标记", () => {
167
+ assert.equal(
168
+ crId("https://code.alibaba-inc.com/group/project/codereview/30086655"),
169
+ "30086655",
170
+ );
171
+ assert.equal(
172
+ crId("[去评审](https://code.alibaba-inc.com/group/project/codereview/30086655?from=table)"),
173
+ "30086655",
174
+ );
175
+ assert.equal(crId("- `group/project`: [!42](https://example.test/42)"), "42");
176
+ assert.equal(crId("https://code.alibaba-inc.com/group/project"), undefined);
177
+ });
178
+
179
+ test("人工易读答复格式也可以恢复", () => {
180
+ assert.deepEqual(parseAnswers("plan-Q1: Web 和移动端"), [
181
+ { id: "plan-Q1", answer: "Web 和移动端" },
182
+ ]);
183
+ });
184
+
185
+ test("CR 等待保持 active,且相同等待事实不会重复回写", async () => {
186
+ const updates: Record<string, unknown>[] = [];
187
+ const row = {
188
+ 状态: "active",
189
+ 阻塞原因: "PRD CR 尚未通过或仍有未解决评论",
190
+ 解除条件: "等待审核通过,或等待新评论被自动修复",
191
+ 当前角色: "PM",
192
+ };
193
+ const context = {
194
+ toolkit: {
195
+ table: {
196
+ updateCurrentRow: async (values: Record<string, unknown>) => {
197
+ updates.push(values);
198
+ },
199
+ },
200
+ },
201
+ };
202
+
203
+ const result = await waitForExternalProgress(
204
+ context as never,
205
+ row,
206
+ "PRD CR 尚未通过或仍有未解决评论",
207
+ "等待审核通过,或等待新评论被自动修复",
208
+ { 当前角色: "PM" },
209
+ );
210
+
211
+ assert.equal(result.status, "needs_human");
212
+ assert.deepEqual(updates, []);
213
+ });
214
+
215
+ test("Agent 原生提问转为可由人工答复恢复的 blocked", async () => {
216
+ const updates: Record<string, unknown>[] = [];
217
+ const result = await handleCodingFailure(
218
+ {
219
+ toolkit: {
220
+ table: {
221
+ updateCurrentRow: async (values: Record<string, unknown>) => {
222
+ updates.push(values);
223
+ },
224
+ },
225
+ },
226
+ } as never,
227
+ { Agent会话ID: "{}" },
228
+ "build",
229
+ "build:dev",
230
+ {
231
+ success: false,
232
+ response: "ASK_HUMAN_DETECTED: 是否允许修改兼容层?",
233
+ sessionId: "session-1",
234
+ },
235
+ "构建 Agent 未完成",
236
+ );
237
+
238
+ assert.equal(result.status, "blocked");
239
+ assert.deepEqual(updates, [{
240
+ 状态: "blocked",
241
+ 阻塞原因: "build 阶段需要人工答复",
242
+ 解除条件: "在人工答复中按问题编号逐项回答后自动续跑",
243
+ AI问题: "Q1: 是否允许修改兼容层?",
244
+ Agent会话ID: JSON.stringify({ "build:dev": "session-1" }),
245
+ }]);
246
+ });
247
+
248
+ test("成功的 Agent session 在 Git 后置操作前持久化", async () => {
249
+ const updates: Record<string, unknown>[] = [];
250
+ const session = await persistCodingSession(
251
+ {
252
+ toolkit: {
253
+ table: {
254
+ updateCurrentRow: async (values: Record<string, unknown>) => {
255
+ updates.push(values);
256
+ },
257
+ },
258
+ },
259
+ } as never,
260
+ { Agent会话ID: "{}" },
261
+ "prd:pm",
262
+ { success: true, response: "已完成", sessionId: "session-2" },
263
+ );
264
+
265
+ assert.equal(session, JSON.stringify({ "prd:pm": "session-2" }));
266
+ assert.deepEqual(updates, [{ Agent会话ID: session }]);
267
+ });
268
+
269
+ test("成功但未返回 sessionId 时不产生无意义表格写入", async () => {
270
+ const updates: Record<string, unknown>[] = [];
271
+ const session = await persistCodingSession(
272
+ {
273
+ toolkit: {
274
+ table: {
275
+ updateCurrentRow: async (values: Record<string, unknown>) => {
276
+ updates.push(values);
277
+ },
278
+ },
279
+ },
280
+ } as never,
281
+ { Agent会话ID: '{"prd:pm":"old-session"}' },
282
+ "prd:pm",
283
+ { success: true, response: "已完成" },
284
+ );
285
+
286
+ assert.equal(session, '{"prd:pm":"old-session"}');
287
+ assert.deepEqual(updates, []);
288
+ });
289
+
290
+ test("非提问类失败把超时等根因写进 trace logs 与阻塞原因", async () => {
291
+ const updates: Record<string, unknown>[] = [];
292
+ const result = await handleCodingFailure(
293
+ {
294
+ toolkit: {
295
+ table: {
296
+ updateCurrentRow: async (values: Record<string, unknown>) => {
297
+ updates.push(values);
298
+ },
299
+ },
300
+ },
301
+ } as never,
302
+ { Agent会话ID: "{}" },
303
+ "prd",
304
+ "prd:pm",
305
+ {
306
+ success: false,
307
+ response: "coding agent 执行超时 (600000ms)",
308
+ // 引擎超时不回传 sessionId
309
+ },
310
+ "PRD Agent 未完成",
311
+ );
312
+
313
+ assert.equal(result.status, "blocked");
314
+ const logs = (result.logs ?? []).join("\n");
315
+ assert.match(logs, /coding agent 执行超时 \(600000ms\)/);
316
+ assert.match(logs, /PRD Agent 未完成|coding agent 执行超时/);
317
+ assert.equal(updates.length, 1);
318
+ assert.match(String(updates[0]!["阻塞原因"]), /PRD Agent 未完成|coding agent 执行超时/);
319
+ assert.match(String(updates[0]!["解除条件"]), /调整 timeoutMs|人工确认/);
320
+ });
321
+
322
+ test("执行工作流只轮询 active,避免 blocked 问答重复执行", () => {
323
+ const workflow = parse(
324
+ readFileSync(resolve("workflows/execution/workflow.yml"), "utf8"),
325
+ ) as { tracker?: { states?: { active?: string[] } } };
326
+
327
+ assert.deepEqual(workflow.tracker?.states?.active, ["active"]);
328
+ });
329
+
330
+ test("需求池状态使用开发生命周期而非已合并", () => {
331
+ const workflow = parse(
332
+ readFileSync(resolve("workflows/intake/workflow.yml"), "utf8"),
333
+ ) as {
334
+ tracker?: { field_definitions?: Array<{ name: string; options?: string[] }> };
335
+ };
336
+
337
+ const demandState = workflow.tracker?.field_definitions?.find(
338
+ (field) => field.name === "需求状态",
339
+ );
340
+ assert.deepEqual(demandState?.options, [
341
+ "待评估",
342
+ "已接受",
343
+ "开发中",
344
+ "已完成",
345
+ "已驳回",
346
+ "待澄清",
347
+ ]);
348
+ });
349
+
350
+ test("PRD 与技术方案均使用独立评审步骤和用户可见阶段", () => {
351
+ const workflow = parse(
352
+ readFileSync(resolve("workflows/execution/workflow.yml"), "utf8"),
353
+ ) as {
354
+ steps?: Array<{ id: string }>;
355
+ edges?: Array<{ from: string; to: string }>;
356
+ tracker?: { field_definitions?: Array<{ name: string; options?: string[] }> };
357
+ };
358
+
359
+ assert.deepEqual(
360
+ workflow.steps?.map((step) => step.id),
361
+ ["prd", "prd_review", "spec", "spec_review", "coding", "review"],
362
+ );
363
+ assert.deepEqual(
364
+ workflow.edges?.filter(
365
+ (edge) =>
366
+ edge.from === "__start__" || edge.from === "prd" || edge.from === "prd_review",
367
+ ),
368
+ [
369
+ { from: "__start__", to: "prd" },
370
+ { from: "prd", to: "prd_review" },
371
+ { from: "prd_review", to: "spec" },
372
+ ],
373
+ );
374
+ assert.ok(
375
+ workflow.tracker?.field_definitions
376
+ ?.find((field) => field.name === "当前阶段")
377
+ ?.options?.includes("PRD 评审"),
378
+ );
379
+ assert.ok(
380
+ workflow.tracker?.field_definitions
381
+ ?.find((field) => field.name === "当前阶段")
382
+ ?.options?.includes("技术方案评审"),
383
+ );
384
+ });
385
+
386
+ test("CR 待审通知展示当前 CR 链接列表,不使用单一去评审按钮", () => {
387
+ for (const file of ["cr-review.json", "cr-review-dev.json"]) {
388
+ const automation = JSON.parse(
389
+ readFileSync(resolve("workflows/execution/automations", file), "utf8"),
390
+ ) as {
391
+ steps?: { start?: { data?: { updatedFields?: string[] } }; send?: { data?: { content?: Array<{ text?: string; ref?: string }>; buttons?: unknown } } };
392
+ };
393
+ assert.ok(automation.steps?.start?.data?.updatedFields?.includes("<评审进展_fieldId>"));
394
+ assert.equal(automation.steps?.send?.data?.buttons, undefined);
395
+ assert.deepEqual(automation.steps?.send?.data?.content?.slice(-2), [
396
+ { text: "\n评审链接:\n" },
397
+ { ref: "$.start.<当前CR链接_fieldId>", title: "待评审 CR" },
398
+ ]);
399
+ }
400
+ });
401
+
402
+ test("评论修复通知按仓库报告数量,评审链接由当前 CR 字段展示", () => {
403
+ assert.equal(
404
+ commentRepairNotice([
405
+ { repoUrl: "alidocs/repo-a", mrUrl: "https://code.example.com/a", commentCount: 3 },
406
+ { repoUrl: "alidocs/repo-b", mrUrl: "https://code.example.com/b", commentCount: 2 },
407
+ ]),
408
+ "repo-a:本轮已修复3条评论、已推送,等待复审\nrepo-b:本轮已修复2条评论、已推送,等待复审",
409
+ );
410
+ });
411
+
412
+ test("当前 CR 链接只展示可点击的仓库短名,并能还原评审所需信息", () => {
413
+ const entries = [{
414
+ repoUrl: "git@gitlab.alibaba-inc.com:alidocs/we-notable.git",
415
+ mrId: "30154119",
416
+ mrUrl: "https://code.alibaba-inc.com/alidocs/we-notable/codereview/30154119",
417
+ }];
418
+ const formatted = formatCrEntries(entries);
419
+
420
+ assert.equal(
421
+ formatted,
422
+ "- [we-notable](https://code.alibaba-inc.com/alidocs/we-notable/codereview/30154119)",
423
+ );
424
+ assert.deepEqual(crEntries(formatted), [{
425
+ repoUrl: "alidocs/we-notable",
426
+ mrId: "30154119",
427
+ mrUrl: "https://code.alibaba-inc.com/alidocs/we-notable/codereview/30154119",
428
+ }]);
429
+ });
430
+
431
+ test("执行通知正文均先展示需求名称", () => {
432
+ for (const file of [
433
+ "blocked-questions.json",
434
+ "blocked-questions-dev.json",
435
+ "cr-review.json",
436
+ "cr-review-dev.json",
437
+ ]) {
438
+ const automation = JSON.parse(
439
+ readFileSync(resolve("workflows/execution/automations", file), "utf8"),
440
+ ) as { steps?: { send?: { data?: { content?: Array<{ text?: string; ref?: string }> } } } };
441
+ const content = automation.steps?.send?.data?.content ?? [];
442
+ assert.ok(content.some((item) => item.ref === "$.start.<需求_fieldId>"));
443
+ if (file.startsWith("cr-review")) {
444
+ assert.ok(content.some((item) => item.ref === "$.start.<当前阶段_fieldId>"));
445
+ }
446
+ }
447
+ });
448
+
449
+ type MechanicalContext = {
450
+ toolkit: {
451
+ table: {
452
+ readCurrentRow: () => Promise<Record<string, unknown>>;
453
+ queryRows: (filter: Record<string, string>, limit: number) => Promise<unknown[]>;
454
+ updateCurrentRow: (values: Record<string, unknown>) => Promise<void>;
455
+ };
456
+ };
457
+ };
458
+
459
+ const executeMechanicalDeduplicate = mechanicalDeduplicate.execute as unknown as (
460
+ ctx: MechanicalContext,
461
+ ) => Promise<{ status: string }>;
462
+
463
+ test("机械去重只按需求标题拦截重复项,不调用 Agent", async () => {
464
+ const updates: Record<string, unknown>[] = [];
465
+ const result = await executeMechanicalDeduplicate({
466
+ toolkit: {
467
+ table: {
468
+ readCurrentRow: async () => ({ 需求标题: "支持多维表导出" }),
469
+ queryRows: async (filter, limit) => {
470
+ assert.deepEqual(filter, { 需求标题: "支持多维表导出" });
471
+ assert.equal(limit, 3);
472
+ return [{}, {}];
473
+ },
474
+ updateCurrentRow: async (values) => {
475
+ updates.push(values);
476
+ },
477
+ },
478
+ },
479
+ });
480
+
481
+ assert.equal(result.status, "blocked");
482
+ assert.deepEqual(updates, [{
483
+ 需求状态: "待澄清",
484
+ 产出物: "机械去重:发现相同标题的已有需求",
485
+ }]);
486
+ });
487
+
488
+ test("唯一标题直接通过机械去重", async () => {
489
+ const result = await executeMechanicalDeduplicate({
490
+ toolkit: {
491
+ table: {
492
+ readCurrentRow: async () => ({ 需求标题: "修复导出乱码" }),
493
+ queryRows: async () => [{}],
494
+ updateCurrentRow: async () => {},
495
+ },
496
+ },
497
+ });
498
+
499
+ assert.equal(result.status, "success");
500
+ });
501
+
502
+ test("消费答复后同时清空人工答复,旧答案不能冒充新问题的答复", async () => {
503
+ const updates: Record<string, unknown>[] = [];
504
+ const row = {
505
+ AI问题: JSON.stringify([{ id: "plan-Q1", question: "需要支持哪些客户端?" }]),
506
+ 人工答复: "plan-Q1: Web 和移动端",
507
+ };
508
+
509
+ const result = await consumeAnswersOrBlock(
510
+ {
511
+ toolkit: {
512
+ table: {
513
+ updateCurrentRow: async (values: Record<string, unknown>) => {
514
+ updates.push(values);
515
+ },
516
+ },
517
+ },
518
+ } as never,
519
+ row,
520
+ );
521
+
522
+ assert.equal(result.blocked, undefined);
523
+ assert.deepEqual(JSON.parse(result.answers), [
524
+ { id: "plan-Q1", answer: "Web 和移动端" },
525
+ ]);
526
+ assert.deepEqual(updates, [{
527
+ 状态: "active",
528
+ 阻塞原因: "",
529
+ 解除条件: "",
530
+ AI问题: "",
531
+ 人工答复: "",
532
+ }]);
533
+ });
534
+
535
+ type CreatedRecord = Record<string, unknown>;
536
+
537
+ const executeCreateChange = createChange.execute as unknown as (
538
+ ctx: unknown,
539
+ ) => Promise<{ status: string }>;
540
+
541
+ function createChangeContext(
542
+ row: Record<string, unknown>,
543
+ created: CreatedRecord[],
544
+ existing?: { recordId: string },
545
+ updates: CreatedRecord[] = [],
546
+ ) {
547
+ return {
548
+ stepId: "create_change",
549
+ config: { execution_tracker: { base_id: "base", table_id: "table" } },
550
+ toolkit: {
551
+ table: {
552
+ readCurrentRow: async () => row,
553
+ findRecordIn: async () => existing,
554
+ createRecordIn: async (_ref: unknown, fields: CreatedRecord) => {
555
+ created.push(fields);
556
+ return { recordId: "rec-new" };
557
+ },
558
+ updateCurrentRow: async (fields: CreatedRecord) => {
559
+ updates.push(fields);
560
+ },
561
+ },
562
+ },
563
+ };
564
+ }
565
+
566
+ const acceptedIntake = (title: string) => ({
567
+ 需求状态: "已接受",
568
+ 需求标题: title,
569
+ 需求详情: "支持导出,需兼容现有权限与异步任务。",
570
+ 模块: "前端与交互",
571
+ });
572
+
573
+ test("不同中文标题的需求生成不同的变更编号", async () => {
574
+ const created: CreatedRecord[] = [];
575
+ await executeCreateChange(
576
+ createChangeContext(acceptedIntake("支持多维表导出"), created),
577
+ );
578
+ await executeCreateChange(
579
+ createChangeContext(acceptedIntake("修复导出乱码"), created),
580
+ );
581
+
582
+ assert.equal(created.length, 2);
583
+ const [first, second] = created.map((record) => String(record["变更编号"]));
584
+ assert.match(first, /^CHG-\d{8}-change-[a-z0-9]{6}$/);
585
+ assert.notEqual(first, second);
586
+ });
587
+
588
+ test("同一需求重试生成相同变更编号并复用已有记录", async () => {
589
+ const created: CreatedRecord[] = [];
590
+ const row = acceptedIntake("支持多维表导出");
591
+ await executeCreateChange(createChangeContext(row, created));
592
+ const id = String(created[0]!["变更编号"]);
593
+
594
+ const reused: CreatedRecord[] = [];
595
+ const result = await executeCreateChange(
596
+ createChangeContext(row, reused, { recordId: "rec-existing" }),
597
+ );
598
+
599
+ assert.equal(result.status, "success");
600
+ assert.equal(reused.length, 0);
601
+ assert.ok(id.length > 0);
602
+ });
603
+
604
+ test("创建变更后需求进入开发中", async () => {
605
+ const created: CreatedRecord[] = [];
606
+ const updates: CreatedRecord[] = [];
607
+ await executeCreateChange(
608
+ createChangeContext(acceptedIntake("支持多维表导出"), created, undefined, updates),
609
+ );
610
+
611
+ assert.deepEqual(updates, [{
612
+ 产出物: "变更流水记录:rec-new",
613
+ 需求状态: "开发中",
614
+ }]);
615
+ });
616
+
617
+ test("创建变更将富文本需求详情复制到执行记录", async () => {
618
+ const created: CreatedRecord[] = [];
619
+ await executeCreateChange(createChangeContext(acceptedIntake("支持多维表导出"), created));
620
+ assert.equal(created[0]!["需求详情"], "支持导出,需兼容现有权限与异步任务。");
621
+ });
622
+
623
+ test("singleSelect 单元格以对象形态读回时仍能识别已接受状态", async () => {
624
+ const created: CreatedRecord[] = [];
625
+ const row = {
626
+ 需求状态: { id: "AZXa4bwhUQ", name: "已接受" },
627
+ 需求标题: "支持多维表导出",
628
+ 模块: "前端与交互",
629
+ };
630
+ const result = await executeCreateChange(createChangeContext(row, created));
631
+
632
+ assert.equal(result.status, "success");
633
+ assert.equal(created.length, 1);
634
+ assert.match(
635
+ String(created[0]!["变更编号"]),
636
+ /^CHG-\d{8}-change-[a-z0-9]{6}$/,
637
+ );
638
+ });
639
+
640
+ test("singleSelect 单元格为非已接受状态时保持 needs_human 自轮询等待受理", async () => {
641
+ const created: CreatedRecord[] = [];
642
+ const row = {
643
+ 需求状态: { id: "pending", name: "待评估" },
644
+ 需求标题: "支持多维表导出",
645
+ 模块: "前端与交互",
646
+ };
647
+ const result = await executeCreateChange(createChangeContext(row, created));
648
+
649
+ assert.equal(result.status, "needs_human");
650
+ assert.equal(created.length, 0);
651
+ });
652
+
653
+ const executeReview = review.execute as unknown as (
654
+ ctx: unknown,
655
+ ) => Promise<{ status: string }>;
656
+
657
+ test("全部代码 CR 通过评审且无未解决评论后将关联需求写为已完成", async () => {
658
+ const intakeUpdates: Array<{ recordId: string; fields: CreatedRecord }> = [];
659
+ const executionUpdates: CreatedRecord[] = [];
660
+ const repo = "git@gitlab.alibaba-inc.com:alidocs/we-notable.git";
661
+
662
+ const result = await executeReview({
663
+ stepId: "review",
664
+ config: {
665
+ dev_repositories: [{ url: repo, default_branch: "master" }],
666
+ intake_tracker: { base_id: "intake-base", table_id: "intake-table" },
667
+ },
668
+ toolkit: {
669
+ table: {
670
+ readCurrentRow: async () => ({
671
+ 变更编号: "CHG-20260917-complete",
672
+ 需求: "支持多维表导出",
673
+ 当前步骤: "review",
674
+ 当前CR链接: "- [we-notable](https://code.alibaba-inc.com/alidocs/we-notable/codereview/42)",
675
+ 审计日志: "",
676
+ }),
677
+ findRecordIn: async (target: unknown, field: string, value: string) => {
678
+ assert.deepEqual(target, { baseId: "intake-base", tableId: "intake-table" });
679
+ assert.equal(field, "需求标题");
680
+ assert.equal(value, "支持多维表导出");
681
+ return { recordId: "demand-record" };
682
+ },
683
+ updateRecordIn: async (
684
+ _target: unknown,
685
+ recordId: string,
686
+ fields: CreatedRecord,
687
+ ) => {
688
+ intakeUpdates.push({ recordId, fields });
689
+ },
690
+ updateCurrentRow: async (fields: CreatedRecord) => {
691
+ executionUpdates.push(fields);
692
+ },
693
+ },
694
+ git: {
695
+ ensureWorktree: async () => ({ dir: "/tmp/worktree" }),
696
+ },
697
+ codeReview: {
698
+ listUnresolvedComments: async () => [],
699
+ isReadyToMerge: async () => true,
700
+ isMerged: async () => {
701
+ throw new Error("review 不应查询 CR 是否已合并");
702
+ },
703
+ },
704
+ },
705
+ });
706
+
707
+ assert.equal(result.status, "success");
708
+ assert.deepEqual(intakeUpdates, [{
709
+ recordId: "demand-record",
710
+ fields: { 需求状态: "已完成" },
711
+ }]);
712
+ assert.deepEqual(executionUpdates, [{
713
+ 当前阶段: "已发布",
714
+ 当前角色: "人",
715
+ 状态: "shipped",
716
+ 当前CR链接: "",
717
+ 评审进展: "",
718
+ 阻塞原因: "",
719
+ 解除条件: "",
720
+ 审计日志: "CHG-20260917-complete+已发布",
721
+ }]);
722
+ });
723
+
724
+ test("CR 未可合并但无评论时保持 active 轮询,且不创建 worktree", async () => {
725
+ let worktreeCalls = 0;
726
+ const updates: CreatedRecord[] = [];
727
+ const repo = "git@gitlab.alibaba-inc.com:alidocs/we-notable.git";
728
+
729
+ const result = await executeReview({
730
+ stepId: "review",
731
+ config: {
732
+ dev_repositories: [{ url: repo, default_branch: "master" }],
733
+ intake_tracker: { base_id: "intake-base", table_id: "intake-table" },
734
+ },
735
+ toolkit: {
736
+ table: {
737
+ readCurrentRow: async () => ({
738
+ 变更编号: "CHG-20260920-wait-review",
739
+ 当前步骤: "review",
740
+ 当前CR链接: "- [we-notable](https://code.alibaba-inc.com/alidocs/we-notable/codereview/42)",
741
+ 状态: "active",
742
+ }),
743
+ updateCurrentRow: async (fields: CreatedRecord) => {
744
+ updates.push(fields);
745
+ },
746
+ },
747
+ git: {
748
+ ensureWorktree: async () => {
749
+ worktreeCalls += 1;
750
+ return { dir: "/tmp/worktree" };
751
+ },
752
+ },
753
+ codeReview: {
754
+ listUnresolvedComments: async () => [],
755
+ isReadyToMerge: async () => false,
756
+ },
757
+ },
758
+ });
759
+
760
+ assert.equal(result.status, "needs_human");
761
+ assert.equal(worktreeCalls, 0);
762
+ assert.deepEqual(updates, [{
763
+ 阻塞原因: "仍有 1 个 CR 未可合并或存在未解决评论",
764
+ 解除条件: "等待审核通过,或等待新评论被自动修复",
765
+ 当前角色: "Dev",
766
+ }]);
767
+ });
768
+
769
+ test("repoPathFromUrl 把 clone URL 解析成 a1 需要的 group/project", () => {
770
+ assert.equal(
771
+ repoPathFromUrl("git@gitlab.alibaba-inc.com:xule-test/ai-staff.git"),
772
+ "xule-test/ai-staff",
773
+ );
774
+ assert.equal(
775
+ repoPathFromUrl("https://gitlab.alibaba-inc.com/xule-test/ai-staff.git"),
776
+ "xule-test/ai-staff",
777
+ );
778
+ assert.equal(repoPathFromUrl("xule-test/ai-staff"), "xule-test/ai-staff");
779
+ assert.equal(repoPathFromUrl(""), "");
780
+ });
781
+
782
+ test("changeTitle 优先用标题,缺失时退回需求再退回变更编号", () => {
783
+ assert.equal(
784
+ changeTitle({ 标题: "支持直接输入表格链接", 需求: "同步需求" }, "CHG-1"),
785
+ "支持直接输入表格链接",
786
+ );
787
+ assert.equal(changeTitle({ 需求: "同步需求" }, "CHG-1"), "同步需求");
788
+ assert.equal(changeTitle({}, "CHG-1"), "CHG-1");
789
+ });
790
+
791
+ test("review 缺少 build 创建的 CR 时阻塞而不调用 Agent", async () => {
792
+ const repo = "git@gitlab.alibaba-inc.com:alidocs/we-notable.git";
793
+ const mrCalls: Array<{ repo: string; title: string }> = [];
794
+ let agentCalls = 0;
795
+
796
+ const result = await executeReview({
797
+ stepId: "review",
798
+ config: {
799
+ dev_repositories: [{ url: repo, default_branch: "master" }],
800
+ intake_tracker: { base_id: "intake-base", table_id: "intake-table" },
801
+ },
802
+ toolkit: {
803
+ table: {
804
+ readCurrentRow: async () => ({
805
+ 变更编号: "CHG-20260917-build",
806
+ 标题: "钉钉表格数据源同步支持直接输入钉钉表格链接",
807
+ 当前步骤: "review",
808
+ 当前CR链接: "",
809
+ 审计日志: "",
810
+ }),
811
+ updateCurrentRow: async () => {},
812
+ },
813
+ git: {
814
+ ensureWorktree: async () => ({ dir: "/tmp/worktree" }),
815
+ commit: async () => ({ committed: true }),
816
+ diff: async () => ({ files: [{ path: "changes/x/06-review.md" }] }),
817
+ push: async () => {},
818
+ },
819
+ coding: {
820
+ run: async () => {
821
+ agentCalls += 1;
822
+ return { success: true, response: "已完成评审" };
823
+ },
824
+ },
825
+ codeReview: {
826
+ createMR: async (
827
+ repoArg: string,
828
+ _source: string,
829
+ _target: string,
830
+ title: string,
831
+ ) => {
832
+ mrCalls.push({ repo: repoArg, title });
833
+ return { mrId: "77", mrUrl: "https://example.test/77" };
834
+ },
835
+ },
836
+ },
837
+ });
838
+
839
+ assert.equal(result.status, "blocked");
840
+ assert.equal(mrCalls.length, 0);
841
+ assert.equal(agentCalls, 0);
842
+ });
843
+
844
+ // ── classify-module:等待人工一律 needs_human(行保持 active 可自推进),不得用 blocked ──
845
+
846
+ const executeClassifyModule = classifyModule.execute as unknown as (
847
+ ctx: unknown,
848
+ ) => Promise<{ status: string; blockReason?: string }>;
849
+
850
+ function classifyModuleContext(
851
+ row: Record<string, unknown>,
852
+ modules: Array<{ fields: Record<string, unknown> }>,
853
+ agentResponse: string | null,
854
+ updates: CreatedRecord[] = [],
855
+ ) {
856
+ const state = { agentCalls: 0 };
857
+ const ctx = {
858
+ stepId: "classify_module",
859
+ config: { module_config: { baseId: "base", tableId: "module-table" } },
860
+ toolkit: {
861
+ table: {
862
+ readCurrentRow: async () => row,
863
+ listRecordsIn: async () => modules,
864
+ updateCurrentRow: async (fields: CreatedRecord) => {
865
+ updates.push(fields);
866
+ },
867
+ },
868
+ coding: {
869
+ run: async () => {
870
+ state.agentCalls += 1;
871
+ if (agentResponse === null) throw new Error("不应调用 Agent");
872
+ return { success: true, response: agentResponse };
873
+ },
874
+ },
875
+ },
876
+ };
877
+ return { ctx, state, updates };
878
+ }
879
+
880
+ test("模块已判定时等待受理用 needs_human 且指引改需求状态", async () => {
881
+ const { ctx, state } = classifyModuleContext(
882
+ { 模块: "数据源与同步", 需求标题: "【测试】数据源不同步" },
883
+ [],
884
+ null,
885
+ );
886
+
887
+ const result = await executeClassifyModule(ctx);
888
+
889
+ assert.equal(result.status, "needs_human");
890
+ assert.equal(state.agentCalls, 0);
891
+ assert.ok(result.blockReason?.includes("已接受"), "文案必须告诉人改哪个字段");
892
+ });
893
+
894
+ test("模块配置表为空时不调 Agent,直接提示先补配置", async () => {
895
+ const { ctx, state } = classifyModuleContext({ 需求标题: "新需求" }, [], null);
896
+
897
+ const result = await executeClassifyModule(ctx);
898
+
899
+ assert.equal(result.status, "needs_human");
900
+ assert.equal(state.agentCalls, 0);
901
+ assert.ok(result.blockReason?.includes("模块配置表"));
902
+ });
903
+
904
+ test("AI 判定不在配置表时暂存为候选并等待人工确认,不丢弃判定", async () => {
905
+ const { ctx, updates } = classifyModuleContext(
906
+ { 需求标题: "【测试】数据源不同步" },
907
+ [{ fields: { 模块名称: "数据源与同步" } }],
908
+ "数据源\n涉及同步链路",
909
+ );
910
+
911
+ const result = await executeClassifyModule(ctx);
912
+
913
+ assert.equal(result.status, "needs_human");
914
+ assert.deepEqual(updates, [{ 模块: "数据源" }]);
915
+ assert.ok(result.blockReason?.includes("数据源"));
916
+ });
917
+
918
+ // 防回归:配置表内容即白名单,不得在代码里硬编码候选模块名——
919
+ // 用户在表里自由维护的模块(哪怕完全不在历史预设分类里)必须可判定。
920
+ test("自定义模块(不在任何预设分类里)命中配置表即判定成功", async () => {
921
+ const { ctx, updates } = classifyModuleContext(
922
+ { 需求标题: "【测试】工单要能导出 PDF" },
923
+ [{ fields: { 模块名称: "报表与导出" } }],
924
+ "报表与导出\n导出链路相关",
925
+ );
926
+
927
+ const result = await executeClassifyModule(ctx);
928
+
929
+ assert.equal(result.status, "success");
930
+ assert.deepEqual(updates, [{ 模块: "报表与导出", 需求状态: "待评估" }]);
931
+ });