@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.
@@ -0,0 +1,870 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * ExternalCommentManager - Read-only renderer for external review comments.
4
+ *
5
+ * Consumes `GET /api/reviews/:reviewId/external-comments?source=<source>` and
6
+ * renders thread-grouped comments as `.external-comment-row` rows inserted
7
+ * after the appropriate diff line.
8
+ *
9
+ * Read-only: no draft / submit / edit / dismiss flows. Only chat-about
10
+ * actions, which delegate to the global chat panel.
11
+ *
12
+ * Ordering rule (shared diff-row surface — see plans/fetch-external-review-comments.md
13
+ * "Hazards"): three independent renderers append rows after the same diff
14
+ * line: `.ai-suggestion-row`, `.user-comment-row`, `.external-comment-row`.
15
+ * External rows sit BELOW AI suggestion + user comment rows for the same
16
+ * diff line. This module ONLY touches `.external-comment-row` elements when
17
+ * clearing — it never strips rows owned by other renderers.
18
+ */
19
+
20
+ class ExternalCommentManager {
21
+ /**
22
+ * @param {Object} opts
23
+ * @param {string|number} opts.reviewId - Review id used to build the API URL
24
+ * @param {string[]} [opts.sources=['github']] - External sources to load
25
+ * @param {Object} [opts.chatPanel=window.chatPanel] - Chat panel reference
26
+ */
27
+ constructor({ reviewId, sources = ['github'], chatPanel } = {}) {
28
+ this.reviewId = reviewId;
29
+ this.sources = sources;
30
+ // Bind lazily so tests / late-loaded chat panel both work
31
+ this.chatPanel = chatPanel || (typeof window !== 'undefined' ? window.chatPanel : null);
32
+ // source -> threads[]
33
+ this.threadsBySource = new Map();
34
+ // Track whether we've already warned about a missing anchor for a given thread
35
+ this._anchorWarnings = new Set();
36
+ // Per-manager in-flight promise. Coalesces concurrent loadAndRender calls
37
+ // (page-load + manual refresh + post-AI re-render) into one round-trip.
38
+ this._inflight = null;
39
+ }
40
+
41
+ // ------------------------------------------------------------------
42
+ // Data fetch
43
+ // ------------------------------------------------------------------
44
+
45
+ /**
46
+ * Fetch threads for a single source and cache them in `threadsBySource`.
47
+ * @param {string} source - Source identifier (e.g. 'github')
48
+ * @returns {Promise<Array>} The fetched threads
49
+ */
50
+ async fetch(source) {
51
+ if (!this.reviewId) {
52
+ throw new Error('ExternalCommentManager: reviewId is required to fetch');
53
+ }
54
+ const url = `/api/reviews/${encodeURIComponent(this.reviewId)}/external-comments?source=${encodeURIComponent(source)}`;
55
+ let res;
56
+ try {
57
+ res = await fetch(url);
58
+ } catch (err) {
59
+ this._toast(`Failed to load ${source} review comments`, 'error');
60
+ throw err;
61
+ }
62
+ if (!res.ok) {
63
+ this._toast(`Failed to load ${source} review comments`, 'error');
64
+ throw new Error(`Failed to fetch external comments: ${res.status}`);
65
+ }
66
+ const data = await res.json();
67
+ const threads = Array.isArray(data) ? data : (data.threads || []);
68
+ this.threadsBySource.set(source, threads);
69
+ return threads;
70
+ }
71
+
72
+ /**
73
+ * Fetch + render for all configured sources. Failure in one source does
74
+ * not abort the rest.
75
+ *
76
+ * Canonical refresh entry point for GET-only callers (analysis rebuilds,
77
+ * whitespace toggles, post-AI rerender). For full sync+load — POST to
78
+ * /external-comments/sync followed by GET + render — use `syncAndRender`
79
+ * instead. Both methods share `this._inflight`, so a GET-only caller
80
+ * that races an in-flight `syncAndRender` joins the full sync+load
81
+ * promise rather than racing the POST with a stale GET. This is NOT
82
+ * declared `async` on purpose; an async wrapper would create a fresh
83
+ * Promise on each call and break the shared-Promise contract.
84
+ */
85
+ loadAndRender() {
86
+ if (this._inflight) return this._inflight;
87
+ const inflight = (async () => {
88
+ const result = await this._fetchAllAndRender();
89
+ return result;
90
+ })().finally(() => {
91
+ this._inflight = null;
92
+ });
93
+ this._inflight = inflight;
94
+ return this._inflight;
95
+ }
96
+
97
+ /**
98
+ * Sync upstream → load → render. The orchestration sequence for the
99
+ * "refresh external comments" button and PR-page load.
100
+ *
101
+ * Shares `this._inflight` with `loadAndRender`: while a sync+load is in
102
+ * flight, any GET-only caller (analysis rebuild, whitespace toggle) that
103
+ * calls `loadAndRender` joins this promise instead of racing the POST
104
+ * with a stale GET. The POST happens BEFORE the GET so the GET sees the
105
+ * latest mirror.
106
+ *
107
+ * @param {Object} options
108
+ * @param {() => Promise<{count: number, lostAnchors: number, deleted: number, syncedAt: string}>} options.syncFn -
109
+ * Async function that performs the POST /external-comments/sync. Injected so the manager
110
+ * doesn't have to know about pr.js — keeps it testable and source-agnostic.
111
+ * @returns {Promise<{errors: Array, syncResult: Object|null, syncError: Error|null}>}
112
+ */
113
+ syncAndRender({ syncFn } = {}) {
114
+ if (this._inflight) return this._inflight;
115
+ const inflight = (async () => {
116
+ let syncResult = null;
117
+ let syncError = null;
118
+ if (typeof syncFn === 'function') {
119
+ try {
120
+ syncResult = await syncFn();
121
+ } catch (err) {
122
+ // Sync failure shouldn't block render — we may have cached rows
123
+ // from a previous run. The caller is responsible for surfacing
124
+ // the failure (toast, etc.); we just keep going.
125
+ syncError = err;
126
+ }
127
+ }
128
+ const renderResult = await this._fetchAllAndRender();
129
+ return { ...renderResult, syncResult, syncError };
130
+ })().finally(() => {
131
+ this._inflight = null;
132
+ });
133
+ this._inflight = inflight;
134
+ return this._inflight;
135
+ }
136
+
137
+ /**
138
+ * Internal helper shared by loadAndRender and syncAndRender. Pulls
139
+ * threads for each configured source and re-renders. Failures in one
140
+ * source don't abort the rest.
141
+ * @private
142
+ */
143
+ async _fetchAllAndRender() {
144
+ const errors = [];
145
+ for (const source of this.sources) {
146
+ try {
147
+ await this.fetch(source);
148
+ } catch (err) {
149
+ errors.push({ source, err });
150
+ // Toast already shown in fetch()
151
+ if (typeof console !== 'undefined') {
152
+ console.warn(`[ExternalCommentManager] Failed to fetch ${source}:`, err);
153
+ }
154
+ }
155
+ }
156
+ await this.render();
157
+ // Hand off the flattened thread list to the Review panel so its
158
+ // External segment stays in sync with the inline rows.
159
+ this._notifyPanel();
160
+ return { errors };
161
+ }
162
+
163
+ /**
164
+ * Flatten threadsBySource into a single array. The Review panel does
165
+ * not care about source grouping in its list — sort + display keys are
166
+ * file + line — so a flat union is the right shape to hand off.
167
+ *
168
+ * @returns {Array<Object>} The flattened thread roots.
169
+ */
170
+ getAllThreads() {
171
+ const out = [];
172
+ for (const threads of this.threadsBySource.values()) {
173
+ if (Array.isArray(threads)) {
174
+ for (const thread of threads) out.push(thread);
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /**
181
+ * Push the current flattened thread list onto the Review panel's
182
+ * External segment. No-op when the panel isn't ready (e.g. before
183
+ * DOMContentLoaded or in tests that don't initialize it).
184
+ * @private
185
+ */
186
+ _notifyPanel() {
187
+ if (typeof window === 'undefined') return;
188
+ const panel = window.aiPanel;
189
+ if (!panel || typeof panel.setExternalThreads !== 'function') return;
190
+ try {
191
+ panel.setExternalThreads(this.getAllThreads());
192
+ } catch (err) {
193
+ if (typeof console !== 'undefined') {
194
+ console.warn('[ExternalCommentManager] setExternalThreads threw', err);
195
+ }
196
+ }
197
+ }
198
+
199
+ // ------------------------------------------------------------------
200
+ // Render
201
+ // ------------------------------------------------------------------
202
+
203
+ /**
204
+ * Render all cached threads into the diff. Idempotent: clears any
205
+ * previously rendered external-comment rows first.
206
+ *
207
+ * Async because `_renderThread` may await `prManager.ensureLinesVisible`
208
+ * when an outdated comment's anchor row is collapsed.
209
+ */
210
+ async render() {
211
+ this.clear();
212
+ for (const [, threads] of this.threadsBySource) {
213
+ if (!Array.isArray(threads)) continue;
214
+ for (const thread of threads) {
215
+ try {
216
+ await this._renderThread(thread);
217
+ } catch (err) {
218
+ if (typeof console !== 'undefined') {
219
+ console.warn('[ExternalCommentManager] Failed to render thread', thread?.id, err);
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ // Rebuild minimize-mode indicators so external rows are reflected in
226
+ // the per-line count badges. Mirrors what comment-manager and
227
+ // suggestion-manager do on render. No-op when the minimizer isn't
228
+ // active (refreshIndicators short-circuits on `!this._active`).
229
+ try {
230
+ if (typeof window !== 'undefined' && window.prManager && window.prManager.commentMinimizer) {
231
+ window.prManager.commentMinimizer.refreshIndicators();
232
+ }
233
+ } catch (err) {
234
+ if (typeof console !== 'undefined') {
235
+ console.warn('[ExternalCommentManager] minimizer.refreshIndicators threw', err);
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Remove all `.external-comment-row` rows we own from the DOM.
242
+ * Leaves `.user-comment-row` and `.ai-suggestion-row` rows untouched.
243
+ */
244
+ clear() {
245
+ if (typeof document === 'undefined') return;
246
+ const rows = document.querySelectorAll('.external-comment-row');
247
+ rows.forEach((row) => row.remove());
248
+ }
249
+
250
+ // ------------------------------------------------------------------
251
+ // Internals
252
+ // ------------------------------------------------------------------
253
+
254
+ /**
255
+ * Resolve the diff row that an external comment / thread root anchors to.
256
+ * Uses the project-wide DiffRenderer file lookup when available, and the
257
+ * LineTracker side-aware coordinate lookup when present — both already
258
+ * power the suggestion-manager and comment-manager renderers, so behavior
259
+ * stays consistent across the three.
260
+ *
261
+ * @param {string} file
262
+ * @param {number} line
263
+ * @param {string} side - 'LEFT' or 'RIGHT'
264
+ * @returns {Element|null}
265
+ */
266
+ _findDiffLineRow(file, line, side) {
267
+ if (typeof document === 'undefined') return null;
268
+ if (!file || !Number.isFinite(line)) return null;
269
+
270
+ let fileElement = null;
271
+ if (typeof window !== 'undefined' && window.DiffRenderer && typeof window.DiffRenderer.findFileElement === 'function') {
272
+ fileElement = window.DiffRenderer.findFileElement(file);
273
+ }
274
+ if (!fileElement) {
275
+ try {
276
+ const escaped = (typeof globalThis !== 'undefined' && globalThis.CSS?.escape)
277
+ ? globalThis.CSS.escape(file)
278
+ : file;
279
+ fileElement = document.querySelector(`[data-file-name="${escaped}"]`);
280
+ } catch {
281
+ fileElement = null;
282
+ }
283
+ }
284
+ if (!fileElement) return null;
285
+
286
+ const wantedSide = side || 'RIGHT';
287
+ const rows = fileElement.querySelectorAll('tr');
288
+ const lineTracker = (typeof window !== 'undefined' && window.LineTracker)
289
+ ? new window.LineTracker()
290
+ : null;
291
+
292
+ for (const row of rows) {
293
+ let lineNum = null;
294
+ if (lineTracker && typeof lineTracker.getLineNumber === 'function') {
295
+ lineNum = lineTracker.getLineNumber(row, wantedSide);
296
+ } else {
297
+ // Minimal fallback: dataset.lineNumber + dataset.side check
298
+ const ds = row.dataset || {};
299
+ if (wantedSide === 'LEFT' && ds.oldLineNumber) {
300
+ lineNum = parseInt(ds.oldLineNumber, 10);
301
+ } else if (wantedSide === 'RIGHT' && ds.newLineNumber) {
302
+ lineNum = parseInt(ds.newLineNumber, 10);
303
+ } else if (ds.lineNumber && (!ds.side || ds.side === wantedSide)) {
304
+ lineNum = parseInt(ds.lineNumber, 10);
305
+ }
306
+ }
307
+ if (lineNum === line) return row;
308
+ }
309
+ return null;
310
+ }
311
+
312
+ /**
313
+ * Compute the (file, line, side) anchor for a comment, accounting for
314
+ * outdated rows that fall back to the original anchor.
315
+ * Returns null if the comment cannot be anchored.
316
+ */
317
+ _resolveAnchor(comment) {
318
+ if (!comment || !comment.file) return null;
319
+ const side = comment.side || 'RIGHT';
320
+ const outdated = comment.is_outdated === 1 || comment.is_outdated === true;
321
+
322
+ // Treat `is_outdated` as a hint about which anchor to PREFER, not a
323
+ // strict switch. The GitHub adapter couples is_outdated with line_end
324
+ // being null, but future adapters (GitLab, Linear) won't necessarily
325
+ // observe that invariant. Falling back to the other anchor when our
326
+ // preferred one is missing keeps cells like "outdated row that still
327
+ // has a live line_end" or "non-outdated row missing line_end"
328
+ // renderable instead of silently dropped.
329
+ const live = Number.isFinite(comment.line_end) ? comment.line_end : null;
330
+ const orig = Number.isFinite(comment.original_line_end) ? comment.original_line_end : null;
331
+ const line = outdated ? (orig ?? live) : (live ?? orig);
332
+ if (line == null) return null;
333
+ return { file: comment.file, line, side };
334
+ }
335
+
336
+ /**
337
+ * Render a single thread (root + replies) into the diff.
338
+ *
339
+ * When the anchor line isn't initially in the DOM (collapsed context for
340
+ * outdated comments is the common case) we ask the PR manager to expand
341
+ * any hidden lines that cover the anchor and re-look-up before giving up.
342
+ * If still missing we drop to a file-level fallback so the discussion
343
+ * stays visible rather than disappearing silently.
344
+ *
345
+ * Async because `PRManager.ensureLinesVisible` returns a Promise — we
346
+ * MUST await it before re-looking-up, otherwise the freshly-materialized
347
+ * row is not yet in the DOM when we ask for it.
348
+ * @private
349
+ */
350
+ async _renderThread(thread) {
351
+ if (!thread) return;
352
+ const anchor = this._resolveAnchor(thread);
353
+ if (!anchor) {
354
+ const key = `${thread.source}:${thread.id}`;
355
+ if (!this._anchorWarnings.has(key)) {
356
+ this._anchorWarnings.add(key);
357
+ if (typeof console !== 'undefined') {
358
+ console.warn('[ExternalCommentManager] Skipping thread with no anchor', thread.id);
359
+ }
360
+ }
361
+ return;
362
+ }
363
+
364
+ let targetRow = this._findDiffLineRow(anchor.file, anchor.line, anchor.side);
365
+
366
+ // Outdated discussions often land on lines that the diff renderer
367
+ // collapsed behind an "expand context" gap. Ask the PR manager (when
368
+ // present) to expand the gap, then await before retrying the lookup.
369
+ // PRManager.ensureLinesVisible takes an array of items and returns a
370
+ // Promise — must await or the row isn't in the DOM yet.
371
+ if (!targetRow && this._canEnsureLinesVisible()) {
372
+ try {
373
+ await window.prManager.ensureLinesVisible([
374
+ {
375
+ file: anchor.file,
376
+ line_start: anchor.line,
377
+ line_end: anchor.line,
378
+ side: anchor.side
379
+ }
380
+ ]);
381
+ } catch (err) {
382
+ if (typeof console !== 'undefined') {
383
+ console.warn('[ExternalCommentManager] ensureLinesVisible threw, falling back', err);
384
+ }
385
+ }
386
+ targetRow = this._findDiffLineRow(anchor.file, anchor.line, anchor.side);
387
+ }
388
+
389
+ if (!targetRow) {
390
+ // File-level fallback: outdated discussions that we can't anchor
391
+ // precisely still need to be discoverable. Render them at the top of
392
+ // the file wrapper so the reviewer can find them via the file panel.
393
+ const fallbackTarget = this._resolveFileFallbackTarget(anchor.file);
394
+ if (fallbackTarget) {
395
+ const externalRow = this._buildThreadRow(thread, fallbackTarget);
396
+ if (externalRow) {
397
+ externalRow.classList.add('external-comment-row--file-fallback');
398
+ this._insertAtOrderedPosition(fallbackTarget, externalRow);
399
+ return;
400
+ }
401
+ }
402
+
403
+ if (typeof console !== 'undefined') {
404
+ console.warn(`[ExternalCommentManager] Could not find diff row for ${anchor.file}:${anchor.line} (${anchor.side})`);
405
+ }
406
+ return;
407
+ }
408
+
409
+ const externalRow = this._buildThreadRow(thread, targetRow);
410
+ if (!externalRow) return;
411
+ this._insertAtOrderedPosition(targetRow, externalRow);
412
+ }
413
+
414
+ /**
415
+ * @returns {boolean} true when `window.prManager.ensureLinesVisible` is callable.
416
+ * @private
417
+ */
418
+ _canEnsureLinesVisible() {
419
+ return (
420
+ typeof window !== 'undefined' &&
421
+ window.prManager &&
422
+ typeof window.prManager.ensureLinesVisible === 'function'
423
+ );
424
+ }
425
+
426
+ /**
427
+ * Find a fallback insertion target for a file when no diff row matches
428
+ * the comment's anchor (e.g. outdated comment whose original line is no
429
+ * longer in the diff at all). Returns the first <tr> in the file
430
+ * wrapper's table so the thread renders at file-level. Returns null when
431
+ * the file wrapper itself isn't in the DOM.
432
+ * @private
433
+ */
434
+ _resolveFileFallbackTarget(file) {
435
+ if (typeof document === 'undefined' || !file) return null;
436
+ let fileElement = null;
437
+ if (typeof window !== 'undefined' && window.DiffRenderer && typeof window.DiffRenderer.findFileElement === 'function') {
438
+ fileElement = window.DiffRenderer.findFileElement(file);
439
+ }
440
+ if (!fileElement) {
441
+ try {
442
+ const escaped = (typeof globalThis !== 'undefined' && globalThis.CSS?.escape)
443
+ ? globalThis.CSS.escape(file)
444
+ : file;
445
+ fileElement = document.querySelector(`[data-file-name="${escaped}"]`);
446
+ } catch {
447
+ fileElement = null;
448
+ }
449
+ }
450
+ if (!fileElement) return null;
451
+ return fileElement.querySelector('tr');
452
+ }
453
+
454
+ /**
455
+ * Build the `<tr class="external-comment-row">` wrapper containing the
456
+ * root comment, replies, and per-thread actions.
457
+ * @private
458
+ */
459
+ _buildThreadRow(thread, targetRow) {
460
+ if (typeof document === 'undefined') return null;
461
+ const tr = document.createElement('tr');
462
+ tr.className = 'external-comment-row';
463
+ tr.dataset.threadId = thread.id != null ? String(thread.id) : '';
464
+ tr.dataset.source = thread.source || '';
465
+
466
+ const td = document.createElement('td');
467
+ // Match user-comment-row colspan of 4 used elsewhere in the diff table
468
+ td.colSpan = this._resolveColSpan(targetRow);
469
+ td.className = 'external-comment-cell';
470
+
471
+ const threadEl = document.createElement('div');
472
+ threadEl.className = 'external-comment-thread';
473
+
474
+ // Root comment
475
+ const rootEl = this._buildCommentElement(thread, { isReply: false });
476
+ threadEl.appendChild(rootEl);
477
+
478
+ // Replies
479
+ const replies = Array.isArray(thread.replies) ? thread.replies : [];
480
+ for (const reply of replies) {
481
+ const replyEl = this._buildCommentElement(reply, { isReply: true });
482
+ threadEl.appendChild(replyEl);
483
+ }
484
+
485
+ td.appendChild(threadEl);
486
+ tr.appendChild(td);
487
+ return tr;
488
+ }
489
+
490
+ /**
491
+ * Best-effort colSpan resolution. The diff renderer uses a 4-column
492
+ * layout (matches `.user-comment-cell` colSpan=4). Fall back to that
493
+ * if we can't introspect the target row's column count.
494
+ * @private
495
+ */
496
+ _resolveColSpan(targetRow) {
497
+ try {
498
+ const cells = targetRow?.cells || targetRow?.children;
499
+ if (cells && cells.length) return cells.length;
500
+ } catch {
501
+ // ignore
502
+ }
503
+ return 4;
504
+ }
505
+
506
+ /**
507
+ * Build a single `.external-comment` element (root or reply).
508
+ * @private
509
+ */
510
+ _buildCommentElement(comment, { isReply }) {
511
+ const escapeHtml = this._escapeHtml;
512
+ const source = comment.source || 'github';
513
+
514
+ const el = document.createElement('div');
515
+ const classes = ['external-comment', `source-${source}`];
516
+ if (isReply) classes.push('is-reply');
517
+ if (comment.is_outdated === 1 || comment.is_outdated === true) classes.push('is-outdated');
518
+ el.className = classes.join(' ');
519
+ el.dataset.commentId = comment.id != null ? String(comment.id) : '';
520
+ el.dataset.source = source;
521
+ if (comment.external_id != null) el.dataset.externalId = String(comment.external_id);
522
+
523
+ // ---- Header ----
524
+ const header = document.createElement('div');
525
+ header.className = 'external-comment-header';
526
+
527
+ const headerLeft = document.createElement('div');
528
+ headerLeft.className = 'external-comment-header-left';
529
+
530
+ // Author (link only when the URL is safe — falls back to plain text
531
+ // for `javascript:`, `data:`, or other non-navigational schemes).
532
+ if (comment.author_url && this._isSafeUrl(comment.author_url)) {
533
+ const a = document.createElement('a');
534
+ a.className = 'external-comment-author';
535
+ a.href = comment.author_url;
536
+ a.target = '_blank';
537
+ a.rel = 'noopener noreferrer';
538
+ a.textContent = comment.author || '';
539
+ headerLeft.appendChild(a);
540
+ } else {
541
+ const span = document.createElement('span');
542
+ span.className = 'external-comment-author';
543
+ span.textContent = comment.author || '';
544
+ headerLeft.appendChild(span);
545
+ }
546
+
547
+ // Outdated badge
548
+ if (comment.is_outdated === 1 || comment.is_outdated === true) {
549
+ const badge = document.createElement('span');
550
+ badge.className = 'external-comment-outdated-badge';
551
+ badge.title = 'This comment was made against an earlier version of the file';
552
+ badge.textContent = 'outdated';
553
+ headerLeft.appendChild(badge);
554
+ }
555
+
556
+ // Timestamp
557
+ const tsText = this._formatTimestamp(comment.external_created_at);
558
+ if (tsText) {
559
+ const ts = document.createElement('span');
560
+ ts.className = 'external-comment-timestamp';
561
+ ts.textContent = tsText;
562
+ if (comment.external_created_at) ts.title = comment.external_created_at;
563
+ headerLeft.appendChild(ts);
564
+ }
565
+
566
+ header.appendChild(headerLeft);
567
+
568
+ // Right side of header: chat-about + permalink, mirroring the
569
+ // header-right layout used by user comments and AI suggestions.
570
+ const headerRight = document.createElement('div');
571
+ headerRight.className = 'external-comment-header-right';
572
+
573
+ const chatBtn = this._buildChatCommentButton(comment, { isReply });
574
+ headerRight.appendChild(chatBtn);
575
+
576
+ if (comment.external_url && this._isSafeUrl(comment.external_url)) {
577
+ const link = document.createElement('a');
578
+ link.className = 'external-comment-permalink';
579
+ link.href = comment.external_url;
580
+ link.target = '_blank';
581
+ link.rel = 'noopener noreferrer';
582
+ link.title = 'Open in source system';
583
+ link.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M7.75 2.5a.75.75 0 0 0 0 1.5h2.69L5.22 9.22a.75.75 0 1 0 1.06 1.06L11.5 5.06v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75ZM3.75 3A1.75 1.75 0 0 0 2 4.75v7.5C2 13.216 2.784 14 3.75 14h7.5A1.75 1.75 0 0 0 13 12.25v-3.5a.75.75 0 0 0-1.5 0v3.5a.25.25 0 0 1-.25.25h-7.5a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25h3.5a.75.75 0 0 0 0-1.5Z"/></svg>';
584
+ headerRight.appendChild(link);
585
+ }
586
+
587
+ header.appendChild(headerRight);
588
+ el.appendChild(header);
589
+
590
+ // ---- Body ----
591
+ const body = document.createElement('div');
592
+ body.className = 'external-comment-body';
593
+ const bodyText = comment.body || '';
594
+ if (typeof window !== 'undefined' && typeof window.renderMarkdown === 'function') {
595
+ body.innerHTML = window.renderMarkdown(bodyText);
596
+ } else {
597
+ body.textContent = bodyText;
598
+ }
599
+ el.appendChild(body);
600
+
601
+ return el;
602
+ }
603
+
604
+ /**
605
+ * Build the per-comment chat button.
606
+ * - On the thread root (`isReply: false`), chats about the whole thread.
607
+ * - On a reply, chats about just that reply.
608
+ * @private
609
+ */
610
+ _buildChatCommentButton(comment, { isReply } = {}) {
611
+ const btn = document.createElement('button');
612
+ btn.type = 'button';
613
+ btn.className = 'btn-chat-comment external-comment-chat-btn';
614
+ btn.title = isReply ? 'Chat about this comment' : 'Chat about thread';
615
+ btn.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/></svg>';
616
+
617
+ btn.addEventListener('click', (e) => {
618
+ e.stopPropagation();
619
+ if (isReply) {
620
+ this._openCommentChat(comment);
621
+ } else {
622
+ this._openThreadChat(comment);
623
+ }
624
+ });
625
+ return btn;
626
+ }
627
+
628
+ /**
629
+ * Dispatch chat-about-comment to the chat panel using the canonical
630
+ * `commentContext` shape (see plans/fetch-external-review-comments.md
631
+ * § 10 and Phase 4 task spec).
632
+ * @private
633
+ */
634
+ _openCommentChat(comment) {
635
+ const panel = this._resolveChatPanel();
636
+ if (!panel || typeof panel.open !== 'function') {
637
+ // The panel is gone — surface that to the reviewer instead of
638
+ // silently dropping the click. Buttons should also be CSS-hidden via
639
+ // [data-chat='disabled'], this is the backstop for racing state.
640
+ this._toast('Chat is unavailable', 'warn');
641
+ return;
642
+ }
643
+ const outdated = comment.is_outdated === 1 || comment.is_outdated === true;
644
+ panel.open({
645
+ commentContext: {
646
+ commentId: comment.id,
647
+ body: comment.body,
648
+ file: comment.file,
649
+ side: comment.side || 'RIGHT',
650
+ line_start: outdated ? comment.original_line_start : comment.line_start,
651
+ line_end: outdated ? comment.original_line_end : comment.line_end,
652
+ source: 'external',
653
+ externalSource: comment.source,
654
+ author: comment.author,
655
+ externalUrl: comment.external_url,
656
+ isOutdated: !!outdated,
657
+ },
658
+ });
659
+ }
660
+
661
+ /**
662
+ * Dispatch chat-about-thread to the chat panel using the canonical
663
+ * `threadContext` shape.
664
+ * @private
665
+ */
666
+ _openThreadChat(root) {
667
+ const panel = this._resolveChatPanel();
668
+ if (!panel || typeof panel.open !== 'function') {
669
+ this._toast('Chat is unavailable', 'warn');
670
+ return;
671
+ }
672
+ const outdated = root.is_outdated === 1 || root.is_outdated === true;
673
+ const replies = Array.isArray(root.replies) ? root.replies : [];
674
+ panel.open({
675
+ threadContext: {
676
+ rootId: root.id,
677
+ source: 'external',
678
+ externalSource: root.source,
679
+ file: root.file,
680
+ side: root.side || 'RIGHT',
681
+ line_start: outdated ? root.original_line_start : root.line_start,
682
+ line_end: outdated ? root.original_line_end : root.line_end,
683
+ comments: [
684
+ {
685
+ author: root.author,
686
+ body: root.body,
687
+ isOutdated: !!outdated,
688
+ externalUrl: root.external_url,
689
+ externalCreatedAt: root.external_created_at,
690
+ },
691
+ ...replies.map((r) => ({
692
+ author: r.author,
693
+ body: r.body,
694
+ isOutdated: !!(r.is_outdated === 1 || r.is_outdated === true),
695
+ externalUrl: r.external_url,
696
+ externalCreatedAt: r.external_created_at,
697
+ })),
698
+ ],
699
+ },
700
+ });
701
+ }
702
+
703
+ /**
704
+ * Resolve the chat panel reference late so callers that attach
705
+ * `window.chatPanel` after this manager is constructed still work.
706
+ * @private
707
+ */
708
+ _resolveChatPanel() {
709
+ if (this.chatPanel) return this.chatPanel;
710
+ if (typeof window !== 'undefined' && window.chatPanel) {
711
+ this.chatPanel = window.chatPanel;
712
+ return this.chatPanel;
713
+ }
714
+ return null;
715
+ }
716
+
717
+ /**
718
+ * Insert the external comment row at the correct position after the diff
719
+ * line, preserving the ordering rule:
720
+ * AI suggestions → user comments → external comments
721
+ *
722
+ * Walk forward from `targetRow.nextSibling` while we see rows that should
723
+ * come BEFORE us (AI suggestion rows, user comment rows, or already-
724
+ * existing external comment rows for the same diff line). Insert before
725
+ * the first non-comment row we encounter, or at the end of the table.
726
+ * @private
727
+ */
728
+ _insertAtOrderedPosition(targetRow, externalRow) {
729
+ const parent = targetRow.parentNode;
730
+ if (!parent) return;
731
+ let insertBefore = targetRow.nextSibling;
732
+ while (insertBefore && this._isOwnedCommentRow(insertBefore)) {
733
+ insertBefore = insertBefore.nextSibling;
734
+ }
735
+ if (insertBefore) {
736
+ parent.insertBefore(externalRow, insertBefore);
737
+ } else {
738
+ parent.appendChild(externalRow);
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Returns true if the given node is a comment-like row that belongs
744
+ * to one of the three diff renderers and should remain ABOVE this
745
+ * new external-comment row.
746
+ * @private
747
+ */
748
+ _isOwnedCommentRow(node) {
749
+ if (!node || node.nodeType !== 1 /* ELEMENT_NODE */) return false;
750
+ const cls = node.classList;
751
+ if (!cls) return false;
752
+ return (
753
+ cls.contains('ai-suggestion-row') ||
754
+ cls.contains('user-comment-row') ||
755
+ cls.contains('external-comment-row')
756
+ );
757
+ }
758
+
759
+ // ------------------------------------------------------------------
760
+ // Small helpers
761
+ // ------------------------------------------------------------------
762
+
763
+ /**
764
+ * Show a toast via the project-wide toast helper. Falls back to console
765
+ * when the helper isn't available (e.g. unit tests).
766
+ * @private
767
+ */
768
+ _toast(message, level = 'error') {
769
+ if (typeof window === 'undefined') return;
770
+ const t = window.toast;
771
+ if (t) {
772
+ if (level === 'error' && typeof t.showError === 'function') return t.showError(message);
773
+ if (level === 'warn' && typeof t.showWarning === 'function') return t.showWarning(message);
774
+ if (level === 'info' && typeof t.showInfo === 'function') return t.showInfo(message);
775
+ if (level === 'success' && typeof t.showSuccess === 'function') return t.showSuccess(message);
776
+ }
777
+ if (typeof window.showToast === 'function') {
778
+ return window.showToast(message, level);
779
+ }
780
+ // Last-resort log only — never throw from a toast helper.
781
+ if (typeof console !== 'undefined') {
782
+ console.warn(`[ExternalCommentManager] toast(${level}): ${message}`);
783
+ }
784
+ }
785
+
786
+ /**
787
+ * Format an ISO timestamp into a short relative description.
788
+ * Falls back to the raw string on parse failure.
789
+ * @private
790
+ */
791
+ _formatTimestamp(iso) {
792
+ if (!iso) return '';
793
+ const date = new Date(iso);
794
+ if (isNaN(date.getTime())) return iso;
795
+ const now = Date.now();
796
+ const diffMs = now - date.getTime();
797
+ const seconds = Math.round(diffMs / 1000);
798
+ if (seconds < 60) return 'just now';
799
+ const minutes = Math.round(seconds / 60);
800
+ if (minutes < 60) return `${minutes}m ago`;
801
+ const hours = Math.round(minutes / 60);
802
+ if (hours < 24) return `${hours}h ago`;
803
+ const days = Math.round(hours / 24);
804
+ if (days < 30) return `${days}d ago`;
805
+ const months = Math.round(days / 30);
806
+ if (months < 12) return `${months}mo ago`;
807
+ const years = Math.round(months / 12);
808
+ return `${years}y ago`;
809
+ }
810
+
811
+ /**
812
+ * Allow only http/https/mailto URLs. Used to gate `<a href>` attributes
813
+ * built from server-supplied data so a malicious upstream cannot smuggle
814
+ * `javascript:` or `data:` URLs into our DOM.
815
+ * @private
816
+ */
817
+ _isSafeUrl(url) {
818
+ if (typeof url !== 'string' || !url) return false;
819
+ const trimmed = url.trim();
820
+ if (!trimmed) return false;
821
+ // Relative URLs are safe — they resolve under our origin.
822
+ if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('?')) return true;
823
+ try {
824
+ const u = new URL(trimmed, (typeof window !== 'undefined' && window.location) ? window.location.href : 'http://localhost/');
825
+ return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'mailto:';
826
+ } catch {
827
+ return false;
828
+ }
829
+ }
830
+
831
+ /**
832
+ * Minimal HTML escape used as a fallback when window helpers aren't loaded.
833
+ * @private
834
+ */
835
+ _escapeHtml(s) {
836
+ if (s == null) return '';
837
+ return String(s)
838
+ .replace(/&/g, '&amp;')
839
+ .replace(/</g, '&lt;')
840
+ .replace(/>/g, '&gt;')
841
+ .replace(/"/g, '&quot;')
842
+ .replace(/'/g, '&#39;');
843
+ }
844
+ }
845
+
846
+ // Browser singleton — instantiated on DOMContentLoaded so the PR page
847
+ // can immediately `await window.externalCommentManager.loadAndRender()`
848
+ // once the review id is known. Callers should set `reviewId` and
849
+ // `sources` before invoking `loadAndRender` (see PR page wiring).
850
+ if (typeof window !== 'undefined') {
851
+ window.ExternalCommentManager = ExternalCommentManager;
852
+
853
+ if (typeof document !== 'undefined' && !window.externalCommentManager) {
854
+ const initSingleton = () => {
855
+ if (!window.externalCommentManager) {
856
+ window.externalCommentManager = new ExternalCommentManager({});
857
+ }
858
+ };
859
+ if (document.readyState === 'loading') {
860
+ document.addEventListener('DOMContentLoaded', initSingleton);
861
+ } else {
862
+ initSingleton();
863
+ }
864
+ }
865
+ }
866
+
867
+ // Export for Node.js testing
868
+ if (typeof module !== 'undefined' && module.exports) {
869
+ module.exports = { ExternalCommentManager };
870
+ }