@tenonhq/dovetail-dashboard 0.0.13
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/README.md +116 -0
- package/package.json +26 -0
- package/public/app.js +1001 -0
- package/public/index.html +79 -0
- package/public/styles.css +819 -0
- package/server.js +785 -0
package/server.js
ADDED
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
const express = require("express");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const axios = require("axios");
|
|
4
|
+
const { wrapper } = require("axios-cookiejar-support");
|
|
5
|
+
const { CookieJar } = require("tough-cookie");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const RateLimit = require("express-rate-limit");
|
|
8
|
+
|
|
9
|
+
// Everything resolves from CWD — run this from your Dovetail project directory
|
|
10
|
+
const PROJECT_ROOT = process.cwd();
|
|
11
|
+
const envPath = path.resolve(PROJECT_ROOT, ".env");
|
|
12
|
+
require("dotenv").config({ path: envPath });
|
|
13
|
+
|
|
14
|
+
const app = express();
|
|
15
|
+
app.disable("x-powered-by");
|
|
16
|
+
const PORT = process.env.DASHBOARD_PORT || 3456;
|
|
17
|
+
|
|
18
|
+
const SN_INSTANCE = process.env.SN_INSTANCE || "";
|
|
19
|
+
const SN_USER = process.env.SN_USER || "";
|
|
20
|
+
|
|
21
|
+
// Rate limiter for recent-edits endpoint: max 100 requests per 15 minutes per IP
|
|
22
|
+
const recentEditsLimiter = RateLimit({
|
|
23
|
+
windowMs: 15 * 60 * 1000,
|
|
24
|
+
max: 100,
|
|
25
|
+
});
|
|
26
|
+
const SN_PASSWORD = process.env.SN_PASSWORD || "";
|
|
27
|
+
const BASE_URL = `https://${SN_INSTANCE}`;
|
|
28
|
+
|
|
29
|
+
// Resolve an artifact path, preferring the dove.* name and falling back to the
|
|
30
|
+
// legacy sinc.* name when only the legacy file exists.
|
|
31
|
+
function resolveDovePath(doveName, sincName) {
|
|
32
|
+
var dovePath = path.join(PROJECT_ROOT, doveName);
|
|
33
|
+
var sincPath = path.join(PROJECT_ROOT, sincName);
|
|
34
|
+
if (fs.existsSync(dovePath)) return dovePath;
|
|
35
|
+
if (fs.existsSync(sincPath)) return sincPath;
|
|
36
|
+
return dovePath;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const UPDATE_SET_CONFIG = resolveDovePath(".dove-update-sets.json", ".sinc-update-sets.json");
|
|
40
|
+
const DOVE_CONFIG_PATH = resolveDovePath("dove.config.js", "sinc.config.js");
|
|
41
|
+
const ACTIVE_TASK_FILE = resolveDovePath(".dove-active-task.json", ".sinc-active-task.json");
|
|
42
|
+
|
|
43
|
+
const CLICKUP_TOKEN = process.env.CLICKUP_API_TOKEN || "";
|
|
44
|
+
const CLICKUP_TEAM_ID = process.env.CLICKUP_TEAM_ID || "";
|
|
45
|
+
|
|
46
|
+
// Rate limiting for ServiceNow API calls.
|
|
47
|
+
// Dashboard uses 10 RPS so that combined with core's 20 RPS the total stays
|
|
48
|
+
// under ServiceNow's per-user throttle when both are active simultaneously.
|
|
49
|
+
var snRequestTimestamps = [];
|
|
50
|
+
var MAX_SN_RPS = 10;
|
|
51
|
+
var SN_WINDOW_MS = 1000;
|
|
52
|
+
|
|
53
|
+
function waitForRateLimit() {
|
|
54
|
+
var now = Date.now();
|
|
55
|
+
// Purge timestamps older than the window
|
|
56
|
+
snRequestTimestamps = snRequestTimestamps.filter(function (ts) {
|
|
57
|
+
return now - ts < SN_WINDOW_MS;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (snRequestTimestamps.length < MAX_SN_RPS) {
|
|
61
|
+
snRequestTimestamps.push(now);
|
|
62
|
+
return Promise.resolve();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Calculate how long to wait until the oldest request falls out of the window
|
|
66
|
+
var oldest = snRequestTimestamps[0];
|
|
67
|
+
var delayMs = SN_WINDOW_MS - (now - oldest) + 10; // +10ms buffer
|
|
68
|
+
return new Promise(function (resolve) {
|
|
69
|
+
setTimeout(function () {
|
|
70
|
+
snRequestTimestamps = snRequestTimestamps.filter(function (ts) {
|
|
71
|
+
return Date.now() - ts < SN_WINDOW_MS;
|
|
72
|
+
});
|
|
73
|
+
snRequestTimestamps.push(Date.now());
|
|
74
|
+
resolve();
|
|
75
|
+
}, delayMs);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
app.use(express.json());
|
|
80
|
+
app.use(express.static(path.join(__dirname, "public")));
|
|
81
|
+
|
|
82
|
+
// Session-persistent ServiceNow client — cookie jar ensures scope changes
|
|
83
|
+
// (changeScope) persist across subsequent requests in the same session.
|
|
84
|
+
var snCookieJar = new CookieJar();
|
|
85
|
+
var snClient = wrapper(axios.create({
|
|
86
|
+
baseURL: BASE_URL,
|
|
87
|
+
auth: { username: SN_USER, password: SN_PASSWORD },
|
|
88
|
+
headers: {
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
"Accept": "application/json",
|
|
91
|
+
},
|
|
92
|
+
jar: snCookieJar,
|
|
93
|
+
withCredentials: true,
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
// Dovetail Scripted REST API rebrand: the API path moved from /api/cadso/claude/*
|
|
97
|
+
// to /api/cadso/dovetail/*. snApi rewrites legacy /api/cadso/claude/* URLs to the
|
|
98
|
+
// new path on first call; if that 404s (instance hasn't been re-imported yet) we
|
|
99
|
+
// latch back to the legacy path for the rest of the session and warn once.
|
|
100
|
+
var _dovetailApiUseLegacyClaudePath = false;
|
|
101
|
+
|
|
102
|
+
async function snApi(method, endpoint, data) {
|
|
103
|
+
await waitForRateLimit();
|
|
104
|
+
// Rewrite legacy /api/cadso/claude/* call sites to the new dovetail path,
|
|
105
|
+
// unless we've already discovered this instance only speaks the legacy path.
|
|
106
|
+
var rewritten = endpoint;
|
|
107
|
+
var isDovetailScopedApi = endpoint.indexOf("api/cadso/claude/") === 0
|
|
108
|
+
|| endpoint.indexOf("/api/cadso/claude/") === 0
|
|
109
|
+
|| endpoint.indexOf("api/cadso/dovetail/") === 0
|
|
110
|
+
|| endpoint.indexOf("/api/cadso/dovetail/") === 0;
|
|
111
|
+
if (isDovetailScopedApi) {
|
|
112
|
+
rewritten = _dovetailApiUseLegacyClaudePath
|
|
113
|
+
? endpoint.replace("api/cadso/dovetail/", "api/cadso/claude/")
|
|
114
|
+
: endpoint.replace("api/cadso/claude/", "api/cadso/dovetail/");
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return await snClient({ method: method, url: rewritten, data: data });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
var status = e && e.response && e.response.status;
|
|
120
|
+
if (isDovetailScopedApi && !_dovetailApiUseLegacyClaudePath && status === 404) {
|
|
121
|
+
// eslint-disable-next-line no-console
|
|
122
|
+
console.warn(
|
|
123
|
+
"[deprecation] " + rewritten +
|
|
124
|
+
" returned 404. Falling back to legacy /api/cadso/claude/* path. Re-import the Dovetail Scripted REST API XML on your ServiceNow instance to silence this warning.",
|
|
125
|
+
);
|
|
126
|
+
_dovetailApiUseLegacyClaudePath = true;
|
|
127
|
+
var legacyUrl = rewritten.replace("api/cadso/dovetail/", "api/cadso/claude/");
|
|
128
|
+
return await snClient({ method: method, url: legacyUrl, data: data });
|
|
129
|
+
}
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ClickUp API helper
|
|
135
|
+
function clickupApi(method, endpoint, data) {
|
|
136
|
+
return axios({
|
|
137
|
+
method,
|
|
138
|
+
url: "https://api.clickup.com/api/v2/" + endpoint,
|
|
139
|
+
headers: {
|
|
140
|
+
Authorization: CLICKUP_TOKEN,
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
},
|
|
143
|
+
data,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Generate update set name from ClickUp task
|
|
148
|
+
function generateUpdateSetName(taskId, taskName) {
|
|
149
|
+
var sanitized = taskName.replace(/[^a-zA-Z0-9\s\-_]/g, "").trim();
|
|
150
|
+
var base = "CU-" + taskId + " — " + sanitized;
|
|
151
|
+
return base.substring(0, 80);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Generate update set description from task
|
|
155
|
+
function generateUpdateSetDescription(taskName, taskDescription) {
|
|
156
|
+
var desc = taskName;
|
|
157
|
+
if (taskDescription) {
|
|
158
|
+
var firstSentence = taskDescription.split(/[.!\n]/)[0].trim();
|
|
159
|
+
if (firstSentence) {
|
|
160
|
+
desc += " — " + firstSentence.substring(0, 150);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return desc;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Read active task from persistence file
|
|
167
|
+
function readActiveTask() {
|
|
168
|
+
if (fs.existsSync(ACTIVE_TASK_FILE)) {
|
|
169
|
+
return JSON.parse(fs.readFileSync(ACTIVE_TASK_FILE, "utf8"));
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Write active task to persistence file
|
|
175
|
+
function writeActiveTask(task) {
|
|
176
|
+
fs.writeFileSync(ACTIVE_TASK_FILE, JSON.stringify(task, null, 2));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Extract duplicate number from ServiceNow auto-numbered name
|
|
180
|
+
// "CU-abc — Name" => -1, "CU-abc — Name 1" => 1, "CU-abc — Name 2" => 2
|
|
181
|
+
function extractDuplicateNumber(name, baseName) {
|
|
182
|
+
if (name === baseName) return -1;
|
|
183
|
+
var suffix = name.substring(baseName.length).trim();
|
|
184
|
+
var num = parseInt(suffix, 10);
|
|
185
|
+
return isNaN(num) ? -1 : num;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Find the best matching update set (highest duplicate number)
|
|
189
|
+
function findBestMatch(updateSets, baseName) {
|
|
190
|
+
var matches = updateSets.filter(function (us) {
|
|
191
|
+
return us.name === baseName || us.name.indexOf(baseName + " ") === 0;
|
|
192
|
+
});
|
|
193
|
+
if (matches.length === 0) return null;
|
|
194
|
+
if (matches.length === 1) return matches[0];
|
|
195
|
+
|
|
196
|
+
var best = matches[0];
|
|
197
|
+
var bestNum = extractDuplicateNumber(best.name, baseName);
|
|
198
|
+
for (var i = 1; i < matches.length; i++) {
|
|
199
|
+
var num = extractDuplicateNumber(matches[i].name, baseName);
|
|
200
|
+
if (num > bestNum) {
|
|
201
|
+
best = matches[i];
|
|
202
|
+
bestNum = num;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return best;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// GET /api/scopes — read from dove.config.js (or legacy sinc.config.js) + resolve display names
|
|
209
|
+
app.get("/api/scopes", async (req, res) => {
|
|
210
|
+
try {
|
|
211
|
+
delete require.cache[require.resolve(DOVE_CONFIG_PATH)];
|
|
212
|
+
const config = require(DOVE_CONFIG_PATH);
|
|
213
|
+
const scopeKeys = Object.keys(config.scopes || {});
|
|
214
|
+
|
|
215
|
+
// Batch query for all scope records
|
|
216
|
+
const scopeQuery = scopeKeys.map((s) => `scope=${s}`).join("^OR");
|
|
217
|
+
const resp = await snApi(
|
|
218
|
+
"get",
|
|
219
|
+
`api/now/table/sys_scope?sysparm_query=${encodeURIComponent(scopeQuery)}&sysparm_fields=sys_id,scope,name&sysparm_limit=50`
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const scopeRecords = resp.data.result || [];
|
|
223
|
+
const scopeMap = {};
|
|
224
|
+
scopeRecords.forEach((r) => {
|
|
225
|
+
scopeMap[r.scope] = { sys_id: r.sys_id, name: r.name, scope: r.scope };
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Load saved selections
|
|
229
|
+
let saved = {};
|
|
230
|
+
if (fs.existsSync(UPDATE_SET_CONFIG)) {
|
|
231
|
+
saved = JSON.parse(fs.readFileSync(UPDATE_SET_CONFIG, "utf8"));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const scopes = scopeKeys.map((key) => ({
|
|
235
|
+
scope: key,
|
|
236
|
+
sys_id: scopeMap[key] ? scopeMap[key].sys_id : null,
|
|
237
|
+
display_name: scopeMap[key] ? scopeMap[key].name : key,
|
|
238
|
+
selected_update_set: saved[key] || null,
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
res.json({ scopes });
|
|
242
|
+
} catch (e) {
|
|
243
|
+
res.status(500).json({ error: e.message });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// GET /api/recent-edits — read local recent edits file, enrich with live SN data
|
|
248
|
+
var RECENT_EDITS_FILE = resolveDovePath(".dove-recent-edits.json", ".sinc-recent-edits.json");
|
|
249
|
+
|
|
250
|
+
app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
|
|
251
|
+
try {
|
|
252
|
+
var edits = [];
|
|
253
|
+
if (fs.existsSync(RECENT_EDITS_FILE)) {
|
|
254
|
+
edits = JSON.parse(fs.readFileSync(RECENT_EDITS_FILE, "utf8"));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (edits.length === 0) {
|
|
258
|
+
return res.json({ edits: [] });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// For each edit, query sys_update_xml to get the live update set
|
|
262
|
+
var enriched = [];
|
|
263
|
+
for (var i = 0; i < edits.length; i++) {
|
|
264
|
+
var edit = edits[i];
|
|
265
|
+
var updateSetName = "unknown";
|
|
266
|
+
try {
|
|
267
|
+
var query = "name=" + edit.tableName + "_" + edit.sys_id + "^ORDERBYDESCsys_created_on";
|
|
268
|
+
var snResp = await snApi(
|
|
269
|
+
"get",
|
|
270
|
+
"api/now/table/sys_update_xml?sysparm_query=" + encodeURIComponent(query) +
|
|
271
|
+
"&sysparm_fields=update_set,update_set.name&sysparm_limit=1"
|
|
272
|
+
);
|
|
273
|
+
var results = snResp.data.result || [];
|
|
274
|
+
if (results.length > 0) {
|
|
275
|
+
updateSetName = results[0]["update_set.name"] || "unknown";
|
|
276
|
+
}
|
|
277
|
+
} catch (snErr) {
|
|
278
|
+
// If SN query fails, still show the entry with "unknown" update set
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
enriched.push({
|
|
282
|
+
tableName: edit.tableName,
|
|
283
|
+
name: edit.name,
|
|
284
|
+
scope: edit.scope,
|
|
285
|
+
updateSet: updateSetName,
|
|
286
|
+
timestamp: edit.timestamp,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
res.json({ edits: enriched });
|
|
291
|
+
} catch (e) {
|
|
292
|
+
res.status(500).json({ error: e.message });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// GET /api/update-sets/:scope — list in-progress update sets for a scope
|
|
297
|
+
app.get("/api/update-sets/:scope", async (req, res) => {
|
|
298
|
+
try {
|
|
299
|
+
const { scope } = req.params;
|
|
300
|
+
const query = `application.scope=${scope}^state=in progress^ORDERBYDESCsys_created_on`;
|
|
301
|
+
const resp = await snApi(
|
|
302
|
+
"get",
|
|
303
|
+
`api/now/table/sys_update_set?sysparm_query=${encodeURIComponent(query)}&sysparm_fields=sys_id,name,state,application,sys_created_on,description&sysparm_limit=50`
|
|
304
|
+
);
|
|
305
|
+
res.json({ update_sets: resp.data.result || [] });
|
|
306
|
+
} catch (e) {
|
|
307
|
+
res.status(500).json({ error: e.message });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// POST /api/update-set — create a new update set
|
|
312
|
+
app.post("/api/update-set", async (req, res) => {
|
|
313
|
+
try {
|
|
314
|
+
const { name, scope, scope_sys_id, description } = req.body;
|
|
315
|
+
if (!name || typeof name !== "string" || name.trim() === "") {
|
|
316
|
+
return res.status(400).json({ error: "name is required" });
|
|
317
|
+
}
|
|
318
|
+
if (!scope_sys_id || typeof scope_sys_id !== "string") {
|
|
319
|
+
return res.status(400).json({ error: "scope_sys_id is required" });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Switch to the target scope before creating — ServiceNow uses session scope
|
|
323
|
+
if (scope) {
|
|
324
|
+
await snApi("get", "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const data = {
|
|
328
|
+
name,
|
|
329
|
+
state: "in progress",
|
|
330
|
+
application: scope_sys_id,
|
|
331
|
+
};
|
|
332
|
+
if (description) data.description = description;
|
|
333
|
+
|
|
334
|
+
const resp = await snApi("post", "api/now/table/sys_update_set", data);
|
|
335
|
+
res.json({ update_set: resp.data.result });
|
|
336
|
+
} catch (e) {
|
|
337
|
+
res.status(500).json({ error: e.message });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// PATCH /api/update-set/:sysId/close — close an update set
|
|
342
|
+
app.patch("/api/update-set/:sysId/close", async (req, res) => {
|
|
343
|
+
try {
|
|
344
|
+
const { sysId } = req.params;
|
|
345
|
+
const resp = await snApi(
|
|
346
|
+
"patch",
|
|
347
|
+
`api/now/table/sys_update_set/${sysId}`,
|
|
348
|
+
{ state: "complete" }
|
|
349
|
+
);
|
|
350
|
+
res.json({ update_set: resp.data.result });
|
|
351
|
+
} catch (e) {
|
|
352
|
+
res.status(500).json({ error: e.message });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// POST /api/select-update-set — save scope->updateSet mapping
|
|
357
|
+
app.post("/api/select-update-set", async (req, res) => {
|
|
358
|
+
try {
|
|
359
|
+
const { scope, update_set_sys_id, update_set_name } = req.body;
|
|
360
|
+
if (!scope || typeof scope !== "string") {
|
|
361
|
+
return res.status(400).json({ error: "scope is required" });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let config = {};
|
|
365
|
+
if (fs.existsSync(UPDATE_SET_CONFIG)) {
|
|
366
|
+
config = JSON.parse(fs.readFileSync(UPDATE_SET_CONFIG, "utf8"));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (update_set_sys_id) {
|
|
370
|
+
config[scope] = { sys_id: update_set_sys_id, name: update_set_name };
|
|
371
|
+
} else {
|
|
372
|
+
delete config[scope];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
fs.writeFileSync(UPDATE_SET_CONFIG, JSON.stringify(config, null, 2));
|
|
376
|
+
res.json({ saved: true, config });
|
|
377
|
+
} catch (e) {
|
|
378
|
+
res.status(500).json({ error: e.message });
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// GET /api/config — return current saved config
|
|
383
|
+
app.get("/api/config", (req, res) => {
|
|
384
|
+
try {
|
|
385
|
+
let config = {};
|
|
386
|
+
if (fs.existsSync(UPDATE_SET_CONFIG)) {
|
|
387
|
+
config = JSON.parse(fs.readFileSync(UPDATE_SET_CONFIG, "utf8"));
|
|
388
|
+
}
|
|
389
|
+
res.json({ config, instance: SN_INSTANCE });
|
|
390
|
+
} catch (e) {
|
|
391
|
+
res.status(500).json({ error: e.message });
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// --- ClickUp Endpoints ---
|
|
396
|
+
|
|
397
|
+
// GET /api/clickup/status — check if ClickUp is configured + active task
|
|
398
|
+
app.get("/api/clickup/status", function (req, res) {
|
|
399
|
+
try {
|
|
400
|
+
var activeTask = readActiveTask();
|
|
401
|
+
res.json({
|
|
402
|
+
configured: !!(CLICKUP_TOKEN && CLICKUP_TOKEN.length > 0),
|
|
403
|
+
hasTeamId: !!(CLICKUP_TEAM_ID && CLICKUP_TEAM_ID.length > 0),
|
|
404
|
+
activeTask: activeTask,
|
|
405
|
+
});
|
|
406
|
+
} catch (e) {
|
|
407
|
+
res.status(500).json({ error: e.message });
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// GET /api/clickup/me — fetch current ClickUp user
|
|
412
|
+
app.get("/api/clickup/me", async function (req, res) {
|
|
413
|
+
try {
|
|
414
|
+
if (!CLICKUP_TOKEN) {
|
|
415
|
+
return res.status(400).json({ error: "CLICKUP_API_TOKEN not configured" });
|
|
416
|
+
}
|
|
417
|
+
var resp = await clickupApi("get", "user");
|
|
418
|
+
var user = resp.data.user || {};
|
|
419
|
+
res.json({
|
|
420
|
+
id: user.id,
|
|
421
|
+
username: user.username || "",
|
|
422
|
+
email: user.email || "",
|
|
423
|
+
initials: user.initials || "",
|
|
424
|
+
});
|
|
425
|
+
} catch (e) {
|
|
426
|
+
var msg = e.message;
|
|
427
|
+
if (e.response && e.response.data) {
|
|
428
|
+
msg = e.response.data.err || e.response.data.error || msg;
|
|
429
|
+
}
|
|
430
|
+
res.status(500).json({ error: msg });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// GET /api/clickup/tasks — fetch user's tasks with optional status filter
|
|
435
|
+
app.get("/api/clickup/tasks", async function (req, res) {
|
|
436
|
+
try {
|
|
437
|
+
if (!CLICKUP_TOKEN) {
|
|
438
|
+
return res.status(400).json({ error: "CLICKUP_API_TOKEN not configured" });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
var teamId = CLICKUP_TEAM_ID;
|
|
442
|
+
if (!teamId) {
|
|
443
|
+
var teamsResp = await clickupApi("get", "team");
|
|
444
|
+
var teams = teamsResp.data.teams || [];
|
|
445
|
+
if (teams.length === 0) {
|
|
446
|
+
return res.status(400).json({ error: "No ClickUp teams found" });
|
|
447
|
+
}
|
|
448
|
+
teamId = teams[0].id;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
var statuses = req.query.statuses;
|
|
452
|
+
var statusList = statuses ? statuses.split(",") : [];
|
|
453
|
+
|
|
454
|
+
var url = "team/" + teamId + "/task?subtasks=true&include_closed=false";
|
|
455
|
+
statusList.forEach(function (s) {
|
|
456
|
+
url += "&statuses[]=" + encodeURIComponent(s.trim());
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
var resp = await clickupApi("get", url);
|
|
460
|
+
var tasks = resp.data.tasks || [];
|
|
461
|
+
|
|
462
|
+
// Group by status
|
|
463
|
+
var byStatus = {};
|
|
464
|
+
var allStatuses = [];
|
|
465
|
+
tasks.forEach(function (t) {
|
|
466
|
+
var statusName = t.status && t.status.status ? t.status.status : "unknown";
|
|
467
|
+
if (!byStatus[statusName]) {
|
|
468
|
+
byStatus[statusName] = [];
|
|
469
|
+
allStatuses.push(statusName);
|
|
470
|
+
}
|
|
471
|
+
var assignees = (t.assignees || []).map(function (a) {
|
|
472
|
+
return {
|
|
473
|
+
id: a.id,
|
|
474
|
+
username: a.username || "",
|
|
475
|
+
initials: a.initials || "",
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
byStatus[statusName].push({
|
|
479
|
+
id: t.id,
|
|
480
|
+
name: t.name,
|
|
481
|
+
description: t.description || "",
|
|
482
|
+
status: statusName,
|
|
483
|
+
statusColor: t.status && t.status.color ? t.status.color : null,
|
|
484
|
+
priority: t.priority ? t.priority.priority : null,
|
|
485
|
+
url: t.url || "",
|
|
486
|
+
customId: t.custom_id || null,
|
|
487
|
+
assignees: assignees,
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
res.json({ tasks: tasks.length, byStatus: byStatus, statuses: allStatuses });
|
|
492
|
+
} catch (e) {
|
|
493
|
+
var msg = e.message;
|
|
494
|
+
if (e.response && e.response.data) {
|
|
495
|
+
msg = e.response.data.err || e.response.data.error || msg;
|
|
496
|
+
}
|
|
497
|
+
res.status(500).json({ error: msg });
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// GET /api/clickup/task/:taskId — fetch single task detail
|
|
502
|
+
app.get("/api/clickup/task/:taskId", async function (req, res) {
|
|
503
|
+
try {
|
|
504
|
+
if (!CLICKUP_TOKEN) {
|
|
505
|
+
return res.status(400).json({ error: "CLICKUP_API_TOKEN not configured" });
|
|
506
|
+
}
|
|
507
|
+
var resp = await clickupApi("get", "task/" + req.params.taskId);
|
|
508
|
+
var t = resp.data;
|
|
509
|
+
res.json({
|
|
510
|
+
task: {
|
|
511
|
+
id: t.id,
|
|
512
|
+
name: t.name,
|
|
513
|
+
description: t.description || "",
|
|
514
|
+
status: t.status && t.status.status ? t.status.status : "unknown",
|
|
515
|
+
statusColor: t.status && t.status.color ? t.status.color : null,
|
|
516
|
+
priority: t.priority ? t.priority.priority : null,
|
|
517
|
+
url: t.url || "",
|
|
518
|
+
customId: t.custom_id || null,
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
} catch (e) {
|
|
522
|
+
var msg = e.message;
|
|
523
|
+
if (e.response && e.response.data) {
|
|
524
|
+
msg = e.response.data.err || e.response.data.error || msg;
|
|
525
|
+
}
|
|
526
|
+
res.status(500).json({ error: msg });
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// POST /api/clickup/select-task — select a task as active
|
|
531
|
+
app.post("/api/clickup/select-task", function (req, res) {
|
|
532
|
+
try {
|
|
533
|
+
var body = req.body;
|
|
534
|
+
if (!body.taskId || !body.taskName) {
|
|
535
|
+
return res.status(400).json({ error: "taskId and taskName are required" });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
var updateSetName = generateUpdateSetName(body.taskId, body.taskName);
|
|
539
|
+
var description = generateUpdateSetDescription(
|
|
540
|
+
body.taskName,
|
|
541
|
+
body.taskDescription || ""
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
var activeTask = {
|
|
545
|
+
taskId: body.taskId,
|
|
546
|
+
taskName: body.taskName,
|
|
547
|
+
taskDescription: body.taskDescription || "",
|
|
548
|
+
updateSetName: updateSetName,
|
|
549
|
+
description: description,
|
|
550
|
+
taskUrl: body.taskUrl || "",
|
|
551
|
+
scopes: {},
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
writeActiveTask(activeTask);
|
|
555
|
+
res.json({ activeTask: activeTask });
|
|
556
|
+
} catch (e) {
|
|
557
|
+
res.status(500).json({ error: e.message });
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Core logic: find or create update set for a scope given an active task
|
|
562
|
+
// Returns { update_set, created }
|
|
563
|
+
async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
|
|
564
|
+
var baseName = activeTask.updateSetName;
|
|
565
|
+
var taskId = activeTask.taskId;
|
|
566
|
+
|
|
567
|
+
if (!taskId || typeof taskId !== "string" || taskId.trim() === "") {
|
|
568
|
+
throw new Error("Cannot search for update sets: activeTask has an empty or missing taskId");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Query ServiceNow for existing update sets matching this task in this scope
|
|
572
|
+
var query =
|
|
573
|
+
"application.scope=" + scope +
|
|
574
|
+
"^nameLIKECU-" + taskId +
|
|
575
|
+
"^state=in progress" +
|
|
576
|
+
"^ORDERBYDESCsys_created_on";
|
|
577
|
+
var searchResp = await snApi(
|
|
578
|
+
"get",
|
|
579
|
+
"api/now/table/sys_update_set?sysparm_query=" +
|
|
580
|
+
encodeURIComponent(query) +
|
|
581
|
+
"&sysparm_fields=sys_id,name,state,application,sys_created_on,description&sysparm_limit=50"
|
|
582
|
+
);
|
|
583
|
+
var existing = searchResp.data.result || [];
|
|
584
|
+
|
|
585
|
+
var updateSet = null;
|
|
586
|
+
|
|
587
|
+
if (existing.length > 0) {
|
|
588
|
+
updateSet = findBestMatch(existing, baseName);
|
|
589
|
+
if (!updateSet) {
|
|
590
|
+
updateSet = existing[0];
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
var created = false;
|
|
595
|
+
if (!updateSet) {
|
|
596
|
+
// Switch to the target scope before creating — ServiceNow uses session scope,
|
|
597
|
+
// not the application field, when creating update sets via Table API
|
|
598
|
+
await snApi("get", "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope));
|
|
599
|
+
|
|
600
|
+
var createData = {
|
|
601
|
+
name: baseName,
|
|
602
|
+
state: "in progress",
|
|
603
|
+
application: scopeSysId,
|
|
604
|
+
};
|
|
605
|
+
if (activeTask.description) {
|
|
606
|
+
createData.description = activeTask.description;
|
|
607
|
+
}
|
|
608
|
+
var createResp = await snApi("post", "api/now/table/sys_update_set", createData);
|
|
609
|
+
updateSet = createResp.data.result;
|
|
610
|
+
created = true;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Change the current update set on the ServiceNow instance and verify
|
|
614
|
+
try {
|
|
615
|
+
await snApi(
|
|
616
|
+
"get",
|
|
617
|
+
"api/cadso/claude/changeUpdateSet?sysId=" + encodeURIComponent(updateSet.sys_id)
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
// Verify the switch was successful
|
|
621
|
+
var verifyResp = await snApi(
|
|
622
|
+
"get",
|
|
623
|
+
"api/cadso/claude/currentUpdateSet" + (scope ? "?scope=" + encodeURIComponent(scope) : "")
|
|
624
|
+
);
|
|
625
|
+
var verifyData = verifyResp.data;
|
|
626
|
+
if (verifyData && verifyData.result) {
|
|
627
|
+
verifyData = verifyData.result;
|
|
628
|
+
}
|
|
629
|
+
var currentSysId = verifyData && verifyData.sysId ? verifyData.sysId : null;
|
|
630
|
+
if (currentSysId !== updateSet.sys_id) {
|
|
631
|
+
// Retry once
|
|
632
|
+
console.warn("Update set verification failed, retrying switch...");
|
|
633
|
+
await snApi(
|
|
634
|
+
"get",
|
|
635
|
+
"api/cadso/claude/changeUpdateSet?sysId=" + encodeURIComponent(updateSet.sys_id)
|
|
636
|
+
);
|
|
637
|
+
var retryResp = await snApi(
|
|
638
|
+
"get",
|
|
639
|
+
"api/cadso/claude/currentUpdateSet" + (scope ? "?scope=" + encodeURIComponent(scope) : "")
|
|
640
|
+
);
|
|
641
|
+
var retryData = retryResp.data;
|
|
642
|
+
if (retryData && retryData.result) {
|
|
643
|
+
retryData = retryData.result;
|
|
644
|
+
}
|
|
645
|
+
var retrySysId = retryData && retryData.sysId ? retryData.sysId : null;
|
|
646
|
+
if (retrySysId !== updateSet.sys_id) {
|
|
647
|
+
var actualName = retryData && retryData.name ? retryData.name : "unknown";
|
|
648
|
+
console.error(
|
|
649
|
+
"Update set " + updateSet.name + " was created but could not be activated. Current update set is " + actualName + "."
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
} catch (changeErr) {
|
|
654
|
+
console.error("Warning: Could not auto-switch update set on instance:", changeErr.message);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return { update_set: updateSet, created: created };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Persist scope activation into both active task file and update set config
|
|
661
|
+
function persistScopeActivation(scope, updateSet, activeTask) {
|
|
662
|
+
activeTask.scopes[scope] = {
|
|
663
|
+
sys_id: updateSet.sys_id,
|
|
664
|
+
name: updateSet.name,
|
|
665
|
+
};
|
|
666
|
+
writeActiveTask(activeTask);
|
|
667
|
+
|
|
668
|
+
var config = {};
|
|
669
|
+
if (fs.existsSync(UPDATE_SET_CONFIG)) {
|
|
670
|
+
config = JSON.parse(fs.readFileSync(UPDATE_SET_CONFIG, "utf8"));
|
|
671
|
+
}
|
|
672
|
+
config[scope] = {
|
|
673
|
+
sys_id: updateSet.sys_id,
|
|
674
|
+
name: updateSet.name,
|
|
675
|
+
};
|
|
676
|
+
fs.writeFileSync(UPDATE_SET_CONFIG, JSON.stringify(config, null, 2));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// POST /api/clickup/activate-scope — find or create update set for a scope
|
|
680
|
+
app.post("/api/clickup/activate-scope", async function (req, res) {
|
|
681
|
+
try {
|
|
682
|
+
var body = req.body;
|
|
683
|
+
if (!body.scope || !body.scope_sys_id) {
|
|
684
|
+
return res.status(400).json({ error: "scope and scope_sys_id are required" });
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
var activeTask = readActiveTask();
|
|
688
|
+
if (!activeTask) {
|
|
689
|
+
return res.status(400).json({ error: "No active task selected" });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
var result = await findOrCreateUpdateSet(body.scope, body.scope_sys_id, activeTask);
|
|
693
|
+
persistScopeActivation(body.scope, result.update_set, activeTask);
|
|
694
|
+
|
|
695
|
+
res.json({
|
|
696
|
+
update_set: result.update_set,
|
|
697
|
+
created: result.created,
|
|
698
|
+
scope: body.scope,
|
|
699
|
+
});
|
|
700
|
+
} catch (e) {
|
|
701
|
+
res.status(500).json({ error: e.message });
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// POST /api/clickup/activate-all-scopes — find or create update sets for all configured scopes
|
|
706
|
+
app.post("/api/clickup/activate-all-scopes", async function (req, res) {
|
|
707
|
+
try {
|
|
708
|
+
var activeTask = readActiveTask();
|
|
709
|
+
if (!activeTask) {
|
|
710
|
+
return res.status(400).json({ error: "No active task selected" });
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Read scopes from dove.config.js
|
|
714
|
+
delete require.cache[require.resolve(DOVE_CONFIG_PATH)];
|
|
715
|
+
var config = require(DOVE_CONFIG_PATH);
|
|
716
|
+
var scopeKeys = Object.keys(config.scopes || {});
|
|
717
|
+
|
|
718
|
+
// Resolve scope sys_ids
|
|
719
|
+
var scopeQuery = scopeKeys.map(function (s) { return "scope=" + s; }).join("^OR");
|
|
720
|
+
var scopeResp = await snApi(
|
|
721
|
+
"get",
|
|
722
|
+
"api/now/table/sys_scope?sysparm_query=" +
|
|
723
|
+
encodeURIComponent(scopeQuery) +
|
|
724
|
+
"&sysparm_fields=sys_id,scope,name&sysparm_limit=50"
|
|
725
|
+
);
|
|
726
|
+
var scopeRecords = scopeResp.data.result || [];
|
|
727
|
+
var scopeMap = {};
|
|
728
|
+
scopeRecords.forEach(function (r) {
|
|
729
|
+
scopeMap[r.scope] = r.sys_id;
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// Activate each scope sequentially (respects rate limits)
|
|
733
|
+
var results = [];
|
|
734
|
+
for (var i = 0; i < scopeKeys.length; i++) {
|
|
735
|
+
var scope = scopeKeys[i];
|
|
736
|
+
var scopeSysId = scopeMap[scope];
|
|
737
|
+
if (!scopeSysId) {
|
|
738
|
+
results.push({ scope: scope, error: "scope not found on instance" });
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
try {
|
|
743
|
+
var result = await findOrCreateUpdateSet(scope, scopeSysId, activeTask);
|
|
744
|
+
persistScopeActivation(scope, result.update_set, activeTask);
|
|
745
|
+
// Re-read active task so subsequent iterations see updated scopes
|
|
746
|
+
activeTask = readActiveTask();
|
|
747
|
+
results.push({
|
|
748
|
+
scope: scope,
|
|
749
|
+
update_set: result.update_set,
|
|
750
|
+
created: result.created,
|
|
751
|
+
});
|
|
752
|
+
} catch (scopeErr) {
|
|
753
|
+
results.push({ scope: scope, error: scopeErr.message });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
res.json({ results: results, activeTask: readActiveTask() });
|
|
758
|
+
} catch (e) {
|
|
759
|
+
res.status(500).json({ error: e.message });
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// POST /api/clickup/deselect-task — clear the active task
|
|
764
|
+
app.post("/api/clickup/deselect-task", function (req, res) {
|
|
765
|
+
try {
|
|
766
|
+
if (fs.existsSync(ACTIVE_TASK_FILE)) {
|
|
767
|
+
fs.unlinkSync(ACTIVE_TASK_FILE);
|
|
768
|
+
}
|
|
769
|
+
res.json({ cleared: true });
|
|
770
|
+
} catch (e) {
|
|
771
|
+
res.status(500).json({ error: e.message });
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
// Only start the server when run directly (not when require()-d).
|
|
776
|
+
// Callers like dashboardCommand.ts and allScopesCommands.ts use
|
|
777
|
+
// spawn("node", [serverPath]) which sets require.main === module.
|
|
778
|
+
if (require.main === module) {
|
|
779
|
+
app.listen(PORT, "127.0.0.1", function () {
|
|
780
|
+
console.log("\n Dovetail Update Set Dashboard");
|
|
781
|
+
console.log(" Instance: " + SN_INSTANCE);
|
|
782
|
+
console.log(" Project: " + PROJECT_ROOT);
|
|
783
|
+
console.log(" Dashboard: http://localhost:" + PORT + "\n");
|
|
784
|
+
});
|
|
785
|
+
}
|