@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
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 =
|
|
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
|
|
|
@@ -3491,6 +3683,377 @@ class CommentRepository {
|
|
|
3491
3683
|
}
|
|
3492
3684
|
}
|
|
3493
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
|
+
|
|
3494
4057
|
/**
|
|
3495
4058
|
* ReviewRepository class for managing review database records
|
|
3496
4059
|
*/
|
|
@@ -5002,6 +5565,7 @@ module.exports = {
|
|
|
5002
5565
|
RepoSettingsRepository,
|
|
5003
5566
|
ReviewRepository,
|
|
5004
5567
|
CommentRepository,
|
|
5568
|
+
ExternalCommentRepository,
|
|
5005
5569
|
PRMetadataRepository,
|
|
5006
5570
|
AnalysisRunRepository,
|
|
5007
5571
|
GitHubReviewRepository,
|