@in-the-loop-labs/pair-review 3.6.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +4 -0
  2. package/package.json +20 -15
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  6. package/public/css/analysis-config.css +1807 -0
  7. package/public/css/pr.css +17 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +89 -44
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ChatPanel.js +11 -1
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/SuggestionNavigator.js +55 -10
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +58 -8
  18. package/public/js/modules/suggestion-manager.js +25 -1
  19. package/public/js/modules/tour-renderer.js +45 -5
  20. package/public/js/pr.js +703 -171
  21. package/public/js/repo-links.js +328 -0
  22. package/public/js/utils/provider-model.js +88 -0
  23. package/public/js/utils/scroll-into-view.js +164 -0
  24. package/public/js/utils/storage-keys.js +50 -0
  25. package/public/local.html +10 -0
  26. package/public/pr.html +10 -0
  27. package/public/repo-settings.html +1 -0
  28. package/public/setup.html +2 -0
  29. package/src/ai/analyzer.js +125 -18
  30. package/src/ai/claude-provider.js +31 -3
  31. package/src/config.js +664 -10
  32. package/src/external/github-adapter.js +114 -25
  33. package/src/git/base-branch.js +11 -4
  34. package/src/github/client.js +482 -588
  35. package/src/github/errors.js +55 -0
  36. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  37. package/src/github/impl/graphql/pending-review.js +153 -0
  38. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  39. package/src/github/impl/graphql/stack-walker.js +210 -0
  40. package/src/github/impl/host/pending-review-comments.js +338 -0
  41. package/src/github/impl/rest/pending-review.js +251 -0
  42. package/src/github/impl/rest/review-lifecycle.js +226 -0
  43. package/src/github/impl/rest/stack-walker.js +309 -0
  44. package/src/github/operations/pending-review-comments.js +79 -0
  45. package/src/github/operations/pending-review.js +89 -0
  46. package/src/github/operations/review-lifecycle.js +126 -0
  47. package/src/github/operations/stack-walker.js +87 -0
  48. package/src/github/parser.js +230 -4
  49. package/src/github/stack-walker.js +14 -189
  50. package/src/links/repo-links.js +230 -0
  51. package/src/local-review.js +13 -4
  52. package/src/main.js +136 -32
  53. package/src/routes/analyses.js +30 -7
  54. package/src/routes/bulk-analysis-configs.js +295 -0
  55. package/src/routes/config.js +102 -2
  56. package/src/routes/external-comments.js +20 -10
  57. package/src/routes/github-collections.js +3 -1
  58. package/src/routes/local.js +101 -11
  59. package/src/routes/mcp.js +47 -4
  60. package/src/routes/pr.js +298 -68
  61. package/src/routes/setup.js +8 -3
  62. package/src/routes/stack-analysis.js +33 -9
  63. package/src/routes/worktrees.js +3 -2
  64. package/src/server.js +2 -0
  65. package/src/setup/pr-setup.js +37 -11
  66. package/src/setup/stack-setup.js +13 -3
  67. package/src/single-port.js +6 -3
@@ -64,9 +64,9 @@ class ReviewModal {
64
64
  </div>
65
65
  <div class="pending-draft-notice-content">
66
66
  <span class="pending-draft-notice-text">
67
- You have a pending draft review on GitHub with <strong id="pending-draft-count">0</strong> comments.
67
+ You have a pending draft review on <span class="rm-host-name">GitHub</span> with <strong id="pending-draft-count">0</strong> comments.
68
68
  Submitting here will add to or complete this review.
69
- <a href="#" id="pending-draft-link" target="_blank" rel="noopener noreferrer">Manage on GitHub</a>.
69
+ <a href="#" id="pending-draft-link" target="_blank" rel="noopener noreferrer">Manage on <span class="rm-host-name">GitHub</span></a>.
70
70
  </span>
71
71
  </div>
72
72
  </div>
@@ -120,7 +120,7 @@ class ReviewModal {
120
120
  <input type="radio" name="review-event" value="DRAFT">
121
121
  <div class="review-type-content">
122
122
  <span class="review-type-label">Save as Draft</span>
123
- <span class="review-type-desc">Save your review as a draft on GitHub to finish later.</span>
123
+ <span class="review-type-desc">Save your review as a draft on <span class="rm-host-name">GitHub</span> to finish later.</span>
124
124
  </div>
125
125
  </label>
126
126
  </div>
@@ -284,6 +284,11 @@ class ReviewModal {
284
284
  // Update AI summary link visibility
285
285
  this.updateAISummaryLink();
286
286
 
287
+ // Apply the configured remote-host display name + icon (resolves
288
+ // asynchronously after the modal HTML was built).
289
+ this.applyHostName();
290
+ this.applySubmitButtonIcon();
291
+
287
292
  // Update pending draft notice
288
293
  this.updatePendingDraftNotice();
289
294
  }
@@ -311,11 +316,17 @@ class ReviewModal {
311
316
  countElement.textContent = String(pendingDraft.comments_count || 0);
312
317
  }
313
318
 
314
- // Update the link - hide if no github_url
319
+ // Update the link. Prefer the URL built from the repo's configured
320
+ // url_template (host-correct) over the server-reported github_url,
321
+ // which some alt-hosts return as a wrong-host github.com/issues URL.
315
322
  const linkElement = notice.querySelector('#pending-draft-link');
316
323
  if (linkElement) {
317
- if (pendingDraft.github_url) {
318
- linkElement.href = pendingDraft.github_url;
324
+ const templatedUrl = (typeof window !== 'undefined' && window.RepoLinks
325
+ && typeof window.RepoLinks.externalUrl === 'function')
326
+ ? window.RepoLinks.externalUrl() : null;
327
+ const manageUrl = templatedUrl || pendingDraft.github_url;
328
+ if (manageUrl) {
329
+ linkElement.href = manageUrl;
319
330
  linkElement.style.display = 'inline';
320
331
  } else {
321
332
  linkElement.style.display = 'none';
@@ -452,6 +463,8 @@ class ReviewModal {
452
463
  submitBtn.disabled = false;
453
464
  cancelBtn.style.display = 'inline-block';
454
465
  closeBtn.style.display = 'inline-block';
466
+ // innerHTML reset drops any host icon — re-apply it.
467
+ this.applySubmitButtonIcon();
455
468
  }
456
469
  }
457
470
 
@@ -531,7 +544,7 @@ class ReviewModal {
531
544
  const reviewUrl = result.reviewUrl || result.github_url;
532
545
  if (isDraft) {
533
546
  window.toast.showSuccess(
534
- 'Draft review submitted to GitHub successfully!',
547
+ `Draft review submitted to ${ReviewModal.escapeHtml(ReviewModal.hostName())} successfully!`,
535
548
  {
536
549
  duration: 5000
537
550
  }
@@ -541,7 +554,7 @@ class ReviewModal {
541
554
  'Review submitted successfully!',
542
555
  {
543
556
  link: reviewUrl,
544
- linkText: 'View on GitHub',
557
+ linkText: `View on ${ReviewModal.escapeHtml(ReviewModal.hostName())}`,
545
558
  duration: 5000
546
559
  }
547
560
  );
@@ -586,11 +599,16 @@ class ReviewModal {
586
599
  }
587
600
 
588
601
  if (isDraft) {
589
- // After 2 seconds, open GitHub PR page for drafts
590
- setTimeout(() => {
591
- const githubUrl = result.github_url || `https://github.com/${pr.owner}/${pr.repo}/pull/${pr.number}`;
592
- window.open(githubUrl, '_blank');
593
- }, 2000);
602
+ // After 2 seconds, open the PR page for drafts. Use the PR's canonical
603
+ // html_url (correct host + `/pull/`) rather than the review's html_url,
604
+ // which some alt-hosts return as a github.com `/issues/<n>` URL. Never
605
+ // assume github.com — see resolveDraftPrUrl.
606
+ const prUrl = ReviewModal.resolveDraftPrUrl(pr, result);
607
+ if (prUrl) {
608
+ setTimeout(() => {
609
+ window.open(prUrl, '_blank');
610
+ }, 2000);
611
+ }
594
612
  }
595
613
 
596
614
  } catch (error) {
@@ -676,6 +694,110 @@ class ReviewModal {
676
694
  localStorage.setItem(ASSISTED_BY_STORAGE_KEY, String(checkbox.checked));
677
695
  }
678
696
 
697
+ /**
698
+ * Display name of the remote code host, for user-facing text in place of
699
+ * the literal "GitHub". Reads the configured `links.external.name` via
700
+ * `window.RepoLinks.hostName()`, falling back to "GitHub".
701
+ *
702
+ * @returns {string}
703
+ */
704
+ static hostName() {
705
+ if (typeof window !== 'undefined' && window.RepoLinks
706
+ && typeof window.RepoLinks.hostName === 'function') {
707
+ return window.RepoLinks.hostName();
708
+ }
709
+ return 'GitHub';
710
+ }
711
+
712
+ /**
713
+ * Escape a string for safe interpolation into HTML. Used for the host
714
+ * name (user-supplied config) before it goes into the success toast,
715
+ * which renders its message/linkText via innerHTML.
716
+ *
717
+ * @param {string} text
718
+ * @returns {string}
719
+ */
720
+ static escapeHtml(text) {
721
+ return String(text == null ? '' : text)
722
+ .replace(/&/g, '&amp;')
723
+ .replace(/</g, '&lt;')
724
+ .replace(/>/g, '&gt;')
725
+ .replace(/"/g, '&quot;')
726
+ .replace(/'/g, '&#39;');
727
+ }
728
+
729
+ /**
730
+ * Resolve the URL to open in a new tab after a draft submit.
731
+ *
732
+ * Precedence:
733
+ * 1. The URL built from the repo's configured `links.external.url_template`
734
+ * (`window.RepoLinks.externalUrl()`) — authoritative and host-correct.
735
+ * 2. The PR's canonical `html_url` (the host's own PR page).
736
+ * 3. The server-reported `github_url` as a last resort.
737
+ *
738
+ * Some alt-hosts return the pending-review `html_url` as a
739
+ * `github.com/.../issues/<n>` URL, which lands on the wrong host and page.
740
+ * We must never assume github.com, so there is no hardcoded fallback host:
741
+ * if none of the above yields a URL we open nothing.
742
+ *
743
+ * @param {{html_url?: string}|null|undefined} pr - current PR (from prManager)
744
+ * @param {{github_url?: string}|null|undefined} result - submit-review response
745
+ * @returns {string|null} URL to open, or null if none is available
746
+ */
747
+ static resolveDraftPrUrl(pr, result) {
748
+ if (typeof window !== 'undefined' && window.RepoLinks
749
+ && typeof window.RepoLinks.externalUrl === 'function') {
750
+ const templated = window.RepoLinks.externalUrl();
751
+ if (templated) return templated;
752
+ }
753
+ if (pr && pr.html_url) return pr.html_url;
754
+ if (result && result.github_url) return result.github_url;
755
+ return null;
756
+ }
757
+
758
+ /**
759
+ * Update host-name-dependent static text in the modal (the pending-draft
760
+ * notice and the "Save as Draft" description) to the configured host name.
761
+ * Called from `show()` because the name resolves asynchronously after the
762
+ * modal HTML is built. No-op when the modal isn't present.
763
+ */
764
+ applyHostName() {
765
+ if (!this.modal) return;
766
+ const name = ReviewModal.hostName();
767
+ const spans = this.modal.querySelectorAll('.rm-host-name');
768
+ spans.forEach((el) => { el.textContent = name; });
769
+ }
770
+
771
+ /**
772
+ * Prepend the configured external-host icon to the submit button, when an
773
+ * icon is configured for the repo. The icon is parsed via
774
+ * `window.RepoLinks.parseSvgIcon` (DOMParser + attribute stripping) and
775
+ * inserted as a DOM node — never via innerHTML. Idempotent: any previously
776
+ * inserted icon is removed first. No-op for plain github.com repos.
777
+ */
778
+ applySubmitButtonIcon() {
779
+ const submitBtn = this.modal?.querySelector('#submit-review-btn-modal');
780
+ if (!submitBtn) return;
781
+
782
+ const existing = submitBtn.querySelector?.('.submit-host-icon');
783
+ if (existing) existing.remove();
784
+
785
+ if (typeof window === 'undefined' || !window.RepoLinks
786
+ || typeof window.RepoLinks.externalIcon !== 'function'
787
+ || typeof window.RepoLinks.parseSvgIcon !== 'function') {
788
+ return;
789
+ }
790
+ const iconStr = window.RepoLinks.externalIcon();
791
+ if (!iconStr) return;
792
+ const svg = window.RepoLinks.parseSvgIcon(iconStr);
793
+ if (!svg) return;
794
+
795
+ svg.classList.add('submit-host-icon');
796
+ if (!svg.getAttribute('width')) svg.setAttribute('width', '16');
797
+ if (!svg.getAttribute('height')) svg.setAttribute('height', '16');
798
+ submitBtn.insertBefore(svg, submitBtn.firstChild);
799
+ }
800
+
679
801
  }
680
802
 
681
803
  // Initialize when DOM is ready if not already initialized
@@ -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
- const diffRow = minimizer.findDiffRowFor(suggestionEl);
378
- if (diffRow) {
379
- diffRow.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
380
- } else {
381
- suggestionEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
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
- suggestionEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
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.SuggestionNavigator = SuggestionNavigator;
516
+ if (typeof window !== 'undefined') {
517
+ window.SuggestionNavigator = SuggestionNavigator;
518
+ }
519
+
520
+ if (typeof module !== 'undefined' && module.exports) {
521
+ module.exports = SuggestionNavigator;
522
+ }
@@ -235,6 +235,37 @@ class VoiceCentricConfigTab {
235
235
  */
236
236
  setDefaultCouncilId(councilId) {
237
237
  this._pendingDefaultCouncilId = councilId;
238
+ // On a cached reopen the councils are already loaded, so loadCouncils() —
239
+ // and the _renderCouncilSelector() call that applies the pending default —
240
+ // will not run again (the modal instance is reused; see AnalysisConfigModal
241
+ // caching on window.analysisConfigModal). Apply it now so the saved/default
242
+ // council is restored instead of being silently dropped onto a blank
243
+ // "+ New Council" selection.
244
+ if (this._councilsLoaded && this._injected) {
245
+ this._renderCouncilSelector();
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Reset selection and editor state for a fresh modal open.
251
+ *
252
+ * The AnalysisConfigModal (and therefore this tab) is reused across runs — and
253
+ * in the index/bulk flow, across different repositories. Without this reset a
254
+ * council selected in a previous run (or its pending default / dirty edits)
255
+ * would carry over and could be displayed or submitted for the next batch.
256
+ */
257
+ reset() {
258
+ this.selectedCouncilId = null;
259
+ this._pendingDefaultCouncilId = null;
260
+ this._isDirty = false;
261
+ if (!this._injected) return;
262
+ const selector = this.modal.querySelector('#vc-council-selector');
263
+ if (selector) {
264
+ selector.value = '';
265
+ selector.classList.add('new-council-selected');
266
+ }
267
+ this._applyConfigToUI(this._defaultConfig());
268
+ this._markClean();
238
269
  }
239
270
 
240
271
  /**
@@ -1494,3 +1525,8 @@ class VoiceCentricConfigTab {
1494
1525
  if (typeof window !== 'undefined') {
1495
1526
  window.VoiceCentricConfigTab = VoiceCentricConfigTab;
1496
1527
  }
1528
+
1529
+ // Export for unit testing (Node/CommonJS environment)
1530
+ if (typeof module !== 'undefined' && module.exports) {
1531
+ module.exports = { VoiceCentricConfigTab };
1532
+ }
@@ -90,6 +90,8 @@
90
90
 
91
91
  /** localStorage key for persisting the active tab */
92
92
  const TAB_STORAGE_KEY = 'pair-review-active-tab';
93
+ const BULK_ANALYSIS_TAB_STORAGE_KEY = 'pair-review-bulk-analysis-tab';
94
+ const BULK_ANALYSIS_INSTRUCTIONS_STORAGE_KEY = 'pair-review-bulk-analysis-instructions';
93
95
 
94
96
  /**
95
97
  * Format a relative time string from a date
@@ -133,6 +135,10 @@
133
135
  return div.innerHTML;
134
136
  }
135
137
 
138
+ // encodeBase64Utf8 / getRepoStorageKey live in public/js/utils/storage-keys.js
139
+ // (window.encodeBase64Utf8 / window.getRepoStorageKey), shared with pr.js so the
140
+ // per-repo keys this page writes stay byte-identical to those the PR page reads.
141
+
136
142
  const LOCAL_REVIEW_PATH_URL_ERROR = 'Local reviews require a filesystem path, not a URL. Pass GitHub or Graphite URLs as PR review inputs instead.';
137
143
 
138
144
  function isUrlLikeLocalReviewPath(value) {
@@ -1377,6 +1383,9 @@
1377
1383
  window.__pairReview.chatProvider = config.chat_provider || 'pi';
1378
1384
  const chatProviders = config.chat_providers || [];
1379
1385
  window.__pairReview.chatProviders = chatProviders;
1386
+ window.__pairReview.defaultProvider = config.default_provider || 'claude';
1387
+ window.__pairReview.defaultModel = config.default_model || 'opus';
1388
+ window.__pairReview.hasGithubToken = Boolean(config.has_github_token);
1380
1389
  window.__pairReview.enableGraphite = config.enable_graphite === true;
1381
1390
  window.__pairReview.chatSpinner = config.chat_spinner || 'dots';
1382
1391
  window.__pairReview.chatEnterToSend = config.chat_enter_to_send !== false;
@@ -1404,6 +1413,7 @@
1404
1413
 
1405
1414
  /** Currently active SelectionMode instance (only one tab at a time) */
1406
1415
  var activeSelection = null;
1416
+ var bulkAnalysisConfigModal = null;
1407
1417
 
1408
1418
  /**
1409
1419
  * SelectionMode manages checkbox-based selection for a single tab's table.
@@ -1877,6 +1887,40 @@
1877
1887
  });
1878
1888
  }
1879
1889
 
1890
+ /**
1891
+ * Read selected collection rows as PR descriptors.
1892
+ * @param {Set} selectedIds - PR URLs (data-pr-url values)
1893
+ * @param {string} tbodyId - tbody element ID
1894
+ * @returns {Array<{owner: string, repo: string, number: string, prUrl: string}>}
1895
+ */
1896
+ function getSelectedCollectionRows(selectedIds, tbodyId) {
1897
+ var tbody = document.getElementById(tbodyId);
1898
+ if (!tbody) return [];
1899
+
1900
+ var rows = [];
1901
+ var trs = tbody.querySelectorAll('tr[data-pr-url]');
1902
+ for (var i = 0; i < trs.length; i++) {
1903
+ var tr = trs[i];
1904
+ var prUrl = tr.dataset.prUrl;
1905
+ if (!selectedIds.has(prUrl)) continue;
1906
+ if (tr.dataset.owner && tr.dataset.repo && tr.dataset.number) {
1907
+ rows.push({
1908
+ owner: tr.dataset.owner,
1909
+ repo: tr.dataset.repo,
1910
+ number: tr.dataset.number,
1911
+ prUrl: prUrl
1912
+ });
1913
+ }
1914
+ }
1915
+ return rows;
1916
+ }
1917
+
1918
+ function buildReviewUrlsFromRows(rows, query) {
1919
+ return rows.map(function (row) {
1920
+ return '/pr/' + encodeURIComponent(row.owner) + '/' + encodeURIComponent(row.repo) + '/' + row.number + (query || '');
1921
+ });
1922
+ }
1923
+
1880
1924
  /**
1881
1925
  * Build pair-review URLs from selected collection rows.
1882
1926
  * @param {Set} selectedIds - PR URLs (data-pr-url values)
@@ -1885,20 +1929,116 @@
1885
1929
  * @returns {string[]} array of pair-review URLs
1886
1930
  */
1887
1931
  function buildReviewUrls(selectedIds, tbodyId, query) {
1888
- var tbody = document.getElementById(tbodyId);
1889
- if (!tbody) return [];
1890
- var urls = [];
1891
- selectedIds.forEach(function (prUrl) {
1892
- var row = tbody.querySelector('tr[data-pr-url="' + CSS.escape(prUrl) + '"]');
1893
- if (!row) return;
1894
- var owner = row.dataset.owner;
1895
- var repo = row.dataset.repo;
1896
- var number = row.dataset.number;
1897
- if (owner && repo && number) {
1898
- urls.push('/pr/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/' + number + (query || ''));
1932
+ return buildReviewUrlsFromRows(getSelectedCollectionRows(selectedIds, tbodyId), query);
1933
+ }
1934
+
1935
+ function getCommonRepository(rows) {
1936
+ if (rows.length === 0) return null;
1937
+ var first = rows[0];
1938
+ var firstKey = (first.owner + '/' + first.repo).toLowerCase();
1939
+ for (var i = 1; i < rows.length; i++) {
1940
+ if ((rows[i].owner + '/' + rows[i].repo).toLowerCase() !== firstKey) {
1941
+ return null;
1899
1942
  }
1943
+ }
1944
+ return { owner: first.owner, repo: first.repo };
1945
+ }
1946
+
1947
+ async function fetchBulkRepoSettings(commonRepo) {
1948
+ if (!commonRepo) return null;
1949
+ try {
1950
+ var response = await fetch('/api/repos/' + encodeURIComponent(commonRepo.owner) + '/' + encodeURIComponent(commonRepo.repo) + '/settings');
1951
+ if (!response.ok) return null;
1952
+ return await response.json();
1953
+ } catch (error) {
1954
+ console.warn('Failed to fetch repo settings for bulk analysis:', error);
1955
+ return null;
1956
+ }
1957
+ }
1958
+
1959
+ function getBulkAnalysisStorageKeys(commonRepo) {
1960
+ if (!commonRepo) {
1961
+ return {
1962
+ tab: BULK_ANALYSIS_TAB_STORAGE_KEY,
1963
+ instructions: BULK_ANALYSIS_INSTRUCTIONS_STORAGE_KEY
1964
+ };
1965
+ }
1966
+ return {
1967
+ tab: window.getRepoStorageKey('pair-review-tab', commonRepo.owner, commonRepo.repo),
1968
+ instructions: window.getRepoStorageKey('pair-review-instructions', commonRepo.owner, commonRepo.repo)
1969
+ };
1970
+ }
1971
+
1972
+ // Cache the /api/providers metadata so the bulk modal can resolve a coherent
1973
+ // provider/model pair before showing (mirrors PRManager._getProvidersInfo).
1974
+ function getBulkProvidersInfo() {
1975
+ window.__pairReview = window.__pairReview || {};
1976
+ if (!window.__pairReview._providersInfoPromise) {
1977
+ window.__pairReview._providersInfoPromise = fetch('/api/providers')
1978
+ .then(function (r) { return r.ok ? r.json() : {}; })
1979
+ .then(function (d) { return Array.isArray(d.providers) ? d.providers : []; })
1980
+ .catch(function () { return []; });
1981
+ }
1982
+ return window.__pairReview._providersInfoPromise;
1983
+ }
1984
+
1985
+ async function showBulkAnalysisConfig(rows) {
1986
+ if (!window.AnalysisConfigModal) return null;
1987
+
1988
+ if (!bulkAnalysisConfigModal) {
1989
+ bulkAnalysisConfigModal = new window.AnalysisConfigModal();
1990
+ }
1991
+
1992
+ var commonRepo = getCommonRepository(rows);
1993
+ var storageKeys = getBulkAnalysisStorageKeys(commonRepo);
1994
+ var repoSettings = await fetchBulkRepoSettings(commonRepo);
1995
+ var rememberedTab = localStorage.getItem(storageKeys.tab);
1996
+ var lastInstructions = localStorage.getItem(storageKeys.instructions) || '';
1997
+
1998
+ bulkAnalysisConfigModal.onTabChange = function (tabId) {
1999
+ localStorage.setItem(storageKeys.tab, tabId);
2000
+ };
2001
+
2002
+ var providersInfo = await getBulkProvidersInfo();
2003
+ var resolvedPair = window.resolveProviderModelPair([
2004
+ { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
2005
+ { provider: window.__pairReview?.defaultProvider, model: window.__pairReview?.defaultModel }
2006
+ ], providersInfo);
2007
+
2008
+ var config = await bulkAnalysisConfigModal.show({
2009
+ currentModel: resolvedPair.model,
2010
+ currentProvider: resolvedPair.provider,
2011
+ defaultTab: rememberedTab || repoSettings?.default_tab || 'single',
2012
+ repoInstructions: commonRepo ? (repoSettings?.default_instructions || '') : '',
2013
+ lastInstructions: lastInstructions,
2014
+ defaultCouncilId: commonRepo ? (repoSettings?.default_council_id || null) : null,
2015
+ hasPr: true,
2016
+ hasGithubToken: window.__pairReview?.hasGithubToken !== false
2017
+ });
2018
+
2019
+ if (!config) return null;
2020
+
2021
+ var submittedInstructions = config.customInstructions || '';
2022
+ if (submittedInstructions) {
2023
+ localStorage.setItem(storageKeys.instructions, submittedInstructions);
2024
+ } else {
2025
+ localStorage.removeItem(storageKeys.instructions);
2026
+ }
2027
+
2028
+ return config;
2029
+ }
2030
+
2031
+ async function storeBulkAnalysisConfig(config) {
2032
+ var response = await fetch('/api/bulk-analysis-configs', {
2033
+ method: 'POST',
2034
+ headers: { 'Content-Type': 'application/json' },
2035
+ body: JSON.stringify({ analysisConfig: config })
1900
2036
  });
1901
- return urls;
2037
+ var data = await response.json().catch(function () { return {}; });
2038
+ if (!response.ok || !data.id) {
2039
+ throw new Error(data.error || 'Failed to save analysis settings');
2040
+ }
2041
+ return data.id;
1902
2042
  }
1903
2043
 
1904
2044
  /**
@@ -1924,10 +2064,29 @@
1924
2064
  bulkOpenUrls(urls);
1925
2065
  }
1926
2066
 
1927
- function handleBulkAnalyze(selectedIds, selectionInstance) {
1928
- var urls = buildReviewUrls(selectedIds, selectionInstance.config.tbodyId, '?analyze=true');
1929
- selectionInstance.exit();
1930
- bulkOpenUrls(urls);
2067
+ async function handleBulkAnalyze(selectedIds, selectionInstance) {
2068
+ var rows = getSelectedCollectionRows(selectedIds, selectionInstance.config.tbodyId);
2069
+ if (rows.length === 0) return;
2070
+
2071
+ if (!window.AnalysisConfigModal) {
2072
+ selectionInstance.exit();
2073
+ bulkOpenUrls(buildReviewUrlsFromRows(rows, '?analyze=true'));
2074
+ return;
2075
+ }
2076
+
2077
+ try {
2078
+ var config = await showBulkAnalysisConfig(rows);
2079
+ if (!config) return;
2080
+
2081
+ var configId = await storeBulkAnalysisConfig(config);
2082
+ var query = '?analyze=true&analysisConfigId=' + encodeURIComponent(configId);
2083
+ var urls = buildReviewUrlsFromRows(rows, query);
2084
+ selectionInstance.exit();
2085
+ bulkOpenUrls(urls);
2086
+ } catch (error) {
2087
+ console.error('Bulk analyze error:', error);
2088
+ if (window.toast) window.toast.error('Failed to start bulk analysis: ' + error.message);
2089
+ }
1931
2090
  }
1932
2091
 
1933
2092
  // ─── Event Delegation ───────────────────────────────────────────────────────