@jira-deploy/core 1.0.14 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- 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 { 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
- export {REPO_MAPS, MODULE_MAP, REPO_LABEL_MAP};
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
- export async function handleGetNextLibVersion(args, {jira}) {
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
- normalizedArgs.summary ?? `[${normalizedArgs.systemCode}][Lib] Release for ${summaryVersion}`;
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: autoSummary,
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
- if (args.dryRun) return ok({dryRun: true, summary: autoSummary, fields});
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 { 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
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, { jira }) {
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) => ({ id: v.id, name: v.name })),
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({ projectKey, systemCode: args.systemCode, total: parsed.length, versions: parsed });
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, { jira, notifier }) {
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, _ctx) {
158
+ export async function handleGetReleaseManager(args = {}) {
159
159
  try {
160
160
 
161
- const date = new Date().toISOString();
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: getDeployConfig().release.dryRunManager.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
- ?? getDeployConfig().release.managerSubCalendarId;
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 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';
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
- authorAccountId: invitee?.name ?? legacyAccountId ?? 'Unknown',
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, { jira, progress = () => {} }) {
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: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
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
+ }