@jira-deploy/core 1.0.1 → 1.0.3

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,8 @@
4
4
  */
5
5
  import { test, describe } from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
- import { executeTool } from './tools/index.js';
7
+ import http from 'node:http';
8
+ import { executeTool, getToolDefinitions } from './tools/index.js';
8
9
 
9
10
  process.env.JIRA_BASE_URL = 'https://jira.test';
10
11
 
@@ -118,6 +119,154 @@ function makeMockJira({
118
119
 
119
120
  const mockNotifier = { notify: async () => [] };
120
121
 
122
+ function getToolDefinition(name) {
123
+ return getToolDefinitions().find((tool) => tool.name === name);
124
+ }
125
+
126
+ describe('tool schemas — agent contract', () => {
127
+ test('create_cd_ticket schema matches handler-supported arguments', () => {
128
+ const tool = getToolDefinition('create_cd_ticket');
129
+
130
+ assert.deepEqual(tool.inputSchema.required, ['systemCode', 'environment']);
131
+ assert.ok(tool.inputSchema.properties.environment.enum.includes('dev'));
132
+ for (const property of [
133
+ 'ciTicket',
134
+ 'linkedCiKey',
135
+ 'clusterDeploy',
136
+ 'moduleChild',
137
+ 'restartOnly',
138
+ 'extraVars',
139
+ ]) {
140
+ assert.ok(tool.inputSchema.properties[property], `missing ${property}`);
141
+ }
142
+ });
143
+
144
+ test('create_library_ticket schema treats module as defaultable', () => {
145
+ const tool = getToolDefinition('create_library_ticket');
146
+
147
+ assert.deepEqual(tool.inputSchema.required, ['systemCode', 'gitBranch']);
148
+ assert.match(tool.inputSchema.properties.module.description, /預設/);
149
+ });
150
+
151
+ test('release workflows are exposed as MCP tools', () => {
152
+ assert.ok(getToolDefinition('run_stg_full_release'));
153
+ assert.ok(getToolDefinition('run_lib_to_stg_release'));
154
+ });
155
+
156
+ test('wait_to_dev is exposed as a CI workflow tool', () => {
157
+ assert.ok(getToolDefinition('wait_to_dev'));
158
+ });
159
+
160
+ test('run_stg_full_release dispatches shared workflow steps', async () => {
161
+ const calls = [];
162
+ const result = await executeTool('run_stg_full_release', {systemCode: 'IBK'}, {
163
+ jira: makeMockJira(),
164
+ notifier: mockNotifier,
165
+ executeToolImpl: async (name, args) => {
166
+ calls.push({name, args});
167
+ 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'},
174
+ };
175
+ return {content: [{type: 'text', text: JSON.stringify(outputs[name])}]};
176
+ },
177
+ });
178
+
179
+ const output = JSON.parse(result.content[0].text);
180
+ assert.equal(output.type, 'STG Full Release');
181
+ assert.equal(output.ciIssueKey, 'CID-100');
182
+ assert.equal(output.cdIssueKey, 'CID-200');
183
+ assert.deepEqual(calls.map((call) => call.name), [
184
+ 'create_ci_ticket',
185
+ 'build_ticket',
186
+ 'wait_to_stg',
187
+ 'create_cd_ticket',
188
+ 'prepare_cd_deployment',
189
+ 'trigger_deployment',
190
+ ]);
191
+ assert.equal(calls[3].args.linkedCiKey, 'CID-100');
192
+ assert.equal(calls[5].args.applyForClose, true);
193
+ });
194
+
195
+ test('run_lib_to_stg_release waits for the Library ticket to reach Released', async () => {
196
+ const calls = [];
197
+ const result = await executeTool('run_lib_to_stg_release', {
198
+ systemCode: 'IBK',
199
+ gitBranch: 'release/v1.5.2.0',
200
+ }, {
201
+ jira: {
202
+ ...makeMockJira(),
203
+ getIssue: async () => ({fields: {status: {name: 'WAIT FOR LIB BUILD'}}}),
204
+ },
205
+ notifier: mockNotifier,
206
+ workflowWaitOptions: {timeoutMs: 1, intervalMs: 1},
207
+ executeToolImpl: async (name, args) => {
208
+ calls.push({name, args});
209
+ const outputs = {
210
+ create_library_ticket: {issueKey: 'LIB-100'},
211
+ build_ticket: {issueKey: args.issueKey, status: 'WAIT FOR LIB BUILD'},
212
+ };
213
+ return {content: [{type: 'text', text: JSON.stringify(outputs[name])}]};
214
+ },
215
+ });
216
+
217
+ assert.equal(result.isError, true);
218
+ assert.match(result.content[0].text, /等待 LIB-100 狀態 Released 超時/);
219
+ assert.deepEqual(calls.map((call) => call.name), [
220
+ 'create_library_ticket',
221
+ 'build_ticket',
222
+ ]);
223
+ });
224
+
225
+ test('workflow tool failures are marked as MCP errors', async () => {
226
+ const result = await executeTool('run_stg_full_release', {systemCode: 'IBK'}, {
227
+ jira: makeMockJira(),
228
+ notifier: mockNotifier,
229
+ executeToolImpl: async () => ({
230
+ content: [{type: 'text', text: '❌ 錯誤: nested tool failed'}],
231
+ isError: true,
232
+ }),
233
+ });
234
+
235
+ assert.equal(result.isError, true);
236
+ assert.match(result.content[0].text, /run_stg_full_release 失敗/);
237
+ assert.match(result.content[0].text, /nested tool failed/);
238
+ });
239
+
240
+ test('core dispatcher tool failures are marked as MCP errors', async () => {
241
+ const result = await executeTool(
242
+ 'prepare_cd_deployment',
243
+ {issueKey: 'CID-1', environment: 'qa'},
244
+ {jira: makeMockJira(), notifier: mockNotifier},
245
+ );
246
+
247
+ assert.equal(result.isError, true);
248
+ assert.match(result.content[0].text, /不支援的 CD 部署環境/);
249
+ });
250
+
251
+ test('read-only tools expose readOnlyHint metadata for CLI confirmation', () => {
252
+ for (const name of [
253
+ 'get_issue_status',
254
+ 'list_transitions',
255
+ 'get_release_status',
256
+ 'get_unreleased_versions',
257
+ 'get_release_manager',
258
+ 'wait_for_comment',
259
+ ]) {
260
+ assert.equal(getToolDefinition(name).annotations?.readOnlyHint, true, `${name} should be read-only`);
261
+ }
262
+ });
263
+
264
+ test('write workflow tools are not marked read-only', () => {
265
+ assert.notEqual(getToolDefinition('run_stg_full_release').annotations?.readOnlyHint, true);
266
+ assert.notEqual(getToolDefinition('run_lib_to_stg_release').annotations?.readOnlyHint, true);
267
+ });
268
+ });
269
+
121
270
  /** 執行 tool,回傳 createIssue 收到的 fields(合併 updateIssue 的欄位,或 throw 若 ❌) */
122
271
  async function getCreatedFields(toolName, args, jiraOpts = {}) {
123
272
  const jira = makeMockJira(jiraOpts);
@@ -224,6 +373,22 @@ describe('create_library_ticket — fields', () => {
224
373
  const f = await getCreatedFields('create_library_ticket', BASE);
225
374
  assert.equal(f['customfield_13431'], 'release/v1.5.2.0');
226
375
  });
376
+ test('通知與回傳使用預設 module,不顯示 undefined', async () => {
377
+ const notifications = [];
378
+ const result = await executeTool(
379
+ 'create_library_ticket',
380
+ {systemCode: 'CWA', gitBranch: 'release/v1.0.0'},
381
+ {
382
+ jira: makeMockJira(),
383
+ notifier: {notify: async (key, message) => notifications.push({key, message})},
384
+ },
385
+ );
386
+
387
+ const data = JSON.parse(result.content[0].text);
388
+ assert.equal(data.module, 'cwa');
389
+ assert.match(notifications.at(-1).message, /模組: cwa/);
390
+ assert.doesNotMatch(notifications.at(-1).message, /undefined/);
391
+ });
227
392
  });
228
393
 
229
394
  // ═══════════════════════════════════════════════════════════════════
@@ -448,6 +613,23 @@ describe('create_cd_ticket — fields', () => {
448
613
  });
449
614
  assert.equal(f['customfield_13442'], 'tvdev-ibk-web01');
450
615
  });
616
+ test('通知與回傳使用自動推導 cluster,不顯示 undefined', async () => {
617
+ const notifications = [];
618
+ const result = await executeTool(
619
+ 'create_cd_ticket',
620
+ {systemCode: 'CWA', environment: 'dev'},
621
+ {
622
+ jira: makeMockJira(),
623
+ notifier: {notify: async (key, message) => notifications.push({key, message})},
624
+ },
625
+ );
626
+
627
+ const data = JSON.parse(result.content[0].text);
628
+ assert.deepEqual(data.clusterDeploy, ['tvdev-cwa-web01']);
629
+ assert.equal(data.isClusterDeploy, false);
630
+ assert.match(notifications.at(-1).message, /Cluster: tvdev-cwa-web01/);
631
+ assert.doesNotMatch(notifications.at(-1).message, /undefined/);
632
+ });
451
633
  test('customfield_13437 (CID_extra_vars) IBK STG 自動生成', async () => {
452
634
  const f = await getCreatedFields('create_cd_ticket', BASE);
453
635
  const ev = JSON.parse(f['customfield_13437']);
@@ -1205,6 +1387,352 @@ describe('create_grayrelease_ticket — fields', () => {
1205
1387
  });
1206
1388
  });
1207
1389
 
1390
+ // ═══════════════════════════════════════════════════════════════════
1391
+ // auto_grayrelease approval payload handling
1392
+ // ═══════════════════════════════════════════════════════════════════
1393
+ describe('auto_grayrelease — approval payload handling', () => {
1394
+ const savedEnv = {
1395
+ CONF_BASE_URL: process.env.CONF_BASE_URL,
1396
+ CONF_TOKEN: process.env.CONF_TOKEN,
1397
+ JABBER_NOTIFY_SCRIPT: process.env.JABBER_NOTIFY_SCRIPT,
1398
+ POLL_INTERVAL_MS: process.env.POLL_INTERVAL_MS,
1399
+ POLL_TIMEOUT_MS: process.env.POLL_TIMEOUT_MS,
1400
+ };
1401
+
1402
+ function restoreEnv() {
1403
+ for (const [key, value] of Object.entries(savedEnv)) {
1404
+ if (value === undefined) {
1405
+ delete process.env[key];
1406
+ } else {
1407
+ process.env[key] = value;
1408
+ }
1409
+ }
1410
+ }
1411
+
1412
+ function makeApprovalJira(environment, {comments = []} = {}) {
1413
+ let status = 'WAIT APPROVAL';
1414
+ const calls = {updateAssignee: [], transitionByName: []};
1415
+
1416
+ return {
1417
+ calls,
1418
+ getIssueFields: async () => ({
1419
+ customfield_13436: {value: environment},
1420
+ customfield_13443: {value: 'CWA'},
1421
+ CID_build_result: 'pass',
1422
+ CID_deploy_result: 'pass',
1423
+ }),
1424
+ getIssue: async () => ({fields: {status: {name: status}, summary: 'GrayRelease'}}),
1425
+ getTransitions: async () => [],
1426
+ updateAssignee: async (issueKey, accountId) => {
1427
+ calls.updateAssignee.push({issueKey, accountId});
1428
+ if (environment === 'stg' || calls.updateAssignee.length >= 2) {
1429
+ status = 'WAIT DEPLOY';
1430
+ }
1431
+ },
1432
+ getComments: async () => comments,
1433
+ searchIssues: async () => [{fields: {customfield_13436: {value: 'stg'}}}],
1434
+ transitionByName: async (issueKey, transitionName) => {
1435
+ calls.transitionByName.push({issueKey, transitionName});
1436
+ if (transitionName === 'To Verify') {
1437
+ status = 'VERIFY';
1438
+ }
1439
+ return {transitioned: transitionName, toStatus: status};
1440
+ },
1441
+ };
1442
+ }
1443
+
1444
+ test('STG approval parses get_release_manager MCP content payload', async () => {
1445
+ const server = http.createServer((_req, res) => {
1446
+ res.setHeader('content-type', 'application/json');
1447
+ res.end(JSON.stringify({events: [{what: 'Sign off staff', who: 'Alvin Wang'}]}));
1448
+ });
1449
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
1450
+
1451
+ try {
1452
+ process.env.CONF_BASE_URL = `http://127.0.0.1:${server.address().port}`;
1453
+ process.env.CONF_TOKEN = 'test-token';
1454
+ process.env.JABBER_NOTIFY_SCRIPT = '/dev/null';
1455
+ process.env.POLL_INTERVAL_MS = '0';
1456
+ process.env.POLL_TIMEOUT_MS = '10';
1457
+
1458
+ const jira = makeApprovalJira('stg');
1459
+ const result = await executeTool(
1460
+ 'auto_grayrelease',
1461
+ {issueKey: 'CID-100', autoVerify: false},
1462
+ {jira, notifier: mockNotifier},
1463
+ );
1464
+
1465
+ assert.ok(!result.content[0].text.startsWith('❌'), 'STG approval should continue');
1466
+ const data = JSON.parse(result.content[0].text);
1467
+ assert.equal(data.finalStatus, 'VERIFY');
1468
+ assert.deepEqual(jira.calls.updateAssignee, [{issueKey: 'CID-100', accountId: 'BK00619'}]);
1469
+ } finally {
1470
+ await new Promise((resolve) => server.close(resolve));
1471
+ restoreEnv();
1472
+ }
1473
+ });
1474
+
1475
+ test('UAT approval parses wait_for_comment MCP content payload', async () => {
1476
+ try {
1477
+ process.env.JABBER_NOTIFY_SCRIPT = '/dev/null';
1478
+ process.env.POLL_INTERVAL_MS = '0';
1479
+ process.env.POLL_TIMEOUT_MS = '10';
1480
+
1481
+ const jira = makeApprovalJira('uat', {
1482
+ comments: [{body: 'Approved, please proceed', author: {name: 'BK00619', displayName: 'James Yu'}}],
1483
+ });
1484
+ const result = await executeTool(
1485
+ 'auto_grayrelease',
1486
+ {issueKey: 'CID-101', autoVerify: false},
1487
+ {jira, notifier: mockNotifier},
1488
+ );
1489
+
1490
+ assert.ok(!result.content[0].text.startsWith('❌'), 'UAT approval should continue');
1491
+ const data = JSON.parse(result.content[0].text);
1492
+ assert.equal(data.finalStatus, 'VERIFY');
1493
+ assert.deepEqual(jira.calls.updateAssignee, [
1494
+ {issueKey: 'CID-101', accountId: 'BK00619'},
1495
+ {issueKey: 'CID-101', accountId: 'BK00619'},
1496
+ ]);
1497
+ } finally {
1498
+ restoreEnv();
1499
+ }
1500
+ });
1501
+
1502
+ test('WAIT APPROVAL fails when GrayRelease environment field is missing', async () => {
1503
+ const transitions = [];
1504
+ const jira = {
1505
+ getIssueFields: async () => ({
1506
+ customfield_13443: {value: 'CWA'},
1507
+ }),
1508
+ getIssue: async () => ({fields: {status: {name: 'WAIT APPROVAL'}, summary: 'GrayRelease'}}),
1509
+ transitionByName: async (_issueKey, transitionName) => {
1510
+ transitions.push(transitionName);
1511
+ },
1512
+ };
1513
+
1514
+ const result = await executeTool(
1515
+ 'auto_grayrelease',
1516
+ {issueKey: 'CID-102', autoVerify: false},
1517
+ {jira, notifier: mockNotifier},
1518
+ );
1519
+
1520
+ assert.ok(result.content[0].text.startsWith('❌'), 'missing environment should fail closed');
1521
+ assert.match(result.content[0].text, /無法讀取 GrayRelease 環境欄位/);
1522
+ assert.deepEqual(transitions, []);
1523
+ });
1524
+ });
1525
+
1526
+ // ═══════════════════════════════════════════════════════════════════
1527
+ // auto_grayrelease build/deploy completion result fields
1528
+ // ═══════════════════════════════════════════════════════════════════
1529
+ describe('auto_grayrelease — build/deploy result fields', () => {
1530
+ const savedEnv = {
1531
+ POLL_INTERVAL_MS: process.env.POLL_INTERVAL_MS,
1532
+ POLL_TIMEOUT_MS: process.env.POLL_TIMEOUT_MS,
1533
+ SWITCH_EXECUTION_NODE_WAIT_MS: process.env.SWITCH_EXECUTION_NODE_WAIT_MS,
1534
+ };
1535
+
1536
+ function useFastPolling() {
1537
+ process.env.POLL_INTERVAL_MS = '0';
1538
+ process.env.POLL_TIMEOUT_MS = '10';
1539
+ process.env.SWITCH_EXECUTION_NODE_WAIT_MS = '0';
1540
+ }
1541
+
1542
+ function restoreEnv() {
1543
+ for (const [key, value] of Object.entries(savedEnv)) {
1544
+ if (value === undefined) {
1545
+ delete process.env[key];
1546
+ } else {
1547
+ process.env[key] = value;
1548
+ }
1549
+ }
1550
+ }
1551
+
1552
+ test('Build waits until CID_build_result becomes pass before Apply to approval', async () => {
1553
+ useFastPolling();
1554
+ let status = 'WAIT FOR BUILD';
1555
+ let buildResultCalls = 0;
1556
+ const transitions = [];
1557
+ const jira = {
1558
+ getIssueFields: async (_issueKey, fieldNames = []) => {
1559
+ if (fieldNames.includes('CID_build_result')) {
1560
+ buildResultCalls++;
1561
+ return {CID_build_result: buildResultCalls >= 3 ? 'pass' : 'starting'};
1562
+ }
1563
+ return {
1564
+ customfield_13436: {value: 'dev'},
1565
+ customfield_13443: {value: 'CWA'},
1566
+ };
1567
+ },
1568
+ getIssue: async () => ({fields: {status: {name: status}, summary: 'GrayRelease'}}),
1569
+ transitionByName: async (_issueKey, transitionName) => {
1570
+ transitions.push(transitionName);
1571
+ if (transitionName === 'Apply to approval') {
1572
+ status = 'DONE';
1573
+ }
1574
+ },
1575
+ };
1576
+
1577
+ try {
1578
+ const result = await executeTool(
1579
+ 'auto_grayrelease',
1580
+ {issueKey: 'CID-200', autoVerify: false},
1581
+ {jira, notifier: mockNotifier},
1582
+ );
1583
+
1584
+ assert.ok(!result.content[0].text.startsWith('❌'), 'Build flow should succeed');
1585
+ assert.equal(buildResultCalls, 3);
1586
+ assert.deepEqual(transitions, ['GrayRelease Build', 'Apply to approval']);
1587
+ } finally {
1588
+ restoreEnv();
1589
+ }
1590
+ });
1591
+
1592
+ test('Deploy waits until CID_deploy_result becomes pass before To Verify', async () => {
1593
+ useFastPolling();
1594
+ let status = 'WAIT DEPLOY';
1595
+ let deployResultCalls = 0;
1596
+ const transitions = [];
1597
+ const jira = {
1598
+ getIssueFields: async (_issueKey, fieldNames = []) => {
1599
+ if (fieldNames.includes('CID_deploy_result')) {
1600
+ deployResultCalls++;
1601
+ return {CID_deploy_result: deployResultCalls >= 3 ? 'pass' : 'starting'};
1602
+ }
1603
+ return {
1604
+ customfield_13436: {value: 'stg'},
1605
+ customfield_13443: {value: 'CWA'},
1606
+ };
1607
+ },
1608
+ getIssue: async () => ({fields: {status: {name: status}, summary: 'GrayRelease'}}),
1609
+ searchIssues: async () => [{fields: {customfield_13436: {value: 'stg'}}}],
1610
+ transitionByName: async (_issueKey, transitionName) => {
1611
+ transitions.push(transitionName);
1612
+ if (transitionName === 'To Verify') {
1613
+ status = 'VERIFY';
1614
+ }
1615
+ },
1616
+ };
1617
+
1618
+ try {
1619
+ const result = await executeTool(
1620
+ 'auto_grayrelease',
1621
+ {issueKey: 'CID-201', autoVerify: false},
1622
+ {jira, notifier: mockNotifier},
1623
+ );
1624
+
1625
+ assert.ok(!result.content[0].text.startsWith('❌'), 'Deploy flow should succeed');
1626
+ assert.equal(deployResultCalls, 3);
1627
+ assert.deepEqual(transitions, ['GrayRelease Deploy', 'To Verify']);
1628
+ } finally {
1629
+ restoreEnv();
1630
+ }
1631
+ });
1632
+
1633
+ test('Deploy skips Switch Execution Node when recent cid jira worker success comment exists', async () => {
1634
+ useFastPolling();
1635
+ let status = 'WAIT DEPLOY';
1636
+ const transitions = [];
1637
+ const jira = {
1638
+ getIssueFields: async (_issueKey, fieldNames = []) => {
1639
+ if (fieldNames.includes('CID_deploy_result')) {
1640
+ return {CID_deploy_result: 'pass'};
1641
+ }
1642
+ return {
1643
+ customfield_13436: {value: 'stg'},
1644
+ customfield_13443: {value: 'IBK'},
1645
+ };
1646
+ },
1647
+ getIssue: async () => ({fields: {status: {name: status}, summary: 'GrayRelease'}}),
1648
+ getComments: async () => [
1649
+ {
1650
+ 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'},
1652
+ created: new Date().toISOString(),
1653
+ },
1654
+ ],
1655
+ searchIssues: async () => [{fields: {customfield_13436: {value: 'prd'}}}],
1656
+ transitionByName: async (_issueKey, transitionName) => {
1657
+ transitions.push(transitionName);
1658
+ if (transitionName === 'To Verify') {
1659
+ status = 'VERIFY';
1660
+ }
1661
+ },
1662
+ };
1663
+
1664
+ try {
1665
+ const result = await executeTool(
1666
+ 'auto_grayrelease',
1667
+ {issueKey: 'CID-202', autoVerify: false},
1668
+ {jira, notifier: mockNotifier},
1669
+ );
1670
+
1671
+ assert.ok(!result.content[0].text.startsWith('❌'), 'Deploy flow should succeed');
1672
+ assert.deepEqual(transitions, ['GrayRelease Deploy', 'To Verify']);
1673
+ } finally {
1674
+ restoreEnv();
1675
+ }
1676
+ });
1677
+
1678
+ test('Deploy waits for worker success comment after Switch Execution Node then deploys directly', async () => {
1679
+ useFastPolling();
1680
+ let status = 'WAIT DEPLOY';
1681
+ let getCommentsCalls = 0;
1682
+ let searchIssuesCalls = 0;
1683
+ const transitions = [];
1684
+ const jira = {
1685
+ getIssueFields: async (_issueKey, fieldNames = []) => {
1686
+ if (fieldNames.includes('CID_deploy_result')) {
1687
+ return {CID_deploy_result: 'pass'};
1688
+ }
1689
+ return {
1690
+ customfield_13436: {value: 'stg'},
1691
+ customfield_13443: {value: 'IBK'},
1692
+ };
1693
+ },
1694
+ getIssue: async () => ({fields: {status: {name: status}, summary: 'GrayRelease'}}),
1695
+ getComments: async () => {
1696
+ getCommentsCalls++;
1697
+ if (getCommentsCalls < 2) {
1698
+ return [];
1699
+ }
1700
+ return [
1701
+ {
1702
+ 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'},
1704
+ created: new Date().toISOString(),
1705
+ },
1706
+ ];
1707
+ },
1708
+ searchIssues: async () => {
1709
+ searchIssuesCalls++;
1710
+ return [{fields: {customfield_13436: {value: 'prd'}}}];
1711
+ },
1712
+ transitionByName: async (_issueKey, transitionName) => {
1713
+ transitions.push(transitionName);
1714
+ if (transitionName === 'To Verify') {
1715
+ status = 'VERIFY';
1716
+ }
1717
+ },
1718
+ };
1719
+
1720
+ try {
1721
+ const result = await executeTool(
1722
+ 'auto_grayrelease',
1723
+ {issueKey: 'CID-203', autoVerify: false},
1724
+ {jira, notifier: mockNotifier},
1725
+ );
1726
+
1727
+ assert.ok(!result.content[0].text.startsWith('❌'), 'Deploy flow should succeed');
1728
+ assert.equal(searchIssuesCalls, 1);
1729
+ assert.deepEqual(transitions, ['Switch Execution Node', 'GrayRelease Deploy', 'To Verify']);
1730
+ } finally {
1731
+ restoreEnv();
1732
+ }
1733
+ });
1734
+ });
1735
+
1208
1736
  // ═══════════════════════════════════════════════════════════════════
1209
1737
  // prepare_cd_deployment
1210
1738
  // ═══════════════════════════════════════════════════════════════════
@@ -1237,6 +1765,19 @@ describe('prepare_cd_deployment', () => {
1237
1765
  };
1238
1766
  }
1239
1767
 
1768
+ test('dev:更新 customfield_13436 為 id 14355', async () => {
1769
+ const jira = makeDeployMock();
1770
+ await executeTool(
1771
+ 'prepare_cd_deployment',
1772
+ { issueKey: 'CID-1697', environment: 'dev' },
1773
+ {
1774
+ jira,
1775
+ notifier: mockNotifier,
1776
+ },
1777
+ );
1778
+ assert.deepEqual(jira.calls.updateIssue[0].fields['customfield_13436'], { id: '14355' });
1779
+ });
1780
+
1240
1781
  test('stg:更新 customfield_13436 為 id 14356', async () => {
1241
1782
  const jira = makeDeployMock();
1242
1783
  await executeTool(
@@ -1430,6 +1971,146 @@ describe('prepare_cd_deployment', () => {
1430
1971
  '第 2 次應觸發 Prepare to create deployment ticket',
1431
1972
  );
1432
1973
  });
1974
+
1975
+ test('dev:建立 deployment 後自助執行 Apply for approval 與 Approved 到 Wait Deploy', async () => {
1976
+ let callCount = 0;
1977
+ let status = 'TO DO';
1978
+ const calls = { transitionById: [] };
1979
+ const jira = {
1980
+ updateIssue: async () => {},
1981
+ getTransitions: async () => {
1982
+ callCount++;
1983
+ if (callCount === 1) {
1984
+ return [{ id: '71', name: 'Prepare to create deployment ticket', to: { name: 'Prepare For Deploy' } }];
1985
+ }
1986
+ if (callCount === 2) {
1987
+ return [{ id: '21', name: 'Apply for approval', to: { name: 'Wait Approval' } }];
1988
+ }
1989
+ if (callCount === 3) {
1990
+ return [{ id: '72', name: 'Approved', to: { name: 'Wait Deploy' } }];
1991
+ }
1992
+ return [];
1993
+ },
1994
+ transitionById: async (key, id) => {
1995
+ calls.transitionById.push({ key, id });
1996
+ if (id === '71') status = 'Prepare For Deploy';
1997
+ if (id === '21') status = 'Wait Approval';
1998
+ if (id === '72') status = 'Wait Deploy';
1999
+ return {};
2000
+ },
2001
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'CD' } }),
2002
+ addComment: async () => {},
2003
+ };
2004
+ const result = await executeTool(
2005
+ 'prepare_cd_deployment',
2006
+ { issueKey: 'CID-1697', environment: 'dev' },
2007
+ {
2008
+ jira,
2009
+ notifier: mockNotifier,
2010
+ },
2011
+ );
2012
+ assert.ok(!result.content[0].text.startsWith('❌'), '應成功觸發');
2013
+ const data = JSON.parse(result.content[0].text);
2014
+ assert.equal(data.status, 'Wait Deploy');
2015
+ assert.deepEqual(calls.transitionById.map((call) => call.id), ['71', '21', '72']);
2016
+ });
2017
+
2018
+ test('dev:deployment ticket 已存在時續跑 approval 到 Wait Deploy', async () => {
2019
+ let callCount = 0;
2020
+ let status = 'Prepare For Deploy';
2021
+ const calls = { transitionById: [] };
2022
+ const jira = {
2023
+ updateIssue: async () => {},
2024
+ getTransitions: async () => {
2025
+ callCount++;
2026
+ if (callCount === 1) {
2027
+ return [{ id: '71', name: 'Prepare to create deployment ticket', to: { name: 'Prepare For Deploy' } }];
2028
+ }
2029
+ if (callCount === 2) {
2030
+ return [{ id: '21', name: 'Apply for approval', to: { name: 'Wait Approval' } }];
2031
+ }
2032
+ if (callCount === 3) {
2033
+ return [{ id: '72', name: 'Approved', to: { name: 'Wait Deploy' } }];
2034
+ }
2035
+ return [];
2036
+ },
2037
+ transitionById: async (key, id) => {
2038
+ calls.transitionById.push({ key, id });
2039
+ if (id === '71') throw new Error('Transition id=71 failed: {"errorMessages":["Already create deployment ticket"],"errors":{}}');
2040
+ if (id === '21') status = 'Wait Approval';
2041
+ if (id === '72') status = 'Wait Deploy';
2042
+ return {};
2043
+ },
2044
+ getIssue: async () => ({ fields: { status: { name: status }, summary: 'CD' } }),
2045
+ addComment: async () => {},
2046
+ };
2047
+ const result = await executeTool(
2048
+ 'prepare_cd_deployment',
2049
+ { issueKey: 'CID-1910', environment: 'dev' },
2050
+ {
2051
+ jira,
2052
+ notifier: mockNotifier,
2053
+ },
2054
+ );
2055
+
2056
+ assert.ok(!result.content[0].text.startsWith('❌'), '應成功續跑');
2057
+ const data = JSON.parse(result.content[0].text);
2058
+ assert.equal(data.status, 'Wait Deploy');
2059
+ assert.deepEqual(calls.transitionById.map((call) => call.id), ['71', '21', '72']);
2060
+ assert.ok(data.steps.some((step) => step.includes('Deployment ticket 已存在')));
2061
+ });
2062
+ });
2063
+
2064
+ // ═══════════════════════════════════════════════════════════════════
2065
+ // wait_to_dev
2066
+ // ═══════════════════════════════════════════════════════════════════
2067
+ describe('wait_to_dev', () => {
2068
+ test('執行 Upload Scan Report 與 Accept,停在 Wait To DEV', async () => {
2069
+ const calls = [];
2070
+ const jira = {
2071
+ getTransitions: async () => [
2072
+ { id: '10', name: 'Upload Scan Report', to: { name: 'Upload Report' } },
2073
+ { id: '11', name: 'Accept', to: { name: 'Wait To DEV' } },
2074
+ ],
2075
+ transitionById: async (key, id) => {
2076
+ calls.push({ key, id });
2077
+ },
2078
+ getIssue: async () => ({ fields: { status: { name: 'Wait To DEV' }, summary: 'CI' } }),
2079
+ addComment: async () => {},
2080
+ };
2081
+
2082
+ const result = await executeTool(
2083
+ 'wait_to_dev',
2084
+ { issueKey: 'CID-1709' },
2085
+ { jira, notifier: mockNotifier },
2086
+ );
2087
+
2088
+ assert.ok(!result.content[0].text.startsWith('❌'), '應成功觸發');
2089
+ const data = JSON.parse(result.content[0].text);
2090
+ assert.equal(data.status, 'Wait To DEV');
2091
+ assert.deepEqual(calls.map((call) => call.id), ['10', '11']);
2092
+ });
2093
+
2094
+ test('已是 Wait To DEV 時可跳過缺少的 Accept transition', async () => {
2095
+ const calls = [];
2096
+ const jira = {
2097
+ getTransitions: async () => [],
2098
+ transitionById: async (key, id) => {
2099
+ calls.push({ key, id });
2100
+ },
2101
+ getIssue: async () => ({ fields: { status: { name: 'Wait To DEV' }, summary: 'CI' } }),
2102
+ addComment: async () => {},
2103
+ };
2104
+
2105
+ const result = await executeTool(
2106
+ 'wait_to_dev',
2107
+ { issueKey: 'CID-1709' },
2108
+ { jira, notifier: mockNotifier },
2109
+ );
2110
+
2111
+ assert.ok(!result.content[0].text.startsWith('❌'), '應成功');
2112
+ assert.equal(calls.length, 0);
2113
+ });
1433
2114
  });
1434
2115
 
1435
2116
  // ═══════════════════════════════════════════════════════════════════
@@ -1523,7 +2204,10 @@ describe('trigger_deployment', () => {
1523
2204
  test('環境比對:[STG] summary 對應 stg → 選擇 STG sub-task,不選 UAT', async () => {
1524
2205
  const jira = makeTriggerMock({
1525
2206
  subTasks: [makeSubTask('CID-9001', '[STG]'), makeSubTask('CID-9002', '[UAT]')],
1526
- deployTrans: [{ id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } }],
2207
+ deployTrans: [
2208
+ { id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } },
2209
+ { id: '12', name: 'Trigger AutoDeploy', to: { name: 'Auto Deploy' } },
2210
+ ],
1527
2211
  });
1528
2212
  const result = await executeTool(
1529
2213
  'trigger_deployment',
@@ -1540,7 +2224,10 @@ describe('trigger_deployment', () => {
1540
2224
  test('環境比對:[UAT] summary 對應 uat → 選擇 UAT sub-task', async () => {
1541
2225
  const jira = makeTriggerMock({
1542
2226
  subTasks: [makeSubTask('CID-9001', '[STG]'), makeSubTask('CID-9002', '[UAT]')],
1543
- deployTrans: [{ id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } }],
2227
+ deployTrans: [
2228
+ { id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } },
2229
+ { id: '12', name: 'Trigger AutoDeploy', to: { name: 'Auto Deploy' } },
2230
+ ],
1544
2231
  });
1545
2232
  const result = await executeTool(
1546
2233
  'trigger_deployment',
@@ -1554,10 +2241,33 @@ describe('trigger_deployment', () => {
1554
2241
  assert.equal(data.deploymentKey, 'CID-9002', '應選 UAT sub-task');
1555
2242
  });
1556
2243
 
2244
+ test('環境比對:[DEV] summary 對應 dev → 選擇 DEV sub-task', async () => {
2245
+ const jira = makeTriggerMock({
2246
+ subTasks: [makeSubTask('CID-9001', '[DEV]'), makeSubTask('CID-9002', '[STG]')],
2247
+ deployTrans: [
2248
+ { id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } },
2249
+ { id: '12', name: 'Trigger AutoDeploy', to: { name: 'Auto Deploy' } },
2250
+ ],
2251
+ });
2252
+ const result = await executeTool(
2253
+ 'trigger_deployment',
2254
+ { cdIssueKey: 'CID-9000', environment: 'dev' },
2255
+ {
2256
+ jira,
2257
+ notifier: mockNotifier,
2258
+ },
2259
+ );
2260
+ const data = JSON.parse(result.content[0].text);
2261
+ assert.equal(data.deploymentKey, 'CID-9001', '應選 DEV sub-task');
2262
+ });
2263
+
1557
2264
  test('無符合環境的 sub-task → fallback 取第一個', async () => {
1558
2265
  const jira = makeTriggerMock({
1559
2266
  subTasks: [makeSubTask('CID-9001', '[DEV]')],
1560
- deployTrans: [{ id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } }],
2267
+ deployTrans: [
2268
+ { id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } },
2269
+ { id: '12', name: 'Trigger AutoDeploy', to: { name: 'Auto Deploy' } },
2270
+ ],
1561
2271
  });
1562
2272
  const result = await executeTool(
1563
2273
  'trigger_deployment',
@@ -1575,7 +2285,7 @@ describe('trigger_deployment', () => {
1575
2285
  );
1576
2286
  });
1577
2287
 
1578
- test('找不到 transition → 跳過,不中斷,仍回傳成功', async () => {
2288
+ test('找不到必要 deployment transition → 回傳錯誤', async () => {
1579
2289
  const jira = makeTriggerMock({
1580
2290
  subTasks: [makeSubTask('CID-9001', '[STG]')],
1581
2291
  deployTrans: [], // 完全沒有 transitions
@@ -1588,12 +2298,8 @@ describe('trigger_deployment', () => {
1588
2298
  notifier: mockNotifier,
1589
2299
  },
1590
2300
  );
1591
- assert.ok(!result.content[0].text.startsWith('❌'), '應成功回傳(跳過所有 transition)');
1592
- const data = JSON.parse(result.content[0].text);
1593
- assert.ok(
1594
- data.steps.some((s) => s.includes('跳過')),
1595
- '應記錄跳過',
1596
- );
2301
+ assert.equal(result.isError, true);
2302
+ assert.match(result.content[0].text, /找不到必要部署 transition/);
1597
2303
  });
1598
2304
 
1599
2305
  test('applyForClose=true → 觸發 CD 單的 Apply for close', async () => {
@@ -1620,7 +2326,10 @@ describe('trigger_deployment', () => {
1620
2326
  test('applyForClose 預設 false → 不觸發 CD 單 transition', async () => {
1621
2327
  const jira = makeTriggerMock({
1622
2328
  subTasks: [makeSubTask('CID-9001', '[STG]')],
1623
- deployTrans: [{ id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } }],
2329
+ deployTrans: [
2330
+ { id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } },
2331
+ { id: '12', name: 'Trigger AutoDeploy', to: { name: 'Auto Deploy' } },
2332
+ ],
1624
2333
  cdTrans: [{ id: '99', name: 'Apply for close', to: { name: 'Wait For Close' } }],
1625
2334
  });
1626
2335
  await executeTool(
@@ -1638,7 +2347,10 @@ describe('trigger_deployment', () => {
1638
2347
  test('applyForClose=true 但 CD 單找不到 Apply for close → 記錄警告不中斷', async () => {
1639
2348
  const jira = makeTriggerMock({
1640
2349
  subTasks: [makeSubTask('CID-9001', '[STG]')],
1641
- deployTrans: [{ id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } }],
2350
+ deployTrans: [
2351
+ { id: '11', name: 'To AutoDeploy', to: { name: 'Pre Auto Deploy' } },
2352
+ { id: '12', name: 'Trigger AutoDeploy', to: { name: 'Auto Deploy' } },
2353
+ ],
1642
2354
  cdTrans: [], // CD 單沒有 Apply for close
1643
2355
  });
1644
2356
  const result = await executeTool(