@in-the-loop-labs/pair-review 1.3.2 → 1.4.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/README.md +67 -38
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/index.html +270 -623
- package/public/js/index.js +1071 -0
- package/public/js/local.js +80 -0
- package/public/js/modules/analysis-history.js +5 -1
- package/public/local.html +45 -2
- package/src/ai/claude-provider.js +12 -7
- package/src/ai/codex-provider.js +9 -7
- package/src/ai/cursor-agent-provider.js +9 -6
- package/src/ai/gemini-provider.js +9 -7
- package/src/ai/index.js +1 -0
- package/src/ai/opencode-provider.js +9 -7
- package/src/ai/pi-provider.js +859 -0
- package/src/ai/provider.js +32 -8
- package/src/ai/stream-parser.js +171 -2
- package/src/config.js +1 -1
- package/src/database.js +170 -40
- package/src/local-review.js +9 -0
- package/src/routes/local.js +390 -41
- package/src/utils/json-extractor.js +129 -39
package/src/ai/provider.js
CHANGED
|
@@ -494,6 +494,28 @@ function getRegisteredProviderIds() {
|
|
|
494
494
|
return Array.from(providerRegistry.keys());
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
+
/**
|
|
498
|
+
* Merge config-override models with a provider's built-in models.
|
|
499
|
+
* Config models with matching IDs replace built-ins; config models with new IDs
|
|
500
|
+
* are appended. If no config models exist, returns built-ins unchanged.
|
|
501
|
+
*
|
|
502
|
+
* @param {Array<Object>} builtInModels - Models from ProviderClass.getModels()
|
|
503
|
+
* @param {Array<Object>|undefined} configModels - Models from config overrides
|
|
504
|
+
* @returns {Array<Object>} Merged model list
|
|
505
|
+
*/
|
|
506
|
+
function mergeModels(builtInModels, configModels) {
|
|
507
|
+
if (!configModels || configModels.length === 0) {
|
|
508
|
+
return builtInModels;
|
|
509
|
+
}
|
|
510
|
+
const configById = new Map(configModels.map(m => [m.id, m]));
|
|
511
|
+
// Replace overridden built-ins in-place to preserve display order
|
|
512
|
+
const merged = builtInModels.map(m => configById.get(m.id) || m);
|
|
513
|
+
// Append config models with new IDs not present in built-ins
|
|
514
|
+
const builtInIds = new Set(builtInModels.map(m => m.id));
|
|
515
|
+
const newModels = configModels.filter(m => !builtInIds.has(m.id));
|
|
516
|
+
return [...merged, ...newModels];
|
|
517
|
+
}
|
|
518
|
+
|
|
497
519
|
/**
|
|
498
520
|
* Get provider info for all registered providers
|
|
499
521
|
* Uses config overrides for models/installInstructions if available
|
|
@@ -504,10 +526,10 @@ function getAllProvidersInfo() {
|
|
|
504
526
|
for (const [id, ProviderClass] of providerRegistry) {
|
|
505
527
|
const overrides = providerConfigOverrides.get(id);
|
|
506
528
|
|
|
507
|
-
//
|
|
508
|
-
const models =
|
|
529
|
+
// Merge config models with built-in models (config wins on ID collision)
|
|
530
|
+
const models = mergeModels(ProviderClass.getModels(), overrides?.models);
|
|
509
531
|
|
|
510
|
-
// Resolve default model from
|
|
532
|
+
// Resolve default model from merged models array
|
|
511
533
|
const defaultModel = resolveDefaultModel(models) || ProviderClass.getDefaultModel();
|
|
512
534
|
|
|
513
535
|
// Use overridden install instructions if available
|
|
@@ -545,9 +567,11 @@ function createProvider(providerId, model = null) {
|
|
|
545
567
|
// Determine the actual model to use
|
|
546
568
|
let actualModel = model;
|
|
547
569
|
if (!actualModel) {
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
|
|
570
|
+
// Resolve default from merged models (config + built-in).
|
|
571
|
+
// Checks both sources because some providers (e.g., Pi) define built-in
|
|
572
|
+
// modes with default:true that aren't in config overrides.
|
|
573
|
+
if (overrides?.models || ProviderClass.getModels().length > 0) {
|
|
574
|
+
actualModel = resolveDefaultModel(mergeModels(ProviderClass.getModels(), overrides?.models));
|
|
551
575
|
}
|
|
552
576
|
// Fall back to provider's built-in default
|
|
553
577
|
if (!actualModel) {
|
|
@@ -572,9 +596,9 @@ function getTierForModel(providerId, modelId) {
|
|
|
572
596
|
return null;
|
|
573
597
|
}
|
|
574
598
|
|
|
575
|
-
//
|
|
599
|
+
// Merge config models with built-in models
|
|
576
600
|
const overrides = providerConfigOverrides.get(providerId);
|
|
577
|
-
const models =
|
|
601
|
+
const models = mergeModels(ProviderClass.getModels(), overrides?.models);
|
|
578
602
|
|
|
579
603
|
const model = models.find(m => m.id === modelId);
|
|
580
604
|
return model?.tier || null;
|
package/src/ai/stream-parser.js
CHANGED
|
@@ -71,8 +71,9 @@ function extractToolDetail(input, cwd) {
|
|
|
71
71
|
// Command execution (bash, shell, etc.)
|
|
72
72
|
if (parsed.command) return parsed.command;
|
|
73
73
|
|
|
74
|
-
// Task/agent description (Claude Code Task tool, etc.)
|
|
74
|
+
// Task/agent description (Claude Code Task tool, Pi task extension, etc.)
|
|
75
75
|
if (parsed.description) return parsed.description;
|
|
76
|
+
if (parsed.task) return parsed.task;
|
|
76
77
|
|
|
77
78
|
// File path (various field naming conventions)
|
|
78
79
|
const rawPath = parsed.file_path || parsed.filePath || parsed.path;
|
|
@@ -462,6 +463,172 @@ function parseCursorAgentLine(line, options = {}) {
|
|
|
462
463
|
}
|
|
463
464
|
}
|
|
464
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Parse a single Pi JSONL line into a normalized event.
|
|
468
|
+
* Returns null if the line should not be emitted.
|
|
469
|
+
*
|
|
470
|
+
* Pi JSONL event types:
|
|
471
|
+
* - message_update (assistantMessageEvent.type === 'text_delta') → assistant_text
|
|
472
|
+
* - message_end (role: 'assistant', content blocks with text) → assistant_text
|
|
473
|
+
* - tool_execution_start (toolName, args) → tool_use
|
|
474
|
+
* - session, turn_start, turn_end, message_start, tool_execution_update,
|
|
475
|
+
* tool_execution_end, agent_start, agent_end → filtered out
|
|
476
|
+
*
|
|
477
|
+
* @param {string} line - A single JSONL line from Pi stdout
|
|
478
|
+
* @param {Object} [options] - Parse options
|
|
479
|
+
* @param {string} [options.cwd] - Working directory to strip from file paths
|
|
480
|
+
* @returns {{ type: string, text: string, timestamp: number } | null}
|
|
481
|
+
*/
|
|
482
|
+
function parsePiLine(line, options = {}) {
|
|
483
|
+
if (!line || !line.trim()) return null;
|
|
484
|
+
|
|
485
|
+
const { cwd } = options;
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
const event = JSON.parse(line);
|
|
489
|
+
const eventType = event.type;
|
|
490
|
+
|
|
491
|
+
// Streaming text deltas from message_update events
|
|
492
|
+
if (eventType === 'message_update') {
|
|
493
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
494
|
+
if (assistantEvent?.type === 'text_delta' && assistantEvent?.delta?.trim()) {
|
|
495
|
+
return {
|
|
496
|
+
type: 'assistant_text',
|
|
497
|
+
text: truncateSnippet(assistantEvent.delta),
|
|
498
|
+
timestamp: Date.now()
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Complete assistant message from message_end
|
|
505
|
+
if (eventType === 'message_end' && event.message?.role === 'assistant') {
|
|
506
|
+
const content = event.message.content;
|
|
507
|
+
if (Array.isArray(content)) {
|
|
508
|
+
for (const block of content) {
|
|
509
|
+
if (block.type === 'text' && block.text?.trim()) {
|
|
510
|
+
return {
|
|
511
|
+
type: 'assistant_text',
|
|
512
|
+
text: truncateSnippet(block.text),
|
|
513
|
+
timestamp: Date.now()
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} else if (typeof content === 'string' && content.trim()) {
|
|
518
|
+
return {
|
|
519
|
+
type: 'assistant_text',
|
|
520
|
+
text: truncateSnippet(content),
|
|
521
|
+
timestamp: Date.now()
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Tool execution start events
|
|
528
|
+
if (eventType === 'tool_execution_start') {
|
|
529
|
+
const toolName = event.toolName || 'unknown';
|
|
530
|
+
const detail = extractToolDetail(event.args, cwd);
|
|
531
|
+
const text = detail ? `${toolName}: ${detail}` : toolName;
|
|
532
|
+
return {
|
|
533
|
+
type: 'tool_use',
|
|
534
|
+
text: truncateSnippet(text),
|
|
535
|
+
timestamp: Date.now()
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Tool execution end — emit for task tools to show completion status
|
|
540
|
+
if (eventType === 'tool_execution_end') {
|
|
541
|
+
const toolName = event.toolName || '';
|
|
542
|
+
if (toolName === 'task') {
|
|
543
|
+
const isError = event.isError || false;
|
|
544
|
+
const result = event.result || '';
|
|
545
|
+
// Extract a short summary from the result
|
|
546
|
+
let summary = '';
|
|
547
|
+
if (typeof result === 'string') {
|
|
548
|
+
// First non-empty line as preview
|
|
549
|
+
const firstLine = result.split('\n').find(l => l.trim());
|
|
550
|
+
if (firstLine) summary = firstLine.trim();
|
|
551
|
+
}
|
|
552
|
+
const status = isError ? '✗' : '✓';
|
|
553
|
+
const text = summary
|
|
554
|
+
? `task ${status}: ${summary}`
|
|
555
|
+
: `task ${status}`;
|
|
556
|
+
return {
|
|
557
|
+
type: 'tool_use',
|
|
558
|
+
text: truncateSnippet(text),
|
|
559
|
+
timestamp: Date.now()
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// session, turn_start, turn_end, message_start, tool_execution_update,
|
|
565
|
+
// tool_execution_end (non-task), agent_start, agent_end — never emit
|
|
566
|
+
return null;
|
|
567
|
+
} catch {
|
|
568
|
+
// Best-effort side channel — silently ignore non-JSON or malformed lines.
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Create a stateful Pi line parser that accumulates text_delta fragments
|
|
575
|
+
* before emitting. This prevents flooding the UI with tiny text updates.
|
|
576
|
+
*
|
|
577
|
+
* Accumulates text_delta content and only emits an assistant_text event
|
|
578
|
+
* when enough text has been collected (>= 80 chars). When a non-text-delta
|
|
579
|
+
* event arrives (tool_execution_start, message_end, etc.), accumulated text
|
|
580
|
+
* is discarded in favor of the more informative event.
|
|
581
|
+
*
|
|
582
|
+
* The returned function has the same signature as parsePiLine and can be
|
|
583
|
+
* used as a drop-in replacement with StreamParser.
|
|
584
|
+
*
|
|
585
|
+
* @returns {Function} Stateful line parser with same signature as parsePiLine
|
|
586
|
+
*/
|
|
587
|
+
function createPiLineParser() {
|
|
588
|
+
let accumulatedDelta = '';
|
|
589
|
+
|
|
590
|
+
return function parsePiLineBuffered(line, options = {}) {
|
|
591
|
+
if (!line || !line.trim()) return null;
|
|
592
|
+
|
|
593
|
+
try {
|
|
594
|
+
const event = JSON.parse(line);
|
|
595
|
+
const eventType = event.type;
|
|
596
|
+
|
|
597
|
+
// Accumulate text_delta fragments instead of emitting each one
|
|
598
|
+
if (eventType === 'message_update') {
|
|
599
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
600
|
+
if (assistantEvent?.type === 'text_delta' && assistantEvent?.delta) {
|
|
601
|
+
accumulatedDelta += assistantEvent.delta;
|
|
602
|
+
// Only emit when we have accumulated a meaningful chunk
|
|
603
|
+
if (accumulatedDelta.length >= 80) {
|
|
604
|
+
const text = accumulatedDelta;
|
|
605
|
+
accumulatedDelta = '';
|
|
606
|
+
return {
|
|
607
|
+
type: 'assistant_text',
|
|
608
|
+
text: truncateSnippet(text),
|
|
609
|
+
timestamp: Date.now()
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
// Not enough accumulated yet — suppress this event
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Non-text-delta event: discard accumulated text (the real event is
|
|
619
|
+
// more informative) and reset the buffer. The accumulated deltas are
|
|
620
|
+
// only preview snippets; authoritative text extraction happens in
|
|
621
|
+
// parsePiResponse.
|
|
622
|
+
accumulatedDelta = '';
|
|
623
|
+
|
|
624
|
+
// Fall through to stateless parsing for non-text-delta events
|
|
625
|
+
return parsePiLine(line, options);
|
|
626
|
+
} catch {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
465
632
|
module.exports = {
|
|
466
633
|
StreamParser,
|
|
467
634
|
truncateSnippet,
|
|
@@ -471,5 +638,7 @@ module.exports = {
|
|
|
471
638
|
parseCodexLine,
|
|
472
639
|
parseGeminiLine,
|
|
473
640
|
parseOpenCodeLine,
|
|
474
|
-
parseCursorAgentLine
|
|
641
|
+
parseCursorAgentLine,
|
|
642
|
+
parsePiLine,
|
|
643
|
+
createPiLineParser
|
|
475
644
|
};
|
package/src/config.js
CHANGED
|
@@ -13,7 +13,7 @@ const DEFAULT_CONFIG = {
|
|
|
13
13
|
github_token: "",
|
|
14
14
|
port: 7247,
|
|
15
15
|
theme: "light",
|
|
16
|
-
default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode'
|
|
16
|
+
default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
|
|
17
17
|
default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
|
|
18
18
|
worktree_retention_days: 7,
|
|
19
19
|
dev_mode: false, // When true, disables static file caching for development
|
package/src/database.js
CHANGED
|
@@ -9,7 +9,7 @@ const DB_PATH = path.join(getConfigDir(), 'database.db');
|
|
|
9
9
|
/**
|
|
10
10
|
* Current schema version - increment this when adding new migrations
|
|
11
11
|
*/
|
|
12
|
-
const CURRENT_SCHEMA_VERSION =
|
|
12
|
+
const CURRENT_SCHEMA_VERSION = 14;
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Database schema SQL statements
|
|
@@ -30,7 +30,8 @@ const SCHEMA_SQL = {
|
|
|
30
30
|
review_type TEXT DEFAULT 'pr' CHECK(review_type IN ('pr', 'local')),
|
|
31
31
|
local_path TEXT,
|
|
32
32
|
local_head_sha TEXT,
|
|
33
|
-
summary TEXT
|
|
33
|
+
summary TEXT,
|
|
34
|
+
name TEXT
|
|
34
35
|
)
|
|
35
36
|
`,
|
|
36
37
|
|
|
@@ -146,6 +147,17 @@ const SCHEMA_SQL = {
|
|
|
146
147
|
)
|
|
147
148
|
`,
|
|
148
149
|
|
|
150
|
+
local_diffs: `
|
|
151
|
+
CREATE TABLE IF NOT EXISTS local_diffs (
|
|
152
|
+
review_id INTEGER PRIMARY KEY,
|
|
153
|
+
diff_text TEXT,
|
|
154
|
+
stats TEXT,
|
|
155
|
+
digest TEXT,
|
|
156
|
+
captured_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
157
|
+
FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
|
|
158
|
+
)
|
|
159
|
+
`,
|
|
160
|
+
|
|
149
161
|
github_reviews: `
|
|
150
162
|
CREATE TABLE IF NOT EXISTS github_reviews (
|
|
151
163
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -212,6 +224,20 @@ function columnExists(db, table, column) {
|
|
|
212
224
|
return rows ? rows.some(row => row.name === column) : false;
|
|
213
225
|
}
|
|
214
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Helper to check if a table exists in the database
|
|
229
|
+
* Used by migrations to safely create tables idempotently
|
|
230
|
+
* @param {Database} db - Database instance
|
|
231
|
+
* @param {string} tableName - Table name
|
|
232
|
+
* @returns {boolean} True if table exists
|
|
233
|
+
*/
|
|
234
|
+
function tableExists(db, tableName) {
|
|
235
|
+
const row = db.prepare(
|
|
236
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`
|
|
237
|
+
).get(tableName);
|
|
238
|
+
return !!row;
|
|
239
|
+
}
|
|
240
|
+
|
|
215
241
|
const MIGRATIONS = {
|
|
216
242
|
// Migration to version 1: handles all legacy column additions
|
|
217
243
|
1: (db) => {
|
|
@@ -234,14 +260,6 @@ const MIGRATIONS = {
|
|
|
234
260
|
}
|
|
235
261
|
};
|
|
236
262
|
|
|
237
|
-
// Helper to check if table exists
|
|
238
|
-
const tableExists = (tableName) => {
|
|
239
|
-
const row = db.prepare(
|
|
240
|
-
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`
|
|
241
|
-
).get(tableName);
|
|
242
|
-
return !!row;
|
|
243
|
-
};
|
|
244
|
-
|
|
245
263
|
// Add columns to comments table
|
|
246
264
|
addColumnIfNotExists('comments', 'diff_position', 'INTEGER');
|
|
247
265
|
addColumnIfNotExists('comments', 'side', "TEXT DEFAULT 'RIGHT'");
|
|
@@ -252,7 +270,7 @@ const MIGRATIONS = {
|
|
|
252
270
|
addColumnIfNotExists('reviews', 'custom_instructions', 'TEXT');
|
|
253
271
|
|
|
254
272
|
// Create repo_settings table if not exists
|
|
255
|
-
const hasRepoSettings = tableExists('repo_settings');
|
|
273
|
+
const hasRepoSettings = tableExists(db, 'repo_settings');
|
|
256
274
|
if (!hasRepoSettings) {
|
|
257
275
|
console.log(' Creating repo_settings table...');
|
|
258
276
|
db.exec(SCHEMA_SQL.repo_settings);
|
|
@@ -280,16 +298,8 @@ const MIGRATIONS = {
|
|
|
280
298
|
3: (db) => {
|
|
281
299
|
console.log('Running migration to schema version 3...');
|
|
282
300
|
|
|
283
|
-
// Helper to check if table exists
|
|
284
|
-
const tableExists = (tableName) => {
|
|
285
|
-
const row = db.prepare(
|
|
286
|
-
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`
|
|
287
|
-
).get(tableName);
|
|
288
|
-
return !!row;
|
|
289
|
-
};
|
|
290
|
-
|
|
291
301
|
// First ensure pr_metadata table exists
|
|
292
|
-
const hasPrMetadata = tableExists('pr_metadata');
|
|
302
|
+
const hasPrMetadata = tableExists(db, 'pr_metadata');
|
|
293
303
|
if (!hasPrMetadata) {
|
|
294
304
|
console.log(' Creating pr_metadata table...');
|
|
295
305
|
db.exec(SCHEMA_SQL.pr_metadata);
|
|
@@ -451,16 +461,8 @@ const MIGRATIONS = {
|
|
|
451
461
|
8: (db) => {
|
|
452
462
|
console.log('Running migration to schema version 8...');
|
|
453
463
|
|
|
454
|
-
// Helper to check if table exists
|
|
455
|
-
const tableExists = (tableName) => {
|
|
456
|
-
const row = db.prepare(
|
|
457
|
-
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`
|
|
458
|
-
).get(tableName);
|
|
459
|
-
return !!row;
|
|
460
|
-
};
|
|
461
|
-
|
|
462
464
|
// Create analysis_runs table if it doesn't exist
|
|
463
|
-
if (!tableExists('analysis_runs')) {
|
|
465
|
+
if (!tableExists(db, 'analysis_runs')) {
|
|
464
466
|
db.exec(`
|
|
465
467
|
CREATE TABLE analysis_runs (
|
|
466
468
|
id TEXT PRIMARY KEY,
|
|
@@ -609,16 +611,8 @@ const MIGRATIONS = {
|
|
|
609
611
|
13: (db) => {
|
|
610
612
|
console.log('Running migration to schema version 13...');
|
|
611
613
|
|
|
612
|
-
// Helper to check if table exists
|
|
613
|
-
const tableExists = (tableName) => {
|
|
614
|
-
const row = db.prepare(
|
|
615
|
-
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`
|
|
616
|
-
).get(tableName);
|
|
617
|
-
return !!row;
|
|
618
|
-
};
|
|
619
|
-
|
|
620
614
|
// Create github_reviews table if it doesn't exist
|
|
621
|
-
if (!tableExists('github_reviews')) {
|
|
615
|
+
if (!tableExists(db, 'github_reviews')) {
|
|
622
616
|
db.exec(`
|
|
623
617
|
CREATE TABLE github_reviews (
|
|
624
618
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -644,6 +638,51 @@ const MIGRATIONS = {
|
|
|
644
638
|
}
|
|
645
639
|
|
|
646
640
|
console.log('Migration to schema version 13 complete');
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
// Migration to version 14: adds name column to reviews and creates local_diffs table
|
|
644
|
+
14: (db) => {
|
|
645
|
+
console.log('Running migration to schema version 14...');
|
|
646
|
+
|
|
647
|
+
// Add name column to reviews if it doesn't exist
|
|
648
|
+
const hasName = columnExists(db, 'reviews', 'name');
|
|
649
|
+
if (!hasName) {
|
|
650
|
+
try {
|
|
651
|
+
db.prepare(`ALTER TABLE reviews ADD COLUMN name TEXT`).run();
|
|
652
|
+
console.log(' Added name column to reviews');
|
|
653
|
+
} catch (error) {
|
|
654
|
+
// Ignore duplicate column errors (race condition protection)
|
|
655
|
+
if (!error.message.includes('duplicate column name')) {
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
658
|
+
console.log(' Column name already exists (race condition)');
|
|
659
|
+
}
|
|
660
|
+
} else {
|
|
661
|
+
console.log(' Column name already exists');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Create local_diffs table if it doesn't exist
|
|
665
|
+
if (!tableExists(db, 'local_diffs')) {
|
|
666
|
+
db.exec(`
|
|
667
|
+
CREATE TABLE local_diffs (
|
|
668
|
+
review_id INTEGER PRIMARY KEY,
|
|
669
|
+
diff_text TEXT,
|
|
670
|
+
stats TEXT,
|
|
671
|
+
digest TEXT,
|
|
672
|
+
captured_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
673
|
+
FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
|
|
674
|
+
)
|
|
675
|
+
`);
|
|
676
|
+
console.log(' Created local_diffs table');
|
|
677
|
+
} else {
|
|
678
|
+
console.log(' Table local_diffs already exists');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Add index for listing local sessions (WHERE review_type = 'local' ORDER BY updated_at DESC)
|
|
682
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_reviews_type_updated ON reviews(review_type, updated_at DESC)');
|
|
683
|
+
console.log(' Created index idx_reviews_type_updated');
|
|
684
|
+
|
|
685
|
+
console.log('Migration to schema version 14 complete');
|
|
647
686
|
}
|
|
648
687
|
};
|
|
649
688
|
|
|
@@ -1808,6 +1847,11 @@ class ReviewRepository {
|
|
|
1808
1847
|
params.push(updates.summary);
|
|
1809
1848
|
}
|
|
1810
1849
|
|
|
1850
|
+
if (updates.name !== undefined) {
|
|
1851
|
+
setClauses.push('name = ?');
|
|
1852
|
+
params.push(updates.name);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1811
1855
|
if (updates.submittedAt !== undefined) {
|
|
1812
1856
|
setClauses.push('submitted_at = ?');
|
|
1813
1857
|
const submittedAt = updates.submittedAt instanceof Date
|
|
@@ -2037,7 +2081,7 @@ class ReviewRepository {
|
|
|
2037
2081
|
const row = await queryOne(this.db, `
|
|
2038
2082
|
SELECT id, pr_number, repository, status, review_id,
|
|
2039
2083
|
created_at, updated_at, submitted_at, review_data, custom_instructions,
|
|
2040
|
-
review_type, local_path, local_head_sha, summary
|
|
2084
|
+
review_type, local_path, local_head_sha, summary, name
|
|
2041
2085
|
FROM reviews
|
|
2042
2086
|
WHERE review_type = 'local' AND local_path = ? AND local_head_sha = ?
|
|
2043
2087
|
`, [localPath, localHeadSha]);
|
|
@@ -2059,7 +2103,7 @@ class ReviewRepository {
|
|
|
2059
2103
|
const row = await queryOne(this.db, `
|
|
2060
2104
|
SELECT id, pr_number, repository, status, review_id,
|
|
2061
2105
|
created_at, updated_at, submitted_at, review_data, custom_instructions,
|
|
2062
|
-
review_type, local_path, local_head_sha, summary
|
|
2106
|
+
review_type, local_path, local_head_sha, summary, name
|
|
2063
2107
|
FROM reviews
|
|
2064
2108
|
WHERE id = ? AND review_type = 'local'
|
|
2065
2109
|
`, [id]);
|
|
@@ -2099,6 +2143,92 @@ class ReviewRepository {
|
|
|
2099
2143
|
|
|
2100
2144
|
return this.createReview({ prNumber, repository, summary });
|
|
2101
2145
|
}
|
|
2146
|
+
|
|
2147
|
+
/**
|
|
2148
|
+
* List local review sessions with cursor-based pagination
|
|
2149
|
+
* @param {Object} options - Pagination options
|
|
2150
|
+
* @param {number} [options.limit=10] - Maximum number of sessions to return
|
|
2151
|
+
* @param {string} [options.before] - ISO timestamp cursor (return sessions updated before this)
|
|
2152
|
+
* @returns {Promise<{sessions: Array<Object>, hasMore: boolean}>}
|
|
2153
|
+
*/
|
|
2154
|
+
async listLocalSessions({ limit = 10, before } = {}) {
|
|
2155
|
+
const params = [];
|
|
2156
|
+
let whereClause = "WHERE review_type = 'local'";
|
|
2157
|
+
|
|
2158
|
+
if (before) {
|
|
2159
|
+
whereClause += ' AND updated_at < ?';
|
|
2160
|
+
params.push(before);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// Fetch one extra to determine hasMore
|
|
2164
|
+
params.push(limit + 1);
|
|
2165
|
+
|
|
2166
|
+
const rows = await query(this.db, `
|
|
2167
|
+
SELECT id, name, repository, local_path, local_head_sha, created_at, updated_at
|
|
2168
|
+
FROM reviews
|
|
2169
|
+
${whereClause}
|
|
2170
|
+
ORDER BY updated_at DESC
|
|
2171
|
+
LIMIT ?
|
|
2172
|
+
`, params);
|
|
2173
|
+
|
|
2174
|
+
const hasMore = rows.length > limit;
|
|
2175
|
+
const sessions = hasMore ? rows.slice(0, limit) : rows;
|
|
2176
|
+
|
|
2177
|
+
return { sessions, hasMore };
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/**
|
|
2181
|
+
* Save or update a local diff snapshot in the database
|
|
2182
|
+
* Uses INSERT OR REPLACE for upsert behavior
|
|
2183
|
+
* @param {number} reviewId - Review ID
|
|
2184
|
+
* @param {Object} diffData - Diff data to persist
|
|
2185
|
+
* @param {string} diffData.diff - The diff text content
|
|
2186
|
+
* @param {Object} diffData.stats - Stats object (will be JSON-stringified)
|
|
2187
|
+
* @param {string} [diffData.digest] - Content digest for staleness detection
|
|
2188
|
+
* @returns {Promise<void>}
|
|
2189
|
+
*/
|
|
2190
|
+
async saveLocalDiff(reviewId, { diff, stats, digest }) {
|
|
2191
|
+
await run(this.db, `
|
|
2192
|
+
INSERT OR REPLACE INTO local_diffs (review_id, diff_text, stats, digest, captured_at)
|
|
2193
|
+
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
2194
|
+
`, [reviewId, diff || '', JSON.stringify(stats || {}), digest || null]);
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
/**
|
|
2198
|
+
* Get a persisted local diff from the database
|
|
2199
|
+
* @param {number} reviewId - Review ID
|
|
2200
|
+
* @returns {Promise<{diff: string, stats: Object, digest: string|null}|null>}
|
|
2201
|
+
*/
|
|
2202
|
+
async getLocalDiff(reviewId) {
|
|
2203
|
+
const row = await queryOne(this.db, `
|
|
2204
|
+
SELECT diff_text, stats, digest FROM local_diffs WHERE review_id = ?
|
|
2205
|
+
`, [reviewId]);
|
|
2206
|
+
|
|
2207
|
+
if (!row) return null;
|
|
2208
|
+
|
|
2209
|
+
return {
|
|
2210
|
+
diff: row.diff_text || '',
|
|
2211
|
+
stats: row.stats ? JSON.parse(row.stats) : {},
|
|
2212
|
+
digest: row.digest || null
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
/**
|
|
2217
|
+
* Delete a local review session and all associated data.
|
|
2218
|
+
* Only deletes DB records; does NOT remove files on disk.
|
|
2219
|
+
*
|
|
2220
|
+
* Because the schema uses ON DELETE CASCADE for foreign keys on local_diffs,
|
|
2221
|
+
* comments, and analysis_runs, deleting the review row cascades automatically.
|
|
2222
|
+
*
|
|
2223
|
+
* @param {number} reviewId - Review ID
|
|
2224
|
+
* @returns {Promise<boolean>} True if a record was deleted
|
|
2225
|
+
*/
|
|
2226
|
+
async deleteLocalSession(reviewId) {
|
|
2227
|
+
const result = await run(this.db, `
|
|
2228
|
+
DELETE FROM reviews WHERE id = ? AND review_type = 'local'
|
|
2229
|
+
`, [reviewId]);
|
|
2230
|
+
return result.changes > 0;
|
|
2231
|
+
}
|
|
2102
2232
|
}
|
|
2103
2233
|
|
|
2104
2234
|
/**
|
package/src/local-review.js
CHANGED
|
@@ -5,6 +5,7 @@ const crypto = require('crypto');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const fs = require('fs').promises;
|
|
7
7
|
const { loadConfig, showWelcomeMessage } = require('./config');
|
|
8
|
+
const logger = require('./utils/logger');
|
|
8
9
|
|
|
9
10
|
const execAsync = promisify(exec);
|
|
10
11
|
const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
|
|
@@ -594,6 +595,14 @@ async function handleLocalReview(targetPath, flags = {}) {
|
|
|
594
595
|
// Store diff data in module-level Map (avoids process.env size limits and security concerns)
|
|
595
596
|
localReviewDiffs.set(sessionId, { diff, stats, digest });
|
|
596
597
|
|
|
598
|
+
// Persist diff to database so past sessions remain viewable without the server running
|
|
599
|
+
try {
|
|
600
|
+
const reviewRepo = new ReviewRepository(db);
|
|
601
|
+
await reviewRepo.saveLocalDiff(sessionId, { diff, stats, digest });
|
|
602
|
+
} catch (persistError) {
|
|
603
|
+
logger.warn(`Could not persist diff to database: ${persistError.message}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
597
606
|
// Set model override if provided
|
|
598
607
|
if (flags.model) {
|
|
599
608
|
process.env.PAIR_REVIEW_MODEL = flags.model;
|