@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.
@@ -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 {getClusterList, ok, error, today} from './tools/helpers.js';
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
- // 取得可執行的 transitions
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.14",
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
+ }