@in-the-loop-labs/pair-review 3.3.0 → 3.3.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  "test:e2e:debug": "playwright test --debug",
33
33
  "generate:skill-prompts": "node scripts/generate-skill-prompts.js",
34
34
  "changeset": "changeset",
35
- "version": "changeset version && pnpm install --lockfile-only && node scripts/sync-plugin-versions.js && git add package.json pnpm-lock.yaml CHANGELOG.md .changeset .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin-code-critic/.claude-plugin/plugin.json && git commit -m \"RELEASING: v$(node -p \"require('./package.json').version\")\"",
35
+ "version": "changeset version && pnpm install --lockfile-only && bash scripts/generate-package-lock.sh && node scripts/sync-plugin-versions.js && git add package.json pnpm-lock.yaml package-lock.json CHANGELOG.md .changeset .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin-code-critic/.claude-plugin/plugin.json && git commit -m \"RELEASING: v$(node -p \"require('./package.json').version\")\"",
36
36
  "release": "npm whoami > /dev/null || { echo 'Error: Not logged in to npm. Run: npm login'; exit 1; } && pnpm run version && changeset tag && npm publish && git push && git push --tags"
37
37
  },
38
38
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -231,15 +231,22 @@ class WorktreePoolLifecycle {
231
231
  // Note: poolEntry was already atomically marked 'switching' by claimAvailable()
232
232
  try {
233
233
  const git = this._simpleGit(poolEntry.path);
234
+ const worktreeManager = new this._GitWorktreeManager(this.db);
234
235
 
235
236
  // Resolve the remote
236
- const remotes = await git.getRemotes();
237
- const remote = remotes.find(r => r.name === 'origin') || remotes[0];
238
- const remoteName = remote ? remote.name : 'origin';
237
+ const remoteName = await worktreeManager.resolveRemoteForPR(git, prData, {
238
+ owner: prInfo.owner,
239
+ repo: prInfo.repo,
240
+ number: prInfo.prNumber,
241
+ });
239
242
 
240
- // Fetch new PR refs (incremental -- cheap on a warm worktree)
243
+ // Fetch new PR refs (incremental -- cheap on a warm worktree) with fallback
241
244
  logger.info(`Fetching PR #${prInfo.prNumber} refs into pool worktree ${poolEntry.id}`);
242
- await git.fetch([remoteName, `+refs/pull/${prInfo.prNumber}/head:refs/remotes/${remoteName}/pr-${prInfo.prNumber}`]);
245
+ const fetchedHead = await worktreeManager.fetchPRHead(git, {
246
+ owner: prInfo.owner,
247
+ repo: prInfo.repo,
248
+ number: prInfo.prNumber,
249
+ }, prData, { remote: remoteName });
243
250
 
244
251
  // Clean the working tree before switching PRs. Without this, untracked
245
252
  // files (build artifacts, generated code) from the previous PR leak into
@@ -254,7 +261,7 @@ class WorktreePoolLifecycle {
254
261
  if (targetSha) {
255
262
  await git.checkout([targetSha]);
256
263
  } else {
257
- await git.checkout([`refs/remotes/${remoteName}/pr-${prInfo.prNumber}`]);
264
+ await git.checkout([fetchedHead.checkoutTarget]);
258
265
  }
259
266
 
260
267
  // Run reset_script if configured
@@ -273,7 +280,6 @@ class WorktreePoolLifecycle {
273
280
  PR_NUMBER: String(prInfo.prNumber),
274
281
  WORKTREE_PATH: poolEntry.path,
275
282
  };
276
- const worktreeManager = new this._GitWorktreeManager();
277
283
  await worktreeManager.executeCheckoutScript(
278
284
  options.resetScript, poolEntry.path, scriptEnv, options.checkoutTimeout
279
285
  );
@@ -285,7 +291,6 @@ class WorktreePoolLifecycle {
285
291
 
286
292
  // Best-effort disk cleanup for deleted non-pool worktree directories
287
293
  if (deletedPaths && deletedPaths.length > 0) {
288
- const worktreeManager = new this._GitWorktreeManager();
289
294
  for (const deletedPath of deletedPaths) {
290
295
  try {
291
296
  await worktreeManager.cleanupWorktree(deletedPath);
@@ -66,8 +66,9 @@ class GitWorktreeManager {
66
66
  /**
67
67
  * Resolve which git remote points to the given repository URLs.
68
68
  * Compares normalized URLs against all configured remotes. If no match is
69
- * found, adds (or updates) a managed `pair-review-base` remote so that
70
- * subsequent fetches target the correct repository (e.g. the base repo of a fork PR).
69
+ * found, falls back to an existing non-managed remote instead of mutating the
70
+ * repository's git config. This preserves proxy/mirror setups where the
71
+ * canonical fetch URL may differ from GitHub's clone URL.
71
72
  *
72
73
  * @param {Object} git - simple-git instance
73
74
  * @param {string} cloneUrl - HTTPS clone URL of the target repository
@@ -81,9 +82,7 @@ class GitWorktreeManager {
81
82
  const remoteOutput = await git.raw(['remote', '-v']);
82
83
 
83
84
  if (!remoteOutput || !remoteOutput.trim()) {
84
- console.log(`No remotes found, adding ${MANAGED_REMOTE} remote for ${cloneUrl}`);
85
- await git.addRemote(MANAGED_REMOTE, cloneUrl);
86
- return MANAGED_REMOTE;
85
+ throw new Error(`No remotes configured cannot resolve base repository for ${cloneUrl}`);
87
86
  }
88
87
 
89
88
  // Parse remote output into { name: url } map (fetch URLs only)
@@ -115,9 +114,16 @@ class GitWorktreeManager {
115
114
 
116
115
  const normalizedCloneUrl = normalizeUrl(cloneUrl);
117
116
  const normalizedSshUrl = sshUrl ? normalizeUrl(sshUrl) : '';
117
+ const remoteNames = Object.keys(remotes);
118
+ const fallbackRemote = remotes.origin
119
+ ? 'origin'
120
+ : remoteNames.find((name) => name !== MANAGED_REMOTE) || 'origin';
118
121
 
119
- // Check each remote for a match
122
+ // Check each non-managed remote for a direct URL match
120
123
  for (const [name, url] of Object.entries(remotes)) {
124
+ if (name === MANAGED_REMOTE) {
125
+ continue;
126
+ }
121
127
  const normalizedRemoteUrl = normalizeUrl(url);
122
128
  if (normalizedRemoteUrl === normalizedCloneUrl ||
123
129
  (normalizedSshUrl && normalizedRemoteUrl === normalizedSshUrl)) {
@@ -126,16 +132,13 @@ class GitWorktreeManager {
126
132
  }
127
133
  }
128
134
 
129
- // No match found — add or update the managed remote
130
- if (remotes[MANAGED_REMOTE]) {
131
- console.log(`Updating ${MANAGED_REMOTE} remote URL to ${cloneUrl}`);
132
- await git.raw(['remote', 'set-url', MANAGED_REMOTE, cloneUrl]);
133
- } else {
134
- console.log(`Adding ${MANAGED_REMOTE} remote for ${cloneUrl}`);
135
- await git.addRemote(MANAGED_REMOTE, cloneUrl);
136
- }
137
-
138
- return MANAGED_REMOTE;
135
+ console.warn(
136
+ `No configured remote matched ${cloneUrl}; using existing remote '${fallbackRemote}' without modifying git config`
137
+ );
138
+ // NOTE: For fork PRs this may return a remote that does not point to the
139
+ // base repository. Callers rely on fetchPRHead's SHA-fallback path and
140
+ // tolerant base-branch fetching to handle this without mutating git config.
141
+ return fallbackRemote;
139
142
  }
140
143
 
141
144
  /**
@@ -170,6 +173,97 @@ class GitWorktreeManager {
170
173
  return this.resolveRemoteForRepo(git, cloneUrl, sshUrl);
171
174
  }
172
175
 
176
+ /**
177
+ * Extract a PR number from either { number } or { prNumber } shapes.
178
+ * @param {Object|null} prInfo
179
+ * @returns {number|null}
180
+ */
181
+ getPRNumber(prInfo) {
182
+ if (!prInfo) return null;
183
+ return prInfo.number || prInfo.prNumber || null;
184
+ }
185
+
186
+ /**
187
+ * Extract the PR head branch name from either REST or stored PR metadata.
188
+ * @param {Object|null} prData
189
+ * @returns {string}
190
+ */
191
+ getPRHeadBranch(prData) {
192
+ return prData?.head?.ref || prData?.head_branch || '';
193
+ }
194
+
195
+ /**
196
+ * Extract the PR head SHA from either REST or stored PR metadata.
197
+ * @param {Object|null} prData
198
+ * @returns {string}
199
+ */
200
+ getPRHeadSha(prData) {
201
+ return prData?.head?.sha || prData?.head_sha || '';
202
+ }
203
+
204
+ /**
205
+ * Detect whether a fetch failed because the remote does not expose a PR ref.
206
+ * @param {Error} error
207
+ * @returns {boolean}
208
+ */
209
+ isMissingRemoteRefError(error) {
210
+ const message = String(error?.message || '').toLowerCase();
211
+ return message.includes('couldn\'t find remote ref') ||
212
+ message.includes('could not find remote ref') ||
213
+ message.includes('remote ref does not exist') ||
214
+ message.includes('fatal: invalid refspec');
215
+ }
216
+
217
+ /**
218
+ * Fetch a PR head into a stable tracking ref, falling back from GitHub PR
219
+ * refs to a direct SHA fetch when the git transport does not expose
220
+ * refs/pull/* (for example, alternate internal fetch backends).
221
+ *
222
+ * @param {Object} git - simple-git instance
223
+ * @param {Object} prInfo - PR info { owner, repo, number } or { prNumber }
224
+ * @param {Object} prData - PR data from GitHub API or stored metadata
225
+ * @param {Object} [options={}]
226
+ * @param {string|null} [options.remote] - Base repository remote to use for PR-ref fetch
227
+ * @returns {Promise<{remote: string, trackingRef: string|null, checkoutTarget: string}>}
228
+ */
229
+ async fetchPRHead(git, prInfo, prData, options = {}) {
230
+ const prNumber = this.getPRNumber(prInfo);
231
+ const headSha = this.getPRHeadSha(prData);
232
+ const baseRemote = options.remote || await this.resolveRemoteForPR(git, prData, prInfo);
233
+
234
+ if (!prNumber) {
235
+ throw new Error('Cannot fetch PR head without a PR number');
236
+ }
237
+
238
+ const prTrackingRef = `refs/remotes/${baseRemote}/pr-${prNumber}`;
239
+
240
+ try {
241
+ await git.fetch([baseRemote, `+refs/pull/${prNumber}/head:${prTrackingRef}`]);
242
+ return {
243
+ remote: baseRemote,
244
+ trackingRef: prTrackingRef,
245
+ checkoutTarget: prTrackingRef
246
+ };
247
+ } catch (prRefError) {
248
+ if (!this.isMissingRemoteRefError(prRefError)) {
249
+ throw prRefError;
250
+ }
251
+
252
+ console.warn(`PR ref fetch unavailable for PR #${prNumber} on remote ${baseRemote}, falling back to SHA fetch: ${prRefError.message}`);
253
+
254
+ if (!headSha) {
255
+ throw prRefError;
256
+ }
257
+
258
+ await git.raw(['fetch', baseRemote, headSha]);
259
+ return {
260
+ remote: baseRemote,
261
+ trackingRef: null,
262
+ checkoutTarget: headSha
263
+ };
264
+ }
265
+ }
266
+
173
267
  /**
174
268
  * Execute a user-provided checkout script in the worktree.
175
269
  * The script receives PR context as environment variables and is responsible
@@ -427,20 +521,21 @@ class GitWorktreeManager {
427
521
  console.log(`Base SHA fetch not needed or already available: ${fetchError.message}`);
428
522
  }
429
523
 
430
- // Fetch the PR head using GitHub's pull request refs (more reliable than branch names)
524
+ // Fetch the PR head using PR refs when available, with a branch/SHA fallback
431
525
  console.log(`Fetching PR #${prInfo.number} head...`);
432
- await worktreeGit.fetch([remote, `+refs/pull/${prInfo.number}/head:refs/remotes/${remote}/pr-${prInfo.number}`]);
526
+ const fetchedHead = await this.fetchPRHead(worktreeGit, prInfo, prData, { remote });
433
527
 
434
528
  // Execute checkout script if configured (before checkout so sparse-checkout is set up)
435
529
  if (checkoutScript) {
436
530
  // Fetch the actual head branch by name (for checkout scripts that expect branch refs)
437
531
  // This may fail for fork PRs where the branch is in a different repo - that's okay
438
- if (prData.head_branch) {
532
+ const headBranch = this.getPRHeadBranch(prData);
533
+ if (headBranch) {
439
534
  try {
440
- console.log(`Fetching head branch ${prData.head_branch}...`);
441
- await worktreeGit.fetch([remote, `+refs/heads/${prData.head_branch}:refs/remotes/${remote}/${prData.head_branch}`]);
535
+ console.log(`Fetching head branch ${headBranch}...`);
536
+ await worktreeGit.fetch([remote, `+refs/heads/${headBranch}:refs/remotes/${remote}/${headBranch}`]);
442
537
  // Create/update a local branch pointing to the fetched ref so tooling can reference it by name
443
- await worktreeGit.branch(['-f', prData.head_branch, `${remote}/${prData.head_branch}`]);
538
+ await worktreeGit.branch(['-f', headBranch, `${remote}/${headBranch}`]);
444
539
  } catch (branchFetchError) {
445
540
  // Expected for fork PRs - the branch exists in the fork, not the base repo
446
541
  console.log(`Could not fetch head branch (may be from a fork): ${branchFetchError.message}`);
@@ -450,9 +545,9 @@ class GitWorktreeManager {
450
545
  console.log(`Executing checkout script: ${checkoutScript}`);
451
546
  const scriptEnv = {
452
547
  BASE_BRANCH: prData.base_branch,
453
- HEAD_BRANCH: prData.head_branch,
548
+ HEAD_BRANCH: headBranch,
454
549
  BASE_SHA: prData.base_sha,
455
- HEAD_SHA: prData.head_sha,
550
+ HEAD_SHA: this.getPRHeadSha(prData),
456
551
  PR_NUMBER: String(prInfo.number),
457
552
  WORKTREE_PATH: worktreePath
458
553
  };
@@ -461,13 +556,13 @@ class GitWorktreeManager {
461
556
  }
462
557
 
463
558
  // Checkout to PR head commit
464
- const targetSha = prData.head_sha;
559
+ const targetSha = this.getPRHeadSha(prData);
465
560
  if (targetSha) {
466
561
  console.log(`Checking out to PR head commit ${targetSha}...`);
467
562
  await worktreeGit.checkout([targetSha]);
468
563
  } else {
469
- console.log(`Checking out to PR head ref ${remote}/pr-${prInfo.number}...`);
470
- await worktreeGit.checkout([`${remote}/pr-${prInfo.number}`]);
564
+ console.log(`Checking out to PR head ref ${fetchedHead.checkoutTarget}...`);
565
+ await worktreeGit.checkout([fetchedHead.checkoutTarget]);
471
566
  }
472
567
 
473
568
  // Verify we're at the correct commit
@@ -540,13 +635,13 @@ class GitWorktreeManager {
540
635
  console.log(`Fetching latest changes from ${remote}...`);
541
636
  await worktreeGit.fetch([remote, '--prune']);
542
637
 
543
- // Fetch the PR head using GitHub's pull request refs
638
+ // Fetch the PR head using PR refs when available, with a branch/SHA fallback
544
639
  console.log(`Fetching PR #${number} head...`);
545
- await worktreeGit.fetch([remote, `+refs/pull/${number}/head:refs/remotes/${remote}/pr-${number}`]);
640
+ const fetchedHead = await this.fetchPRHead(worktreeGit, prInfo, prData, { remote });
546
641
 
547
642
  // Checkout to PR head commit
548
643
  console.log(`Checking out to PR head commit ${headSha}...`);
549
- await worktreeGit.checkout([`${remote}/pr-${number}`]);
644
+ await worktreeGit.checkout([fetchedHead.checkoutTarget]);
550
645
 
551
646
  // Verify we're at the correct commit
552
647
  const currentCommit = await worktreeGit.revparse(['HEAD']);
@@ -919,11 +1014,11 @@ class GitWorktreeManager {
919
1014
 
920
1015
  // Fetch the latest PR head from remote
921
1016
  console.log(`Fetching PR #${prNumber} head from ${remote}...`);
922
- await git.fetch([remote, `pull/${prNumber}/head`]);
1017
+ const fetchedHead = await this.fetchPRHead(git, prInfo || { number: prNumber }, prData, { remote });
923
1018
 
924
1019
  // Reset to the fetched PR head
925
1020
  console.log(`Resetting worktree to PR head...`);
926
- await git.raw(['reset', '--hard', 'FETCH_HEAD']);
1021
+ await git.raw(['reset', '--hard', fetchedHead.checkoutTarget]);
927
1022
 
928
1023
  // Update last_accessed_at in database
929
1024
  if (this.worktreeRepo) {
@@ -977,13 +1072,13 @@ class GitWorktreeManager {
977
1072
  ? await this.resolveRemoteForPR(git, prData, prInfo)
978
1073
  : defaultRemote;
979
1074
 
980
- // 3. Fetch PR head into a persistent ref
981
- console.log(`Fetching PR #${prNumber} head from ${remote} into refs/remotes/${remote}/pr-${prNumber}...`);
982
- await git.fetch([remote, `+refs/pull/${prNumber}/head:refs/remotes/${remote}/pr-${prNumber}`]);
1075
+ // 3. Fetch PR head into a persistent ref (or by SHA when refs are unavailable)
1076
+ console.log(`Fetching PR #${prNumber} head from ${remote}...`);
1077
+ const fetchedHead = await this.fetchPRHead(git, prInfo || { number: prNumber }, prData, { remote });
983
1078
 
984
1079
  // 4. Reset worktree to the fetched ref
985
- console.log(`Resetting worktree to refs/remotes/${remote}/pr-${prNumber}...`);
986
- await git.raw(['reset', '--hard', `refs/remotes/${remote}/pr-${prNumber}`]);
1080
+ console.log(`Resetting worktree to ${fetchedHead.checkoutTarget}...`);
1081
+ await git.raw(['reset', '--hard', fetchedHead.checkoutTarget]);
987
1082
 
988
1083
  // 5. Return the new HEAD SHA
989
1084
  const headSha = (await git.revparse(['HEAD'])).trim();
@@ -1219,4 +1314,4 @@ class GitWorktreeManager {
1219
1314
  }
1220
1315
  }
1221
1316
 
1222
- module.exports = { GitWorktreeManager };
1317
+ module.exports = { GitWorktreeManager };