@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.
@@ -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 || []).map(f => f.file);
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 = { getDiffFileList };
162
+ module.exports = {
163
+ getDiffFileList,
164
+ parseUnifiedDiffPatches,
165
+ countPatchStats,
166
+ mergeChangedFilesWithDiff
167
+ };
@@ -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