@in-the-loop-labs/pair-review 3.6.0 → 3.7.1

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 (67) hide show
  1. package/README.md +4 -0
  2. package/package.json +20 -15
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  6. package/public/css/analysis-config.css +1807 -0
  7. package/public/css/pr.css +17 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +89 -44
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ChatPanel.js +11 -1
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/SuggestionNavigator.js +55 -10
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +58 -8
  18. package/public/js/modules/suggestion-manager.js +25 -1
  19. package/public/js/modules/tour-renderer.js +45 -5
  20. package/public/js/pr.js +703 -171
  21. package/public/js/repo-links.js +328 -0
  22. package/public/js/utils/provider-model.js +88 -0
  23. package/public/js/utils/scroll-into-view.js +164 -0
  24. package/public/js/utils/storage-keys.js +50 -0
  25. package/public/local.html +10 -0
  26. package/public/pr.html +10 -0
  27. package/public/repo-settings.html +1 -0
  28. package/public/setup.html +2 -0
  29. package/src/ai/analyzer.js +125 -18
  30. package/src/ai/claude-provider.js +31 -3
  31. package/src/config.js +664 -10
  32. package/src/external/github-adapter.js +114 -25
  33. package/src/git/base-branch.js +11 -4
  34. package/src/github/client.js +482 -588
  35. package/src/github/errors.js +55 -0
  36. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  37. package/src/github/impl/graphql/pending-review.js +153 -0
  38. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  39. package/src/github/impl/graphql/stack-walker.js +210 -0
  40. package/src/github/impl/host/pending-review-comments.js +338 -0
  41. package/src/github/impl/rest/pending-review.js +251 -0
  42. package/src/github/impl/rest/review-lifecycle.js +226 -0
  43. package/src/github/impl/rest/stack-walker.js +309 -0
  44. package/src/github/operations/pending-review-comments.js +79 -0
  45. package/src/github/operations/pending-review.js +89 -0
  46. package/src/github/operations/review-lifecycle.js +126 -0
  47. package/src/github/operations/stack-walker.js +87 -0
  48. package/src/github/parser.js +230 -4
  49. package/src/github/stack-walker.js +14 -189
  50. package/src/links/repo-links.js +230 -0
  51. package/src/local-review.js +13 -4
  52. package/src/main.js +136 -32
  53. package/src/routes/analyses.js +30 -7
  54. package/src/routes/bulk-analysis-configs.js +295 -0
  55. package/src/routes/config.js +102 -2
  56. package/src/routes/external-comments.js +20 -10
  57. package/src/routes/github-collections.js +3 -1
  58. package/src/routes/local.js +101 -11
  59. package/src/routes/mcp.js +47 -4
  60. package/src/routes/pr.js +298 -68
  61. package/src/routes/setup.js +8 -3
  62. package/src/routes/stack-analysis.js +33 -9
  63. package/src/routes/worktrees.js +3 -2
  64. package/src/server.js +2 -0
  65. package/src/setup/pr-setup.js +37 -11
  66. package/src/setup/stack-setup.js +13 -3
  67. package/src/single-port.js +6 -3
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;
@@ -343,6 +388,10 @@ async function loadConfig() {
343
388
  mergedConfig.repos = deepMerge(lowerMonorepos, lowerRepos);
344
389
  delete mergedConfig.monorepos;
345
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
+
346
395
  // PORT env var overrides all config layers (used by Preview and similar harnesses)
347
396
  if (process.env.PORT) {
348
397
  const envPort = Number(process.env.PORT);
@@ -403,26 +452,294 @@ function getConfigDir() {
403
452
  }
404
453
 
405
454
  /**
406
- * Gets the GitHub token with environment variable taking precedence over config file.
407
- * Priority:
408
- * 1. GITHUB_TOKEN environment variable (highest priority)
409
- * 2. config.github_token from ~/.pair-review/config.json
410
- * 3. config.github_token_command execute shell command, use stdout (cached on success)
411
- * 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.
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
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)
412
722
  *
413
723
  * @param {Object} config - Configuration object from loadConfig()
724
+ * @param {string} [repository] - Optional "owner/repo" identifier
414
725
  * @returns {string} - GitHub token or empty string if not configured
415
726
  */
416
- 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.
417
734
  if (process.env.GITHUB_TOKEN) {
418
735
  logger.debug('Using GitHub token from GITHUB_TOKEN environment variable');
419
736
  return process.env.GITHUB_TOKEN;
420
737
  }
421
- if (config.github_token) {
738
+ if (config && config.github_token) {
422
739
  logger.debug('Using GitHub token from config.github_token');
423
740
  return config.github_token;
424
741
  }
425
- if (config.github_token_command) {
742
+ if (config && config.github_token_command) {
426
743
  if (_cachedCommandToken !== null) {
427
744
  logger.debug('Using GitHub token from github_token_command (cached)');
428
745
  return _cachedCommandToken;
@@ -450,11 +767,338 @@ function getGitHubToken(config) {
450
767
  return '';
451
768
  }
452
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
+
453
1096
  /**
454
1097
  * Resets the cached command token. Exported for testing only.
455
1098
  */
456
1099
  function _resetTokenCache() {
457
1100
  _cachedCommandToken = null;
1101
+ _cachedRepoTokens.clear();
458
1102
  }
459
1103
 
460
1104
  /**
@@ -867,6 +1511,11 @@ module.exports = {
867
1511
  getConfigDir,
868
1512
  validatePort,
869
1513
  getGitHubToken,
1514
+ resolveHostBinding,
1515
+ invalidateTokenCache,
1516
+ validateRepoConfig,
1517
+ matchRepoByUrl,
1518
+ resolveBindingRepositoryFromPR,
870
1519
  getDefaultProvider,
871
1520
  getDefaultModel,
872
1521
  getSummaryProvider,
@@ -901,5 +1550,10 @@ module.exports = {
901
1550
  warnIfDevModeWithoutDbName,
902
1551
  shouldSkipUpdateNotifier,
903
1552
  _resetTokenCache,
904
- 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
905
1559
  };