@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/src/database.js CHANGED
@@ -21,7 +21,7 @@ function getDbPath() {
21
21
  /**
22
22
  * Current schema version - increment this when adding new migrations
23
23
  */
24
- const CURRENT_SCHEMA_VERSION = 44;
24
+ const CURRENT_SCHEMA_VERSION = 46;
25
25
 
26
26
  /**
27
27
  * Database schema SQL statements
@@ -299,6 +299,35 @@ const SCHEMA_SQL = {
299
299
  last_fetched_at TEXT,
300
300
  created_at TEXT NOT NULL
301
301
  )
302
+ `,
303
+
304
+ external_comments: `
305
+ CREATE TABLE IF NOT EXISTS external_comments (
306
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
307
+ review_id INTEGER NOT NULL,
308
+ source TEXT NOT NULL,
309
+ external_id TEXT NOT NULL,
310
+ in_reply_to_id TEXT,
311
+ parent_id INTEGER,
312
+ external_url TEXT,
313
+ author TEXT,
314
+ author_url TEXT,
315
+ file TEXT NOT NULL,
316
+ side TEXT,
317
+ line_start INTEGER,
318
+ line_end INTEGER,
319
+ diff_position INTEGER,
320
+ commit_sha TEXT,
321
+ is_outdated INTEGER NOT NULL DEFAULT 0,
322
+ original_line_start INTEGER,
323
+ original_line_end INTEGER,
324
+ original_commit_sha TEXT,
325
+ body TEXT,
326
+ external_created_at TEXT,
327
+ synced_at TEXT,
328
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
329
+ FOREIGN KEY (parent_id) REFERENCES external_comments(id) ON DELETE SET NULL
330
+ )
302
331
  `
303
332
  };
304
333
 
@@ -341,7 +370,11 @@ const INDEX_SQL = [
341
370
  // Worktree pool indexes
342
371
  'CREATE INDEX IF NOT EXISTS idx_worktree_pool_repo ON worktree_pool(repository)',
343
372
  'CREATE INDEX IF NOT EXISTS idx_worktree_pool_status ON worktree_pool(repository, status)',
344
- 'CREATE INDEX IF NOT EXISTS idx_worktree_pool_lru ON worktree_pool(repository, status, last_switched_at)'
373
+ 'CREATE INDEX IF NOT EXISTS idx_worktree_pool_lru ON worktree_pool(repository, status, last_switched_at)',
374
+ // External comments indexes (read-only mirror of GitHub/etc. PR review comments)
375
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_external_comments_unique ON external_comments(review_id, source, external_id)',
376
+ 'CREATE INDEX IF NOT EXISTS idx_external_comments_anchor ON external_comments(review_id, file, line_end)',
377
+ 'CREATE INDEX IF NOT EXISTS idx_external_comments_parent_lookup ON external_comments(review_id, source, in_reply_to_id)'
345
378
  ];
346
379
 
347
380
  /**
@@ -1886,6 +1919,165 @@ const MIGRATIONS = {
1886
1919
  console.log(' Column level_outcomes already exists');
1887
1920
  }
1888
1921
  console.log('Migration to schema version 44 complete');
1922
+ },
1923
+
1924
+ // Migration to version 45: Create external_comments table for read-only mirroring
1925
+ // of GitHub (and future GitLab/Linear/etc.) PR review comments.
1926
+ // Idempotent: CREATE TABLE / CREATE INDEX use IF NOT EXISTS. Wrapped in a
1927
+ // transaction so a crash mid-rebuild won't leave the schema half-built.
1928
+ // No foreign_keys pragma toggle needed — this is a brand-new table with no
1929
+ // existing data to migrate.
1930
+ // NOTE: Migration 46 rebuilds this table to change the parent_id FK from
1931
+ // ON DELETE CASCADE to ON DELETE SET NULL. The constraint here matches
1932
+ // what 46 enforces so fresh installs land at the correct state before 46
1933
+ // runs, and so a databases re-running this migration (idempotent path)
1934
+ // doesn't fight 46's rebuild.
1935
+ 45: (db) => {
1936
+ console.log('Running migration to schema version 45: Create external_comments table...');
1937
+
1938
+ const createSchema = db.transaction(() => {
1939
+ db.exec(`
1940
+ CREATE TABLE IF NOT EXISTS external_comments (
1941
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1942
+ review_id INTEGER NOT NULL,
1943
+ source TEXT NOT NULL,
1944
+ external_id TEXT NOT NULL,
1945
+ in_reply_to_id TEXT,
1946
+ parent_id INTEGER,
1947
+ external_url TEXT,
1948
+ author TEXT,
1949
+ author_url TEXT,
1950
+ file TEXT NOT NULL,
1951
+ side TEXT,
1952
+ line_start INTEGER,
1953
+ line_end INTEGER,
1954
+ diff_position INTEGER,
1955
+ commit_sha TEXT,
1956
+ is_outdated INTEGER NOT NULL DEFAULT 0,
1957
+ original_line_start INTEGER,
1958
+ original_line_end INTEGER,
1959
+ original_commit_sha TEXT,
1960
+ body TEXT,
1961
+ external_created_at TEXT,
1962
+ synced_at TEXT,
1963
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
1964
+ FOREIGN KEY (parent_id) REFERENCES external_comments(id) ON DELETE SET NULL
1965
+ )
1966
+ `);
1967
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_external_comments_unique ON external_comments(review_id, source, external_id)');
1968
+ db.exec('CREATE INDEX IF NOT EXISTS idx_external_comments_anchor ON external_comments(review_id, file, line_end)');
1969
+ db.exec('CREATE INDEX IF NOT EXISTS idx_external_comments_parent_lookup ON external_comments(review_id, source, in_reply_to_id)');
1970
+ });
1971
+ createSchema();
1972
+
1973
+ console.log(' Created external_comments table with indexes');
1974
+ console.log('Migration to schema version 45 complete');
1975
+ },
1976
+
1977
+ // Migration to version 46: Rebuild external_comments so parent_id uses
1978
+ // ON DELETE SET NULL instead of ON DELETE CASCADE.
1979
+ //
1980
+ // Why: the sync route prunes parent rows that disappear from the upstream
1981
+ // snapshot while keeping replies whose external IDs are still present.
1982
+ // Under CASCADE the kept replies were silently destroyed when their
1983
+ // parent was deleted, defeating `listThreadsByReview`'s orphan-promotion
1984
+ // logic and causing silent data loss.
1985
+ //
1986
+ // Follows the SQLite migration safety rules in CLAUDE.md:
1987
+ // - DROP TABLE IF EXISTS for the temp/rebuild table (idempotent restart)
1988
+ // - PRAGMA foreign_keys OFF in try/finally so the toggle is restored
1989
+ // even if the rebuild throws
1990
+ // - Transaction-wrapped DDL so partial failures don't leave a broken schema
1991
+ // - Indexes recreated by name to match production exactly
1992
+ 46: (db) => {
1993
+ console.log('Running migration to schema version 46: Rebuild external_comments with ON DELETE SET NULL on parent_id...');
1994
+
1995
+ // Quick exit when the table doesn't exist yet — migration 45 hasn't run
1996
+ // (older instance with version<45 will run 45 first; if someone reset
1997
+ // user_version backwards this avoids crashing).
1998
+ const tableInfo = db.prepare(
1999
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='external_comments'"
2000
+ ).get();
2001
+ if (!tableInfo) {
2002
+ console.log(' external_comments table not present; nothing to rebuild');
2003
+ console.log('Migration to schema version 46 complete');
2004
+ return;
2005
+ }
2006
+
2007
+ // Disable foreign-key enforcement for the duration of the rebuild so
2008
+ // existing rows can be copied without spurious cascade checks. ALWAYS
2009
+ // restore in a finally — partial restores corrupt later writes.
2010
+ const originalForeignKeys = db.prepare('PRAGMA foreign_keys').get().foreign_keys;
2011
+ db.exec('PRAGMA foreign_keys = OFF');
2012
+ try {
2013
+ const rebuild = db.transaction(() => {
2014
+ // Drop any leftover rebuild table from a previous crashed run.
2015
+ // Without this the next CREATE would either UNIQUE-conflict on its
2016
+ // index name or silently keep stale rows around.
2017
+ db.exec('DROP TABLE IF EXISTS external_comments_new');
2018
+
2019
+ db.exec(`
2020
+ CREATE TABLE external_comments_new (
2021
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2022
+ review_id INTEGER NOT NULL,
2023
+ source TEXT NOT NULL,
2024
+ external_id TEXT NOT NULL,
2025
+ in_reply_to_id TEXT,
2026
+ parent_id INTEGER,
2027
+ external_url TEXT,
2028
+ author TEXT,
2029
+ author_url TEXT,
2030
+ file TEXT NOT NULL,
2031
+ side TEXT,
2032
+ line_start INTEGER,
2033
+ line_end INTEGER,
2034
+ diff_position INTEGER,
2035
+ commit_sha TEXT,
2036
+ is_outdated INTEGER NOT NULL DEFAULT 0,
2037
+ original_line_start INTEGER,
2038
+ original_line_end INTEGER,
2039
+ original_commit_sha TEXT,
2040
+ body TEXT,
2041
+ external_created_at TEXT,
2042
+ synced_at TEXT,
2043
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
2044
+ FOREIGN KEY (parent_id) REFERENCES external_comments(id) ON DELETE SET NULL
2045
+ )
2046
+ `);
2047
+
2048
+ db.exec(`
2049
+ INSERT INTO external_comments_new (
2050
+ id, review_id, source, external_id, in_reply_to_id, parent_id,
2051
+ external_url, author, author_url, file, side,
2052
+ line_start, line_end, diff_position, commit_sha,
2053
+ is_outdated, original_line_start, original_line_end, original_commit_sha,
2054
+ body, external_created_at, synced_at
2055
+ )
2056
+ SELECT
2057
+ id, review_id, source, external_id, in_reply_to_id, parent_id,
2058
+ external_url, author, author_url, file, side,
2059
+ line_start, line_end, diff_position, commit_sha,
2060
+ is_outdated, original_line_start, original_line_end, original_commit_sha,
2061
+ body, external_created_at, synced_at
2062
+ FROM external_comments
2063
+ `);
2064
+
2065
+ db.exec('DROP TABLE external_comments');
2066
+ db.exec('ALTER TABLE external_comments_new RENAME TO external_comments');
2067
+
2068
+ // Recreate indexes by their canonical names — must match the
2069
+ // INDEX_SQL block and the test schemas exactly.
2070
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_external_comments_unique ON external_comments(review_id, source, external_id)');
2071
+ db.exec('CREATE INDEX IF NOT EXISTS idx_external_comments_anchor ON external_comments(review_id, file, line_end)');
2072
+ db.exec('CREATE INDEX IF NOT EXISTS idx_external_comments_parent_lookup ON external_comments(review_id, source, in_reply_to_id)');
2073
+ });
2074
+ rebuild();
2075
+ } finally {
2076
+ db.exec(`PRAGMA foreign_keys = ${originalForeignKeys ? 'ON' : 'OFF'}`);
2077
+ }
2078
+
2079
+ console.log(' Rebuilt external_comments with ON DELETE SET NULL on parent_id');
2080
+ console.log('Migration to schema version 46 complete');
1889
2081
  }
1890
2082
  };
1891
2083
 
@@ -3001,6 +3193,20 @@ class RepoSettingsRepository {
3001
3193
  await run(this.db, `UPDATE repo_settings SET pool_fetch_finished_at = ? WHERE repository = ?`, [now, repository]);
3002
3194
  }
3003
3195
 
3196
+ /**
3197
+ * List repositories with pool settings stored in the database.
3198
+ * Includes rows with a fetch interval only so callers can resolve complete
3199
+ * pool configuration with file fallback through resolvePoolConfig().
3200
+ * @returns {Promise<Array<{repository: string, pool_size: number|null, pool_fetch_interval_minutes: number|null}>>}
3201
+ */
3202
+ async findPoolConfiguredRepoSettings() {
3203
+ return await query(this.db, `
3204
+ SELECT repository, pool_size, pool_fetch_interval_minutes
3205
+ FROM repo_settings
3206
+ WHERE pool_size IS NOT NULL OR pool_fetch_interval_minutes IS NOT NULL
3207
+ `);
3208
+ }
3209
+
3004
3210
  /**
3005
3211
  * Delete settings for a repository
3006
3212
  * @param {string} repository - Repository in owner/repo format
@@ -3477,6 +3683,377 @@ class CommentRepository {
3477
3683
  }
3478
3684
  }
3479
3685
 
3686
+ /**
3687
+ * ExternalCommentRepository class for managing external review comments
3688
+ *
3689
+ * External comments are a read-only mirror of review comments from external
3690
+ * systems (GitHub, GitLab, etc.). The repository is the ONLY layer that talks
3691
+ * to the external_comments table — routes and sync logic go through it.
3692
+ *
3693
+ * Upsert is keyed on UNIQUE(review_id, source, external_id). Parent resolution
3694
+ * is a separate second pass because external APIs return rows in arrival order,
3695
+ * not parent-before-child.
3696
+ */
3697
+ class ExternalCommentRepository {
3698
+ /**
3699
+ * Create a new ExternalCommentRepository instance
3700
+ * @param {Database} db - Database instance
3701
+ */
3702
+ constructor(db) {
3703
+ this.db = db;
3704
+ }
3705
+
3706
+ /**
3707
+ * Insert or update a single external comment row.
3708
+ *
3709
+ * Keyed on (review_id, source, external_id). On conflict, updates all
3710
+ * columns except `id` and `parent_id` (parent_id is set later by
3711
+ * `resolveParents`). `synced_at` is set automatically to the current
3712
+ * ISO timestamp.
3713
+ *
3714
+ * @param {number} reviewId - Local review id (FK → reviews.id)
3715
+ * @param {string} source - Source system identifier (e.g. 'github')
3716
+ * @param {Object} mappedRow - Mapped comment row (output of an adapter)
3717
+ * @param {string} mappedRow.external_id - Source-system comment id
3718
+ * @param {string} [mappedRow.in_reply_to_id] - Source-system parent comment id
3719
+ * @param {string} [mappedRow.external_url] - Permalink
3720
+ * @param {string} [mappedRow.author]
3721
+ * @param {string} [mappedRow.author_url]
3722
+ * @param {string} mappedRow.file
3723
+ * @param {string} [mappedRow.side] - 'LEFT' or 'RIGHT'
3724
+ * @param {number} [mappedRow.line_start]
3725
+ * @param {number} [mappedRow.line_end]
3726
+ * @param {number} [mappedRow.diff_position]
3727
+ * @param {string} [mappedRow.commit_sha]
3728
+ * @param {boolean|number} [mappedRow.is_outdated]
3729
+ * @param {number} [mappedRow.original_line_start]
3730
+ * @param {number} [mappedRow.original_line_end]
3731
+ * @param {string} [mappedRow.original_commit_sha]
3732
+ * @param {string} [mappedRow.body]
3733
+ * @param {string} [mappedRow.external_created_at]
3734
+ * @returns {Promise<number>} Local id of the inserted/updated row
3735
+ */
3736
+ async upsert(reviewId, source, mappedRow) {
3737
+ if (!reviewId) {
3738
+ throw new Error('upsert: reviewId is required');
3739
+ }
3740
+ if (!source) {
3741
+ throw new Error('upsert: source is required');
3742
+ }
3743
+ if (!mappedRow || mappedRow.external_id === undefined || mappedRow.external_id === null) {
3744
+ throw new Error('upsert: mappedRow.external_id is required');
3745
+ }
3746
+ if (!mappedRow.file) {
3747
+ throw new Error('upsert: mappedRow.file is required');
3748
+ }
3749
+
3750
+ const syncedAt = new Date().toISOString();
3751
+ const externalId = String(mappedRow.external_id);
3752
+ const inReplyToId = mappedRow.in_reply_to_id !== undefined && mappedRow.in_reply_to_id !== null
3753
+ ? String(mappedRow.in_reply_to_id)
3754
+ : null;
3755
+ const isOutdated = mappedRow.is_outdated ? 1 : 0;
3756
+ const side = mappedRow.side === 'LEFT' ? 'LEFT' : (mappedRow.side === 'RIGHT' ? 'RIGHT' : null);
3757
+
3758
+ // SQLite UPSERT. Update every column except id and parent_id; parent_id is
3759
+ // resolved by the second pass (resolveParents). RETURNING id gives the
3760
+ // local row id whether it was inserted or updated.
3761
+ const row = await queryOne(this.db, `
3762
+ INSERT INTO external_comments (
3763
+ review_id, source, external_id, in_reply_to_id,
3764
+ external_url, author, author_url,
3765
+ file, side, line_start, line_end, diff_position, commit_sha,
3766
+ is_outdated, original_line_start, original_line_end, original_commit_sha,
3767
+ body, external_created_at, synced_at
3768
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3769
+ ON CONFLICT(review_id, source, external_id) DO UPDATE SET
3770
+ in_reply_to_id = excluded.in_reply_to_id,
3771
+ external_url = excluded.external_url,
3772
+ author = excluded.author,
3773
+ author_url = excluded.author_url,
3774
+ file = excluded.file,
3775
+ side = excluded.side,
3776
+ line_start = excluded.line_start,
3777
+ line_end = excluded.line_end,
3778
+ diff_position = excluded.diff_position,
3779
+ commit_sha = excluded.commit_sha,
3780
+ is_outdated = excluded.is_outdated,
3781
+ original_line_start = excluded.original_line_start,
3782
+ original_line_end = excluded.original_line_end,
3783
+ original_commit_sha = excluded.original_commit_sha,
3784
+ body = excluded.body,
3785
+ external_created_at = excluded.external_created_at,
3786
+ synced_at = excluded.synced_at
3787
+ RETURNING id
3788
+ `, [
3789
+ reviewId,
3790
+ source,
3791
+ externalId,
3792
+ inReplyToId,
3793
+ mappedRow.external_url ?? null,
3794
+ mappedRow.author ?? null,
3795
+ mappedRow.author_url ?? null,
3796
+ mappedRow.file,
3797
+ side,
3798
+ mappedRow.line_start ?? null,
3799
+ mappedRow.line_end ?? null,
3800
+ mappedRow.diff_position ?? null,
3801
+ mappedRow.commit_sha ?? null,
3802
+ isOutdated,
3803
+ mappedRow.original_line_start ?? null,
3804
+ mappedRow.original_line_end ?? null,
3805
+ mappedRow.original_commit_sha ?? null,
3806
+ mappedRow.body ?? null,
3807
+ mappedRow.external_created_at ?? null,
3808
+ syncedAt
3809
+ ]);
3810
+
3811
+ return row.id;
3812
+ }
3813
+
3814
+ /**
3815
+ * Resolve `parent_id` for every row that has an `in_reply_to_id`.
3816
+ *
3817
+ * Looks up the parent row by (review_id, source, external_id =
3818
+ * in_reply_to_id) and sets parent_id to its local id. Rows whose
3819
+ * in_reply_to_id doesn't match any sibling stay with parent_id = NULL
3820
+ * (orphan / out-of-batch parent).
3821
+ *
3822
+ * Returns the count of rows that had a non-null parent_id after the
3823
+ * update — i.e. rows that successfully resolved. Idempotent: re-running
3824
+ * produces the same count without further changes.
3825
+ *
3826
+ * @param {number} reviewId
3827
+ * @param {string} source
3828
+ * @returns {Promise<number>} Number of rows whose parent_id is now non-null
3829
+ */
3830
+ async resolveParents(reviewId, source) {
3831
+ if (!reviewId) {
3832
+ throw new Error('resolveParents: reviewId is required');
3833
+ }
3834
+ if (!source) {
3835
+ throw new Error('resolveParents: source is required');
3836
+ }
3837
+
3838
+ // EXISTS guard: SQLite's correlated subquery returns NULL when no
3839
+ // sibling matches, which would silently NULL-overwrite a
3840
+ // previously-resolved parent_id. Restrict the UPDATE to rows where the
3841
+ // parent actually exists in the current snapshot — replies whose
3842
+ // parent has been pruned (deleteMissing) keep their previously-resolved
3843
+ // parent_id, and orphan-promotion in listThreadsByReview takes over.
3844
+ await run(this.db, `
3845
+ UPDATE external_comments
3846
+ SET parent_id = (
3847
+ SELECT p.id
3848
+ FROM external_comments AS p
3849
+ WHERE p.review_id = external_comments.review_id
3850
+ AND p.source = external_comments.source
3851
+ AND p.external_id = external_comments.in_reply_to_id
3852
+ )
3853
+ WHERE review_id = ?
3854
+ AND source = ?
3855
+ AND in_reply_to_id IS NOT NULL
3856
+ AND EXISTS (
3857
+ SELECT 1
3858
+ FROM external_comments AS p
3859
+ WHERE p.review_id = external_comments.review_id
3860
+ AND p.source = external_comments.source
3861
+ AND p.external_id = external_comments.in_reply_to_id
3862
+ )
3863
+ `, [reviewId, source]);
3864
+
3865
+ // Return the count of resolved (non-null parent_id) rows for this batch.
3866
+ const row = await queryOne(this.db, `
3867
+ SELECT COUNT(*) AS count
3868
+ FROM external_comments
3869
+ WHERE review_id = ?
3870
+ AND source = ?
3871
+ AND in_reply_to_id IS NOT NULL
3872
+ AND parent_id IS NOT NULL
3873
+ `, [reviewId, source]);
3874
+
3875
+ return row ? row.count : 0;
3876
+ }
3877
+
3878
+ /**
3879
+ * List all external comments for a review, flat (roots + replies).
3880
+ *
3881
+ * Ordered by file, then line_end with NULLs last (outdated rows sink to
3882
+ * the bottom of their file group), then external_created_at.
3883
+ *
3884
+ * @param {number} reviewId
3885
+ * @param {Object} [options]
3886
+ * @param {string} [options.source] - Filter by source if provided
3887
+ * @returns {Promise<Array<Object>>} All matching rows
3888
+ */
3889
+ async listByReview(reviewId, options = {}) {
3890
+ if (!reviewId) {
3891
+ throw new Error('listByReview: reviewId is required');
3892
+ }
3893
+
3894
+ const params = [reviewId];
3895
+ let sql = `
3896
+ SELECT *
3897
+ FROM external_comments
3898
+ WHERE review_id = ?
3899
+ `;
3900
+ if (options.source) {
3901
+ sql += ' AND source = ?';
3902
+ params.push(options.source);
3903
+ }
3904
+ // Sort by COALESCE(line_end, original_line_end) so outdated rows (line_end
3905
+ // null because the comment's current anchor was lost upstream) still sort
3906
+ // near their renderable position via original_line_end. Without COALESCE,
3907
+ // every outdated row sinks to the bottom of its file regardless of which
3908
+ // original line it was anchored to — confusing when the original anchor
3909
+ // is in the middle of the file.
3910
+ sql += `
3911
+ ORDER BY
3912
+ file ASC,
3913
+ CASE WHEN COALESCE(line_end, original_line_end) IS NULL THEN 1 ELSE 0 END,
3914
+ COALESCE(line_end, original_line_end) ASC,
3915
+ external_created_at ASC,
3916
+ id ASC
3917
+ `;
3918
+
3919
+ return query(this.db, sql, params);
3920
+ }
3921
+
3922
+ /**
3923
+ * List external comments grouped into threads.
3924
+ *
3925
+ * Returns one object per thread root (rows where parent_id IS NULL).
3926
+ * Each root has a `replies` array containing reply rows ordered by
3927
+ * `external_created_at`. Roots themselves are ordered the same way as
3928
+ * `listByReview`.
3929
+ *
3930
+ * A reply whose `parent_id` does not resolve to a root in this result
3931
+ * set (shouldn't happen if `resolveParents` ran, but defensive) is
3932
+ * promoted to a root with `replies: []`.
3933
+ *
3934
+ * @param {number} reviewId
3935
+ * @param {Object} [options]
3936
+ * @param {string} [options.source] - Filter by source if provided
3937
+ * @returns {Promise<Array<Object>>}
3938
+ */
3939
+ async listThreadsByReview(reviewId, options = {}) {
3940
+ const rows = await this.listByReview(reviewId, options);
3941
+
3942
+ // Split rows into roots and replies in a single pass.
3943
+ const roots = [];
3944
+ const rootById = new Map();
3945
+ const replies = [];
3946
+
3947
+ for (const row of rows) {
3948
+ if (row.parent_id === null || row.parent_id === undefined) {
3949
+ const thread = { ...row, replies: [] };
3950
+ roots.push(thread);
3951
+ rootById.set(row.id, thread);
3952
+ } else {
3953
+ replies.push(row);
3954
+ }
3955
+ }
3956
+
3957
+ // Attach replies to their root. Orphans (parent_id refers to a row not
3958
+ // in this result — possible if filtered by source, or a defensive guard
3959
+ // against data inconsistency) are promoted to standalone roots.
3960
+ for (const reply of replies) {
3961
+ const root = rootById.get(reply.parent_id);
3962
+ if (root) {
3963
+ root.replies.push(reply);
3964
+ } else {
3965
+ const thread = { ...reply, replies: [] };
3966
+ roots.push(thread);
3967
+ rootById.set(reply.id, thread);
3968
+ }
3969
+ }
3970
+
3971
+ // listByReview already ordered replies by external_created_at, so reply
3972
+ // arrays are pre-sorted. Re-sort defensively for promoted orphans that
3973
+ // were appended after roots in their file group.
3974
+ for (const thread of roots) {
3975
+ if (thread.replies.length > 1) {
3976
+ thread.replies.sort((a, b) => {
3977
+ const ta = a.external_created_at || '';
3978
+ const tb = b.external_created_at || '';
3979
+ if (ta < tb) return -1;
3980
+ if (ta > tb) return 1;
3981
+ return a.id - b.id;
3982
+ });
3983
+ }
3984
+ }
3985
+
3986
+ return roots;
3987
+ }
3988
+
3989
+ /**
3990
+ * Delete external comment rows whose external_id is NOT in the provided
3991
+ * set, scoped to (review_id, source). Used by the sync route to reconcile
3992
+ * the local mirror after upserting the latest snapshot — rows that
3993
+ * upstream removed (or that the snapshot no longer contains because they
3994
+ * lost anchors) get pruned.
3995
+ *
3996
+ * When `keepExternalIds` is empty, this deletes every row for that
3997
+ * (review_id, source). Callers must wrap this in the same transaction as
3998
+ * the upserts to avoid a window where rows are missing.
3999
+ *
4000
+ * @param {number} reviewId
4001
+ * @param {string} source
4002
+ * @param {Iterable<string>} keepExternalIds - Set/Array of external_ids to keep
4003
+ * @returns {Promise<number>} Count of rows deleted
4004
+ */
4005
+ async deleteMissing(reviewId, source, keepExternalIds) {
4006
+ if (!reviewId) {
4007
+ throw new Error('deleteMissing: reviewId is required');
4008
+ }
4009
+ if (!source) {
4010
+ throw new Error('deleteMissing: source is required');
4011
+ }
4012
+
4013
+ const keepList = Array.from(keepExternalIds || []).map((v) => String(v));
4014
+
4015
+ if (keepList.length === 0) {
4016
+ const result = await run(this.db,
4017
+ 'DELETE FROM external_comments WHERE review_id = ? AND source = ?',
4018
+ [reviewId, source]
4019
+ );
4020
+ return result.changes || 0;
4021
+ }
4022
+
4023
+ const placeholders = keepList.map(() => '?').join(',');
4024
+ const result = await run(this.db, `
4025
+ DELETE FROM external_comments
4026
+ WHERE review_id = ?
4027
+ AND source = ?
4028
+ AND external_id NOT IN (${placeholders})
4029
+ `, [reviewId, source, ...keepList]);
4030
+ return result.changes || 0;
4031
+ }
4032
+
4033
+ /**
4034
+ * Count of external comments for a review, optionally filtered by source.
4035
+ *
4036
+ * @param {number} reviewId
4037
+ * @param {string} [source]
4038
+ * @returns {Promise<number>}
4039
+ */
4040
+ async countByReview(reviewId, source) {
4041
+ if (!reviewId) {
4042
+ throw new Error('countByReview: reviewId is required');
4043
+ }
4044
+
4045
+ let sql = 'SELECT COUNT(*) AS count FROM external_comments WHERE review_id = ?';
4046
+ const params = [reviewId];
4047
+ if (source) {
4048
+ sql += ' AND source = ?';
4049
+ params.push(source);
4050
+ }
4051
+
4052
+ const row = await queryOne(this.db, sql, params);
4053
+ return row ? row.count : 0;
4054
+ }
4055
+ }
4056
+
3480
4057
  /**
3481
4058
  * ReviewRepository class for managing review database records
3482
4059
  */
@@ -4988,6 +5565,7 @@ module.exports = {
4988
5565
  RepoSettingsRepository,
4989
5566
  ReviewRepository,
4990
5567
  CommentRepository,
5568
+ ExternalCommentRepository,
4991
5569
  PRMetadataRepository,
4992
5570
  AnalysisRunRepository,
4993
5571
  GitHubReviewRepository,