@in-the-loop-labs/pair-review 3.5.1 → 3.6.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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +603 -6
  5. package/public/index.html +90 -0
  6. package/public/js/components/ChatPanel.js +163 -3
  7. package/public/js/components/KeyboardShortcuts.js +10 -26
  8. package/public/js/components/TourBar.js +248 -0
  9. package/public/js/index.js +298 -25
  10. package/public/js/local.js +6 -0
  11. package/public/js/modules/cancel-background-job.js +183 -0
  12. package/public/js/modules/hunk-summary-renderer.js +116 -0
  13. package/public/js/modules/storage-cleanup.js +16 -0
  14. package/public/js/modules/tour-renderer.js +725 -0
  15. package/public/js/pr.js +1276 -2
  16. package/public/js/utils/modal-detection.js +77 -0
  17. package/public/local.html +17 -0
  18. package/public/pr.html +17 -0
  19. package/src/ai/abort-signal-wiring.js +130 -0
  20. package/src/ai/background-queue.js +290 -0
  21. package/src/ai/claude-cli.js +1 -1
  22. package/src/ai/claude-provider.js +50 -7
  23. package/src/ai/codex-provider.js +28 -5
  24. package/src/ai/copilot-provider.js +22 -3
  25. package/src/ai/cursor-agent-provider.js +22 -6
  26. package/src/ai/executable-provider.js +4 -19
  27. package/src/ai/gemini-provider.js +22 -5
  28. package/src/ai/hunk-hashing.js +161 -0
  29. package/src/ai/index.js +2 -0
  30. package/src/ai/opencode-provider.js +21 -5
  31. package/src/ai/pi-provider.js +21 -5
  32. package/src/ai/prompts/hunk-summary.js +199 -0
  33. package/src/ai/prompts/tour.js +232 -0
  34. package/src/ai/provider.js +21 -1
  35. package/src/ai/summary-generator.js +469 -0
  36. package/src/ai/tour-generator.js +568 -0
  37. package/src/config.js +114 -0
  38. package/src/database.js +282 -1
  39. package/src/local-review.js +189 -169
  40. package/src/routes/config.js +16 -1
  41. package/src/routes/context-files.js +2 -29
  42. package/src/routes/github-collections.js +168 -90
  43. package/src/routes/local.js +311 -4
  44. package/src/routes/middleware/validate-review-id.js +53 -0
  45. package/src/routes/pr.js +259 -4
  46. package/src/routes/reviews.js +145 -29
  47. package/src/utils/diff-hunks.js +65 -0
  48. package/src/utils/json-extractor.js +5 -2
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 = 46;
24
+ const CURRENT_SCHEMA_VERSION = 48;
25
25
 
26
26
  /**
27
27
  * Database schema SQL statements
@@ -270,6 +270,36 @@ const SCHEMA_SQL = {
270
270
  )
271
271
  `,
272
272
 
273
+ hunk_summaries: `
274
+ CREATE TABLE IF NOT EXISTS hunk_summaries (
275
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
276
+ review_id INTEGER NOT NULL,
277
+ file_path TEXT NOT NULL,
278
+ content_hash TEXT NOT NULL,
279
+ summary_text TEXT,
280
+ trivial_reason TEXT,
281
+ provider TEXT,
282
+ model TEXT,
283
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
284
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
285
+ CHECK (summary_text IS NOT NULL OR trivial_reason IS NOT NULL),
286
+ UNIQUE (review_id, content_hash)
287
+ )
288
+ `,
289
+
290
+ tours: `
291
+ CREATE TABLE IF NOT EXISTS tours (
292
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
293
+ review_id INTEGER NOT NULL UNIQUE,
294
+ stops TEXT NOT NULL,
295
+ diff_hash TEXT NOT NULL,
296
+ provider TEXT,
297
+ model TEXT,
298
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
299
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
300
+ )
301
+ `,
302
+
273
303
  github_pr_cache: `
274
304
  CREATE TABLE IF NOT EXISTS github_pr_cache (
275
305
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -365,6 +395,8 @@ const INDEX_SQL = [
365
395
  'CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id)',
366
396
  // Context files indexes
367
397
  'CREATE INDEX IF NOT EXISTS idx_context_files_review ON context_files(review_id)',
398
+ // Hunk summaries indexes
399
+ 'CREATE INDEX IF NOT EXISTS idx_hunk_summaries_review ON hunk_summaries(review_id)',
368
400
  // GitHub PR cache indexes
369
401
  'CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)',
370
402
  // Worktree pool indexes
@@ -2078,6 +2110,55 @@ const MIGRATIONS = {
2078
2110
 
2079
2111
  console.log(' Rebuilt external_comments with ON DELETE SET NULL on parent_id');
2080
2112
  console.log('Migration to schema version 46 complete');
2113
+ },
2114
+
2115
+ 47: (db) => {
2116
+ console.log('Running migration to schema version 47: Add hunk_summaries table...');
2117
+ if (!tableExists(db, 'hunk_summaries')) {
2118
+ db.exec(`
2119
+ CREATE TABLE IF NOT EXISTS hunk_summaries (
2120
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2121
+ review_id INTEGER NOT NULL,
2122
+ file_path TEXT NOT NULL,
2123
+ content_hash TEXT NOT NULL,
2124
+ summary_text TEXT,
2125
+ trivial_reason TEXT,
2126
+ provider TEXT,
2127
+ model TEXT,
2128
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
2129
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
2130
+ CHECK (summary_text IS NOT NULL OR trivial_reason IS NOT NULL),
2131
+ UNIQUE (review_id, content_hash)
2132
+ )
2133
+ `);
2134
+ console.log(' Created hunk_summaries table');
2135
+ } else {
2136
+ console.log(' Table hunk_summaries already exists');
2137
+ }
2138
+ db.exec('CREATE INDEX IF NOT EXISTS idx_hunk_summaries_review ON hunk_summaries(review_id)');
2139
+ console.log('Migration to schema version 47 complete');
2140
+ },
2141
+
2142
+ 48: (db) => {
2143
+ console.log('Running migration to schema version 48: Add tours table...');
2144
+ if (!tableExists(db, 'tours')) {
2145
+ db.exec(`
2146
+ CREATE TABLE IF NOT EXISTS tours (
2147
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2148
+ review_id INTEGER NOT NULL UNIQUE,
2149
+ stops TEXT NOT NULL,
2150
+ diff_hash TEXT NOT NULL,
2151
+ provider TEXT,
2152
+ model TEXT,
2153
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
2154
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
2155
+ )
2156
+ `);
2157
+ console.log(' Created tours table');
2158
+ } else {
2159
+ console.log(' Table tours already exists');
2160
+ }
2161
+ console.log('Migration to schema version 48 complete');
2081
2162
  }
2082
2163
  };
2083
2164
 
@@ -5546,6 +5627,204 @@ class ContextFileRepository {
5546
5627
  }
5547
5628
  }
5548
5629
 
5630
+ /**
5631
+ * HunkSummaryRepository class for managing per-hunk natural-language summary
5632
+ * records. Summaries are generated by background AI jobs and keyed by content
5633
+ * hash so that unchanged hunks don't get re-enqueued across reloads.
5634
+ *
5635
+ * Trivial hunks (whitespace, imports, version bumps, etc.) are persisted with
5636
+ * `summary_text = NULL` and a non-null `trivial_reason` so the missing-hash
5637
+ * lookup treats them as "already known, don't re-enqueue". The frontend hides
5638
+ * trivial rows.
5639
+ */
5640
+ class HunkSummaryRepository {
5641
+ /**
5642
+ * Create a new HunkSummaryRepository instance
5643
+ * @param {Database} db - Database instance
5644
+ */
5645
+ constructor(db) {
5646
+ this.db = db;
5647
+ }
5648
+
5649
+ /**
5650
+ * Get all hunk summary rows for a review, ordered by file_path then id
5651
+ * @param {number} reviewId - Review ID
5652
+ * @returns {Promise<Array<Object>>} Array of hunk summary rows
5653
+ */
5654
+ async getByReview(reviewId) {
5655
+ return query(
5656
+ this.db,
5657
+ 'SELECT * FROM hunk_summaries WHERE review_id = ? ORDER BY file_path, id',
5658
+ [reviewId]
5659
+ );
5660
+ }
5661
+
5662
+ /**
5663
+ * Get all hunk summary rows for a review scoped to a single file path,
5664
+ * ordered by id.
5665
+ * @param {number} reviewId - Review ID
5666
+ * @param {string} filePath - File path to scope to
5667
+ * @returns {Promise<Array<Object>>} Array of hunk summary rows
5668
+ */
5669
+ async getByReviewAndFile(reviewId, filePath) {
5670
+ return query(
5671
+ this.db,
5672
+ 'SELECT * FROM hunk_summaries WHERE review_id = ? AND file_path = ? ORDER BY id',
5673
+ [reviewId, filePath]
5674
+ );
5675
+ }
5676
+
5677
+ /**
5678
+ * Get hunk summary rows for a specific set of content hashes within a review.
5679
+ * Used to identify which hashes already have persisted summaries and which
5680
+ * still need to be enqueued.
5681
+ * @param {number} reviewId - Review ID
5682
+ * @param {Array<string>} hashes - Content hashes to look up
5683
+ * @returns {Promise<Array<Object>>} Array of matching hunk summary rows
5684
+ */
5685
+ async getByHashes(reviewId, hashes) {
5686
+ if (!Array.isArray(hashes) || hashes.length === 0) {
5687
+ return [];
5688
+ }
5689
+ const placeholders = hashes.map(() => '?').join(',');
5690
+ return query(
5691
+ this.db,
5692
+ `SELECT * FROM hunk_summaries WHERE review_id = ? AND content_hash IN (${placeholders})`,
5693
+ [reviewId, ...hashes]
5694
+ );
5695
+ }
5696
+
5697
+ /**
5698
+ * Insert or update many hunk summary rows in a single transaction.
5699
+ * On conflict by (review_id, content_hash), the existing row's mutable
5700
+ * fields are overwritten.
5701
+ * @param {Array<Object>} rows - Rows to upsert. Each row should have
5702
+ * {review_id, file_path, content_hash, summary_text?, trivial_reason?, provider?, model?}
5703
+ * @returns {Promise<number>} Number of rows processed
5704
+ */
5705
+ async upsertMany(rows) {
5706
+ if (!Array.isArray(rows) || rows.length === 0) {
5707
+ return 0;
5708
+ }
5709
+ return withTransaction(this.db, () => {
5710
+ const stmt = this.db.prepare(`
5711
+ INSERT INTO hunk_summaries (
5712
+ review_id, file_path, content_hash, summary_text, trivial_reason, provider, model
5713
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
5714
+ ON CONFLICT(review_id, content_hash) DO UPDATE SET
5715
+ file_path = excluded.file_path,
5716
+ summary_text = excluded.summary_text,
5717
+ trivial_reason = excluded.trivial_reason,
5718
+ provider = excluded.provider,
5719
+ model = excluded.model
5720
+ `);
5721
+ let count = 0;
5722
+ for (const row of rows) {
5723
+ if (row.summary_text == null && row.trivial_reason == null) {
5724
+ throw new Error(
5725
+ `HunkSummaryRepository.upsertMany: row must set summary_text or trivial_reason ` +
5726
+ `(review_id=${row.review_id}, content_hash=${row.content_hash})`
5727
+ );
5728
+ }
5729
+ stmt.run(
5730
+ row.review_id,
5731
+ row.file_path,
5732
+ row.content_hash,
5733
+ row.summary_text ?? null,
5734
+ row.trivial_reason ?? null,
5735
+ row.provider ?? null,
5736
+ row.model ?? null
5737
+ );
5738
+ count++;
5739
+ }
5740
+ return count;
5741
+ });
5742
+ }
5743
+
5744
+ /**
5745
+ * Delete all hunk summary rows for a review
5746
+ * @param {number} reviewId - Review ID
5747
+ * @returns {Promise<Object>} Run result with `changes` count
5748
+ */
5749
+ async deleteByReview(reviewId) {
5750
+ return run(this.db, 'DELETE FROM hunk_summaries WHERE review_id = ?', [reviewId]);
5751
+ }
5752
+ }
5753
+
5754
+ /**
5755
+ * TourRepository class for managing per-review guided-tour records. A tour is
5756
+ * a single ordered narrative walkthrough (a JSON array of stops). `diff_hash`
5757
+ * is a 16-char SHA-256 prefix of the diff text; the tour-generator uses it to
5758
+ * detect staleness (when the diff changes, the tour is regenerated).
5759
+ *
5760
+ * One row per review (review_id is UNIQUE). On regeneration, upsert replaces
5761
+ * the existing row.
5762
+ */
5763
+ class TourRepository {
5764
+ /**
5765
+ * Create a new TourRepository instance
5766
+ * @param {Database} db - Database instance
5767
+ */
5768
+ constructor(db) {
5769
+ this.db = db;
5770
+ }
5771
+
5772
+ /**
5773
+ * Get the tour row for a review.
5774
+ * `stops` is returned as a raw JSON string; the caller parses.
5775
+ * @param {number} reviewId - Review ID
5776
+ * @returns {Promise<Object|undefined>} The tour row or undefined if none
5777
+ */
5778
+ async get(reviewId) {
5779
+ return queryOne(
5780
+ this.db,
5781
+ 'SELECT * FROM tours WHERE review_id = ?',
5782
+ [reviewId]
5783
+ );
5784
+ }
5785
+
5786
+ /**
5787
+ * Insert or replace the tour row for a review. `stops` is stored verbatim;
5788
+ * the caller is responsible for JSON.stringify.
5789
+ * @param {Object} row - { review_id, stops, diff_hash, provider?, model? }
5790
+ * @returns {Promise<Object>} Run result with `changes` count
5791
+ */
5792
+ async upsert(row) {
5793
+ if (row.review_id == null) {
5794
+ throw new Error('TourRepository.upsert: row.review_id is required');
5795
+ }
5796
+ return run(
5797
+ this.db,
5798
+ `
5799
+ INSERT INTO tours (review_id, stops, diff_hash, provider, model)
5800
+ VALUES (?, ?, ?, ?, ?)
5801
+ ON CONFLICT(review_id) DO UPDATE SET
5802
+ stops = excluded.stops,
5803
+ diff_hash = excluded.diff_hash,
5804
+ provider = excluded.provider,
5805
+ model = excluded.model,
5806
+ created_at = CURRENT_TIMESTAMP
5807
+ `,
5808
+ [
5809
+ row.review_id,
5810
+ row.stops,
5811
+ row.diff_hash,
5812
+ row.provider ?? null,
5813
+ row.model ?? null,
5814
+ ]
5815
+ );
5816
+ }
5817
+
5818
+ /**
5819
+ * Delete the tour row for a review.
5820
+ * @param {number} reviewId - Review ID
5821
+ * @returns {Promise<Object>} Run result with `changes` count
5822
+ */
5823
+ async deleteByReview(reviewId) {
5824
+ return run(this.db, 'DELETE FROM tours WHERE review_id = ?', [reviewId]);
5825
+ }
5826
+ }
5827
+
5549
5828
  module.exports = {
5550
5829
  initializeDatabase,
5551
5830
  closeDatabase,
@@ -5571,6 +5850,8 @@ module.exports = {
5571
5850
  GitHubReviewRepository,
5572
5851
  CouncilRepository,
5573
5852
  ContextFileRepository,
5853
+ HunkSummaryRepository,
5854
+ TourRepository,
5574
5855
  generateWorktreeId,
5575
5856
  migrateExistingWorktrees,
5576
5857
  // Exported for testing only