@in-the-loop-labs/pair-review 2.4.1 → 2.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "2.4.1",
3
+ "version": "2.4.3",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "2.4.1",
3
+ "version": "2.4.3",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "2.4.1",
3
+ "version": "2.4.3",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
package/public/css/pr.css CHANGED
@@ -12277,9 +12277,12 @@ body.resizing * {
12277
12277
  font-size: 14px;
12278
12278
  }
12279
12279
 
12280
- /* Override flex: 1 so the file name doesn't push the badge away */
12280
+ /* Don't grow (keeps badge next to text), shrink for long paths, clip from the left so the filename stays visible */
12281
12281
  .context-file-header .d2h-file-name {
12282
- flex: none;
12282
+ flex: 0 1 auto;
12283
+ direction: rtl;
12284
+ unicode-bidi: plaintext;
12285
+ text-overflow: ellipsis;
12283
12286
  }
12284
12287
 
12285
12288
  /* Push viewed checkbox (and everything after it) to the right */
package/public/js/pr.js CHANGED
@@ -4480,7 +4480,16 @@ class PRManager {
4480
4480
  const tbody = document.createElement('tbody');
4481
4481
  tbody.className = 'd2h-diff-tbody context-chunk';
4482
4482
  tbody.dataset.contextId = contextFile.id;
4483
- tbody.dataset.lineStart = contextFile.line_start;
4483
+
4484
+ // Compute effective display range, shifting for end-of-file
4485
+ const clampedEnd = Math.min(contextFile.line_end, data.lines.length);
4486
+ const intendedSize = contextFile.line_end - contextFile.line_start + 1;
4487
+ let effectiveStart = contextFile.line_start;
4488
+ const actualSize = clampedEnd - effectiveStart + 1;
4489
+ if (actualSize < intendedSize && effectiveStart > 1) {
4490
+ effectiveStart = Math.max(1, effectiveStart - (intendedSize - actualSize));
4491
+ }
4492
+ tbody.dataset.lineStart = effectiveStart;
4484
4493
 
4485
4494
  // Chunk header row with range label and per-chunk dismiss button
4486
4495
  const headerRow = document.createElement('tr');
@@ -4493,8 +4502,7 @@ class PRManager {
4493
4502
  contentTd.colSpan = 3;
4494
4503
  const rangeLabel = document.createElement('span');
4495
4504
  rangeLabel.className = 'context-range-label';
4496
- const lineEnd = Math.min(contextFile.line_end, data.lines.length);
4497
- rangeLabel.textContent = `Lines ${contextFile.line_start}\u2013${lineEnd}`;
4505
+ rangeLabel.textContent = `Lines ${effectiveStart}\u2013${clampedEnd}`;
4498
4506
  contentTd.appendChild(rangeLabel);
4499
4507
  const chunkDismiss = document.createElement('button');
4500
4508
  chunkDismiss.className = 'context-chunk-dismiss';
@@ -4508,25 +4516,22 @@ class PRManager {
4508
4516
  headerRow.appendChild(contentTd);
4509
4517
  tbody.appendChild(headerRow);
4510
4518
 
4511
- const lineStart = contextFile.line_start;
4512
- const clampedEnd = Math.min(contextFile.line_end, data.lines.length);
4513
-
4514
4519
  // Add expand-up gap row if there are lines above the context range
4515
- if (lineStart > 1) {
4516
- const gapAboveSize = lineStart - 1;
4520
+ if (effectiveStart > 1) {
4521
+ const gapAboveSize = effectiveStart - 1;
4517
4522
  const gapAbove = window.HunkParser.createGapRowElement(
4518
4523
  contextFile.file,
4519
- 1, // startLine (old coords)
4520
- lineStart - 1, // endLine (old coords)
4524
+ 1, // startLine (old coords)
4525
+ effectiveStart - 1, // endLine (old coords)
4521
4526
  gapAboveSize,
4522
4527
  'above',
4523
4528
  this.expandGapContext.bind(this),
4524
- 1 // startLineNew (same as old for context files — no diff offset)
4529
+ 1 // startLineNew (same as old for context files — no diff offset)
4525
4530
  );
4526
4531
  tbody.appendChild(gapAbove);
4527
4532
  }
4528
4533
 
4529
- for (let i = lineStart; i <= clampedEnd; i++) {
4534
+ for (let i = effectiveStart; i <= clampedEnd; i++) {
4530
4535
  const lineData = {
4531
4536
  type: 'context',
4532
4537
  oldNumber: i,
@@ -4874,8 +4879,9 @@ class PRManager {
4874
4879
  lineStartVal = 1;
4875
4880
  lineEndVal = 100;
4876
4881
  } else if (lineEnd == null) {
4877
- lineStartVal = lineStart;
4878
- lineEndVal = lineStart + 49;
4882
+ // Center a ~21-line window around the target line (±10 lines)
4883
+ lineStartVal = Math.max(1, lineStart - 10);
4884
+ lineEndVal = lineStartVal + 20;
4879
4885
  } else {
4880
4886
  lineStartVal = lineStart;
4881
4887
  lineEndVal = Math.min(lineEnd, lineStart + 499);
@@ -38,6 +38,29 @@ function buildReviewerLabel(idx, voice) {
38
38
  return `Reviewer ${idx + 1} (${voice.provider}/${voice.model})`;
39
39
  }
40
40
 
41
+ /**
42
+ * Capture a unified diff snapshot for an analysis run.
43
+ * This ensures the diff is preserved even if the branch is force-pushed later.
44
+ *
45
+ * @param {Analyzer} analyzer - Analyzer instance (provides buildGitDiffCommand)
46
+ * @param {string} worktreePath - Path to the git worktree
47
+ * @param {Object} prMetadata - PR metadata with base/head branch info
48
+ * @param {string} logPrefix - Prefix for log messages
49
+ * @returns {Promise<string|null>} The diff snapshot or null if capture failed
50
+ */
51
+ async function captureDiffSnapshot(analyzer, worktreePath, prMetadata, logPrefix = '') {
52
+ try {
53
+ const diffCmd = analyzer.buildGitDiffCommand(prMetadata);
54
+ const { stdout } = await execPromise(diffCmd, { cwd: worktreePath, maxBuffer: 50 * 1024 * 1024 });
55
+ logger.info(`${logPrefix}Captured diff snapshot (${stdout.length} bytes)`);
56
+ return stdout;
57
+ } catch (diffError) {
58
+ logger.warn(`${logPrefix}Failed to capture diff snapshot: ${diffError.message}`);
59
+ // Continue without diff snapshot - share endpoint will fall back to current PR diff
60
+ return null;
61
+ }
62
+ }
63
+
41
64
  /**
42
65
  * Build shared context for a council voice/reviewer.
43
66
  * Used by both single-voice and multi-voice paths in runReviewerCentricCouncil.
@@ -167,7 +190,11 @@ class Analyzer {
167
190
  logger.info(`${logPrefix}HEAD SHA: ${headSha}`);
168
191
  }
169
192
 
170
- // Create analysis run record in database (skip when caller already created it)
193
+ // Capture unified diff snapshot for this analysis run
194
+ // This ensures the diff is preserved even if the branch is force-pushed later
195
+ const diffSnapshot = await captureDiffSnapshot(this, worktreePath, prMetadata, logPrefix);
196
+
197
+ // Create or update analysis run record in database
171
198
  const analysisRunRepo = new AnalysisRunRepository(this.db);
172
199
  if (!skipRunCreation) {
173
200
  try {
@@ -180,13 +207,22 @@ class Analyzer {
180
207
  customInstructions: mergedInstructions, // Keep for backward compat
181
208
  repoInstructions,
182
209
  requestInstructions,
183
- headSha
210
+ headSha,
211
+ diff: diffSnapshot
184
212
  });
185
213
  logger.info(`${logPrefix}Created analysis_run record: ${runId}`);
186
214
  } catch (createError) {
187
215
  logger.warn(`${logPrefix}Failed to create analysis_run record: ${createError.message}`);
188
216
  // Continue with analysis even if record creation fails
189
217
  }
218
+ } else if (diffSnapshot) {
219
+ // Run was created by caller, but we still need to store the diff snapshot
220
+ try {
221
+ await analysisRunRepo.update(runId, { diff: diffSnapshot });
222
+ logger.info(`${logPrefix}Updated analysis_run with diff snapshot`);
223
+ } catch (updateError) {
224
+ logger.warn(`${logPrefix}Failed to update analysis_run with diff: ${updateError.message}`);
225
+ }
190
226
  }
191
227
 
192
228
  // Load generated file patterns to skip during analysis
@@ -2657,6 +2693,9 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2657
2693
  const headSha = prMetadata?.head_sha || null;
2658
2694
  const analysisRunRepo = new AnalysisRunRepository(this.db);
2659
2695
 
2696
+ // Capture unified diff snapshot for this analysis run
2697
+ const diffSnapshot = await captureDiffSnapshot(this, worktreePath, prMetadata, '[ReviewerCouncil] ');
2698
+
2660
2699
  // Create parent analysis run only if caller didn't already create it
2661
2700
  // (when runId is passed via options, the route handler has already inserted the record)
2662
2701
  if (!options.runId) {
@@ -2671,6 +2710,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2671
2710
  repoInstructions: instructions?.repoInstructions || null,
2672
2711
  requestInstructions: instructions?.requestInstructions || null,
2673
2712
  headSha,
2713
+ diff: diffSnapshot,
2674
2714
  configType: 'council',
2675
2715
  levelsConfig: enabledLevels
2676
2716
  });
@@ -2678,6 +2718,14 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2678
2718
  } catch (err) {
2679
2719
  logger.warn(`[ReviewerCouncil] Failed to create parent run record: ${err.message}`);
2680
2720
  }
2721
+ } else if (diffSnapshot) {
2722
+ // Run was created by caller, but we still need to store the diff snapshot
2723
+ try {
2724
+ await analysisRunRepo.update(parentRunId, { diff: diffSnapshot });
2725
+ logger.info(`[ReviewerCouncil] Updated analysis_run with diff snapshot`);
2726
+ } catch (updateError) {
2727
+ logger.warn(`[ReviewerCouncil] Failed to update analysis_run with diff: ${updateError.message}`);
2728
+ }
2681
2729
  }
2682
2730
 
2683
2731
  const voices = councilConfig.voices || [];
@@ -2768,6 +2816,7 @@ File-level suggestions should NOT have a line number. They apply to the entire f
2768
2816
  repoInstructions: instructions?.repoInstructions || null,
2769
2817
  requestInstructions: instructions?.requestInstructions || null,
2770
2818
  headSha,
2819
+ diff: diffSnapshot,
2771
2820
  parentRunId,
2772
2821
  configType: 'council',
2773
2822
  levelsConfig: enabledLevels
@@ -465,6 +465,14 @@ class ClaudeCodeBridge extends EventEmitter {
465
465
  toolName: name,
466
466
  status: 'start',
467
467
  });
468
+ } else if (event.content_block && event.content_block.type === 'text') {
469
+ // When a new text block starts and we already have accumulated text
470
+ // from a previous block, inject paragraph separation so the markdown
471
+ // renderer doesn't smash the blocks together (e.g., "diff.The").
472
+ if (this._accumulatedText) {
473
+ this._accumulatedText += '\n\n';
474
+ this.emit('delta', { text: '\n\n' });
475
+ }
468
476
  }
469
477
  break;
470
478
 
package/src/database.js CHANGED
@@ -20,7 +20,7 @@ function getDbPath() {
20
20
  /**
21
21
  * Current schema version - increment this when adding new migrations
22
22
  */
23
- const CURRENT_SCHEMA_VERSION = 26;
23
+ const CURRENT_SCHEMA_VERSION = 27;
24
24
 
25
25
  /**
26
26
  * Database schema SQL statements
@@ -157,6 +157,7 @@ const SCHEMA_SQL = {
157
157
  repo_instructions TEXT,
158
158
  request_instructions TEXT,
159
159
  head_sha TEXT,
160
+ diff TEXT,
160
161
  summary TEXT,
161
162
  status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'completed', 'failed', 'cancelled')),
162
163
  total_suggestions INTEGER DEFAULT 0,
@@ -1206,6 +1207,29 @@ const MIGRATIONS = {
1206
1207
  }
1207
1208
 
1208
1209
  console.log('Migration to schema version 26 complete');
1210
+ },
1211
+
1212
+ // Migration to version 27: adds diff column to analysis_runs for snapshot preservation
1213
+ 27: (db) => {
1214
+ console.log('Migrating to schema version 27: Add diff column to analysis_runs');
1215
+
1216
+ const hasDiff = columnExists(db, 'analysis_runs', 'diff');
1217
+ if (!hasDiff) {
1218
+ try {
1219
+ db.prepare('ALTER TABLE analysis_runs ADD COLUMN diff TEXT').run();
1220
+ console.log(' Added diff column to analysis_runs');
1221
+ } catch (error) {
1222
+ // Ignore duplicate column errors (race condition protection)
1223
+ if (!error.message.includes('duplicate column name')) {
1224
+ throw error;
1225
+ }
1226
+ console.log(' Column diff already exists (race condition)');
1227
+ }
1228
+ } else {
1229
+ console.log(' Column diff already exists');
1230
+ }
1231
+
1232
+ console.log('Migration to schema version 27 complete');
1209
1233
  }
1210
1234
  };
1211
1235
 
@@ -2899,17 +2923,18 @@ class AnalysisRunRepository {
2899
2923
  * @param {string} [runInfo.repoInstructions] - Repository-level instructions from repo_settings
2900
2924
  * @param {string} [runInfo.requestInstructions] - Request-level instructions from the analyze request
2901
2925
  * @param {string} [runInfo.headSha] - Git HEAD SHA at the time of analysis (PR head commit or local HEAD)
2926
+ * @param {string} [runInfo.diff] - Unified diff snapshot at the time of analysis
2902
2927
  * @param {string} [runInfo.status='running'] - Initial status (default 'running'; pass 'completed' for externally-produced results)
2903
2928
  * @returns {Promise<Object>} Created analysis run record
2904
2929
  */
2905
- async create({ id, reviewId, provider = null, model = null, tier = null, customInstructions = null, repoInstructions = null, requestInstructions = null, headSha = null, status = 'running', parentRunId = null, configType = 'single', levelsConfig = null }) {
2930
+ async create({ id, reviewId, provider = null, model = null, tier = null, customInstructions = null, repoInstructions = null, requestInstructions = null, headSha = null, diff = null, status = 'running', parentRunId = null, configType = 'single', levelsConfig = null }) {
2906
2931
  const isTerminal = ['completed', 'failed', 'cancelled'].includes(status);
2907
2932
  const completedAt = isTerminal ? 'CURRENT_TIMESTAMP' : 'NULL';
2908
2933
  const levelsConfigJson = levelsConfig ? JSON.stringify(levelsConfig) : null;
2909
2934
  await run(this.db, `
2910
- INSERT INTO analysis_runs (id, review_id, provider, model, tier, custom_instructions, repo_instructions, request_instructions, head_sha, status, completed_at, parent_run_id, config_type, levels_config)
2911
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${completedAt}, ?, ?, ?)
2912
- `, [id, reviewId, provider, model, tier, customInstructions, repoInstructions, requestInstructions, headSha, status, parentRunId, configType, levelsConfigJson]);
2935
+ INSERT INTO analysis_runs (id, review_id, provider, model, tier, custom_instructions, repo_instructions, request_instructions, head_sha, diff, status, completed_at, parent_run_id, config_type, levels_config)
2936
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${completedAt}, ?, ?, ?)
2937
+ `, [id, reviewId, provider, model, tier, customInstructions, repoInstructions, requestInstructions, headSha, diff, status, parentRunId, configType, levelsConfigJson]);
2913
2938
 
2914
2939
  // Query back the inserted row to return actual database values (including timestamps)
2915
2940
  return await this.getById(id);
@@ -2923,6 +2948,7 @@ class AnalysisRunRepository {
2923
2948
  * @param {string} [updates.summary] - Analysis summary
2924
2949
  * @param {number} [updates.totalSuggestions] - Total suggestions count
2925
2950
  * @param {number} [updates.filesAnalyzed] - Files analyzed count
2951
+ * @param {string} [updates.diff] - Unified diff snapshot to store
2926
2952
  * @param {Object} [options] - Update options
2927
2953
  * @param {string} [options.skipIfStatus] - Skip the update if the record already has this status (prevents redundant writes)
2928
2954
  * @returns {Promise<boolean>} True if record was updated
@@ -2956,6 +2982,11 @@ class AnalysisRunRepository {
2956
2982
  params.push(updates.filesAnalyzed);
2957
2983
  }
2958
2984
 
2985
+ if (updates.diff !== undefined) {
2986
+ setClauses.push('diff = ?');
2987
+ params.push(updates.diff);
2988
+ }
2989
+
2959
2990
  if (setClauses.length === 0) {
2960
2991
  return false;
2961
2992
  }
@@ -2980,13 +3011,21 @@ class AnalysisRunRepository {
2980
3011
  /**
2981
3012
  * Get an analysis run by ID
2982
3013
  * @param {string} id - Analysis run ID
3014
+ * @param {Object} [options] - Optional query options
3015
+ * @param {boolean} [options.includeDiff=false] - Include the diff column (can be large)
2983
3016
  * @returns {Promise<Object|null>} Analysis run record or null
2984
3017
  */
2985
- async getById(id) {
3018
+ async getById(id, { includeDiff = false } = {}) {
3019
+ const columns = [
3020
+ 'id', 'review_id', 'provider', 'model', 'tier', 'custom_instructions', 'repo_instructions', 'request_instructions',
3021
+ 'head_sha', 'summary', 'status', 'total_suggestions', 'files_analyzed', 'started_at', 'completed_at',
3022
+ 'parent_run_id', 'config_type', 'levels_config'
3023
+ ];
3024
+ if (includeDiff) {
3025
+ columns.splice(9, 0, 'diff'); // Insert diff after head_sha
3026
+ }
2986
3027
  const row = await queryOne(this.db, `
2987
- SELECT id, review_id, provider, model, tier, custom_instructions, repo_instructions, request_instructions,
2988
- head_sha, summary, status, total_suggestions, files_analyzed, started_at, completed_at,
2989
- parent_run_id, config_type, levels_config
3028
+ SELECT ${columns.join(', ')}
2990
3029
  FROM analysis_runs
2991
3030
  WHERE id = ?
2992
3031
  `, [id]);
@@ -3000,14 +3039,21 @@ class AnalysisRunRepository {
3000
3039
  * @param {number} reviewId - Review ID (works for both PR and local modes)
3001
3040
  * @param {Object} [options] - Optional query options
3002
3041
  * @param {number} [options.limit] - Maximum number of runs to return
3042
+ * @param {boolean} [options.includeDiff=false] - Include the diff column (can be large)
3003
3043
  * @returns {Promise<Array<Object>>} Array of analysis run records
3004
3044
  */
3005
- async getByReviewId(reviewId, { limit } = {}) {
3045
+ async getByReviewId(reviewId, { limit, includeDiff = false } = {}) {
3006
3046
  const params = [reviewId];
3047
+ const columns = [
3048
+ 'id', 'review_id', 'provider', 'model', 'tier', 'custom_instructions', 'repo_instructions', 'request_instructions',
3049
+ 'head_sha', 'summary', 'status', 'total_suggestions', 'files_analyzed', 'started_at', 'completed_at',
3050
+ 'parent_run_id', 'config_type', 'levels_config'
3051
+ ];
3052
+ if (includeDiff) {
3053
+ columns.splice(9, 0, 'diff'); // Insert diff after head_sha
3054
+ }
3007
3055
  let sql = `
3008
- SELECT id, review_id, provider, model, tier, custom_instructions, repo_instructions, request_instructions,
3009
- head_sha, summary, status, total_suggestions, files_analyzed, started_at, completed_at,
3010
- parent_run_id, config_type, levels_config
3056
+ SELECT ${columns.join(', ')}
3011
3057
  FROM analysis_runs
3012
3058
  WHERE review_id = ?
3013
3059
  ORDER BY COALESCE(completed_at, started_at) DESC, CASE WHEN parent_run_id IS NULL THEN 0 ELSE 1 END, started_at DESC, id DESC`;
@@ -3028,12 +3074,41 @@ class AnalysisRunRepository {
3028
3074
  return rows.length > 0 ? rows[0] : null;
3029
3075
  }
3030
3076
 
3077
+ /**
3078
+ * Get the most recently completed analysis run for a review
3079
+ * @param {number} reviewId - Review ID (works for both PR and local modes)
3080
+ * @param {Object} [options] - Optional query options
3081
+ * @param {boolean} [options.includeDiff=false] - Include the diff column (can be large)
3082
+ * @returns {Promise<Object|null>} Most recently completed analysis run or null
3083
+ */
3084
+ async getLatestCompletedByReviewId(reviewId, { includeDiff = false } = {}) {
3085
+ const columns = [
3086
+ 'id', 'review_id', 'provider', 'model', 'tier', 'custom_instructions', 'repo_instructions', 'request_instructions',
3087
+ 'head_sha', 'summary', 'status', 'total_suggestions', 'files_analyzed', 'started_at', 'completed_at',
3088
+ 'parent_run_id', 'config_type', 'levels_config'
3089
+ ];
3090
+ if (includeDiff) {
3091
+ columns.splice(9, 0, 'diff'); // Insert diff after head_sha
3092
+ }
3093
+ const row = await queryOne(this.db, `
3094
+ SELECT ${columns.join(', ')}
3095
+ FROM analysis_runs
3096
+ WHERE review_id = ? AND status = 'completed'
3097
+ ORDER BY completed_at DESC
3098
+ LIMIT 1
3099
+ `, [reviewId]);
3100
+
3101
+ return row || null;
3102
+ }
3103
+
3031
3104
  /**
3032
3105
  * Get child runs for a parent council run, ordered by start time ascending
3033
3106
  * @param {string} parentRunId - Parent analysis run ID
3034
3107
  * @returns {Promise<Array<Object>>} Array of child analysis run records
3035
3108
  */
3036
3109
  async getChildRuns(parentRunId) {
3110
+ // Note: diff column is intentionally omitted - child runs share the same diff as parent
3111
+ // to avoid data duplication. Use the parent run's diff when needed.
3037
3112
  return query(this.db, `
3038
3113
  SELECT id, review_id, provider, model, tier, custom_instructions, repo_instructions, request_instructions,
3039
3114
  head_sha, summary, status, total_suggestions, files_analyzed, started_at, completed_at,
package/src/routes/pr.js CHANGED
@@ -1900,7 +1900,8 @@ router.get('/api/pr/:owner/:repo/:number/share', async (req, res) => {
1900
1900
 
1901
1901
  if (requestedRunId) {
1902
1902
  // Specific run requested - fetch it directly
1903
- targetRun = await analysisRunRepo.getById(requestedRunId);
1903
+ // Include diff since the share endpoint needs it for the snapshot
1904
+ targetRun = await analysisRunRepo.getById(requestedRunId, { includeDiff: true });
1904
1905
  // Verify it belongs to this review and is completed
1905
1906
  if (!targetRun || targetRun.review_id !== review.id || targetRun.status !== 'completed') {
1906
1907
  targetRun = null;
@@ -1909,8 +1910,8 @@ router.get('/api/pr/:owner/:repo/:number/share', async (req, res) => {
1909
1910
 
1910
1911
  // If no specific run requested or it wasn't found/valid, fall back to the most recently completed run
1911
1912
  if (!targetRun) {
1912
- const runs = await analysisRunRepo.getByReviewId(review.id);
1913
- targetRun = runs.find(r => r.status === 'completed') || null;
1913
+ // Include diff since the share endpoint needs it for the snapshot
1914
+ targetRun = await analysisRunRepo.getLatestCompletedByReviewId(review.id, { includeDiff: true });
1914
1915
  }
1915
1916
 
1916
1917
  if (targetRun) {
@@ -1927,6 +1928,15 @@ router.get('/api/pr/:owner/:repo/:number/share', async (req, res) => {
1927
1928
  customInstructions: targetRun.custom_instructions || targetRun.request_instructions || null
1928
1929
  };
1929
1930
 
1931
+ // Use the run's snapshot of headSha and diff if available (for consistency with suggestions)
1932
+ // Falls back to current PR data for old runs that predate snapshot capture
1933
+ if (targetRun.head_sha) {
1934
+ payload.headSha = targetRun.head_sha;
1935
+ }
1936
+ if (targetRun.diff) {
1937
+ payload.diff = targetRun.diff;
1938
+ }
1939
+
1930
1940
  // Get suggestions for this run
1931
1941
  const rows = await query(db, `
1932
1942
  SELECT