@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.
- package/constants/defaults.js +1 -1
- package/constants/field-ids.js +4 -4
- package/package.json +1 -1
- package/poller.js +16 -3
- package/tools/cd.js +6 -4
- package/tools/grayrelease.js +546 -296
- package/tools/index.js +171 -106
- package/tools/release.js +48 -21
- package/tools/workflows.js +20 -0
- package/tools.test.js +444 -97
package/tools/grayrelease.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Gray Release 相關 tools
|
|
3
3
|
* - create_grayrelease_ticket
|
|
4
4
|
* - link_stg_grayrelease
|
|
5
|
-
* - auto_grayrelease
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
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
|
-
|
|
78
|
-
|
|
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[
|
|
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 =
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
898
|
+
// 等待 build 完成
|
|
593
899
|
log.push(' ⏳ 等待 Jenkins Build 完成...');
|
|
594
|
-
await
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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 (
|
|
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 (
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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 (
|
|
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 (
|
|
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 =
|
|
1100
|
+
const managerData = parseToolResult(managerResult);
|
|
733
1101
|
|
|
734
|
-
if (!managerData
|
|
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 =
|
|
1194
|
+
const commentData = parseToolResult(commentResult);
|
|
802
1195
|
|
|
803
|
-
if (!commentData
|
|
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
|
}
|