@in-the-loop-labs/pair-review 3.7.2 → 3.8.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 +14 -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/js/local.js +1 -0
- package/public/js/pr.js +92 -7
- package/public/setup.html +8 -0
- package/src/ai/claude-provider.js +4 -3
- package/src/ai/codex-provider.js +4 -3
- package/src/ai/copilot-provider.js +15 -1
- package/src/ai/cursor-agent-provider.js +4 -3
- package/src/ai/executable-provider.js +12 -4
- package/src/ai/gemini-provider.js +15 -1
- package/src/ai/index.js +6 -0
- package/src/ai/opencode-provider.js +15 -1
- package/src/ai/pi-provider.js +4 -3
- package/src/ai/provider.js +69 -11
- package/src/chat/chat-providers.js +30 -9
- package/src/councils/headless-council.js +118 -0
- package/src/councils/resolve-council.js +167 -0
- package/src/local-review.js +13 -4
- package/src/main.js +209 -15
- package/src/single-port.js +20 -4
package/src/ai/provider.js
CHANGED
|
@@ -113,9 +113,13 @@ class AIProvider {
|
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
115
|
* Test if the provider's CLI is available
|
|
116
|
+
* @param {number} [timeoutMs] - Optional timeout in milliseconds for the
|
|
117
|
+
* availability probe. Subclasses that spawn a child process should use this
|
|
118
|
+
* to bound the check (the value is resolved per-provider by
|
|
119
|
+
* testProviderAvailability). Defaults to DEFAULT_AVAILABILITY_TIMEOUT_MS.
|
|
116
120
|
* @returns {Promise<boolean>}
|
|
117
121
|
*/
|
|
118
|
-
async testAvailability() {
|
|
122
|
+
async testAvailability(timeoutMs) {
|
|
119
123
|
throw new Error('testAvailability() must be implemented by subclass');
|
|
120
124
|
}
|
|
121
125
|
|
|
@@ -353,6 +357,28 @@ const providerRegistry = new Map();
|
|
|
353
357
|
*/
|
|
354
358
|
const providerConfigOverrides = new Map();
|
|
355
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Default timeout (ms) for a provider availability probe when no
|
|
362
|
+
* per-provider `availability_timeout_seconds` is configured.
|
|
363
|
+
*/
|
|
364
|
+
const DEFAULT_AVAILABILITY_TIMEOUT_MS = 10000;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Convert a configured `availability_timeout_seconds` value to milliseconds.
|
|
368
|
+
* Single source of truth for the "valid positive seconds" predicate shared by
|
|
369
|
+
* every availability-probe timeout (AI providers, executable providers, and
|
|
370
|
+
* chat providers); falls back to `defaultMs` when the value is unset,
|
|
371
|
+
* non-numeric, non-finite, or <= 0.
|
|
372
|
+
* @param {*} seconds - Raw config value, expected to be a number of seconds
|
|
373
|
+
* @param {number} [defaultMs=DEFAULT_AVAILABILITY_TIMEOUT_MS] - Fallback in ms
|
|
374
|
+
* @returns {number} Timeout in milliseconds
|
|
375
|
+
*/
|
|
376
|
+
function secondsToTimeoutMs(seconds, defaultMs = DEFAULT_AVAILABILITY_TIMEOUT_MS) {
|
|
377
|
+
return (typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0)
|
|
378
|
+
? seconds * 1000
|
|
379
|
+
: defaultMs;
|
|
380
|
+
}
|
|
381
|
+
|
|
356
382
|
/**
|
|
357
383
|
* Whether yolo mode is enabled (skips fine-grained provider permissions)
|
|
358
384
|
*/
|
|
@@ -534,6 +560,7 @@ function applyConfigOverrides(config) {
|
|
|
534
560
|
env: providerConfig.env,
|
|
535
561
|
load_skills: providerConfig.load_skills,
|
|
536
562
|
app_extensions: providerConfig.app_extensions,
|
|
563
|
+
availability_timeout_seconds: providerConfig.availability_timeout_seconds,
|
|
537
564
|
models: AliasClass.getModels() !== BaseClass.getModels() ? AliasClass.getModels() : null
|
|
538
565
|
});
|
|
539
566
|
logger.debug(`Registered aliased provider: ${providerId} (base: ${providerConfig.type})`);
|
|
@@ -561,6 +588,7 @@ function applyConfigOverrides(config) {
|
|
|
561
588
|
env: providerConfig.env,
|
|
562
589
|
load_skills: providerConfig.load_skills,
|
|
563
590
|
app_extensions: providerConfig.app_extensions,
|
|
591
|
+
availability_timeout_seconds: providerConfig.availability_timeout_seconds,
|
|
564
592
|
models: processedModels
|
|
565
593
|
});
|
|
566
594
|
}
|
|
@@ -724,27 +752,54 @@ function createProvider(providerId, model = null, overrides = {}) {
|
|
|
724
752
|
return new ProviderClass(actualModel, { ...(configOverrides || {}), ...overrides, yolo: yoloMode });
|
|
725
753
|
}
|
|
726
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Resolve the availability-probe timeout (ms) for a provider.
|
|
757
|
+
* Reads the per-provider `availability_timeout_seconds` config override
|
|
758
|
+
* (in seconds, mirroring `checkout_timeout_seconds`); falls back to
|
|
759
|
+
* DEFAULT_AVAILABILITY_TIMEOUT_MS when unset, non-numeric, or <= 0.
|
|
760
|
+
* @param {string} providerId - Provider ID
|
|
761
|
+
* @returns {number} Timeout in milliseconds
|
|
762
|
+
*/
|
|
763
|
+
function resolveAvailabilityTimeoutMs(providerId) {
|
|
764
|
+
const seconds = providerConfigOverrides.get(providerId)?.availability_timeout_seconds;
|
|
765
|
+
return secondsToTimeoutMs(seconds);
|
|
766
|
+
}
|
|
767
|
+
|
|
727
768
|
/**
|
|
728
769
|
* Test availability of a provider with timeout
|
|
729
770
|
* @param {string} providerId - Provider ID
|
|
730
|
-
* @param {number}
|
|
771
|
+
* @param {number} [timeoutMs] - Timeout in milliseconds. When omitted, resolved
|
|
772
|
+
* per-provider from `availability_timeout_seconds` (default 10 seconds).
|
|
731
773
|
* @returns {Promise<{available: boolean, error?: string}>}
|
|
732
774
|
*/
|
|
733
|
-
async function testProviderAvailability(providerId,
|
|
775
|
+
async function testProviderAvailability(providerId, timeoutMs) {
|
|
776
|
+
const effectiveTimeoutMs = timeoutMs != null ? timeoutMs : resolveAvailabilityTimeoutMs(providerId);
|
|
734
777
|
try {
|
|
735
778
|
const provider = createProvider(providerId);
|
|
736
779
|
|
|
737
|
-
// Race between availability test and timeout
|
|
780
|
+
// Race between availability test and timeout. The provider's own probe
|
|
781
|
+
// receives the same timeout so it can kill its child process on expiry;
|
|
782
|
+
// this race is the only guard for providers without an internal timeout.
|
|
783
|
+
// Capture the timer handle so we can clear it once the race settles —
|
|
784
|
+
// otherwise a fast probe with a large configured timeout would keep the
|
|
785
|
+
// Node event loop alive (and its rejection unobserved) until the timer fires.
|
|
786
|
+
let timeoutId;
|
|
738
787
|
const timeoutPromise = new Promise((_, reject) => {
|
|
739
|
-
|
|
788
|
+
timeoutId = setTimeout(
|
|
789
|
+
() => reject(new Error(`Provider test timed out after ${Math.round(effectiveTimeoutMs / 1000)}s`)),
|
|
790
|
+
effectiveTimeoutMs
|
|
791
|
+
);
|
|
740
792
|
});
|
|
741
793
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
794
|
+
try {
|
|
795
|
+
const available = await Promise.race([
|
|
796
|
+
provider.testAvailability(effectiveTimeoutMs),
|
|
797
|
+
timeoutPromise
|
|
798
|
+
]);
|
|
799
|
+
return { available };
|
|
800
|
+
} finally {
|
|
801
|
+
clearTimeout(timeoutId);
|
|
802
|
+
}
|
|
748
803
|
} catch (error) {
|
|
749
804
|
const ProviderClass = providerRegistry.get(providerId);
|
|
750
805
|
const installInstructions = ProviderClass?.getInstallInstructions() || 'Check the provider documentation.';
|
|
@@ -791,6 +846,9 @@ module.exports = {
|
|
|
791
846
|
getAllProvidersInfo,
|
|
792
847
|
createProvider,
|
|
793
848
|
testProviderAvailability,
|
|
849
|
+
resolveAvailabilityTimeoutMs,
|
|
850
|
+
secondsToTimeoutMs,
|
|
851
|
+
DEFAULT_AVAILABILITY_TIMEOUT_MS,
|
|
794
852
|
// Config override support
|
|
795
853
|
applyConfigOverrides,
|
|
796
854
|
createAliasedProviderClass,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const { spawn } = require('child_process');
|
|
10
|
-
const { getCachedAvailability } = require('../ai');
|
|
10
|
+
const { getCachedAvailability, secondsToTimeoutMs, DEFAULT_AVAILABILITY_TIMEOUT_MS } = require('../ai');
|
|
11
11
|
const logger = require('../utils/logger');
|
|
12
12
|
|
|
13
13
|
// Default dependencies (overridable for testing)
|
|
@@ -123,6 +123,9 @@ function getChatProvider(id) {
|
|
|
123
123
|
if (overrides.availability_command !== undefined) {
|
|
124
124
|
provider.availability_command = overrides.availability_command;
|
|
125
125
|
}
|
|
126
|
+
if (overrides.availability_timeout_seconds !== undefined) {
|
|
127
|
+
provider.availability_timeout_seconds = overrides.availability_timeout_seconds;
|
|
128
|
+
}
|
|
126
129
|
if (overrides.extra_args && Array.isArray(overrides.extra_args)) {
|
|
127
130
|
provider.args = [...provider.args, ...overrides.extra_args];
|
|
128
131
|
}
|
|
@@ -147,6 +150,9 @@ function getChatProvider(id) {
|
|
|
147
150
|
if (overrides.availability_command !== undefined) {
|
|
148
151
|
merged.availability_command = overrides.availability_command;
|
|
149
152
|
}
|
|
153
|
+
if (overrides.availability_timeout_seconds !== undefined) {
|
|
154
|
+
merged.availability_timeout_seconds = overrides.availability_timeout_seconds;
|
|
155
|
+
}
|
|
150
156
|
if (overrides.env) merged.env = { ...merged.env, ...overrides.env };
|
|
151
157
|
if (overrides.args) {
|
|
152
158
|
merged.args = overrides.args;
|
|
@@ -230,9 +236,10 @@ function isCodexProvider(id) {
|
|
|
230
236
|
/**
|
|
231
237
|
* Check availability of a single chat provider.
|
|
232
238
|
* Providers with `availability_command` run that command first.
|
|
233
|
-
* Without an availability command,
|
|
234
|
-
*
|
|
235
|
-
*
|
|
239
|
+
* Without an availability command, the built-in Pi provider (no `command`
|
|
240
|
+
* override) delegates to the existing AI provider availability cache; every
|
|
241
|
+
* other provider — including custom `type: 'pi'` providers and built-in Pi with
|
|
242
|
+
* a `command` override — spawns `<command> --version` to verify the binary exists.
|
|
236
243
|
* @param {string} id - Provider ID
|
|
237
244
|
* @param {Object} [_deps] - Dependency overrides for testing
|
|
238
245
|
* @returns {Promise<{available: boolean, error?: string}>}
|
|
@@ -245,6 +252,12 @@ async function checkChatProviderAvailability(id, _deps) {
|
|
|
245
252
|
|
|
246
253
|
const deps = { ...defaults, ..._deps };
|
|
247
254
|
|
|
255
|
+
// Per-provider availability-probe timeout. Configured in seconds (mirrors
|
|
256
|
+
// checkout_timeout_seconds); falls back to the shared default when
|
|
257
|
+
// unset/invalid. Build-based availability commands can raise this via
|
|
258
|
+
// `availability_timeout_seconds`.
|
|
259
|
+
const timeout = secondsToTimeoutMs(provider.availability_timeout_seconds);
|
|
260
|
+
|
|
248
261
|
if (provider.availability_command) {
|
|
249
262
|
return runCommandAvailabilityCheck({
|
|
250
263
|
deps,
|
|
@@ -253,11 +266,18 @@ async function checkChatProviderAvailability(id, _deps) {
|
|
|
253
266
|
displayCommand: 'availability command',
|
|
254
267
|
shell: true,
|
|
255
268
|
env: provider.env,
|
|
269
|
+
timeout,
|
|
256
270
|
});
|
|
257
271
|
}
|
|
258
272
|
|
|
259
|
-
//
|
|
260
|
-
|
|
273
|
+
// Delegate to the AI provider's cached availability only for the built-in Pi
|
|
274
|
+
// chat provider with no command override — i.e. the same binary the AI
|
|
275
|
+
// provider already probed. The built-in `pi` definition intentionally omits
|
|
276
|
+
// `command` (PiBridge resolves its own default at runtime), so `!provider.command`
|
|
277
|
+
// cleanly distinguishes it. Custom `type: 'pi'` providers and built-in Pi
|
|
278
|
+
// overridden with a different `command` point at a different binary, so they
|
|
279
|
+
// fall through to the `<command> --version` probe below (which honors `timeout`).
|
|
280
|
+
if (provider.type === 'pi' && !provider.command) {
|
|
261
281
|
const cached = getCachedAvailability('pi');
|
|
262
282
|
return { available: cached?.available || false, error: cached?.error };
|
|
263
283
|
}
|
|
@@ -278,6 +298,7 @@ async function checkChatProviderAvailability(id, _deps) {
|
|
|
278
298
|
displayCommand: `${command} --version`,
|
|
279
299
|
shell: useShell,
|
|
280
300
|
env: provider.env,
|
|
301
|
+
timeout,
|
|
281
302
|
});
|
|
282
303
|
}
|
|
283
304
|
|
|
@@ -296,15 +317,15 @@ async function checkChatProviderAvailability(id, _deps) {
|
|
|
296
317
|
* - `displayCommand` is used in error messages so user-configured shell strings
|
|
297
318
|
* do not need to be printed verbatim.
|
|
298
319
|
*
|
|
299
|
-
* @param {{deps: {spawn: Function}, command: string, args: string[], displayCommand: string, shell: boolean, env?: Object}} opts
|
|
320
|
+
* @param {{deps: {spawn: Function}, command: string, args: string[], displayCommand: string, shell: boolean, env?: Object, timeout?: number}} opts
|
|
300
321
|
* @returns {Promise<{available: boolean, error?: string}>}
|
|
301
322
|
*/
|
|
302
|
-
function runCommandAvailabilityCheck({ deps, command, args, displayCommand, shell, env }) {
|
|
323
|
+
function runCommandAvailabilityCheck({ deps, command, args, displayCommand, shell, env, timeout = DEFAULT_AVAILABILITY_TIMEOUT_MS }) {
|
|
303
324
|
return new Promise((resolve) => {
|
|
304
325
|
try {
|
|
305
326
|
const proc = deps.spawn(command, args, {
|
|
306
327
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
307
|
-
timeout
|
|
328
|
+
timeout,
|
|
308
329
|
shell,
|
|
309
330
|
env: { ...process.env, ...(env || {}) },
|
|
310
331
|
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const { v4: uuidv4 } = require('uuid');
|
|
4
|
+
const { AnalysisRunRepository, CouncilRepository } = require('../database');
|
|
5
|
+
const logger = require('../utils/logger');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run a council analysis without any server infrastructure.
|
|
9
|
+
*
|
|
10
|
+
* This is the server-free twin of `launchCouncilAnalysis` in
|
|
11
|
+
* `src/routes/analyses.js`. It exists for the CLI headless path, where the
|
|
12
|
+
* SSE broadcast helpers (`broadcastProgress`/`broadcastReviewEvent`), the
|
|
13
|
+
* `activeAnalyses` map, the worktree pool lifecycle, and the hook firing of
|
|
14
|
+
* the route are all absent. It reuses the SAME analyzer methods
|
|
15
|
+
* (`runReviewerCentricCouncil` / `runCouncilAnalysis`) and the SAME
|
|
16
|
+
* run-record semantics as the route.
|
|
17
|
+
*
|
|
18
|
+
* IMPORTANT: The two functions MUST stay in parity for run-record fields and
|
|
19
|
+
* completion semantics. Specifically:
|
|
20
|
+
* - The parent `analysis_runs` row is PRE-CREATED here with
|
|
21
|
+
* `model = council.id` and `runId` is passed into the analyzer. This is
|
|
22
|
+
* what attributes the run to the council. The analyzer methods do not
|
|
23
|
+
* create the parent row when a `runId` is supplied.
|
|
24
|
+
* - The analyzer methods do NOT mark the parent run completed/failed; this
|
|
25
|
+
* helper does it (mirroring the route's `.then()` / `.catch()`).
|
|
26
|
+
* Any change to run-record fields or completion handling must be applied to
|
|
27
|
+
* BOTH this function and `launchCouncilAnalysis`.
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} db - Database instance
|
|
30
|
+
* @param {Object} params - Analysis parameters
|
|
31
|
+
* @param {Object} params.analyzer - Analyzer instance exposing
|
|
32
|
+
* `runReviewerCentricCouncil` and `runCouncilAnalysis`
|
|
33
|
+
* @param {number} params.reviewId - Review ID (PR or local)
|
|
34
|
+
* @param {Object} params.council - Full council row (must have `.id`)
|
|
35
|
+
* @param {string} params.configType - 'council' (voice-centric) or 'advanced'
|
|
36
|
+
* @param {Object} params.councilConfig - The council's parsed config object
|
|
37
|
+
* @param {string} params.worktreePath - Path to the checked-out worktree
|
|
38
|
+
* @param {Object} [params.prMetadata] - PR metadata (head_sha used for the run row)
|
|
39
|
+
* @param {Object} params.instructions - { globalInstructions, repoInstructions, requestInstructions }
|
|
40
|
+
* (requestInstructions may be null)
|
|
41
|
+
* @param {Object} [params.githubClient] - GitHub client passed through to the analyzer
|
|
42
|
+
* @returns {Promise<Object>} The analyzer result ({ suggestions, summary, levelOutcomes, ... })
|
|
43
|
+
*/
|
|
44
|
+
async function runHeadlessCouncilAnalysis(db, params) {
|
|
45
|
+
const {
|
|
46
|
+
analyzer,
|
|
47
|
+
reviewId,
|
|
48
|
+
council,
|
|
49
|
+
configType,
|
|
50
|
+
councilConfig,
|
|
51
|
+
worktreePath,
|
|
52
|
+
prMetadata,
|
|
53
|
+
instructions,
|
|
54
|
+
githubClient,
|
|
55
|
+
} = params;
|
|
56
|
+
|
|
57
|
+
const runId = uuidv4();
|
|
58
|
+
|
|
59
|
+
// Compute levelsConfig the same way launchCouncilAnalysis (analyses.js:533-541) does.
|
|
60
|
+
let levelsConfig = null;
|
|
61
|
+
if (configType === 'council') {
|
|
62
|
+
levelsConfig = councilConfig.levels || null;
|
|
63
|
+
} else if (councilConfig.levels) {
|
|
64
|
+
levelsConfig = {};
|
|
65
|
+
for (const [key, val] of Object.entries(councilConfig.levels)) {
|
|
66
|
+
levelsConfig[key] = val?.enabled !== false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const runRepo = new AnalysisRunRepository(db);
|
|
71
|
+
await runRepo.create({
|
|
72
|
+
id: runId,
|
|
73
|
+
reviewId,
|
|
74
|
+
provider: 'council',
|
|
75
|
+
model: council.id,
|
|
76
|
+
tier: null,
|
|
77
|
+
globalInstructions: instructions.globalInstructions || null,
|
|
78
|
+
repoInstructions: instructions.repoInstructions || null,
|
|
79
|
+
requestInstructions: instructions.requestInstructions || null,
|
|
80
|
+
headSha: prMetadata?.head_sha || null,
|
|
81
|
+
configType,
|
|
82
|
+
levelsConfig
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
new CouncilRepository(db).touchLastUsedAt(council.id).catch(err => {
|
|
86
|
+
logger.warn(`Failed to update council last_used_at: ${err.message}`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const reviewContext = {
|
|
90
|
+
reviewId,
|
|
91
|
+
worktreePath,
|
|
92
|
+
prMetadata,
|
|
93
|
+
changedFiles: null,
|
|
94
|
+
instructions
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const result = configType === 'council'
|
|
99
|
+
? await analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { runId, progressCallback: null, githubClient })
|
|
100
|
+
: await analyzer.runCouncilAnalysis(reviewContext, councilConfig, { runId, progressCallback: null, githubClient });
|
|
101
|
+
|
|
102
|
+
await runRepo.update(runId, {
|
|
103
|
+
status: 'completed',
|
|
104
|
+
summary: result.summary,
|
|
105
|
+
totalSuggestions: result.suggestions.length,
|
|
106
|
+
...(result.levelOutcomes ? { levelOutcomes: result.levelOutcomes } : {})
|
|
107
|
+
}).catch(err => {
|
|
108
|
+
logger.warn(`Failed to update analysis_run: ${err.message}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return result;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
await runRepo.update(runId, { status: 'failed' }).catch(() => {});
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { runHeadlessCouncilAnalysis };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Council handle resolution helpers.
|
|
4
|
+
*
|
|
5
|
+
* Resolves user-supplied CLI handles (id, id-prefix, name, normalized name) to a
|
|
6
|
+
* saved council row, and gathers "Last Used With" metadata for the council list.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { query, CouncilRepository } = require('../database');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Normalize a string for fuzzy name matching: lowercase, trim, collapse any run
|
|
13
|
+
* of non-alphanumeric characters to a single dash, and strip leading/trailing dashes.
|
|
14
|
+
* @param {string} s - Input string
|
|
15
|
+
* @returns {string} Normalized slug-like string
|
|
16
|
+
*/
|
|
17
|
+
function normalizeForMatch(s) {
|
|
18
|
+
return String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Truncate an id to its first 8 characters for display.
|
|
23
|
+
* @param {string} id - Full council id (UUID)
|
|
24
|
+
* @returns {string} Short id
|
|
25
|
+
*/
|
|
26
|
+
function shortId(id) {
|
|
27
|
+
return String(id || '').slice(0, 8);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a clear, multi-line ambiguity error for a handle that matched several councils.
|
|
32
|
+
* @param {string} handle - The user-supplied handle
|
|
33
|
+
* @param {Array<Object>} matches - The matching council rows
|
|
34
|
+
* @returns {Error} Error with a readable, aligned candidate list
|
|
35
|
+
* @private
|
|
36
|
+
*/
|
|
37
|
+
function _ambiguityError(handle, matches) {
|
|
38
|
+
const padTo = Math.max(...matches.map(c => String(c.name || '').length));
|
|
39
|
+
const lines = matches.map(c => {
|
|
40
|
+
const name = String(c.name || '').padEnd(padTo);
|
|
41
|
+
// Show the FULL id, not shortId: when the collision is on the 8-char prefix,
|
|
42
|
+
// every shortId would be identical and could not disambiguate.
|
|
43
|
+
return ` ${name} (${c.id})`;
|
|
44
|
+
});
|
|
45
|
+
return new Error(
|
|
46
|
+
`Ambiguous council "${handle}" matches ${matches.length} councils. Disambiguate with the id:\n` +
|
|
47
|
+
lines.join('\n')
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a user-supplied council handle to a full council row.
|
|
53
|
+
*
|
|
54
|
+
* Matching order (first unambiguous match wins):
|
|
55
|
+
* 1. Exact id
|
|
56
|
+
* 2. UUID-prefix (only for hex-ish handles of length >= 4)
|
|
57
|
+
* 3. Exact name (case-insensitive)
|
|
58
|
+
* 4. Normalized name
|
|
59
|
+
* 5. Partial (substring) name fragment (last resort, never shadows the above)
|
|
60
|
+
*
|
|
61
|
+
* @param {Database} db - Database instance
|
|
62
|
+
* @param {string} handle - The handle to resolve (id, id-prefix, or name)
|
|
63
|
+
* @returns {Promise<Object>} The matching council row
|
|
64
|
+
* @throws {Error} If the handle is missing, ambiguous, or matches nothing
|
|
65
|
+
*/
|
|
66
|
+
async function resolveCouncilHandle(db, handle) {
|
|
67
|
+
const all = await new CouncilRepository(db).list();
|
|
68
|
+
|
|
69
|
+
if (!handle) {
|
|
70
|
+
throw new Error('A council handle is required.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 1. Exact id
|
|
74
|
+
const exactId = all.find(c => c.id === handle);
|
|
75
|
+
if (exactId) return exactId;
|
|
76
|
+
|
|
77
|
+
// 2. UUID-prefix match (only for hex-ish handles of meaningful length)
|
|
78
|
+
if (handle.length >= 4 && /^[0-9a-f-]+$/i.test(handle)) {
|
|
79
|
+
const m = all.filter(c => c.id.toLowerCase().startsWith(handle.toLowerCase()));
|
|
80
|
+
if (m.length === 1) return m[0];
|
|
81
|
+
if (m.length > 1) throw _ambiguityError(handle, m);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3. Exact name (case-insensitive)
|
|
85
|
+
{
|
|
86
|
+
const m = all.filter(c => c.name.toLowerCase() === handle.toLowerCase());
|
|
87
|
+
if (m.length === 1) return m[0];
|
|
88
|
+
if (m.length > 1) throw _ambiguityError(handle, m);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 4. Normalized name
|
|
92
|
+
{
|
|
93
|
+
const hn = normalizeForMatch(handle);
|
|
94
|
+
const m = all.filter(c => normalizeForMatch(c.name) === hn);
|
|
95
|
+
if (m.length === 1) return m[0];
|
|
96
|
+
if (m.length > 1) throw _ambiguityError(handle, m);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 5. Partial (substring) name fragment — last resort. A council matches if its
|
|
100
|
+
// name contains the handle (case-insensitive) OR its normalized name contains
|
|
101
|
+
// the normalized handle. Union both, de-duplicated by id so a council matched
|
|
102
|
+
// both ways isn't double-counted.
|
|
103
|
+
{
|
|
104
|
+
const hl = handle.toLowerCase();
|
|
105
|
+
const hn = normalizeForMatch(handle);
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const m = [];
|
|
108
|
+
for (const c of all) {
|
|
109
|
+
const byName = String(c.name || '').toLowerCase().includes(hl);
|
|
110
|
+
const byNorm = hn !== '' && normalizeForMatch(c.name).includes(hn);
|
|
111
|
+
if ((byName || byNorm) && !seen.has(c.id)) {
|
|
112
|
+
seen.add(c.id);
|
|
113
|
+
m.push(c);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (m.length === 1) return m[0];
|
|
117
|
+
if (m.length > 1) throw _ambiguityError(handle, m);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// No match
|
|
121
|
+
throw new Error(
|
|
122
|
+
`No council matches "${handle}". Run \`pair-review --list-councils\` to see available councils.`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build a map of the most recent council RUN per saved council, for the
|
|
128
|
+
* "Last Used With" column in the council list.
|
|
129
|
+
*
|
|
130
|
+
* Only counts true council runs (provider = 'council', model != 'inline-config').
|
|
131
|
+
* Councils with no council run simply won't appear in the map.
|
|
132
|
+
*
|
|
133
|
+
* @param {Database} db - Database instance
|
|
134
|
+
* @returns {Promise<Map<string, {repository: string, review_type: string, pr_number: number, last_started: string}>>}
|
|
135
|
+
* Map keyed by council id.
|
|
136
|
+
*/
|
|
137
|
+
async function getCouncilLastUsedRepos(db) {
|
|
138
|
+
const rows = await query(db, `
|
|
139
|
+
SELECT ar.model AS council_id,
|
|
140
|
+
r.repository AS repository,
|
|
141
|
+
r.review_type AS review_type,
|
|
142
|
+
r.pr_number AS pr_number,
|
|
143
|
+
MAX(ar.started_at) AS last_started
|
|
144
|
+
FROM analysis_runs ar
|
|
145
|
+
JOIN reviews r ON r.id = ar.review_id
|
|
146
|
+
WHERE ar.provider = 'council' AND ar.model != 'inline-config'
|
|
147
|
+
GROUP BY ar.model
|
|
148
|
+
`);
|
|
149
|
+
|
|
150
|
+
const map = new Map();
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
map.set(row.council_id, {
|
|
153
|
+
repository: row.repository,
|
|
154
|
+
review_type: row.review_type,
|
|
155
|
+
pr_number: row.pr_number,
|
|
156
|
+
last_started: row.last_started
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return map;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
normalizeForMatch,
|
|
164
|
+
shortId,
|
|
165
|
+
resolveCouncilHandle,
|
|
166
|
+
getCouncilLastUsedRepos
|
|
167
|
+
};
|
package/src/local-review.js
CHANGED
|
@@ -13,6 +13,7 @@ const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = r
|
|
|
13
13
|
const execAsync = promisify(exec);
|
|
14
14
|
const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope } = require('./local-scope');
|
|
15
15
|
const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
|
|
16
|
+
const { resolveCouncilHandle } = require('./councils/resolve-council');
|
|
16
17
|
const { startServer } = require('./server');
|
|
17
18
|
const { localReviewDiffs } = require('./routes/shared');
|
|
18
19
|
const summaryGenerator = require('./ai/summary-generator');
|
|
@@ -906,10 +907,18 @@ async function handleLocalReview(targetPath, flags = {}) {
|
|
|
906
907
|
console.log('Initializing database...');
|
|
907
908
|
db = await initializeDatabase(resolveDbName(config));
|
|
908
909
|
|
|
910
|
+
// Resolve the council handle FAIL-FAST before any further setup, so a bad
|
|
911
|
+
// handle surfaces as a clean error instead of after the browser opens.
|
|
912
|
+
let councilSelection = null;
|
|
913
|
+
if (flags.council) {
|
|
914
|
+
councilSelection = await resolveCouncilHandle(db, flags.council);
|
|
915
|
+
}
|
|
916
|
+
|
|
909
917
|
const session = await setupLocalReviewSession({ db, config, repoPath, flags });
|
|
910
918
|
setupComplete = true;
|
|
911
919
|
|
|
912
|
-
|
|
920
|
+
// Skipped when --council is set: council voices carry their own per-voice models.
|
|
921
|
+
if (flags.model && !flags.council) {
|
|
913
922
|
process.env.PAIR_REVIEW_MODEL = flags.model;
|
|
914
923
|
}
|
|
915
924
|
|
|
@@ -917,9 +926,9 @@ async function handleLocalReview(targetPath, flags = {}) {
|
|
|
917
926
|
const port = await startServer(db);
|
|
918
927
|
|
|
919
928
|
let url = `http://localhost:${port}/local/${session.sessionId}`;
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
}
|
|
929
|
+
const shouldAnalyze = flags.ai || flags.council;
|
|
930
|
+
if (shouldAnalyze) url += '?analyze=true';
|
|
931
|
+
if (councilSelection) url += `&council=${councilSelection.id}`;
|
|
923
932
|
console.log(`\nOpening browser to: ${url}`);
|
|
924
933
|
await open(url);
|
|
925
934
|
|