@anura-gate/watcher-jira 0.1.0

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/cli.js ADDED
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+
3
+ // -----------------------------------------------------------------
4
+ // GATE Watcher -- Jira CLI
5
+ //
6
+ // Quick-start mode: runs the watcher with terminal output.
7
+ // Jira uses API token auth -- no QR code needed.
8
+ //
9
+ // Usage:
10
+ // npx @anura-gate/watcher-jira
11
+ // # or
12
+ // GATE_URL=... GATE_KEY=... GATE_INTEGRATION_ID=... \
13
+ // JIRA_DOMAIN=yourcompany.atlassian.net \
14
+ // JIRA_EMAIL=you@company.com JIRA_TOKEN=ATATT3x... \
15
+ // JIRA_PROJECTS=PROJ,ENG \
16
+ // npx @anura-gate/watcher-jira
17
+ // -----------------------------------------------------------------
18
+
19
+ require("dotenv").config();
20
+ const { GateJiraWatcher } = require("./lib/gate-watcher-jira");
21
+
22
+ // -- Config ----------------------------------------------------------
23
+
24
+ const GATE_URL = process.env.GATE_URL; // optional -- SDK has default
25
+ const GATE_KEY = process.env.GATE_KEY;
26
+ const INTEGRATION_ID = process.env.GATE_INTEGRATION_ID;
27
+ const JIRA_DOMAIN = process.env.JIRA_DOMAIN;
28
+ const JIRA_EMAIL = process.env.JIRA_EMAIL;
29
+ const JIRA_TOKEN = process.env.JIRA_TOKEN;
30
+ const JIRA_PROJECTS = process.env.JIRA_PROJECTS; // comma-separated: "PROJ,ENG"
31
+
32
+ if (!GATE_KEY) {
33
+ console.error("Missing GATE_KEY environment variable");
34
+ console.error(" Get your virtual key from GATE Dashboard -> Keys");
35
+ process.exit(1);
36
+ }
37
+
38
+ if (!INTEGRATION_ID) {
39
+ console.error("Missing GATE_INTEGRATION_ID environment variable");
40
+ console.error(" Get your integration ID from GATE Dashboard -> Integrations");
41
+ process.exit(1);
42
+ }
43
+
44
+ if (!JIRA_DOMAIN) {
45
+ console.error("Missing JIRA_DOMAIN environment variable");
46
+ console.error(" e.g. yourcompany.atlassian.net");
47
+ process.exit(1);
48
+ }
49
+
50
+ if (!JIRA_EMAIL) {
51
+ console.error("Missing JIRA_EMAIL environment variable");
52
+ console.error(" Your Atlassian account email address");
53
+ process.exit(1);
54
+ }
55
+
56
+ if (!JIRA_TOKEN) {
57
+ console.error("Missing JIRA_TOKEN environment variable");
58
+ console.error(" Create an API token at https://id.atlassian.com/manage-profile/security/api-tokens");
59
+ console.error(" It stays local and is NEVER sent to GATE.");
60
+ process.exit(1);
61
+ }
62
+
63
+ // -- Create Watcher --------------------------------------------------
64
+
65
+ const projects = JIRA_PROJECTS
66
+ ? JIRA_PROJECTS.split(",").map((p) => p.trim()).filter(Boolean)
67
+ : [];
68
+
69
+ const opts = {
70
+ gateKey: GATE_KEY,
71
+ integrationId: INTEGRATION_ID,
72
+ jiraDomain: JIRA_DOMAIN,
73
+ jiraEmail: JIRA_EMAIL,
74
+ jiraToken: JIRA_TOKEN,
75
+ projects,
76
+ };
77
+ if (GATE_URL) opts.gateUrl = GATE_URL;
78
+
79
+ const watcher = new GateJiraWatcher(opts);
80
+
81
+ // -- Wire up events for terminal display -----------------------------
82
+
83
+ watcher.on("ready", (displayName) => {
84
+ console.log(`Jira ready: ${displayName}`);
85
+ console.log(` GATE URL: ${opts.gateUrl || "https://anuragate.com"}`);
86
+ console.log(` Integration ID: ${INTEGRATION_ID}`);
87
+ console.log(` Jira domain: ${JIRA_DOMAIN}`);
88
+ if (projects.length > 0) {
89
+ console.log(` Watching: ${projects.join(", ")}`);
90
+ } else {
91
+ console.log(` Watching: all projects`);
92
+ }
93
+ console.log("");
94
+ });
95
+
96
+ watcher.on("event", (event, result) => {
97
+ if (result.rateLimited) {
98
+ console.warn("Daily event limit reached -- event not processed");
99
+ return;
100
+ }
101
+ const actions = result.securityActions;
102
+ const suffix = actions.length > 0 ? ` [${actions.join(", ")}]` : "";
103
+ const text = event.content.text || "(no text)";
104
+ const body = text.length > 80 ? text.slice(0, 80) + "..." : text;
105
+ console.log(`[${event.eventType}] ${body}${suffix}`);
106
+ });
107
+
108
+ watcher.on("action", (action) => {
109
+ console.log(`[ACTION] ${action.action} -> ${JSON.stringify(action.params)}`);
110
+ });
111
+
112
+ watcher.on("action_result", ({ actionId, action, success, error }) => {
113
+ if (success) {
114
+ console.log(` ${action} completed`);
115
+ } else {
116
+ console.error(` ${action} failed: ${error}`);
117
+ }
118
+ });
119
+
120
+ watcher.on("gate_error", ({ path, status, error }) => {
121
+ console.error(`GATE ${path} -> ${status}: ${error?.message || "unknown error"}`);
122
+ });
123
+
124
+ watcher.on("jira_error", ({ path, error }) => {
125
+ console.error(`Jira ${path} -> ${error}`);
126
+ });
127
+
128
+ watcher.on("limit_reached", (type) => {
129
+ console.warn(`Plan limit reached: ${type}`);
130
+ });
131
+
132
+ // -- Graceful shutdown -----------------------------------------------
133
+
134
+ async function shutdown(signal) {
135
+ console.log(`\n${signal} -- shutting down...`);
136
+ await watcher.stop();
137
+ console.log("Disconnected");
138
+ process.exit(0);
139
+ }
140
+
141
+ process.on("SIGINT", () => shutdown("SIGINT"));
142
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
143
+
144
+ // -- Web Panel (optional) --------------------------------------------
145
+
146
+ const WEB_PORT = process.env.WEB_PORT ? parseInt(process.env.WEB_PORT, 10) : null;
147
+
148
+ if (WEB_PORT) {
149
+ watcher.startPanel(WEB_PORT);
150
+ console.log(`Dashboard: http://localhost:${WEB_PORT}\n`);
151
+ }
152
+
153
+ // -- Start -----------------------------------------------------------
154
+
155
+ console.log("GATE Watcher (Jira) starting...\n");
156
+
157
+ watcher.start().catch((err) => {
158
+ console.error("Failed to start:", err.message);
159
+ process.exit(1);
160
+ });
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ const { GateJiraWatcher } = require("./lib/gate-watcher-jira");
2
+ module.exports = { GateJiraWatcher };
@@ -0,0 +1,745 @@
1
+ // -----------------------------------------------------------------
2
+ // GateJiraWatcher -- SDK class for Jira event monitoring
3
+ //
4
+ // Polls Jira for issue updates, comments, and transitions using
5
+ // an API token + email. Token stays local, never sent to GATE.
6
+ //
7
+ // Usage:
8
+ // const { GateJiraWatcher } = require("@anura-gate/watcher-jira");
9
+ // const watcher = new GateJiraWatcher({
10
+ // gateKey: "gk-xxx",
11
+ // integrationId: "int_xxx",
12
+ // jiraDomain: "yourcompany.atlassian.net",
13
+ // jiraEmail: "you@company.com",
14
+ // jiraToken: "ATATT3x...",
15
+ // projects: ["PROJ", "ENG"], // optional -- watch specific projects
16
+ // });
17
+ // watcher.on("ready", (user) => console.log("Connected:", user));
18
+ // await watcher.start();
19
+ // -----------------------------------------------------------------
20
+
21
+ const { EventEmitter } = require("events");
22
+
23
+ class GateJiraWatcher extends EventEmitter {
24
+ /**
25
+ * @param {object} opts
26
+ * @param {string} opts.gateKey -- Virtual key (gk-xxx)
27
+ * @param {string} opts.integrationId -- Integration ID (int_xxx)
28
+ * @param {string} opts.jiraDomain -- e.g. "yourcompany.atlassian.net"
29
+ * @param {string} opts.jiraEmail -- Atlassian account email
30
+ * @param {string} opts.jiraToken -- Jira API token (stays local, NEVER sent to GATE)
31
+ * @param {string[]} [opts.projects] -- Project keys to watch. Empty = all.
32
+ * @param {string} [opts.gateUrl] -- GATE URL (default: "https://anuragate.com")
33
+ * @param {number} [opts.pollInterval] -- ms between Jira polls (default: 30000)
34
+ * @param {number} [opts.heartbeatInterval] -- ms between heartbeats (default: 30000)
35
+ * @param {string} [opts.sessionId] -- Session ID for multi-tenant
36
+ * @param {string} [opts.sessionLabel] -- Human-readable label
37
+ * @param {object} [opts.sessionMetadata] -- Arbitrary metadata
38
+ */
39
+ constructor(opts) {
40
+ super();
41
+
42
+ if (!opts.gateKey) throw new Error("gateKey is required");
43
+ if (!opts.integrationId) throw new Error("integrationId is required");
44
+ if (!opts.jiraDomain) throw new Error("jiraDomain is required");
45
+ if (!opts.jiraEmail) throw new Error("jiraEmail is required");
46
+ if (!opts.jiraToken) throw new Error("jiraToken is required");
47
+
48
+ this.gateUrl = (opts.gateUrl || "https://anuragate.com").replace(/\/$/, "");
49
+ this.gateKey = opts.gateKey;
50
+ this.integrationId = opts.integrationId;
51
+ this.sessionId = opts.sessionId || null;
52
+ this._sessionLabel = opts.sessionLabel || opts.sessionId || null;
53
+ this._sessionMetadata = opts.sessionMetadata || {};
54
+
55
+ this.jiraDomain = opts.jiraDomain.replace(/\/$/, "");
56
+ this.jiraEmail = opts.jiraEmail;
57
+ this.jiraToken = opts.jiraToken;
58
+ this.projects = opts.projects || [];
59
+ this.pollInterval = opts.pollInterval || 30_000;
60
+ this.heartbeatInterval = opts.heartbeatInterval || 30_000;
61
+ this.timeout = 15_000;
62
+
63
+ this._heartbeatTimer = null;
64
+ this._outboundTimer = null;
65
+ this._pollTimer = null;
66
+ this._running = false;
67
+ this._ready = false;
68
+ this._panel = null;
69
+ this._displayName = null;
70
+
71
+ // Track last poll time for incremental fetching
72
+ this._lastPollTime = null;
73
+ // Seen issue event IDs to prevent duplicates
74
+ this._seenEventIds = new Set();
75
+ this._maxSeenIds = 5000;
76
+
77
+ this._headers = {
78
+ "Content-Type": "application/json",
79
+ "x-gate-key": this.gateKey,
80
+ };
81
+
82
+ // Jira Cloud uses Basic auth: email:token base64-encoded
83
+ const authStr = Buffer.from(`${this.jiraEmail}:${this.jiraToken}`).toString("base64");
84
+ this._jiraHeaders = {
85
+ Accept: "application/json",
86
+ "Content-Type": "application/json",
87
+ Authorization: `Basic ${authStr}`,
88
+ };
89
+ }
90
+
91
+ // -- Public API ---------------------------------------------------
92
+
93
+ async start() {
94
+ if (this._running) throw new Error("Watcher is already running");
95
+ this._running = true;
96
+
97
+ if (this.sessionId) {
98
+ await this._registerSession();
99
+ }
100
+
101
+ // Verify credentials by fetching current user
102
+ const userRes = await this._jiraGet("/rest/api/3/myself");
103
+ if (!userRes.ok) {
104
+ this._running = false;
105
+ this._updateSessionStatus("error");
106
+ throw new Error(
107
+ `Jira auth failed (${userRes.status}): ${userRes.data?.message || userRes.data?.errorMessages?.[0] || "invalid credentials"}`
108
+ );
109
+ }
110
+
111
+ this._displayName = userRes.data.displayName || userRes.data.emailAddress || "unknown";
112
+ this._ready = true;
113
+ this._updateSessionStatus("connected");
114
+ this.emit("ready", this._displayName);
115
+
116
+ // Set initial poll time to now minus 5 minutes (catch recent activity on first run)
117
+ this._lastPollTime = new Date(Date.now() - 5 * 60_000).toISOString().replace("Z", "+0000");
118
+
119
+ // Start Jira polling loop
120
+ this._pollJira();
121
+ this._pollTimer = setInterval(() => this._pollJira(), this.pollInterval);
122
+
123
+ // Start GATE heartbeat
124
+ this._sendHeartbeat();
125
+ this._heartbeatTimer = setInterval(() => this._sendHeartbeat(), this.heartbeatInterval);
126
+
127
+ // Start GATE outbound poll
128
+ this._pollOutbound();
129
+ this._outboundTimer = setInterval(() => this._pollOutbound(), 3_000);
130
+ }
131
+
132
+ async stop() {
133
+ this._running = false;
134
+ this._ready = false;
135
+
136
+ if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
137
+ if (this._outboundTimer) clearInterval(this._outboundTimer);
138
+ if (this._pollTimer) clearInterval(this._pollTimer);
139
+ this._heartbeatTimer = null;
140
+ this._outboundTimer = null;
141
+ this._pollTimer = null;
142
+
143
+ await this._updateSessionStatus("disconnected");
144
+
145
+ if (this._panel) {
146
+ this._panel.close();
147
+ this._panel = null;
148
+ }
149
+
150
+ this.emit("stopped");
151
+ }
152
+
153
+ attach(server, path = "/ws/jira") {
154
+ const { PanelServer } = require("./panel-server");
155
+ this._panel = new PanelServer(this);
156
+ this._panel.attachTo(server, path);
157
+ return this._panel;
158
+ }
159
+
160
+ startPanel(port = 3001) {
161
+ const { PanelServer } = require("./panel-server");
162
+ this._panel = new PanelServer(this);
163
+ this._panel.listen(port);
164
+ return this._panel;
165
+ }
166
+
167
+ getStatus() {
168
+ if (!this._running) return "disconnected";
169
+ if (this._ready) return "connected";
170
+ return "connecting";
171
+ }
172
+
173
+ getQR() {
174
+ return null; // Jira uses token auth
175
+ }
176
+
177
+ // -- Internal: Jira API -------------------------------------------
178
+
179
+ _jiraUrl(path) {
180
+ const domain = this.jiraDomain.includes("://")
181
+ ? this.jiraDomain
182
+ : `https://${this.jiraDomain}`;
183
+ return `${domain}${path}`;
184
+ }
185
+
186
+ async _jiraGet(path) {
187
+ const url = this._jiraUrl(path);
188
+ try {
189
+ const res = await fetch(url, {
190
+ method: "GET",
191
+ headers: this._jiraHeaders,
192
+ signal: AbortSignal.timeout(this.timeout),
193
+ });
194
+ if (res.status === 204) return { ok: true, status: 204, data: {} };
195
+ const data = await res.json();
196
+ return { ok: res.ok, status: res.status, data };
197
+ } catch (err) {
198
+ this.emit("jira_error", { path, error: err.message });
199
+ return { ok: false, status: 0, data: null };
200
+ }
201
+ }
202
+
203
+ async _jiraPost(path, body) {
204
+ const url = this._jiraUrl(path);
205
+ try {
206
+ const res = await fetch(url, {
207
+ method: "POST",
208
+ headers: this._jiraHeaders,
209
+ body: JSON.stringify(body),
210
+ signal: AbortSignal.timeout(this.timeout),
211
+ });
212
+ if (res.status === 204) return { ok: true, status: 204, data: {} };
213
+ const data = await res.json();
214
+ return { ok: res.ok, status: res.status, data };
215
+ } catch (err) {
216
+ this.emit("jira_error", { path, error: err.message });
217
+ return { ok: false, status: 0, data: null };
218
+ }
219
+ }
220
+
221
+ async _jiraPut(path, body) {
222
+ const url = this._jiraUrl(path);
223
+ try {
224
+ const res = await fetch(url, {
225
+ method: "PUT",
226
+ headers: this._jiraHeaders,
227
+ body: JSON.stringify(body),
228
+ signal: AbortSignal.timeout(this.timeout),
229
+ });
230
+ if (res.status === 204) return { ok: true, status: 204, data: {} };
231
+ const data = await res.json().catch(() => ({}));
232
+ return { ok: res.ok, status: res.status, data };
233
+ } catch (err) {
234
+ this.emit("jira_error", { path, error: err.message });
235
+ return { ok: false, status: 0, data: null };
236
+ }
237
+ }
238
+
239
+ // -- Internal: Jira Polling ----------------------------------------
240
+
241
+ async _pollJira() {
242
+ if (!this._running) return;
243
+
244
+ try {
245
+ // Poll recently updated issues via JQL search
246
+ await this._pollUpdatedIssues();
247
+ } catch (err) {
248
+ this._updateSessionStatus("error");
249
+ this.emit("jira_error", { path: "poll", error: err.message });
250
+ }
251
+ }
252
+
253
+ async _pollUpdatedIssues() {
254
+ // Build JQL: recently updated issues, optionally scoped to projects
255
+ let jql = `updated >= "${this._lastPollTime}"`;
256
+ if (this.projects.length > 0) {
257
+ const projectList = this.projects.map((p) => `"${p}"`).join(", ");
258
+ jql += ` AND project IN (${projectList})`;
259
+ }
260
+ jql += " ORDER BY updated ASC";
261
+
262
+ const params = new URLSearchParams({
263
+ jql,
264
+ maxResults: "50",
265
+ fields: "summary,status,assignee,reporter,priority,issuetype,project,comment,updated,created,labels,description",
266
+ expand: "changelog",
267
+ });
268
+
269
+ const res = await this._jiraGet(`/rest/api/3/search?${params.toString()}`);
270
+ if (!res.ok || !res.data?.issues) return;
271
+
272
+ // Update poll time for next cycle
273
+ this._lastPollTime = new Date().toISOString().replace("Z", "+0000");
274
+
275
+ for (const issue of res.data.issues) {
276
+ // Process changelog entries (status transitions, field changes)
277
+ if (issue.changelog?.histories) {
278
+ for (const history of issue.changelog.histories) {
279
+ await this._processChangelog(issue, history);
280
+ }
281
+ }
282
+
283
+ // Process new comments
284
+ if (issue.fields?.comment?.comments) {
285
+ for (const comment of issue.fields.comment.comments) {
286
+ await this._processComment(issue, comment);
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ async _processChangelog(issue, history) {
293
+ const eventId = `changelog_${history.id}`;
294
+ if (this._seenEventIds.has(eventId)) return;
295
+ this._markSeen(eventId);
296
+
297
+ const author = history.author?.displayName || history.author?.emailAddress || "unknown";
298
+
299
+ for (const item of history.items || []) {
300
+ const eventType = this._mapChangeType(item.field);
301
+ const text = this._formatChangeText(issue, author, item);
302
+
303
+ const normalized = {
304
+ id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
305
+ tool: "jira",
306
+ eventType,
307
+ timestamp: history.created || new Date().toISOString(),
308
+ source: {
309
+ id: author,
310
+ type: "user",
311
+ name: author,
312
+ },
313
+ content: {
314
+ text,
315
+ metadata: {
316
+ issueKey: issue.key,
317
+ issueId: issue.id,
318
+ summary: issue.fields?.summary || "",
319
+ project: issue.fields?.project?.key || "",
320
+ field: item.field,
321
+ fieldType: item.fieldtype,
322
+ from: item.fromString,
323
+ to: item.toString,
324
+ issueType: issue.fields?.issuetype?.name || "",
325
+ priority: issue.fields?.priority?.name || "",
326
+ status: issue.fields?.status?.name || "",
327
+ assignee: issue.fields?.assignee?.displayName || null,
328
+ },
329
+ },
330
+ raw: { issue: { key: issue.key, id: issue.id }, history, item },
331
+ };
332
+
333
+ const gateRes = await this._sendToGate(normalized);
334
+ this.emit("event", normalized, {
335
+ ok: gateRes.ok,
336
+ status: gateRes.status,
337
+ securityActions: gateRes.data?.securityActions || [],
338
+ blocked: gateRes.data?.blocked || false,
339
+ rateLimited: gateRes.status === 429,
340
+ });
341
+ }
342
+ }
343
+
344
+ async _processComment(issue, comment) {
345
+ const eventId = `comment_${comment.id}`;
346
+ if (this._seenEventIds.has(eventId)) return;
347
+ this._markSeen(eventId);
348
+
349
+ const author = comment.author?.displayName || comment.author?.emailAddress || "unknown";
350
+
351
+ // Extract text from Atlassian Document Format (ADF)
352
+ const commentText = this._extractAdfText(comment.body);
353
+
354
+ const normalized = {
355
+ id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
356
+ tool: "jira",
357
+ eventType: "comment_added",
358
+ timestamp: comment.created || new Date().toISOString(),
359
+ source: {
360
+ id: author,
361
+ type: "user",
362
+ name: author,
363
+ },
364
+ content: {
365
+ text: `${author} commented on ${issue.key}: ${commentText.slice(0, 200)}`,
366
+ metadata: {
367
+ issueKey: issue.key,
368
+ issueId: issue.id,
369
+ summary: issue.fields?.summary || "",
370
+ project: issue.fields?.project?.key || "",
371
+ commentId: comment.id,
372
+ commentBody: commentText,
373
+ issueType: issue.fields?.issuetype?.name || "",
374
+ priority: issue.fields?.priority?.name || "",
375
+ status: issue.fields?.status?.name || "",
376
+ },
377
+ },
378
+ raw: { issue: { key: issue.key, id: issue.id }, comment },
379
+ };
380
+
381
+ const gateRes = await this._sendToGate(normalized);
382
+ this.emit("event", normalized, {
383
+ ok: gateRes.ok,
384
+ status: gateRes.status,
385
+ securityActions: gateRes.data?.securityActions || [],
386
+ blocked: gateRes.data?.blocked || false,
387
+ rateLimited: gateRes.status === 429,
388
+ });
389
+ }
390
+
391
+ // -- Internal: Event Normalization ---------------------------------
392
+
393
+ _mapChangeType(field) {
394
+ const map = {
395
+ status: "issue_transitioned",
396
+ assignee: "issue_assigned",
397
+ priority: "priority_changed",
398
+ summary: "issue_updated",
399
+ description: "issue_updated",
400
+ labels: "labels_changed",
401
+ "Fix Version": "fix_version_changed",
402
+ Sprint: "sprint_changed",
403
+ resolution: "issue_resolved",
404
+ issuetype: "issue_type_changed",
405
+ };
406
+ return map[field] || "issue_updated";
407
+ }
408
+
409
+ _formatChangeText(issue, author, item) {
410
+ const key = issue.key;
411
+
412
+ switch (item.field) {
413
+ case "status":
414
+ return `${author} transitioned ${key} from "${item.fromString}" to "${item.toString}"`;
415
+ case "assignee":
416
+ return `${author} assigned ${key} to ${item.toString || "unassigned"}`;
417
+ case "priority":
418
+ return `${author} changed priority of ${key} from "${item.fromString}" to "${item.toString}"`;
419
+ case "resolution":
420
+ return item.toString
421
+ ? `${author} resolved ${key} as "${item.toString}"`
422
+ : `${author} reopened ${key}`;
423
+ case "summary":
424
+ return `${author} updated summary of ${key}: "${item.toString}"`;
425
+ case "labels":
426
+ return `${author} changed labels on ${key}: ${item.toString || "(none)"}`;
427
+ case "Sprint":
428
+ return `${author} moved ${key} to sprint "${item.toString || "(none)"}"`;
429
+ default:
430
+ return `${author} changed ${item.field} on ${key}: "${item.fromString || ""}" → "${item.toString || ""}"`;
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Extract plain text from Jira's Atlassian Document Format (ADF).
436
+ * ADF is a nested JSON structure — we walk it to find text nodes.
437
+ */
438
+ _extractAdfText(adf) {
439
+ if (!adf) return "";
440
+ if (typeof adf === "string") return adf;
441
+ if (adf.type === "text") return adf.text || "";
442
+
443
+ let result = "";
444
+ if (Array.isArray(adf.content)) {
445
+ for (const node of adf.content) {
446
+ const text = this._extractAdfText(node);
447
+ if (text) {
448
+ // Add newline between paragraph-level nodes
449
+ if (node.type === "paragraph" || node.type === "heading" || node.type === "codeBlock") {
450
+ result += (result ? "\n" : "") + text;
451
+ } else {
452
+ result += text;
453
+ }
454
+ }
455
+ }
456
+ }
457
+ return result;
458
+ }
459
+
460
+ // -- Internal: Send to GATE ---------------------------------------
461
+
462
+ async _sendToGate(normalizedEvent) {
463
+ return this._gatePost("/v1/tools/watcher/event", {
464
+ integrationId: this.integrationId,
465
+ ...(this.sessionId ? { sessionId: this.sessionId } : {}),
466
+ event: normalizedEvent,
467
+ });
468
+ }
469
+
470
+ // -- Internal: GATE API -------------------------------------------
471
+
472
+ async _gatePost(path, body) {
473
+ const url = `${this.gateUrl}${path}`;
474
+ try {
475
+ const res = await fetch(url, {
476
+ method: "POST",
477
+ headers: this._headers,
478
+ body: JSON.stringify(body),
479
+ signal: AbortSignal.timeout(this.timeout),
480
+ });
481
+ const data = await res.json();
482
+ if (!res.ok) {
483
+ this.emit("gate_error", { path, status: res.status, error: data.error });
484
+ }
485
+ return { ok: res.ok, status: res.status, data };
486
+ } catch (err) {
487
+ this.emit("gate_error", { path, status: 0, error: { message: err.message } });
488
+ return { ok: false, status: 0, data: null };
489
+ }
490
+ }
491
+
492
+ async _gateGet(reqPath) {
493
+ const url = `${this.gateUrl}${reqPath}`;
494
+ try {
495
+ const res = await fetch(url, {
496
+ method: "GET",
497
+ headers: this._headers,
498
+ signal: AbortSignal.timeout(this.timeout),
499
+ });
500
+ const data = await res.json();
501
+ if (!res.ok && res.status !== 404) {
502
+ this.emit("gate_error", { path: reqPath, status: res.status, error: data.error });
503
+ }
504
+ return { ok: res.ok, status: res.status, data };
505
+ } catch (err) {
506
+ this.emit("gate_error", { path: reqPath, status: 0, error: { message: err.message } });
507
+ return { ok: false, status: 0, data: null };
508
+ }
509
+ }
510
+
511
+ async _gatePatch(path, body) {
512
+ const url = `${this.gateUrl}${path}`;
513
+ try {
514
+ const res = await fetch(url, {
515
+ method: "PATCH",
516
+ headers: this._headers,
517
+ body: JSON.stringify(body),
518
+ signal: AbortSignal.timeout(this.timeout),
519
+ });
520
+ const data = await res.json();
521
+ if (!res.ok) {
522
+ this.emit("gate_error", { path, status: res.status, error: data.error });
523
+ }
524
+ return { ok: res.ok, status: res.status, data };
525
+ } catch (err) {
526
+ this.emit("gate_error", { path, status: 0, error: { message: err.message } });
527
+ return { ok: false, status: 0, data: null };
528
+ }
529
+ }
530
+
531
+ // -- Internal: Session Management ---------------------------------
532
+
533
+ async _registerSession() {
534
+ if (!this.sessionId) return;
535
+ await this._gatePost("/v1/tools/watcher/session", {
536
+ integrationId: this.integrationId,
537
+ sessionId: this.sessionId,
538
+ label: this._sessionLabel,
539
+ metadata: this._sessionMetadata,
540
+ });
541
+ }
542
+
543
+ async _updateSessionStatus(status) {
544
+ if (!this.sessionId) return;
545
+ await this._gatePatch("/v1/tools/watcher/session", {
546
+ integrationId: this.integrationId,
547
+ sessionId: this.sessionId,
548
+ status,
549
+ }).catch(() => {});
550
+ }
551
+
552
+ // -- Internal: Heartbeat ------------------------------------------
553
+
554
+ async _sendHeartbeat() {
555
+ const body = { integrationId: this.integrationId };
556
+ if (this.sessionId) { body.sessionIds = [this.sessionId]; }
557
+ const res = await this._gatePost("/v1/tools/watcher/heartbeat", body);
558
+ if (res.status === 429) {
559
+ this.emit("limit_reached", "watcher_agents");
560
+ }
561
+ }
562
+
563
+ // -- Internal: Outbound Poll --------------------------------------
564
+
565
+ async _pollOutbound() {
566
+ let url = `/v1/tools/watcher/outbound?integrationId=${this.integrationId}`;
567
+ if (this.sessionId) { url += `&sessionId=${this.sessionId}`; }
568
+ const res = await this._gateGet(url);
569
+ if (!res.ok || !res.data?.actions?.length) return;
570
+
571
+ for (const action of res.data.actions) {
572
+ this.emit("action", action);
573
+ await this._executeAction(action);
574
+ }
575
+ }
576
+
577
+ async _executeAction(action) {
578
+ let success = false;
579
+ let result = null;
580
+ let error = null;
581
+
582
+ try {
583
+ switch (action.action) {
584
+ case "create_issue": {
585
+ const projectKey = action.params.project || action.params.projectKey;
586
+ const summary = action.params.summary || action.params.title;
587
+ const description = action.params.description || action.params.body || "";
588
+ const issueType = action.params.issueType || "Task";
589
+ if (!projectKey || !summary) throw new Error("Missing 'project' or 'summary' in params");
590
+
591
+ const fields = {
592
+ project: { key: projectKey },
593
+ summary,
594
+ issuetype: { name: issueType },
595
+ };
596
+
597
+ // Description as ADF
598
+ if (description) {
599
+ fields.description = {
600
+ type: "doc",
601
+ version: 1,
602
+ content: [{ type: "paragraph", content: [{ type: "text", text: description }] }],
603
+ };
604
+ }
605
+
606
+ if (action.params.assignee) {
607
+ fields.assignee = { accountId: action.params.assignee };
608
+ }
609
+ if (action.params.priority) {
610
+ fields.priority = { name: action.params.priority };
611
+ }
612
+ if (action.params.labels) {
613
+ fields.labels = action.params.labels;
614
+ }
615
+
616
+ const res = await this._jiraPost("/rest/api/3/issue", { fields });
617
+ if (!res.ok) throw new Error(res.data?.errorMessages?.[0] || `Jira API error ${res.status}`);
618
+ result = { created: true, key: res.data.key, id: res.data.id, url: `https://${this.jiraDomain}/browse/${res.data.key}` };
619
+ success = true;
620
+ break;
621
+ }
622
+
623
+ case "add_comment": {
624
+ const issueKey = action.params.issueKey || action.params.issue;
625
+ const body = action.params.body || action.params.text || action.params.comment;
626
+ if (!issueKey || !body) throw new Error("Missing 'issueKey' or 'body' in params");
627
+
628
+ const commentBody = {
629
+ body: {
630
+ type: "doc",
631
+ version: 1,
632
+ content: [{ type: "paragraph", content: [{ type: "text", text: body }] }],
633
+ },
634
+ };
635
+
636
+ const res = await this._jiraPost(`/rest/api/3/issue/${issueKey}/comment`, commentBody);
637
+ if (!res.ok) throw new Error(res.data?.errorMessages?.[0] || `Jira API error ${res.status}`);
638
+ result = { created: true, commentId: res.data.id };
639
+ success = true;
640
+ break;
641
+ }
642
+
643
+ case "transition_issue": {
644
+ const issueKey = action.params.issueKey || action.params.issue;
645
+ const transitionName = action.params.transition || action.params.status;
646
+ if (!issueKey || !transitionName) throw new Error("Missing 'issueKey' or 'transition' in params");
647
+
648
+ // First, get available transitions
649
+ const transRes = await this._jiraGet(`/rest/api/3/issue/${issueKey}/transitions`);
650
+ if (!transRes.ok) throw new Error("Failed to fetch transitions");
651
+
652
+ const transition = transRes.data.transitions?.find(
653
+ (t) => t.name.toLowerCase() === transitionName.toLowerCase()
654
+ );
655
+ if (!transition) {
656
+ const available = (transRes.data.transitions || []).map((t) => t.name).join(", ");
657
+ throw new Error(`Transition "${transitionName}" not found. Available: ${available}`);
658
+ }
659
+
660
+ const res = await this._jiraPost(`/rest/api/3/issue/${issueKey}/transitions`, {
661
+ transition: { id: transition.id },
662
+ });
663
+ if (!res.ok) throw new Error(res.data?.errorMessages?.[0] || `Jira API error ${res.status}`);
664
+ result = { transitioned: true, from: null, to: transitionName };
665
+ success = true;
666
+ break;
667
+ }
668
+
669
+ case "assign_issue": {
670
+ const issueKey = action.params.issueKey || action.params.issue;
671
+ const accountId = action.params.accountId || action.params.assignee;
672
+ if (!issueKey) throw new Error("Missing 'issueKey' in params");
673
+
674
+ const res = await this._jiraPut(`/rest/api/3/issue/${issueKey}/assignee`, {
675
+ accountId: accountId || null, // null = unassign
676
+ });
677
+ if (!res.ok) throw new Error(res.data?.errorMessages?.[0] || `Jira API error ${res.status}`);
678
+ result = { assigned: true, assignee: accountId || null };
679
+ success = true;
680
+ break;
681
+ }
682
+
683
+ case "update_issue": {
684
+ const issueKey = action.params.issueKey || action.params.issue;
685
+ if (!issueKey) throw new Error("Missing 'issueKey' in params");
686
+
687
+ const fields = {};
688
+ if (action.params.summary) fields.summary = action.params.summary;
689
+ if (action.params.priority) fields.priority = { name: action.params.priority };
690
+ if (action.params.labels) fields.labels = action.params.labels;
691
+ if (action.params.description) {
692
+ fields.description = {
693
+ type: "doc",
694
+ version: 1,
695
+ content: [{ type: "paragraph", content: [{ type: "text", text: action.params.description }] }],
696
+ };
697
+ }
698
+
699
+ const res = await this._jiraPut(`/rest/api/3/issue/${issueKey}`, { fields });
700
+ if (!res.ok) throw new Error(res.data?.errorMessages?.[0] || `Jira API error ${res.status}`);
701
+ result = { updated: true };
702
+ success = true;
703
+ break;
704
+ }
705
+
706
+ default:
707
+ throw new Error(`Unknown action: ${action.action}`);
708
+ }
709
+ } catch (err) {
710
+ error = err.message;
711
+ }
712
+
713
+ await this._gatePost("/v1/tools/watcher/outbound", {
714
+ actionId: action.id,
715
+ integrationId: this.integrationId,
716
+ ...(this.sessionId ? { sessionId: this.sessionId } : {}),
717
+ success,
718
+ result,
719
+ error,
720
+ });
721
+
722
+ this.emit("action_result", {
723
+ actionId: action.id,
724
+ action: action.action,
725
+ success,
726
+ result,
727
+ error,
728
+ });
729
+ }
730
+
731
+ // -- Internal: Helpers --------------------------------------------
732
+
733
+ _markSeen(id) {
734
+ this._seenEventIds.add(id);
735
+ if (this._seenEventIds.size > this._maxSeenIds) {
736
+ const iter = this._seenEventIds.values();
737
+ for (let i = 0; i < 1000; i++) {
738
+ const val = iter.next().value;
739
+ if (val !== undefined) this._seenEventIds.delete(val);
740
+ }
741
+ }
742
+ }
743
+ }
744
+
745
+ module.exports = { GateJiraWatcher };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@anura-gate/watcher-jira",
3
+ "version": "0.1.0",
4
+ "description": "GATE Watcher — Self-hosted Jira event monitor. API token never leaves your machine.",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "gate-watcher-jira": "./cli.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "cli.js",
12
+ "lib/"
13
+ ],
14
+ "scripts": {
15
+ "start": "node cli.js"
16
+ },
17
+ "keywords": [
18
+ "gate",
19
+ "watcher",
20
+ "jira",
21
+ "monitoring",
22
+ "self-hosted"
23
+ ],
24
+ "license": "MIT",
25
+ "homepage": "https://anuragate.com",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/ahmad-ajmal/AnuraGate_Sample"
29
+ },
30
+ "dependencies": {
31
+ "dotenv": "^17.3.1"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ }
36
+ }