buddy-workbench 0.1.74 → 0.1.76

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.
@@ -1,12 +1,14 @@
1
1
  import https from 'node:https';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import axios from 'axios';
3
4
  import { Router } from 'express';
4
- import { readSettings, readPrReviewCustomRules, readPrReviewRules, savePrReviewRules } from '../repositories/settings.js';
5
+ import { readSettings, readPrReviewCustomRules, readPrReviewFileFilters, readPrReviewRules, savePrReviewRules } from '../repositories/settings.js';
5
6
  import { recordApiError } from '../lib/api-errors.js';
6
7
 
7
8
  const router = Router();
8
9
  const previewLimit = 2000;
9
10
  let lastUsedHost = '';
11
+ const aiReviewJobs = new Map();
10
12
 
11
13
  const PR_REVIEW_RULES = [
12
14
  { id: 'lodash-get', name: 'Lodash Get Required', description: 'Prefer lodash/get for nested object property access.', pattern: String.raw`\b([a-zA-Z_$][\w$]*)\s*(\?\.|\.)\s*([a-zA-Z_$][\w$]*)\b(?!\s*\()`, severity: 'warning' },
@@ -26,10 +28,10 @@ const PR_REVIEW_RULES = [
26
28
  { id: 'no-magic-numbers', name: 'No Magic Numbers', description: 'Define named constants for business-logic numbers.', pattern: String.raw`(===|==|!==|!=)\s*([2-9]|\d{2,})\b|\bsetTimeout\s*\([^,]+,\s*([2-9]|\d{2,})\)`, severity: 'info' }
27
29
  ];
28
30
 
29
- router.get('/rules', (_req, res) => res.json({ rules: readPrReviewRules(PR_REVIEW_RULES), customRules: readPrReviewCustomRules() }));
31
+ router.get('/rules', (_req, res) => res.json({ rules: readPrReviewRules(PR_REVIEW_RULES), customRules: readPrReviewCustomRules(), fileFilters: readPrReviewFileFilters() }));
30
32
  router.put('/rules', (req, res) => {
31
33
  if (!Array.isArray(req.body?.rules)) return res.status(400).json({ error: 'Rules must be an array.' });
32
- res.json(savePrReviewRules(req.body.rules, req.body.customRules, PR_REVIEW_RULES));
34
+ res.json(savePrReviewRules(req.body.rules, req.body.customRules, req.body.fileFilters, PR_REVIEW_RULES));
33
35
  });
34
36
 
35
37
  function getRuleSettings() {
@@ -53,6 +55,123 @@ const httpClient = axios.create({
53
55
  validateStatus: () => true
54
56
  });
55
57
 
58
+ const getChangePath = (change) => {
59
+ if (typeof change?.path === 'string') return change.path;
60
+ return change?.path?.toString || '';
61
+ };
62
+
63
+ async function fetchFullFileContent({ host, projectKey, repositorySlug, filePath, commit, headers }) {
64
+ if (!commit || !filePath) return null;
65
+ const url = `https://${host}/rest/api/latest/projects/${projectKey}/repos/${repositorySlug}/raw/${filePath}?at=${encodeURIComponent(commit)}`;
66
+ const response = await httpClient.get(url, { headers, responseType: 'text' });
67
+ if (response.status < 200 || response.status >= 300) {
68
+ recordApiError({
69
+ source: 'Bitbucket API (PR Review file content)',
70
+ method: 'GET',
71
+ url,
72
+ status: response.status,
73
+ message: `Bitbucket raw file API returned ${response.status}: ${response.statusText || 'Failed to retrieve file content'}`
74
+ });
75
+ return null;
76
+ }
77
+ return String(response.data ?? '');
78
+ }
79
+
80
+ function aiCompletionUrl(host) {
81
+ const normalizedHost = String(host || '').replace(/\/+$/, '');
82
+ return /\/chat\/completions$/i.test(normalizedHost) ? normalizedHost : `${normalizedHost}/chat/completions`;
83
+ }
84
+
85
+ function getAiMessageContent(data) {
86
+ const content = data?.choices?.[0]?.message?.content;
87
+ if (Array.isArray(content)) return content.map((part) => part?.text || '').join('');
88
+ return typeof content === 'string' ? content : '';
89
+ }
90
+
91
+ function parseAiReview(content) {
92
+ const candidate = String(content || '').replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
93
+ try {
94
+ const parsed = JSON.parse(candidate);
95
+ return {
96
+ summary: typeof parsed.summary === 'string' ? parsed.summary : '',
97
+ findings: Array.isArray(parsed.findings) ? parsed.findings
98
+ .filter((finding) => finding && typeof finding.file === 'string' && finding.file.trim())
99
+ .map((finding) => ({
100
+ file: finding.file.trim(),
101
+ line: finding.line == null || finding.line === '' || !Number.isFinite(Number(finding.line)) ? null : Number(finding.line),
102
+ severity: ['critical', 'warning', 'info'].includes(finding.severity) ? finding.severity : 'warning',
103
+ title: typeof finding.title === 'string' && finding.title.trim() ? finding.title.trim() : 'AI finding',
104
+ explanation: typeof finding.explanation === 'string' ? finding.explanation.trim() : '',
105
+ recommendation: typeof finding.recommendation === 'string' ? finding.recommendation.trim() : ''
106
+ })) : []
107
+ };
108
+ } catch {
109
+ return { summary: content, findings: [] };
110
+ }
111
+ }
112
+
113
+ async function runAiReview({ aiApiHost, pr, files }) {
114
+ if (!aiApiHost) return { configured: false, status: 'skipped' };
115
+ if (!files.length) return { configured: true, status: 'completed', reviewedFiles: 0, content: 'No eligible files were available for AI review.' };
116
+
117
+ const prompt = [
118
+ 'Review the complete contents of the changed files in this pull request.',
119
+ 'Focus on correctness, security, reliability, maintainability, and regressions introduced by the change.',
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
+ '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":"..."}]}',
123
+ 'Do not invent files, code, or line numbers. Use null for line when a precise line cannot be identified.',
124
+ '',
125
+ `Pull request: ${pr.title || ''}`,
126
+ `Description: ${pr.description || ''}`,
127
+ `Branches: ${pr.sourceBranch || ''} -> ${pr.targetBranch || ''}`,
128
+ '',
129
+ 'Changed files (complete current file contents):',
130
+ ...files.map((file) => [
131
+ `--- FILE: ${file.filePath} (${file.type || 'MODIFY'}) ---`,
132
+ file.content,
133
+ `--- END FILE: ${file.filePath} ---`
134
+ ].join('\n'))
135
+ ].join('\n');
136
+
137
+ try {
138
+ const response = await httpClient.post(aiCompletionUrl(aiApiHost), {
139
+ model: 'gpt-5.5',
140
+ messages: [
141
+ { role: 'system', content: 'You are a careful senior code reviewer. Be precise and evidence-based.' },
142
+ { role: 'user', content: prompt }
143
+ ]
144
+ }, {
145
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
146
+ timeout: 120000
147
+ });
148
+
149
+ if (response.status < 200 || response.status >= 300) {
150
+ const detail = response.data?.error?.message || response.statusText || `HTTP ${response.status}`;
151
+ recordApiError({ source: 'AI API (PR Review)', method: 'POST', url: aiCompletionUrl(aiApiHost), status: response.status, message: detail });
152
+ return { configured: true, status: 'error', reviewedFiles: files.length, error: `AI review failed: ${detail}` };
153
+ }
154
+
155
+ const content = getAiMessageContent(response.data);
156
+ if (!content.trim()) return { configured: true, status: 'error', reviewedFiles: files.length, error: 'AI review returned an empty response.' };
157
+ const parsed = parseAiReview(content);
158
+ return { configured: true, status: 'completed', reviewedFiles: files.length, content, ...parsed };
159
+ } catch (error) {
160
+ recordApiError({ source: 'AI API (PR Review)', method: 'POST', url: aiCompletionUrl(aiApiHost), message: error.message });
161
+ return { configured: true, status: 'error', reviewedFiles: files.length, error: `AI review failed: ${error.message}` };
162
+ }
163
+ }
164
+
165
+ function startAiReviewJob({ files, runner }) {
166
+ const jobId = randomUUID();
167
+ aiReviewJobs.set(jobId, { configured: true, status: 'loading', jobId, reviewedFiles: files.length });
168
+ Promise.resolve()
169
+ .then(runner)
170
+ .then((result) => aiReviewJobs.set(jobId, { ...result, jobId }))
171
+ .catch((error) => aiReviewJobs.set(jobId, { configured: true, status: 'error', jobId, reviewedFiles: files.length, error: `AI review failed: ${error.message}` }));
172
+ return { configured: true, status: 'loading', jobId, reviewedFiles: files.length };
173
+ }
174
+
56
175
  function getBitbucketHost() {
57
176
  if (lastUsedHost) return lastUsedHost;
58
177
  const settings = readSettings();
@@ -92,11 +211,280 @@ function parseBitbucketPrUrl(url) {
92
211
  return null;
93
212
  }
94
213
 
95
- const isFiltered = (filePath) => {
214
+ const globToRegExp = (pattern) => {
215
+ let source = '';
216
+ for (let index = 0; index < pattern.length; index += 1) {
217
+ const char = pattern[index];
218
+ if (char === '*' && pattern[index + 1] === '*') {
219
+ if (pattern[index + 2] === '/') {
220
+ index += 2;
221
+ source += '(?:.*\\/)?';
222
+ } else {
223
+ index += 1;
224
+ source += '.*';
225
+ }
226
+ } else if (char === '*') {
227
+ source += '[^/]*';
228
+ } else if (char === '?') {
229
+ source += '[^/]';
230
+ } else {
231
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
232
+ }
233
+ }
234
+ return new RegExp(`^${source}$`, 'i');
235
+ };
236
+
237
+ const matchesFileFilter = (filePath, pattern) => {
238
+ let normalizedPattern = String(pattern || '').trim().replaceAll('\\', '/');
239
+ if (!normalizedPattern || normalizedPattern.startsWith('#')) return false;
240
+ if (normalizedPattern.startsWith('!')) return false;
241
+ normalizedPattern = normalizedPattern.replace(/^\/+/, '');
242
+ const directoryPattern = normalizedPattern.endsWith('/');
243
+ normalizedPattern = normalizedPattern.replace(/\/+$/, '');
244
+ if (!normalizedPattern) return false;
245
+ if (directoryPattern) normalizedPattern += '/**';
246
+
247
+ const hasSlash = normalizedPattern.includes('/');
248
+ const expression = globToRegExp(normalizedPattern);
249
+ if (hasSlash) return expression.test(filePath);
250
+ return expression.test(filePath) || expression.test(filePath.split('/').pop());
251
+ };
252
+
253
+ const isFiltered = (filePath, fileFilters = []) => {
96
254
  const name = filePath.split('/').pop();
97
255
  if (name === 'bd.json' || name === 'package-lock.json') return true;
98
256
  if (/\.spec\.[^.]+$/.test(name) || /\.test\.[^.]+$/.test(name)) return true;
99
- return false;
257
+ return fileFilters.some((pattern) => matchesFileFilter(filePath, pattern));
258
+ };
259
+
260
+ const getDiffLineMetadata = (hunks) => (hunks || []).flatMap((hunk) => (hunk.segments || []).flatMap((segment) => (segment.lines || []).map((line) => ({
261
+ type: segment.type,
262
+ oldLine: line.source ?? line.sourceLine ?? null,
263
+ newLine: line.destination ?? line.destinationLine ?? null
264
+ }))));
265
+
266
+ const demoCodeTemplates = {
267
+ 'src/components/UserProfile.tsx': `import React, { useCallback, useMemo, useState } from 'react';
268
+ import { Card, Spinner, Text, Button } from '@acme/ui';
269
+ import { getUser, updateUser } from '../api/users';
270
+ import type { User, UserStats } from '../types';
271
+
272
+ type UserProfileProps = {
273
+ userId: string;
274
+ onSaved?: (user: User) => void;
275
+ };
276
+
277
+ const emptyStats: UserStats = { posts: 0, followers: 0, following: 0 };
278
+
279
+ export function UserProfile({ userId, onSaved }: UserProfileProps) {
280
+ const [user, setUser] = useState<User | null>(null);
281
+ const [stats, setStats] = useState<UserStats>(emptyStats);
282
+ const [loading, setLoading] = useState(true);
283
+ const [error, setError] = useState<string | null>(null);
284
+ const [isSaving, setIsSaving] = useState(false);
285
+
286
+ const userName = user.profile.name;
287
+ const displayName = useMemo(() => user?.profile?.name || 'Unknown user', [user]);
288
+ const canEdit = user?.permissions.includes('edit') === true;
289
+
290
+ const handleData = (payload: any): void => {
291
+ if (payload?.stats) setStats(payload.stats);
292
+ };
293
+
294
+ const title = "User Profile Settings";
295
+ const saveProfile = useCallback(async () => {
296
+ if (!user) return;
297
+ setIsSaving(true);
298
+ try {
299
+ const savedUser = await updateUser(user.id, user);
300
+ setUser(savedUser);
301
+ onSaved?.(savedUser);
302
+ } finally {
303
+ setIsSaving(false);
304
+ }
305
+ }, [onSaved, user]);
306
+
307
+ const loadProfile = useCallback(async () => {
308
+ setLoading(true);
309
+ setError(null);
310
+ try {
311
+ const response = await getUser(userId);
312
+ setUser(response.user);
313
+ handleData(response);
314
+ } catch (requestError) {
315
+ setError(requestError instanceof Error ? requestError.message : 'Unable to load user');
316
+ } finally {
317
+ setLoading(false);
318
+ }
319
+ }, [userId]);
320
+
321
+ React.useEffect(() => { void loadProfile(); }, [loadProfile]);
322
+
323
+ if (loading) return <Spinner label="Loading profile" />;
324
+ if (error) return <Text role="alert">{error}</Text>;
325
+ if (!user) return <Text>No profile found.</Text>;
326
+
327
+ const UserCard = () => <Card title={displayName}><Text>{user.email}</Text></Card>;
328
+ const userId = response!.data!.id;
329
+
330
+ return (
331
+ <Card title={title}>
332
+ <UserCard />
333
+ <Text>{stats.posts} posts · {stats.followers} followers</Text>
334
+ {canEdit && <Button loading={isSaving} onClick={saveProfile}>Save</Button>}
335
+ </Card>
336
+ );
337
+ }
338
+ `,
339
+ 'src/hooks/useUserStats.ts': `import { useCallback, useEffect, useState } from 'react';
340
+ import { getUserStats } from '../api/users';
341
+ import type { UserStats } from '../types';
342
+
343
+ type UseUserStatsResult = {
344
+ data: UserStats | null;
345
+ loading: boolean;
346
+ error: string | null;
347
+ refresh: () => Promise<void>;
348
+ };
349
+
350
+ const initialStats: UserStats = { posts: 0, followers: 0, following: 0 };
351
+
352
+ export function useUserStats(userId: string): UseUserStatsResult {
353
+ const [data, setData] = useState<UserStats | null>(initialStats);
354
+ const [loading, setLoading] = useState(false);
355
+ const [error, setError] = useState<string | null>(null);
356
+
357
+ const refresh = useCallback(async () => {
358
+ setLoading(true);
359
+ setError(null);
360
+ try {
361
+ const nextStats = await getUserStats(userId);
362
+ setData(nextStats);
363
+ } catch (requestError) {
364
+ setError(requestError instanceof Error ? requestError.message : 'Unable to load stats');
365
+ } finally {
366
+ setLoading(false);
367
+ }
368
+ }, [userId]);
369
+
370
+ useEffect(() => { void refresh(); }, [refresh]);
371
+
372
+ const memoizedValue = useMemo(() => calculateStats(data), []);
373
+ const addRecentItem = (newItem: string) => {
374
+ state.list.push(newItem);
375
+ setData((current) => current ? { ...current, recent: [...(current.recent || []), newItem] } : current);
376
+ };
377
+
378
+ return { data, loading, error, refresh };
379
+ }
380
+ `,
381
+ 'src/pages/Dashboard.tsx': `import React, { useCallback, useEffect, useMemo, useState } from 'react';
382
+ import { Button, Card, EmptyState, Page, Section, Spinner } from '@acme/ui';
383
+ import { fetchDashboard, saveDashboardPreferences } from '../api/dashboard';
384
+ import { UserCard } from '../components/UserCard';
385
+ import { ActivityList } from '../components/ActivityList';
386
+ import type { DashboardData } from '../types';
387
+
388
+ export default function Dashboard() {
389
+ const [data, setData] = useState<DashboardData | null>(null);
390
+ const [loading, setLoading] = useState(true);
391
+ const [saving, setSaving] = useState(false);
392
+ const [error, setError] = useState<string | null>(null);
393
+
394
+ const loadDashboard = useCallback(async () => {
395
+ setLoading(true);
396
+ try {
397
+ setData(await fetchDashboard());
398
+ } catch (requestError) {
399
+ setError(requestError instanceof Error ? requestError.message : 'Unable to load dashboard');
400
+ } finally {
401
+ setLoading(false);
402
+ }
403
+ }, []);
404
+
405
+ useEffect(() => { void loadDashboard(); }, [loadDashboard]);
406
+ const visibleItems = useMemo(() => data?.items || [], [data]);
407
+ const handleSave = async () => {
408
+ setSaving(true);
409
+ await saveDashboardPreferences({ compact: true });
410
+ setSaving(false);
411
+ };
412
+
413
+ if (loading) return <Page><Spinner label="Loading dashboard" /></Page>;
414
+ if (error) return <Page><EmptyState title={error} action={<Button onClick={loadDashboard}>Retry</Button>} /></Page>;
415
+ if (!data) return <Page><EmptyState title="No dashboard data" /></Page>;
416
+
417
+ return (
418
+ <Page title="Dashboard">
419
+ <Section>
420
+ <Card>
421
+ <UserCard user={data.user} />
422
+ <ActivityList items={visibleItems} />
423
+ </Card>
424
+ </Section>
425
+ <Section title="Preferences">
426
+ <div style={{ padding: "20px", backgroundColor: "#fff" }}>
427
+ <div className="btn-wrapper" onClick={handleSave}>Save User</div>
428
+ {data.items.map((item, index) => <ActivityList key={index} items={[item]} />)}
429
+ <button onClick={async () => fetchMore()}>Load More</button>
430
+ {saving && <Spinner label="Saving" />}
431
+ {data.userStatus === 5 && <Card>Needs attention</Card>}
432
+ </div>
433
+ </Section>
434
+ </Page>
435
+ );
436
+ }
437
+ `,
438
+ 'src/services/api.ts': `import type { DashboardData, User, UserStats } from '../types';
439
+
440
+ const API_BASE = '/api';
441
+
442
+ async function request<T>(path: string, options?: RequestInit): Promise<T> {
443
+ const response = await fetch(API_BASE + path, {
444
+ ...options,
445
+ headers: { Accept: 'application/json', ...options?.headers }
446
+ });
447
+ if (!response.ok) throw new Error('Request failed: ' + response.status);
448
+ return response.json() as Promise<T>;
449
+ }
450
+
451
+ export function getUser(userId: string) {
452
+ return request<{ user: User; stats: UserStats }>('/users/' + userId);
453
+ }
454
+
455
+ export function getDashboard() {
456
+ return request<DashboardData>('/dashboard');
457
+ }
458
+
459
+ export async function saveUser(user: User): Promise<User> {
460
+ return request<User>('/users/' + user.id, {
461
+ method: 'PUT',
462
+ headers: { 'Content-Type': 'application/json' },
463
+ body: JSON.stringify(user)
464
+ });
465
+ }
466
+
467
+ export function fetchMore() {
468
+ return request<{ items: string[] }>('/dashboard/items?limit=20');
469
+ }
470
+
471
+ export function logResponse(response: unknown) {
472
+ debugger;
473
+ console.log("Fetch user response:", response);
474
+ return response;
475
+ }
476
+ `
477
+ };
478
+
479
+ const buildDemoContent = (filePath, hunks) => {
480
+ const template = demoCodeTemplates[filePath] || '';
481
+ const lines = template.split('\n');
482
+ while (lines.length < 100) lines.push(`// ${filePath} continues with shared application code`);
483
+ for (const line of (hunks || []).flatMap((hunk) => (hunk.segments || []).flatMap((segment) => segment.lines || []))) {
484
+ const lineNumber = Number(line.destinationLine || line.destination || 0);
485
+ if (lineNumber > 0) lines[lineNumber - 1] = line.text || line.line || '';
486
+ }
487
+ return lines.join('\n');
100
488
  };
101
489
 
102
490
  const analyzeDiff = (filePath, hunks) => {
@@ -361,16 +749,26 @@ router.post('/check', async (req, res) => {
361
749
  ]
362
750
  };
363
751
 
752
+ const settings = readSettings();
753
+ const fileFilters = settings.prReviewFileFilters || [];
754
+ const filteredOut = ['package-lock.json'];
364
755
  const reviewFiles = [];
756
+ const aiFiles = [];
365
757
  let totalIssues = 0;
366
758
  let criticalCount = 0;
367
759
  let warningCount = 0;
368
760
  let infoCount = 0;
369
761
 
370
762
  for (const [filePath, hunks] of Object.entries(demoHunksMap)) {
763
+ if (isFiltered(filePath, fileFilters)) {
764
+ filteredOut.push(filePath);
765
+ continue;
766
+ }
371
767
  const fileIssues = analyzeDiff(filePath, hunks);
768
+ const content = buildDemoContent(filePath, hunks);
769
+ aiFiles.push({ filePath, type: 'MODIFY', content });
770
+ reviewFiles.push({ filePath, type: 'MODIFY', content, diffLines: getDiffLineMetadata(hunks), issues: fileIssues });
372
771
  if (fileIssues.length > 0) {
373
- reviewFiles.push({ filePath, type: 'MODIFY', issues: fileIssues });
374
772
  totalIssues += fileIssues.length;
375
773
  criticalCount += fileIssues.filter(i => i.severity === 'critical').length;
376
774
  warningCount += fileIssues.filter(i => i.severity === 'warning').length;
@@ -378,8 +776,7 @@ router.post('/check', async (req, res) => {
378
776
  }
379
777
  }
380
778
 
381
- return res.json({
382
- pr: {
779
+ const pr = {
383
780
  title: 'Demo: Feature User Management & Code Standard Check',
384
781
  description: 'Demo Pull Request showcasing all 9 PR Review Rules.',
385
782
  author: 'Developer Alex',
@@ -388,23 +785,52 @@ router.post('/check', async (req, res) => {
388
785
  projectKey: 'DEMO',
389
786
  repositorySlug: 'demo-repo',
390
787
  pullRequestId: '999'
391
- },
788
+ };
789
+ const demoAiFindings = [
790
+ { file: 'src/components/UserProfile.tsx', line: 22, severity: 'critical', title: 'Avoid the any type', explanation: 'The handler accepts any payload, which removes type safety at the boundary.', recommendation: 'Define a payload interface and use it as the handler parameter type.' },
791
+ { file: 'src/components/UserProfile.tsx', line: 52, severity: 'warning', title: 'Non-null assertion can hide runtime failures', explanation: 'The response and data objects may be missing when the request fails or returns an unexpected shape.', recommendation: 'Validate the response before reading the id, or use a typed result with an explicit error path.' },
792
+ { file: 'src/hooks/useUserStats.ts', line: 26, severity: 'warning', title: 'State is mutated directly', explanation: 'Mutating state.list in place can prevent consumers from observing the update and makes state changes harder to reason about.', recommendation: 'Create a new list and update state immutably.' },
793
+ { file: 'src/pages/Dashboard.tsx', line: 45, severity: 'warning', title: 'Inline styling reduces reuse', explanation: 'The component embeds presentation details directly in the view.', recommendation: 'Move the styles into a reusable class or styled component.' },
794
+ { file: 'src/pages/Dashboard.tsx', line: 52, severity: 'critical', title: 'Use a semantic interactive element', explanation: 'A div with onClick is not keyboard accessible by default.', recommendation: 'Use a button or an accessible link for this action.' },
795
+ { file: 'src/services/api.ts', line: 88, severity: 'critical', title: 'Remove debugger before merging', explanation: 'A debugger statement can pause execution for users with developer tools open.', recommendation: 'Remove the statement before shipping.' },
796
+ { file: 'src/services/api.ts', line: 95, severity: 'warning', title: 'Remove debug logging', explanation: 'Logging the full response may expose sensitive data and creates noisy production logs.', recommendation: 'Remove the log or gate sanitized diagnostics behind the application logger.' }
797
+ ].filter((finding) => aiFiles.some((file) => file.filePath === finding.file));
798
+ const aiReview = startAiReviewJob({
799
+ files: aiFiles,
800
+ runner: async () => {
801
+ await new Promise((resolve) => setTimeout(resolve, 900));
802
+ return {
803
+ configured: true,
804
+ status: 'completed',
805
+ source: 'demo',
806
+ 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
+ content: 'Demo AI review: found several actionable quality and accessibility issues.',
809
+ findings: demoAiFindings
810
+ };
811
+ }
812
+ });
813
+
814
+ return res.json({
815
+ pr,
392
816
  stats: {
393
817
  totalFilesChanged: 5,
394
- filteredCount: 1,
395
- reviewedCount: 4,
818
+ filteredCount: filteredOut.length,
819
+ reviewedCount: 5 - filteredOut.length,
396
820
  totalIssues,
397
821
  criticalCount,
398
822
  warningCount,
399
823
  infoCount
400
824
  },
401
- filteredOut: ['package-lock.json'],
402
- files: reviewFiles
825
+ filteredOut,
826
+ files: reviewFiles,
827
+ aiReview
403
828
  });
404
829
  }
405
830
 
406
- const settings = readSettings();
407
- const token = settings.bitbucketAccessToken;
831
+ const settings = readSettings();
832
+ const token = settings.bitbucketAccessToken;
833
+ const fileFilters = settings.prReviewFileFilters || [];
408
834
 
409
835
  const headers = { 'Accept': 'application/json' };
410
836
  if (token) headers['Authorization'] = `Bearer ${token}`;
@@ -434,8 +860,8 @@ router.post('/check', async (req, res) => {
434
860
  const targetChanges = [];
435
861
 
436
862
  for (const change of allChanges) {
437
- const filePath = change.path.toString;
438
- if (isFiltered(filePath)) {
863
+ const filePath = getChangePath(change);
864
+ if (isFiltered(filePath, fileFilters)) {
439
865
  filteredOut.push(filePath);
440
866
  } else {
441
867
  targetChanges.push(change);
@@ -444,6 +870,7 @@ router.post('/check', async (req, res) => {
444
870
 
445
871
  // 3. Fetch diffs and run rule-based checks
446
872
  const reviewFiles = [];
873
+ const aiFiles = [];
447
874
  let totalIssues = 0;
448
875
  let criticalCount = 0;
449
876
  let warningCount = 0;
@@ -453,7 +880,7 @@ router.post('/check', async (req, res) => {
453
880
  const sinceCommit = prInfo.toRef?.latestCommit || prInfo.toRef?.id;
454
881
 
455
882
  for (const change of targetChanges) {
456
- const filePath = change.path.toString;
883
+ const filePath = getChangePath(change);
457
884
  let diffUrl = `https://${host}/rest/api/latest/projects/${projectKey}/repos/${repositorySlug}/commits/${encodeURIComponent(untilCommit)}/diff/${filePath}?contextLines=10000&whitespace=ignore-all&withComments=false`;
458
885
  if (sinceCommit) diffUrl += `&since=${encodeURIComponent(sinceCommit)}`;
459
886
 
@@ -471,13 +898,12 @@ router.post('/check', async (req, res) => {
471
898
  }
472
899
 
473
900
  const fileIssues = analyzeDiff(filePath, hunks || []);
901
+ const contentCommit = change.type === 'DELETE' ? sinceCommit : untilCommit;
902
+ const content = await fetchFullFileContent({ host, projectKey, repositorySlug, filePath, commit: contentCommit, headers });
903
+ if (content !== null) aiFiles.push({ filePath, type: change.type, content });
474
904
 
905
+ reviewFiles.push({ filePath, type: change.type, content: content || '', diffLines: getDiffLineMetadata(hunks), issues: fileIssues });
475
906
  if (fileIssues.length > 0) {
476
- reviewFiles.push({
477
- filePath,
478
- type: change.type,
479
- issues: fileIssues
480
- });
481
907
  totalIssues += fileIssues.length;
482
908
  criticalCount += fileIssues.filter(i => i.severity === 'critical').length;
483
909
  warningCount += fileIssues.filter(i => i.severity === 'warning').length;
@@ -485,8 +911,7 @@ router.post('/check', async (req, res) => {
485
911
  }
486
912
  }
487
913
 
488
- res.json({
489
- pr: {
914
+ const pr = {
490
915
  title: prInfo.title,
491
916
  description: prInfo.description,
492
917
  author: prInfo.author?.user?.displayName || 'Unknown',
@@ -495,7 +920,13 @@ router.post('/check', async (req, res) => {
495
920
  projectKey,
496
921
  repositorySlug,
497
922
  pullRequestId
498
- },
923
+ };
924
+ const aiReview = settings.aiApiHost
925
+ ? startAiReviewJob({ files: aiFiles, runner: () => runAiReview({ aiApiHost: settings.aiApiHost, pr, files: aiFiles }) })
926
+ : { configured: false, status: 'skipped' };
927
+
928
+ res.json({
929
+ pr,
499
930
  stats: {
500
931
  totalFilesChanged: allChanges.length,
501
932
  filteredCount: filteredOut.length,
@@ -506,7 +937,8 @@ router.post('/check', async (req, res) => {
506
937
  infoCount
507
938
  },
508
939
  filteredOut,
509
- files: reviewFiles
940
+ files: reviewFiles,
941
+ aiReview
510
942
  });
511
943
 
512
944
  } catch (error) {
@@ -514,6 +946,13 @@ router.post('/check', async (req, res) => {
514
946
  }
515
947
  });
516
948
 
949
+ router.get('/ai-review/:jobId', (req, res) => {
950
+ const job = aiReviewJobs.get(req.params.jobId);
951
+ if (!job) return res.status(404).json({ error: 'AI review job was not found.' });
952
+ res.json(job);
953
+ if (job.status !== 'loading') aiReviewJobs.delete(req.params.jobId);
954
+ });
955
+
517
956
  function enrichPrList(values, host) {
518
957
  return values.map(pr => {
519
958
  let href = '';
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import { Router } from 'express';
3
3
  import { paths } from '../config.js';
4
- import { saveAccessToken, saveClipboardDeduplicateMinutes, saveClipboardEnabled, saveClipboardImageEnabled, saveDefaultBrowser, saveDefaultEditor, saveDomain, saveJiraIssuePrefix, saveTheme, settingsStatus } from '../repositories/settings.js';
4
+ import { saveAccessToken, saveAiApiHost, saveClipboardDeduplicateMinutes, saveClipboardEnabled, saveClipboardImageEnabled, saveDefaultBrowser, saveDefaultEditor, saveDomain, saveJiraIssuePrefix, saveTheme, settingsStatus } from '../repositories/settings.js';
5
5
  import { getNpmPackageVersions, installNvmVersion, manageGlobalNpmPackage, readDevConfigurations, readNvmConfiguration, saveDevConfigurations, searchNpmPackages, useSuggestedNpmPrefix } from '../services/dev-configurations.js';
6
6
  import { openInSystemBrowser, openInSystemEditor, openInSystemFolder } from '../services/browser.js';
7
7
  import { selectDirectory, selectFile } from '../services/dialog.js';
@@ -42,6 +42,20 @@ router.put('/domain', (req, res) => {
42
42
  if (typeof domain !== 'string') return res.status(400).json({ error: 'Domain must be a string.' });
43
43
  res.json(saveDomain(domain.trim()));
44
44
  });
45
+ router.put('/ai-api-host', (req, res) => {
46
+ const { aiApiHost } = req.body || {};
47
+ if (typeof aiApiHost !== 'string') return res.status(400).json({ error: 'AI API host must be a string.' });
48
+ const normalizedHost = aiApiHost.trim().replace(/\/+$/, '');
49
+ if (normalizedHost) {
50
+ try {
51
+ const url = new URL(normalizedHost);
52
+ if (!['http:', 'https:'].includes(url.protocol) || !url.host) throw new Error();
53
+ } catch {
54
+ return res.status(400).json({ error: 'AI API host must be a valid HTTP(S) URL.' });
55
+ }
56
+ }
57
+ res.json(saveAiApiHost(normalizedHost));
58
+ });
45
59
  router.put('/jira-issue-prefix', (req, res) => {
46
60
  const { jiraIssuePrefix } = req.body || {};
47
61
  if (typeof jiraIssuePrefix !== 'string') return res.status(400).json({ error: 'Jira issue prefix must be a string.' });