@in-the-loop-labs/pair-review 2.0.1 → 2.0.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/bin/pair-review.js +1 -1
- 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/css/pr.css +99 -3
- package/public/js/components/DiffOptionsDropdown.js +207 -0
- package/public/js/components/ReviewModal.js +8 -3
- package/public/js/local.js +31 -1
- package/public/js/pr.js +61 -3
- package/public/local.html +6 -0
- package/public/pr.html +6 -0
- package/src/database.js +5 -3
- package/src/github/client.js +80 -20
- package/src/local-review.js +5 -4
- package/src/routes/context-files.js +1 -1
- package/src/routes/local.js +17 -1
- package/src/routes/pr.js +60 -11
- package/src/utils/auto-context.js +1 -1
package/bin/pair-review.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.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": "2.0.
|
|
3
|
+
"version": "2.0.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",
|
package/public/css/pr.css
CHANGED
|
@@ -6362,12 +6362,29 @@ body:not([data-theme="dark"]) .theme-icon-light {
|
|
|
6362
6362
|
}
|
|
6363
6363
|
|
|
6364
6364
|
/* Small icon buttons in toolbar have reduced dimensions */
|
|
6365
|
-
.toolbar-actions .btn-sm.btn-icon
|
|
6365
|
+
.toolbar-actions .btn-sm.btn-icon,
|
|
6366
|
+
.toolbar-meta .btn-sm.btn-icon {
|
|
6366
6367
|
padding: 6px;
|
|
6367
6368
|
width: 32px;
|
|
6368
6369
|
height: 32px;
|
|
6369
6370
|
}
|
|
6370
6371
|
|
|
6372
|
+
/* Icon buttons inside toolbar-meta need the same visual treatment as toolbar-actions */
|
|
6373
|
+
.toolbar-meta .btn-icon {
|
|
6374
|
+
background: var(--color-bg-secondary);
|
|
6375
|
+
border: 1px solid var(--color-border-primary);
|
|
6376
|
+
color: var(--color-text-secondary);
|
|
6377
|
+
border-radius: var(--radius-sm);
|
|
6378
|
+
cursor: pointer;
|
|
6379
|
+
transition: all var(--transition-fast);
|
|
6380
|
+
}
|
|
6381
|
+
|
|
6382
|
+
.toolbar-meta .btn-icon:hover {
|
|
6383
|
+
background: var(--color-bg-tertiary);
|
|
6384
|
+
color: var(--color-text-primary);
|
|
6385
|
+
border-color: var(--color-border-secondary);
|
|
6386
|
+
}
|
|
6387
|
+
|
|
6371
6388
|
/* ============================================
|
|
6372
6389
|
Analysis Progress Dots
|
|
6373
6390
|
============================================ */
|
|
@@ -6962,17 +6979,96 @@ body.resizing * {
|
|
|
6962
6979
|
}
|
|
6963
6980
|
|
|
6964
6981
|
/* Dark theme toolbar button overrides */
|
|
6965
|
-
[data-theme="dark"] .toolbar-actions .btn-icon
|
|
6982
|
+
[data-theme="dark"] .toolbar-actions .btn-icon,
|
|
6983
|
+
[data-theme="dark"] .toolbar-meta .btn-icon {
|
|
6966
6984
|
background: var(--color-bg-tertiary);
|
|
6967
6985
|
border-color: var(--color-border-secondary);
|
|
6968
6986
|
color: var(--color-text-secondary);
|
|
6969
6987
|
}
|
|
6970
6988
|
|
|
6971
|
-
[data-theme="dark"] .toolbar-actions .btn-icon:hover
|
|
6989
|
+
[data-theme="dark"] .toolbar-actions .btn-icon:hover,
|
|
6990
|
+
[data-theme="dark"] .toolbar-meta .btn-icon:hover {
|
|
6972
6991
|
background: var(--color-bg-elevated);
|
|
6973
6992
|
color: var(--color-text-primary);
|
|
6974
6993
|
}
|
|
6975
6994
|
|
|
6995
|
+
/* --------------------------------------------------------------------------
|
|
6996
|
+
Diff Options Popover (gear icon dropdown for whitespace toggle, etc.)
|
|
6997
|
+
-------------------------------------------------------------------------- */
|
|
6998
|
+
.diff-options-popover {
|
|
6999
|
+
position: fixed;
|
|
7000
|
+
z-index: 1100;
|
|
7001
|
+
background: var(--color-bg-primary);
|
|
7002
|
+
border: 1px solid var(--color-border-primary);
|
|
7003
|
+
border-radius: 8px;
|
|
7004
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08), 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
7005
|
+
padding: 4px 0;
|
|
7006
|
+
opacity: 0;
|
|
7007
|
+
transform: translateY(-4px);
|
|
7008
|
+
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
7009
|
+
pointer-events: none;
|
|
7010
|
+
}
|
|
7011
|
+
|
|
7012
|
+
.diff-options-popover.visible {
|
|
7013
|
+
opacity: 1;
|
|
7014
|
+
transform: translateY(0);
|
|
7015
|
+
pointer-events: auto;
|
|
7016
|
+
}
|
|
7017
|
+
|
|
7018
|
+
.diff-options-popover label {
|
|
7019
|
+
display: flex;
|
|
7020
|
+
align-items: center;
|
|
7021
|
+
gap: 8px;
|
|
7022
|
+
padding: 8px 12px;
|
|
7023
|
+
cursor: pointer;
|
|
7024
|
+
font-size: 0.8125rem;
|
|
7025
|
+
color: var(--color-text-primary);
|
|
7026
|
+
white-space: nowrap;
|
|
7027
|
+
user-select: none;
|
|
7028
|
+
transition: background-color 0.1s ease;
|
|
7029
|
+
}
|
|
7030
|
+
|
|
7031
|
+
.diff-options-popover label:hover {
|
|
7032
|
+
background: var(--color-bg-tertiary);
|
|
7033
|
+
}
|
|
7034
|
+
|
|
7035
|
+
.diff-options-popover input[type="checkbox"] {
|
|
7036
|
+
margin: 0;
|
|
7037
|
+
cursor: pointer;
|
|
7038
|
+
}
|
|
7039
|
+
|
|
7040
|
+
/* Active state for the diff-options gear button when a filter is applied */
|
|
7041
|
+
#diff-options-btn.active {
|
|
7042
|
+
color: var(--color-accent-primary);
|
|
7043
|
+
border-color: var(--color-accent-primary);
|
|
7044
|
+
background: var(--color-accent-light);
|
|
7045
|
+
}
|
|
7046
|
+
|
|
7047
|
+
#diff-options-btn.active:hover {
|
|
7048
|
+
background: var(--color-accent-lighter);
|
|
7049
|
+
}
|
|
7050
|
+
|
|
7051
|
+
/* Dark theme */
|
|
7052
|
+
[data-theme="dark"] .diff-options-popover {
|
|
7053
|
+
background: var(--color-bg-secondary);
|
|
7054
|
+
border-color: var(--color-border-secondary);
|
|
7055
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
7056
|
+
}
|
|
7057
|
+
|
|
7058
|
+
[data-theme="dark"] .diff-options-popover label:hover {
|
|
7059
|
+
background: var(--color-bg-tertiary);
|
|
7060
|
+
}
|
|
7061
|
+
|
|
7062
|
+
[data-theme="dark"] #diff-options-btn.active {
|
|
7063
|
+
color: #58a6ff;
|
|
7064
|
+
border-color: #58a6ff;
|
|
7065
|
+
background: rgba(88, 166, 255, 0.1);
|
|
7066
|
+
}
|
|
7067
|
+
|
|
7068
|
+
[data-theme="dark"] #diff-options-btn.active:hover {
|
|
7069
|
+
background: rgba(88, 166, 255, 0.15);
|
|
7070
|
+
}
|
|
7071
|
+
|
|
6976
7072
|
.ai-panel-header {
|
|
6977
7073
|
display: flex;
|
|
6978
7074
|
align-items: center;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* DiffOptionsDropdown - Gear-icon popover for diff display options.
|
|
4
|
+
*
|
|
5
|
+
* Anchors a small dropdown below the gear button (#diff-options-btn) with
|
|
6
|
+
* checkbox toggles that control diff rendering. Currently supports a single
|
|
7
|
+
* option: "Hide whitespace changes".
|
|
8
|
+
*
|
|
9
|
+
* Follows the same popover pattern used by PanelGroup._showPopover() /
|
|
10
|
+
* _hidePopover() (fixed positioning via getBoundingClientRect, click-outside
|
|
11
|
+
* and Escape to dismiss, opacity+transform animation).
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* const dropdown = new DiffOptionsDropdown(
|
|
15
|
+
* document.getElementById('diff-options-btn'),
|
|
16
|
+
* { onToggleWhitespace: (hidden) => { … } }
|
|
17
|
+
* );
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const STORAGE_KEY = 'pair-review-hide-whitespace';
|
|
21
|
+
|
|
22
|
+
class DiffOptionsDropdown {
|
|
23
|
+
/**
|
|
24
|
+
* @param {HTMLElement} buttonElement - The gear icon button already in the DOM
|
|
25
|
+
* @param {Object} callbacks
|
|
26
|
+
* @param {function(boolean):void} callbacks.onToggleWhitespace
|
|
27
|
+
*/
|
|
28
|
+
constructor(buttonElement, { onToggleWhitespace }) {
|
|
29
|
+
this._btn = buttonElement;
|
|
30
|
+
this._onToggleWhitespace = onToggleWhitespace;
|
|
31
|
+
|
|
32
|
+
this._popoverEl = null;
|
|
33
|
+
this._checkbox = null;
|
|
34
|
+
this._visible = false;
|
|
35
|
+
this._outsideClickHandler = null;
|
|
36
|
+
this._escapeHandler = null;
|
|
37
|
+
|
|
38
|
+
// Read persisted state
|
|
39
|
+
this._hideWhitespace = localStorage.getItem(STORAGE_KEY) === 'true';
|
|
40
|
+
|
|
41
|
+
this._renderPopover();
|
|
42
|
+
this._syncButtonActive();
|
|
43
|
+
|
|
44
|
+
// Toggle popover on button click
|
|
45
|
+
this._btn.addEventListener('click', (e) => {
|
|
46
|
+
e.stopPropagation();
|
|
47
|
+
if (this._visible) {
|
|
48
|
+
this._hide();
|
|
49
|
+
} else {
|
|
50
|
+
this._show();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Fire initial callback so the consumer can apply the persisted state
|
|
55
|
+
if (this._hideWhitespace) {
|
|
56
|
+
this._onToggleWhitespace(true);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Public API
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** @returns {boolean} Whether whitespace changes are currently hidden */
|
|
65
|
+
get hideWhitespace() {
|
|
66
|
+
return this._hideWhitespace;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Programmatically set the whitespace toggle (updates UI + storage). */
|
|
70
|
+
set hideWhitespace(value) {
|
|
71
|
+
const bool = Boolean(value);
|
|
72
|
+
if (bool === this._hideWhitespace) return;
|
|
73
|
+
this._hideWhitespace = bool;
|
|
74
|
+
if (this._checkbox) this._checkbox.checked = bool;
|
|
75
|
+
this._persist();
|
|
76
|
+
this._syncButtonActive();
|
|
77
|
+
this._onToggleWhitespace(bool);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// DOM construction
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
_renderPopover() {
|
|
85
|
+
const popover = document.createElement('div');
|
|
86
|
+
popover.className = 'diff-options-popover';
|
|
87
|
+
// Start hidden (opacity 0, shifted up)
|
|
88
|
+
popover.style.opacity = '0';
|
|
89
|
+
popover.style.transform = 'translateY(-4px)';
|
|
90
|
+
popover.style.pointerEvents = 'none';
|
|
91
|
+
popover.style.position = 'fixed';
|
|
92
|
+
popover.style.zIndex = '1100';
|
|
93
|
+
popover.style.transition = 'opacity 0.15s ease, transform 0.15s ease';
|
|
94
|
+
|
|
95
|
+
// Label wrapping checkbox for a nice click target
|
|
96
|
+
const label = document.createElement('label');
|
|
97
|
+
label.style.display = 'flex';
|
|
98
|
+
label.style.alignItems = 'center';
|
|
99
|
+
label.style.gap = '8px';
|
|
100
|
+
label.style.cursor = 'pointer';
|
|
101
|
+
label.style.fontSize = '0.8125rem';
|
|
102
|
+
label.style.whiteSpace = 'nowrap';
|
|
103
|
+
label.style.padding = '8px 12px';
|
|
104
|
+
label.style.userSelect = 'none';
|
|
105
|
+
|
|
106
|
+
const checkbox = document.createElement('input');
|
|
107
|
+
checkbox.type = 'checkbox';
|
|
108
|
+
checkbox.checked = this._hideWhitespace;
|
|
109
|
+
checkbox.style.margin = '0';
|
|
110
|
+
checkbox.style.cursor = 'pointer';
|
|
111
|
+
|
|
112
|
+
const text = document.createTextNode('Hide whitespace changes');
|
|
113
|
+
|
|
114
|
+
label.appendChild(checkbox);
|
|
115
|
+
label.appendChild(text);
|
|
116
|
+
popover.appendChild(label);
|
|
117
|
+
|
|
118
|
+
document.body.appendChild(popover);
|
|
119
|
+
|
|
120
|
+
this._popoverEl = popover;
|
|
121
|
+
this._checkbox = checkbox;
|
|
122
|
+
|
|
123
|
+
// Respond to checkbox changes
|
|
124
|
+
checkbox.addEventListener('change', () => {
|
|
125
|
+
this._hideWhitespace = checkbox.checked;
|
|
126
|
+
this._persist();
|
|
127
|
+
this._syncButtonActive();
|
|
128
|
+
this._onToggleWhitespace(this._hideWhitespace);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Show / Hide (mirrors PanelGroup pattern)
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
_show() {
|
|
137
|
+
if (!this._popoverEl || !this._btn) return;
|
|
138
|
+
|
|
139
|
+
// Position below the button
|
|
140
|
+
const rect = this._btn.getBoundingClientRect();
|
|
141
|
+
this._popoverEl.style.top = `${rect.bottom + 4}px`;
|
|
142
|
+
this._popoverEl.style.left = `${rect.left + rect.width / 2}px`;
|
|
143
|
+
this._popoverEl.style.transform = 'translateX(-50%) translateY(-4px)';
|
|
144
|
+
|
|
145
|
+
// Make visible
|
|
146
|
+
this._popoverEl.style.opacity = '1';
|
|
147
|
+
this._popoverEl.style.pointerEvents = 'auto';
|
|
148
|
+
this._visible = true;
|
|
149
|
+
|
|
150
|
+
// Animate into final position
|
|
151
|
+
requestAnimationFrame(() => {
|
|
152
|
+
if (this._popoverEl) {
|
|
153
|
+
this._popoverEl.style.transform = 'translateX(-50%) translateY(0)';
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Click-outside-to-close
|
|
158
|
+
this._outsideClickHandler = (e) => {
|
|
159
|
+
if (!this._popoverEl.contains(e.target) && !this._btn.contains(e.target)) {
|
|
160
|
+
this._hide();
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
document.addEventListener('click', this._outsideClickHandler, true);
|
|
164
|
+
|
|
165
|
+
// Escape to dismiss
|
|
166
|
+
this._escapeHandler = (e) => {
|
|
167
|
+
if (e.key === 'Escape') {
|
|
168
|
+
this._hide();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
document.addEventListener('keydown', this._escapeHandler, true);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_hide() {
|
|
175
|
+
if (!this._popoverEl) return;
|
|
176
|
+
|
|
177
|
+
this._popoverEl.style.opacity = '0';
|
|
178
|
+
this._popoverEl.style.transform = 'translateX(-50%) translateY(-4px)';
|
|
179
|
+
this._popoverEl.style.pointerEvents = 'none';
|
|
180
|
+
this._visible = false;
|
|
181
|
+
|
|
182
|
+
if (this._outsideClickHandler) {
|
|
183
|
+
document.removeEventListener('click', this._outsideClickHandler, true);
|
|
184
|
+
this._outsideClickHandler = null;
|
|
185
|
+
}
|
|
186
|
+
if (this._escapeHandler) {
|
|
187
|
+
document.removeEventListener('keydown', this._escapeHandler, true);
|
|
188
|
+
this._escapeHandler = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Helpers
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
_persist() {
|
|
197
|
+
localStorage.setItem(STORAGE_KEY, String(this._hideWhitespace));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Add/remove `.active` on the gear button as a visual cue that filtering is on. */
|
|
201
|
+
_syncButtonActive() {
|
|
202
|
+
if (!this._btn) return;
|
|
203
|
+
this._btn.classList.toggle('active', this._hideWhitespace);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
window.DiffOptionsDropdown = DiffOptionsDropdown;
|
|
@@ -132,7 +132,7 @@ class ReviewModal {
|
|
|
132
132
|
|
|
133
133
|
<div class="modal-footer review-modal-footer">
|
|
134
134
|
<button class="btn btn-secondary" onclick="reviewModal.handleCloseClick()" id="cancel-review-btn">Cancel</button>
|
|
135
|
-
<button class="btn btn-primary" id="submit-review-btn-modal" onclick="reviewModal.submitReview()">
|
|
135
|
+
<button class="btn btn-primary" id="submit-review-btn-modal" onclick="reviewModal.submitReview()" title="Submit review (Cmd/Ctrl+Enter)">
|
|
136
136
|
Submit review
|
|
137
137
|
</button>
|
|
138
138
|
</div>
|
|
@@ -157,11 +157,16 @@ class ReviewModal {
|
|
|
157
157
|
}
|
|
158
158
|
ReviewModal._listenersRegistered = true;
|
|
159
159
|
|
|
160
|
-
// Handle
|
|
160
|
+
// Handle keyboard shortcuts - uses window.reviewModal to get the current instance
|
|
161
161
|
document.addEventListener('keydown', (e) => {
|
|
162
162
|
const instance = window.reviewModal;
|
|
163
|
-
if (
|
|
163
|
+
if (!instance?.isVisible) return;
|
|
164
|
+
|
|
165
|
+
if (e.key === 'Escape' && !instance.isSubmitting) {
|
|
164
166
|
instance.hide();
|
|
167
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !instance.isSubmitting) {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
instance.submitReview();
|
|
165
170
|
}
|
|
166
171
|
});
|
|
167
172
|
|
package/public/js/local.js
CHANGED
|
@@ -463,6 +463,32 @@ class LocalManager {
|
|
|
463
463
|
}
|
|
464
464
|
};
|
|
465
465
|
|
|
466
|
+
// Override handleWhitespaceToggle for local mode.
|
|
467
|
+
// The base PRManager implementation calls loadAndDisplayFiles() which
|
|
468
|
+
// uses the PR diff endpoint. In local mode we need to call loadLocalDiff()
|
|
469
|
+
// instead, which uses the local diff endpoint.
|
|
470
|
+
manager.handleWhitespaceToggle = async function(hide) {
|
|
471
|
+
manager.hideWhitespace = hide;
|
|
472
|
+
|
|
473
|
+
// Nothing to reload if we haven't loaded a review yet
|
|
474
|
+
if (!manager.currentPR) return;
|
|
475
|
+
|
|
476
|
+
const scrollY = window.scrollY;
|
|
477
|
+
|
|
478
|
+
// Re-fetch and re-render the diff (loadLocalDiff reads hideWhitespace)
|
|
479
|
+
await self.loadLocalDiff();
|
|
480
|
+
|
|
481
|
+
// Re-anchor comments and suggestions on the fresh DOM
|
|
482
|
+
const includeDismissed = window.aiPanel?.showDismissedComments || false;
|
|
483
|
+
await manager.loadUserComments(includeDismissed);
|
|
484
|
+
await manager.loadAISuggestions(null, manager.selectedRunId);
|
|
485
|
+
|
|
486
|
+
// Restore scroll position after the DOM settles
|
|
487
|
+
requestAnimationFrame(() => {
|
|
488
|
+
window.scrollTo(0, scrollY);
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
|
|
466
492
|
console.log('PRManager patched for local mode');
|
|
467
493
|
}
|
|
468
494
|
|
|
@@ -1050,7 +1076,11 @@ class LocalManager {
|
|
|
1050
1076
|
const manager = window.prManager;
|
|
1051
1077
|
|
|
1052
1078
|
try {
|
|
1053
|
-
|
|
1079
|
+
let diffUrl = `/api/local/${this.reviewId}/diff`;
|
|
1080
|
+
if (manager.hideWhitespace) {
|
|
1081
|
+
diffUrl += '?w=1';
|
|
1082
|
+
}
|
|
1083
|
+
const response = await fetch(diffUrl);
|
|
1054
1084
|
|
|
1055
1085
|
if (!response.ok) {
|
|
1056
1086
|
throw new Error('Failed to load local diff');
|
package/public/js/pr.js
CHANGED
|
@@ -130,6 +130,12 @@ class PRManager {
|
|
|
130
130
|
this.selectedRunId = null;
|
|
131
131
|
// Keyboard shortcuts manager
|
|
132
132
|
this.keyboardShortcuts = null;
|
|
133
|
+
// Hide whitespace toggle state — must be set before DiffOptionsDropdown
|
|
134
|
+
// is constructed because it fires the callback synchronously on init
|
|
135
|
+
// when localStorage has a persisted `true` value.
|
|
136
|
+
this.hideWhitespace = false;
|
|
137
|
+
// Diff options dropdown (gear icon popover)
|
|
138
|
+
this.diffOptionsDropdown = null;
|
|
133
139
|
// Unique client ID for self-echo suppression on SSE review events.
|
|
134
140
|
// Sent as X-Client-Id header on mutation requests; the server echoes
|
|
135
141
|
// it back in the SSE broadcast so this tab can skip its own events.
|
|
@@ -174,6 +180,16 @@ class PRManager {
|
|
|
174
180
|
this.initAnalysisConfigModal();
|
|
175
181
|
this.initKeyboardShortcuts();
|
|
176
182
|
|
|
183
|
+
// Initialize diff options dropdown (gear icon for whitespace toggle).
|
|
184
|
+
// Must happen before init() so the persisted hideWhitespace state is
|
|
185
|
+
// applied before the first loadAndDisplayFiles() call.
|
|
186
|
+
const diffOptionsBtn = document.getElementById('diff-options-btn');
|
|
187
|
+
if (diffOptionsBtn && window.DiffOptionsDropdown) {
|
|
188
|
+
this.diffOptionsDropdown = new window.DiffOptionsDropdown(diffOptionsBtn, {
|
|
189
|
+
onToggleWhitespace: (hide) => this.handleWhitespaceToggle(hide),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
177
193
|
// In local mode, LocalManager handles init instead
|
|
178
194
|
if (!window.PAIR_REVIEW_LOCAL_MODE) {
|
|
179
195
|
this.init();
|
|
@@ -576,7 +592,11 @@ class PRManager {
|
|
|
576
592
|
*/
|
|
577
593
|
async loadAndDisplayFiles(owner, repo, number) {
|
|
578
594
|
try {
|
|
579
|
-
|
|
595
|
+
let diffUrl = `/api/pr/${owner}/${repo}/${number}/diff`;
|
|
596
|
+
if (this.hideWhitespace) {
|
|
597
|
+
diffUrl += '?w=1';
|
|
598
|
+
}
|
|
599
|
+
const response = await fetch(diffUrl);
|
|
580
600
|
|
|
581
601
|
if (response.ok) {
|
|
582
602
|
const data = await response.json();
|
|
@@ -642,6 +662,35 @@ class PRManager {
|
|
|
642
662
|
}
|
|
643
663
|
}
|
|
644
664
|
|
|
665
|
+
/**
|
|
666
|
+
* Handle the whitespace visibility toggle from DiffOptionsDropdown.
|
|
667
|
+
* Re-fetches the diff (with or without ?w=1), re-renders it, and
|
|
668
|
+
* re-anchors user comments and AI suggestions on the fresh DOM.
|
|
669
|
+
* @param {boolean} hide - Whether to hide whitespace-only changes
|
|
670
|
+
*/
|
|
671
|
+
async handleWhitespaceToggle(hide) {
|
|
672
|
+
this.hideWhitespace = hide;
|
|
673
|
+
|
|
674
|
+
// Nothing to reload if we haven't loaded a PR yet
|
|
675
|
+
if (!this.currentPR) return;
|
|
676
|
+
|
|
677
|
+
const { owner, repo, number } = this.currentPR;
|
|
678
|
+
const scrollY = window.scrollY;
|
|
679
|
+
|
|
680
|
+
// Re-fetch and re-render the diff
|
|
681
|
+
await this.loadAndDisplayFiles(owner, repo, number);
|
|
682
|
+
|
|
683
|
+
// Re-anchor comments and suggestions on the fresh DOM
|
|
684
|
+
const includeDismissed = window.aiPanel?.showDismissedComments || false;
|
|
685
|
+
await this.loadUserComments(includeDismissed);
|
|
686
|
+
await this.loadAISuggestions(null, this.selectedRunId);
|
|
687
|
+
|
|
688
|
+
// Restore scroll position after the DOM settles
|
|
689
|
+
requestAnimationFrame(() => {
|
|
690
|
+
window.scrollTo(0, scrollY);
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
645
694
|
/**
|
|
646
695
|
* Parse unified diff to extract per-file patches
|
|
647
696
|
* @param {string} diff - Full unified diff
|
|
@@ -2214,8 +2263,17 @@ class PRManager {
|
|
|
2214
2263
|
// If a parent suggestion existed, the suggestion card is still collapsed/dismissed in the diff view.
|
|
2215
2264
|
// Update AIPanel to show the suggestion as 'dismissed' (matching its visual state).
|
|
2216
2265
|
// User can click "Show" to restore it to active state if they want to re-adopt.
|
|
2217
|
-
if (apiResult.dismissedSuggestionId
|
|
2218
|
-
window.aiPanel
|
|
2266
|
+
if (apiResult.dismissedSuggestionId) {
|
|
2267
|
+
if (window.aiPanel?.updateFindingStatus) {
|
|
2268
|
+
window.aiPanel.updateFindingStatus(apiResult.dismissedSuggestionId, 'dismissed');
|
|
2269
|
+
}
|
|
2270
|
+
// Clear hiddenForAdoption so that restoring the suggestion takes the API code path
|
|
2271
|
+
// instead of the toggle-only shortcut. Without this, restoring a previously-adopted
|
|
2272
|
+
// suggestion would only toggle visibility without updating its status.
|
|
2273
|
+
const suggestionDiv = document.querySelector(`[data-suggestion-id="${apiResult.dismissedSuggestionId}"]`);
|
|
2274
|
+
if (suggestionDiv) {
|
|
2275
|
+
delete suggestionDiv.dataset.hiddenForAdoption;
|
|
2276
|
+
}
|
|
2219
2277
|
}
|
|
2220
2278
|
|
|
2221
2279
|
// Show success toast
|
package/public/local.html
CHANGED
|
@@ -338,6 +338,11 @@
|
|
|
338
338
|
<span class="toolbar-stat toolbar-stat-additions" id="pr-additions">+0</span>
|
|
339
339
|
<span class="toolbar-stat toolbar-stat-deletions" id="pr-deletions">-0</span>
|
|
340
340
|
<span class="toolbar-files" id="pr-files-count">0 files</span>
|
|
341
|
+
<button class="btn btn-sm btn-icon" id="diff-options-btn" title="Diff options">
|
|
342
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
343
|
+
<path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>
|
|
344
|
+
</svg>
|
|
345
|
+
</button>
|
|
341
346
|
<span class="toolbar-separator"></span>
|
|
342
347
|
<span class="toolbar-commit" id="pr-commit" title="Commit SHA">
|
|
343
348
|
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
@@ -529,6 +534,7 @@
|
|
|
529
534
|
<script src="/js/components/AIPanel.js"></script>
|
|
530
535
|
<script src="/js/components/EmojiPicker.js"></script>
|
|
531
536
|
<script src="/js/components/KeyboardShortcuts.js"></script>
|
|
537
|
+
<script src="/js/components/DiffOptionsDropdown.js"></script>
|
|
532
538
|
|
|
533
539
|
<!-- PR Modules (must load before pr.js) -->
|
|
534
540
|
<script src="/js/modules/storage-cleanup.js"></script>
|
package/public/pr.html
CHANGED
|
@@ -168,6 +168,11 @@
|
|
|
168
168
|
<span class="toolbar-stat toolbar-stat-additions" id="pr-additions">+0</span>
|
|
169
169
|
<span class="toolbar-stat toolbar-stat-deletions" id="pr-deletions">-0</span>
|
|
170
170
|
<span class="toolbar-files" id="pr-files-count">0 files</span>
|
|
171
|
+
<button class="btn btn-sm btn-icon" id="diff-options-btn" title="Diff options">
|
|
172
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
173
|
+
<path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>
|
|
174
|
+
</svg>
|
|
175
|
+
</button>
|
|
171
176
|
<span class="toolbar-separator"></span>
|
|
172
177
|
<span class="toolbar-commit" id="pr-commit" title="Commit SHA">
|
|
173
178
|
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
|
@@ -352,6 +357,7 @@
|
|
|
352
357
|
<script src="/js/components/AIPanel.js"></script>
|
|
353
358
|
<script src="/js/components/EmojiPicker.js"></script>
|
|
354
359
|
<script src="/js/components/KeyboardShortcuts.js"></script>
|
|
360
|
+
<script src="/js/components/DiffOptionsDropdown.js"></script>
|
|
355
361
|
|
|
356
362
|
<!-- PR Modules (must load before pr.js) -->
|
|
357
363
|
<script src="/js/modules/storage-cleanup.js"></script>
|
package/src/database.js
CHANGED
|
@@ -1921,6 +1921,7 @@ class CommentRepository {
|
|
|
1921
1921
|
throw new Error('This suggestion has already been processed');
|
|
1922
1922
|
}
|
|
1923
1923
|
|
|
1924
|
+
|
|
1924
1925
|
// Create user comment preserving metadata from the suggestion
|
|
1925
1926
|
const result = await run(this.db, `
|
|
1926
1927
|
INSERT INTO comments (
|
|
@@ -3394,14 +3395,15 @@ class ContextFileRepository {
|
|
|
3394
3395
|
/**
|
|
3395
3396
|
* Update the line range of an existing context file record
|
|
3396
3397
|
* @param {number} id - Context file record ID
|
|
3398
|
+
* @param {number} reviewId - Review ID (ensures update is scoped to the correct review)
|
|
3397
3399
|
* @param {number} lineStart - New start line number
|
|
3398
3400
|
* @param {number} lineEnd - New end line number
|
|
3399
3401
|
* @returns {Promise<boolean>} True if record was updated
|
|
3400
3402
|
*/
|
|
3401
|
-
async updateRange(id, lineStart, lineEnd) {
|
|
3403
|
+
async updateRange(id, reviewId, lineStart, lineEnd) {
|
|
3402
3404
|
const result = await run(this.db, `
|
|
3403
|
-
UPDATE context_files SET line_start = ?, line_end = ? WHERE id = ?
|
|
3404
|
-
`, [lineStart, lineEnd, id]);
|
|
3405
|
+
UPDATE context_files SET line_start = ?, line_end = ? WHERE id = ? AND review_id = ?
|
|
3406
|
+
`, [lineStart, lineEnd, id, reviewId]);
|
|
3405
3407
|
|
|
3406
3408
|
return result.changes > 0;
|
|
3407
3409
|
}
|
package/src/github/client.js
CHANGED
|
@@ -19,6 +19,45 @@ class GitHubApiError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Detect whether a GraphQL error is a complexity/cost limit error from GitHub.
|
|
24
|
+
* These errors mean the mutation was too large and can be retried with fewer items.
|
|
25
|
+
*
|
|
26
|
+
* @param {Error} error - The error thrown by octokit.graphql
|
|
27
|
+
* @returns {boolean} True if the error is a complexity/cost limit error
|
|
28
|
+
*/
|
|
29
|
+
function isComplexityError(error) {
|
|
30
|
+
const patterns = [
|
|
31
|
+
/complexity/i,
|
|
32
|
+
/MAX_NODE_LIMIT/,
|
|
33
|
+
/cost exceeds/i,
|
|
34
|
+
/too large/i,
|
|
35
|
+
/query size exceeds/i,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Check the top-level error message
|
|
39
|
+
if (error.message) {
|
|
40
|
+
for (const pattern of patterns) {
|
|
41
|
+
if (pattern.test(error.message)) return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check individual GraphQL error messages in the errors array
|
|
46
|
+
if (error.errors && Array.isArray(error.errors)) {
|
|
47
|
+
for (const err of error.errors) {
|
|
48
|
+
if (err.message) {
|
|
49
|
+
for (const pattern of patterns) {
|
|
50
|
+
if (pattern.test(err.message)) return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const MIN_BATCH_SIZE = 1;
|
|
60
|
+
|
|
22
61
|
/**
|
|
23
62
|
* GitHub API client wrapper with error handling and rate limiting
|
|
24
63
|
*/
|
|
@@ -347,42 +386,38 @@ class GitHubClient {
|
|
|
347
386
|
* Add comments to a pending review in batches
|
|
348
387
|
* This helper splits comments into batches to avoid GitHub API limits
|
|
349
388
|
* on large mutations. Each batch is executed sequentially with retry logic.
|
|
389
|
+
* If a batch fails due to GitHub GraphQL complexity/cost limits, the batch
|
|
390
|
+
* size is automatically halved and the failed batch is retried.
|
|
350
391
|
*
|
|
351
392
|
* @param {string} prNodeId - GraphQL node ID for the PR (e.g., "PR_kwDOM...")
|
|
352
393
|
* @param {string} reviewId - GraphQL node ID for the pending review
|
|
353
394
|
* @param {Array} comments - Array of comments with path, line (optional), side, body, isFileLevel
|
|
354
|
-
* @param {number} batchSize - Number of comments per batch (default:
|
|
395
|
+
* @param {number} batchSize - Number of comments per batch (default: 10)
|
|
355
396
|
* @returns {Promise<Object>} Result with successCount, failed flag, and failedDetails array of error strings
|
|
356
397
|
*/
|
|
357
|
-
|
|
358
|
-
// mutation size limits while still being efficient for large reviews.
|
|
359
|
-
async addCommentsInBatches(prNodeId, reviewId, comments, batchSize = 25) {
|
|
398
|
+
async addCommentsInBatches(prNodeId, reviewId, comments, batchSize = 10) {
|
|
360
399
|
if (comments.length === 0) {
|
|
361
400
|
return { successCount: 0, failed: false, failedDetails: [] };
|
|
362
401
|
}
|
|
363
402
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
for (let i = 0; i < comments.length; i += batchSize) {
|
|
367
|
-
batches.push(comments.slice(i, i + batchSize));
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
console.log(`Adding ${comments.length} comments in ${batches.length} batch(es) of up to ${batchSize} comments each`);
|
|
371
|
-
|
|
403
|
+
let currentBatchSize = batchSize;
|
|
404
|
+
let remaining = comments.slice();
|
|
372
405
|
let totalSuccessful = 0;
|
|
373
406
|
const failedDetails = [];
|
|
407
|
+
let batchNumber = 0;
|
|
374
408
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
409
|
+
console.log(`Adding ${comments.length} comments in batches of up to ${currentBatchSize}`);
|
|
410
|
+
|
|
411
|
+
while (remaining.length > 0) {
|
|
412
|
+
batchNumber++;
|
|
413
|
+
const batch = remaining.slice(0, currentBatchSize);
|
|
414
|
+
console.log(`Adding comments batch ${batchNumber} (${batch.length} comments, ${remaining.length} remaining)...`);
|
|
379
415
|
|
|
380
416
|
// Build mutation for this batch
|
|
381
417
|
const commentMutations = batch.map((comment, index) => {
|
|
382
418
|
const isFileLevel = comment.isFileLevel || !comment.line;
|
|
383
419
|
|
|
384
420
|
if (isFileLevel) {
|
|
385
|
-
// File-level comment (for expanded context lines)
|
|
386
421
|
return `
|
|
387
422
|
comment${index}: addPullRequestReviewThread(input: {
|
|
388
423
|
pullRequestId: $prId
|
|
@@ -395,7 +430,6 @@ class GitHubClient {
|
|
|
395
430
|
}
|
|
396
431
|
`;
|
|
397
432
|
} else {
|
|
398
|
-
// Line-level comment
|
|
399
433
|
const side = comment.side || 'RIGHT';
|
|
400
434
|
const startLineField = comment.start_line ? `startLine: ${comment.start_line}\n ` : '';
|
|
401
435
|
return `
|
|
@@ -424,6 +458,7 @@ class GitHubClient {
|
|
|
424
458
|
let batchError = null;
|
|
425
459
|
let retryAttempt = 0;
|
|
426
460
|
const maxRetries = 1;
|
|
461
|
+
let reducedBatchSize = false;
|
|
427
462
|
|
|
428
463
|
while (retryAttempt <= maxRetries) {
|
|
429
464
|
try {
|
|
@@ -435,6 +470,22 @@ class GitHubClient {
|
|
|
435
470
|
break; // Success, exit retry loop
|
|
436
471
|
} catch (error) {
|
|
437
472
|
batchError = error;
|
|
473
|
+
|
|
474
|
+
// Check for complexity/cost limit errors — reduce batch size instead of retrying
|
|
475
|
+
if (isComplexityError(error)) {
|
|
476
|
+
const newSize = Math.max(MIN_BATCH_SIZE, Math.floor(currentBatchSize / 2));
|
|
477
|
+
if (newSize < currentBatchSize) {
|
|
478
|
+
console.warn(
|
|
479
|
+
`Batch ${batchNumber} hit complexity limit (size ${currentBatchSize}), ` +
|
|
480
|
+
`reducing batch size to ${newSize}`
|
|
481
|
+
);
|
|
482
|
+
currentBatchSize = newSize;
|
|
483
|
+
reducedBatchSize = true;
|
|
484
|
+
break; // Exit retry loop — will re-attempt with smaller batch
|
|
485
|
+
}
|
|
486
|
+
// Already at minimum batch size — fall through to normal retry logic
|
|
487
|
+
}
|
|
488
|
+
|
|
438
489
|
if (retryAttempt < maxRetries) {
|
|
439
490
|
console.warn(`Batch ${batchNumber} failed, retrying... (${error.message})`);
|
|
440
491
|
retryAttempt++;
|
|
@@ -450,6 +501,12 @@ class GitHubClient {
|
|
|
450
501
|
}
|
|
451
502
|
}
|
|
452
503
|
|
|
504
|
+
// If we reduced batch size due to complexity, retry from top of loop
|
|
505
|
+
// with the same remaining comments but a smaller batch
|
|
506
|
+
if (reducedBatchSize) {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
453
510
|
// Check if batch succeeded
|
|
454
511
|
if (batchError) {
|
|
455
512
|
// Build a map of per-comment errors from the GraphQL errors array.
|
|
@@ -522,9 +579,12 @@ class GitHubClient {
|
|
|
522
579
|
totalSuccessful += batchSuccessful;
|
|
523
580
|
console.log(`Batch ${batchNumber} complete: ${batchSuccessful} comments added`);
|
|
524
581
|
}
|
|
582
|
+
|
|
583
|
+
// Advance past the successfully processed batch
|
|
584
|
+
remaining = remaining.slice(batch.length);
|
|
525
585
|
}
|
|
526
586
|
|
|
527
|
-
console.log(`All
|
|
587
|
+
console.log(`All batches complete: ${totalSuccessful} total comments added`);
|
|
528
588
|
return { successCount: totalSuccessful, failed: false, failedDetails };
|
|
529
589
|
}
|
|
530
590
|
|
|
@@ -1124,4 +1184,4 @@ class GitHubClient {
|
|
|
1124
1184
|
}
|
|
1125
1185
|
}
|
|
1126
1186
|
|
|
1127
|
-
module.exports = { GitHubClient, GitHubApiError };
|
|
1187
|
+
module.exports = { GitHubClient, GitHubApiError, isComplexityError };
|
package/src/local-review.js
CHANGED
|
@@ -366,8 +366,9 @@ async function getUntrackedFiles(repoPath) {
|
|
|
366
366
|
* @param {string} repoPath - Path to the git repository
|
|
367
367
|
* @returns {Promise<{diff: string, untrackedFiles: Array, stats: Object}>}
|
|
368
368
|
*/
|
|
369
|
-
async function generateLocalDiff(repoPath) {
|
|
369
|
+
async function generateLocalDiff(repoPath, options = {}) {
|
|
370
370
|
let diff = '';
|
|
371
|
+
const wFlag = options.hideWhitespace ? ' -w' : '';
|
|
371
372
|
const stats = {
|
|
372
373
|
trackedChanges: 0,
|
|
373
374
|
untrackedFiles: 0,
|
|
@@ -378,7 +379,7 @@ async function generateLocalDiff(repoPath) {
|
|
|
378
379
|
try {
|
|
379
380
|
// Count staged changes for stats (but don't include in diff)
|
|
380
381
|
// This is informational only - staged files are excluded from review
|
|
381
|
-
const stagedDiff = execSync(
|
|
382
|
+
const stagedDiff = execSync(`git diff --cached --no-color --no-ext-diff --unified=25${wFlag}`, {
|
|
382
383
|
cwd: repoPath,
|
|
383
384
|
encoding: 'utf8',
|
|
384
385
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -390,7 +391,7 @@ async function generateLocalDiff(repoPath) {
|
|
|
390
391
|
}
|
|
391
392
|
|
|
392
393
|
// Get unstaged changes to tracked files (this is what we show in the review)
|
|
393
|
-
const unstagedDiff = execSync(
|
|
394
|
+
const unstagedDiff = execSync(`git diff --no-color --no-ext-diff --unified=25${wFlag}`, {
|
|
394
395
|
cwd: repoPath,
|
|
395
396
|
encoding: 'utf8',
|
|
396
397
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -427,7 +428,7 @@ async function generateLocalDiff(repoPath) {
|
|
|
427
428
|
// git diff --no-index exits with code 1 when files differ, code 0 when identical
|
|
428
429
|
let fileDiff;
|
|
429
430
|
try {
|
|
430
|
-
fileDiff = execSync(`git diff --no-index --no-color --no-ext-diff -- /dev/null "${filePath}"`, {
|
|
431
|
+
fileDiff = execSync(`git diff --no-index --no-color --no-ext-diff${wFlag} -- /dev/null "${filePath}"`, {
|
|
431
432
|
cwd: repoPath,
|
|
432
433
|
encoding: 'utf8',
|
|
433
434
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -209,7 +209,7 @@ router.patch('/api/reviews/:reviewId/context-files/:id', validateReviewId, async
|
|
|
209
209
|
const db = req.app.get('db');
|
|
210
210
|
const contextFileRepo = new ContextFileRepository(db);
|
|
211
211
|
|
|
212
|
-
const updated = await contextFileRepo.updateRange(id, lineStart, lineEnd);
|
|
212
|
+
const updated = await contextFileRepo.updateRange(id, req.reviewId, lineStart, lineEnd);
|
|
213
213
|
|
|
214
214
|
if (!updated) {
|
|
215
215
|
return res.status(404).json({ error: 'Context file not found' });
|
package/src/routes/local.js
CHANGED
|
@@ -465,8 +465,24 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
|
465
465
|
});
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
+
// When ?w=1, regenerate the diff with whitespace changes hidden (transient view, not cached)
|
|
469
|
+
const hideWhitespace = req.query.w === '1';
|
|
470
|
+
let diffData;
|
|
471
|
+
|
|
472
|
+
if (hideWhitespace && review.local_path) {
|
|
473
|
+
try {
|
|
474
|
+
const wsResult = await generateLocalDiff(review.local_path, { hideWhitespace: true });
|
|
475
|
+
diffData = { diff: wsResult.diff, stats: wsResult.stats };
|
|
476
|
+
} catch (wsError) {
|
|
477
|
+
logger.warn(`Could not generate whitespace-filtered diff for review #${reviewId}: ${wsError.message}`);
|
|
478
|
+
// Fall through to cached diff below
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
468
482
|
// Get diff from module-level storage, falling back to database
|
|
469
|
-
|
|
483
|
+
if (!diffData) {
|
|
484
|
+
diffData = getLocalReviewDiff(reviewId);
|
|
485
|
+
}
|
|
470
486
|
|
|
471
487
|
if (!diffData) {
|
|
472
488
|
// Try loading from database
|
package/src/routes/pr.js
CHANGED
|
@@ -18,7 +18,7 @@ const { query, queryOne, run, withTransaction, WorktreeRepository, ReviewReposit
|
|
|
18
18
|
const { GitWorktreeManager } = require('../git/worktree');
|
|
19
19
|
const { GitHubClient } = require('../github/client');
|
|
20
20
|
const { getGeneratedFilePatterns } = require('../git/gitattributes');
|
|
21
|
-
const { normalizeRepository } = require('../utils/paths');
|
|
21
|
+
const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
|
|
22
22
|
const { mergeInstructions } = require('../utils/instructions');
|
|
23
23
|
const Analyzer = require('../ai/analyzer');
|
|
24
24
|
const { v4: uuidv4 } = require('uuid');
|
|
@@ -652,15 +652,54 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
652
652
|
});
|
|
653
653
|
}
|
|
654
654
|
|
|
655
|
-
//
|
|
656
|
-
let changedFiles = prData.changed_files || [];
|
|
657
|
-
|
|
658
|
-
// Look up worktree path to read .gitattributes
|
|
655
|
+
// Look up worktree path (needed for .gitattributes and whitespace-filtered diffs)
|
|
659
656
|
const db = req.app.get('db');
|
|
660
657
|
const worktreeRepo = new WorktreeRepository(db);
|
|
661
658
|
const worktreeRecord = await worktreeRepo.findByPR(prNumber, repository);
|
|
662
659
|
|
|
663
|
-
|
|
660
|
+
// When ?w=1, regenerate the diff from the worktree with whitespace changes hidden
|
|
661
|
+
const hideWhitespace = req.query.w === '1';
|
|
662
|
+
let diffContent = prData.diff || '';
|
|
663
|
+
let changedFiles = prData.changed_files || [];
|
|
664
|
+
|
|
665
|
+
if (hideWhitespace && worktreeRecord && worktreeRecord.path) {
|
|
666
|
+
try {
|
|
667
|
+
const worktreePath = worktreeRecord.path;
|
|
668
|
+
await fs.access(worktreePath);
|
|
669
|
+
const git = simpleGit(worktreePath);
|
|
670
|
+
const baseSha = prData.base_sha;
|
|
671
|
+
const headSha = prData.head_sha;
|
|
672
|
+
|
|
673
|
+
if (baseSha && headSha) {
|
|
674
|
+
// Regenerate diff with -w flag to ignore whitespace changes
|
|
675
|
+
diffContent = await git.diff([`${baseSha}...${headSha}`, '--unified=3', '-w']);
|
|
676
|
+
|
|
677
|
+
// Regenerate changed files stats with -w flag
|
|
678
|
+
const diffSummary = await git.diffSummary([`${baseSha}...${headSha}`, '-w']);
|
|
679
|
+
const gitattributes = await getGeneratedFilePatterns(worktreePath);
|
|
680
|
+
changedFiles = diffSummary.files.map(file => {
|
|
681
|
+
const resolvedFile = resolveRenamedFile(file.file);
|
|
682
|
+
const isRenamed = resolvedFile !== file.file;
|
|
683
|
+
const result = {
|
|
684
|
+
file: resolvedFile,
|
|
685
|
+
insertions: file.insertions,
|
|
686
|
+
deletions: file.deletions,
|
|
687
|
+
changes: file.changes,
|
|
688
|
+
generated: gitattributes.isGenerated(resolvedFile)
|
|
689
|
+
};
|
|
690
|
+
if (isRenamed) {
|
|
691
|
+
result.renamed = true;
|
|
692
|
+
result.renamedFrom = resolveRenamedFileOld(file.file);
|
|
693
|
+
}
|
|
694
|
+
return result;
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
} catch (wsError) {
|
|
698
|
+
logger.warn(`Could not generate whitespace-filtered diff for PR #${prNumber}: ${wsError.message}`);
|
|
699
|
+
// Fall back to cached diff (diffContent and changedFiles already set from prData)
|
|
700
|
+
}
|
|
701
|
+
} else if (worktreeRecord && worktreeRecord.path) {
|
|
702
|
+
// Add generated flag to changed files based on .gitattributes
|
|
664
703
|
try {
|
|
665
704
|
const gitattributes = await getGeneratedFilePatterns(worktreeRecord.path);
|
|
666
705
|
changedFiles = changedFiles.map(file => ({
|
|
@@ -668,23 +707,33 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
668
707
|
generated: gitattributes.isGenerated(file.file)
|
|
669
708
|
}));
|
|
670
709
|
} catch (error) {
|
|
671
|
-
|
|
710
|
+
logger.warn(`Could not load .gitattributes: ${error.message}`);
|
|
672
711
|
// Continue without generated flags
|
|
673
712
|
}
|
|
674
713
|
}
|
|
675
714
|
|
|
715
|
+
// When hideWhitespace is active and diff was regenerated, compute
|
|
716
|
+
// aggregate stats from the regenerated changedFiles instead of using
|
|
717
|
+
// stale cached values from prData.
|
|
718
|
+
const additions = hideWhitespace
|
|
719
|
+
? changedFiles.reduce((sum, f) => sum + (f.insertions || 0), 0)
|
|
720
|
+
: (prData.additions || 0);
|
|
721
|
+
const deletions = hideWhitespace
|
|
722
|
+
? changedFiles.reduce((sum, f) => sum + (f.deletions || 0), 0)
|
|
723
|
+
: (prData.deletions || 0);
|
|
724
|
+
|
|
676
725
|
res.json({
|
|
677
|
-
diff:
|
|
726
|
+
diff: diffContent,
|
|
678
727
|
changed_files: changedFiles,
|
|
679
728
|
stats: {
|
|
680
|
-
additions
|
|
681
|
-
deletions
|
|
729
|
+
additions,
|
|
730
|
+
deletions,
|
|
682
731
|
changed_files: changedFiles.length
|
|
683
732
|
}
|
|
684
733
|
});
|
|
685
734
|
|
|
686
735
|
} catch (error) {
|
|
687
|
-
|
|
736
|
+
logger.error('Error fetching PR diff:', error);
|
|
688
737
|
res.status(500).json({
|
|
689
738
|
error: 'Internal server error while fetching diff data'
|
|
690
739
|
});
|
|
@@ -67,7 +67,7 @@ async function ensureContextFileForComment(db, review, { file, line_start, line_
|
|
|
67
67
|
newEnd = newStart + MAX_RANGE - 1;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
await contextFileRepo.updateRange(overlapping.id, newStart, newEnd);
|
|
70
|
+
await contextFileRepo.updateRange(overlapping.id, review.id, newStart, newEnd);
|
|
71
71
|
return { created: false, expanded: true, contextFileId: overlapping.id };
|
|
72
72
|
}
|
|
73
73
|
|