@jira-deploy/core 1.0.3 → 1.0.5

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
@@ -6,6 +6,7 @@ import { test, describe } from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
7
  import http from 'node:http';
8
8
  import { executeTool, getToolDefinitions } from './tools/index.js';
9
+ import { Poller } from './poller.js';
9
10
 
10
11
  process.env.JIRA_BASE_URL = 'https://jira.test';
11
12
 
@@ -110,9 +111,9 @@ function makeMockJira({
110
111
  },
111
112
  getIssue: async () => ({ fields: { status: { name: 'TO DO' }, summary: 'test' } }),
112
113
  getTransitions: async () => [],
113
- transitionById: async () => {},
114
+ transitionById: async () => { },
114
115
  transitionByName: async () => ({ transitioned: 'Test', toStatus: 'Done' }),
115
- addComment: async () => {},
116
+ addComment: async () => { },
116
117
  getSubTasks: async () => [],
117
118
  };
118
119
  }
@@ -141,6 +142,19 @@ describe('tool schemas — agent contract', () => {
141
142
  }
142
143
  });
143
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
+
144
158
  test('create_library_ticket schema treats module as defaultable', () => {
145
159
  const tool = getToolDefinition('create_library_ticket');
146
160
 
@@ -157,22 +171,108 @@ describe('tool schemas — agent contract', () => {
157
171
  assert.ok(getToolDefinition('wait_to_dev'));
158
172
  });
159
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
+
160
260
  test('run_stg_full_release dispatches shared workflow steps', async () => {
161
261
  const calls = [];
162
- const result = await executeTool('run_stg_full_release', {systemCode: 'IBK'}, {
262
+ const result = await executeTool('run_stg_full_release', { systemCode: 'IBK' }, {
163
263
  jira: makeMockJira(),
164
264
  notifier: mockNotifier,
165
265
  executeToolImpl: async (name, args) => {
166
- calls.push({name, args});
266
+ calls.push({ name, args });
167
267
  const outputs = {
168
- create_ci_ticket: {issueKey: 'CID-100'},
169
- build_ticket: {issueKey: args.issueKey, status: 'Compliance Scan'},
170
- wait_to_stg: {issueKey: args.issueKey, status: 'Wait To STG'},
171
- create_cd_ticket: {issueKey: 'CID-200'},
172
- prepare_cd_deployment: {issueKey: args.issueKey, status: 'Deployment Created'},
173
- 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' },
174
274
  };
175
- return {content: [{type: 'text', text: JSON.stringify(outputs[name])}]};
275
+ return { content: [{ type: 'text', text: JSON.stringify(outputs[name]) }] };
176
276
  },
177
277
  });
178
278
 
@@ -192,6 +292,38 @@ describe('tool schemas — agent contract', () => {
192
292
  assert.equal(calls[5].args.applyForClose, true);
193
293
  });
194
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
+
195
327
  test('run_lib_to_stg_release waits for the Library ticket to reach Released', async () => {
196
328
  const calls = [];
197
329
  const result = await executeTool('run_lib_to_stg_release', {
@@ -200,17 +332,17 @@ describe('tool schemas — agent contract', () => {
200
332
  }, {
201
333
  jira: {
202
334
  ...makeMockJira(),
203
- getIssue: async () => ({fields: {status: {name: 'WAIT FOR LIB BUILD'}}}),
335
+ getIssue: async () => ({ fields: { status: { name: 'WAIT FOR LIB BUILD' } } }),
204
336
  },
205
337
  notifier: mockNotifier,
206
- workflowWaitOptions: {timeoutMs: 1, intervalMs: 1},
338
+ workflowWaitOptions: { timeoutMs: 1, intervalMs: 1 },
207
339
  executeToolImpl: async (name, args) => {
208
- calls.push({name, args});
340
+ calls.push({ name, args });
209
341
  const outputs = {
210
- create_library_ticket: {issueKey: 'LIB-100'},
211
- 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' },
212
344
  };
213
- return {content: [{type: 'text', text: JSON.stringify(outputs[name])}]};
345
+ return { content: [{ type: 'text', text: JSON.stringify(outputs[name]) }] };
214
346
  },
215
347
  });
216
348
 
@@ -223,11 +355,11 @@ describe('tool schemas — agent contract', () => {
223
355
  });
224
356
 
225
357
  test('workflow tool failures are marked as MCP errors', async () => {
226
- const result = await executeTool('run_stg_full_release', {systemCode: 'IBK'}, {
358
+ const result = await executeTool('run_stg_full_release', { systemCode: 'IBK' }, {
227
359
  jira: makeMockJira(),
228
360
  notifier: mockNotifier,
229
361
  executeToolImpl: async () => ({
230
- content: [{type: 'text', text: '❌ 錯誤: nested tool failed'}],
362
+ content: [{ type: 'text', text: '❌ 錯誤: nested tool failed' }],
231
363
  isError: true,
232
364
  }),
233
365
  });
@@ -240,8 +372,8 @@ describe('tool schemas — agent contract', () => {
240
372
  test('core dispatcher tool failures are marked as MCP errors', async () => {
241
373
  const result = await executeTool(
242
374
  'prepare_cd_deployment',
243
- {issueKey: 'CID-1', environment: 'qa'},
244
- {jira: makeMockJira(), notifier: mockNotifier},
375
+ { issueKey: 'CID-1', environment: 'qa' },
376
+ { jira: makeMockJira(), notifier: mockNotifier },
245
377
  );
246
378
 
247
379
  assert.equal(result.isError, true);
@@ -265,6 +397,75 @@ describe('tool schemas — agent contract', () => {
265
397
  assert.notEqual(getToolDefinition('run_stg_full_release').annotations?.readOnlyHint, true);
266
398
  assert.notEqual(getToolDefinition('run_lib_to_stg_release').annotations?.readOnlyHint, true);
267
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
+ });
268
469
  });
269
470
 
270
471
  /** 執行 tool,回傳 createIssue 收到的 fields(合併 updateIssue 的欄位,或 throw 若 ❌) */
@@ -377,10 +578,10 @@ describe('create_library_ticket — fields', () => {
377
578
  const notifications = [];
378
579
  const result = await executeTool(
379
580
  'create_library_ticket',
380
- {systemCode: 'CWA', gitBranch: 'release/v1.0.0'},
581
+ { systemCode: 'CWA', gitBranch: 'release/v1.0.0' },
381
582
  {
382
583
  jira: makeMockJira(),
383
- notifier: {notify: async (key, message) => notifications.push({key, message})},
584
+ notifier: { notify: async (key, message) => notifications.push({ key, message }) },
384
585
  },
385
586
  );
386
587
 
@@ -617,10 +818,10 @@ describe('create_cd_ticket — fields', () => {
617
818
  const notifications = [];
618
819
  const result = await executeTool(
619
820
  'create_cd_ticket',
620
- {systemCode: 'CWA', environment: 'dev'},
821
+ { systemCode: 'CWA', environment: 'dev' },
621
822
  {
622
823
  jira: makeMockJira(),
623
- notifier: {notify: async (key, message) => notifications.push({key, message})},
824
+ notifier: { notify: async (key, message) => notifications.push({ key, message }) },
624
825
  },
625
826
  );
626
827
 
@@ -1409,34 +1610,34 @@ describe('auto_grayrelease — approval payload handling', () => {
1409
1610
  }
1410
1611
  }
1411
1612
 
1412
- function makeApprovalJira(environment, {comments = []} = {}) {
1613
+ function makeApprovalJira(environment, { comments = [] } = {}) {
1413
1614
  let status = 'WAIT APPROVAL';
1414
- const calls = {updateAssignee: [], transitionByName: []};
1615
+ const calls = { updateAssignee: [], transitionByName: [] };
1415
1616
 
1416
1617
  return {
1417
1618
  calls,
1418
1619
  getIssueFields: async () => ({
1419
- customfield_13436: {value: environment},
1420
- customfield_13443: {value: 'CWA'},
1421
- CID_build_result: 'pass',
1422
- CID_deploy_result: 'pass',
1620
+ customfield_13436: { value: environment },
1621
+ customfield_13443: { value: 'CWA' },
1622
+ customfield_13432: 'pass',
1623
+ customfield_13433: 'pass',
1423
1624
  }),
1424
- getIssue: async () => ({fields: {status: {name: status}, summary: 'GrayRelease'}}),
1625
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
1425
1626
  getTransitions: async () => [],
1426
1627
  updateAssignee: async (issueKey, accountId) => {
1427
- calls.updateAssignee.push({issueKey, accountId});
1628
+ calls.updateAssignee.push({ issueKey, accountId });
1428
1629
  if (environment === 'stg' || calls.updateAssignee.length >= 2) {
1429
1630
  status = 'WAIT DEPLOY';
1430
1631
  }
1431
1632
  },
1432
1633
  getComments: async () => comments,
1433
- searchIssues: async () => [{fields: {customfield_13436: {value: 'stg'}}}],
1634
+ searchIssues: async () => [{ fields: { customfield_13436: { value: 'stg' } } }],
1434
1635
  transitionByName: async (issueKey, transitionName) => {
1435
- calls.transitionByName.push({issueKey, transitionName});
1636
+ calls.transitionByName.push({ issueKey, transitionName });
1436
1637
  if (transitionName === 'To Verify') {
1437
1638
  status = 'VERIFY';
1438
1639
  }
1439
- return {transitioned: transitionName, toStatus: status};
1640
+ return { transitioned: transitionName, toStatus: status };
1440
1641
  },
1441
1642
  };
1442
1643
  }
@@ -1444,7 +1645,7 @@ describe('auto_grayrelease — approval payload handling', () => {
1444
1645
  test('STG approval parses get_release_manager MCP content payload', async () => {
1445
1646
  const server = http.createServer((_req, res) => {
1446
1647
  res.setHeader('content-type', 'application/json');
1447
- res.end(JSON.stringify({events: [{what: 'Sign off staff', who: 'Alvin Wang'}]}));
1648
+ res.end(JSON.stringify({ events: [{ what: 'Sign off staff', who: 'Alvin Wang' }] }));
1448
1649
  });
1449
1650
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
1450
1651
 
@@ -1458,16 +1659,19 @@ describe('auto_grayrelease — approval payload handling', () => {
1458
1659
  const jira = makeApprovalJira('stg');
1459
1660
  const result = await executeTool(
1460
1661
  'auto_grayrelease',
1461
- {issueKey: 'CID-100', autoVerify: false},
1462
- {jira, notifier: mockNotifier},
1662
+ { issueKey: 'CID-100', autoVerify: false },
1663
+ { jira, notifier: mockNotifier },
1463
1664
  );
1464
1665
 
1465
1666
  assert.ok(!result.content[0].text.startsWith('❌'), 'STG approval should continue');
1466
1667
  const data = JSON.parse(result.content[0].text);
1467
1668
  assert.equal(data.finalStatus, 'VERIFY');
1468
- assert.deepEqual(jira.calls.updateAssignee, [{issueKey: 'CID-100', accountId: 'BK00619'}]);
1669
+ assert.deepEqual(jira.calls.updateAssignee, [{ issueKey: 'CID-100', accountId: 'BK00619' }]);
1469
1670
  } finally {
1470
- await new Promise((resolve) => server.close(resolve));
1671
+ await new Promise((resolve, reject) => {
1672
+ server.close((err) => (err ? reject(err) : resolve()));
1673
+ server.closeAllConnections?.();
1674
+ });
1471
1675
  restoreEnv();
1472
1676
  }
1473
1677
  });
@@ -1479,33 +1683,64 @@ describe('auto_grayrelease — approval payload handling', () => {
1479
1683
  process.env.POLL_TIMEOUT_MS = '10';
1480
1684
 
1481
1685
  const jira = makeApprovalJira('uat', {
1482
- comments: [{body: 'Approved, please proceed', author: {name: 'BK00619', displayName: 'James Yu'}}],
1686
+ comments: [{ body: 'Approved, please proceed', author: { name: 'BK00619', displayName: 'James Yu' } }],
1483
1687
  });
1484
1688
  const result = await executeTool(
1485
1689
  'auto_grayrelease',
1486
- {issueKey: 'CID-101', autoVerify: false},
1487
- {jira, notifier: mockNotifier},
1690
+ { issueKey: 'CID-101', autoVerify: false },
1691
+ { jira, notifier: mockNotifier },
1488
1692
  );
1489
1693
 
1490
1694
  assert.ok(!result.content[0].text.startsWith('❌'), 'UAT approval should continue');
1491
1695
  const data = JSON.parse(result.content[0].text);
1492
1696
  assert.equal(data.finalStatus, 'VERIFY');
1493
1697
  assert.deepEqual(jira.calls.updateAssignee, [
1494
- {issueKey: 'CID-101', accountId: 'BK00619'},
1495
- {issueKey: 'CID-101', accountId: 'BK00619'},
1698
+ { issueKey: 'CID-101', accountId: 'BK00619' },
1699
+ { issueKey: 'CID-101', accountId: 'BK00619' },
1496
1700
  ]);
1497
1701
  } finally {
1498
1702
  restoreEnv();
1499
1703
  }
1500
1704
  });
1501
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
+
1502
1737
  test('WAIT APPROVAL fails when GrayRelease environment field is missing', async () => {
1503
1738
  const transitions = [];
1504
1739
  const jira = {
1505
1740
  getIssueFields: async () => ({
1506
- customfield_13443: {value: 'CWA'},
1741
+ customfield_13443: { value: 'CWA' },
1507
1742
  }),
1508
- getIssue: async () => ({fields: {status: {name: 'WAIT APPROVAL'}, summary: 'GrayRelease'}}),
1743
+ getIssue: async () => ({ fields: { status: { name: 'WAIT APPROVAL' }, summary: 'GrayRelease' } }),
1509
1744
  transitionByName: async (_issueKey, transitionName) => {
1510
1745
  transitions.push(transitionName);
1511
1746
  },
@@ -1513,8 +1748,8 @@ describe('auto_grayrelease — approval payload handling', () => {
1513
1748
 
1514
1749
  const result = await executeTool(
1515
1750
  'auto_grayrelease',
1516
- {issueKey: 'CID-102', autoVerify: false},
1517
- {jira, notifier: mockNotifier},
1751
+ { issueKey: 'CID-102', autoVerify: false },
1752
+ { jira, notifier: mockNotifier },
1518
1753
  );
1519
1754
 
1520
1755
  assert.ok(result.content[0].text.startsWith('❌'), 'missing environment should fail closed');
@@ -1549,23 +1784,130 @@ describe('auto_grayrelease — build/deploy result fields', () => {
1549
1784
  }
1550
1785
  }
1551
1786
 
1552
- test('Build waits until CID_build_result becomes pass before Apply to approval', async () => {
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 () => {
1553
1894
  useFastPolling();
1895
+ const events = [];
1554
1896
  let status = 'WAIT FOR BUILD';
1555
1897
  let buildResultCalls = 0;
1556
1898
  const transitions = [];
1557
1899
  const jira = {
1558
1900
  getIssueFields: async (_issueKey, fieldNames = []) => {
1559
- if (fieldNames.includes('CID_build_result')) {
1901
+ if (fieldNames.includes('customfield_13432')) {
1560
1902
  buildResultCalls++;
1561
- return {CID_build_result: buildResultCalls >= 3 ? 'pass' : 'starting'};
1903
+ return { customfield_13432: buildResultCalls >= 3 ? 'pass' : 'starting' };
1562
1904
  }
1563
1905
  return {
1564
- customfield_13436: {value: 'dev'},
1565
- customfield_13443: {value: 'CWA'},
1906
+ customfield_13436: { value: 'dev' },
1907
+ customfield_13443: { value: 'CWA' },
1566
1908
  };
1567
1909
  },
1568
- getIssue: async () => ({fields: {status: {name: status}, summary: 'GrayRelease'}}),
1910
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
1569
1911
  transitionByName: async (_issueKey, transitionName) => {
1570
1912
  transitions.push(transitionName);
1571
1913
  if (transitionName === 'Apply to approval') {
@@ -1577,36 +1919,41 @@ describe('auto_grayrelease — build/deploy result fields', () => {
1577
1919
  try {
1578
1920
  const result = await executeTool(
1579
1921
  'auto_grayrelease',
1580
- {issueKey: 'CID-200', autoVerify: false},
1581
- {jira, notifier: mockNotifier},
1922
+ { issueKey: 'CID-200', autoVerify: false },
1923
+ { jira, notifier: mockNotifier, progress: (event) => events.push(event) },
1582
1924
  );
1583
1925
 
1584
1926
  assert.ok(!result.content[0].text.startsWith('❌'), 'Build flow should succeed');
1585
1927
  assert.equal(buildResultCalls, 3);
1586
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
+ )));
1587
1934
  } finally {
1588
1935
  restoreEnv();
1589
1936
  }
1590
1937
  });
1591
1938
 
1592
- test('Deploy waits until CID_deploy_result becomes pass before To Verify', async () => {
1939
+ test('Deploy waits until customfield_13433 becomes pass before To Verify', async () => {
1593
1940
  useFastPolling();
1594
1941
  let status = 'WAIT DEPLOY';
1595
1942
  let deployResultCalls = 0;
1596
1943
  const transitions = [];
1597
1944
  const jira = {
1598
1945
  getIssueFields: async (_issueKey, fieldNames = []) => {
1599
- if (fieldNames.includes('CID_deploy_result')) {
1946
+ if (fieldNames.includes('customfield_13433')) {
1600
1947
  deployResultCalls++;
1601
- return {CID_deploy_result: deployResultCalls >= 3 ? 'pass' : 'starting'};
1948
+ return { customfield_13433: deployResultCalls >= 3 ? 'pass' : 'starting' };
1602
1949
  }
1603
1950
  return {
1604
- customfield_13436: {value: 'stg'},
1605
- customfield_13443: {value: 'CWA'},
1951
+ customfield_13436: { value: 'stg' },
1952
+ customfield_13443: { value: 'CWA' },
1606
1953
  };
1607
1954
  },
1608
- getIssue: async () => ({fields: {status: {name: status}, summary: 'GrayRelease'}}),
1609
- searchIssues: async () => [{fields: {customfield_13436: {value: 'stg'}}}],
1955
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
1956
+ searchIssues: async () => [{ fields: { customfield_13436: { value: 'stg' } } }],
1610
1957
  transitionByName: async (_issueKey, transitionName) => {
1611
1958
  transitions.push(transitionName);
1612
1959
  if (transitionName === 'To Verify') {
@@ -1618,8 +1965,8 @@ describe('auto_grayrelease — build/deploy result fields', () => {
1618
1965
  try {
1619
1966
  const result = await executeTool(
1620
1967
  'auto_grayrelease',
1621
- {issueKey: 'CID-201', autoVerify: false},
1622
- {jira, notifier: mockNotifier},
1968
+ { issueKey: 'CID-201', autoVerify: false },
1969
+ { jira, notifier: mockNotifier },
1623
1970
  );
1624
1971
 
1625
1972
  assert.ok(!result.content[0].text.startsWith('❌'), 'Deploy flow should succeed');
@@ -1636,23 +1983,23 @@ describe('auto_grayrelease — build/deploy result fields', () => {
1636
1983
  const transitions = [];
1637
1984
  const jira = {
1638
1985
  getIssueFields: async (_issueKey, fieldNames = []) => {
1639
- if (fieldNames.includes('CID_deploy_result')) {
1640
- return {CID_deploy_result: 'pass'};
1986
+ if (fieldNames.includes('customfield_13433')) {
1987
+ return { customfield_13433: 'pass' };
1641
1988
  }
1642
1989
  return {
1643
- customfield_13436: {value: 'stg'},
1644
- customfield_13443: {value: 'IBK'},
1990
+ customfield_13436: { value: 'stg' },
1991
+ customfield_13443: { value: 'IBK' },
1645
1992
  };
1646
1993
  },
1647
- getIssue: async () => ({fields: {status: {name: status}, summary: 'GrayRelease'}}),
1994
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
1648
1995
  getComments: async () => [
1649
1996
  {
1650
1997
  body: "Trigger update IBK's instance_group to [ING]NonPRD_ExecutionNode success, please wait about 3 mins before trigger deploy.",
1651
- author: {displayName: 'cid jira worker'},
1998
+ author: { displayName: 'cid jira worker' },
1652
1999
  created: new Date().toISOString(),
1653
2000
  },
1654
2001
  ],
1655
- searchIssues: async () => [{fields: {customfield_13436: {value: 'prd'}}}],
2002
+ searchIssues: async () => [{ fields: { customfield_13436: { value: 'prd' } } }],
1656
2003
  transitionByName: async (_issueKey, transitionName) => {
1657
2004
  transitions.push(transitionName);
1658
2005
  if (transitionName === 'To Verify') {
@@ -1664,8 +2011,8 @@ describe('auto_grayrelease — build/deploy result fields', () => {
1664
2011
  try {
1665
2012
  const result = await executeTool(
1666
2013
  'auto_grayrelease',
1667
- {issueKey: 'CID-202', autoVerify: false},
1668
- {jira, notifier: mockNotifier},
2014
+ { issueKey: 'CID-202', autoVerify: false },
2015
+ { jira, notifier: mockNotifier },
1669
2016
  );
1670
2017
 
1671
2018
  assert.ok(!result.content[0].text.startsWith('❌'), 'Deploy flow should succeed');
@@ -1683,15 +2030,15 @@ describe('auto_grayrelease — build/deploy result fields', () => {
1683
2030
  const transitions = [];
1684
2031
  const jira = {
1685
2032
  getIssueFields: async (_issueKey, fieldNames = []) => {
1686
- if (fieldNames.includes('CID_deploy_result')) {
1687
- return {CID_deploy_result: 'pass'};
2033
+ if (fieldNames.includes('customfield_13433')) {
2034
+ return { customfield_13433: 'pass' };
1688
2035
  }
1689
2036
  return {
1690
- customfield_13436: {value: 'stg'},
1691
- customfield_13443: {value: 'IBK'},
2037
+ customfield_13436: { value: 'stg' },
2038
+ customfield_13443: { value: 'IBK' },
1692
2039
  };
1693
2040
  },
1694
- getIssue: async () => ({fields: {status: {name: status}, summary: 'GrayRelease'}}),
2041
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'GrayRelease' } }),
1695
2042
  getComments: async () => {
1696
2043
  getCommentsCalls++;
1697
2044
  if (getCommentsCalls < 2) {
@@ -1700,14 +2047,14 @@ describe('auto_grayrelease — build/deploy result fields', () => {
1700
2047
  return [
1701
2048
  {
1702
2049
  body: "Trigger update IBK's instance_group to [ING]NonPRD_ExecutionNode success, please wait about 3 mins before trigger deploy.",
1703
- author: {displayName: 'CID Jira Worker'},
2050
+ author: { displayName: 'CID Jira Worker' },
1704
2051
  created: new Date().toISOString(),
1705
2052
  },
1706
2053
  ];
1707
2054
  },
1708
2055
  searchIssues: async () => {
1709
2056
  searchIssuesCalls++;
1710
- return [{fields: {customfield_13436: {value: 'prd'}}}];
2057
+ return [{ fields: { customfield_13436: { value: 'prd' } } }];
1711
2058
  },
1712
2059
  transitionByName: async (_issueKey, transitionName) => {
1713
2060
  transitions.push(transitionName);
@@ -1720,8 +2067,8 @@ describe('auto_grayrelease — build/deploy result fields', () => {
1720
2067
  try {
1721
2068
  const result = await executeTool(
1722
2069
  'auto_grayrelease',
1723
- {issueKey: 'CID-203', autoVerify: false},
1724
- {jira, notifier: mockNotifier},
2070
+ { issueKey: 'CID-203', autoVerify: false },
2071
+ { jira, notifier: mockNotifier },
1725
2072
  );
1726
2073
 
1727
2074
  assert.ok(!result.content[0].text.startsWith('❌'), 'Deploy flow should succeed');
@@ -1761,7 +2108,7 @@ describe('prepare_cd_deployment', () => {
1761
2108
  return {};
1762
2109
  },
1763
2110
  getIssue: async () => ({ fields: { status: { name: resultStatus }, summary: 'CD test' } }),
1764
- addComment: async () => {},
2111
+ addComment: async () => { },
1765
2112
  };
1766
2113
  }
1767
2114
 
@@ -1890,12 +2237,12 @@ describe('prepare_cd_deployment', () => {
1890
2237
 
1891
2238
  test('找不到任何 deploy transition → 回傳錯誤', async () => {
1892
2239
  const jira = {
1893
- updateIssue: async () => {},
2240
+ updateIssue: async () => { },
1894
2241
  getTransitions: async () => [
1895
2242
  { id: '1', name: 'Some Other Transition', to: { name: 'Other' } },
1896
2243
  ],
1897
2244
  getIssue: async () => ({ fields: { status: { name: 'Approved' }, summary: 'CD test' } }),
1898
- addComment: async () => {},
2245
+ addComment: async () => { },
1899
2246
  };
1900
2247
  const result = await executeTool(
1901
2248
  'prepare_cd_deployment',
@@ -1927,7 +2274,7 @@ describe('prepare_cd_deployment', () => {
1927
2274
  let callCount = 0;
1928
2275
  const calls = { transitionById: [] };
1929
2276
  const jira = {
1930
- updateIssue: async () => {},
2277
+ updateIssue: async () => { },
1931
2278
  getTransitions: async () => {
1932
2279
  callCount++;
1933
2280
  // findDeployTrans(1) → 無;pre-transition check(2) → Accept;findDeployTrans after Accept(3) → deploy
@@ -1946,7 +2293,7 @@ describe('prepare_cd_deployment', () => {
1946
2293
  return {};
1947
2294
  },
1948
2295
  getIssue: async () => ({ fields: { status: { name: 'Prepare For Deploy' }, summary: 'CD' } }),
1949
- addComment: async () => {},
2296
+ addComment: async () => { },
1950
2297
  };
1951
2298
  const result = await executeTool(
1952
2299
  'prepare_cd_deployment',
@@ -1977,7 +2324,7 @@ describe('prepare_cd_deployment', () => {
1977
2324
  let status = 'TO DO';
1978
2325
  const calls = { transitionById: [] };
1979
2326
  const jira = {
1980
- updateIssue: async () => {},
2327
+ updateIssue: async () => { },
1981
2328
  getTransitions: async () => {
1982
2329
  callCount++;
1983
2330
  if (callCount === 1) {
@@ -1999,7 +2346,7 @@ describe('prepare_cd_deployment', () => {
1999
2346
  return {};
2000
2347
  },
2001
2348
  getIssue: async () => ({ fields: { status: { name: status }, summary: 'CD' } }),
2002
- addComment: async () => {},
2349
+ addComment: async () => { },
2003
2350
  };
2004
2351
  const result = await executeTool(
2005
2352
  'prepare_cd_deployment',
@@ -2020,7 +2367,7 @@ describe('prepare_cd_deployment', () => {
2020
2367
  let status = 'Prepare For Deploy';
2021
2368
  const calls = { transitionById: [] };
2022
2369
  const jira = {
2023
- updateIssue: async () => {},
2370
+ updateIssue: async () => { },
2024
2371
  getTransitions: async () => {
2025
2372
  callCount++;
2026
2373
  if (callCount === 1) {
@@ -2042,7 +2389,7 @@ describe('prepare_cd_deployment', () => {
2042
2389
  return {};
2043
2390
  },
2044
2391
  getIssue: async () => ({ fields: { status: { name: status }, summary: 'CD' } }),
2045
- addComment: async () => {},
2392
+ addComment: async () => { },
2046
2393
  };
2047
2394
  const result = await executeTool(
2048
2395
  'prepare_cd_deployment',
@@ -2076,7 +2423,7 @@ describe('wait_to_dev', () => {
2076
2423
  calls.push({ key, id });
2077
2424
  },
2078
2425
  getIssue: async () => ({ fields: { status: { name: 'Wait To DEV' }, summary: 'CI' } }),
2079
- addComment: async () => {},
2426
+ addComment: async () => { },
2080
2427
  };
2081
2428
 
2082
2429
  const result = await executeTool(
@@ -2099,7 +2446,7 @@ describe('wait_to_dev', () => {
2099
2446
  calls.push({ key, id });
2100
2447
  },
2101
2448
  getIssue: async () => ({ fields: { status: { name: 'Wait To DEV' }, summary: 'CI' } }),
2102
- addComment: async () => {},
2449
+ addComment: async () => { },
2103
2450
  };
2104
2451
 
2105
2452
  const result = await executeTool(
@@ -2153,7 +2500,7 @@ describe('trigger_deployment', () => {
2153
2500
  getIssue: async (key) => ({
2154
2501
  fields: { status: { name: finalStatus }, summary: `[MOCK] ${key}` },
2155
2502
  }),
2156
- addComment: async () => {},
2503
+ addComment: async () => { },
2157
2504
  };
2158
2505
  }
2159
2506