@in-the-loop-labs/pair-review 3.7.0 → 3.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +17 -0
- package/public/js/components/AIPanel.js +63 -34
- package/public/js/components/ChatPanel.js +11 -1
- package/public/js/components/SuggestionNavigator.js +55 -10
- package/public/js/modules/tour-renderer.js +12 -2
- package/public/js/pr.js +55 -15
- package/public/js/utils/scroll-into-view.js +164 -0
- package/public/local.html +3 -0
- package/public/pr.html +3 -0
- package/src/ai/claude-provider.js +31 -3
- package/src/main.js +3 -2
- package/src/routes/stack-analysis.js +45 -13
- package/src/setup/stack-setup.js +32 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.2",
|
|
4
4
|
"description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-critic",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.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
|
@@ -1661,6 +1661,23 @@
|
|
|
1661
1661
|
scroll-margin-top: var(--toolbar-height, 0px);
|
|
1662
1662
|
}
|
|
1663
1663
|
|
|
1664
|
+
/* Suggestion / finding / comment / chat-line navigation lands the target at
|
|
1665
|
+
the TOP of the diff panel (scrollIntoView block:'start'). Offset it below
|
|
1666
|
+
the sticky toolbar AND the sticky file header so it isn't hidden behind
|
|
1667
|
+
them. `--diff-file-header-height` is measured in JS (_measureFileHeaderHeight)
|
|
1668
|
+
with a sane fallback. Suppressed during a tour, which scrolls to center and
|
|
1669
|
+
manages its own sticky offsets. */
|
|
1670
|
+
:root {
|
|
1671
|
+
--diff-scroll-offset: calc(var(--toolbar-height, 0px) + var(--diff-file-header-height, 38px));
|
|
1672
|
+
}
|
|
1673
|
+
body:not(.tour-active) .d2h-file-wrapper tr,
|
|
1674
|
+
body:not(.tour-active) .ai-suggestion,
|
|
1675
|
+
body:not(.tour-active) .file-comment-card,
|
|
1676
|
+
body:not(.tour-active) .user-comment-row,
|
|
1677
|
+
body:not(.tour-active) .external-comment-row {
|
|
1678
|
+
scroll-margin-top: var(--diff-scroll-offset);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1664
1681
|
/* Hide diff content when collapsed */
|
|
1665
1682
|
.d2h-file-wrapper.collapsed .d2h-file-body,
|
|
1666
1683
|
.d2h-file-wrapper.collapsed .d2h-diff-table {
|
|
@@ -44,6 +44,10 @@ class AIPanel {
|
|
|
44
44
|
// Track selected item by stable identifier for restoration
|
|
45
45
|
this.selectedItemKey = null; // Format: "file:lineNumber:itemType:identity"
|
|
46
46
|
|
|
47
|
+
// Monotonic token so a fast move between items that supersedes an
|
|
48
|
+
// in-flight scrollTo* can tell the older call to bail after its await.
|
|
49
|
+
this._navGen = 0;
|
|
50
|
+
|
|
47
51
|
// Canonical file order for consistent sorting across components
|
|
48
52
|
this.fileOrder = new Map(); // Map of file path -> index
|
|
49
53
|
|
|
@@ -1178,9 +1182,18 @@ class AIPanel {
|
|
|
1178
1182
|
/**
|
|
1179
1183
|
* Scroll to an AI finding/suggestion in the diff view
|
|
1180
1184
|
*/
|
|
1181
|
-
scrollToFinding(findingId, file, line) {
|
|
1185
|
+
async scrollToFinding(findingId, file, line) {
|
|
1186
|
+
const myGen = ++this._navGen;
|
|
1182
1187
|
// Expand the file first if it's collapsed
|
|
1183
1188
|
const expansion = this.expandFileIfCollapsed(file);
|
|
1189
|
+
if (expansion && typeof expansion.then === 'function') await expansion;
|
|
1190
|
+
// Always render the target's lazy body — an expanded-but-offscreen
|
|
1191
|
+
// body has no suggestion rows until rendered, so the lookup below
|
|
1192
|
+
// would miss on the first attempt (expansion only covers the
|
|
1193
|
+
// collapsed case).
|
|
1194
|
+
if (file && window.prManager?.ensureFileBodyRendered) {
|
|
1195
|
+
try { await window.prManager.ensureFileBodyRendered(file); } catch { /* best effort */ }
|
|
1196
|
+
}
|
|
1184
1197
|
|
|
1185
1198
|
const doScroll = () => {
|
|
1186
1199
|
let targetSuggestion = null;
|
|
@@ -1216,36 +1229,54 @@ class AIPanel {
|
|
|
1216
1229
|
|
|
1217
1230
|
if (targetSuggestion) {
|
|
1218
1231
|
const minimizer = window.prManager?.commentMinimizer;
|
|
1232
|
+
let scrollTarget = targetSuggestion;
|
|
1219
1233
|
if (minimizer?.active) {
|
|
1220
1234
|
// Expand file-level comments so the target becomes visible
|
|
1221
1235
|
minimizer.expandForElement(targetSuggestion);
|
|
1222
1236
|
// Comments are minimized — scroll to the parent diff line instead
|
|
1223
|
-
|
|
1224
|
-
(diffRow || targetSuggestion).scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1225
|
-
} else {
|
|
1226
|
-
targetSuggestion.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1237
|
+
scrollTarget = minimizer.findDiffRowFor(targetSuggestion) || targetSuggestion;
|
|
1227
1238
|
}
|
|
1239
|
+
this._scrollDiffTarget(scrollTarget);
|
|
1228
1240
|
targetSuggestion.classList.add('current-suggestion');
|
|
1229
1241
|
setTimeout(() => targetSuggestion.classList.remove('current-suggestion'), 2000);
|
|
1230
1242
|
}
|
|
1231
1243
|
};
|
|
1232
1244
|
|
|
1233
|
-
//
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1245
|
+
// A newer navigation took over while we awaited — let it win.
|
|
1246
|
+
if (myGen !== this._navGen) return;
|
|
1247
|
+
doScroll();
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Scroll a diff-panel element into view, preferring the stable helper
|
|
1252
|
+
* (re-corrects after lazy file bodies render mid-scroll and shift
|
|
1253
|
+
* layout). Fire-and-forget.
|
|
1254
|
+
* @param {Element} target
|
|
1255
|
+
*/
|
|
1256
|
+
_scrollDiffTarget(target) {
|
|
1257
|
+
// Land the target at the top of the diff panel (scroll-margin-top in
|
|
1258
|
+
// pr.css offsets it below the sticky toolbar + file header).
|
|
1259
|
+
const options = { behavior: 'smooth', block: 'start' };
|
|
1260
|
+
if (window.ScrollUtils?.scrollIntoViewStable) {
|
|
1261
|
+
window.ScrollUtils.scrollIntoViewStable(target, options);
|
|
1238
1262
|
} else {
|
|
1239
|
-
|
|
1263
|
+
target.scrollIntoView(options);
|
|
1240
1264
|
}
|
|
1241
1265
|
}
|
|
1242
1266
|
|
|
1243
1267
|
/**
|
|
1244
1268
|
* Scroll to a user comment in the diff view
|
|
1245
1269
|
*/
|
|
1246
|
-
scrollToComment(commentId, file, line) {
|
|
1270
|
+
async scrollToComment(commentId, file, line) {
|
|
1271
|
+
const myGen = ++this._navGen;
|
|
1247
1272
|
// Expand the file first if it's collapsed
|
|
1248
1273
|
const expansion = this.expandFileIfCollapsed(file);
|
|
1274
|
+
if (expansion && typeof expansion.then === 'function') await expansion;
|
|
1275
|
+
// Always render the target's lazy body — comment rows don't exist
|
|
1276
|
+
// inside an unrendered body, so the lookup below would miss.
|
|
1277
|
+
if (file && window.prManager?.ensureFileBodyRendered) {
|
|
1278
|
+
try { await window.prManager.ensureFileBodyRendered(file); } catch { /* best effort */ }
|
|
1279
|
+
}
|
|
1249
1280
|
|
|
1250
1281
|
const doScroll = () => {
|
|
1251
1282
|
let targetElement = null;
|
|
@@ -1288,13 +1319,13 @@ class AIPanel {
|
|
|
1288
1319
|
|
|
1289
1320
|
if (targetElement) {
|
|
1290
1321
|
const minimizer = window.prManager?.commentMinimizer;
|
|
1322
|
+
let scrollTarget = targetElement;
|
|
1291
1323
|
if (minimizer?.active) {
|
|
1292
1324
|
minimizer.expandForElement(targetElement);
|
|
1293
1325
|
const diffRow = isFileLevel ? null : minimizer.findDiffRowFor(targetElement);
|
|
1294
|
-
|
|
1295
|
-
} else {
|
|
1296
|
-
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1326
|
+
scrollTarget = diffRow || targetElement;
|
|
1297
1327
|
}
|
|
1328
|
+
this._scrollDiffTarget(scrollTarget);
|
|
1298
1329
|
// Add highlight effect
|
|
1299
1330
|
const commentDiv = isFileLevel ? targetElement : targetElement.querySelector('.user-comment');
|
|
1300
1331
|
if (commentDiv) {
|
|
@@ -1304,13 +1335,9 @@ class AIPanel {
|
|
|
1304
1335
|
}
|
|
1305
1336
|
};
|
|
1306
1337
|
|
|
1307
|
-
//
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
expansion.then(doScroll);
|
|
1311
|
-
} else {
|
|
1312
|
-
doScroll();
|
|
1313
|
-
}
|
|
1338
|
+
// A newer navigation took over while we awaited — let it win.
|
|
1339
|
+
if (myGen !== this._navGen) return;
|
|
1340
|
+
doScroll();
|
|
1314
1341
|
}
|
|
1315
1342
|
|
|
1316
1343
|
/**
|
|
@@ -1325,9 +1352,16 @@ class AIPanel {
|
|
|
1325
1352
|
* @param {string} file - File path for collapse-expand fallback
|
|
1326
1353
|
* @param {string|number} line - Anchor line; used for file/line fallback
|
|
1327
1354
|
*/
|
|
1328
|
-
scrollToExternalThread(threadId, source, file, line) {
|
|
1355
|
+
async scrollToExternalThread(threadId, source, file, line) {
|
|
1356
|
+
const myGen = ++this._navGen;
|
|
1329
1357
|
// Expand the file first if it's collapsed
|
|
1330
1358
|
const expansion = this.expandFileIfCollapsed(file);
|
|
1359
|
+
if (expansion && typeof expansion.then === 'function') await expansion;
|
|
1360
|
+
// Always render the target's lazy body — external thread rows don't
|
|
1361
|
+
// exist inside an unrendered body, so the lookup below would miss.
|
|
1362
|
+
if (file && window.prManager?.ensureFileBodyRendered) {
|
|
1363
|
+
try { await window.prManager.ensureFileBodyRendered(file); } catch { /* best effort */ }
|
|
1364
|
+
}
|
|
1331
1365
|
|
|
1332
1366
|
const doScroll = () => {
|
|
1333
1367
|
let target = null;
|
|
@@ -1368,13 +1402,12 @@ class AIPanel {
|
|
|
1368
1402
|
|
|
1369
1403
|
if (target) {
|
|
1370
1404
|
const minimizer = window.prManager?.commentMinimizer;
|
|
1405
|
+
let scrollTarget = target;
|
|
1371
1406
|
if (minimizer?.active) {
|
|
1372
1407
|
minimizer.expandForElement(target);
|
|
1373
|
-
|
|
1374
|
-
(diffRow || target).scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1375
|
-
} else {
|
|
1376
|
-
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1408
|
+
scrollTarget = minimizer.findDiffRowFor(target) || target;
|
|
1377
1409
|
}
|
|
1410
|
+
this._scrollDiffTarget(scrollTarget);
|
|
1378
1411
|
|
|
1379
1412
|
// Transient focus flash. The class is removed after 2s — if
|
|
1380
1413
|
// the row is rebuilt before then, the class is lost with it,
|
|
@@ -1384,13 +1417,9 @@ class AIPanel {
|
|
|
1384
1417
|
}
|
|
1385
1418
|
};
|
|
1386
1419
|
|
|
1387
|
-
//
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
expansion.then(doScroll);
|
|
1391
|
-
} else {
|
|
1392
|
-
doScroll();
|
|
1393
|
-
}
|
|
1420
|
+
// A newer navigation took over while we awaited — let it win.
|
|
1421
|
+
if (myGen !== this._navGen) return;
|
|
1422
|
+
doScroll();
|
|
1394
1423
|
}
|
|
1395
1424
|
|
|
1396
1425
|
// ========================================
|
|
@@ -4613,7 +4613,17 @@ class ChatPanel {
|
|
|
4613
4613
|
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
|
|
4614
4614
|
|
|
4615
4615
|
if (!isVisible) {
|
|
4616
|
-
|
|
4616
|
+
// Stable variant re-corrects after lazy file bodies render mid-scroll
|
|
4617
|
+
// and shift the layout (plain scrollIntoView lands off target the
|
|
4618
|
+
// first time on large diffs). Fire-and-forget.
|
|
4619
|
+
// Land the target at the top of the diff panel (scroll-margin-top in
|
|
4620
|
+
// pr.css offsets it below the sticky toolbar + file header).
|
|
4621
|
+
const scrollOptions = { behavior: 'smooth', block: 'start' };
|
|
4622
|
+
if (window.ScrollUtils?.scrollIntoViewStable) {
|
|
4623
|
+
window.ScrollUtils.scrollIntoViewStable(primaryRow, scrollOptions);
|
|
4624
|
+
} else {
|
|
4625
|
+
primaryRow.scrollIntoView(scrollOptions);
|
|
4626
|
+
}
|
|
4617
4627
|
}
|
|
4618
4628
|
|
|
4619
4629
|
// Apply the highlight to all target rows
|
|
@@ -10,7 +10,10 @@ class SuggestionNavigator {
|
|
|
10
10
|
this.isCollapsed = this.loadCollapsedState();
|
|
11
11
|
this.element = null;
|
|
12
12
|
this.collapseToggle = null;
|
|
13
|
-
|
|
13
|
+
// Monotonic token so a fast Next/Prev that supersedes an in-flight
|
|
14
|
+
// goToSuggestion can tell the older call to bail after its await.
|
|
15
|
+
this._navGen = 0;
|
|
16
|
+
|
|
14
17
|
this.init();
|
|
15
18
|
this.bindEvents();
|
|
16
19
|
}
|
|
@@ -236,7 +239,7 @@ class SuggestionNavigator {
|
|
|
236
239
|
/**
|
|
237
240
|
* Navigate to specific suggestion by index
|
|
238
241
|
*/
|
|
239
|
-
goToSuggestion(index) {
|
|
242
|
+
async goToSuggestion(index) {
|
|
240
243
|
if (index < 0 || index >= this.suggestions.length) {
|
|
241
244
|
return;
|
|
242
245
|
}
|
|
@@ -244,10 +247,42 @@ class SuggestionNavigator {
|
|
|
244
247
|
this.currentSuggestionIndex = index;
|
|
245
248
|
this.updateCounter();
|
|
246
249
|
this.updateNavigationButtons();
|
|
250
|
+
// The suggestion's row only exists once its file body has rendered
|
|
251
|
+
// (lazy bodies start empty), so render it before the highlight/scroll
|
|
252
|
+
// lookups below — otherwise both silently miss on the first attempt.
|
|
253
|
+
// A collapsed file is expanded first so the row is actually visible.
|
|
254
|
+
const myGen = ++this._navGen;
|
|
255
|
+
await this.ensureSuggestionVisible(this.suggestions[index]);
|
|
256
|
+
// A newer goToSuggestion ran while we awaited and moved
|
|
257
|
+
// this.currentSuggestionIndex — let it own the highlight/scroll.
|
|
258
|
+
if (myGen !== this._navGen) return;
|
|
247
259
|
this.highlightCurrentSuggestion();
|
|
248
260
|
this.scrollToSuggestion();
|
|
249
261
|
}
|
|
250
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Make sure a suggestion's file is expanded and its lazy diff body is
|
|
265
|
+
* rendered so the suggestion row exists in the DOM. Best effort: any
|
|
266
|
+
* failure falls through to the old lookup-miss behavior.
|
|
267
|
+
* @param {Object} suggestion
|
|
268
|
+
*/
|
|
269
|
+
async ensureSuggestionVisible(suggestion) {
|
|
270
|
+
const file = suggestion?.file;
|
|
271
|
+
const pm = window.prManager;
|
|
272
|
+
if (!file || !pm) return;
|
|
273
|
+
try {
|
|
274
|
+
const wrapper = pm.findFileElement?.(file);
|
|
275
|
+
if (wrapper?.classList.contains('collapsed') && pm.toggleFileCollapse) {
|
|
276
|
+
// Renders the lazy body and removes `collapsed`.
|
|
277
|
+
await pm.toggleFileCollapse(wrapper.dataset.fileName || file);
|
|
278
|
+
} else if (pm.ensureFileBodyRendered) {
|
|
279
|
+
await pm.ensureFileBodyRendered(file);
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.warn('[SuggestionNavigator] could not prepare suggestion file', file, err);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
251
286
|
/**
|
|
252
287
|
* Check if a suggestion should be skipped during navigation
|
|
253
288
|
*/
|
|
@@ -370,18 +405,22 @@ class SuggestionNavigator {
|
|
|
370
405
|
|
|
371
406
|
if (suggestionEl) {
|
|
372
407
|
const minimizer = window.prManager?.commentMinimizer;
|
|
408
|
+
let scrollTarget = suggestionEl;
|
|
373
409
|
if (minimizer?.active) {
|
|
374
410
|
// Expand file-level comments so the target becomes visible
|
|
375
411
|
minimizer.expandForElement(suggestionEl);
|
|
376
412
|
// Comments are minimized — scroll to the parent diff line instead
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
413
|
+
scrollTarget = minimizer.findDiffRowFor(suggestionEl) || suggestionEl;
|
|
414
|
+
}
|
|
415
|
+
// Land the target at the top of the diff panel (scroll-margin-top in
|
|
416
|
+
// pr.css offsets it below the sticky toolbar + file header).
|
|
417
|
+
const options = { behavior: 'smooth', block: 'start', inline: 'nearest' };
|
|
418
|
+
// Stable variant re-corrects after lazy file bodies render
|
|
419
|
+
// mid-scroll and shift the layout. Fire-and-forget.
|
|
420
|
+
if (window.ScrollUtils?.scrollIntoViewStable) {
|
|
421
|
+
window.ScrollUtils.scrollIntoViewStable(scrollTarget, options);
|
|
383
422
|
} else {
|
|
384
|
-
|
|
423
|
+
scrollTarget.scrollIntoView(options);
|
|
385
424
|
}
|
|
386
425
|
}
|
|
387
426
|
}
|
|
@@ -474,4 +513,10 @@ class SuggestionNavigator {
|
|
|
474
513
|
}
|
|
475
514
|
|
|
476
515
|
// Export for use
|
|
477
|
-
window
|
|
516
|
+
if (typeof window !== 'undefined') {
|
|
517
|
+
window.SuggestionNavigator = SuggestionNavigator;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
521
|
+
module.exports = SuggestionNavigator;
|
|
522
|
+
}
|
|
@@ -488,10 +488,20 @@ class TourRenderer {
|
|
|
488
488
|
scrollToStop(index) {
|
|
489
489
|
const row = this._mounted.get(index);
|
|
490
490
|
if (!row || !row.isConnected) return;
|
|
491
|
-
|
|
491
|
+
const options = {
|
|
492
492
|
behavior: this._reduceMotion ? 'auto' : 'smooth',
|
|
493
493
|
block: 'center'
|
|
494
|
-
}
|
|
494
|
+
};
|
|
495
|
+
// Lazy bodies between the viewport and the stop render as the scroll
|
|
496
|
+
// passes them, shifting layout so a plain scrollIntoView lands off
|
|
497
|
+
// target. The stable variant re-corrects once the scroll settles.
|
|
498
|
+
// Fire-and-forget: it bails on its own if the row unmounts (tour exit)
|
|
499
|
+
// or the user scrolls.
|
|
500
|
+
if (window.ScrollUtils?.scrollIntoViewStable) {
|
|
501
|
+
window.ScrollUtils.scrollIntoViewStable(row, options);
|
|
502
|
+
} else {
|
|
503
|
+
row.scrollIntoView(options);
|
|
504
|
+
}
|
|
495
505
|
}
|
|
496
506
|
|
|
497
507
|
/**
|
package/public/js/pr.js
CHANGED
|
@@ -402,6 +402,22 @@ class PRManager {
|
|
|
402
402
|
}
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Keep --diff-file-header-height in sync with the rendered sticky file
|
|
407
|
+
* header so navigation (block:'start' + scroll-margin-top in pr.css) lands
|
|
408
|
+
* targets just below the header rather than hidden behind it. Headers are
|
|
409
|
+
* single-line and uniform, so measuring the first one is representative.
|
|
410
|
+
* Call after renderDiff appends the headers.
|
|
411
|
+
*/
|
|
412
|
+
_measureFileHeaderHeight() {
|
|
413
|
+
const header = document.querySelector('.d2h-file-wrapper .d2h-file-header');
|
|
414
|
+
if (header && header.offsetHeight) {
|
|
415
|
+
document.documentElement.style.setProperty(
|
|
416
|
+
'--diff-file-header-height', header.offsetHeight + 'px'
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
405
421
|
/**
|
|
406
422
|
* Set up event handlers
|
|
407
423
|
*/
|
|
@@ -1674,8 +1690,12 @@ class PRManager {
|
|
|
1674
1690
|
label = 'Exit guided tour';
|
|
1675
1691
|
} else if (this._tourStops && this._tourStops.length > 0) {
|
|
1676
1692
|
label = 'Start guided tour';
|
|
1693
|
+
} else if (this._toursAutoGenerate === false) {
|
|
1694
|
+
// No stops yet and auto-generation is off: a click kicks off manual
|
|
1695
|
+
// generation (see startOrToggleTour), so the verb is "Generate".
|
|
1696
|
+
label = 'Generate guided tour';
|
|
1677
1697
|
} else {
|
|
1678
|
-
label = '
|
|
1698
|
+
label = 'Guided tour (none available yet)';
|
|
1679
1699
|
}
|
|
1680
1700
|
btn.title = label;
|
|
1681
1701
|
btn.setAttribute('aria-label', label);
|
|
@@ -3323,6 +3343,10 @@ class PRManager {
|
|
|
3323
3343
|
|
|
3324
3344
|
// NOTE: end-of-file gap validation runs per-file inside _renderFileBodyNow
|
|
3325
3345
|
// now (bodies render lazily), not once globally here.
|
|
3346
|
+
|
|
3347
|
+
// Measure the now-rendered sticky file header so navigation can offset
|
|
3348
|
+
// targets below it (scroll-margin-top in pr.css).
|
|
3349
|
+
this._measureFileHeaderHeight();
|
|
3326
3350
|
} else {
|
|
3327
3351
|
diffContainer.innerHTML = '<div class="no-diff">No files changed</div>';
|
|
3328
3352
|
}
|
|
@@ -6383,7 +6407,16 @@ class PRManager {
|
|
|
6383
6407
|
if (!fileWrapper.classList.contains('collapsed')) {
|
|
6384
6408
|
await this.ensureFileBodyRendered(filePath);
|
|
6385
6409
|
}
|
|
6386
|
-
|
|
6410
|
+
// Stable variant: lazy bodies between here and the target render as
|
|
6411
|
+
// the smooth scroll passes them, shifting layout mid-flight. The
|
|
6412
|
+
// helper re-corrects after the scroll settles so the first attempt
|
|
6413
|
+
// lands where the second used to.
|
|
6414
|
+
const scrollOptions = { behavior: 'smooth', block: 'start' };
|
|
6415
|
+
if (window.ScrollUtils?.scrollIntoViewStable) {
|
|
6416
|
+
await window.ScrollUtils.scrollIntoViewStable(fileWrapper, scrollOptions);
|
|
6417
|
+
} else {
|
|
6418
|
+
fileWrapper.scrollIntoView(scrollOptions);
|
|
6419
|
+
}
|
|
6387
6420
|
}
|
|
6388
6421
|
}
|
|
6389
6422
|
|
|
@@ -7750,7 +7783,7 @@ class PRManager {
|
|
|
7750
7783
|
* @param {string} file - File path
|
|
7751
7784
|
* @param {number} [lineStart] - Optional line number to highlight
|
|
7752
7785
|
*/
|
|
7753
|
-
scrollToContextFile(file, lineStart, contextId) {
|
|
7786
|
+
async scrollToContextFile(file, lineStart, contextId) {
|
|
7754
7787
|
// Use contextId to find a specific chunk tbody within a merged wrapper,
|
|
7755
7788
|
// or fall back to a standalone wrapper or the file-level wrapper.
|
|
7756
7789
|
let target;
|
|
@@ -7769,23 +7802,30 @@ class PRManager {
|
|
|
7769
7802
|
}
|
|
7770
7803
|
if (!target) return;
|
|
7771
7804
|
|
|
7772
|
-
|
|
7805
|
+
// Stable variant ensures the target's lazy body is rendered and
|
|
7806
|
+
// re-corrects after lazy renders along the scroll path shift layout.
|
|
7807
|
+
const scrollOptions = { behavior: 'smooth', block: 'start' };
|
|
7808
|
+
if (window.ScrollUtils?.scrollIntoViewStable) {
|
|
7809
|
+
await window.ScrollUtils.scrollIntoViewStable(target, scrollOptions);
|
|
7810
|
+
} else {
|
|
7811
|
+
target.scrollIntoView(scrollOptions);
|
|
7812
|
+
}
|
|
7773
7813
|
|
|
7774
7814
|
if (lineStart) {
|
|
7775
7815
|
// Search for the line row within the wrapper (not just the target chunk)
|
|
7776
7816
|
const wrapper = target.closest('.d2h-file-wrapper') || target;
|
|
7777
|
-
//
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7817
|
+
// The awaited stable scroll has already settled (and rendered the lazy
|
|
7818
|
+
// body), so the row exists now — highlight it immediately rather than
|
|
7819
|
+
// pulsing on a stale timer that would fire after the scroll completes.
|
|
7820
|
+
const row = wrapper.querySelector(`tr[data-line-number="${lineStart}"]`);
|
|
7821
|
+
if (row) {
|
|
7822
|
+
row.classList.remove('chat-line-highlight');
|
|
7823
|
+
void row.offsetWidth;
|
|
7824
|
+
row.classList.add('chat-line-highlight');
|
|
7825
|
+
row.addEventListener('animationend', () => {
|
|
7781
7826
|
row.classList.remove('chat-line-highlight');
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
row.addEventListener('animationend', () => {
|
|
7785
|
-
row.classList.remove('chat-line-highlight');
|
|
7786
|
-
}, { once: true });
|
|
7787
|
-
}
|
|
7788
|
-
}, 400);
|
|
7827
|
+
}, { once: true });
|
|
7828
|
+
}
|
|
7789
7829
|
}
|
|
7790
7830
|
}
|
|
7791
7831
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Stable scroll-into-view for the lazily rendered diff panel.
|
|
4
|
+
*
|
|
5
|
+
* Since the large-PR perf fix, expanded file bodies start as empty
|
|
6
|
+
* placeholders (`minHeight` ≈ patch lines × APPROX_DIFF_LINE_PX) and only
|
|
7
|
+
* render real rows when an IntersectionObserver sees them near the viewport.
|
|
8
|
+
* A plain `scrollIntoView()` therefore lands wrong on the first attempt:
|
|
9
|
+
* the browser computes the destination from placeholder heights, then the
|
|
10
|
+
* bodies passed during the scroll render and change height, shifting the
|
|
11
|
+
* target away from where the animation ends. A second attempt "works"
|
|
12
|
+
* because everything along the path has rendered by then.
|
|
13
|
+
*
|
|
14
|
+
* `scrollIntoViewStable()` fixes this by:
|
|
15
|
+
* 1. Rendering the target's own file body first (rows inside a lazy body
|
|
16
|
+
* don't exist until rendered, and its placeholder height is wrong).
|
|
17
|
+
* 2. Issuing the caller's scroll (smooth behavior preserved).
|
|
18
|
+
* 3. Waiting for the viewport-relative position of the target to stop
|
|
19
|
+
* moving (scroll animation done AND observer-triggered renders settled),
|
|
20
|
+
* then re-issuing an instant scroll. If that correction moved the
|
|
21
|
+
* target, newly revealed placeholders rendered and shifted layout
|
|
22
|
+
* again — so settle and correct again, up to MAX_CORRECTIONS times.
|
|
23
|
+
*
|
|
24
|
+
* The settle loop aborts if the user starts scrolling themselves (wheel /
|
|
25
|
+
* touch / scroll-intent keys) so corrections never fight real input, and
|
|
26
|
+
* whenever the target leaves the DOM (file list re-render, tour unmount).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Corrective re-scroll attempts after the initial scroll. */
|
|
30
|
+
const MAX_CORRECTIONS = 4;
|
|
31
|
+
/** Position delta (px) treated as "didn't move". */
|
|
32
|
+
const STABLE_PX = 2;
|
|
33
|
+
/** Consecutive same-position frames before the target counts as settled. */
|
|
34
|
+
const SETTLE_FRAMES = 3;
|
|
35
|
+
/** Hard cap on one settle wait — covers the longest smooth animation. */
|
|
36
|
+
const SETTLE_TIMEOUT_MS = 2000;
|
|
37
|
+
|
|
38
|
+
/** Keys that express scroll intent and should cancel pending corrections. */
|
|
39
|
+
const SCROLL_KEYS = new Set([
|
|
40
|
+
'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Latest-scroll-wins token. Every call captures `++activeGeneration`; any
|
|
45
|
+
* call whose captured value no longer matches has been superseded by a newer
|
|
46
|
+
* scroll and must bow out so stale settle loops don't fight or snap the
|
|
47
|
+
* viewport back to an old target.
|
|
48
|
+
*/
|
|
49
|
+
let activeGeneration = 0;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Wait until `target`'s viewport-relative top is unchanged for
|
|
53
|
+
* SETTLE_FRAMES consecutive animation frames (or SETTLE_TIMEOUT_MS passes).
|
|
54
|
+
* Resolves early when the target is disconnected or `isCancelled()` trips.
|
|
55
|
+
* @param {Element} target
|
|
56
|
+
* @param {() => boolean} isCancelled
|
|
57
|
+
* @returns {Promise<void>}
|
|
58
|
+
*/
|
|
59
|
+
function waitForStablePosition(target, isCancelled) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const start = Date.now();
|
|
62
|
+
let lastTop = null;
|
|
63
|
+
let stableFrames = 0;
|
|
64
|
+
const tick = () => {
|
|
65
|
+
if (isCancelled() || !target.isConnected || Date.now() - start > SETTLE_TIMEOUT_MS) {
|
|
66
|
+
resolve();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const top = target.getBoundingClientRect().top;
|
|
70
|
+
if (lastTop !== null && Math.abs(top - lastTop) <= STABLE_PX) {
|
|
71
|
+
stableFrames += 1;
|
|
72
|
+
if (stableFrames >= SETTLE_FRAMES) {
|
|
73
|
+
resolve();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
stableFrames = 0;
|
|
78
|
+
}
|
|
79
|
+
lastTop = top;
|
|
80
|
+
requestAnimationFrame(tick);
|
|
81
|
+
};
|
|
82
|
+
requestAnimationFrame(tick);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Scroll `target` into view and keep it there while lazy file bodies render.
|
|
88
|
+
* Safe to call on any element; outside a lazy diff it degrades to one
|
|
89
|
+
* `scrollIntoView` plus a settle wait. Fire-and-forget friendly.
|
|
90
|
+
* @param {Element} target - Element to bring into view
|
|
91
|
+
* @param {ScrollIntoViewOptions} [options] - Passed to the initial scroll;
|
|
92
|
+
* corrective re-scrolls force `behavior: 'auto'`.
|
|
93
|
+
* @returns {Promise<void>} resolves once the position is stable (or aborted)
|
|
94
|
+
*/
|
|
95
|
+
async function scrollIntoViewStable(target, options = {}) {
|
|
96
|
+
if (!target || !target.isConnected || typeof target.scrollIntoView !== 'function') return;
|
|
97
|
+
|
|
98
|
+
// Claim the active-scroll slot. A later call bumps activeGeneration past
|
|
99
|
+
// ours, at which point isCancelled() trips and this call bows out.
|
|
100
|
+
const myGen = ++activeGeneration;
|
|
101
|
+
|
|
102
|
+
// Render the target's own lazy body first: until then its rows don't
|
|
103
|
+
// exist and the wrapper's height is the placeholder estimate. Skip
|
|
104
|
+
// collapsed wrappers — their body is display:none (zero height), so
|
|
105
|
+
// rendering would pay full renderPatch cost without changing the scroll.
|
|
106
|
+
// Skip file-level comment/suggestion cards too: they live in
|
|
107
|
+
// `.file-comments-zone`, which sits above the lazy body, so rendering the
|
|
108
|
+
// body can't move them — only burn the renderPatch cost we want to avoid.
|
|
109
|
+
const prManager = (typeof window !== 'undefined') ? window.prManager : null;
|
|
110
|
+
const wrapper = target.closest?.('.d2h-file-wrapper');
|
|
111
|
+
if (wrapper && !target.closest?.('.file-comments-zone')
|
|
112
|
+
&& !wrapper.classList.contains('collapsed')
|
|
113
|
+
&& typeof prManager?.ensureFileBodyRendered === 'function') {
|
|
114
|
+
try {
|
|
115
|
+
await prManager.ensureFileBodyRendered(wrapper);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.warn('[ScrollUtils] ensureFileBodyRendered failed; scrolling anyway', err);
|
|
118
|
+
}
|
|
119
|
+
if (!target.isConnected || myGen !== activeGeneration) return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Cancel corrections when the user scrolls on their own OR when a newer
|
|
123
|
+
// scrollIntoViewStable call supersedes this one (latest-scroll-wins).
|
|
124
|
+
let cancelled = false;
|
|
125
|
+
const isCancelled = () => cancelled || myGen !== activeGeneration;
|
|
126
|
+
const cancel = () => { cancelled = true; };
|
|
127
|
+
const onKeyDown = (e) => {
|
|
128
|
+
// Scroll-intent keys are also everyday caret/typing keys inside form
|
|
129
|
+
// fields — there they mean "move the cursor", not "scroll the page", so
|
|
130
|
+
// they must not abort the correction loop.
|
|
131
|
+
if (e.target?.closest?.('input, textarea, select') || e.target?.isContentEditable) return;
|
|
132
|
+
if (SCROLL_KEYS.has(e.key)) cancelled = true;
|
|
133
|
+
};
|
|
134
|
+
window.addEventListener('wheel', cancel, { capture: true, passive: true });
|
|
135
|
+
window.addEventListener('touchstart', cancel, { capture: true, passive: true });
|
|
136
|
+
window.addEventListener('keydown', onKeyDown, { capture: true });
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
target.scrollIntoView(options);
|
|
140
|
+
for (let i = 0; i < MAX_CORRECTIONS; i++) {
|
|
141
|
+
await waitForStablePosition(target, isCancelled);
|
|
142
|
+
if (isCancelled() || !target.isConnected) return;
|
|
143
|
+
// Re-issue instantly: a no-op when the smooth scroll landed true, a
|
|
144
|
+
// snap to the real position when lazy renders shifted the layout.
|
|
145
|
+
const before = target.getBoundingClientRect().top;
|
|
146
|
+
target.scrollIntoView({ ...options, behavior: 'auto' });
|
|
147
|
+
if (Math.abs(target.getBoundingClientRect().top - before) <= STABLE_PX) return;
|
|
148
|
+
// The correction moved us — newly revealed bodies may render and
|
|
149
|
+
// shift layout once more; loop to settle and verify again.
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
window.removeEventListener('wheel', cancel, { capture: true });
|
|
153
|
+
window.removeEventListener('touchstart', cancel, { capture: true });
|
|
154
|
+
window.removeEventListener('keydown', onKeyDown, { capture: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (typeof window !== 'undefined') {
|
|
159
|
+
window.ScrollUtils = { scrollIntoViewStable, waitForStablePosition };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
163
|
+
module.exports = { scrollIntoViewStable, waitForStablePosition, MAX_CORRECTIONS, STABLE_PX, SETTLE_FRAMES, SETTLE_TIMEOUT_MS };
|
|
164
|
+
}
|
package/public/local.html
CHANGED
|
@@ -614,6 +614,9 @@
|
|
|
614
614
|
<!-- Modal detection (shared by KeyboardShortcuts and PRManager) -->
|
|
615
615
|
<script src="/js/utils/modal-detection.js"></script>
|
|
616
616
|
|
|
617
|
+
<!-- Stable scroll-into-view for lazily rendered diff bodies -->
|
|
618
|
+
<script src="/js/utils/scroll-into-view.js"></script>
|
|
619
|
+
|
|
617
620
|
<!-- WebSocket client -->
|
|
618
621
|
<script src="/js/ws-client.js"></script>
|
|
619
622
|
|
package/public/pr.html
CHANGED
|
@@ -417,6 +417,9 @@
|
|
|
417
417
|
<!-- Modal detection (shared by KeyboardShortcuts and PRManager) -->
|
|
418
418
|
<script src="/js/utils/modal-detection.js"></script>
|
|
419
419
|
|
|
420
|
+
<!-- Stable scroll-into-view for lazily rendered diff bodies -->
|
|
421
|
+
<script src="/js/utils/scroll-into-view.js"></script>
|
|
422
|
+
|
|
420
423
|
<!-- WebSocket client -->
|
|
421
424
|
<script src="/js/ws-client.js"></script>
|
|
422
425
|
|
|
@@ -26,12 +26,40 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
|
26
26
|
* in the constructor's base args; individual models can override this via extra_args
|
|
27
27
|
* (e.g., Haiku uses adaptive thinking for efficiency).
|
|
28
28
|
*
|
|
29
|
-
* Effort support by model (newest CLIs): Opus 4.8 / 4.7 support
|
|
30
|
-
* xhigh|max; Opus 4.6 & Sonnet 4.6 support low|medium|high|max
|
|
31
|
-
* has no effort levels.
|
|
29
|
+
* Effort support by model (newest CLIs): Fable 5 and Opus 4.8 / 4.7 support
|
|
30
|
+
* low|medium|high|xhigh|max; Opus 4.6 & Sonnet 4.6 support low|medium|high|max
|
|
31
|
+
* (no xhigh); Haiku has no effort levels.
|
|
32
32
|
*/
|
|
33
33
|
const CLAUDE_MODELS = [
|
|
34
34
|
// ── Thorough tier ───────────────────────────────────────────────────────
|
|
35
|
+
{
|
|
36
|
+
id: 'fable',
|
|
37
|
+
aliases: ['fable-5-xhigh'],
|
|
38
|
+
cli_model: 'claude-fable-5',
|
|
39
|
+
env: { CLAUDE_CODE_EFFORT_LEVEL: 'xhigh' },
|
|
40
|
+
name: 'Fable 5 XHigh',
|
|
41
|
+
tier: 'thorough',
|
|
42
|
+
tagline: 'New Model Tier',
|
|
43
|
+
description: 'Fable 5 (new tier above Opus) with extra-high effort',
|
|
44
|
+
badge: 'Extra-High Effort',
|
|
45
|
+
badgeClass: 'badge-power',
|
|
46
|
+
// Fable 5 is adaptive-thinking-only: an explicit "enabled"/"disabled"
|
|
47
|
+
// thinking mode is rejected by the API, so override the global
|
|
48
|
+
// `--thinking enabled` base arg (last occurrence wins in commander).
|
|
49
|
+
extra_args: ['--thinking', 'adaptive']
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'fable-5-high',
|
|
53
|
+
cli_model: 'claude-fable-5',
|
|
54
|
+
env: { CLAUDE_CODE_EFFORT_LEVEL: 'high' },
|
|
55
|
+
name: 'Fable 5 High',
|
|
56
|
+
tier: 'thorough',
|
|
57
|
+
tagline: 'New Model Tier',
|
|
58
|
+
description: 'Fable 5 with high effort — quicker than XHigh',
|
|
59
|
+
badge: 'High Effort',
|
|
60
|
+
badgeClass: 'badge-power',
|
|
61
|
+
extra_args: ['--thinking', 'adaptive']
|
|
62
|
+
},
|
|
35
63
|
{
|
|
36
64
|
id: 'opus',
|
|
37
65
|
aliases: ['opus-4.7-xhigh'],
|
package/src/main.js
CHANGED
|
@@ -159,8 +159,9 @@ OPTIONS:
|
|
|
159
159
|
The web UI also starts for the human reviewer.
|
|
160
160
|
--model <name> Override the AI model. Claude Code is the default provider.
|
|
161
161
|
Available models: opus, sonnet, haiku (Claude Code);
|
|
162
|
-
also:
|
|
163
|
-
opus-4.
|
|
162
|
+
also: fable-5-xhigh, fable-5-high, opus-4.8-xhigh,
|
|
163
|
+
opus-4.8-high, opus-4.7-xhigh, opus-4.7-high,
|
|
164
|
+
opus-4.6-high, opus-4.6-1m, sonnet-4.6
|
|
164
165
|
(opus is Opus 4.7 XHigh, the default)
|
|
165
166
|
or use provider-specific models with Gemini/Codex
|
|
166
167
|
--use-checkout Use current directory instead of creating worktree
|
|
@@ -19,7 +19,7 @@ const { normalizeRepository } = require('../utils/paths');
|
|
|
19
19
|
const { mergeInstructions } = require('../utils/instructions');
|
|
20
20
|
const { GitWorktreeManager } = require('../git/worktree');
|
|
21
21
|
const { GitHubClient } = require('../github/client');
|
|
22
|
-
const { getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
|
|
22
|
+
const { getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR, resolveRepoOptions, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
|
|
23
23
|
const { setupStackPR } = require('../setup/stack-setup');
|
|
24
24
|
const Analyzer = require('../ai/analyzer');
|
|
25
25
|
const { getProviderClass, createProvider } = require('../ai/provider');
|
|
@@ -166,6 +166,7 @@ const defaults = {
|
|
|
166
166
|
getGitHubToken,
|
|
167
167
|
resolveHostBinding,
|
|
168
168
|
resolveBindingRepositoryFromPR,
|
|
169
|
+
resolveRepoOptions,
|
|
169
170
|
setupStackPR,
|
|
170
171
|
Analyzer,
|
|
171
172
|
getProviderClass,
|
|
@@ -205,8 +206,23 @@ async function executeStackAnalysis(params) {
|
|
|
205
206
|
if (!state) return;
|
|
206
207
|
|
|
207
208
|
try {
|
|
209
|
+
// 0. Resolve the config-binding key once. It drives both the per-repo
|
|
210
|
+
// worktree config (just below) and the host binding (step 3). For
|
|
211
|
+
// monorepo-style `url_pattern` configs this differs from the PR
|
|
212
|
+
// identity `${owner}/${repo}`.
|
|
213
|
+
const bindingRepository = deps.resolveBindingRepositoryFromPR(owner, repo, config);
|
|
214
|
+
// Honor the repo's configured worktree options so stack worktrees match the
|
|
215
|
+
// non-stack path. This carries through:
|
|
216
|
+
// - worktreeConfig (worktree_name_template / worktree_directory) so they
|
|
217
|
+
// don't fall back to pair-review's default naming and location, and
|
|
218
|
+
// - the checkout script + timeout and sparse-checkout inheritance used
|
|
219
|
+
// during per-PR worktree creation (forwarded to createWorktreeForPR).
|
|
220
|
+
// These derive purely from file config, so DB repo_settings (pool config)
|
|
221
|
+
// are not needed here.
|
|
222
|
+
const { worktreeConfig, checkoutScript, checkoutTimeout } = deps.resolveRepoOptions(config, bindingRepository);
|
|
223
|
+
|
|
208
224
|
// 1. Resolve repositoryPath from trigger worktree
|
|
209
|
-
const worktreeManager = new deps.GitWorktreeManager(db);
|
|
225
|
+
const worktreeManager = new deps.GitWorktreeManager(db, worktreeConfig || {});
|
|
210
226
|
let repositoryPath;
|
|
211
227
|
try {
|
|
212
228
|
const owningRepoGit = await worktreeManager.resolveOwningRepo(triggerWorktreePath);
|
|
@@ -230,13 +246,13 @@ async function executeStackAnalysis(params) {
|
|
|
230
246
|
logger.warn(`Bulk git fetch failed, will fetch per-PR: ${fetchError.message}`);
|
|
231
247
|
}
|
|
232
248
|
|
|
233
|
-
// 3. Fetch all PR data from GitHub in parallel
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
249
|
+
// 3. Fetch all PR data from GitHub in parallel.
|
|
250
|
+
// `bindingRepository` (resolved in step 0) is the config-binding key. We use
|
|
251
|
+
// it — not the PR identity `${owner}/${repo}` — for host-binding lookups so
|
|
252
|
+
// alt-host stack analyses target the right host. The two differ for
|
|
253
|
+
// monorepo-style `url_pattern` configs (one `repos[...]` entry serves many
|
|
254
|
+
// captured owner/repo pairs). The PR identity is still used for DB rows and
|
|
255
|
+
// worktree identity.
|
|
240
256
|
const stackBinding = deps.resolveHostBinding(bindingRepository, config);
|
|
241
257
|
const githubToken = stackBinding.token;
|
|
242
258
|
const prDataMap = new Map();
|
|
@@ -275,7 +291,16 @@ async function executeStackAnalysis(params) {
|
|
|
275
291
|
|
|
276
292
|
const prInfo = { owner, repo, number: prNum };
|
|
277
293
|
const { path: perPRWorktreePath } = await worktreeManager.createWorktreeForPR(
|
|
278
|
-
prInfo, prData, repositoryPath
|
|
294
|
+
prInfo, prData, repositoryPath,
|
|
295
|
+
{
|
|
296
|
+
checkoutScript,
|
|
297
|
+
checkoutTimeout,
|
|
298
|
+
// No checkout script → inherit the trigger worktree's sparse-checkout
|
|
299
|
+
// layout instead of a full checkout from the repo root. When a script is
|
|
300
|
+
// configured it sets up sparse-checkout itself, so worktreeSourcePath is
|
|
301
|
+
// unused (and omitted) in that case.
|
|
302
|
+
...(checkoutScript ? {} : { worktreeSourcePath: triggerWorktreePath }),
|
|
303
|
+
}
|
|
279
304
|
);
|
|
280
305
|
worktreePathMap.set(prNum, perPRWorktreePath);
|
|
281
306
|
} catch (wtError) {
|
|
@@ -307,6 +332,7 @@ async function executeStackAnalysis(params) {
|
|
|
307
332
|
worktreePath: worktreePathMap.get(prNum),
|
|
308
333
|
analysisConfig, stackAnalysisId, state,
|
|
309
334
|
githubToken, binding: stackBinding, prData: prDataMap.get(prNum),
|
|
335
|
+
worktreeConfig, checkoutScript,
|
|
310
336
|
onAnalysisIdReady
|
|
311
337
|
}).then(result => {
|
|
312
338
|
state.prStatuses.set(prNum, {
|
|
@@ -346,6 +372,7 @@ async function executeStackAnalysis(params) {
|
|
|
346
372
|
async function analyzeStackPR(deps, db, config, {
|
|
347
373
|
owner, repo, repository, bindingRepository, prNum, worktreePath,
|
|
348
374
|
analysisConfig, stackAnalysisId, state, githubToken, binding, prData,
|
|
375
|
+
worktreeConfig, checkoutScript,
|
|
349
376
|
onAnalysisIdReady
|
|
350
377
|
}) {
|
|
351
378
|
// Build a GitHubClient for analyzer-side dedup pre-fetch. The stack
|
|
@@ -357,12 +384,17 @@ async function analyzeStackPR(deps, db, config, {
|
|
|
357
384
|
if (stackGithubClient) {
|
|
358
385
|
logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNum} (stack)`);
|
|
359
386
|
}
|
|
360
|
-
// 1. Setup PR (generates diff, stores metadata)
|
|
361
|
-
|
|
387
|
+
// 1. Setup PR (expands sparse-checkout, generates diff, stores metadata)
|
|
388
|
+
// Construct with the repo's resolved worktreeConfig for consistency with the
|
|
389
|
+
// creation manager. setupStackPR operates against the explicit worktreePath
|
|
390
|
+
// (diff generation + sparse-cone expansion), so the worktreeConfig naming /
|
|
391
|
+
// directory options are not exercised today — threading them through guards
|
|
392
|
+
// against silent latent regressions if that changes.
|
|
393
|
+
const worktreeManager = new deps.GitWorktreeManager(db, worktreeConfig || {});
|
|
362
394
|
await deps.setupStackPR({
|
|
363
395
|
db, owner, repo, prNumber: prNum,
|
|
364
396
|
githubToken, binding, bindingRepository,
|
|
365
|
-
worktreePath, worktreeManager, prData
|
|
397
|
+
worktreePath, worktreeManager, prData, checkoutScript
|
|
366
398
|
});
|
|
367
399
|
|
|
368
400
|
// 2. Fetch prMetadata from DB
|
package/src/setup/stack-setup.js
CHANGED
|
@@ -32,9 +32,12 @@ const logger = require('../utils/logger');
|
|
|
32
32
|
* @param {string} params.worktreePath - Path to the per-PR worktree
|
|
33
33
|
* @param {import('../git/worktree').GitWorktreeManager} params.worktreeManager - Worktree manager instance
|
|
34
34
|
* @param {Object} [params.prData] - Pre-fetched PR data from GitHub (skips API call when provided)
|
|
35
|
+
* @param {string|null} [params.checkoutScript] - Repo's configured checkout script, if any. When set,
|
|
36
|
+
* the script owns all sparse-checkout setup, so built-in sparse-cone expansion is skipped (mirrors
|
|
37
|
+
* the non-stack `pr-setup.js` contract).
|
|
35
38
|
* @returns {Promise<{ reviewId: number, prMetadata: Object, prData: Object, isNew: boolean }>}
|
|
36
39
|
*/
|
|
37
|
-
async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, bindingRepository, worktreePath, worktreeManager, prData: prefetchedPRData }) {
|
|
40
|
+
async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, bindingRepository, worktreePath, worktreeManager, prData: prefetchedPRData, checkoutScript }) {
|
|
38
41
|
// `bindingRepository` is accepted so callers (e.g. `executeStackAnalysis`)
|
|
39
42
|
// can thread the resolved config-binding key through to any downstream
|
|
40
43
|
// per-repo lookups added in this function. Currently unused inside this
|
|
@@ -56,13 +59,38 @@ async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, b
|
|
|
56
59
|
const prFiles = await githubClient.fetchPullRequestFiles(owner, repo, prNumber);
|
|
57
60
|
logger.info(`PR #${prNumber} has ${prFiles.length} changed files`);
|
|
58
61
|
|
|
59
|
-
// 3.
|
|
62
|
+
// 3. Expand sparse-checkout for PR-changed directories (mirrors pr-setup.js).
|
|
63
|
+
// Stack worktrees inherit the trigger worktree's sparse-checkout layout, which
|
|
64
|
+
// may omit directories a sibling PR touches. The SHA-based diff below reads
|
|
65
|
+
// commit objects (not the working tree) so it is unaffected, but the later
|
|
66
|
+
// file-context and codebase-context analysis steps DO read files from disk —
|
|
67
|
+
// an unexpanded cone would silently under-review those files. Expanding here
|
|
68
|
+
// ensures every PR-changed directory is present on disk.
|
|
69
|
+
//
|
|
70
|
+
// IMPORTANT: when a checkout_script is configured the script owns all
|
|
71
|
+
// sparse-checkout setup, so we must NOT auto-expand — doing so would override
|
|
72
|
+
// the cone the script just configured. This matches the pr-setup.js contract.
|
|
73
|
+
if (!checkoutScript && prFiles.length > 0) {
|
|
74
|
+
const isSparse = await worktreeManager.isSparseCheckoutEnabled(worktreePath);
|
|
75
|
+
if (isSparse) {
|
|
76
|
+
try {
|
|
77
|
+
const addedDirs = await worktreeManager.ensurePRDirectoriesInSparseCheckout(worktreePath, prFiles);
|
|
78
|
+
if (addedDirs.length > 0) {
|
|
79
|
+
logger.info(`Stack PR #${prNumber}: expanded sparse-checkout for: ${addedDirs.join(', ')}`);
|
|
80
|
+
}
|
|
81
|
+
} catch (sparseError) {
|
|
82
|
+
logger.warn(`Stack PR #${prNumber}: sparse-checkout expansion failed (non-fatal): ${sparseError.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 4. Generate diff in the worktree (SHA-based, works after checkout)
|
|
60
88
|
const diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
|
|
61
89
|
|
|
62
|
-
//
|
|
90
|
+
// 5. Get changed files with stats
|
|
63
91
|
const changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
|
|
64
92
|
|
|
65
|
-
//
|
|
93
|
+
// 6. Store via storePRData (creates/updates pr_metadata, reviews, worktrees records)
|
|
66
94
|
const prInfo = { owner, repo, number: prNumber };
|
|
67
95
|
const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath);
|
|
68
96
|
|