buddy-workbench 0.1.77 → 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.77",
3
+ "version": "0.1.78",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -232,10 +232,121 @@ router.delete('/templates/:id', (req, res) => {
232
232
 
233
233
  async function getJiraIdentity(jiraHost, token) {
234
234
  const headers = { Accept: 'application/json', Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}` };
235
- const meResponse = await httpClient.get(`https://${jiraHost}/rest/api/2/myself`, { headers });
236
- if (meResponse.status < 200 || meResponse.status >= 300) return {};
237
- const me = meResponse.data || {};
238
- return { accountId: me.accountId || '', name: me.name || '', displayName: me.displayName || '', emailAddress: me.emailAddress || '' };
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
+ }
239
350
  }
240
351
 
241
352
  router.get('/recently-created', (_req, res) => {
@@ -252,49 +363,54 @@ router.get('/recently-commented', async (_req, res) => {
252
363
  const headers = { Accept: 'application/json', Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}` };
253
364
  try {
254
365
  const identity = await getJiraIdentity(jiraHost, token);
255
- const jql = 'updated >= -60d AND (comment ~ currentUser() OR assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC';
256
- const searchUrl = `https://${jiraHost}/rest/api/2/search?jql=${encodeURIComponent(jql)}&maxResults=30&fields=summary,priority,status,updated,assignee,reporter`;
257
- const response = await httpClient.get(searchUrl, { headers });
258
- if (response.status < 200 || response.status >= 300) return res.json([]);
259
366
 
260
- const issues = response.data?.issues || [];
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);
261
382
  const results = [];
262
383
 
263
- for (const issue of issues.slice(0, 25)) {
384
+ const issueCommentsPromises = issues.slice(0, 30).map(async (issue) => {
264
385
  const key = issue.key;
265
386
  const fields = issue.fields || {};
266
- const commentsUrl = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(key)}/comment?orderBy=-updated&maxResults=10`;
267
- const commentsResponse = await httpClient.get(commentsUrl, { headers }).catch(() => null);
268
- const comments = commentsResponse?.status === 200 ? (commentsResponse.data?.comments || []) : [];
269
-
270
- if (comments.length > 0) {
271
- const myComments = comments.filter((c) => {
272
- const author = c.author || {};
273
- return (
274
- (identity.name && author.name === identity.name) ||
275
- (identity.accountId && author.accountId === identity.accountId) ||
276
- (identity.displayName && author.displayName === identity.displayName)
277
- );
278
- });
279
-
280
- if (myComments.length > 0) {
281
- const latestMyComment = myComments[0];
282
- const body = typeof latestMyComment.body === 'string' ? latestMyComment.body : JSON.stringify(latestMyComment.body || '');
283
- const commentSnippet = body.slice(0, 140).replace(/\r?\n+/g, ' ');
284
- const commentId = latestMyComment.id;
285
-
286
- results.push({
287
- id: `${issue.id}-${commentId || 'c'}`,
288
- key,
289
- summary: fields.summary || key,
290
- priority: fields.priority?.name || '',
291
- status: fields.status?.name || '',
292
- commentSnippet,
293
- commentedAt: latestMyComment.updated || latestMyComment.created || fields.updated,
294
- url: `https://${jiraHost}/browse/${key}${commentId ? `?focusedCommentId=${commentId}#comment-${commentId}` : ''}`
295
- });
296
- }
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
+ };
297
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);
298
414
  }
299
415
 
300
416
  results.sort((a, b) => new Date(b.commentedAt || 0).getTime() - new Date(a.commentedAt || 0).getTime());
@@ -315,46 +431,44 @@ router.get('/recently-mentioned', async (_req, res) => {
315
431
  const headers = { Accept: 'application/json', Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}` };
316
432
  try {
317
433
  const identity = await getJiraIdentity(jiraHost, token);
318
- let issues = [];
319
- try {
320
- const searchUrl = `https://${jiraHost}/rest/api/2/search?jql=${encodeURIComponent('text ~ currentUser() ORDER BY updated DESC')}&maxResults=30&fields=summary,priority,status,updated,assignee,reporter`;
321
- const response = await httpClient.get(searchUrl, { headers });
322
- if (response.status === 200) issues = response.data?.issues || [];
323
- } catch {}
324
434
 
325
- if (issues.length === 0) {
326
- try {
327
- const fallbackUrl = `https://${jiraHost}/rest/api/2/search?jql=${encodeURIComponent('updated >= -30d AND (assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC')}&maxResults=30&fields=summary,priority,status,updated,assignee,reporter`;
328
- const response = await httpClient.get(fallbackUrl, { headers });
329
- if (response.status === 200) issues = response.data?.issues || [];
330
- } catch {}
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`);
331
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');
332
449
 
450
+ const issues = await searchJiraWithFallbacks(jiraHost, headers, jqlCandidates, 'summary,priority,status,updated,assignee,reporter', 40);
333
451
  const results = [];
334
- for (const issue of issues.slice(0, 25)) {
452
+
453
+ const issueMentionsPromises = issues.slice(0, 30).map(async (issue) => {
335
454
  const key = issue.key;
336
455
  const fields = issue.fields || {};
337
- const commentsUrl = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(key)}/comment?orderBy=-updated&maxResults=10`;
338
- const commentsResponse = await httpClient.get(commentsUrl, { headers }).catch(() => null);
339
- const comments = commentsResponse?.status === 200 ? (commentsResponse.data?.comments || []) : [];
340
-
341
- const mentionComment = comments.find((c) => {
342
- const body = typeof c.body === 'string' ? c.body : JSON.stringify(c.body || '');
343
- const author = c.author || {};
344
- const isNotMe = author.name !== identity.name && author.accountId !== identity.accountId;
345
- const mentionsMe = [identity.name, identity.accountId, identity.displayName, identity.emailAddress]
346
- .filter(Boolean)
347
- .some((val) => body.toLowerCase().includes(val.toLowerCase()));
348
- return mentionsMe && isNotMe;
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);
349
461
  });
350
462
 
351
- if (mentionComment) {
352
- const author = mentionComment.author?.displayName || mentionComment.author?.name || '';
353
- const body = typeof mentionComment.body === 'string' ? mentionComment.body : JSON.stringify(mentionComment.body || '');
354
- const snippet = body.slice(0, 140).replace(/\r?\n+/g, ' ');
355
- const commentId = mentionComment.id;
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;
356
470
 
357
- results.push({
471
+ return {
358
472
  id: `${issue.id}-${commentId || 'm'}`,
359
473
  key,
360
474
  summary: fields.summary || key,
@@ -362,10 +476,16 @@ router.get('/recently-mentioned', async (_req, res) => {
362
476
  status: fields.status?.name || '',
363
477
  mentionedBy: author,
364
478
  mentionSnippet: snippet,
365
- mentionedAt: mentionComment.updated || mentionComment.created || fields.updated,
479
+ mentionedAt: latestMention.updated || latestMention.created || fields.updated,
366
480
  url: `https://${jiraHost}/browse/${key}${commentId ? `?focusedCommentId=${commentId}#comment-${commentId}` : ''}`
367
- });
481
+ };
368
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);
369
489
  }
370
490
 
371
491
  results.sort((a, b) => new Date(b.mentionedAt || 0).getTime() - new Date(a.mentionedAt || 0).getTime());
@@ -385,22 +505,14 @@ router.get('/recently-updated', async (_req, res) => {
385
505
 
386
506
  const headers = { Accept: 'application/json', Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}` };
387
507
  try {
388
- let issues = [];
389
- try {
390
- const jql = 'updatedBy(currentUser()) ORDER BY updated DESC';
391
- const searchUrl = `https://${jiraHost}/rest/api/2/search?jql=${encodeURIComponent(jql)}&maxResults=40&fields=summary,priority,status,updated,duedate,assignee,reporter`;
392
- const response = await httpClient.get(searchUrl, { headers });
393
- if (response.status === 200) issues = response.data?.issues || [];
394
- } catch {}
395
-
396
- if (issues.length === 0) {
397
- try {
398
- const fallbackJql = '(assignee = currentUser() OR reporter = currentUser()) AND updated >= -30d ORDER BY updated DESC';
399
- const searchUrl = `https://${jiraHost}/rest/api/2/search?jql=${encodeURIComponent(fallbackJql)}&maxResults=40&fields=summary,priority,status,updated,duedate,assignee,reporter`;
400
- const response = await httpClient.get(searchUrl, { headers });
401
- if (response.status === 200) issues = response.data?.issues || [];
402
- } catch {}
403
- }
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);
404
516
 
405
517
  const results = issues.map((issue) => {
406
518
  const fields = issue.fields || {};
@@ -83,22 +83,42 @@ async function loadJiraData(host, token) {
83
83
 
84
84
  // 2. Mentioned / active issues
85
85
  let activityIssues = [];
86
- try {
87
- activityIssues = await searchJira(host, token, 'text ~ currentUser() ORDER BY updated DESC', fields, 30);
88
- } catch {
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) {
89
101
  try {
90
- activityIssues = await searchJira(host, token, 'updated >= -30d AND (assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC', fields, 30);
91
- } catch {
92
- activityIssues = [];
93
- }
102
+ activityIssues = await searchJira(host, token, jql, fields, 30);
103
+ if (activityIssues.length > 0) break;
104
+ } catch {}
94
105
  }
95
106
 
96
107
  const activityItems = [];
97
108
  for (const issue of activityIssues.slice(0, 20)) {
98
109
  const key = issue.key;
99
- const commentsUrl = jiraUrl(host, `/issue/${encodeURIComponent(key)}/comment?orderBy=-updated&maxResults=10`);
110
+ const commentsUrl = jiraUrl(host, `/issue/${encodeURIComponent(key)}/comment?maxResults=50`);
100
111
  const commentsResponse = await httpClient.get(commentsUrl, { headers }).catch(() => null);
101
- const comments = commentsResponse && isSuccess(commentsResponse) ? (commentsResponse.data?.comments || []) : [];
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
+ }
121
+ }
102
122
 
103
123
  const mentionComment = comments.find((comment) => {
104
124
  const body = typeof comment.body === 'string' ? comment.body : JSON.stringify(comment.body || '');
@@ -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
  };