@in-the-loop-labs/pair-review 2.2.0 → 2.3.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/README.md +77 -0
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/components/ChatPanel.js +164 -100
- package/public/js/components/CouncilProgressModal.js +56 -64
- package/public/js/components/PanelGroup.js +4 -2
- package/public/js/modules/file-comment-manager.js +11 -30
- package/public/js/modules/panel-resizer.js +84 -1
- package/public/js/modules/suggestion-manager.js +2 -23
- package/public/js/pr.js +20 -15
- package/public/js/ws-client.js +155 -0
- package/public/local.html +3 -0
- package/public/pr.html +3 -0
- package/public/setup.html +51 -90
- package/src/ai/analyzer.js +5 -8
- package/src/config.js +70 -49
- package/src/database.js +29 -9
- package/src/events/review-events.js +30 -0
- package/src/routes/analyses.js +3 -102
- package/src/routes/chat.js +37 -74
- package/src/routes/config.js +62 -4
- package/src/routes/context-files.js +1 -1
- package/src/routes/local.js +1 -2
- package/src/routes/mcp.js +1 -1
- package/src/routes/pr.js +1 -2
- package/src/routes/reviews.js +36 -29
- package/src/routes/setup.js +17 -114
- package/src/routes/shared.js +5 -49
- package/src/server.js +4 -0
- package/src/utils/comment-formatter.js +137 -0
- package/src/ws/index.js +2 -0
- package/src/ws/server.js +123 -0
- package/src/sse/review-events.js +0 -46
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Browser-side WebSocket client singleton.
|
|
5
|
+
* Provides topic-based pub/sub over a single WebSocket connection
|
|
6
|
+
* with automatic reconnection and subscription restoration.
|
|
7
|
+
*/
|
|
8
|
+
(function () {
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
class WSClient {
|
|
12
|
+
constructor() {
|
|
13
|
+
/** @type {WebSocket|null} */
|
|
14
|
+
this._ws = null;
|
|
15
|
+
/** @type {Map<string, Set<Function>>} topic -> callbacks */
|
|
16
|
+
this._subscriptions = new Map();
|
|
17
|
+
/** @type {boolean} */
|
|
18
|
+
this.connected = false;
|
|
19
|
+
/** @type {number} current backoff delay in ms */
|
|
20
|
+
this._backoff = 1000;
|
|
21
|
+
/** @type {number} */
|
|
22
|
+
this._backoffMax = 10000;
|
|
23
|
+
/** @type {boolean} whether close() was called intentionally */
|
|
24
|
+
this._closed = false;
|
|
25
|
+
/** @type {number|null} reconnect timer id */
|
|
26
|
+
this._reconnectTimer = null;
|
|
27
|
+
/** @type {boolean} whether at least one connection has been established */
|
|
28
|
+
this._hasConnected = false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Open the WebSocket connection. No-op if already connected or connecting.
|
|
33
|
+
*/
|
|
34
|
+
connect() {
|
|
35
|
+
if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this._closed = false;
|
|
39
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
40
|
+
const url = `${protocol}//${location.host}/ws`;
|
|
41
|
+
this._ws = new WebSocket(url);
|
|
42
|
+
|
|
43
|
+
this._ws.onopen = () => {
|
|
44
|
+
this.connected = true;
|
|
45
|
+
this._backoff = 1000;
|
|
46
|
+
// Re-subscribe to all active topics (_subscriptions is authoritative)
|
|
47
|
+
for (const topic of this._subscriptions.keys()) {
|
|
48
|
+
this._ws.send(JSON.stringify({ action: 'subscribe', topic }));
|
|
49
|
+
}
|
|
50
|
+
// Emit reconnected event on subsequent opens (not the initial connect)
|
|
51
|
+
if (this._hasConnected) {
|
|
52
|
+
window.dispatchEvent(new CustomEvent('wsReconnected'));
|
|
53
|
+
}
|
|
54
|
+
this._hasConnected = true;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
this._ws.onmessage = (event) => {
|
|
58
|
+
let msg;
|
|
59
|
+
try {
|
|
60
|
+
msg = JSON.parse(event.data);
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const callbacks = this._subscriptions.get(msg.topic);
|
|
65
|
+
if (callbacks) {
|
|
66
|
+
for (const cb of [...callbacks]) {
|
|
67
|
+
try {
|
|
68
|
+
cb(msg);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error('[WSClient] Subscriber error:', e);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
this._ws.onclose = () => {
|
|
77
|
+
this.connected = false;
|
|
78
|
+
this._ws = null;
|
|
79
|
+
if (!this._closed) {
|
|
80
|
+
this._scheduleReconnect();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
this._ws.onerror = () => {
|
|
85
|
+
// onclose will fire after onerror, which handles reconnection
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Subscribe to a topic. Returns an unsubscribe function.
|
|
91
|
+
* Safe to call before connect() — the subscribe message will be
|
|
92
|
+
* sent once the connection is established.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} topic
|
|
95
|
+
* @param {Function} callback - receives the full parsed message object
|
|
96
|
+
* @returns {Function} unsubscribe
|
|
97
|
+
*/
|
|
98
|
+
subscribe(topic, callback) {
|
|
99
|
+
let callbacks = this._subscriptions.get(topic);
|
|
100
|
+
if (!callbacks) {
|
|
101
|
+
callbacks = new Set();
|
|
102
|
+
this._subscriptions.set(topic, callbacks);
|
|
103
|
+
}
|
|
104
|
+
callbacks.add(callback);
|
|
105
|
+
|
|
106
|
+
// Send subscribe message if connected (otherwise it will be sent on connect via _subscriptions)
|
|
107
|
+
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
108
|
+
this._ws.send(JSON.stringify({ action: 'subscribe', topic }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Return unsubscribe function
|
|
112
|
+
return () => {
|
|
113
|
+
callbacks.delete(callback);
|
|
114
|
+
if (callbacks.size === 0) {
|
|
115
|
+
this._subscriptions.delete(topic);
|
|
116
|
+
const unsub = { action: 'unsubscribe', topic };
|
|
117
|
+
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
118
|
+
this._ws.send(JSON.stringify(unsub));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Close the WebSocket and stop reconnection.
|
|
126
|
+
*/
|
|
127
|
+
close() {
|
|
128
|
+
this._closed = true;
|
|
129
|
+
if (this._reconnectTimer !== null) {
|
|
130
|
+
clearTimeout(this._reconnectTimer);
|
|
131
|
+
this._reconnectTimer = null;
|
|
132
|
+
}
|
|
133
|
+
if (this._ws) {
|
|
134
|
+
this._ws.close();
|
|
135
|
+
this._ws = null;
|
|
136
|
+
}
|
|
137
|
+
this.connected = false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @private */
|
|
141
|
+
_scheduleReconnect() {
|
|
142
|
+
this._reconnectTimer = setTimeout(() => {
|
|
143
|
+
this._reconnectTimer = null;
|
|
144
|
+
this.connect();
|
|
145
|
+
}, this._backoff);
|
|
146
|
+
this._backoff = Math.min(this._backoff * 2, this._backoffMax);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Export as singleton on window
|
|
151
|
+
if (typeof window !== 'undefined') {
|
|
152
|
+
window.WSClient = WSClient;
|
|
153
|
+
window.wsClient = new WSClient();
|
|
154
|
+
}
|
|
155
|
+
})();
|
package/public/local.html
CHANGED
|
@@ -516,6 +516,9 @@
|
|
|
516
516
|
<!-- Timestamp parsing utility -->
|
|
517
517
|
<script src="/js/utils/time.js"></script>
|
|
518
518
|
|
|
519
|
+
<!-- WebSocket client -->
|
|
520
|
+
<script src="/js/ws-client.js"></script>
|
|
521
|
+
|
|
519
522
|
<!-- Components -->
|
|
520
523
|
<script src="/js/components/Toast.js"></script>
|
|
521
524
|
<script src="/js/components/ConfirmDialog.js"></script>
|
package/public/pr.html
CHANGED
|
@@ -339,6 +339,9 @@
|
|
|
339
339
|
<!-- Timestamp parsing utility -->
|
|
340
340
|
<script src="/js/utils/time.js"></script>
|
|
341
341
|
|
|
342
|
+
<!-- WebSocket client -->
|
|
343
|
+
<script src="/js/ws-client.js"></script>
|
|
344
|
+
|
|
342
345
|
<!-- Components -->
|
|
343
346
|
<script src="/js/components/Toast.js"></script>
|
|
344
347
|
<script src="/js/components/ConfirmDialog.js"></script>
|
package/public/setup.html
CHANGED
|
@@ -517,6 +517,9 @@
|
|
|
517
517
|
</div>
|
|
518
518
|
</div>
|
|
519
519
|
|
|
520
|
+
<!-- WebSocket client -->
|
|
521
|
+
<script src="/js/ws-client.js"></script>
|
|
522
|
+
|
|
520
523
|
<script>
|
|
521
524
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
522
525
|
|
|
@@ -718,17 +721,15 @@
|
|
|
718
721
|
|
|
719
722
|
/* ── Setup Flow ── */
|
|
720
723
|
async function startSetup() {
|
|
721
|
-
var postUrl
|
|
724
|
+
var postUrl;
|
|
722
725
|
|
|
723
726
|
if (mode === 'pr') {
|
|
724
727
|
var o = encodeURIComponent(context.owner);
|
|
725
728
|
var r = encodeURIComponent(context.repo);
|
|
726
729
|
var n = encodeURIComponent(context.number);
|
|
727
730
|
postUrl = '/api/setup/pr/' + o + '/' + r + '/' + n;
|
|
728
|
-
sseBaseUrl = postUrl + '/progress';
|
|
729
731
|
} else if (mode === 'local') {
|
|
730
732
|
postUrl = '/api/setup/local';
|
|
731
|
-
sseBaseUrl = '/api/setup/local';
|
|
732
733
|
} else {
|
|
733
734
|
showError('Unable to determine review mode. Please return home and try again.');
|
|
734
735
|
return;
|
|
@@ -765,15 +766,7 @@
|
|
|
765
766
|
throw new Error('No setup ID returned from server');
|
|
766
767
|
}
|
|
767
768
|
|
|
768
|
-
|
|
769
|
-
var sseUrl;
|
|
770
|
-
if (mode === 'pr') {
|
|
771
|
-
sseUrl = sseBaseUrl + '?setupId=' + encodeURIComponent(data.setupId);
|
|
772
|
-
} else {
|
|
773
|
-
sseUrl = sseBaseUrl + '/' + encodeURIComponent(data.setupId) + '/progress';
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
connectSSE(sseUrl);
|
|
769
|
+
connectWS(data.setupId);
|
|
777
770
|
|
|
778
771
|
} catch (err) {
|
|
779
772
|
showError(err.message || 'Failed to start setup. Please try again.');
|
|
@@ -784,98 +777,66 @@
|
|
|
784
777
|
}
|
|
785
778
|
}
|
|
786
779
|
|
|
787
|
-
/* ──
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
eventSource.addEventListener('complete', function(e) {
|
|
807
|
-
try {
|
|
808
|
-
var payload = JSON.parse(e.data);
|
|
780
|
+
/* ── WebSocket Connection ── */
|
|
781
|
+
// Note: No wsReconnected recovery here. Setup is a short-lived,
|
|
782
|
+
// one-time flow — if the WS drops mid-setup, the user can simply
|
|
783
|
+
// reload the page. Not worth the complexity of state recovery.
|
|
784
|
+
function connectWS(setupId) {
|
|
785
|
+
window.wsClient.connect();
|
|
786
|
+
|
|
787
|
+
var unsub = window.wsClient.subscribe('setup:' + setupId, function(msg) {
|
|
788
|
+
switch (msg.type) {
|
|
789
|
+
case 'step': {
|
|
790
|
+
var stepId = msg.step || msg.id;
|
|
791
|
+
var status = msg.status || 'running';
|
|
792
|
+
var message = msg.message || '';
|
|
793
|
+
|
|
794
|
+
stepStates[stepId] = { status: status, message: message };
|
|
795
|
+
renderSteps();
|
|
796
|
+
updateProgressBar();
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
809
799
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
800
|
+
case 'complete': {
|
|
801
|
+
unsub();
|
|
802
|
+
// Mark all steps as completed
|
|
803
|
+
for (var i = 0; i < steps.length; i++) {
|
|
804
|
+
if (!stepStates[steps[i].id] || stepStates[steps[i].id].status !== 'completed') {
|
|
805
|
+
stepStates[steps[i].id] = { status: 'completed', message: '' };
|
|
806
|
+
}
|
|
814
807
|
}
|
|
808
|
+
renderSteps();
|
|
809
|
+
updateProgressBar();
|
|
810
|
+
showRedirect();
|
|
811
|
+
|
|
812
|
+
// Small delay so the user sees the completed state
|
|
813
|
+
setTimeout(function() {
|
|
814
|
+
if (msg.reviewUrl) {
|
|
815
|
+
var targetUrl = new URL(msg.reviewUrl, window.location.origin);
|
|
816
|
+
var qs = new URLSearchParams(window.location.search);
|
|
817
|
+
if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
|
|
818
|
+
window.location.href = targetUrl.toString();
|
|
819
|
+
}
|
|
820
|
+
}, 400);
|
|
821
|
+
break;
|
|
815
822
|
}
|
|
816
|
-
renderSteps();
|
|
817
|
-
updateProgressBar();
|
|
818
|
-
|
|
819
|
-
showRedirect();
|
|
820
|
-
eventSource.close();
|
|
821
|
-
|
|
822
|
-
// Small delay so the user sees the completed state
|
|
823
|
-
setTimeout(function() {
|
|
824
|
-
if (payload.reviewUrl) {
|
|
825
|
-
var targetUrl = new URL(payload.reviewUrl, window.location.origin);
|
|
826
|
-
var qs = new URLSearchParams(window.location.search);
|
|
827
|
-
if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
|
|
828
|
-
window.location.href = targetUrl.toString();
|
|
829
|
-
}
|
|
830
|
-
}, 400);
|
|
831
|
-
} catch (err) {
|
|
832
|
-
console.error('Error parsing complete event:', err);
|
|
833
|
-
}
|
|
834
|
-
});
|
|
835
823
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
var payload = JSON.parse(e.data);
|
|
841
|
-
var failedStep = payload.step;
|
|
842
|
-
var errorMsg = payload.message || 'An unexpected error occurred';
|
|
824
|
+
case 'error': {
|
|
825
|
+
unsub();
|
|
826
|
+
var failedStep = msg.step;
|
|
827
|
+
var errorMsg = msg.message || 'An unexpected error occurred';
|
|
843
828
|
|
|
844
|
-
if (failedStep
|
|
845
|
-
stepStates[failedStep] = { status: 'error', message: errorMsg };
|
|
846
|
-
} else if (failedStep) {
|
|
829
|
+
if (failedStep) {
|
|
847
830
|
stepStates[failedStep] = { status: 'error', message: errorMsg };
|
|
848
831
|
}
|
|
849
832
|
|
|
850
833
|
renderSteps();
|
|
851
834
|
updateProgressBar();
|
|
852
835
|
showError(errorMsg);
|
|
853
|
-
|
|
854
|
-
return;
|
|
855
|
-
} catch (parseErr) {
|
|
856
|
-
// Not a JSON error event, fall through
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// Native EventSource error (connection lost, etc.)
|
|
861
|
-
if (eventSource.readyState === EventSource.CLOSED) {
|
|
862
|
-
// Check if we got any errors already shown
|
|
863
|
-
if (!errorSection.classList.contains('visible')) {
|
|
864
|
-
showError('Connection to server was lost. Please retry.');
|
|
865
|
-
updateProgressBar();
|
|
836
|
+
break;
|
|
866
837
|
}
|
|
867
838
|
}
|
|
868
839
|
});
|
|
869
|
-
|
|
870
|
-
eventSource.onerror = function() {
|
|
871
|
-
// EventSource connection failure
|
|
872
|
-
if (eventSource.readyState === EventSource.CLOSED) {
|
|
873
|
-
if (!errorSection.classList.contains('visible') && !redirectSection.classList.contains('visible')) {
|
|
874
|
-
showError('Connection to server was lost. Please retry.');
|
|
875
|
-
updateProgressBar();
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
};
|
|
879
840
|
}
|
|
880
841
|
|
|
881
842
|
/* ── Kick Off ── */
|
package/src/ai/analyzer.js
CHANGED
|
@@ -3505,12 +3505,8 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3505
3505
|
continue;
|
|
3506
3506
|
}
|
|
3507
3507
|
|
|
3508
|
-
const
|
|
3509
|
-
const
|
|
3510
|
-
const body = suggestion.description +
|
|
3511
|
-
(suggestionText
|
|
3512
|
-
? (hasSuggestionBlock ? '\n\n' + suggestionText : '\n\n**Suggestion:** ' + suggestionText)
|
|
3513
|
-
: '');
|
|
3508
|
+
const body = suggestion.description;
|
|
3509
|
+
const suggestionText = suggestion.suggestion || null;
|
|
3514
3510
|
|
|
3515
3511
|
const isFileLevel = suggestion.is_file_level === true || suggestion.line_start === null ? 1 : 0;
|
|
3516
3512
|
const side = suggestion.old_or_new === 'OLD' ? 'LEFT' : 'RIGHT';
|
|
@@ -3518,9 +3514,9 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3518
3514
|
await dbRun(this.db, `
|
|
3519
3515
|
INSERT INTO comments (
|
|
3520
3516
|
review_id, source, author, ai_run_id, ai_level, ai_confidence,
|
|
3521
|
-
file, line_start, line_end, side, type, title, body, reasoning, status, is_file_level,
|
|
3517
|
+
file, line_start, line_end, side, type, title, body, suggestion_text, reasoning, status, is_file_level,
|
|
3522
3518
|
voice_id, is_raw
|
|
3523
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
3519
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
3524
3520
|
`, [
|
|
3525
3521
|
reviewId,
|
|
3526
3522
|
'ai',
|
|
@@ -3535,6 +3531,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3535
3531
|
suggestion.type,
|
|
3536
3532
|
suggestion.title,
|
|
3537
3533
|
body,
|
|
3534
|
+
suggestionText,
|
|
3538
3535
|
suggestion.reasoning ? JSON.stringify(suggestion.reasoning) : null,
|
|
3539
3536
|
'active',
|
|
3540
3537
|
isFileLevel,
|
package/src/config.js
CHANGED
|
@@ -7,6 +7,7 @@ const logger = require('./utils/logger');
|
|
|
7
7
|
const CONFIG_DIR = path.join(os.homedir(), '.pair-review');
|
|
8
8
|
const DEFAULT_CHECKOUT_TIMEOUT_MS = 300000;
|
|
9
9
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
10
|
+
const CONFIG_LOCAL_FILE = path.join(CONFIG_DIR, 'config.local.json');
|
|
10
11
|
const CONFIG_EXAMPLE_FILE = path.join(CONFIG_DIR, 'config.example.json');
|
|
11
12
|
const PACKAGE_ROOT = path.join(__dirname, '..');
|
|
12
13
|
|
|
@@ -22,6 +23,7 @@ const DEFAULT_CONFIG = {
|
|
|
22
23
|
db_name: "", // Custom database filename (default: database.db). Useful for per-worktree isolation.
|
|
23
24
|
yolo: false, // When true, skips fine-grained AI provider permission setup (equivalent to --yolo CLI flag)
|
|
24
25
|
enable_chat: true, // When true, enables the chat panel feature (requires Pi AI provider)
|
|
26
|
+
comment_format: "legacy", // Comment format preset or custom template for adopted suggestions
|
|
25
27
|
chat: { enable_shortcuts: true }, // Chat panel settings (enable_shortcuts: show action shortcut buttons)
|
|
26
28
|
providers: {}, // Custom provider configurations (overrides built-in defaults)
|
|
27
29
|
monorepos: {}, // Monorepo configurations: { "owner/repo": { path: "~/path/to/clone" } }
|
|
@@ -37,6 +39,36 @@ function validatePort(port) {
|
|
|
37
39
|
return Number.isInteger(port) && port >= 1024 && port <= 65535;
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Recursively merges source into target for plain objects.
|
|
44
|
+
* Arrays and scalars in source replace the corresponding value in target.
|
|
45
|
+
* Null in source overwrites target. Returns a new object; inputs are not mutated.
|
|
46
|
+
* @param {Object} target - Base object
|
|
47
|
+
* @param {Object} source - Object to merge on top
|
|
48
|
+
* @returns {Object} - Merged result
|
|
49
|
+
*/
|
|
50
|
+
function deepMerge(target, source) {
|
|
51
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) return target;
|
|
52
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) return { ...source };
|
|
53
|
+
|
|
54
|
+
const result = { ...target };
|
|
55
|
+
for (const key of Object.keys(source)) {
|
|
56
|
+
const srcVal = source[key];
|
|
57
|
+
const tgtVal = target[key];
|
|
58
|
+
if (
|
|
59
|
+
srcVal !== null &&
|
|
60
|
+
typeof srcVal === 'object' && !Array.isArray(srcVal) &&
|
|
61
|
+
tgtVal !== null &&
|
|
62
|
+
typeof tgtVal === 'object' && !Array.isArray(tgtVal)
|
|
63
|
+
) {
|
|
64
|
+
result[key] = deepMerge(tgtVal, srcVal);
|
|
65
|
+
} else {
|
|
66
|
+
result[key] = srcVal;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
40
72
|
/**
|
|
41
73
|
* Gets a config value with fallback to legacy key names
|
|
42
74
|
* Supports backwards compatibility without modifying the config file
|
|
@@ -135,63 +167,51 @@ async function ensureConfigDir() {
|
|
|
135
167
|
async function loadConfig() {
|
|
136
168
|
await ensureConfigDir();
|
|
137
169
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
mergedConfig[key] = { ...DEFAULT_CONFIG[key], ...config[key] };
|
|
149
|
-
}
|
|
150
|
-
}
|
|
170
|
+
const localDir = path.join(process.cwd(), '.pair-review');
|
|
171
|
+
const sources = [
|
|
172
|
+
{ path: CONFIG_FILE, label: 'global config', required: true },
|
|
173
|
+
{ path: CONFIG_LOCAL_FILE, label: 'global local config', required: false },
|
|
174
|
+
{ path: path.join(localDir, 'config.json'), label: 'project config', required: false },
|
|
175
|
+
{ path: path.join(localDir, 'config.local.json'), label: 'project local config', required: false },
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
let mergedConfig = { ...DEFAULT_CONFIG };
|
|
179
|
+
let isFirstRun = false;
|
|
151
180
|
|
|
152
|
-
|
|
181
|
+
for (const source of sources) {
|
|
153
182
|
try {
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
183
|
+
const data = await fs.readFile(source.path, 'utf8');
|
|
184
|
+
const parsed = JSON.parse(data);
|
|
185
|
+
mergedConfig = deepMerge(mergedConfig, parsed);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (error.code === 'ENOENT') {
|
|
188
|
+
if (source.required) {
|
|
189
|
+
// Global config doesn't exist — create it with defaults
|
|
190
|
+
const config = { ...DEFAULT_CONFIG };
|
|
191
|
+
await saveConfig(config);
|
|
192
|
+
logger.debug(`Created default config file: ${CONFIG_FILE}`);
|
|
193
|
+
isFirstRun = true;
|
|
162
194
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
} else {
|
|
169
|
-
throw localError;
|
|
195
|
+
// Optional files: skip silently
|
|
196
|
+
} else if (error instanceof SyntaxError) {
|
|
197
|
+
if (source.required) {
|
|
198
|
+
console.error(`Invalid configuration file at ~/.pair-review/config.json`);
|
|
199
|
+
process.exit(1);
|
|
170
200
|
}
|
|
201
|
+
logger.warn(`Malformed config at ${source.label}, skipping`);
|
|
202
|
+
} else {
|
|
203
|
+
throw error;
|
|
171
204
|
}
|
|
172
205
|
}
|
|
206
|
+
}
|
|
173
207
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return { config: mergedConfig, isFirstRun: false };
|
|
181
|
-
} catch (error) {
|
|
182
|
-
if (error.code === 'ENOENT') {
|
|
183
|
-
// Config file doesn't exist, create it with defaults
|
|
184
|
-
const config = { ...DEFAULT_CONFIG };
|
|
185
|
-
await saveConfig(config);
|
|
186
|
-
logger.debug(`Created default config file: ${CONFIG_FILE}`);
|
|
187
|
-
return { config, isFirstRun: true };
|
|
188
|
-
} else if (error instanceof SyntaxError) {
|
|
189
|
-
console.error(`Invalid configuration file at ~/.pair-review/config.json`);
|
|
190
|
-
process.exit(1);
|
|
191
|
-
} else {
|
|
192
|
-
throw error;
|
|
193
|
-
}
|
|
208
|
+
// Validate port
|
|
209
|
+
if (!validatePort(mergedConfig.port)) {
|
|
210
|
+
console.error(`Invalid port number ${mergedConfig.port}`);
|
|
211
|
+
process.exit(1);
|
|
194
212
|
}
|
|
213
|
+
|
|
214
|
+
return { config: mergedConfig, isFirstRun };
|
|
195
215
|
}
|
|
196
216
|
|
|
197
217
|
/**
|
|
@@ -424,6 +444,7 @@ function warnIfDevModeWithoutDbName(config) {
|
|
|
424
444
|
}
|
|
425
445
|
|
|
426
446
|
module.exports = {
|
|
447
|
+
deepMerge,
|
|
427
448
|
loadConfig,
|
|
428
449
|
saveConfig,
|
|
429
450
|
getConfigDir,
|
package/src/database.js
CHANGED
|
@@ -20,7 +20,7 @@ function getDbPath() {
|
|
|
20
20
|
/**
|
|
21
21
|
* Current schema version - increment this when adding new migrations
|
|
22
22
|
*/
|
|
23
|
-
const CURRENT_SCHEMA_VERSION =
|
|
23
|
+
const CURRENT_SCHEMA_VERSION = 25;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Database schema SQL statements
|
|
@@ -80,6 +80,7 @@ const SCHEMA_SQL = {
|
|
|
80
80
|
type TEXT,
|
|
81
81
|
title TEXT,
|
|
82
82
|
body TEXT,
|
|
83
|
+
suggestion_text TEXT,
|
|
83
84
|
reasoning TEXT,
|
|
84
85
|
|
|
85
86
|
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'dismissed', 'adopted', 'submitted', 'draft', 'inactive')),
|
|
@@ -1135,6 +1136,28 @@ const MIGRATIONS = {
|
|
|
1135
1136
|
}
|
|
1136
1137
|
|
|
1137
1138
|
console.log('Migration to schema version 24 complete');
|
|
1139
|
+
},
|
|
1140
|
+
|
|
1141
|
+
// Migration to version 25: adds suggestion_text column to comments for structured suggestion storage
|
|
1142
|
+
25: (db) => {
|
|
1143
|
+
console.log('Migrating to schema version 25: Add suggestion_text column to comments');
|
|
1144
|
+
|
|
1145
|
+
const columns = db.prepare('PRAGMA table_info(comments)').all();
|
|
1146
|
+
if (!columns.some(c => c.name === 'suggestion_text')) {
|
|
1147
|
+
try {
|
|
1148
|
+
db.prepare('ALTER TABLE comments ADD COLUMN suggestion_text TEXT').run();
|
|
1149
|
+
console.log(' Added suggestion_text column to comments');
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
if (!error.message.includes('duplicate column name')) {
|
|
1152
|
+
throw error;
|
|
1153
|
+
}
|
|
1154
|
+
console.log(' Column suggestion_text already exists (race condition)');
|
|
1155
|
+
}
|
|
1156
|
+
} else {
|
|
1157
|
+
console.log(' Column suggestion_text already exists');
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
console.log('Migration to schema version 25 complete');
|
|
1138
1161
|
}
|
|
1139
1162
|
};
|
|
1140
1163
|
|
|
@@ -2193,12 +2216,8 @@ class CommentRepository {
|
|
|
2193
2216
|
}
|
|
2194
2217
|
|
|
2195
2218
|
for (const suggestion of normalized) {
|
|
2196
|
-
const
|
|
2197
|
-
const
|
|
2198
|
-
const body = suggestion.description +
|
|
2199
|
-
(suggestionText
|
|
2200
|
-
? (hasSuggestionBlock ? '\n\n' + suggestionText : '\n\n**Suggestion:** ' + suggestionText)
|
|
2201
|
-
: '');
|
|
2219
|
+
const body = suggestion.description;
|
|
2220
|
+
const suggestionText = suggestion.suggestion || null;
|
|
2202
2221
|
|
|
2203
2222
|
// File-level suggestions have is_file_level=true or have null line_start
|
|
2204
2223
|
const isFileLevel = suggestion.is_file_level === true || suggestion.line_start === null ? 1 : 0;
|
|
@@ -2209,8 +2228,8 @@ class CommentRepository {
|
|
|
2209
2228
|
await run(this.db, `
|
|
2210
2229
|
INSERT INTO comments (
|
|
2211
2230
|
review_id, source, author, ai_run_id, ai_level, ai_confidence,
|
|
2212
|
-
file, line_start, line_end, side, type, title, body, reasoning, status, is_file_level
|
|
2213
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2231
|
+
file, line_start, line_end, side, type, title, body, suggestion_text, reasoning, status, is_file_level
|
|
2232
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2214
2233
|
`, [
|
|
2215
2234
|
reviewId,
|
|
2216
2235
|
'ai',
|
|
@@ -2225,6 +2244,7 @@ class CommentRepository {
|
|
|
2225
2244
|
suggestion.type,
|
|
2226
2245
|
suggestion.title,
|
|
2227
2246
|
body,
|
|
2247
|
+
suggestionText,
|
|
2228
2248
|
suggestion.reasoning ? JSON.stringify(suggestion.reasoning) : null,
|
|
2229
2249
|
'active',
|
|
2230
2250
|
isFileLevel
|