@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/jabber.js
CHANGED
|
@@ -7,6 +7,7 @@ import fs from 'fs';
|
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import {fileURLToPath} from 'url';
|
|
9
9
|
import {error, ok} from './helpers.js';
|
|
10
|
+
import {getRuntimeConfigValue} from '../constants/config.js';
|
|
10
11
|
|
|
11
12
|
const packageScriptPath = path.resolve(
|
|
12
13
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -32,8 +33,9 @@ export function resolveJabberNotifyScriptPath({
|
|
|
32
33
|
execPath = process.execPath,
|
|
33
34
|
cwd = process.cwd(),
|
|
34
35
|
} = {}) {
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
const configuredScript = getRuntimeConfigValue('JABBER_NOTIFY_SCRIPT') ?? env.JABBER_NOTIFY_SCRIPT;
|
|
37
|
+
if (configuredScript) {
|
|
38
|
+
return configuredScript;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
const resolvedExecPath = realpathOrNull(execPath);
|
|
@@ -91,14 +93,20 @@ export async function handleSendJabberMessage(args, _ctx) {
|
|
|
91
93
|
sent: false,
|
|
92
94
|
dryRun: true,
|
|
93
95
|
message: args.message,
|
|
94
|
-
to: args.to ??
|
|
95
|
-
room: args.room ??
|
|
96
|
-
preview: `[DRY RUN] 將發送 Jabber 給 ${args.to ??
|
|
96
|
+
to: args.to ?? getRuntimeConfigValue('JABBER_TO') ?? '(JABBER_TO not set)',
|
|
97
|
+
room: args.room ?? getRuntimeConfigValue('JABBER_ROOM'),
|
|
98
|
+
preview: `[DRY RUN] 將發送 Jabber 給 ${args.to ?? getRuntimeConfigValue('JABBER_TO') ?? '?'}: ${args.message}`,
|
|
97
99
|
});
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
// 允許透過 tool 參數覆蓋 JABBER_TO / JABBER_ROOM
|
|
101
103
|
const env = {...process.env};
|
|
104
|
+
for (const key of ['JABBER_SERVER', 'JABBER_USER', 'JABBER_DOMAIN', 'JABBER_KEYCHAIN_SERVICE', 'JABBER_KEYCHAIN_ACCOUNT', 'JABBER_TO', 'JABBER_ROOM', 'JABBER_PORT', 'JABBER_RESOURCE', 'JABBER_NICK']) {
|
|
105
|
+
const value = getRuntimeConfigValue(key);
|
|
106
|
+
if (value !== undefined) {
|
|
107
|
+
env[key] = value;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
102
110
|
if (args.to) env.JABBER_TO = args.to;
|
|
103
111
|
if (args.room) env.JABBER_ROOM = args.room;
|
|
104
112
|
|
package/tools/library.js
CHANGED
|
@@ -1,26 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Library Release 相關 tools
|
|
3
3
|
* - create_library_ticket
|
|
4
|
+
* - build_library
|
|
4
5
|
*/
|
|
5
6
|
import {
|
|
6
|
-
|
|
7
|
+
DEPT_CODES,
|
|
7
8
|
ENV_CODES,
|
|
8
|
-
|
|
9
|
+
FIELD_OPTIONS,
|
|
9
10
|
ISSUE_TYPE_IDS,
|
|
10
11
|
JIRA_PROJECT_ID,
|
|
11
12
|
LIBRARY_FIELD_IDS,
|
|
12
|
-
|
|
13
|
-
DEPT_CODES,
|
|
14
|
-
SYSTEM_CODE_JIRA_IDS,
|
|
15
|
-
FIELD_OPTIONS,
|
|
16
|
-
MODULE_TO_REPO_MAP,
|
|
17
|
-
REPO_MAPS,
|
|
13
|
+
LIBRARY_MODULE_IDS,
|
|
18
14
|
MODULE_MAP,
|
|
15
|
+
MODULE_TO_REPO_MAP,
|
|
19
16
|
REPO_LABEL_MAP,
|
|
17
|
+
REPO_MAPS,
|
|
18
|
+
SYSTEM_CODE_JIRA_IDS,
|
|
19
|
+
SYSTEM_CODES,
|
|
20
|
+
SYSTEM_TO_DEPT_MAP,
|
|
20
21
|
} from '../constants/index.js';
|
|
21
|
-
import {
|
|
22
|
+
import {getRuntimeConfigValue} from '../constants/config.js';
|
|
23
|
+
import { error, getPollIntervalMs, getPollTimeoutMs, ok, today } from './helpers.js';
|
|
24
|
+
import { assertNoOpenPRBeforeCreate } from './branch-prs.js';
|
|
25
|
+
import {
|
|
26
|
+
findAnyTransition as findAnyTransitionForIssue,
|
|
27
|
+
sleep,
|
|
28
|
+
waitForAnyTransition as waitForAnyTransitionForIssue,
|
|
29
|
+
} from './transition-helpers.js';
|
|
30
|
+
|
|
31
|
+
export { REPO_MAPS, MODULE_MAP, REPO_LABEL_MAP };
|
|
32
|
+
|
|
33
|
+
// ── Flow Definition ──────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Library Release 狀態流程定義
|
|
37
|
+
*
|
|
38
|
+
* 完整流程:
|
|
39
|
+
* TO DO → (Upload Lib Report) → UPLOAD LIB REPORT
|
|
40
|
+
* → (Apply for approval) → WAIT APPROVAL
|
|
41
|
+
* → (Approved) → WAIT FOR LIB BUILD
|
|
42
|
+
* → (Build) → RELEASED
|
|
43
|
+
*
|
|
44
|
+
* Jira Automation 可能在前置狀態推進後自動觸發 Jenkins,
|
|
45
|
+
* 此時不一定會出現手動 Build transition。
|
|
46
|
+
*/
|
|
47
|
+
const LIBRARY_BUILD_TRANSITIONS = ['Build'];
|
|
48
|
+
const LIBRARY_PRE_BUILD_TRANSITIONS = ['Upload Lib Report', 'Apply for approval', 'Approved'];
|
|
22
49
|
|
|
23
|
-
|
|
50
|
+
function progress(ctx, event) {
|
|
51
|
+
if (typeof ctx.progress === 'function') {
|
|
52
|
+
ctx.progress(event);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
24
55
|
|
|
25
56
|
// ── Schema definitions ───────────────────────────────────────────
|
|
26
57
|
export function getLibraryToolDefinitions() {
|
|
@@ -62,6 +93,21 @@ export function getLibraryToolDefinitions() {
|
|
|
62
93
|
},
|
|
63
94
|
},
|
|
64
95
|
},
|
|
96
|
+
{
|
|
97
|
+
name: 'build_library',
|
|
98
|
+
description:
|
|
99
|
+
'觸發 Library Release 上版單的 Jenkins Build。自動處理 Upload Lib Report、Apply for approval、Approved 等 Library 前置狀態;CI 單請使用 build_ci。',
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
required: ['issueKey'],
|
|
103
|
+
properties: {
|
|
104
|
+
issueKey: {
|
|
105
|
+
type: 'string',
|
|
106
|
+
description: 'Library Release 單 issue key,例如 CID-1708',
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
65
111
|
];
|
|
66
112
|
}
|
|
67
113
|
|
|
@@ -70,8 +116,8 @@ export function getLibraryToolDefinitions() {
|
|
|
70
116
|
/**
|
|
71
117
|
* 計算下一個 Library Release 版號
|
|
72
118
|
*/
|
|
73
|
-
|
|
74
|
-
const {module, branch} = args;
|
|
119
|
+
async function handleGetNextLibVersion(args, { jira }) {
|
|
120
|
+
const { module, branch } = args;
|
|
75
121
|
|
|
76
122
|
const repoName = MODULE_TO_REPO_MAP[module];
|
|
77
123
|
if (!repoName) {
|
|
@@ -132,7 +178,7 @@ export async function handleGetNextLibVersion(args, {jira}) {
|
|
|
132
178
|
/**
|
|
133
179
|
* 建立 Library Release 上版單
|
|
134
180
|
*/
|
|
135
|
-
export async function handleCreateLibraryTicket(args, {jira, notifier}) {
|
|
181
|
+
export async function handleCreateLibraryTicket(args, { jira, notifier }) {
|
|
136
182
|
try {
|
|
137
183
|
const normalizedArgs = {
|
|
138
184
|
...args,
|
|
@@ -145,8 +191,8 @@ export async function handleCreateLibraryTicket(args, {jira, notifier}) {
|
|
|
145
191
|
if (!childId) return error(`找不到模組 "${normalizedArgs.module}" 的 Library ID`);
|
|
146
192
|
|
|
147
193
|
const nextVersionResult = await handleGetNextLibVersion(
|
|
148
|
-
{module: normalizedArgs.module, branch: normalizedArgs.gitBranch},
|
|
149
|
-
{jira},
|
|
194
|
+
{ module: normalizedArgs.module, branch: normalizedArgs.gitBranch },
|
|
195
|
+
{ jira },
|
|
150
196
|
);
|
|
151
197
|
const nextVersionText = nextVersionResult.content[0].text;
|
|
152
198
|
let summaryVersion;
|
|
@@ -160,30 +206,30 @@ export async function handleCreateLibraryTicket(args, {jira, notifier}) {
|
|
|
160
206
|
} else {
|
|
161
207
|
summaryVersion = JSON.parse(nextVersionText).summaryVersion;
|
|
162
208
|
}
|
|
163
|
-
const autoSummary =
|
|
164
|
-
|
|
209
|
+
const autoSummary = `[${normalizedArgs.systemCode}-${normalizedArgs.module}][Lib] Release for ${summaryVersion}`;
|
|
210
|
+
const summary = normalizedArgs.summary ?? autoSummary;
|
|
165
211
|
|
|
166
212
|
const fields = {
|
|
167
|
-
project: {key: JIRA_PROJECT_ID},
|
|
168
|
-
issuetype: {id: ISSUE_TYPE_IDS.Library},
|
|
169
|
-
summary
|
|
213
|
+
project: { key: JIRA_PROJECT_ID },
|
|
214
|
+
issuetype: { id: ISSUE_TYPE_IDS.Library },
|
|
215
|
+
summary,
|
|
170
216
|
duedate: today(),
|
|
171
217
|
[LIBRARY_FIELD_IDS.libModuleParent]: {
|
|
172
218
|
id: parentId,
|
|
173
|
-
child: {id: childId},
|
|
219
|
+
child: { id: childId },
|
|
174
220
|
},
|
|
175
221
|
};
|
|
176
222
|
|
|
177
223
|
// env(預設 stg)
|
|
178
224
|
const envCode = args.environment?.toLowerCase() ?? 'stg';
|
|
179
225
|
if (LIBRARY_FIELD_IDS.env && ENV_CODES[envCode]) {
|
|
180
|
-
fields[LIBRARY_FIELD_IDS.env] = {id: ENV_CODES[envCode]};
|
|
226
|
+
fields[LIBRARY_FIELD_IDS.env] = { id: ENV_CODES[envCode] };
|
|
181
227
|
}
|
|
182
228
|
|
|
183
229
|
// dept_code(由 systemCode 推導)
|
|
184
230
|
const deptStr = SYSTEM_TO_DEPT_MAP[normalizedArgs.systemCode];
|
|
185
231
|
if (LIBRARY_FIELD_IDS.deptCode && deptStr && DEPT_CODES[deptStr]) {
|
|
186
|
-
fields[LIBRARY_FIELD_IDS.deptCode] = {id: DEPT_CODES[deptStr]};
|
|
232
|
+
fields[LIBRARY_FIELD_IDS.deptCode] = { id: DEPT_CODES[deptStr] };
|
|
187
233
|
}
|
|
188
234
|
|
|
189
235
|
// fortify_scan:預設 scanned
|
|
@@ -205,7 +251,14 @@ export async function handleCreateLibraryTicket(args, {jira, notifier}) {
|
|
|
205
251
|
fields[LIBRARY_FIELD_IDS.gitBranch] = normalizedArgs.gitBranch;
|
|
206
252
|
}
|
|
207
253
|
|
|
208
|
-
|
|
254
|
+
await assertNoOpenPRBeforeCreate({
|
|
255
|
+
ticketType: 'library',
|
|
256
|
+
systemCode: normalizedArgs.systemCode,
|
|
257
|
+
module: normalizedArgs.module,
|
|
258
|
+
branch: normalizedArgs.gitBranch,
|
|
259
|
+
}, { jira });
|
|
260
|
+
|
|
261
|
+
if (args.dryRun) return ok({ dryRun: true, summary, fields });
|
|
209
262
|
|
|
210
263
|
const issue = await jira.createIssue(fields);
|
|
211
264
|
await notifier.notify(
|
|
@@ -215,7 +268,7 @@ export async function handleCreateLibraryTicket(args, {jira, notifier}) {
|
|
|
215
268
|
return ok({
|
|
216
269
|
issueKey: issue.key,
|
|
217
270
|
issueId: issue.id,
|
|
218
|
-
url: `${
|
|
271
|
+
url: `${getRuntimeConfigValue('JIRA_BASE_URL')}/browse/${issue.key}`,
|
|
219
272
|
type: 'Library Release',
|
|
220
273
|
system: normalizedArgs.systemCode,
|
|
221
274
|
module: normalizedArgs.module,
|
|
@@ -224,3 +277,146 @@ export async function handleCreateLibraryTicket(args, {jira, notifier}) {
|
|
|
224
277
|
return error(`無法建立 Library 單: ${err.message}`);
|
|
225
278
|
}
|
|
226
279
|
}
|
|
280
|
+
|
|
281
|
+
export async function handleBuildLibrary(args, ctx) {
|
|
282
|
+
const { issueKey } = args;
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const result = await executeLibraryBuildFlow(issueKey, ctx);
|
|
286
|
+
return ok(result);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
return error(`build_library 失敗: ${err.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function executeLibraryBuildFlow(issueKey, ctx) {
|
|
293
|
+
const { jira, notifier } = ctx;
|
|
294
|
+
const log = [];
|
|
295
|
+
const pollIntervalMs = getPollIntervalMs();
|
|
296
|
+
const maxWaitMs = getPollTimeoutMs();
|
|
297
|
+
|
|
298
|
+
const findAnyTransition = (names) => findAnyTransitionForIssue({ jira, issueKey, names });
|
|
299
|
+
|
|
300
|
+
const waitForAnyTransition = (names) => waitForAnyTransitionForIssue({
|
|
301
|
+
jira,
|
|
302
|
+
issueKey,
|
|
303
|
+
names,
|
|
304
|
+
intervalMs: pollIntervalMs,
|
|
305
|
+
timeoutMs: maxWaitMs,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const initIssue = await jira.getIssue(issueKey);
|
|
309
|
+
const initStatus = initIssue.fields.status.name;
|
|
310
|
+
progress(ctx, {
|
|
311
|
+
phase: 'action',
|
|
312
|
+
title: '開始 Library Build 流程',
|
|
313
|
+
issueKey,
|
|
314
|
+
currentStatus: initStatus,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const resolution = await resolveLibraryBuildTransition({
|
|
318
|
+
issueKey,
|
|
319
|
+
jira,
|
|
320
|
+
log,
|
|
321
|
+
initStatus,
|
|
322
|
+
findAnyTransition,
|
|
323
|
+
waitForAnyTransition,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (resolution.autoTriggered) {
|
|
327
|
+
await notifier.notify(
|
|
328
|
+
issueKey,
|
|
329
|
+
`Library Build 已觸發(Jira Auto)狀態:${initStatus} → ${resolution.status}`,
|
|
330
|
+
);
|
|
331
|
+
return { issueKey, status: resolution.status, steps: log, autoTriggered: true };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const buildTrans = resolution.buildTransition;
|
|
335
|
+
|
|
336
|
+
if (!buildTrans) {
|
|
337
|
+
const issue = await jira.getIssue(issueKey);
|
|
338
|
+
throw new Error(`找不到 Build transition,目前狀態:${issue.fields.status.name}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
log.push(`執行 ${buildTrans.name} transition (id: ${buildTrans.id})...`);
|
|
342
|
+
await jira.transitionById(issueKey, buildTrans.id);
|
|
343
|
+
|
|
344
|
+
const issue = await jira.getIssue(issueKey);
|
|
345
|
+
const newStatus = issue.fields.status.name;
|
|
346
|
+
log.push(`✅ ${buildTrans.name} 已觸發,目前狀態:${newStatus}`);
|
|
347
|
+
await notifier.notify(issueKey, `Jenkins ${buildTrans.name} 已觸發(${newStatus})`);
|
|
348
|
+
|
|
349
|
+
return { issueKey, status: newStatus, steps: log };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function resolveLibraryBuildTransition({
|
|
353
|
+
issueKey,
|
|
354
|
+
jira,
|
|
355
|
+
log,
|
|
356
|
+
initStatus,
|
|
357
|
+
findAnyTransition,
|
|
358
|
+
waitForAnyTransition,
|
|
359
|
+
}) {
|
|
360
|
+
const buildTransition = await findAnyTransition(LIBRARY_BUILD_TRANSITIONS);
|
|
361
|
+
if (buildTransition) {
|
|
362
|
+
return { buildTransition };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
log.push('未找到 Build transition,逐步觸發 Library 前置狀態...');
|
|
366
|
+
const preBuildResult = await runLibraryPreBuildTransitions({
|
|
367
|
+
issueKey,
|
|
368
|
+
jira,
|
|
369
|
+
log,
|
|
370
|
+
findAnyTransition,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
if (preBuildResult.buildTransition) {
|
|
374
|
+
return { buildTransition: preBuildResult.buildTransition };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
log.push(` 等待 Jira Automation 推進(最多 ${getPollTimeoutMs() / 1000}s)...`);
|
|
378
|
+
const automationBuildTransition = await waitForAnyTransition(LIBRARY_BUILD_TRANSITIONS);
|
|
379
|
+
if (automationBuildTransition) {
|
|
380
|
+
return { buildTransition: automationBuildTransition };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (preBuildResult.preTriggered) {
|
|
384
|
+
const currentIssue = await jira.getIssue(issueKey);
|
|
385
|
+
const currentStatus = currentIssue.fields.status.name;
|
|
386
|
+
if (currentStatus !== initStatus) {
|
|
387
|
+
log.push(`⚠️ 無手動 Build transition,但狀態已推進:${initStatus} → ${currentStatus}`);
|
|
388
|
+
log.push(' Jenkins Build 可能已由 Jira Automation 自動觸發');
|
|
389
|
+
return { autoTriggered: true, status: currentStatus };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return { buildTransition: null };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function runLibraryPreBuildTransitions({ issueKey, jira, log, findAnyTransition }) {
|
|
397
|
+
let preTriggered = false;
|
|
398
|
+
|
|
399
|
+
for (let step = 0; step < LIBRARY_PRE_BUILD_TRANSITIONS.length; step++) {
|
|
400
|
+
const preTransition = await findAnyTransition(LIBRARY_PRE_BUILD_TRANSITIONS);
|
|
401
|
+
if (!preTransition) {
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
log.push(` [step ${step + 1}] 觸發「${preTransition.name}」...`);
|
|
406
|
+
try {
|
|
407
|
+
await jira.transitionById(issueKey, preTransition.id);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
log.push(` ⚠️ ${preTransition.name} transition 失敗: ${err.message}`);
|
|
410
|
+
}
|
|
411
|
+
preTriggered = true;
|
|
412
|
+
await sleep(2000);
|
|
413
|
+
|
|
414
|
+
const buildTransition = await findAnyTransition(LIBRARY_BUILD_TRANSITIONS);
|
|
415
|
+
if (buildTransition) {
|
|
416
|
+
log.push(` 已找到 ${buildTransition.name} transition`);
|
|
417
|
+
return { buildTransition, preTriggered };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { buildTransition: null, preTriggered };
|
|
422
|
+
}
|
package/tools/release.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import https from 'https';
|
|
9
9
|
import http from 'http';
|
|
10
|
-
import {
|
|
10
|
+
import {error, getModuleName, ok} from './helpers.js';
|
|
11
11
|
import {getDeployConfig, getReleaseProjectKey, SYSTEM_CODES, SYSTEM_MODULES} from '../constants/index.js';
|
|
12
|
+
import {getRuntimeConfigNumber, getRuntimeConfigValue} from '../constants/config.js';
|
|
12
13
|
|
|
13
14
|
// ── Schema definitions ───────────────────────────────────────────
|
|
14
15
|
export function getReleaseToolDefinitions() {
|
|
@@ -100,7 +101,7 @@ export function getReleaseToolDefinitions() {
|
|
|
100
101
|
|
|
101
102
|
// ── Handlers ─────────────────────────────────────────────────────
|
|
102
103
|
|
|
103
|
-
export async function handleGetUnreleasedVersions(args, {
|
|
104
|
+
export async function handleGetUnreleasedVersions(args, {jira}) {
|
|
104
105
|
try {
|
|
105
106
|
const projectKey = args.projectKey ?? getReleaseProjectKey();
|
|
106
107
|
const versions = await jira.getUnreleasedVersionsList(projectKey);
|
|
@@ -109,7 +110,7 @@ export async function handleGetUnreleasedVersions(args, { jira }) {
|
|
|
109
110
|
return ok({
|
|
110
111
|
projectKey,
|
|
111
112
|
total: versions.length,
|
|
112
|
-
versions: versions.map((v) => ({
|
|
113
|
+
versions: versions.map((v) => ({id: v.id, name: v.name})),
|
|
113
114
|
});
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -135,13 +136,13 @@ export async function handleGetUnreleasedVersions(args, { jira }) {
|
|
|
135
136
|
};
|
|
136
137
|
});
|
|
137
138
|
|
|
138
|
-
return ok({
|
|
139
|
+
return ok({projectKey, systemCode: args.systemCode, total: parsed.length, versions: parsed});
|
|
139
140
|
} catch (err) {
|
|
140
141
|
return error(`無法查詢 unreleased versions: ${err.message}`);
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
export async function handleTransitionToWaitApproval(args, {
|
|
145
|
+
export async function handleTransitionToWaitApproval(args, {jira, notifier}) {
|
|
145
146
|
try {
|
|
146
147
|
await jira.transitionByName(args.cdIssueKey, 'Apply for approval');
|
|
147
148
|
await notifier.notify(args.cdIssueKey, '已切換到 Wait Approval,等待主管簽核');
|
|
@@ -155,46 +156,42 @@ export async function handleTransitionToWaitApproval(args, { jira, notifier }) {
|
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
export async function handleGetReleaseManager(args
|
|
159
|
+
export async function handleGetReleaseManager(args = {}) {
|
|
159
160
|
try {
|
|
160
161
|
|
|
161
|
-
const date = new Date()
|
|
162
|
+
const date = normalizeCalendarDate(args.date ?? new Date());
|
|
163
|
+
const endDate = addDays(date, 1);
|
|
164
|
+
const releaseConfig = getDeployConfig().release;
|
|
162
165
|
|
|
163
166
|
// dryRun 模式:回傳 mock 資料
|
|
164
167
|
if (args.dryRun) {
|
|
165
168
|
return ok({
|
|
166
169
|
date,
|
|
167
170
|
found: true,
|
|
168
|
-
name:
|
|
171
|
+
name: releaseConfig.dryRunManager.name,
|
|
172
|
+
substituted: false,
|
|
173
|
+
substitutionChecked: false,
|
|
169
174
|
dryRun: true,
|
|
170
175
|
event: {
|
|
171
176
|
what: 'Sign off staff',
|
|
172
|
-
who: `${getDeployConfig().release.dryRunManager.name} (${getDeployConfig().release.dryRunManager.accountId})`,
|
|
173
177
|
},
|
|
174
178
|
});
|
|
175
179
|
}
|
|
176
180
|
|
|
177
|
-
const CONF_BASE_URL =
|
|
178
|
-
const CONF_TOKEN =
|
|
181
|
+
const CONF_BASE_URL = getRuntimeConfigValue('CONF_BASE_URL');
|
|
182
|
+
const CONF_TOKEN = getRuntimeConfigValue('CONF_TOKEN');
|
|
179
183
|
if (!CONF_BASE_URL || !CONF_TOKEN) {
|
|
180
184
|
return error('缺少環境變數 CONF_BASE_URL 或 CONF_TOKEN');
|
|
181
185
|
}
|
|
182
186
|
|
|
183
|
-
const SUB_CALENDAR_ID =
|
|
184
|
-
??
|
|
187
|
+
const SUB_CALENDAR_ID = getRuntimeConfigValue('CONF_RELEASE_MANAGER_SUB_CALENDAR_ID')
|
|
188
|
+
?? releaseConfig.managerSubCalendarId;
|
|
185
189
|
if (!SUB_CALENDAR_ID) {
|
|
186
190
|
return error('缺少環境變數 CONF_RELEASE_MANAGER_SUB_CALENDAR_ID 或 release.managerSubCalendarId config');
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
const nextDay = new Date(date);
|
|
190
|
-
nextDay.setDate(nextDay.getDate() + 1);
|
|
191
|
-
const endDate = nextDay.toISOString();
|
|
192
|
-
|
|
193
193
|
const path =
|
|
194
|
-
`/rest/calendar-services/1.0/calendar/events.json
|
|
195
|
-
`?subCalendarId=${encodeURIComponent(SUB_CALENDAR_ID)}` +
|
|
196
|
-
`&start=${encodeURIComponent(date)}&end=${encodeURIComponent(endDate)}` +
|
|
197
|
-
`&userTimeZoneId=Asia%2FTaipei`;
|
|
194
|
+
`/rest/calendar-services/1.0/calendar/events.json?subCalendarId=${encodeURIComponent(SUB_CALENDAR_ID)}&start=${encodeURIComponent(date)}&end=${encodeURIComponent(endDate)}&userTimeZoneId=Asia%2FTaipei`;
|
|
198
195
|
|
|
199
196
|
const data = await calendarRequest(CONF_BASE_URL, path, CONF_TOKEN);
|
|
200
197
|
const events = data.events ?? [];
|
|
@@ -202,8 +199,7 @@ export async function handleGetReleaseManager(args, _ctx) {
|
|
|
202
199
|
// 找 event title/what 欄位包含 "Sign off staff" 的事件。
|
|
203
200
|
const signOffEvent = events.find(
|
|
204
201
|
(e) =>
|
|
205
|
-
(e.title ?? '').toLowerCase().includes('sign off staff')
|
|
206
|
-
(e.what ?? '').toLowerCase().includes('sign off staff'),
|
|
202
|
+
(e.title ?? '').toLowerCase().includes('sign off staff')
|
|
207
203
|
);
|
|
208
204
|
|
|
209
205
|
if (!signOffEvent) {
|
|
@@ -212,8 +208,6 @@ export async function handleGetReleaseManager(args, _ctx) {
|
|
|
212
208
|
found: false,
|
|
213
209
|
allEvents: events.map((e) => ({
|
|
214
210
|
title: e.title,
|
|
215
|
-
what: e.what,
|
|
216
|
-
who: e.who,
|
|
217
211
|
})),
|
|
218
212
|
message: `${date} 找不到 Sign off staff 事件,請手動確認值班組長`,
|
|
219
213
|
});
|
|
@@ -221,18 +215,32 @@ export async function handleGetReleaseManager(args, _ctx) {
|
|
|
221
215
|
|
|
222
216
|
// 回傳值班人員資訊
|
|
223
217
|
const invitee = signOffEvent.invitees?.[0];
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
218
|
+
const managerName = (invitee?.displayName) || 'Unknown';
|
|
219
|
+
|
|
220
|
+
const substitution = await resolveManagerSubstitution({
|
|
221
|
+
managerName,
|
|
222
|
+
baseUrl: CONF_BASE_URL,
|
|
223
|
+
token: CONF_TOKEN,
|
|
224
|
+
startDate: date,
|
|
225
|
+
endDate,
|
|
226
|
+
releaseConfig,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (substitution.error) {
|
|
230
|
+
return error(substitution.error);
|
|
231
|
+
}
|
|
232
|
+
|
|
231
233
|
return ok({
|
|
232
234
|
date,
|
|
233
235
|
found: true,
|
|
234
|
-
name: managerName,
|
|
235
|
-
|
|
236
|
+
name: substitution.name ?? managerName,
|
|
237
|
+
originalName: substitution.substituted ? managerName : undefined,
|
|
238
|
+
substituted: substitution.substituted,
|
|
239
|
+
substitutionChecked: substitution.checked,
|
|
240
|
+
substitutionSkippedReason: substitution.skippedReason,
|
|
241
|
+
leaveReason: substitution.leaveReason,
|
|
242
|
+
leaveEvent: substitution.leaveEvent,
|
|
243
|
+
authorAccountId: invitee?.name ?? 'Unknown',
|
|
236
244
|
event: signOffEvent,
|
|
237
245
|
});
|
|
238
246
|
} catch (err) {
|
|
@@ -240,7 +248,10 @@ export async function handleGetReleaseManager(args, _ctx) {
|
|
|
240
248
|
}
|
|
241
249
|
}
|
|
242
250
|
|
|
243
|
-
export async function handleWaitForComment(args, {
|
|
251
|
+
export async function handleWaitForComment(args, {
|
|
252
|
+
jira, progress = () => {
|
|
253
|
+
}
|
|
254
|
+
}) {
|
|
244
255
|
// dryRun 模式:直接回傳 mock 結果
|
|
245
256
|
if (args.dryRun) {
|
|
246
257
|
return ok({
|
|
@@ -255,8 +266,8 @@ export async function handleWaitForComment(args, { jira, progress = () => {} })
|
|
|
255
266
|
});
|
|
256
267
|
}
|
|
257
268
|
|
|
258
|
-
const intervalMs = args.intervalMs ??
|
|
259
|
-
const timeoutMs = args.timeoutMs ??
|
|
269
|
+
const intervalMs = args.intervalMs ?? getRuntimeConfigNumber('POLL_INTERVAL_MS', 30000);
|
|
270
|
+
const timeoutMs = args.timeoutMs ?? getRuntimeConfigNumber('POLL_TIMEOUT_MS', 3600000);
|
|
260
271
|
const startTime = Date.now();
|
|
261
272
|
let attempts = 0;
|
|
262
273
|
|
|
@@ -324,7 +335,7 @@ function calendarRequest(baseUrl, path, token) {
|
|
|
324
335
|
path: url.pathname + url.search,
|
|
325
336
|
method: 'GET',
|
|
326
337
|
rejectUnauthorized: false,
|
|
327
|
-
headers: {
|
|
338
|
+
headers: {Authorization: `Bearer ${token}`, Accept: 'application/json'},
|
|
328
339
|
},
|
|
329
340
|
(res) => {
|
|
330
341
|
let data = '';
|
|
@@ -351,3 +362,91 @@ function calendarRequest(baseUrl, path, token) {
|
|
|
351
362
|
req.end();
|
|
352
363
|
});
|
|
353
364
|
}
|
|
365
|
+
|
|
366
|
+
async function resolveManagerSubstitution({managerName, baseUrl, token, startDate, endDate, releaseConfig}) {
|
|
367
|
+
const calendarIds = [releaseConfig.birthdayCalendarId, releaseConfig.leaveCalendarId].filter(Boolean);
|
|
368
|
+
|
|
369
|
+
if (calendarIds.length === 0) {
|
|
370
|
+
return {
|
|
371
|
+
checked: false,
|
|
372
|
+
substituted: false,
|
|
373
|
+
skippedReason: '未設定 release.birthdayCalendarId 或 release.leaveCalendarId',
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const eventGroups = await Promise.all(calendarIds.map(async (calendarId) => {
|
|
378
|
+
const path =
|
|
379
|
+
`/rest/calendar-services/1.0/calendar/events.json?subCalendarId=${encodeURIComponent(calendarId)}&start=${encodeURIComponent(startDate)}&end=${encodeURIComponent(endDate)}&userTimeZoneId=Asia%2FTaipei`;
|
|
380
|
+
const data = await calendarRequest(baseUrl, path, token);
|
|
381
|
+
return data.events ?? [];
|
|
382
|
+
}));
|
|
383
|
+
|
|
384
|
+
const leaveEvent = eventGroups.flat().find((event) => eventMatchesPerson(event, managerName));
|
|
385
|
+
if (!leaveEvent) {
|
|
386
|
+
return {checked: true, substituted: false};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const substituteName = resolveSubstituteName(managerName, leaveEvent, releaseConfig.managerSubstitutes ?? {});
|
|
390
|
+
if (!substituteName) {
|
|
391
|
+
return {
|
|
392
|
+
checked: true,
|
|
393
|
+
substituted: false,
|
|
394
|
+
error: `值班組長 ${managerName} 今日請假,但無法解析代理人,請設定 release.managerSubstitutes`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
checked: true,
|
|
400
|
+
substituted: true,
|
|
401
|
+
name: substituteName,
|
|
402
|
+
leaveReason: leaveEvent.shortTitle ?? leaveEvent.description ?? leaveEvent.title ?? '',
|
|
403
|
+
leaveEvent,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function resolveSubstituteName(managerName, leaveEvent, substitutes) {
|
|
408
|
+
const configured = findConfiguredSubstitute(managerName, substitutes);
|
|
409
|
+
if (configured) return configured;
|
|
410
|
+
|
|
411
|
+
const text = `${leaveEvent.description ?? ''} ${leaveEvent.shortTitle ?? ''}`;
|
|
412
|
+
const match = text.match(/-\s*([^\-::]+?)\s*代理/);
|
|
413
|
+
return match?.[1]?.trim() || null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function findConfiguredSubstitute(managerName, substitutes) {
|
|
417
|
+
const entries = Object.entries(substitutes);
|
|
418
|
+
const exact = entries.find(([name]) => samePerson(name, managerName));
|
|
419
|
+
return exact?.[1] ?? null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function eventMatchesPerson(event, personName) {
|
|
423
|
+
const title = String(event.title ?? '');
|
|
424
|
+
return samePerson(title, personName);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function samePerson(left, right) {
|
|
428
|
+
const a = normalizePersonName(left);
|
|
429
|
+
const b = normalizePersonName(right);
|
|
430
|
+
return Boolean(a && b && (a.includes(b) || b.includes(a)));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function normalizePersonName(value) {
|
|
434
|
+
return String(value ?? '')
|
|
435
|
+
.toLowerCase()
|
|
436
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
437
|
+
.replace(/\s+/g, ' ')
|
|
438
|
+
.trim();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function normalizeCalendarDate(value) {
|
|
442
|
+
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
443
|
+
return value;
|
|
444
|
+
}
|
|
445
|
+
return new Date(value).toISOString().slice(0, 10);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function addDays(dateString, days) {
|
|
449
|
+
const date = new Date(`${dateString}T00:00:00.000Z`);
|
|
450
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
451
|
+
return date.toISOString().slice(0, 10);
|
|
452
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function sleep(ms) {
|
|
2
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export async function findAnyTransition({ jira, issueKey, names }) {
|
|
6
|
+
const list = await jira.getTransitions(issueKey);
|
|
7
|
+
return list.find((transition) => (
|
|
8
|
+
names.some((name) => transition.name.toLowerCase() === name.toLowerCase())
|
|
9
|
+
));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function waitForAnyTransition({ jira, issueKey, names, intervalMs, timeoutMs }) {
|
|
13
|
+
const deadline = Date.now() + timeoutMs;
|
|
14
|
+
while (Date.now() < deadline) {
|
|
15
|
+
const transition = await findAnyTransition({ jira, issueKey, names });
|
|
16
|
+
if (transition) {
|
|
17
|
+
return transition;
|
|
18
|
+
}
|
|
19
|
+
await sleep(intervalMs);
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|