@in-the-loop-labs/pair-review 3.5.2 → 3.7.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 (93) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. package/src/utils/json-extractor.js +5 -2
@@ -20,7 +20,19 @@ const {
20
20
  isCheckInProgress
21
21
  } = require('../ai');
22
22
  const { normalizeRepository } = require('../utils/paths');
23
- const { isRunningViaNpx, getGitHubToken } = require('../config');
23
+ const {
24
+ isRunningViaNpx,
25
+ getGitHubToken,
26
+ getDefaultProvider,
27
+ getDefaultModel,
28
+ resolveHostBinding,
29
+ resolveBindingRepositoryFromPR,
30
+ getSummaryEnabled,
31
+ getSummaryAutoGenerate,
32
+ getTourEnabled,
33
+ getTourAutoGenerate
34
+ } = require('../config');
35
+ const { resolveRepoLinks } = require('../links/repo-links');
24
36
  const { version } = require('../../package.json');
25
37
  const semver = require('semver');
26
38
  const { getAllChatProviders, getAllCachedChatAvailability } = require('../chat/chat-providers');
@@ -63,10 +75,54 @@ router.get('/runtime-config.js', (req, res) => {
63
75
 
64
76
  /**
65
77
  * Get user configuration (for frontend use)
66
- * Returns safe-to-expose configuration values
78
+ * Returns safe-to-expose configuration values.
79
+ *
80
+ * GitHub token presence is reported with two distinct fields:
81
+ * - has_global_github_token: always present. True iff `getGitHubToken(config)`
82
+ * resolves a token from the top-level config / env (no repo context).
83
+ * - has_github_token: present ONLY when both ?owner and ?repo query
84
+ * parameters are supplied. True iff a token can be resolved for that
85
+ * specific repository via `resolveHostBinding(repo, config)` — this
86
+ * covers repo-scoped `token`, `token_command`, alt-host bindings, AND
87
+ * falls through to the global lookup. Callers that know which repo
88
+ * they're rendering should pass these params so that repo-scoped
89
+ * authentication is reflected accurately (e.g. for deciding whether
90
+ * to enable GitHub-comment dedup). When the params are absent, the
91
+ * repo-aware field is omitted entirely — there is no safe default
92
+ * that doesn't risk silently meaning the wrong thing.
93
+ */
94
+ /**
95
+ * Resolve a coherent default provider/model PAIR for the frontend.
96
+ *
97
+ * `default_provider` and `default_model` seed the bulk modal, auto-analyze, and
98
+ * the manual analyze dialog as a single selection, so they must belong together.
99
+ * An explicitly configured model wins (the user opted into it). When no model is
100
+ * configured, derive it from the selected provider's own default rather than the
101
+ * provider-agnostic global default — otherwise a provider-only override (e.g.
102
+ * `default_provider: 'gemini'`) would pair with an Anthropic model like 'opus'.
103
+ *
104
+ * @param {Object} config - Configuration object
105
+ * @returns {{ provider: string, model: string }}
67
106
  */
107
+ function resolveDefaultProviderModel(config) {
108
+ const provider = getDefaultProvider(config);
109
+ const providerInfo = getAllProvidersInfo().find(p => p.id === provider);
110
+ const explicitModel = config.default_model || config.model;
111
+ // Only honour an explicit model if it actually belongs to the selected provider.
112
+ // `DEFAULT_CONFIG.default_model` is always populated (e.g. 'opus'), so a
113
+ // provider-only override like `default_provider: 'gemini'` would otherwise inherit
114
+ // a foreign Anthropic model and return a mismatched pair. When the model does not
115
+ // belong to the provider, derive a coherent default from the provider itself.
116
+ const modelBelongs = explicitModel && providerInfo?.models?.some(m => m.id === explicitModel);
117
+ if (modelBelongs) {
118
+ return { provider, model: explicitModel };
119
+ }
120
+ return { provider, model: providerInfo?.defaultModel || getDefaultModel(config) };
121
+ }
122
+
68
123
  router.get('/api/config', (req, res) => {
69
124
  const config = req.app.get('config') || {};
125
+ const defaultPair = resolveDefaultProviderModel(config);
70
126
 
71
127
  // Build chat_providers array with availability
72
128
  const chatAvailability = getAllCachedChatAvailability();
@@ -74,13 +130,34 @@ router.get('/api/config', (req, res) => {
74
130
  id: p.id, name: p.name, type: p.type, available: chatAvailability[p.id]?.available || false
75
131
  }));
76
132
 
133
+ // Repo-aware token resolution (opt-in via ?owner & ?repo query params).
134
+ // Both must be non-empty strings; missing/partial params fall back to the
135
+ // global-only response shape.
136
+ const { owner, repo } = req.query;
137
+ const hasRepoContext = typeof owner === 'string' && owner.length > 0
138
+ && typeof repo === 'string' && repo.length > 0;
139
+
140
+ const hasGlobalGithubToken = Boolean(getGitHubToken(config));
141
+ let hasRepoGithubToken = null;
142
+ if (hasRepoContext) {
143
+ const repository = `${owner}/${repo}`;
144
+ // resolveHostBinding already falls through to top-level config when
145
+ // no repo-scoped token is configured, so this is a true union of
146
+ // "repo-scoped binding works" OR "global token works".
147
+ hasRepoGithubToken = Boolean(resolveHostBinding(repository, config).token);
148
+ }
149
+
77
150
  // Only return safe configuration values (not secrets like github_token)
78
151
  res.json({
79
152
  version,
80
153
  theme: config.theme || 'light',
81
- has_github_token: Boolean(getGitHubToken(config)),
154
+ has_global_github_token: hasGlobalGithubToken,
155
+ // Repo-aware field — only included when owner+repo were supplied.
156
+ ...(hasRepoContext ? { has_github_token: hasRepoGithubToken } : {}),
82
157
  comment_button_action: config.comment_button_action || 'submit',
83
158
  comment_format: config.comment_format || 'legacy',
159
+ default_provider: defaultPair.provider,
160
+ default_model: defaultPair.model,
84
161
  // Include npx detection for frontend command examples
85
162
  is_running_via_npx: isRunningViaNpx(),
86
163
  enable_chat: config.enable_chat !== false,
@@ -93,6 +170,14 @@ router.get('/api/config', (req, res) => {
93
170
  enable_graphite: config.enable_graphite === true,
94
171
  external_comments: config.external_comments !== false,
95
172
  chat_spinner: config.chat_spinner || 'dots',
173
+ summaries: {
174
+ enabled: getSummaryEnabled(config),
175
+ auto_generate: getSummaryAutoGenerate(config)
176
+ },
177
+ tours: {
178
+ enabled: getTourEnabled(config),
179
+ auto_generate: getTourAutoGenerate(config)
180
+ },
96
181
  // Share configuration for external review viewers.
97
182
  // - url: The base URL of the external share site
98
183
  // - method: Plumbed through for future use (e.g., POST-based share flows).
@@ -200,6 +285,35 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
200
285
  }
201
286
  });
202
287
 
288
+ /**
289
+ * Get repository-specific header link configuration.
290
+ * Reads `config.repos["owner/repo"].links` and returns:
291
+ * - external: { label, url_template, icon } | null
292
+ * - github: boolean (false means hide the default GitHub link)
293
+ * - graphite: boolean (false means hide the Graphite link)
294
+ *
295
+ * The icon SVG is sanitised server-side (script tags, on* handlers, and
296
+ * `javascript:` URLs stripped). The url_template is NOT substituted here —
297
+ * the frontend has the live PR/branch context, so it performs the
298
+ * whitelisted substitution at render time.
299
+ */
300
+ router.get('/api/repos/:owner/:repo/links', (req, res) => {
301
+ try {
302
+ const { owner, repo } = req.params;
303
+ const repository = normalizeRepository(owner, repo);
304
+ const config = req.app.get('config') || {};
305
+ // Resolve via bindingRepository so monorepo-style configs (one
306
+ // `repos[...]` entry serving many captured owner/repo via
307
+ // url_pattern) surface the right link config.
308
+ const bindingRepository = resolveBindingRepositoryFromPR(owner, repo, config);
309
+ const links = resolveRepoLinks(config, bindingRepository);
310
+ res.json({ repository, links });
311
+ } catch (error) {
312
+ logger.error('Error resolving repo links:', error);
313
+ res.status(500).json({ error: 'Failed to resolve repository links' });
314
+ }
315
+ });
316
+
203
317
  /**
204
318
  * Save repository-specific settings
205
319
  * Saves default_instructions, default_provider, and/or default_model for the repository
@@ -410,3 +524,4 @@ function _resetPendingUpdate() {
410
524
 
411
525
  module.exports = router;
412
526
  module.exports._resetPendingUpdate = _resetPendingUpdate;
527
+ module.exports._resolveDefaultProviderModel = resolveDefaultProviderModel;
@@ -11,10 +11,11 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
  const express = require('express');
14
- const { ReviewRepository, ContextFileRepository, WorktreeRepository } = require('../database');
14
+ const { ContextFileRepository, WorktreeRepository } = require('../database');
15
15
  const logger = require('../utils/logger');
16
16
  const { broadcastReviewEvent } = require('../events/review-events');
17
17
  const { getDiffFileList } = require('../utils/diff-file-list');
18
+ const validateReviewId = require('./middleware/validate-review-id');
18
19
 
19
20
  const router = express.Router();
20
21
 
@@ -45,34 +46,6 @@ async function resolveRepoRoot(db, review) {
45
46
  return null;
46
47
  }
47
48
 
48
- /**
49
- * Middleware: validate that :reviewId exists in the reviews table.
50
- * Attaches the review record to req.review for downstream handlers.
51
- */
52
- async function validateReviewId(req, res, next) {
53
- try {
54
- const reviewId = parseInt(req.params.reviewId, 10);
55
-
56
- if (isNaN(reviewId) || reviewId <= 0) {
57
- return res.status(400).json({ error: 'Invalid review ID' });
58
- }
59
-
60
- const db = req.app.get('db');
61
- const reviewRepo = new ReviewRepository(db);
62
- const review = await reviewRepo.getReview(reviewId);
63
-
64
- if (!review) {
65
- return res.status(404).json({ error: `Review #${reviewId} not found` });
66
- }
67
-
68
- req.review = review;
69
- req.reviewId = reviewId;
70
- next();
71
- } catch (error) {
72
- next(error);
73
- }
74
- }
75
-
76
49
  /**
77
50
  * POST /api/reviews/:reviewId/context-files
78
51
  * Add a context file range for a review.
@@ -34,8 +34,9 @@ const router = express.Router();
34
34
  * Default dependencies for the sync flow. Tests override these via the
35
35
  * `externalCommentsDeps` Express app setting (or by passing `_deps` to
36
36
  * `executeSync` directly). Credential resolution is delegated to the
37
- * adapter via `adapter.resolveCredentials(config)` — keeps the route
38
- * source-agnostic and lets each adapter name its own env var in errors.
37
+ * adapter via `adapter.resolveCredentials(config, repository)` — keeps the
38
+ * route source-agnostic, lets each adapter name its own env var in errors,
39
+ * and threads the repo through so per-repo alt-host bindings apply.
39
40
  */
40
41
  const defaults = {
41
42
  getAdapter
@@ -115,7 +116,7 @@ function isPRMode(review) {
115
116
  * @param {Object} params.config - Server config (for token lookup)
116
117
  * @param {Object} params.review - Validated review row
117
118
  * @param {string} params.source - Adapter source name (e.g. 'github')
118
- * @param {Object} [params._deps] - Test overrides for { GitHubClient, getGitHubToken, getAdapter }
119
+ * @param {Object} [params._deps] - Test overrides for { GitHubClient, getGitHubToken, getAdapter, resolveHostBinding, resolveBindingRepositoryFromPR }
119
120
  * @returns {Promise<{count: number, lostAnchors: number, syncedAt: string}>}
120
121
  */
121
122
  async function executeSync({ db, config, review, source, _deps }) {
@@ -124,12 +125,9 @@ async function executeSync({ db, config, review, source, _deps }) {
124
125
  // Look up adapter — throws on unknown sources, caught by the route.
125
126
  const adapter = deps.getAdapter(source);
126
127
 
127
- // Delegate credential resolution to the adapter so the route stays
128
- // source-agnostic and each adapter can name its own env var in errors.
129
- // The adapter throws (e.g. GitHubApiError 401) when credentials are
130
- // missing — the route's catch maps it to a 401 response.
131
- const { client } = adapter.resolveCredentials(config || {}, _deps);
132
-
128
+ // Parse owner/repo BEFORE resolving credentials: the repository drives
129
+ // binding-aware credential resolution (per-repo api_host/token for
130
+ // alt-host repos), so it must be validated first.
133
131
  const [owner, repo] = String(review.repository).split('/');
134
132
  if (!owner || !repo) {
135
133
  throw new BadRequestError(
@@ -137,6 +135,18 @@ async function executeSync({ db, config, review, source, _deps }) {
137
135
  );
138
136
  }
139
137
 
138
+ // Delegate credential resolution to the adapter so the route stays
139
+ // source-agnostic and each adapter can name its own env var in errors.
140
+ // Thread `review.repository` through so the adapter resolves the
141
+ // repo-scoped host binding (alt-host api_host + repo token) instead of
142
+ // always targeting api.github.com with the top-level github.com token.
143
+ // The adapter throws (e.g. GitHubApiError 401) when credentials are
144
+ // missing — the route's catch maps it to a 401 response.
145
+ // `isAltHost` reflects whether the resolved binding targets an alternate
146
+ // Git host. Alt-hosts don't implement GitHub's deprecated `position`
147
+ // field, so it drives line-based anchoring in `mapComment` below.
148
+ const { client, isAltHost } = adapter.resolveCredentials(config || {}, review.repository, _deps);
149
+
140
150
  const apiRows = await adapter.fetchComments({
141
151
  client,
142
152
  owner,
@@ -156,7 +166,7 @@ async function executeSync({ db, config, review, source, _deps }) {
156
166
  for (const apiRow of apiRows || []) {
157
167
  let mapped;
158
168
  try {
159
- mapped = adapter.mapComment(apiRow);
169
+ mapped = adapter.mapComment(apiRow, { isAltHost });
160
170
  } catch (mapError) {
161
171
  // A malformed row from the source shouldn't kill the whole sync — log
162
172
  // it and keep going. The adapter only throws for genuinely malformed
@@ -161,7 +161,9 @@ function registerCollection(def) {
161
161
  }
162
162
 
163
163
  const config = req.app.get('config');
164
- const githubToken = getGitHubToken(config);
164
+ // Cross-repo search on github.com — no per-repo binding applies here.
165
+ // Explicit `undefined` repository selects the no-repo (top-level) path.
166
+ const githubToken = getGitHubToken(config, undefined);
165
167
  if (!githubToken) {
166
168
  return res.status(401).json({ success: false, error: 'GitHub token not configured' });
167
169
  }