@in-the-loop-labs/pair-review 3.4.0 → 3.4.1
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/index.js +43 -0
- package/src/config.js +19 -16
- package/src/database.js +14 -0
- package/src/git/fetch-helpers.js +29 -0
- package/src/git/worktree-pool-lifecycle.js +16 -5
- package/src/git/worktree.js +9 -8
- package/src/local-review.js +3 -0
- package/src/main.js +6 -3
- package/src/routes/local.js +7 -0
- package/src/routes/setup.js +7 -0
- package/src/routes/stack-analysis.js +1 -1
- package/src/setup/local-setup.js +5 -1
- package/src/setup/pr-setup.js +7 -4
- package/src/single-port.js +2 -0
- package/src/utils/local-path-input.js +44 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.1",
|
|
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.4.
|
|
3
|
+
"version": "3.4.1",
|
|
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",
|
package/public/js/index.js
CHANGED
|
@@ -133,6 +133,20 @@
|
|
|
133
133
|
return div.innerHTML;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
const LOCAL_REVIEW_PATH_URL_ERROR = 'Local reviews require a filesystem path, not a URL. Pass GitHub or Graphite URLs as PR review inputs instead.';
|
|
137
|
+
|
|
138
|
+
function isUrlLikeLocalReviewPath(value) {
|
|
139
|
+
if (!value || typeof value !== 'string') return false;
|
|
140
|
+
const trimmed = value.trim();
|
|
141
|
+
if (!trimmed) return false;
|
|
142
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return true;
|
|
143
|
+
if (/^(?:github\.com|app\.graphite\.(?:dev|com))\//i.test(trimmed)) return true;
|
|
144
|
+
// Keep this aligned with src/utils/local-path-input.js: only a leading
|
|
145
|
+
// user@host:path token is treated as an SSH-style remote.
|
|
146
|
+
if (/^[^@/\\\s]+@[^:/\\\s]+:[^\s]+$/.test(trimmed)) return true;
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
136
150
|
/**
|
|
137
151
|
* Set loading state for a tab's form
|
|
138
152
|
* @param {string} tab - 'pr' or 'local'
|
|
@@ -710,6 +724,12 @@
|
|
|
710
724
|
return;
|
|
711
725
|
}
|
|
712
726
|
|
|
727
|
+
if (isUrlLikeLocalReviewPath(pathValue)) {
|
|
728
|
+
showError('local', LOCAL_REVIEW_PATH_URL_ERROR);
|
|
729
|
+
input.focus();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
713
733
|
// Navigate to the setup page which shows step-by-step progress
|
|
714
734
|
// The /local?path= route serves setup.html which handles the full setup flow
|
|
715
735
|
let href = '/local?path=' + encodeURIComponent(pathValue);
|
|
@@ -717,6 +737,21 @@
|
|
|
717
737
|
window.location.href = href;
|
|
718
738
|
}
|
|
719
739
|
|
|
740
|
+
function handleLocalPathInput(event) {
|
|
741
|
+
const input = event && event.target ? event.target : document.getElementById('local-path-input');
|
|
742
|
+
const errorEl = document.getElementById('start-review-error-local');
|
|
743
|
+
if (!input || !errorEl) return;
|
|
744
|
+
|
|
745
|
+
if (isUrlLikeLocalReviewPath(input.value)) {
|
|
746
|
+
showError('local', LOCAL_REVIEW_PATH_URL_ERROR);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (errorEl.textContent === LOCAL_REVIEW_PATH_URL_ERROR) {
|
|
751
|
+
errorEl.classList.remove('visible', 'info');
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
720
755
|
// ─── Browse Directory ──────────────────────────────────────────────────────
|
|
721
756
|
|
|
722
757
|
/**
|
|
@@ -746,6 +781,9 @@
|
|
|
746
781
|
|
|
747
782
|
if (!data.cancelled && data.path) {
|
|
748
783
|
input.value = data.path;
|
|
784
|
+
// Setting .value in JavaScript does not fire an input event, so run the
|
|
785
|
+
// same handler used for typing to clear any stale URL-specific error.
|
|
786
|
+
handleLocalPathInput({ target: input });
|
|
749
787
|
input.focus();
|
|
750
788
|
}
|
|
751
789
|
|
|
@@ -1873,6 +1911,11 @@
|
|
|
1873
1911
|
localForm.addEventListener('submit', handleStartLocal);
|
|
1874
1912
|
}
|
|
1875
1913
|
|
|
1914
|
+
const localPathInput = document.getElementById('local-path-input');
|
|
1915
|
+
if (localPathInput) {
|
|
1916
|
+
localPathInput.addEventListener('input', handleLocalPathInput);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1876
1919
|
// Set up browse button handler
|
|
1877
1920
|
const browseBtn = document.getElementById('browse-local-btn');
|
|
1878
1921
|
if (browseBtn) {
|
package/src/config.js
CHANGED
|
@@ -222,19 +222,19 @@ async function loadConfig() {
|
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
// Normalize legacy monorepos
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (mergedConfig.repos) {
|
|
232
|
-
const normalized = {};
|
|
233
|
-
for (const [key, value] of Object.entries(mergedConfig.repos)) {
|
|
234
|
-
normalized[key.toLowerCase()] = value;
|
|
225
|
+
// Normalize legacy monorepos into one canonical repos map. Lowercase both
|
|
226
|
+
// sides before merging so JS object identity matches DB COLLATE NOCASE.
|
|
227
|
+
const lowercaseKeys = (obj) => {
|
|
228
|
+
const out = {};
|
|
229
|
+
for (const [key, value] of Object.entries(obj || {})) {
|
|
230
|
+
out[key.toLowerCase()] = value;
|
|
235
231
|
}
|
|
236
|
-
|
|
237
|
-
}
|
|
232
|
+
return out;
|
|
233
|
+
};
|
|
234
|
+
const lowerMonorepos = lowercaseKeys(mergedConfig.monorepos);
|
|
235
|
+
const lowerRepos = lowercaseKeys(mergedConfig.repos);
|
|
236
|
+
mergedConfig.repos = deepMerge(lowerMonorepos, lowerRepos);
|
|
237
|
+
delete mergedConfig.monorepos;
|
|
238
238
|
|
|
239
239
|
// PORT env var overrides all config layers (used by Preview and similar harnesses)
|
|
240
240
|
if (process.env.PORT) {
|
|
@@ -424,12 +424,15 @@ function expandPath(p) {
|
|
|
424
424
|
* @returns {object|null}
|
|
425
425
|
*/
|
|
426
426
|
function getRepoConfig(config, repository) {
|
|
427
|
+
const key = String(repository).toLowerCase();
|
|
427
428
|
const reposSection = config.repos || {};
|
|
428
|
-
const
|
|
429
|
-
|
|
429
|
+
const repoEntry = reposSection[key] || reposSection[repository] || Object.entries(reposSection)
|
|
430
|
+
.find(([repoName]) => repoName.toLowerCase() === key)?.[1];
|
|
431
|
+
if (repoEntry) return repoEntry;
|
|
430
432
|
|
|
431
433
|
const legacySection = config.monorepos || {};
|
|
432
|
-
return legacySection[repository] ||
|
|
434
|
+
return legacySection[key] || legacySection[repository] || Object.entries(legacySection)
|
|
435
|
+
.find(([repoName]) => repoName.toLowerCase() === key)?.[1] || null;
|
|
433
436
|
}
|
|
434
437
|
|
|
435
438
|
/**
|
|
@@ -784,4 +787,4 @@ module.exports = {
|
|
|
784
787
|
shouldSkipUpdateNotifier,
|
|
785
788
|
_resetTokenCache,
|
|
786
789
|
DEFAULT_CHECKOUT_TIMEOUT_MS
|
|
787
|
-
};
|
|
790
|
+
};
|
package/src/database.js
CHANGED
|
@@ -3001,6 +3001,20 @@ class RepoSettingsRepository {
|
|
|
3001
3001
|
await run(this.db, `UPDATE repo_settings SET pool_fetch_finished_at = ? WHERE repository = ?`, [now, repository]);
|
|
3002
3002
|
}
|
|
3003
3003
|
|
|
3004
|
+
/**
|
|
3005
|
+
* List repositories with pool settings stored in the database.
|
|
3006
|
+
* Includes rows with a fetch interval only so callers can resolve complete
|
|
3007
|
+
* pool configuration with file fallback through resolvePoolConfig().
|
|
3008
|
+
* @returns {Promise<Array<{repository: string, pool_size: number|null, pool_fetch_interval_minutes: number|null}>>}
|
|
3009
|
+
*/
|
|
3010
|
+
async findPoolConfiguredRepoSettings() {
|
|
3011
|
+
return await query(this.db, `
|
|
3012
|
+
SELECT repository, pool_size, pool_fetch_interval_minutes
|
|
3013
|
+
FROM repo_settings
|
|
3014
|
+
WHERE pool_size IS NOT NULL OR pool_fetch_interval_minutes IS NOT NULL
|
|
3015
|
+
`);
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3004
3018
|
/**
|
|
3005
3019
|
* Delete settings for a repository
|
|
3006
3020
|
* @param {string} repository - Repository in owner/repo format
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fetch from a remote without auto-following tags reachable from the fetched
|
|
5
|
+
* commits. Large monorepos can have very large tag namespaces, and pair-review
|
|
6
|
+
* only needs commits/refs for review setup.
|
|
7
|
+
* @param {Object} git - simple-git instance
|
|
8
|
+
* @param {string[]} args - Arguments after `git fetch --no-tags`
|
|
9
|
+
* @returns {Promise<*>}
|
|
10
|
+
*/
|
|
11
|
+
async function fetchNoTags(git, args) {
|
|
12
|
+
return git.fetch(['--no-tags', ...args]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Raw `git fetch --no-tags` wrapper for fetch forms not exposed cleanly by
|
|
17
|
+
* simple-git helpers.
|
|
18
|
+
* @param {Object} git - simple-git instance
|
|
19
|
+
* @param {string[]} args - Arguments after `git fetch --no-tags`
|
|
20
|
+
* @returns {Promise<*>}
|
|
21
|
+
*/
|
|
22
|
+
async function rawFetchNoTags(git, args) {
|
|
23
|
+
return git.raw(['fetch', '--no-tags', ...args]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
fetchNoTags,
|
|
28
|
+
rawFetchNoTags,
|
|
29
|
+
};
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const logger = require('../utils/logger');
|
|
6
|
-
const { WorktreePoolRepository, WorktreeRepository, generateWorktreeId } = require('../database');
|
|
6
|
+
const { WorktreePoolRepository, WorktreeRepository, RepoSettingsRepository, generateWorktreeId } = require('../database');
|
|
7
7
|
const { GitWorktreeManager } = require('./worktree');
|
|
8
8
|
const { WorktreePoolUsageTracker } = require('./worktree-pool-usage');
|
|
9
9
|
const { normalizeRepository } = require('../utils/paths');
|
|
10
|
-
const {
|
|
10
|
+
const { resolvePoolConfig } = require('../config');
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Consolidates the worktree pool state machine: absorbs WorktreePoolManager
|
|
@@ -25,6 +25,7 @@ class WorktreePoolLifecycle {
|
|
|
25
25
|
const defaults = {
|
|
26
26
|
poolRepo: new WorktreePoolRepository(db),
|
|
27
27
|
worktreeRepo: new WorktreeRepository(db),
|
|
28
|
+
repoSettingsRepo: new RepoSettingsRepository(db),
|
|
28
29
|
usageTracker: new WorktreePoolUsageTracker(),
|
|
29
30
|
fs: fs,
|
|
30
31
|
simpleGit: require('simple-git'),
|
|
@@ -36,6 +37,7 @@ class WorktreePoolLifecycle {
|
|
|
36
37
|
this.config = config;
|
|
37
38
|
this._poolRepo = deps.poolRepo;
|
|
38
39
|
this._worktreeRepo = deps.worktreeRepo;
|
|
40
|
+
this._repoSettingsRepo = deps.repoSettingsRepo;
|
|
39
41
|
this._usageTracker = deps.usageTracker;
|
|
40
42
|
this._fs = deps.fs;
|
|
41
43
|
this._simpleGit = deps.simpleGit;
|
|
@@ -628,11 +630,20 @@ class WorktreePoolLifecycle {
|
|
|
628
630
|
* @private
|
|
629
631
|
*/
|
|
630
632
|
async _adoptExistingWorktrees() {
|
|
631
|
-
const
|
|
633
|
+
const config = this.config || {};
|
|
634
|
+
const repoSettingsRows = await this._repoSettingsRepo.findPoolConfiguredRepoSettings();
|
|
635
|
+
const settingsByRepo = new Map(
|
|
636
|
+
repoSettingsRows.map(row => [String(row.repository).toLowerCase(), row])
|
|
637
|
+
);
|
|
638
|
+
const repoNames = new Set(Object.keys(config.repos || {}));
|
|
639
|
+
for (const row of repoSettingsRows) {
|
|
640
|
+
repoNames.add(String(row.repository).toLowerCase());
|
|
641
|
+
}
|
|
632
642
|
const adoptedInUse = [];
|
|
633
643
|
|
|
634
|
-
for (const repoName of
|
|
635
|
-
const
|
|
644
|
+
for (const repoName of repoNames) {
|
|
645
|
+
const repoSettings = settingsByRepo.get(String(repoName).toLowerCase()) || null;
|
|
646
|
+
const { poolSize } = resolvePoolConfig(config, repoName, repoSettings);
|
|
636
647
|
if (!poolSize) continue;
|
|
637
648
|
|
|
638
649
|
// Count existing pool entries for this repo
|
package/src/git/worktree.js
CHANGED
|
@@ -8,6 +8,7 @@ const { WorktreeRepository, generateWorktreeId } = require('../database');
|
|
|
8
8
|
const { getGeneratedFilePatterns } = require('./gitattributes');
|
|
9
9
|
const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
|
|
10
10
|
const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./diff-flags');
|
|
11
|
+
const { fetchNoTags, rawFetchNoTags } = require('./fetch-helpers');
|
|
11
12
|
const { spawn, execSync } = require('child_process');
|
|
12
13
|
|
|
13
14
|
const MISSING_COMMIT_ERROR_CODE = 'PAIR_REVIEW_MISSING_COMMIT';
|
|
@@ -250,7 +251,7 @@ class GitWorktreeManager {
|
|
|
250
251
|
|
|
251
252
|
let fetchError = null;
|
|
252
253
|
try {
|
|
253
|
-
await git
|
|
254
|
+
await rawFetchNoTags(git, [remote, sha]);
|
|
254
255
|
} catch (error) {
|
|
255
256
|
fetchError = error;
|
|
256
257
|
}
|
|
@@ -370,7 +371,7 @@ class GitWorktreeManager {
|
|
|
370
371
|
const prTrackingRef = `refs/remotes/${baseRemote}/pr-${prNumber}`;
|
|
371
372
|
|
|
372
373
|
try {
|
|
373
|
-
await git
|
|
374
|
+
await fetchNoTags(git, [baseRemote, `+refs/pull/${prNumber}/head:${prTrackingRef}`]);
|
|
374
375
|
return {
|
|
375
376
|
remote: baseRemote,
|
|
376
377
|
trackingRef: prTrackingRef,
|
|
@@ -387,7 +388,7 @@ class GitWorktreeManager {
|
|
|
387
388
|
throw prRefError;
|
|
388
389
|
}
|
|
389
390
|
|
|
390
|
-
await git
|
|
391
|
+
await rawFetchNoTags(git, [baseRemote, headSha]);
|
|
391
392
|
return {
|
|
392
393
|
remote: baseRemote,
|
|
393
394
|
trackingRef: null,
|
|
@@ -591,13 +592,13 @@ class GitWorktreeManager {
|
|
|
591
592
|
// Fetch only the specific base branch we need, with error handling for ref conflicts
|
|
592
593
|
console.log(`Fetching base branch ${prData.base_branch} from ${remote}...`);
|
|
593
594
|
try {
|
|
594
|
-
await git
|
|
595
|
+
await fetchNoTags(git, [remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
|
|
595
596
|
} catch (fetchError) {
|
|
596
597
|
// If fetch fails due to ref conflicts, try alternative approaches
|
|
597
598
|
console.log(`Standard fetch failed, trying alternative: ${fetchError.message}`);
|
|
598
599
|
try {
|
|
599
600
|
// Try fetching with force flag to overwrite conflicting refs
|
|
600
|
-
await git
|
|
601
|
+
await rawFetchNoTags(git, ['--force', remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
|
|
601
602
|
} catch (altFetchError) {
|
|
602
603
|
console.warn(`Could not fetch base branch ${prData.base_branch}, will try to use existing ref`);
|
|
603
604
|
// Continue anyway - the branch might already be available locally
|
|
@@ -658,7 +659,7 @@ class GitWorktreeManager {
|
|
|
658
659
|
if (headBranch) {
|
|
659
660
|
try {
|
|
660
661
|
console.log(`Fetching head branch ${headBranch}...`);
|
|
661
|
-
await worktreeGit
|
|
662
|
+
await fetchNoTags(worktreeGit, [remote, `+refs/heads/${headBranch}:refs/remotes/${remote}/${headBranch}`]);
|
|
662
663
|
// Create/update a local branch pointing to the fetched ref so tooling can reference it by name
|
|
663
664
|
await worktreeGit.branch(['-f', headBranch, `${remote}/${headBranch}`]);
|
|
664
665
|
} catch (branchFetchError) {
|
|
@@ -769,14 +770,14 @@ class GitWorktreeManager {
|
|
|
769
770
|
// This mirrors the targeted fetch used in createWorktreeForPR.
|
|
770
771
|
if (prData?.base_branch) {
|
|
771
772
|
try {
|
|
772
|
-
await worktreeGit
|
|
773
|
+
await fetchNoTags(worktreeGit, [remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
|
|
773
774
|
} catch (fetchError) {
|
|
774
775
|
console.warn(`Targeted base-branch fetch failed, will rely on existing refs: ${fetchError.message}`);
|
|
775
776
|
}
|
|
776
777
|
}
|
|
777
778
|
} else {
|
|
778
779
|
console.log(`Fetching latest changes from ${remote}...`);
|
|
779
|
-
await worktreeGit
|
|
780
|
+
await fetchNoTags(worktreeGit, ['--prune', remote]);
|
|
780
781
|
}
|
|
781
782
|
|
|
782
783
|
await this.ensureBaseShaAvailable(worktreeGit, prData, remote);
|
package/src/local-review.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('path');
|
|
|
6
6
|
const fs = require('fs').promises;
|
|
7
7
|
const { loadConfig, showWelcomeMessage, resolveDbName, getGitHubToken } = require('./config');
|
|
8
8
|
const logger = require('./utils/logger');
|
|
9
|
+
const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
|
|
9
10
|
const { fireHooks, hasHooks } = require('./hooks/hook-runner');
|
|
10
11
|
const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('./hooks/payloads');
|
|
11
12
|
|
|
@@ -699,6 +700,8 @@ async function handleLocalReview(targetPath, flags = {}) {
|
|
|
699
700
|
let db = null;
|
|
700
701
|
|
|
701
702
|
try {
|
|
703
|
+
rejectUrlLikeLocalReviewPath(targetPath);
|
|
704
|
+
|
|
702
705
|
// Resolve target path
|
|
703
706
|
const resolvedPath = path.resolve(targetPath || process.cwd());
|
|
704
707
|
|
package/src/main.js
CHANGED
|
@@ -5,6 +5,7 @@ const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, Work
|
|
|
5
5
|
const { PRArgumentParser } = require('./github/parser');
|
|
6
6
|
const { GitHubClient } = require('./github/client');
|
|
7
7
|
const { GitWorktreeManager } = require('./git/worktree');
|
|
8
|
+
const { fetchNoTags } = require('./git/fetch-helpers');
|
|
8
9
|
const { WorktreePoolLifecycle } = require('./git/worktree-pool-lifecycle');
|
|
9
10
|
const { startServer } = require('./server');
|
|
10
11
|
const Analyzer = require('./ai/analyzer');
|
|
@@ -13,6 +14,7 @@ const { handleLocalReview, findMainGitRoot } = require('./local-review');
|
|
|
13
14
|
const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
|
|
14
15
|
const { fireReviewStartedHook } = require('./hooks/payloads');
|
|
15
16
|
const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
|
|
17
|
+
const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
|
|
16
18
|
const logger = require('./utils/logger');
|
|
17
19
|
const simpleGit = require('simple-git');
|
|
18
20
|
const { getGeneratedFilePatterns } = require('./git/gitattributes');
|
|
@@ -288,6 +290,7 @@ function parseArgs(args) {
|
|
|
288
290
|
flags.local = true;
|
|
289
291
|
// Next argument is optional path (if not starting with -)
|
|
290
292
|
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
293
|
+
rejectUrlLikeLocalReviewPath(args[i + 1]);
|
|
291
294
|
flags.localPath = args[i + 1];
|
|
292
295
|
i++; // Skip next argument since we consumed it
|
|
293
296
|
}
|
|
@@ -723,7 +726,7 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
723
726
|
|
|
724
727
|
// Ensure we have the base SHA available (fetch if needed)
|
|
725
728
|
try {
|
|
726
|
-
await git
|
|
729
|
+
await fetchNoTags(git, ['origin', prData.base_sha]);
|
|
727
730
|
} catch (fetchError) {
|
|
728
731
|
// Fetch by SHA may fail (not all servers support it); verify SHA is available locally
|
|
729
732
|
try {
|
|
@@ -1225,7 +1228,7 @@ function startPoolBackgroundFetches(db, config) {
|
|
|
1225
1228
|
const git = simpleGit(entry.path, { timeout: { block: 300000 } });
|
|
1226
1229
|
const remotes = await git.getRemotes();
|
|
1227
1230
|
const remote = remotes.find(r => r.name === 'origin') || remotes[0];
|
|
1228
|
-
if (remote) await git
|
|
1231
|
+
if (remote) await fetchNoTags(git, ['--prune', remote.name]);
|
|
1229
1232
|
await poolRepo.updateLastFetched(entry.id);
|
|
1230
1233
|
// Refresh the lease so the stale guard only needs to outlive
|
|
1231
1234
|
// a single stalled fetch, not the entire serial loop.
|
|
@@ -1294,4 +1297,4 @@ if (require.main === module) {
|
|
|
1294
1297
|
main();
|
|
1295
1298
|
}
|
|
1296
1299
|
|
|
1297
|
-
module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
|
|
1300
|
+
module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
|
package/src/routes/local.js
CHANGED
|
@@ -35,6 +35,7 @@ const { getProviderClass, createProvider } = require('../ai/provider');
|
|
|
35
35
|
const { getDefaultBranch, tryGraphiteState } = require('../git/base-branch');
|
|
36
36
|
const { CommentRepository } = require('../database');
|
|
37
37
|
const { runExecutableAnalysis, getChangedFiles } = require('./executable-analysis');
|
|
38
|
+
const { rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
|
|
38
39
|
const {
|
|
39
40
|
activeAnalyses,
|
|
40
41
|
localReviewDiffs,
|
|
@@ -380,6 +381,12 @@ router.post('/api/local/start', async (req, res) => {
|
|
|
380
381
|
});
|
|
381
382
|
}
|
|
382
383
|
|
|
384
|
+
try {
|
|
385
|
+
rejectUrlLikeLocalReviewPath(inputPath);
|
|
386
|
+
} catch (err) {
|
|
387
|
+
return res.status(400).json({ error: err.message });
|
|
388
|
+
}
|
|
389
|
+
|
|
383
390
|
// Required inline (not reusing top-level import) so that vi.spyOn()
|
|
384
391
|
// replacements on the module exports are visible at call time in integration tests.
|
|
385
392
|
const { findGitRoot, getHeadSha, getRepositoryName, getCurrentBranch } = require('../local-review');
|
package/src/routes/setup.js
CHANGED
|
@@ -18,6 +18,7 @@ const { setupLocalReview } = require('../setup/local-setup');
|
|
|
18
18
|
const { getGitHubToken, expandPath } = require('../config');
|
|
19
19
|
const { queryOne, ReviewRepository } = require('../database');
|
|
20
20
|
const { normalizeRepository } = require('../utils/paths');
|
|
21
|
+
const { rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
|
|
21
22
|
const logger = require('../utils/logger');
|
|
22
23
|
|
|
23
24
|
const router = express.Router();
|
|
@@ -186,6 +187,12 @@ router.post('/api/setup/local', async (req, res) => {
|
|
|
186
187
|
return res.status(400).json({ error: 'Missing required field: path' });
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
try {
|
|
191
|
+
rejectUrlLikeLocalReviewPath(rawPath);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
return res.status(400).json({ error: err.message });
|
|
194
|
+
}
|
|
195
|
+
|
|
189
196
|
const targetPath = expandPath(rawPath);
|
|
190
197
|
const db = req.app.get('db');
|
|
191
198
|
|
|
@@ -220,7 +220,7 @@ async function executeStackAnalysis(params) {
|
|
|
220
220
|
// 2. Bulk fetch all PR refs (runs against trigger worktree)
|
|
221
221
|
const refspecs = prNumbers.map(n => `+refs/pull/${n}/head:refs/remotes/origin/pr-${n}`);
|
|
222
222
|
try {
|
|
223
|
-
deps.execSync(`git fetch origin ${refspecs.join(' ')}`, {
|
|
223
|
+
deps.execSync(`git fetch --no-tags origin ${refspecs.join(' ')}`, {
|
|
224
224
|
cwd: triggerWorktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
225
225
|
timeout: 60000
|
|
226
226
|
});
|
package/src/setup/local-setup.js
CHANGED
|
@@ -6,6 +6,7 @@ const { fireHooks, hasHooks } = require('../hooks/hook-runner');
|
|
|
6
6
|
const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('../hooks/payloads');
|
|
7
7
|
const { STOPS, DEFAULT_SCOPE, reviewScope } = require('../local-scope');
|
|
8
8
|
const logger = require('../utils/logger');
|
|
9
|
+
const { LOCAL_REVIEW_PATH_URL_ERROR, rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
|
|
9
10
|
const path = require('path');
|
|
10
11
|
const fs = require('fs').promises;
|
|
11
12
|
|
|
@@ -31,11 +32,14 @@ async function setupLocalReview({ db, targetPath, onProgress, config }) {
|
|
|
31
32
|
let resolvedPath;
|
|
32
33
|
try {
|
|
33
34
|
progress({ step: 'validate', status: 'running', message: 'Validating target path...' });
|
|
35
|
+
rejectUrlLikeLocalReviewPath(targetPath);
|
|
34
36
|
resolvedPath = path.resolve(targetPath);
|
|
35
37
|
await fs.access(resolvedPath);
|
|
36
38
|
progress({ step: 'validate', status: 'completed', message: `Path resolved to ${resolvedPath}` });
|
|
37
39
|
} catch (err) {
|
|
38
|
-
const message =
|
|
40
|
+
const message = err.message === LOCAL_REVIEW_PATH_URL_ERROR
|
|
41
|
+
? err.message
|
|
42
|
+
: `Path does not exist: ${path.resolve(targetPath)}`;
|
|
39
43
|
progress({ step: 'validate', status: 'error', message });
|
|
40
44
|
throw new Error(message);
|
|
41
45
|
}
|
package/src/setup/pr-setup.js
CHANGED
|
@@ -17,7 +17,7 @@ const { WorktreePoolLifecycle } = require('../git/worktree-pool-lifecycle');
|
|
|
17
17
|
const { GitHubClient } = require('../github/client');
|
|
18
18
|
const { normalizeRepository } = require('../utils/paths');
|
|
19
19
|
const { findMainGitRoot } = require('../local-review');
|
|
20
|
-
const { getConfigDir, getRepoPath, resolveRepoOptions,
|
|
20
|
+
const { getConfigDir, getRepoPath, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
|
|
21
21
|
const logger = require('../utils/logger');
|
|
22
22
|
const { fireReviewStartedHook } = require('../hooks/payloads');
|
|
23
23
|
const simpleGit = require('simple-git');
|
|
@@ -229,6 +229,7 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
|
|
|
229
229
|
const worktreeManager = new GitWorktreeManager(db);
|
|
230
230
|
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
231
231
|
const worktreeRepo = new WorktreeRepository(db);
|
|
232
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
232
233
|
|
|
233
234
|
let repositoryPath = null;
|
|
234
235
|
let worktreeSourcePath = null; // Path to use as cwd for `git worktree add` (may differ from repositoryPath)
|
|
@@ -288,7 +289,7 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
|
|
|
288
289
|
// ------------------------------------------------------------------
|
|
289
290
|
// Resolve monorepo worktree options (checkout_script, worktree_directory, worktree_name_template)
|
|
290
291
|
// ------------------------------------------------------------------
|
|
291
|
-
const resolved = config ? resolveRepoOptions(config, repository) : { checkoutScript: null, checkoutTimeout: DEFAULT_CHECKOUT_TIMEOUT_MS, worktreeConfig: null };
|
|
292
|
+
const resolved = config ? resolveRepoOptions(config, repository, repoSettings) : { checkoutScript: null, checkoutTimeout: DEFAULT_CHECKOUT_TIMEOUT_MS, worktreeConfig: null };
|
|
292
293
|
const { checkoutScript, checkoutTimeout, worktreeConfig } = resolved;
|
|
293
294
|
|
|
294
295
|
// When a checkout script is configured, null out worktreeSourcePath —
|
|
@@ -301,7 +302,7 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
|
|
|
301
302
|
// ------------------------------------------------------------------
|
|
302
303
|
// Tier 0: Check known local path from repo_settings
|
|
303
304
|
// ------------------------------------------------------------------
|
|
304
|
-
const knownPath =
|
|
305
|
+
const knownPath = repoSettings?.local_path || null;
|
|
305
306
|
|
|
306
307
|
if (!repositoryPath && knownPath && await worktreeManager.pathExists(knownPath)) {
|
|
307
308
|
try {
|
|
@@ -464,7 +465,9 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, config, o
|
|
|
464
465
|
// Step: worktree - Create git worktree for the PR
|
|
465
466
|
// ------------------------------------------------------------------
|
|
466
467
|
const prInfo = { owner, repo, number: prNumber };
|
|
467
|
-
const
|
|
468
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
469
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
470
|
+
const { poolSize } = resolvePoolConfig(config || {}, repository, repoSettings);
|
|
468
471
|
const resetScript = config ? getRepoResetScript(config, repository) : null;
|
|
469
472
|
|
|
470
473
|
let worktreePath;
|
package/src/single-port.js
CHANGED
|
@@ -4,6 +4,7 @@ const path = require('path');
|
|
|
4
4
|
const semver = require('semver');
|
|
5
5
|
const { PRArgumentParser } = require('./github/parser');
|
|
6
6
|
const logger = require('./utils/logger');
|
|
7
|
+
const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
|
|
7
8
|
const { version: packageVersion } = require('../package.json');
|
|
8
9
|
|
|
9
10
|
const HEALTH_TIMEOUT_MS = 2000;
|
|
@@ -158,6 +159,7 @@ async function attemptDelegation(config, flags, prArgs, _deps) {
|
|
|
158
159
|
// Determine mode and build URL
|
|
159
160
|
let url;
|
|
160
161
|
if (flags.local) {
|
|
162
|
+
rejectUrlLikeLocalReviewPath(flags.localPath);
|
|
161
163
|
const targetPath = path.resolve(flags.localPath || process.cwd());
|
|
162
164
|
url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze: flags.ai });
|
|
163
165
|
} else if (prArgs.length > 0) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const LOCAL_REVIEW_PATH_URL_ERROR = 'Local reviews require a filesystem path, not a URL. Pass GitHub or Graphite URLs as PR review inputs instead.';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect inputs that are URLs or remote-style Git URLs rather than filesystem paths.
|
|
7
|
+
* This intentionally checks only unambiguous URL forms so normal absolute,
|
|
8
|
+
* relative, tilde, and Windows paths continue to work.
|
|
9
|
+
*
|
|
10
|
+
* @param {unknown} input
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
function isUrlLikeLocalReviewPath(input) {
|
|
14
|
+
if (typeof input !== 'string') return false;
|
|
15
|
+
|
|
16
|
+
const value = input.trim();
|
|
17
|
+
if (!value) return false;
|
|
18
|
+
|
|
19
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return true;
|
|
20
|
+
if (/^(?:github\.com|app\.graphite\.(?:dev|com))\//i.test(value)) return true;
|
|
21
|
+
// Treat only a leading user@host:path token as SSH remote syntax; if a
|
|
22
|
+
// directory prefix contains @ and : it should remain a filesystem path.
|
|
23
|
+
if (/^[^@/\\\s]+@[^:/\\\s]+:[^\s]+$/.test(value)) return true;
|
|
24
|
+
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Throw a user-facing error when a local review path is actually a URL.
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} input
|
|
32
|
+
* @throws {Error}
|
|
33
|
+
*/
|
|
34
|
+
function rejectUrlLikeLocalReviewPath(input) {
|
|
35
|
+
if (isUrlLikeLocalReviewPath(input)) {
|
|
36
|
+
throw new Error(LOCAL_REVIEW_PATH_URL_ERROR);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
LOCAL_REVIEW_PATH_URL_ERROR,
|
|
42
|
+
isUrlLikeLocalReviewPath,
|
|
43
|
+
rejectUrlLikeLocalReviewPath
|
|
44
|
+
};
|