@in-the-loop-labs/pair-review 3.8.0 → 3.9.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 +175 -3
- 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 +27 -8
- package/public/js/utils/provider-model.js +9 -2
- package/src/ai/claude-provider.js +27 -27
- package/src/ai/index.js +8 -0
- package/src/ai/provider.js +225 -27
- package/src/councils/headless-council.js +13 -3
- package/src/database.js +52 -0
- package/src/git/worktree.js +7 -1
- package/src/interactive-analysis-config.js +152 -0
- package/src/local-review.js +43 -21
- package/src/main.js +951 -45
- package/src/mcp-stdio.js +40 -5
- package/src/review-config.js +164 -0
- package/src/routes/bulk-analysis-configs.js +45 -15
- package/src/routes/config.js +9 -4
- package/src/routes/executable-analysis.js +10 -1
- package/src/routes/local.js +170 -109
- package/src/routes/mcp.js +28 -31
- package/src/routes/pr.js +118 -56
- package/src/single-port.js +116 -6
- package/src/utils/logger.js +25 -4
package/src/mcp-stdio.js
CHANGED
|
@@ -15,12 +15,47 @@ const logger = require('./utils/logger');
|
|
|
15
15
|
/**
|
|
16
16
|
* Redirect all console output and logger writes to stderr.
|
|
17
17
|
* Must be called before any other module logs to stdout.
|
|
18
|
+
*
|
|
19
|
+
* Also sets `PAIR_REVIEW_QUIET_STDOUT=1` so that code which writes directly to
|
|
20
|
+
* `process.stdout` (bypassing console/logger) can detect that stdout is reserved
|
|
21
|
+
* and route its output to stderr instead. The only such path today is the child
|
|
22
|
+
* stdout of a configured checkout script (`executeCheckoutScript`,
|
|
23
|
+
* src/git/worktree.js). In both modes that call this function — MCP stdio (stdout
|
|
24
|
+
* is JSON-RPC) and headless `--json` (stdout is the JSON document) — keeping that
|
|
25
|
+
* child output off stdout is the correct, desired behavior.
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} [opts]
|
|
28
|
+
* @param {boolean} [opts.quiet=false] - When true (headless `--json` without
|
|
29
|
+
* `--debug`), *drop* progress narration entirely rather than relocating it to
|
|
30
|
+
* stderr. A coding agent's shell tool captures stderr into its context window,
|
|
31
|
+
* so relocated narration still costs tokens; quiet mode no-ops the ungated
|
|
32
|
+
* `console.log/info` narration and puts the logger into quiet mode
|
|
33
|
+
* (suppressing info/success/log/section). `console.warn`, `console.error`,
|
|
34
|
+
* `logger.warn`, and `logger.error` still emit to stderr — quiet drops only
|
|
35
|
+
* progress narration and never swallows warnings/errors.
|
|
18
36
|
*/
|
|
19
|
-
function redirectConsoleToStderr() {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
function redirectConsoleToStderr({ quiet = false } = {}) {
|
|
38
|
+
if (quiet) {
|
|
39
|
+
// Drop ungated narration; keep console.error real (→ stderr).
|
|
40
|
+
const noop = () => {};
|
|
41
|
+
console.log = noop;
|
|
42
|
+
console.info = noop;
|
|
43
|
+
// console.warn carries genuine diagnostics agents need (e.g. the
|
|
44
|
+
// --council/--model advisory, worktree-migration warnings) — route it to
|
|
45
|
+
// stderr rather than swallowing it. Quiet drops progress narration only.
|
|
46
|
+
console.warn = console.error;
|
|
47
|
+
// logger.warn writes to _stdout — point it at stderr so a warning during a
|
|
48
|
+
// run never corrupts the JSON document on real stdout.
|
|
49
|
+
logger.setOutputStream(process.stderr);
|
|
50
|
+
logger.setQuietEnabled(true);
|
|
51
|
+
} else {
|
|
52
|
+
console.log = console.error;
|
|
53
|
+
console.info = console.error;
|
|
54
|
+
console.warn = console.error;
|
|
55
|
+
logger.setOutputStream(process.stderr);
|
|
56
|
+
}
|
|
57
|
+
// Process-level signal for raw process.stdout.write paths (see JSDoc above).
|
|
58
|
+
process.env.PAIR_REVIEW_QUIET_STDOUT = '1';
|
|
24
59
|
}
|
|
25
60
|
|
|
26
61
|
/**
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Repo-default review-configuration resolver.
|
|
4
|
+
*
|
|
5
|
+
* A single helper that decides whether a review run should use a council or a
|
|
6
|
+
* single provider/model, honoring (in order of precedence): explicit CLI/request
|
|
7
|
+
* picks, the repo's saved defaults (`repo_settings.default_council_id` /
|
|
8
|
+
* `default_provider` / `default_model`), and finally the global config defaults.
|
|
9
|
+
*
|
|
10
|
+
* Both the headless CLI path and the interactive web analyze routes use this so
|
|
11
|
+
* that a repo's `default_council_id` — previously stored but never consulted —
|
|
12
|
+
* is honored consistently everywhere.
|
|
13
|
+
*
|
|
14
|
+
* The council-selection object returned here is intentionally drop-in compatible
|
|
15
|
+
* with what `runHeadlessCouncilAnalysis` (src/councils/headless-council.js) and
|
|
16
|
+
* the existing `performHeadlessReview` council path (src/main.js) consume:
|
|
17
|
+
* `{ council, configType, councilConfig }`.
|
|
18
|
+
* The derivation of `configType`/`councilConfig` mirrors that code exactly
|
|
19
|
+
* (`council.type || 'advanced'` + `normalizeCouncilConfig`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { RepoSettingsRepository, CouncilRepository } = require('./database');
|
|
23
|
+
const { resolveCouncilHandle } = require('./councils/resolve-council');
|
|
24
|
+
const { normalizeCouncilConfig, validateCouncilConfig } = require('./routes/councils');
|
|
25
|
+
const logger = require('./utils/logger');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Derive the `{ council, configType, councilConfig }` selection object from a
|
|
29
|
+
* resolved council row, applying the same normalization + validation as the
|
|
30
|
+
* existing headless council path (`performHeadlessReview`, src/main.js).
|
|
31
|
+
*
|
|
32
|
+
* @param {Object} council - Full council row (parsed config), as returned by
|
|
33
|
+
* `resolveCouncilHandle` or `CouncilRepository.getById`.
|
|
34
|
+
* @returns {{ council: Object, configType: string, councilConfig: Object }}
|
|
35
|
+
* @throws {Error} If the council's config is invalid for its type.
|
|
36
|
+
* @private
|
|
37
|
+
*/
|
|
38
|
+
function _buildCouncilSelection(council) {
|
|
39
|
+
const configType = council.type || 'advanced';
|
|
40
|
+
const councilConfig = normalizeCouncilConfig(council.config, configType);
|
|
41
|
+
// validateCouncilConfig returns an error string, or null when valid.
|
|
42
|
+
const validationError = validateCouncilConfig(councilConfig, configType);
|
|
43
|
+
if (validationError) {
|
|
44
|
+
throw new Error(`Invalid council "${council.name}": ${validationError}`);
|
|
45
|
+
}
|
|
46
|
+
return { council, configType, councilConfig };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the single-provider/model pair from the per-field precedence ladders.
|
|
51
|
+
*
|
|
52
|
+
* Mirrors the canonical (pre-refactor) headless derivation in
|
|
53
|
+
* `performHeadlessReview` and the MCP ladder (`src/routes/mcp.js`), which both
|
|
54
|
+
* let a deliberate per-invocation environment override win over a sticky,
|
|
55
|
+
* persisted repo default:
|
|
56
|
+
* provider: explicit › PAIR_REVIEW_PROVIDER › repo default › config.default_provider › config.provider › 'claude'
|
|
57
|
+
* model: explicit › PAIR_REVIEW_MODEL › repo default › config.default_model › config.model › 'opus'
|
|
58
|
+
*
|
|
59
|
+
* The environment variables sit ABOVE the repo defaults on purpose: `--ai-draft`
|
|
60
|
+
* / `--ai-review` (which route through this resolver via `performHeadlessReview`)
|
|
61
|
+
* historically resolved env-first, and CI/agent callers set `PAIR_REVIEW_MODEL` /
|
|
62
|
+
* `PAIR_REVIEW_PROVIDER` as one-shot overrides that should beat a repo's saved
|
|
63
|
+
* `default_model` / `default_provider`. (The interactive web "Analyze" route keeps
|
|
64
|
+
* its own repo-before-env single ladder in `src/routes/{pr,local}.js`; this
|
|
65
|
+
* resolver is consulted there only to detect council mode, not to pick the model.)
|
|
66
|
+
*
|
|
67
|
+
* Each field falls through independently, so supplying only `explicit.model`
|
|
68
|
+
* still resolves the provider from env/repo/config defaults (and vice versa).
|
|
69
|
+
*
|
|
70
|
+
* @param {Object} explicit - { provider, model } (either may be undefined)
|
|
71
|
+
* @param {Object|null} repoSettings - Row from RepoSettingsRepository.getRepoSettings
|
|
72
|
+
* @param {Object} config - Global config object
|
|
73
|
+
* @returns {{ type: 'single', provider: string, model: string }}
|
|
74
|
+
* @private
|
|
75
|
+
*/
|
|
76
|
+
function _buildSingleSelection(explicit, repoSettings, config) {
|
|
77
|
+
const cfg = config || {};
|
|
78
|
+
const provider = explicit.provider
|
|
79
|
+
|| process.env.PAIR_REVIEW_PROVIDER
|
|
80
|
+
|| repoSettings?.default_provider
|
|
81
|
+
|| cfg.default_provider
|
|
82
|
+
|| cfg.provider
|
|
83
|
+
|| 'claude';
|
|
84
|
+
const model = explicit.model
|
|
85
|
+
|| process.env.PAIR_REVIEW_MODEL
|
|
86
|
+
|| repoSettings?.default_model
|
|
87
|
+
|| cfg.default_model
|
|
88
|
+
|| cfg.model
|
|
89
|
+
|| 'opus';
|
|
90
|
+
return { type: 'single', provider, model };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the review configuration (council vs single provider/model) for a run.
|
|
95
|
+
*
|
|
96
|
+
* Precedence (highest first):
|
|
97
|
+
* 1. `explicit.council` — a `--council` handle (id / id-prefix / name). Resolved
|
|
98
|
+
* via `resolveCouncilHandle`. A bad handle throws (fail-fast for CLI/UI).
|
|
99
|
+
* 2. `explicit.provider` / `explicit.model` — an explicit single-model pick
|
|
100
|
+
* (e.g. `--model`). Returns a single selection; any missing field falls
|
|
101
|
+
* through to env/repo/config defaults.
|
|
102
|
+
* 3. `repo_settings.default_council_id` — looked up directly by id via
|
|
103
|
+
* `CouncilRepository.getById` (we already hold the UUID, so no handle
|
|
104
|
+
* matching is needed). If the id points to a council that no longer exists,
|
|
105
|
+
* a warning is logged and resolution falls through to the single default.
|
|
106
|
+
* 4. `PAIR_REVIEW_PROVIDER` / `PAIR_REVIEW_MODEL` — one-shot env overrides
|
|
107
|
+
* (deliberately above repo defaults — see `_buildSingleSelection`).
|
|
108
|
+
* 5. `repo_settings.default_provider` / `default_model` — single selection.
|
|
109
|
+
* 6. Global `config` defaults — single selection (final hardcoded fallbacks
|
|
110
|
+
* 'claude' / 'opus').
|
|
111
|
+
*
|
|
112
|
+
* @param {Object} db - Database instance.
|
|
113
|
+
* @param {string} repository - Repository in `owner/repo` form (may be null/undefined
|
|
114
|
+
* for repos with no saved settings; treated as "no repo defaults").
|
|
115
|
+
* @param {Object} [explicit] - Explicit picks: `{ council, provider, model }`.
|
|
116
|
+
* `council` is a CLI handle string (id-prefix/name) or a pre-resolved id.
|
|
117
|
+
* Any field may be undefined.
|
|
118
|
+
* @param {Object} [config] - Global config object (default provider/model, etc.).
|
|
119
|
+
* @returns {Promise<{ type: 'council', council: Object, configType: string, councilConfig: Object }
|
|
120
|
+
* | { type: 'single', provider: string, model: string }>}
|
|
121
|
+
* @throws {Error} If `explicit.council` cannot be resolved or its config is invalid.
|
|
122
|
+
*/
|
|
123
|
+
async function resolveReviewConfig(db, repository, explicit = {}, config = {}) {
|
|
124
|
+
const { council: explicitCouncil, provider: explicitProvider, model: explicitModel } = explicit || {};
|
|
125
|
+
|
|
126
|
+
// 1. Explicit --council handle wins over everything.
|
|
127
|
+
if (explicitCouncil) {
|
|
128
|
+
const council = await resolveCouncilHandle(db, explicitCouncil);
|
|
129
|
+
return { type: 'council', ..._buildCouncilSelection(council) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Explicit single-model pick (--provider / --model). Returns single; any
|
|
133
|
+
// missing field still resolves from repo/config defaults via the ladder.
|
|
134
|
+
if (explicitProvider || explicitModel) {
|
|
135
|
+
const repoSettings = repository
|
|
136
|
+
? await new RepoSettingsRepository(db).getRepoSettings(repository)
|
|
137
|
+
: null;
|
|
138
|
+
return _buildSingleSelection({ provider: explicitProvider, model: explicitModel }, repoSettings, config);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// No explicit pick — consult the repo's saved defaults.
|
|
142
|
+
const repoSettings = repository
|
|
143
|
+
? await new RepoSettingsRepository(db).getRepoSettings(repository)
|
|
144
|
+
: null;
|
|
145
|
+
|
|
146
|
+
// 3. Repo default council (resolve directly by id — we already hold the UUID).
|
|
147
|
+
if (repoSettings?.default_council_id) {
|
|
148
|
+
const council = await new CouncilRepository(db).getById(repoSettings.default_council_id);
|
|
149
|
+
if (council) {
|
|
150
|
+
return { type: 'council', ..._buildCouncilSelection(council) };
|
|
151
|
+
}
|
|
152
|
+
// The configured default council no longer exists. Don't fail the run —
|
|
153
|
+
// fall through to the single-provider default so analysis still proceeds.
|
|
154
|
+
logger.warn(
|
|
155
|
+
`Repo default council "${repoSettings.default_council_id}" for ${repository} ` +
|
|
156
|
+
`was not found; falling back to default provider/model.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 4 & 5. Single selection from repo defaults, then global config, then hardcoded.
|
|
161
|
+
return _buildSingleSelection({}, repoSettings, config);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { resolveReviewConfig };
|
|
@@ -13,6 +13,7 @@ const express = require('express');
|
|
|
13
13
|
const logger = require('../utils/logger');
|
|
14
14
|
const { VALID_TIERS } = require('../ai/prompts/config');
|
|
15
15
|
const { getAllProvidersInfo } = require('../ai');
|
|
16
|
+
const { modelMatches } = require('../ai/provider');
|
|
16
17
|
const { normalizeCouncilConfig, validateCouncilConfig } = require('./councils');
|
|
17
18
|
|
|
18
19
|
const router = express.Router();
|
|
@@ -150,9 +151,16 @@ function sanitizeSingleConfig(config) {
|
|
|
150
151
|
// provider (a mismatched pair that slipped past the client resolver), fall back
|
|
151
152
|
// to the provider's own default rather than forwarding an invalid pair. Unknown
|
|
152
153
|
// providers (custom/unavailable, not in the registry) pass through unchanged.
|
|
154
|
+
// A model "belongs" to the provider if config.model matches a model id OR one of
|
|
155
|
+
// its aliases (e.g. 'opus' is an alias of the canonical 'opus-4.8-xhigh'). Matching
|
|
156
|
+
// id-only would wrongly treat a valid alias as a mismatched pair and silently coerce
|
|
157
|
+
// it to the provider default.
|
|
153
158
|
let normalizedModel = config.model;
|
|
154
159
|
const providerInfo = getAllProvidersInfo().find(p => p.id === config.provider);
|
|
155
|
-
|
|
160
|
+
const modelBelongsToProvider = providerInfo && providerInfo.models.some(
|
|
161
|
+
m => modelMatches(m, config.model)
|
|
162
|
+
);
|
|
163
|
+
if (providerInfo && !modelBelongsToProvider) {
|
|
156
164
|
normalizedModel = providerInfo.defaultModel || config.model;
|
|
157
165
|
}
|
|
158
166
|
|
|
@@ -239,24 +247,45 @@ function sanitizeAnalysisConfig(config) {
|
|
|
239
247
|
return sanitizeSingleConfig(config);
|
|
240
248
|
}
|
|
241
249
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
250
|
+
/**
|
|
251
|
+
* Validate + store an analysis config, returning its short id. Single source of
|
|
252
|
+
* truth for the in-memory store, shared by the HTTP POST handler (index-page bulk
|
|
253
|
+
* launcher) and the CLI interactive `--instructions` flow, which stashes the
|
|
254
|
+
* resolved config so the browser-side auto-analyze can pick it up via the
|
|
255
|
+
* `analysisConfigId` URL param (same in-process Map the GET handler reads).
|
|
256
|
+
*
|
|
257
|
+
* @param {Object} analysisConfig - Raw analysis config (single or council shape)
|
|
258
|
+
* @returns {{ id: string, expiresInMs: number }}
|
|
259
|
+
* @throws {Error} with `.statusCode = 400` when the config fails validation
|
|
260
|
+
*/
|
|
261
|
+
function createBulkAnalysisConfig(analysisConfig) {
|
|
262
|
+
const result = sanitizeAnalysisConfig(analysisConfig);
|
|
263
|
+
if (result.error) {
|
|
264
|
+
const err = new Error(result.error);
|
|
265
|
+
err.statusCode = 400;
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
248
268
|
|
|
249
|
-
|
|
269
|
+
pruneExpired();
|
|
270
|
+
|
|
271
|
+
const id = crypto.randomUUID();
|
|
272
|
+
configs.set(id, {
|
|
273
|
+
analysisConfig: result.config,
|
|
274
|
+
expiresAt: Date.now() + CONFIG_TTL_MS
|
|
275
|
+
});
|
|
276
|
+
enforceMaxConfigs();
|
|
250
277
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
analysisConfig: result.config,
|
|
254
|
-
expiresAt: Date.now() + CONFIG_TTL_MS
|
|
255
|
-
});
|
|
256
|
-
enforceMaxConfigs();
|
|
278
|
+
return { id, expiresInMs: CONFIG_TTL_MS };
|
|
279
|
+
}
|
|
257
280
|
|
|
258
|
-
|
|
281
|
+
router.post('/api/bulk-analysis-configs', (req, res) => {
|
|
282
|
+
try {
|
|
283
|
+
const { id, expiresInMs } = createBulkAnalysisConfig(req.body?.analysisConfig);
|
|
284
|
+
res.json({ success: true, id, expiresInMs });
|
|
259
285
|
} catch (error) {
|
|
286
|
+
if (error.statusCode === 400) {
|
|
287
|
+
return res.status(400).json({ error: error.message });
|
|
288
|
+
}
|
|
260
289
|
logger.error('Failed to store bulk analysis config:', error);
|
|
261
290
|
res.status(500).json({ error: 'Failed to store bulk analysis config' });
|
|
262
291
|
}
|
|
@@ -287,6 +316,7 @@ function _getBulkAnalysisConfig(id) {
|
|
|
287
316
|
}
|
|
288
317
|
|
|
289
318
|
module.exports = router;
|
|
319
|
+
module.exports.createBulkAnalysisConfig = createBulkAnalysisConfig;
|
|
290
320
|
module.exports._resetBulkAnalysisConfigs = _resetBulkAnalysisConfigs;
|
|
291
321
|
module.exports._getBulkAnalysisConfig = _getBulkAnalysisConfig;
|
|
292
322
|
module.exports._pruneExpired = pruneExpired;
|
package/src/routes/config.js
CHANGED
|
@@ -17,7 +17,8 @@ const {
|
|
|
17
17
|
getCachedAvailability,
|
|
18
18
|
getAllCachedAvailability,
|
|
19
19
|
checkAllProviders,
|
|
20
|
-
isCheckInProgress
|
|
20
|
+
isCheckInProgress,
|
|
21
|
+
modelMatches
|
|
21
22
|
} = require('../ai');
|
|
22
23
|
const { normalizeRepository } = require('../utils/paths');
|
|
23
24
|
const {
|
|
@@ -113,9 +114,13 @@ function resolveDefaultProviderModel(config) {
|
|
|
113
114
|
// provider-only override like `default_provider: 'gemini'` would otherwise inherit
|
|
114
115
|
// a foreign Anthropic model and return a mismatched pair. When the model does not
|
|
115
116
|
// belong to the provider, derive a coherent default from the provider itself.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// Match by canonical id OR alias so a config naming an alias (e.g. 'opus') is
|
|
118
|
+
// recognized — but return the canonical id, because the frontend matches model
|
|
119
|
+
// cards by canonical id only; returning the raw alias would fail to match and
|
|
120
|
+
// silently fall back to the provider default.
|
|
121
|
+
const matchedModel = explicitModel && providerInfo?.models?.find(m => modelMatches(m, explicitModel));
|
|
122
|
+
if (matchedModel) {
|
|
123
|
+
return { provider, model: matchedModel.id };
|
|
119
124
|
}
|
|
120
125
|
return { provider, model: providerInfo?.defaultModel || getDefaultModel(config) };
|
|
121
126
|
}
|
|
@@ -96,9 +96,15 @@ async function generateDiffForExecutable(cwd, context, diffArgs, outputPath) {
|
|
|
96
96
|
* (branch, staged, unstaged, untracked) that were included in the diff.
|
|
97
97
|
* @param {string} cwd - Working directory
|
|
98
98
|
* @param {Object} context - Executable context with baseSha/headSha or scope fields
|
|
99
|
+
* @param {Object} [options]
|
|
100
|
+
* @param {boolean} [options.throwOnError=false] - When true, a git failure rethrows
|
|
101
|
+
* instead of being logged-and-swallowed to `[]`. Callers that have already
|
|
102
|
+
* established the scope is non-empty (e.g. headless local, which checks the
|
|
103
|
+
* session diff first) use this so an operational git error becomes a non-zero
|
|
104
|
+
* exit rather than masquerading as an empty changed-file set / "no changes".
|
|
99
105
|
* @returns {Promise<string[]>} Changed file paths
|
|
100
106
|
*/
|
|
101
|
-
async function getChangedFiles(cwd, context) {
|
|
107
|
+
async function getChangedFiles(cwd, context, { throwOnError = false } = {}) {
|
|
102
108
|
try {
|
|
103
109
|
if (context.baseSha && context.headSha) {
|
|
104
110
|
const { stdout } = await execPromise(
|
|
@@ -154,6 +160,9 @@ async function getChangedFiles(cwd, context) {
|
|
|
154
160
|
.filter(f => f.length > 0);
|
|
155
161
|
return [...new Set(all)];
|
|
156
162
|
} catch (error) {
|
|
163
|
+
if (throwOnError) {
|
|
164
|
+
throw new Error(`Failed to enumerate changed files in ${cwd}: ${error.message}`);
|
|
165
|
+
}
|
|
157
166
|
logger.warn(`Could not get changed files list: ${error.message}`);
|
|
158
167
|
return [];
|
|
159
168
|
}
|
package/src/routes/local.js
CHANGED
|
@@ -32,6 +32,7 @@ const { STOPS, isValidScope, normalizeScope, reviewScope, includesBranch, DEFAUL
|
|
|
32
32
|
const { getGeneratedFilePatterns } = require('../git/gitattributes');
|
|
33
33
|
const { getShaAbbrevLength } = require('../git/sha-abbrev');
|
|
34
34
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
35
|
+
const { resolveReviewConfig } = require('../review-config');
|
|
35
36
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
36
37
|
const { getProviderClass, createProvider } = require('../ai/provider');
|
|
37
38
|
const { getDefaultBranch, tryGraphiteState } = require('../git/base-branch');
|
|
@@ -1219,6 +1220,137 @@ async function handleExecutableAnalysis(req, res, {
|
|
|
1219
1220
|
});
|
|
1220
1221
|
}
|
|
1221
1222
|
|
|
1223
|
+
/**
|
|
1224
|
+
* Launch a local-mode council analysis.
|
|
1225
|
+
*
|
|
1226
|
+
* Shared by the explicit council endpoint (`POST .../analyses/council`) and the
|
|
1227
|
+
* plain-analyze default path (`POST .../analyses`) when a repo's
|
|
1228
|
+
* `default_council_id` resolves to a council and the request made no explicit
|
|
1229
|
+
* single-model pick. Both entry points build the same modeContext and call
|
|
1230
|
+
* `analysesRouter.launchCouncilAnalysis`, so council dispatch is not duplicated.
|
|
1231
|
+
*
|
|
1232
|
+
* The caller is responsible for resolving + validating `councilConfig`/`configType`
|
|
1233
|
+
* and for the empty-scope guard (`rejectIfEmptyScope`) before invoking this.
|
|
1234
|
+
*
|
|
1235
|
+
* @returns {{ analysisId: string, runId: string }}
|
|
1236
|
+
*/
|
|
1237
|
+
async function launchLocalCouncilAnalysis(req, {
|
|
1238
|
+
reviewId, review, localPath, councilConfig, councilId, configType,
|
|
1239
|
+
requestInstructions, excludePrevious
|
|
1240
|
+
}) {
|
|
1241
|
+
const db = req.app.get('db');
|
|
1242
|
+
|
|
1243
|
+
const { start: councilScopeStart, end: councilScopeEnd } = reviewScope(review);
|
|
1244
|
+
const councilHasBranch = includesBranch(councilScopeStart);
|
|
1245
|
+
|
|
1246
|
+
// Compute merge-base when branch is in scope
|
|
1247
|
+
let analysisBaseSha = review.local_head_sha;
|
|
1248
|
+
if (councilHasBranch && review.local_base_branch) {
|
|
1249
|
+
try {
|
|
1250
|
+
analysisBaseSha = await findMergeBase(localPath, review.local_base_branch);
|
|
1251
|
+
} catch {
|
|
1252
|
+
// Fall back to HEAD
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const prMetadata = {
|
|
1257
|
+
reviewType: 'local',
|
|
1258
|
+
repository: review.repository,
|
|
1259
|
+
title: null,
|
|
1260
|
+
description: null,
|
|
1261
|
+
base_sha: analysisBaseSha,
|
|
1262
|
+
head_sha: review.local_head_sha,
|
|
1263
|
+
base_branch: review.local_base_branch || null,
|
|
1264
|
+
head_branch: review.local_head_branch || null,
|
|
1265
|
+
scopeStart: councilScopeStart,
|
|
1266
|
+
scopeEnd: councilScopeEnd,
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
// Use the scope-aware helper so the file list matches the generated diff
|
|
1270
|
+
// (covers branch, staged, unstaged, and untracked stops as appropriate).
|
|
1271
|
+
const changedFiles = await getChangedFiles(localPath, {
|
|
1272
|
+
scopeStart: councilScopeStart,
|
|
1273
|
+
scopeEnd: councilScopeEnd,
|
|
1274
|
+
baseBranch: review.local_base_branch || null,
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
// Generate and cache diff. Hoist the result out of the try so we can also
|
|
1278
|
+
// persist it to `local_diffs` below (after reviewRepo is constructed) — the
|
|
1279
|
+
// council path previously cached the diff in-memory only, which left the
|
|
1280
|
+
// manual tour/summary buttons reporting a false "no-diff" after a restart.
|
|
1281
|
+
let councilDiff = null;
|
|
1282
|
+
let councilStats = null;
|
|
1283
|
+
let councilDigest = null;
|
|
1284
|
+
try {
|
|
1285
|
+
const diffResult = await generateScopedDiff(localPath, councilScopeStart, councilScopeEnd, review.local_base_branch);
|
|
1286
|
+
councilDigest = await computeScopedDigest(localPath, councilScopeStart, councilScopeEnd);
|
|
1287
|
+
councilDiff = diffResult.diff;
|
|
1288
|
+
councilStats = diffResult.stats;
|
|
1289
|
+
setLocalReviewDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
|
|
1290
|
+
} catch (diffError) {
|
|
1291
|
+
logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Resolve instructions
|
|
1295
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1296
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1297
|
+
|
|
1298
|
+
// Durably persist the diff so it survives a restart and the manual
|
|
1299
|
+
// tour/summary buttons can find it (parity with the analysis-push path).
|
|
1300
|
+
if (councilDiff) {
|
|
1301
|
+
try {
|
|
1302
|
+
await reviewRepo.saveLocalDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
|
|
1303
|
+
} catch (saveError) {
|
|
1304
|
+
logger.warn(`Could not persist diff for local council review ${reviewId}: ${saveError.message}`);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
|
|
1308
|
+
const repoInstructions = repoSettings?.default_instructions || null;
|
|
1309
|
+
|
|
1310
|
+
if (requestInstructions) {
|
|
1311
|
+
await reviewRepo.updateReview(reviewId, {
|
|
1312
|
+
customInstructions: requestInstructions
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// Import launchCouncilAnalysis from analyses.js
|
|
1317
|
+
const analysesRouter = require('./analyses');
|
|
1318
|
+
const localCouncilConfig = req.app.get('config') || {};
|
|
1319
|
+
|
|
1320
|
+
const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
|
|
1321
|
+
buildCouncilProviderOverrides(localCouncilConfig, review.repository, repoSettings);
|
|
1322
|
+
|
|
1323
|
+
// Local mode has no associated GitHub PR, so we do not pass a githubClient.
|
|
1324
|
+
// The analyzer drops the GitHub dedup section when no client is supplied.
|
|
1325
|
+
return analysesRouter.launchCouncilAnalysis(
|
|
1326
|
+
db,
|
|
1327
|
+
{
|
|
1328
|
+
reviewId,
|
|
1329
|
+
worktreePath: localPath,
|
|
1330
|
+
prMetadata,
|
|
1331
|
+
changedFiles,
|
|
1332
|
+
repository: review.repository,
|
|
1333
|
+
headSha: review.local_head_sha,
|
|
1334
|
+
logLabel: `local review #${reviewId}`,
|
|
1335
|
+
initialStatusExtra: { reviewId, reviewType: 'local' },
|
|
1336
|
+
config: localCouncilConfig,
|
|
1337
|
+
excludePrevious,
|
|
1338
|
+
serverPort: req.socket.localPort,
|
|
1339
|
+
providerOverrides: councilProviderOverrides,
|
|
1340
|
+
providerOverridesMap: councilProviderOverridesMap,
|
|
1341
|
+
hookContext: {
|
|
1342
|
+
mode: 'local',
|
|
1343
|
+
localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
|
|
1344
|
+
},
|
|
1345
|
+
runUpdateExtra: { filesAnalyzed: changedFiles ? changedFiles.length : 0 }
|
|
1346
|
+
},
|
|
1347
|
+
councilConfig,
|
|
1348
|
+
councilId,
|
|
1349
|
+
{ globalInstructions: localCouncilConfig.globalInstructions || null, repoInstructions, requestInstructions },
|
|
1350
|
+
configType
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1222
1354
|
/**
|
|
1223
1355
|
* Start Level 1 AI analysis for local review
|
|
1224
1356
|
*/
|
|
@@ -1273,6 +1405,40 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
1273
1405
|
|
|
1274
1406
|
const appConfig = req.app.get('config') || {};
|
|
1275
1407
|
|
|
1408
|
+
// Repo default-council parity: when the request makes NO explicit single-model
|
|
1409
|
+
// pick (no provider/model in the body), honor the repo's saved
|
|
1410
|
+
// default_council_id by dispatching to the same council path the explicit
|
|
1411
|
+
// council endpoint uses. An explicit provider/model in the request always
|
|
1412
|
+
// wins and falls through to the single-provider path unchanged below.
|
|
1413
|
+
// For repos with no default_council_id the resolver returns type:'single' and
|
|
1414
|
+
// we fall through, so single-provider behavior is byte-identical to before.
|
|
1415
|
+
if (!requestProvider && !requestModel) {
|
|
1416
|
+
const reviewConfig = await resolveReviewConfig(
|
|
1417
|
+
db,
|
|
1418
|
+
repository,
|
|
1419
|
+
{ provider: requestProvider, model: requestModel },
|
|
1420
|
+
appConfig
|
|
1421
|
+
);
|
|
1422
|
+
if (reviewConfig.type === 'council') {
|
|
1423
|
+
logger.log('API', `Honoring repo default council for ${repository}: ${reviewConfig.council.name}`, 'cyan');
|
|
1424
|
+
const { analysisId: councilAnalysisId, runId: councilRunId } = await launchLocalCouncilAnalysis(req, {
|
|
1425
|
+
reviewId, review, localPath,
|
|
1426
|
+
councilConfig: reviewConfig.councilConfig,
|
|
1427
|
+
councilId: reviewConfig.council.id,
|
|
1428
|
+
configType: reviewConfig.configType,
|
|
1429
|
+
requestInstructions,
|
|
1430
|
+
excludePrevious
|
|
1431
|
+
});
|
|
1432
|
+
return res.json({
|
|
1433
|
+
analysisId: councilAnalysisId,
|
|
1434
|
+
runId: councilRunId,
|
|
1435
|
+
status: 'started',
|
|
1436
|
+
message: 'Council analysis started in background',
|
|
1437
|
+
isCouncil: true
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1276
1442
|
// Determine provider: request body > repo settings > config > default ('claude')
|
|
1277
1443
|
let selectedProvider;
|
|
1278
1444
|
if (requestProvider) {
|
|
@@ -2238,117 +2404,12 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
|
|
|
2238
2404
|
// Guard: reject if scope resolves to zero changed files
|
|
2239
2405
|
if (await rejectIfEmptyScope(res, review, localPath)) return;
|
|
2240
2406
|
|
|
2241
|
-
const {
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
let analysisBaseSha = review.local_head_sha;
|
|
2246
|
-
if (councilHasBranch && review.local_base_branch) {
|
|
2247
|
-
try {
|
|
2248
|
-
analysisBaseSha = await findMergeBase(localPath, review.local_base_branch);
|
|
2249
|
-
} catch {
|
|
2250
|
-
// Fall back to HEAD
|
|
2251
|
-
}
|
|
2252
|
-
}
|
|
2253
|
-
|
|
2254
|
-
const prMetadata = {
|
|
2255
|
-
reviewType: 'local',
|
|
2256
|
-
repository: review.repository,
|
|
2257
|
-
title: null,
|
|
2258
|
-
description: null,
|
|
2259
|
-
base_sha: analysisBaseSha,
|
|
2260
|
-
head_sha: review.local_head_sha,
|
|
2261
|
-
base_branch: review.local_base_branch || null,
|
|
2262
|
-
head_branch: review.local_head_branch || null,
|
|
2263
|
-
scopeStart: councilScopeStart,
|
|
2264
|
-
scopeEnd: councilScopeEnd,
|
|
2265
|
-
};
|
|
2266
|
-
|
|
2267
|
-
// Use the scope-aware helper so the file list matches the generated diff
|
|
2268
|
-
// (covers branch, staged, unstaged, and untracked stops as appropriate).
|
|
2269
|
-
const changedFiles = await getChangedFiles(localPath, {
|
|
2270
|
-
scopeStart: councilScopeStart,
|
|
2271
|
-
scopeEnd: councilScopeEnd,
|
|
2272
|
-
baseBranch: review.local_base_branch || null,
|
|
2407
|
+
const { analysisId, runId } = await launchLocalCouncilAnalysis(req, {
|
|
2408
|
+
reviewId, review, localPath, councilConfig, councilId, configType,
|
|
2409
|
+
requestInstructions: rawInstructions?.trim() || null,
|
|
2410
|
+
excludePrevious
|
|
2273
2411
|
});
|
|
2274
2412
|
|
|
2275
|
-
// Generate and cache diff. Hoist the result out of the try so we can also
|
|
2276
|
-
// persist it to `local_diffs` below (after reviewRepo is constructed) — the
|
|
2277
|
-
// council path previously cached the diff in-memory only, which left the
|
|
2278
|
-
// manual tour/summary buttons reporting a false "no-diff" after a restart.
|
|
2279
|
-
let councilDiff = null;
|
|
2280
|
-
let councilStats = null;
|
|
2281
|
-
let councilDigest = null;
|
|
2282
|
-
try {
|
|
2283
|
-
const diffResult = await generateScopedDiff(localPath, councilScopeStart, councilScopeEnd, review.local_base_branch);
|
|
2284
|
-
councilDigest = await computeScopedDigest(localPath, councilScopeStart, councilScopeEnd);
|
|
2285
|
-
councilDiff = diffResult.diff;
|
|
2286
|
-
councilStats = diffResult.stats;
|
|
2287
|
-
setLocalReviewDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
|
|
2288
|
-
} catch (diffError) {
|
|
2289
|
-
logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
|
|
2290
|
-
}
|
|
2291
|
-
|
|
2292
|
-
// Resolve instructions
|
|
2293
|
-
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
2294
|
-
const reviewRepo = new ReviewRepository(db);
|
|
2295
|
-
|
|
2296
|
-
// Durably persist the diff so it survives a restart and the manual
|
|
2297
|
-
// tour/summary buttons can find it (parity with the analysis-push path).
|
|
2298
|
-
if (councilDiff) {
|
|
2299
|
-
try {
|
|
2300
|
-
await reviewRepo.saveLocalDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
|
|
2301
|
-
} catch (saveError) {
|
|
2302
|
-
logger.warn(`Could not persist diff for local council review ${reviewId}: ${saveError.message}`);
|
|
2303
|
-
}
|
|
2304
|
-
}
|
|
2305
|
-
const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
|
|
2306
|
-
const repoInstructions = repoSettings?.default_instructions || null;
|
|
2307
|
-
const requestInstructions = rawInstructions?.trim() || null;
|
|
2308
|
-
|
|
2309
|
-
if (requestInstructions) {
|
|
2310
|
-
await reviewRepo.updateReview(reviewId, {
|
|
2311
|
-
customInstructions: requestInstructions
|
|
2312
|
-
});
|
|
2313
|
-
}
|
|
2314
|
-
|
|
2315
|
-
// Import launchCouncilAnalysis from analyses.js
|
|
2316
|
-
const analysesRouter = require('./analyses');
|
|
2317
|
-
const localCouncilConfig = req.app.get('config') || {};
|
|
2318
|
-
|
|
2319
|
-
const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
|
|
2320
|
-
buildCouncilProviderOverrides(localCouncilConfig, review.repository, repoSettings);
|
|
2321
|
-
|
|
2322
|
-
// Local mode has no associated GitHub PR, so we do not pass a githubClient.
|
|
2323
|
-
// The analyzer drops the GitHub dedup section when no client is supplied.
|
|
2324
|
-
const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
|
|
2325
|
-
db,
|
|
2326
|
-
{
|
|
2327
|
-
reviewId,
|
|
2328
|
-
worktreePath: localPath,
|
|
2329
|
-
prMetadata,
|
|
2330
|
-
changedFiles,
|
|
2331
|
-
repository: review.repository,
|
|
2332
|
-
headSha: review.local_head_sha,
|
|
2333
|
-
logLabel: `local review #${reviewId}`,
|
|
2334
|
-
initialStatusExtra: { reviewId, reviewType: 'local' },
|
|
2335
|
-
config: localCouncilConfig,
|
|
2336
|
-
excludePrevious,
|
|
2337
|
-
serverPort: req.socket.localPort,
|
|
2338
|
-
providerOverrides: councilProviderOverrides,
|
|
2339
|
-
providerOverridesMap: councilProviderOverridesMap,
|
|
2340
|
-
hookContext: {
|
|
2341
|
-
mode: 'local',
|
|
2342
|
-
localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
|
|
2343
|
-
},
|
|
2344
|
-
runUpdateExtra: { filesAnalyzed: changedFiles ? changedFiles.length : 0 }
|
|
2345
|
-
},
|
|
2346
|
-
councilConfig,
|
|
2347
|
-
councilId,
|
|
2348
|
-
{ globalInstructions: localCouncilConfig.globalInstructions || null, repoInstructions, requestInstructions },
|
|
2349
|
-
configType
|
|
2350
|
-
);
|
|
2351
|
-
|
|
2352
2413
|
res.json({
|
|
2353
2414
|
analysisId,
|
|
2354
2415
|
runId,
|