@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/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 -138
- package/tools/helpers.js +28 -7
- package/tools/index.js +135 -565
- 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/constants/config.js
CHANGED
|
@@ -28,6 +28,7 @@ const DEFAULT_DEPLOY_CONFIG = {
|
|
|
28
28
|
deptManagerSign: 'customfield_dept_manager_sign',
|
|
29
29
|
authManagerSign: 'customfield_auth_manager_sign',
|
|
30
30
|
releaseInfo: 'customfield_release_info',
|
|
31
|
+
buildResult: 'customfield_build_result',
|
|
31
32
|
},
|
|
32
33
|
cd: {
|
|
33
34
|
systemSubmodule: 'customfield_system_submodule',
|
|
@@ -49,12 +50,12 @@ const DEFAULT_DEPLOY_CONFIG = {
|
|
|
49
50
|
antiScanRequired: 'customfield_anti_scan_required',
|
|
50
51
|
ciNotes: 'customfield_ci_notes',
|
|
51
52
|
releaseVersion: 'customfield_release_version',
|
|
53
|
+
uploadResult: 'customfield_13452',
|
|
52
54
|
},
|
|
53
55
|
grayRelease: {
|
|
54
56
|
grayReleaseVersion: 'customfield_grayrelease_version',
|
|
55
57
|
clusterList: 'customfield_cluster_list',
|
|
56
58
|
grayReleaseNotes: 'customfield_grayrelease_notes',
|
|
57
|
-
buildResult: 'customfield_build_result',
|
|
58
59
|
deployResult: 'customfield_deploy_result',
|
|
59
60
|
},
|
|
60
61
|
},
|
|
@@ -112,11 +113,22 @@ const DEFAULT_DEPLOY_CONFIG = {
|
|
|
112
113
|
},
|
|
113
114
|
release: {
|
|
114
115
|
managerSubCalendarId: '',
|
|
116
|
+
birthdayCalendarId: '',
|
|
117
|
+
leaveCalendarId: '',
|
|
115
118
|
dryRunManager: { name: 'Demo User', accountId: 'demo-user' },
|
|
116
119
|
grayReleaseUatApprovers: {
|
|
117
120
|
commentReviewerAlias: 'uat-comment-reviewer',
|
|
118
121
|
finalApproverAlias: 'uat-final-approver',
|
|
119
122
|
},
|
|
123
|
+
managerSubstitutes: {},
|
|
124
|
+
cdApprovers: {
|
|
125
|
+
prd: {
|
|
126
|
+
leadReviewerAliases: [],
|
|
127
|
+
secondReviewerAlias: '',
|
|
128
|
+
thirdReviewerAlias: '',
|
|
129
|
+
finalApproverAlias: '',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
120
132
|
},
|
|
121
133
|
jabber: {
|
|
122
134
|
domain: 'example.internal',
|
package/index.js
CHANGED
|
@@ -3,4 +3,4 @@ export {Notifier} from './notifier.js';
|
|
|
3
3
|
export {Poller} from './poller.js';
|
|
4
4
|
export {getPlatformConfig, PLATFORM_TO_SYSTEM_CODE, PLATFORMS} from './platform-config.js';
|
|
5
5
|
export {getToolDefinitions, executeTool} from './tools/index.js';
|
|
6
|
-
export {
|
|
6
|
+
export {getServerList, ok, error, today} from './tools/helpers.js';
|
package/jira-client.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import https from 'https';
|
|
3
3
|
|
|
4
|
-
const httpsAgent = new https.Agent({rejectUnauthorized: false});
|
|
4
|
+
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
5
5
|
|
|
6
6
|
const DRY_RUN = process.env.DRY_RUN === 'true';
|
|
7
7
|
|
|
8
8
|
export class JiraClient {
|
|
9
9
|
constructor() {
|
|
10
|
-
const {JIRA_BASE_URL, JIRA_API_TOKEN} = process.env;
|
|
10
|
+
const { JIRA_BASE_URL, JIRA_API_TOKEN } = process.env;
|
|
11
11
|
if (!JIRA_BASE_URL || !JIRA_API_TOKEN) {
|
|
12
12
|
throw new Error('Missing required Jira env vars: JIRA_BASE_URL, JIRA_API_TOKEN');
|
|
13
13
|
}
|
|
@@ -28,9 +28,9 @@ export class JiraClient {
|
|
|
28
28
|
async createIssue(fields) {
|
|
29
29
|
if (this.dryRun) {
|
|
30
30
|
console.log('[DRY RUN] JiraClient.createIssue', JSON.stringify(fields).slice(0, 1000));
|
|
31
|
-
return {id: 'DRY-RUN', key: 'DRY-RUN', self: ''};
|
|
31
|
+
return { id: 'DRY-RUN', key: 'DRY-RUN', self: '' };
|
|
32
32
|
}
|
|
33
|
-
const res = await this.http.post('/issue', {fields}).catch((err) => {
|
|
33
|
+
const res = await this.http.post('/issue', { fields }).catch((err) => {
|
|
34
34
|
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
35
35
|
throw new Error(`Create issue failed: ${detail}`);
|
|
36
36
|
});
|
|
@@ -45,22 +45,22 @@ export class JiraClient {
|
|
|
45
45
|
issueKey,
|
|
46
46
|
JSON.stringify(fields).slice(0, 1000),
|
|
47
47
|
);
|
|
48
|
-
return {issueKey, updated: Object.keys(fields)};
|
|
48
|
+
return { issueKey, updated: Object.keys(fields) };
|
|
49
49
|
}
|
|
50
|
-
await this.http.put(`/issue/${issueKey}`, {fields});
|
|
51
|
-
return {issueKey, updated: Object.keys(fields)};
|
|
50
|
+
await this.http.put(`/issue/${issueKey}`, { fields });
|
|
51
|
+
return { issueKey, updated: Object.keys(fields) };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// 更新 Assignee(使用專用 endpoint,Jira Server 相容)
|
|
55
55
|
async updateAssignee(issueKey, accountId) {
|
|
56
56
|
if (this.dryRun) {
|
|
57
57
|
console.log('[DRY RUN] JiraClient.updateAssignee', issueKey, accountId);
|
|
58
|
-
return {issueKey, accountId};
|
|
58
|
+
return { issueKey, accountId };
|
|
59
59
|
}
|
|
60
|
-
await this.http.put(`/issue/${issueKey}/assignee`, {name: accountId}).catch(async (err) => {
|
|
60
|
+
await this.http.put(`/issue/${issueKey}/assignee`, { name: accountId }).catch(async (err) => {
|
|
61
61
|
// 若 name 格式失敗,改試 accountId 格式(Jira Cloud)
|
|
62
62
|
if (err.response?.status === 400) {
|
|
63
|
-
await this.http.put(`/issue/${issueKey}/assignee`, {accountId}).catch((e) => {
|
|
63
|
+
await this.http.put(`/issue/${issueKey}/assignee`, { accountId }).catch((e) => {
|
|
64
64
|
const detail = e.response?.data ? JSON.stringify(e.response.data) : e.message;
|
|
65
65
|
throw new Error(`updateAssignee failed: ${detail}`);
|
|
66
66
|
});
|
|
@@ -69,18 +69,18 @@ export class JiraClient {
|
|
|
69
69
|
throw new Error(`updateAssignee failed: ${detail}`);
|
|
70
70
|
}
|
|
71
71
|
});
|
|
72
|
-
return {issueKey, accountId};
|
|
72
|
+
return { issueKey, accountId };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
// 查詢 issue 目前狀態
|
|
76
76
|
async getIssue(issueKey) {
|
|
77
77
|
const res = await this.http.get(`/issue/${issueKey}`, {
|
|
78
|
-
params: {fields: 'status,summary,assignee,comment'},
|
|
78
|
+
params: { fields: 'status,summary,assignee,comment' },
|
|
79
79
|
});
|
|
80
80
|
return res.data;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
//
|
|
83
|
+
// 取得當前狀態可執行的 transitions (可以執行的狀態切換列表)
|
|
84
84
|
async getTransitions(issueKey) {
|
|
85
85
|
const res = await this.http.get(`/issue/${issueKey}/transitions`);
|
|
86
86
|
return res.data.transitions; // [{ id, name, to: { name } }]
|
|
@@ -89,7 +89,7 @@ export class JiraClient {
|
|
|
89
89
|
// 查詢 issue 的指定欄位(例如取得 CI 單的 CID_release_version)
|
|
90
90
|
async getIssueFields(issueKey, fields = []) {
|
|
91
91
|
const res = await this.http.get(`/issue/${issueKey}`, {
|
|
92
|
-
params: {fields: fields.join(',')},
|
|
92
|
+
params: { fields: fields.join(',') },
|
|
93
93
|
});
|
|
94
94
|
return res.data.fields;
|
|
95
95
|
}
|
|
@@ -110,24 +110,24 @@ export class JiraClient {
|
|
|
110
110
|
async transitionById(issueKey, transitionId) {
|
|
111
111
|
if (this.dryRun) {
|
|
112
112
|
console.log('[DRY RUN] JiraClient.transitionById', issueKey, transitionId);
|
|
113
|
-
return {issueKey, transitionId};
|
|
113
|
+
return { issueKey, transitionId };
|
|
114
114
|
}
|
|
115
115
|
await this.http
|
|
116
116
|
.post(`/issue/${issueKey}/transitions`, {
|
|
117
|
-
transition: {id: transitionId},
|
|
117
|
+
transition: { id: transitionId },
|
|
118
118
|
})
|
|
119
119
|
.catch((err) => {
|
|
120
120
|
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
121
121
|
throw new Error(`Transition id=${transitionId} failed: ${detail}`);
|
|
122
122
|
});
|
|
123
|
-
return {issueKey, transitionId};
|
|
123
|
+
return { issueKey, transitionId };
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
// 依名稱切換狀態(不用記 id)
|
|
127
127
|
async transitionByName(issueKey, transitionName) {
|
|
128
128
|
if (this.dryRun) {
|
|
129
129
|
console.log('[DRY RUN] JiraClient.transitionByName', issueKey, transitionName);
|
|
130
|
-
return {issueKey, transitioned: transitionName, toStatus: 'DRY'};
|
|
130
|
+
return { issueKey, transitioned: transitionName, toStatus: 'DRY' };
|
|
131
131
|
}
|
|
132
132
|
const transitions = await this.getTransitions(issueKey);
|
|
133
133
|
const match = transitions.find((t) => t.name.toLowerCase() === transitionName.toLowerCase());
|
|
@@ -137,34 +137,34 @@ export class JiraClient {
|
|
|
137
137
|
}
|
|
138
138
|
await this.http
|
|
139
139
|
.post(`/issue/${issueKey}/transitions`, {
|
|
140
|
-
transition: {id: match.id},
|
|
140
|
+
transition: { id: match.id },
|
|
141
141
|
})
|
|
142
142
|
.catch((err) => {
|
|
143
143
|
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
144
144
|
throw new Error(`Transition "${match.name}" failed: ${detail}`);
|
|
145
145
|
});
|
|
146
|
-
return {issueKey, transitioned: match.name, toStatus: match.to.name};
|
|
146
|
+
return { issueKey, transitioned: match.name, toStatus: match.to.name };
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
// 新增 issue link(relates to / blocks / is blocked by 等)
|
|
150
150
|
async linkIssue(inwardKey, outwardKey, linkTypeName = 'Relates') {
|
|
151
151
|
if (this.dryRun) {
|
|
152
152
|
console.log('[DRY RUN] JiraClient.linkIssue', inwardKey, outwardKey, linkTypeName);
|
|
153
|
-
return {inwardKey, outwardKey, linkType: linkTypeName};
|
|
153
|
+
return { inwardKey, outwardKey, linkType: linkTypeName };
|
|
154
154
|
}
|
|
155
155
|
await this.http.post('/issueLink', {
|
|
156
|
-
type: {name: linkTypeName},
|
|
157
|
-
inwardIssue: {key: inwardKey},
|
|
158
|
-
outwardIssue: {key: outwardKey},
|
|
156
|
+
type: { name: linkTypeName },
|
|
157
|
+
inwardIssue: { key: inwardKey },
|
|
158
|
+
outwardIssue: { key: outwardKey },
|
|
159
159
|
});
|
|
160
|
-
return {inwardKey, outwardKey, linkType: linkTypeName};
|
|
160
|
+
return { inwardKey, outwardKey, linkType: linkTypeName };
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
// 新增 comment(/rest/api/2 用純文字 body)
|
|
164
164
|
async addComment(issueKey, text) {
|
|
165
165
|
if (this.dryRun) {
|
|
166
166
|
console.log('[DRY RUN] JiraClient.addComment', issueKey, text);
|
|
167
|
-
return {issueKey, body: text};
|
|
167
|
+
return { issueKey, body: text };
|
|
168
168
|
}
|
|
169
169
|
const res = await this.http.post(`/issue/${issueKey}/comment`, {
|
|
170
170
|
body: text,
|
|
@@ -176,11 +176,11 @@ export class JiraClient {
|
|
|
176
176
|
async addRemoteLink(issueKey, url, title) {
|
|
177
177
|
if (this.dryRun) {
|
|
178
178
|
console.log('[DRY RUN] JiraClient.addRemoteLink', issueKey, url, title);
|
|
179
|
-
return {issueKey, url, title};
|
|
179
|
+
return { issueKey, url, title };
|
|
180
180
|
}
|
|
181
181
|
const res = await this.http
|
|
182
182
|
.post(`/issue/${issueKey}/remotelink`, {
|
|
183
|
-
object: {url, title},
|
|
183
|
+
object: { url, title },
|
|
184
184
|
})
|
|
185
185
|
.catch((err) => {
|
|
186
186
|
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
@@ -197,7 +197,7 @@ export class JiraClient {
|
|
|
197
197
|
//
|
|
198
198
|
// status: 'unreleased'
|
|
199
199
|
// query: 相當於 UI 的 contains 參數(版本名稱包含此字串,不分大小寫)
|
|
200
|
-
async getProjectVersions(projectKey, {name = {}}) {
|
|
200
|
+
async getProjectVersions(projectKey, { name = {} }) {
|
|
201
201
|
const res = await this.http.get(`/project/${projectKey}/versions`);
|
|
202
202
|
let versions = res.data ?? [];
|
|
203
203
|
|
|
@@ -231,7 +231,7 @@ export class JiraClient {
|
|
|
231
231
|
async getSubTasks(issueKey) {
|
|
232
232
|
const res = await this.http
|
|
233
233
|
.get(`/issue/${issueKey}`, {
|
|
234
|
-
params: {fields: 'subtasks,issuetype'},
|
|
234
|
+
params: { fields: 'subtasks,issuetype' },
|
|
235
235
|
})
|
|
236
236
|
.catch((err) => {
|
|
237
237
|
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
@@ -244,7 +244,7 @@ export class JiraClient {
|
|
|
244
244
|
async searchIssues(jql, fields = [], maxResults = 10) {
|
|
245
245
|
const res = await this.http
|
|
246
246
|
.get('/search', {
|
|
247
|
-
params: {jql, fields: fields.join(','), maxResults},
|
|
247
|
+
params: { jql, fields: fields.join(','), maxResults },
|
|
248
248
|
})
|
|
249
249
|
.catch((err) => {
|
|
250
250
|
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
@@ -265,7 +265,7 @@ export class JiraClient {
|
|
|
265
265
|
Accept: 'text/plain',
|
|
266
266
|
Authorization: `Bearer ${process.env.BITBUCKET_API_TOKEN}`,
|
|
267
267
|
},
|
|
268
|
-
params: {at: branch},
|
|
268
|
+
params: { at: branch },
|
|
269
269
|
responseType: 'text',
|
|
270
270
|
})
|
|
271
271
|
.catch((err) => {
|
|
@@ -279,7 +279,7 @@ export class JiraClient {
|
|
|
279
279
|
async getBitbucketTags(
|
|
280
280
|
project,
|
|
281
281
|
repo,
|
|
282
|
-
{filterValue = '', orderBy = 'MODIFICATION', limit = 1} = {},
|
|
282
|
+
{ filterValue = '', orderBy = 'MODIFICATION', limit = 1 } = {},
|
|
283
283
|
) {
|
|
284
284
|
const BB_BASE = process.env.BITBUCKET_URL ?? process.env.BITBUCKET_BASE_URL;
|
|
285
285
|
if (!BB_BASE) throw new Error('Missing required Bitbucket env var: BITBUCKET_URL');
|
|
@@ -292,7 +292,7 @@ export class JiraClient {
|
|
|
292
292
|
Accept: 'application/json',
|
|
293
293
|
Authorization: `Bearer ${process.env.BITBUCKET_API_TOKEN}`,
|
|
294
294
|
},
|
|
295
|
-
params: {filterValue, orderBy, limit},
|
|
295
|
+
params: { filterValue, orderBy, limit },
|
|
296
296
|
})
|
|
297
297
|
.catch((err) => {
|
|
298
298
|
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
@@ -305,7 +305,7 @@ export class JiraClient {
|
|
|
305
305
|
async getBitbucketBranches(
|
|
306
306
|
project,
|
|
307
307
|
repo,
|
|
308
|
-
{filterValue = '', orderBy = 'MODIFICATION', limit = 1} = {},
|
|
308
|
+
{ filterValue = '', orderBy = 'MODIFICATION', limit = 1 } = {},
|
|
309
309
|
) {
|
|
310
310
|
const BB_BASE = process.env.BITBUCKET_URL ?? process.env.BITBUCKET_BASE_URL;
|
|
311
311
|
if (!BB_BASE) throw new Error('Missing required Bitbucket env var: BITBUCKET_URL');
|
|
@@ -318,7 +318,7 @@ export class JiraClient {
|
|
|
318
318
|
Accept: 'application/json',
|
|
319
319
|
Authorization: `Bearer ${process.env.BITBUCKET_API_TOKEN}`,
|
|
320
320
|
},
|
|
321
|
-
params: {filterValue, orderBy, limit},
|
|
321
|
+
params: { filterValue, orderBy, limit },
|
|
322
322
|
})
|
|
323
323
|
.catch((err) => {
|
|
324
324
|
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
@@ -326,4 +326,35 @@ export class JiraClient {
|
|
|
326
326
|
});
|
|
327
327
|
return res.data?.values ?? [];
|
|
328
328
|
}
|
|
329
|
+
|
|
330
|
+
// 取得 Bitbucket repo 的 pull requests(Bitbucket REST API 1.0)
|
|
331
|
+
async getBitbucketPullRequests(
|
|
332
|
+
project,
|
|
333
|
+
repo,
|
|
334
|
+
{ branch = '', state = 'OPEN', limit = 100 } = {},
|
|
335
|
+
) {
|
|
336
|
+
const BB_BASE = process.env.BITBUCKET_URL ?? process.env.BITBUCKET_BASE_URL;
|
|
337
|
+
if (!BB_BASE) throw new Error('Missing required Bitbucket env var: BITBUCKET_URL');
|
|
338
|
+
const url = `${BB_BASE}/rest/api/1.0/projects/${project}/repos/${repo}/pull-requests`;
|
|
339
|
+
const res = await axios
|
|
340
|
+
.get(url, {
|
|
341
|
+
httpsAgent,
|
|
342
|
+
headers: {
|
|
343
|
+
'Content-Type': 'application/json',
|
|
344
|
+
Accept: 'application/json',
|
|
345
|
+
Authorization: `Bearer ${process.env.BITBUCKET_API_TOKEN}`,
|
|
346
|
+
},
|
|
347
|
+
params: {
|
|
348
|
+
state,
|
|
349
|
+
limit,
|
|
350
|
+
direction: 'OUTGOING',
|
|
351
|
+
...(branch ? { at: `refs/heads/${branch}` } : {}),
|
|
352
|
+
},
|
|
353
|
+
})
|
|
354
|
+
.catch((err) => {
|
|
355
|
+
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
|
356
|
+
throw new Error(`getBitbucketPullRequests failed: ${detail}`);
|
|
357
|
+
});
|
|
358
|
+
return res.data?.values ?? [];
|
|
359
|
+
}
|
|
329
360
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jira-deploy/core",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"repository": {
|
|
@@ -16,19 +16,25 @@
|
|
|
16
16
|
"./poller": "./poller.js",
|
|
17
17
|
"./platform-config": "./platform-config.js",
|
|
18
18
|
"./tools": "./tools/index.js",
|
|
19
|
+
"./tools/build": "./tools/build.js",
|
|
19
20
|
"./tools/helpers": "./tools/helpers.js",
|
|
20
21
|
"./tools/library": "./tools/library.js"
|
|
21
22
|
},
|
|
22
23
|
"files": [
|
|
23
24
|
"constants/**/*.js",
|
|
24
25
|
"scripts/jabber_notify.py",
|
|
26
|
+
"tools/branch-prs.js",
|
|
27
|
+
"tools/build.js",
|
|
25
28
|
"tools/cd.js",
|
|
26
29
|
"tools/ci.js",
|
|
30
|
+
"tools/deployment.js",
|
|
31
|
+
"tools/deployment-helpers.js",
|
|
27
32
|
"tools/grayrelease.js",
|
|
28
33
|
"tools/helpers.js",
|
|
29
34
|
"tools/index.js",
|
|
30
35
|
"tools/jabber.js",
|
|
31
36
|
"tools/library.js",
|
|
37
|
+
"tools/transition-helpers.js",
|
|
32
38
|
"tools/release.js",
|
|
33
39
|
"tools/workflows.js",
|
|
34
40
|
"index.js",
|
|
@@ -42,6 +48,6 @@
|
|
|
42
48
|
"dotenv": "^16.3.0"
|
|
43
49
|
},
|
|
44
50
|
"scripts": {
|
|
45
|
-
"test": "node --import ./test-env.js --test tools.test.js tools/jabber.test.js config.test.js"
|
|
51
|
+
"test": "node --import ./test-env.js --test tools.test.js tools/jabber.test.js tools/transition-helpers.test.js config.test.js"
|
|
46
52
|
}
|
|
47
53
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MODULE_TO_REPO_MAP,
|
|
3
|
+
REPO_MAPS,
|
|
4
|
+
SYSTEM_TO_CI_REPO_MAP,
|
|
5
|
+
} from '../constants/index.js';
|
|
6
|
+
import { error, ok } from './helpers.js';
|
|
7
|
+
|
|
8
|
+
const SUPPORTED_TICKET_TYPES = ['library', 'grayrelease', 'ci'];
|
|
9
|
+
|
|
10
|
+
export function getBranchPRToolDefinitions() {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
name: 'validate_branch_no_open_pr',
|
|
14
|
+
description:
|
|
15
|
+
'檢查開 Library、GrayRelease 或 CI 單前,對應 Bitbucket branch 是否有尚未 merge 的 OPEN pull request。Library/GrayRelease 使用輸入 branch;CI 固定檢查 master。CD 與 Deployment 不需要使用。',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
required: ['ticketType', 'systemCode'],
|
|
19
|
+
properties: {
|
|
20
|
+
ticketType: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
enum: SUPPORTED_TICKET_TYPES,
|
|
23
|
+
description: '要檢查的開單類型:library / grayrelease / ci。',
|
|
24
|
+
},
|
|
25
|
+
systemCode: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: '系統代碼,例如 IBK、CWA。CI 會用 systemCode 找 Golden Image repo。',
|
|
28
|
+
},
|
|
29
|
+
module: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Library 或 GrayRelease 模組名稱,例如 ssr、cwa。CI 不需要。',
|
|
32
|
+
},
|
|
33
|
+
branch: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Library / GrayRelease 要檢查的 Git branch。CI 會忽略此值並固定檢查 master。',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function handleValidateBranchNoOpenPR(args, { jira }) {
|
|
44
|
+
try {
|
|
45
|
+
const result = await validateBranchNoOpenPR(args, { jira });
|
|
46
|
+
return ok(result);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return error(`validate_branch_no_open_pr 失敗: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function assertNoOpenPRBeforeCreate(args, { jira }) {
|
|
53
|
+
const result = await validateBranchNoOpenPR(args, { jira });
|
|
54
|
+
if (result.blocked) {
|
|
55
|
+
throw new Error(formatOpenPRBlockMessage(result));
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function validateBranchNoOpenPR(args, { jira }) {
|
|
61
|
+
const target = resolveBranchPRCheckTarget(args);
|
|
62
|
+
const pullRequests = await jira.getBitbucketPullRequests(target.project, target.repo, {
|
|
63
|
+
branch: target.branch,
|
|
64
|
+
state: 'OPEN',
|
|
65
|
+
limit: 100,
|
|
66
|
+
});
|
|
67
|
+
const openPullRequests = normalizePullRequests(pullRequests, target.branch);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
ok: openPullRequests.length === 0,
|
|
71
|
+
blocked: openPullRequests.length > 0,
|
|
72
|
+
reason: openPullRequests.length > 0 ? 'branch_has_open_pull_requests' : null,
|
|
73
|
+
ticketType: target.ticketType,
|
|
74
|
+
systemCode: target.systemCode,
|
|
75
|
+
module: target.module,
|
|
76
|
+
repo: target.repo,
|
|
77
|
+
project: target.project,
|
|
78
|
+
branch: target.branch,
|
|
79
|
+
openPullRequests,
|
|
80
|
+
message: openPullRequests.length > 0
|
|
81
|
+
? `${target.branch} 仍有尚未 merge 的 PR,請先完成 merge 後再開單。`
|
|
82
|
+
: `${target.branch} 沒有尚未 merge 的 OPEN PR,可繼續開單。`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveBranchPRCheckTarget(args) {
|
|
87
|
+
const ticketType = String(args.ticketType ?? '').trim().toLowerCase();
|
|
88
|
+
const systemCode = String(args.systemCode ?? '').trim().toUpperCase();
|
|
89
|
+
const module = String(args.module ?? '').trim().toLowerCase();
|
|
90
|
+
const branch = ticketType === 'ci'
|
|
91
|
+
? 'master'
|
|
92
|
+
: String(args.branch ?? args.gitBranch ?? '').trim();
|
|
93
|
+
|
|
94
|
+
if (!SUPPORTED_TICKET_TYPES.includes(ticketType)) {
|
|
95
|
+
throw new Error(`不支援的 PR 檢查類型:${args.ticketType},僅支援 ${SUPPORTED_TICKET_TYPES.join(' / ')}`);
|
|
96
|
+
}
|
|
97
|
+
if (!systemCode) {
|
|
98
|
+
throw new Error('缺少 systemCode');
|
|
99
|
+
}
|
|
100
|
+
if (ticketType !== 'ci' && !module) {
|
|
101
|
+
throw new Error(`${ticketType} PR 檢查缺少 module`);
|
|
102
|
+
}
|
|
103
|
+
if (!branch) {
|
|
104
|
+
throw new Error(`${ticketType} PR 檢查缺少 branch`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const repo = ticketType === 'ci'
|
|
108
|
+
? SYSTEM_TO_CI_REPO_MAP[systemCode]
|
|
109
|
+
: MODULE_TO_REPO_MAP[module];
|
|
110
|
+
if (!repo) {
|
|
111
|
+
throw new Error(ticketType === 'ci'
|
|
112
|
+
? `找不到系統 "${systemCode}" 的 CI repo 對應,請確認 SYSTEM_TO_CI_REPO_MAP`
|
|
113
|
+
: `找不到模組 "${module}" 的 repo 對應,請確認 MODULE_TO_REPO_MAP`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const repoMeta = REPO_MAPS[repo];
|
|
117
|
+
if (!repoMeta?.project) {
|
|
118
|
+
throw new Error(`找不到 repo "${repo}" 的 Bitbucket 設定`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
ticketType,
|
|
123
|
+
systemCode,
|
|
124
|
+
module: module || null,
|
|
125
|
+
repo,
|
|
126
|
+
project: repoMeta.project,
|
|
127
|
+
branch,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizePullRequests(pullRequests, branch) {
|
|
132
|
+
return (pullRequests ?? [])
|
|
133
|
+
.filter((pr) => isOpenPullRequestFromBranch(pr, branch))
|
|
134
|
+
.map((pr) => ({
|
|
135
|
+
id: pr.id,
|
|
136
|
+
title: pr.title ?? '',
|
|
137
|
+
state: pr.state ?? '',
|
|
138
|
+
fromRef: pr.fromRef?.displayId ?? normalizeRefId(pr.fromRef?.id),
|
|
139
|
+
toRef: pr.toRef?.displayId ?? normalizeRefId(pr.toRef?.id),
|
|
140
|
+
author: pr.author?.user?.displayName ?? pr.author?.user?.name ?? '',
|
|
141
|
+
url: pr.links?.self?.[0]?.href ?? pr.link?.url ?? '',
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isOpenPullRequestFromBranch(pr, branch) {
|
|
146
|
+
if (String(pr.state ?? '').toUpperCase() !== 'OPEN') {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const expectedRef = `refs/heads/${branch}`;
|
|
150
|
+
return pr.fromRef?.displayId === branch
|
|
151
|
+
|| pr.fromRef?.id === expectedRef
|
|
152
|
+
|| normalizeRefId(pr.fromRef?.id) === branch;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeRefId(refId) {
|
|
156
|
+
return String(refId ?? '').replace(/^refs\/heads\//, '');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formatOpenPRBlockMessage(result) {
|
|
160
|
+
const prLines = result.openPullRequests
|
|
161
|
+
.map((pr) => `#${pr.id} ${pr.title}${pr.url ? ` ${pr.url}` : ''}`)
|
|
162
|
+
.join('; ');
|
|
163
|
+
return `${result.branch} 仍有尚未 merge 的 PR,請先完成 merge 後再開單。${prLines ? ` (${prLines})` : ''}`;
|
|
164
|
+
}
|
package/tools/build.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build 相關共用 tools
|
|
3
|
+
* - wait_build_result
|
|
4
|
+
*/
|
|
5
|
+
import { SHARED_FIELD_IDS } from '../constants/index.js';
|
|
6
|
+
import {
|
|
7
|
+
error,
|
|
8
|
+
getPollIntervalMs,
|
|
9
|
+
getPollTimeoutMs,
|
|
10
|
+
isFailingResult,
|
|
11
|
+
isPassingResult,
|
|
12
|
+
ok,
|
|
13
|
+
} from './helpers.js';
|
|
14
|
+
|
|
15
|
+
function progress(ctx, event) {
|
|
16
|
+
if (typeof ctx.progress === 'function') {
|
|
17
|
+
ctx.progress(event);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Schema definitions ───────────────────────────────────────────
|
|
22
|
+
export function getBuildToolDefinitions() {
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
name: 'wait_build_result',
|
|
26
|
+
description:
|
|
27
|
+
'輪詢等待 Jira issue 的共用 Build 結果欄位變成 pass 或 fail。支援 CI、Library、GrayRelease;通常接在 build_ci、build_library 或 build_grayrelease 成功觸發 Jenkins build 後使用。',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
required: ['issueKey'],
|
|
31
|
+
properties: {
|
|
32
|
+
issueKey: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: '要等待 build 結果的 issue key,例如 CI、Library 或 GrayRelease 單 CID-822',
|
|
35
|
+
},
|
|
36
|
+
resultFieldId: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: '(選填) Build 結果欄位 ID,預設 customfield_13432',
|
|
39
|
+
default: SHARED_FIELD_IDS.buildResult,
|
|
40
|
+
},
|
|
41
|
+
pollIntervalMs: {
|
|
42
|
+
type: 'number',
|
|
43
|
+
description: '輪詢間隔毫秒,預設讀 POLL_INTERVAL_MS',
|
|
44
|
+
},
|
|
45
|
+
timeoutMs: {
|
|
46
|
+
type: 'number',
|
|
47
|
+
description: '最長等待毫秒,預設讀 POLL_TIMEOUT_MS',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Handlers ─────────────────────────────────────────────────────
|
|
56
|
+
export async function handleWaitBuildResult(args, ctx) {
|
|
57
|
+
const { issueKey } = args;
|
|
58
|
+
const { jira } = ctx;
|
|
59
|
+
const fieldId = args.resultFieldId ?? SHARED_FIELD_IDS.buildResult;
|
|
60
|
+
const intervalMs = args.pollIntervalMs ?? getPollIntervalMs();
|
|
61
|
+
const timeoutMs = args.timeoutMs ?? getPollTimeoutMs();
|
|
62
|
+
const startedAt = Date.now();
|
|
63
|
+
const deadline = Date.now() + timeoutMs;
|
|
64
|
+
let attempts = 0;
|
|
65
|
+
let lastValue;
|
|
66
|
+
let currentStatus;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
while (true) {
|
|
70
|
+
attempts++;
|
|
71
|
+
const issue = await jira.getIssue(issueKey);
|
|
72
|
+
currentStatus = issue.fields.status?.name;
|
|
73
|
+
const fields = await jira.getIssueFields(issueKey, [fieldId]);
|
|
74
|
+
const raw = fields[fieldId];
|
|
75
|
+
lastValue = raw?.value ?? raw;
|
|
76
|
+
|
|
77
|
+
progress(ctx, {
|
|
78
|
+
phase: 'polling',
|
|
79
|
+
title: '等待 Build 結果',
|
|
80
|
+
detail: `${fieldId}: ${lastValue ?? 'empty'}`,
|
|
81
|
+
issueKey,
|
|
82
|
+
currentStatus,
|
|
83
|
+
attempts,
|
|
84
|
+
elapsedMs: Date.now() - startedAt,
|
|
85
|
+
timeoutMs,
|
|
86
|
+
nextPollMs: intervalMs,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (isPassingResult(lastValue)) {
|
|
90
|
+
return ok({
|
|
91
|
+
issueKey,
|
|
92
|
+
fieldId,
|
|
93
|
+
result: lastValue,
|
|
94
|
+
buildResult: lastValue,
|
|
95
|
+
currentStatus,
|
|
96
|
+
attempts,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isFailingResult(lastValue)) {
|
|
101
|
+
return error(`Build 失敗,${fieldId}: ${lastValue}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (Date.now() >= deadline) {
|
|
105
|
+
return error(`Build 結果等待逾時,${fieldId}: ${lastValue ?? 'empty'}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await sleep(intervalMs);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return error(`wait_build_result 失敗: ${err.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sleep(ms) {
|
|
116
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
117
|
+
}
|