@jira-deploy/core 1.0.0

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/index.js ADDED
@@ -0,0 +1,1119 @@
1
+ import {Poller} from '../poller.js';
2
+ import {
3
+ getLibraryToolDefinitions,
4
+ handleCreateLibraryTicket,
5
+ handleGetNextLibVersion,
6
+ } from './library.js';
7
+ import {getCIToolDefinitions, handleCreateCITicket, handleGetNextCIVersion} from './ci.js';
8
+ import {
9
+ SYSTEM_CODES,
10
+ ENV_CODES,
11
+ CD_FIELD_IDS,
12
+ SUPPORTED_ENVS,
13
+ resolveAccountId,
14
+ } from '../constants/index.js';
15
+ import {getCDToolDefinitions, handleCreateCDTicket} from './cd.js';
16
+ import {
17
+ getGrayReleaseToolDefinitions,
18
+ handleCreateGrayReleaseTicket,
19
+ handleLinkStgGrayRelease,
20
+ } from './grayrelease.js';
21
+ import {
22
+ getReleaseToolDefinitions,
23
+ handleGetUnreleasedVersions,
24
+ handleTransitionToWaitApproval,
25
+ handleGetReleaseManager,
26
+ handleWaitForComment,
27
+ } from './release.js';
28
+ import {getJabberToolDefinitions, handleSendJabberMessage} from './jabber.js';
29
+
30
+ /**
31
+ * 回傳所有 tool 定義(schema)給 MCP Server 註冊
32
+ *
33
+ * ✨ 所有配置現已遷移至 src/constants/
34
+ * - field-ids.js: Jira customfield 映射
35
+ * - system-codes.js: 系統代碼及模組
36
+ * - environments.js: 環境代碼
37
+ * - server.js: 部署列表
38
+ * - issue-types.js: Issue 類型定義
39
+ * - modules.js: 模組 ID 映射
40
+ * - defaults.js: 預設值及範本
41
+ */
42
+ export function getToolDefinitions() {
43
+ return [
44
+ ...getLibraryToolDefinitions(),
45
+ ...getCIToolDefinitions(),
46
+ ...getCDToolDefinitions(),
47
+ ...getGrayReleaseToolDefinitions(),
48
+ ...getReleaseToolDefinitions(),
49
+ ...getJabberToolDefinitions(),
50
+ {
51
+ name: 'transition_issue',
52
+ description: '切換 Jira issue 狀態,用名稱指定(不需要知道 transition ID)',
53
+ inputSchema: {
54
+ type: 'object',
55
+ required: ['issueKey', 'transitionName'],
56
+ properties: {
57
+ issueKey: {type: 'string', description: '例如 OPS-123'},
58
+ transitionName: {
59
+ type: 'string',
60
+ description: '狀態名稱,例如 "Pending Approval"、"Approved"、"In Progress"、"Done"',
61
+ },
62
+ },
63
+ },
64
+ },
65
+ {
66
+ name: 'wait_for_approval',
67
+ description:
68
+ '輪詢等待 issue 被主管 approve(狀態變為 Approved)。這個工具會持續輪詢直到狀態改變或超時。',
69
+ inputSchema: {
70
+ type: 'object',
71
+ required: ['issueKey'],
72
+ properties: {
73
+ issueKey: {type: 'string'},
74
+ targetStatus: {
75
+ type: 'string',
76
+ default: 'Approved',
77
+ description: '等待的目標狀態名稱(預設 Approved)',
78
+ },
79
+ pollIntervalMs: {
80
+ type: 'number',
81
+ description: '輪詢間隔毫秒(預設讀 env POLL_INTERVAL_MS)',
82
+ },
83
+ timeoutMs: {
84
+ type: 'number',
85
+ description: '最長等待毫秒(預設讀 env POLL_TIMEOUT_MS)',
86
+ },
87
+ },
88
+ },
89
+ },
90
+ {
91
+ name: 'get_issue_status',
92
+ description: '取得 issue 目前狀態與摘要',
93
+ inputSchema: {
94
+ type: 'object',
95
+ required: ['issueKey'],
96
+ properties: {
97
+ issueKey: {type: 'string'},
98
+ },
99
+ },
100
+ },
101
+ {
102
+ name: 'add_deploy_comment',
103
+ description: '在 issue 上新增留言(部署結果、build log 連結等)',
104
+ inputSchema: {
105
+ type: 'object',
106
+ required: ['issueKey', 'message'],
107
+ properties: {
108
+ issueKey: {type: 'string'},
109
+ message: {type: 'string', description: '留言內容'},
110
+ },
111
+ },
112
+ },
113
+ {
114
+ name: 'list_transitions',
115
+ description: '列出 issue 目前可執行的所有 transitions(debug 用)',
116
+ inputSchema: {
117
+ type: 'object',
118
+ required: ['issueKey'],
119
+ properties: {
120
+ issueKey: {type: 'string'},
121
+ },
122
+ },
123
+ },
124
+ {
125
+ name: 'link_issues',
126
+ description:
127
+ '在兩個 Jira issue 之間建立關聯。常用類型:Relates(互相關聯)、Hierarchy link (WBSGantt)(CI contains CD,outward=contains / inward=is contained in)',
128
+ inputSchema: {
129
+ type: 'object',
130
+ required: ['inwardKey', 'outwardKey'],
131
+ properties: {
132
+ inwardKey: {type: 'string', description: '被包含方 issue key,例如 CD 單 CID-1669'},
133
+ outwardKey: {type: 'string', description: '包含方 issue key,例如 CI 單 CID-1668'},
134
+ linkType: {
135
+ type: 'string',
136
+ description:
137
+ 'Link 類型,預設 "Relates",CI→CD 請用 "Hierarchy link (WBSGantt)"(outward=contains)',
138
+ default: 'Relates',
139
+ },
140
+ },
141
+ },
142
+ },
143
+ {
144
+ name: 'build_ticket',
145
+ description:
146
+ '觸發 Library 或 CI 上版單的 Jenkins Build。自動處理前置狀態切換與 Jira Automation 等待,不需要手動切換狀態。',
147
+ inputSchema: {
148
+ type: 'object',
149
+ required: ['issueKey'],
150
+ properties: {
151
+ issueKey: {
152
+ type: 'string',
153
+ description: '要 build 的 issue key,例如 CID-1668',
154
+ },
155
+ },
156
+ },
157
+ },
158
+ {
159
+ name: 'wait_to_stg',
160
+ description:
161
+ 'CI 單 build 完成後,自動走完掃描流程切到 Wait To STG 狀態。流程:Compliance Scan → Upload Scan Report → Accept → Dev Done → Wait To STG',
162
+ inputSchema: {
163
+ type: 'object',
164
+ required: ['issueKey'],
165
+ properties: {
166
+ issueKey: {
167
+ type: 'string',
168
+ description: 'CI issue key,例如 CID-1668',
169
+ },
170
+ },
171
+ },
172
+ },
173
+ {
174
+ name: 'trigger_deployment',
175
+ description:
176
+ '在 CD 單的 Deployment sub-task 上依序觸發部署 transitions(To Pretask → To AutoDeploy → Trigger AutoDeploy)。接在 prepare_cd_deployment 之後使用。當使用者說「幫我 Deploy」、「上版」、「deploy」時優先使用這個 tool',
177
+ inputSchema: {
178
+ type: 'object',
179
+ required: ['cdIssueKey', 'environment'],
180
+ properties: {
181
+ cdIssueKey: {
182
+ type: 'string',
183
+ description: 'CD 單 issue key,例如 CID-1710',
184
+ },
185
+ environment: {
186
+ type: 'string',
187
+ enum: SUPPORTED_ENVS.cd,
188
+ description: `部署目標環境:${SUPPORTED_ENVS.cd.join(' / ')}`,
189
+ },
190
+ applyForClose: {
191
+ type: 'boolean',
192
+ description: '部署觸發後是否同步對 CD 單執行 Apply for close(預設 false)',
193
+ },
194
+ },
195
+ },
196
+ },
197
+ {
198
+ name: 'prepare_cd_deployment',
199
+ description:
200
+ '在 CD 單點擊「Prepare to Create Deployment」觸發部署。自動更新環境欄位並執行 transition,啟動 Jenkins 部署流程。',
201
+ inputSchema: {
202
+ type: 'object',
203
+ required: ['issueKey', 'environment'],
204
+ properties: {
205
+ issueKey: {
206
+ type: 'string',
207
+ description: 'CD 單 issue key,例如 CID-1697',
208
+ },
209
+ environment: {
210
+ type: 'string',
211
+ enum: SUPPORTED_ENVS.cd,
212
+ description: `部署目標環境:${SUPPORTED_ENVS.cd.join(' / ')}`,
213
+ },
214
+ },
215
+ },
216
+ },
217
+ {
218
+ name: 'cancel_release',
219
+ description:
220
+ '取消目前進行中的 CI 單,並回傳關聯的 Library 模組清單(含 gitBranch)與其他關聯 CI 單,供後續 reroll 流程使用。當使用者說「cancel IBK release」、「取消 IBK release」、「can 掉 IBK」時使用。',
221
+ inputSchema: {
222
+ type: 'object',
223
+ required: ['systemCode'],
224
+ properties: {
225
+ systemCode: {
226
+ type: 'string',
227
+ description: '系統代碼,例如 IBK、EIB、EVT',
228
+ },
229
+ ciIssueKey: {
230
+ type: 'string',
231
+ description: '若已知 CI 單 key,直接指定(不填則自動查最新進行中的 CI 單)',
232
+ },
233
+ },
234
+ },
235
+ },
236
+ {
237
+ name: 'get_release_status',
238
+ description:
239
+ '查詢指定系統代號的 Release 現況。以最新 CI 單為錨點,列出關聯的 Library 單、CD 單狀態與建議下一步。當使用者說「IBK 目前 release 到哪了」、「現在 CI 狀態怎樣」、「幫我看一下進度」時使用。',
240
+ inputSchema: {
241
+ type: 'object',
242
+ required: ['systemCode'],
243
+ properties: {
244
+ systemCode: {
245
+ type: 'string',
246
+ description: '系統代碼,例如 IBK、EIB、EVT、CWA、BOF',
247
+ },
248
+ },
249
+ },
250
+ },
251
+ {
252
+ name: 'update_assignee',
253
+ description:
254
+ '更新 Jira issue 的 Assignee。當使用者說「切單給 Solar」或「切單給 xxx(BK00129)」時使用。支援直接輸入名稱(會自動查找 accountId)或直接指定 accountId。',
255
+ inputSchema: {
256
+ type: 'object',
257
+ required: ['issueKey'],
258
+ properties: {
259
+ issueKey: {
260
+ type: 'string',
261
+ description: '要更新的 issue key,例如 CID-1718',
262
+ },
263
+ accountId: {
264
+ type: 'string',
265
+ description: 'Jira 用戶 accountId,例如 BK00129(與 displayName 擇一填寫即可)',
266
+ },
267
+ displayName: {
268
+ type: 'string',
269
+ description: '用戶顯示名稱或暱稱,例如 Solar(系統會自動查找對應的 accountId)',
270
+ },
271
+ },
272
+ },
273
+ },
274
+ ];
275
+ }
276
+
277
+ /**
278
+ * 執行 tool,回傳 { content: [{ type: 'text', text }] }
279
+ */
280
+ export async function executeTool(name, args, {jira, notifier}) {
281
+ const poller = new Poller(jira);
282
+
283
+ switch (name) {
284
+ case 'transition_issue': {
285
+ try {
286
+ const result = await jira.transitionByName(args.issueKey, args.transitionName);
287
+ await notifier.notify(args.issueKey, `狀態已切換為:${result.toStatus}`);
288
+ return ok(result);
289
+ } catch (err) {
290
+ return error(`transition 失敗: ${err.message}`);
291
+ }
292
+ }
293
+
294
+ case 'wait_for_approval': {
295
+ const targetStatus = args.targetStatus ?? 'Approved';
296
+
297
+ await notifier.notify(args.issueKey, `等待主管 Approve(目標狀態:${targetStatus})`);
298
+
299
+ const result = await poller.waitForStatus(args.issueKey, targetStatus, {
300
+ intervalMs: args.pollIntervalMs,
301
+ timeoutMs: args.timeoutMs,
302
+ });
303
+
304
+ await notifier.notify(
305
+ args.issueKey,
306
+ `已收到 Approve(${result.attempts} 次輪詢,耗時 ${Math.round(result.elapsedMs / 1000)}s)`,
307
+ );
308
+
309
+ return ok(result);
310
+ }
311
+
312
+ case 'get_issue_status': {
313
+ const issue = await jira.getIssue(args.issueKey);
314
+ return ok({
315
+ issueKey: args.issueKey,
316
+ status: issue.fields.status.name,
317
+ summary: issue.fields.summary,
318
+ });
319
+ }
320
+
321
+ case 'add_deploy_comment': {
322
+ await notifier.notify(args.issueKey, args.message);
323
+ return ok({issueKey: args.issueKey, commented: true});
324
+ }
325
+
326
+ case 'list_transitions': {
327
+ const transitions = await jira.getTransitions(args.issueKey);
328
+ return ok(transitions.map((t) => ({id: t.id, name: t.name, to: t.to.name})));
329
+ }
330
+
331
+ case 'create_library_ticket':
332
+ return handleCreateLibraryTicket(args, {jira, notifier});
333
+
334
+ case 'create_ci_ticket': {
335
+ return handleCreateCITicket(args, {jira, notifier});
336
+ }
337
+
338
+ case 'create_cd_ticket': {
339
+ return handleCreateCDTicket(args, {jira, notifier});
340
+ }
341
+
342
+ case 'create_grayrelease_ticket': {
343
+ return handleCreateGrayReleaseTicket(args, {jira, notifier});
344
+ }
345
+
346
+ case 'link_stg_grayrelease':
347
+ return handleLinkStgGrayRelease(args, {jira, notifier});
348
+
349
+ case 'get_unreleased_versions':
350
+ return handleGetUnreleasedVersions(args, {jira});
351
+
352
+ case 'transition_to_wait_approval':
353
+ return handleTransitionToWaitApproval(args, {jira, notifier});
354
+
355
+ case 'get_release_manager':
356
+ return handleGetReleaseManager(args, {});
357
+
358
+ case 'wait_for_comment':
359
+ return handleWaitForComment(args, {jira});
360
+
361
+ case 'send_jabber_message':
362
+ return handleSendJabberMessage(args, {});
363
+
364
+ case 'link_issues': {
365
+ try {
366
+ const linkType = args.linkType ?? 'Relates';
367
+ const result = await jira.linkIssue(args.inwardKey, args.outwardKey, linkType);
368
+ await notifier.notify(args.inwardKey, `已建立關聯至 ${args.outwardKey}(${linkType})`);
369
+ return ok(result);
370
+ } catch (err) {
371
+ return error(`無法建立關聯: ${err.message}`);
372
+ }
373
+ }
374
+
375
+ case 'build_ticket': {
376
+ const {issueKey} = args;
377
+ const log = [];
378
+
379
+ /**
380
+ * CI Release 完整狀態流程:
381
+ * TO DO → (Accept) → Wait for Build → (Build) → Compliance Scan
382
+ * → (Upload Scan Report) → Upload Report → (Accept) → Wait To DEV
383
+ * → (Dev Done) → Wait To STG → (STG Done) → Wait To UAT
384
+ * → (UAT Done) → Wait For Upload → (Upload To Pre-Release)
385
+ * → Wait For Upload → (Upload Done) → Wait To PRD/DR
386
+ * → (PRD/DR Done)
387
+ *
388
+ * Library Release 完整狀態流程:
389
+ * TO DO → (Upload Lib Report) → UPLOAD LIB REPORT → (Apply for approval) → Wait Approval → (Approved) → WAIT FOR LIB BUILD → (Build) → Released
390
+ * 或:TO DO → (Upload Lib Report) → UPLOAD LIB REPORT → Jira Automation 自動觸發 Jenkins(無手動 Build)
391
+ */
392
+
393
+ // 前置 transitions:若 "Build" 尚未出現,先觸發這些讓 Jira Automation 推進
394
+ // 'Upload Lib Report':Library 票從 TO DO 的第一步(對應 UI 的 Upload Library Report)
395
+ const PRE_TRANSITIONS = ['Upload Lib Report', 'Apply for approval', 'Approved', 'Accept'];
396
+ const POLL_INTERVAL = 3000; // 3s
397
+ const MAX_WAIT_MS = 30000; // 最多等 30s(避免 MCP client timeout)
398
+
399
+ const findTransition = async (name) => {
400
+ const list = await jira.getTransitions(issueKey);
401
+ return list.find((t) => t.name.toLowerCase() === name.toLowerCase());
402
+ };
403
+
404
+ const waitForTransition = async (name) => {
405
+ const deadline = Date.now() + MAX_WAIT_MS;
406
+ while (Date.now() < deadline) {
407
+ const t = await findTransition(name);
408
+ if (t) return t;
409
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL));
410
+ }
411
+ return null;
412
+ };
413
+
414
+ try {
415
+ // 記錄初始狀態(用於判斷是否有狀態推進)
416
+ const initIssue = await jira.getIssue(issueKey);
417
+ const initStatus = initIssue.fields.status.name;
418
+
419
+ // Step 1: 確認目前是否已有 Build transition
420
+ let buildTrans = await findTransition('Build');
421
+
422
+ // Step 2: 若沒有,逐步觸發前置 transitions
423
+ if (!buildTrans) {
424
+ log.push('未找到 Build transition,逐步觸發前置狀態...');
425
+ let preTriggered = false;
426
+
427
+ // 最多嘗試 PRE_TRANSITIONS.length 輪,每輪觸發一個可用的前置 transition
428
+ for (let step = 0; step < PRE_TRANSITIONS.length; step++) {
429
+ const transitions = await jira.getTransitions(issueKey);
430
+ const pre = transitions.find((t) =>
431
+ PRE_TRANSITIONS.some((name) => t.name.toLowerCase() === name.toLowerCase()),
432
+ );
433
+ if (!pre) break;
434
+
435
+ log.push(` [step ${step + 1}] 觸發「${pre.name}」...`);
436
+ await jira.transitionById(issueKey, pre.id).catch(() => {
437
+ });
438
+ preTriggered = true;
439
+ // 等 Jira 更新狀態
440
+ await new Promise((r) => setTimeout(r, 2000));
441
+
442
+ buildTrans = await findTransition('Build');
443
+ if (buildTrans) {
444
+ log.push(` 已找到 Build transition`);
445
+ break;
446
+ }
447
+ }
448
+
449
+ // 若還沒出現,再給 Jira Automation 最多 30s
450
+ if (!buildTrans) {
451
+ log.push(' 等待 Jira Automation 推進(最多 30s)...');
452
+ buildTrans = await waitForTransition('Build');
453
+ }
454
+
455
+ // 若仍未找到 Build transition,但有觸發前置 transition 且狀態已推進,
456
+ // 代表此 Library workflow 由 Jira Automation 自動觸發 Jenkins(無需手動 Build)
457
+ if (!buildTrans && preTriggered) {
458
+ const currentIssue = await jira.getIssue(issueKey);
459
+ const currentStatus = currentIssue.fields.status.name;
460
+ if (currentStatus !== initStatus) {
461
+ log.push(
462
+ `⚠️ 無手動 Build transition,但狀態已推進:${initStatus} → ${currentStatus}`,
463
+ );
464
+ log.push(' Jenkins Build 可能已由 Jira Automation 自動觸發');
465
+ await notifier.notify(
466
+ issueKey,
467
+ `Library Build 已觸發(Jira Auto)狀態:${initStatus} → ${currentStatus}`,
468
+ );
469
+ return ok({issueKey, status: currentStatus, steps: log, autoTriggered: true});
470
+ }
471
+ }
472
+ }
473
+
474
+ if (!buildTrans) {
475
+ const issue = await jira.getIssue(issueKey);
476
+ return error(`找不到 Build transition,目前狀態:${issue.fields.status.name}`);
477
+ }
478
+
479
+ // Step 4: 執行 Build
480
+ log.push(`執行 Build transition (id: ${buildTrans.id})...`);
481
+ await jira.transitionById(issueKey, buildTrans.id);
482
+
483
+ const issue = await jira.getIssue(issueKey);
484
+ const newStatus = issue.fields.status.name;
485
+ log.push(`✅ Build 已觸發,目前狀態:${newStatus}`);
486
+ await notifier.notify(issueKey, `Jenkins Build 已觸發(${newStatus})`);
487
+
488
+ return ok({issueKey, status: newStatus, steps: log});
489
+ } catch (err) {
490
+ return error(`build_ticket 失敗: ${err.message}`);
491
+ }
492
+ }
493
+
494
+ case 'wait_to_stg': {
495
+ const {issueKey} = args;
496
+ const log = [];
497
+
498
+ // CI 掃描 → STG 的標準流程步驟
499
+ // 每個元素:{ transition: '名稱', targetStatus: '到達後的狀態名稱' }
500
+ const STEPS = [
501
+ {transition: 'Upload Scan Report', targetStatus: 'Upload Report'},
502
+ {transition: 'Accept', targetStatus: 'Wait To DEV'},
503
+ {transition: 'Dev Done', targetStatus: 'Wait To STG'},
504
+ ];
505
+
506
+ try {
507
+ for (const step of STEPS) {
508
+ const transitions = await jira.getTransitions(issueKey);
509
+ const t = transitions.find((t) => t.name.toLowerCase() === step.transition.toLowerCase());
510
+ if (!t) {
511
+ // 確認目前狀態是否已達目標,若是則跳過此步
512
+ const issue = await jira.getIssue(issueKey);
513
+ const current = issue.fields.status.name;
514
+ if (current.toLowerCase() === step.targetStatus.toLowerCase()) {
515
+ log.push(` 已是 ${current},跳過「${step.transition}」`);
516
+ continue;
517
+ }
518
+ return error(`找不到 transition「${step.transition}」,目前狀態:${current}`);
519
+ }
520
+ log.push(`執行「${t.name}」→ ${step.targetStatus}`);
521
+ await jira.transitionById(issueKey, t.id);
522
+ }
523
+
524
+ const issue = await jira.getIssue(issueKey);
525
+ const finalStatus = issue.fields.status.name;
526
+ log.push(`✅ 完成,目前狀態:${finalStatus}`);
527
+ await notifier.notify(issueKey, `已切換至 ${finalStatus},可進行 STG 部署`);
528
+
529
+ return ok({issueKey, status: finalStatus, steps: log});
530
+ } catch (err) {
531
+ return error(`wait_to_stg 失敗: ${err.message}`);
532
+ }
533
+ }
534
+
535
+ case 'trigger_deployment': {
536
+ const {cdIssueKey, environment} = args;
537
+ const log = [];
538
+
539
+ // 判斷環境是否屬於 PRD 群組(prd / dr / prd/dr / prd&dr)
540
+ const isPrdEnv = (env) =>
541
+ ['prd', 'dr', 'prd/dr', 'prd&dr'].includes(env.toLowerCase().trim());
542
+
543
+ // 查詢同系統最近一筆已完成的部署(CD 或 GrayRelease),回傳 'prd' | 'nonPrd' | null
544
+ const getLastDeployEnvGroup = async (systemCode, excludeKey) => {
545
+ const jql_cd = `project = CID AND issuetype = CD AND "System Code[Select List (single choice)]" = "${systemCode}" AND status = Done AND issueKey != "${excludeKey}" ORDER BY updated DESC`;
546
+ const jql_gr = `project = CID AND issuetype = GrayRelease AND "System Code[Select List (single choice)]" = "${systemCode}" AND status = Done ORDER BY updated DESC`;
547
+
548
+ const [cdResults, grResults] = await Promise.all([
549
+ jira.searchIssues(jql_cd, ['customfield_13436', 'updated'], 1).catch(() => []),
550
+ jira.searchIssues(jql_gr, ['updated'], 1).catch(() => []),
551
+ ]);
552
+
553
+ const cdIssue = cdResults[0] ?? null;
554
+ const grIssue = grResults[0] ?? null;
555
+
556
+ // 沒有任何記錄 → 保守執行
557
+ if (!cdIssue && !grIssue) return null;
558
+
559
+ // 若只有其中一筆,直接用那筆
560
+ // 若兩筆都有,取 updated 較新者
561
+ let lastIssue = null;
562
+ let lastIsGray = false;
563
+
564
+ if (cdIssue && grIssue) {
565
+ const cdUpdated = new Date(cdIssue.fields?.updated ?? 0);
566
+ const grUpdated = new Date(grIssue.fields?.updated ?? 0);
567
+ if (grUpdated > cdUpdated) {
568
+ lastIssue = grIssue;
569
+ lastIsGray = true;
570
+ } else {
571
+ lastIssue = cdIssue;
572
+ }
573
+ } else if (grIssue) {
574
+ lastIssue = grIssue;
575
+ lastIsGray = true;
576
+ } else {
577
+ lastIssue = cdIssue;
578
+ }
579
+
580
+ // GrayRelease 永遠是 nonPrd
581
+ if (lastIsGray) return 'nonPrd';
582
+
583
+ // CD:讀 customfield_13436.value 判斷群組
584
+ const envVal = lastIssue.fields?.customfield_13436?.value ?? '';
585
+ return isPrdEnv(envVal) ? 'prd' : 'nonPrd';
586
+ };
587
+
588
+ // 依序觸發的 Deployment transitions
589
+ // conditionalWait: true → 依 needSwitch 決定是否執行並等待 180s
590
+ const DEPLOY_TRANSITIONS = [
591
+ {name: 'To Pretask'},
592
+ {name: 'Swtich Execution Node', conditionalWait: true},
593
+ {name: 'To AutoDeploy'},
594
+ {name: 'Trigger AutoDeploy'},
595
+ ];
596
+ // 環境字串對照(用於 sub-task summary 比對)
597
+ const envUpper = environment.toUpperCase().replace('/', '').replace('&', '');
598
+
599
+ try {
600
+ // Step 1: 取得 CD 單的 sub-tasks
601
+ const subTasks = await jira.getSubTasks(cdIssueKey);
602
+ if (!subTasks.length) {
603
+ return error(
604
+ `CD 單 ${cdIssueKey} 目前沒有 Deployment sub-task,請先執行 prepare_cd_deployment`,
605
+ );
606
+ }
607
+
608
+ log.push(`找到 ${subTasks.length} 個 sub-task:${subTasks.map((t) => t.key).join(', ')}`);
609
+
610
+ // Step 2: 依環境篩選 Deployment sub-task(比對 summary 中的環境標籤)
611
+ // 支援格式:[STG]、[UAT]、[PRD]、[DR]、[PRD/DR]、PRDDR 等
612
+ const envVariants = [envUpper, `[${envUpper}]`];
613
+ let deploymentKey = null;
614
+
615
+ for (const st of subTasks) {
616
+ const summaryUpper = (st.fields?.summary ?? '').toUpperCase().replace(/[\s/]/g, '');
617
+ if (envVariants.some((v) => summaryUpper.includes(v.replace(/[\s/]/g, '')))) {
618
+ deploymentKey = st.key;
619
+ break;
620
+ }
621
+ }
622
+
623
+ // fallback:若沒有符合環境的,取第一個 sub-task
624
+ if (!deploymentKey) {
625
+ deploymentKey = subTasks[0].key;
626
+ log.push(
627
+ ` ⚠️ 未找到明確符合環境 ${environment} 的 sub-task,使用第一個:${deploymentKey}`,
628
+ );
629
+ } else {
630
+ log.push(` 對應環境 ${environment} 的 Deployment 單:${deploymentKey}`);
631
+ }
632
+
633
+ // Step 2.5: 判斷是否需要執行 Switch Execution Node
634
+ // 規則:上次部署環境群組(prd/nonPrd)與本次不同時才需要切換 Ansible instance
635
+ const thisGroup = isPrdEnv(environment) ? 'prd' : 'nonPrd';
636
+ let needSwitch = true; // 預設保守執行
637
+
638
+ try {
639
+ const cdFields = await jira.getIssueFields(cdIssueKey, ['customfield_13443']);
640
+ const systemCode = cdFields?.customfield_13443?.value;
641
+ if (systemCode) {
642
+ const lastGroup = await getLastDeployEnvGroup(systemCode, cdIssueKey);
643
+ if (lastGroup === null) {
644
+ log.push(` ⚠️ 找不到 ${systemCode} 的歷史部署記錄,保守執行 Switch Execution Node`);
645
+ } else if (lastGroup === thisGroup) {
646
+ needSwitch = false;
647
+ log.push(` ✅ 上次部署同為 ${lastGroup} 群組,跳過 Switch Execution Node`);
648
+ } else {
649
+ log.push(` 🔄 環境群組變更(${lastGroup} → ${thisGroup}),需執行 Switch Execution Node`);
650
+ }
651
+ } else {
652
+ log.push(` ⚠️ 無法取得 systemCode,保守執行 Switch Execution Node`);
653
+ }
654
+ } catch (e) {
655
+ log.push(` ⚠️ 判斷 Switch 條件失敗(${e.message}),保守執行`);
656
+ }
657
+
658
+ // Step 3: 依序執行 transitions
659
+ for (const step of DEPLOY_TRANSITIONS) {
660
+ // conditionalWait: 依 needSwitch 決定是否執行
661
+ if (step.conditionalWait && !needSwitch) {
662
+ log.push(` ⏭️ 跳過「${step.name}」(同環境群組,無需切換 Ansible instance)`);
663
+ continue;
664
+ }
665
+
666
+ const transitions = await jira.getTransitions(deploymentKey);
667
+ const t = transitions.find((t) => t.name.toLowerCase() === step.name.toLowerCase());
668
+
669
+ if (!t) {
670
+ const issue = await jira.getIssue(deploymentKey);
671
+ const available = transitions.map((t) => t.name).join(', ');
672
+ // 若 transition 不存在但狀態已超前,跳過
673
+ log.push(
674
+ ` ⚠️ 找不到「${step.name}」(目前狀態:${issue.fields.status.name},可用:${available || '無'}),跳過`,
675
+ );
676
+ continue;
677
+ }
678
+
679
+ log.push(` 執行「${t.name}」...`);
680
+ await jira.transitionById(deploymentKey, t.id);
681
+ await new Promise((r) => setTimeout(r, 2000));
682
+
683
+ if (step.conditionalWait) {
684
+ const SWITCH_WAIT_MS = 180_000;
685
+ const secs = SWITCH_WAIT_MS / 1000;
686
+ log.push(` ⏳ 等待 ${secs}s(等待 Ansible instance 切換完成)...`);
687
+ await notifier.notify(deploymentKey, `⏳ 等待 ${secs}s 後繼續部署...`);
688
+ await new Promise((r) => setTimeout(r, SWITCH_WAIT_MS));
689
+ log.push(` ✅ 等待完成,繼續執行`);
690
+ }
691
+ }
692
+
693
+ const finalIssue = await jira.getIssue(deploymentKey);
694
+ const finalStatus = finalIssue.fields.status.name;
695
+ log.push(`✅ Deployment 已觸发,目前狀態:${finalStatus}`);
696
+ await notifier.notify(
697
+ deploymentKey,
698
+ `Deployment 部署已觸發(環境:${environment.toUpperCase()},狀態:${finalStatus})`,
699
+ );
700
+
701
+ // 若 applyForClose=true,同步對 CD 單觸發 Apply for close
702
+ if (args.applyForClose) {
703
+ try {
704
+ const cdTransitions = await jira.getTransitions(cdIssueKey);
705
+ const closeT = cdTransitions.find((t) => t.name.toLowerCase() === 'apply for close');
706
+ if (closeT) {
707
+ log.push(` 對 CD 單 ${cdIssueKey} 執行「${closeT.name}」...`);
708
+ await jira.transitionById(cdIssueKey, closeT.id);
709
+ const cdIssue = await jira.getIssue(cdIssueKey);
710
+ log.push(` CD 單狀態:${cdIssue.fields.status.name}`);
711
+ await notifier.notify(cdIssueKey, `CD 單已提交關閉申請(Apply for close)`);
712
+ } else {
713
+ const available = cdTransitions.map((t) => t.name).join(', ');
714
+ log.push(
715
+ ` ⚠️ CD 單找不到「Apply for close」transition(可用:${available || '無'})`,
716
+ );
717
+ }
718
+ } catch (e) {
719
+ log.push(` ⚠️ Apply for close 失敗:${e.message}`);
720
+ }
721
+ }
722
+
723
+ return ok({
724
+ cdIssueKey,
725
+ deploymentKey,
726
+ environment,
727
+ status: finalStatus,
728
+ steps: log,
729
+ });
730
+ } catch (err) {
731
+ return error(`trigger_deployment 失敗: ${err.message}`);
732
+ }
733
+ }
734
+
735
+ case 'prepare_cd_deployment': {
736
+ const {issueKey} = args;
737
+ // 正規化環境名稱(prd&dr → prd/dr)
738
+ const envCode = args.environment.toLowerCase().replace('&', '/');
739
+ const log = [];
740
+
741
+ // CD 部署前置 transitions(依序嘗試,直到找到部署 transition 或完成申請流程)
742
+ // STG 完整流程:
743
+ // TO DO → (Accept) → Wait For Send Notice Email
744
+ // → (Prepare to create deployment ticket) → Prepare For Deploy ← 建立 Deployment sub-task
745
+ // → (Apply for approval) → Wait Approval ← 等 Reviewer 核准
746
+ // → Wait Deploy ← 核准後自動切換,再呼叫 trigger_deployment
747
+ // ⚠️ 不加 'Approved':Approved 需要其他主管才能執行
748
+ const CD_PRE_TRANSITIONS = [
749
+ 'Accept',
750
+ 'Prepare to create deployment ticket',
751
+ 'Apply for approval',
752
+ 'To Wait Deploy',
753
+ ];
754
+
755
+ // 部署 transition 名稱(支援多種命名)
756
+ const DEPLOY_TRANSITION_NAMES = [
757
+ 'Prepare to create deployment ticket', // 實際 Jira transition 名稱
758
+ 'Prepare to Create Deployment',
759
+ 'Prepare Deploy',
760
+ 'Create Deployment',
761
+ 'Deploy',
762
+ 'Start Deploy',
763
+ ];
764
+
765
+ try {
766
+ // Step 1: 驗證環境是否合法
767
+ if (!SUPPORTED_ENVS.cd.includes(envCode)) {
768
+ return error(`不支援的 CD 部署環境:${envCode},合法值:${SUPPORTED_ENVS.cd.join(', ')}`);
769
+ }
770
+
771
+ // Step 2: 更新 CD 單的環境欄位(customfield_13436)
772
+ if (CD_FIELD_IDS.env && ENV_CODES[envCode]) {
773
+ await jira.updateIssue(issueKey, {
774
+ [CD_FIELD_IDS.env]: {id: ENV_CODES[envCode]},
775
+ });
776
+ log.push(`✅ 環境欄位更新為 ${envCode.toUpperCase()}`);
777
+ }
778
+
779
+ // Step 3: 尋找部署 transition,若不存在則先觸發前置 transitions
780
+ const findDeployTrans = async () => {
781
+ const list = await jira.getTransitions(issueKey);
782
+ for (const name of DEPLOY_TRANSITION_NAMES) {
783
+ const t = list.find((t) => t.name.toLowerCase() === name.toLowerCase());
784
+ if (t) return t;
785
+ }
786
+ return null;
787
+ };
788
+
789
+ let deployTrans = await findDeployTrans();
790
+
791
+ if (!deployTrans) {
792
+ log.push('未找到部署 transition,逐步觸發前置狀態...');
793
+ for (const preName of CD_PRE_TRANSITIONS) {
794
+ const transitions = await jira.getTransitions(issueKey);
795
+ const pre = transitions.find((t) => t.name.toLowerCase() === preName.toLowerCase());
796
+ if (!pre) continue;
797
+
798
+ log.push(` 觸發「${pre.name}」...`);
799
+ await jira.transitionById(issueKey, pre.id);
800
+ await new Promise((r) => setTimeout(r, 2000));
801
+
802
+ deployTrans = await findDeployTrans();
803
+ if (deployTrans) {
804
+ log.push(` 找到部署 transition:「${deployTrans.name}」`);
805
+ break;
806
+ }
807
+ }
808
+ }
809
+
810
+ if (!deployTrans) {
811
+ const issue = await jira.getIssue(issueKey);
812
+ const transitions = await jira.getTransitions(issueKey);
813
+ const available = transitions.map((t) => t.name).join(', ');
814
+ return error(
815
+ `找不到部署 transition(嘗試:${DEPLOY_TRANSITION_NAMES.join(', ')})\n` +
816
+ `目前狀態:${issue.fields.status.name}\n` +
817
+ `可用 transitions:${available || '(無)'}`,
818
+ );
819
+ }
820
+
821
+ // Step 4: 觸发部署 transition
822
+ log.push(`執行「${deployTrans.name}」transition(id: ${deployTrans.id})...`);
823
+ await jira.transitionById(issueKey, deployTrans.id);
824
+
825
+ const issue = await jira.getIssue(issueKey);
826
+ const newStatus = issue.fields.status.name;
827
+ log.push(`✅ 部署已觸发,目前狀態:${newStatus}`);
828
+
829
+ await notifier.notify(
830
+ issueKey,
831
+ `CD 部署已觸發(環境: ${envCode.toUpperCase()},狀態: ${newStatus})`,
832
+ );
833
+
834
+ return ok({
835
+ issueKey,
836
+ environment: envCode,
837
+ status: newStatus,
838
+ steps: log,
839
+ });
840
+ } catch (err) {
841
+ return error(`prepare_cd_deployment 失敗: ${err.message}`);
842
+ }
843
+ }
844
+
845
+ case 'update_assignee': {
846
+ const {issueKey} = args;
847
+ let {accountId, displayName} = args;
848
+
849
+ // 若只提供 displayName(或名稱),嘗試從 USER_MAP 查找 accountId
850
+ if (!accountId && displayName) {
851
+ const resolved = resolveAccountId(displayName);
852
+ if (!resolved) {
853
+ return error(
854
+ `找不到使用者「${displayName}」的 accountId,請直接指定 accountId 或更新 USER_MAP`,
855
+ );
856
+ }
857
+ accountId = resolved;
858
+ }
859
+
860
+ if (!accountId) {
861
+ return error('請提供 accountId 或使用者名稱(displayName)');
862
+ }
863
+
864
+ try {
865
+ await jira.updateAssignee(issueKey, accountId);
866
+ return ok({
867
+ issueKey,
868
+ assignee: {accountId, displayName: displayName ?? accountId},
869
+ message: `✅ ${issueKey} Assignee 已更新為 ${displayName ? `${displayName}(${accountId})` : accountId}`,
870
+ });
871
+ } catch (err) {
872
+ return error(`update_assignee 失敗: ${err.message}`);
873
+ }
874
+ }
875
+
876
+ case 'cancel_release': {
877
+ const {systemCode} = args;
878
+ let ciIssueKey = args.ciIssueKey ?? null;
879
+
880
+ try {
881
+ // Step 1: 若未指定 CI key,查最新進行中的 CI 單
882
+ if (!ciIssueKey) {
883
+ const ciIssues = await jira.searchIssues(
884
+ `project = CID AND issuetype = CI AND cf[13443] = "${systemCode}" AND status NOT IN (Done, Cancelled) ORDER BY updated DESC`,
885
+ ['summary', 'status'],
886
+ 1,
887
+ );
888
+ if (!ciIssues.length) {
889
+ return error(`找不到 ${systemCode} 進行中的 CI 單`);
890
+ }
891
+ ciIssueKey = ciIssues[0].key;
892
+ }
893
+
894
+ // Step 2: 讀 CI 單的 issuelinks(取 Library + 關聯 CI)
895
+ const ciFields = await jira.getIssueFields(ciIssueKey, [
896
+ 'summary',
897
+ 'status',
898
+ 'issuelinks',
899
+ ]);
900
+
901
+ const allLinks = ciFields.issuelinks ?? [];
902
+
903
+ // 關聯 Library 單(Relates,issuetype = Library)
904
+ const libraryLinks = allLinks.filter((l) => {
905
+ const linked = l.inwardIssue ?? l.outwardIssue;
906
+ return linked?.fields?.issuetype?.name === 'Library';
907
+ });
908
+
909
+ // 關聯 CI 單(Relates,issuetype = CI,排除自己)
910
+ const relatedCiLinks = allLinks.filter((l) => {
911
+ const linked = l.inwardIssue ?? l.outwardIssue;
912
+ return (
913
+ linked?.fields?.issuetype?.name === 'CI' &&
914
+ l.type?.name === 'Relates' &&
915
+ linked.key !== ciIssueKey
916
+ );
917
+ });
918
+
919
+ // Step 3: 批次查每個 Library 單的 gitBranch
920
+ const libraryKeys = libraryLinks.map((l) => (l.inwardIssue ?? l.outwardIssue).key);
921
+ const libraryExtraFields = await Promise.all(
922
+ libraryKeys.map((key) =>
923
+ jira.getIssueFields(key, ['customfield_13431', 'customfield_13444']).catch(() => ({})),
924
+ ),
925
+ );
926
+
927
+ const libraries = libraryLinks.map((l, i) => {
928
+ const linked = l.inwardIssue ?? l.outwardIssue;
929
+ const gitBranch = libraryExtraFields[i]?.customfield_13431 ?? null;
930
+ const moduleField = libraryExtraFields[i]?.customfield_13444;
931
+ const moduleName =
932
+ (Array.isArray(moduleField) ? moduleField[0]?.value : moduleField?.value) ??
933
+ linked.fields.summary ?? linked.key;
934
+ return {
935
+ key: linked.key,
936
+ module: moduleName,
937
+ gitBranch,
938
+ status: linked.fields.status?.name ?? '',
939
+ summary: linked.fields.summary ?? '',
940
+ };
941
+ });
942
+
943
+ const relatedCiKeys = relatedCiLinks.map((l) => (l.inwardIssue ?? l.outwardIssue).key);
944
+
945
+ // Step 4: 執行 Cancel transition
946
+ const transitions = await jira.getTransitions(ciIssueKey);
947
+ const cancelT = transitions.find((t) => t.name.toLowerCase() === 'cancelled');
948
+ if (!cancelT) {
949
+ const available = transitions.map((t) => t.name).join(', ');
950
+ return error(
951
+ `${ciIssueKey} 找不到 Cancelled transition,目前狀態:${ciFields.status?.name},可用:${available}`,
952
+ );
953
+ }
954
+ await jira.transitionById(ciIssueKey, cancelT.id);
955
+ await notifier.notify(ciIssueKey, `CI 單已取消(Release Reroll 流程啟動)`);
956
+
957
+ return ok({
958
+ cancelledCiKey: ciIssueKey,
959
+ relatedCiKeys,
960
+ libraries,
961
+ message: `✅ ${ciIssueKey} 已取消,找到 ${libraries.length} 個 Library 模組`,
962
+ });
963
+ } catch (err) {
964
+ return error(`cancel_release 失敗: ${err.message}`);
965
+ }
966
+ }
967
+
968
+ case 'get_release_status': {
969
+ const {systemCode} = args;
970
+
971
+ // 狀態 → 說明 + 建議下一步
972
+ const describeStatus = (issueType, status) => {
973
+ const s = status.toLowerCase();
974
+ if (issueType === 'Library') {
975
+ if (s === 'to do') return {emoji: '⏳', desc: '尚未開始', next: `執行 build_ticket`};
976
+ if (s === 'upload lib report') return {emoji: '⏳', desc: '等待上傳 Lib Report', next: `執行 build_ticket`};
977
+ if (s === 'wait approval') return {emoji: '⏳', desc: '等待主管核准', next: `wait_for_approval`};
978
+ if (s === 'wait for lib build') return {emoji: '🔨', desc: '等待 Jenkins Build', next: `執行 build_ticket`};
979
+ if (s === 'released') return {emoji: '✅', desc: '已完成', next: '可開 CI'};
980
+ if (s === 'cancelled') return {emoji: '❌', desc: '已取消', next: null};
981
+ return {emoji: '🔄', desc: status, next: null};
982
+ }
983
+ if (issueType === 'CI') {
984
+ if (s === 'to do') return {emoji: '⏳', desc: '尚未開始', next: '執行 build_ticket'};
985
+ if (s === 'wait for build') return {emoji: '⏳', desc: '等待 Jenkins Build', next: '執行 build_ticket'};
986
+ if (s === 'compliance scan') return {emoji: '🔍', desc: '掃描中', next: '執行 wait_to_stg'};
987
+ if (s === 'upload report') return {emoji: '📋', desc: '上傳掃描報告', next: '執行 wait_to_stg'};
988
+ if (s === 'wait to dev') return {emoji: '🔄', desc: 'Build 完成,等待 DEV', next: '執行 wait_to_stg'};
989
+ if (s === 'wait to stg') return {emoji: '✅', desc: 'Build 完成,等待 STG 部署', next: '可建 CD(STG) 並執行 prepare_cd_deployment'};
990
+ if (s === 'wait to uat') return {emoji: '✅', desc: 'STG 完成,等待 UAT', next: '可建 CD(UAT)'};
991
+ if (s === 'wait for upload') return {emoji: '📦', desc: '等待上傳 Pre-Release', next: null};
992
+ if (s === 'wait to prd/dr') return {emoji: '✅', desc: 'UAT 完成,等待 PRD/DR', next: '可建 CD(PRD/DR)'};
993
+ if (s === 'done') return {emoji: '✅', desc: '已完成', next: null};
994
+ if (s === 'cancelled') return {emoji: '❌', desc: '已取消', next: null};
995
+ return {emoji: '🔄', desc: status, next: null};
996
+ }
997
+ if (issueType === 'CD') {
998
+ if (s === 'to do') return {emoji: '⏳', desc: '尚未開始', next: '執行 prepare_cd_deployment'};
999
+ if (s === 'wait for send notice email') return {emoji: '⏳', desc: '等待通知信', next: '執行 prepare_cd_deployment'};
1000
+ if (s === 'prepare for deploy') return {emoji: '⏳', desc: '部署單建立中', next: null};
1001
+ if (s === 'wait approval') return {emoji: '⏳', desc: '等待主管核准', next: 'wait_for_approval'};
1002
+ if (s === 'wait deploy') return {emoji: '✅', desc: '已核准,等待部署', next: '執行 trigger_deployment'};
1003
+ if (s === 'deploying') return {emoji: '🚀', desc: '部署中', next: null};
1004
+ if (s === 'done') return {emoji: '✅', desc: '部署完成', next: null};
1005
+ if (s === 'cancelled') return {emoji: '❌', desc: '已取消', next: null};
1006
+ return {emoji: '🔄', desc: status, next: null};
1007
+ }
1008
+ return {emoji: '🔄', desc: status, next: null};
1009
+ };
1010
+
1011
+ try {
1012
+ // Step 1: 查最新 CI 單
1013
+ const ciIssues = await jira.searchIssues(
1014
+ `project = CID AND issuetype = CI AND cf[13443] = "${systemCode}" ORDER BY updated DESC`,
1015
+ ['summary', 'status', 'issuelinks', 'customfield_13438'],
1016
+ 1,
1017
+ );
1018
+
1019
+ if (!ciIssues.length) {
1020
+ return error(`找不到 ${systemCode} 的 CI 單`);
1021
+ }
1022
+
1023
+ const ciIssue = ciIssues[0];
1024
+ const ciStatus = describeStatus('CI', ciIssue.fields.status.name);
1025
+ const ciVersion = ciIssue.fields.customfield_13438
1026
+ ? (() => { try { return JSON.stringify(JSON.parse(ciIssue.fields.customfield_13438)); } catch { return ciIssue.fields.customfield_13438; } })()
1027
+ : '(尚無版本)';
1028
+
1029
+ // Step 2: 從 CI issuelinks 找 Library 單(Relates),並平行查 gitBranch
1030
+ const libraryLinks = (ciIssue.fields.issuelinks ?? []).filter((l) => {
1031
+ const linked = l.inwardIssue ?? l.outwardIssue;
1032
+ return linked?.fields?.issuetype?.name === 'Library';
1033
+ });
1034
+
1035
+ const libraryExtraFields = await Promise.all(
1036
+ libraryLinks.map((l) => {
1037
+ const linked = l.inwardIssue ?? l.outwardIssue;
1038
+ return jira
1039
+ .getIssueFields(linked.key, ['customfield_13431'])
1040
+ .catch(() => ({}));
1041
+ }),
1042
+ );
1043
+
1044
+ const libraryInfos = libraryLinks.map((l, i) => {
1045
+ const linked = l.inwardIssue ?? l.outwardIssue;
1046
+ const d = describeStatus('Library', linked.fields.status?.name ?? '');
1047
+ const gitBranch = libraryExtraFields[i]?.customfield_13431 ?? '(無)';
1048
+ return ` ${linked.key} [${linked.fields.status?.name}] ${d.emoji} ${d.desc}${
1049
+ d.next ? ` → ${d.next}(${linked.key})` : ''
1050
+ }\n branch: ${gitBranch}\n ${linked.fields.summary ?? ''}`;
1051
+ });
1052
+
1053
+ // Step 3: 查近期 CD 單(取 5 筆)
1054
+ const cdIssues = await jira.searchIssues(
1055
+ `project = CID AND issuetype = CD AND cf[13443] = "${systemCode}" ORDER BY updated DESC`,
1056
+ ['summary', 'status', 'customfield_13436', 'customfield_14101', 'updated'],
1057
+ 5,
1058
+ );
1059
+
1060
+ // 依環境分組(取每個環境最新一筆)
1061
+ const cdByEnv = {};
1062
+ for (const cd of cdIssues) {
1063
+ const envVal = (cd.fields?.customfield_13436?.value ?? 'unknown').toLowerCase();
1064
+ if (!cdByEnv[envVal]) cdByEnv[envVal] = cd;
1065
+ }
1066
+
1067
+ const cdLines = Object.entries(cdByEnv).map(([env, cd]) => {
1068
+ const d = describeStatus('CD', cd.fields.status.name);
1069
+ return ` ${env.toUpperCase()}:${cd.key} [${cd.fields.status.name}] ${d.emoji} ${d.desc}${
1070
+ d.next ? ` → ${d.next}` : ''
1071
+ }`;
1072
+ });
1073
+
1074
+ // Step 4: 組合輸出
1075
+ const today = new Date().toISOString().slice(0, 10);
1076
+ const lines = [
1077
+ `📦 ${systemCode} Release 現況(${today})`,
1078
+ '',
1079
+ '── CI ──────────────────────────────',
1080
+ ` ${ciIssue.key} [${ciIssue.fields.status.name}] ${ciStatus.emoji} ${ciStatus.desc}${
1081
+ ciStatus.next ? ` → ${ciStatus.next}` : ''
1082
+ }`,
1083
+ ` 版本:${ciVersion}`,
1084
+ '',
1085
+ '── Library(本次 CI 關聯)────────────',
1086
+ ...(libraryInfos.length ? libraryInfos : [' (無關聯 Library 單)']),
1087
+ '',
1088
+ '── CD ──────────────────────────────',
1089
+ ...(cdLines.length ? cdLines : [' (尚無 CD 單)']),
1090
+ ];
1091
+
1092
+ return ok({summary: lines.join('\n'), ci: ciIssue.key, version: ciVersion});
1093
+ } catch (err) {
1094
+ return error(`get_release_status 失敗: ${err.message}`);
1095
+ }
1096
+ }
1097
+
1098
+ case 'get_next_lib_version':
1099
+ return handleGetNextLibVersion(args, {jira});
1100
+
1101
+ case 'get_next_ci_version':
1102
+ return handleGetNextCIVersion(args, {jira});
1103
+
1104
+ default:
1105
+ throw new Error(`Unknown tool: ${name}`);
1106
+ }
1107
+ }
1108
+
1109
+ function ok(data) {
1110
+ return {
1111
+ content: [{type: 'text', text: JSON.stringify(data, null, 2)}],
1112
+ };
1113
+ }
1114
+
1115
+ function error(msg) {
1116
+ return {
1117
+ content: [{type: 'text', text: `❌ 錯誤: ${msg}`}],
1118
+ };
1119
+ }