buddy-workbench 0.1.75 → 0.1.77

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.75",
3
+ "version": "0.1.77",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -230,10 +230,199 @@ 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
+ 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 || '' };
239
+ }
240
+
233
241
  router.get('/recently-created', (_req, res) => {
234
242
  res.json(listRecentlyCreatedJiraIssues());
235
243
  });
236
244
 
245
+ router.get('/recently-commented', async (_req, res) => {
246
+ const jiraHost = getJiraHost();
247
+ if (!jiraHost) return res.json([]);
248
+ const settings = readSettings();
249
+ const token = settings.jiraAccessToken;
250
+ if (!token) return res.json([]);
251
+
252
+ const headers = { Accept: 'application/json', Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}` };
253
+ try {
254
+ 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
+
260
+ const issues = response.data?.issues || [];
261
+ const results = [];
262
+
263
+ for (const issue of issues.slice(0, 25)) {
264
+ const key = issue.key;
265
+ 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
+ }
297
+ }
298
+ }
299
+
300
+ results.sort((a, b) => new Date(b.commentedAt || 0).getTime() - new Date(a.commentedAt || 0).getTime());
301
+ res.json(results);
302
+ } catch (error) {
303
+ recordApiError({ source: 'Jira (Recently Commented)', message: error.message });
304
+ res.json([]);
305
+ }
306
+ });
307
+
308
+ router.get('/recently-mentioned', async (_req, res) => {
309
+ const jiraHost = getJiraHost();
310
+ if (!jiraHost) return res.json([]);
311
+ const settings = readSettings();
312
+ const token = settings.jiraAccessToken;
313
+ if (!token) return res.json([]);
314
+
315
+ const headers = { Accept: 'application/json', Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}` };
316
+ try {
317
+ 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
+
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 {}
331
+ }
332
+
333
+ const results = [];
334
+ for (const issue of issues.slice(0, 25)) {
335
+ const key = issue.key;
336
+ 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;
349
+ });
350
+
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;
356
+
357
+ results.push({
358
+ id: `${issue.id}-${commentId || 'm'}`,
359
+ key,
360
+ summary: fields.summary || key,
361
+ priority: fields.priority?.name || '',
362
+ status: fields.status?.name || '',
363
+ mentionedBy: author,
364
+ mentionSnippet: snippet,
365
+ mentionedAt: mentionComment.updated || mentionComment.created || fields.updated,
366
+ url: `https://${jiraHost}/browse/${key}${commentId ? `?focusedCommentId=${commentId}#comment-${commentId}` : ''}`
367
+ });
368
+ }
369
+ }
370
+
371
+ results.sort((a, b) => new Date(b.mentionedAt || 0).getTime() - new Date(a.mentionedAt || 0).getTime());
372
+ res.json(results);
373
+ } catch (error) {
374
+ recordApiError({ source: 'Jira (Recently Mentioned)', message: error.message });
375
+ res.json([]);
376
+ }
377
+ });
378
+
379
+ router.get('/recently-updated', async (_req, res) => {
380
+ const jiraHost = getJiraHost();
381
+ if (!jiraHost) return res.json([]);
382
+ const settings = readSettings();
383
+ const token = settings.jiraAccessToken;
384
+ if (!token) return res.json([]);
385
+
386
+ const headers = { Accept: 'application/json', Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}` };
387
+ 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
+ }
404
+
405
+ const results = issues.map((issue) => {
406
+ const fields = issue.fields || {};
407
+ return {
408
+ id: issue.id,
409
+ key: issue.key,
410
+ summary: fields.summary || issue.key,
411
+ priority: fields.priority?.name || '',
412
+ status: fields.status?.name || '',
413
+ updatedAt: fields.updated || null,
414
+ dueDate: fields.duedate || null,
415
+ url: `https://${jiraHost}/browse/${issue.key}`
416
+ };
417
+ });
418
+
419
+ res.json(results);
420
+ } catch (error) {
421
+ recordApiError({ source: 'Jira (Recently Updated)', message: error.message });
422
+ res.json([]);
423
+ }
424
+ });
425
+
237
426
  // Create filter
238
427
  router.post('/', (req, res) => {
239
428
  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,128 @@ 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
+ try {
87
+ activityIssues = await searchJira(host, token, 'text ~ currentUser() ORDER BY updated DESC', fields, 30);
88
+ } catch {
89
+ 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
+ }
94
+ }
77
95
 
78
96
  const activityItems = [];
79
- for (const issue of activity.slice(0, 15)) {
97
+ for (const issue of activityIssues.slice(0, 20)) {
80
98
  const key = issue.key;
81
99
  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;
86
- }
87
- const comments = commentsResponse.data?.comments || [];
88
- const relevant = comments.find((comment) => {
89
- const author = comment.author || {};
100
+ const commentsResponse = await httpClient.get(commentsUrl, { headers }).catch(() => null);
101
+ const comments = commentsResponse && isSuccess(commentsResponse) ? (commentsResponse.data?.comments || []) : [];
102
+
103
+ const mentionComment = comments.find((comment) => {
90
104
  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;
105
+ const author = comment.author || {};
106
+ const mentionsMe = [identity.name, identity.accountId, identity.displayName, identity.emailAddress]
107
+ .filter(Boolean)
108
+ .some((value) => body.toLowerCase().includes(value.toLowerCase()));
109
+ const isNotSelf = author.name !== identity.name && author.accountId !== identity.accountId;
110
+ return mentionsMe && isNotSelf;
111
+ });
112
+
113
+ const relevantComment = mentionComment || comments.find((comment) => {
114
+ const author = comment.author || {};
115
+ return author.name !== identity.name && author.accountId !== identity.accountId;
116
+ });
117
+
118
+ const isMention = Boolean(mentionComment);
119
+ const authorName = relevantComment?.author?.displayName || relevantComment?.author?.name || issue.fields?.reporter?.displayName || '';
120
+ const date = relevantComment?.updated || relevantComment?.created || issue.fields?.updated;
121
+ const commentId = relevantComment?.id;
122
+ const itemUrl = `https://${host}/browse/${issue.key}${commentId ? `?focusedCommentId=${commentId}#comment-${commentId}` : ''}`;
123
+
124
+ activityItems.push({
125
+ ...mapJiraIssue(issue),
126
+ source: 'Jira',
127
+ sourceType: 'jira',
128
+ author: authorName,
129
+ activityType: isMention ? 'Mentioned in Jira' : 'Commented in Jira',
130
+ activityAt: date,
131
+ url: itemUrl
94
132
  });
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
133
  }
103
134
 
104
135
  return {
105
136
  identity,
106
137
  dueToday: due.map((issue) => ({ ...mapJiraIssue(issue), url: issueUrl(host, issue.key) })),
107
- activity: activityItems.map((item) => ({ ...item, url: issueUrl(host, item.key) }))
138
+ activity: activityItems
108
139
  };
109
140
  }
110
141
 
142
+ async function loadConfluenceMentions(host, token) {
143
+ if (!host || !token) return [];
144
+ const authHeader = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
145
+ const headers = { Accept: 'application/json', Authorization: authHeader };
146
+
147
+ const cqlQueries = [
148
+ 'mention = currentUser() order by lastmodified desc',
149
+ 'type in (page, blogpost, comment) and text ~ currentUser() order by lastmodified desc'
150
+ ];
151
+
152
+ let results = [];
153
+ for (const cql of cqlQueries) {
154
+ const url = `https://${host}/rest/api/content/search?cql=${encodeURIComponent(cql)}&expand=history.lastUpdated,version,space,container&limit=25`;
155
+ try {
156
+ const response = await httpClient.get(url, { headers });
157
+ if (isSuccess(response) && Array.isArray(response.data?.results) && response.data.results.length > 0) {
158
+ results = response.data.results;
159
+ break;
160
+ }
161
+ } catch {}
162
+ }
163
+
164
+ return results.map((item) => {
165
+ const spaceKey = item.space?.key || item.space?.name || 'Confluence';
166
+ const containerTitle = item.container?.title || '';
167
+ const itemTitle = item.title || (item.type === 'comment' ? 'Comment' : 'Page');
168
+ const displayTitle = containerTitle ? `${containerTitle} (${itemTitle})` : itemTitle;
169
+ const authorName = item.history?.lastUpdated?.by?.displayName || item.version?.by?.displayName || item.history?.createdBy?.displayName || '';
170
+ const date = item.history?.lastUpdated?.when || item.version?.when || item.history?.createdDate;
171
+ const webui = item._links?.webui || '';
172
+ const fullUrl = webui ? (webui.startsWith('http') ? webui : `https://${host}${webui}`) : `https://${host}/pages/viewpage.action?pageId=${item.id}`;
173
+
174
+ return {
175
+ id: `confluence-${item.id}`,
176
+ key: spaceKey,
177
+ title: displayTitle,
178
+ summary: displayTitle,
179
+ source: 'Confluence',
180
+ sourceType: 'confluence',
181
+ author: authorName,
182
+ activityType: 'Mentioned in Confluence',
183
+ activityAt: date ? new Date(date).toISOString() : null,
184
+ updated: date ? new Date(date).toISOString() : null,
185
+ url: fullUrl
186
+ };
187
+ });
188
+ }
189
+
111
190
  async function loadAuthoredPullRequests(host, token) {
191
+ const authHeader = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
112
192
  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}` } });
193
+ const response = await httpClient.get(url, { headers: { Accept: 'application/json', Authorization: authHeader } });
114
194
  if (!isSuccess(response)) throw new Error(`Bitbucket author pull requests failed (${response.status}): ${apiMessage(response, 'Request failed')}`);
115
195
  return (response.data?.values || []).map((pr) => ({
116
196
  id: pr.id,
@@ -130,21 +210,35 @@ router.get('/', async (_req, res) => {
130
210
  }
131
211
  const jira = jiraHost(settings.domain);
132
212
  const bitbucket = bitbucketHost(settings.domain);
213
+ const confluence = confluenceHost(settings.domain);
214
+ const confluenceToken = settings.confluenceAccessToken || settings.jiraAccessToken;
215
+
133
216
  if (!jira || !bitbucket) return res.json({ configured: false, missingTokens: ['domain'], fetchedAt: new Date().toISOString(), categories: {} });
134
217
 
135
218
  const sections = await Promise.all([
136
219
  requestSection('Jira', () => loadJiraData(jira, settings.jiraAccessToken)),
137
- requestSection('Bitbucket Author', () => loadAuthoredPullRequests(bitbucket, settings.bitbucketAccessToken))
220
+ requestSection('Bitbucket Author', () => loadAuthoredPullRequests(bitbucket, settings.bitbucketAccessToken)),
221
+ requestSection('Confluence Mentions', () => loadConfluenceMentions(confluence, confluenceToken))
138
222
  ]);
139
- const [jiraData, authoredPrs] = sections;
223
+ const [jiraData, authoredPrs, confluenceData] = sections;
140
224
  const todos = listTodos().filter((todo) => !todo.completed && !todo.archived).map((todo) => ({ ...todo, url: '#/todo-list' }));
141
225
  const errors = sections.filter((section) => section.error).map((section) => section.error);
226
+
227
+ const allActivity = [
228
+ ...(jiraData.items?.activity || []),
229
+ ...(confluenceData.items || [])
230
+ ].sort((a, b) => {
231
+ const timeA = new Date(a.activityAt || a.updated || 0).getTime();
232
+ const timeB = new Date(b.activityAt || b.updated || 0).getTime();
233
+ return timeB - timeA;
234
+ });
235
+
142
236
  res.json({
143
237
  configured: true,
144
238
  missingTokens: [],
145
239
  fetchedAt: new Date().toISOString(),
146
240
  categories: {
147
- activity: jiraData.items?.activity || [],
241
+ activity: allActivity,
148
242
  jiraDueToday: jiraData.items?.dueToday || [],
149
243
  assignedTodos: todos,
150
244
  authoredPrs: authoredPrs.items || []
@@ -118,13 +118,62 @@ function pathOf(change) {
118
118
  return change?.path?.toString || change?.path?.name || change?.path || change?.src?.toString || '';
119
119
  }
120
120
 
121
+ function extractTextFromContent(data) {
122
+ if (data === null || data === undefined) return '';
123
+ if (typeof data === 'object') {
124
+ if (Array.isArray(data.lines)) {
125
+ return data.lines.map((line) => {
126
+ if (typeof line === 'string') return line;
127
+ if (line && typeof line.text === 'string') return line.text;
128
+ return typeof line === 'object' && line !== null ? (line.text ?? JSON.stringify(line)) : String(line ?? '');
129
+ }).join('\n');
130
+ }
131
+ if (typeof data.text === 'string') return data.text;
132
+ return JSON.stringify(data);
133
+ }
134
+ if (typeof data === 'string') {
135
+ const trimmed = data.trim();
136
+ if (trimmed.startsWith('{') && (trimmed.includes('"lines"') || trimmed.includes('"text"'))) {
137
+ try {
138
+ const parsed = JSON.parse(trimmed);
139
+ if (Array.isArray(parsed?.lines)) {
140
+ return parsed.lines.map((line) => {
141
+ if (typeof line === 'string') return line;
142
+ if (line && typeof line.text === 'string') return line.text;
143
+ return typeof line === 'object' && line !== null ? (line.text ?? JSON.stringify(line)) : String(line ?? '');
144
+ }).join('\n');
145
+ }
146
+ if (typeof parsed?.text === 'string') return parsed.text;
147
+ } catch {}
148
+ }
149
+ return data;
150
+ }
151
+ return String(data);
152
+ }
153
+
121
154
  async function readFile({ host, projectKey, repositorySlug, commit, path }) {
122
155
  if (!commit || !path) return '';
123
- const url = `https://${host}/rest/api/latest/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repositorySlug)}/browse/${path.split('/').map(encodeURIComponent).join('/')}?at=${encodeURIComponent(commit)}&raw=true`;
124
- const response = await client.get(url, { headers: authHeaders(), responseType: 'text' });
125
- if (response.status < 200 || response.status >= 300) return '';
126
- if (typeof response.data === 'string') return response.data;
127
- return (response.data.lines || []).map((line) => line.text ?? line).join('\n');
156
+ const encodedPath = path.split('/').map(encodeURIComponent).join('/');
157
+
158
+ // 1. Try raw endpoint first (returns pure plaintext)
159
+ const rawUrl = `https://${host}/rest/api/latest/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repositorySlug)}/raw/${encodedPath}?at=${encodeURIComponent(commit)}`;
160
+ try {
161
+ const rawRes = await client.get(rawUrl, { headers: authHeaders(), responseType: 'text' });
162
+ if (rawRes.status >= 200 && rawRes.status < 300 && rawRes.data !== undefined) {
163
+ return extractTextFromContent(rawRes.data);
164
+ }
165
+ } catch {}
166
+
167
+ // 2. Fallback to browse endpoint with limit=10000
168
+ const browseUrl = `https://${host}/rest/api/latest/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repositorySlug)}/browse/${encodedPath}?at=${encodeURIComponent(commit)}&limit=10000`;
169
+ try {
170
+ const response = await client.get(browseUrl, { headers: authHeaders(), responseType: 'text' });
171
+ if (response.status >= 200 && response.status < 300 && response.data !== undefined) {
172
+ return extractTextFromContent(response.data);
173
+ }
174
+ } catch {}
175
+
176
+ return '';
128
177
  }
129
178
 
130
179
  router.post('/load', async (req, res) => {