@jira-deploy/core 1.0.5 → 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.
@@ -5,12 +5,12 @@
5
5
  */
6
6
  export const USER_MAP = {
7
7
  // 格式:'名稱/暱稱(不分大小寫)': 'accountId'
8
- 'Solar Chen': 'BK00619',
9
- 'Rex Li': 'BK00619',
10
- 'James Yu': 'BK00619',
11
- 'Alvin Wang': 'BK00619',
12
- 'Chester Kuo': 'BK00619',
13
- 'Riemann Tseng': 'BK00619',
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.5",
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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.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: 'BK00619' }]);
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: 'BK00619', displayName: 'James Yu' } }],
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: 'BK00619' },
1699
- { issueKey: 'CID-101', accountId: 'BK00619' },
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: 'BK00619', displayName: 'James Yu' } }],
1714
+ comments: [{ body: 'Approved, please proceed', author: { name: 'BK00325', displayName: 'James Yu' } }],
1715
1715
  });
1716
1716
  const result = await executeTool(
1717
1717
  'auto_grayrelease',