@jira-deploy/core 1.0.0

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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Jabber 通知工具
3
+ * - send_jabber_message 發送訊息到 Jabber MUC 群組或個人
4
+ */
5
+ import {execFile} from 'child_process';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import {error, ok} from './helpers.js';
9
+
10
+ function getDefaultScriptPath() {
11
+ const candidates = [
12
+ path.resolve(process.cwd(), 'packages/jira-core/scripts/jabber_notify.py'),
13
+ path.resolve(process.cwd(), 'scripts/jabber_notify.py'),
14
+ ];
15
+ return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
16
+ }
17
+
18
+ // ── Schema definitions ───────────────────────────────────────────
19
+ export function getJabberToolDefinitions() {
20
+ return [
21
+ {
22
+ name: 'send_jabber_message',
23
+ description:
24
+ '發送訊息到 Jabber。可傳給個人(to 參數,格式 BK00xxx@linebank.com.tw)或 MUC 群組(room 參數)。' +
25
+ '若 to/room 都未提供,則使用環境變數 JABBER_TO 或 JABBER_ROOM。' +
26
+ '用於上版通知、請主管簽單、上版完成公告等。',
27
+ inputSchema: {
28
+ type: 'object',
29
+ required: ['message'],
30
+ properties: {
31
+ message: {
32
+ type: 'string',
33
+ description: '要發送的訊息內容,支援純文字',
34
+ },
35
+ to: {
36
+ type: 'string',
37
+ description: '直接傳給個人的 JID,例如 BK00178@linebank.com.tw',
38
+ },
39
+ room: {
40
+ type: 'string',
41
+ description: 'MUC 群組 JID,例如 webqa@conference.linebank.com.tw',
42
+ },
43
+ },
44
+ },
45
+ },
46
+ ];
47
+ }
48
+
49
+ // ── Handler ───────────────────────────────────────────────────────
50
+
51
+ export async function handleSendJabberMessage(args, _ctx) {
52
+ const scriptPath = process.env.JABBER_NOTIFY_SCRIPT ?? getDefaultScriptPath();
53
+
54
+ // dryRun 模式:不實際發送,直接回傳預覽
55
+ if (args.dryRun) {
56
+ return ok({
57
+ sent: false,
58
+ dryRun: true,
59
+ message: args.message,
60
+ to: args.to ?? process.env.JABBER_TO ?? '(JABBER_TO not set)',
61
+ room: args.room ?? process.env.JABBER_ROOM,
62
+ preview: `[DRY RUN] 將發送 Jabber 給 ${args.to ?? process.env.JABBER_TO ?? '?'}: ${args.message}`,
63
+ });
64
+ }
65
+
66
+ // 允許透過 tool 參數覆蓋 JABBER_TO / JABBER_ROOM
67
+ const env = {...process.env};
68
+ if (args.to) env.JABBER_TO = args.to;
69
+ if (args.room) env.JABBER_ROOM = args.room;
70
+
71
+ return new Promise((resolve) => {
72
+ execFile(
73
+ 'python3',
74
+ [scriptPath, args.message],
75
+ {timeout: 30000, env},
76
+ (err, _stdout, stderr) => {
77
+ if (err) {
78
+ resolve(
79
+ error(
80
+ `Jabber 發送失敗: ${err.message}` +
81
+ (stderr ? ` (stderr: ${stderr.trim().slice(0, 200)})` : ''),
82
+ ),
83
+ );
84
+ } else {
85
+ resolve(
86
+ ok({
87
+ sent: true,
88
+ message: args.message,
89
+ to: args.to ?? env.JABBER_TO,
90
+ room: args.room ?? env.JABBER_ROOM,
91
+ }),
92
+ );
93
+ }
94
+ },
95
+ );
96
+ });
97
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Library Release 相關 tools
3
+ * - create_library_ticket
4
+ */
5
+ import {
6
+ SYSTEM_CODES,
7
+ ENV_CODES,
8
+ LIBRARY_MODULE_IDS,
9
+ ISSUE_TYPE_IDS,
10
+ JIRA_PROJECT_ID,
11
+ 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,
18
+ MODULE_MAP,
19
+ REPO_LABEL_MAP,
20
+ } from '../constants/index.js';
21
+ import {error, ok, today} from './helpers.js';
22
+
23
+ export {REPO_MAPS, MODULE_MAP, REPO_LABEL_MAP};
24
+
25
+ // ── Schema definitions ───────────────────────────────────────────
26
+ export function getLibraryToolDefinitions() {
27
+ return [
28
+ {
29
+ name: 'create_library_ticket',
30
+ description:
31
+ '建立 Library Release 上版單。專用工具,提前驗證必填欄位。當使用者說「幫我開 Lib」、「開 Library」時優先使用這個 tool',
32
+ inputSchema: {
33
+ type: 'object',
34
+ required: ['systemCode', 'module', 'gitBranch'],
35
+ properties: {
36
+ systemCode: {
37
+ type: 'string',
38
+ enum: Object.values(SYSTEM_CODES),
39
+ description: '系統代碼',
40
+ },
41
+ module: {
42
+ type: 'string',
43
+ description: '模組名稱(選填,預設同 systemCode)',
44
+ },
45
+ gitBranch: {
46
+ type: 'string',
47
+ description: 'Git branch 或倉庫名稱(例如 release/v1.5.2.0)',
48
+ },
49
+ jenkinsBranch: {
50
+ type: 'string',
51
+ description: 'Jenkins branch 預設 master(選填)',
52
+ },
53
+ environment: {
54
+ type: 'string',
55
+ enum: Object.keys(ENV_CODES),
56
+ description: '部署環境,(選填,預設 stg)',
57
+ },
58
+ dryRun: {
59
+ type: 'boolean',
60
+ description: '(選填) 預覽模式,不實際建立 Jira 單,回傳會送出的 payload',
61
+ },
62
+ },
63
+ },
64
+ },
65
+ ];
66
+ }
67
+
68
+ // ── Handlers ─────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * 計算下一個 Library Release 版號
72
+ */
73
+ export async function handleGetNextLibVersion(args, {jira}) {
74
+ const {module, branch} = args;
75
+
76
+ const repoName = MODULE_TO_REPO_MAP[module];
77
+ if (!repoName) {
78
+ return error(`找不到模組 "${module}" 的 repo 對應,請確認 MODULE_TO_REPO_MAP`);
79
+ }
80
+ const repoMeta = REPO_MAPS[repoName];
81
+ if (!repoMeta) {
82
+ return error(`找不到 repo "${repoName}" 的 Bitbucket 設定`);
83
+ }
84
+
85
+ let xmlVersion = null;
86
+ let xmlSerialFromXml = null;
87
+ try {
88
+ const xmlContent = await jira.getBitbucketFileContent(
89
+ repoMeta.project,
90
+ repoName,
91
+ `${module}.xml`,
92
+ branch,
93
+ );
94
+ // 格式:release-v1.5.2.0-0.0.1-SNAPSHOT → base=release/v1.5.2.0, serial=0.0.1
95
+ const match = xmlContent.match(/<version>(.+?)-(\d+\.\d+\.\d+)(?:-[^<]+)?<\/version>/);
96
+ if (!match) {
97
+ return error(`${module}.xml 中找不到 <version> 標籤(branch: ${branch})`);
98
+ }
99
+ xmlVersion = match[1].trim().replace('-', '/'); // e.g. "release/v1.5.2.0"
100
+ xmlSerialFromXml = match[2].trim(); // e.g. "0.0.1"
101
+ } catch (err) {
102
+ return error(`無法取得 ${module}.xml:${err.message}`);
103
+ }
104
+
105
+ const branchBase = branch.replace(/^release\//, '').replace(/^release-/, '');
106
+ const xmlBase = xmlVersion?.replace(/^release\//, '').replace(/^release-/, '');
107
+ let newBranch = branchBase;
108
+ let newSerial = '0.0.1';
109
+
110
+ if (xmlBase === branchBase && xmlSerialFromXml) {
111
+ const parts = xmlSerialFromXml.split('.').map(Number);
112
+ if (parts.length === 3 && parts.every(Number.isFinite)) {
113
+ parts[2] += 1;
114
+ newSerial = parts.join('.');
115
+ } else {
116
+ newSerial = xmlSerialFromXml;
117
+ }
118
+ }
119
+
120
+ const summaryVersion = `${newBranch}-${newSerial}`;
121
+ return ok({
122
+ module,
123
+ branch,
124
+ xmlVersion,
125
+ xmlSerial: xmlSerialFromXml,
126
+ serial: newSerial,
127
+ summaryVersion,
128
+ message: `✅ 下一個版本:${summaryVersion}(XML 現版:${xmlVersion}-${xmlSerialFromXml})`,
129
+ });
130
+ }
131
+
132
+ /**
133
+ * 建立 Library Release 上版單
134
+ */
135
+ export async function handleCreateLibraryTicket(args, {jira, notifier}) {
136
+ try {
137
+ const normalizedArgs = {
138
+ ...args,
139
+ module: args.module ?? args.moduleChild ?? args.systemCode?.toLowerCase(),
140
+ gitBranch: args.gitBranch ?? args.repo,
141
+ };
142
+ const parentId = SYSTEM_CODE_JIRA_IDS.library?.[normalizedArgs.systemCode];
143
+ const childId = LIBRARY_MODULE_IDS[normalizedArgs.systemCode]?.[normalizedArgs.module];
144
+ if (!parentId) return error(`找不到系統代碼 "${normalizedArgs.systemCode}" 的 Library ID`);
145
+ if (!childId) return error(`找不到模組 "${normalizedArgs.module}" 的 Library ID`);
146
+
147
+ const nextVersionResult = await handleGetNextLibVersion(
148
+ {module: normalizedArgs.module, branch: normalizedArgs.gitBranch},
149
+ {jira},
150
+ );
151
+ const nextVersionText = nextVersionResult.content[0].text;
152
+ let summaryVersion;
153
+ if (nextVersionText.startsWith('❌')) {
154
+ if (args.dryRun) {
155
+ // dryRun 下 Bitbucket 不通時,用 branch 名稱當 placeholder
156
+ summaryVersion = normalizedArgs.gitBranch?.replace('release/', '') ?? 'unknown';
157
+ } else {
158
+ return nextVersionResult;
159
+ }
160
+ } else {
161
+ summaryVersion = JSON.parse(nextVersionText).summaryVersion;
162
+ }
163
+ const autoSummary =
164
+ normalizedArgs.summary ?? `[${normalizedArgs.systemCode}][Lib] Release for ${summaryVersion}`;
165
+
166
+ const fields = {
167
+ project: {key: JIRA_PROJECT_ID},
168
+ issuetype: {id: ISSUE_TYPE_IDS.Library},
169
+ summary: autoSummary,
170
+ duedate: today(),
171
+ [LIBRARY_FIELD_IDS.libModuleParent]: {
172
+ id: parentId,
173
+ child: {id: childId},
174
+ },
175
+ };
176
+
177
+ // env(預設 stg)
178
+ const envCode = args.environment?.toLowerCase() ?? 'stg';
179
+ if (LIBRARY_FIELD_IDS.env && ENV_CODES[envCode]) {
180
+ fields[LIBRARY_FIELD_IDS.env] = {id: ENV_CODES[envCode]};
181
+ }
182
+
183
+ // dept_code(由 systemCode 推導)
184
+ const deptStr = SYSTEM_TO_DEPT_MAP[normalizedArgs.systemCode];
185
+ if (LIBRARY_FIELD_IDS.deptCode && deptStr && DEPT_CODES[deptStr]) {
186
+ fields[LIBRARY_FIELD_IDS.deptCode] = {id: DEPT_CODES[deptStr]};
187
+ }
188
+
189
+ // fortify_scan:預設 scanned
190
+ if (LIBRARY_FIELD_IDS.fortifyScan) {
191
+ fields[LIBRARY_FIELD_IDS.fortifyScan] = {
192
+ id: normalizedArgs.fortifyScan === false
193
+ ? FIELD_OPTIONS.fortifyScan.notScanned
194
+ : FIELD_OPTIONS.fortifyScan.scanned,
195
+ };
196
+ }
197
+
198
+ // CID_jenkinsfile_branch:預設 master
199
+ if (LIBRARY_FIELD_IDS.jenkinsBranch) {
200
+ fields[LIBRARY_FIELD_IDS.jenkinsBranch] = normalizedArgs.jenkinsBranch || 'master';
201
+ }
202
+
203
+ // CID_branch
204
+ if (LIBRARY_FIELD_IDS.gitBranch && normalizedArgs.gitBranch) {
205
+ fields[LIBRARY_FIELD_IDS.gitBranch] = normalizedArgs.gitBranch;
206
+ }
207
+
208
+ if (args.dryRun) return ok({dryRun: true, summary: autoSummary, fields});
209
+
210
+ const issue = await jira.createIssue(fields);
211
+ await notifier.notify(
212
+ issue.key,
213
+ `Library Release 單已建立。系統: ${args.systemCode}, 模組: ${args.module}, 環境: ${envCode}`,
214
+ );
215
+ return ok({
216
+ issueKey: issue.key,
217
+ issueId: issue.id,
218
+ url: `${process.env.JIRA_BASE_URL}/browse/${issue.key}`,
219
+ type: 'Library Release',
220
+ system: normalizedArgs.systemCode,
221
+ });
222
+ } catch (err) {
223
+ return error(`無法建立 Library 單: ${err.message}`);
224
+ }
225
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Release workflow 工具群
3
+ * - get_unreleased_versions 查詢 LBPRJ unreleased 版本
4
+ * - transition_to_wait_approval CD 單切到 Wait Approval
5
+ * - get_release_manager 查詢今日 STG 值班組長
6
+ * - wait_for_comment 輪詢等待 comment 關鍵字
7
+ */
8
+ import https from 'https';
9
+ import http from 'http';
10
+ import {error, getModuleName, ok, today} from './helpers.js';
11
+ import {SYSTEM_CODES, SYSTEM_MODULES} from '../constants/index.js';
12
+
13
+ // ── Schema definitions ───────────────────────────────────────────
14
+ export function getReleaseToolDefinitions() {
15
+ return [
16
+ {
17
+ name: 'get_unreleased_versions',
18
+ description:
19
+ '查詢 LBPRJ 所有 unreleased versions。可依 systemCode 篩選,自動解析版本名稱為 module + branch。',
20
+ inputSchema: {
21
+ type: 'object',
22
+ required: [],
23
+ properties: {
24
+ systemCode: {
25
+ type: 'string',
26
+ enum: Object.values(SYSTEM_CODES),
27
+ description:
28
+ '(選填) 只列出此系統的版本,例如 IBK → 回傳 IBK_、IBK_ssr_、IBK_wealth_、IBK_accessibility_ 開頭的版本,並解析出 module 與 branch',
29
+ },
30
+ projectKey: {
31
+ type: 'string',
32
+ description: '(選填) Jira 專案 key,預設 LBPRJ',
33
+ },
34
+ },
35
+ },
36
+ },
37
+ {
38
+ name: 'transition_to_wait_approval',
39
+ description:
40
+ '將 CD 單執行 "Apply for approval" transition,切換到 Wait Approval 狀態(讓主管可以簽核)',
41
+ inputSchema: {
42
+ type: 'object',
43
+ required: ['cdIssueKey'],
44
+ properties: {
45
+ cdIssueKey: {
46
+ type: 'string',
47
+ description: 'CD 單 issue key,例如 CID-1710',
48
+ },
49
+ },
50
+ },
51
+ },
52
+ {
53
+ name: 'get_release_manager',
54
+ description:
55
+ '查詢指定日期的 STG release 值班組長(從 Confluence Team Calendar 讀取 Sign off staff 事件)。今日 4/26 是 Alvin Wang BK00236。',
56
+ inputSchema: {
57
+ type: 'object',
58
+ required: [],
59
+ properties: {
60
+ date: {
61
+ type: 'string',
62
+ description: '(選填) 查詢日期 YYYY-MM-DD,預設今天',
63
+ },
64
+ },
65
+ },
66
+ },
67
+ {
68
+ name: 'wait_for_comment',
69
+ description:
70
+ '輪詢等待 Jira issue 出現包含特定關鍵字的 comment。用於等待 James Yu (BK00178) 在 UAT CD 單留 "Approved" 等場景。',
71
+ inputSchema: {
72
+ type: 'object',
73
+ required: ['issueKey', 'keyword'],
74
+ properties: {
75
+ issueKey: {
76
+ type: 'string',
77
+ description: 'Jira issue key,例如 CID-1710',
78
+ },
79
+ keyword: {
80
+ type: 'string',
81
+ description: '等待出現的關鍵字,例如 "Approved"',
82
+ },
83
+ authorAccountId: {
84
+ type: 'string',
85
+ description: '(選填) 限定特定用戶的 comment,例如 BK00178(James Yu)',
86
+ },
87
+ timeoutMs: {
88
+ type: 'number',
89
+ description: '(選填) 最長等待毫秒,預設 3600000(1小時)',
90
+ },
91
+ intervalMs: {
92
+ type: 'number',
93
+ description: '(選填) 輪詢間隔毫秒,預設 30000(30秒)',
94
+ },
95
+ },
96
+ },
97
+ },
98
+ ];
99
+ }
100
+
101
+ // ── Handlers ─────────────────────────────────────────────────────
102
+
103
+ export async function handleGetUnreleasedVersions(args, {jira}) {
104
+ try {
105
+ const projectKey = args.projectKey ?? 'LBPRJ';
106
+ const versions = await jira.getUnreleasedVersionsList(projectKey);
107
+
108
+ if (!args.systemCode) {
109
+ return ok({
110
+ projectKey,
111
+ total: versions.length,
112
+ versions: versions.map((v) => ({id: v.id, name: v.name})),
113
+ });
114
+ }
115
+
116
+ // 建立 module → prefix 對應表
117
+ const modules = SYSTEM_MODULES[args.systemCode] ?? [];
118
+ const prefixes = modules.map((m) => getModuleName(args.systemCode, m, ''));
119
+
120
+ // 篩選名稱以 prefix 開頭的版本(longest match 優先,避免 'IBK_' 誤匹配 'IBK_ssr_xxx')
121
+ const filtered = versions.filter((v) => prefixes.some((p) => v.name.startsWith(p)));
122
+
123
+ // 解析版本名稱 → module + branch(取最長匹配的 prefix)
124
+ const parsed = filtered.map((v) => {
125
+ const matchedPrefix = prefixes
126
+ .filter((p) => v.name.startsWith(p))
127
+ .sort((a, b) => b.length - a.length)[0];
128
+ const module = modules[prefixes.indexOf(matchedPrefix)];
129
+ const versionSuffix = v.name.slice(matchedPrefix.length);
130
+ return {
131
+ id: v.id,
132
+ name: v.name,
133
+ module,
134
+ branch: `release/v${versionSuffix}`,
135
+ };
136
+ });
137
+
138
+ return ok({projectKey, systemCode: args.systemCode, total: parsed.length, versions: parsed});
139
+ } catch (err) {
140
+ return error(`無法查詢 unreleased versions: ${err.message}`);
141
+ }
142
+ }
143
+
144
+ export async function handleTransitionToWaitApproval(args, {jira, notifier}) {
145
+ try {
146
+ await jira.transitionByName(args.cdIssueKey, 'Apply for approval');
147
+ await notifier.notify(args.cdIssueKey, '已切換到 Wait Approval,等待主管簽核');
148
+ return ok({
149
+ issueKey: args.cdIssueKey,
150
+ transitioned: 'Apply for approval',
151
+ toStatus: 'Wait Approval',
152
+ });
153
+ } catch (err) {
154
+ return error(`無法切換到 Wait Approval: ${err.message}`);
155
+ }
156
+ }
157
+
158
+ export async function handleGetReleaseManager(args, _ctx) {
159
+ try {
160
+ const date = args.date ?? today();
161
+
162
+ // dryRun 模式:回傳 mock 資料
163
+ if (args.dryRun) {
164
+ return ok({
165
+ date,
166
+ found: true,
167
+ name: 'Alvin Wang',
168
+ dryRun: true,
169
+ event: {what: 'Sign off staff', who: 'Alvin Wang (BK00236)'},
170
+ });
171
+ }
172
+
173
+ const CONF_BASE_URL = process.env.CONF_BASE_URL;
174
+ const CONF_TOKEN = process.env.CONF_TOKEN;
175
+ if (!CONF_BASE_URL || !CONF_TOKEN) {
176
+ return error('缺少環境變數 CONF_BASE_URL 或 CONF_TOKEN');
177
+ }
178
+
179
+ // Release Manager 值班表的 sub-calendar ID(來自 wiki 頁 109861360)
180
+ const SUB_CALENDAR_ID = 'afcd2271-8dfa-410e-a922-454f1eec03c0';
181
+
182
+ const nextDay = new Date(date);
183
+ nextDay.setDate(nextDay.getDate() + 1);
184
+ const endDate = nextDay.toISOString().slice(0, 10);
185
+
186
+ const path =
187
+ `/rest/calendar-services/1.0/calendar/events.json` +
188
+ `?subCalendarId=${encodeURIComponent(SUB_CALENDAR_ID)}` +
189
+ `&start=${date}&end=${endDate}` +
190
+ `&userTimeZoneId=Asia%2FTaipei`;
191
+
192
+ const data = await calendarRequest(CONF_BASE_URL, path, CONF_TOKEN);
193
+ const events = data.events ?? [];
194
+
195
+ // 找 what 欄位(event title)包含 "Sign off staff" 的事件
196
+ const signOffEvent = events.find(
197
+ (e) =>
198
+ (e.what ?? '').toLowerCase().includes('sign off staff') ||
199
+ (e.title ?? '').toLowerCase().includes('sign off staff'),
200
+ );
201
+
202
+ if (!signOffEvent) {
203
+ return ok({
204
+ date,
205
+ found: false,
206
+ allEvents: events.map((e) => ({what: e.what, who: e.who})),
207
+ message: `${date} 找不到 Sign off staff 事件,請手動確認值班組長`,
208
+ });
209
+ }
210
+
211
+ // 回傳值班人員資訊
212
+ return ok({
213
+ date,
214
+ found: true,
215
+ name: signOffEvent.who ?? signOffEvent.what ?? '',
216
+ event: signOffEvent,
217
+ });
218
+ } catch (err) {
219
+ return error(`無法查詢 Release Manager: ${err.message}`);
220
+ }
221
+ }
222
+
223
+ export async function handleWaitForComment(args, {jira}) {
224
+ // dryRun 模式:直接回傳 mock 結果
225
+ if (args.dryRun) {
226
+ return ok({
227
+ found: true,
228
+ issueKey: args.issueKey,
229
+ keyword: args.keyword,
230
+ author: args.authorAccountId ?? '(any)',
231
+ comment: `[DRY RUN] ${args.keyword}`,
232
+ dryRun: true,
233
+ attempts: 1,
234
+ elapsedMs: 0,
235
+ });
236
+ }
237
+
238
+ const intervalMs = args.intervalMs ?? parseInt(process.env.POLL_INTERVAL_MS ?? '30000');
239
+ const timeoutMs = args.timeoutMs ?? parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000');
240
+ const startTime = Date.now();
241
+ let attempts = 0;
242
+
243
+ console.error(
244
+ `[wait_for_comment] Waiting for keyword="${args.keyword}" in ${args.issueKey}` +
245
+ (args.authorAccountId ? ` from author=${args.authorAccountId}` : ''),
246
+ );
247
+
248
+ while (true) {
249
+ attempts++;
250
+ const comments = await jira.getComments(args.issueKey);
251
+
252
+ const match = comments.find((c) => {
253
+ const bodyMatch = (c.body ?? '').toLowerCase().includes(args.keyword.toLowerCase());
254
+ const authorMatch = !args.authorAccountId || (c.author?.name ?? '') === args.authorAccountId;
255
+ return bodyMatch && authorMatch;
256
+ });
257
+
258
+ if (match) {
259
+ return ok({
260
+ found: true,
261
+ issueKey: args.issueKey,
262
+ keyword: args.keyword,
263
+ author: match.author?.displayName ?? match.author?.name ?? '',
264
+ comment: (match.body ?? '').slice(0, 500),
265
+ attempts,
266
+ elapsedMs: Date.now() - startTime,
267
+ });
268
+ }
269
+
270
+ const elapsed = Date.now() - startTime;
271
+ if (elapsed >= timeoutMs) {
272
+ return error(
273
+ `Timeout:等待 "${args.keyword}" comment in ${args.issueKey}(${attempts} 次輪詢,${Math.round(elapsed / 1000)}s)`,
274
+ );
275
+ }
276
+
277
+ console.error(`[wait_for_comment] attempt #${attempts}, no match, sleeping ${intervalMs}ms`);
278
+ await new Promise((r) => setTimeout(r, intervalMs));
279
+ }
280
+ }
281
+
282
+ // ── Private helpers ───────────────────────────────────────────────
283
+
284
+ function calendarRequest(baseUrl, path, token) {
285
+ return new Promise((resolve, reject) => {
286
+ const url = new URL(path, baseUrl);
287
+ const lib = url.protocol === 'https:' ? https : http;
288
+ const req = lib.request(
289
+ {
290
+ hostname: url.hostname,
291
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
292
+ path: url.pathname + url.search,
293
+ method: 'GET',
294
+ rejectUnauthorized: false,
295
+ headers: {Authorization: `Bearer ${token}`, Accept: 'application/json'},
296
+ },
297
+ (res) => {
298
+ let data = '';
299
+ res.on('data', (c) => (data += c));
300
+ res.on('end', () => {
301
+ if (res.statusCode >= 200 && res.statusCode < 300) {
302
+ try {
303
+ resolve(JSON.parse(data));
304
+ } catch (e) {
305
+ reject(new Error(`JSON parse error: ${e.message}`));
306
+ }
307
+ } else {
308
+ reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
309
+ }
310
+ });
311
+ },
312
+ );
313
+ req.on('error', reject);
314
+ req.setTimeout(15000, () => {
315
+ req.destroy();
316
+ reject(new Error('Request timeout'));
317
+ });
318
+ req.end();
319
+ });
320
+ }