@in-the-loop-labs/pair-review 3.6.0 → 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 +20 -15
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +0 -1737
- 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/ReviewModal.js +135 -13
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +58 -8
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +33 -3
- package/public/js/pr.js +653 -157
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +7 -0
- package/public/pr.html +7 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/analyzer.js +125 -18
- package/src/config.js +664 -10
- 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 +13 -4
- 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 +102 -2
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +101 -11
- package/src/routes/mcp.js +47 -4
- package/src/routes/pr.js +298 -68
- 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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Bulk analysis configuration routes.
|
|
4
|
+
*
|
|
5
|
+
* The index page can open many PR tabs at once. Rather than placing a large
|
|
6
|
+
* analysis configuration in every PR URL, the browser stores it here and passes
|
|
7
|
+
* a short ID through the setup/review URL. Each PR tab then resolves the ID
|
|
8
|
+
* before starting auto-analysis.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const express = require('express');
|
|
13
|
+
const logger = require('../utils/logger');
|
|
14
|
+
const { VALID_TIERS } = require('../ai/prompts/config');
|
|
15
|
+
const { getAllProvidersInfo } = require('../ai');
|
|
16
|
+
const { normalizeCouncilConfig, validateCouncilConfig } = require('./councils');
|
|
17
|
+
|
|
18
|
+
const router = express.Router();
|
|
19
|
+
|
|
20
|
+
const configs = new Map();
|
|
21
|
+
const CONFIG_TTL_MS = 30 * 60 * 1000;
|
|
22
|
+
const MAX_CONFIGS = 1000;
|
|
23
|
+
const MAX_INSTRUCTIONS_LENGTH = 5000;
|
|
24
|
+
const VALID_TIER_SET = new Set(VALID_TIERS);
|
|
25
|
+
const VALID_CONFIG_TYPES = new Set(['council', 'advanced']);
|
|
26
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
27
|
+
|
|
28
|
+
function pruneExpired(now = Date.now()) {
|
|
29
|
+
for (const [id, entry] of configs.entries()) {
|
|
30
|
+
if (entry.expiresAt <= now) {
|
|
31
|
+
configs.delete(id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function enforceMaxConfigs() {
|
|
37
|
+
while (configs.size > MAX_CONFIGS) {
|
|
38
|
+
const oldestId = configs.keys().next().value;
|
|
39
|
+
if (!oldestId) return;
|
|
40
|
+
configs.delete(oldestId);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isPlainObject(value) {
|
|
45
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateString(value, field, { required = false, max = 200 } = {}) {
|
|
49
|
+
if (value == null) {
|
|
50
|
+
return required ? `${field} is required` : null;
|
|
51
|
+
}
|
|
52
|
+
if (typeof value !== 'string' || value.trim().length === 0 || value.length > max) {
|
|
53
|
+
return `${field} must be a non-empty string up to ${max} characters`;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateCustomInstructions(value) {
|
|
59
|
+
if (value == null) return null;
|
|
60
|
+
if (typeof value !== 'string') return 'customInstructions must be a string';
|
|
61
|
+
if (value.length > MAX_INSTRUCTIONS_LENGTH) {
|
|
62
|
+
return `customInstructions exceed maximum length of ${MAX_INSTRUCTIONS_LENGTH} characters`;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateJsonShape(value, path = 'analysisConfig', depth = 0) {
|
|
68
|
+
if (depth > 20) return `${path} is too deeply nested`;
|
|
69
|
+
if (value == null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
if (value.length > 200) return `${path} has too many items`;
|
|
74
|
+
for (let i = 0; i < value.length; i++) {
|
|
75
|
+
const error = validateJsonShape(value[i], `${path}[${i}]`, depth + 1);
|
|
76
|
+
if (error) return error;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (!isPlainObject(value)) return `${path} must contain only JSON values`;
|
|
81
|
+
|
|
82
|
+
const entries = Object.entries(value);
|
|
83
|
+
if (entries.length > 200) return `${path} has too many keys`;
|
|
84
|
+
for (const [key, child] of entries) {
|
|
85
|
+
if (FORBIDDEN_KEYS.has(key)) return `${path} contains forbidden key ${key}`;
|
|
86
|
+
const error = validateJsonShape(child, `${path}.${key}`, depth + 1);
|
|
87
|
+
if (error) return error;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sanitizeExcludePrevious(value) {
|
|
93
|
+
if (value == null) return { error: null, value: undefined };
|
|
94
|
+
if (!isPlainObject(value)) return { error: 'excludePrevious must be an object' };
|
|
95
|
+
return {
|
|
96
|
+
error: null,
|
|
97
|
+
value: {
|
|
98
|
+
github: value.github === true,
|
|
99
|
+
feedback: value.feedback === true
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sanitizeEnabledLevels(value) {
|
|
105
|
+
if (value == null) return { error: null, value: undefined };
|
|
106
|
+
if (!Array.isArray(value)) return { error: 'enabledLevels must be an array' };
|
|
107
|
+
|
|
108
|
+
const levels = [];
|
|
109
|
+
for (const level of value) {
|
|
110
|
+
const number = Number(level);
|
|
111
|
+
if (![1, 2, 3].includes(number)) {
|
|
112
|
+
return { error: 'enabledLevels may only include levels 1, 2, and 3' };
|
|
113
|
+
}
|
|
114
|
+
if (!levels.includes(number)) levels.push(number);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (levels.length === 0) return { error: 'enabledLevels must include at least one level' };
|
|
118
|
+
return { error: null, value: levels };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sanitizeSingleConfig(config) {
|
|
122
|
+
let error = validateString(config.provider, 'provider', { required: true });
|
|
123
|
+
if (error) return { error };
|
|
124
|
+
|
|
125
|
+
error = validateString(config.model, 'model', { required: true });
|
|
126
|
+
if (error) return { error };
|
|
127
|
+
|
|
128
|
+
if (config.tier != null && (!VALID_TIER_SET.has(config.tier))) {
|
|
129
|
+
return { error: `tier must be one of ${VALID_TIERS.join(', ')}` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The modal builds two related fields: `instructions` carries the *effective*
|
|
133
|
+
// prompt (selected preset chips concatenated with the textarea) while
|
|
134
|
+
// `customInstructions` is the raw textarea only. Persist the effective prompt
|
|
135
|
+
// so bulk-launched analyses see the same prompt the modal showed the user —
|
|
136
|
+
// otherwise any chosen preset chips are silently dropped.
|
|
137
|
+
const effectiveInstructions = config.instructions || config.customInstructions;
|
|
138
|
+
|
|
139
|
+
error = validateCustomInstructions(effectiveInstructions);
|
|
140
|
+
if (error) return { error };
|
|
141
|
+
|
|
142
|
+
const enabledLevels = sanitizeEnabledLevels(config.enabledLevels);
|
|
143
|
+
if (enabledLevels.error) return { error: enabledLevels.error };
|
|
144
|
+
|
|
145
|
+
const excludePrevious = sanitizeExcludePrevious(config.excludePrevious);
|
|
146
|
+
if (excludePrevious.error) return { error: excludePrevious.error };
|
|
147
|
+
|
|
148
|
+
// Defense in depth: the bulk replay path forwards this stored pair straight to
|
|
149
|
+
// analysis with no client-side guard. If the model does not belong to the
|
|
150
|
+
// provider (a mismatched pair that slipped past the client resolver), fall back
|
|
151
|
+
// to the provider's own default rather than forwarding an invalid pair. Unknown
|
|
152
|
+
// providers (custom/unavailable, not in the registry) pass through unchanged.
|
|
153
|
+
let normalizedModel = config.model;
|
|
154
|
+
const providerInfo = getAllProvidersInfo().find(p => p.id === config.provider);
|
|
155
|
+
if (providerInfo && !providerInfo.models.some(m => m.id === config.model)) {
|
|
156
|
+
normalizedModel = providerInfo.defaultModel || config.model;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
error: null,
|
|
161
|
+
config: {
|
|
162
|
+
provider: config.provider,
|
|
163
|
+
model: normalizedModel,
|
|
164
|
+
tier: config.tier,
|
|
165
|
+
customInstructions: effectiveInstructions || null,
|
|
166
|
+
enabledLevels: enabledLevels.value,
|
|
167
|
+
skipLevel3: config.skipLevel3 === true,
|
|
168
|
+
noLevels: config.noLevels === true,
|
|
169
|
+
excludePrevious: excludePrevious.value
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sanitizeCouncilConfig(config) {
|
|
175
|
+
// configType selects which downstream validator runs, so reject unrecognized
|
|
176
|
+
// values rather than silently coercing them (which could route councilConfig
|
|
177
|
+
// through the wrong validator).
|
|
178
|
+
if (config.configType != null && !VALID_CONFIG_TYPES.has(config.configType)) {
|
|
179
|
+
return { error: `configType must be one of ${[...VALID_CONFIG_TYPES].join(', ')}` };
|
|
180
|
+
}
|
|
181
|
+
const configType = config.configType || 'advanced';
|
|
182
|
+
|
|
183
|
+
let error = validateString(config.councilId, 'councilId', { max: 128 });
|
|
184
|
+
if (error) return { error };
|
|
185
|
+
|
|
186
|
+
if (config.councilName != null) {
|
|
187
|
+
error = validateString(config.councilName, 'councilName', { max: 200 });
|
|
188
|
+
if (error) return { error };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
error = validateCustomInstructions(config.customInstructions);
|
|
192
|
+
if (error) return { error };
|
|
193
|
+
|
|
194
|
+
const excludePrevious = sanitizeExcludePrevious(config.excludePrevious);
|
|
195
|
+
if (excludePrevious.error) return { error: excludePrevious.error };
|
|
196
|
+
|
|
197
|
+
if (!config.councilId && !config.councilConfig) {
|
|
198
|
+
return { error: 'Either councilId or councilConfig is required' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let councilConfig;
|
|
202
|
+
if (config.councilConfig != null) {
|
|
203
|
+
const shapeError = validateJsonShape(config.councilConfig, 'councilConfig');
|
|
204
|
+
if (shapeError) return { error: shapeError };
|
|
205
|
+
|
|
206
|
+
councilConfig = normalizeCouncilConfig(config.councilConfig, configType);
|
|
207
|
+
const councilError = validateCouncilConfig(councilConfig, configType);
|
|
208
|
+
if (councilError) return { error: `Invalid council config: ${councilError}` };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
error: null,
|
|
213
|
+
config: {
|
|
214
|
+
isCouncil: true,
|
|
215
|
+
configType,
|
|
216
|
+
// When an inline snapshot is stored, drop councilId so the downstream
|
|
217
|
+
// analysis route is forced to use the exact modal-selected councilConfig
|
|
218
|
+
// rather than re-fetching (and possibly diverging from) the DB record.
|
|
219
|
+
councilId: councilConfig ? undefined : (config.councilId || undefined),
|
|
220
|
+
councilName: config.councilName || null,
|
|
221
|
+
councilConfig,
|
|
222
|
+
customInstructions: config.customInstructions || null,
|
|
223
|
+
excludePrevious: excludePrevious.value
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sanitizeAnalysisConfig(config) {
|
|
229
|
+
if (!isPlainObject(config)) {
|
|
230
|
+
return { error: 'analysisConfig object required' };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const shapeError = validateJsonShape(config);
|
|
234
|
+
if (shapeError) return { error: shapeError };
|
|
235
|
+
|
|
236
|
+
if (config.isCouncil === true) {
|
|
237
|
+
return sanitizeCouncilConfig(config);
|
|
238
|
+
}
|
|
239
|
+
return sanitizeSingleConfig(config);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
router.post('/api/bulk-analysis-configs', (req, res) => {
|
|
243
|
+
try {
|
|
244
|
+
const result = sanitizeAnalysisConfig(req.body?.analysisConfig);
|
|
245
|
+
if (result.error) {
|
|
246
|
+
return res.status(400).json({ error: result.error });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
pruneExpired();
|
|
250
|
+
|
|
251
|
+
const id = crypto.randomUUID();
|
|
252
|
+
configs.set(id, {
|
|
253
|
+
analysisConfig: result.config,
|
|
254
|
+
expiresAt: Date.now() + CONFIG_TTL_MS
|
|
255
|
+
});
|
|
256
|
+
enforceMaxConfigs();
|
|
257
|
+
|
|
258
|
+
res.json({ success: true, id, expiresInMs: CONFIG_TTL_MS });
|
|
259
|
+
} catch (error) {
|
|
260
|
+
logger.error('Failed to store bulk analysis config:', error);
|
|
261
|
+
res.status(500).json({ error: 'Failed to store bulk analysis config' });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
router.get('/api/bulk-analysis-configs/:id', (req, res) => {
|
|
266
|
+
const { id } = req.params;
|
|
267
|
+
pruneExpired();
|
|
268
|
+
|
|
269
|
+
const entry = configs.get(id);
|
|
270
|
+
if (!entry) {
|
|
271
|
+
return res.status(404).json({ error: 'Bulk analysis config not found' });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
res.json({
|
|
275
|
+
success: true,
|
|
276
|
+
analysisConfig: entry.analysisConfig
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
function _resetBulkAnalysisConfigs() {
|
|
281
|
+
configs.clear();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function _getBulkAnalysisConfig(id) {
|
|
285
|
+
const entry = configs.get(id);
|
|
286
|
+
return entry ? entry.analysisConfig : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = router;
|
|
290
|
+
module.exports._resetBulkAnalysisConfigs = _resetBulkAnalysisConfigs;
|
|
291
|
+
module.exports._getBulkAnalysisConfig = _getBulkAnalysisConfig;
|
|
292
|
+
module.exports._pruneExpired = pruneExpired;
|
|
293
|
+
module.exports._CONFIG_TTL_MS = CONFIG_TTL_MS;
|
|
294
|
+
module.exports._MAX_CONFIGS = MAX_CONFIGS;
|
|
295
|
+
module.exports._sanitizeAnalysisConfig = sanitizeAnalysisConfig;
|
package/src/routes/config.js
CHANGED
|
@@ -23,11 +23,16 @@ const { normalizeRepository } = require('../utils/paths');
|
|
|
23
23
|
const {
|
|
24
24
|
isRunningViaNpx,
|
|
25
25
|
getGitHubToken,
|
|
26
|
+
getDefaultProvider,
|
|
27
|
+
getDefaultModel,
|
|
28
|
+
resolveHostBinding,
|
|
29
|
+
resolveBindingRepositoryFromPR,
|
|
26
30
|
getSummaryEnabled,
|
|
27
31
|
getSummaryAutoGenerate,
|
|
28
32
|
getTourEnabled,
|
|
29
33
|
getTourAutoGenerate
|
|
30
34
|
} = require('../config');
|
|
35
|
+
const { resolveRepoLinks } = require('../links/repo-links');
|
|
31
36
|
const { version } = require('../../package.json');
|
|
32
37
|
const semver = require('semver');
|
|
33
38
|
const { getAllChatProviders, getAllCachedChatAvailability } = require('../chat/chat-providers');
|
|
@@ -70,10 +75,54 @@ router.get('/runtime-config.js', (req, res) => {
|
|
|
70
75
|
|
|
71
76
|
/**
|
|
72
77
|
* Get user configuration (for frontend use)
|
|
73
|
-
* Returns safe-to-expose configuration values
|
|
78
|
+
* Returns safe-to-expose configuration values.
|
|
79
|
+
*
|
|
80
|
+
* GitHub token presence is reported with two distinct fields:
|
|
81
|
+
* - has_global_github_token: always present. True iff `getGitHubToken(config)`
|
|
82
|
+
* resolves a token from the top-level config / env (no repo context).
|
|
83
|
+
* - has_github_token: present ONLY when both ?owner and ?repo query
|
|
84
|
+
* parameters are supplied. True iff a token can be resolved for that
|
|
85
|
+
* specific repository via `resolveHostBinding(repo, config)` — this
|
|
86
|
+
* covers repo-scoped `token`, `token_command`, alt-host bindings, AND
|
|
87
|
+
* falls through to the global lookup. Callers that know which repo
|
|
88
|
+
* they're rendering should pass these params so that repo-scoped
|
|
89
|
+
* authentication is reflected accurately (e.g. for deciding whether
|
|
90
|
+
* to enable GitHub-comment dedup). When the params are absent, the
|
|
91
|
+
* repo-aware field is omitted entirely — there is no safe default
|
|
92
|
+
* that doesn't risk silently meaning the wrong thing.
|
|
93
|
+
*/
|
|
94
|
+
/**
|
|
95
|
+
* Resolve a coherent default provider/model PAIR for the frontend.
|
|
96
|
+
*
|
|
97
|
+
* `default_provider` and `default_model` seed the bulk modal, auto-analyze, and
|
|
98
|
+
* the manual analyze dialog as a single selection, so they must belong together.
|
|
99
|
+
* An explicitly configured model wins (the user opted into it). When no model is
|
|
100
|
+
* configured, derive it from the selected provider's own default rather than the
|
|
101
|
+
* provider-agnostic global default — otherwise a provider-only override (e.g.
|
|
102
|
+
* `default_provider: 'gemini'`) would pair with an Anthropic model like 'opus'.
|
|
103
|
+
*
|
|
104
|
+
* @param {Object} config - Configuration object
|
|
105
|
+
* @returns {{ provider: string, model: string }}
|
|
74
106
|
*/
|
|
107
|
+
function resolveDefaultProviderModel(config) {
|
|
108
|
+
const provider = getDefaultProvider(config);
|
|
109
|
+
const providerInfo = getAllProvidersInfo().find(p => p.id === provider);
|
|
110
|
+
const explicitModel = config.default_model || config.model;
|
|
111
|
+
// Only honour an explicit model if it actually belongs to the selected provider.
|
|
112
|
+
// `DEFAULT_CONFIG.default_model` is always populated (e.g. 'opus'), so a
|
|
113
|
+
// provider-only override like `default_provider: 'gemini'` would otherwise inherit
|
|
114
|
+
// a foreign Anthropic model and return a mismatched pair. When the model does not
|
|
115
|
+
// belong to the provider, derive a coherent default from the provider itself.
|
|
116
|
+
const modelBelongs = explicitModel && providerInfo?.models?.some(m => m.id === explicitModel);
|
|
117
|
+
if (modelBelongs) {
|
|
118
|
+
return { provider, model: explicitModel };
|
|
119
|
+
}
|
|
120
|
+
return { provider, model: providerInfo?.defaultModel || getDefaultModel(config) };
|
|
121
|
+
}
|
|
122
|
+
|
|
75
123
|
router.get('/api/config', (req, res) => {
|
|
76
124
|
const config = req.app.get('config') || {};
|
|
125
|
+
const defaultPair = resolveDefaultProviderModel(config);
|
|
77
126
|
|
|
78
127
|
// Build chat_providers array with availability
|
|
79
128
|
const chatAvailability = getAllCachedChatAvailability();
|
|
@@ -81,13 +130,34 @@ router.get('/api/config', (req, res) => {
|
|
|
81
130
|
id: p.id, name: p.name, type: p.type, available: chatAvailability[p.id]?.available || false
|
|
82
131
|
}));
|
|
83
132
|
|
|
133
|
+
// Repo-aware token resolution (opt-in via ?owner & ?repo query params).
|
|
134
|
+
// Both must be non-empty strings; missing/partial params fall back to the
|
|
135
|
+
// global-only response shape.
|
|
136
|
+
const { owner, repo } = req.query;
|
|
137
|
+
const hasRepoContext = typeof owner === 'string' && owner.length > 0
|
|
138
|
+
&& typeof repo === 'string' && repo.length > 0;
|
|
139
|
+
|
|
140
|
+
const hasGlobalGithubToken = Boolean(getGitHubToken(config));
|
|
141
|
+
let hasRepoGithubToken = null;
|
|
142
|
+
if (hasRepoContext) {
|
|
143
|
+
const repository = `${owner}/${repo}`;
|
|
144
|
+
// resolveHostBinding already falls through to top-level config when
|
|
145
|
+
// no repo-scoped token is configured, so this is a true union of
|
|
146
|
+
// "repo-scoped binding works" OR "global token works".
|
|
147
|
+
hasRepoGithubToken = Boolean(resolveHostBinding(repository, config).token);
|
|
148
|
+
}
|
|
149
|
+
|
|
84
150
|
// Only return safe configuration values (not secrets like github_token)
|
|
85
151
|
res.json({
|
|
86
152
|
version,
|
|
87
153
|
theme: config.theme || 'light',
|
|
88
|
-
|
|
154
|
+
has_global_github_token: hasGlobalGithubToken,
|
|
155
|
+
// Repo-aware field — only included when owner+repo were supplied.
|
|
156
|
+
...(hasRepoContext ? { has_github_token: hasRepoGithubToken } : {}),
|
|
89
157
|
comment_button_action: config.comment_button_action || 'submit',
|
|
90
158
|
comment_format: config.comment_format || 'legacy',
|
|
159
|
+
default_provider: defaultPair.provider,
|
|
160
|
+
default_model: defaultPair.model,
|
|
91
161
|
// Include npx detection for frontend command examples
|
|
92
162
|
is_running_via_npx: isRunningViaNpx(),
|
|
93
163
|
enable_chat: config.enable_chat !== false,
|
|
@@ -215,6 +285,35 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
215
285
|
}
|
|
216
286
|
});
|
|
217
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Get repository-specific header link configuration.
|
|
290
|
+
* Reads `config.repos["owner/repo"].links` and returns:
|
|
291
|
+
* - external: { label, url_template, icon } | null
|
|
292
|
+
* - github: boolean (false means hide the default GitHub link)
|
|
293
|
+
* - graphite: boolean (false means hide the Graphite link)
|
|
294
|
+
*
|
|
295
|
+
* The icon SVG is sanitised server-side (script tags, on* handlers, and
|
|
296
|
+
* `javascript:` URLs stripped). The url_template is NOT substituted here —
|
|
297
|
+
* the frontend has the live PR/branch context, so it performs the
|
|
298
|
+
* whitelisted substitution at render time.
|
|
299
|
+
*/
|
|
300
|
+
router.get('/api/repos/:owner/:repo/links', (req, res) => {
|
|
301
|
+
try {
|
|
302
|
+
const { owner, repo } = req.params;
|
|
303
|
+
const repository = normalizeRepository(owner, repo);
|
|
304
|
+
const config = req.app.get('config') || {};
|
|
305
|
+
// Resolve via bindingRepository so monorepo-style configs (one
|
|
306
|
+
// `repos[...]` entry serving many captured owner/repo via
|
|
307
|
+
// url_pattern) surface the right link config.
|
|
308
|
+
const bindingRepository = resolveBindingRepositoryFromPR(owner, repo, config);
|
|
309
|
+
const links = resolveRepoLinks(config, bindingRepository);
|
|
310
|
+
res.json({ repository, links });
|
|
311
|
+
} catch (error) {
|
|
312
|
+
logger.error('Error resolving repo links:', error);
|
|
313
|
+
res.status(500).json({ error: 'Failed to resolve repository links' });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
218
317
|
/**
|
|
219
318
|
* Save repository-specific settings
|
|
220
319
|
* Saves default_instructions, default_provider, and/or default_model for the repository
|
|
@@ -425,3 +524,4 @@ function _resetPendingUpdate() {
|
|
|
425
524
|
|
|
426
525
|
module.exports = router;
|
|
427
526
|
module.exports._resetPendingUpdate = _resetPendingUpdate;
|
|
527
|
+
module.exports._resolveDefaultProviderModel = resolveDefaultProviderModel;
|
|
@@ -34,8 +34,9 @@ const router = express.Router();
|
|
|
34
34
|
* Default dependencies for the sync flow. Tests override these via the
|
|
35
35
|
* `externalCommentsDeps` Express app setting (or by passing `_deps` to
|
|
36
36
|
* `executeSync` directly). Credential resolution is delegated to the
|
|
37
|
-
* adapter via `adapter.resolveCredentials(config)` — keeps the
|
|
38
|
-
* source-agnostic
|
|
37
|
+
* adapter via `adapter.resolveCredentials(config, repository)` — keeps the
|
|
38
|
+
* route source-agnostic, lets each adapter name its own env var in errors,
|
|
39
|
+
* and threads the repo through so per-repo alt-host bindings apply.
|
|
39
40
|
*/
|
|
40
41
|
const defaults = {
|
|
41
42
|
getAdapter
|
|
@@ -115,7 +116,7 @@ function isPRMode(review) {
|
|
|
115
116
|
* @param {Object} params.config - Server config (for token lookup)
|
|
116
117
|
* @param {Object} params.review - Validated review row
|
|
117
118
|
* @param {string} params.source - Adapter source name (e.g. 'github')
|
|
118
|
-
* @param {Object} [params._deps] - Test overrides for { GitHubClient, getGitHubToken, getAdapter }
|
|
119
|
+
* @param {Object} [params._deps] - Test overrides for { GitHubClient, getGitHubToken, getAdapter, resolveHostBinding, resolveBindingRepositoryFromPR }
|
|
119
120
|
* @returns {Promise<{count: number, lostAnchors: number, syncedAt: string}>}
|
|
120
121
|
*/
|
|
121
122
|
async function executeSync({ db, config, review, source, _deps }) {
|
|
@@ -124,12 +125,9 @@ async function executeSync({ db, config, review, source, _deps }) {
|
|
|
124
125
|
// Look up adapter — throws on unknown sources, caught by the route.
|
|
125
126
|
const adapter = deps.getAdapter(source);
|
|
126
127
|
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// missing — the route's catch maps it to a 401 response.
|
|
131
|
-
const { client } = adapter.resolveCredentials(config || {}, _deps);
|
|
132
|
-
|
|
128
|
+
// Parse owner/repo BEFORE resolving credentials: the repository drives
|
|
129
|
+
// binding-aware credential resolution (per-repo api_host/token for
|
|
130
|
+
// alt-host repos), so it must be validated first.
|
|
133
131
|
const [owner, repo] = String(review.repository).split('/');
|
|
134
132
|
if (!owner || !repo) {
|
|
135
133
|
throw new BadRequestError(
|
|
@@ -137,6 +135,18 @@ async function executeSync({ db, config, review, source, _deps }) {
|
|
|
137
135
|
);
|
|
138
136
|
}
|
|
139
137
|
|
|
138
|
+
// Delegate credential resolution to the adapter so the route stays
|
|
139
|
+
// source-agnostic and each adapter can name its own env var in errors.
|
|
140
|
+
// Thread `review.repository` through so the adapter resolves the
|
|
141
|
+
// repo-scoped host binding (alt-host api_host + repo token) instead of
|
|
142
|
+
// always targeting api.github.com with the top-level github.com token.
|
|
143
|
+
// The adapter throws (e.g. GitHubApiError 401) when credentials are
|
|
144
|
+
// missing — the route's catch maps it to a 401 response.
|
|
145
|
+
// `isAltHost` reflects whether the resolved binding targets an alternate
|
|
146
|
+
// Git host. Alt-hosts don't implement GitHub's deprecated `position`
|
|
147
|
+
// field, so it drives line-based anchoring in `mapComment` below.
|
|
148
|
+
const { client, isAltHost } = adapter.resolveCredentials(config || {}, review.repository, _deps);
|
|
149
|
+
|
|
140
150
|
const apiRows = await adapter.fetchComments({
|
|
141
151
|
client,
|
|
142
152
|
owner,
|
|
@@ -156,7 +166,7 @@ async function executeSync({ db, config, review, source, _deps }) {
|
|
|
156
166
|
for (const apiRow of apiRows || []) {
|
|
157
167
|
let mapped;
|
|
158
168
|
try {
|
|
159
|
-
mapped = adapter.mapComment(apiRow);
|
|
169
|
+
mapped = adapter.mapComment(apiRow, { isAltHost });
|
|
160
170
|
} catch (mapError) {
|
|
161
171
|
// A malformed row from the source shouldn't kill the whole sync — log
|
|
162
172
|
// it and keep going. The adapter only throws for genuinely malformed
|
|
@@ -161,7 +161,9 @@ function registerCollection(def) {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
const config = req.app.get('config');
|
|
164
|
-
|
|
164
|
+
// Cross-repo search on github.com — no per-repo binding applies here.
|
|
165
|
+
// Explicit `undefined` repository selects the no-repo (top-level) path.
|
|
166
|
+
const githubToken = getGitHubToken(config, undefined);
|
|
165
167
|
if (!githubToken) {
|
|
166
168
|
return res.status(401).json({ success: false, error: 'GitHub token not configured' });
|
|
167
169
|
}
|