@jira-deploy/core 1.0.2 → 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,6 +4,7 @@
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';
8
9
 
9
10
  process.env.JIRA_BASE_URL = 'https://jira.test';
@@ -127,6 +128,7 @@ describe('tool schemas — agent contract', () => {
127
128
  const tool = getToolDefinition('create_cd_ticket');
128
129
 
129
130
  assert.deepEqual(tool.inputSchema.required, ['systemCode', 'environment']);
131
+ assert.ok(tool.inputSchema.properties.environment.enum.includes('dev'));
130
132
  for (const property of [
131
133
  'ciTicket',
132
134
  'linkedCiKey',
@@ -151,6 +153,10 @@ describe('tool schemas — agent contract', () => {
151
153
  assert.ok(getToolDefinition('run_lib_to_stg_release'));
152
154
  });
153
155
 
156
+ test('wait_to_dev is exposed as a CI workflow tool', () => {
157
+ assert.ok(getToolDefinition('wait_to_dev'));
158
+ });
159
+
154
160
  test('run_stg_full_release dispatches shared workflow steps', async () => {
155
161
  const calls = [];
156
162
  const result = await executeTool('run_stg_full_release', {systemCode: 'IBK'}, {
@@ -367,6 +373,22 @@ describe('create_library_ticket — fields', () => {
367
373
  const f = await getCreatedFields('create_library_ticket', BASE);
368
374
  assert.equal(f['customfield_13431'], 'release/v1.5.2.0');
369
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
+ });
370
392
  });
371
393
 
372
394
  // ═══════════════════════════════════════════════════════════════════
@@ -591,6 +613,23 @@ describe('create_cd_ticket — fields', () => {
591
613
  });
592
614
  assert.equal(f['customfield_13442'], 'tvdev-ibk-web01');
593
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
+ });
594
633
  test('customfield_13437 (CID_extra_vars) IBK STG 自動生成', async () => {
595
634
  const f = await getCreatedFields('create_cd_ticket', BASE);
596
635
  const ev = JSON.parse(f['customfield_13437']);
@@ -1348,6 +1387,352 @@ describe('create_grayrelease_ticket — fields', () => {
1348
1387
  });
1349
1388
  });
1350
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
+
1351
1736
  // ═══════════════════════════════════════════════════════════════════
1352
1737
  // prepare_cd_deployment
1353
1738
  // ═══════════════════════════════════════════════════════════════════
@@ -1380,6 +1765,19 @@ describe('prepare_cd_deployment', () => {
1380
1765
  };
1381
1766
  }
1382
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
+
1383
1781
  test('stg:更新 customfield_13436 為 id 14356', async () => {
1384
1782
  const jira = makeDeployMock();
1385
1783
  await executeTool(
@@ -1573,6 +1971,146 @@ describe('prepare_cd_deployment', () => {
1573
1971
  '第 2 次應觸發 Prepare to create deployment ticket',
1574
1972
  );
1575
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
+ });
1576
2114
  });
1577
2115
 
1578
2116
  // ═══════════════════════════════════════════════════════════════════
@@ -1703,6 +2241,26 @@ describe('trigger_deployment', () => {
1703
2241
  assert.equal(data.deploymentKey, 'CID-9002', '應選 UAT sub-task');
1704
2242
  });
1705
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
+
1706
2264
  test('無符合環境的 sub-task → fallback 取第一個', async () => {
1707
2265
  const jira = makeTriggerMock({
1708
2266
  subTasks: [makeSubTask('CID-9001', '[DEV]')],