@in-the-loop-labs/pair-review 1.6.2 → 2.0.1
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/README.md +77 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +4 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
- package/public/css/pr.css +1962 -114
- package/public/js/CONVENTIONS.md +16 -0
- package/public/js/components/AIPanel.js +66 -0
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +2955 -0
- package/public/js/components/CouncilProgressModal.js +12 -16
- package/public/js/components/KeyboardShortcuts.js +3 -0
- package/public/js/components/PanelGroup.js +723 -0
- package/public/js/components/PreviewModal.js +3 -8
- package/public/js/index.js +8 -0
- package/public/js/local.js +17 -615
- package/public/js/modules/analysis-history.js +19 -68
- package/public/js/modules/comment-manager.js +103 -20
- package/public/js/modules/diff-context.js +176 -0
- package/public/js/modules/diff-renderer.js +30 -0
- package/public/js/modules/file-comment-manager.js +126 -105
- package/public/js/modules/file-list-merger.js +64 -0
- package/public/js/modules/panel-resizer.js +25 -6
- package/public/js/modules/suggestion-manager.js +40 -125
- package/public/js/pr.js +1009 -159
- package/public/js/repo-settings.js +36 -6
- package/public/js/utils/category-emoji.js +44 -0
- package/public/js/utils/time.js +32 -0
- package/public/local.html +107 -70
- package/public/pr.html +107 -70
- package/public/repo-settings.html +32 -0
- package/src/ai/analyzer.js +5 -1
- package/src/ai/copilot-provider.js +39 -9
- package/src/ai/cursor-agent-provider.js +45 -11
- package/src/ai/gemini-provider.js +17 -4
- package/src/ai/prompts/config.js +7 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +25 -37
- package/src/chat/CONVENTIONS.md +18 -0
- package/src/chat/pi-bridge.js +491 -0
- package/src/chat/prompt-builder.js +272 -0
- package/src/chat/session-manager.js +619 -0
- package/src/config.js +14 -0
- package/src/database.js +322 -15
- package/src/main.js +4 -17
- package/src/routes/analyses.js +721 -0
- package/src/routes/chat.js +655 -0
- package/src/routes/config.js +29 -8
- package/src/routes/context-files.js +274 -0
- package/src/routes/local.js +225 -1133
- package/src/routes/mcp.js +39 -30
- package/src/routes/pr.js +424 -58
- package/src/routes/reviews.js +1035 -0
- package/src/routes/shared.js +4 -29
- package/src/server.js +34 -12
- package/src/sse/review-events.js +46 -0
- package/src/utils/auto-context.js +88 -0
- package/src/utils/category-emoji.js +33 -0
- package/src/utils/diff-annotator.js +75 -1
- package/src/utils/diff-file-list.js +57 -0
- package/src/routes/analysis.js +0 -1600
- package/src/routes/comments.js +0 -534
package/src/routes/shared.js
CHANGED
|
@@ -23,8 +23,9 @@ class CancellationError extends Error {
|
|
|
23
23
|
// Store active analysis runs in memory for status tracking
|
|
24
24
|
const activeAnalyses = new Map();
|
|
25
25
|
|
|
26
|
-
// Store mapping of
|
|
27
|
-
|
|
26
|
+
// Store mapping of integer reviewId to analysis UUID for tracking.
|
|
27
|
+
// Unified map: replaces the previous separate prToAnalysisId and localReviewToAnalysisId maps.
|
|
28
|
+
const reviewToAnalysisId = new Map();
|
|
28
29
|
|
|
29
30
|
// Store SSE clients for real-time progress updates
|
|
30
31
|
const progressClients = new Map();
|
|
@@ -37,9 +38,6 @@ const localReviewDiffs = new Map();
|
|
|
37
38
|
// Maps analysisId -> Set of ChildProcess objects
|
|
38
39
|
const activeProcesses = new Map();
|
|
39
40
|
|
|
40
|
-
// Store mapping of local review key to analysis ID for tracking
|
|
41
|
-
const localReviewToAnalysisId = new Map();
|
|
42
|
-
|
|
43
41
|
// Store active review setup operations (concurrency guard)
|
|
44
42
|
// Maps setupKey (e.g., "pr:owner/repo/123" or "local:/path") -> { setupId, promise }
|
|
45
43
|
const activeSetups = new Map();
|
|
@@ -48,26 +46,6 @@ const activeSetups = new Map();
|
|
|
48
46
|
// Maps setupId -> Set of response objects
|
|
49
47
|
const setupProgressClients = new Map();
|
|
50
48
|
|
|
51
|
-
/**
|
|
52
|
-
* Generate a consistent PR key for mapping
|
|
53
|
-
* @param {string} owner - Repository owner
|
|
54
|
-
* @param {string} repo - Repository name
|
|
55
|
-
* @param {number} prNumber - Pull request number
|
|
56
|
-
* @returns {string} PR key in format "owner/repo/number"
|
|
57
|
-
*/
|
|
58
|
-
function getPRKey(owner, repo, prNumber) {
|
|
59
|
-
return `${owner}/${repo}/${prNumber}`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Generate a consistent key for local review mapping
|
|
64
|
-
* @param {number} reviewId - Local review ID
|
|
65
|
-
* @returns {string} Review key
|
|
66
|
-
*/
|
|
67
|
-
function getLocalReviewKey(reviewId) {
|
|
68
|
-
return `local/${reviewId}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
49
|
/**
|
|
72
50
|
* Get the model to use for AI analysis
|
|
73
51
|
* Priority: CLI flag (PAIR_REVIEW_MODEL env var) > config.default_model > 'opus' default
|
|
@@ -447,15 +425,12 @@ function parseEnabledLevels(requestEnabledLevels, skipLevel3 = false) {
|
|
|
447
425
|
module.exports = {
|
|
448
426
|
CancellationError,
|
|
449
427
|
activeAnalyses,
|
|
450
|
-
|
|
451
|
-
localReviewToAnalysisId,
|
|
428
|
+
reviewToAnalysisId,
|
|
452
429
|
progressClients,
|
|
453
430
|
localReviewDiffs,
|
|
454
431
|
activeProcesses,
|
|
455
432
|
activeSetups,
|
|
456
433
|
setupProgressClients,
|
|
457
|
-
getPRKey,
|
|
458
|
-
getLocalReviewKey,
|
|
459
434
|
getModel,
|
|
460
435
|
determineCompletionInfo,
|
|
461
436
|
broadcastProgress,
|
package/src/server.js
CHANGED
|
@@ -9,6 +9,7 @@ const logger = require('./utils/logger');
|
|
|
9
9
|
|
|
10
10
|
let db = null;
|
|
11
11
|
let server = null;
|
|
12
|
+
let chatSessionManager = null;
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Request logging middleware (disabled for cleaner output)
|
|
@@ -248,20 +249,29 @@ async function startServer(sharedDb = null) {
|
|
|
248
249
|
// API routes - split into focused modules
|
|
249
250
|
// Order matters: more specific routes must be mounted before general ones
|
|
250
251
|
// to ensure proper route matching
|
|
251
|
-
const analysisRoutes = require('./routes/
|
|
252
|
+
const analysisRoutes = require('./routes/analyses');
|
|
252
253
|
const worktreesRoutes = require('./routes/worktrees');
|
|
253
|
-
const
|
|
254
|
+
const reviewsRoutes = require('./routes/reviews');
|
|
254
255
|
const configRoutes = require('./routes/config');
|
|
255
256
|
const prRoutes = require('./routes/pr');
|
|
256
257
|
const localRoutes = require('./routes/local');
|
|
257
258
|
const setupRoutes = require('./routes/setup');
|
|
258
259
|
const mcpRoutes = require('./routes/mcp');
|
|
259
260
|
const councilRoutes = require('./routes/councils');
|
|
261
|
+
const chatRoutes = require('./routes/chat');
|
|
262
|
+
const contextFilesRoutes = require('./routes/context-files');
|
|
263
|
+
|
|
264
|
+
// Initialize chat session manager
|
|
265
|
+
const ChatSessionManager = require('./chat/session-manager');
|
|
266
|
+
chatSessionManager = new ChatSessionManager(db);
|
|
267
|
+
app.chatSessionManager = chatSessionManager;
|
|
260
268
|
|
|
261
269
|
// Mount specific routes first to ensure they match before general PR routes
|
|
270
|
+
app.use('/', chatRoutes);
|
|
262
271
|
app.use('/', analysisRoutes);
|
|
263
272
|
app.use('/', councilRoutes);
|
|
264
|
-
app.use('/',
|
|
273
|
+
app.use('/', reviewsRoutes);
|
|
274
|
+
app.use('/', contextFilesRoutes);
|
|
265
275
|
app.use('/', configRoutes);
|
|
266
276
|
app.use('/', worktreesRoutes);
|
|
267
277
|
app.use('/', localRoutes);
|
|
@@ -283,15 +293,18 @@ async function startServer(sharedDb = null) {
|
|
|
283
293
|
// Find available port and start server
|
|
284
294
|
const port = await findAvailablePort(app, config.port);
|
|
285
295
|
|
|
296
|
+
// Check provider availability before accepting requests so /api/config
|
|
297
|
+
// returns accurate pi_available on the very first request (avoids race
|
|
298
|
+
// condition where the frontend fetches config before the cache is populated)
|
|
299
|
+
const defaultProvider = config.default_provider || 'claude';
|
|
300
|
+
try {
|
|
301
|
+
await checkAllProviders(defaultProvider);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.warn('Provider availability check failed:', err.message);
|
|
304
|
+
}
|
|
305
|
+
|
|
286
306
|
server = app.listen(port, () => {
|
|
287
307
|
console.log(`Server running on http://localhost:${port}`);
|
|
288
|
-
|
|
289
|
-
// Check provider availability in background after server is listening
|
|
290
|
-
// Use the configured default provider as priority (if set)
|
|
291
|
-
const defaultProvider = config.default_provider || 'claude';
|
|
292
|
-
checkAllProviders(defaultProvider).catch(err => {
|
|
293
|
-
console.warn('Background provider availability check failed:', err.message);
|
|
294
|
-
});
|
|
295
308
|
});
|
|
296
309
|
|
|
297
310
|
server.on('error', (error) => {
|
|
@@ -318,9 +331,18 @@ async function startServer(sharedDb = null) {
|
|
|
318
331
|
/**
|
|
319
332
|
* Graceful shutdown handler
|
|
320
333
|
*/
|
|
321
|
-
function gracefulShutdown(signal) {
|
|
334
|
+
async function gracefulShutdown(signal) {
|
|
322
335
|
console.log('\nServer shutting down...');
|
|
323
|
-
|
|
336
|
+
|
|
337
|
+
// Close all active chat sessions
|
|
338
|
+
if (chatSessionManager) {
|
|
339
|
+
try {
|
|
340
|
+
await chatSessionManager.closeAll();
|
|
341
|
+
} catch (error) {
|
|
342
|
+
console.error('Error closing chat sessions:', error.message);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
324
346
|
if (server) {
|
|
325
347
|
server.close(() => {
|
|
326
348
|
if (db) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Shared SSE client registry and review-scoped event broadcaster.
|
|
4
|
+
*
|
|
5
|
+
* All SSE connections (chat, analysis, etc.) share a single Set of
|
|
6
|
+
* Express response objects. broadcastReviewEvent sends review-level
|
|
7
|
+
* events (as opposed to session-level events handled in chat.js).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const logger = require('../utils/logger');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Connected SSE clients shared across all route modules.
|
|
14
|
+
* Each entry is an Express response object with an open SSE connection.
|
|
15
|
+
* @type {Set<import('express').Response>}
|
|
16
|
+
*/
|
|
17
|
+
const sseClients = new Set();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Broadcast a review-scoped SSE event to all connected clients.
|
|
21
|
+
* Optionally includes a `sourceClientId` so the originating browser tab
|
|
22
|
+
* can recognise (and skip) its own echo.
|
|
23
|
+
*
|
|
24
|
+
* @param {number} reviewId - Review ID to include in the event
|
|
25
|
+
* @param {Object} payload - Event data (must include at minimum a `type` field)
|
|
26
|
+
* @param {Object} [options]
|
|
27
|
+
* @param {string} [options.sourceClientId] - Client ID of the tab that triggered the mutation
|
|
28
|
+
*/
|
|
29
|
+
function broadcastReviewEvent(reviewId, payload, options = {}) {
|
|
30
|
+
const envelope = { ...payload, reviewId };
|
|
31
|
+
if (options.sourceClientId) {
|
|
32
|
+
envelope.sourceClientId = options.sourceClientId;
|
|
33
|
+
}
|
|
34
|
+
const data = JSON.stringify(envelope);
|
|
35
|
+
for (const client of sseClients) {
|
|
36
|
+
try {
|
|
37
|
+
client.write(`data: ${data}\n\n`);
|
|
38
|
+
} catch {
|
|
39
|
+
// Client disconnected — remove from set
|
|
40
|
+
sseClients.delete(client);
|
|
41
|
+
logger.debug('[ReviewEvents] Removed disconnected SSE client');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { sseClients, broadcastReviewEvent };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
const { getDiffFileList } = require('./diff-file-list');
|
|
3
|
+
const { ContextFileRepository } = require('../database');
|
|
4
|
+
const logger = require('./logger');
|
|
5
|
+
|
|
6
|
+
const LINE_PADDING = 10;
|
|
7
|
+
const FILE_COMMENT_DEFAULT_LINES = 50;
|
|
8
|
+
const MAX_RANGE = 500;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ensure a context file entry exists for a comment that targets a file
|
|
12
|
+
* outside the review's diff. If the file IS in the diff, this is a no-op.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} db - SQLite database handle
|
|
15
|
+
* @param {object} review - Review row from the database
|
|
16
|
+
* @param {object} opts
|
|
17
|
+
* @param {string} opts.file - File path the comment targets
|
|
18
|
+
* @param {number|null} opts.line_start - Start line (falsy for file-level comments)
|
|
19
|
+
* @param {number|null} opts.line_end - End line
|
|
20
|
+
* @returns {Promise<{created: boolean, expanded: boolean, contextFileId?: number}>}
|
|
21
|
+
*/
|
|
22
|
+
async function ensureContextFileForComment(db, review, { file, line_start, line_end }) {
|
|
23
|
+
try {
|
|
24
|
+
// 1. If the file is already in the diff, nothing to do
|
|
25
|
+
const diffFiles = await getDiffFileList(db, review);
|
|
26
|
+
if (diffFiles.includes(file)) {
|
|
27
|
+
return { created: false, expanded: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 2. Compute desired range
|
|
31
|
+
let desiredStart, desiredEnd;
|
|
32
|
+
if (line_start) {
|
|
33
|
+
desiredStart = Math.max(1, line_start - LINE_PADDING);
|
|
34
|
+
desiredEnd = (line_end ?? line_start) + LINE_PADDING;
|
|
35
|
+
} else {
|
|
36
|
+
desiredStart = 1;
|
|
37
|
+
desiredEnd = FILE_COMMENT_DEFAULT_LINES;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 3. Clamp total range to MAX_RANGE
|
|
41
|
+
if (desiredEnd - desiredStart + 1 > MAX_RANGE) {
|
|
42
|
+
desiredEnd = desiredStart + MAX_RANGE - 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 4. Look up existing context file entries for this file
|
|
46
|
+
const contextFileRepo = new ContextFileRepository(db);
|
|
47
|
+
const existing = await contextFileRepo.getByReviewIdAndFile(review.id, file);
|
|
48
|
+
|
|
49
|
+
if (existing.length > 0) {
|
|
50
|
+
// 5. Check if ANY existing entry already covers the desired range
|
|
51
|
+
const covering = existing.find(e => e.line_start <= desiredStart && e.line_end >= desiredEnd);
|
|
52
|
+
if (covering) {
|
|
53
|
+
return { created: false, expanded: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 6. Find an entry that overlaps with the desired range
|
|
57
|
+
const overlapping = existing.find(e =>
|
|
58
|
+
e.line_start <= desiredEnd && e.line_end >= desiredStart
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (overlapping) {
|
|
62
|
+
// 7. Expand to the union of old and desired ranges
|
|
63
|
+
let newStart = Math.min(overlapping.line_start, desiredStart);
|
|
64
|
+
let newEnd = Math.max(overlapping.line_end, desiredEnd);
|
|
65
|
+
|
|
66
|
+
if (newEnd - newStart + 1 > MAX_RANGE) {
|
|
67
|
+
newEnd = newStart + MAX_RANGE - 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await contextFileRepo.updateRange(overlapping.id, newStart, newEnd);
|
|
71
|
+
return { created: false, expanded: true, contextFileId: overlapping.id };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// No overlapping entry — fall through to create a new one
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 8. No existing entry — create one
|
|
78
|
+
const inserted = await contextFileRepo.add(
|
|
79
|
+
review.id, file, desiredStart, desiredEnd, 'Auto-added for comment'
|
|
80
|
+
);
|
|
81
|
+
return { created: true, expanded: false, contextFileId: inserted.id };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
logger.warn(`[AutoContext] Failed to ensure context file: ${err.message}`);
|
|
84
|
+
return { created: false, expanded: false };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { ensureContextFileForComment };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Canonical category-to-emoji mapping for AI suggestion types.
|
|
4
|
+
* Used by server-side code to format adopted comments.
|
|
5
|
+
*
|
|
6
|
+
* Canonical types from src/ai/prompts/shared/output-schema.js:
|
|
7
|
+
* bug|improvement|praise|suggestion|design|performance|security|code-style
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const CATEGORY_EMOJI_MAP = {
|
|
11
|
+
'bug': '\u{1F41B}', // bug
|
|
12
|
+
'improvement': '\u{1F4A1}', // lightbulb
|
|
13
|
+
'praise': '\u{2B50}', // star
|
|
14
|
+
'suggestion': '\u{1F4AC}', // speech bubble
|
|
15
|
+
'design': '\u{1F4D0}', // triangular ruler
|
|
16
|
+
'performance': '\u{26A1}', // high voltage
|
|
17
|
+
'security': '\u{1F512}', // lock
|
|
18
|
+
'code-style': '\u{1F3A8}', // artist palette
|
|
19
|
+
'style': '\u{1F3A8}' // artist palette (alias for code-style)
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DEFAULT_EMOJI = '\u{1F4AC}'; // speech bubble
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get emoji for a suggestion category
|
|
26
|
+
* @param {string} category - Category name
|
|
27
|
+
* @returns {string} Emoji character
|
|
28
|
+
*/
|
|
29
|
+
function getEmoji(category) {
|
|
30
|
+
return CATEGORY_EMOJI_MAP[category] || DEFAULT_EMOJI;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { CATEGORY_EMOJI_MAP, DEFAULT_EMOJI, getEmoji };
|
|
@@ -187,6 +187,7 @@ function annotateDiff(rawDiff) {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
const lines = rawDiff.split('\n');
|
|
190
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
190
191
|
const output = [];
|
|
191
192
|
|
|
192
193
|
let currentFile = {};
|
|
@@ -404,11 +405,84 @@ function parseAnnotatedDiff(annotatedDiff) {
|
|
|
404
405
|
return files;
|
|
405
406
|
}
|
|
406
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Build a lookup of which file+side+line combinations appear in diff hunks.
|
|
410
|
+
* Used to determine whether a comment targets a line GitHub can render inline
|
|
411
|
+
* (inside a hunk) vs. one that must be submitted as file-level.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} rawDiff - Raw unified diff from git
|
|
414
|
+
* @returns {{ isLineInDiff: (file: string, line: number, side?: string) => boolean }}
|
|
415
|
+
*/
|
|
416
|
+
function buildDiffLineSet(rawDiff) {
|
|
417
|
+
if (!rawDiff || rawDiff.trim() === '') {
|
|
418
|
+
return { isLineInDiff: () => false };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const entries = new Set();
|
|
422
|
+
const diffLines = rawDiff.split('\n');
|
|
423
|
+
// Trim trailing empty element produced by split('\n') on newline-terminated input.
|
|
424
|
+
// Without this, the empty string matches the context-line branch and adds phantom
|
|
425
|
+
// entries for lines beyond the actual hunk boundary.
|
|
426
|
+
if (diffLines[diffLines.length - 1] === '') diffLines.pop();
|
|
427
|
+
let currentFile = {};
|
|
428
|
+
let oldLineNum = 0;
|
|
429
|
+
let newLineNum = 0;
|
|
430
|
+
let inHunk = false;
|
|
431
|
+
|
|
432
|
+
for (const line of diffLines) {
|
|
433
|
+
if (line.startsWith('diff --git')) {
|
|
434
|
+
currentFile = {};
|
|
435
|
+
inHunk = false;
|
|
436
|
+
parseFileHeader(line, currentFile);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (parseFileHeader(line, currentFile)) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const hunkInfo = parseHunkHeader(line);
|
|
445
|
+
if (hunkInfo) {
|
|
446
|
+
oldLineNum = hunkInfo.oldStart;
|
|
447
|
+
newLineNum = hunkInfo.newStart;
|
|
448
|
+
inHunk = true;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (!inHunk) continue;
|
|
453
|
+
|
|
454
|
+
if (line.startsWith('\\ No newline')) continue;
|
|
455
|
+
|
|
456
|
+
const filePath = currentFile.newPath || currentFile.oldPath;
|
|
457
|
+
if (!filePath) continue;
|
|
458
|
+
|
|
459
|
+
if (line.startsWith('+')) {
|
|
460
|
+
entries.add(`${filePath}:RIGHT:${newLineNum}`);
|
|
461
|
+
newLineNum++;
|
|
462
|
+
} else if (line.startsWith('-')) {
|
|
463
|
+
entries.add(`${filePath}:LEFT:${oldLineNum}`);
|
|
464
|
+
oldLineNum++;
|
|
465
|
+
} else if (line.startsWith(' ') || line === '') {
|
|
466
|
+
entries.add(`${filePath}:LEFT:${oldLineNum}`);
|
|
467
|
+
entries.add(`${filePath}:RIGHT:${newLineNum}`);
|
|
468
|
+
oldLineNum++;
|
|
469
|
+
newLineNum++;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
isLineInDiff(file, lineNum, side = 'RIGHT') {
|
|
475
|
+
return entries.has(`${file}:${side}:${lineNum}`);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
407
480
|
module.exports = {
|
|
408
481
|
annotateDiff,
|
|
409
482
|
parseAnnotatedDiff,
|
|
410
483
|
parseHunkHeader,
|
|
411
484
|
formatLineNum,
|
|
412
485
|
getLineMarker,
|
|
413
|
-
getLineContent
|
|
486
|
+
getLineContent,
|
|
487
|
+
buildDiffLineSet
|
|
414
488
|
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
const { promisify } = require('util');
|
|
3
|
+
const { exec } = require('child_process');
|
|
4
|
+
const { queryOne } = require('../database');
|
|
5
|
+
|
|
6
|
+
const execPromise = promisify(exec);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Return the list of file paths that belong to the review's diff.
|
|
10
|
+
* Works for both PR-mode and local-mode reviews.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} db - SQLite database handle
|
|
13
|
+
* @param {object} review - Review row from the database
|
|
14
|
+
* @returns {Promise<string[]>} Array of relative file paths in the diff
|
|
15
|
+
*/
|
|
16
|
+
async function getDiffFileList(db, review) {
|
|
17
|
+
// PR mode – pull from pr_metadata table
|
|
18
|
+
if (review.pr_number && review.repository) {
|
|
19
|
+
try {
|
|
20
|
+
const prRecord = await queryOne(db, `
|
|
21
|
+
SELECT pr_data FROM pr_metadata
|
|
22
|
+
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
23
|
+
`, [review.pr_number, review.repository]);
|
|
24
|
+
|
|
25
|
+
if (prRecord?.pr_data) {
|
|
26
|
+
const prData = JSON.parse(prRecord.pr_data);
|
|
27
|
+
return (prData.changed_files || []).map(f => f.file);
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// parse / query error – fall through to empty list
|
|
31
|
+
}
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Local mode – ask git for changed / untracked files
|
|
36
|
+
if (review.local_path) {
|
|
37
|
+
try {
|
|
38
|
+
const opts = { cwd: review.local_path };
|
|
39
|
+
const [{ stdout: unstaged }, { stdout: untracked }] = await Promise.all([
|
|
40
|
+
execPromise('git diff --name-only', opts),
|
|
41
|
+
execPromise('git ls-files --others --exclude-standard', opts),
|
|
42
|
+
]);
|
|
43
|
+
const combined = `${unstaged}\n${untracked}`
|
|
44
|
+
.split('\n')
|
|
45
|
+
.map(l => l.trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
return [...new Set(combined)];
|
|
48
|
+
} catch {
|
|
49
|
+
// git error – fall through to empty list
|
|
50
|
+
}
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { getDiffFileList };
|