@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 +160 -0
- package/index.js +2 -0
- package/lib/gate-watcher-jira.js +745 -0
- package/package.json +36 -0
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,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
|
+
}
|