@in-the-loop-labs/pair-review 2.6.3 → 3.0.0
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/.pi/extensions/task/index.ts +1 -1
- package/.pi/skills/review-roulette/SKILL.md +1 -1
- package/LICENSE +201 -674
- package/README.md +2 -2
- package/bin/pair-review.js +1 -1
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin-code-critic/.claude-plugin/plugin.json +2 -2
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
- package/public/css/ai-summary-modal.css +1 -1
- package/public/css/pr.css +194 -0
- package/public/index.html +168 -3
- package/public/js/components/AIPanel.js +17 -3
- package/public/js/components/AISummaryModal.js +1 -1
- package/public/js/components/AdvancedConfigTab.js +1 -1
- package/public/js/components/AnalysisConfigModal.js +1 -1
- package/public/js/components/ChatPanel.js +42 -7
- package/public/js/components/ConfirmDialog.js +22 -3
- package/public/js/components/CouncilProgressModal.js +14 -1
- package/public/js/components/DiffOptionsDropdown.js +411 -24
- package/public/js/components/EmojiPicker.js +1 -1
- package/public/js/components/KeyboardShortcuts.js +1 -1
- package/public/js/components/PanelGroup.js +1 -1
- package/public/js/components/PreviewModal.js +1 -1
- package/public/js/components/ReviewModal.js +1 -1
- package/public/js/components/SplitButton.js +1 -1
- package/public/js/components/StatusIndicator.js +1 -1
- package/public/js/components/SuggestionNavigator.js +13 -6
- package/public/js/components/TabTitle.js +96 -0
- package/public/js/components/TextInputDialog.js +1 -1
- package/public/js/components/TimeoutSelect.js +1 -1
- package/public/js/components/Toast.js +7 -1
- package/public/js/components/VoiceCentricConfigTab.js +1 -1
- package/public/js/index.js +649 -44
- package/public/js/local.js +570 -77
- package/public/js/modules/analysis-history.js +4 -3
- package/public/js/modules/comment-manager.js +6 -1
- package/public/js/modules/comment-minimizer.js +304 -0
- package/public/js/modules/diff-context.js +1 -1
- package/public/js/modules/diff-renderer.js +1 -1
- package/public/js/modules/file-comment-manager.js +1 -1
- package/public/js/modules/file-list-merger.js +1 -1
- package/public/js/modules/gap-coordinates.js +1 -1
- package/public/js/modules/hunk-parser.js +1 -1
- package/public/js/modules/line-tracker.js +1 -1
- package/public/js/modules/panel-resizer.js +1 -1
- package/public/js/modules/storage-cleanup.js +1 -1
- package/public/js/modules/suggestion-manager.js +1 -1
- package/public/js/pr.js +83 -7
- package/public/js/repo-settings.js +1 -1
- package/public/js/utils/category-emoji.js +1 -1
- package/public/js/utils/file-order.js +1 -1
- package/public/js/utils/markdown.js +1 -1
- package/public/js/utils/suggestion-ui.js +1 -1
- package/public/js/utils/tier-icons.js +1 -1
- package/public/js/utils/time.js +1 -1
- package/public/js/ws-client.js +1 -1
- package/public/local.html +14 -0
- package/public/pr.html +3 -0
- package/public/setup.html +1 -1
- package/src/ai/analyzer.js +18 -12
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +1 -1
- package/src/ai/codex-provider.js +1 -1
- package/src/ai/copilot-provider.js +1 -1
- package/src/ai/cursor-agent-provider.js +1 -1
- package/src/ai/gemini-provider.js +1 -1
- package/src/ai/index.js +1 -1
- package/src/ai/opencode-provider.js +1 -1
- package/src/ai/pi-provider.js +1 -1
- package/src/ai/prompts/baseline/consolidation/balanced.js +1 -1
- package/src/ai/prompts/baseline/consolidation/fast.js +1 -1
- package/src/ai/prompts/baseline/consolidation/thorough.js +1 -1
- package/src/ai/prompts/baseline/level1/balanced.js +1 -1
- package/src/ai/prompts/baseline/level1/fast.js +1 -1
- package/src/ai/prompts/baseline/level1/thorough.js +1 -1
- package/src/ai/prompts/baseline/level2/balanced.js +1 -1
- package/src/ai/prompts/baseline/level2/fast.js +1 -1
- package/src/ai/prompts/baseline/level2/thorough.js +1 -1
- package/src/ai/prompts/baseline/level3/balanced.js +1 -1
- package/src/ai/prompts/baseline/level3/fast.js +1 -1
- package/src/ai/prompts/baseline/level3/thorough.js +1 -1
- package/src/ai/prompts/baseline/orchestration/balanced.js +1 -1
- package/src/ai/prompts/baseline/orchestration/fast.js +1 -1
- package/src/ai/prompts/baseline/orchestration/thorough.js +1 -1
- package/src/ai/prompts/config.js +1 -1
- package/src/ai/prompts/index.js +1 -1
- package/src/ai/prompts/line-number-guidance.js +1 -1
- package/src/ai/prompts/render-for-skill.js +1 -1
- package/src/ai/prompts/shared/diff-instructions.js +1 -1
- package/src/ai/prompts/shared/output-schema.js +1 -1
- package/src/ai/prompts/shared/valid-files.js +1 -1
- package/src/ai/prompts/sparse-checkout-guidance.js +1 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +1 -1
- package/src/ai/stream-parser.js +1 -1
- package/src/chat/acp-bridge.js +1 -1
- package/src/chat/api-reference.js +1 -1
- package/src/chat/chat-providers.js +1 -1
- package/src/chat/claude-code-bridge.js +1 -1
- package/src/chat/codex-bridge.js +1 -1
- package/src/chat/pi-bridge.js +1 -1
- package/src/chat/prompt-builder.js +1 -1
- package/src/chat/session-manager.js +1 -1
- package/src/config.js +3 -1
- package/src/database.js +591 -40
- package/src/events/review-events.js +1 -1
- package/src/git/base-branch.js +173 -0
- package/src/git/gitattributes.js +1 -1
- package/src/git/sha-abbrev.js +35 -0
- package/src/git/worktree.js +1 -1
- package/src/github/client.js +33 -2
- package/src/github/parser.js +1 -1
- package/src/hooks/hook-runner.js +100 -0
- package/src/hooks/payloads.js +212 -0
- package/src/local-review.js +469 -130
- package/src/local-scope.js +58 -0
- package/src/main.js +56 -5
- package/src/mcp-stdio.js +1 -1
- package/src/protocol-handler.js +1 -1
- package/src/routes/analyses.js +74 -11
- package/src/routes/chat.js +34 -1
- package/src/routes/config.js +2 -1
- package/src/routes/context-files.js +1 -1
- package/src/routes/councils.js +1 -1
- package/src/routes/github-collections.js +1 -1
- package/src/routes/local.js +735 -69
- package/src/routes/mcp.js +21 -11
- package/src/routes/pr.js +91 -13
- package/src/routes/reviews.js +1 -1
- package/src/routes/setup.js +2 -1
- package/src/routes/shared.js +1 -1
- package/src/routes/worktrees.js +213 -149
- package/src/server.js +31 -1
- package/src/setup/local-setup.js +47 -6
- package/src/setup/pr-setup.js +29 -6
- package/src/utils/auto-context.js +1 -1
- package/src/utils/category-emoji.js +1 -1
- package/src/utils/comment-formatter.js +1 -1
- package/src/utils/diff-annotator.js +1 -1
- package/src/utils/diff-file-list.js +1 -1
- package/src/utils/instructions.js +1 -1
- package/src/utils/json-extractor.js +1 -1
- package/src/utils/line-validation.js +1 -1
- package/src/utils/logger.js +1 -1
- package/src/utils/paths.js +1 -1
- package/src/utils/safe-parse-json.js +1 -1
- package/src/utils/stats-calculator.js +1 -1
- package/src/ws/index.js +1 -1
- package/src/ws/server.js +1 -1
package/src/routes/local.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// SPDX-License-Identifier:
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
/**
|
|
3
3
|
* Local Review Routes
|
|
4
4
|
*
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const express = require('express');
|
|
16
|
+
const { execSync } = require('child_process');
|
|
16
17
|
const path = require('path');
|
|
17
18
|
const fs = require('fs').promises;
|
|
18
19
|
const { queryOne, run, ReviewRepository, RepoSettingsRepository, AnalysisRunRepository, CouncilRepository } = require('../database');
|
|
@@ -20,9 +21,14 @@ const Analyzer = require('../ai/analyzer');
|
|
|
20
21
|
const { v4: uuidv4 } = require('uuid');
|
|
21
22
|
const logger = require('../utils/logger');
|
|
22
23
|
const { broadcastReviewEvent } = require('../events/review-events');
|
|
24
|
+
const { fireHooks, hasHooks } = require('../hooks/hook-runner');
|
|
25
|
+
const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
|
|
23
26
|
const { mergeInstructions } = require('../utils/instructions');
|
|
24
|
-
const {
|
|
27
|
+
const { getGitHubToken } = require('../config');
|
|
28
|
+
const { generateScopedDiff, computeScopedDigest, getBranchCommitCount, getFirstCommitSubject, detectAndBuildBranchInfo, findMergeBase, getCurrentBranch, getRepositoryName } = require('../local-review');
|
|
29
|
+
const { STOPS, isValidScope, scopeIncludes, includesBranch, DEFAULT_SCOPE } = require('../local-scope');
|
|
25
30
|
const { getGeneratedFilePatterns } = require('../git/gitattributes');
|
|
31
|
+
const { getShaAbbrevLength } = require('../git/sha-abbrev');
|
|
26
32
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
27
33
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
28
34
|
const {
|
|
@@ -60,6 +66,22 @@ function deleteLocalReviewDiff(reviewId) {
|
|
|
60
66
|
localReviewDiffs.delete(toIntKey(reviewId));
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Delete a local review session and its in-memory diff cache.
|
|
71
|
+
* Shared by both single-delete and bulk-delete routes.
|
|
72
|
+
*
|
|
73
|
+
* @param {ReviewRepository} reviewRepo - Repository instance
|
|
74
|
+
* @param {number} id - Review ID
|
|
75
|
+
* @returns {boolean} true if deleted, false if not found
|
|
76
|
+
*/
|
|
77
|
+
async function deleteLocalReviewFull(reviewRepo, id) {
|
|
78
|
+
const deleted = await reviewRepo.deleteLocalSession(id);
|
|
79
|
+
if (deleted) {
|
|
80
|
+
deleteLocalReviewDiff(id);
|
|
81
|
+
}
|
|
82
|
+
return deleted;
|
|
83
|
+
}
|
|
84
|
+
|
|
63
85
|
/**
|
|
64
86
|
* Open native OS directory picker dialog and return the selected path.
|
|
65
87
|
* Uses osascript on macOS, zenity/kdialog on Linux, PowerShell on Windows.
|
|
@@ -165,9 +187,19 @@ router.get('/api/local/sessions', async (req, res) => {
|
|
|
165
187
|
const reviewRepo = new ReviewRepository(db);
|
|
166
188
|
const { sessions, hasMore } = await reviewRepo.listLocalSessions({ limit, before });
|
|
167
189
|
|
|
190
|
+
// Compute SHA abbreviation length per unique repo path
|
|
191
|
+
const abbrevCache = new Map();
|
|
192
|
+
const enrichedSessions = sessions.map(session => {
|
|
193
|
+
if (!session.local_path) return session;
|
|
194
|
+
if (!abbrevCache.has(session.local_path)) {
|
|
195
|
+
abbrevCache.set(session.local_path, getShaAbbrevLength(session.local_path));
|
|
196
|
+
}
|
|
197
|
+
return { ...session, sha_abbrev_length: abbrevCache.get(session.local_path) };
|
|
198
|
+
});
|
|
199
|
+
|
|
168
200
|
res.json({
|
|
169
201
|
success: true,
|
|
170
|
-
sessions,
|
|
202
|
+
sessions: enrichedSessions,
|
|
171
203
|
hasMore
|
|
172
204
|
});
|
|
173
205
|
|
|
@@ -179,6 +211,74 @@ router.get('/api/local/sessions', async (req, res) => {
|
|
|
179
211
|
}
|
|
180
212
|
});
|
|
181
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Bulk delete local review sessions.
|
|
216
|
+
* Accepts { ids: number[] } in request body. Max 50 IDs per request.
|
|
217
|
+
* Must be registered BEFORE /:reviewId param routes.
|
|
218
|
+
* Only deletes DB records — does NOT remove files on disk.
|
|
219
|
+
*/
|
|
220
|
+
router.post('/api/local/sessions/bulk-delete', async (req, res) => {
|
|
221
|
+
try {
|
|
222
|
+
const { ids } = req.body || {};
|
|
223
|
+
|
|
224
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
225
|
+
return res.status(400).json({
|
|
226
|
+
success: false,
|
|
227
|
+
error: 'Request body must contain a non-empty "ids" array'
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (ids.length > 50) {
|
|
232
|
+
return res.status(400).json({
|
|
233
|
+
success: false,
|
|
234
|
+
error: 'Maximum 50 IDs per request'
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const parsedIds = ids.map(id => parseInt(id, 10));
|
|
239
|
+
if (parsedIds.some(id => isNaN(id) || id <= 0)) {
|
|
240
|
+
return res.status(400).json({
|
|
241
|
+
success: false,
|
|
242
|
+
error: 'All IDs must be positive integers'
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const db = req.app.get('db');
|
|
247
|
+
const reviewRepo = new ReviewRepository(db);
|
|
248
|
+
let deleted = 0;
|
|
249
|
+
const errors = [];
|
|
250
|
+
|
|
251
|
+
for (const id of parsedIds) {
|
|
252
|
+
try {
|
|
253
|
+
const result = await deleteLocalReviewFull(reviewRepo, id);
|
|
254
|
+
if (result) {
|
|
255
|
+
deleted++;
|
|
256
|
+
} else {
|
|
257
|
+
errors.push({ id, error: `Local review #${id} not found` });
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
errors.push({ id, error: err.message });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (deleted > 0) logger.success(`Bulk deleted ${deleted} local review session(s)`);
|
|
265
|
+
|
|
266
|
+
res.json({
|
|
267
|
+
success: deleted > 0 || errors.length === 0,
|
|
268
|
+
deleted,
|
|
269
|
+
failed: errors.length,
|
|
270
|
+
errors
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
} catch (error) {
|
|
274
|
+
logger.error(`Error in bulk delete local sessions: ${error.message}`);
|
|
275
|
+
res.status(500).json({
|
|
276
|
+
success: false,
|
|
277
|
+
error: 'Failed to process bulk delete'
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
182
282
|
/**
|
|
183
283
|
* Delete a local review session
|
|
184
284
|
* Must be registered BEFORE /:reviewId param routes
|
|
@@ -196,7 +296,7 @@ router.delete('/api/local/sessions/:reviewId', async (req, res) => {
|
|
|
196
296
|
|
|
197
297
|
const db = req.app.get('db');
|
|
198
298
|
const reviewRepo = new ReviewRepository(db);
|
|
199
|
-
const deleted = await reviewRepo
|
|
299
|
+
const deleted = await deleteLocalReviewFull(reviewRepo, reviewId);
|
|
200
300
|
|
|
201
301
|
if (!deleted) {
|
|
202
302
|
return res.status(404).json({
|
|
@@ -204,9 +304,6 @@ router.delete('/api/local/sessions/:reviewId', async (req, res) => {
|
|
|
204
304
|
});
|
|
205
305
|
}
|
|
206
306
|
|
|
207
|
-
// Clean up in-memory diff cache to avoid stale data
|
|
208
|
-
deleteLocalReviewDiff(reviewId);
|
|
209
|
-
|
|
210
307
|
logger.success(`Deleted local review session #${reviewId}`);
|
|
211
308
|
|
|
212
309
|
res.json({
|
|
@@ -275,21 +372,79 @@ router.post('/api/local/start', async (req, res) => {
|
|
|
275
372
|
// Create or resume session
|
|
276
373
|
const db = req.app.get('db');
|
|
277
374
|
const reviewRepo = new ReviewRepository(db);
|
|
278
|
-
const sessionId = await reviewRepo.upsertLocalReview({
|
|
279
|
-
localPath: repoPath,
|
|
280
|
-
localHeadSha: headSha,
|
|
281
|
-
repository
|
|
282
|
-
});
|
|
283
375
|
|
|
284
|
-
|
|
376
|
+
let sessionId;
|
|
377
|
+
// Try exact match (path + sha + branch)
|
|
378
|
+
let existing = await reviewRepo.getLocalReview(repoPath, headSha, branch);
|
|
379
|
+
|
|
380
|
+
// Adopt legacy sessions that predate branch tracking
|
|
381
|
+
if (!existing) {
|
|
382
|
+
const legacy = await reviewRepo.getLocalReviewByPathAndSha(repoPath, headSha);
|
|
383
|
+
if (legacy && legacy.local_head_branch === null) {
|
|
384
|
+
existing = legacy;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Check for branch-scope session (persists across HEAD changes)
|
|
389
|
+
if (!existing) {
|
|
390
|
+
const branchSession = await reviewRepo.getLocalBranchScopeReview(repoPath, branch);
|
|
391
|
+
if (branchSession) existing = branchSession;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (existing) {
|
|
395
|
+
sessionId = existing.id;
|
|
396
|
+
if (existing.local_head_sha !== headSha) {
|
|
397
|
+
await reviewRepo.updateLocalHeadSha(sessionId, headSha);
|
|
398
|
+
}
|
|
399
|
+
if (existing.local_head_branch === null) {
|
|
400
|
+
await reviewRepo.updateReview(sessionId, { local_head_branch: branch });
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
sessionId = await reviewRepo.upsertLocalReview({
|
|
404
|
+
localPath: repoPath,
|
|
405
|
+
localHeadSha: headSha,
|
|
406
|
+
repository,
|
|
407
|
+
scopeStart: DEFAULT_SCOPE.start,
|
|
408
|
+
scopeEnd: DEFAULT_SCOPE.end,
|
|
409
|
+
localHeadBranch: branch
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Fire review hook (non-blocking)
|
|
414
|
+
const config = req.app.get('config') || {};
|
|
415
|
+
// Generate diff using default scope
|
|
285
416
|
logger.log('API', `Starting local review for ${repoPath}`, 'cyan');
|
|
286
|
-
const
|
|
417
|
+
const scopeStart = existing?.local_scope_start || DEFAULT_SCOPE.start;
|
|
418
|
+
const scopeEnd = existing?.local_scope_end || DEFAULT_SCOPE.end;
|
|
419
|
+
|
|
420
|
+
// Fire review hook (non-blocking, after scope is resolved)
|
|
421
|
+
const hookEvent = existing ? 'review.loaded' : 'review.started';
|
|
422
|
+
if (hasHooks(hookEvent, config)) {
|
|
423
|
+
getCachedUser(config).then(user => {
|
|
424
|
+
const builder = existing ? buildReviewLoadedPayload : buildReviewStartedPayload;
|
|
425
|
+
const si = STOPS.indexOf(scopeStart);
|
|
426
|
+
const ei = STOPS.indexOf(scopeEnd);
|
|
427
|
+
const scope = STOPS.slice(si, ei + 1);
|
|
428
|
+
const payload = builder({ reviewId: sessionId, mode: 'local', localContext: { path: repoPath, branch, headSha, scope }, user });
|
|
429
|
+
fireHooks(hookEvent, payload, config);
|
|
430
|
+
}).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
|
|
431
|
+
}
|
|
432
|
+
const baseBranch = existing?.local_base_branch || null;
|
|
433
|
+
const { diff, stats } = await generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch);
|
|
287
434
|
|
|
288
435
|
// Compute digest for staleness detection
|
|
289
|
-
const digest = await
|
|
436
|
+
const digest = await computeScopedDigest(repoPath, scopeStart, scopeEnd);
|
|
437
|
+
|
|
438
|
+
// Branch detection: when no uncommitted changes, check if branch has commits ahead
|
|
439
|
+
const branchInfo = await detectAndBuildBranchInfo(repoPath, branch, {
|
|
440
|
+
repository,
|
|
441
|
+
diff,
|
|
442
|
+
githubToken: getGitHubToken(config),
|
|
443
|
+
enableGraphite: config.enable_graphite === true
|
|
444
|
+
});
|
|
290
445
|
|
|
291
446
|
// Persist to in-memory Map
|
|
292
|
-
setLocalReviewDiff(sessionId, { diff, stats, digest });
|
|
447
|
+
setLocalReviewDiff(sessionId, { diff, stats, digest, branchInfo });
|
|
293
448
|
|
|
294
449
|
// Persist to database
|
|
295
450
|
await reviewRepo.saveLocalDiff(sessionId, { diff, stats, digest });
|
|
@@ -302,6 +457,7 @@ router.post('/api/local/start', async (req, res) => {
|
|
|
302
457
|
sessionId,
|
|
303
458
|
repository,
|
|
304
459
|
branch,
|
|
460
|
+
branchInfo,
|
|
305
461
|
stats: {
|
|
306
462
|
trackedChanges: stats.trackedChanges || 0,
|
|
307
463
|
untrackedFiles: stats.untrackedFiles || 0,
|
|
@@ -376,6 +532,73 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
376
532
|
}
|
|
377
533
|
}
|
|
378
534
|
|
|
535
|
+
// Build scope info for the response
|
|
536
|
+
const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
|
|
537
|
+
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
538
|
+
const baseBranch = review.local_base_branch || null;
|
|
539
|
+
|
|
540
|
+
// When scope does NOT include branch, check for branch detection info
|
|
541
|
+
// Frontend uses this to suggest expanding scope to include branch
|
|
542
|
+
let branchInfo = null;
|
|
543
|
+
const cachedDiff = getLocalReviewDiff(reviewId);
|
|
544
|
+
if (!includesBranch(scopeStart) && cachedDiff?.branchInfo) {
|
|
545
|
+
branchInfo = cachedDiff.branchInfo;
|
|
546
|
+
} else if (!includesBranch(scopeStart) && !cachedDiff && review.local_path) {
|
|
547
|
+
// No cache (web UI started session) — run detection on-demand
|
|
548
|
+
const config = req.app.get('config') || {};
|
|
549
|
+
branchInfo = await detectAndBuildBranchInfo(review.local_path, branchName, {
|
|
550
|
+
repository: repositoryName,
|
|
551
|
+
githubToken: getGitHubToken(config),
|
|
552
|
+
enableGraphite: config.enable_graphite === true
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Check repo settings for auto_branch_review preference
|
|
557
|
+
let autoBranchReview = 0;
|
|
558
|
+
if (branchInfo && repositoryName && repositoryName.includes('/')) {
|
|
559
|
+
try {
|
|
560
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
561
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repositoryName);
|
|
562
|
+
if (repoSettings) {
|
|
563
|
+
autoBranchReview = repoSettings.auto_branch_review || 0;
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
// Non-fatal
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// If auto_branch_review is -1 (never), suppress branchInfo
|
|
571
|
+
if (autoBranchReview === -1) {
|
|
572
|
+
branchInfo = null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Determine if Branch stop should be selectable in the scope range selector.
|
|
576
|
+
// This is independent of branchInfo (which guards on no uncommitted changes).
|
|
577
|
+
// Branch is available when: not detached HEAD, not on default branch, and has commits ahead.
|
|
578
|
+
let branchAvailable = includesBranch(scopeStart) || Boolean(branchInfo);
|
|
579
|
+
if (!branchAvailable && branchName && branchName !== 'HEAD' && branchName !== 'unknown' && review.local_path) {
|
|
580
|
+
try {
|
|
581
|
+
const { getBranchCommitCount } = require('../local-review');
|
|
582
|
+
const { detectBaseBranch } = require('../git/base-branch');
|
|
583
|
+
const config = req.app.get('config') || {};
|
|
584
|
+
const depsOverride = getGitHubToken(config) ? { getGitHubToken: () => getGitHubToken(config) } : undefined;
|
|
585
|
+
const detection = await detectBaseBranch(review.local_path, branchName, {
|
|
586
|
+
repository: repositoryName,
|
|
587
|
+
enableGraphite: config.enable_graphite === true,
|
|
588
|
+
_deps: depsOverride
|
|
589
|
+
});
|
|
590
|
+
if (detection) {
|
|
591
|
+
const commitCount = await getBranchCommitCount(review.local_path, detection.baseBranch);
|
|
592
|
+
branchAvailable = commitCount > 0;
|
|
593
|
+
}
|
|
594
|
+
} catch {
|
|
595
|
+
// Non-fatal — branch stop stays disabled
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Compute SHA abbreviation length from the repo's git config
|
|
600
|
+
const shaAbbrevLength = getShaAbbrevLength(review.local_path);
|
|
601
|
+
|
|
379
602
|
res.json({
|
|
380
603
|
id: review.id,
|
|
381
604
|
localPath: review.local_path,
|
|
@@ -385,10 +608,35 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
385
608
|
reviewType: 'local',
|
|
386
609
|
status: review.status,
|
|
387
610
|
name: review.name || null,
|
|
611
|
+
localMode: review.local_mode || 'uncommitted',
|
|
612
|
+
scopeStart,
|
|
613
|
+
scopeEnd,
|
|
614
|
+
baseBranch,
|
|
615
|
+
branchInfo,
|
|
616
|
+
branchAvailable,
|
|
617
|
+
shaAbbrevLength,
|
|
388
618
|
createdAt: review.created_at,
|
|
389
619
|
updatedAt: review.updated_at
|
|
390
620
|
});
|
|
391
621
|
|
|
622
|
+
// Fire review.loaded hook (session already exists to be fetched by ID)
|
|
623
|
+
const hookConfig = req.app.get('config') || {};
|
|
624
|
+
if (hasHooks('review.loaded', hookConfig)) {
|
|
625
|
+
getCachedUser(hookConfig).then(user => {
|
|
626
|
+
const hookScopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
|
|
627
|
+
const hookScopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
628
|
+
const si = STOPS.indexOf(hookScopeStart);
|
|
629
|
+
const ei = STOPS.indexOf(hookScopeEnd);
|
|
630
|
+
const scope = STOPS.slice(si, ei + 1);
|
|
631
|
+
const payload = buildReviewLoadedPayload({
|
|
632
|
+
reviewId: review.id, mode: 'local',
|
|
633
|
+
localContext: { path: review.local_path, branch: branchName, headSha: review.local_head_sha, scope },
|
|
634
|
+
user,
|
|
635
|
+
});
|
|
636
|
+
fireHooks('review.loaded', payload, hookConfig);
|
|
637
|
+
}).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
|
|
638
|
+
}
|
|
639
|
+
|
|
392
640
|
} catch (error) {
|
|
393
641
|
logger.error('Error fetching local review:', error.stack || error.message);
|
|
394
642
|
res.status(500).json({
|
|
@@ -467,11 +715,13 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
|
467
715
|
|
|
468
716
|
// When ?w=1, regenerate the diff with whitespace changes hidden (transient view, not cached)
|
|
469
717
|
const hideWhitespace = req.query.w === '1';
|
|
718
|
+
const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
|
|
719
|
+
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
470
720
|
let diffData;
|
|
471
721
|
|
|
472
722
|
if (hideWhitespace && review.local_path) {
|
|
473
723
|
try {
|
|
474
|
-
const wsResult = await
|
|
724
|
+
const wsResult = await generateScopedDiff(review.local_path, scopeStart, scopeEnd, review.local_base_branch, { hideWhitespace: true });
|
|
475
725
|
diffData = { diff: wsResult.diff, stats: wsResult.stats };
|
|
476
726
|
} catch (wsError) {
|
|
477
727
|
logger.warn(`Could not generate whitespace-filtered diff for review #${reviewId}: ${wsError.message}`);
|
|
@@ -572,6 +822,42 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
|
572
822
|
});
|
|
573
823
|
}
|
|
574
824
|
|
|
825
|
+
const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
|
|
826
|
+
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
827
|
+
|
|
828
|
+
// Always check HEAD SHA for supplementary fields
|
|
829
|
+
let headShaChanged = false;
|
|
830
|
+
let previousHeadSha = review.local_head_sha || null;
|
|
831
|
+
let currentHeadSha = null;
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
const { getHeadSha } = require('../local-review');
|
|
835
|
+
currentHeadSha = await getHeadSha(localPath);
|
|
836
|
+
headShaChanged = !!(previousHeadSha && currentHeadSha && currentHeadSha !== previousHeadSha);
|
|
837
|
+
} catch (error) {
|
|
838
|
+
// If branch is in scope, HEAD SHA failure is fatal (existing behavior)
|
|
839
|
+
if (includesBranch(scopeStart)) {
|
|
840
|
+
return res.json({
|
|
841
|
+
isStale: true,
|
|
842
|
+
headShaChanged,
|
|
843
|
+
previousHeadSha,
|
|
844
|
+
currentHeadSha: null,
|
|
845
|
+
error: `Could not check HEAD SHA: ${error.message}`
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
// Otherwise, just continue with digest check
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// When branch is in scope and HEAD changed, early return (existing behavior)
|
|
852
|
+
if (includesBranch(scopeStart) && headShaChanged) {
|
|
853
|
+
return res.json({
|
|
854
|
+
isStale: true,
|
|
855
|
+
headShaChanged,
|
|
856
|
+
previousHeadSha,
|
|
857
|
+
currentHeadSha
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
575
861
|
// Get stored diff data (in-memory first, then fall back to DB)
|
|
576
862
|
let storedDiffData = getLocalReviewDiff(reviewId);
|
|
577
863
|
if (!storedDiffData) {
|
|
@@ -584,6 +870,9 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
|
584
870
|
} else {
|
|
585
871
|
return res.json({
|
|
586
872
|
isStale: null,
|
|
873
|
+
headShaChanged,
|
|
874
|
+
previousHeadSha,
|
|
875
|
+
currentHeadSha,
|
|
587
876
|
error: 'No stored diff data found'
|
|
588
877
|
});
|
|
589
878
|
}
|
|
@@ -595,17 +884,23 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
|
595
884
|
// Assume stale to be safe and prompt user to refresh
|
|
596
885
|
return res.json({
|
|
597
886
|
isStale: true,
|
|
887
|
+
headShaChanged,
|
|
888
|
+
previousHeadSha,
|
|
889
|
+
currentHeadSha,
|
|
598
890
|
error: 'No baseline digest - please refresh to enable staleness detection'
|
|
599
891
|
});
|
|
600
892
|
}
|
|
601
893
|
|
|
602
894
|
// Compute current digest to compare against baseline
|
|
603
|
-
const currentDigest = await
|
|
895
|
+
const currentDigest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
|
|
604
896
|
|
|
605
897
|
// If current digest computation failed, assume stale to be safe
|
|
606
898
|
if (!currentDigest) {
|
|
607
899
|
return res.json({
|
|
608
900
|
isStale: true,
|
|
901
|
+
headShaChanged,
|
|
902
|
+
previousHeadSha,
|
|
903
|
+
currentHeadSha,
|
|
609
904
|
error: 'Could not compute current digest - refresh recommended'
|
|
610
905
|
});
|
|
611
906
|
}
|
|
@@ -615,13 +910,19 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
|
615
910
|
res.json({
|
|
616
911
|
isStale,
|
|
617
912
|
storedDigest: storedDiffData.digest,
|
|
618
|
-
currentDigest
|
|
913
|
+
currentDigest,
|
|
914
|
+
headShaChanged,
|
|
915
|
+
previousHeadSha,
|
|
916
|
+
currentHeadSha
|
|
619
917
|
});
|
|
620
918
|
|
|
621
919
|
} catch (error) {
|
|
622
920
|
logger.warn(`Error checking local review staleness: ${error.message}`);
|
|
623
921
|
res.json({
|
|
624
922
|
isStale: null,
|
|
923
|
+
headShaChanged: false,
|
|
924
|
+
previousHeadSha: null,
|
|
925
|
+
currentHeadSha: null,
|
|
625
926
|
error: error.message
|
|
626
927
|
});
|
|
627
928
|
}
|
|
@@ -715,6 +1016,10 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
715
1016
|
const runId = uuidv4();
|
|
716
1017
|
const analysisId = runId;
|
|
717
1018
|
|
|
1019
|
+
// Extract scope early — needed for both analysis run creation and diff generation
|
|
1020
|
+
const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
|
|
1021
|
+
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
1022
|
+
|
|
718
1023
|
// Create DB analysis_runs record immediately so it's queryable for polling
|
|
719
1024
|
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
720
1025
|
const levelsConfig = parseEnabledLevels(requestEnabledLevels, requestSkipLevel3);
|
|
@@ -730,7 +1035,9 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
730
1035
|
requestInstructions,
|
|
731
1036
|
headSha: review.local_head_sha || null,
|
|
732
1037
|
configType: 'single',
|
|
733
|
-
levelsConfig
|
|
1038
|
+
levelsConfig,
|
|
1039
|
+
scopeStart,
|
|
1040
|
+
scopeEnd
|
|
734
1041
|
});
|
|
735
1042
|
} catch (error) {
|
|
736
1043
|
logger.error('Failed to create analysis run record:', error);
|
|
@@ -764,26 +1071,54 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
764
1071
|
// Broadcast initial status
|
|
765
1072
|
broadcastProgress(analysisId, initialStatus);
|
|
766
1073
|
broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
|
|
1074
|
+
const analysisHookConfig = req.app.get('config') || {};
|
|
1075
|
+
if (hasHooks('analysis.started', analysisHookConfig)) {
|
|
1076
|
+
getCachedUser(analysisHookConfig).then(user => {
|
|
1077
|
+
fireHooks('analysis.started', buildAnalysisStartedPayload({
|
|
1078
|
+
reviewId, analysisId, provider: selectedProvider, model: selectedModel,
|
|
1079
|
+
mode: 'local',
|
|
1080
|
+
localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
|
|
1081
|
+
user,
|
|
1082
|
+
}), analysisHookConfig);
|
|
1083
|
+
}).catch(() => {});
|
|
1084
|
+
}
|
|
767
1085
|
|
|
768
1086
|
// Create analyzer instance with provider and model
|
|
769
1087
|
const analyzer = new Analyzer(db, selectedModel, selectedProvider);
|
|
770
1088
|
|
|
771
1089
|
// Build local review metadata for the analyzer
|
|
772
1090
|
// The analyzer uses base_sha and head_sha for git diff commands
|
|
773
|
-
//
|
|
1091
|
+
// When branch is in scope, base_sha is the merge-base; otherwise, HEAD
|
|
1092
|
+
const hasBranch = includesBranch(scopeStart);
|
|
1093
|
+
let analysisBaseSha = review.local_head_sha;
|
|
1094
|
+
if (hasBranch && review.local_base_branch) {
|
|
1095
|
+
try {
|
|
1096
|
+
analysisBaseSha = await findMergeBase(localPath, review.local_base_branch);
|
|
1097
|
+
} catch {
|
|
1098
|
+
// Fall back to HEAD
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
774
1101
|
const localMetadata = {
|
|
775
1102
|
id: reviewId,
|
|
776
|
-
repository: review.repository,
|
|
777
|
-
title:
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1103
|
+
repository: review.repository,
|
|
1104
|
+
title: hasBranch
|
|
1105
|
+
? `Branch changes: ${review.local_base_branch}..HEAD`
|
|
1106
|
+
: `Local changes in ${repository}`,
|
|
1107
|
+
description: hasBranch
|
|
1108
|
+
? `Reviewing committed changes on branch against ${review.local_base_branch}`
|
|
1109
|
+
: `Reviewing uncommitted changes in ${localPath}`,
|
|
1110
|
+
base_sha: analysisBaseSha,
|
|
1111
|
+
head_sha: review.local_head_sha,
|
|
781
1112
|
reviewType: 'local'
|
|
782
1113
|
};
|
|
783
1114
|
|
|
784
1115
|
// Get changed files for local mode path validation
|
|
785
|
-
//
|
|
786
|
-
|
|
1116
|
+
// When branch is in scope, pass null so analyzer falls through to getChangedFilesList
|
|
1117
|
+
// which correctly uses git diff base_sha...head_sha --name-only
|
|
1118
|
+
const hasStaged = scopeIncludes(scopeStart, scopeEnd, 'staged');
|
|
1119
|
+
const changedFiles = hasBranch
|
|
1120
|
+
? null
|
|
1121
|
+
: await analyzer.getLocalChangedFiles(localPath, { includeStaged: hasStaged });
|
|
787
1122
|
|
|
788
1123
|
// Log analysis start
|
|
789
1124
|
logger.section(`Local AI Analysis Request - Review #${reviewId}`);
|
|
@@ -793,7 +1128,7 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
793
1128
|
logger.log('API', `Provider: ${selectedProvider}`, 'cyan');
|
|
794
1129
|
logger.log('API', `Model: ${selectedModel}`, 'cyan');
|
|
795
1130
|
logger.log('API', `Tier: ${tier}`, 'cyan');
|
|
796
|
-
logger.log('API', `Changed files: ${changedFiles.length}`, 'cyan');
|
|
1131
|
+
logger.log('API', `Changed files: ${changedFiles ? changedFiles.length : '(branch mode)'}`, 'cyan');
|
|
797
1132
|
if (combinedInstructions) {
|
|
798
1133
|
logger.log('API', `Custom instructions: ${combinedInstructions.length} chars`, 'cyan');
|
|
799
1134
|
}
|
|
@@ -865,6 +1200,21 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
865
1200
|
// Broadcast completion status
|
|
866
1201
|
broadcastProgress(analysisId, completedStatus);
|
|
867
1202
|
broadcastReviewEvent(reviewId, { type: 'review:analysis_completed' });
|
|
1203
|
+
|
|
1204
|
+
// Fire analysis.completed hook
|
|
1205
|
+
const hookConfig = req.app.get('config') || {};
|
|
1206
|
+
if (hasHooks('analysis.completed', hookConfig)) {
|
|
1207
|
+
getCachedUser(hookConfig).then(user => {
|
|
1208
|
+
fireHooks('analysis.completed', buildAnalysisCompletedPayload({
|
|
1209
|
+
reviewId, analysisId, provider: selectedProvider, model: selectedModel,
|
|
1210
|
+
status: 'success',
|
|
1211
|
+
totalSuggestions: completionInfo.totalSuggestions,
|
|
1212
|
+
mode: 'local',
|
|
1213
|
+
localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
|
|
1214
|
+
user,
|
|
1215
|
+
}), hookConfig);
|
|
1216
|
+
}).catch(() => {});
|
|
1217
|
+
}
|
|
868
1218
|
})
|
|
869
1219
|
.catch(error => {
|
|
870
1220
|
const currentStatus = activeAnalyses.get(analysisId);
|
|
@@ -877,6 +1227,18 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
877
1227
|
if (error.isCancellation) {
|
|
878
1228
|
logger.info(`Local analysis cancelled for review #${reviewId}`);
|
|
879
1229
|
// Status is already set to 'cancelled' by the cancel endpoint
|
|
1230
|
+
const cancelConfig = req.app.get('config') || {};
|
|
1231
|
+
if (hasHooks('analysis.completed', cancelConfig)) {
|
|
1232
|
+
getCachedUser(cancelConfig).then(user => {
|
|
1233
|
+
fireHooks('analysis.completed', buildAnalysisCompletedPayload({
|
|
1234
|
+
reviewId, analysisId, provider: selectedProvider, model: selectedModel,
|
|
1235
|
+
status: 'cancelled', totalSuggestions: 0,
|
|
1236
|
+
mode: 'local',
|
|
1237
|
+
localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
|
|
1238
|
+
user,
|
|
1239
|
+
}), cancelConfig);
|
|
1240
|
+
}).catch(() => {});
|
|
1241
|
+
}
|
|
880
1242
|
return;
|
|
881
1243
|
}
|
|
882
1244
|
|
|
@@ -902,6 +1264,19 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
902
1264
|
|
|
903
1265
|
// Broadcast failure status
|
|
904
1266
|
broadcastProgress(analysisId, failedStatus);
|
|
1267
|
+
|
|
1268
|
+
const failConfig = req.app.get('config') || {};
|
|
1269
|
+
if (hasHooks('analysis.completed', failConfig)) {
|
|
1270
|
+
getCachedUser(failConfig).then(user => {
|
|
1271
|
+
fireHooks('analysis.completed', buildAnalysisCompletedPayload({
|
|
1272
|
+
reviewId, analysisId, provider: selectedProvider, model: selectedModel,
|
|
1273
|
+
status: 'failed', totalSuggestions: 0,
|
|
1274
|
+
mode: 'local',
|
|
1275
|
+
localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
|
|
1276
|
+
user,
|
|
1277
|
+
}), failConfig);
|
|
1278
|
+
}).catch(() => {});
|
|
1279
|
+
}
|
|
905
1280
|
})
|
|
906
1281
|
.finally(() => {
|
|
907
1282
|
// Clean up review to analysis ID mapping (unified map)
|
|
@@ -964,64 +1339,66 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
|
|
|
964
1339
|
|
|
965
1340
|
// Check if HEAD has changed
|
|
966
1341
|
const { getHeadSha } = require('../local-review');
|
|
1342
|
+
const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
|
|
1343
|
+
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
1344
|
+
const hasBranch = includesBranch(scopeStart);
|
|
967
1345
|
let currentHeadSha;
|
|
968
|
-
let
|
|
969
|
-
let newSessionId = null;
|
|
1346
|
+
let headShaChanged = false;
|
|
970
1347
|
|
|
971
1348
|
try {
|
|
972
1349
|
currentHeadSha = await getHeadSha(localPath);
|
|
973
1350
|
|
|
974
1351
|
if (originalHeadSha && currentHeadSha !== originalHeadSha) {
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
logger.log('API', `
|
|
983
|
-
} else {
|
|
984
|
-
// Create a new session for the new HEAD
|
|
985
|
-
const { getRepositoryName } = require('../local-review');
|
|
986
|
-
const repository = await getRepositoryName(localPath);
|
|
987
|
-
newSessionId = await reviewRepo.upsertLocalReview({
|
|
988
|
-
localPath: localPath,
|
|
989
|
-
localHeadSha: currentHeadSha,
|
|
990
|
-
repository
|
|
991
|
-
});
|
|
992
|
-
logger.log('API', `Created new session for new HEAD: ${newSessionId}`, 'cyan');
|
|
1352
|
+
headShaChanged = true;
|
|
1353
|
+
const abbrevLen = getShaAbbrevLength(localPath);
|
|
1354
|
+
logger.log('API', `HEAD changed: ${originalHeadSha.substring(0, abbrevLen)} -> ${currentHeadSha.substring(0, abbrevLen)}`, 'yellow');
|
|
1355
|
+
|
|
1356
|
+
if (hasBranch) {
|
|
1357
|
+
// Branch scope: session persists across HEAD changes — just update the SHA
|
|
1358
|
+
await reviewRepo.updateLocalHeadSha(reviewId, currentHeadSha);
|
|
1359
|
+
logger.log('API', `Updated HEAD SHA on branch-scope session ${reviewId}`, 'cyan');
|
|
993
1360
|
}
|
|
1361
|
+
// Non-branch scope: defer decision to frontend via resolve-head-change endpoint
|
|
994
1362
|
}
|
|
995
1363
|
} catch (headError) {
|
|
996
1364
|
logger.warn(`Could not check HEAD SHA: ${headError.message}`);
|
|
997
1365
|
}
|
|
998
1366
|
|
|
999
|
-
//
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
//
|
|
1003
|
-
|
|
1367
|
+
// Non-branch HEAD change: skip diff computation entirely — the old diff is
|
|
1368
|
+
// preserved until the user decides (via resolve-head-change) what to do.
|
|
1369
|
+
// The resolve-head-change endpoint will recompute the diff for whichever
|
|
1370
|
+
// action the user picks (update or new-session).
|
|
1371
|
+
if (headShaChanged && !hasBranch) {
|
|
1372
|
+
return res.json({
|
|
1373
|
+
success: true,
|
|
1374
|
+
message: 'HEAD changed — awaiting user decision',
|
|
1375
|
+
headShaChanged,
|
|
1376
|
+
previousHeadSha: originalHeadSha,
|
|
1377
|
+
currentHeadSha: currentHeadSha || null,
|
|
1378
|
+
stats: {}
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1004
1381
|
|
|
1005
|
-
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1382
|
+
const scopedResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
|
|
1383
|
+
const diff = scopedResult.diff;
|
|
1384
|
+
const stats = scopedResult.stats;
|
|
1385
|
+
const digest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
|
|
1008
1386
|
|
|
1009
|
-
|
|
1387
|
+
setLocalReviewDiff(reviewId, { diff, stats, digest });
|
|
1010
1388
|
try {
|
|
1011
|
-
await reviewRepo.saveLocalDiff(
|
|
1389
|
+
await reviewRepo.saveLocalDiff(reviewId, { diff, stats, digest });
|
|
1012
1390
|
} catch (persistError) {
|
|
1013
1391
|
logger.warn(`Could not persist diff to database: ${persistError.message}`);
|
|
1014
1392
|
}
|
|
1015
1393
|
|
|
1016
|
-
logger.success(`Diff refreshed
|
|
1394
|
+
logger.success(`Diff refreshed (scope ${scopeStart}–${scopeEnd}): ${stats.trackedChanges || 0} file(s)`);
|
|
1017
1395
|
|
|
1018
1396
|
res.json({
|
|
1019
1397
|
success: true,
|
|
1020
1398
|
message: 'Diff refreshed successfully',
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
originalHeadSha: originalHeadSha,
|
|
1399
|
+
headShaChanged,
|
|
1400
|
+
previousHeadSha: originalHeadSha,
|
|
1401
|
+
currentHeadSha: currentHeadSha || null,
|
|
1025
1402
|
stats: {
|
|
1026
1403
|
trackedChanges: stats.trackedChanges || 0,
|
|
1027
1404
|
untrackedFiles: stats.untrackedFiles || 0,
|
|
@@ -1038,6 +1415,272 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
|
|
|
1038
1415
|
}
|
|
1039
1416
|
});
|
|
1040
1417
|
|
|
1418
|
+
/**
|
|
1419
|
+
* Resolve a HEAD SHA change on a non-branch-scoped review.
|
|
1420
|
+
* Called by the frontend after the user chooses how to handle a detected HEAD change.
|
|
1421
|
+
*
|
|
1422
|
+
* action: 'update' — keep the current session, update its SHA, recompute diff
|
|
1423
|
+
* action: 'new-session' — create a fresh session for the new HEAD, return its ID
|
|
1424
|
+
*/
|
|
1425
|
+
router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
|
|
1426
|
+
try {
|
|
1427
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1428
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1429
|
+
return res.status(400).json({ error: 'Invalid review ID' });
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const { action, newHeadSha } = req.body || {};
|
|
1433
|
+
if (!action || !['update', 'new-session'].includes(action)) {
|
|
1434
|
+
return res.status(400).json({ error: 'action must be "update" or "new-session"' });
|
|
1435
|
+
}
|
|
1436
|
+
if (!newHeadSha || typeof newHeadSha !== 'string') {
|
|
1437
|
+
return res.status(400).json({ error: 'newHeadSha is required' });
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const db = req.app.get('db');
|
|
1441
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1442
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
1443
|
+
if (!review) {
|
|
1444
|
+
return res.status(404).json({ error: `Local review #${reviewId} not found` });
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const localPath = review.local_path;
|
|
1448
|
+
if (!localPath) {
|
|
1449
|
+
return res.status(400).json({ error: 'Local review is missing path information' });
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
|
|
1453
|
+
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
1454
|
+
|
|
1455
|
+
if (action === 'update') {
|
|
1456
|
+
// Check for UNIQUE conflict before updating
|
|
1457
|
+
const headBranch = review.local_head_branch || null;
|
|
1458
|
+
const conflict = await reviewRepo.getLocalReview(localPath, newHeadSha, headBranch);
|
|
1459
|
+
if (conflict && conflict.id !== reviewId) {
|
|
1460
|
+
logger.log('API', `UNIQUE conflict: session #${conflict.id} already exists for this HEAD`, 'yellow');
|
|
1461
|
+
return res.json({ success: true, action: 'redirect', sessionId: conflict.id });
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Update SHA
|
|
1465
|
+
await reviewRepo.updateLocalHeadSha(reviewId, newHeadSha);
|
|
1466
|
+
logger.log('API', `Updated HEAD SHA on session ${reviewId}`, 'cyan');
|
|
1467
|
+
|
|
1468
|
+
// Recompute and persist diff
|
|
1469
|
+
const scopedResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
|
|
1470
|
+
const digest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
|
|
1471
|
+
setLocalReviewDiff(reviewId, { diff: scopedResult.diff, stats: scopedResult.stats, digest });
|
|
1472
|
+
try {
|
|
1473
|
+
await reviewRepo.saveLocalDiff(reviewId, { diff: scopedResult.diff, stats: scopedResult.stats, digest });
|
|
1474
|
+
} catch (persistError) {
|
|
1475
|
+
logger.warn(`Could not persist diff to database: ${persistError.message}`);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
return res.json({ success: true, action: 'updated' });
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// action === 'new-session'
|
|
1482
|
+
let branch;
|
|
1483
|
+
try { branch = await getCurrentBranch(localPath); } catch (_) { /* non-fatal */ }
|
|
1484
|
+
const repository = await getRepositoryName(localPath);
|
|
1485
|
+
|
|
1486
|
+
// Check for an existing session at the new HEAD
|
|
1487
|
+
const existing = await reviewRepo.findLocalReview(localPath, newHeadSha, branch);
|
|
1488
|
+
if (existing) {
|
|
1489
|
+
logger.log('API', `Existing session found for new HEAD: ${existing.id}`, 'cyan');
|
|
1490
|
+
return res.json({ success: true, action: 'new-session', newSessionId: existing.id });
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const newSessionId = await reviewRepo.upsertLocalReview({
|
|
1494
|
+
localPath,
|
|
1495
|
+
localHeadSha: newHeadSha,
|
|
1496
|
+
repository,
|
|
1497
|
+
scopeStart,
|
|
1498
|
+
scopeEnd,
|
|
1499
|
+
localHeadBranch: branch
|
|
1500
|
+
});
|
|
1501
|
+
logger.log('API', `Created new session for new HEAD: ${newSessionId}`, 'cyan');
|
|
1502
|
+
|
|
1503
|
+
// Compute and persist diff so the new session is immediately usable
|
|
1504
|
+
const newScopeResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
|
|
1505
|
+
const newDigest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
|
|
1506
|
+
setLocalReviewDiff(newSessionId, { diff: newScopeResult.diff, stats: newScopeResult.stats, digest: newDigest });
|
|
1507
|
+
try {
|
|
1508
|
+
await reviewRepo.saveLocalDiff(newSessionId, { diff: newScopeResult.diff, stats: newScopeResult.stats, digest: newDigest });
|
|
1509
|
+
} catch (persistError) {
|
|
1510
|
+
logger.warn(`Could not persist diff for new session: ${persistError.message}`);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
return res.json({ success: true, action: 'new-session', newSessionId });
|
|
1514
|
+
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
logger.error('Error resolving head change:', error);
|
|
1517
|
+
res.status(500).json({ error: 'Failed to resolve head change: ' + error.message });
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* Set the scope range for a local review.
|
|
1523
|
+
* Validates scope, detects baseBranch if needed, regenerates diff.
|
|
1524
|
+
*/
|
|
1525
|
+
router.post('/api/local/:reviewId/set-scope', async (req, res) => {
|
|
1526
|
+
try {
|
|
1527
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1528
|
+
|
|
1529
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1530
|
+
return res.status(400).json({ error: 'Invalid review ID' });
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const { scopeStart, scopeEnd, baseBranch: requestBaseBranch } = req.body || {};
|
|
1534
|
+
|
|
1535
|
+
if (!scopeStart || !scopeEnd) {
|
|
1536
|
+
return res.status(400).json({ error: 'scopeStart and scopeEnd are required' });
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
if (!isValidScope(scopeStart, scopeEnd)) {
|
|
1540
|
+
return res.status(400).json({ error: `Invalid scope range: ${scopeStart}–${scopeEnd}` });
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
const db = req.app.get('db');
|
|
1544
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1545
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
1546
|
+
|
|
1547
|
+
if (!review) {
|
|
1548
|
+
return res.status(404).json({ error: `Local review #${reviewId} not found` });
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const localPath = review.local_path;
|
|
1552
|
+
if (!localPath) {
|
|
1553
|
+
return res.status(400).json({ error: 'Local review is missing path information' });
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// When branch is in scope, resolve baseBranch and current branch
|
|
1557
|
+
let baseBranch = requestBaseBranch || null;
|
|
1558
|
+
let currentBranch = null;
|
|
1559
|
+
if (includesBranch(scopeStart)) {
|
|
1560
|
+
currentBranch = await getCurrentBranch(localPath);
|
|
1561
|
+
if (!baseBranch) {
|
|
1562
|
+
const { detectBaseBranch } = require('../git/base-branch');
|
|
1563
|
+
const config = req.app.get('config') || {};
|
|
1564
|
+
const token = getGitHubToken(config);
|
|
1565
|
+
const detection = await detectBaseBranch(localPath, currentBranch, {
|
|
1566
|
+
repository: review.repository,
|
|
1567
|
+
enableGraphite: config.enable_graphite === true,
|
|
1568
|
+
_deps: token ? { getGitHubToken: () => token } : undefined
|
|
1569
|
+
});
|
|
1570
|
+
if (!detection) {
|
|
1571
|
+
return res.status(400).json({ error: 'Could not detect base branch' });
|
|
1572
|
+
}
|
|
1573
|
+
baseBranch = detection.baseBranch;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// Validate branch name to prevent shell injection
|
|
1577
|
+
if (!/^[\w.\-/]+$/.test(baseBranch)) {
|
|
1578
|
+
return res.status(400).json({ error: 'Invalid branch name' });
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
logger.log('API', `Setting scope on review #${reviewId}: ${scopeStart}–${scopeEnd}${baseBranch ? ` (base: ${baseBranch})` : ''}`, 'cyan');
|
|
1583
|
+
|
|
1584
|
+
// Generate diff for the new scope
|
|
1585
|
+
const { diff, stats, mergeBaseSha } = await generateScopedDiff(localPath, scopeStart, scopeEnd, baseBranch);
|
|
1586
|
+
|
|
1587
|
+
// Get the HEAD SHA for staleness tracking
|
|
1588
|
+
const { getHeadSha } = require('../local-review');
|
|
1589
|
+
const headSha = await getHeadSha(localPath);
|
|
1590
|
+
|
|
1591
|
+
// Update the review record with new scope (headBranch stored on branch scope, cleared otherwise)
|
|
1592
|
+
await reviewRepo.updateLocalScope(reviewId, scopeStart, scopeEnd, baseBranch, currentBranch);
|
|
1593
|
+
await reviewRepo.updateLocalHeadSha(reviewId, headSha);
|
|
1594
|
+
|
|
1595
|
+
// Auto-name review from first commit subject when branch is newly in scope
|
|
1596
|
+
const oldScopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
|
|
1597
|
+
if (!review.name && includesBranch(scopeStart) && !includesBranch(oldScopeStart) && baseBranch) {
|
|
1598
|
+
const firstSubject = await getFirstCommitSubject(localPath, baseBranch);
|
|
1599
|
+
if (firstSubject) {
|
|
1600
|
+
await reviewRepo.updateReview(reviewId, { name: firstSubject.slice(0, 200) });
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// Compute digest
|
|
1605
|
+
const digest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
|
|
1606
|
+
|
|
1607
|
+
// Store diff in cache and DB
|
|
1608
|
+
setLocalReviewDiff(reviewId, { diff, stats, digest });
|
|
1609
|
+
await reviewRepo.saveLocalDiff(reviewId, { diff, stats, digest });
|
|
1610
|
+
|
|
1611
|
+
logger.success(`Review #${reviewId} scope set to ${scopeStart}–${scopeEnd}: ${stats.trackedChanges || 0} file(s) changed`);
|
|
1612
|
+
|
|
1613
|
+
res.json({
|
|
1614
|
+
success: true,
|
|
1615
|
+
scopeStart,
|
|
1616
|
+
scopeEnd,
|
|
1617
|
+
localMode: includesBranch(scopeStart) ? 'branch' : 'uncommitted',
|
|
1618
|
+
baseBranch,
|
|
1619
|
+
mergeBaseSha,
|
|
1620
|
+
stats: {
|
|
1621
|
+
trackedChanges: stats.trackedChanges || 0,
|
|
1622
|
+
untrackedFiles: stats.untrackedFiles || 0,
|
|
1623
|
+
stagedChanges: stats.stagedChanges || 0,
|
|
1624
|
+
unstagedChanges: stats.unstagedChanges || 0
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
logger.error(`Error setting scope: ${error.message}`);
|
|
1630
|
+
res.status(500).json({ error: 'Failed to set scope: ' + error.message });
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* Save "don't ask again" preference for branch review
|
|
1636
|
+
*/
|
|
1637
|
+
router.post('/api/local/:reviewId/branch-review-preference', async (req, res) => {
|
|
1638
|
+
try {
|
|
1639
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1640
|
+
|
|
1641
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1642
|
+
return res.status(400).json({ error: 'Invalid review ID' });
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
const { preference } = req.body || {};
|
|
1646
|
+
if (![0, 1, -1].includes(preference)) {
|
|
1647
|
+
return res.status(400).json({ error: 'Invalid preference value. Must be 0, 1, or -1.' });
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const db = req.app.get('db');
|
|
1651
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1652
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
1653
|
+
|
|
1654
|
+
if (!review) {
|
|
1655
|
+
return res.status(404).json({ error: `Local review #${reviewId} not found` });
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const repository = review.repository;
|
|
1659
|
+
if (!repository || !repository.includes('/')) {
|
|
1660
|
+
return res.status(400).json({ error: 'Cannot save preference: no repository identified' });
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1664
|
+
const existing = await repoSettingsRepo.getRepoSettings(repository);
|
|
1665
|
+
|
|
1666
|
+
if (existing) {
|
|
1667
|
+
await run(db, `
|
|
1668
|
+
UPDATE repo_settings SET auto_branch_review = ?, updated_at = ? WHERE repository = ? COLLATE NOCASE
|
|
1669
|
+
`, [preference, new Date().toISOString(), repository]);
|
|
1670
|
+
} else {
|
|
1671
|
+
await run(db, `
|
|
1672
|
+
INSERT INTO repo_settings (repository, auto_branch_review, created_at, updated_at) VALUES (?, ?, ?, ?)
|
|
1673
|
+
`, [repository, preference, new Date().toISOString(), new Date().toISOString()]);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
res.json({ success: true, preference });
|
|
1677
|
+
|
|
1678
|
+
} catch (error) {
|
|
1679
|
+
logger.error(`Error saving branch review preference: ${error.message}`);
|
|
1680
|
+
res.status(500).json({ error: 'Failed to save preference' });
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1041
1684
|
/**
|
|
1042
1685
|
* Get review settings for a local review
|
|
1043
1686
|
* Returns the custom_instructions from the review record
|
|
@@ -1180,21 +1823,39 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
|
|
|
1180
1823
|
|
|
1181
1824
|
const localPath = review.local_path;
|
|
1182
1825
|
|
|
1826
|
+
const councilScopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
|
|
1827
|
+
const councilScopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
1828
|
+
const councilHasBranch = includesBranch(councilScopeStart);
|
|
1829
|
+
|
|
1830
|
+
// Compute merge-base when branch is in scope
|
|
1831
|
+
let analysisBaseSha = review.local_head_sha;
|
|
1832
|
+
if (councilHasBranch && review.local_base_branch) {
|
|
1833
|
+
try {
|
|
1834
|
+
analysisBaseSha = await findMergeBase(localPath, review.local_base_branch);
|
|
1835
|
+
} catch {
|
|
1836
|
+
// Fall back to HEAD
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1183
1840
|
const prMetadata = {
|
|
1184
1841
|
reviewType: 'local',
|
|
1185
1842
|
repository: review.repository,
|
|
1186
|
-
title: review.name || 'Local changes',
|
|
1843
|
+
title: review.name || (councilHasBranch ? `Branch changes: ${review.local_base_branch}..HEAD` : 'Local changes'),
|
|
1187
1844
|
description: '',
|
|
1845
|
+
base_sha: analysisBaseSha,
|
|
1188
1846
|
head_sha: review.local_head_sha
|
|
1189
1847
|
};
|
|
1190
1848
|
|
|
1191
1849
|
const analyzer = new Analyzer(db, 'council', 'council');
|
|
1192
|
-
const
|
|
1850
|
+
const councilHasStaged = scopeIncludes(councilScopeStart, councilScopeEnd, 'staged');
|
|
1851
|
+
const changedFiles = councilHasBranch
|
|
1852
|
+
? null
|
|
1853
|
+
: await analyzer.getLocalChangedFiles(localPath, { includeStaged: councilHasStaged });
|
|
1193
1854
|
|
|
1194
1855
|
// Generate and cache diff
|
|
1195
1856
|
try {
|
|
1196
|
-
const diffResult = await
|
|
1197
|
-
const digest = await
|
|
1857
|
+
const diffResult = await generateScopedDiff(localPath, councilScopeStart, councilScopeEnd, review.local_base_branch);
|
|
1858
|
+
const digest = await computeScopedDigest(localPath, councilScopeStart, councilScopeEnd);
|
|
1198
1859
|
setLocalReviewDiff(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
|
|
1199
1860
|
} catch (diffError) {
|
|
1200
1861
|
logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
|
|
@@ -1226,7 +1887,12 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
|
|
|
1226
1887
|
headSha: review.local_head_sha,
|
|
1227
1888
|
logLabel: `local review #${reviewId}`,
|
|
1228
1889
|
initialStatusExtra: { reviewId, reviewType: 'local' },
|
|
1229
|
-
|
|
1890
|
+
config: req.app.get('config') || {},
|
|
1891
|
+
hookContext: {
|
|
1892
|
+
mode: 'local',
|
|
1893
|
+
localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
|
|
1894
|
+
},
|
|
1895
|
+
runUpdateExtra: { filesAnalyzed: changedFiles ? changedFiles.length : 0 }
|
|
1230
1896
|
},
|
|
1231
1897
|
councilConfig,
|
|
1232
1898
|
councilId,
|