@in-the-loop-labs/pair-review 3.3.7 → 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 +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/UpdateBanner.js +143 -0
- package/public/js/index.js +43 -0
- package/public/js/modules/diff-renderer.js +3 -0
- package/public/local.html +2 -0
- package/public/pr.html +2 -0
- package/public/setup.html +1 -0
- package/src/config.js +35 -21
- 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 +39 -23
- package/src/mcp-stdio.js +7 -0
- package/src/routes/config.js +55 -1
- package/src/routes/local.js +7 -0
- package/src/routes/setup.js +7 -0
- package/src/routes/stack-analysis.js +1 -1
- package/src/server.js +51 -9
- package/src/setup/local-setup.js +5 -1
- package/src/setup/pr-setup.js +7 -4
- package/src/single-port.js +193 -0
- package/src/utils/local-path-input.js +44 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@in-the-loop-labs/pair-review",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.1",
|
|
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": {
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"glob": "^13.0.6",
|
|
69
69
|
"markdown-it": "^13.0.2",
|
|
70
70
|
"open": "^9.1.0",
|
|
71
|
+
"semver": "^7.7.4",
|
|
71
72
|
"simple-git": "^3.19.1",
|
|
72
73
|
"update-notifier": "^5.1.0",
|
|
73
74
|
"uuid": "^11.1.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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/css/styles.css
CHANGED
|
@@ -31,7 +31,13 @@
|
|
|
31
31
|
--color-warning-bg: #fffae6;
|
|
32
32
|
--color-warning-border: #ffc552;
|
|
33
33
|
--color-warning-text: #7d4e00;
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
--color-info-bg: #eff6ff;
|
|
36
|
+
--color-info-border: #bfdbfe;
|
|
37
|
+
--color-info-accent: #3b82f6;
|
|
38
|
+
--color-info-text: #1e3a8a;
|
|
39
|
+
--color-info-text-muted: #3b5998;
|
|
40
|
+
|
|
35
41
|
--color-selection: #fff5b1;
|
|
36
42
|
--color-selection-num: #ffeb3b;
|
|
37
43
|
|
|
@@ -72,7 +78,13 @@
|
|
|
72
78
|
--color-warning-bg: #3d2e00;
|
|
73
79
|
--color-warning-border: #9e6a03;
|
|
74
80
|
--color-warning-text: #e3b341;
|
|
75
|
-
|
|
81
|
+
|
|
82
|
+
--color-info-bg: #152033;
|
|
83
|
+
--color-info-border: #2d4a7a;
|
|
84
|
+
--color-info-accent: #4a90e2;
|
|
85
|
+
--color-info-text: #cfe0f5;
|
|
86
|
+
--color-info-text-muted: #8aa9cf;
|
|
87
|
+
|
|
76
88
|
--color-selection: #3d3300;
|
|
77
89
|
--color-selection-num: #4d4000;
|
|
78
90
|
|
package/public/index.html
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* UpdateBanner Component
|
|
4
|
+
* Shows a persistent, dismissible corner-card notification when a newer
|
|
5
|
+
* version of pair-review is available. Single delivery path: on construction,
|
|
6
|
+
* fetch /api/config and show the banner if a pending_update exists.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DISMISS_KEY = 'update-banner-dismissed';
|
|
10
|
+
|
|
11
|
+
class UpdateBanner {
|
|
12
|
+
constructor() {
|
|
13
|
+
this._banner = null;
|
|
14
|
+
this._dismissBtn = null;
|
|
15
|
+
this._version = null;
|
|
16
|
+
|
|
17
|
+
// Single path: fetch current config at construction; show banner if a
|
|
18
|
+
// pending update exists. No event listener, no WebSocket coupling.
|
|
19
|
+
// `fetch()` returns a Promise; `.then()` chains async steps; the final
|
|
20
|
+
// `.catch()` swallows network errors because the banner is non-critical.
|
|
21
|
+
fetch('/api/config')
|
|
22
|
+
.then(r => (r.ok ? r.json() : null))
|
|
23
|
+
.then(config => {
|
|
24
|
+
if (config && config.pending_update) this.show(config.pending_update);
|
|
25
|
+
})
|
|
26
|
+
.catch(() => { /* non-critical */ });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Show the update banner for the given version.
|
|
31
|
+
* No-op if already showing or dismissed for this version.
|
|
32
|
+
* @param {string} version
|
|
33
|
+
*/
|
|
34
|
+
show(version) {
|
|
35
|
+
if (!version) return;
|
|
36
|
+
|
|
37
|
+
// Already dismissed for this version in this session
|
|
38
|
+
if (sessionStorage.getItem(DISMISS_KEY) === version) return;
|
|
39
|
+
|
|
40
|
+
// Already showing this version
|
|
41
|
+
if (this._banner && this._version === version) return;
|
|
42
|
+
|
|
43
|
+
// Remove any existing banner (e.g., for an older version)
|
|
44
|
+
this._remove();
|
|
45
|
+
|
|
46
|
+
this._version = version;
|
|
47
|
+
|
|
48
|
+
// Theme-aware colors come from CSS custom properties (set in styles.css
|
|
49
|
+
// under :root and [data-theme="dark"]). The inline `var(..., fallback)`
|
|
50
|
+
// form keeps the banner readable even if the stylesheet hasn't loaded
|
|
51
|
+
// yet. No MutationObserver needed — CSS handles the theme switch.
|
|
52
|
+
const banner = document.createElement('div');
|
|
53
|
+
banner.setAttribute('data-update-banner', '');
|
|
54
|
+
Object.assign(banner.style, {
|
|
55
|
+
position: 'fixed',
|
|
56
|
+
top: '16px',
|
|
57
|
+
left: '16px',
|
|
58
|
+
zIndex: '1000',
|
|
59
|
+
maxWidth: '360px',
|
|
60
|
+
background: 'var(--color-info-bg, #eff6ff)',
|
|
61
|
+
border: '1px solid var(--color-info-border, #bfdbfe)',
|
|
62
|
+
borderLeft: '4px solid var(--color-info-accent, #3b82f6)',
|
|
63
|
+
borderRadius: '8px',
|
|
64
|
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
|
65
|
+
padding: '12px 14px',
|
|
66
|
+
fontSize: '13px',
|
|
67
|
+
lineHeight: '1.4',
|
|
68
|
+
color: 'var(--color-info-text, #1e3a8a)',
|
|
69
|
+
display: 'flex',
|
|
70
|
+
alignItems: 'flex-start',
|
|
71
|
+
gap: '10px'
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Two-line layout: headline + restart instruction on its own line.
|
|
75
|
+
const text = document.createElement('div');
|
|
76
|
+
text.style.flex = '1';
|
|
77
|
+
|
|
78
|
+
const headline = document.createElement('div');
|
|
79
|
+
headline.textContent = `pair-review v${version} is available.`;
|
|
80
|
+
|
|
81
|
+
const instruction = document.createElement('div');
|
|
82
|
+
instruction.textContent = 'Restart the server to update.';
|
|
83
|
+
instruction.style.marginTop = '2px';
|
|
84
|
+
|
|
85
|
+
text.appendChild(headline);
|
|
86
|
+
text.appendChild(instruction);
|
|
87
|
+
|
|
88
|
+
const dismissBtn = document.createElement('button');
|
|
89
|
+
dismissBtn.textContent = '\u00d7';
|
|
90
|
+
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
|
91
|
+
Object.assign(dismissBtn.style, {
|
|
92
|
+
background: 'none',
|
|
93
|
+
border: 'none',
|
|
94
|
+
color: 'var(--color-info-text-muted, #3b5998)',
|
|
95
|
+
cursor: 'pointer',
|
|
96
|
+
fontSize: '18px',
|
|
97
|
+
padding: '0',
|
|
98
|
+
lineHeight: '1',
|
|
99
|
+
flexShrink: '0',
|
|
100
|
+
opacity: '0.8'
|
|
101
|
+
});
|
|
102
|
+
dismissBtn.addEventListener('click', () => this.dismiss());
|
|
103
|
+
|
|
104
|
+
banner.appendChild(text);
|
|
105
|
+
banner.appendChild(dismissBtn);
|
|
106
|
+
document.body.appendChild(banner);
|
|
107
|
+
this._banner = banner;
|
|
108
|
+
this._dismissBtn = dismissBtn;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Dismiss the banner and remember the choice for this session. */
|
|
112
|
+
dismiss() {
|
|
113
|
+
if (this._version) {
|
|
114
|
+
sessionStorage.setItem(DISMISS_KEY, this._version);
|
|
115
|
+
}
|
|
116
|
+
this._remove();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @private */
|
|
120
|
+
_remove() {
|
|
121
|
+
if (this._banner && this._banner.parentNode) {
|
|
122
|
+
this._banner.parentNode.removeChild(this._banner);
|
|
123
|
+
}
|
|
124
|
+
this._banner = null;
|
|
125
|
+
this._dismissBtn = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Singleton init (browser only)
|
|
130
|
+
if (typeof window !== 'undefined' && !window.updateBanner) {
|
|
131
|
+
if (document.readyState === 'loading') {
|
|
132
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
133
|
+
window.updateBanner = new UpdateBanner();
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
window.updateBanner = new UpdateBanner();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// CommonJS export for unit tests
|
|
141
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
142
|
+
module.exports = { UpdateBanner };
|
|
143
|
+
}
|
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/public/local.html
CHANGED
|
@@ -562,6 +562,7 @@
|
|
|
562
562
|
|
|
563
563
|
<!-- Highlight.js for syntax highlighting -->
|
|
564
564
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
565
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/elixir.min.js"></script>
|
|
565
566
|
|
|
566
567
|
<!-- Markdown-it Library for rendering markdown in comments -->
|
|
567
568
|
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.2/dist/markdown-it.min.js"></script>
|
|
@@ -591,6 +592,7 @@
|
|
|
591
592
|
<!-- Components -->
|
|
592
593
|
<script src="/js/components/TabTitle.js"></script>
|
|
593
594
|
<script src="/js/components/Toast.js"></script>
|
|
595
|
+
<script src="/js/components/UpdateBanner.js"></script>
|
|
594
596
|
<script src="/js/components/ConfirmDialog.js"></script>
|
|
595
597
|
<script src="/js/components/TextInputDialog.js"></script>
|
|
596
598
|
<script src="/js/components/AnalysisConfigModal.js"></script>
|
package/public/pr.html
CHANGED
|
@@ -358,6 +358,7 @@
|
|
|
358
358
|
|
|
359
359
|
<!-- Highlight.js for syntax highlighting -->
|
|
360
360
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
361
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/elixir.min.js"></script>
|
|
361
362
|
|
|
362
363
|
<!-- Markdown-it Library for rendering markdown in comments -->
|
|
363
364
|
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.2/dist/markdown-it.min.js"></script>
|
|
@@ -387,6 +388,7 @@
|
|
|
387
388
|
<!-- Components -->
|
|
388
389
|
<script src="/js/components/TabTitle.js"></script>
|
|
389
390
|
<script src="/js/components/Toast.js"></script>
|
|
391
|
+
<script src="/js/components/UpdateBanner.js"></script>
|
|
390
392
|
<script src="/js/components/ConfirmDialog.js"></script>
|
|
391
393
|
<script src="/js/components/TextInputDialog.js"></script>
|
|
392
394
|
<script src="/js/components/AnalysisConfigModal.js"></script>
|
package/public/setup.html
CHANGED
package/src/config.js
CHANGED
|
@@ -19,6 +19,7 @@ const DEFAULT_CONFIG = {
|
|
|
19
19
|
github_token: "",
|
|
20
20
|
github_token_command: "gh auth token", // Shell command whose stdout is used as the GitHub token
|
|
21
21
|
port: 7247,
|
|
22
|
+
single_port: true, // When true, reuse a single server on the configured port; new invocations delegate to the running server
|
|
22
23
|
theme: "light",
|
|
23
24
|
default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
|
|
24
25
|
default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
|
|
@@ -128,7 +129,7 @@ async function copyExampleConfig() {
|
|
|
128
129
|
try {
|
|
129
130
|
await fs.access(sourceExample);
|
|
130
131
|
await fs.copyFile(sourceExample, CONFIG_EXAMPLE_FILE);
|
|
131
|
-
|
|
132
|
+
logger.info(`Copied config.example.json to: ${CONFIG_EXAMPLE_FILE}`);
|
|
132
133
|
return true;
|
|
133
134
|
} catch (error) {
|
|
134
135
|
if (error.code === 'ENOENT') {
|
|
@@ -154,13 +155,13 @@ async function ensureConfigDir() {
|
|
|
154
155
|
if (error.code === 'ENOENT') {
|
|
155
156
|
try {
|
|
156
157
|
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
157
|
-
|
|
158
|
+
logger.info(`Created config directory: ${CONFIG_DIR}`);
|
|
158
159
|
// Copy example config to new directory
|
|
159
160
|
await copyExampleConfig();
|
|
160
161
|
return true; // Directory was newly created
|
|
161
162
|
} catch (mkdirError) {
|
|
162
163
|
if (mkdirError.code === 'EACCES' || mkdirError.code === 'EPERM') {
|
|
163
|
-
|
|
164
|
+
logger.error(`Cannot create configuration directory at ~/.pair-review/`);
|
|
164
165
|
process.exit(1);
|
|
165
166
|
}
|
|
166
167
|
throw mkdirError;
|
|
@@ -211,7 +212,7 @@ async function loadConfig() {
|
|
|
211
212
|
// Optional files or managed-config-present: skip silently
|
|
212
213
|
} else if (error instanceof SyntaxError) {
|
|
213
214
|
if (source.required) {
|
|
214
|
-
|
|
215
|
+
logger.error(`Invalid configuration file at ~/.pair-review/config.json`);
|
|
215
216
|
process.exit(1);
|
|
216
217
|
}
|
|
217
218
|
logger.warn(`Malformed config at ${source.label}, skipping`);
|
|
@@ -221,23 +222,33 @@ async function loadConfig() {
|
|
|
221
222
|
}
|
|
222
223
|
}
|
|
223
224
|
|
|
224
|
-
// Normalize legacy monorepos
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (mergedConfig.repos) {
|
|
231
|
-
const normalized = {};
|
|
232
|
-
for (const [key, value] of Object.entries(mergedConfig.repos)) {
|
|
233
|
-
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;
|
|
234
231
|
}
|
|
235
|
-
|
|
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
|
+
|
|
239
|
+
// PORT env var overrides all config layers (used by Preview and similar harnesses)
|
|
240
|
+
if (process.env.PORT) {
|
|
241
|
+
const envPort = Number(process.env.PORT);
|
|
242
|
+
if (!validatePort(envPort)) {
|
|
243
|
+
logger.error(`Invalid PORT env var "${process.env.PORT}" (must be an integer between 1024 and 65535)`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
mergedConfig.port = envPort;
|
|
236
247
|
}
|
|
237
248
|
|
|
238
249
|
// Validate port
|
|
239
250
|
if (!validatePort(mergedConfig.port)) {
|
|
240
|
-
|
|
251
|
+
logger.error(`Invalid port number ${mergedConfig.port}`);
|
|
241
252
|
process.exit(1);
|
|
242
253
|
}
|
|
243
254
|
|
|
@@ -269,7 +280,7 @@ async function saveConfig(config) {
|
|
|
269
280
|
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
270
281
|
} catch (error) {
|
|
271
282
|
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
272
|
-
|
|
283
|
+
logger.error(`Cannot create configuration directory at ~/.pair-review/`);
|
|
273
284
|
process.exit(1);
|
|
274
285
|
}
|
|
275
286
|
throw error;
|
|
@@ -413,12 +424,15 @@ function expandPath(p) {
|
|
|
413
424
|
* @returns {object|null}
|
|
414
425
|
*/
|
|
415
426
|
function getRepoConfig(config, repository) {
|
|
427
|
+
const key = String(repository).toLowerCase();
|
|
416
428
|
const reposSection = config.repos || {};
|
|
417
|
-
const
|
|
418
|
-
|
|
429
|
+
const repoEntry = reposSection[key] || reposSection[repository] || Object.entries(reposSection)
|
|
430
|
+
.find(([repoName]) => repoName.toLowerCase() === key)?.[1];
|
|
431
|
+
if (repoEntry) return repoEntry;
|
|
419
432
|
|
|
420
433
|
const legacySection = config.monorepos || {};
|
|
421
|
-
return legacySection[repository] ||
|
|
434
|
+
return legacySection[key] || legacySection[repository] || Object.entries(legacySection)
|
|
435
|
+
.find(([repoName]) => repoName.toLowerCase() === key)?.[1] || null;
|
|
422
436
|
}
|
|
423
437
|
|
|
424
438
|
/**
|
|
@@ -773,4 +787,4 @@ module.exports = {
|
|
|
773
787
|
shouldSkipUpdateNotifier,
|
|
774
788
|
_resetTokenCache,
|
|
775
789
|
DEFAULT_CHECKOUT_TIMEOUT_MS
|
|
776
|
-
};
|
|
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
|
|