@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.
- package/README.md +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +1029 -2169
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/TourBar.js +248 -0
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +64 -8
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +755 -0
- package/public/js/pr.js +1826 -56
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/modal-detection.js +77 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +24 -0
- package/public/pr.html +24 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +778 -10
- package/src/database.js +282 -1
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +201 -172
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +118 -3
- package/src/routes/context-files.js +2 -29
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +410 -13
- package/src/routes/mcp.js +47 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +556 -71
- package/src/routes/reviews.js +145 -29
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
- package/src/utils/diff-hunks.js +65 -0
- 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
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
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
|
};
|