@in-the-loop-labs/pair-review 3.4.1 → 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/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 +2 -1
- package/src/database.js +566 -2
- package/src/external/github-adapter.js +152 -0
- package/src/external/index.js +37 -0
- package/src/github/client.js +77 -1
- package/src/routes/config.js +27 -0
- package/src/routes/external-comments.js +394 -0
- package/src/server.js +9 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* External Comment Routes
|
|
4
|
+
*
|
|
5
|
+
* Endpoints for syncing and reading review comments from external systems
|
|
6
|
+
* (currently GitHub PR review comments; designed for GitLab/Linear/etc.).
|
|
7
|
+
* External comments are stored as a read-only mirror in the
|
|
8
|
+
* `external_comments` table — see ExternalCommentRepository.
|
|
9
|
+
*
|
|
10
|
+
* This file is shared between two implementation agents:
|
|
11
|
+
* --- SYNC ROUTES --- : POST /api/reviews/:reviewId/external-comments/sync
|
|
12
|
+
* --- FETCH ROUTES --- : GET /api/reviews/:reviewId/external-comments
|
|
13
|
+
*
|
|
14
|
+
* Canonical PR-mode predicate: `isPRMode(review)`. Use it from EVERY route
|
|
15
|
+
* in this file — sync and fetch must agree on what counts as a PR review,
|
|
16
|
+
* otherwise the two endpoints diverge on local-mode handling.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const express = require('express');
|
|
20
|
+
const {
|
|
21
|
+
ExternalCommentRepository,
|
|
22
|
+
ReviewRepository,
|
|
23
|
+
withTransaction
|
|
24
|
+
} = require('../database');
|
|
25
|
+
const { getAdapter } = require('../external');
|
|
26
|
+
const { GitHubApiError } = require('../github/client');
|
|
27
|
+
const logger = require('../utils/logger');
|
|
28
|
+
|
|
29
|
+
const router = express.Router();
|
|
30
|
+
|
|
31
|
+
// --- SYNC ROUTES ---
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default dependencies for the sync flow. Tests override these via the
|
|
35
|
+
* `externalCommentsDeps` Express app setting (or by passing `_deps` to
|
|
36
|
+
* `executeSync` directly). Credential resolution is delegated to the
|
|
37
|
+
* adapter via `adapter.resolveCredentials(config)` — keeps the route
|
|
38
|
+
* source-agnostic and lets each adapter name its own env var in errors.
|
|
39
|
+
*/
|
|
40
|
+
const defaults = {
|
|
41
|
+
getAdapter
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* In-flight sync registry keyed by `${reviewId}:${source}`.
|
|
46
|
+
*
|
|
47
|
+
* Page-load auto-sync and the manual "refresh external comments" button
|
|
48
|
+
* can race. When a sync is already running for a (reviewId, source) pair,
|
|
49
|
+
* a second caller awaits the same promise instead of making a duplicate
|
|
50
|
+
* GitHub round-trip. This also avoids two parent-resolution passes briefly
|
|
51
|
+
* interleaving (the hazard called out in the plan).
|
|
52
|
+
*
|
|
53
|
+
* Entries are removed in a `finally` so failures do not permanently block
|
|
54
|
+
* retries.
|
|
55
|
+
*
|
|
56
|
+
* @type {Map<string, Promise<{count: number, lostAnchors: number, syncedAt: string}>>}
|
|
57
|
+
*/
|
|
58
|
+
const inFlight = new Map();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Global write-phase serializer. The per-key `inFlight` map only dedupes
|
|
62
|
+
* matching (reviewId, source) pairs — two syncs for DIFFERENT reviews can
|
|
63
|
+
* still race their write phases on the same better-sqlite3 connection,
|
|
64
|
+
* which cannot nest BEGIN…COMMIT (throws "cannot start a transaction
|
|
65
|
+
* within a transaction"). We do all network I/O and mapping outside the
|
|
66
|
+
* transaction (cheap to interleave), then chain transactional writes
|
|
67
|
+
* through this single promise so only one BEGIN…COMMIT runs at a time.
|
|
68
|
+
*
|
|
69
|
+
* Per-db serialization would be cleaner if the route handled multiple DBs,
|
|
70
|
+
* but pair-review uses one SQLite file per process; a module-level chain
|
|
71
|
+
* is sufficient and avoids a per-db WeakMap dance.
|
|
72
|
+
*/
|
|
73
|
+
let writeChain = Promise.resolve();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Typed 400 error. Mirrors the GitHubApiError shape (name/message/status)
|
|
77
|
+
* so the route's catch ladder can fan-out by `instanceof` rather than
|
|
78
|
+
* string-sniff. Used for client-correctable problems (malformed inputs)
|
|
79
|
+
* that previously bubbled out as plain Error → 500.
|
|
80
|
+
*/
|
|
81
|
+
class BadRequestError extends Error {
|
|
82
|
+
constructor(message) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = 'BadRequestError';
|
|
85
|
+
this.status = 400;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Canonical PR-mode predicate. Both routes in this file (sync + fetch) must
|
|
91
|
+
* use this same check so the two endpoints agree on what a "PR review" is.
|
|
92
|
+
*
|
|
93
|
+
* A row is PR-mode iff:
|
|
94
|
+
* - it has a numeric `pr_number`
|
|
95
|
+
* - it has a non-empty `repository`
|
|
96
|
+
* - its `review_type` is not 'local' (default is 'pr')
|
|
97
|
+
* - it has no `local_path` (which would identify a local-mode review)
|
|
98
|
+
*/
|
|
99
|
+
function isPRMode(review) {
|
|
100
|
+
if (!review) return false;
|
|
101
|
+
if (review.review_type && review.review_type !== 'pr') return false;
|
|
102
|
+
if (review.local_path) return false;
|
|
103
|
+
if (!Number.isInteger(review.pr_number)) return false;
|
|
104
|
+
if (!review.repository) return false;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run a full sync for one (reviewId, source) pair. Idempotent. Throws
|
|
110
|
+
* domain errors (Error / GitHubApiError) — the route handler catches them
|
|
111
|
+
* and maps them to HTTP responses.
|
|
112
|
+
*
|
|
113
|
+
* @param {Object} params
|
|
114
|
+
* @param {Object} params.db - Database handle
|
|
115
|
+
* @param {Object} params.config - Server config (for token lookup)
|
|
116
|
+
* @param {Object} params.review - Validated review row
|
|
117
|
+
* @param {string} params.source - Adapter source name (e.g. 'github')
|
|
118
|
+
* @param {Object} [params._deps] - Test overrides for { GitHubClient, getGitHubToken, getAdapter }
|
|
119
|
+
* @returns {Promise<{count: number, lostAnchors: number, syncedAt: string}>}
|
|
120
|
+
*/
|
|
121
|
+
async function executeSync({ db, config, review, source, _deps }) {
|
|
122
|
+
const deps = { ...defaults, ..._deps };
|
|
123
|
+
|
|
124
|
+
// Look up adapter — throws on unknown sources, caught by the route.
|
|
125
|
+
const adapter = deps.getAdapter(source);
|
|
126
|
+
|
|
127
|
+
// Delegate credential resolution to the adapter so the route stays
|
|
128
|
+
// source-agnostic and each adapter can name its own env var in errors.
|
|
129
|
+
// The adapter throws (e.g. GitHubApiError 401) when credentials are
|
|
130
|
+
// missing — the route's catch maps it to a 401 response.
|
|
131
|
+
const { client } = adapter.resolveCredentials(config || {}, _deps);
|
|
132
|
+
|
|
133
|
+
const [owner, repo] = String(review.repository).split('/');
|
|
134
|
+
if (!owner || !repo) {
|
|
135
|
+
throw new BadRequestError(
|
|
136
|
+
`Invalid review.repository "${review.repository}"; expected "owner/repo"`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const apiRows = await adapter.fetchComments({
|
|
141
|
+
client,
|
|
142
|
+
owner,
|
|
143
|
+
repo,
|
|
144
|
+
pull_number: review.pr_number
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Map raw API rows and filter out "lost anchors" (BOTH current AND original
|
|
148
|
+
// position fields null — unrenderable). Counting them lets the UI tell the
|
|
149
|
+
// user why their visible count differs from GitHub's reported total.
|
|
150
|
+
// Track external_ids seen this sync so we can prune rows that upstream
|
|
151
|
+
// has removed (or that we no longer render because they lost anchors)
|
|
152
|
+
// inside the same transaction as the upserts.
|
|
153
|
+
let lostAnchors = 0;
|
|
154
|
+
const mappedRows = [];
|
|
155
|
+
const seenExternalIds = new Set();
|
|
156
|
+
for (const apiRow of apiRows || []) {
|
|
157
|
+
let mapped;
|
|
158
|
+
try {
|
|
159
|
+
mapped = adapter.mapComment(apiRow);
|
|
160
|
+
} catch (mapError) {
|
|
161
|
+
// A malformed row from the source shouldn't kill the whole sync — log
|
|
162
|
+
// it and keep going. The adapter only throws for genuinely malformed
|
|
163
|
+
// rows (e.g. missing required `path`).
|
|
164
|
+
logger.warn(`External comment adapter ${source} could not map row: ${mapError.message}`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (mapped.line_end == null && mapped.original_line_end == null) {
|
|
169
|
+
lostAnchors++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
mappedRows.push(mapped);
|
|
173
|
+
seenExternalIds.add(String(mapped.external_id));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const repository = new ExternalCommentRepository(db);
|
|
177
|
+
const syncedAt = new Date().toISOString();
|
|
178
|
+
|
|
179
|
+
// Write phase: upsert all rows, conditionally prune rows missing from
|
|
180
|
+
// this snapshot, then resolve parents. Wrapped in a single transaction
|
|
181
|
+
// so concurrent readers never see a partial mirror.
|
|
182
|
+
//
|
|
183
|
+
// Empty-snapshot prune is intentionally skipped (`seenExternalIds.size`
|
|
184
|
+
// gate below). A transient empty response from upstream (e.g. GitHub
|
|
185
|
+
// briefly returning [] while a PR is being reorganized) used to wipe the
|
|
186
|
+
// entire local mirror for (review_id, source). Skipping the prune turns
|
|
187
|
+
// that transient into a no-op; the non-empty case still prunes rows that
|
|
188
|
+
// upstream removed.
|
|
189
|
+
//
|
|
190
|
+
// We serialize the transactional write phase through a module-level
|
|
191
|
+
// promise chain because better-sqlite3 cannot nest BEGIN…COMMIT — two
|
|
192
|
+
// concurrent syncs for DIFFERENT reviews would otherwise collide here.
|
|
193
|
+
let deletedCount = 0;
|
|
194
|
+
const performWrites = async () => {
|
|
195
|
+
await withTransaction(db, async () => {
|
|
196
|
+
for (const mapped of mappedRows) {
|
|
197
|
+
await repository.upsert(review.id, source, mapped);
|
|
198
|
+
}
|
|
199
|
+
if (seenExternalIds.size > 0) {
|
|
200
|
+
deletedCount = await repository.deleteMissing(review.id, source, seenExternalIds);
|
|
201
|
+
}
|
|
202
|
+
await repository.resolveParents(review.id, source);
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Chain the current write phase onto whatever's already pending. The
|
|
207
|
+
// chain swallows errors at the join point so a failed sync doesn't
|
|
208
|
+
// permanently break the next caller's link in the chain — the *current*
|
|
209
|
+
// caller still observes its own failure via the `await` below.
|
|
210
|
+
const previous = writeChain;
|
|
211
|
+
const myWrite = previous.then(performWrites, performWrites);
|
|
212
|
+
writeChain = myWrite.catch(() => {});
|
|
213
|
+
await myWrite;
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
count: mappedRows.length,
|
|
217
|
+
lostAnchors,
|
|
218
|
+
deleted: deletedCount,
|
|
219
|
+
syncedAt
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Middleware: validate `:reviewId`, attach `req.review`.
|
|
225
|
+
*
|
|
226
|
+
* Mirrors the pattern in `routes/reviews.js` but lives here to keep the
|
|
227
|
+
* sync route self-contained. The fetch route below intentionally uses a
|
|
228
|
+
* different (older) shape because it predates this middleware.
|
|
229
|
+
*/
|
|
230
|
+
async function validateReviewId(req, res, next) {
|
|
231
|
+
try {
|
|
232
|
+
const reviewId = parseInt(req.params.reviewId, 10);
|
|
233
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
234
|
+
return res.status(400).json({ error: 'Invalid review ID' });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const db = req.app.get('db');
|
|
238
|
+
const reviewRepo = new ReviewRepository(db);
|
|
239
|
+
const review = await reviewRepo.getReview(reviewId);
|
|
240
|
+
|
|
241
|
+
if (!review) {
|
|
242
|
+
return res.status(404).json({ error: 'Review not found' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
req.review = review;
|
|
246
|
+
req.reviewId = reviewId;
|
|
247
|
+
next();
|
|
248
|
+
} catch (error) {
|
|
249
|
+
next(error);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* POST /api/reviews/:reviewId/external-comments/sync?source=github
|
|
255
|
+
*
|
|
256
|
+
* Fetches inline review comments from the external source and upserts them
|
|
257
|
+
* into the local mirror. Idempotent. Returns
|
|
258
|
+
* `{ count, lostAnchors, syncedAt }`. See module header for the
|
|
259
|
+
* concurrent-sync guard contract.
|
|
260
|
+
*/
|
|
261
|
+
router.post('/api/reviews/:reviewId/external-comments/sync', validateReviewId, async (req, res) => {
|
|
262
|
+
const source = (req.query.source || 'github').toString();
|
|
263
|
+
const review = req.review;
|
|
264
|
+
|
|
265
|
+
if (!isPRMode(review)) {
|
|
266
|
+
return res.status(400).json({ error: 'External comment sync requires a PR mode review' });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const db = req.app.get('db');
|
|
270
|
+
const config = req.app.get('config') || {};
|
|
271
|
+
const key = `${review.id}:${source}`;
|
|
272
|
+
|
|
273
|
+
// Tests inject dependency overrides via the app setting
|
|
274
|
+
// `externalCommentsDeps`. In production this is undefined and the module
|
|
275
|
+
// defaults win.
|
|
276
|
+
const _deps = req.app.get('externalCommentsDeps') || undefined;
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
let promise = inFlight.get(key);
|
|
280
|
+
if (!promise) {
|
|
281
|
+
promise = executeSync({ db, config, review, source, _deps })
|
|
282
|
+
.finally(() => {
|
|
283
|
+
// Remove the slot only after the promise settles — concurrent
|
|
284
|
+
// callers awaiting this entry must see the same outcome.
|
|
285
|
+
inFlight.delete(key);
|
|
286
|
+
});
|
|
287
|
+
inFlight.set(key, promise);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const result = await promise;
|
|
291
|
+
res.json(result);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
// Unknown source — the adapter dispatcher throws a plain Error.
|
|
294
|
+
if (error && typeof error.message === 'string' && error.message.startsWith('Unknown external comment source:')) {
|
|
295
|
+
logger.warn(`External comments sync rejected: ${error.message}`);
|
|
296
|
+
return res.status(400).json({ error: error.message });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Client-correctable problem (e.g. malformed review.repository).
|
|
300
|
+
// BadRequestError carries status=400 explicitly so we don't fall
|
|
301
|
+
// through to the catch-all 500.
|
|
302
|
+
if (error instanceof BadRequestError) {
|
|
303
|
+
logger.warn(`External comments sync rejected: ${error.message}`);
|
|
304
|
+
return res.status(error.status).json({ error: error.message });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (error instanceof GitHubApiError) {
|
|
308
|
+
logger.error(`External comments sync GitHub error (${error.status}): ${error.message}`);
|
|
309
|
+
|
|
310
|
+
// Single mapping path: trust GitHubApiError.message, which
|
|
311
|
+
// `handleApiError` already populates with the retry-after seconds on
|
|
312
|
+
// 429s and the auth/rate context on other failures. The previously
|
|
313
|
+
// separate 429 branch read `error.retryAfter`, which GitHubApiError
|
|
314
|
+
// doesn't carry — dead code that masked the real message.
|
|
315
|
+
if (error.status >= 400 && error.status < 600) {
|
|
316
|
+
return res.status(error.status).json({ error: error.message });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return res.status(500).json({ error: error.message });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
logger.error('External comments sync failed:', error);
|
|
323
|
+
res.status(500).json({ error: error.message || 'Failed to sync external comments' });
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// --- FETCH ROUTES ---
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* GET /api/reviews/:reviewId/external-comments?source=github
|
|
331
|
+
*
|
|
332
|
+
* Returns external comments persisted for a review, grouped into threads.
|
|
333
|
+
* Each thread is a root comment object with all original row fields plus a
|
|
334
|
+
* `replies` array of the same shape.
|
|
335
|
+
*
|
|
336
|
+
* Query params:
|
|
337
|
+
* - source: (optional) filter to one external source (e.g. 'github').
|
|
338
|
+
* If omitted, returns rows from all known sources.
|
|
339
|
+
* If provided but unknown, responds 400.
|
|
340
|
+
*
|
|
341
|
+
* Responses:
|
|
342
|
+
* - 200: { threads: Array<Thread> }
|
|
343
|
+
* - 400: unknown source
|
|
344
|
+
* - 404: review not found
|
|
345
|
+
* - 500: unexpected
|
|
346
|
+
*
|
|
347
|
+
* Local-mode reviews always return { threads: [] } — external comments
|
|
348
|
+
* are a PR-mode concept, but the endpoint is safe to call from local pages.
|
|
349
|
+
*/
|
|
350
|
+
router.get('/api/reviews/:reviewId/external-comments', validateReviewId, async (req, res) => {
|
|
351
|
+
try {
|
|
352
|
+
const reviewId = req.reviewId;
|
|
353
|
+
const review = req.review;
|
|
354
|
+
|
|
355
|
+
const source = req.query.source;
|
|
356
|
+
|
|
357
|
+
// If a source filter is provided, validate it against the adapter registry
|
|
358
|
+
// before touching the DB. Catches typos early with a meaningful message.
|
|
359
|
+
if (source !== undefined && source !== null && source !== '') {
|
|
360
|
+
try {
|
|
361
|
+
getAdapter(source);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
return res.status(400).json({ error: `Unknown external comment source: ${source}` });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Non-PR reviews (local-mode, malformed rows) never have external
|
|
368
|
+
// comments. Return an empty thread list so the frontend can call this
|
|
369
|
+
// endpoint unconditionally. We use the canonical `isPRMode` predicate
|
|
370
|
+
// here so sync and fetch stay in lockstep on what counts as PR mode.
|
|
371
|
+
if (!isPRMode(review)) {
|
|
372
|
+
return res.json({ threads: [] });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const db = req.app.get('db');
|
|
376
|
+
const repo = new ExternalCommentRepository(db);
|
|
377
|
+
const listOptions = {};
|
|
378
|
+
if (source) {
|
|
379
|
+
listOptions.source = source;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const threads = await repo.listThreadsByReview(reviewId, listOptions);
|
|
383
|
+
|
|
384
|
+
res.json({ threads });
|
|
385
|
+
} catch (error) {
|
|
386
|
+
logger.error('Error fetching external comments:', error);
|
|
387
|
+
res.status(500).json({ error: 'Failed to fetch external comments' });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
module.exports = router;
|
|
392
|
+
module.exports.executeSync = executeSync;
|
|
393
|
+
// Exported for tests only — production code should not reach into this map.
|
|
394
|
+
module.exports._inFlight = inFlight;
|
package/src/server.js
CHANGED
|
@@ -349,6 +349,7 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
|
|
|
349
349
|
const contextFilesRoutes = require('./routes/context-files');
|
|
350
350
|
const githubCollectionsRoutes = require('./routes/github-collections');
|
|
351
351
|
const stackAnalysisRoutes = require('./routes/stack-analysis');
|
|
352
|
+
const externalCommentsRoutes = require('./routes/external-comments');
|
|
352
353
|
const { createSoundRouter } = require('./routes/sound');
|
|
353
354
|
|
|
354
355
|
// Initialize chat session manager
|
|
@@ -369,6 +370,14 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
|
|
|
369
370
|
app.use('/', mcpRoutes);
|
|
370
371
|
app.use('/', githubCollectionsRoutes);
|
|
371
372
|
app.use('/', stackAnalysisRoutes);
|
|
373
|
+
// External-comments routes (GitHub PR review-comment sync + fetch) are
|
|
374
|
+
// gated by the `external_comments` config flag. When disabled, the
|
|
375
|
+
// router is not mounted so any GET/POST against `/api/reviews/*/
|
|
376
|
+
// external-comments*` returns 404 — matching the frontend's intent that
|
|
377
|
+
// the feature simply doesn't exist for that user.
|
|
378
|
+
if (config.external_comments !== false) {
|
|
379
|
+
app.use('/', externalCommentsRoutes);
|
|
380
|
+
}
|
|
372
381
|
app.use('/', createSoundRouter());
|
|
373
382
|
app.use('/', prRoutes);
|
|
374
383
|
|