buddy-workbench 0.1.76 → 0.1.78
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/package.json +1 -1
- package/server/routes/jira-filters.js +301 -0
- package/server/routes/overview.js +145 -31
- package/server/routes/pr-conflicts.js +54 -0
- package/server/routes/pr-review.js +15 -2
- package/ui/dist/assets/index-DEV-rP05.js +585 -0
- package/ui/dist/assets/index-DaQcmRb2.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/sw.js +2 -2
- package/ui/dist/assets/index-CIElY9EY.css +0 -1
- package/ui/dist/assets/index-Ci4SjAkd.js +0 -582
package/package.json
CHANGED
|
@@ -230,10 +230,311 @@ router.delete('/templates/:id', (req, res) => {
|
|
|
230
230
|
res.status(204).end();
|
|
231
231
|
});
|
|
232
232
|
|
|
233
|
+
async function getJiraIdentity(jiraHost, token) {
|
|
234
|
+
const headers = { Accept: 'application/json', Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}` };
|
|
235
|
+
try {
|
|
236
|
+
const meResponse = await httpClient.get(`https://${jiraHost}/rest/api/2/myself`, { headers });
|
|
237
|
+
if (meResponse.status >= 200 && meResponse.status < 300 && meResponse.data) {
|
|
238
|
+
const me = meResponse.data;
|
|
239
|
+
return {
|
|
240
|
+
accountId: me.accountId || '',
|
|
241
|
+
name: me.name || me.key || '',
|
|
242
|
+
key: me.key || me.name || '',
|
|
243
|
+
displayName: me.displayName || '',
|
|
244
|
+
emailAddress: me.emailAddress || ''
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
recordApiError({ source: 'Jira API (myself)', method: 'GET', url: `https://${jiraHost}/rest/api/2/myself`, message: err.message });
|
|
249
|
+
}
|
|
250
|
+
return {};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isAuthorMatch(author, identity) {
|
|
254
|
+
if (!author || typeof author !== 'object') return false;
|
|
255
|
+
const authorName = String(author.name || '').toLowerCase();
|
|
256
|
+
const authorKey = String(author.key || '').toLowerCase();
|
|
257
|
+
const authorAccountId = String(author.accountId || '').toLowerCase();
|
|
258
|
+
const authorDisplayName = String(author.displayName || '').toLowerCase();
|
|
259
|
+
const authorEmail = String(author.emailAddress || '').toLowerCase();
|
|
260
|
+
|
|
261
|
+
if (identity.name && (authorName === identity.name.toLowerCase() || authorKey === identity.name.toLowerCase())) return true;
|
|
262
|
+
if (identity.key && (authorKey === identity.key.toLowerCase() || authorName === identity.key.toLowerCase())) return true;
|
|
263
|
+
if (identity.accountId && authorAccountId === identity.accountId.toLowerCase()) return true;
|
|
264
|
+
if (identity.displayName && authorDisplayName === identity.displayName.toLowerCase()) return true;
|
|
265
|
+
if (identity.emailAddress && authorEmail === identity.emailAddress.toLowerCase()) return true;
|
|
266
|
+
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function extractCommentBodyText(body) {
|
|
271
|
+
if (!body) return '';
|
|
272
|
+
if (typeof body === 'string') return body;
|
|
273
|
+
if (typeof body === 'object') {
|
|
274
|
+
try {
|
|
275
|
+
const extractTextFromAdf = (node) => {
|
|
276
|
+
if (!node) return '';
|
|
277
|
+
if (typeof node === 'string') return node;
|
|
278
|
+
if (node.type === 'text' && node.text) return node.text;
|
|
279
|
+
if (node.type === 'mention' && (node.attrs?.text || node.attrs?.id)) {
|
|
280
|
+
return `${node.attrs.text || node.attrs.id} `;
|
|
281
|
+
}
|
|
282
|
+
if (Array.isArray(node.content)) {
|
|
283
|
+
return node.content.map(extractTextFromAdf).join(' ');
|
|
284
|
+
}
|
|
285
|
+
return '';
|
|
286
|
+
};
|
|
287
|
+
const adfText = extractTextFromAdf(body).trim();
|
|
288
|
+
if (adfText) return adfText;
|
|
289
|
+
return JSON.stringify(body);
|
|
290
|
+
} catch {
|
|
291
|
+
return String(body || '');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return String(body || '');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isMentionMatch(bodyText, author, identity) {
|
|
298
|
+
if (!bodyText) return false;
|
|
299
|
+
if (isAuthorMatch(author, identity)) return false;
|
|
300
|
+
|
|
301
|
+
const lower = bodyText.toLowerCase();
|
|
302
|
+
const terms = [
|
|
303
|
+
identity.name ? `[~${identity.name.toLowerCase()}]` : null,
|
|
304
|
+
identity.key ? `[~${identity.key.toLowerCase()}]` : null,
|
|
305
|
+
identity.name ? `@${identity.name.toLowerCase()}` : null,
|
|
306
|
+
identity.key ? `@${identity.key.toLowerCase()}` : null,
|
|
307
|
+
identity.name ? identity.name.toLowerCase() : null,
|
|
308
|
+
identity.key ? identity.key.toLowerCase() : null,
|
|
309
|
+
identity.accountId ? identity.accountId.toLowerCase() : null,
|
|
310
|
+
identity.displayName ? identity.displayName.toLowerCase() : null,
|
|
311
|
+
identity.emailAddress ? identity.emailAddress.toLowerCase() : null
|
|
312
|
+
].filter(Boolean);
|
|
313
|
+
|
|
314
|
+
return terms.some((term) => lower.includes(term));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function searchJiraWithFallbacks(jiraHost, headers, jqlCandidates, fields = 'summary,priority,status,updated,assignee,reporter', maxResults = 50) {
|
|
318
|
+
for (const jql of jqlCandidates) {
|
|
319
|
+
if (!jql) continue;
|
|
320
|
+
try {
|
|
321
|
+
const searchUrl = `https://${jiraHost}/rest/api/2/search?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=${encodeURIComponent(fields)}`;
|
|
322
|
+
const response = await httpClient.get(searchUrl, { headers });
|
|
323
|
+
if (response.status === 200 && Array.isArray(response.data?.issues) && response.data.issues.length > 0) {
|
|
324
|
+
return response.data.issues;
|
|
325
|
+
}
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function fetchCommentsForIssue(jiraHost, headers, issueKey, maxResults = 50) {
|
|
332
|
+
try {
|
|
333
|
+
const url = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment?maxResults=${maxResults}`;
|
|
334
|
+
const response = await httpClient.get(url, { headers });
|
|
335
|
+
if (response.status !== 200) return [];
|
|
336
|
+
const total = response.data?.total || 0;
|
|
337
|
+
const comments = Array.isArray(response.data?.comments) ? response.data.comments : [];
|
|
338
|
+
if (total > comments.length && total > maxResults) {
|
|
339
|
+
const startAt = Math.max(0, total - maxResults);
|
|
340
|
+
const latestUrl = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment?startAt=${startAt}&maxResults=${maxResults}`;
|
|
341
|
+
const latestResponse = await httpClient.get(latestUrl, { headers });
|
|
342
|
+
if (latestResponse.status === 200 && Array.isArray(latestResponse.data?.comments)) {
|
|
343
|
+
return latestResponse.data.comments;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return comments;
|
|
347
|
+
} catch {
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
233
352
|
router.get('/recently-created', (_req, res) => {
|
|
234
353
|
res.json(listRecentlyCreatedJiraIssues());
|
|
235
354
|
});
|
|
236
355
|
|
|
356
|
+
router.get('/recently-commented', async (_req, res) => {
|
|
357
|
+
const jiraHost = getJiraHost();
|
|
358
|
+
if (!jiraHost) return res.json([]);
|
|
359
|
+
const settings = readSettings();
|
|
360
|
+
const token = settings.jiraAccessToken;
|
|
361
|
+
if (!token) return res.json([]);
|
|
362
|
+
|
|
363
|
+
const headers = { Accept: 'application/json', Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}` };
|
|
364
|
+
try {
|
|
365
|
+
const identity = await getJiraIdentity(jiraHost, token);
|
|
366
|
+
|
|
367
|
+
const jqlCandidates = [];
|
|
368
|
+
if (identity.name) {
|
|
369
|
+
jqlCandidates.push(`comment ~ "\\"${identity.name}\\"" ORDER BY updated DESC`);
|
|
370
|
+
jqlCandidates.push(`comment ~ "${identity.name}" ORDER BY updated DESC`);
|
|
371
|
+
}
|
|
372
|
+
if (identity.displayName && identity.displayName !== identity.name) {
|
|
373
|
+
jqlCandidates.push(`comment ~ "\\"${identity.displayName}\\"" ORDER BY updated DESC`);
|
|
374
|
+
}
|
|
375
|
+
if (identity.accountId) {
|
|
376
|
+
jqlCandidates.push(`comment ~ "${identity.accountId}" ORDER BY updated DESC`);
|
|
377
|
+
}
|
|
378
|
+
jqlCandidates.push('(assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) AND updated >= -60d ORDER BY updated DESC');
|
|
379
|
+
jqlCandidates.push('updated >= -60d ORDER BY updated DESC');
|
|
380
|
+
|
|
381
|
+
const issues = await searchJiraWithFallbacks(jiraHost, headers, jqlCandidates, 'summary,priority,status,updated,assignee,reporter', 40);
|
|
382
|
+
const results = [];
|
|
383
|
+
|
|
384
|
+
const issueCommentsPromises = issues.slice(0, 30).map(async (issue) => {
|
|
385
|
+
const key = issue.key;
|
|
386
|
+
const fields = issue.fields || {};
|
|
387
|
+
const comments = await fetchCommentsForIssue(jiraHost, headers, key, 50);
|
|
388
|
+
|
|
389
|
+
const myComments = comments.filter((c) => isAuthorMatch(c.author || c.updateAuthor, identity));
|
|
390
|
+
if (myComments.length > 0) {
|
|
391
|
+
myComments.sort((a, b) => new Date(b.updated || b.created || 0).getTime() - new Date(a.updated || a.created || 0).getTime());
|
|
392
|
+
const latestMyComment = myComments[0];
|
|
393
|
+
const bodyText = extractCommentBodyText(latestMyComment.body);
|
|
394
|
+
const commentSnippet = bodyText.slice(0, 140).replace(/\r?\n+/g, ' ');
|
|
395
|
+
const commentId = latestMyComment.id;
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
id: `${issue.id}-${commentId || 'c'}`,
|
|
399
|
+
key,
|
|
400
|
+
summary: fields.summary || key,
|
|
401
|
+
priority: fields.priority?.name || '',
|
|
402
|
+
status: fields.status?.name || '',
|
|
403
|
+
commentSnippet,
|
|
404
|
+
commentedAt: latestMyComment.updated || latestMyComment.created || fields.updated,
|
|
405
|
+
url: `https://${jiraHost}/browse/${key}${commentId ? `?focusedCommentId=${commentId}#comment-${commentId}` : ''}`
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const settled = await Promise.all(issueCommentsPromises);
|
|
412
|
+
for (const item of settled) {
|
|
413
|
+
if (item) results.push(item);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
results.sort((a, b) => new Date(b.commentedAt || 0).getTime() - new Date(a.commentedAt || 0).getTime());
|
|
417
|
+
res.json(results);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
recordApiError({ source: 'Jira (Recently Commented)', message: error.message });
|
|
420
|
+
res.json([]);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
router.get('/recently-mentioned', async (_req, res) => {
|
|
425
|
+
const jiraHost = getJiraHost();
|
|
426
|
+
if (!jiraHost) return res.json([]);
|
|
427
|
+
const settings = readSettings();
|
|
428
|
+
const token = settings.jiraAccessToken;
|
|
429
|
+
if (!token) return res.json([]);
|
|
430
|
+
|
|
431
|
+
const headers = { Accept: 'application/json', Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}` };
|
|
432
|
+
try {
|
|
433
|
+
const identity = await getJiraIdentity(jiraHost, token);
|
|
434
|
+
|
|
435
|
+
const jqlCandidates = [];
|
|
436
|
+
if (identity.name) {
|
|
437
|
+
jqlCandidates.push(`text ~ "[~${identity.name}]" OR comment ~ "[~${identity.name}]" ORDER BY updated DESC`);
|
|
438
|
+
jqlCandidates.push(`text ~ "${identity.name}" OR comment ~ "${identity.name}" ORDER BY updated DESC`);
|
|
439
|
+
jqlCandidates.push(`text ~ "\\"${identity.name}\\"" ORDER BY updated DESC`);
|
|
440
|
+
}
|
|
441
|
+
if (identity.displayName && identity.displayName !== identity.name) {
|
|
442
|
+
jqlCandidates.push(`text ~ "\\"${identity.displayName}\\"" OR comment ~ "\\"${identity.displayName}\\"" ORDER BY updated DESC`);
|
|
443
|
+
}
|
|
444
|
+
if (identity.accountId) {
|
|
445
|
+
jqlCandidates.push(`text ~ "${identity.accountId}" OR comment ~ "${identity.accountId}" ORDER BY updated DESC`);
|
|
446
|
+
}
|
|
447
|
+
jqlCandidates.push('(assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) AND updated >= -60d ORDER BY updated DESC');
|
|
448
|
+
jqlCandidates.push('updated >= -60d ORDER BY updated DESC');
|
|
449
|
+
|
|
450
|
+
const issues = await searchJiraWithFallbacks(jiraHost, headers, jqlCandidates, 'summary,priority,status,updated,assignee,reporter', 40);
|
|
451
|
+
const results = [];
|
|
452
|
+
|
|
453
|
+
const issueMentionsPromises = issues.slice(0, 30).map(async (issue) => {
|
|
454
|
+
const key = issue.key;
|
|
455
|
+
const fields = issue.fields || {};
|
|
456
|
+
const comments = await fetchCommentsForIssue(jiraHost, headers, key, 50);
|
|
457
|
+
|
|
458
|
+
const mentionComments = comments.filter((c) => {
|
|
459
|
+
const bodyText = extractCommentBodyText(c.body);
|
|
460
|
+
return isMentionMatch(bodyText, c.author || c.updateAuthor, identity);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
if (mentionComments.length > 0) {
|
|
464
|
+
mentionComments.sort((a, b) => new Date(b.updated || b.created || 0).getTime() - new Date(a.updated || a.created || 0).getTime());
|
|
465
|
+
const latestMention = mentionComments[0];
|
|
466
|
+
const author = latestMention.author?.displayName || latestMention.author?.name || 'Someone';
|
|
467
|
+
const bodyText = extractCommentBodyText(latestMention.body);
|
|
468
|
+
const snippet = bodyText.slice(0, 140).replace(/\r?\n+/g, ' ');
|
|
469
|
+
const commentId = latestMention.id;
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
id: `${issue.id}-${commentId || 'm'}`,
|
|
473
|
+
key,
|
|
474
|
+
summary: fields.summary || key,
|
|
475
|
+
priority: fields.priority?.name || '',
|
|
476
|
+
status: fields.status?.name || '',
|
|
477
|
+
mentionedBy: author,
|
|
478
|
+
mentionSnippet: snippet,
|
|
479
|
+
mentionedAt: latestMention.updated || latestMention.created || fields.updated,
|
|
480
|
+
url: `https://${jiraHost}/browse/${key}${commentId ? `?focusedCommentId=${commentId}#comment-${commentId}` : ''}`
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const settled = await Promise.all(issueMentionsPromises);
|
|
487
|
+
for (const item of settled) {
|
|
488
|
+
if (item) results.push(item);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
results.sort((a, b) => new Date(b.mentionedAt || 0).getTime() - new Date(a.mentionedAt || 0).getTime());
|
|
492
|
+
res.json(results);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
recordApiError({ source: 'Jira (Recently Mentioned)', message: error.message });
|
|
495
|
+
res.json([]);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
router.get('/recently-updated', async (_req, res) => {
|
|
500
|
+
const jiraHost = getJiraHost();
|
|
501
|
+
if (!jiraHost) return res.json([]);
|
|
502
|
+
const settings = readSettings();
|
|
503
|
+
const token = settings.jiraAccessToken;
|
|
504
|
+
if (!token) return res.json([]);
|
|
505
|
+
|
|
506
|
+
const headers = { Accept: 'application/json', Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}` };
|
|
507
|
+
try {
|
|
508
|
+
const jqlCandidates = [
|
|
509
|
+
'updatedBy(currentUser()) ORDER BY updated DESC',
|
|
510
|
+
'(assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) AND updated >= -60d ORDER BY updated DESC',
|
|
511
|
+
'assignee = currentUser() ORDER BY updated DESC',
|
|
512
|
+
'updated >= -60d ORDER BY updated DESC'
|
|
513
|
+
];
|
|
514
|
+
|
|
515
|
+
const issues = await searchJiraWithFallbacks(jiraHost, headers, jqlCandidates, 'summary,priority,status,updated,duedate,assignee,reporter', 40);
|
|
516
|
+
|
|
517
|
+
const results = issues.map((issue) => {
|
|
518
|
+
const fields = issue.fields || {};
|
|
519
|
+
return {
|
|
520
|
+
id: issue.id,
|
|
521
|
+
key: issue.key,
|
|
522
|
+
summary: fields.summary || issue.key,
|
|
523
|
+
priority: fields.priority?.name || '',
|
|
524
|
+
status: fields.status?.name || '',
|
|
525
|
+
updatedAt: fields.updated || null,
|
|
526
|
+
dueDate: fields.duedate || null,
|
|
527
|
+
url: `https://${jiraHost}/browse/${issue.key}`
|
|
528
|
+
};
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
res.json(results);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
recordApiError({ source: 'Jira (Recently Updated)', message: error.message });
|
|
534
|
+
res.json([]);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
237
538
|
// Create filter
|
|
238
539
|
router.post('/', (req, res) => {
|
|
239
540
|
const { name, filterId } = req.body || {};
|
|
@@ -27,6 +27,12 @@ function bitbucketHost(domain) {
|
|
|
27
27
|
return clean.includes('bitbucket') ? clean : `bitbucket.${clean}`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function confluenceHost(domain) {
|
|
31
|
+
const clean = String(domain || '').replace(/^https?:\/\//i, '').replace(/\/+$/, '');
|
|
32
|
+
if (!clean) return '';
|
|
33
|
+
return clean.includes('confluence') ? clean : `confluence.${clean}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
function jiraUrl(host, path) { return `https://${host}/rest/api/2${path}`; }
|
|
31
37
|
|
|
32
38
|
async function requestSection(name, loader) {
|
|
@@ -39,8 +45,9 @@ async function requestSection(name, loader) {
|
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
async function searchJira(host, token, jql, fields, maxResults = 25) {
|
|
48
|
+
const authHeader = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
|
|
42
49
|
const url = jiraUrl(host, `/search?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=${encodeURIComponent(fields)}`);
|
|
43
|
-
const response = await httpClient.get(url, { headers: { Accept: 'application/json', Authorization:
|
|
50
|
+
const response = await httpClient.get(url, { headers: { Accept: 'application/json', Authorization: authHeader } });
|
|
44
51
|
if (!isSuccess(response)) throw new Error(`Jira search failed (${response.status}): ${apiMessage(response, 'Request failed')}`);
|
|
45
52
|
return response.data?.issues || [];
|
|
46
53
|
}
|
|
@@ -62,55 +69,148 @@ function mapJiraIssue(issue) {
|
|
|
62
69
|
function issueUrl(host, key) { return `https://${host}/browse/${encodeURIComponent(key)}`; }
|
|
63
70
|
|
|
64
71
|
async function loadJiraData(host, token) {
|
|
65
|
-
const
|
|
72
|
+
const authHeader = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
|
|
73
|
+
const headers = { Accept: 'application/json', Authorization: authHeader };
|
|
66
74
|
const meUrl = jiraUrl(host, '/myself');
|
|
67
75
|
const meResponse = await httpClient.get(meUrl, { headers });
|
|
68
76
|
if (!isSuccess(meResponse)) throw new Error(`Jira identity lookup failed (${meResponse.status}): ${apiMessage(meResponse, 'Request failed')}`);
|
|
69
77
|
const me = meResponse.data || {};
|
|
70
|
-
const identity = { accountId: me.accountId || '', name: me.name || '', displayName: me.displayName || '' };
|
|
78
|
+
const identity = { accountId: me.accountId || '', name: me.name || '', displayName: me.displayName || '', emailAddress: me.emailAddress || '' };
|
|
71
79
|
const fields = 'summary,status,priority,duedate,updated,assignee,reporter';
|
|
72
80
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
// 1. Due today issues
|
|
82
|
+
const due = await searchJira(host, token, 'assignee = currentUser() AND due = startOfDay() AND resolution = Unresolved ORDER BY priority DESC', fields).catch(() => []);
|
|
83
|
+
|
|
84
|
+
// 2. Mentioned / active issues
|
|
85
|
+
let activityIssues = [];
|
|
86
|
+
const jqlCandidates = [];
|
|
87
|
+
if (identity.name) {
|
|
88
|
+
jqlCandidates.push(`text ~ "[~${identity.name}]" OR comment ~ "[~${identity.name}]" ORDER BY updated DESC`);
|
|
89
|
+
jqlCandidates.push(`text ~ "${identity.name}" OR comment ~ "${identity.name}" ORDER BY updated DESC`);
|
|
90
|
+
}
|
|
91
|
+
if (identity.displayName && identity.displayName !== identity.name) {
|
|
92
|
+
jqlCandidates.push(`text ~ "\\"${identity.displayName}\\"" ORDER BY updated DESC`);
|
|
93
|
+
}
|
|
94
|
+
if (identity.accountId) {
|
|
95
|
+
jqlCandidates.push(`text ~ "${identity.accountId}" OR comment ~ "${identity.accountId}" ORDER BY updated DESC`);
|
|
96
|
+
}
|
|
97
|
+
jqlCandidates.push('updated >= -30d AND (assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC');
|
|
98
|
+
jqlCandidates.push('updated >= -30d ORDER BY updated DESC');
|
|
99
|
+
|
|
100
|
+
for (const jql of jqlCandidates) {
|
|
101
|
+
try {
|
|
102
|
+
activityIssues = await searchJira(host, token, jql, fields, 30);
|
|
103
|
+
if (activityIssues.length > 0) break;
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
77
106
|
|
|
78
107
|
const activityItems = [];
|
|
79
|
-
for (const issue of
|
|
108
|
+
for (const issue of activityIssues.slice(0, 20)) {
|
|
80
109
|
const key = issue.key;
|
|
81
|
-
const commentsUrl = jiraUrl(host, `/issue/${encodeURIComponent(key)}/comment?
|
|
82
|
-
const commentsResponse = await httpClient.get(commentsUrl, { headers });
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
110
|
+
const commentsUrl = jiraUrl(host, `/issue/${encodeURIComponent(key)}/comment?maxResults=50`);
|
|
111
|
+
const commentsResponse = await httpClient.get(commentsUrl, { headers }).catch(() => null);
|
|
112
|
+
let comments = commentsResponse && isSuccess(commentsResponse) ? (commentsResponse.data?.comments || []) : [];
|
|
113
|
+
const total = commentsResponse?.data?.total || 0;
|
|
114
|
+
if (total > comments.length && total > 50) {
|
|
115
|
+
const startAt = Math.max(0, total - 50);
|
|
116
|
+
const latestUrl = jiraUrl(host, `/issue/${encodeURIComponent(key)}/comment?startAt=${startAt}&maxResults=50`);
|
|
117
|
+
const latestRes = await httpClient.get(latestUrl, { headers }).catch(() => null);
|
|
118
|
+
if (latestRes && isSuccess(latestRes) && Array.isArray(latestRes.data?.comments)) {
|
|
119
|
+
comments = latestRes.data.comments;
|
|
120
|
+
}
|
|
86
121
|
}
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const author = comment.author || {};
|
|
122
|
+
|
|
123
|
+
const mentionComment = comments.find((comment) => {
|
|
90
124
|
const body = typeof comment.body === 'string' ? comment.body : JSON.stringify(comment.body || '');
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
125
|
+
const author = comment.author || {};
|
|
126
|
+
const mentionsMe = [identity.name, identity.accountId, identity.displayName, identity.emailAddress]
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.some((value) => body.toLowerCase().includes(value.toLowerCase()));
|
|
129
|
+
const isNotSelf = author.name !== identity.name && author.accountId !== identity.accountId;
|
|
130
|
+
return mentionsMe && isNotSelf;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const relevantComment = mentionComment || comments.find((comment) => {
|
|
134
|
+
const author = comment.author || {};
|
|
135
|
+
return author.name !== identity.name && author.accountId !== identity.accountId;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const isMention = Boolean(mentionComment);
|
|
139
|
+
const authorName = relevantComment?.author?.displayName || relevantComment?.author?.name || issue.fields?.reporter?.displayName || '';
|
|
140
|
+
const date = relevantComment?.updated || relevantComment?.created || issue.fields?.updated;
|
|
141
|
+
const commentId = relevantComment?.id;
|
|
142
|
+
const itemUrl = `https://${host}/browse/${issue.key}${commentId ? `?focusedCommentId=${commentId}#comment-${commentId}` : ''}`;
|
|
143
|
+
|
|
144
|
+
activityItems.push({
|
|
145
|
+
...mapJiraIssue(issue),
|
|
146
|
+
source: 'Jira',
|
|
147
|
+
sourceType: 'jira',
|
|
148
|
+
author: authorName,
|
|
149
|
+
activityType: isMention ? 'Mentioned in Jira' : 'Commented in Jira',
|
|
150
|
+
activityAt: date,
|
|
151
|
+
url: itemUrl
|
|
94
152
|
});
|
|
95
|
-
if (relevant) {
|
|
96
|
-
activityItems.push({
|
|
97
|
-
...mapJiraIssue(issue),
|
|
98
|
-
activityType: [identity.name, identity.accountId, identity.displayName].filter(Boolean).some((value) => String(relevant.body || '').includes(value)) ? 'Mentioned' : 'Commented',
|
|
99
|
-
activityAt: relevant.updated || relevant.created || issue.fields?.updated
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
153
|
}
|
|
103
154
|
|
|
104
155
|
return {
|
|
105
156
|
identity,
|
|
106
157
|
dueToday: due.map((issue) => ({ ...mapJiraIssue(issue), url: issueUrl(host, issue.key) })),
|
|
107
|
-
activity: activityItems
|
|
158
|
+
activity: activityItems
|
|
108
159
|
};
|
|
109
160
|
}
|
|
110
161
|
|
|
162
|
+
async function loadConfluenceMentions(host, token) {
|
|
163
|
+
if (!host || !token) return [];
|
|
164
|
+
const authHeader = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
|
|
165
|
+
const headers = { Accept: 'application/json', Authorization: authHeader };
|
|
166
|
+
|
|
167
|
+
const cqlQueries = [
|
|
168
|
+
'mention = currentUser() order by lastmodified desc',
|
|
169
|
+
'type in (page, blogpost, comment) and text ~ currentUser() order by lastmodified desc'
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
let results = [];
|
|
173
|
+
for (const cql of cqlQueries) {
|
|
174
|
+
const url = `https://${host}/rest/api/content/search?cql=${encodeURIComponent(cql)}&expand=history.lastUpdated,version,space,container&limit=25`;
|
|
175
|
+
try {
|
|
176
|
+
const response = await httpClient.get(url, { headers });
|
|
177
|
+
if (isSuccess(response) && Array.isArray(response.data?.results) && response.data.results.length > 0) {
|
|
178
|
+
results = response.data.results;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return results.map((item) => {
|
|
185
|
+
const spaceKey = item.space?.key || item.space?.name || 'Confluence';
|
|
186
|
+
const containerTitle = item.container?.title || '';
|
|
187
|
+
const itemTitle = item.title || (item.type === 'comment' ? 'Comment' : 'Page');
|
|
188
|
+
const displayTitle = containerTitle ? `${containerTitle} (${itemTitle})` : itemTitle;
|
|
189
|
+
const authorName = item.history?.lastUpdated?.by?.displayName || item.version?.by?.displayName || item.history?.createdBy?.displayName || '';
|
|
190
|
+
const date = item.history?.lastUpdated?.when || item.version?.when || item.history?.createdDate;
|
|
191
|
+
const webui = item._links?.webui || '';
|
|
192
|
+
const fullUrl = webui ? (webui.startsWith('http') ? webui : `https://${host}${webui}`) : `https://${host}/pages/viewpage.action?pageId=${item.id}`;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
id: `confluence-${item.id}`,
|
|
196
|
+
key: spaceKey,
|
|
197
|
+
title: displayTitle,
|
|
198
|
+
summary: displayTitle,
|
|
199
|
+
source: 'Confluence',
|
|
200
|
+
sourceType: 'confluence',
|
|
201
|
+
author: authorName,
|
|
202
|
+
activityType: 'Mentioned in Confluence',
|
|
203
|
+
activityAt: date ? new Date(date).toISOString() : null,
|
|
204
|
+
updated: date ? new Date(date).toISOString() : null,
|
|
205
|
+
url: fullUrl
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
111
210
|
async function loadAuthoredPullRequests(host, token) {
|
|
211
|
+
const authHeader = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
|
|
112
212
|
const url = `https://${host}/rest/api/latest/dashboard/pull-requests?role=author&state=OPEN&limit=100`;
|
|
113
|
-
const response = await httpClient.get(url, { headers: { Accept: 'application/json', Authorization:
|
|
213
|
+
const response = await httpClient.get(url, { headers: { Accept: 'application/json', Authorization: authHeader } });
|
|
114
214
|
if (!isSuccess(response)) throw new Error(`Bitbucket author pull requests failed (${response.status}): ${apiMessage(response, 'Request failed')}`);
|
|
115
215
|
return (response.data?.values || []).map((pr) => ({
|
|
116
216
|
id: pr.id,
|
|
@@ -130,21 +230,35 @@ router.get('/', async (_req, res) => {
|
|
|
130
230
|
}
|
|
131
231
|
const jira = jiraHost(settings.domain);
|
|
132
232
|
const bitbucket = bitbucketHost(settings.domain);
|
|
233
|
+
const confluence = confluenceHost(settings.domain);
|
|
234
|
+
const confluenceToken = settings.confluenceAccessToken || settings.jiraAccessToken;
|
|
235
|
+
|
|
133
236
|
if (!jira || !bitbucket) return res.json({ configured: false, missingTokens: ['domain'], fetchedAt: new Date().toISOString(), categories: {} });
|
|
134
237
|
|
|
135
238
|
const sections = await Promise.all([
|
|
136
239
|
requestSection('Jira', () => loadJiraData(jira, settings.jiraAccessToken)),
|
|
137
|
-
requestSection('Bitbucket Author', () => loadAuthoredPullRequests(bitbucket, settings.bitbucketAccessToken))
|
|
240
|
+
requestSection('Bitbucket Author', () => loadAuthoredPullRequests(bitbucket, settings.bitbucketAccessToken)),
|
|
241
|
+
requestSection('Confluence Mentions', () => loadConfluenceMentions(confluence, confluenceToken))
|
|
138
242
|
]);
|
|
139
|
-
const [jiraData, authoredPrs] = sections;
|
|
243
|
+
const [jiraData, authoredPrs, confluenceData] = sections;
|
|
140
244
|
const todos = listTodos().filter((todo) => !todo.completed && !todo.archived).map((todo) => ({ ...todo, url: '#/todo-list' }));
|
|
141
245
|
const errors = sections.filter((section) => section.error).map((section) => section.error);
|
|
246
|
+
|
|
247
|
+
const allActivity = [
|
|
248
|
+
...(jiraData.items?.activity || []),
|
|
249
|
+
...(confluenceData.items || [])
|
|
250
|
+
].sort((a, b) => {
|
|
251
|
+
const timeA = new Date(a.activityAt || a.updated || 0).getTime();
|
|
252
|
+
const timeB = new Date(b.activityAt || b.updated || 0).getTime();
|
|
253
|
+
return timeB - timeA;
|
|
254
|
+
});
|
|
255
|
+
|
|
142
256
|
res.json({
|
|
143
257
|
configured: true,
|
|
144
258
|
missingTokens: [],
|
|
145
259
|
fetchedAt: new Date().toISOString(),
|
|
146
260
|
categories: {
|
|
147
|
-
activity:
|
|
261
|
+
activity: allActivity,
|
|
148
262
|
jiraDueToday: jiraData.items?.dueToday || [],
|
|
149
263
|
assignedTodos: todos,
|
|
150
264
|
authoredPrs: authoredPrs.items || []
|
|
@@ -177,6 +177,60 @@ async function readFile({ host, projectKey, repositorySlug, commit, path }) {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
router.post('/load', async (req, res) => {
|
|
180
|
+
const prUrl = String(req.body?.prUrl || '').trim();
|
|
181
|
+
if (prUrl.toLowerCase().includes('demo') || prUrl.toLowerCase().includes('mock')) {
|
|
182
|
+
return res.json({
|
|
183
|
+
conflicted: true,
|
|
184
|
+
pr: {
|
|
185
|
+
title: 'Demo: Feature User Management with Conflict',
|
|
186
|
+
sourceBranch: 'feature/user-management-v2',
|
|
187
|
+
targetBranch: 'main'
|
|
188
|
+
},
|
|
189
|
+
files: [
|
|
190
|
+
{
|
|
191
|
+
path: 'src/components/UserProfile.tsx',
|
|
192
|
+
sourceContent: `import React, { useState } from 'react';
|
|
193
|
+
import { Card, Button } from '@acme/ui';
|
|
194
|
+
|
|
195
|
+
// Source branch: contains upgraded configuration URL and new permissions check
|
|
196
|
+
export const PROFILE_ENDPOINT_URL = 'https://api.internal.company.com/v2/users/profile';
|
|
197
|
+
|
|
198
|
+
export function UserProfile({ userId }: { userId: string }) {
|
|
199
|
+
const [activeTab, setActiveTab] = useState('overview');
|
|
200
|
+
return (
|
|
201
|
+
<Card title="User Profile (V2)">
|
|
202
|
+
<p>Source branch implementation with updated permissions model and telemetry hooks.</p>
|
|
203
|
+
<Button onClick={() => setActiveTab('details')}>View Details</Button>
|
|
204
|
+
</Card>
|
|
205
|
+
);
|
|
206
|
+
}`,
|
|
207
|
+
targetContent: `import React, { useState } from 'react';
|
|
208
|
+
import { Card, Button } from '@acme/ui';
|
|
209
|
+
|
|
210
|
+
// Target branch: contains legacy configuration URL
|
|
211
|
+
export const PROFILE_ENDPOINT_URL = 'https://legacy-api.internal.company.com/v1/users/profile';
|
|
212
|
+
|
|
213
|
+
export function UserProfile({ userId }: { userId: string }) {
|
|
214
|
+
const [tab, setTab] = useState('summary');
|
|
215
|
+
return (
|
|
216
|
+
<Card title="User Profile (Main)">
|
|
217
|
+
<p>Main branch implementation with legacy summary view.</p>
|
|
218
|
+
<Button onClick={() => setTab('summary')}>Summary</Button>
|
|
219
|
+
</Card>
|
|
220
|
+
);
|
|
221
|
+
}`,
|
|
222
|
+
defaultChoice: 'source'
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
path: 'src/config/application-settings.json',
|
|
226
|
+
sourceContent: `{\n "name": "enterprise-workbench",\n "version": "2.4.0",\n "endpoints": {\n "auth": "https://auth.internal.company.com/oauth2/token",\n "storage": "https://storage.internal.company.com/data"\n }\n}`,
|
|
227
|
+
targetContent: `{\n "name": "enterprise-workbench",\n "version": "2.3.1",\n "endpoints": {\n "auth": "https://auth-legacy.internal.company.com/token",\n "storage": "https://storage.internal.company.com/data"\n }\n}`,
|
|
228
|
+
defaultChoice: 'target'
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
180
234
|
const parsed = parsePrUrl(req.body?.prUrl);
|
|
181
235
|
if (!parsed) return res.status(400).json({ error: 'Invalid Bitbucket Server Pull Request URL.' });
|
|
182
236
|
|
|
@@ -119,7 +119,8 @@ async function runAiReview({ aiApiHost, pr, files }) {
|
|
|
119
119
|
'Focus on correctness, security, reliability, maintainability, and regressions introduced by the change.',
|
|
120
120
|
'Report only actionable findings. For each finding include severity (critical, warning, or info), file, line if identifiable, title, explanation, and a concrete recommendation.',
|
|
121
121
|
'If there are no actionable findings, use an empty findings array and say LGTM in the summary.',
|
|
122
|
-
'
|
|
122
|
+
'Format the "summary" field in rich, well-structured Markdown (with headings, bullet points, bold highlights, code blocks, etc.) providing an executive review overview, risk assessment, and recommendations.',
|
|
123
|
+
'Return only valid JSON in this exact shape: {"summary":"# PR Review Summary\\n\\n...Markdown content...","findings":[{"severity":"warning","file":"path/to/file","line":12,"title":"...","explanation":"...","recommendation":"..."}]}',
|
|
123
124
|
'Do not invent files, code, or line numbers. Use null for line when a precise line cannot be identified.',
|
|
124
125
|
'',
|
|
125
126
|
`Pull request: ${pr.title || ''}`,
|
|
@@ -804,7 +805,19 @@ router.post('/check', async (req, res) => {
|
|
|
804
805
|
status: 'completed',
|
|
805
806
|
source: 'demo',
|
|
806
807
|
reviewedFiles: aiFiles.length,
|
|
807
|
-
summary:
|
|
808
|
+
summary: `### 📋 PR Review Executive Summary
|
|
809
|
+
|
|
810
|
+
The pull request introduces feature updates across **${aiFiles.length} files**. The overall design is coherent, but several **critical quality**, **type-safety**, and **accessibility** concerns require attention before merge.
|
|
811
|
+
|
|
812
|
+
#### 🚨 Key Action Items
|
|
813
|
+
- **Type Safety**: Avoid using \`any\` in \`UserProfile.tsx\` (\`payload: any\`). Strongly type the interface.
|
|
814
|
+
- **State Mutation**: In \`useUserStats.ts\`, \`state.list.push()\` directly mutates state. Use immutable state updates.
|
|
815
|
+
- **Accessibility (a11y)**: In \`Dashboard.tsx\`, \`<div className="btn-wrapper" onClick={...}>\` is not keyboard accessible. Switch to \`<button>\` or an interactive component.
|
|
816
|
+
- **Debugging Artifacts**: Remove \`debugger;\` and debug \`console.log\` statements in \`api.ts\`.
|
|
817
|
+
|
|
818
|
+
#### 💡 Recommendations
|
|
819
|
+
1. Validate external API payload shapes defensively at runtime.
|
|
820
|
+
2. Ensure long configuration strings and secrets are loaded via environment variables rather than hardcoded URLs.`,
|
|
808
821
|
content: 'Demo AI review: found several actionable quality and accessibility issues.',
|
|
809
822
|
findings: demoAiFindings
|
|
810
823
|
};
|