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