@in-the-loop-labs/pair-review 3.3.6 → 3.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/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/styles.css +14 -2
- package/public/index.html +1 -0
- package/public/js/components/AIPanel.js +3 -14
- package/public/js/components/UpdateBanner.js +143 -0
- package/public/js/modules/diff-renderer.js +103 -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/local.html +2 -0
- package/public/pr.html +2 -0
- package/public/setup.html +1 -0
- package/src/config.js +29 -6
- 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 +33 -20
- package/src/mcp-stdio.js +7 -0
- package/src/routes/config.js +55 -1
- package/src/routes/pr.js +26 -10
- package/src/server.js +51 -9
- package/src/single-port.js +191 -0
- package/src/utils/diff-file-list.js +111 -2
- package/src/utils/paths.js +4 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const semver = require('semver');
|
|
5
|
+
const { PRArgumentParser } = require('./github/parser');
|
|
6
|
+
const logger = require('./utils/logger');
|
|
7
|
+
const { version: packageVersion } = require('../package.json');
|
|
8
|
+
|
|
9
|
+
const HEALTH_TIMEOUT_MS = 2000;
|
|
10
|
+
|
|
11
|
+
// Default dependencies (overridable for testing)
|
|
12
|
+
const defaults = {
|
|
13
|
+
httpGet: http.get,
|
|
14
|
+
httpRequest: http.request,
|
|
15
|
+
logger,
|
|
16
|
+
open: (...args) => process.env.PAIR_REVIEW_NO_OPEN
|
|
17
|
+
? Promise.resolve()
|
|
18
|
+
: import('open').then(({ default: open }) => open(...args)),
|
|
19
|
+
PRArgumentParser
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a pair-review server is already running on the given port.
|
|
24
|
+
* @param {number} port
|
|
25
|
+
* @param {object} [_deps] - Dependency overrides for testing
|
|
26
|
+
* @returns {Promise<{running: boolean, isPairReview?: boolean, version?: string}>}
|
|
27
|
+
*/
|
|
28
|
+
function detectRunningServer(port, _deps) {
|
|
29
|
+
const deps = { ...defaults, ..._deps };
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const req = deps.httpGet(`http://localhost:${port}/health`, { timeout: HEALTH_TIMEOUT_MS }, (res) => {
|
|
32
|
+
let data = '';
|
|
33
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
34
|
+
res.on('end', () => {
|
|
35
|
+
try {
|
|
36
|
+
const body = JSON.parse(data);
|
|
37
|
+
if (body.service === 'pair-review') {
|
|
38
|
+
resolve({ running: true, isPairReview: true, version: body.version || null });
|
|
39
|
+
} else {
|
|
40
|
+
resolve({ running: true, isPairReview: false });
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
resolve({ running: true, isPairReview: false });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
req.on('error', () => {
|
|
49
|
+
resolve({ running: false });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
req.on('timeout', () => {
|
|
53
|
+
req.destroy();
|
|
54
|
+
resolve({ running: false });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Notify the running server that a newer version is available.
|
|
61
|
+
* Fire-and-forget — does not block on response.
|
|
62
|
+
* @param {number} port
|
|
63
|
+
* @param {string} currentVersion - Version of the current CLI invocation
|
|
64
|
+
* @param {object} [_deps] - Dependency overrides for testing
|
|
65
|
+
*/
|
|
66
|
+
function notifyVersion(port, currentVersion, _deps) {
|
|
67
|
+
const deps = { ...defaults, ..._deps };
|
|
68
|
+
const payload = JSON.stringify({ version: currentVersion });
|
|
69
|
+
const req = deps.httpRequest({
|
|
70
|
+
hostname: 'localhost',
|
|
71
|
+
port,
|
|
72
|
+
path: '/api/notify-update',
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
77
|
+
},
|
|
78
|
+
timeout: HEALTH_TIMEOUT_MS
|
|
79
|
+
}, () => { /* ignore response */ });
|
|
80
|
+
|
|
81
|
+
req.on('error', () => { /* fire and forget */ });
|
|
82
|
+
req.on('timeout', () => { req.destroy(); });
|
|
83
|
+
req.write(payload);
|
|
84
|
+
req.end();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the URL to delegate to an existing server.
|
|
89
|
+
* @param {number} port
|
|
90
|
+
* @param {'pr'|'local'|'server'} mode
|
|
91
|
+
* @param {object} context
|
|
92
|
+
* @param {string} [context.owner]
|
|
93
|
+
* @param {string} [context.repo]
|
|
94
|
+
* @param {number} [context.number]
|
|
95
|
+
* @param {string} [context.localPath]
|
|
96
|
+
* @param {boolean} [context.analyze] - Whether to trigger auto-analysis
|
|
97
|
+
* @returns {string} Full URL
|
|
98
|
+
*/
|
|
99
|
+
function buildDelegationUrl(port, mode, context = {}) {
|
|
100
|
+
const base = `http://localhost:${port}`;
|
|
101
|
+
if (mode === 'pr') {
|
|
102
|
+
let url = `${base}/pr/${context.owner}/${context.repo}/${context.number}`;
|
|
103
|
+
if (context.analyze) url += '?analyze=true';
|
|
104
|
+
return url;
|
|
105
|
+
}
|
|
106
|
+
if (mode === 'local') {
|
|
107
|
+
let url = `${base}/local?path=${encodeURIComponent(context.localPath)}`;
|
|
108
|
+
if (context.analyze) url += '&analyze=true';
|
|
109
|
+
return url;
|
|
110
|
+
}
|
|
111
|
+
return `${base}/`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parse PR arguments for URL construction without starting a server.
|
|
116
|
+
* Reuses PRArgumentParser — synchronous for URLs, async for bare numbers.
|
|
117
|
+
* @param {string[]} prArgs - Raw CLI PR arguments
|
|
118
|
+
* @param {object} [_deps] - Dependency overrides for testing
|
|
119
|
+
* @returns {Promise<{owner: string, repo: string, number: number}>}
|
|
120
|
+
*/
|
|
121
|
+
async function parsePRArgsForDelegation(prArgs, _deps) {
|
|
122
|
+
const deps = { ...defaults, ..._deps };
|
|
123
|
+
const parser = new deps.PRArgumentParser();
|
|
124
|
+
return parser.parsePRArguments(prArgs);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Attempt single-port delegation. Returns true if delegation happened (caller should exit).
|
|
129
|
+
* Returns false if no running server was found (caller should start normally).
|
|
130
|
+
* Throws if port is occupied by a non-pair-review service.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} config - Loaded config
|
|
133
|
+
* @param {object} flags - Parsed CLI flags
|
|
134
|
+
* @param {string[]} prArgs - PR arguments from CLI
|
|
135
|
+
* @param {object} [_deps] - Dependency overrides for testing
|
|
136
|
+
* @returns {Promise<boolean>} true if delegated, false if should start fresh
|
|
137
|
+
*/
|
|
138
|
+
async function attemptDelegation(config, flags, prArgs, _deps) {
|
|
139
|
+
const deps = { ...defaults, ..._deps };
|
|
140
|
+
const port = config.port;
|
|
141
|
+
|
|
142
|
+
const result = await detectRunningServer(port, _deps);
|
|
143
|
+
|
|
144
|
+
if (result.running && !result.isPairReview) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Port ${port} is in use by another service. ` +
|
|
147
|
+
`Either stop that service, or set a different port in ~/.pair-review/config.json`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!result.running) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Server is running — delegate to it
|
|
156
|
+
deps.logger.info(`Existing pair-review server detected on port ${port} (v${result.version})`);
|
|
157
|
+
|
|
158
|
+
// Determine mode and build URL
|
|
159
|
+
let url;
|
|
160
|
+
if (flags.local) {
|
|
161
|
+
const targetPath = path.resolve(flags.localPath || process.cwd());
|
|
162
|
+
url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze: flags.ai });
|
|
163
|
+
} else if (prArgs.length > 0) {
|
|
164
|
+
const prInfo = await parsePRArgsForDelegation(prArgs, _deps);
|
|
165
|
+
url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze: flags.ai });
|
|
166
|
+
} else {
|
|
167
|
+
url = buildDelegationUrl(port, 'server');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Notify running server of newer version if applicable
|
|
171
|
+
if (result.version && semver.valid(packageVersion) && semver.valid(result.version)) {
|
|
172
|
+
if (semver.gt(packageVersion, result.version)) {
|
|
173
|
+
deps.logger.info(`Notifying server of newer version: ${packageVersion} > ${result.version}`);
|
|
174
|
+
notifyVersion(port, packageVersion, _deps);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Open browser and exit
|
|
179
|
+
deps.logger.info(`Delegating to running server: ${url}`);
|
|
180
|
+
await deps.open(url);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
detectRunningServer,
|
|
186
|
+
notifyVersion,
|
|
187
|
+
buildDelegationUrl,
|
|
188
|
+
parsePRArgsForDelegation,
|
|
189
|
+
attemptDelegation,
|
|
190
|
+
HEALTH_TIMEOUT_MS
|
|
191
|
+
};
|
|
@@ -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
|