@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.
@@ -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