@in-the-loop-labs/pair-review 3.3.5 → 3.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/components/AIPanel.js +3 -14
- package/public/js/modules/diff-renderer.js +100 -7
- package/public/js/modules/file-comment-manager.js +34 -13
- package/public/js/modules/suggestion-manager.js +22 -6
- package/public/js/pr.js +3 -3
- package/public/js/repo-settings.js +37 -7
- package/src/ai/claude-provider.js +12 -1
- package/src/ai/codex-provider.js +134 -44
- package/src/ai/index.js +3 -1
- package/src/ai/pi-provider.js +403 -77
- package/src/ai/provider.js +6 -3
- package/src/config.js +12 -0
- package/src/git/diff-flags.js +5 -1
- package/src/git/worktree.js +23 -4
- package/src/local-review.js +26 -10
- package/src/main.js +1 -1
- package/src/routes/pr.js +26 -10
- package/src/utils/diff-file-list.js +111 -2
- package/src/utils/paths.js +4 -0
package/src/local-review.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
const { execSync, exec } = require('child_process');
|
|
2
|
+
const { execSync, exec, execFileSync } = require('child_process');
|
|
3
3
|
const { promisify } = require('util');
|
|
4
4
|
const crypto = require('crypto');
|
|
5
5
|
const path = require('path');
|
|
@@ -15,7 +15,7 @@ const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require
|
|
|
15
15
|
const { startServer } = require('./server');
|
|
16
16
|
const { localReviewDiffs } = require('./routes/shared');
|
|
17
17
|
const { getShaAbbrevLength } = require('./git/sha-abbrev');
|
|
18
|
-
const { GIT_DIFF_FLAGS } = require('./git/diff-flags');
|
|
18
|
+
const { GIT_DIFF_FLAGS, GIT_DIFF_FLAGS_ARRAY } = require('./git/diff-flags');
|
|
19
19
|
const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({ default: open }) => open(...args));
|
|
20
20
|
|
|
21
21
|
// Design note: This module uses execSync for git commands despite async function signatures.
|
|
@@ -393,12 +393,23 @@ async function findMergeBase(repoPath, baseBranch) {
|
|
|
393
393
|
* Generate diff output for untracked files using git diff --no-index.
|
|
394
394
|
* @param {string} repoPath - Path to the git repository
|
|
395
395
|
* @param {Array} untrackedFiles - Array from getUntrackedFiles()
|
|
396
|
-
* @param {
|
|
397
|
-
* @param {
|
|
398
|
-
* @param {
|
|
396
|
+
* @param {Object} [options]
|
|
397
|
+
* @param {boolean} [options.hideWhitespace=false] - Whether to pass -w
|
|
398
|
+
* @param {number} [options.contextLines=25] - Number of unified context lines
|
|
399
|
+
* @param {string[]} [options.extraArgs=[]] - Additional git diff flags
|
|
399
400
|
* @returns {string} Combined diff text for untracked files
|
|
400
401
|
*/
|
|
401
|
-
function generateUntrackedDiffs(repoPath, untrackedFiles,
|
|
402
|
+
function generateUntrackedDiffs(repoPath, untrackedFiles, options = {}) {
|
|
403
|
+
const diffArgs = [
|
|
404
|
+
'diff',
|
|
405
|
+
'--no-index',
|
|
406
|
+
...GIT_DIFF_FLAGS_ARRAY,
|
|
407
|
+
`--unified=${options.contextLines ?? 25}`,
|
|
408
|
+
...(options.extraArgs || []),
|
|
409
|
+
...(options.hideWhitespace ? ['-w'] : []),
|
|
410
|
+
'--',
|
|
411
|
+
'/dev/null'
|
|
412
|
+
];
|
|
402
413
|
let diff = '';
|
|
403
414
|
for (const untracked of untrackedFiles) {
|
|
404
415
|
if (!untracked.skipped) {
|
|
@@ -406,16 +417,21 @@ function generateUntrackedDiffs(repoPath, untrackedFiles, wFlag, contextFlag = '
|
|
|
406
417
|
const filePath = path.join(repoPath, untracked.file);
|
|
407
418
|
let fileDiff;
|
|
408
419
|
try {
|
|
409
|
-
fileDiff =
|
|
420
|
+
fileDiff = execFileSync('git', [...diffArgs, filePath], {
|
|
410
421
|
cwd: repoPath,
|
|
411
422
|
encoding: 'utf8',
|
|
412
423
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
413
424
|
maxBuffer: 10 * 1024 * 1024
|
|
414
425
|
});
|
|
415
426
|
} catch (diffError) {
|
|
427
|
+
const diffStdout = typeof diffError?.stdout === 'string'
|
|
428
|
+
? diffError.stdout
|
|
429
|
+
: Buffer.isBuffer(diffError?.stdout)
|
|
430
|
+
? diffError.stdout.toString('utf8')
|
|
431
|
+
: null;
|
|
416
432
|
if (diffError && typeof diffError === 'object' &&
|
|
417
|
-
diffError.status === GIT_DIFF_HAS_DIFFERENCES &&
|
|
418
|
-
fileDiff =
|
|
433
|
+
diffError.status === GIT_DIFF_HAS_DIFFERENCES && diffStdout !== null) {
|
|
434
|
+
fileDiff = diffStdout;
|
|
419
435
|
} else {
|
|
420
436
|
throw diffError;
|
|
421
437
|
}
|
|
@@ -552,7 +568,7 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
|
|
|
552
568
|
const untrackedFiles = await getUntrackedFiles(repoPath);
|
|
553
569
|
stats.untrackedFiles = untrackedFiles.length;
|
|
554
570
|
|
|
555
|
-
const untrackedDiff = generateUntrackedDiffs(repoPath, untrackedFiles,
|
|
571
|
+
const untrackedDiff = generateUntrackedDiffs(repoPath, untrackedFiles, options);
|
|
556
572
|
if (untrackedDiff) {
|
|
557
573
|
if (diff) diff += '\n';
|
|
558
574
|
diff += untrackedDiff;
|
package/src/main.js
CHANGED
|
@@ -116,7 +116,7 @@ OPTIONS:
|
|
|
116
116
|
--model <name> Override the AI model. Claude Code is the default provider.
|
|
117
117
|
Available models: opus, sonnet, haiku (Claude Code);
|
|
118
118
|
also: opus-4.5, opus-4.6-low, opus-4.6-medium, opus-4.6-1m,
|
|
119
|
-
opus-4.7-xhigh
|
|
119
|
+
opus-4.7-high, opus-4.7-xhigh
|
|
120
120
|
or use provider-specific models with Gemini/Codex
|
|
121
121
|
--use-checkout Use current directory instead of creating worktree
|
|
122
122
|
(automatic in GitHub Actions)
|
package/src/routes/pr.js
CHANGED
|
@@ -25,7 +25,7 @@ const Analyzer = require('../ai/analyzer');
|
|
|
25
25
|
const { v4: uuidv4 } = require('uuid');
|
|
26
26
|
const fs = require('fs').promises;
|
|
27
27
|
const path = require('path');
|
|
28
|
-
const { getGitHubToken, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
|
|
28
|
+
const { getGitHubToken, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides, getRepoSkipBulkFetch } = require('../config');
|
|
29
29
|
const logger = require('../utils/logger');
|
|
30
30
|
const { buildDiffLineSet } = require('../utils/diff-annotator');
|
|
31
31
|
const { broadcastReviewEvent } = require('../events/review-events');
|
|
@@ -45,6 +45,7 @@ const {
|
|
|
45
45
|
registerProcess: registerProcessForCancellation
|
|
46
46
|
} = require('./shared');
|
|
47
47
|
const { safeParseJson } = require('../utils/safe-parse-json');
|
|
48
|
+
const { mergeChangedFilesWithDiff } = require('../utils/diff-file-list');
|
|
48
49
|
const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
|
|
49
50
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
50
51
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
@@ -254,6 +255,8 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
// Prepare response
|
|
258
|
+
const changedFiles = mergeChangedFilesWithDiff(extendedData.changed_files || [], extendedData.diff || '');
|
|
259
|
+
|
|
257
260
|
// Use review.id instead of prMetadata.id to avoid ID collision with local mode
|
|
258
261
|
const response = {
|
|
259
262
|
success: true,
|
|
@@ -274,8 +277,8 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
274
277
|
shaAbbrevLength,
|
|
275
278
|
created_at: prMetadata.created_at,
|
|
276
279
|
updated_at: prMetadata.updated_at,
|
|
277
|
-
file_changes:
|
|
278
|
-
changed_files:
|
|
280
|
+
file_changes: changedFiles.length,
|
|
281
|
+
changed_files: changedFiles,
|
|
279
282
|
additions: extendedData.additions || 0,
|
|
280
283
|
deletions: extendedData.deletions || 0,
|
|
281
284
|
diff_content: extendedData.diff || '',
|
|
@@ -375,7 +378,13 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
375
378
|
|
|
376
379
|
// Update worktree with latest changes
|
|
377
380
|
const worktreeManager = new GitWorktreeManager(db);
|
|
378
|
-
const worktreePath = await worktreeManager.updateWorktree(
|
|
381
|
+
const worktreePath = await worktreeManager.updateWorktree(
|
|
382
|
+
owner,
|
|
383
|
+
repo,
|
|
384
|
+
prNumber,
|
|
385
|
+
prData,
|
|
386
|
+
{ skipBulkFetch: getRepoSkipBulkFetch(config, repository) }
|
|
387
|
+
);
|
|
379
388
|
|
|
380
389
|
// Generate fresh diff and get changed files
|
|
381
390
|
const diffPrData = {
|
|
@@ -759,6 +768,8 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
759
768
|
let diffContent = prData.diff || '';
|
|
760
769
|
let changedFiles = prData.changed_files || [];
|
|
761
770
|
|
|
771
|
+
let gitattributes = null;
|
|
772
|
+
|
|
762
773
|
if (hideWhitespace && worktreeRecord && worktreeRecord.path) {
|
|
763
774
|
try {
|
|
764
775
|
const worktreePath = worktreeRecord.path;
|
|
@@ -774,7 +785,7 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
774
785
|
|
|
775
786
|
const summaryArgs = [`${baseSha}...${headSha}`, ...GIT_DIFF_SUMMARY_FLAGS_ARRAY, '-w'];
|
|
776
787
|
const diffSummary = await git.diffSummary(summaryArgs);
|
|
777
|
-
|
|
788
|
+
gitattributes = await getGeneratedFilePatterns(worktreePath);
|
|
778
789
|
changedFiles = diffSummary.files.map(file => {
|
|
779
790
|
const resolvedFile = resolveRenamedFile(file.file);
|
|
780
791
|
const isRenamed = resolvedFile !== file.file;
|
|
@@ -799,17 +810,22 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
799
810
|
} else if (worktreeRecord && worktreeRecord.path) {
|
|
800
811
|
// Add generated flag to changed files based on .gitattributes
|
|
801
812
|
try {
|
|
802
|
-
|
|
803
|
-
changedFiles = changedFiles.map(file => ({
|
|
804
|
-
...file,
|
|
805
|
-
generated: gitattributes.isGenerated(file.file)
|
|
806
|
-
}));
|
|
813
|
+
gitattributes = await getGeneratedFilePatterns(worktreeRecord.path);
|
|
807
814
|
} catch (error) {
|
|
808
815
|
logger.warn(`Could not load .gitattributes: ${error.message}`);
|
|
809
816
|
// Continue without generated flags
|
|
810
817
|
}
|
|
811
818
|
}
|
|
812
819
|
|
|
820
|
+
changedFiles = mergeChangedFilesWithDiff(changedFiles, diffContent);
|
|
821
|
+
|
|
822
|
+
if (gitattributes) {
|
|
823
|
+
changedFiles = changedFiles.map(file => ({
|
|
824
|
+
...file,
|
|
825
|
+
generated: gitattributes.isGenerated(file.file)
|
|
826
|
+
}));
|
|
827
|
+
}
|
|
828
|
+
|
|
813
829
|
// When diff was regenerated (whitespace), compute aggregate stats from
|
|
814
830
|
// the regenerated changedFiles instead of using stale cached values from prData.
|
|
815
831
|
const additions = hideWhitespace
|
|
@@ -3,9 +3,111 @@ const { promisify } = require('util');
|
|
|
3
3
|
const { exec } = require('child_process');
|
|
4
4
|
const { queryOne } = require('../database');
|
|
5
5
|
const { GIT_DIFF_FLAGS } = require('../git/diff-flags');
|
|
6
|
+
const { normalizePath, resolveRenamedFile } = require('./paths');
|
|
6
7
|
|
|
7
8
|
const execPromise = promisify(exec);
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Parse a unified diff into a map of file path -> per-file patch.
|
|
12
|
+
* Uses the "b/" path from the diff header as the canonical file path.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} diff - Full unified diff
|
|
15
|
+
* @returns {Map<string, string>} Map of file paths to full patch text
|
|
16
|
+
*/
|
|
17
|
+
function parseUnifiedDiffPatches(diff) {
|
|
18
|
+
const filePatchMap = new Map();
|
|
19
|
+
if (!diff) return filePatchMap;
|
|
20
|
+
|
|
21
|
+
const parts = diff.split(/(?=^diff --git )/m);
|
|
22
|
+
|
|
23
|
+
for (const part of parts) {
|
|
24
|
+
if (!part.trim()) continue;
|
|
25
|
+
|
|
26
|
+
const match = part.match(/^diff --git a\/(.+?) b\/(.+)$/m);
|
|
27
|
+
if (match) {
|
|
28
|
+
filePatchMap.set(match[2], part);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return filePatchMap;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Count additions and deletions inside a single patch body.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} patch - Per-file patch text
|
|
39
|
+
* @returns {{ insertions: number, deletions: number }}
|
|
40
|
+
*/
|
|
41
|
+
function countPatchStats(patch) {
|
|
42
|
+
let insertions = 0;
|
|
43
|
+
let deletions = 0;
|
|
44
|
+
|
|
45
|
+
for (const line of patch.split('\n')) {
|
|
46
|
+
if (line.startsWith('+') && !line.startsWith('+++ ')) {
|
|
47
|
+
insertions++;
|
|
48
|
+
} else if (line.startsWith('-') && !line.startsWith('--- ')) {
|
|
49
|
+
deletions++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { insertions, deletions };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merge changed_files metadata with the authoritative file list from the diff.
|
|
58
|
+
* This recovers files when cached changed_files were derived from abbreviated
|
|
59
|
+
* diff --stat output and no longer match the full patch headers.
|
|
60
|
+
*
|
|
61
|
+
* @param {Array<object|string>} changedFiles - Existing changed_files array
|
|
62
|
+
* @param {string} diff - Full unified diff
|
|
63
|
+
* @returns {Array<object|string>} Merged changed_files array
|
|
64
|
+
*/
|
|
65
|
+
function mergeChangedFilesWithDiff(changedFiles, diff) {
|
|
66
|
+
const patchMap = parseUnifiedDiffPatches(diff);
|
|
67
|
+
if (patchMap.size === 0) {
|
|
68
|
+
return Array.isArray(changedFiles) ? changedFiles : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Drop cached `git diff --stat` ellipsis stubs once we have authoritative
|
|
72
|
+
// patch headers to recover the full file paths from.
|
|
73
|
+
const existing = (Array.isArray(changedFiles) ? changedFiles : []).filter(entry => {
|
|
74
|
+
const filePath = typeof entry === 'string' ? entry : entry?.file;
|
|
75
|
+
return filePath && !filePath.includes('...');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const normalizedExisting = new Set(existing.map(file => {
|
|
79
|
+
const filePath = typeof file === 'string' ? file : file?.file;
|
|
80
|
+
return normalizePath(resolveRenamedFile(filePath));
|
|
81
|
+
}).filter(Boolean));
|
|
82
|
+
|
|
83
|
+
const merged = [...existing];
|
|
84
|
+
|
|
85
|
+
for (const [filePath, patch] of patchMap.entries()) {
|
|
86
|
+
const normalizedPatchPath = normalizePath(resolveRenamedFile(filePath));
|
|
87
|
+
if (normalizedExisting.has(normalizedPatchPath)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { insertions, deletions } = countPatchStats(patch);
|
|
92
|
+
const renameFrom = patch.match(/^rename from (.+)$/m)?.[1] || null;
|
|
93
|
+
const renameTo = patch.match(/^rename to (.+)$/m)?.[1] || null;
|
|
94
|
+
const binary = /^Binary files .* differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
|
|
95
|
+
|
|
96
|
+
merged.push({
|
|
97
|
+
file: filePath,
|
|
98
|
+
insertions,
|
|
99
|
+
deletions,
|
|
100
|
+
changes: insertions + deletions,
|
|
101
|
+
binary,
|
|
102
|
+
renamed: Boolean(renameFrom && renameTo),
|
|
103
|
+
renamedFrom: renameFrom
|
|
104
|
+
});
|
|
105
|
+
normalizedExisting.add(normalizedPatchPath);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return merged;
|
|
109
|
+
}
|
|
110
|
+
|
|
9
111
|
/**
|
|
10
112
|
* Return the list of file paths that belong to the review's diff.
|
|
11
113
|
* Works for both PR-mode and local-mode reviews.
|
|
@@ -25,7 +127,9 @@ async function getDiffFileList(db, review) {
|
|
|
25
127
|
|
|
26
128
|
if (prRecord?.pr_data) {
|
|
27
129
|
const prData = JSON.parse(prRecord.pr_data);
|
|
28
|
-
return (prData.changed_files || []
|
|
130
|
+
return mergeChangedFilesWithDiff(prData.changed_files || [], prData.diff || '')
|
|
131
|
+
.map(f => typeof f === 'string' ? f : f.file)
|
|
132
|
+
.filter(Boolean);
|
|
29
133
|
}
|
|
30
134
|
} catch {
|
|
31
135
|
// parse / query error – fall through to empty list
|
|
@@ -55,4 +159,9 @@ async function getDiffFileList(db, review) {
|
|
|
55
159
|
return [];
|
|
56
160
|
}
|
|
57
161
|
|
|
58
|
-
module.exports = {
|
|
162
|
+
module.exports = {
|
|
163
|
+
getDiffFileList,
|
|
164
|
+
parseUnifiedDiffPatches,
|
|
165
|
+
countPatchStats,
|
|
166
|
+
mergeChangedFilesWithDiff
|
|
167
|
+
};
|
package/src/utils/paths.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* - Handles interleaved patterns like '/./src' by iterating
|
|
14
14
|
* - Normalizes multiple consecutive slashes to single slash
|
|
15
15
|
* - Does NOT modify case (paths are case-sensitive on most systems)
|
|
16
|
+
* - Frontend mirror lives in `DiffRenderer.normalizeFilePath`
|
|
17
|
+
* (`public/js/modules/diff-renderer.js`); keep behavior in sync
|
|
16
18
|
*
|
|
17
19
|
* @param {string} filePath - The file path to normalize
|
|
18
20
|
* @returns {string} Normalized path
|
|
@@ -159,6 +161,8 @@ function normalizeRepository(owner, repo) {
|
|
|
159
161
|
* "tests/{old.js => new.js}" → "tests/new.js"
|
|
160
162
|
* "{old-dir => new-dir}/file.js" → "new-dir/file.js"
|
|
161
163
|
* "a/{b => c}/d.js" → "a/c/d.js"
|
|
164
|
+
* Frontend mirror lives in `DiffRenderer.normalizeFilePath`
|
|
165
|
+
* (`public/js/modules/diff-renderer.js`); keep behavior in sync.
|
|
162
166
|
*
|
|
163
167
|
* @param {string} fileName - File name possibly containing rename syntax
|
|
164
168
|
* @returns {string} Resolved file name with the new path, or original if no rename syntax
|