@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
package/src/config.js CHANGED
@@ -5,7 +5,52 @@ const os = require('os');
5
5
  const childProcess = require('child_process');
6
6
  const logger = require('./utils/logger');
7
7
 
8
+ // Implementation matrix for per-area dispatch. Each operations module
9
+ // exports an `IMPLEMENTED_MODES` Set lifted from its dispatcher's if/else
10
+ // chain, so validateRepoConfig() and the dispatcher cannot drift.
11
+ const _stackWalkerOps = require('./github/operations/stack-walker');
12
+ const _pendingReviewOps = require('./github/operations/pending-review');
13
+ const _reviewLifecycleOps = require('./github/operations/review-lifecycle');
14
+ const _pendingReviewCommentsOps = require('./github/operations/pending-review-comments');
15
+ const IMPLEMENTATION_MATRIX = {
16
+ stack_walker: _stackWalkerOps.IMPLEMENTED_MODES,
17
+ pending_review_check: _pendingReviewOps.IMPLEMENTED_MODES,
18
+ review_lifecycle: _reviewLifecycleOps.IMPLEMENTED_MODES,
19
+ pending_review_comments: _pendingReviewCommentsOps.IMPLEMENTED_MODES
20
+ };
21
+
22
+ // Recognised `_endpoint` sub-keys. These ride alongside the area key in
23
+ // `features` (e.g. `pending_review_comments_endpoint`) and are validated
24
+ // separately from area modes. Listed explicitly so a typo like
25
+ // `pending_review_commentes_endpoint` is rejected at startup rather than
26
+ // silently ignored.
27
+ const KNOWN_ENDPOINT_SUBKEYS = new Set([
28
+ 'pending_review_comments_endpoint'
29
+ ]);
30
+
8
31
  let _cachedCommandToken = null;
32
+ // Per-(repository, command) cache for repo-scoped token_command shell-outs.
33
+ // Key format: `${repository ?? ""}|${command}`. Repo-aware resolution must
34
+ // not collapse different (repo, command) pairs to a single shared token,
35
+ // so we key on both. See plan Hazards: "Token caching across hosts".
36
+ const _cachedRepoTokens = new Map();
37
+
38
+ // Areas that have a GraphQL implementation in this codebase today. When
39
+ // `api_host` is unset, these default to "graphql"; all other areas default
40
+ // to "rest". When `api_host` is set, all areas default to "rest" regardless.
41
+ const FEATURE_AREAS = [
42
+ 'pending_review_check',
43
+ 'stack_walker',
44
+ 'review_lifecycle',
45
+ 'pending_review_comments'
46
+ ];
47
+ const GRAPHQL_DEFAULT_AREAS = new Set([
48
+ 'pending_review_check',
49
+ 'stack_walker',
50
+ 'review_lifecycle',
51
+ 'pending_review_comments'
52
+ ]);
53
+ const ALLOWED_FEATURE_VALUES = new Set(['graphql', 'rest', 'host']);
9
54
 
10
55
  const CONFIG_DIR = path.join(os.homedir(), '.pair-review');
11
56
  const DEFAULT_CHECKOUT_TIMEOUT_MS = 300000;
@@ -23,6 +68,20 @@ const DEFAULT_CONFIG = {
23
68
  theme: "light",
24
69
  default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
25
70
  default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
71
+ tours: {
72
+ enabled: false, // When true, the guided-tour feature is available (toolbar button visible, etc.)
73
+ auto_generate: true, // When true, a tour generation job is kicked off automatically on review load
74
+ provider: "", // Provider for agentic tour generation. Empty = falls back to summaries.provider, then default_provider
75
+ model: "" // Model for tour generation. Empty = falls back to summaries.model resolution
76
+ },
77
+ summaries: {
78
+ enabled: false, // When true, the hunk-summaries feature is available (toolbar button + per-file toggles visible)
79
+ auto_generate: true, // When true, a summary generation job is kicked off automatically on review load
80
+ provider: "", // Provider for one-shot hunk summary AI tasks. Empty = falls back to default_provider
81
+ model: "", // Model for hunk summary tasks. Empty = uses provider's fast-tier model, then default_model
82
+ max_files: 50, // Skip summary generation for reviews touching more than this many files (perf cap)
83
+ max_lines_added: 3000 // Skip summary generation when the diff adds more than this many lines (perf cap)
84
+ },
26
85
  worktree_retention_days: 7,
27
86
  review_retention_days: 21,
28
87
  dev_mode: false, // When true, disables static file caching for development
@@ -121,6 +180,98 @@ function getDefaultModel(config) {
121
180
  return getConfigValue(config, 'default_model', 'model') || DEFAULT_CONFIG.default_model;
122
181
  }
123
182
 
183
+ /**
184
+ * Whether the summaries feature is enabled (toolbar button visible, kickoff allowed).
185
+ * @param {Object} config - Configuration object
186
+ * @returns {boolean}
187
+ */
188
+ function getSummaryEnabled(config) {
189
+ return Boolean(config && config.summaries && config.summaries.enabled === true);
190
+ }
191
+
192
+ /**
193
+ * Whether summaries should auto-generate on review load. Defaults to true when
194
+ * unset so the feature stays opt-out within the enabled flag.
195
+ * @param {Object} config - Configuration object
196
+ * @returns {boolean}
197
+ */
198
+ function getSummaryAutoGenerate(config) {
199
+ if (!config || !config.summaries) return true;
200
+ return config.summaries.auto_generate !== false;
201
+ }
202
+
203
+ /**
204
+ * Whether the tours feature is enabled (toolbar button visible, kickoff allowed).
205
+ * @param {Object} config - Configuration object
206
+ * @returns {boolean}
207
+ */
208
+ function getTourEnabled(config) {
209
+ return Boolean(config && config.tours && config.tours.enabled === true);
210
+ }
211
+
212
+ /**
213
+ * Whether tours should auto-generate on review load. Defaults to true when
214
+ * unset so the feature stays opt-out within the enabled flag.
215
+ * @param {Object} config - Configuration object
216
+ * @returns {boolean}
217
+ */
218
+ function getTourAutoGenerate(config) {
219
+ if (!config || !config.tours) return true;
220
+ return config.tours.auto_generate !== false;
221
+ }
222
+
223
+ /**
224
+ * Gets the summary provider for summary/tour generation
225
+ * Falls back to default_provider when summaries.provider is not set
226
+ * @param {Object} config - Configuration object
227
+ * @returns {string} - Provider name
228
+ */
229
+ function getSummaryProvider(config) {
230
+ const explicit = config && config.summaries && config.summaries.provider;
231
+ return explicit || getDefaultProvider(config);
232
+ }
233
+
234
+ /**
235
+ * Gets the summary model for summary/tour generation
236
+ * Resolution order: summaries.model → providerClass fast-tier → default_model
237
+ * @param {Object} config - Configuration object
238
+ * @param {Function} [providerClass] - Optional provider class with static getModels()
239
+ * @returns {string} - Model name
240
+ */
241
+ function getSummaryModel(config, providerClass = null) {
242
+ const explicit = config && config.summaries && config.summaries.model;
243
+ if (explicit) return explicit;
244
+ if (providerClass && typeof providerClass.getModels === 'function') {
245
+ const fast = providerClass.getModels().find(m => m.tier === 'fast');
246
+ if (fast) return fast.id;
247
+ }
248
+ return getDefaultModel(config);
249
+ }
250
+
251
+ /**
252
+ * Gets the provider for tour generation.
253
+ * Resolution order: tours.provider → summaries.provider → default_provider
254
+ * @param {Object} config - Configuration object
255
+ * @returns {string} - Provider name
256
+ */
257
+ function getTourProvider(config) {
258
+ const explicit = config && config.tours && config.tours.provider;
259
+ return explicit || getSummaryProvider(config);
260
+ }
261
+
262
+ /**
263
+ * Gets the model for tour generation.
264
+ * Resolution order: tours.model → summaries.model → providerClass fast-tier → default_model
265
+ * @param {Object} config - Configuration object
266
+ * @param {Function} [providerClass] - Optional provider class with static getModels()
267
+ * @returns {string} - Model name
268
+ */
269
+ function getTourModel(config, providerClass = null) {
270
+ const explicit = config && config.tours && config.tours.model;
271
+ if (explicit) return explicit;
272
+ return getSummaryModel(config, providerClass);
273
+ }
274
+
124
275
  /**
125
276
  * Copies the example config file to the user's config directory
126
277
  * @returns {Promise<boolean>} True if copied successfully, false if source doesn't exist
@@ -237,6 +388,10 @@ async function loadConfig() {
237
388
  mergedConfig.repos = deepMerge(lowerMonorepos, lowerRepos);
238
389
  delete mergedConfig.monorepos;
239
390
 
391
+ // Validate per-repo config invariants. Throws on the first violation
392
+ // so misconfiguration fails loudly at startup rather than at runtime.
393
+ validateRepoConfig(mergedConfig);
394
+
240
395
  // PORT env var overrides all config layers (used by Preview and similar harnesses)
241
396
  if (process.env.PORT) {
242
397
  const envPort = Number(process.env.PORT);
@@ -297,26 +452,294 @@ function getConfigDir() {
297
452
  }
298
453
 
299
454
  /**
300
- * Gets the GitHub token with environment variable taking precedence over config file.
301
- * Priority:
302
- * 1. GITHUB_TOKEN environment variable (highest priority)
303
- * 2. config.github_token from ~/.pair-review/config.json
304
- * 3. config.github_token_command execute shell command, use stdout (cached on success)
305
- * 4. Empty string (no token)
455
+ * Executes a shell command and returns its trimmed stdout as a token.
456
+ * Returns '' on failure or empty output; logs warnings via the shared
457
+ * logger.
458
+ *
459
+ * Results are cached in `_cachedRepoTokens` keyed on
460
+ * `${repository ?? ""}|${command}` to avoid re-running expensive
461
+ * helpers (e.g. `gh auth token`) on every API call while still
462
+ * keeping per-repo tokens isolated.
463
+ *
464
+ * @param {string} command - Shell command to execute
465
+ * @param {string|null|undefined} repository - Owner/repo for cache key (null for no-repo / top-level)
466
+ * @param {string} logContext - Short label for log messages (e.g. "github_token_command", "repo:token_command")
467
+ * @returns {string} - Token or empty string
468
+ */
469
+ function _runTokenCommand(command, repository, logContext) {
470
+ const cacheKey = `${repository ?? ''}|${command}`;
471
+ if (_cachedRepoTokens.has(cacheKey)) {
472
+ logger.debug(`Using token from ${logContext} (cached)`);
473
+ return _cachedRepoTokens.get(cacheKey);
474
+ }
475
+ logger.debug(`Attempting token from ${logContext}: ${command}`);
476
+ try {
477
+ const result = childProcess.execSync(command, {
478
+ encoding: 'utf8',
479
+ timeout: 5000,
480
+ stdio: ['pipe', 'pipe', 'ignore']
481
+ }).trim();
482
+ if (!result) {
483
+ logger.warn(`${logContext} did not produce a token (command: ${command})`);
484
+ return '';
485
+ }
486
+ logger.debug(`Using token from ${logContext}`);
487
+ _cachedRepoTokens.set(cacheKey, result);
488
+ return result;
489
+ } catch (error) {
490
+ logger.warn(`${logContext} failed (command: ${command}): ${error.message}`);
491
+ return '';
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Builds the `features` object for a host binding, filling in defaults.
497
+ * Default value is "graphql" when `apiHost` is null AND a GraphQL impl
498
+ * exists for that area; otherwise "rest". When `apiHost` is set, all
499
+ * defaults shift to "rest", EXCEPT `pending_review_comments` which
500
+ * defaults to "host" — the REST endpoint cannot reliably attach inline
501
+ * comments to a pending review, so the host-extension contract is the
502
+ * only supported alt-host mode (see docs/alt-host.md).
503
+ *
504
+ * @param {string|null} apiHost - Resolved api_host (null for github.com)
505
+ * @param {Object} explicit - User-supplied features overrides
506
+ * @returns {Object} - Features object with every known area populated
507
+ */
508
+ function _resolveFeatures(apiHost, explicit) {
509
+ const out = {};
510
+ const overrides = (explicit && typeof explicit === 'object') ? explicit : {};
511
+ for (const area of FEATURE_AREAS) {
512
+ if (typeof overrides[area] === 'string') {
513
+ out[area] = overrides[area];
514
+ continue;
515
+ }
516
+ if (apiHost === null && GRAPHQL_DEFAULT_AREAS.has(area)) {
517
+ out[area] = 'graphql';
518
+ } else if (apiHost !== null && area === 'pending_review_comments') {
519
+ // REST has no working pending-review comments path; default to
520
+ // the host-extension contract for alt-hosts.
521
+ out[area] = 'host';
522
+ } else {
523
+ out[area] = 'rest';
524
+ }
525
+ }
526
+ // Preserve endpoint-override sub-keys (e.g. `pending_review_comments_endpoint`)
527
+ // so the operations layer can read them at dispatch time. Validation of
528
+ // these keys happens in `validateRepoConfig()` at startup.
529
+ for (const [key, value] of Object.entries(overrides)) {
530
+ if (key.endsWith('_endpoint') && typeof value === 'string') {
531
+ out[key] = value;
532
+ }
533
+ }
534
+ return out;
535
+ }
536
+
537
+ /**
538
+ * Resolves the host binding for a given repository. The binding describes
539
+ * which API host pair-review should talk to, the token to authenticate
540
+ * with, and per-area dispatch flags.
541
+ *
542
+ * Token resolution priority for a repo with no `api_host` (github.com):
543
+ * 1. GITHUB_TOKEN environment variable
544
+ * 2. repo-level `token`
545
+ * 3. repo-level `token_command` (cached per (repo, command))
546
+ * 4. top-level `github_token`
547
+ * 5. top-level `github_token_command` (cached per (repo, command))
548
+ *
549
+ * For a repo with an `api_host` (alt-host), the github.com top-level
550
+ * credentials are NOT used — `GITHUB_TOKEN`, `config.github_token`, and
551
+ * `config.github_token_command` are all github.com-only and would be
552
+ * the wrong token for an alt-host endpoint. Only the repo-scoped
553
+ * `token` / `token_command` keys are consulted; missing those, the
554
+ * lookup returns an empty token so the caller can surface a clear
555
+ * "missing credential" error.
556
+ *
557
+ * Refreshable sources (`repo:token_command`, `config:github_token_command`)
558
+ * additionally carry a `refresh` closure on the returned binding. Calling
559
+ * `refresh()` busts the cached token for that exact source, re-runs the
560
+ * command, and resolves to the fresh token (empty string if the command now
561
+ * fails). For non-refreshable sources (`env:GITHUB_TOKEN`, `repo:token`,
562
+ * `config:github_token`, `none`) `refresh` is `null` — re-running would not
563
+ * change a literal token or env var.
564
+ *
565
+ * @param {string|null|undefined} repository - "owner/repo" identifier, or null/undefined for no-repo fallback
566
+ * @param {Object} config - Configuration object from loadConfig()
567
+ * @returns {{ apiHost: string|null, token: string, features: Object, source: string, refresh: (function(): string)|null }}
568
+ */
569
+ function resolveHostBinding(repository, config) {
570
+ const safeConfig = config || {};
571
+ const repoConfig = repository ? getRepoConfig(safeConfig, repository) : null;
572
+ const apiHost = (repoConfig && typeof repoConfig.api_host === 'string' && repoConfig.api_host)
573
+ ? repoConfig.api_host
574
+ : null;
575
+ const features = _resolveFeatures(apiHost, repoConfig?.features);
576
+
577
+ // Token resolution
578
+ let token = '';
579
+ let source = 'none';
580
+
581
+ // 1. GITHUB_TOKEN env var, only for github.com (no api_host)
582
+ if (apiHost === null && process.env.GITHUB_TOKEN) {
583
+ token = process.env.GITHUB_TOKEN;
584
+ source = 'env:GITHUB_TOKEN';
585
+ logger.debug('Using GitHub token from GITHUB_TOKEN environment variable');
586
+ return { apiHost, token, features, source, refresh: null };
587
+ }
588
+
589
+ // 2. Repo-level literal token
590
+ if (repoConfig && typeof repoConfig.token === 'string' && repoConfig.token) {
591
+ token = repoConfig.token;
592
+ source = 'repo:token';
593
+ logger.debug(`Using token from repos[${repository}].token`);
594
+ return { apiHost, token, features, source, refresh: null };
595
+ }
596
+
597
+ // 3. Repo-level token_command
598
+ if (repoConfig && typeof repoConfig.token_command === 'string' && repoConfig.token_command) {
599
+ const result = _runTokenCommand(repoConfig.token_command, repository, 'repo:token_command');
600
+ if (result) {
601
+ return {
602
+ apiHost,
603
+ token: result,
604
+ features,
605
+ source: 'repo:token_command',
606
+ refresh: _makeRefresh(repository, safeConfig, 'repo:token_command')
607
+ };
608
+ }
609
+ }
610
+
611
+ // 4. Top-level github_token. Only consulted for github.com bindings —
612
+ // the top-level token is a github.com credential and would fail
613
+ // authentication when sent to an alt-host.
614
+ if (apiHost === null && typeof safeConfig.github_token === 'string' && safeConfig.github_token) {
615
+ token = safeConfig.github_token;
616
+ source = 'config:github_token';
617
+ logger.debug('Using GitHub token from config.github_token');
618
+ return { apiHost, token, features, source, refresh: null };
619
+ }
620
+
621
+ // 5. Top-level github_token_command. Like step 4, github.com-only.
622
+ // The top-level command is a SINGLE shared provider, so cache by
623
+ // command only — keying on repository would re-invoke the (often
624
+ // slow) command per repo per session. Repo-level `token_command`
625
+ // above keeps its per-(repo, command) cache key.
626
+ if (apiHost === null && typeof safeConfig.github_token_command === 'string' && safeConfig.github_token_command) {
627
+ const result = _runTokenCommand(safeConfig.github_token_command, null, 'config:github_token_command');
628
+ if (result) {
629
+ return {
630
+ apiHost,
631
+ token: result,
632
+ features,
633
+ source: 'config:github_token_command',
634
+ refresh: _makeRefresh(repository, safeConfig, 'config:github_token_command')
635
+ };
636
+ }
637
+ }
638
+
639
+ if (apiHost !== null && repository) {
640
+ logger.debug(`No repo-scoped token resolved for alt-host repo ${repository} (${apiHost}); github.com top-level credentials are not used for alt-hosts`);
641
+ } else {
642
+ logger.debug('No token resolved for host binding');
643
+ }
644
+ return { apiHost, token: '', features, source: 'none', refresh: null };
645
+ }
646
+
647
+ /**
648
+ * Builds the `refresh` closure attached to a refreshable host binding.
306
649
  *
650
+ * The closure busts the cached token for the exact (repository, command)
651
+ * pair backing `source`, then re-resolves the binding and returns the
652
+ * freshly-resolved token. Cache invalidation happens BEFORE re-resolving so
653
+ * `_runTokenCommand` re-executes the command rather than returning the stale
654
+ * cached value — without this ordering, refresh would be a no-op.
655
+ *
656
+ * @param {string|null|undefined} repository - "owner/repo" identifier as supplied to resolveHostBinding
307
657
  * @param {Object} config - Configuration object from loadConfig()
658
+ * @param {('repo:token_command'|'config:github_token_command')} source - The refreshable source backing the binding
659
+ * @returns {function(): string} - Closure resolving to the fresh token (empty string on failure)
660
+ */
661
+ function _makeRefresh(repository, config, source) {
662
+ return function refresh() {
663
+ invalidateTokenCache(repository, config, source);
664
+ // Re-resolve after invalidation so _runTokenCommand re-executes the
665
+ // command. Returns '' if the command now fails or yields nothing.
666
+ return resolveHostBinding(repository, config).token;
667
+ };
668
+ }
669
+
670
+ /**
671
+ * Invalidates the cached token for a single refreshable source so the next
672
+ * resolution re-runs its command. Surgical: deletes ONLY the cache key for
673
+ * the supplied (repository, command) pair — other repos' cached tokens are
674
+ * left intact.
675
+ *
676
+ * - `repo:token_command` → key `${repository}|${repoConfig.token_command}`
677
+ * - `config:github_token_command` → key `|${config.github_token_command}`
678
+ * (also clears the single-slot `_cachedCommandToken` defensively, since
679
+ * the no-repo `getGitHubToken()` path caches the top-level command there)
680
+ *
681
+ * Literal-token and env sources are not refreshable, so calling this with
682
+ * any other `source` is a no-op.
683
+ *
684
+ * @param {string|null|undefined} repository - "owner/repo" identifier
685
+ * @param {Object} config - Configuration object from loadConfig()
686
+ * @param {('repo:token_command'|'config:github_token_command')} source - Source to invalidate
687
+ */
688
+ function invalidateTokenCache(repository, config, source) {
689
+ const safeConfig = config || {};
690
+ if (source === 'repo:token_command') {
691
+ const repoConfig = repository ? getRepoConfig(safeConfig, repository) : null;
692
+ const command = repoConfig && typeof repoConfig.token_command === 'string' ? repoConfig.token_command : '';
693
+ if (!command) return;
694
+ const cacheKey = `${repository ?? ''}|${command}`;
695
+ _cachedRepoTokens.delete(cacheKey);
696
+ logger.debug(`Invalidated cached token for repo:token_command (${repository})`);
697
+ return;
698
+ }
699
+ if (source === 'config:github_token_command') {
700
+ const command = typeof safeConfig.github_token_command === 'string' ? safeConfig.github_token_command : '';
701
+ if (!command) return;
702
+ const cacheKey = `|${command}`;
703
+ _cachedRepoTokens.delete(cacheKey);
704
+ // The no-repo getGitHubToken() path caches the same top-level command in
705
+ // a separate single slot; clear it too so both paths re-run the command.
706
+ _cachedCommandToken = null;
707
+ logger.debug('Invalidated cached token for config:github_token_command');
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Gets the GitHub token. When `repository` is supplied, the lookup is
713
+ * delegated to `resolveHostBinding()` for repo-aware resolution. When
714
+ * `repository` is omitted, the no-repo fallback shape is preserved
715
+ * (top-level keys only, env var wins) so callers without repo context
716
+ * (setup flows, auth-test) continue to work unchanged.
717
+ *
718
+ * Priority (no-repo case):
719
+ * 1. GITHUB_TOKEN environment variable
720
+ * 2. config.github_token
721
+ * 3. config.github_token_command (cached on success)
722
+ *
723
+ * @param {Object} config - Configuration object from loadConfig()
724
+ * @param {string} [repository] - Optional "owner/repo" identifier
308
725
  * @returns {string} - GitHub token or empty string if not configured
309
726
  */
310
- function getGitHubToken(config) {
727
+ function getGitHubToken(config, repository) {
728
+ if (repository) {
729
+ return resolveHostBinding(repository, config).token;
730
+ }
731
+ // No-repo fallback path. Preserves previous behaviour (and previous
732
+ // single-slot cache via _cachedCommandToken) for callers that have no
733
+ // repo context.
311
734
  if (process.env.GITHUB_TOKEN) {
312
735
  logger.debug('Using GitHub token from GITHUB_TOKEN environment variable');
313
736
  return process.env.GITHUB_TOKEN;
314
737
  }
315
- if (config.github_token) {
738
+ if (config && config.github_token) {
316
739
  logger.debug('Using GitHub token from config.github_token');
317
740
  return config.github_token;
318
741
  }
319
- if (config.github_token_command) {
742
+ if (config && config.github_token_command) {
320
743
  if (_cachedCommandToken !== null) {
321
744
  logger.debug('Using GitHub token from github_token_command (cached)');
322
745
  return _cachedCommandToken;
@@ -344,11 +767,338 @@ function getGitHubToken(config) {
344
767
  return '';
345
768
  }
346
769
 
770
+ /**
771
+ * Validates per-repo configuration entries. Called from `loadConfig()`
772
+ * after merging so that misconfiguration fails loudly at startup rather
773
+ * than silently degrading at runtime. Throws on the first invariant
774
+ * violation found.
775
+ *
776
+ * Invariants checked:
777
+ * - `api_host` set + any `features.<area>: "graphql"` → fail.
778
+ * Alt-hosts have no GraphQL endpoint; silently falling back would
779
+ * mislead.
780
+ * - `api_host` unset + any `features.<area>: "host"` → fail. Host
781
+ * extensions require a host.
782
+ * - `url_pattern` is present but not a valid regex → fail.
783
+ * - `git_remote_pattern` is present but not a valid regex → fail.
784
+ * - `links.external` is set but missing `label`/`url_template`, or
785
+ * the `url_template` doesn't start with `https://` → fail.
786
+ *
787
+ * @param {Object} config - Merged configuration object
788
+ * @throws {Error} On the first invalid repo entry
789
+ */
790
+ function validateRepoConfig(config) {
791
+ const repos = (config && config.repos) || {};
792
+ for (const [repoKey, repoEntry] of Object.entries(repos)) {
793
+ if (!repoEntry || typeof repoEntry !== 'object') continue;
794
+
795
+ const apiHost = (typeof repoEntry.api_host === 'string' && repoEntry.api_host) ? repoEntry.api_host : null;
796
+ const features = (repoEntry.features && typeof repoEntry.features === 'object') ? repoEntry.features : {};
797
+
798
+ for (const [area, value] of Object.entries(features)) {
799
+ // Endpoint-override sub-keys (e.g. `pending_review_comments_endpoint`)
800
+ // are validated separately below. Reject anything that ends in
801
+ // `_endpoint` but isn't a recognised override so typos surface here.
802
+ if (area.endsWith('_endpoint')) {
803
+ if (!KNOWN_ENDPOINT_SUBKEYS.has(area)) {
804
+ throw new Error(
805
+ `Invalid pair-review config: repos["${repoKey}"].features.${area} is not a recognised endpoint override. ` +
806
+ `Valid endpoint overrides: ${Array.from(KNOWN_ENDPOINT_SUBKEYS).join(', ')}.`
807
+ );
808
+ }
809
+ continue;
810
+ }
811
+ // Reject unknown feature keys (e.g. `pendin_review_check` typo).
812
+ // Without this, the key is silently ignored by _resolveFeatures.
813
+ if (!FEATURE_AREAS.includes(area)) {
814
+ throw new Error(
815
+ `Invalid pair-review config: repos["${repoKey}"].features.${area} is not a recognised feature area. ` +
816
+ `Valid feature areas: ${FEATURE_AREAS.join(', ')}.`
817
+ );
818
+ }
819
+ if (!ALLOWED_FEATURE_VALUES.has(value)) {
820
+ throw new Error(
821
+ `Invalid pair-review config: repos["${repoKey}"].features.${area} = "${value}" is not one of "graphql", "rest", or "host".`
822
+ );
823
+ }
824
+ // Implementation-matrix check: refuse modes that have no dispatcher
825
+ // entry. Without this, an unimplemented (area, mode) pair fails at
826
+ // dispatch time — which for review_lifecycle/pending_review_comments
827
+ // can happen AFTER a pending review has been created on GitHub.
828
+ const implemented = IMPLEMENTATION_MATRIX[area];
829
+ if (implemented && !implemented.has(value)) {
830
+ throw new Error(
831
+ `Invalid pair-review config: repos["${repoKey}"].features.${area} = "${value}" is not implemented. ` +
832
+ `Implemented modes for ${area}: ${Array.from(implemented).join(', ')}.`
833
+ );
834
+ }
835
+ if (apiHost && value === 'graphql') {
836
+ throw new Error(
837
+ `Invalid pair-review config: repos["${repoKey}"] sets api_host but features.${area} = "graphql". Alt-hosts do not support GraphQL; use "rest" or "host".`
838
+ );
839
+ }
840
+ if (!apiHost && value === 'host') {
841
+ throw new Error(
842
+ `Invalid pair-review config: repos["${repoKey}"].features.${area} = "host" requires api_host to be set.`
843
+ );
844
+ }
845
+ }
846
+
847
+ // Validate the resolved defaults so that areas the user did NOT
848
+ // override are also checked against the implementation matrix.
849
+ // Catches misconfigurations where the default for an area on a
850
+ // particular host kind has no dispatcher (e.g. an area whose default
851
+ // would resolve to a mode lacking a runtime implementation).
852
+ const resolvedFeatures = _resolveFeatures(apiHost, features);
853
+ for (const [area, value] of Object.entries(resolvedFeatures)) {
854
+ if (area.endsWith('_endpoint')) continue;
855
+ if (Object.prototype.hasOwnProperty.call(features, area)) continue; // already checked above
856
+ const implemented = IMPLEMENTATION_MATRIX[area];
857
+ if (implemented && !implemented.has(value)) {
858
+ throw new Error(
859
+ `Invalid pair-review config: repos["${repoKey}"] resolves features.${area} to "${value}" by default, which is not implemented. ` +
860
+ `Implemented modes for ${area}: ${Array.from(implemented).join(', ')}. ` +
861
+ `Set features.${area} explicitly to an implemented mode.`
862
+ );
863
+ }
864
+ }
865
+
866
+ // Validate the optional endpoint override for the host-extension
867
+ // `pending_review_comments` area. Only meaningful when that area is
868
+ // set to "host" — applying it otherwise is a config error.
869
+ const endpointOverride = features.pending_review_comments_endpoint;
870
+ if (endpointOverride !== undefined && endpointOverride !== null) {
871
+ if (typeof endpointOverride !== 'string' || !endpointOverride) {
872
+ throw new Error(
873
+ `Invalid pair-review config: repos["${repoKey}"].features.pending_review_comments_endpoint must be a non-empty string.`
874
+ );
875
+ }
876
+ if (features.pending_review_comments !== 'host') {
877
+ throw new Error(
878
+ `Invalid pair-review config: repos["${repoKey}"].features.pending_review_comments_endpoint is only valid when pending_review_comments = "host".`
879
+ );
880
+ }
881
+ // Must be a relative path (resolved against the configured baseUrl).
882
+ // Absolute URLs would silently bypass the host's baseUrl.
883
+ if (/^https?:\/\//i.test(endpointOverride) || /^\/\//.test(endpointOverride)) {
884
+ throw new Error(
885
+ `Invalid pair-review config: repos["${repoKey}"].features.pending_review_comments_endpoint must be a relative path (e.g. "/repos/{owner}/{repo}/..."), not an absolute URL.`
886
+ );
887
+ }
888
+ // All four placeholders must be present so callers don't accidentally
889
+ // send a request missing path components.
890
+ const required = ['{owner}', '{repo}', '{pull_number}', '{review_id}'];
891
+ const missing = required.filter(p => !endpointOverride.includes(p));
892
+ if (missing.length > 0) {
893
+ throw new Error(
894
+ `Invalid pair-review config: repos["${repoKey}"].features.pending_review_comments_endpoint is missing required placeholder(s): ${missing.join(', ')}.`
895
+ );
896
+ }
897
+ }
898
+
899
+ if (repoEntry.url_pattern !== undefined && repoEntry.url_pattern !== null) {
900
+ if (typeof repoEntry.url_pattern !== 'string') {
901
+ throw new Error(
902
+ `Invalid pair-review config: repos["${repoKey}"].url_pattern must be a string regex.`
903
+ );
904
+ }
905
+ try {
906
+ // eslint-disable-next-line no-new
907
+ new RegExp(repoEntry.url_pattern);
908
+ } catch (err) {
909
+ throw new Error(
910
+ `Invalid pair-review config: repos["${repoKey}"].url_pattern is not a valid regular expression: ${err.message}`
911
+ );
912
+ }
913
+ }
914
+
915
+ // Optional escape-hatch regex used by parseRepositoryFromURL to match
916
+ // a non-standard git remote URL to this repo entry. Validated here
917
+ // so misconfiguration fails loudly at startup rather than as a
918
+ // silent fall-through at CLI parse time.
919
+ if (repoEntry.git_remote_pattern !== undefined && repoEntry.git_remote_pattern !== null) {
920
+ if (typeof repoEntry.git_remote_pattern !== 'string') {
921
+ throw new Error(
922
+ `Invalid pair-review config: repos["${repoKey}"].git_remote_pattern must be a string regex.`
923
+ );
924
+ }
925
+ try {
926
+ // eslint-disable-next-line no-new
927
+ new RegExp(repoEntry.git_remote_pattern);
928
+ } catch (err) {
929
+ throw new Error(
930
+ `Invalid pair-review config: repos["${repoKey}"].git_remote_pattern is not a valid regular expression: ${err.message}`
931
+ );
932
+ }
933
+ }
934
+
935
+ const links = repoEntry.links;
936
+ if (links && typeof links === 'object' && links.external !== undefined && links.external !== null) {
937
+ const ext = links.external;
938
+ if (typeof ext !== 'object') {
939
+ throw new Error(
940
+ `Invalid pair-review config: repos["${repoKey}"].links.external must be an object with "label" and "url_template".`
941
+ );
942
+ }
943
+ if (typeof ext.label !== 'string' || !ext.label) {
944
+ throw new Error(
945
+ `Invalid pair-review config: repos["${repoKey}"].links.external.label must be a non-empty string.`
946
+ );
947
+ }
948
+ if (typeof ext.url_template !== 'string' || !ext.url_template) {
949
+ throw new Error(
950
+ `Invalid pair-review config: repos["${repoKey}"].links.external.url_template must be a non-empty string.`
951
+ );
952
+ }
953
+ if (!ext.url_template.startsWith('https://')) {
954
+ throw new Error(
955
+ `Invalid pair-review config: repos["${repoKey}"].links.external.url_template must start with "https://".`
956
+ );
957
+ }
958
+ // Optional display name for the host (e.g. "Meteorite"). Used in place
959
+ // of the literal "GitHub" in user-facing text. When omitted, callers
960
+ // fall back to "GitHub" (see resolveHostName in src/links/repo-links.js).
961
+ if (ext.name !== undefined && ext.name !== null
962
+ && (typeof ext.name !== 'string' || !ext.name)) {
963
+ throw new Error(
964
+ `Invalid pair-review config: repos["${repoKey}"].links.external.name must be a non-empty string when present.`
965
+ );
966
+ }
967
+ }
968
+ }
969
+ }
970
+
971
+ /**
972
+ * Matches a URL against per-repo `url_pattern` regexes and returns the
973
+ * resolved repo identifier and any named-group captures. Does NOT fall
974
+ * back to GitHub URL parsing — callers should try `matchRepoByUrl()`
975
+ * first and then `parseGitHubUrl()`.
976
+ *
977
+ * Repo configs are expected to be valid at this point (regex compilation
978
+ * is checked at startup by `validateRepoConfig()`); invalid regexes are
979
+ * silently skipped here as a defensive measure.
980
+ *
981
+ * The returned shape includes both:
982
+ * - `repository`: the canonical PR identity (`<owner>/<repo>`),
983
+ * preferring named-group captures so monorepo-style configs where one
984
+ * URL pattern maps to many sub-repos still return the captured PR.
985
+ * - `bindingRepository`: the matched `repos[...]` key. Use this when
986
+ * looking up host bindings (token, api_host, features) so a single
987
+ * monorepo-shaped binding can serve URLs whose captured
988
+ * owner/repo differ from the config key.
989
+ *
990
+ * When no pattern matches, this function returns `null`; callers are
991
+ * expected to fall back to `bindingRepository = "<owner>/<repo>"` on
992
+ * their own.
993
+ *
994
+ * @param {string} url - URL to match
995
+ * @param {Object} config - Configuration object from loadConfig()
996
+ * @returns {{ repository: string, bindingRepository: string, repoConfig: Object, owner?: string, repo?: string, number?: number }|null}
997
+ */
998
+ function matchRepoByUrl(url, config) {
999
+ if (!url || typeof url !== 'string') return null;
1000
+ const repos = (config && config.repos) || {};
1001
+ for (const [repoKey, repoEntry] of Object.entries(repos)) {
1002
+ if (!repoEntry || typeof repoEntry !== 'object' || !repoEntry.url_pattern) continue;
1003
+ let regex;
1004
+ try {
1005
+ regex = new RegExp(repoEntry.url_pattern);
1006
+ } catch {
1007
+ // Invalid regex — would have been caught at startup; skip.
1008
+ continue;
1009
+ }
1010
+ const match = regex.exec(url);
1011
+ if (!match) continue;
1012
+
1013
+ const groups = match.groups || {};
1014
+ const result = {
1015
+ repository: groups.owner && groups.repo ? `${groups.owner}/${groups.repo}` : repoKey,
1016
+ bindingRepository: repoKey,
1017
+ repoConfig: repoEntry
1018
+ };
1019
+ if (groups.owner) result.owner = groups.owner;
1020
+ if (groups.repo) result.repo = groups.repo;
1021
+ if (groups.number !== undefined) {
1022
+ const n = Number(groups.number);
1023
+ if (!Number.isNaN(n)) result.number = n;
1024
+ }
1025
+ return result;
1026
+ }
1027
+ return null;
1028
+ }
1029
+
1030
+ /**
1031
+ * Resolve the `repos[...]` binding-key for a PR identified by `<owner>/<repo>`.
1032
+ *
1033
+ * Most of the time the binding key is just `<owner>/<repo>` (lowercased)
1034
+ * and a direct lookup in `config.repos` suffices. For monorepo-style
1035
+ * configs where one `repos[...]` entry serves URLs whose captured
1036
+ * `owner/repo` differ from the config key (matched via `url_pattern`
1037
+ * named capture groups), the direct lookup misses. In that case we
1038
+ * scan `repos[...]` for an entry whose `url_pattern` regex captures
1039
+ * the supplied owner and repo via named groups when probed against a
1040
+ * candidate URL synthesized from its `api_host`.
1041
+ *
1042
+ * Returns the normalized `<owner>/<repo>` fallback when no monorepo
1043
+ * entry matches, so callers always have a stable lookup key to pass to
1044
+ * `resolveHostBinding()`.
1045
+ *
1046
+ * @param {string} owner
1047
+ * @param {string} repo
1048
+ * @param {Object} config - Configuration object from loadConfig()
1049
+ * @returns {string} - The repository key to use with resolveHostBinding()
1050
+ */
1051
+ function resolveBindingRepositoryFromPR(owner, repo, config) {
1052
+ const fallback = `${String(owner || '').toLowerCase()}/${String(repo || '').toLowerCase()}`;
1053
+ if (!owner || !repo) return fallback;
1054
+ const safeConfig = config || {};
1055
+ const repos = safeConfig.repos || {};
1056
+
1057
+ // Fast path: direct key hit.
1058
+ if (repos[fallback]) return fallback;
1059
+ // Case-insensitive scan in case the user keyed their config with
1060
+ // mixed-case entries despite the loader's normalisation.
1061
+ for (const repoKey of Object.keys(repos)) {
1062
+ if (repoKey.toLowerCase() === fallback) return repoKey;
1063
+ }
1064
+
1065
+ // Slow path: probe each entry's `url_pattern` against a synthetic URL
1066
+ // built from `api_host`. If the regex captures named groups whose
1067
+ // values equal the supplied owner/repo, the entry serves this PR.
1068
+ for (const [repoKey, repoEntry] of Object.entries(repos)) {
1069
+ if (!repoEntry || typeof repoEntry !== 'object') continue;
1070
+ const pattern = repoEntry.url_pattern;
1071
+ const apiHost = repoEntry.api_host;
1072
+ if (typeof pattern !== 'string' || !pattern) continue;
1073
+ if (typeof apiHost !== 'string' || !apiHost) continue;
1074
+ let regex;
1075
+ try { regex = new RegExp(pattern); } catch { continue; }
1076
+ // Strip api_host to a bare scheme + host to construct candidate
1077
+ // URLs the user's pattern might match. We try a couple of common
1078
+ // shapes; if the user's URL layout is exotic, they can set the
1079
+ // bindingRepository explicitly from the CLI parse path.
1080
+ const hostOnly = apiHost.replace(/\/api(\/v\d+)?\/?$/i, '');
1081
+ const candidates = [
1082
+ `${hostOnly}/${owner}/${repo}/pull/1`,
1083
+ `${apiHost}/${owner}/${repo}/pull/1`
1084
+ ];
1085
+ for (const candidate of candidates) {
1086
+ const m = regex.exec(candidate);
1087
+ if (m && m.groups && m.groups.owner === owner && m.groups.repo === repo) {
1088
+ return repoKey;
1089
+ }
1090
+ }
1091
+ }
1092
+
1093
+ return fallback;
1094
+ }
1095
+
347
1096
  /**
348
1097
  * Resets the cached command token. Exported for testing only.
349
1098
  */
350
1099
  function _resetTokenCache() {
351
1100
  _cachedCommandToken = null;
1101
+ _cachedRepoTokens.clear();
352
1102
  }
353
1103
 
354
1104
  /**
@@ -761,8 +1511,21 @@ module.exports = {
761
1511
  getConfigDir,
762
1512
  validatePort,
763
1513
  getGitHubToken,
1514
+ resolveHostBinding,
1515
+ invalidateTokenCache,
1516
+ validateRepoConfig,
1517
+ matchRepoByUrl,
1518
+ resolveBindingRepositoryFromPR,
764
1519
  getDefaultProvider,
765
1520
  getDefaultModel,
1521
+ getSummaryProvider,
1522
+ getSummaryModel,
1523
+ getSummaryEnabled,
1524
+ getSummaryAutoGenerate,
1525
+ getTourProvider,
1526
+ getTourModel,
1527
+ getTourEnabled,
1528
+ getTourAutoGenerate,
766
1529
  isRunningViaNpx,
767
1530
  showWelcomeMessage,
768
1531
  expandPath,
@@ -787,5 +1550,10 @@ module.exports = {
787
1550
  warnIfDevModeWithoutDbName,
788
1551
  shouldSkipUpdateNotifier,
789
1552
  _resetTokenCache,
790
- DEFAULT_CHECKOUT_TIMEOUT_MS
1553
+ DEFAULT_CHECKOUT_TIMEOUT_MS,
1554
+ // Canonical lists for per-area feature dispatch. Exported so tests
1555
+ // (and `src/github/client.js`'s `DEFAULT_FEATURES`) can assert against
1556
+ // a single source of truth.
1557
+ FEATURE_AREAS,
1558
+ GRAPHQL_DEFAULT_AREAS
791
1559
  };