@jira-deploy/core 1.0.15 → 1.0.17

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,103 @@
1
+ import {getPollIntervalMs} from './helpers.js';
2
+ import {sleep} from './transition-helpers.js';
3
+
4
+ export function normalizeDeploymentEnvironment(environment) {
5
+ return String(environment ?? '')
6
+ .trim()
7
+ .toLowerCase()
8
+ .replace('&', '/');
9
+ }
10
+
11
+ export function isPrdLikeEnv(environment) {
12
+ return ['prd', 'dr', 'prd/dr'].includes(normalizeDeploymentEnvironment(environment));
13
+ }
14
+
15
+ export function getDeploymentEnvGroup(environment) {
16
+ return isPrdLikeEnv(environment) ? 'prd' : 'nonPrd';
17
+ }
18
+
19
+ export async function needSwitchExecutionNode({
20
+ issueKey,
21
+ systemCode,
22
+ environment,
23
+ envGroup,
24
+ jira,
25
+ log = [],
26
+ }) {
27
+ const thisGroup = envGroup ?? getDeploymentEnvGroup(environment);
28
+
29
+ if (await hasRecentSwitchExecutionNodeComment({issueKey, systemCode, envGroup: thisGroup, jira})) {
30
+ log.push(` ✅ 已找到 ${thisGroup} execution node 切換成功留言,跳過 Switch Execution Node`);
31
+ return false;
32
+ }
33
+
34
+ const jql = `project = CID AND (issuetype = CD OR issuetype = GrayRelease) AND text ~ "${systemCode}" AND status = Done AND issueKey != "${issueKey}" ORDER BY updated DESC`;
35
+
36
+ try {
37
+ log.push(' 查詢歷史 CD/GrayRelease 單,判斷是否需要切換 Execution Node');
38
+ const results = await jira.searchIssues(jql, ['customfield_13436', 'updated', 'issuetype'], 1);
39
+ const lastIssue = results[0] ?? null;
40
+
41
+ if (!lastIssue) {
42
+ log.push(' ⚠️ 找不到歷史部署記錄,保守執行 Switch Execution Node');
43
+ return true;
44
+ }
45
+
46
+ const issueTypeName = lastIssue.fields?.issuetype?.name ?? '';
47
+ const envValue = lastIssue.fields?.customfield_13436?.value ?? '';
48
+ const lastGroup = issueTypeName.toLowerCase() === 'grayrelease'
49
+ ? 'nonPrd'
50
+ : getDeploymentEnvGroup(envValue);
51
+
52
+ if (lastGroup === thisGroup) {
53
+ log.push(` ✅ 上次部署同為 ${lastGroup} 群組,跳過 Switch Execution Node`);
54
+ return false;
55
+ }
56
+
57
+ log.push(` 🔄 環境群組變更(${lastGroup} → ${thisGroup}),需執行 Switch Execution Node`);
58
+ return true;
59
+ } catch (err) {
60
+ log.push(` ⚠️ 判斷 Switch 條件失敗(${err.message}),保守執行`);
61
+ return true;
62
+ }
63
+ }
64
+
65
+ export async function waitForSwitchExecutionNode({issueKey, systemCode, envGroup = 'nonPrd', jira}) {
66
+ const waitMs = parseInt(process.env.SWITCH_EXECUTION_NODE_WAIT_MS ?? '180000');
67
+ const intervalMs = getPollIntervalMs();
68
+ const deadline = Date.now() + waitMs;
69
+
70
+ while (true) {
71
+ if (await hasRecentSwitchExecutionNodeComment({issueKey, systemCode, envGroup, jira})) {
72
+ return true;
73
+ }
74
+
75
+ if (Date.now() >= deadline) {
76
+ return false;
77
+ }
78
+
79
+ await sleep(intervalMs);
80
+ }
81
+ }
82
+
83
+ async function hasRecentSwitchExecutionNodeComment({issueKey, systemCode, envGroup, jira}) {
84
+ if (!jira.getComments) {
85
+ return false;
86
+ }
87
+
88
+ try {
89
+ const comments = await jira.getComments(issueKey);
90
+ const lowerSystemCode = String(systemCode ?? '').toLowerCase();
91
+ const groupNeedle = envGroup === 'prd' ? 'prd_executionnode' : 'nonprd_executionnode';
92
+ return comments.some((comment) => {
93
+ const body = String(comment.body ?? '').toLowerCase();
94
+ const author = String(comment.author?.displayName ?? '').toLowerCase();
95
+ return author === 'cid jira worker'
96
+ && body.includes('instance_group')
97
+ && body.includes(groupNeedle)
98
+ && (!lowerSystemCode || body.includes(lowerSystemCode));
99
+ });
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
@@ -0,0 +1,269 @@
1
+ import {
2
+ error,
3
+ getPollIntervalMs,
4
+ getPollTimeoutMs,
5
+ isFailingResult,
6
+ isPassingResult,
7
+ ok,
8
+ } from './helpers.js';
9
+ import {sleep} from './transition-helpers.js';
10
+ import {CD_FIELD_IDS, GRAY_RELEASE_FIELD_IDS} from '../constants/index.js';
11
+ import {
12
+ getDeploymentEnvGroup,
13
+ isPrdLikeEnv,
14
+ needSwitchExecutionNode,
15
+ normalizeDeploymentEnvironment,
16
+ waitForSwitchExecutionNode,
17
+ } from './deployment-helpers.js';
18
+
19
+ const DEPLOY_RESULT_FIELD_ID = GRAY_RELEASE_FIELD_IDS.deployResult;
20
+
21
+ export function getDeploymentToolDefinitions() {
22
+ return [
23
+ {
24
+ name: 'trigger_deployment',
25
+ description:
26
+ '在 CD 單的 Deployment sub-task 上依序觸發部署 transitions(To Pretask → Switch Execution Node 視 prd/nonPrd 群組決定 → To AutoDeploy → Trigger AutoDeploy),並等待 CID_deploy_result(customfield_13433) pass。接在 prepare_cd_deployment 之後使用。',
27
+ inputSchema: {
28
+ type: 'object',
29
+ required: ['cdIssueKey', 'environment'],
30
+ properties: {
31
+ cdIssueKey: {
32
+ type: 'string',
33
+ description: 'CD 單 issue key',
34
+ },
35
+ environment: {
36
+ type: 'string',
37
+ enum: ['dev', 'stg', 'uat', 'prd/dr', 'prd', 'dr'],
38
+ description: '部署目標環境:dev / stg / uat / prd/dr / prd / dr',
39
+ },
40
+ applyForClose: {
41
+ type: 'boolean',
42
+ description: '部署結果 pass 後是否同步對 CD 單執行 Apply for close(預設 false;正式 release flow 不會自動關單)',
43
+ },
44
+ },
45
+ },
46
+ },
47
+ ];
48
+ }
49
+
50
+ export async function handleTriggerDeployment(args, ctx) {
51
+ const {jira, notifier} = ctx;
52
+ const {cdIssueKey} = args;
53
+ const environment = normalizeDeploymentEnvironment(args.environment);
54
+ const log = [];
55
+
56
+ try {
57
+ const subTasks = await jira.getSubTasks(cdIssueKey);
58
+ if (!subTasks.length) {
59
+ return error(`CD 單 ${cdIssueKey} 目前沒有 Deployment sub-task,請先執行 prepare_cd_deployment`);
60
+ }
61
+
62
+ log.push(`找到 ${subTasks.length} 個 sub-task:${subTasks.map((task) => task.key).join(', ')}`);
63
+ const deploymentKey = selectDeploymentSubtask(subTasks, environment, log);
64
+ const systemCode = await getCDSystemCode(cdIssueKey, jira);
65
+ const envGroup = getDeploymentEnvGroup(environment);
66
+ const shouldSwitch = await needSwitchExecutionNode({
67
+ issueKey: cdIssueKey,
68
+ systemCode,
69
+ environment,
70
+ envGroup,
71
+ jira,
72
+ log,
73
+ });
74
+
75
+ const deploySteps = [
76
+ {names: ['To Pretask']},
77
+ {names: ['Switch Execution Node', 'Swtich Execution Node'], conditionalWait: true},
78
+ {names: ['To AutoDeploy'], required: true},
79
+ {names: ['Trigger AutoDeploy'], required: true},
80
+ ];
81
+
82
+ for (const step of deploySteps) {
83
+ if (step.conditionalWait && !shouldSwitch) {
84
+ log.push(' ⏭️ 跳過「Switch Execution Node」(同環境群組,無需切換 Ansible instance)');
85
+ continue;
86
+ }
87
+
88
+ const transition = await findAnyTransition(deploymentKey, step.names, jira);
89
+ if (!transition) {
90
+ const issue = await jira.getIssue(deploymentKey);
91
+ const transitions = await jira.getTransitions(deploymentKey);
92
+ const available = transitions.map((item) => item.name).join(', ');
93
+ const displayName = step.names[0];
94
+ if (step.required) {
95
+ return error(`找不到必要部署 transition「${displayName}」,目前狀態:${issue.fields.status.name},可用:${available || '無'}`);
96
+ }
97
+ log.push(` ⚠️ 找不到「${displayName}」(目前狀態:${issue.fields.status.name},可用:${available || '無'}),跳過`);
98
+ continue;
99
+ }
100
+
101
+ log.push(` 執行「${transition.name}」...`);
102
+ await jira.transitionById(deploymentKey, transition.id);
103
+ await sleep(2000);
104
+
105
+ if (step.conditionalWait) {
106
+ const switched = await waitForSwitchExecutionNode({
107
+ issueKey: cdIssueKey,
108
+ systemCode,
109
+ envGroup,
110
+ jira,
111
+ });
112
+ log.push(switched
113
+ ? ' ✅ 已確認 Ansible instance 切換完成'
114
+ : ' ⚠️ 未在等待時間內確認切換留言,繼續執行部署');
115
+ }
116
+ }
117
+
118
+ const deployResult = await waitForDeploymentResult(deploymentKey, environment, ctx, log);
119
+ const finalIssue = await jira.getIssue(deploymentKey);
120
+ const finalStatus = finalIssue.fields.status.name;
121
+ await notifier.notify(
122
+ deploymentKey,
123
+ `Deployment 部署結果已通過(環境:${environment.toUpperCase()},狀態:${finalStatus}),請檢查 CD 單狀況`,
124
+ );
125
+
126
+ if (args.applyForClose) {
127
+ await applyForCloseIfRequested(cdIssueKey, jira, notifier, log);
128
+ }
129
+
130
+ return ok({
131
+ cdIssueKey,
132
+ deploymentKey,
133
+ environment,
134
+ status: finalStatus,
135
+ deployResult,
136
+ deployResultFieldId: DEPLOY_RESULT_FIELD_ID,
137
+ closeNotificationSuggestion: await buildCloseNotificationSuggestion(environment),
138
+ steps: log,
139
+ });
140
+ } catch (err) {
141
+ return error(`trigger_deployment 失敗: ${err.message}`);
142
+ }
143
+ }
144
+
145
+ async function waitForDeploymentResult(deploymentKey, environment, ctx, log) {
146
+ const {jira} = ctx;
147
+ const waitMs = Math.min(getPollTimeoutMs(), 10 * 60 * 1000);
148
+ const pollMs = Math.min(getPollIntervalMs(), 3 * 60 * 1000);
149
+ const startedAt = Date.now();
150
+ const deadline = Date.now() + waitMs;
151
+ let attempts = 0;
152
+ let lastValue;
153
+
154
+ log.push(' ⏳ 等待 Deployment 結果 pass(最多 10 分鐘)...');
155
+ while (true) {
156
+ attempts++;
157
+ const issue = await jira.getIssue(deploymentKey);
158
+ const fields = await jira.getIssueFields(deploymentKey, [DEPLOY_RESULT_FIELD_ID]);
159
+ const raw = fields[DEPLOY_RESULT_FIELD_ID];
160
+ lastValue = raw?.value ?? raw;
161
+
162
+ if (typeof ctx.progress === 'function') {
163
+ ctx.progress({
164
+ phase: 'polling',
165
+ title: '等待 CD Deployment deploy 結果',
166
+ detail: `${DEPLOY_RESULT_FIELD_ID}: ${lastValue ?? 'empty'}`,
167
+ issueKey: deploymentKey,
168
+ environment,
169
+ currentStatus: issue.fields.status.name,
170
+ attempts,
171
+ elapsedMs: Date.now() - startedAt,
172
+ timeoutMs: waitMs,
173
+ nextPollMs: pollMs,
174
+ });
175
+ }
176
+
177
+ if (isPassingResult(lastValue)) {
178
+ log.push(` ✅ Deployment 結果 pass,${DEPLOY_RESULT_FIELD_ID}: ${lastValue}`);
179
+ return lastValue;
180
+ }
181
+
182
+ if (isFailingResult(lastValue)) {
183
+ throw new Error(`Deployment Deploy 失敗,${DEPLOY_RESULT_FIELD_ID}: ${lastValue}`);
184
+ }
185
+
186
+ if (Date.now() >= deadline) {
187
+ throw new Error(`Deployment Deploy 等待逾時,${DEPLOY_RESULT_FIELD_ID}: ${lastValue ?? 'empty'}`);
188
+ }
189
+
190
+ await sleep(pollMs);
191
+ }
192
+ }
193
+
194
+ function selectDeploymentSubtask(subTasks, environment, log) {
195
+ const envUpper = environment.toUpperCase().replace('/', '').replace('&', '');
196
+ const envVariants = [envUpper, `[${envUpper}]`];
197
+ const matched = subTasks.find((subTask) => {
198
+ const summaryUpper = String(subTask.fields?.summary ?? '').toUpperCase().replace(/[\s/]/g, '');
199
+ return envVariants.some((variant) => summaryUpper.includes(variant.replace(/[\s/]/g, '')));
200
+ });
201
+
202
+ if (matched) {
203
+ log.push(` 對應環境 ${environment} 的 Deployment 單:${matched.key}`);
204
+ return matched.key;
205
+ }
206
+
207
+ const fallback = subTasks[0].key;
208
+ log.push(` ⚠️ 未找到明確符合環境 ${environment} 的 sub-task,使用第一個:${fallback}`);
209
+ return fallback;
210
+ }
211
+
212
+ async function getCDSystemCode(cdIssueKey, jira) {
213
+ try {
214
+ const fields = await jira.getIssueFields(cdIssueKey, [CD_FIELD_IDS.systemCode]);
215
+ return fields?.[CD_FIELD_IDS.systemCode]?.value ?? fields?.[CD_FIELD_IDS.systemCode] ?? '';
216
+ } catch {
217
+ return '';
218
+ }
219
+ }
220
+
221
+ async function findAnyTransition(issueKey, names, jira) {
222
+ const transitions = await jira.getTransitions(issueKey);
223
+ return transitions.find((transition) => (
224
+ names.some((name) => transition.name.toLowerCase() === name.toLowerCase())
225
+ ));
226
+ }
227
+
228
+ async function applyForCloseIfRequested(cdIssueKey, jira, notifier, log) {
229
+ try {
230
+ const transitions = await jira.getTransitions(cdIssueKey);
231
+ const closeTransition = transitions.find((transition) => transition.name.toLowerCase() === 'apply for close');
232
+ if (!closeTransition) {
233
+ const available = transitions.map((transition) => transition.name).join(', ');
234
+ log.push(` ⚠️ CD 單找不到「Apply for close」transition(可用:${available || '無'})`);
235
+ return;
236
+ }
237
+
238
+ log.push(` 對 CD 單 ${cdIssueKey} 執行「${closeTransition.name}」...`);
239
+ await jira.transitionById(cdIssueKey, closeTransition.id);
240
+ const cdIssue = await jira.getIssue(cdIssueKey);
241
+ log.push(` CD 單狀態:${cdIssue.fields.status.name}`);
242
+ await notifier.notify(cdIssueKey, 'CD 單已提交關閉申請(Apply for close)');
243
+ } catch (err) {
244
+ log.push(` ⚠️ Apply for close 失敗:${err.message}`);
245
+ }
246
+ }
247
+
248
+ async function buildCloseNotificationSuggestion(environment) {
249
+ const env = normalizeDeploymentEnvironment(environment);
250
+ if (env === 'stg') {
251
+ return {
252
+ environment: env,
253
+ message: 'Deployment 已 pass。是否通知 STG 簽核人檢查並關單?',
254
+ approverLookupTool: 'get_release_manager',
255
+ notifyTool: 'send_jabber_message',
256
+ };
257
+ }
258
+
259
+ if (env === 'uat' || isPrdLikeEnv(env)) {
260
+ return {
261
+ environment: env,
262
+ message: `Deployment 已 pass。是否通知 James 檢查 ${env.toUpperCase()} CD 單並關單?`,
263
+ approverAlias: 'James',
264
+ notifyTool: 'send_jabber_message',
265
+ };
266
+ }
267
+
268
+ return null;
269
+ }