@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.
- package/constants/defaults.js +111 -0
- package/constants/environments.js +37 -0
- package/constants/field-ids.js +63 -0
- package/constants/index.js +53 -0
- package/constants/issue-types.js +23 -0
- package/constants/modules.js +55 -0
- package/constants/repos.js +55 -0
- package/constants/server.js +100 -0
- package/constants/system-codes.js +79 -0
- package/constants/users.js +47 -0
- package/dry-run.js +691 -0
- package/index.js +6 -0
- package/jira-client.js +313 -0
- package/notifier.js +56 -0
- package/package.json +34 -0
- package/platform-config.js +64 -0
- package/poller.js +64 -0
- package/tools/cd.js +666 -0
- package/tools/ci.js +204 -0
- package/tools/ci.test.js +154 -0
- package/tools/grayrelease.js +296 -0
- package/tools/helpers.js +78 -0
- package/tools/index.js +1119 -0
- package/tools/jabber.js +97 -0
- package/tools/library.js +225 -0
- package/tools/release.js +320 -0
- package/tools/release.test.js +137 -0
- package/tools.test.js +1711 -0
package/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export {JiraClient} from './jira-client.js';
|
|
2
|
+
export {Notifier} from './notifier.js';
|
|
3
|
+
export {Poller} from './poller.js';
|
|
4
|
+
export {getPlatformConfig, PLATFORM_TO_SYSTEM_CODE, PLATFORMS} from './platform-config.js';
|
|
5
|
+
export {getToolDefinitions, executeTool} from './tools/index.js';
|
|
6
|
+
export {getClusterList, ok, error, today} from './tools/helpers.js';
|
package/jira-client.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import https from 'https';
|
|
3
|
+
|
|
4
|
+
const httpsAgent = new https.Agent({rejectUnauthorized: false});
|
|
5
|
+
|
|
6
|
+
const DRY_RUN = process.env.DRY_RUN === 'true';
|
|
7
|
+
|
|
8
|
+
export class JiraClient {
|
|
9
|
+
constructor() {
|
|
10
|
+
const {JIRA_BASE_URL, JIRA_API_TOKEN} = process.env;
|
|
11
|
+
if (!JIRA_BASE_URL || !JIRA_API_TOKEN) {
|
|
12
|
+
throw new Error('Missing required Jira env vars: JIRA_BASE_URL, JIRA_API_TOKEN');
|
|
13
|
+
}
|
|
14
|
+
this.http = axios.create({
|
|
15
|
+
baseURL: `${JIRA_BASE_URL}/rest/api/2`,
|
|
16
|
+
httpsAgent,
|
|
17
|
+
headers: {
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
Accept: 'application/json',
|
|
20
|
+
Authorization: `Bearer ${JIRA_API_TOKEN}`,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
this.dryRun = DRY_RUN;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 建立 issue
|
|
27
|
+
async createIssue(fields) {
|
|
28
|
+
if (this.dryRun) {
|
|
29
|
+
console.log('[DRY RUN] JiraClient.createIssue', JSON.stringify(fields).slice(0, 1000));
|
|
30
|
+
return {id: 'DRY-RUN', key: 'DRY-RUN', self: ''};
|
|
31
|
+
}
|
|
32
|
+
const res = await this.http.post('/issue', {fields}).catch((err) => {
|
|
33
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
34
|
+
throw new Error(`Create issue failed: ${detail}`);
|
|
35
|
+
});
|
|
36
|
+
return res.data; // { id, key, self }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 更新 issue 欄位
|
|
40
|
+
async updateIssue(issueKey, fields) {
|
|
41
|
+
if (this.dryRun) {
|
|
42
|
+
console.log(
|
|
43
|
+
'[DRY RUN] JiraClient.updateIssue',
|
|
44
|
+
issueKey,
|
|
45
|
+
JSON.stringify(fields).slice(0, 1000),
|
|
46
|
+
);
|
|
47
|
+
return {issueKey, updated: Object.keys(fields)};
|
|
48
|
+
}
|
|
49
|
+
await this.http.put(`/issue/${issueKey}`, {fields});
|
|
50
|
+
return {issueKey, updated: Object.keys(fields)};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 更新 Assignee(使用專用 endpoint,Jira Server 相容)
|
|
54
|
+
async updateAssignee(issueKey, accountId) {
|
|
55
|
+
if (this.dryRun) {
|
|
56
|
+
console.log('[DRY RUN] JiraClient.updateAssignee', issueKey, accountId);
|
|
57
|
+
return {issueKey, accountId};
|
|
58
|
+
}
|
|
59
|
+
await this.http.put(`/issue/${issueKey}/assignee`, {name: accountId}).catch(async (err) => {
|
|
60
|
+
// 若 name 格式失敗,改試 accountId 格式(Jira Cloud)
|
|
61
|
+
if (err.response?.status === 400) {
|
|
62
|
+
await this.http.put(`/issue/${issueKey}/assignee`, {accountId}).catch((e) => {
|
|
63
|
+
const detail = e.response?.data ? JSON.stringify(e.response.data) : e.message;
|
|
64
|
+
throw new Error(`updateAssignee failed: ${detail}`);
|
|
65
|
+
});
|
|
66
|
+
} else {
|
|
67
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
68
|
+
throw new Error(`updateAssignee failed: ${detail}`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return {issueKey, accountId};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 查詢 issue 目前狀態
|
|
75
|
+
async getIssue(issueKey) {
|
|
76
|
+
const res = await this.http.get(`/issue/${issueKey}`, {
|
|
77
|
+
params: {fields: 'status,summary,assignee,comment'},
|
|
78
|
+
});
|
|
79
|
+
return res.data;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 取得可執行的 transitions
|
|
83
|
+
async getTransitions(issueKey) {
|
|
84
|
+
const res = await this.http.get(`/issue/${issueKey}/transitions`);
|
|
85
|
+
return res.data.transitions; // [{ id, name, to: { name } }]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 查詢 issue 的指定欄位(例如取得 CI 單的 CID_release_version)
|
|
89
|
+
async getIssueFields(issueKey, fields = []) {
|
|
90
|
+
const res = await this.http.get(`/issue/${issueKey}`, {
|
|
91
|
+
params: {fields: fields.join(',')},
|
|
92
|
+
});
|
|
93
|
+
return res.data.fields;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 依 ID 直接切換狀態(不查名稱,適合 race condition 處理)
|
|
97
|
+
async transitionById(issueKey, transitionId) {
|
|
98
|
+
if (this.dryRun) {
|
|
99
|
+
console.log('[DRY RUN] JiraClient.transitionById', issueKey, transitionId);
|
|
100
|
+
return {issueKey, transitionId};
|
|
101
|
+
}
|
|
102
|
+
await this.http
|
|
103
|
+
.post(`/issue/${issueKey}/transitions`, {
|
|
104
|
+
transition: {id: transitionId},
|
|
105
|
+
})
|
|
106
|
+
.catch((err) => {
|
|
107
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
108
|
+
throw new Error(`Transition id=${transitionId} failed: ${detail}`);
|
|
109
|
+
});
|
|
110
|
+
return {issueKey, transitionId};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 依名稱切換狀態(不用記 id)
|
|
114
|
+
async transitionByName(issueKey, transitionName) {
|
|
115
|
+
if (this.dryRun) {
|
|
116
|
+
console.log('[DRY RUN] JiraClient.transitionByName', issueKey, transitionName);
|
|
117
|
+
return {issueKey, transitioned: transitionName, toStatus: 'DRY'};
|
|
118
|
+
}
|
|
119
|
+
const transitions = await this.getTransitions(issueKey);
|
|
120
|
+
const match = transitions.find((t) => t.name.toLowerCase() === transitionName.toLowerCase());
|
|
121
|
+
if (!match) {
|
|
122
|
+
const available = transitions.map((t) => t.name).join(', ');
|
|
123
|
+
throw new Error(`Transition "${transitionName}" not found. Available: ${available}`);
|
|
124
|
+
}
|
|
125
|
+
await this.http
|
|
126
|
+
.post(`/issue/${issueKey}/transitions`, {
|
|
127
|
+
transition: {id: match.id},
|
|
128
|
+
})
|
|
129
|
+
.catch((err) => {
|
|
130
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
131
|
+
throw new Error(`Transition "${match.name}" failed: ${detail}`);
|
|
132
|
+
});
|
|
133
|
+
return {issueKey, transitioned: match.name, toStatus: match.to.name};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 新增 issue link(relates to / blocks / is blocked by 等)
|
|
137
|
+
async linkIssue(inwardKey, outwardKey, linkTypeName = 'Relates') {
|
|
138
|
+
if (this.dryRun) {
|
|
139
|
+
console.log('[DRY RUN] JiraClient.linkIssue', inwardKey, outwardKey, linkTypeName);
|
|
140
|
+
return {inwardKey, outwardKey, linkType: linkTypeName};
|
|
141
|
+
}
|
|
142
|
+
await this.http.post('/issueLink', {
|
|
143
|
+
type: {name: linkTypeName},
|
|
144
|
+
inwardIssue: {key: inwardKey},
|
|
145
|
+
outwardIssue: {key: outwardKey},
|
|
146
|
+
});
|
|
147
|
+
return {inwardKey, outwardKey, linkType: linkTypeName};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 新增 comment(/rest/api/2 用純文字 body)
|
|
151
|
+
async addComment(issueKey, text) {
|
|
152
|
+
if (this.dryRun) {
|
|
153
|
+
console.log('[DRY RUN] JiraClient.addComment', issueKey, text);
|
|
154
|
+
return {issueKey, body: text};
|
|
155
|
+
}
|
|
156
|
+
const res = await this.http.post(`/issue/${issueKey}/comment`, {
|
|
157
|
+
body: text,
|
|
158
|
+
});
|
|
159
|
+
return res.data;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 新增 Web Link(remote link)到 issue
|
|
163
|
+
async addRemoteLink(issueKey, url, title) {
|
|
164
|
+
if (this.dryRun) {
|
|
165
|
+
console.log('[DRY RUN] JiraClient.addRemoteLink', issueKey, url, title);
|
|
166
|
+
return {issueKey, url, title};
|
|
167
|
+
}
|
|
168
|
+
const res = await this.http
|
|
169
|
+
.post(`/issue/${issueKey}/remotelink`, {
|
|
170
|
+
object: {url, title},
|
|
171
|
+
})
|
|
172
|
+
.catch((err) => {
|
|
173
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
174
|
+
throw new Error(`addRemoteLink failed: ${detail}`);
|
|
175
|
+
});
|
|
176
|
+
return res.data;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 取得 Jira 專案的 versions(用於查找 LBPRJ release 版本頁面)
|
|
180
|
+
// 對應 UI 網址:/projects/LBPRJ?selectedItem=...release-page&status=unreleased&contains=cwa
|
|
181
|
+
//
|
|
182
|
+
// ⚠️ Jira Server 的 /project/{key}/version 分頁端點不支援 status/query 過濾
|
|
183
|
+
// (僅 Jira Cloud 支援)。改用 /versions 取全量後在 client 端過濾。
|
|
184
|
+
//
|
|
185
|
+
// status: 'unreleased'
|
|
186
|
+
// query: 相當於 UI 的 contains 參數(版本名稱包含此字串,不分大小寫)
|
|
187
|
+
async getProjectVersions(projectKey, {name = {}}) {
|
|
188
|
+
const res = await this.http.get(`/project/${projectKey}/versions`);
|
|
189
|
+
let versions = res.data ?? [];
|
|
190
|
+
|
|
191
|
+
// Client-side 過濾(對齊 UI status=unreleased&contains=xxx)
|
|
192
|
+
const version = versions.find((v) => !v.released && !v.archived && v.name === name);
|
|
193
|
+
|
|
194
|
+
if (version) {
|
|
195
|
+
return version.id;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return ''; // 沒有找到符合條件的版本
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 取得 LBPRJ 所有 unreleased 版本列表(不含已 release 或已 archive 的)
|
|
202
|
+
async getUnreleasedVersionsList(projectKey) {
|
|
203
|
+
const res = await this.http.get(`/project/${projectKey}/versions`);
|
|
204
|
+
const versions = res.data ?? [];
|
|
205
|
+
return versions.filter((v) => !v.released && !v.archived);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 取得 issue 的所有 comments
|
|
209
|
+
async getComments(issueKey) {
|
|
210
|
+
const res = await this.http.get(`/issue/${issueKey}/comment`).catch((err) => {
|
|
211
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
212
|
+
throw new Error(`getComments failed: ${detail}`);
|
|
213
|
+
});
|
|
214
|
+
return res.data?.comments ?? [];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 取得 issue 的 sub-tasks(Deployment 單等)
|
|
218
|
+
async getSubTasks(issueKey) {
|
|
219
|
+
const res = await this.http
|
|
220
|
+
.get(`/issue/${issueKey}`, {
|
|
221
|
+
params: {fields: 'subtasks,issuetype'},
|
|
222
|
+
})
|
|
223
|
+
.catch((err) => {
|
|
224
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
225
|
+
throw new Error(`getSubTasks failed: ${detail}`);
|
|
226
|
+
});
|
|
227
|
+
return res.data.fields?.subtasks ?? [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// JQL 搜尋,回傳 issues 陣列 [{ id, key, fields }]
|
|
231
|
+
async searchIssues(jql, fields = [], maxResults = 10) {
|
|
232
|
+
const res = await this.http
|
|
233
|
+
.get('/search', {
|
|
234
|
+
params: {jql, fields: fields.join(','), maxResults},
|
|
235
|
+
})
|
|
236
|
+
.catch((err) => {
|
|
237
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
238
|
+
throw new Error(`Search issues failed: ${detail}`);
|
|
239
|
+
});
|
|
240
|
+
return res.data.issues ?? [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 取得 Bitbucket repo 的原始檔案內容(Bitbucket REST API 1.0)
|
|
244
|
+
async getBitbucketFileContent(project, repo, filePath, branch) {
|
|
245
|
+
const BB_BASE = process.env.BITBUCKET_URL ?? 'https://bitbucket.linebank.com.tw';
|
|
246
|
+
const url = `${BB_BASE}/rest/api/1.0/projects/${project}/repos/${repo}/raw/${filePath}`;
|
|
247
|
+
const res = await axios
|
|
248
|
+
.get(url, {
|
|
249
|
+
httpsAgent,
|
|
250
|
+
headers: {
|
|
251
|
+
Accept: 'text/plain',
|
|
252
|
+
Authorization: `Bearer ${process.env.BITBUCKET_API_TOKEN}`,
|
|
253
|
+
},
|
|
254
|
+
params: {at: branch},
|
|
255
|
+
responseType: 'text',
|
|
256
|
+
})
|
|
257
|
+
.catch((err) => {
|
|
258
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
259
|
+
throw new Error(`getBitbucketFileContent(${repo}/${filePath}@${branch}) failed: ${detail}`);
|
|
260
|
+
});
|
|
261
|
+
return res.data;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 取得 Bitbucket repo 的 tags(Bitbucket REST API 1.0)
|
|
265
|
+
async getBitbucketTags(
|
|
266
|
+
project,
|
|
267
|
+
repo,
|
|
268
|
+
{filterValue = '', orderBy = 'MODIFICATION', limit = 1} = {},
|
|
269
|
+
) {
|
|
270
|
+
const BB_BASE = process.env.BITBUCKET_URL ?? 'https://bitbucket.linebank.com.tw';
|
|
271
|
+
const url = `${BB_BASE}/rest/api/1.0/projects/${project}/repos/${repo}/tags`;
|
|
272
|
+
const res = await axios
|
|
273
|
+
.get(url, {
|
|
274
|
+
httpsAgent,
|
|
275
|
+
headers: {
|
|
276
|
+
'Content-Type': 'application/json',
|
|
277
|
+
Accept: 'application/json',
|
|
278
|
+
Authorization: `Bearer ${process.env.BITBUCKET_API_TOKEN}`,
|
|
279
|
+
},
|
|
280
|
+
params: {filterValue, orderBy, limit},
|
|
281
|
+
})
|
|
282
|
+
.catch((err) => {
|
|
283
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
284
|
+
throw new Error(`getBitbucketTags failed: ${detail}`);
|
|
285
|
+
});
|
|
286
|
+
return res.data?.values ?? [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 取得 Bitbucket repo 的 branches(Bitbucket REST API 1.0)
|
|
290
|
+
async getBitbucketBranches(
|
|
291
|
+
project,
|
|
292
|
+
repo,
|
|
293
|
+
{filterValue = '', orderBy = 'MODIFICATION', limit = 1} = {},
|
|
294
|
+
) {
|
|
295
|
+
const BB_BASE = process.env.BITBUCKET_URL ?? 'https://bitbucket.linebank.com.tw';
|
|
296
|
+
const url = `${BB_BASE}/rest/api/1.0/projects/${project}/repos/${repo}/branches`;
|
|
297
|
+
const res = await axios
|
|
298
|
+
.get(url, {
|
|
299
|
+
httpsAgent,
|
|
300
|
+
headers: {
|
|
301
|
+
'Content-Type': 'application/json',
|
|
302
|
+
Accept: 'application/json',
|
|
303
|
+
Authorization: `Bearer ${process.env.BITBUCKET_API_TOKEN}`,
|
|
304
|
+
},
|
|
305
|
+
params: {filterValue, orderBy, limit},
|
|
306
|
+
})
|
|
307
|
+
.catch((err) => {
|
|
308
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
309
|
+
throw new Error(`getBitbucketBranches failed: ${detail}`);
|
|
310
|
+
});
|
|
311
|
+
return res.data?.values ?? [];
|
|
312
|
+
}
|
|
313
|
+
}
|
package/notifier.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifier — 集中處理所有通知
|
|
3
|
+
*
|
|
4
|
+
* 目前支援:Jira comment
|
|
5
|
+
* 擴充 Slack:填入 .env 的 SLACK_BOT_TOKEN + SLACK_CHANNEL_ID 後,
|
|
6
|
+
* 把下方 notifySlack 的 TODO 實作即可,其他地方不用動。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class Notifier {
|
|
10
|
+
constructor(jiraClient) {
|
|
11
|
+
this.jira = jiraClient;
|
|
12
|
+
this.slackEnabled = !!process.env.SLACK_BOT_TOKEN && !!process.env.SLACK_CHANNEL_ID;
|
|
13
|
+
this.dryRun = process.env.DRY_RUN === 'true';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async notify(issueKey, message) {
|
|
17
|
+
const results = [];
|
|
18
|
+
|
|
19
|
+
if (this.dryRun) {
|
|
20
|
+
console.log(`[DRY RUN] Notifier.notify ${issueKey}: ${message}`);
|
|
21
|
+
// don't actually call Jira or Slack
|
|
22
|
+
results.push('dry_run');
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 永遠寫 Jira comment(失敗不中斷主流程)
|
|
27
|
+
try {
|
|
28
|
+
await this.jira.addComment(issueKey, `[Bot] ${message}`);
|
|
29
|
+
results.push('jira_comment');
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error(`[Notifier] addComment failed for ${issueKey}: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Slack(有設定就送)
|
|
35
|
+
if (this.slackEnabled) {
|
|
36
|
+
try {
|
|
37
|
+
await this.notifySlack(issueKey, message);
|
|
38
|
+
results.push('slack');
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(`[Notifier] slack notify failed: ${err.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async notifySlack(issueKey, message) {
|
|
48
|
+
// TODO: 擴充 Slack 通知
|
|
49
|
+
// const { SLACK_BOT_TOKEN, SLACK_CHANNEL_ID } = process.env;
|
|
50
|
+
// await axios.post('https://slack.com/api/chat.postMessage', {
|
|
51
|
+
// channel: SLACK_CHANNEL_ID,
|
|
52
|
+
// text: `*[${issueKey}]* ${message}`,
|
|
53
|
+
// }, { headers: { Authorization: `Bearer ${SLACK_BOT_TOKEN}` } });
|
|
54
|
+
console.log(`[Slack TODO] ${issueKey}: ${message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jira-deploy/core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./index.js",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ap311036/jira-deploy-mcp.git",
|
|
9
|
+
"directory": "packages/jira-core"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./index.js",
|
|
13
|
+
"./constants": "./constants/index.js",
|
|
14
|
+
"./jira-client": "./jira-client.js",
|
|
15
|
+
"./notifier": "./notifier.js",
|
|
16
|
+
"./poller": "./poller.js",
|
|
17
|
+
"./platform-config": "./platform-config.js",
|
|
18
|
+
"./tools": "./tools/index.js",
|
|
19
|
+
"./tools/helpers": "./tools/helpers.js",
|
|
20
|
+
"./tools/library": "./tools/library.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"constants/**/*.js",
|
|
24
|
+
"tools/**/*.js",
|
|
25
|
+
"*.js"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"axios": "^1.6.0",
|
|
29
|
+
"dotenv": "^16.3.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test tools.test.js"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 平台配置(已遷移到 src/constants/)
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 此檔案已棄用,保留供向後相容性
|
|
5
|
+
* 新的配置請在 src/constants/ 目錄下管理
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {SYSTEM_CODES, GRAY_RELEASE_MODULE_IDS} from './constants/index.js';
|
|
9
|
+
import {getServerList} from './tools/helpers.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 平台映射 - 將使用者友善的平台名稱對應到系統代碼
|
|
13
|
+
*/
|
|
14
|
+
export const PLATFORM_TO_SYSTEM_CODE = {
|
|
15
|
+
cwa: SYSTEM_CODES.CWA,
|
|
16
|
+
ibk: SYSTEM_CODES.IBK,
|
|
17
|
+
eib: SYSTEM_CODES.EIB,
|
|
18
|
+
evt: SYSTEM_CODES.EVT,
|
|
19
|
+
bof: SYSTEM_CODES.BOF,
|
|
20
|
+
npm: SYSTEM_CODES.NPM,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 取得平台設定 - 根據平台名稱回傳相關設定
|
|
25
|
+
* @param {string} platformName - 平台名稱 (例如 'cwa', 'ibk')
|
|
26
|
+
* @param {string} environment - 環境 (例如 'stg', 'prd')
|
|
27
|
+
* @returns {object} 平台設定
|
|
28
|
+
*/
|
|
29
|
+
export function getPlatformConfig(platformName, environment = 'stg') {
|
|
30
|
+
const systemCode = PLATFORM_TO_SYSTEM_CODE[platformName.toLowerCase()];
|
|
31
|
+
|
|
32
|
+
if (!systemCode) {
|
|
33
|
+
console.warn(`Unknown platform: ${platformName}, using defaults`);
|
|
34
|
+
return {
|
|
35
|
+
projectKey: process.env.JIRA_PROJECT_KEY ?? 'OPS',
|
|
36
|
+
systemCode: undefined,
|
|
37
|
+
clusters: [],
|
|
38
|
+
environment,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
systemCode,
|
|
44
|
+
environment,
|
|
45
|
+
clusters: getServerList(systemCode, environment),
|
|
46
|
+
supportedModules: Object.keys(GRAY_RELEASE_MODULE_IDS).filter(
|
|
47
|
+
(m) => GRAY_RELEASE_MODULE_IDS[m],
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @deprecated 保留供向後相容,新代碼請使用 getPlatformConfig()
|
|
54
|
+
*/
|
|
55
|
+
export const PLATFORMS = {
|
|
56
|
+
cwa: {
|
|
57
|
+
systemCode: SYSTEM_CODES.CWA,
|
|
58
|
+
issueType: 'GrayRelease',
|
|
59
|
+
},
|
|
60
|
+
ibk: {
|
|
61
|
+
systemCode: SYSTEM_CODES.IBK,
|
|
62
|
+
issueType: 'GrayRelease',
|
|
63
|
+
},
|
|
64
|
+
};
|
package/poller.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poller — 輪詢等待 Jira issue 達到目標狀態
|
|
3
|
+
*
|
|
4
|
+
* 使用方式:
|
|
5
|
+
* const poller = new Poller(jiraClient);
|
|
6
|
+
* const result = await poller.waitForStatus('OPS-123', 'Approved', {
|
|
7
|
+
* intervalMs: 30000,
|
|
8
|
+
* timeoutMs: 3600000,
|
|
9
|
+
* });
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class Poller {
|
|
13
|
+
constructor(jiraClient) {
|
|
14
|
+
this.jira = jiraClient;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 輪詢直到 issue 狀態 === targetStatus
|
|
19
|
+
* @returns {{ issueKey, status, elapsedMs, attempts }}
|
|
20
|
+
*/
|
|
21
|
+
async waitForStatus(issueKey, targetStatus, options = {}) {
|
|
22
|
+
const intervalMs = options.intervalMs ?? parseInt(process.env.POLL_INTERVAL_MS ?? '30000');
|
|
23
|
+
const timeoutMs = options.timeoutMs ?? parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000');
|
|
24
|
+
|
|
25
|
+
const startTime = Date.now();
|
|
26
|
+
let attempts = 0;
|
|
27
|
+
|
|
28
|
+
console.error(
|
|
29
|
+
`[Poller] Waiting for "${issueKey}" → "${targetStatus}" ` +
|
|
30
|
+
`(interval ${intervalMs / 1000}s, timeout ${timeoutMs / 1000}s)`,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
while (true) {
|
|
34
|
+
attempts++;
|
|
35
|
+
const issue = await this.jira.getIssue(issueKey);
|
|
36
|
+
const currentStatus = issue.fields.status.name;
|
|
37
|
+
|
|
38
|
+
console.error(`[Poller] attempt #${attempts} — current: "${currentStatus}"`);
|
|
39
|
+
|
|
40
|
+
if (currentStatus.toLowerCase() === targetStatus.toLowerCase()) {
|
|
41
|
+
return {
|
|
42
|
+
issueKey,
|
|
43
|
+
status: currentStatus,
|
|
44
|
+
elapsedMs: Date.now() - startTime,
|
|
45
|
+
attempts,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const elapsed = Date.now() - startTime;
|
|
50
|
+
if (elapsed >= timeoutMs) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Timeout waiting for "${issueKey}" to reach "${targetStatus}". ` +
|
|
53
|
+
`Last status: "${currentStatus}" after ${attempts} attempts.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await sleep(intervalMs);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sleep(ms) {
|
|
63
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
64
|
+
}
|