@jira-deploy/core 1.0.4 → 1.0.6
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/users.js +6 -6
- package/package.json +2 -1
- package/scripts/jabber_notify.py +298 -0
- package/tools/grayrelease.js +16 -15
- package/tools/index.js +2 -2
- package/tools.test.js +5 -5
package/constants/users.js
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export const USER_MAP = {
|
|
7
7
|
// 格式:'名稱/暱稱(不分大小寫)': 'accountId'
|
|
8
|
-
'Solar Chen': '
|
|
9
|
-
'Rex Li': '
|
|
10
|
-
'James Yu': '
|
|
11
|
-
'Alvin Wang': '
|
|
12
|
-
'Chester Kuo': '
|
|
13
|
-
'Riemann Tseng': '
|
|
8
|
+
'Solar Chen': 'BK00325',
|
|
9
|
+
'Rex Li': 'BK00325',
|
|
10
|
+
'James Yu': 'BK00325',
|
|
11
|
+
'Alvin Wang': 'BK00325',
|
|
12
|
+
'Chester Kuo': 'BK00325',
|
|
13
|
+
'Riemann Tseng': 'BK00325',
|
|
14
14
|
// 'Solar Chen': 'BK00129',
|
|
15
15
|
// 'Rex Li': 'BK00136',
|
|
16
16
|
// 'James Yu': 'BK00178',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jira-deploy/core",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"repository": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"constants/**/*.js",
|
|
24
|
+
"scripts/**/*.py",
|
|
24
25
|
"tools/**/*.js",
|
|
25
26
|
"*.js"
|
|
26
27
|
],
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
jabber_notify.py — Send a Jabber message to a MUC room or a specific user.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 src/scripts/jabber_notify.py "<message>"
|
|
7
|
+
|
|
8
|
+
Required environment variables:
|
|
9
|
+
JABBER_SERVER
|
|
10
|
+
JABBER_USER
|
|
11
|
+
JABBER_DOMAIN
|
|
12
|
+
JABBER_KEYCHAIN_SERVICE
|
|
13
|
+
JABBER_KEYCHAIN_ACCOUNT
|
|
14
|
+
|
|
15
|
+
One of the following must be set:
|
|
16
|
+
JABBER_ROOM — MUC room JID (e.g. webqa@conference.linebank.com.tw)
|
|
17
|
+
JABBER_TO — Direct message recipient JID (e.g. BK00178@linebank.com.tw)
|
|
18
|
+
|
|
19
|
+
Optional environment variables:
|
|
20
|
+
JABBER_PORT (defaults to 5222)
|
|
21
|
+
JABBER_RESOURCE (defaults to copilot)
|
|
22
|
+
JABBER_NICK (defaults to Copilot) — only used for MUC
|
|
23
|
+
|
|
24
|
+
To add or update Keychain entry:
|
|
25
|
+
security add-generic-password -a "$JABBER_KEYCHAIN_ACCOUNT" -s "$JABBER_KEYCHAIN_SERVICE" -w
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import os
|
|
30
|
+
import socket
|
|
31
|
+
import ssl
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def required_env(key: str) -> str:
|
|
38
|
+
value = os.getenv(key, "").strip()
|
|
39
|
+
if not value:
|
|
40
|
+
raise RuntimeError(f"Missing required environment variable: {key}")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config() -> dict:
|
|
45
|
+
room = os.getenv("JABBER_ROOM", "").strip()
|
|
46
|
+
to = os.getenv("JABBER_TO", "").strip()
|
|
47
|
+
if not room and not to:
|
|
48
|
+
raise RuntimeError("Either JABBER_ROOM or JABBER_TO must be set")
|
|
49
|
+
return {
|
|
50
|
+
"server": required_env("JABBER_SERVER"),
|
|
51
|
+
"port": int(os.getenv("JABBER_PORT", "5222")),
|
|
52
|
+
"user": required_env("JABBER_USER"),
|
|
53
|
+
"domain": required_env("JABBER_DOMAIN"),
|
|
54
|
+
"resource": os.getenv("JABBER_RESOURCE", "copilot").strip() or "copilot",
|
|
55
|
+
"room": room,
|
|
56
|
+
"to": to,
|
|
57
|
+
"nick": os.getenv("JABBER_NICK", "Copilot").strip() or "Copilot",
|
|
58
|
+
"keychain_service": required_env("JABBER_KEYCHAIN_SERVICE"),
|
|
59
|
+
"keychain_account": required_env("JABBER_KEYCHAIN_ACCOUNT"),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def recv_all(sock, timeout=2):
|
|
64
|
+
sock.settimeout(timeout)
|
|
65
|
+
data = b""
|
|
66
|
+
try:
|
|
67
|
+
while True:
|
|
68
|
+
chunk = sock.recv(8192)
|
|
69
|
+
if not chunk:
|
|
70
|
+
break
|
|
71
|
+
data += chunk
|
|
72
|
+
except socket.timeout:
|
|
73
|
+
pass
|
|
74
|
+
return data.decode(errors="replace")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_jabber_password(keychain_service: str, keychain_account: str) -> str:
|
|
78
|
+
# Keychain credential must be created before using this script.
|
|
79
|
+
# Example setup:
|
|
80
|
+
# security add-generic-password -a "$JABBER_KEYCHAIN_ACCOUNT" -s "$JABBER_KEYCHAIN_SERVICE" -w
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
[
|
|
83
|
+
"security",
|
|
84
|
+
"find-generic-password",
|
|
85
|
+
"-s",
|
|
86
|
+
keychain_service,
|
|
87
|
+
"-a",
|
|
88
|
+
keychain_account,
|
|
89
|
+
"-w",
|
|
90
|
+
],
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
)
|
|
94
|
+
if result.returncode != 0:
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
"Cannot read Jabber password from Keychain. "
|
|
97
|
+
"Check JABBER_KEYCHAIN_SERVICE and JABBER_KEYCHAIN_ACCOUNT."
|
|
98
|
+
)
|
|
99
|
+
return result.stdout.strip()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_error_reason(xml_text: str) -> str:
|
|
103
|
+
reasons = [
|
|
104
|
+
"not-authorized",
|
|
105
|
+
"forbidden",
|
|
106
|
+
"conflict",
|
|
107
|
+
"service-unavailable",
|
|
108
|
+
"item-not-found",
|
|
109
|
+
"not-allowed",
|
|
110
|
+
"registration-required",
|
|
111
|
+
"remote-server-not-found",
|
|
112
|
+
"internal-server-error",
|
|
113
|
+
]
|
|
114
|
+
for reason in reasons:
|
|
115
|
+
if f"<{reason}" in xml_text:
|
|
116
|
+
return reason
|
|
117
|
+
if "<error" in xml_text:
|
|
118
|
+
return "unknown"
|
|
119
|
+
return ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _wait_for_join_confirmation(tls, room: str, nick: str, timeout_seconds: int = 10) -> None:
|
|
123
|
+
deadline = time.time() + timeout_seconds
|
|
124
|
+
self_presence_markers = [
|
|
125
|
+
'<status code="110"',
|
|
126
|
+
"<status code='110'",
|
|
127
|
+
]
|
|
128
|
+
explicit_nick_markers = [
|
|
129
|
+
f'from="{room}/{nick}"',
|
|
130
|
+
f"from='{room}/{nick}'",
|
|
131
|
+
]
|
|
132
|
+
room_presence_markers = [
|
|
133
|
+
f'from="{room}/',
|
|
134
|
+
f"from='{room}/",
|
|
135
|
+
]
|
|
136
|
+
saw_room_presence = False
|
|
137
|
+
|
|
138
|
+
while time.time() < deadline:
|
|
139
|
+
chunk = recv_all(tls, 1)
|
|
140
|
+
if not chunk:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
error_reason = _extract_error_reason(chunk)
|
|
144
|
+
if error_reason:
|
|
145
|
+
raise RuntimeError(f"Jabber room join failed: {error_reason}")
|
|
146
|
+
|
|
147
|
+
if "<presence" in chunk and any(marker in chunk for marker in room_presence_markers):
|
|
148
|
+
saw_room_presence = True
|
|
149
|
+
if "type=\"error\"" in chunk or "type='error'" in chunk:
|
|
150
|
+
raise RuntimeError("Jabber room join failed: error")
|
|
151
|
+
if "type=\"unavailable\"" in chunk or "type='unavailable'" in chunk:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
if any(marker in chunk for marker in self_presence_markers):
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
if any(marker in chunk for marker in explicit_nick_markers):
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if saw_room_presence:
|
|
161
|
+
return
|
|
162
|
+
raise RuntimeError("Jabber room join not confirmed")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _wait_for_message_confirmation(
|
|
166
|
+
tls,
|
|
167
|
+
room: str,
|
|
168
|
+
msg_id: str,
|
|
169
|
+
timeout_seconds: int = 5,
|
|
170
|
+
) -> None:
|
|
171
|
+
deadline = time.time() + timeout_seconds
|
|
172
|
+
id_markers = [f'id="{msg_id}"', f"id='{msg_id}'"]
|
|
173
|
+
|
|
174
|
+
while time.time() < deadline:
|
|
175
|
+
chunk = recv_all(tls, 1)
|
|
176
|
+
if not chunk:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if "<message" in chunk and any(marker in chunk for marker in id_markers):
|
|
180
|
+
if "type=\"error\"" in chunk or "type='error'" in chunk:
|
|
181
|
+
error_reason = _extract_error_reason(chunk) or "error"
|
|
182
|
+
raise RuntimeError(f"Jabber message rejected: {error_reason}")
|
|
183
|
+
|
|
184
|
+
if "<message" in chunk and (
|
|
185
|
+
"type=\"error\"" in chunk or "type='error'" in chunk
|
|
186
|
+
):
|
|
187
|
+
error_reason = _extract_error_reason(chunk) or "error"
|
|
188
|
+
raise RuntimeError(f"Jabber message rejected: {error_reason}")
|
|
189
|
+
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def send_to_room(message: str) -> None:
|
|
194
|
+
config = load_config()
|
|
195
|
+
password = get_jabber_password(
|
|
196
|
+
config["keychain_service"],
|
|
197
|
+
config["keychain_account"],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
sasl_plain = base64.b64encode(f"\x00{config['user']}\x00{password}".encode()).decode()
|
|
201
|
+
|
|
202
|
+
ctx = ssl.create_default_context()
|
|
203
|
+
ctx.check_hostname = False
|
|
204
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
205
|
+
|
|
206
|
+
raw = None
|
|
207
|
+
tls = None
|
|
208
|
+
try:
|
|
209
|
+
# ── TCP connect ──────────────────────────────────────────────────────
|
|
210
|
+
raw = socket.create_connection((config["server"], config["port"]), timeout=10)
|
|
211
|
+
raw.sendall(
|
|
212
|
+
f"<?xml version='1.0'?><stream:stream xmlns='jabber:client' version='1.0' "
|
|
213
|
+
f"xmlns:stream='http://etherx.jabber.org/streams' to='{config['domain']}'>".encode()
|
|
214
|
+
)
|
|
215
|
+
time.sleep(0.3)
|
|
216
|
+
recv_all(raw, 1)
|
|
217
|
+
|
|
218
|
+
# ── STARTTLS ─────────────────────────────────────────────────────────
|
|
219
|
+
raw.sendall(b"<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
|
|
220
|
+
time.sleep(0.3)
|
|
221
|
+
recv_all(raw, 1)
|
|
222
|
+
tls = ctx.wrap_socket(raw, server_hostname=config["server"])
|
|
223
|
+
|
|
224
|
+
# ── Re-open stream over TLS ──────────────────────────────────────────
|
|
225
|
+
tls.sendall(
|
|
226
|
+
f"<?xml version='1.0'?><stream:stream xmlns='jabber:client' version='1.0' "
|
|
227
|
+
f"xmlns:stream='http://etherx.jabber.org/streams' to='{config['domain']}'>".encode()
|
|
228
|
+
)
|
|
229
|
+
time.sleep(0.3)
|
|
230
|
+
recv_all(tls, 1)
|
|
231
|
+
|
|
232
|
+
# ── SASL PLAIN auth ──────────────────────────────────────────────────
|
|
233
|
+
tls.sendall(
|
|
234
|
+
f"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>{sasl_plain}</auth>".encode()
|
|
235
|
+
)
|
|
236
|
+
time.sleep(1)
|
|
237
|
+
auth_resp = recv_all(tls, 2)
|
|
238
|
+
if "success" not in auth_resp:
|
|
239
|
+
raise RuntimeError("Jabber authentication failed")
|
|
240
|
+
|
|
241
|
+
# ── Re-open stream after auth ────────────────────────────────────────
|
|
242
|
+
tls.sendall(
|
|
243
|
+
f"<?xml version='1.0'?><stream:stream xmlns='jabber:client' version='1.0' "
|
|
244
|
+
f"xmlns:stream='http://etherx.jabber.org/streams' to='{config['domain']}'>".encode()
|
|
245
|
+
)
|
|
246
|
+
time.sleep(0.5)
|
|
247
|
+
recv_all(tls, 1)
|
|
248
|
+
|
|
249
|
+
# ── Resource bind ────────────────────────────────────────────────────
|
|
250
|
+
tls.sendall(
|
|
251
|
+
f'<iq type="set" id="b1"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">'
|
|
252
|
+
f'<resource>{config["resource"]}</resource></bind></iq>'.encode()
|
|
253
|
+
)
|
|
254
|
+
time.sleep(0.5)
|
|
255
|
+
bind_resp = recv_all(tls, 2)
|
|
256
|
+
bind_error = _extract_error_reason(bind_resp)
|
|
257
|
+
if bind_error:
|
|
258
|
+
raise RuntimeError(f"Jabber resource bind failed: {bind_error}")
|
|
259
|
+
|
|
260
|
+
safe_msg = message.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
261
|
+
msg_id = f"notify-{int(time.time() * 1000)}"
|
|
262
|
+
|
|
263
|
+
if config["to"]:
|
|
264
|
+
# ── Direct message ───────────────────────────────────────────────
|
|
265
|
+
tls.sendall(
|
|
266
|
+
f'<message id="{msg_id}" to="{config["to"]}" type="chat"><body>{safe_msg}</body></message>'.encode()
|
|
267
|
+
)
|
|
268
|
+
time.sleep(0.5)
|
|
269
|
+
else:
|
|
270
|
+
# ── Join MUC room ────────────────────────────────────────────────
|
|
271
|
+
tls.sendall(
|
|
272
|
+
f'<presence to="{config["room"]}/{config["nick"]}"><x xmlns="http://jabber.org/protocol/muc"/></presence>'.encode()
|
|
273
|
+
)
|
|
274
|
+
_wait_for_join_confirmation(tls, config["room"], config["nick"], timeout_seconds=20)
|
|
275
|
+
|
|
276
|
+
# ── Send MUC message ─────────────────────────────────────────────
|
|
277
|
+
tls.sendall(
|
|
278
|
+
f'<message id="{msg_id}" to="{config["room"]}" type="groupchat"><body>{safe_msg}</body></message>'.encode()
|
|
279
|
+
)
|
|
280
|
+
_wait_for_message_confirmation(tls, config["room"], msg_id, timeout_seconds=5)
|
|
281
|
+
|
|
282
|
+
# ── Leave room ───────────────────────────────────────────────────
|
|
283
|
+
tls.sendall(f'<presence to="{config["room"]}/{config["nick"]}" type="unavailable"/>'.encode())
|
|
284
|
+
time.sleep(0.3)
|
|
285
|
+
finally:
|
|
286
|
+
if tls is not None:
|
|
287
|
+
tls.close()
|
|
288
|
+
elif raw is not None:
|
|
289
|
+
raw.close()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
if len(sys.argv) < 2:
|
|
294
|
+
print("Usage: python3 src/scripts/jabber_notify.py '<message>'")
|
|
295
|
+
sys.exit(1)
|
|
296
|
+
msg = sys.argv[1]
|
|
297
|
+
send_to_room(msg)
|
|
298
|
+
print("Jabber message sent")
|
package/tools/grayrelease.js
CHANGED
|
@@ -570,7 +570,7 @@ export async function handleAutoGrayRelease(args, ctx) {
|
|
|
570
570
|
});
|
|
571
571
|
await notifier.notify(issueKey, '開始自動執行 GrayRelease 流程');
|
|
572
572
|
|
|
573
|
-
const result = await
|
|
573
|
+
const result = await executeAutoGrayReleaseFlow(
|
|
574
574
|
issueKey,
|
|
575
575
|
{
|
|
576
576
|
maxBuildRetries: args.maxBuildRetries ?? 3,
|
|
@@ -655,7 +655,7 @@ export async function handleContinueGrayRelease(args, ctx) {
|
|
|
655
655
|
currentStatus: status.currentStatus,
|
|
656
656
|
});
|
|
657
657
|
|
|
658
|
-
const result = await
|
|
658
|
+
const result = await executeAutoGrayReleaseFlow(
|
|
659
659
|
issueKey,
|
|
660
660
|
{
|
|
661
661
|
maxBuildRetries: args.maxBuildRetries ?? 3,
|
|
@@ -830,7 +830,7 @@ async function executeGrayReleaseDeployFlow(issueKey, ctx) {
|
|
|
830
830
|
/**
|
|
831
831
|
* 主執行流程:從當前狀態開始,自動執行到完成或需要人工介入
|
|
832
832
|
*/
|
|
833
|
-
async function
|
|
833
|
+
async function executeAutoGrayReleaseFlow(issueKey, options, ctx) {
|
|
834
834
|
const { jira, notifier } = ctx;
|
|
835
835
|
const log = [];
|
|
836
836
|
let buildAttempts = 0;
|
|
@@ -1007,16 +1007,16 @@ async function runGrayReleaseDeployStep(issueKey, systemCode, ctx, log) {
|
|
|
1007
1007
|
await notifier.notify(issueKey, '觸發 GrayRelease Deploy');
|
|
1008
1008
|
|
|
1009
1009
|
// 等待部署完成:優先輪詢 deploy result、VERIFY 狀態,
|
|
1010
|
-
//
|
|
1011
|
-
// 最多等待 3 分鐘(避免 MCP client timeout),
|
|
1010
|
+
// 最多等待 10 分鐘(避免 MCP client timeout),
|
|
1012
1011
|
// 若超時則回傳「部署中」訊息,請使用者稍後繼續。
|
|
1013
|
-
|
|
1014
|
-
const
|
|
1012
|
+
// 每 3 分鐘 輪詢一次
|
|
1013
|
+
const DEPLOY_WAIT_MS = Math.min(getPollTimeoutMs(), 10 * 60 * 1000);
|
|
1014
|
+
const DEPLOY_POLL_MS = Math.min(getPollIntervalMs(), 3 * 60 * 1000);
|
|
1015
1015
|
const deployDeadline = Date.now() + DEPLOY_WAIT_MS;
|
|
1016
1016
|
let deployCompleted = false;
|
|
1017
1017
|
let attempts = 0;
|
|
1018
1018
|
|
|
1019
|
-
log.push(' ⏳ 等待部署完成(最多
|
|
1019
|
+
log.push(' ⏳ 等待部署完成(最多 10 分鐘)...');
|
|
1020
1020
|
while (Date.now() <= deployDeadline) {
|
|
1021
1021
|
attempts++;
|
|
1022
1022
|
const issue = await jira.getIssue(issueKey);
|
|
@@ -1037,10 +1037,10 @@ async function runGrayReleaseDeployStep(issueKey, systemCode, ctx, log) {
|
|
|
1037
1037
|
nextPollMs: DEPLOY_POLL_MS,
|
|
1038
1038
|
});
|
|
1039
1039
|
|
|
1040
|
-
if (statusNow === 'VERIFY') {
|
|
1040
|
+
if (statusNow === 'VERIFY' && isPassingResult(deployResult)) {
|
|
1041
1041
|
// Jira automation 或 Jenkins 已自動切到 VERIFY
|
|
1042
1042
|
deployCompleted = true;
|
|
1043
|
-
log.push(' ✅
|
|
1043
|
+
log.push(' ✅ Deploy 完成,狀態已進入 VERIFY(cid jira worker 自動切換),進入驗證階段');
|
|
1044
1044
|
break;
|
|
1045
1045
|
}
|
|
1046
1046
|
|
|
@@ -1071,8 +1071,9 @@ async function runGrayReleaseDeployStep(issueKey, systemCode, ctx, log) {
|
|
|
1071
1071
|
await sleep(DEPLOY_POLL_MS);
|
|
1072
1072
|
}
|
|
1073
1073
|
|
|
1074
|
+
|
|
1074
1075
|
if (!deployCompleted) {
|
|
1075
|
-
log.push(' ⚠️
|
|
1076
|
+
log.push(' ⚠️ 10 分鐘內未看到部署結果,部署可能仍在進行中');
|
|
1076
1077
|
log.push(' 請稍後使用 get_grayrelease_status 或 deploy_grayrelease 繼續');
|
|
1077
1078
|
await notifier.notify(issueKey, '部署仍在進行中,請稍後查詢狀態');
|
|
1078
1079
|
return false;
|
|
@@ -1246,8 +1247,7 @@ async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx)
|
|
|
1246
1247
|
|
|
1247
1248
|
/**
|
|
1248
1249
|
* 判斷是否需要執行 Switch Execution Node
|
|
1249
|
-
* 規則:查詢同系統上次 CD 部署的環境群組,若與本次不同則需切換
|
|
1250
|
-
* 注意:不查詢 GrayRelease 歷史,因為 GrayRelease 永遠是 nonPrd
|
|
1250
|
+
* 規則:查詢同系統上次 CD or GrayRelease 部署的環境群組,若與本次不同則需切換
|
|
1251
1251
|
*/
|
|
1252
1252
|
async function needSwitchExecutionNode(issueKey, systemCode, jira) {
|
|
1253
1253
|
if (await hasRecentSwitchExecutionNodeComment(issueKey, systemCode, jira)) {
|
|
@@ -1260,12 +1260,13 @@ async function needSwitchExecutionNode(issueKey, systemCode, jira) {
|
|
|
1260
1260
|
// 只查詢 CD 單的部署歷史(不包含 GrayRelease)
|
|
1261
1261
|
const isPrdEnv = (env) => ['prd', 'dr', 'prd/dr', 'prd&dr'].includes(env?.toLowerCase()?.trim());
|
|
1262
1262
|
|
|
1263
|
-
const jql_cd = `project = CID AND issuetype = CD
|
|
1263
|
+
const jql_cd = `project = CID AND (issuetype = CD OR issuetype = GrayRelease) AND text ~ "${systemCode}" AND status = Done AND issueKey != "${issueKey}" ORDER BY updated DESC`;
|
|
1264
1264
|
|
|
1265
1265
|
try {
|
|
1266
1266
|
const cdResults = await jira.searchIssues(jql_cd, ['customfield_13436', 'updated'], 1);
|
|
1267
1267
|
const cdIssue = cdResults[0] ?? null;
|
|
1268
1268
|
|
|
1269
|
+
|
|
1269
1270
|
// 查不到歷史 → 保守執行 Switch
|
|
1270
1271
|
if (!cdIssue) {
|
|
1271
1272
|
return true;
|
|
@@ -1311,7 +1312,7 @@ async function hasRecentSwitchExecutionNodeComment(issueKey, systemCode, jira) {
|
|
|
1311
1312
|
const lowerSystemCode = String(systemCode ?? '').toLowerCase();
|
|
1312
1313
|
return comments.some((comment) => {
|
|
1313
1314
|
const body = String(comment.body ?? '').toLowerCase();
|
|
1314
|
-
const author = String(comment.author?.displayName ??
|
|
1315
|
+
const author = String(comment.author?.displayName ?? '').toLowerCase();
|
|
1315
1316
|
return author === 'cid jira worker'
|
|
1316
1317
|
&& body.includes('instance_group')
|
|
1317
1318
|
&& body.includes('nonprd_executionnode')
|
package/tools/index.js
CHANGED
|
@@ -727,8 +727,8 @@ export async function executeTool(name, args, deps) {
|
|
|
727
727
|
|
|
728
728
|
// 查詢同系統最近一筆已完成的部署(CD 或 GrayRelease),回傳 'prd' | 'nonPrd' | null
|
|
729
729
|
const getLastDeployEnvGroup = async (systemCode, excludeKey) => {
|
|
730
|
-
const jql_cd = `project = CID AND issuetype = CD AND
|
|
731
|
-
const jql_gr = `project = CID AND issuetype = GrayRelease AND
|
|
730
|
+
const jql_cd = `project = CID AND issuetype = CD AND text ~ "${systemCode}" AND status = Done AND issueKey != "${excludeKey}" ORDER BY updated DESC`;
|
|
731
|
+
const jql_gr = `project = CID AND issuetype = GrayRelease AND text ~ "${systemCode}" AND status = Done ORDER BY updated DESC`;
|
|
732
732
|
|
|
733
733
|
const [cdResults, grResults] = await Promise.all([
|
|
734
734
|
jira.searchIssues(jql_cd, ['customfield_13436', 'updated'], 1).catch(() => []),
|
package/tools.test.js
CHANGED
|
@@ -1666,7 +1666,7 @@ describe('auto_grayrelease — approval payload handling', () => {
|
|
|
1666
1666
|
assert.ok(!result.content[0].text.startsWith('❌'), 'STG approval should continue');
|
|
1667
1667
|
const data = JSON.parse(result.content[0].text);
|
|
1668
1668
|
assert.equal(data.finalStatus, 'VERIFY');
|
|
1669
|
-
assert.deepEqual(jira.calls.updateAssignee, [{ issueKey: 'CID-100', accountId: '
|
|
1669
|
+
assert.deepEqual(jira.calls.updateAssignee, [{ issueKey: 'CID-100', accountId: 'BK00325' }]);
|
|
1670
1670
|
} finally {
|
|
1671
1671
|
await new Promise((resolve, reject) => {
|
|
1672
1672
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
@@ -1683,7 +1683,7 @@ describe('auto_grayrelease — approval payload handling', () => {
|
|
|
1683
1683
|
process.env.POLL_TIMEOUT_MS = '10';
|
|
1684
1684
|
|
|
1685
1685
|
const jira = makeApprovalJira('uat', {
|
|
1686
|
-
comments: [{ body: 'Approved, please proceed', author: { name: '
|
|
1686
|
+
comments: [{ body: 'Approved, please proceed', author: { name: 'BK00325', displayName: 'James Yu' } }],
|
|
1687
1687
|
});
|
|
1688
1688
|
const result = await executeTool(
|
|
1689
1689
|
'auto_grayrelease',
|
|
@@ -1695,8 +1695,8 @@ describe('auto_grayrelease — approval payload handling', () => {
|
|
|
1695
1695
|
const data = JSON.parse(result.content[0].text);
|
|
1696
1696
|
assert.equal(data.finalStatus, 'VERIFY');
|
|
1697
1697
|
assert.deepEqual(jira.calls.updateAssignee, [
|
|
1698
|
-
{ issueKey: 'CID-101', accountId: '
|
|
1699
|
-
{ issueKey: 'CID-101', accountId: '
|
|
1698
|
+
{ issueKey: 'CID-101', accountId: 'BK00325' },
|
|
1699
|
+
{ issueKey: 'CID-101', accountId: 'BK00325' },
|
|
1700
1700
|
]);
|
|
1701
1701
|
} finally {
|
|
1702
1702
|
restoreEnv();
|
|
@@ -1711,7 +1711,7 @@ describe('auto_grayrelease — approval payload handling', () => {
|
|
|
1711
1711
|
process.env.POLL_TIMEOUT_MS = '10';
|
|
1712
1712
|
|
|
1713
1713
|
const jira = makeApprovalJira('uat', {
|
|
1714
|
-
comments: [{ body: 'Approved, please proceed', author: { name: '
|
|
1714
|
+
comments: [{ body: 'Approved, please proceed', author: { name: 'BK00325', displayName: 'James Yu' } }],
|
|
1715
1715
|
});
|
|
1716
1716
|
const result = await executeTool(
|
|
1717
1717
|
'auto_grayrelease',
|