@in-the-loop-labs/pair-review 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- 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 +762 -6
- package/public/js/components/AIPanel.js +486 -42
- package/public/js/components/ChatPanel.js +2002 -528
- package/public/js/index.js +43 -0
- package/public/js/modules/comment-minimizer.js +66 -20
- package/public/js/modules/external-comment-manager.js +870 -0
- package/public/js/pr.js +297 -20
- package/public/local.html +21 -5
- package/public/pr.html +31 -5
- package/src/chat/chat-providers.js +64 -12
- package/src/config.js +21 -17
- package/src/database.js +580 -2
- package/src/external/github-adapter.js +152 -0
- package/src/external/index.js +37 -0
- package/src/git/fetch-helpers.js +29 -0
- package/src/git/worktree-pool-lifecycle.js +16 -5
- package/src/git/worktree.js +9 -8
- package/src/github/client.js +77 -1
- package/src/local-review.js +3 -0
- package/src/main.js +6 -3
- package/src/routes/config.js +27 -0
- package/src/routes/external-comments.js +394 -0
- package/src/routes/local.js +7 -0
- package/src/routes/setup.js +7 -0
- package/src/routes/stack-analysis.js +1 -1
- package/src/server.js +9 -0
- package/src/setup/local-setup.js +5 -1
- package/src/setup/pr-setup.js +7 -4
- package/src/single-port.js +2 -0
- package/src/utils/local-path-input.js +44 -0
package/public/js/pr.js
CHANGED
|
@@ -305,6 +305,21 @@ class PRManager {
|
|
|
305
305
|
refreshBtn.addEventListener('click', () => this.refreshPR());
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
// Refresh external (GitHub) review comments. Handler lives on the
|
|
309
|
+
// instance so unit tests can call it directly (avoids duplicating the
|
|
310
|
+
// production click logic in tests, per CLAUDE.md).
|
|
311
|
+
// Skip wiring entirely when the external_comments feature toggle is
|
|
312
|
+
// off — the button is also CSS-hidden via .external-comments-only, so
|
|
313
|
+
// this is belt-and-suspenders.
|
|
314
|
+
if (this._externalCommentsEnabled()) {
|
|
315
|
+
const refreshExternalBtnPanel = document.getElementById('refresh-external-comments-btn-panel');
|
|
316
|
+
if (refreshExternalBtnPanel) {
|
|
317
|
+
refreshExternalBtnPanel.addEventListener('click', () => {
|
|
318
|
+
void this._handleExternalCommentsRefreshClick({ button: refreshExternalBtnPanel });
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
308
323
|
// PR description popover
|
|
309
324
|
this.setupPRDescriptionPopover();
|
|
310
325
|
|
|
@@ -612,6 +627,15 @@ class PRManager {
|
|
|
612
627
|
// Fire-and-forget staleness check — shows badge or auto-refreshes
|
|
613
628
|
this._stalenessPromise = this._checkStalenessOnLoad(owner, repo, number);
|
|
614
629
|
|
|
630
|
+
// Fire-and-forget external review-comment sync + render.
|
|
631
|
+
// Runs after the diff and user comments have been rendered so the
|
|
632
|
+
// external-comment manager has DOM anchors to attach blue rows to.
|
|
633
|
+
// Failures are swallowed inside _loadExternalComments and surface via
|
|
634
|
+
// console.warn; they must never block the main PR-load path.
|
|
635
|
+
void this._loadExternalComments().catch((err) => {
|
|
636
|
+
console.warn('[external-comments] _loadExternalComments threw', err);
|
|
637
|
+
});
|
|
638
|
+
|
|
615
639
|
} catch (error) {
|
|
616
640
|
console.error('Error loading PR:', error);
|
|
617
641
|
this.showError(error.message);
|
|
@@ -623,16 +647,260 @@ class PRManager {
|
|
|
623
647
|
}
|
|
624
648
|
|
|
625
649
|
/**
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
650
|
+
* Sync external review comments against the source system and render the
|
|
651
|
+
* results. Non-blocking from the user's perspective: a sync failure does
|
|
652
|
+
* not prevent rendering of any cached rows from a previous run.
|
|
653
|
+
*
|
|
654
|
+
* No-op in local mode (external sources require a real GitHub PR).
|
|
655
|
+
*/
|
|
656
|
+
/**
|
|
657
|
+
* Re-render every overlay (AI suggestions, user comments, external comments)
|
|
658
|
+
* after the underlying diff DOM has been rebuilt.
|
|
659
|
+
*
|
|
660
|
+
* Used by anything that destroys and re-mounts the diff (refreshPR, the
|
|
661
|
+
* whitespace toggle, pre-analysis refresh). Centralizing keeps the three
|
|
662
|
+
* renderers from drifting: each one has its own clear()/append cycle and
|
|
663
|
+
* forgetting any of them produces hard-to-spot bugs like "comments
|
|
664
|
+
* disappeared after refresh".
|
|
665
|
+
*
|
|
666
|
+
* @param {Object} [options]
|
|
667
|
+
* @param {string} [options.analysisRunId] - Optional run id to pin AI suggestions to.
|
|
668
|
+
* @param {boolean} [options.syncExternal=false] - When true, fire the
|
|
669
|
+
* external-comments sync POST before re-rendering. Used by `refreshPR`
|
|
670
|
+
* where the diff was just fetched fresh from GitHub and cached anchors
|
|
671
|
+
* may not match the new commit. GET-only callers (whitespace toggle,
|
|
672
|
+
* analysis rebuilds, post-analysis reload) pass the default and reuse
|
|
673
|
+
* the existing mirror.
|
|
674
|
+
*/
|
|
675
|
+
async _rerenderAllOverlays({ analysisRunId, syncExternal = false } = {}) {
|
|
676
|
+
const includeDismissed = window.aiPanel?.showDismissedComments || false;
|
|
677
|
+
await this.loadUserComments(includeDismissed);
|
|
678
|
+
await this.loadAISuggestions(null, analysisRunId);
|
|
679
|
+
// Skip external-comment re-rendering entirely when the feature is off.
|
|
680
|
+
// The manager is never populated with rows in that case, so there's
|
|
681
|
+
// nothing to re-anchor.
|
|
682
|
+
if (!this._externalCommentsEnabled()) return;
|
|
683
|
+
try {
|
|
684
|
+
if (typeof window !== 'undefined' && window.externalCommentManager) {
|
|
685
|
+
if (this.currentPR && this.currentPR.id) {
|
|
686
|
+
window.externalCommentManager.reviewId = this.currentPR.id;
|
|
687
|
+
}
|
|
688
|
+
if (syncExternal) {
|
|
689
|
+
// refreshPR path: full sync+load through the canonical entry
|
|
690
|
+
// point. `_loadExternalComments` already owns the toast + error
|
|
691
|
+
// wiring for the sync result.
|
|
692
|
+
await this._loadExternalComments();
|
|
693
|
+
} else {
|
|
694
|
+
// GET-only path: the local mirror is current; just re-anchor
|
|
695
|
+
// rows on the freshly-rebuilt diff DOM.
|
|
696
|
+
await window.externalCommentManager.loadAndRender();
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
} catch (err) {
|
|
700
|
+
console.warn('[external-comments] re-render after diff rebuild failed', err);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Click handler for the "refresh external comments" header button.
|
|
706
|
+
*
|
|
707
|
+
* Extracted from `setupEventListeners` so unit tests can exercise the
|
|
708
|
+
* production handler directly without re-implementing it (CLAUDE.md
|
|
709
|
+
* forbids duplicating production code in tests). The DOM button id is
|
|
710
|
+
* the canonical attach point — pass it in for tests that build their
|
|
711
|
+
* own button.
|
|
712
|
+
*
|
|
713
|
+
* @param {Object} [options]
|
|
714
|
+
* @param {HTMLElement} [options.button] - The button element to toggle. Defaults to `#refresh-external-comments-btn-panel`.
|
|
715
|
+
* @returns {Promise<void>}
|
|
716
|
+
*/
|
|
717
|
+
async _handleExternalCommentsRefreshClick({ button } = {}) {
|
|
718
|
+
// Defensive guard: even if a stale caller invokes this with the
|
|
719
|
+
// feature disabled, swallow it. UI wiring already skips this path.
|
|
720
|
+
if (!this._externalCommentsEnabled()) return;
|
|
721
|
+
const btn = button
|
|
722
|
+
|| (typeof document !== 'undefined' ? document.getElementById('refresh-external-comments-btn-panel') : null);
|
|
723
|
+
if (!btn || btn.disabled) return;
|
|
724
|
+
btn.disabled = true;
|
|
725
|
+
btn.classList.add('is-refreshing');
|
|
726
|
+
btn.setAttribute('aria-busy', 'true');
|
|
727
|
+
btn.removeAttribute('data-state');
|
|
728
|
+
try {
|
|
729
|
+
await this._loadExternalComments();
|
|
730
|
+
} finally {
|
|
731
|
+
btn.disabled = false;
|
|
732
|
+
btn.classList.remove('is-refreshing');
|
|
733
|
+
btn.removeAttribute('aria-busy');
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Feature toggle for the external-comments (GitHub PR review-comment)
|
|
739
|
+
* subsystem. Reads `window.PAIR_REVIEW_RUNTIME_CONFIG` which is set
|
|
740
|
+
* synchronously by `/runtime-config.js` BEFORE this file loads, so the
|
|
741
|
+
* check is safe at any call site. Defaults to enabled when the flag is
|
|
742
|
+
* missing — preserves behavior for any environment that omits the
|
|
743
|
+
* runtime-config script (e.g., older test fixtures).
|
|
744
|
+
* @returns {boolean}
|
|
745
|
+
*/
|
|
746
|
+
_externalCommentsEnabled() {
|
|
747
|
+
if (typeof window === 'undefined') return true;
|
|
748
|
+
return window.PAIR_REVIEW_RUNTIME_CONFIG?.external_comments_enabled !== false;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async _loadExternalComments() {
|
|
752
|
+
if (typeof window !== 'undefined' && window.PAIR_REVIEW_LOCAL_MODE) return;
|
|
753
|
+
if (!this._externalCommentsEnabled()) return;
|
|
754
|
+
if (!this.currentPR || !this.currentPR.id) return;
|
|
755
|
+
if (typeof window === 'undefined' || !window.externalCommentManager) return;
|
|
756
|
+
|
|
757
|
+
window.externalCommentManager.reviewId = this.currentPR.id;
|
|
758
|
+
|
|
759
|
+
// Route through the manager's `syncAndRender`. That method owns the
|
|
760
|
+
// in-flight guard for the FULL sync+load sequence, so a GET-only
|
|
761
|
+
// caller (analysis rebuild, whitespace toggle) that hits
|
|
762
|
+
// `loadAndRender` during this window joins the same promise instead
|
|
763
|
+
// of racing the POST with a stale GET. The manager surfaces sync
|
|
764
|
+
// result and sync error separately so this method can fire the
|
|
765
|
+
// status-aware toasts without intercepting render.
|
|
766
|
+
let result;
|
|
767
|
+
try {
|
|
768
|
+
result = await window.externalCommentManager.syncAndRender({
|
|
769
|
+
syncFn: () => this._syncExternalComments(),
|
|
770
|
+
});
|
|
771
|
+
} catch (err) {
|
|
772
|
+
console.warn('[external-comments] syncAndRender failed', err);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (result && result.syncError) {
|
|
777
|
+
// Sync failed but render proceeded against the previously-cached
|
|
778
|
+
// mirror. Toast + button-error cue so the reviewer knows the
|
|
779
|
+
// counts may lag upstream.
|
|
780
|
+
this._showExternalSyncErrorToast(result.syncError);
|
|
781
|
+
this._markExternalRefreshErrorState();
|
|
782
|
+
console.warn('[external-comments] sync failed; rendering cached data only', result.syncError);
|
|
783
|
+
} else if (result && result.syncResult && typeof result.syncResult.lostAnchors === 'number' && result.syncResult.lostAnchors > 0) {
|
|
784
|
+
// Surface lost-anchor counts so the reviewer knows why visible
|
|
785
|
+
// external-comment counts may lag what GitHub shows — these are
|
|
786
|
+
// comments whose anchors were destroyed upstream (force-push, file
|
|
787
|
+
// delete, etc.) and have no place to render in the current diff.
|
|
788
|
+
this._showExternalLostAnchorsToast(result.syncResult.lostAnchors);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Pick a toast message by HTTP status for a failed external-comment sync.
|
|
794
|
+
* Falls back to a generic message when status is missing or unknown.
|
|
795
|
+
* @private
|
|
796
|
+
*/
|
|
797
|
+
_showExternalSyncErrorToast(err) {
|
|
798
|
+
const status = err && typeof err.status === 'number' ? err.status : 0;
|
|
799
|
+
let message;
|
|
800
|
+
if (status === 401) {
|
|
801
|
+
message = 'GitHub token missing or invalid — external comments not refreshed.';
|
|
802
|
+
} else if (status === 403) {
|
|
803
|
+
message = 'GitHub denied the request (403) — external comments not refreshed.';
|
|
804
|
+
} else if (status === 429) {
|
|
805
|
+
message = 'GitHub rate limited — external comments not refreshed.';
|
|
806
|
+
} else {
|
|
807
|
+
message = 'Could not refresh GitHub review comments.';
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
if (typeof window !== 'undefined') {
|
|
811
|
+
if (window.toast && typeof window.toast.showError === 'function') {
|
|
812
|
+
window.toast.showError(message);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (typeof window.showToast === 'function') {
|
|
816
|
+
window.showToast(message, 'error');
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
} catch {
|
|
821
|
+
// toast helpers must never break the page; swallow.
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Show a warning toast describing how many external comments couldn't be
|
|
827
|
+
* anchored to the current diff (lost-anchor count from the sync result).
|
|
828
|
+
* Mirrors `_showExternalSyncErrorToast` so failures and partial successes
|
|
829
|
+
* have symmetrical UI treatment.
|
|
830
|
+
* @param {number} count - Number of lost-anchor comments
|
|
831
|
+
* @private
|
|
832
|
+
*/
|
|
833
|
+
_showExternalLostAnchorsToast(count) {
|
|
834
|
+
if (typeof window === 'undefined') return;
|
|
835
|
+
const noun = count === 1 ? 'comment' : 'comments';
|
|
836
|
+
const message = `${count} ${noun} lost their anchor due to upstream changes`;
|
|
837
|
+
try {
|
|
838
|
+
if (window.toast && typeof window.toast.showWarning === 'function') {
|
|
839
|
+
window.toast.showWarning(message);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (window.toast && typeof window.toast.showInfo === 'function') {
|
|
843
|
+
window.toast.showInfo(message);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (typeof window.showToast === 'function') {
|
|
847
|
+
window.showToast(message, 'warn');
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
} catch {
|
|
851
|
+
// toast helpers must never break the page; swallow.
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Briefly mark the refresh button so the user notices the failure even if
|
|
857
|
+
* they dismissed the toast. Cleared after 4s; no state machine — best
|
|
858
|
+
* effort cue. No-op when the button isn't present.
|
|
859
|
+
* @private
|
|
860
|
+
*/
|
|
861
|
+
_markExternalRefreshErrorState() {
|
|
862
|
+
if (typeof document === 'undefined') return;
|
|
863
|
+
const btn = document.getElementById('refresh-external-comments-btn-panel');
|
|
864
|
+
if (!btn) return;
|
|
865
|
+
btn.setAttribute('data-state', 'error');
|
|
866
|
+
setTimeout(() => {
|
|
867
|
+
if (btn.getAttribute('data-state') === 'error') {
|
|
868
|
+
btn.removeAttribute('data-state');
|
|
869
|
+
}
|
|
870
|
+
}, 4000);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* POST to the sync endpoint for the GitHub source. Coalesced server-side
|
|
875
|
+
* for concurrent (review, source) calls; safe to invoke from page-load and
|
|
876
|
+
* the manual refresh button in parallel.
|
|
877
|
+
* @returns {Promise<{ count: number, lostAnchors: number, syncedAt: string }>}
|
|
878
|
+
*/
|
|
879
|
+
async _syncExternalComments() {
|
|
880
|
+
const source = 'github';
|
|
881
|
+
const res = await fetch(
|
|
882
|
+
`/api/reviews/${this.currentPR.id}/external-comments/sync?source=${source}`,
|
|
883
|
+
{ method: 'POST' }
|
|
884
|
+
);
|
|
885
|
+
if (!res.ok) {
|
|
886
|
+
const body = await res.json().catch(() => ({}));
|
|
887
|
+
const err = new Error(body.error || `Sync failed with status ${res.status}`);
|
|
888
|
+
err.status = res.status;
|
|
889
|
+
throw err;
|
|
890
|
+
}
|
|
891
|
+
return res.json();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Reload AI suggestions, user comments, and external comments after an
|
|
896
|
+
* analysis completes. Shared by the foreground `analysis_completed`
|
|
897
|
+
* handler and the deferred `_dirtyAnalysis` branch in the
|
|
898
|
+
* visibilitychange listener. Routing through `_rerenderAllOverlays`
|
|
899
|
+
* keeps external-comment rows in sync with the other two overlays
|
|
900
|
+
* whenever post-analysis refresh fires.
|
|
629
901
|
*/
|
|
630
902
|
_reloadAfterAnalysis() {
|
|
631
|
-
|
|
632
|
-
return Promise.all([
|
|
633
|
-
this.loadAISuggestions(),
|
|
634
|
-
this.loadUserComments(includeDismissed)
|
|
635
|
-
]);
|
|
903
|
+
return this._rerenderAllOverlays();
|
|
636
904
|
}
|
|
637
905
|
|
|
638
906
|
/**
|
|
@@ -842,10 +1110,11 @@ class PRManager {
|
|
|
842
1110
|
// Re-fetch and re-render the diff
|
|
843
1111
|
await this.loadAndDisplayFiles(owner, repo, number);
|
|
844
1112
|
|
|
845
|
-
// Re-anchor
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1113
|
+
// Re-anchor every overlay (user comments, AI suggestions, external
|
|
1114
|
+
// comments) via the shared helper so the three renderers can't drift.
|
|
1115
|
+
// The diff DOM was just rebuilt, so external-comment rows are gone
|
|
1116
|
+
// until loadAndRender re-inserts them.
|
|
1117
|
+
await this._rerenderAllOverlays({ analysisRunId: this.selectedRunId });
|
|
849
1118
|
|
|
850
1119
|
// Restore scroll position after the DOM settles
|
|
851
1120
|
requestAnimationFrame(() => {
|
|
@@ -5221,14 +5490,22 @@ class PRManager {
|
|
|
5221
5490
|
// Reload the files/diff with fresh data
|
|
5222
5491
|
await this.loadAndDisplayFiles(owner, repo, number);
|
|
5223
5492
|
|
|
5224
|
-
// Re-render
|
|
5225
|
-
// (renderDiff clears the diff container
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
//
|
|
5229
|
-
// (
|
|
5230
|
-
//
|
|
5231
|
-
|
|
5493
|
+
// Re-render the three independent overlay layers on the fresh DOM
|
|
5494
|
+
// (renderDiff clears the diff container). Going through the shared
|
|
5495
|
+
// helper guarantees the three renderers can't drift again — adding
|
|
5496
|
+
// a fourth overlay only requires updating this one place.
|
|
5497
|
+
// `syncExternal: true` because refreshPR fetched a brand-new diff
|
|
5498
|
+
// (commit SHA may have changed). Cached external-comment anchors
|
|
5499
|
+
// need a sync POST to re-evaluate which ones are outdated against
|
|
5500
|
+
// the new HEAD — otherwise we'd render stale `is_outdated` flags.
|
|
5501
|
+
// Note: Unlike loadPR() which skips loadAISuggestions when
|
|
5502
|
+
// analysisHistoryManager exists (because the manager triggers it via
|
|
5503
|
+
// onSelectionChange on init), refresh must call unconditionally
|
|
5504
|
+
// since the manager won't re-fire its callback.
|
|
5505
|
+
await this._rerenderAllOverlays({
|
|
5506
|
+
analysisRunId: this.selectedRunId,
|
|
5507
|
+
syncExternal: true,
|
|
5508
|
+
});
|
|
5232
5509
|
|
|
5233
5510
|
// Restore expanded folders
|
|
5234
5511
|
this.expandedFolders = expandedFolders;
|
package/public/local.html
CHANGED
|
@@ -525,11 +525,20 @@
|
|
|
525
525
|
|
|
526
526
|
<!-- Segment Control -->
|
|
527
527
|
<div class="segment-control" id="segment-control">
|
|
528
|
-
<
|
|
529
|
-
<
|
|
530
|
-
|
|
531
|
-
|
|
528
|
+
<button type="button" class="segment-scroll segment-scroll--left" id="segment-scroll-left" hidden aria-label="Scroll segments left" tabindex="-1">
|
|
529
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M9.78 12.78a.75.75 0 0 1-1.06 0L4.47 8.53a.75.75 0 0 1 0-1.06l4.25-4.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L6.06 8l3.72 3.72a.75.75 0 0 1 0 1.06Z"/></svg>
|
|
530
|
+
</button>
|
|
531
|
+
<div class="segment-control-scroll" id="segment-control-scroll">
|
|
532
|
+
<div class="segment-control-inner">
|
|
533
|
+
<button class="segment-btn active" data-segment="ai">AI <span class="segment-count">(0)</span></button>
|
|
534
|
+
<button class="segment-btn" data-segment="comments">User <span class="segment-count">(0)</span></button>
|
|
535
|
+
<button class="segment-btn" data-segment="external" hidden>External <span class="segment-count">(0)</span></button>
|
|
536
|
+
<button class="segment-btn" data-segment="all">All <span class="segment-count">(0)</span></button>
|
|
537
|
+
</div>
|
|
532
538
|
</div>
|
|
539
|
+
<button type="button" class="segment-scroll segment-scroll--right" id="segment-scroll-right" hidden aria-label="Scroll segments right" tabindex="-1">
|
|
540
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>
|
|
541
|
+
</button>
|
|
533
542
|
</div>
|
|
534
543
|
|
|
535
544
|
<!-- Level Filter -->
|
|
@@ -543,7 +552,9 @@
|
|
|
543
552
|
<!-- Findings Summary -->
|
|
544
553
|
<div class="findings-summary" id="findings-summary">
|
|
545
554
|
<div class="findings-header">
|
|
546
|
-
|
|
555
|
+
<!-- Nav controls (prev | n of N | next) are injected by AIPanel.updateFindingsHeader.
|
|
556
|
+
Local mode has no external comments, so the actions slot stays empty. -->
|
|
557
|
+
<div class="findings-nav" id="findings-nav"></div>
|
|
547
558
|
</div>
|
|
548
559
|
<div class="findings-list" id="findings-list">
|
|
549
560
|
<div class="findings-empty">
|
|
@@ -641,6 +652,11 @@
|
|
|
641
652
|
window.PAIR_REVIEW_LOCAL_MODE = true;
|
|
642
653
|
</script>
|
|
643
654
|
|
|
655
|
+
<!-- Runtime configuration: sets window.PAIR_REVIEW_RUNTIME_CONFIG and
|
|
656
|
+
the `external-comments-disabled` class on documentElement before
|
|
657
|
+
pr.js / AIPanel initialise. Must load BEFORE pr.js. -->
|
|
658
|
+
<script src="/runtime-config.js"></script>
|
|
659
|
+
|
|
644
660
|
<!-- PR JavaScript (shared with local mode) -->
|
|
645
661
|
<script src="/js/pr.js"></script>
|
|
646
662
|
|
package/public/pr.html
CHANGED
|
@@ -321,11 +321,20 @@
|
|
|
321
321
|
|
|
322
322
|
<!-- Segment Control -->
|
|
323
323
|
<div class="segment-control" id="segment-control">
|
|
324
|
-
<
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
324
|
+
<button type="button" class="segment-scroll segment-scroll--left" id="segment-scroll-left" hidden aria-label="Scroll segments left" tabindex="-1">
|
|
325
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M9.78 12.78a.75.75 0 0 1-1.06 0L4.47 8.53a.75.75 0 0 1 0-1.06l4.25-4.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L6.06 8l3.72 3.72a.75.75 0 0 1 0 1.06Z"/></svg>
|
|
326
|
+
</button>
|
|
327
|
+
<div class="segment-control-scroll" id="segment-control-scroll">
|
|
328
|
+
<div class="segment-control-inner">
|
|
329
|
+
<button class="segment-btn active" data-segment="ai">AI <span class="segment-count">(0)</span></button>
|
|
330
|
+
<button class="segment-btn" data-segment="comments">User <span class="segment-count">(0)</span></button>
|
|
331
|
+
<button class="segment-btn" data-segment="external">External <span class="segment-count">(0)</span></button>
|
|
332
|
+
<button class="segment-btn" data-segment="all">All <span class="segment-count">(0)</span></button>
|
|
333
|
+
</div>
|
|
328
334
|
</div>
|
|
335
|
+
<button type="button" class="segment-scroll segment-scroll--right" id="segment-scroll-right" hidden aria-label="Scroll segments right" tabindex="-1">
|
|
336
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>
|
|
337
|
+
</button>
|
|
329
338
|
</div>
|
|
330
339
|
|
|
331
340
|
<!-- Level Filter -->
|
|
@@ -339,7 +348,16 @@
|
|
|
339
348
|
<!-- Findings Summary -->
|
|
340
349
|
<div class="findings-summary" id="findings-summary">
|
|
341
350
|
<div class="findings-header">
|
|
342
|
-
|
|
351
|
+
<!-- Nav controls (prev | n of N | next) are injected by AIPanel.updateFindingsHeader.
|
|
352
|
+
Kept as a static slot so the sibling actions container survives re-render. -->
|
|
353
|
+
<div class="findings-nav" id="findings-nav"></div>
|
|
354
|
+
<div class="findings-header-actions">
|
|
355
|
+
<button class="findings-nav-btn btn-refresh-external-comments external-comments-only" id="refresh-external-comments-btn-panel" title="Refresh GitHub comments" aria-label="Refresh GitHub comments">
|
|
356
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12" aria-hidden="true">
|
|
357
|
+
<path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/>
|
|
358
|
+
</svg>
|
|
359
|
+
</button>
|
|
360
|
+
</div>
|
|
343
361
|
</div>
|
|
344
362
|
<div class="findings-list" id="findings-list">
|
|
345
363
|
<div class="findings-empty">
|
|
@@ -429,9 +447,17 @@
|
|
|
429
447
|
<!-- Chat Panel Component -->
|
|
430
448
|
<script src="/js/components/ChatPanel.js"></script>
|
|
431
449
|
|
|
450
|
+
<!-- External review-comment renderer (must load after chat-panel + comment-manager) -->
|
|
451
|
+
<script src="/js/modules/external-comment-manager.js"></script>
|
|
452
|
+
|
|
432
453
|
<!-- Panel Group Component -->
|
|
433
454
|
<script src="/js/components/PanelGroup.js"></script>
|
|
434
455
|
|
|
456
|
+
<!-- Runtime configuration: sets window.PAIR_REVIEW_RUNTIME_CONFIG and
|
|
457
|
+
the `external-comments-disabled` class on documentElement before
|
|
458
|
+
pr.js / AIPanel initialise. Must load BEFORE pr.js. -->
|
|
459
|
+
<script src="/runtime-config.js"></script>
|
|
460
|
+
|
|
435
461
|
<!-- PR JavaScript -->
|
|
436
462
|
<script src="/js/pr.js"></script>
|
|
437
463
|
</body>
|
|
@@ -118,6 +118,9 @@ function getChatProvider(id) {
|
|
|
118
118
|
};
|
|
119
119
|
if (overrides.model) provider.model = overrides.model;
|
|
120
120
|
if (overrides.provider) provider.provider = overrides.provider;
|
|
121
|
+
if (overrides.availability_command !== undefined) {
|
|
122
|
+
provider.availability_command = overrides.availability_command;
|
|
123
|
+
}
|
|
121
124
|
if (overrides.extra_args && Array.isArray(overrides.extra_args)) {
|
|
122
125
|
provider.args = [...provider.args, ...overrides.extra_args];
|
|
123
126
|
}
|
|
@@ -136,6 +139,9 @@ function getChatProvider(id) {
|
|
|
136
139
|
if (overrides.command) merged.command = overrides.command;
|
|
137
140
|
if (overrides.model) merged.model = overrides.model;
|
|
138
141
|
if (overrides.provider) merged.provider = overrides.provider;
|
|
142
|
+
if (overrides.availability_command !== undefined) {
|
|
143
|
+
merged.availability_command = overrides.availability_command;
|
|
144
|
+
}
|
|
139
145
|
if (overrides.env) merged.env = { ...merged.env, ...overrides.env };
|
|
140
146
|
if (overrides.args) {
|
|
141
147
|
merged.args = overrides.args;
|
|
@@ -197,8 +203,10 @@ function isCodexProvider(id) {
|
|
|
197
203
|
|
|
198
204
|
/**
|
|
199
205
|
* Check availability of a single chat provider.
|
|
200
|
-
*
|
|
201
|
-
*
|
|
206
|
+
* Providers with `availability_command` run that command first.
|
|
207
|
+
* Without an availability command, Pi delegates to the existing AI provider
|
|
208
|
+
* availability cache and other providers spawn `<command> --version` to verify
|
|
209
|
+
* the binary exists.
|
|
202
210
|
* @param {string} id - Provider ID
|
|
203
211
|
* @param {Object} [_deps] - Dependency overrides for testing
|
|
204
212
|
* @returns {Promise<{available: boolean, error?: string}>}
|
|
@@ -209,6 +217,19 @@ async function checkChatProviderAvailability(id, _deps) {
|
|
|
209
217
|
return { available: false, error: `Unknown provider: ${id}` };
|
|
210
218
|
}
|
|
211
219
|
|
|
220
|
+
const deps = { ...defaults, ..._deps };
|
|
221
|
+
|
|
222
|
+
if (provider.availability_command) {
|
|
223
|
+
return runCommandAvailabilityCheck({
|
|
224
|
+
deps,
|
|
225
|
+
command: provider.availability_command,
|
|
226
|
+
args: [],
|
|
227
|
+
displayCommand: 'availability command',
|
|
228
|
+
shell: true,
|
|
229
|
+
env: provider.env,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
212
233
|
// Pi delegates to existing AI provider availability
|
|
213
234
|
if (provider.type === 'pi') {
|
|
214
235
|
const cached = getCachedAvailability('pi');
|
|
@@ -218,30 +239,61 @@ async function checkChatProviderAvailability(id, _deps) {
|
|
|
218
239
|
// Codex uses the same binary-check pattern as ACP providers
|
|
219
240
|
// (falls through to the spawn check below)
|
|
220
241
|
|
|
221
|
-
const deps = { ...defaults, ..._deps };
|
|
222
242
|
const command = provider.command;
|
|
223
243
|
const useShell = provider.useShell || false;
|
|
224
244
|
|
|
245
|
+
// For multi-word commands, use shell mode
|
|
246
|
+
const spawnCmd = useShell ? `${command} --version` : command;
|
|
247
|
+
const spawnArgs = useShell ? [] : ['--version'];
|
|
248
|
+
return runCommandAvailabilityCheck({
|
|
249
|
+
deps,
|
|
250
|
+
command: spawnCmd,
|
|
251
|
+
args: spawnArgs,
|
|
252
|
+
displayCommand: `${command} --version`,
|
|
253
|
+
shell: useShell,
|
|
254
|
+
env: provider.env,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Spawn a command and resolve based on its exit status. Shared by the
|
|
260
|
+
* configured `availability_command` path and the legacy `<command> --version`
|
|
261
|
+
* fallback.
|
|
262
|
+
*
|
|
263
|
+
* Notes on the option choices:
|
|
264
|
+
* - `stdio: ['ignore', 'ignore', 'ignore']` discards output so a verbose probe
|
|
265
|
+
* cannot fill an OS pipe buffer and block while waiting for a reader.
|
|
266
|
+
* - `shell: true` allows multi-word configured commands to run through the
|
|
267
|
+
* user's shell.
|
|
268
|
+
* - `once()` avoids leaking listeners or resolving twice if multiple child
|
|
269
|
+
* process events fire.
|
|
270
|
+
* - `displayCommand` is used in error messages so user-configured shell strings
|
|
271
|
+
* do not need to be printed verbatim.
|
|
272
|
+
*
|
|
273
|
+
* @param {{deps: {spawn: Function}, command: string, args: string[], displayCommand: string, shell: boolean, env?: Object}} opts
|
|
274
|
+
* @returns {Promise<{available: boolean, error?: string}>}
|
|
275
|
+
*/
|
|
276
|
+
function runCommandAvailabilityCheck({ deps, command, args, displayCommand, shell, env }) {
|
|
225
277
|
return new Promise((resolve) => {
|
|
226
278
|
try {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const spawnArgs = useShell ? [] : ['--version'];
|
|
230
|
-
const proc = deps.spawn(spawnCmd, spawnArgs, {
|
|
231
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
279
|
+
const proc = deps.spawn(command, args, {
|
|
280
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
232
281
|
timeout: 10000,
|
|
233
|
-
shell
|
|
282
|
+
shell,
|
|
283
|
+
env: { ...process.env, ...(env || {}) },
|
|
234
284
|
});
|
|
235
285
|
|
|
236
|
-
proc.
|
|
286
|
+
proc.once('error', (err) => {
|
|
237
287
|
resolve({ available: false, error: err.message });
|
|
238
288
|
});
|
|
239
289
|
|
|
240
|
-
proc.
|
|
290
|
+
proc.once('close', (code, signal) => {
|
|
241
291
|
if (code === 0) {
|
|
242
292
|
resolve({ available: true });
|
|
293
|
+
} else if (signal) {
|
|
294
|
+
resolve({ available: false, error: `${displayCommand} timed out or was terminated (${signal})` });
|
|
243
295
|
} else {
|
|
244
|
-
resolve({ available: false, error: `${
|
|
296
|
+
resolve({ available: false, error: `${displayCommand} exited with code ${code}` });
|
|
245
297
|
}
|
|
246
298
|
});
|
|
247
299
|
} catch (err) {
|
package/src/config.js
CHANGED
|
@@ -39,7 +39,8 @@ const DEFAULT_CONFIG = {
|
|
|
39
39
|
assisted_by_url: "https://github.com/in-the-loop-labs/pair-review", // URL for "Review assisted by" footer link
|
|
40
40
|
hooks: {}, // Hook commands per event: { "review.started": { "my_hook": { "command": "..." } } }
|
|
41
41
|
enable_graphite: false, // When true, shows Graphite links alongside GitHub links
|
|
42
|
-
skip_update_notifier: false // When true, suppresses the "update available" notification on exit
|
|
42
|
+
skip_update_notifier: false, // When true, suppresses the "update available" notification on exit
|
|
43
|
+
external_comments: false // Opt-in: set to true to enable GitHub PR review-comment sync (External segment, refresh button, /api/reviews/*/external-comments routes)
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -222,19 +223,19 @@ async function loadConfig() {
|
|
|
222
223
|
}
|
|
223
224
|
}
|
|
224
225
|
|
|
225
|
-
// Normalize legacy monorepos
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (mergedConfig.repos) {
|
|
232
|
-
const normalized = {};
|
|
233
|
-
for (const [key, value] of Object.entries(mergedConfig.repos)) {
|
|
234
|
-
normalized[key.toLowerCase()] = value;
|
|
226
|
+
// Normalize legacy monorepos into one canonical repos map. Lowercase both
|
|
227
|
+
// sides before merging so JS object identity matches DB COLLATE NOCASE.
|
|
228
|
+
const lowercaseKeys = (obj) => {
|
|
229
|
+
const out = {};
|
|
230
|
+
for (const [key, value] of Object.entries(obj || {})) {
|
|
231
|
+
out[key.toLowerCase()] = value;
|
|
235
232
|
}
|
|
236
|
-
|
|
237
|
-
}
|
|
233
|
+
return out;
|
|
234
|
+
};
|
|
235
|
+
const lowerMonorepos = lowercaseKeys(mergedConfig.monorepos);
|
|
236
|
+
const lowerRepos = lowercaseKeys(mergedConfig.repos);
|
|
237
|
+
mergedConfig.repos = deepMerge(lowerMonorepos, lowerRepos);
|
|
238
|
+
delete mergedConfig.monorepos;
|
|
238
239
|
|
|
239
240
|
// PORT env var overrides all config layers (used by Preview and similar harnesses)
|
|
240
241
|
if (process.env.PORT) {
|
|
@@ -424,12 +425,15 @@ function expandPath(p) {
|
|
|
424
425
|
* @returns {object|null}
|
|
425
426
|
*/
|
|
426
427
|
function getRepoConfig(config, repository) {
|
|
428
|
+
const key = String(repository).toLowerCase();
|
|
427
429
|
const reposSection = config.repos || {};
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
+
const repoEntry = reposSection[key] || reposSection[repository] || Object.entries(reposSection)
|
|
431
|
+
.find(([repoName]) => repoName.toLowerCase() === key)?.[1];
|
|
432
|
+
if (repoEntry) return repoEntry;
|
|
430
433
|
|
|
431
434
|
const legacySection = config.monorepos || {};
|
|
432
|
-
return legacySection[repository] ||
|
|
435
|
+
return legacySection[key] || legacySection[repository] || Object.entries(legacySection)
|
|
436
|
+
.find(([repoName]) => repoName.toLowerCase() === key)?.[1] || null;
|
|
433
437
|
}
|
|
434
438
|
|
|
435
439
|
/**
|
|
@@ -784,4 +788,4 @@ module.exports = {
|
|
|
784
788
|
shouldSkipUpdateNotifier,
|
|
785
789
|
_resetTokenCache,
|
|
786
790
|
DEFAULT_CHECKOUT_TIMEOUT_MS
|
|
787
|
-
};
|
|
791
|
+
};
|