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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-workbench",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -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: `Bearer ${token}` } });
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 headers = { Accept: 'application/json', Authorization: `Bearer ${token}` };
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
- const [due, activity] = await Promise.all([
74
- searchJira(host, token, 'assignee = currentUser() AND due = startOfDay() AND resolution = Unresolved ORDER BY priority DESC', fields),
75
- searchJira(host, token, 'updated >= -14d AND resolution = Unresolved AND (assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC', fields, 30)
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 activity.slice(0, 15)) {
108
+ for (const issue of activityIssues.slice(0, 20)) {
80
109
  const key = issue.key;
81
- const commentsUrl = jiraUrl(host, `/issue/${encodeURIComponent(key)}/comment?orderBy=-updated&maxResults=10`);
82
- const commentsResponse = await httpClient.get(commentsUrl, { headers });
83
- if (!isSuccess(commentsResponse)) {
84
- recordApiError({ source: 'Overview (Jira Activity)', method: 'GET', url: commentsUrl, status: commentsResponse.status, message: apiMessage(commentsResponse, 'Failed to fetch comments.') });
85
- continue;
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
- const comments = commentsResponse.data?.comments || [];
88
- const relevant = comments.find((comment) => {
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 mentionsMe = [identity.name, identity.accountId, identity.displayName].filter(Boolean).some((value) => body.includes(value));
92
- const writtenBySomeoneElse = author.name !== identity.name && author.accountId !== identity.accountId;
93
- return mentionsMe || writtenBySomeoneElse;
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.map((item) => ({ ...item, url: issueUrl(host, item.key) }))
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: `Bearer ${token}` } });
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: jiraData.items?.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
- 'Return only valid JSON in this exact shape: {"summary":"...","findings":[{"severity":"warning","file":"path/to/file","line":12,"title":"...","explanation":"...","recommendation":"..."}]}',
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: 'Demo AI review: found several actionable quality and accessibility issues. Select a file to inspect the findings on the corresponding lines.',
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
  };