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