@jira-deploy/core 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/constants/environments.js +1 -1
- package/constants/field-ids.js +3 -0
- package/dry-run.js +4 -0
- package/jira-client.js +13 -0
- package/package.json +1 -1
- package/tools/cd.js +3 -1
- package/tools/grayrelease.js +781 -0
- package/tools/index.js +158 -5
- package/tools/library.js +2 -1
- package/tools.test.js +558 -0
package/tools/grayrelease.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Gray Release 相關 tools
|
|
3
3
|
* - create_grayrelease_ticket
|
|
4
|
+
* - link_stg_grayrelease
|
|
5
|
+
* - auto_grayrelease (NEW)
|
|
6
|
+
* - get_grayrelease_status (NEW)
|
|
7
|
+
* - continue_grayrelease (NEW)
|
|
4
8
|
*/
|
|
5
9
|
import {
|
|
6
10
|
SYSTEM_CODES,
|
|
@@ -16,8 +20,62 @@ import {
|
|
|
16
20
|
SIGN_VALUES,
|
|
17
21
|
NOTES_TEMPLATES,
|
|
18
22
|
JIRA_DEFAULTS,
|
|
23
|
+
resolveAccountId,
|
|
19
24
|
} from '../constants/index.js';
|
|
20
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';
|
|
29
|
+
|
|
30
|
+
// ── Flow Definition ──────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* GrayRelease 狀態流程定義
|
|
34
|
+
* 每個狀態定義其可能的下一步 transitions
|
|
35
|
+
*/
|
|
36
|
+
const GRAYRELEASE_FLOW_MAP = {
|
|
37
|
+
'PLANNING': {
|
|
38
|
+
transitions: ['Accept'],
|
|
39
|
+
desc: '接受需求開始執行',
|
|
40
|
+
nextState: 'WAIT FOR BUILD',
|
|
41
|
+
},
|
|
42
|
+
'WAIT FOR BUILD': {
|
|
43
|
+
transitions: ['GrayRelease Build'],
|
|
44
|
+
desc: 'Build 程式',
|
|
45
|
+
nextState: 'WAIT FOR BUILD', // build 完會回到同狀態
|
|
46
|
+
afterBuild: 'Apply to approval', // build 完後要切到簽核
|
|
47
|
+
},
|
|
48
|
+
'WAIT APPROVAL': {
|
|
49
|
+
transitions: ['Approve'],
|
|
50
|
+
desc: '等待主管簽核',
|
|
51
|
+
nextState: 'WAIT DEPLOY',
|
|
52
|
+
requiresApproval: true,
|
|
53
|
+
},
|
|
54
|
+
'WAIT DEPLOY': {
|
|
55
|
+
transitions: ['Switch Execution Node', 'GrayRelease Deploy', 'To Verify'],
|
|
56
|
+
desc: '部署程式',
|
|
57
|
+
nextState: 'WAIT DEPLOY', // deploy 完會回到同狀態
|
|
58
|
+
afterDeploy: 'To Verify', // deploy 完後切到驗證
|
|
59
|
+
},
|
|
60
|
+
'VERIFY': {
|
|
61
|
+
transitions: ['Verify Success', 'Verify fail'],
|
|
62
|
+
desc: '驗證部署結果',
|
|
63
|
+
onSuccess: 'MERGE CODE AND TAG',
|
|
64
|
+
onFail: 'WAIT FOR BUILD', // 失敗重新 build
|
|
65
|
+
},
|
|
66
|
+
'MERGE CODE AND TAG': {
|
|
67
|
+
transitions: ['To Done'],
|
|
68
|
+
desc: '合併程式碼並打 tag',
|
|
69
|
+
nextState: 'DONE',
|
|
70
|
+
},
|
|
71
|
+
'DONE': {
|
|
72
|
+
completed: true,
|
|
73
|
+
desc: '流程完成',
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const SWITCH_EXECUTION_NODE_COMMENT_WINDOW_MS = 5 * 60 * 1000;
|
|
78
|
+
const SWITCH_EXECUTION_NODE_SETTLE_MS = 3 * 60 * 1000;
|
|
21
79
|
|
|
22
80
|
// ── Schema definitions ───────────────────────────────────────────
|
|
23
81
|
export function getGrayReleaseToolDefinitions() {
|
|
@@ -81,6 +139,60 @@ export function getGrayReleaseToolDefinitions() {
|
|
|
81
139
|
},
|
|
82
140
|
},
|
|
83
141
|
},
|
|
142
|
+
{
|
|
143
|
+
name: 'auto_grayrelease',
|
|
144
|
+
description:
|
|
145
|
+
'自動執行 GrayRelease 完整流程:PLANNING → Build → Approval → Deploy → Verify → Done。' +
|
|
146
|
+
'依環境自動處理簽核(DEV 跳過 / STG 組長簽核 / UAT 部長簽核)。支援 Build/Deploy 無限循環。',
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
required: ['issueKey'],
|
|
150
|
+
properties: {
|
|
151
|
+
issueKey: {
|
|
152
|
+
type: 'string',
|
|
153
|
+
description: 'GrayRelease 單 issue key,例如 CID-822',
|
|
154
|
+
},
|
|
155
|
+
autoVerify: {
|
|
156
|
+
type: 'boolean',
|
|
157
|
+
description: '(選填) 是否自動執行 Verify Success(預設 false,需人工確認)',
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'get_grayrelease_status',
|
|
164
|
+
description:
|
|
165
|
+
'查詢 GrayRelease 單當前狀態,並給出下一步建議。用於了解流程進度或中斷後查看位置。',
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
required: ['issueKey'],
|
|
169
|
+
properties: {
|
|
170
|
+
issueKey: {
|
|
171
|
+
type: 'string',
|
|
172
|
+
description: 'GrayRelease 單 issue key,例如 CID-822',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'continue_grayrelease',
|
|
179
|
+
description:
|
|
180
|
+
'從 GrayRelease 單當前狀態繼續執行流程。適用於流程中斷或需要從中間開始的場景。',
|
|
181
|
+
inputSchema: {
|
|
182
|
+
type: 'object',
|
|
183
|
+
required: ['issueKey'],
|
|
184
|
+
properties: {
|
|
185
|
+
issueKey: {
|
|
186
|
+
type: 'string',
|
|
187
|
+
description: 'GrayRelease 單 issue key,例如 CID-822',
|
|
188
|
+
},
|
|
189
|
+
autoVerify: {
|
|
190
|
+
type: 'boolean',
|
|
191
|
+
description: '(選填) 是否自動執行 Verify Success(預設 false,需人工確認)',
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
84
196
|
];
|
|
85
197
|
}
|
|
86
198
|
|
|
@@ -294,3 +406,672 @@ export async function handleLinkStgGrayRelease(args, {jira, notifier}) {
|
|
|
294
406
|
return error(`無法建立關聯: ${err.message}`);
|
|
295
407
|
}
|
|
296
408
|
}
|
|
409
|
+
|
|
410
|
+
// ── Handlers (NEW) ───────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 自動執行 GrayRelease 完整流程
|
|
414
|
+
*/
|
|
415
|
+
export async function handleAutoGrayRelease(args, ctx) {
|
|
416
|
+
const {issueKey} = args;
|
|
417
|
+
const {jira, notifier} = ctx;
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
await notifier.notify(issueKey, '開始自動執行 GrayRelease 流程');
|
|
421
|
+
|
|
422
|
+
const result = await executeGrayReleaseFlow(
|
|
423
|
+
issueKey,
|
|
424
|
+
{
|
|
425
|
+
autoVerify: args.autoVerify ?? false,
|
|
426
|
+
},
|
|
427
|
+
ctx,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
return ok(result);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
await notifier.notify(issueKey, `GrayRelease 流程執行失敗: ${err.message}`);
|
|
433
|
+
return error(`auto_grayrelease 失敗: ${err.message}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 查詢 GrayRelease 單當前狀態並給出建議
|
|
439
|
+
*/
|
|
440
|
+
export async function handleGetGrayReleaseStatus(args, {jira}) {
|
|
441
|
+
try {
|
|
442
|
+
const status = await getGrayReleaseStatus(args.issueKey, jira);
|
|
443
|
+
return ok(status);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
return error(`get_grayrelease_status 失敗: ${err.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 從當前狀態繼續執行 GrayRelease 流程
|
|
451
|
+
*/
|
|
452
|
+
export async function handleContinueGrayRelease(args, ctx) {
|
|
453
|
+
const {issueKey} = args;
|
|
454
|
+
const {jira, notifier} = ctx;
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
const status = await getGrayReleaseStatus(issueKey, jira);
|
|
458
|
+
|
|
459
|
+
if (status.completed) {
|
|
460
|
+
return ok({
|
|
461
|
+
issueKey,
|
|
462
|
+
message: '此 GrayRelease 單已完成(狀態: DONE),無需繼續執行',
|
|
463
|
+
currentStatus: status.currentStatus,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
await notifier.notify(
|
|
468
|
+
issueKey,
|
|
469
|
+
`從狀態 ${status.currentStatus} 繼續執行 GrayRelease 流程`,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const result = await executeGrayReleaseFlow(
|
|
473
|
+
issueKey,
|
|
474
|
+
{
|
|
475
|
+
autoVerify: args.autoVerify ?? false,
|
|
476
|
+
},
|
|
477
|
+
ctx,
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
return ok(result);
|
|
481
|
+
} catch (err) {
|
|
482
|
+
await notifier.notify(issueKey, `GrayRelease 流程執行失敗: ${err.message}`);
|
|
483
|
+
return error(`continue_grayrelease 失敗: ${err.message}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── Internal Helpers ─────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 查詢 GrayRelease 單狀態並分析下一步
|
|
491
|
+
*/
|
|
492
|
+
async function getGrayReleaseStatus(issueKey, jira) {
|
|
493
|
+
const issue = await jira.getIssue(issueKey);
|
|
494
|
+
const currentStatus = issue.fields.status.name;
|
|
495
|
+
const summary = issue.fields.summary;
|
|
496
|
+
|
|
497
|
+
// 查詢環境欄位
|
|
498
|
+
const fields = await jira.getIssueFields(issueKey, [
|
|
499
|
+
GRAY_RELEASE_FIELD_IDS.env,
|
|
500
|
+
GRAY_RELEASE_FIELD_IDS.systemCode,
|
|
501
|
+
]);
|
|
502
|
+
const environment = fields[GRAY_RELEASE_FIELD_IDS.env]?.value ?? 'unknown';
|
|
503
|
+
const systemCode = fields[GRAY_RELEASE_FIELD_IDS.systemCode]?.value ?? 'unknown';
|
|
504
|
+
|
|
505
|
+
// 查詢可用 transitions
|
|
506
|
+
const transitions = await jira.getTransitions(issueKey);
|
|
507
|
+
const availableTransitions = transitions.map((t) => t.name);
|
|
508
|
+
|
|
509
|
+
// 根據當前狀態查找流程定義
|
|
510
|
+
const flowDef = GRAYRELEASE_FLOW_MAP[currentStatus];
|
|
511
|
+
const completed = flowDef?.completed === true;
|
|
512
|
+
|
|
513
|
+
let nextSteps = [];
|
|
514
|
+
let recommendation = '';
|
|
515
|
+
|
|
516
|
+
if (completed) {
|
|
517
|
+
recommendation = '✅ GrayRelease 流程已完成';
|
|
518
|
+
} else if (flowDef) {
|
|
519
|
+
nextSteps = flowDef.transitions ?? [];
|
|
520
|
+
recommendation = `${flowDef.desc}。可執行: ${nextSteps.join(' / ')}`;
|
|
521
|
+
} else {
|
|
522
|
+
recommendation = `⚠️ 未知狀態,請檢查 Jira 或手動處理。可用 transitions: ${availableTransitions.join(', ')}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
issueKey,
|
|
527
|
+
summary,
|
|
528
|
+
currentStatus,
|
|
529
|
+
environment,
|
|
530
|
+
systemCode,
|
|
531
|
+
completed,
|
|
532
|
+
flowDefinition: flowDef,
|
|
533
|
+
nextSteps,
|
|
534
|
+
availableTransitions,
|
|
535
|
+
recommendation,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* 主執行流程:從當前狀態開始,自動執行到完成或需要人工介入
|
|
541
|
+
*/
|
|
542
|
+
async function executeGrayReleaseFlow(issueKey, options, ctx) {
|
|
543
|
+
const {jira, notifier} = ctx;
|
|
544
|
+
const log = [];
|
|
545
|
+
let buildAttempts = 0;
|
|
546
|
+
|
|
547
|
+
// 讀取環境和系統代碼
|
|
548
|
+
const fields = await jira.getIssueFields(issueKey, [
|
|
549
|
+
GRAY_RELEASE_FIELD_IDS.env,
|
|
550
|
+
GRAY_RELEASE_FIELD_IDS.systemCode,
|
|
551
|
+
]);
|
|
552
|
+
const environment = normalizeGrayReleaseEnvironment(fields[GRAY_RELEASE_FIELD_IDS.env]);
|
|
553
|
+
const systemCode = fields[GRAY_RELEASE_FIELD_IDS.systemCode]?.value;
|
|
554
|
+
|
|
555
|
+
log.push(`🚀 開始執行 GrayRelease 流程 - 環境: ${environment.toUpperCase()}`);
|
|
556
|
+
|
|
557
|
+
while (true) {
|
|
558
|
+
const issue = await jira.getIssue(issueKey);
|
|
559
|
+
const currentStatus = issue.fields.status.name;
|
|
560
|
+
const flowDef = GRAYRELEASE_FLOW_MAP[currentStatus];
|
|
561
|
+
|
|
562
|
+
log.push(`\n📍 當前狀態: ${currentStatus}`);
|
|
563
|
+
|
|
564
|
+
// 已完成
|
|
565
|
+
if (flowDef?.completed) {
|
|
566
|
+
log.push('✅ GrayRelease 流程完成!');
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// 未知狀態
|
|
571
|
+
if (!flowDef) {
|
|
572
|
+
log.push(`⚠️ 未知狀態: ${currentStatus},停止自動執行`);
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ── PLANNING → Accept ────────────────────────────────────
|
|
577
|
+
if (currentStatus === 'PLANNING') {
|
|
578
|
+
log.push(' 執行: Accept');
|
|
579
|
+
await jira.transitionByName(issueKey, 'Accept');
|
|
580
|
+
await notifier.notify(issueKey, 'Accept 需求,進入 WAIT FOR BUILD');
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ── WAIT FOR BUILD → GrayRelease Build ───────────────────
|
|
585
|
+
if (currentStatus === 'WAIT FOR BUILD') {
|
|
586
|
+
buildAttempts++;
|
|
587
|
+
|
|
588
|
+
log.push(` 執行: GrayRelease Build (第 ${buildAttempts} 次)`);
|
|
589
|
+
await jira.transitionByName(issueKey, 'GrayRelease Build');
|
|
590
|
+
await notifier.notify(issueKey, `觸發 GrayRelease Build (第 ${buildAttempts} 次)`);
|
|
591
|
+
|
|
592
|
+
// 等待 build 完成(Jenkins 會更新 CID_build_result)
|
|
593
|
+
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
|
+
});
|
|
599
|
+
|
|
600
|
+
log.push(' ✅ Build 完成');
|
|
601
|
+
|
|
602
|
+
// Build 完成後切到 WAIT APPROVAL
|
|
603
|
+
log.push(' 執行: Apply to approval');
|
|
604
|
+
await jira.transitionByName(issueKey, 'Apply to approval');
|
|
605
|
+
await notifier.notify(issueKey, 'Build 完成,申請簽核');
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ── WAIT APPROVAL → Approve ───────────────────────────────
|
|
610
|
+
if (currentStatus === 'WAIT APPROVAL') {
|
|
611
|
+
log.push(` 處理簽核流程 (環境: ${environment})`);
|
|
612
|
+
|
|
613
|
+
const approvalResult = await handleGrayReleaseApproval(
|
|
614
|
+
issueKey,
|
|
615
|
+
environment,
|
|
616
|
+
systemCode,
|
|
617
|
+
ctx,
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
if (approvalResult.skipped) {
|
|
621
|
+
log.push(` ✅ ${approvalResult.reason}`);
|
|
622
|
+
// DEV 環境直接執行 Approve
|
|
623
|
+
await jira.transitionByName(issueKey, 'Approve');
|
|
624
|
+
await notifier.notify(issueKey, 'DEV 環境無需簽核,直接 Approve');
|
|
625
|
+
} else {
|
|
626
|
+
log.push(` ✅ 簽核完成 by ${approvalResult.by}`);
|
|
627
|
+
}
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ── 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`);
|
|
657
|
+
}
|
|
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
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── VERIFY → 需要人工決定 ─────────────────────────────────
|
|
682
|
+
if (currentStatus === 'VERIFY') {
|
|
683
|
+
if (options.autoVerify) {
|
|
684
|
+
log.push(' ⚠️ autoVerify=true,自動執行 Verify Success');
|
|
685
|
+
await jira.transitionByName(issueKey, 'Verify Success');
|
|
686
|
+
await notifier.notify(issueKey, '自動驗證成功');
|
|
687
|
+
continue;
|
|
688
|
+
} else {
|
|
689
|
+
log.push(' ⏸️ 需要人工驗證結果,停止自動執行');
|
|
690
|
+
log.push(' 可執行: Verify Success(成功)/ Verify fail(失敗重新 build)');
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ── MERGE CODE AND TAG → To Done ─────────────────────────
|
|
696
|
+
if (currentStatus === 'MERGE CODE AND TAG') {
|
|
697
|
+
log.push(' 執行: To Done');
|
|
698
|
+
await jira.transitionByName(issueKey, 'To Done');
|
|
699
|
+
await notifier.notify(issueKey, 'GrayRelease 流程完成');
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// 未處理的狀態
|
|
704
|
+
log.push(`⚠️ 狀態 ${currentStatus} 沒有自動處理邏輯,停止執行`);
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const finalIssue = await jira.getIssue(issueKey);
|
|
709
|
+
return {
|
|
710
|
+
issueKey,
|
|
711
|
+
finalStatus: finalIssue.fields.status.name,
|
|
712
|
+
buildAttempts,
|
|
713
|
+
log,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* 處理簽核流程(依環境別)
|
|
719
|
+
*/
|
|
720
|
+
async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx) {
|
|
721
|
+
const {jira, notifier} = ctx;
|
|
722
|
+
const env = environment.toLowerCase();
|
|
723
|
+
|
|
724
|
+
// DEV: 跳過簽核
|
|
725
|
+
if (env === 'dev') {
|
|
726
|
+
return {skipped: true, reason: 'DEV 環境無需簽核'};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// STG: 查 wiki 值班組長 → assign → 發 jabber → 等待 approve
|
|
730
|
+
if (env === 'stg') {
|
|
731
|
+
const managerResult = await handleGetReleaseManager({}, {});
|
|
732
|
+
const managerData = parseMcpToolData(managerResult, 'get_release_manager');
|
|
733
|
+
|
|
734
|
+
if (!managerData.found) {
|
|
735
|
+
throw new Error('無法查詢 STG 值班組長,請手動處理');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const managerName = managerData.name;
|
|
739
|
+
const accountId = resolveAccountId(managerName);
|
|
740
|
+
|
|
741
|
+
if (!accountId) {
|
|
742
|
+
throw new Error(`找不到 ${managerName} 的 accountId`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Assign 給組長
|
|
746
|
+
await jira.updateAssignee(issueKey, accountId);
|
|
747
|
+
await notifier.notify(issueKey, `已指派給 STG 值班組長 ${managerName}`);
|
|
748
|
+
|
|
749
|
+
// 發送 jabber 通知
|
|
750
|
+
const jabberTo = `${accountId}@linebank.com.tw`;
|
|
751
|
+
await handleSendJabberMessage(
|
|
752
|
+
{
|
|
753
|
+
to: jabberTo,
|
|
754
|
+
message: `[GrayRelease 簽核通知] ${issueKey} 需要您的簽核。環境: STG\n${process.env.JIRA_BASE_URL}/browse/${issueKey}`,
|
|
755
|
+
},
|
|
756
|
+
{},
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
// 輪詢等待 Approve(狀態變為 WAIT DEPLOY)
|
|
760
|
+
const poller = new Poller(jira);
|
|
761
|
+
await poller.waitForStatus(issueKey, 'WAIT DEPLOY', {
|
|
762
|
+
intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
|
|
763
|
+
timeoutMs: parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000'),
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
return {approved: true, by: managerName};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// UAT: assign James Yu → 等待留言 → 轉 Solar Chen → 等待 approve
|
|
770
|
+
if (env === 'uat') {
|
|
771
|
+
const jamesAccountId = resolveAccountId('James Yu');
|
|
772
|
+
if (!jamesAccountId) {
|
|
773
|
+
throw new Error('找不到 James Yu 的 accountId');
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Assign 給 James Yu
|
|
777
|
+
await jira.updateAssignee(issueKey, jamesAccountId);
|
|
778
|
+
await notifier.notify(issueKey, '已指派給部長 James Yu,等待留言確認');
|
|
779
|
+
|
|
780
|
+
// 發送 jabber 通知給 James Yu
|
|
781
|
+
const jamesJabber = `${jamesAccountId}@linebank.com.tw`;
|
|
782
|
+
await handleSendJabberMessage(
|
|
783
|
+
{
|
|
784
|
+
to: jamesJabber,
|
|
785
|
+
message: `[GrayRelease 簽核通知] ${issueKey} 需要您的簽核並留言確認。環境: UAT\n${process.env.JIRA_BASE_URL}/browse/${issueKey}`,
|
|
786
|
+
},
|
|
787
|
+
{},
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
// 等待 James Yu 留言 "Approved"
|
|
791
|
+
const commentResult = await handleWaitForComment(
|
|
792
|
+
{
|
|
793
|
+
issueKey,
|
|
794
|
+
keyword: 'approved',
|
|
795
|
+
authorAccountId: jamesAccountId,
|
|
796
|
+
timeoutMs: parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000'),
|
|
797
|
+
intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
|
|
798
|
+
},
|
|
799
|
+
{jira},
|
|
800
|
+
);
|
|
801
|
+
const commentData = parseMcpToolData(commentResult, 'wait_for_comment');
|
|
802
|
+
|
|
803
|
+
if (!commentData.found) {
|
|
804
|
+
throw new Error('等待 James Yu 留言超時');
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
await notifier.notify(issueKey, `James Yu 已留言確認: ${commentData.comment}`);
|
|
808
|
+
|
|
809
|
+
// 轉給 Solar Chen
|
|
810
|
+
const solarAccountId = resolveAccountId('Solar Chen');
|
|
811
|
+
if (!solarAccountId) {
|
|
812
|
+
throw new Error('找不到 Solar Chen 的 accountId');
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
await jira.updateAssignee(issueKey, solarAccountId);
|
|
816
|
+
await notifier.notify(issueKey, '已轉單給 Solar Chen,等待最終簽核');
|
|
817
|
+
|
|
818
|
+
// 發送 jabber 通知給 Solar Chen
|
|
819
|
+
const solarJabber = `${solarAccountId}@linebank.com.tw`;
|
|
820
|
+
await handleSendJabberMessage(
|
|
821
|
+
{
|
|
822
|
+
to: solarJabber,
|
|
823
|
+
message: `[GrayRelease 簽核通知] ${issueKey} 已由 James Yu 確認,需要您的最終簽核。環境: UAT\n${process.env.JIRA_BASE_URL}/browse/${issueKey}`,
|
|
824
|
+
},
|
|
825
|
+
{},
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
// 輪詢等待 Solar Approve(狀態變為 WAIT DEPLOY)
|
|
829
|
+
const poller = new Poller(jira);
|
|
830
|
+
await poller.waitForStatus(issueKey, 'WAIT DEPLOY', {
|
|
831
|
+
intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
|
|
832
|
+
timeoutMs: parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000'),
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
return {approved: true, by: 'James Yu → Solar Chen'};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
throw new Error(`不支援的環境: ${environment}`);
|
|
839
|
+
}
|
|
840
|
+
|
|
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
|
+
/**
|
|
1041
|
+
* 判斷是否需要執行 Switch Execution Node
|
|
1042
|
+
* 規則:查詢同系統上次 CD 部署的環境群組,若與本次不同則需切換
|
|
1043
|
+
* 注意:不查詢 GrayRelease 歷史,因為 GrayRelease 永遠是 nonPrd
|
|
1044
|
+
*/
|
|
1045
|
+
async function needSwitchExecutionNode(issueKey, systemCode, jira) {
|
|
1046
|
+
// GrayRelease 永遠是 nonPrd 群組
|
|
1047
|
+
const thisGroup = 'nonPrd';
|
|
1048
|
+
|
|
1049
|
+
// 只查詢 CD 單的部署歷史(不包含 GrayRelease)
|
|
1050
|
+
const isPrdEnv = (env) => ['prd', 'dr', 'prd/dr', 'prd&dr'].includes(env?.toLowerCase()?.trim());
|
|
1051
|
+
|
|
1052
|
+
const jql_cd = `project = CID AND issuetype = CD AND "System Code[Select List (single choice)]" = "${systemCode}" AND status = Done AND issueKey != "${issueKey}" ORDER BY updated DESC`;
|
|
1053
|
+
|
|
1054
|
+
try {
|
|
1055
|
+
const cdResults = await jira.searchIssues(jql_cd, ['customfield_13436', 'updated'], 1);
|
|
1056
|
+
const cdIssue = cdResults[0] ?? null;
|
|
1057
|
+
|
|
1058
|
+
// 查不到歷史 → 保守執行 Switch
|
|
1059
|
+
if (!cdIssue) {
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// CD:讀 customfield_13436.value 判斷群組
|
|
1064
|
+
const envVal = cdIssue.fields?.customfield_13436?.value ?? '';
|
|
1065
|
+
const lastGroup = isPrdEnv(envVal) ? 'prd' : 'nonPrd';
|
|
1066
|
+
|
|
1067
|
+
// 群組不同 → 需要 Switch
|
|
1068
|
+
return lastGroup !== thisGroup;
|
|
1069
|
+
} catch {
|
|
1070
|
+
// 查詢失敗 → 保守執行 Switch
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function sleep(ms) {
|
|
1076
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1077
|
+
}
|