@jira-deploy/core 1.0.3 → 1.0.4

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.
@@ -2,9 +2,10 @@
2
2
  * Gray Release 相關 tools
3
3
  * - create_grayrelease_ticket
4
4
  * - link_stg_grayrelease
5
- * - auto_grayrelease (NEW)
6
- * - get_grayrelease_status (NEW)
7
- * - continue_grayrelease (NEW)
5
+ * - auto_grayrelease
6
+ * - deploy_grayrelease
7
+ * - get_grayrelease_status
8
+ * - continue_grayrelease
8
9
  */
9
10
  import {
10
11
  SYSTEM_CODES,
@@ -22,16 +23,33 @@ import {
22
23
  JIRA_DEFAULTS,
23
24
  resolveAccountId,
24
25
  } from '../constants/index.js';
25
- import {error, ok, today, getServerList} from './helpers.js';
26
- import {Poller} from '../poller.js';
27
- import {handleGetReleaseManager, handleWaitForComment} from './release.js';
28
- import {handleSendJabberMessage} from './jabber.js';
26
+ import { error, ok, today, getServerList } from './helpers.js';
27
+ import { Poller } from '../poller.js';
28
+ import { handleGetReleaseManager, handleWaitForComment } from './release.js';
29
+ import { handleSendJabberMessage } from './jabber.js';
29
30
 
30
31
  // ── Flow Definition ──────────────────────────────────────────────
31
32
 
32
33
  /**
33
34
  * GrayRelease 狀態流程定義
34
35
  * 每個狀態定義其可能的下一步 transitions
36
+ *
37
+ * 完整流程:
38
+ * PLANNING → (Accept) → WAIT FOR BUILD → (GrayRelease Build)
39
+ * → WAIT FOR BUILD → (Apply to approval) → WAIT APPROVAL
40
+ * → (Approve,依環境處理簽核) → WAIT DEPLOY
41
+ * → (Switch Execution Node,視上次 CD 部署環境群組 prd/nonPrd 決定是否需要)
42
+ * → WAIT DEPLOY → (GrayRelease Deploy) → WAIT DEPLOY → (To Verify)
43
+ * → VERIFY → (Verify Success) → MERGE CODE AND TAG → (To Done) → DONE
44
+ *
45
+ * 簽核規則:
46
+ * - DEV:直接 Approve
47
+ * - STG:指派給當日 Release Manager,等待簽核
48
+ * - UAT:指派 James Yu 等待留言,再轉給 Solar Chen 等待簽核
49
+ *
50
+ * Rebuild 規則:
51
+ * VERIFY 狀態只有在使用者明確要求 build/rebuild 時,才可透過 build_ticket({rebuild: true})
52
+ * 執行 Verify fail 回到 PLANNING 後重新 GrayRelease Build。
35
53
  */
36
54
  const GRAYRELEASE_FLOW_MAP = {
37
55
  'PLANNING': {
@@ -74,8 +92,111 @@ const GRAYRELEASE_FLOW_MAP = {
74
92
  },
75
93
  };
76
94
 
77
- const SWITCH_EXECUTION_NODE_COMMENT_WINDOW_MS = 5 * 60 * 1000;
78
- const SWITCH_EXECUTION_NODE_SETTLE_MS = 3 * 60 * 1000;
95
+ function normalizeStatusName(statusName) {
96
+ return String(statusName ?? '')
97
+ .trim()
98
+ .replace(/\s+/g, ' ')
99
+ .toUpperCase();
100
+ }
101
+
102
+ function getPollIntervalMs() {
103
+ return parseInt(process.env.POLL_INTERVAL_MS ?? '30000');
104
+ }
105
+
106
+ function getPollTimeoutMs() {
107
+ return parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000');
108
+ }
109
+
110
+ function progress(ctx, event) {
111
+ if (typeof ctx.progress === 'function') {
112
+ ctx.progress(event);
113
+ }
114
+ }
115
+
116
+ function isPassingResult(value) {
117
+ return ['pass', 'passed', 'success', 'succeeded', 'done'].includes(
118
+ String(value ?? '').trim().toLowerCase(),
119
+ );
120
+ }
121
+
122
+ function isFailingResult(value) {
123
+ return ['fail', 'failed', 'failure', 'error'].includes(
124
+ String(value ?? '').trim().toLowerCase(),
125
+ );
126
+ }
127
+
128
+ async function findTransitionByName(issueKey, transitionName, jira) {
129
+ const transitions = await jira.getTransitions(issueKey);
130
+ return transitions.find((transition) => (
131
+ transition.name.toLowerCase() === transitionName.toLowerCase()
132
+ ));
133
+ }
134
+
135
+ async function waitForGrayReleaseResult(issueKey, fieldId, label, jira, onProgress = () => {}) {
136
+ const intervalMs = getPollIntervalMs();
137
+ const timeoutMs = getPollTimeoutMs();
138
+ const startedAt = Date.now();
139
+ const deadline = Date.now() + timeoutMs;
140
+ let attempts = 0;
141
+ let lastValue;
142
+
143
+ while (true) {
144
+ attempts++;
145
+ const fields = await jira.getIssueFields(issueKey, [fieldId]);
146
+ const raw = fields[fieldId];
147
+ lastValue = raw?.value ?? raw; // Jira Select List 回傳物件 {value:'pass',...},純字串也相容
148
+ onProgress({
149
+ phase: 'polling',
150
+ title: `等待 ${label} 結果`,
151
+ detail: `${fieldId}: ${lastValue ?? 'empty'}`,
152
+ issueKey,
153
+ attempts,
154
+ elapsedMs: Date.now() - startedAt,
155
+ timeoutMs,
156
+ nextPollMs: intervalMs,
157
+ });
158
+
159
+ if (isPassingResult(lastValue)) {
160
+ return { issueKey, fieldId, result: lastValue, attempts };
161
+ }
162
+
163
+ if (isFailingResult(lastValue)) {
164
+ throw new Error(`${label} 失敗,${fieldId}: ${lastValue}`);
165
+ }
166
+
167
+ if (Date.now() >= deadline) {
168
+ throw new Error(`${label} 等待逾時,${fieldId}: ${lastValue ?? 'empty'}`);
169
+ }
170
+
171
+ await sleep(intervalMs);
172
+ }
173
+ }
174
+
175
+ function parseToolResult(result) {
176
+ if (!result || result.isError) {
177
+ return null;
178
+ }
179
+
180
+ if (result.success || result.data) {
181
+ return result.data ?? result;
182
+ }
183
+
184
+ const text = result.content
185
+ ?.filter((item) => item.type === 'text')
186
+ .map((item) => item.text)
187
+ .join('\n')
188
+ .trim();
189
+
190
+ if (!text) {
191
+ return null;
192
+ }
193
+
194
+ try {
195
+ return JSON.parse(text);
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
79
200
 
80
201
  // ── Schema definitions ───────────────────────────────────────────
81
202
  export function getGrayReleaseToolDefinitions() {
@@ -133,7 +254,7 @@ export function getGrayReleaseToolDefinitions() {
133
254
  },
134
255
  stgKeys: {
135
256
  type: 'array',
136
- items: {type: 'string'},
257
+ items: { type: 'string' },
137
258
  description: '要關聯的 STG 灰度單 key 陣列,例如 ["CID-1234", "CID-1235"]',
138
259
  },
139
260
  },
@@ -152,6 +273,10 @@ export function getGrayReleaseToolDefinitions() {
152
273
  type: 'string',
153
274
  description: 'GrayRelease 單 issue key,例如 CID-822',
154
275
  },
276
+ maxBuildRetries: {
277
+ type: 'number',
278
+ description: '(選填) Build 失敗最大重試次數,預設 3',
279
+ },
155
280
  autoVerify: {
156
281
  type: 'boolean',
157
282
  description: '(選填) 是否自動執行 Verify Success(預設 false,需人工確認)',
@@ -159,6 +284,23 @@ export function getGrayReleaseToolDefinitions() {
159
284
  },
160
285
  },
161
286
  },
287
+ {
288
+ name: 'deploy_grayrelease',
289
+ description:
290
+ '部署既有 GrayRelease 單,與 CD 部署完全分離。使用者說 GrayRelease deploy、部署灰度單時優先使用此 tool;' +
291
+ '若上下文 issue 是 GrayRelease 且使用者只說 deploy/部署,應先確認是否部署該 GrayRelease 單。' +
292
+ '此 tool 不會建立 CD 單、不接受 CI 單、不會重新 build;若目前在 Wait for Build 且可 Apply to approval,會往簽核/部署前進,最後停在 VERIFY 等人工驗證。',
293
+ inputSchema: {
294
+ type: 'object',
295
+ required: ['issueKey'],
296
+ properties: {
297
+ issueKey: {
298
+ type: 'string',
299
+ description: 'GrayRelease 單 issue key,例如 CID-822',
300
+ },
301
+ },
302
+ },
303
+ },
162
304
  {
163
305
  name: 'get_grayrelease_status',
164
306
  description:
@@ -186,6 +328,10 @@ export function getGrayReleaseToolDefinitions() {
186
328
  type: 'string',
187
329
  description: 'GrayRelease 單 issue key,例如 CID-822',
188
330
  },
331
+ maxBuildRetries: {
332
+ type: 'number',
333
+ description: '(選填) Build 失敗最大重試次數,預設 3',
334
+ },
189
335
  autoVerify: {
190
336
  type: 'boolean',
191
337
  description: '(選填) 是否自動執行 Verify Success(預設 false,需人工確認)',
@@ -233,13 +379,13 @@ async function findUnlinkedStgGrayReleases(gitBranch, moduleId, jira) {
233
379
  return linked?.fields?.issuetype?.id === GR_ISSUE_TYPE;
234
380
  });
235
381
  })
236
- .map((issue) => ({key: issue.key, summary: issue.fields?.summary ?? ''}));
382
+ .map((issue) => ({ key: issue.key, summary: issue.fields?.summary ?? '' }));
237
383
  }
238
384
 
239
385
  /**
240
386
  * 建立 Gray Release 上版單
241
387
  */
242
- export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
388
+ export async function handleCreateGrayReleaseTicket(args, { jira, notifier }) {
243
389
  try {
244
390
  const normalizedArgs = {
245
391
  ...args,
@@ -280,14 +426,14 @@ export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
280
426
  const serverList = getServerList(normalizedArgs.systemCode, envCode, false, normalizedArgs.module) || [];
281
427
 
282
428
  const fields = {
283
- project: {key: JIRA_PROJECT_ID},
284
- issuetype: {id: ISSUE_TYPE_IDS.GrayRelease},
429
+ project: { key: JIRA_PROJECT_ID },
430
+ issuetype: { id: ISSUE_TYPE_IDS.GrayRelease },
285
431
  summary: normalizedArgs.summary ?? autoSummary,
286
432
  duedate: today(),
287
433
  };
288
434
 
289
435
  // systemCode
290
- fields[GRAY_RELEASE_FIELD_IDS.systemCode] = {value: normalizedArgs.systemCode};
436
+ fields[GRAY_RELEASE_FIELD_IDS.systemCode] = { value: normalizedArgs.systemCode };
291
437
 
292
438
  // sign fields
293
439
  fields[GRAY_RELEASE_FIELD_IDS.deptManagerSign] = SIGN_VALUES.deptManagerSign;
@@ -296,17 +442,17 @@ export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
296
442
 
297
443
  // env
298
444
  if (ENV_CODES[envCode]) {
299
- fields[GRAY_RELEASE_FIELD_IDS.env] = {value: envCode};
445
+ fields[GRAY_RELEASE_FIELD_IDS.env] = { value: envCode };
300
446
  }
301
447
 
302
448
  // dept code
303
449
  if (deptStr && DEPT_CODES[deptStr]) {
304
- fields[GRAY_RELEASE_FIELD_IDS.deptCode] = {value: deptStr};
450
+ fields[GRAY_RELEASE_FIELD_IDS.deptCode] = { value: deptStr };
305
451
  }
306
452
 
307
453
  // system module
308
454
  if (moduleId) {
309
- fields[GRAY_RELEASE_FIELD_IDS.systemModule] = {id: moduleId};
455
+ fields[GRAY_RELEASE_FIELD_IDS.systemModule] = { id: moduleId };
310
456
  }
311
457
 
312
458
  // jenkins branch
@@ -320,7 +466,7 @@ export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
320
466
  fields[GRAY_RELEASE_FIELD_IDS.grayReleaseVersion] = grayReleaseVersion;
321
467
 
322
468
  // cluster deploy (always true)
323
- fields[GRAY_RELEASE_FIELD_IDS.clusterDeploy] = {id: FIELD_OPTIONS.clusterDeploy.true};
469
+ fields[GRAY_RELEASE_FIELD_IDS.clusterDeploy] = { id: FIELD_OPTIONS.clusterDeploy.true };
324
470
 
325
471
  // cluster list
326
472
  fields[GRAY_RELEASE_FIELD_IDS.clusterList] = serverList.join('\n');
@@ -329,7 +475,7 @@ export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
329
475
  fields[GRAY_RELEASE_FIELD_IDS.grayReleaseNotes] = NOTES_TEMPLATES.grayRelease;
330
476
 
331
477
  if (normalizedArgs.dryRun) {
332
- return ok({dryRun: true, summary: fields.summary, grayReleaseVersion, fields});
478
+ return ok({ dryRun: true, summary: fields.summary, grayReleaseVersion, fields });
333
479
  }
334
480
 
335
481
  const issue = await jira.createIssue(fields);
@@ -370,9 +516,9 @@ export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
370
516
  /**
371
517
  * 將 STG 灰度單關聯(relates to)到 UAT 灰度單
372
518
  */
373
- export async function handleLinkStgGrayRelease(args, {jira, notifier}) {
519
+ export async function handleLinkStgGrayRelease(args, { jira, notifier }) {
374
520
  try {
375
- const {uatKey, stgKeys} = args;
521
+ const { uatKey, stgKeys } = args;
376
522
  if (!Array.isArray(stgKeys) || stgKeys.length === 0) {
377
523
  return error('stgKeys 不可為空');
378
524
  }
@@ -385,7 +531,7 @@ export async function handleLinkStgGrayRelease(args, {jira, notifier}) {
385
531
  await jira.linkIssue(stgKey, uatKey, 'Relates');
386
532
  linked.push(stgKey);
387
533
  } catch (e) {
388
- failed.push({key: stgKey, reason: e.message});
534
+ failed.push({ key: stgKey, reason: e.message });
389
535
  }
390
536
  }
391
537
 
@@ -413,15 +559,21 @@ export async function handleLinkStgGrayRelease(args, {jira, notifier}) {
413
559
  * 自動執行 GrayRelease 完整流程
414
560
  */
415
561
  export async function handleAutoGrayRelease(args, ctx) {
416
- const {issueKey} = args;
417
- const {jira, notifier} = ctx;
562
+ const { issueKey } = args;
563
+ const { jira, notifier } = ctx;
418
564
 
419
565
  try {
566
+ progress(ctx, {
567
+ phase: 'action',
568
+ title: '開始 GrayRelease 自動流程',
569
+ issueKey,
570
+ });
420
571
  await notifier.notify(issueKey, '開始自動執行 GrayRelease 流程');
421
572
 
422
573
  const result = await executeGrayReleaseFlow(
423
574
  issueKey,
424
575
  {
576
+ maxBuildRetries: args.maxBuildRetries ?? 3,
425
577
  autoVerify: args.autoVerify ?? false,
426
578
  },
427
579
  ctx,
@@ -437,7 +589,7 @@ export async function handleAutoGrayRelease(args, ctx) {
437
589
  /**
438
590
  * 查詢 GrayRelease 單當前狀態並給出建議
439
591
  */
440
- export async function handleGetGrayReleaseStatus(args, {jira}) {
592
+ export async function handleGetGrayReleaseStatus(args, { jira }) {
441
593
  try {
442
594
  const status = await getGrayReleaseStatus(args.issueKey, jira);
443
595
  return ok(status);
@@ -446,17 +598,45 @@ export async function handleGetGrayReleaseStatus(args, {jira}) {
446
598
  }
447
599
  }
448
600
 
601
+ /**
602
+ * 執行 GrayRelease 部署流程,不觸發 rebuild。
603
+ */
604
+ export async function handleDeployGrayRelease(args, ctx) {
605
+ const { issueKey } = args;
606
+ const { jira, notifier } = ctx;
607
+
608
+ try {
609
+ progress(ctx, {
610
+ phase: 'action',
611
+ title: '開始 GrayRelease deploy 流程',
612
+ issueKey,
613
+ });
614
+ const result = await executeGrayReleaseDeployFlow(issueKey, ctx);
615
+ await notifier.notify(issueKey, `GrayRelease deploy 流程結束,目前狀態:${result.finalStatus}`);
616
+ return ok(result);
617
+ } catch (err) {
618
+ await notifier.notify(issueKey, `GrayRelease deploy 失敗: ${err.message}`);
619
+ return error(`deploy_grayrelease 失敗: ${err.message}`);
620
+ }
621
+ }
622
+
449
623
  /**
450
624
  * 從當前狀態繼續執行 GrayRelease 流程
451
625
  */
452
626
  export async function handleContinueGrayRelease(args, ctx) {
453
- const {issueKey} = args;
454
- const {jira, notifier} = ctx;
627
+ const { issueKey } = args;
628
+ const { jira, notifier } = ctx;
455
629
 
456
630
  try {
457
631
  const status = await getGrayReleaseStatus(issueKey, jira);
458
632
 
459
633
  if (status.completed) {
634
+ progress(ctx, {
635
+ phase: 'done',
636
+ title: 'GrayRelease 已完成',
637
+ issueKey,
638
+ currentStatus: status.currentStatus,
639
+ });
460
640
  return ok({
461
641
  issueKey,
462
642
  message: '此 GrayRelease 單已完成(狀態: DONE),無需繼續執行',
@@ -468,10 +648,17 @@ export async function handleContinueGrayRelease(args, ctx) {
468
648
  issueKey,
469
649
  `從狀態 ${status.currentStatus} 繼續執行 GrayRelease 流程`,
470
650
  );
651
+ progress(ctx, {
652
+ phase: 'action',
653
+ title: '繼續 GrayRelease 流程',
654
+ issueKey,
655
+ currentStatus: status.currentStatus,
656
+ });
471
657
 
472
658
  const result = await executeGrayReleaseFlow(
473
659
  issueKey,
474
660
  {
661
+ maxBuildRetries: args.maxBuildRetries ?? 3,
475
662
  autoVerify: args.autoVerify ?? false,
476
663
  },
477
664
  ctx,
@@ -492,6 +679,7 @@ export async function handleContinueGrayRelease(args, ctx) {
492
679
  async function getGrayReleaseStatus(issueKey, jira) {
493
680
  const issue = await jira.getIssue(issueKey);
494
681
  const currentStatus = issue.fields.status.name;
682
+ const normalizedStatus = normalizeStatusName(currentStatus);
495
683
  const summary = issue.fields.summary;
496
684
 
497
685
  // 查詢環境欄位
@@ -507,7 +695,7 @@ async function getGrayReleaseStatus(issueKey, jira) {
507
695
  const availableTransitions = transitions.map((t) => t.name);
508
696
 
509
697
  // 根據當前狀態查找流程定義
510
- const flowDef = GRAYRELEASE_FLOW_MAP[currentStatus];
698
+ const flowDef = GRAYRELEASE_FLOW_MAP[normalizedStatus];
511
699
  const completed = flowDef?.completed === true;
512
700
 
513
701
  let nextSteps = [];
@@ -526,6 +714,7 @@ async function getGrayReleaseStatus(issueKey, jira) {
526
714
  issueKey,
527
715
  summary,
528
716
  currentStatus,
717
+ normalizedStatus,
529
718
  environment,
530
719
  systemCode,
531
720
  completed,
@@ -536,11 +725,113 @@ async function getGrayReleaseStatus(issueKey, jira) {
536
725
  };
537
726
  }
538
727
 
728
+ async function executeGrayReleaseDeployFlow(issueKey, ctx) {
729
+ const { jira, notifier } = ctx;
730
+ const log = [];
731
+ const fields = await jira.getIssueFields(issueKey, [
732
+ GRAY_RELEASE_FIELD_IDS.env,
733
+ GRAY_RELEASE_FIELD_IDS.systemCode,
734
+ ]);
735
+ const environment = fields[GRAY_RELEASE_FIELD_IDS.env]?.value;
736
+ const systemCode = fields[GRAY_RELEASE_FIELD_IDS.systemCode]?.value;
737
+
738
+ if (!environment) {
739
+ throw new Error('無法讀取 GrayRelease 環境欄位');
740
+ }
741
+
742
+ log.push(`🚀 開始執行 GrayRelease Deploy - 環境: ${environment.toUpperCase()}`);
743
+
744
+ while (true) {
745
+ const issue = await jira.getIssue(issueKey);
746
+ const currentStatus = issue.fields.status.name;
747
+ const normalizedStatus = normalizeStatusName(currentStatus);
748
+ const flowDef = GRAYRELEASE_FLOW_MAP[normalizedStatus];
749
+
750
+ log.push(`\n📍 當前狀態: ${currentStatus}`);
751
+ progress(ctx, {
752
+ phase: 'action',
753
+ title: '檢查 GrayRelease deploy 狀態',
754
+ issueKey,
755
+ currentStatus,
756
+ });
757
+
758
+ if (flowDef?.completed) {
759
+ log.push('✅ GrayRelease 流程已完成,無需 deploy');
760
+ break;
761
+ }
762
+
763
+ if (!flowDef) {
764
+ log.push(`⚠️ 未知狀態: ${currentStatus},停止 deploy`);
765
+ break;
766
+ }
767
+
768
+ if (normalizedStatus === 'PLANNING') {
769
+ log.push(' 執行: Accept');
770
+ await jira.transitionByName(issueKey, 'Accept');
771
+ await notifier.notify(issueKey, 'Accept 需求,準備進入部署流程');
772
+ continue;
773
+ }
774
+
775
+ if (normalizedStatus === 'WAIT FOR BUILD') {
776
+ const applyTransition = await findTransitionByName(issueKey, 'Apply to approval', jira);
777
+ if (!applyTransition) {
778
+ log.push(' ⏸️ 目前仍需 build,deploy_grayrelease 不會觸發 rebuild');
779
+ break;
780
+ }
781
+ log.push(' 執行: Apply to approval');
782
+ await jira.transitionByName(issueKey, 'Apply to approval');
783
+ await notifier.notify(issueKey, '進入 GrayRelease 部署簽核流程');
784
+ continue;
785
+ }
786
+
787
+ if (normalizedStatus === 'WAIT APPROVAL') {
788
+ log.push(` 處理簽核流程 (環境: ${environment})`);
789
+ const approvalResult = await handleGrayReleaseApproval(issueKey, environment, systemCode, ctx);
790
+ if (approvalResult.skipped) {
791
+ log.push(` ✅ ${approvalResult.reason}`);
792
+ await jira.transitionByName(issueKey, 'Approve');
793
+ await notifier.notify(issueKey, 'DEV 環境無需簽核,直接 Approve');
794
+ } else {
795
+ log.push(` ✅ 簽核完成 by ${approvalResult.by}`);
796
+ }
797
+ continue;
798
+ }
799
+
800
+ if (normalizedStatus === 'WAIT DEPLOY') {
801
+ const deployCompleted = await runGrayReleaseDeployStep(issueKey, systemCode, ctx, log);
802
+ if (!deployCompleted) {
803
+ break;
804
+ }
805
+ continue;
806
+ }
807
+
808
+ if (normalizedStatus === 'VERIFY') {
809
+ log.push(' ⏸️ 已進入 VERIFY,請人工驗證;若需重 build 請明確執行 build/rebuild');
810
+ break;
811
+ }
812
+
813
+ if (normalizedStatus === 'MERGE CODE AND TAG') {
814
+ log.push(' ✅ 已完成部署與驗證,等待合併程式碼與打 tag');
815
+ break;
816
+ }
817
+
818
+ log.push(`⚠️ 狀態 ${currentStatus} 沒有 deploy 處理邏輯,停止執行`);
819
+ break;
820
+ }
821
+
822
+ const finalIssue = await jira.getIssue(issueKey);
823
+ return {
824
+ issueKey,
825
+ finalStatus: finalIssue.fields.status.name,
826
+ log,
827
+ };
828
+ }
829
+
539
830
  /**
540
831
  * 主執行流程:從當前狀態開始,自動執行到完成或需要人工介入
541
832
  */
542
833
  async function executeGrayReleaseFlow(issueKey, options, ctx) {
543
- const {jira, notifier} = ctx;
834
+ const { jira, notifier } = ctx;
544
835
  const log = [];
545
836
  let buildAttempts = 0;
546
837
 
@@ -549,17 +840,28 @@ async function executeGrayReleaseFlow(issueKey, options, ctx) {
549
840
  GRAY_RELEASE_FIELD_IDS.env,
550
841
  GRAY_RELEASE_FIELD_IDS.systemCode,
551
842
  ]);
552
- const environment = normalizeGrayReleaseEnvironment(fields[GRAY_RELEASE_FIELD_IDS.env]);
843
+ const environment = fields[GRAY_RELEASE_FIELD_IDS.env]?.value;
553
844
  const systemCode = fields[GRAY_RELEASE_FIELD_IDS.systemCode]?.value;
554
845
 
846
+ if (!environment) {
847
+ throw new Error('無法讀取 GrayRelease 環境欄位');
848
+ }
849
+
555
850
  log.push(`🚀 開始執行 GrayRelease 流程 - 環境: ${environment.toUpperCase()}`);
556
851
 
557
852
  while (true) {
558
853
  const issue = await jira.getIssue(issueKey);
559
854
  const currentStatus = issue.fields.status.name;
560
- const flowDef = GRAYRELEASE_FLOW_MAP[currentStatus];
855
+ const normalizedStatus = normalizeStatusName(currentStatus);
856
+ const flowDef = GRAYRELEASE_FLOW_MAP[normalizedStatus];
561
857
 
562
858
  log.push(`\n📍 當前狀態: ${currentStatus}`);
859
+ progress(ctx, {
860
+ phase: 'action',
861
+ title: '檢查 GrayRelease 狀態',
862
+ issueKey,
863
+ currentStatus,
864
+ });
563
865
 
564
866
  // 已完成
565
867
  if (flowDef?.completed) {
@@ -574,7 +876,7 @@ async function executeGrayReleaseFlow(issueKey, options, ctx) {
574
876
  }
575
877
 
576
878
  // ── PLANNING → Accept ────────────────────────────────────
577
- if (currentStatus === 'PLANNING') {
879
+ if (normalizedStatus === 'PLANNING') {
578
880
  log.push(' 執行: Accept');
579
881
  await jira.transitionByName(issueKey, 'Accept');
580
882
  await notifier.notify(issueKey, 'Accept 需求,進入 WAIT FOR BUILD');
@@ -582,20 +884,26 @@ async function executeGrayReleaseFlow(issueKey, options, ctx) {
582
884
  }
583
885
 
584
886
  // ── WAIT FOR BUILD → GrayRelease Build ───────────────────
585
- if (currentStatus === 'WAIT FOR BUILD') {
887
+ if (normalizedStatus === 'WAIT FOR BUILD') {
586
888
  buildAttempts++;
889
+ if (buildAttempts > options.maxBuildRetries) {
890
+ log.push(`⚠️ Build 已達最大重試次數 (${options.maxBuildRetries}),停止執行`);
891
+ break;
892
+ }
587
893
 
588
894
  log.push(` 執行: GrayRelease Build (第 ${buildAttempts} 次)`);
589
895
  await jira.transitionByName(issueKey, 'GrayRelease Build');
590
896
  await notifier.notify(issueKey, `觸發 GrayRelease Build (第 ${buildAttempts} 次)`);
591
897
 
592
- // 等待 build 完成(Jenkins 會更新 CID_build_result)
898
+ // 等待 build 完成
593
899
  log.push(' ⏳ 等待 Jenkins Build 完成...');
594
- await waitForResultField(issueKey, GRAY_RELEASE_FIELD_IDS.buildResult, 'pass', jira, {
595
- intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
596
- timeoutMs: parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000'),
597
- label: 'CID_build_result',
598
- });
900
+ await waitForGrayReleaseResult(
901
+ issueKey,
902
+ GRAY_RELEASE_FIELD_IDS.buildResult,
903
+ 'GrayRelease Build',
904
+ jira,
905
+ ctx.progress,
906
+ );
599
907
 
600
908
  log.push(' ✅ Build 完成');
601
909
 
@@ -607,7 +915,7 @@ async function executeGrayReleaseFlow(issueKey, options, ctx) {
607
915
  }
608
916
 
609
917
  // ── WAIT APPROVAL → Approve ───────────────────────────────
610
- if (currentStatus === 'WAIT APPROVAL') {
918
+ if (normalizedStatus === 'WAIT APPROVAL') {
611
919
  log.push(` 處理簽核流程 (環境: ${environment})`);
612
920
 
613
921
  const approvalResult = await handleGrayReleaseApproval(
@@ -629,57 +937,16 @@ async function executeGrayReleaseFlow(issueKey, options, ctx) {
629
937
  }
630
938
 
631
939
  // ── WAIT DEPLOY → Switch / Deploy ─────────────────────────
632
- if (currentStatus === 'WAIT DEPLOY') {
633
- let switchComment = await findRecentSwitchExecutionNodeComment(issueKey, systemCode, jira);
634
-
635
- if (switchComment) {
636
- log.push(' ✅ 偵測到 5 分鐘內 cid jira worker 已完成 Switch Execution Node');
637
- } else {
638
- // 檢查是否需要 Switch Execution Node
639
- const needSwitch = await needSwitchExecutionNode(issueKey, systemCode, jira);
640
-
641
- if (needSwitch) {
642
- log.push(' 執行: Switch Execution Node');
643
- await jira.transitionByName(issueKey, 'Switch Execution Node');
644
- await notifier.notify(issueKey, '切換 Ansible instance');
645
-
646
- log.push(' ⏳ 等待 cid jira worker 留下 Switch Execution Node 成功 comment...');
647
- switchComment = await waitForRecentSwitchExecutionNodeComment(issueKey, systemCode, jira, {
648
- intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
649
- timeoutMs: SWITCH_EXECUTION_NODE_COMMENT_WINDOW_MS,
650
- });
651
- }
652
- }
653
-
654
- if (switchComment) {
655
- const waitedMs = await waitForSwitchExecutionNodeSettle(switchComment);
656
- log.push(` ✅ Switch Execution Node 已確認,等待 ${Math.round(waitedMs / 1000)}s 後執行 Deploy`);
940
+ if (normalizedStatus === 'WAIT DEPLOY') {
941
+ const deployCompleted = await runGrayReleaseDeployStep(issueKey, systemCode, ctx, log);
942
+ if (!deployCompleted) {
943
+ break;
657
944
  }
658
-
659
- // 執行 GrayRelease Deploy
660
- log.push(' 執行: GrayRelease Deploy');
661
- await jira.transitionByName(issueKey, 'GrayRelease Deploy');
662
- await notifier.notify(issueKey, '觸發 GrayRelease Deploy');
663
-
664
- // 等待 deploy 完成(Jenkins 會更新 CID_deploy_result)
665
- log.push(' ⏳ 等待部署完成...');
666
- await waitForResultField(issueKey, GRAY_RELEASE_FIELD_IDS.deployResult, 'pass', jira, {
667
- intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
668
- timeoutMs: parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000'),
669
- label: 'CID_deploy_result',
670
- });
671
-
672
- log.push(' ✅ Deploy 完成');
673
-
674
- // Deploy 完成後切到 VERIFY
675
- log.push(' 執行: To Verify');
676
- await jira.transitionByName(issueKey, 'To Verify');
677
- await notifier.notify(issueKey, 'Deploy 完成,進入驗證階段');
678
945
  continue;
679
946
  }
680
947
 
681
948
  // ── VERIFY → 需要人工決定 ─────────────────────────────────
682
- if (currentStatus === 'VERIFY') {
949
+ if (normalizedStatus === 'VERIFY') {
683
950
  if (options.autoVerify) {
684
951
  log.push(' ⚠️ autoVerify=true,自動執行 Verify Success');
685
952
  await jira.transitionByName(issueKey, 'Verify Success');
@@ -693,7 +960,7 @@ async function executeGrayReleaseFlow(issueKey, options, ctx) {
693
960
  }
694
961
 
695
962
  // ── MERGE CODE AND TAG → To Done ─────────────────────────
696
- if (currentStatus === 'MERGE CODE AND TAG') {
963
+ if (normalizedStatus === 'MERGE CODE AND TAG') {
697
964
  log.push(' 執行: To Done');
698
965
  await jira.transitionByName(issueKey, 'To Done');
699
966
  await notifier.notify(issueKey, 'GrayRelease 流程完成');
@@ -714,24 +981,125 @@ async function executeGrayReleaseFlow(issueKey, options, ctx) {
714
981
  };
715
982
  }
716
983
 
984
+ async function runGrayReleaseDeployStep(issueKey, systemCode, ctx, log) {
985
+ const { jira, notifier } = ctx;
986
+ const needSwitch = await needSwitchExecutionNode(issueKey, systemCode, jira);
987
+
988
+ if (needSwitch) {
989
+ log.push(' 執行: Switch Execution Node');
990
+ progress(ctx, {
991
+ phase: 'action',
992
+ title: '切換 GrayRelease execution node',
993
+ issueKey,
994
+ });
995
+ await jira.transitionByName(issueKey, 'Switch Execution Node');
996
+ await notifier.notify(issueKey, '切換 Ansible instance');
997
+ await waitForSwitchExecutionNode(issueKey, systemCode, jira);
998
+ }
999
+
1000
+ log.push(' 執行: GrayRelease Deploy');
1001
+ progress(ctx, {
1002
+ phase: 'action',
1003
+ title: '觸發 GrayRelease Deploy',
1004
+ issueKey,
1005
+ });
1006
+ await jira.transitionByName(issueKey, 'GrayRelease Deploy');
1007
+ await notifier.notify(issueKey, '觸發 GrayRelease Deploy');
1008
+
1009
+ // 等待部署完成:優先輪詢 deploy result、VERIFY 狀態,
1010
+ // 或「To Verify」transition 是否出現。
1011
+ // 最多等待 3 分鐘(避免 MCP client timeout),
1012
+ // 若超時則回傳「部署中」訊息,請使用者稍後繼續。
1013
+ const DEPLOY_WAIT_MS = Math.min(getPollTimeoutMs(), 3 * 60 * 1000);
1014
+ const DEPLOY_POLL_MS = Math.min(getPollIntervalMs(), 10_000);
1015
+ const deployDeadline = Date.now() + DEPLOY_WAIT_MS;
1016
+ let deployCompleted = false;
1017
+ let attempts = 0;
1018
+
1019
+ log.push(' ⏳ 等待部署完成(最多 3 分鐘)...');
1020
+ while (Date.now() <= deployDeadline) {
1021
+ attempts++;
1022
+ const issue = await jira.getIssue(issueKey);
1023
+ const statusNow = normalizeStatusName(issue.fields.status.name);
1024
+ const fields = await jira.getIssueFields(issueKey, [GRAY_RELEASE_FIELD_IDS.deployResult]);
1025
+ const rawDeployResult = fields[GRAY_RELEASE_FIELD_IDS.deployResult];
1026
+ const deployResult = rawDeployResult?.value ?? rawDeployResult;
1027
+ progress(ctx, {
1028
+ phase: 'polling',
1029
+ title: '等待 GrayRelease deploy 完成',
1030
+ detail: `${GRAY_RELEASE_FIELD_IDS.deployResult}: ${deployResult ?? 'empty'}`,
1031
+ issueKey,
1032
+ currentStatus: issue.fields.status.name,
1033
+ targetStatus: 'VERIFY',
1034
+ attempts,
1035
+ elapsedMs: DEPLOY_WAIT_MS - Math.max(0, deployDeadline - Date.now()),
1036
+ timeoutMs: DEPLOY_WAIT_MS,
1037
+ nextPollMs: DEPLOY_POLL_MS,
1038
+ });
1039
+
1040
+ if (statusNow === 'VERIFY') {
1041
+ // Jira automation 或 Jenkins 已自動切到 VERIFY
1042
+ deployCompleted = true;
1043
+ log.push(' ✅ 狀態已進入 VERIFY(自動切換),跳過手動 To Verify');
1044
+ break;
1045
+ }
1046
+
1047
+ if (isFailingResult(deployResult)) {
1048
+ throw new Error(`GrayRelease Deploy 失敗,${GRAY_RELEASE_FIELD_IDS.deployResult}: ${deployResult}`);
1049
+ }
1050
+
1051
+ const transitions = await jira.getTransitions?.(issueKey) ?? [];
1052
+ const toVerifyTrans = transitions.find(
1053
+ (t) => t.name.toLowerCase() === 'to verify',
1054
+ );
1055
+ if (isPassingResult(deployResult) || toVerifyTrans) {
1056
+ log.push(' ✅ Deploy 完成,執行: To Verify');
1057
+ if (toVerifyTrans?.id && jira.transitionById) {
1058
+ await jira.transitionById(issueKey, toVerifyTrans.id);
1059
+ } else {
1060
+ await jira.transitionByName(issueKey, 'To Verify');
1061
+ }
1062
+ await notifier.notify(issueKey, 'Deploy 完成,進入驗證階段');
1063
+ deployCompleted = true;
1064
+ break;
1065
+ }
1066
+
1067
+ if (Date.now() >= deployDeadline) {
1068
+ break;
1069
+ }
1070
+
1071
+ await sleep(DEPLOY_POLL_MS);
1072
+ }
1073
+
1074
+ if (!deployCompleted) {
1075
+ log.push(' ⚠️ 3 分鐘內未看到部署結果,部署可能仍在進行中');
1076
+ log.push(' 請稍後使用 get_grayrelease_status 或 deploy_grayrelease 繼續');
1077
+ await notifier.notify(issueKey, '部署仍在進行中,請稍後查詢狀態');
1078
+ return false;
1079
+ }
1080
+
1081
+ await notifier.notify(issueKey, 'Deploy 完成,進入驗證階段');
1082
+ return true;
1083
+ }
1084
+
717
1085
  /**
718
1086
  * 處理簽核流程(依環境別)
719
1087
  */
720
1088
  async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx) {
721
- const {jira, notifier} = ctx;
1089
+ const { jira, notifier } = ctx;
722
1090
  const env = environment.toLowerCase();
723
1091
 
724
1092
  // DEV: 跳過簽核
725
1093
  if (env === 'dev') {
726
- return {skipped: true, reason: 'DEV 環境無需簽核'};
1094
+ return { skipped: true, reason: 'DEV 環境無需簽核' };
727
1095
  }
728
1096
 
729
1097
  // STG: 查 wiki 值班組長 → assign → 發 jabber → 等待 approve
730
1098
  if (env === 'stg') {
731
1099
  const managerResult = await handleGetReleaseManager({}, {});
732
- const managerData = parseMcpToolData(managerResult, 'get_release_manager');
1100
+ const managerData = parseToolResult(managerResult);
733
1101
 
734
- if (!managerData.found) {
1102
+ if (!managerData?.found) {
735
1103
  throw new Error('無法查詢 STG 值班組長,請手動處理');
736
1104
  }
737
1105
 
@@ -743,11 +1111,23 @@ async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx)
743
1111
  }
744
1112
 
745
1113
  // Assign 給組長
1114
+ progress(ctx, {
1115
+ phase: 'action',
1116
+ title: '指派 GrayRelease 簽核人',
1117
+ detail: `STG 值班組長 ${managerName}`,
1118
+ issueKey,
1119
+ });
746
1120
  await jira.updateAssignee(issueKey, accountId);
747
1121
  await notifier.notify(issueKey, `已指派給 STG 值班組長 ${managerName}`);
748
1122
 
749
1123
  // 發送 jabber 通知
750
1124
  const jabberTo = `${accountId}@linebank.com.tw`;
1125
+ progress(ctx, {
1126
+ phase: 'waiting',
1127
+ title: '發送 GrayRelease 簽核通知',
1128
+ detail: `to ${managerName} (${jabberTo})`,
1129
+ issueKey,
1130
+ });
751
1131
  await handleSendJabberMessage(
752
1132
  {
753
1133
  to: jabberTo,
@@ -761,9 +1141,10 @@ async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx)
761
1141
  await poller.waitForStatus(issueKey, 'WAIT DEPLOY', {
762
1142
  intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
763
1143
  timeoutMs: parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000'),
1144
+ onProgress: ctx.progress,
764
1145
  });
765
1146
 
766
- return {approved: true, by: managerName};
1147
+ return { approved: true, by: managerName };
767
1148
  }
768
1149
 
769
1150
  // UAT: assign James Yu → 等待留言 → 轉 Solar Chen → 等待 approve
@@ -774,11 +1155,23 @@ async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx)
774
1155
  }
775
1156
 
776
1157
  // Assign 給 James Yu
1158
+ progress(ctx, {
1159
+ phase: 'action',
1160
+ title: '指派 GrayRelease 簽核人',
1161
+ detail: '部長 James Yu',
1162
+ issueKey,
1163
+ });
777
1164
  await jira.updateAssignee(issueKey, jamesAccountId);
778
1165
  await notifier.notify(issueKey, '已指派給部長 James Yu,等待留言確認');
779
1166
 
780
1167
  // 發送 jabber 通知給 James Yu
781
1168
  const jamesJabber = `${jamesAccountId}@linebank.com.tw`;
1169
+ progress(ctx, {
1170
+ phase: 'waiting',
1171
+ title: '發送 GrayRelease 簽核通知',
1172
+ detail: `to James Yu (${jamesJabber})`,
1173
+ issueKey,
1174
+ });
782
1175
  await handleSendJabberMessage(
783
1176
  {
784
1177
  to: jamesJabber,
@@ -796,11 +1189,11 @@ async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx)
796
1189
  timeoutMs: parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000'),
797
1190
  intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
798
1191
  },
799
- {jira},
1192
+ { jira, progress: ctx.progress },
800
1193
  );
801
- const commentData = parseMcpToolData(commentResult, 'wait_for_comment');
1194
+ const commentData = parseToolResult(commentResult);
802
1195
 
803
- if (!commentData.found) {
1196
+ if (!commentData?.found) {
804
1197
  throw new Error('等待 James Yu 留言超時');
805
1198
  }
806
1199
 
@@ -812,11 +1205,23 @@ async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx)
812
1205
  throw new Error('找不到 Solar Chen 的 accountId');
813
1206
  }
814
1207
 
1208
+ progress(ctx, {
1209
+ phase: 'action',
1210
+ title: '指派 GrayRelease 簽核人',
1211
+ detail: 'Solar Chen',
1212
+ issueKey,
1213
+ });
815
1214
  await jira.updateAssignee(issueKey, solarAccountId);
816
1215
  await notifier.notify(issueKey, '已轉單給 Solar Chen,等待最終簽核');
817
1216
 
818
1217
  // 發送 jabber 通知給 Solar Chen
819
1218
  const solarJabber = `${solarAccountId}@linebank.com.tw`;
1219
+ progress(ctx, {
1220
+ phase: 'waiting',
1221
+ title: '發送 GrayRelease 簽核通知',
1222
+ detail: `to Solar Chen (${solarJabber})`,
1223
+ issueKey,
1224
+ });
820
1225
  await handleSendJabberMessage(
821
1226
  {
822
1227
  to: solarJabber,
@@ -830,219 +1235,25 @@ async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx)
830
1235
  await poller.waitForStatus(issueKey, 'WAIT DEPLOY', {
831
1236
  intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
832
1237
  timeoutMs: parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000'),
1238
+ onProgress: ctx.progress,
833
1239
  });
834
1240
 
835
- return {approved: true, by: 'James Yu → Solar Chen'};
1241
+ return { approved: true, by: 'James Yu → Solar Chen' };
836
1242
  }
837
1243
 
838
1244
  throw new Error(`不支援的環境: ${environment}`);
839
1245
  }
840
1246
 
841
- function parseMcpToolData(result, toolName) {
842
- const text = result?.content?.[0]?.text;
843
-
844
- if (result?.isError || text?.startsWith('❌')) {
845
- throw new Error(text?.replace(/^❌ 錯誤:\s*/, '') ?? `${toolName} 回傳錯誤`);
846
- }
847
-
848
- if (!text) {
849
- throw new Error(`${toolName} 回傳空內容`);
850
- }
851
-
852
- try {
853
- return JSON.parse(text);
854
- } catch (err) {
855
- throw new Error(`${toolName} 回傳格式不是 JSON: ${err.message}`);
856
- }
857
- }
858
-
859
- async function waitForResultField(issueKey, fieldName, expectedValue, jira, options = {}) {
860
- const intervalMs = options.intervalMs ?? parseInt(process.env.POLL_INTERVAL_MS ?? '30000');
861
- const timeoutMs = options.timeoutMs ?? parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000');
862
- const label = options.label ?? fieldName;
863
- const fieldId = await resolveJiraFieldId(fieldName, jira);
864
- const expected = normalizeResultValue(expectedValue);
865
- const failedValues = new Set(['fail', 'failed', 'failure', 'error']);
866
- const startTime = Date.now();
867
- let attempts = 0;
868
-
869
- while (true) {
870
- attempts++;
871
- const fields = await jira.getIssueFields(issueKey, [fieldId]);
872
- const rawValue = fields[fieldId] ?? fields[fieldName];
873
- const value = normalizeResultValue(rawValue);
874
-
875
- if (value === expected) {
876
- return {issueKey, field: label, value, attempts, elapsedMs: Date.now() - startTime};
877
- }
878
-
879
- if (failedValues.has(value)) {
880
- throw new Error(`${label}=${formatResultValue(rawValue)},Jenkins 結果失敗`);
881
- }
882
-
883
- const elapsed = Date.now() - startTime;
884
- if (elapsed >= timeoutMs) {
885
- throw new Error(
886
- `Timeout waiting for ${label} to become ${expectedValue}. ` +
887
- `Last value: ${formatResultValue(rawValue)} after ${attempts} attempts.`,
888
- );
889
- }
890
-
891
- await sleep(intervalMs);
892
- }
893
- }
894
-
895
- async function resolveJiraFieldId(fieldName, jira) {
896
- if (typeof jira.getFieldIdByName !== 'function') {
897
- return fieldName;
898
- }
899
- return jira.getFieldIdByName(fieldName);
900
- }
901
-
902
- function normalizeResultValue(value) {
903
- if (value && typeof value === 'object') {
904
- return String(value.value ?? value.name ?? value.id ?? '').trim().toLowerCase();
905
- }
906
- return String(value ?? '').trim().toLowerCase();
907
- }
908
-
909
- function formatResultValue(value) {
910
- if (value === undefined || value === null || value === '') {
911
- return '(empty)';
912
- }
913
- if (typeof value === 'object') {
914
- return JSON.stringify(value);
915
- }
916
- return String(value);
917
- }
918
-
919
- function normalizeGrayReleaseEnvironment(value) {
920
- const environment = normalizeResultValue(value);
921
-
922
- if (!environment) {
923
- throw new Error('無法讀取 GrayRelease 環境欄位,請確認 Jira 單欄位是否正確填寫');
924
- }
925
-
926
- if (!SUPPORTED_ENVS.grayRelease.includes(environment)) {
927
- throw new Error(
928
- `GrayRelease 環境欄位不合法: ${formatResultValue(value)},僅支援 ${SUPPORTED_ENVS.grayRelease.join(' / ')}`,
929
- );
930
- }
931
-
932
- return environment;
933
- }
934
-
935
- async function waitForRecentSwitchExecutionNodeComment(issueKey, systemCode, jira, options = {}) {
936
- const intervalMs = options.intervalMs ?? parseInt(process.env.POLL_INTERVAL_MS ?? '30000');
937
- const timeoutMs = options.timeoutMs ?? SWITCH_EXECUTION_NODE_COMMENT_WINDOW_MS;
938
- const startTime = Date.now();
939
- let attempts = 0;
940
-
941
- while (true) {
942
- attempts++;
943
- const match = await findRecentSwitchExecutionNodeComment(issueKey, systemCode, jira);
944
-
945
- if (match) {
946
- return {...match, attempts, elapsedMs: Date.now() - startTime};
947
- }
948
-
949
- const elapsed = Date.now() - startTime;
950
- if (elapsed >= timeoutMs) {
951
- throw new Error(
952
- `Timeout waiting for cid jira worker Switch Execution Node comment in ${issueKey} after ${attempts} attempts.`,
953
- );
954
- }
955
-
956
- await sleep(intervalMs);
957
- }
958
- }
959
-
960
- async function findRecentSwitchExecutionNodeComment(issueKey, systemCode, jira) {
961
- if (typeof jira.getComments !== 'function') {
962
- return null;
963
- }
964
-
965
- const comments = await jira.getComments(issueKey);
966
- const now = Date.now();
967
-
968
- return comments.find((comment) => {
969
- if (!isCidJiraWorkerComment(comment)) {
970
- return false;
971
- }
972
-
973
- if (!isSwitchExecutionNodeSuccessComment(comment, systemCode)) {
974
- return false;
975
- }
976
-
977
- const timestamp = getCommentTimestamp(comment);
978
- if (!timestamp) {
979
- return false;
980
- }
981
-
982
- const ageMs = now - timestamp;
983
- return ageMs >= 0 && ageMs <= SWITCH_EXECUTION_NODE_COMMENT_WINDOW_MS;
984
- }) ?? null;
985
- }
986
-
987
- async function waitForSwitchExecutionNodeSettle(comment) {
988
- const waitMs = parseInt(
989
- process.env.SWITCH_EXECUTION_NODE_WAIT_MS ?? String(SWITCH_EXECUTION_NODE_SETTLE_MS),
990
- );
991
- const timestamp = getCommentTimestamp(comment);
992
- const elapsedSinceCommentMs = timestamp ? Date.now() - timestamp : 0;
993
- const remainingMs = Math.max(0, waitMs - elapsedSinceCommentMs);
994
-
995
- if (remainingMs > 0) {
996
- await sleep(remainingMs);
997
- }
998
-
999
- return remainingMs;
1000
- }
1001
-
1002
- function isCidJiraWorkerComment(comment) {
1003
- const authorFields = [
1004
- comment?.author?.displayName,
1005
- comment?.author?.name,
1006
- comment?.author?.emailAddress,
1007
- comment?.author?.accountId,
1008
- comment?.author?.key,
1009
- ];
1010
-
1011
- return authorFields
1012
- .map((value) => String(value ?? '').trim().toLowerCase())
1013
- .some((value) => value.includes('cid jira worker'));
1014
- }
1015
-
1016
- function isSwitchExecutionNodeSuccessComment(comment, systemCode) {
1017
- const body = getCommentBody(comment).toLowerCase();
1018
- const system = String(systemCode ?? '').trim().toLowerCase();
1019
-
1020
- return (
1021
- system &&
1022
- body.includes(`trigger update ${system}'s instance_group to [ing]nonprd_executionnode success`)
1023
- );
1024
- }
1025
-
1026
- function getCommentBody(comment) {
1027
- const body = comment?.body ?? '';
1028
- if (typeof body === 'string') {
1029
- return body;
1030
- }
1031
- return JSON.stringify(body);
1032
- }
1033
-
1034
- function getCommentTimestamp(comment) {
1035
- const rawTimestamp = comment?.created ?? comment?.updated;
1036
- const timestamp = Date.parse(rawTimestamp ?? '');
1037
- return Number.isNaN(timestamp) ? 0 : timestamp;
1038
- }
1039
-
1040
1247
  /**
1041
1248
  * 判斷是否需要執行 Switch Execution Node
1042
1249
  * 規則:查詢同系統上次 CD 部署的環境群組,若與本次不同則需切換
1043
1250
  * 注意:不查詢 GrayRelease 歷史,因為 GrayRelease 永遠是 nonPrd
1044
1251
  */
1045
1252
  async function needSwitchExecutionNode(issueKey, systemCode, jira) {
1253
+ if (await hasRecentSwitchExecutionNodeComment(issueKey, systemCode, jira)) {
1254
+ return false;
1255
+ }
1256
+
1046
1257
  // GrayRelease 永遠是 nonPrd 群組
1047
1258
  const thisGroup = 'nonPrd';
1048
1259
 
@@ -1072,6 +1283,45 @@ async function needSwitchExecutionNode(issueKey, systemCode, jira) {
1072
1283
  }
1073
1284
  }
1074
1285
 
1286
+ async function waitForSwitchExecutionNode(issueKey, systemCode, jira) {
1287
+ const waitMs = parseInt(process.env.SWITCH_EXECUTION_NODE_WAIT_MS ?? '180000');
1288
+ const intervalMs = getPollIntervalMs();
1289
+ const deadline = Date.now() + waitMs;
1290
+
1291
+ while (true) {
1292
+ if (await hasRecentSwitchExecutionNodeComment(issueKey, systemCode, jira)) {
1293
+ return;
1294
+ }
1295
+
1296
+ if (Date.now() >= deadline) {
1297
+ return;
1298
+ }
1299
+
1300
+ await sleep(intervalMs);
1301
+ }
1302
+ }
1303
+
1304
+ async function hasRecentSwitchExecutionNodeComment(issueKey, systemCode, jira) {
1305
+ if (!jira.getComments) {
1306
+ return false;
1307
+ }
1308
+
1309
+ try {
1310
+ const comments = await jira.getComments(issueKey);
1311
+ const lowerSystemCode = String(systemCode ?? '').toLowerCase();
1312
+ return comments.some((comment) => {
1313
+ const body = String(comment.body ?? '').toLowerCase();
1314
+ const author = String(comment.author?.displayName ?? comment.author?.name ?? '').toLowerCase();
1315
+ return author === 'cid jira worker'
1316
+ && body.includes('instance_group')
1317
+ && body.includes('nonprd_executionnode')
1318
+ && (!lowerSystemCode || body.includes(lowerSystemCode));
1319
+ });
1320
+ } catch {
1321
+ return false;
1322
+ }
1323
+ }
1324
+
1075
1325
  function sleep(ms) {
1076
1326
  return new Promise((resolve) => setTimeout(resolve, ms));
1077
1327
  }