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.
- package/package.json +1 -1
- package/server/repositories/settings.js +22 -2
- package/server/routes/branch-sync.js +1 -1
- package/server/routes/pr-conflicts.js +54 -5
- package/server/routes/pr-review.js +466 -27
- package/server/routes/settings.js +15 -1
- package/ui/dist/assets/index-CIElY9EY.css +1 -0
- package/ui/dist/assets/{index-BqVFfTXR.js → index-Ci4SjAkd.js} +181 -168
- package/ui/dist/index.html +2 -2
- package/ui/dist/sw.js +2 -2
- package/ui/dist/assets/index-CAdLVTJk.css +0 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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:
|
|
395
|
-
reviewedCount:
|
|
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
|
|
402
|
-
files: reviewFiles
|
|
825
|
+
filteredOut,
|
|
826
|
+
files: reviewFiles,
|
|
827
|
+
aiReview
|
|
403
828
|
});
|
|
404
829
|
}
|
|
405
830
|
|
|
406
|
-
|
|
407
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.' });
|