@jira-deploy/core 1.0.14 → 1.0.16

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/cd.js CHANGED
@@ -1,27 +1,47 @@
1
1
  /**
2
- * CI Golden Image 相關 tools
2
+ * CD 相關 tools
3
3
  * - create_cd_ticket
4
4
  */
5
- import {error, getModuleName, getServerList, ok, today} from './helpers.js';
5
+ import {Poller} from '../poller.js';
6
+ import {ensureCIReadyForCD} from './ci.js';
7
+ import {handleSendJabberMessage} from './jabber.js';
8
+ import {handleGetReleaseManager, handleWaitForComment} from './release.js';
9
+ import {error, getModuleName, getPollIntervalMs, getPollTimeoutMs, getServerList, ok, today} from './helpers.js';
6
10
  import {
7
11
  CD_FIELD_IDS,
8
12
  CI_FIELD_IDS,
9
- SERVERS,
10
13
  DEPT_CODES,
11
14
  ENV_CODES,
12
15
  FIELD_OPTIONS,
16
+ getDeployConfig,
13
17
  ISSUE_TYPE_IDS,
14
18
  JIRA_PROJECT_ID,
15
19
  LIBRARY_MODULE_IDS,
20
+ MODULE_MAP,
21
+ REPO_LABEL_MAP,
22
+ REPO_MAPS,
23
+ resolveAccountId,
16
24
  SYSTEM_CODES,
17
25
  SYSTEM_MODULES,
18
26
  SYSTEM_TO_DEPT_MAP,
19
- MODULE_MAP,
20
- REPO_MAPS,
21
- REPO_LABEL_MAP,
22
27
  } from '../constants/index.js';
23
- import {SERVER_MODULE_MAP} from '../constants/server.js';
24
28
 
29
+ // ── Flow Definition ──────────────────────────────────────────────
30
+
31
+ /**
32
+ * CD 狀態流程定義
33
+ *
34
+ * 完整流程:
35
+ * TO DO → (Accept) → PREPARE FOR DEPLOY → (Prepare to create deployment ticket) 自動開立 Deployment 單
36
+ * → PREPARE FOR DEPLOY → (Apply for approval) → WAIT APPROVAL → (Approved,依環境處理簽核) → WAIT FOR SEND NOTICE EMAIL
37
+ * → (To Wait Deploy) → Wait Deploy → (Apply for close) → Wait APPROVAL TO CLOSED → (To Done) → Done
38
+ *
39
+ * 簽核規則:
40
+ * - DEV:直接 Approve
41
+ * - STG:指派給當日 Release Manager,等待簽核
42
+ * - UAT:依 env/config 指派第一階段與最終簽核人
43
+ * - PRD:依 env/config 指派第一~三階段與最終簽核人
44
+ */
25
45
  // ── Schema definitions ───────────────────────────────────────────
26
46
  export function getCDToolDefinitions() {
27
47
  return [
@@ -48,11 +68,6 @@ export function getCDToolDefinitions() {
48
68
  type: 'boolean',
49
69
  description: '(選填) 是否要集群部署,預設為 true',
50
70
  },
51
- summary: {
52
- type: 'string',
53
- description:
54
- '(選填)自訂標題,不填則自動生成:[IBK][STG] 程式上版作業申請單_YYYYMMDD (CD deployment with {release_version})',
55
- },
56
71
  ciTicket: {
57
72
  type: 'string',
58
73
  description:
@@ -68,15 +83,11 @@ export function getCDToolDefinitions() {
68
83
  },
69
84
  moduleChild: {
70
85
  type: 'string',
71
- description: '(選填) 模組 child 名稱;不填時預設同 systemCode 小寫',
86
+ description: '(選填) 模組 child 名稱',
72
87
  },
73
88
  restartOnly: {
74
89
  type: 'boolean',
75
- description: '(選填) 是否只重啟不部署',
76
- },
77
- extraVars: {
78
- type: 'string',
79
- description: '(選填) 自訂部署 extra vars JSON 字串;不填時依 system/module/env 自動生成',
90
+ description: '(選填) 是否只重啟不部署,只跑 before after script,預設為 false',
80
91
  },
81
92
  metaTest: {
82
93
  type: 'boolean',
@@ -97,18 +108,13 @@ export function getCDToolDefinitions() {
97
108
  /**
98
109
  * 建立 CD 上版單
99
110
  */
100
- export async function handleCreateCDTicket(args, {jira, notifier}) {
111
+ export async function handleCreateCDTicket(args, {jira, notifier, progress: reportProgress}) {
101
112
  try {
102
- const envCode = (args.environment ?? '').toLowerCase();
113
+ const envCode = (args.environment ?? 'STG').toLowerCase();
103
114
  const ciTicket = args.ciTicket ?? args.linkedCiKey;
104
- const clusterDeploy = typeof args.clusterDeploy === 'string'
105
- ? args.clusterDeploy.split(',').map((cluster) => cluster.trim()).filter(Boolean)
106
- : null;
107
- const isClusterDeploy = args.isClusterDeploy ?? (envCode !== 'dev');
108
115
  const normalizedArgs = {
109
116
  ...args,
110
117
  ciTicket,
111
- isClusterDeploy,
112
118
  environment: envCode,
113
119
  };
114
120
 
@@ -121,30 +127,34 @@ export async function handleCreateCDTicket(args, {jira, notifier}) {
121
127
  });
122
128
  }
123
129
 
124
- // extra_vars 參數邏輯:只對 ibk/cwa prd 環境生效
125
- const serverList = clusterDeploy ?? getServerList(
126
- normalizedArgs.systemCode,
127
- normalizedArgs.environment,
128
- normalizedArgs.metaTest,
129
- );
130
+ const moduleResolution = await resolveCDModules(normalizedArgs, {jira});
131
+ if (moduleResolution.needsModuleSelection) return ok(moduleResolution);
132
+
133
+ if (!normalizedArgs.dryRun && normalizedArgs.ciTicket) {
134
+ await ensureCIReadyForCD(
135
+ {issueKey: normalizedArgs.ciTicket, environment: envCode},
136
+ {jira, notifier, progress: reportProgress},
137
+ );
138
+ }
139
+
140
+ const serverList = getModuleServerList(normalizedArgs, moduleResolution.modules);
130
141
 
131
142
  let goldenImageVersion = '';
132
143
 
133
- // 先取 CI 單的 deploy_version JSON(customfield_13438,如 {"cwa_ap_version":"0.0.12"})
144
+ // 先取 CI 單的 releaseVersion JSON(customfield_13438,如 {"cwa_ap_version":"0.0.12"})
134
145
  let ciReleaseVersion = '';
135
146
  if (normalizedArgs.ciTicket) {
136
147
  try {
137
148
  const ciFields = await jira.getIssueFields(normalizedArgs.ciTicket, [CI_FIELD_IDS.releaseVersion]);
138
149
  ciReleaseVersion = ciFields?.[CI_FIELD_IDS.releaseVersion] ?? '';
139
- if (!ciReleaseVersion) {
140
- const legacyFields = await jira.getIssueFields(normalizedArgs.ciTicket, ['customfield_14705']);
141
- ciReleaseVersion = legacyFields?.customfield_14705 ?? '';
142
- }
150
+
151
+ // 如果找不到 CI 單的 releaseVersion,用 CI 單的 summary 抓 golden image version
143
152
  if (!ciReleaseVersion) {
144
153
  const summaryFields = await jira.getIssueFields(normalizedArgs.ciTicket, ['summary']);
145
154
  const summaryMatch = summaryFields?.summary?.match(/\bfor\s+([0-9]+(?:\.[0-9]+){2,})\b/i);
146
155
  ciReleaseVersion = summaryMatch?.[1] ?? '';
147
156
  }
157
+
148
158
  if (ciReleaseVersion) {
149
159
  try {
150
160
  const parsed = JSON.parse(ciReleaseVersion);
@@ -163,14 +173,14 @@ export async function handleCreateCDTicket(args, {jira, notifier}) {
163
173
  const dateStr = today().replace(/-/g, '');
164
174
  const displayEnv =
165
175
  normalizedArgs.metaTest ? 'prd' : (envCode === 'prd' || envCode === 'prd/dr') ? 'prd/dr' : envCode;
166
- const summaryVersion = goldenImageVersion || normalizedArgs.ciTicket || '';
176
+ const summaryVersion = goldenImageVersion || '';
167
177
  const autoSummary = `[${normalizedArgs.systemCode}][${displayEnv.toUpperCase()}] 程式上版作業申請單_${dateStr} (CD deployment with ${summaryVersion})`;
168
178
 
169
179
  const fields = {
170
180
  project: {key: JIRA_PROJECT_ID},
171
181
  issuetype: {id: ISSUE_TYPE_IDS.CD},
172
182
  duedate: today(),
173
- summary: normalizedArgs.summary ?? autoSummary,
183
+ summary: autoSummary,
174
184
  };
175
185
 
176
186
  // CID_deploy_version:deploy JSON 優先,否則 release_version
@@ -202,19 +212,19 @@ export async function handleCreateCDTicket(args, {jira, notifier}) {
202
212
  fields[CD_FIELD_IDS.serverList] = serverList.map((c) => c.trim()).join('\n');
203
213
 
204
214
  // CID_restart_only(option id 格式)
205
- if (normalizedArgs.restartOnly !== undefined) {
206
- fields[CD_FIELD_IDS.restartOnly] = {
207
- id: normalizedArgs.restartOnly
208
- ? FIELD_OPTIONS.restartOnly.true
209
- : FIELD_OPTIONS.restartOnly.false,
210
- };
211
- }
215
+ fields[CD_FIELD_IDS.restartOnly] = {
216
+ id: normalizedArgs.restartOnly
217
+ ? FIELD_OPTIONS.restartOnly.true
218
+ : FIELD_OPTIONS.restartOnly.false,
219
+ };
212
220
 
213
- fields[CD_FIELD_IDS.extraVars] = normalizedArgs.extraVars
214
- ?? await getExtraVarsJson({serverList, ...normalizedArgs}, {jira});
221
+ fields[CD_FIELD_IDS.extraVars] = await getExtraVarsJson({
222
+ modules: moduleResolution.modules,
223
+ ...normalizedArgs,
224
+ }, {jira});
215
225
 
216
226
  // 預先生成 description(dryRun 預覽用,正式開單後也會再呼叫 updateIssue 寫入)
217
- let previewDescription = generateCDDescription(ciReleaseVersion || '');
227
+ let previewDescription = generateCDDescription(goldenImageVersion || ciReleaseVersion);
218
228
  if (normalizedArgs.ciTicket) {
219
229
  const releaseNotesStr = await generateReleaseNotes(
220
230
  jira,
@@ -251,7 +261,7 @@ export async function handleCreateCDTicket(args, {jira, notifier}) {
251
261
  // 更新 description 表格,因為 cid jira worker 會將 init 的表格回朔
252
262
  if (issue.key) {
253
263
  // version = ciReleaseVersion(CI golden image version,例如 0.0.11)
254
- let cdDescription = generateCDDescription(ciReleaseVersion || '');
264
+ let cdDescription = generateCDDescription(goldenImageVersion || ciReleaseVersion);
255
265
 
256
266
  // 生成 release notes Bitbucket compare URL
257
267
  if (normalizedArgs.ciTicket) {
@@ -293,82 +303,440 @@ export async function handleCreateCDTicket(args, {jira, notifier}) {
293
303
  }
294
304
  }
295
305
 
306
+ export async function handleCDApproval(issueKey, environment, systemCode, ctx) {
307
+ const env = String(environment ?? '').toLowerCase().replace('&', '/');
308
+
309
+ if (env === 'dev') {
310
+ return {skipped: true, reason: 'DEV 環境使用自助 Approved 流程'};
311
+ }
312
+
313
+ if (env === 'stg') {
314
+ return handleCDStgApproval(issueKey, systemCode, ctx);
315
+ }
316
+
317
+ if (env === 'uat') {
318
+ return handleCDUatApproval(issueKey, systemCode, ctx);
319
+ }
320
+
321
+ if (isPrdLikeEnv(env)) {
322
+ return handleCDPrdApproval(issueKey, env, systemCode, ctx);
323
+ }
324
+
325
+ throw new Error(`不支援的 CD 簽核環境: ${environment}`);
326
+ }
327
+
328
+ async function handleCDStgApproval(issueKey, systemCode, ctx) {
329
+ const {jira, notifier} = ctx;
330
+ const managerResult = await handleGetReleaseManager();
331
+ const managerData = parseToolResult(managerResult);
332
+
333
+ if (!managerData?.found) {
334
+ throw new Error('無法查詢 STG 值班組長,請手動處理');
335
+ }
336
+
337
+ const managerName = managerData.name;
338
+ const accountId = resolveRequiredAccountId(managerName, 'STG 值班組長');
339
+
340
+ progress(ctx, {
341
+ phase: 'action',
342
+ title: '指派 CD 簽核人',
343
+ detail: managerName,
344
+ issueKey,
345
+ });
346
+ await jira.updateAssignee(issueKey, accountId);
347
+ await notifier.notify(issueKey, `已指派給 STG 值班組長 ${managerName}`);
348
+
349
+ const substituteMessage = managerData.substituted
350
+ ? `\n原值班組長 ${managerData.originalName} 今日請假(${managerData.leaveReason ?? '無請假說明'}),請 ${managerName} 協助簽單。`
351
+ : '';
352
+ await sendCDApprovalJabber(ctx, {
353
+ issueKey,
354
+ alias: managerName,
355
+ accountId,
356
+ message: `[CD 簽核通知] ${issueKey} 需要您的簽核。環境: STG,系統: ${systemCode}${substituteMessage}\n${getIssueUrl(issueKey)}`,
357
+ });
358
+
359
+ await waitForPostApprovalStatus(issueKey, ctx);
360
+
361
+ return {approved: true, by: managerName, substituted: Boolean(managerData.substituted)};
362
+ }
363
+
364
+ async function handleCDUatApproval(issueKey, systemCode, ctx) {
365
+ const {jira, notifier} = ctx;
366
+ const approvers = getDeployConfig().release.grayReleaseUatApprovers ?? {};
367
+ const commentReviewerAlias = approvers.commentReviewerAlias;
368
+ const finalApproverAlias = approvers.finalApproverAlias;
369
+ const commentReviewerAccountId = resolveRequiredAccountId(
370
+ commentReviewerAlias,
371
+ 'UAT 第一階段簽核人 release.grayReleaseUatApprovers.commentReviewerAlias',
372
+ );
373
+
374
+ await assignAndNotify(ctx, {
375
+ issueKey,
376
+ alias: commentReviewerAlias,
377
+ accountId: commentReviewerAccountId,
378
+ assigneeMessage: `已指派給 ${commentReviewerAlias},等待 approved 留言確認`,
379
+ jabberMessage: `[CD 簽核通知] ${issueKey} 需要您的簽核並留言 approved。環境: UAT,系統: ${systemCode}\n${getIssueUrl(issueKey)}`,
380
+ });
381
+
382
+ const commentData = await waitForCommentWithKeyword(ctx, {
383
+ issueKey,
384
+ alias: commentReviewerAlias,
385
+ accountId: commentReviewerAccountId,
386
+ keyword: 'approved',
387
+ });
388
+ await notifier.notify(issueKey, `${commentReviewerAlias} 已留言確認: ${commentData.comment}`);
389
+
390
+ const finalApproverAccountId = resolveRequiredAccountId(
391
+ finalApproverAlias,
392
+ 'UAT 最終簽核人 release.grayReleaseUatApprovers.finalApproverAlias',
393
+ );
394
+ await assignAndNotify(ctx, {
395
+ issueKey,
396
+ alias: finalApproverAlias,
397
+ accountId: finalApproverAccountId,
398
+ assigneeMessage: `已轉單給 ${finalApproverAlias},等待最終簽核`,
399
+ jabberMessage: `[CD 簽核通知] ${issueKey} 已由 ${commentReviewerAlias} 留言確認,需要您的最終簽核。環境: UAT,系統: ${systemCode}\n${getIssueUrl(issueKey)}`,
400
+ });
401
+
402
+ await waitForPostApprovalStatus(issueKey, ctx);
403
+
404
+ return {approved: true, by: `${commentReviewerAlias} → ${finalApproverAlias}`};
405
+ }
406
+
407
+ async function handleCDPrdApproval(issueKey, env, systemCode, ctx) {
408
+ const {notifier} = ctx;
409
+ const approvers = getDeployConfig().release.cdApprovers?.prd ?? {};
410
+ const leadReviewerAliases = approvers.leadReviewerAliases ?? [];
411
+ if (leadReviewerAliases.length < 2) {
412
+ throw new Error('缺少 release.cdApprovers.prd.leadReviewerAliases,PRD CD 需要兩位組長');
413
+ }
414
+
415
+ const leadReviewers = leadReviewerAliases.map((alias) => ({
416
+ alias,
417
+ accountId: resolveRequiredAccountId(alias, 'PRD 組長簽核人 release.cdApprovers.prd.leadReviewerAliases'),
418
+ }));
419
+
420
+ for (const reviewer of leadReviewers) {
421
+ await sendCDApprovalJabber(ctx, {
422
+ issueKey,
423
+ alias: reviewer.alias,
424
+ accountId: reviewer.accountId,
425
+ message: `[CD PRD 簽核通知] ${issueKey} 需要您的留言確認。環境: ${env.toUpperCase()},系統: ${systemCode}\n${getIssueUrl(issueKey)}`,
426
+ });
427
+ }
428
+
429
+ for (const reviewer of leadReviewers) {
430
+ const commentData = await waitForAnyCommentByAuthor(ctx, {
431
+ issueKey,
432
+ alias: reviewer.alias,
433
+ accountId: reviewer.accountId,
434
+ });
435
+ await notifier.notify(issueKey, `${reviewer.alias} 已留言確認: ${commentData.comment}`);
436
+ }
437
+
438
+ const secondReviewerAlias = approvers.secondReviewerAlias;
439
+ const thirdReviewerAlias = approvers.thirdReviewerAlias;
440
+ const finalApproverAlias = approvers.finalApproverAlias;
441
+
442
+ const secondReviewerAccountId = resolveRequiredAccountId(
443
+ secondReviewerAlias,
444
+ 'PRD 第二階段簽核人 release.cdApprovers.prd.secondReviewerAlias',
445
+ );
446
+ await assignAndNotify(ctx, {
447
+ issueKey,
448
+ alias: secondReviewerAlias,
449
+ accountId: secondReviewerAccountId,
450
+ assigneeMessage: `已轉單給 ${secondReviewerAlias},等待 approved 留言確認`,
451
+ jabberMessage: `[CD PRD 簽核通知] ${issueKey} 兩位組長已留言確認,需要您的 approved 留言。環境: ${env.toUpperCase()},系統: ${systemCode}\n${getIssueUrl(issueKey)}`,
452
+ });
453
+ const secondCommentData = await waitForCommentWithKeyword(ctx, {
454
+ issueKey,
455
+ alias: secondReviewerAlias,
456
+ accountId: secondReviewerAccountId,
457
+ keyword: 'approved',
458
+ });
459
+ await notifier.notify(issueKey, `${secondReviewerAlias} 已留言確認: ${secondCommentData.comment}`);
460
+
461
+ const thirdReviewerAccountId = resolveRequiredAccountId(
462
+ thirdReviewerAlias,
463
+ 'PRD 第三階段簽核人 release.cdApprovers.prd.thirdReviewerAlias',
464
+ );
465
+ await assignAndNotify(ctx, {
466
+ issueKey,
467
+ alias: thirdReviewerAlias,
468
+ accountId: thirdReviewerAccountId,
469
+ assigneeMessage: `已轉單給 ${thirdReviewerAlias},等待 approved 留言確認`,
470
+ jabberMessage: `[CD PRD 簽核通知] ${issueKey} 已由 ${secondReviewerAlias} 留言確認,需要您的 approved 留言。環境: ${env.toUpperCase()},系統: ${systemCode}\n${getIssueUrl(issueKey)}`,
471
+ });
472
+ const thirdCommentData = await waitForCommentWithKeyword(ctx, {
473
+ issueKey,
474
+ alias: thirdReviewerAlias,
475
+ accountId: thirdReviewerAccountId,
476
+ keyword: 'approved',
477
+ });
478
+ await notifier.notify(issueKey, `${thirdReviewerAlias} 已留言確認: ${thirdCommentData.comment}`);
479
+
480
+ const finalApproverAccountId = resolveRequiredAccountId(
481
+ finalApproverAlias,
482
+ 'PRD 最終簽核人 release.cdApprovers.prd.finalApproverAlias',
483
+ );
484
+ await assignAndNotify(ctx, {
485
+ issueKey,
486
+ alias: finalApproverAlias,
487
+ accountId: finalApproverAccountId,
488
+ assigneeMessage: `已轉單給 ${finalApproverAlias},等待最終 Approved`,
489
+ jabberMessage: `[CD PRD 簽核通知] ${issueKey} 已由 ${thirdReviewerAlias} 留言確認,需要您的最終 Approved。環境: ${env.toUpperCase()},系統: ${systemCode}\n${getIssueUrl(issueKey)}`,
490
+ });
491
+
492
+ await waitForPostApprovalStatus(issueKey, ctx);
493
+
494
+ return {
495
+ approved: true,
496
+ by: `${leadReviewerAliases.join(' + ')} → ${secondReviewerAlias} → ${thirdReviewerAlias} → ${finalApproverAlias}`,
497
+ };
498
+ }
499
+
500
+ async function assignAndNotify(ctx, {issueKey, alias, accountId, assigneeMessage, jabberMessage}) {
501
+ const {jira, notifier} = ctx;
502
+ progress(ctx, {
503
+ phase: 'action',
504
+ title: '指派 CD 簽核人',
505
+ detail: alias,
506
+ issueKey,
507
+ });
508
+ await jira.updateAssignee(issueKey, accountId);
509
+ await notifier.notify(issueKey, assigneeMessage);
510
+ await sendCDApprovalJabber(ctx, {issueKey, alias, accountId, message: jabberMessage});
511
+ }
512
+
513
+ async function sendCDApprovalJabber(ctx, {issueKey, alias, accountId, message}) {
514
+ const jabberTo = getJabberJid(accountId);
515
+ progress(ctx, {
516
+ phase: 'waiting',
517
+ title: '發送 CD 簽核通知',
518
+ detail: `to ${alias} (${jabberTo})`,
519
+ issueKey,
520
+ });
521
+ const result = await handleSendJabberMessage({to: jabberTo, message}, {});
522
+ if (result.isError) {
523
+ throw new Error(result.content[0].text.replace(/^❌ 錯誤: /, ''));
524
+ }
525
+ }
526
+
527
+ async function waitForCommentWithKeyword(ctx, {issueKey, alias, accountId, keyword}) {
528
+ const result = await handleWaitForComment(
529
+ {
530
+ issueKey,
531
+ keyword,
532
+ authorAccountId: accountId,
533
+ intervalMs: getPollIntervalMs(),
534
+ timeoutMs: getPollTimeoutMs(),
535
+ },
536
+ {jira: ctx.jira, progress: ctx.progress},
537
+ );
538
+ const data = parseToolResult(result);
539
+ if (!data?.found) {
540
+ throw new Error(`等待 ${alias} 留言 ${keyword} 超時`);
541
+ }
542
+ return data;
543
+ }
544
+
545
+ async function waitForAnyCommentByAuthor(ctx, {issueKey, alias, accountId}) {
546
+ const {jira} = ctx;
547
+ const intervalMs = getPollIntervalMs();
548
+ const timeoutMs = getPollTimeoutMs();
549
+ const startedAt = Date.now();
550
+ let attempts = 0;
551
+
552
+ while (true) {
553
+ attempts++;
554
+ const comments = await jira.getComments(issueKey);
555
+ const elapsedMs = Date.now() - startedAt;
556
+ const match = comments.find((comment) => commentMatchesAuthor(comment, accountId));
557
+
558
+ progress(ctx, {
559
+ phase: 'polling',
560
+ title: '等待 Jira comment',
561
+ detail: `author=${accountId}`,
562
+ issueKey,
563
+ attempts,
564
+ elapsedMs,
565
+ timeoutMs,
566
+ nextPollMs: intervalMs,
567
+ });
568
+
569
+ if (match) {
570
+ return {
571
+ found: true,
572
+ issueKey,
573
+ author: match.author?.displayName ?? match.author?.name ?? '',
574
+ comment: (match.body ?? '').slice(0, 500),
575
+ attempts,
576
+ elapsedMs,
577
+ };
578
+ }
579
+
580
+ if (elapsedMs >= timeoutMs) {
581
+ throw new Error(`Timeout:等待 ${alias} 在 ${issueKey} 留言(${attempts} 次輪詢,${Math.round(elapsedMs / 1000)}s)`);
582
+ }
583
+
584
+ await sleep(intervalMs);
585
+ }
586
+ }
587
+
588
+ async function waitForPostApprovalStatus(issueKey, ctx) {
589
+ const poller = new Poller(ctx.jira);
590
+ return poller.waitForStatus(issueKey, 'WAIT FOR SEND NOTICE EMAIL', {
591
+ intervalMs: getPollIntervalMs(),
592
+ timeoutMs: getPollTimeoutMs(),
593
+ onProgress: ctx.progress,
594
+ });
595
+ }
596
+
597
+ function parseToolResult(result) {
598
+ if (result?.isError) {
599
+ throw new Error(result.content?.[0]?.text?.replace(/^❌ 錯誤: /, '') ?? 'tool execution failed');
600
+ }
601
+ const text = result?.content?.[0]?.text;
602
+ if (!text) return null;
603
+ return JSON.parse(text);
604
+ }
605
+
606
+ function resolveRequiredAccountId(alias, label) {
607
+ if (!alias) {
608
+ throw new Error(`缺少 ${label}`);
609
+ }
610
+ const accountId = resolveAccountId(alias);
611
+ if (!accountId) {
612
+ throw new Error(`找不到 ${label}「${alias}」的 accountId,請更新 users.aliases`);
613
+ }
614
+ return accountId;
615
+ }
616
+
617
+ function getJabberJid(accountId) {
618
+ const domain = process.env.JABBER_DOMAIN ?? getDeployConfig().jabber?.domain;
619
+ return domain ? `${accountId}@${domain}` : accountId;
620
+ }
621
+
622
+ function getIssueUrl(issueKey) {
623
+ return `${process.env.JIRA_BASE_URL}/browse/${issueKey}`;
624
+ }
625
+
626
+ function isPrdLikeEnv(env) {
627
+ return ['prd', 'dr', 'prd/dr'].includes(env);
628
+ }
629
+
630
+ function commentMatchesAuthor(comment, accountId) {
631
+ const author = comment.author ?? {};
632
+ return author.name === accountId || author.accountId === accountId;
633
+ }
634
+
635
+ function progress(ctx, event) {
636
+ if (typeof ctx.progress === 'function') {
637
+ ctx.progress(event);
638
+ }
639
+ }
640
+
641
+ function sleep(ms) {
642
+ return new Promise((resolve) => setTimeout(resolve, ms));
643
+ }
644
+
296
645
  /**
297
- * 計算下一個 Golden Image Release 版號
646
+ * 取得 extra var JSON 給 ansible playbook,格式為 {"ibk_cust_installation": {"server1": true, "server2": true}, ...}
298
647
  */
299
648
  export async function getExtraVarsJson(args, {jira}) {
300
649
  try {
301
- const {serverList, module} = args;
302
-
303
- let extraVarsJson = null;
304
- // ① 從 CI 關聯的 Library 票推導模組(只含本次實際部署的模組)
305
- let modules = null;
650
+ const modules = args.modules ?? (await resolveCDModules(args, {jira})).modules;
651
+ if (!modules?.length) throw new Error('缺少部署模組,無法產生 CID_extra_vars');
652
+ const sysLower = args.systemCode.toLowerCase();
306
653
 
307
- if (args.ciTicket) {
308
- try {
309
- const ciFields = await jira.getIssueFields(args.ciTicket, ['issuelinks']);
310
- const issueLinks = ciFields?.issuelinks ?? [];
311
- // 建立反查表:child module ID → module name(僅限本系統)
312
- const childIdToName = Object.fromEntries(
313
- Object.entries(LIBRARY_MODULE_IDS[args.systemCode] ?? {}).map(([name, id]) => [id, name]),
654
+ const vars = Object.fromEntries(
655
+ modules.map((module) => {
656
+ const moduleServerList = getServerList(
657
+ args.systemCode,
658
+ args.environment,
659
+ args.metaTest,
660
+ module,
314
661
  );
662
+ return [
663
+ `${sysLower}_${module}_installation`,
664
+ Object.fromEntries(moduleServerList.map((server) => [server, true])),
665
+ ];
666
+ }),
667
+ );
668
+ return JSON.stringify(vars);
669
+ } catch (e) {
670
+ throw new Error(`無法產生 CID_extra_vars: ${e.message}`);
671
+ }
672
+ }
315
673
 
316
- const detectedModules = [];
317
- for (const link of issueLinks) {
318
- const linked = link.inwardIssue ?? link.outwardIssue;
319
- if (linked?.fields?.issuetype?.name !== 'Library') continue;
320
- try {
321
- const libFields = await jira.getIssueFields(linked.key, ['customfield_13702']);
322
- const childId = libFields?.['customfield_13702']?.child?.id;
323
- if (childId && childIdToName[childId]) {
324
- detectedModules.push(childIdToName[childId]);
325
- }
326
- } catch (_) {
327
- /* skip this library */
328
- }
329
- }
330
- if (detectedModules.length > 0) modules = [...new Set(detectedModules)];
331
- } catch (_) {
332
- /* fall through */
333
- }
334
- }
674
+ // ── Private helpers ───────────────────────────────────────────────
335
675
 
336
- // fallback:CI 無關聯 Library 時使用全系統模組
337
- if (!modules) modules = SYSTEM_MODULES[args.systemCode] ?? [];
676
+ async function resolveCDModules(args, {jira}) {
677
+ const modulesFromCi = await getModulesFromCiLibraries(args, {jira});
678
+ if (modulesFromCi.length > 0) return {modules: modulesFromCi};
338
679
 
339
- const sysLower = args.systemCode.toLowerCase();
680
+ const modulesFromArgs = parseModuleChild(args.moduleChild);
681
+ if (modulesFromArgs.length > 0) return {modules: modulesFromArgs};
340
682
 
341
- const envServerList = SERVERS[args.systemCode]?.[args.environment];
683
+ return {
684
+ needsModuleSelection: true,
685
+ message: '請問要部署哪些模組?',
686
+ options: SYSTEM_MODULES[args.systemCode] ?? [],
687
+ systemCode: args.systemCode,
688
+ environment: args.environment,
689
+ ciTicket: args.ciTicket,
690
+ hint: '請指定 moduleChild 後再建立 CD 單;未明確指定模組時不會自動部署全模組。',
691
+ };
692
+ }
342
693
 
343
- // 只對 cluster flat array 的系統自動生成(EIB/EVT 結構較複雜,另外處理)
344
- const flatClusters = Array.isArray(envServerList) ? serverList : null;
694
+ async function getModulesFromCiLibraries(args, {jira}) {
695
+ if (!args.ciTicket) return [];
696
+ try {
697
+ const ciFields = await jira.getIssueFields(args.ciTicket, ['issuelinks']);
698
+ const issueLinks = ciFields?.issuelinks ?? [];
699
+ const childIdToName = Object.fromEntries(
700
+ Object.entries(LIBRARY_MODULE_IDS[args.systemCode] ?? {}).map(([name, id]) => [id, name]),
701
+ );
345
702
 
346
- if (flatClusters) {
347
- const serverMap = Object.fromEntries(flatClusters.map((s) => [s, true]));
348
- const vars = Object.fromEntries(
349
- modules.map((mod) => [`${sysLower}_${mod}_installation`, serverMap]),
350
- );
351
- extraVarsJson = JSON.stringify(vars);
352
- } else {
353
- // EVT & EIB 有分 web was 機器
354
- const serverType = modules.map((module) => SERVER_MODULE_MAP[module]);
355
- const vars = Object.fromEntries(
356
- modules.map((mod, i) => {
357
- const servers = envServerList[serverType[i]] ?? [];
358
- return [
359
- `${sysLower}_${mod}_installation`,
360
- Object.fromEntries(servers.map((s) => [s, true])),
361
- ];
362
- }),
363
- );
364
- extraVarsJson = JSON.stringify(vars);
703
+ const detectedModules = [];
704
+ for (const link of issueLinks) {
705
+ const linked = link.inwardIssue ?? link.outwardIssue;
706
+ if (linked?.fields?.issuetype?.name !== 'Library') continue;
707
+ try {
708
+ const libFields = await jira.getIssueFields(linked.key, ['customfield_13702']);
709
+ const childId = libFields?.['customfield_13702']?.child?.id;
710
+ if (childId && childIdToName[childId]) detectedModules.push(childIdToName[childId]);
711
+ } catch (_) {
712
+ /* skip this library */
713
+ }
365
714
  }
366
- return extraVarsJson;
367
- } catch (e) {
715
+ return [...new Set(detectedModules)];
716
+ } catch (_) {
717
+ return [];
368
718
  }
369
- } // end getExtraVarsJson
719
+ }
370
720
 
371
- // ── Private helpers ───────────────────────────────────────────────
721
+ function parseModuleChild(moduleChild) {
722
+ return String(moduleChild ?? '')
723
+ .split(',')
724
+ .map((module) => module.trim())
725
+ .filter(Boolean);
726
+ }
727
+
728
+ function getModuleServerList(args, modules) {
729
+ return [
730
+ ...new Set(
731
+ modules.flatMap((module) => getServerList(
732
+ args.systemCode,
733
+ args.environment,
734
+ args.metaTest,
735
+ module,
736
+ )),
737
+ ),
738
+ ];
739
+ }
372
740
 
373
741
  function generateCDDescription(version) {
374
742
  return `||一般資訊作業申請單||參考符號 ◼️◻️||
@@ -600,12 +968,8 @@ async function generateReleaseNotes(jira, ciKey, env, systemCode) {
600
968
  if (prevVersion) {
601
969
  targetBranch = `ci/${branchWithDashes}-${prevVersion}`;
602
970
  } else {
603
- // 找最新的 release/ 開頭 branch(非 tag)
604
- const branches = await jira.getBitbucketBranches(repoMeta.project, repoName, {
605
- filterValue: 'release/',
606
- });
607
- if (!branches?.length) continue;
608
- targetBranch = branches[0].displayId;
971
+ targetBranch = await findLatestReleaseBranchWithTag(jira, repoMeta, repoName);
972
+ if (!targetBranch) continue;
609
973
  }
610
974
  const baseUrl = process.env.BITBUCKET_URL || 'https://bitbucket.example.com';
611
975
  const compareUrl = `${baseUrl}/projects/${repoMeta.project}/repos/${repoName}/compare/diff?sourceBranch=${sourceBranch.replace(/\//g, '%2F')}&targetBranch=${targetBranch.replace(/\//g, '%2F')}&targetRepoId=${repoMeta.repoId}`;
@@ -620,6 +984,28 @@ async function generateReleaseNotes(jira, ciKey, env, systemCode) {
620
984
  }
621
985
  }
622
986
 
987
+ async function findLatestReleaseBranchWithTag(jira, repoMeta, repoName) {
988
+ const tags = await jira.getBitbucketTags(repoMeta.project, repoName, {
989
+ filterValue: 'release/',
990
+ orderBy: 'MODIFICATION',
991
+ limit: 1,
992
+ });
993
+ const tagName = getBitbucketRefName(tags?.[0]);
994
+ if (!tagName.startsWith('release/')) return '';
995
+
996
+ const branches = await jira.getBitbucketBranches(repoMeta.project, repoName, {
997
+ filterValue: tagName,
998
+ orderBy: 'MODIFICATION',
999
+ limit: 1,
1000
+ });
1001
+ const matchedBranch = branches?.find((branch) => getBitbucketRefName(branch) === tagName);
1002
+ return matchedBranch ? tagName : '';
1003
+ }
1004
+
1005
+ function getBitbucketRefName(ref) {
1006
+ return String(ref?.displayId ?? ref?.id ?? '').replace(/^refs\/(heads|tags)\//, '');
1007
+ }
1008
+
623
1009
  /**
624
1010
  * 自動從 CI 單的 relates Library 單取 CID_branch,比對 LBPRJ 版本頁,每個 Library 單加一筆 Web Link
625
1011
  */