@jira-deploy/core 1.0.2 → 1.0.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.
package/tools.test.js CHANGED
@@ -4,7 +4,9 @@
4
4
  */
5
5
  import { test, describe } from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
+ import http from 'node:http';
7
8
  import { executeTool, getToolDefinitions } from './tools/index.js';
9
+ import { Poller } from './poller.js';
8
10
 
9
11
  process.env.JIRA_BASE_URL = 'https://jira.test';
10
12
 
@@ -109,9 +111,9 @@ function makeMockJira({
109
111
  },
110
112
  getIssue: async () => ({ fields: { status: { name: 'TO DO' }, summary: 'test' } }),
111
113
  getTransitions: async () => [],
112
- transitionById: async () => {},
114
+ transitionById: async () => { },
113
115
  transitionByName: async () => ({ transitioned: 'Test', toStatus: 'Done' }),
114
- addComment: async () => {},
116
+ addComment: async () => { },
115
117
  getSubTasks: async () => [],
116
118
  };
117
119
  }
@@ -127,6 +129,7 @@ describe('tool schemas — agent contract', () => {
127
129
  const tool = getToolDefinition('create_cd_ticket');
128
130
 
129
131
  assert.deepEqual(tool.inputSchema.required, ['systemCode', 'environment']);
132
+ assert.ok(tool.inputSchema.properties.environment.enum.includes('dev'));
130
133
  for (const property of [
131
134
  'ciTicket',
132
135
  'linkedCiKey',
@@ -139,6 +142,19 @@ describe('tool schemas — agent contract', () => {
139
142
  }
140
143
  });
141
144
 
145
+ test('create_cd_ticket schema requires explicit CD intent and CI ticket context', () => {
146
+ const tool = getToolDefinition('create_cd_ticket');
147
+
148
+ assert.match(tool.description, /明確要求或確認/);
149
+ assert.match(tool.description, /deploy_grayrelease/);
150
+ assert.match(tool.description, /不可直接開 CD 單/);
151
+ assert.match(tool.inputSchema.properties.environment.description, /應先詢問/);
152
+ assert.match(tool.inputSchema.properties.environment.description, /不可.*自行補 stg/);
153
+ assert.match(tool.inputSchema.properties.ciTicket.description, /必須是 CI 單/);
154
+ assert.match(tool.inputSchema.properties.ciTicket.description, /不可使用 GrayRelease 單/);
155
+ assert.match(tool.inputSchema.properties.linkedCiKey.description, /不可使用 GrayRelease 單/);
156
+ });
157
+
142
158
  test('create_library_ticket schema treats module as defaultable', () => {
143
159
  const tool = getToolDefinition('create_library_ticket');
144
160
 
@@ -151,22 +167,112 @@ describe('tool schemas — agent contract', () => {
151
167
  assert.ok(getToolDefinition('run_lib_to_stg_release'));
152
168
  });
153
169
 
170
+ test('wait_to_dev is exposed as a CI workflow tool', () => {
171
+ assert.ok(getToolDefinition('wait_to_dev'));
172
+ });
173
+
174
+ test('deploy_grayrelease is exposed as the GrayRelease deployment tool', () => {
175
+ const deployTool = getToolDefinition('deploy_grayrelease');
176
+ const buildTool = getToolDefinition('build_ticket');
177
+
178
+ assert.ok(deployTool);
179
+ assert.match(deployTool.description, /與 CD 部署完全分離/);
180
+ assert.match(deployTool.description, /只說 deploy\/部署/);
181
+ assert.match(deployTool.description, /先確認是否部署該 GrayRelease 單/);
182
+ assert.match(deployTool.description, /不會建立 CD 單/);
183
+ assert.match(deployTool.description, /不接受 CI 單/);
184
+ assert.match(deployTool.description, /不會重新 build/);
185
+ assert.match(buildTool.description, /deploy_grayrelease/);
186
+ assert.match(buildTool.description, /不可用此 tool 觸發 rebuild/);
187
+ });
188
+
189
+ test('build_ticket schema is limited to explicit GrayRelease build or rebuild', () => {
190
+ const tool = getToolDefinition('build_ticket');
191
+
192
+ assert.match(tool.description, /GrayRelease/);
193
+ assert.match(tool.description, /build\/rebuild/);
194
+ assert.doesNotMatch(tool.description, /build\/deploy/);
195
+ assert.match(tool.inputSchema.properties.issueKey.description, /GrayRelease/);
196
+ assert.equal(tool.inputSchema.properties.rebuild.type, 'boolean');
197
+ assert.match(tool.inputSchema.properties.rebuild.description, /rebuild=true/);
198
+ });
199
+
200
+ test('build_ticket refuses GrayRelease VERIFY rebuild without rebuild flag', async () => {
201
+ let status = 'VERIFY';
202
+ const transitions = [];
203
+ const jira = {
204
+ ...makeMockJira(),
205
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
206
+ getTransitions: async () => {
207
+ if (status === 'VERIFY') return [{ id: '10', name: 'Verify fail', to: { name: 'PLANNING' } }];
208
+ return [];
209
+ },
210
+ transitionById: async (_issueKey, transitionId) => {
211
+ transitions.push(transitionId);
212
+ },
213
+ };
214
+
215
+ const result = await executeTool(
216
+ 'build_ticket',
217
+ { issueKey: 'CID-822' },
218
+ { jira, notifier: mockNotifier },
219
+ );
220
+
221
+ assert.equal(result.isError, true);
222
+ assert.match(result.content[0].text, /rebuild=true/);
223
+ assert.deepEqual(transitions, []);
224
+ });
225
+
226
+ test('build_ticket resets GrayRelease VERIFY flow before GrayRelease Build when rebuild is explicit', async () => {
227
+ let status = 'VERIFY';
228
+ const transitions = [];
229
+ const jira = {
230
+ ...makeMockJira(),
231
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
232
+ getTransitions: async () => {
233
+ if (status === 'VERIFY') return [{ id: '10', name: 'Verify fail', to: { name: 'PLANNING' } }];
234
+ if (status === 'PLANNING') return [{ id: '11', name: 'Accept', to: { name: 'WAIT FOR BUILD' } }];
235
+ if (status === 'WAIT FOR BUILD') {
236
+ return [{ id: '12', name: 'GrayRelease Build', to: { name: 'WAIT FOR BUILD' } }];
237
+ }
238
+ return [];
239
+ },
240
+ transitionById: async (_issueKey, transitionId) => {
241
+ transitions.push(transitionId);
242
+ if (transitionId === '10') status = 'PLANNING';
243
+ if (transitionId === '11') status = 'WAIT FOR BUILD';
244
+ },
245
+ };
246
+
247
+ const result = await executeTool(
248
+ 'build_ticket',
249
+ { issueKey: 'CID-822', rebuild: true },
250
+ { jira, notifier: mockNotifier },
251
+ );
252
+
253
+ assert.ok(!result.isError, result.content[0].text);
254
+ const output = JSON.parse(result.content[0].text);
255
+ assert.equal(output.status, 'WAIT FOR BUILD');
256
+ assert.deepEqual(transitions, ['10', '11', '12']);
257
+ assert.match(output.steps.join('\n'), /GrayRelease Build/);
258
+ });
259
+
154
260
  test('run_stg_full_release dispatches shared workflow steps', async () => {
155
261
  const calls = [];
156
- const result = await executeTool('run_stg_full_release', {systemCode: 'IBK'}, {
262
+ const result = await executeTool('run_stg_full_release', { systemCode: 'IBK' }, {
157
263
  jira: makeMockJira(),
158
264
  notifier: mockNotifier,
159
265
  executeToolImpl: async (name, args) => {
160
- calls.push({name, args});
266
+ calls.push({ name, args });
161
267
  const outputs = {
162
- create_ci_ticket: {issueKey: 'CID-100'},
163
- build_ticket: {issueKey: args.issueKey, status: 'Compliance Scan'},
164
- wait_to_stg: {issueKey: args.issueKey, status: 'Wait To STG'},
165
- create_cd_ticket: {issueKey: 'CID-200'},
166
- prepare_cd_deployment: {issueKey: args.issueKey, status: 'Deployment Created'},
167
- trigger_deployment: {cdIssueKey: args.cdIssueKey, status: 'Done'},
268
+ create_ci_ticket: { issueKey: 'CID-100' },
269
+ build_ticket: { issueKey: args.issueKey, status: 'Compliance Scan' },
270
+ wait_to_stg: { issueKey: args.issueKey, status: 'Wait To STG' },
271
+ create_cd_ticket: { issueKey: 'CID-200' },
272
+ prepare_cd_deployment: { issueKey: args.issueKey, status: 'Deployment Created' },
273
+ trigger_deployment: { cdIssueKey: args.cdIssueKey, status: 'Done' },
168
274
  };
169
- return {content: [{type: 'text', text: JSON.stringify(outputs[name])}]};
275
+ return { content: [{ type: 'text', text: JSON.stringify(outputs[name]) }] };
170
276
  },
171
277
  });
172
278
 
@@ -186,6 +292,38 @@ describe('tool schemas — agent contract', () => {
186
292
  assert.equal(calls[5].args.applyForClose, true);
187
293
  });
188
294
 
295
+ test('run_stg_full_release emits route progress for each workflow step', async () => {
296
+ const events = [];
297
+ const result = await executeTool('run_stg_full_release', { systemCode: 'IBK' }, {
298
+ jira: makeMockJira(),
299
+ notifier: mockNotifier,
300
+ progress: (event) => events.push(event),
301
+ executeToolImpl: async (name, args) => {
302
+ const outputs = {
303
+ create_ci_ticket: { issueKey: 'CID-100' },
304
+ build_ticket: { issueKey: args.issueKey, status: 'Compliance Scan' },
305
+ wait_to_stg: { issueKey: args.issueKey, status: 'Wait To STG' },
306
+ create_cd_ticket: { issueKey: 'CID-200' },
307
+ prepare_cd_deployment: { issueKey: args.issueKey, status: 'Deployment Created' },
308
+ trigger_deployment: { cdIssueKey: args.cdIssueKey, status: 'Done' },
309
+ };
310
+ return { content: [{ type: 'text', text: JSON.stringify(outputs[name]) }] };
311
+ },
312
+ });
313
+
314
+ assert.ok(!result.isError, result.content[0].text);
315
+ assert.deepEqual(events.map((event) => event.toolName), [
316
+ 'create_ci_ticket',
317
+ 'build_ticket',
318
+ 'wait_to_stg',
319
+ 'create_cd_ticket',
320
+ 'prepare_cd_deployment',
321
+ 'trigger_deployment',
322
+ ]);
323
+ assert.ok(events.every((event) => event.phase === 'action'));
324
+ assert.equal(events[0].title, '執行 workflow step: create_ci_ticket');
325
+ });
326
+
189
327
  test('run_lib_to_stg_release waits for the Library ticket to reach Released', async () => {
190
328
  const calls = [];
191
329
  const result = await executeTool('run_lib_to_stg_release', {
@@ -194,17 +332,17 @@ describe('tool schemas — agent contract', () => {
194
332
  }, {
195
333
  jira: {
196
334
  ...makeMockJira(),
197
- getIssue: async () => ({fields: {status: {name: 'WAIT FOR LIB BUILD'}}}),
335
+ getIssue: async () => ({ fields: { status: { name: 'WAIT FOR LIB BUILD' } } }),
198
336
  },
199
337
  notifier: mockNotifier,
200
- workflowWaitOptions: {timeoutMs: 1, intervalMs: 1},
338
+ workflowWaitOptions: { timeoutMs: 1, intervalMs: 1 },
201
339
  executeToolImpl: async (name, args) => {
202
- calls.push({name, args});
340
+ calls.push({ name, args });
203
341
  const outputs = {
204
- create_library_ticket: {issueKey: 'LIB-100'},
205
- build_ticket: {issueKey: args.issueKey, status: 'WAIT FOR LIB BUILD'},
342
+ create_library_ticket: { issueKey: 'LIB-100' },
343
+ build_ticket: { issueKey: args.issueKey, status: 'WAIT FOR LIB BUILD' },
206
344
  };
207
- return {content: [{type: 'text', text: JSON.stringify(outputs[name])}]};
345
+ return { content: [{ type: 'text', text: JSON.stringify(outputs[name]) }] };
208
346
  },
209
347
  });
210
348
 
@@ -217,11 +355,11 @@ describe('tool schemas — agent contract', () => {
217
355
  });
218
356
 
219
357
  test('workflow tool failures are marked as MCP errors', async () => {
220
- const result = await executeTool('run_stg_full_release', {systemCode: 'IBK'}, {
358
+ const result = await executeTool('run_stg_full_release', { systemCode: 'IBK' }, {
221
359
  jira: makeMockJira(),
222
360
  notifier: mockNotifier,
223
361
  executeToolImpl: async () => ({
224
- content: [{type: 'text', text: '❌ 錯誤: nested tool failed'}],
362
+ content: [{ type: 'text', text: '❌ 錯誤: nested tool failed' }],
225
363
  isError: true,
226
364
  }),
227
365
  });
@@ -234,8 +372,8 @@ describe('tool schemas — agent contract', () => {
234
372
  test('core dispatcher tool failures are marked as MCP errors', async () => {
235
373
  const result = await executeTool(
236
374
  'prepare_cd_deployment',
237
- {issueKey: 'CID-1', environment: 'qa'},
238
- {jira: makeMockJira(), notifier: mockNotifier},
375
+ { issueKey: 'CID-1', environment: 'qa' },
376
+ { jira: makeMockJira(), notifier: mockNotifier },
239
377
  );
240
378
 
241
379
  assert.equal(result.isError, true);
@@ -259,6 +397,75 @@ describe('tool schemas — agent contract', () => {
259
397
  assert.notEqual(getToolDefinition('run_stg_full_release').annotations?.readOnlyHint, true);
260
398
  assert.notEqual(getToolDefinition('run_lib_to_stg_release').annotations?.readOnlyHint, true);
261
399
  });
400
+
401
+ test('Poller reports each polling attempt with current status and timing', async () => {
402
+ const events = [];
403
+ let calls = 0;
404
+ const jira = {
405
+ getIssue: async () => {
406
+ calls += 1;
407
+ return {
408
+ fields: {
409
+ status: {
410
+ name: calls === 1 ? 'WAIT APPROVAL' : 'WAIT DEPLOY',
411
+ },
412
+ },
413
+ };
414
+ },
415
+ };
416
+
417
+ const result = await new Poller(jira).waitForStatus('CID-822', 'WAIT DEPLOY', {
418
+ intervalMs: 1,
419
+ timeoutMs: 100,
420
+ onProgress: (event) => events.push(event),
421
+ });
422
+
423
+ assert.equal(result.attempts, 2);
424
+ assert.deepEqual(events.map((event) => event.attempts), [1, 2]);
425
+ assert.equal(events[0].phase, 'polling');
426
+ assert.equal(events[0].issueKey, 'CID-822');
427
+ assert.equal(events[0].currentStatus, 'WAIT APPROVAL');
428
+ assert.equal(events[0].targetStatus, 'WAIT DEPLOY');
429
+ assert.equal(events[0].nextPollMs, 1);
430
+ assert.equal(events[1].currentStatus, 'WAIT DEPLOY');
431
+ });
432
+
433
+ test('wait_for_comment reports each polling attempt before the comment is found', async () => {
434
+ const events = [];
435
+ let calls = 0;
436
+ const jira = {
437
+ getComments: async () => {
438
+ calls += 1;
439
+ if (calls === 1) return [];
440
+ return [
441
+ {
442
+ body: 'Approved',
443
+ author: { name: 'BK00178', displayName: 'James Yu' },
444
+ },
445
+ ];
446
+ },
447
+ };
448
+
449
+ const result = await executeTool('wait_for_comment', {
450
+ issueKey: 'CID-822',
451
+ keyword: 'approved',
452
+ authorAccountId: 'BK00178',
453
+ intervalMs: 1,
454
+ timeoutMs: 100,
455
+ }, {
456
+ jira,
457
+ notifier: mockNotifier,
458
+ progress: (event) => events.push(event),
459
+ });
460
+
461
+ const data = JSON.parse(result.content[0].text);
462
+ assert.equal(data.found, true);
463
+ assert.deepEqual(events.map((event) => event.attempts), [1, 2]);
464
+ assert.equal(events[0].phase, 'polling');
465
+ assert.equal(events[0].title, '等待 Jira comment');
466
+ assert.equal(events[0].detail, 'keyword="approved", author=BK00178');
467
+ assert.equal(events[0].issueKey, 'CID-822');
468
+ });
262
469
  });
263
470
 
264
471
  /** 執行 tool,回傳 createIssue 收到的 fields(合併 updateIssue 的欄位,或 throw 若 ❌) */
@@ -367,6 +574,22 @@ describe('create_library_ticket — fields', () => {
367
574
  const f = await getCreatedFields('create_library_ticket', BASE);
368
575
  assert.equal(f['customfield_13431'], 'release/v1.5.2.0');
369
576
  });
577
+ test('通知與回傳使用預設 module,不顯示 undefined', async () => {
578
+ const notifications = [];
579
+ const result = await executeTool(
580
+ 'create_library_ticket',
581
+ { systemCode: 'CWA', gitBranch: 'release/v1.0.0' },
582
+ {
583
+ jira: makeMockJira(),
584
+ notifier: { notify: async (key, message) => notifications.push({ key, message }) },
585
+ },
586
+ );
587
+
588
+ const data = JSON.parse(result.content[0].text);
589
+ assert.equal(data.module, 'cwa');
590
+ assert.match(notifications.at(-1).message, /模組: cwa/);
591
+ assert.doesNotMatch(notifications.at(-1).message, /undefined/);
592
+ });
370
593
  });
371
594
 
372
595
  // ═══════════════════════════════════════════════════════════════════
@@ -591,6 +814,23 @@ describe('create_cd_ticket — fields', () => {
591
814
  });
592
815
  assert.equal(f['customfield_13442'], 'tvdev-ibk-web01');
593
816
  });
817
+ test('通知與回傳使用自動推導 cluster,不顯示 undefined', async () => {
818
+ const notifications = [];
819
+ const result = await executeTool(
820
+ 'create_cd_ticket',
821
+ { systemCode: 'CWA', environment: 'dev' },
822
+ {
823
+ jira: makeMockJira(),
824
+ notifier: { notify: async (key, message) => notifications.push({ key, message }) },
825
+ },
826
+ );
827
+
828
+ const data = JSON.parse(result.content[0].text);
829
+ assert.deepEqual(data.clusterDeploy, ['tvdev-cwa-web01']);
830
+ assert.equal(data.isClusterDeploy, false);
831
+ assert.match(notifications.at(-1).message, /Cluster: tvdev-cwa-web01/);
832
+ assert.doesNotMatch(notifications.at(-1).message, /undefined/);
833
+ });
594
834
  test('customfield_13437 (CID_extra_vars) IBK STG 自動生成', async () => {
595
835
  const f = await getCreatedFields('create_cd_ticket', BASE);
596
836
  const ev = JSON.parse(f['customfield_13437']);
@@ -1348,6 +1588,498 @@ describe('create_grayrelease_ticket — fields', () => {
1348
1588
  });
1349
1589
  });
1350
1590
 
1591
+ // ═══════════════════════════════════════════════════════════════════
1592
+ // auto_grayrelease approval payload handling
1593
+ // ═══════════════════════════════════════════════════════════════════
1594
+ describe('auto_grayrelease — approval payload handling', () => {
1595
+ const savedEnv = {
1596
+ CONF_BASE_URL: process.env.CONF_BASE_URL,
1597
+ CONF_TOKEN: process.env.CONF_TOKEN,
1598
+ JABBER_NOTIFY_SCRIPT: process.env.JABBER_NOTIFY_SCRIPT,
1599
+ POLL_INTERVAL_MS: process.env.POLL_INTERVAL_MS,
1600
+ POLL_TIMEOUT_MS: process.env.POLL_TIMEOUT_MS,
1601
+ };
1602
+
1603
+ function restoreEnv() {
1604
+ for (const [key, value] of Object.entries(savedEnv)) {
1605
+ if (value === undefined) {
1606
+ delete process.env[key];
1607
+ } else {
1608
+ process.env[key] = value;
1609
+ }
1610
+ }
1611
+ }
1612
+
1613
+ function makeApprovalJira(environment, { comments = [] } = {}) {
1614
+ let status = 'WAIT APPROVAL';
1615
+ const calls = { updateAssignee: [], transitionByName: [] };
1616
+
1617
+ return {
1618
+ calls,
1619
+ getIssueFields: async () => ({
1620
+ customfield_13436: { value: environment },
1621
+ customfield_13443: { value: 'CWA' },
1622
+ customfield_13432: 'pass',
1623
+ customfield_13433: 'pass',
1624
+ }),
1625
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
1626
+ getTransitions: async () => [],
1627
+ updateAssignee: async (issueKey, accountId) => {
1628
+ calls.updateAssignee.push({ issueKey, accountId });
1629
+ if (environment === 'stg' || calls.updateAssignee.length >= 2) {
1630
+ status = 'WAIT DEPLOY';
1631
+ }
1632
+ },
1633
+ getComments: async () => comments,
1634
+ searchIssues: async () => [{ fields: { customfield_13436: { value: 'stg' } } }],
1635
+ transitionByName: async (issueKey, transitionName) => {
1636
+ calls.transitionByName.push({ issueKey, transitionName });
1637
+ if (transitionName === 'To Verify') {
1638
+ status = 'VERIFY';
1639
+ }
1640
+ return { transitioned: transitionName, toStatus: status };
1641
+ },
1642
+ };
1643
+ }
1644
+
1645
+ test('STG approval parses get_release_manager MCP content payload', async () => {
1646
+ const server = http.createServer((_req, res) => {
1647
+ res.setHeader('content-type', 'application/json');
1648
+ res.end(JSON.stringify({ events: [{ what: 'Sign off staff', who: 'Alvin Wang' }] }));
1649
+ });
1650
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
1651
+
1652
+ try {
1653
+ process.env.CONF_BASE_URL = `http://127.0.0.1:${server.address().port}`;
1654
+ process.env.CONF_TOKEN = 'test-token';
1655
+ process.env.JABBER_NOTIFY_SCRIPT = '/dev/null';
1656
+ process.env.POLL_INTERVAL_MS = '0';
1657
+ process.env.POLL_TIMEOUT_MS = '10';
1658
+
1659
+ const jira = makeApprovalJira('stg');
1660
+ const result = await executeTool(
1661
+ 'auto_grayrelease',
1662
+ { issueKey: 'CID-100', autoVerify: false },
1663
+ { jira, notifier: mockNotifier },
1664
+ );
1665
+
1666
+ assert.ok(!result.content[0].text.startsWith('❌'), 'STG approval should continue');
1667
+ const data = JSON.parse(result.content[0].text);
1668
+ assert.equal(data.finalStatus, 'VERIFY');
1669
+ assert.deepEqual(jira.calls.updateAssignee, [{ issueKey: 'CID-100', accountId: 'BK00619' }]);
1670
+ } finally {
1671
+ await new Promise((resolve, reject) => {
1672
+ server.close((err) => (err ? reject(err) : resolve()));
1673
+ server.closeAllConnections?.();
1674
+ });
1675
+ restoreEnv();
1676
+ }
1677
+ });
1678
+
1679
+ test('UAT approval parses wait_for_comment MCP content payload', async () => {
1680
+ try {
1681
+ process.env.JABBER_NOTIFY_SCRIPT = '/dev/null';
1682
+ process.env.POLL_INTERVAL_MS = '0';
1683
+ process.env.POLL_TIMEOUT_MS = '10';
1684
+
1685
+ const jira = makeApprovalJira('uat', {
1686
+ comments: [{ body: 'Approved, please proceed', author: { name: 'BK00619', displayName: 'James Yu' } }],
1687
+ });
1688
+ const result = await executeTool(
1689
+ 'auto_grayrelease',
1690
+ { issueKey: 'CID-101', autoVerify: false },
1691
+ { jira, notifier: mockNotifier },
1692
+ );
1693
+
1694
+ assert.ok(!result.content[0].text.startsWith('❌'), 'UAT approval should continue');
1695
+ const data = JSON.parse(result.content[0].text);
1696
+ assert.equal(data.finalStatus, 'VERIFY');
1697
+ assert.deepEqual(jira.calls.updateAssignee, [
1698
+ { issueKey: 'CID-101', accountId: 'BK00619' },
1699
+ { issueKey: 'CID-101', accountId: 'BK00619' },
1700
+ ]);
1701
+ } finally {
1702
+ restoreEnv();
1703
+ }
1704
+ });
1705
+
1706
+ test('UAT approval emits progress for assignee, notification, comment wait, and final approval wait', async () => {
1707
+ const events = [];
1708
+ try {
1709
+ process.env.JABBER_NOTIFY_SCRIPT = '/dev/null';
1710
+ process.env.POLL_INTERVAL_MS = '0';
1711
+ process.env.POLL_TIMEOUT_MS = '10';
1712
+
1713
+ const jira = makeApprovalJira('uat', {
1714
+ comments: [{ body: 'Approved, please proceed', author: { name: 'BK00619', displayName: 'James Yu' } }],
1715
+ });
1716
+ const result = await executeTool(
1717
+ 'auto_grayrelease',
1718
+ { issueKey: 'CID-101', autoVerify: false },
1719
+ {
1720
+ jira,
1721
+ notifier: mockNotifier,
1722
+ progress: (event) => events.push(event),
1723
+ },
1724
+ );
1725
+
1726
+ assert.ok(!result.content[0].text.startsWith('❌'), result.content[0].text);
1727
+ assert.ok(events.some((event) => event.title === '指派 GrayRelease 簽核人' && event.detail.includes('James Yu')));
1728
+ assert.ok(events.some((event) => event.title === '發送 GrayRelease 簽核通知' && event.detail.includes('James Yu')));
1729
+ assert.ok(events.some((event) => event.title === '等待 Jira comment' && event.attempts === 1));
1730
+ assert.ok(events.some((event) => event.title === '指派 GrayRelease 簽核人' && event.detail.includes('Solar Chen')));
1731
+ assert.ok(events.some((event) => event.phase === 'polling' && event.targetStatus === 'WAIT DEPLOY'));
1732
+ } finally {
1733
+ restoreEnv();
1734
+ }
1735
+ });
1736
+
1737
+ test('WAIT APPROVAL fails when GrayRelease environment field is missing', async () => {
1738
+ const transitions = [];
1739
+ const jira = {
1740
+ getIssueFields: async () => ({
1741
+ customfield_13443: { value: 'CWA' },
1742
+ }),
1743
+ getIssue: async () => ({ fields: { status: { name: 'WAIT APPROVAL' }, summary: 'GrayRelease' } }),
1744
+ transitionByName: async (_issueKey, transitionName) => {
1745
+ transitions.push(transitionName);
1746
+ },
1747
+ };
1748
+
1749
+ const result = await executeTool(
1750
+ 'auto_grayrelease',
1751
+ { issueKey: 'CID-102', autoVerify: false },
1752
+ { jira, notifier: mockNotifier },
1753
+ );
1754
+
1755
+ assert.ok(result.content[0].text.startsWith('❌'), 'missing environment should fail closed');
1756
+ assert.match(result.content[0].text, /無法讀取 GrayRelease 環境欄位/);
1757
+ assert.deepEqual(transitions, []);
1758
+ });
1759
+ });
1760
+
1761
+ // ═══════════════════════════════════════════════════════════════════
1762
+ // auto_grayrelease build/deploy completion result fields
1763
+ // ═══════════════════════════════════════════════════════════════════
1764
+ describe('auto_grayrelease — build/deploy result fields', () => {
1765
+ const savedEnv = {
1766
+ POLL_INTERVAL_MS: process.env.POLL_INTERVAL_MS,
1767
+ POLL_TIMEOUT_MS: process.env.POLL_TIMEOUT_MS,
1768
+ SWITCH_EXECUTION_NODE_WAIT_MS: process.env.SWITCH_EXECUTION_NODE_WAIT_MS,
1769
+ };
1770
+
1771
+ function useFastPolling() {
1772
+ process.env.POLL_INTERVAL_MS = '0';
1773
+ process.env.POLL_TIMEOUT_MS = '10';
1774
+ process.env.SWITCH_EXECUTION_NODE_WAIT_MS = '0';
1775
+ }
1776
+
1777
+ function restoreEnv() {
1778
+ for (const [key, value] of Object.entries(savedEnv)) {
1779
+ if (value === undefined) {
1780
+ delete process.env[key];
1781
+ } else {
1782
+ process.env[key] = value;
1783
+ }
1784
+ }
1785
+ }
1786
+
1787
+ test('get_grayrelease_status normalizes Jira display status names', async () => {
1788
+ const jira = {
1789
+ getIssueFields: async () => ({
1790
+ customfield_13436: { value: 'dev' },
1791
+ customfield_13443: { value: 'IBK' },
1792
+ }),
1793
+ getIssue: async () => ({ fields: { status: { name: 'Wait for Build' }, summary: 'GrayRelease' } }),
1794
+ getTransitions: async () => [
1795
+ { id: '1', name: 'Apply to approval', to: { name: 'Wait Approval' } },
1796
+ { id: '2', name: 'GrayRelease Build', to: { name: 'Wait for Build' } },
1797
+ ],
1798
+ };
1799
+
1800
+ const result = await executeTool(
1801
+ 'get_grayrelease_status',
1802
+ { issueKey: 'CID-822' },
1803
+ { jira, notifier: mockNotifier },
1804
+ );
1805
+
1806
+ assert.ok(!result.isError, result.content[0].text);
1807
+ const output = JSON.parse(result.content[0].text);
1808
+ assert.equal(output.currentStatus, 'Wait for Build');
1809
+ assert.equal(output.normalizedStatus, 'WAIT FOR BUILD');
1810
+ assert.deepEqual(output.nextSteps, ['GrayRelease Build']);
1811
+ });
1812
+
1813
+ test('deploy_grayrelease advances Wait for Build via approval without rebuilding', async () => {
1814
+ useFastPolling();
1815
+ let status = 'Wait for Build';
1816
+ const transitions = [];
1817
+ const jira = {
1818
+ getIssueFields: async (_issueKey, fieldNames = []) => {
1819
+ if (fieldNames.includes('customfield_13433')) {
1820
+ return { customfield_13433: 'pass' };
1821
+ }
1822
+ return {
1823
+ customfield_13436: { value: 'dev' },
1824
+ customfield_13443: { value: 'IBK' },
1825
+ };
1826
+ },
1827
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
1828
+ getTransitions: async () => {
1829
+ if (status === 'Wait for Build') {
1830
+ return [
1831
+ { id: '1', name: 'Apply to approval', to: { name: 'Wait Approval' } },
1832
+ { id: '2', name: 'GrayRelease Build', to: { name: 'Wait for Build' } },
1833
+ ];
1834
+ }
1835
+ return [];
1836
+ },
1837
+ searchIssues: async () => [{ fields: { customfield_13436: { value: 'stg' } } }],
1838
+ transitionByName: async (_issueKey, transitionName) => {
1839
+ transitions.push(transitionName);
1840
+ if (transitionName === 'Apply to approval') status = 'Wait Approval';
1841
+ if (transitionName === 'Approve') status = 'Wait Deploy';
1842
+ if (transitionName === 'To Verify') status = 'VERIFY';
1843
+ },
1844
+ };
1845
+
1846
+ try {
1847
+ const result = await executeTool(
1848
+ 'deploy_grayrelease',
1849
+ { issueKey: 'CID-822' },
1850
+ { jira, notifier: mockNotifier },
1851
+ );
1852
+
1853
+ assert.ok(!result.isError, result.content[0].text);
1854
+ const output = JSON.parse(result.content[0].text);
1855
+ assert.equal(output.finalStatus, 'VERIFY');
1856
+ assert.deepEqual(transitions, [
1857
+ 'Apply to approval',
1858
+ 'Approve',
1859
+ 'GrayRelease Deploy',
1860
+ 'To Verify',
1861
+ ]);
1862
+ } finally {
1863
+ restoreEnv();
1864
+ }
1865
+ });
1866
+
1867
+ test('deploy_grayrelease stops in VERIFY without rebuilding', async () => {
1868
+ const transitions = [];
1869
+ const jira = {
1870
+ getIssueFields: async () => ({
1871
+ customfield_13436: { value: 'dev' },
1872
+ customfield_13443: { value: 'IBK' },
1873
+ }),
1874
+ getIssue: async () => ({ fields: { status: { name: 'VERIFY' }, summary: 'GrayRelease' } }),
1875
+ transitionByName: async (_issueKey, transitionName) => {
1876
+ transitions.push(transitionName);
1877
+ },
1878
+ };
1879
+
1880
+ const result = await executeTool(
1881
+ 'deploy_grayrelease',
1882
+ { issueKey: 'CID-822' },
1883
+ { jira, notifier: mockNotifier },
1884
+ );
1885
+
1886
+ assert.ok(!result.isError, result.content[0].text);
1887
+ const output = JSON.parse(result.content[0].text);
1888
+ assert.equal(output.finalStatus, 'VERIFY');
1889
+ assert.deepEqual(transitions, []);
1890
+ assert.match(output.log.join('\n'), /若需重 build 請明確執行 build\/rebuild/);
1891
+ });
1892
+
1893
+ test('Build waits until customfield_13432 becomes pass before Apply to approval', async () => {
1894
+ useFastPolling();
1895
+ const events = [];
1896
+ let status = 'WAIT FOR BUILD';
1897
+ let buildResultCalls = 0;
1898
+ const transitions = [];
1899
+ const jira = {
1900
+ getIssueFields: async (_issueKey, fieldNames = []) => {
1901
+ if (fieldNames.includes('customfield_13432')) {
1902
+ buildResultCalls++;
1903
+ return { customfield_13432: buildResultCalls >= 3 ? 'pass' : 'starting' };
1904
+ }
1905
+ return {
1906
+ customfield_13436: { value: 'dev' },
1907
+ customfield_13443: { value: 'CWA' },
1908
+ };
1909
+ },
1910
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
1911
+ transitionByName: async (_issueKey, transitionName) => {
1912
+ transitions.push(transitionName);
1913
+ if (transitionName === 'Apply to approval') {
1914
+ status = 'DONE';
1915
+ }
1916
+ },
1917
+ };
1918
+
1919
+ try {
1920
+ const result = await executeTool(
1921
+ 'auto_grayrelease',
1922
+ { issueKey: 'CID-200', autoVerify: false },
1923
+ { jira, notifier: mockNotifier, progress: (event) => events.push(event) },
1924
+ );
1925
+
1926
+ assert.ok(!result.content[0].text.startsWith('❌'), 'Build flow should succeed');
1927
+ assert.equal(buildResultCalls, 3);
1928
+ assert.deepEqual(transitions, ['GrayRelease Build', 'Apply to approval']);
1929
+ assert.ok(events.some((event) => (
1930
+ event.phase === 'polling' &&
1931
+ event.title === '等待 GrayRelease Build 結果' &&
1932
+ event.attempts === 3
1933
+ )));
1934
+ } finally {
1935
+ restoreEnv();
1936
+ }
1937
+ });
1938
+
1939
+ test('Deploy waits until customfield_13433 becomes pass before To Verify', async () => {
1940
+ useFastPolling();
1941
+ let status = 'WAIT DEPLOY';
1942
+ let deployResultCalls = 0;
1943
+ const transitions = [];
1944
+ const jira = {
1945
+ getIssueFields: async (_issueKey, fieldNames = []) => {
1946
+ if (fieldNames.includes('customfield_13433')) {
1947
+ deployResultCalls++;
1948
+ return { customfield_13433: deployResultCalls >= 3 ? 'pass' : 'starting' };
1949
+ }
1950
+ return {
1951
+ customfield_13436: { value: 'stg' },
1952
+ customfield_13443: { value: 'CWA' },
1953
+ };
1954
+ },
1955
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
1956
+ searchIssues: async () => [{ fields: { customfield_13436: { value: 'stg' } } }],
1957
+ transitionByName: async (_issueKey, transitionName) => {
1958
+ transitions.push(transitionName);
1959
+ if (transitionName === 'To Verify') {
1960
+ status = 'VERIFY';
1961
+ }
1962
+ },
1963
+ };
1964
+
1965
+ try {
1966
+ const result = await executeTool(
1967
+ 'auto_grayrelease',
1968
+ { issueKey: 'CID-201', autoVerify: false },
1969
+ { jira, notifier: mockNotifier },
1970
+ );
1971
+
1972
+ assert.ok(!result.content[0].text.startsWith('❌'), 'Deploy flow should succeed');
1973
+ assert.equal(deployResultCalls, 3);
1974
+ assert.deepEqual(transitions, ['GrayRelease Deploy', 'To Verify']);
1975
+ } finally {
1976
+ restoreEnv();
1977
+ }
1978
+ });
1979
+
1980
+ test('Deploy skips Switch Execution Node when recent cid jira worker success comment exists', async () => {
1981
+ useFastPolling();
1982
+ let status = 'WAIT DEPLOY';
1983
+ const transitions = [];
1984
+ const jira = {
1985
+ getIssueFields: async (_issueKey, fieldNames = []) => {
1986
+ if (fieldNames.includes('customfield_13433')) {
1987
+ return { customfield_13433: 'pass' };
1988
+ }
1989
+ return {
1990
+ customfield_13436: { value: 'stg' },
1991
+ customfield_13443: { value: 'IBK' },
1992
+ };
1993
+ },
1994
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
1995
+ getComments: async () => [
1996
+ {
1997
+ body: "Trigger update IBK's instance_group to [ING]NonPRD_ExecutionNode success, please wait about 3 mins before trigger deploy.",
1998
+ author: { displayName: 'cid jira worker' },
1999
+ created: new Date().toISOString(),
2000
+ },
2001
+ ],
2002
+ searchIssues: async () => [{ fields: { customfield_13436: { value: 'prd' } } }],
2003
+ transitionByName: async (_issueKey, transitionName) => {
2004
+ transitions.push(transitionName);
2005
+ if (transitionName === 'To Verify') {
2006
+ status = 'VERIFY';
2007
+ }
2008
+ },
2009
+ };
2010
+
2011
+ try {
2012
+ const result = await executeTool(
2013
+ 'auto_grayrelease',
2014
+ { issueKey: 'CID-202', autoVerify: false },
2015
+ { jira, notifier: mockNotifier },
2016
+ );
2017
+
2018
+ assert.ok(!result.content[0].text.startsWith('❌'), 'Deploy flow should succeed');
2019
+ assert.deepEqual(transitions, ['GrayRelease Deploy', 'To Verify']);
2020
+ } finally {
2021
+ restoreEnv();
2022
+ }
2023
+ });
2024
+
2025
+ test('Deploy waits for worker success comment after Switch Execution Node then deploys directly', async () => {
2026
+ useFastPolling();
2027
+ let status = 'WAIT DEPLOY';
2028
+ let getCommentsCalls = 0;
2029
+ let searchIssuesCalls = 0;
2030
+ const transitions = [];
2031
+ const jira = {
2032
+ getIssueFields: async (_issueKey, fieldNames = []) => {
2033
+ if (fieldNames.includes('customfield_13433')) {
2034
+ return { customfield_13433: 'pass' };
2035
+ }
2036
+ return {
2037
+ customfield_13436: { value: 'stg' },
2038
+ customfield_13443: { value: 'IBK' },
2039
+ };
2040
+ },
2041
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
2042
+ getComments: async () => {
2043
+ getCommentsCalls++;
2044
+ if (getCommentsCalls < 2) {
2045
+ return [];
2046
+ }
2047
+ return [
2048
+ {
2049
+ body: "Trigger update IBK's instance_group to [ING]NonPRD_ExecutionNode success, please wait about 3 mins before trigger deploy.",
2050
+ author: { displayName: 'CID Jira Worker' },
2051
+ created: new Date().toISOString(),
2052
+ },
2053
+ ];
2054
+ },
2055
+ searchIssues: async () => {
2056
+ searchIssuesCalls++;
2057
+ return [{ fields: { customfield_13436: { value: 'prd' } } }];
2058
+ },
2059
+ transitionByName: async (_issueKey, transitionName) => {
2060
+ transitions.push(transitionName);
2061
+ if (transitionName === 'To Verify') {
2062
+ status = 'VERIFY';
2063
+ }
2064
+ },
2065
+ };
2066
+
2067
+ try {
2068
+ const result = await executeTool(
2069
+ 'auto_grayrelease',
2070
+ { issueKey: 'CID-203', autoVerify: false },
2071
+ { jira, notifier: mockNotifier },
2072
+ );
2073
+
2074
+ assert.ok(!result.content[0].text.startsWith('❌'), 'Deploy flow should succeed');
2075
+ assert.equal(searchIssuesCalls, 1);
2076
+ assert.deepEqual(transitions, ['Switch Execution Node', 'GrayRelease Deploy', 'To Verify']);
2077
+ } finally {
2078
+ restoreEnv();
2079
+ }
2080
+ });
2081
+ });
2082
+
1351
2083
  // ═══════════════════════════════════════════════════════════════════
1352
2084
  // prepare_cd_deployment
1353
2085
  // ═══════════════════════════════════════════════════════════════════
@@ -1376,10 +2108,23 @@ describe('prepare_cd_deployment', () => {
1376
2108
  return {};
1377
2109
  },
1378
2110
  getIssue: async () => ({ fields: { status: { name: resultStatus }, summary: 'CD test' } }),
1379
- addComment: async () => {},
2111
+ addComment: async () => { },
1380
2112
  };
1381
2113
  }
1382
2114
 
2115
+ test('dev:更新 customfield_13436 為 id 14355', async () => {
2116
+ const jira = makeDeployMock();
2117
+ await executeTool(
2118
+ 'prepare_cd_deployment',
2119
+ { issueKey: 'CID-1697', environment: 'dev' },
2120
+ {
2121
+ jira,
2122
+ notifier: mockNotifier,
2123
+ },
2124
+ );
2125
+ assert.deepEqual(jira.calls.updateIssue[0].fields['customfield_13436'], { id: '14355' });
2126
+ });
2127
+
1383
2128
  test('stg:更新 customfield_13436 為 id 14356', async () => {
1384
2129
  const jira = makeDeployMock();
1385
2130
  await executeTool(
@@ -1492,12 +2237,12 @@ describe('prepare_cd_deployment', () => {
1492
2237
 
1493
2238
  test('找不到任何 deploy transition → 回傳錯誤', async () => {
1494
2239
  const jira = {
1495
- updateIssue: async () => {},
2240
+ updateIssue: async () => { },
1496
2241
  getTransitions: async () => [
1497
2242
  { id: '1', name: 'Some Other Transition', to: { name: 'Other' } },
1498
2243
  ],
1499
2244
  getIssue: async () => ({ fields: { status: { name: 'Approved' }, summary: 'CD test' } }),
1500
- addComment: async () => {},
2245
+ addComment: async () => { },
1501
2246
  };
1502
2247
  const result = await executeTool(
1503
2248
  'prepare_cd_deployment',
@@ -1529,7 +2274,7 @@ describe('prepare_cd_deployment', () => {
1529
2274
  let callCount = 0;
1530
2275
  const calls = { transitionById: [] };
1531
2276
  const jira = {
1532
- updateIssue: async () => {},
2277
+ updateIssue: async () => { },
1533
2278
  getTransitions: async () => {
1534
2279
  callCount++;
1535
2280
  // findDeployTrans(1) → 無;pre-transition check(2) → Accept;findDeployTrans after Accept(3) → deploy
@@ -1548,7 +2293,7 @@ describe('prepare_cd_deployment', () => {
1548
2293
  return {};
1549
2294
  },
1550
2295
  getIssue: async () => ({ fields: { status: { name: 'Prepare For Deploy' }, summary: 'CD' } }),
1551
- addComment: async () => {},
2296
+ addComment: async () => { },
1552
2297
  };
1553
2298
  const result = await executeTool(
1554
2299
  'prepare_cd_deployment',
@@ -1573,6 +2318,146 @@ describe('prepare_cd_deployment', () => {
1573
2318
  '第 2 次應觸發 Prepare to create deployment ticket',
1574
2319
  );
1575
2320
  });
2321
+
2322
+ test('dev:建立 deployment 後自助執行 Apply for approval 與 Approved 到 Wait Deploy', async () => {
2323
+ let callCount = 0;
2324
+ let status = 'TO DO';
2325
+ const calls = { transitionById: [] };
2326
+ const jira = {
2327
+ updateIssue: async () => { },
2328
+ getTransitions: async () => {
2329
+ callCount++;
2330
+ if (callCount === 1) {
2331
+ return [{ id: '71', name: 'Prepare to create deployment ticket', to: { name: 'Prepare For Deploy' } }];
2332
+ }
2333
+ if (callCount === 2) {
2334
+ return [{ id: '21', name: 'Apply for approval', to: { name: 'Wait Approval' } }];
2335
+ }
2336
+ if (callCount === 3) {
2337
+ return [{ id: '72', name: 'Approved', to: { name: 'Wait Deploy' } }];
2338
+ }
2339
+ return [];
2340
+ },
2341
+ transitionById: async (key, id) => {
2342
+ calls.transitionById.push({ key, id });
2343
+ if (id === '71') status = 'Prepare For Deploy';
2344
+ if (id === '21') status = 'Wait Approval';
2345
+ if (id === '72') status = 'Wait Deploy';
2346
+ return {};
2347
+ },
2348
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'CD' } }),
2349
+ addComment: async () => { },
2350
+ };
2351
+ const result = await executeTool(
2352
+ 'prepare_cd_deployment',
2353
+ { issueKey: 'CID-1697', environment: 'dev' },
2354
+ {
2355
+ jira,
2356
+ notifier: mockNotifier,
2357
+ },
2358
+ );
2359
+ assert.ok(!result.content[0].text.startsWith('❌'), '應成功觸發');
2360
+ const data = JSON.parse(result.content[0].text);
2361
+ assert.equal(data.status, 'Wait Deploy');
2362
+ assert.deepEqual(calls.transitionById.map((call) => call.id), ['71', '21', '72']);
2363
+ });
2364
+
2365
+ test('dev:deployment ticket 已存在時續跑 approval 到 Wait Deploy', async () => {
2366
+ let callCount = 0;
2367
+ let status = 'Prepare For Deploy';
2368
+ const calls = { transitionById: [] };
2369
+ const jira = {
2370
+ updateIssue: async () => { },
2371
+ getTransitions: async () => {
2372
+ callCount++;
2373
+ if (callCount === 1) {
2374
+ return [{ id: '71', name: 'Prepare to create deployment ticket', to: { name: 'Prepare For Deploy' } }];
2375
+ }
2376
+ if (callCount === 2) {
2377
+ return [{ id: '21', name: 'Apply for approval', to: { name: 'Wait Approval' } }];
2378
+ }
2379
+ if (callCount === 3) {
2380
+ return [{ id: '72', name: 'Approved', to: { name: 'Wait Deploy' } }];
2381
+ }
2382
+ return [];
2383
+ },
2384
+ transitionById: async (key, id) => {
2385
+ calls.transitionById.push({ key, id });
2386
+ if (id === '71') throw new Error('Transition id=71 failed: {"errorMessages":["Already create deployment ticket"],"errors":{}}');
2387
+ if (id === '21') status = 'Wait Approval';
2388
+ if (id === '72') status = 'Wait Deploy';
2389
+ return {};
2390
+ },
2391
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'CD' } }),
2392
+ addComment: async () => { },
2393
+ };
2394
+ const result = await executeTool(
2395
+ 'prepare_cd_deployment',
2396
+ { issueKey: 'CID-1910', environment: 'dev' },
2397
+ {
2398
+ jira,
2399
+ notifier: mockNotifier,
2400
+ },
2401
+ );
2402
+
2403
+ assert.ok(!result.content[0].text.startsWith('❌'), '應成功續跑');
2404
+ const data = JSON.parse(result.content[0].text);
2405
+ assert.equal(data.status, 'Wait Deploy');
2406
+ assert.deepEqual(calls.transitionById.map((call) => call.id), ['71', '21', '72']);
2407
+ assert.ok(data.steps.some((step) => step.includes('Deployment ticket 已存在')));
2408
+ });
2409
+ });
2410
+
2411
+ // ═══════════════════════════════════════════════════════════════════
2412
+ // wait_to_dev
2413
+ // ═══════════════════════════════════════════════════════════════════
2414
+ describe('wait_to_dev', () => {
2415
+ test('執行 Upload Scan Report 與 Accept,停在 Wait To DEV', async () => {
2416
+ const calls = [];
2417
+ const jira = {
2418
+ getTransitions: async () => [
2419
+ { id: '10', name: 'Upload Scan Report', to: { name: 'Upload Report' } },
2420
+ { id: '11', name: 'Accept', to: { name: 'Wait To DEV' } },
2421
+ ],
2422
+ transitionById: async (key, id) => {
2423
+ calls.push({ key, id });
2424
+ },
2425
+ getIssue: async () => ({ fields: { status: { name: 'Wait To DEV' }, summary: 'CI' } }),
2426
+ addComment: async () => { },
2427
+ };
2428
+
2429
+ const result = await executeTool(
2430
+ 'wait_to_dev',
2431
+ { issueKey: 'CID-1709' },
2432
+ { jira, notifier: mockNotifier },
2433
+ );
2434
+
2435
+ assert.ok(!result.content[0].text.startsWith('❌'), '應成功觸發');
2436
+ const data = JSON.parse(result.content[0].text);
2437
+ assert.equal(data.status, 'Wait To DEV');
2438
+ assert.deepEqual(calls.map((call) => call.id), ['10', '11']);
2439
+ });
2440
+
2441
+ test('已是 Wait To DEV 時可跳過缺少的 Accept transition', async () => {
2442
+ const calls = [];
2443
+ const jira = {
2444
+ getTransitions: async () => [],
2445
+ transitionById: async (key, id) => {
2446
+ calls.push({ key, id });
2447
+ },
2448
+ getIssue: async () => ({ fields: { status: { name: 'Wait To DEV' }, summary: 'CI' } }),
2449
+ addComment: async () => { },
2450
+ };
2451
+
2452
+ const result = await executeTool(
2453
+ 'wait_to_dev',
2454
+ { issueKey: 'CID-1709' },
2455
+ { jira, notifier: mockNotifier },
2456
+ );
2457
+
2458
+ assert.ok(!result.content[0].text.startsWith('❌'), '應成功');
2459
+ assert.equal(calls.length, 0);
2460
+ });
1576
2461
  });
1577
2462
 
1578
2463
  // ═══════════════════════════════════════════════════════════════════
@@ -1615,7 +2500,7 @@ describe('trigger_deployment', () => {
1615
2500
  getIssue: async (key) => ({
1616
2501
  fields: { status: { name: finalStatus }, summary: `[MOCK] ${key}` },
1617
2502
  }),
1618
- addComment: async () => {},
2503
+ addComment: async () => { },
1619
2504
  };
1620
2505
  }
1621
2506
 
@@ -1703,6 +2588,26 @@ describe('trigger_deployment', () => {
1703
2588
  assert.equal(data.deploymentKey, 'CID-9002', '應選 UAT sub-task');
1704
2589
  });
1705
2590
 
2591
+ test('環境比對:[DEV] summary 對應 dev → 選擇 DEV sub-task', async () => {
2592
+ const jira = makeTriggerMock({
2593
+ subTasks: [makeSubTask('CID-9001', '[DEV]'), makeSubTask('CID-9002', '[STG]')],
2594
+ deployTrans: [
2595
+ { id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } },
2596
+ { id: '12', name: 'Trigger AutoDeploy', to: { name: 'Auto Deploy' } },
2597
+ ],
2598
+ });
2599
+ const result = await executeTool(
2600
+ 'trigger_deployment',
2601
+ { cdIssueKey: 'CID-9000', environment: 'dev' },
2602
+ {
2603
+ jira,
2604
+ notifier: mockNotifier,
2605
+ },
2606
+ );
2607
+ const data = JSON.parse(result.content[0].text);
2608
+ assert.equal(data.deploymentKey, 'CID-9001', '應選 DEV sub-task');
2609
+ });
2610
+
1706
2611
  test('無符合環境的 sub-task → fallback 取第一個', async () => {
1707
2612
  const jira = makeTriggerMock({
1708
2613
  subTasks: [makeSubTask('CID-9001', '[DEV]')],