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