@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/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
- if (env.JABBER_NOTIFY_SCRIPT) {
36
- return env.JABBER_NOTIFY_SCRIPT;
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 ?? process.env.JABBER_TO ?? '(JABBER_TO not set)',
95
- room: args.room ?? process.env.JABBER_ROOM,
96
- preview: `[DRY RUN] 將發送 Jabber 給 ${args.to ?? process.env.JABBER_TO ?? '?'}: ${args.message}`,
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
- SYSTEM_CODES,
7
+ DEPT_CODES,
7
8
  ENV_CODES,
8
- LIBRARY_MODULE_IDS,
9
+ FIELD_OPTIONS,
9
10
  ISSUE_TYPE_IDS,
10
11
  JIRA_PROJECT_ID,
11
12
  LIBRARY_FIELD_IDS,
12
- SYSTEM_TO_DEPT_MAP,
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 {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
- export {REPO_MAPS, MODULE_MAP, REPO_LABEL_MAP};
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
- export async function handleGetNextLibVersion(args, {jira}) {
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
- normalizedArgs.summary ?? `[${normalizedArgs.systemCode}][Lib] Release for ${summaryVersion}`;
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: autoSummary,
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
- if (args.dryRun) return ok({dryRun: true, summary: autoSummary, fields});
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: `${process.env.JIRA_BASE_URL}/browse/${issue.key}`,
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 { error, getModuleName, ok } from './helpers.js';
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, { jira }) {
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) => ({ id: v.id, name: v.name })),
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({ projectKey, systemCode: args.systemCode, total: parsed.length, versions: parsed });
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, { jira, notifier }) {
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, _ctx) {
159
+ export async function handleGetReleaseManager(args = {}) {
159
160
  try {
160
161
 
161
- const date = new Date().toISOString();
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: getDeployConfig().release.dryRunManager.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 = process.env.CONF_BASE_URL;
178
- const CONF_TOKEN = process.env.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 = process.env.CONF_RELEASE_MANAGER_SUB_CALENDAR_ID
184
- ?? getDeployConfig().release.managerSubCalendarId;
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 legacyWho = signOffEvent.who ?? '';
225
- const legacyName = legacyWho.replace(/\s*\([^)]*\)\s*$/, '').trim();
226
- const legacyAccountId = legacyWho.match(/\(([^)]+)\)/)?.[1];
227
- const managerName = (invitee?.displayName ?? legacyName)
228
- || signOffEvent.title
229
- || signOffEvent.what
230
- || 'Unknown';
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
- authorAccountId: invitee?.name ?? legacyAccountId ?? 'Unknown',
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, { jira, progress = () => {} }) {
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 ?? parseInt(process.env.POLL_INTERVAL_MS ?? '30000');
259
- const timeoutMs = args.timeoutMs ?? parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000');
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: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
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
+ }