@jira-deploy/core 1.0.2 → 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/environments.js +1 -1
- package/constants/field-ids.js +5 -2
- package/dry-run.js +4 -0
- package/jira-client.js +13 -0
- package/package.json +1 -1
- package/poller.js +16 -3
- package/tools/cd.js +9 -5
- package/tools/grayrelease.js +1046 -15
- package/tools/index.js +322 -104
- package/tools/library.js +2 -1
- package/tools/release.js +48 -21
- package/tools/workflows.js +20 -0
- package/tools.test.js +932 -27
package/tools/grayrelease.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Gray Release 相關 tools
|
|
3
3
|
* - create_grayrelease_ticket
|
|
4
|
+
* - link_stg_grayrelease
|
|
5
|
+
* - auto_grayrelease
|
|
6
|
+
* - deploy_grayrelease
|
|
7
|
+
* - get_grayrelease_status
|
|
8
|
+
* - continue_grayrelease
|
|
4
9
|
*/
|
|
5
10
|
import {
|
|
6
11
|
SYSTEM_CODES,
|
|
@@ -16,8 +21,182 @@ import {
|
|
|
16
21
|
SIGN_VALUES,
|
|
17
22
|
NOTES_TEMPLATES,
|
|
18
23
|
JIRA_DEFAULTS,
|
|
24
|
+
resolveAccountId,
|
|
19
25
|
} from '../constants/index.js';
|
|
20
|
-
import {error, ok, today, getServerList} from './helpers.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';
|
|
30
|
+
|
|
31
|
+
// ── Flow Definition ──────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* GrayRelease 狀態流程定義
|
|
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。
|
|
53
|
+
*/
|
|
54
|
+
const GRAYRELEASE_FLOW_MAP = {
|
|
55
|
+
'PLANNING': {
|
|
56
|
+
transitions: ['Accept'],
|
|
57
|
+
desc: '接受需求開始執行',
|
|
58
|
+
nextState: 'WAIT FOR BUILD',
|
|
59
|
+
},
|
|
60
|
+
'WAIT FOR BUILD': {
|
|
61
|
+
transitions: ['GrayRelease Build'],
|
|
62
|
+
desc: 'Build 程式',
|
|
63
|
+
nextState: 'WAIT FOR BUILD', // build 完會回到同狀態
|
|
64
|
+
afterBuild: 'Apply to approval', // build 完後要切到簽核
|
|
65
|
+
},
|
|
66
|
+
'WAIT APPROVAL': {
|
|
67
|
+
transitions: ['Approve'],
|
|
68
|
+
desc: '等待主管簽核',
|
|
69
|
+
nextState: 'WAIT DEPLOY',
|
|
70
|
+
requiresApproval: true,
|
|
71
|
+
},
|
|
72
|
+
'WAIT DEPLOY': {
|
|
73
|
+
transitions: ['Switch Execution Node', 'GrayRelease Deploy', 'To Verify'],
|
|
74
|
+
desc: '部署程式',
|
|
75
|
+
nextState: 'WAIT DEPLOY', // deploy 完會回到同狀態
|
|
76
|
+
afterDeploy: 'To Verify', // deploy 完後切到驗證
|
|
77
|
+
},
|
|
78
|
+
'VERIFY': {
|
|
79
|
+
transitions: ['Verify Success', 'Verify fail'],
|
|
80
|
+
desc: '驗證部署結果',
|
|
81
|
+
onSuccess: 'MERGE CODE AND TAG',
|
|
82
|
+
onFail: 'WAIT FOR BUILD', // 失敗重新 build
|
|
83
|
+
},
|
|
84
|
+
'MERGE CODE AND TAG': {
|
|
85
|
+
transitions: ['To Done'],
|
|
86
|
+
desc: '合併程式碼並打 tag',
|
|
87
|
+
nextState: 'DONE',
|
|
88
|
+
},
|
|
89
|
+
'DONE': {
|
|
90
|
+
completed: true,
|
|
91
|
+
desc: '流程完成',
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
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
|
+
}
|
|
21
200
|
|
|
22
201
|
// ── Schema definitions ───────────────────────────────────────────
|
|
23
202
|
export function getGrayReleaseToolDefinitions() {
|
|
@@ -75,12 +254,91 @@ export function getGrayReleaseToolDefinitions() {
|
|
|
75
254
|
},
|
|
76
255
|
stgKeys: {
|
|
77
256
|
type: 'array',
|
|
78
|
-
items: {type: 'string'},
|
|
257
|
+
items: { type: 'string' },
|
|
79
258
|
description: '要關聯的 STG 灰度單 key 陣列,例如 ["CID-1234", "CID-1235"]',
|
|
80
259
|
},
|
|
81
260
|
},
|
|
82
261
|
},
|
|
83
262
|
},
|
|
263
|
+
{
|
|
264
|
+
name: 'auto_grayrelease',
|
|
265
|
+
description:
|
|
266
|
+
'自動執行 GrayRelease 完整流程:PLANNING → Build → Approval → Deploy → Verify → Done。' +
|
|
267
|
+
'依環境自動處理簽核(DEV 跳過 / STG 組長簽核 / UAT 部長簽核)。支援 Build/Deploy 無限循環。',
|
|
268
|
+
inputSchema: {
|
|
269
|
+
type: 'object',
|
|
270
|
+
required: ['issueKey'],
|
|
271
|
+
properties: {
|
|
272
|
+
issueKey: {
|
|
273
|
+
type: 'string',
|
|
274
|
+
description: 'GrayRelease 單 issue key,例如 CID-822',
|
|
275
|
+
},
|
|
276
|
+
maxBuildRetries: {
|
|
277
|
+
type: 'number',
|
|
278
|
+
description: '(選填) Build 失敗最大重試次數,預設 3',
|
|
279
|
+
},
|
|
280
|
+
autoVerify: {
|
|
281
|
+
type: 'boolean',
|
|
282
|
+
description: '(選填) 是否自動執行 Verify Success(預設 false,需人工確認)',
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
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
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: 'get_grayrelease_status',
|
|
306
|
+
description:
|
|
307
|
+
'查詢 GrayRelease 單當前狀態,並給出下一步建議。用於了解流程進度或中斷後查看位置。',
|
|
308
|
+
inputSchema: {
|
|
309
|
+
type: 'object',
|
|
310
|
+
required: ['issueKey'],
|
|
311
|
+
properties: {
|
|
312
|
+
issueKey: {
|
|
313
|
+
type: 'string',
|
|
314
|
+
description: 'GrayRelease 單 issue key,例如 CID-822',
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: 'continue_grayrelease',
|
|
321
|
+
description:
|
|
322
|
+
'從 GrayRelease 單當前狀態繼續執行流程。適用於流程中斷或需要從中間開始的場景。',
|
|
323
|
+
inputSchema: {
|
|
324
|
+
type: 'object',
|
|
325
|
+
required: ['issueKey'],
|
|
326
|
+
properties: {
|
|
327
|
+
issueKey: {
|
|
328
|
+
type: 'string',
|
|
329
|
+
description: 'GrayRelease 單 issue key,例如 CID-822',
|
|
330
|
+
},
|
|
331
|
+
maxBuildRetries: {
|
|
332
|
+
type: 'number',
|
|
333
|
+
description: '(選填) Build 失敗最大重試次數,預設 3',
|
|
334
|
+
},
|
|
335
|
+
autoVerify: {
|
|
336
|
+
type: 'boolean',
|
|
337
|
+
description: '(選填) 是否自動執行 Verify Success(預設 false,需人工確認)',
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
},
|
|
84
342
|
];
|
|
85
343
|
}
|
|
86
344
|
|
|
@@ -121,13 +379,13 @@ async function findUnlinkedStgGrayReleases(gitBranch, moduleId, jira) {
|
|
|
121
379
|
return linked?.fields?.issuetype?.id === GR_ISSUE_TYPE;
|
|
122
380
|
});
|
|
123
381
|
})
|
|
124
|
-
.map((issue) => ({key: issue.key, summary: issue.fields?.summary ?? ''}));
|
|
382
|
+
.map((issue) => ({ key: issue.key, summary: issue.fields?.summary ?? '' }));
|
|
125
383
|
}
|
|
126
384
|
|
|
127
385
|
/**
|
|
128
386
|
* 建立 Gray Release 上版單
|
|
129
387
|
*/
|
|
130
|
-
export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
|
|
388
|
+
export async function handleCreateGrayReleaseTicket(args, { jira, notifier }) {
|
|
131
389
|
try {
|
|
132
390
|
const normalizedArgs = {
|
|
133
391
|
...args,
|
|
@@ -168,14 +426,14 @@ export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
|
|
|
168
426
|
const serverList = getServerList(normalizedArgs.systemCode, envCode, false, normalizedArgs.module) || [];
|
|
169
427
|
|
|
170
428
|
const fields = {
|
|
171
|
-
project: {key: JIRA_PROJECT_ID},
|
|
172
|
-
issuetype: {id: ISSUE_TYPE_IDS.GrayRelease},
|
|
429
|
+
project: { key: JIRA_PROJECT_ID },
|
|
430
|
+
issuetype: { id: ISSUE_TYPE_IDS.GrayRelease },
|
|
173
431
|
summary: normalizedArgs.summary ?? autoSummary,
|
|
174
432
|
duedate: today(),
|
|
175
433
|
};
|
|
176
434
|
|
|
177
435
|
// systemCode
|
|
178
|
-
fields[GRAY_RELEASE_FIELD_IDS.systemCode] = {value: normalizedArgs.systemCode};
|
|
436
|
+
fields[GRAY_RELEASE_FIELD_IDS.systemCode] = { value: normalizedArgs.systemCode };
|
|
179
437
|
|
|
180
438
|
// sign fields
|
|
181
439
|
fields[GRAY_RELEASE_FIELD_IDS.deptManagerSign] = SIGN_VALUES.deptManagerSign;
|
|
@@ -184,17 +442,17 @@ export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
|
|
|
184
442
|
|
|
185
443
|
// env
|
|
186
444
|
if (ENV_CODES[envCode]) {
|
|
187
|
-
fields[GRAY_RELEASE_FIELD_IDS.env] = {value: envCode};
|
|
445
|
+
fields[GRAY_RELEASE_FIELD_IDS.env] = { value: envCode };
|
|
188
446
|
}
|
|
189
447
|
|
|
190
448
|
// dept code
|
|
191
449
|
if (deptStr && DEPT_CODES[deptStr]) {
|
|
192
|
-
fields[GRAY_RELEASE_FIELD_IDS.deptCode] = {value: deptStr};
|
|
450
|
+
fields[GRAY_RELEASE_FIELD_IDS.deptCode] = { value: deptStr };
|
|
193
451
|
}
|
|
194
452
|
|
|
195
453
|
// system module
|
|
196
454
|
if (moduleId) {
|
|
197
|
-
fields[GRAY_RELEASE_FIELD_IDS.systemModule] = {id: moduleId};
|
|
455
|
+
fields[GRAY_RELEASE_FIELD_IDS.systemModule] = { id: moduleId };
|
|
198
456
|
}
|
|
199
457
|
|
|
200
458
|
// jenkins branch
|
|
@@ -208,7 +466,7 @@ export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
|
|
|
208
466
|
fields[GRAY_RELEASE_FIELD_IDS.grayReleaseVersion] = grayReleaseVersion;
|
|
209
467
|
|
|
210
468
|
// cluster deploy (always true)
|
|
211
|
-
fields[GRAY_RELEASE_FIELD_IDS.clusterDeploy] = {id: FIELD_OPTIONS.clusterDeploy.true};
|
|
469
|
+
fields[GRAY_RELEASE_FIELD_IDS.clusterDeploy] = { id: FIELD_OPTIONS.clusterDeploy.true };
|
|
212
470
|
|
|
213
471
|
// cluster list
|
|
214
472
|
fields[GRAY_RELEASE_FIELD_IDS.clusterList] = serverList.join('\n');
|
|
@@ -217,7 +475,7 @@ export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
|
|
|
217
475
|
fields[GRAY_RELEASE_FIELD_IDS.grayReleaseNotes] = NOTES_TEMPLATES.grayRelease;
|
|
218
476
|
|
|
219
477
|
if (normalizedArgs.dryRun) {
|
|
220
|
-
return ok({dryRun: true, summary: fields.summary, grayReleaseVersion, fields});
|
|
478
|
+
return ok({ dryRun: true, summary: fields.summary, grayReleaseVersion, fields });
|
|
221
479
|
}
|
|
222
480
|
|
|
223
481
|
const issue = await jira.createIssue(fields);
|
|
@@ -258,9 +516,9 @@ export async function handleCreateGrayReleaseTicket(args, {jira, notifier}) {
|
|
|
258
516
|
/**
|
|
259
517
|
* 將 STG 灰度單關聯(relates to)到 UAT 灰度單
|
|
260
518
|
*/
|
|
261
|
-
export async function handleLinkStgGrayRelease(args, {jira, notifier}) {
|
|
519
|
+
export async function handleLinkStgGrayRelease(args, { jira, notifier }) {
|
|
262
520
|
try {
|
|
263
|
-
const {uatKey, stgKeys} = args;
|
|
521
|
+
const { uatKey, stgKeys } = args;
|
|
264
522
|
if (!Array.isArray(stgKeys) || stgKeys.length === 0) {
|
|
265
523
|
return error('stgKeys 不可為空');
|
|
266
524
|
}
|
|
@@ -273,7 +531,7 @@ export async function handleLinkStgGrayRelease(args, {jira, notifier}) {
|
|
|
273
531
|
await jira.linkIssue(stgKey, uatKey, 'Relates');
|
|
274
532
|
linked.push(stgKey);
|
|
275
533
|
} catch (e) {
|
|
276
|
-
failed.push({key: stgKey, reason: e.message});
|
|
534
|
+
failed.push({ key: stgKey, reason: e.message });
|
|
277
535
|
}
|
|
278
536
|
}
|
|
279
537
|
|
|
@@ -294,3 +552,776 @@ export async function handleLinkStgGrayRelease(args, {jira, notifier}) {
|
|
|
294
552
|
return error(`無法建立關聯: ${err.message}`);
|
|
295
553
|
}
|
|
296
554
|
}
|
|
555
|
+
|
|
556
|
+
// ── Handlers (NEW) ───────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 自動執行 GrayRelease 完整流程
|
|
560
|
+
*/
|
|
561
|
+
export async function handleAutoGrayRelease(args, ctx) {
|
|
562
|
+
const { issueKey } = args;
|
|
563
|
+
const { jira, notifier } = ctx;
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
progress(ctx, {
|
|
567
|
+
phase: 'action',
|
|
568
|
+
title: '開始 GrayRelease 自動流程',
|
|
569
|
+
issueKey,
|
|
570
|
+
});
|
|
571
|
+
await notifier.notify(issueKey, '開始自動執行 GrayRelease 流程');
|
|
572
|
+
|
|
573
|
+
const result = await executeGrayReleaseFlow(
|
|
574
|
+
issueKey,
|
|
575
|
+
{
|
|
576
|
+
maxBuildRetries: args.maxBuildRetries ?? 3,
|
|
577
|
+
autoVerify: args.autoVerify ?? false,
|
|
578
|
+
},
|
|
579
|
+
ctx,
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
return ok(result);
|
|
583
|
+
} catch (err) {
|
|
584
|
+
await notifier.notify(issueKey, `GrayRelease 流程執行失敗: ${err.message}`);
|
|
585
|
+
return error(`auto_grayrelease 失敗: ${err.message}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 查詢 GrayRelease 單當前狀態並給出建議
|
|
591
|
+
*/
|
|
592
|
+
export async function handleGetGrayReleaseStatus(args, { jira }) {
|
|
593
|
+
try {
|
|
594
|
+
const status = await getGrayReleaseStatus(args.issueKey, jira);
|
|
595
|
+
return ok(status);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
return error(`get_grayrelease_status 失敗: ${err.message}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
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
|
+
|
|
623
|
+
/**
|
|
624
|
+
* 從當前狀態繼續執行 GrayRelease 流程
|
|
625
|
+
*/
|
|
626
|
+
export async function handleContinueGrayRelease(args, ctx) {
|
|
627
|
+
const { issueKey } = args;
|
|
628
|
+
const { jira, notifier } = ctx;
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
const status = await getGrayReleaseStatus(issueKey, jira);
|
|
632
|
+
|
|
633
|
+
if (status.completed) {
|
|
634
|
+
progress(ctx, {
|
|
635
|
+
phase: 'done',
|
|
636
|
+
title: 'GrayRelease 已完成',
|
|
637
|
+
issueKey,
|
|
638
|
+
currentStatus: status.currentStatus,
|
|
639
|
+
});
|
|
640
|
+
return ok({
|
|
641
|
+
issueKey,
|
|
642
|
+
message: '此 GrayRelease 單已完成(狀態: DONE),無需繼續執行',
|
|
643
|
+
currentStatus: status.currentStatus,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
await notifier.notify(
|
|
648
|
+
issueKey,
|
|
649
|
+
`從狀態 ${status.currentStatus} 繼續執行 GrayRelease 流程`,
|
|
650
|
+
);
|
|
651
|
+
progress(ctx, {
|
|
652
|
+
phase: 'action',
|
|
653
|
+
title: '繼續 GrayRelease 流程',
|
|
654
|
+
issueKey,
|
|
655
|
+
currentStatus: status.currentStatus,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const result = await executeGrayReleaseFlow(
|
|
659
|
+
issueKey,
|
|
660
|
+
{
|
|
661
|
+
maxBuildRetries: args.maxBuildRetries ?? 3,
|
|
662
|
+
autoVerify: args.autoVerify ?? false,
|
|
663
|
+
},
|
|
664
|
+
ctx,
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
return ok(result);
|
|
668
|
+
} catch (err) {
|
|
669
|
+
await notifier.notify(issueKey, `GrayRelease 流程執行失敗: ${err.message}`);
|
|
670
|
+
return error(`continue_grayrelease 失敗: ${err.message}`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── Internal Helpers ─────────────────────────────────────────────
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* 查詢 GrayRelease 單狀態並分析下一步
|
|
678
|
+
*/
|
|
679
|
+
async function getGrayReleaseStatus(issueKey, jira) {
|
|
680
|
+
const issue = await jira.getIssue(issueKey);
|
|
681
|
+
const currentStatus = issue.fields.status.name;
|
|
682
|
+
const normalizedStatus = normalizeStatusName(currentStatus);
|
|
683
|
+
const summary = issue.fields.summary;
|
|
684
|
+
|
|
685
|
+
// 查詢環境欄位
|
|
686
|
+
const fields = await jira.getIssueFields(issueKey, [
|
|
687
|
+
GRAY_RELEASE_FIELD_IDS.env,
|
|
688
|
+
GRAY_RELEASE_FIELD_IDS.systemCode,
|
|
689
|
+
]);
|
|
690
|
+
const environment = fields[GRAY_RELEASE_FIELD_IDS.env]?.value ?? 'unknown';
|
|
691
|
+
const systemCode = fields[GRAY_RELEASE_FIELD_IDS.systemCode]?.value ?? 'unknown';
|
|
692
|
+
|
|
693
|
+
// 查詢可用 transitions
|
|
694
|
+
const transitions = await jira.getTransitions(issueKey);
|
|
695
|
+
const availableTransitions = transitions.map((t) => t.name);
|
|
696
|
+
|
|
697
|
+
// 根據當前狀態查找流程定義
|
|
698
|
+
const flowDef = GRAYRELEASE_FLOW_MAP[normalizedStatus];
|
|
699
|
+
const completed = flowDef?.completed === true;
|
|
700
|
+
|
|
701
|
+
let nextSteps = [];
|
|
702
|
+
let recommendation = '';
|
|
703
|
+
|
|
704
|
+
if (completed) {
|
|
705
|
+
recommendation = '✅ GrayRelease 流程已完成';
|
|
706
|
+
} else if (flowDef) {
|
|
707
|
+
nextSteps = flowDef.transitions ?? [];
|
|
708
|
+
recommendation = `${flowDef.desc}。可執行: ${nextSteps.join(' / ')}`;
|
|
709
|
+
} else {
|
|
710
|
+
recommendation = `⚠️ 未知狀態,請檢查 Jira 或手動處理。可用 transitions: ${availableTransitions.join(', ')}`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
issueKey,
|
|
715
|
+
summary,
|
|
716
|
+
currentStatus,
|
|
717
|
+
normalizedStatus,
|
|
718
|
+
environment,
|
|
719
|
+
systemCode,
|
|
720
|
+
completed,
|
|
721
|
+
flowDefinition: flowDef,
|
|
722
|
+
nextSteps,
|
|
723
|
+
availableTransitions,
|
|
724
|
+
recommendation,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
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
|
+
|
|
830
|
+
/**
|
|
831
|
+
* 主執行流程:從當前狀態開始,自動執行到完成或需要人工介入
|
|
832
|
+
*/
|
|
833
|
+
async function executeGrayReleaseFlow(issueKey, options, ctx) {
|
|
834
|
+
const { jira, notifier } = ctx;
|
|
835
|
+
const log = [];
|
|
836
|
+
let buildAttempts = 0;
|
|
837
|
+
|
|
838
|
+
// 讀取環境和系統代碼
|
|
839
|
+
const fields = await jira.getIssueFields(issueKey, [
|
|
840
|
+
GRAY_RELEASE_FIELD_IDS.env,
|
|
841
|
+
GRAY_RELEASE_FIELD_IDS.systemCode,
|
|
842
|
+
]);
|
|
843
|
+
const environment = fields[GRAY_RELEASE_FIELD_IDS.env]?.value;
|
|
844
|
+
const systemCode = fields[GRAY_RELEASE_FIELD_IDS.systemCode]?.value;
|
|
845
|
+
|
|
846
|
+
if (!environment) {
|
|
847
|
+
throw new Error('無法讀取 GrayRelease 環境欄位');
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
log.push(`🚀 開始執行 GrayRelease 流程 - 環境: ${environment.toUpperCase()}`);
|
|
851
|
+
|
|
852
|
+
while (true) {
|
|
853
|
+
const issue = await jira.getIssue(issueKey);
|
|
854
|
+
const currentStatus = issue.fields.status.name;
|
|
855
|
+
const normalizedStatus = normalizeStatusName(currentStatus);
|
|
856
|
+
const flowDef = GRAYRELEASE_FLOW_MAP[normalizedStatus];
|
|
857
|
+
|
|
858
|
+
log.push(`\n📍 當前狀態: ${currentStatus}`);
|
|
859
|
+
progress(ctx, {
|
|
860
|
+
phase: 'action',
|
|
861
|
+
title: '檢查 GrayRelease 狀態',
|
|
862
|
+
issueKey,
|
|
863
|
+
currentStatus,
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// 已完成
|
|
867
|
+
if (flowDef?.completed) {
|
|
868
|
+
log.push('✅ GrayRelease 流程完成!');
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// 未知狀態
|
|
873
|
+
if (!flowDef) {
|
|
874
|
+
log.push(`⚠️ 未知狀態: ${currentStatus},停止自動執行`);
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ── PLANNING → Accept ────────────────────────────────────
|
|
879
|
+
if (normalizedStatus === 'PLANNING') {
|
|
880
|
+
log.push(' 執行: Accept');
|
|
881
|
+
await jira.transitionByName(issueKey, 'Accept');
|
|
882
|
+
await notifier.notify(issueKey, 'Accept 需求,進入 WAIT FOR BUILD');
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ── WAIT FOR BUILD → GrayRelease Build ───────────────────
|
|
887
|
+
if (normalizedStatus === 'WAIT FOR BUILD') {
|
|
888
|
+
buildAttempts++;
|
|
889
|
+
if (buildAttempts > options.maxBuildRetries) {
|
|
890
|
+
log.push(`⚠️ Build 已達最大重試次數 (${options.maxBuildRetries}),停止執行`);
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
log.push(` 執行: GrayRelease Build (第 ${buildAttempts} 次)`);
|
|
895
|
+
await jira.transitionByName(issueKey, 'GrayRelease Build');
|
|
896
|
+
await notifier.notify(issueKey, `觸發 GrayRelease Build (第 ${buildAttempts} 次)`);
|
|
897
|
+
|
|
898
|
+
// 等待 build 完成
|
|
899
|
+
log.push(' ⏳ 等待 Jenkins Build 完成...');
|
|
900
|
+
await waitForGrayReleaseResult(
|
|
901
|
+
issueKey,
|
|
902
|
+
GRAY_RELEASE_FIELD_IDS.buildResult,
|
|
903
|
+
'GrayRelease Build',
|
|
904
|
+
jira,
|
|
905
|
+
ctx.progress,
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
log.push(' ✅ Build 完成');
|
|
909
|
+
|
|
910
|
+
// Build 完成後切到 WAIT APPROVAL
|
|
911
|
+
log.push(' 執行: Apply to approval');
|
|
912
|
+
await jira.transitionByName(issueKey, 'Apply to approval');
|
|
913
|
+
await notifier.notify(issueKey, 'Build 完成,申請簽核');
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ── WAIT APPROVAL → Approve ───────────────────────────────
|
|
918
|
+
if (normalizedStatus === 'WAIT APPROVAL') {
|
|
919
|
+
log.push(` 處理簽核流程 (環境: ${environment})`);
|
|
920
|
+
|
|
921
|
+
const approvalResult = await handleGrayReleaseApproval(
|
|
922
|
+
issueKey,
|
|
923
|
+
environment,
|
|
924
|
+
systemCode,
|
|
925
|
+
ctx,
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
if (approvalResult.skipped) {
|
|
929
|
+
log.push(` ✅ ${approvalResult.reason}`);
|
|
930
|
+
// DEV 環境直接執行 Approve
|
|
931
|
+
await jira.transitionByName(issueKey, 'Approve');
|
|
932
|
+
await notifier.notify(issueKey, 'DEV 環境無需簽核,直接 Approve');
|
|
933
|
+
} else {
|
|
934
|
+
log.push(` ✅ 簽核完成 by ${approvalResult.by}`);
|
|
935
|
+
}
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// ── WAIT DEPLOY → Switch / Deploy ─────────────────────────
|
|
940
|
+
if (normalizedStatus === 'WAIT DEPLOY') {
|
|
941
|
+
const deployCompleted = await runGrayReleaseDeployStep(issueKey, systemCode, ctx, log);
|
|
942
|
+
if (!deployCompleted) {
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ── VERIFY → 需要人工決定 ─────────────────────────────────
|
|
949
|
+
if (normalizedStatus === 'VERIFY') {
|
|
950
|
+
if (options.autoVerify) {
|
|
951
|
+
log.push(' ⚠️ autoVerify=true,自動執行 Verify Success');
|
|
952
|
+
await jira.transitionByName(issueKey, 'Verify Success');
|
|
953
|
+
await notifier.notify(issueKey, '自動驗證成功');
|
|
954
|
+
continue;
|
|
955
|
+
} else {
|
|
956
|
+
log.push(' ⏸️ 需要人工驗證結果,停止自動執行');
|
|
957
|
+
log.push(' 可執行: Verify Success(成功)/ Verify fail(失敗重新 build)');
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ── MERGE CODE AND TAG → To Done ─────────────────────────
|
|
963
|
+
if (normalizedStatus === 'MERGE CODE AND TAG') {
|
|
964
|
+
log.push(' 執行: To Done');
|
|
965
|
+
await jira.transitionByName(issueKey, 'To Done');
|
|
966
|
+
await notifier.notify(issueKey, 'GrayRelease 流程完成');
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// 未處理的狀態
|
|
971
|
+
log.push(`⚠️ 狀態 ${currentStatus} 沒有自動處理邏輯,停止執行`);
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const finalIssue = await jira.getIssue(issueKey);
|
|
976
|
+
return {
|
|
977
|
+
issueKey,
|
|
978
|
+
finalStatus: finalIssue.fields.status.name,
|
|
979
|
+
buildAttempts,
|
|
980
|
+
log,
|
|
981
|
+
};
|
|
982
|
+
}
|
|
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
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* 處理簽核流程(依環境別)
|
|
1087
|
+
*/
|
|
1088
|
+
async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx) {
|
|
1089
|
+
const { jira, notifier } = ctx;
|
|
1090
|
+
const env = environment.toLowerCase();
|
|
1091
|
+
|
|
1092
|
+
// DEV: 跳過簽核
|
|
1093
|
+
if (env === 'dev') {
|
|
1094
|
+
return { skipped: true, reason: 'DEV 環境無需簽核' };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// STG: 查 wiki 值班組長 → assign → 發 jabber → 等待 approve
|
|
1098
|
+
if (env === 'stg') {
|
|
1099
|
+
const managerResult = await handleGetReleaseManager({}, {});
|
|
1100
|
+
const managerData = parseToolResult(managerResult);
|
|
1101
|
+
|
|
1102
|
+
if (!managerData?.found) {
|
|
1103
|
+
throw new Error('無法查詢 STG 值班組長,請手動處理');
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const managerName = managerData.name;
|
|
1107
|
+
const accountId = resolveAccountId(managerName);
|
|
1108
|
+
|
|
1109
|
+
if (!accountId) {
|
|
1110
|
+
throw new Error(`找不到 ${managerName} 的 accountId`);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Assign 給組長
|
|
1114
|
+
progress(ctx, {
|
|
1115
|
+
phase: 'action',
|
|
1116
|
+
title: '指派 GrayRelease 簽核人',
|
|
1117
|
+
detail: `STG 值班組長 ${managerName}`,
|
|
1118
|
+
issueKey,
|
|
1119
|
+
});
|
|
1120
|
+
await jira.updateAssignee(issueKey, accountId);
|
|
1121
|
+
await notifier.notify(issueKey, `已指派給 STG 值班組長 ${managerName}`);
|
|
1122
|
+
|
|
1123
|
+
// 發送 jabber 通知
|
|
1124
|
+
const jabberTo = `${accountId}@linebank.com.tw`;
|
|
1125
|
+
progress(ctx, {
|
|
1126
|
+
phase: 'waiting',
|
|
1127
|
+
title: '發送 GrayRelease 簽核通知',
|
|
1128
|
+
detail: `to ${managerName} (${jabberTo})`,
|
|
1129
|
+
issueKey,
|
|
1130
|
+
});
|
|
1131
|
+
await handleSendJabberMessage(
|
|
1132
|
+
{
|
|
1133
|
+
to: jabberTo,
|
|
1134
|
+
message: `[GrayRelease 簽核通知] ${issueKey} 需要您的簽核。環境: STG\n${process.env.JIRA_BASE_URL}/browse/${issueKey}`,
|
|
1135
|
+
},
|
|
1136
|
+
{},
|
|
1137
|
+
);
|
|
1138
|
+
|
|
1139
|
+
// 輪詢等待 Approve(狀態變為 WAIT DEPLOY)
|
|
1140
|
+
const poller = new Poller(jira);
|
|
1141
|
+
await poller.waitForStatus(issueKey, 'WAIT DEPLOY', {
|
|
1142
|
+
intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
|
|
1143
|
+
timeoutMs: parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000'),
|
|
1144
|
+
onProgress: ctx.progress,
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
return { approved: true, by: managerName };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// UAT: assign James Yu → 等待留言 → 轉 Solar Chen → 等待 approve
|
|
1151
|
+
if (env === 'uat') {
|
|
1152
|
+
const jamesAccountId = resolveAccountId('James Yu');
|
|
1153
|
+
if (!jamesAccountId) {
|
|
1154
|
+
throw new Error('找不到 James Yu 的 accountId');
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Assign 給 James Yu
|
|
1158
|
+
progress(ctx, {
|
|
1159
|
+
phase: 'action',
|
|
1160
|
+
title: '指派 GrayRelease 簽核人',
|
|
1161
|
+
detail: '部長 James Yu',
|
|
1162
|
+
issueKey,
|
|
1163
|
+
});
|
|
1164
|
+
await jira.updateAssignee(issueKey, jamesAccountId);
|
|
1165
|
+
await notifier.notify(issueKey, '已指派給部長 James Yu,等待留言確認');
|
|
1166
|
+
|
|
1167
|
+
// 發送 jabber 通知給 James Yu
|
|
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
|
+
});
|
|
1175
|
+
await handleSendJabberMessage(
|
|
1176
|
+
{
|
|
1177
|
+
to: jamesJabber,
|
|
1178
|
+
message: `[GrayRelease 簽核通知] ${issueKey} 需要您的簽核並留言確認。環境: UAT\n${process.env.JIRA_BASE_URL}/browse/${issueKey}`,
|
|
1179
|
+
},
|
|
1180
|
+
{},
|
|
1181
|
+
);
|
|
1182
|
+
|
|
1183
|
+
// 等待 James Yu 留言 "Approved"
|
|
1184
|
+
const commentResult = await handleWaitForComment(
|
|
1185
|
+
{
|
|
1186
|
+
issueKey,
|
|
1187
|
+
keyword: 'approved',
|
|
1188
|
+
authorAccountId: jamesAccountId,
|
|
1189
|
+
timeoutMs: parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000'),
|
|
1190
|
+
intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
|
|
1191
|
+
},
|
|
1192
|
+
{ jira, progress: ctx.progress },
|
|
1193
|
+
);
|
|
1194
|
+
const commentData = parseToolResult(commentResult);
|
|
1195
|
+
|
|
1196
|
+
if (!commentData?.found) {
|
|
1197
|
+
throw new Error('等待 James Yu 留言超時');
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
await notifier.notify(issueKey, `James Yu 已留言確認: ${commentData.comment}`);
|
|
1201
|
+
|
|
1202
|
+
// 轉給 Solar Chen
|
|
1203
|
+
const solarAccountId = resolveAccountId('Solar Chen');
|
|
1204
|
+
if (!solarAccountId) {
|
|
1205
|
+
throw new Error('找不到 Solar Chen 的 accountId');
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
progress(ctx, {
|
|
1209
|
+
phase: 'action',
|
|
1210
|
+
title: '指派 GrayRelease 簽核人',
|
|
1211
|
+
detail: 'Solar Chen',
|
|
1212
|
+
issueKey,
|
|
1213
|
+
});
|
|
1214
|
+
await jira.updateAssignee(issueKey, solarAccountId);
|
|
1215
|
+
await notifier.notify(issueKey, '已轉單給 Solar Chen,等待最終簽核');
|
|
1216
|
+
|
|
1217
|
+
// 發送 jabber 通知給 Solar Chen
|
|
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
|
+
});
|
|
1225
|
+
await handleSendJabberMessage(
|
|
1226
|
+
{
|
|
1227
|
+
to: solarJabber,
|
|
1228
|
+
message: `[GrayRelease 簽核通知] ${issueKey} 已由 James Yu 確認,需要您的最終簽核。環境: UAT\n${process.env.JIRA_BASE_URL}/browse/${issueKey}`,
|
|
1229
|
+
},
|
|
1230
|
+
{},
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
// 輪詢等待 Solar Approve(狀態變為 WAIT DEPLOY)
|
|
1234
|
+
const poller = new Poller(jira);
|
|
1235
|
+
await poller.waitForStatus(issueKey, 'WAIT DEPLOY', {
|
|
1236
|
+
intervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '30000'),
|
|
1237
|
+
timeoutMs: parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000'),
|
|
1238
|
+
onProgress: ctx.progress,
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
return { approved: true, by: 'James Yu → Solar Chen' };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
throw new Error(`不支援的環境: ${environment}`);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* 判斷是否需要執行 Switch Execution Node
|
|
1249
|
+
* 規則:查詢同系統上次 CD 部署的環境群組,若與本次不同則需切換
|
|
1250
|
+
* 注意:不查詢 GrayRelease 歷史,因為 GrayRelease 永遠是 nonPrd
|
|
1251
|
+
*/
|
|
1252
|
+
async function needSwitchExecutionNode(issueKey, systemCode, jira) {
|
|
1253
|
+
if (await hasRecentSwitchExecutionNodeComment(issueKey, systemCode, jira)) {
|
|
1254
|
+
return false;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// GrayRelease 永遠是 nonPrd 群組
|
|
1258
|
+
const thisGroup = 'nonPrd';
|
|
1259
|
+
|
|
1260
|
+
// 只查詢 CD 單的部署歷史(不包含 GrayRelease)
|
|
1261
|
+
const isPrdEnv = (env) => ['prd', 'dr', 'prd/dr', 'prd&dr'].includes(env?.toLowerCase()?.trim());
|
|
1262
|
+
|
|
1263
|
+
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`;
|
|
1264
|
+
|
|
1265
|
+
try {
|
|
1266
|
+
const cdResults = await jira.searchIssues(jql_cd, ['customfield_13436', 'updated'], 1);
|
|
1267
|
+
const cdIssue = cdResults[0] ?? null;
|
|
1268
|
+
|
|
1269
|
+
// 查不到歷史 → 保守執行 Switch
|
|
1270
|
+
if (!cdIssue) {
|
|
1271
|
+
return true;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// CD:讀 customfield_13436.value 判斷群組
|
|
1275
|
+
const envVal = cdIssue.fields?.customfield_13436?.value ?? '';
|
|
1276
|
+
const lastGroup = isPrdEnv(envVal) ? 'prd' : 'nonPrd';
|
|
1277
|
+
|
|
1278
|
+
// 群組不同 → 需要 Switch
|
|
1279
|
+
return lastGroup !== thisGroup;
|
|
1280
|
+
} catch {
|
|
1281
|
+
// 查詢失敗 → 保守執行 Switch
|
|
1282
|
+
return true;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
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
|
+
|
|
1325
|
+
function sleep(ms) {
|
|
1326
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1327
|
+
}
|