@in-the-loop-labs/pair-review 3.7.1 → 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 +97 -8
- 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/routes/stack-analysis.js +45 -13
- package/src/setup/stack-setup.js +32 -4
- package/src/single-port.js +20 -4
package/src/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
const fs = require('fs');
|
|
3
|
-
const { loadConfig, getConfigDir, getGitHubToken, resolveHostBinding, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills } = require('./config');
|
|
4
|
-
const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository } = require('./database');
|
|
3
|
+
const { loadConfig, getConfigDir, getGitHubToken, resolveHostBinding, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills, buildCouncilProviderOverrides } = require('./config');
|
|
4
|
+
const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository, CouncilRepository } = require('./database');
|
|
5
5
|
const { PRArgumentParser } = require('./github/parser');
|
|
6
6
|
const { GitHubClient } = require('./github/client');
|
|
7
7
|
const { GitWorktreeManager } = require('./git/worktree');
|
|
@@ -11,6 +11,9 @@ const { startServer } = require('./server');
|
|
|
11
11
|
const Analyzer = require('./ai/analyzer');
|
|
12
12
|
const { applyConfigOverrides } = require('./ai');
|
|
13
13
|
const { handleLocalReview, findMainGitRoot } = require('./local-review');
|
|
14
|
+
const { resolveCouncilHandle, getCouncilLastUsedRepos, shortId } = require('./councils/resolve-council');
|
|
15
|
+
const { runHeadlessCouncilAnalysis } = require('./councils/headless-council');
|
|
16
|
+
const { normalizeCouncilConfig, validateCouncilConfig } = require('./routes/councils');
|
|
14
17
|
const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
|
|
15
18
|
const { fireReviewStartedHook } = require('./hooks/payloads');
|
|
16
19
|
const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
|
|
@@ -164,6 +167,9 @@ OPTIONS:
|
|
|
164
167
|
opus-4.6-high, opus-4.6-1m, sonnet-4.6
|
|
165
168
|
(opus is Opus 4.7 XHigh, the default)
|
|
166
169
|
or use provider-specific models with Gemini/Codex
|
|
170
|
+
--council <handle> Run analysis with a saved council (multi-voice). Handle is a
|
|
171
|
+
council name, name-slug, or id (prefix). See --list-councils.
|
|
172
|
+
--list-councils List saved councils (handles to use with --council) and exit
|
|
167
173
|
--use-checkout Use current directory instead of creating worktree
|
|
168
174
|
(automatic in GitHub Actions)
|
|
169
175
|
--yolo Allow AI providers full system access (skip read-only
|
|
@@ -179,6 +185,8 @@ EXAMPLES:
|
|
|
179
185
|
pair-review --local # Review uncommitted local changes
|
|
180
186
|
pair-review 123 --ai # Auto-run AI analysis
|
|
181
187
|
pair-review --ai-review # CI mode: auto-detect PR, submit review
|
|
188
|
+
pair-review 123 --ai-draft --council security-review # Headless council draft
|
|
189
|
+
pair-review --list-councils # List saved councils and their handles
|
|
182
190
|
pair-review --register # Register URL scheme handler
|
|
183
191
|
pair-review --register --command "node bin/pair-review.js" # Custom command
|
|
184
192
|
|
|
@@ -207,6 +215,74 @@ MORE INFO:
|
|
|
207
215
|
`);
|
|
208
216
|
}
|
|
209
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Print the list of saved councils (handles, names, types, and "last used"
|
|
220
|
+
* metadata) as an aligned table, then exit guidance. Used by --list-councils.
|
|
221
|
+
*
|
|
222
|
+
* @param {Object} db - Database instance
|
|
223
|
+
*/
|
|
224
|
+
async function printCouncilList(db) {
|
|
225
|
+
const councils = await new CouncilRepository(db).list();
|
|
226
|
+
|
|
227
|
+
if (!councils || councils.length === 0) {
|
|
228
|
+
console.log('No councils found. Create one in the web UI under Analysis settings.');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const repoMap = await getCouncilLastUsedRepos(db);
|
|
233
|
+
|
|
234
|
+
const rows = councils.map(c => {
|
|
235
|
+
const entry = repoMap.get(c.id);
|
|
236
|
+
const lastTs = entry?.last_started || c.last_used_at;
|
|
237
|
+
const lastUsed = lastTs ? String(lastTs).slice(0, 10) : 'never';
|
|
238
|
+
let lastUsedWith = '—';
|
|
239
|
+
if (entry) {
|
|
240
|
+
lastUsedWith = `${entry.repository}${entry.pr_number ? `#${entry.pr_number}` : ''}`;
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
handle: shortId(c.id),
|
|
244
|
+
name: c.name || '',
|
|
245
|
+
type: c.type || '',
|
|
246
|
+
lastUsed,
|
|
247
|
+
lastUsedWith
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const headers = {
|
|
252
|
+
handle: 'HANDLE',
|
|
253
|
+
name: 'NAME',
|
|
254
|
+
type: 'TYPE',
|
|
255
|
+
lastUsed: 'LAST USED',
|
|
256
|
+
lastUsedWith: 'LAST USED WITH'
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const widths = {};
|
|
260
|
+
for (const key of Object.keys(headers)) {
|
|
261
|
+
widths[key] = Math.max(
|
|
262
|
+
headers[key].length,
|
|
263
|
+
...rows.map(r => String(r[key]).length)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const formatRow = r =>
|
|
268
|
+
[
|
|
269
|
+
String(r.handle).padEnd(widths.handle),
|
|
270
|
+
String(r.name).padEnd(widths.name),
|
|
271
|
+
String(r.type).padEnd(widths.type),
|
|
272
|
+
String(r.lastUsed).padEnd(widths.lastUsed),
|
|
273
|
+
String(r.lastUsedWith).padEnd(widths.lastUsedWith)
|
|
274
|
+
].join(' ');
|
|
275
|
+
|
|
276
|
+
console.log(formatRow(headers));
|
|
277
|
+
for (const r of rows) {
|
|
278
|
+
console.log(formatRow(r));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
console.log('');
|
|
282
|
+
console.log('Pass a handle with --council, e.g.:');
|
|
283
|
+
console.log(' pair-review <pr> --ai-draft --council ' + shortId(councils[0].id));
|
|
284
|
+
}
|
|
285
|
+
|
|
210
286
|
/**
|
|
211
287
|
* Print version and exit
|
|
212
288
|
*/
|
|
@@ -279,6 +355,8 @@ const KNOWN_FLAGS = new Set([
|
|
|
279
355
|
'-l', '--local',
|
|
280
356
|
'--mcp',
|
|
281
357
|
'--model',
|
|
358
|
+
'--council',
|
|
359
|
+
'--list-councils',
|
|
282
360
|
'--register',
|
|
283
361
|
'--unregister',
|
|
284
362
|
'--command',
|
|
@@ -328,6 +406,16 @@ function parseArgs(args) {
|
|
|
328
406
|
} else {
|
|
329
407
|
throw new Error('--model flag requires a model name (e.g., --model sonnet)');
|
|
330
408
|
}
|
|
409
|
+
} else if (arg === '--council') {
|
|
410
|
+
// Next argument is the council handle (name, name-slug, or id prefix)
|
|
411
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
412
|
+
flags.council = args[i + 1];
|
|
413
|
+
i++; // Skip next argument since we consumed it
|
|
414
|
+
} else {
|
|
415
|
+
throw new Error('--council flag requires a council handle (e.g., --council my-council)');
|
|
416
|
+
}
|
|
417
|
+
} else if (arg === '--list-councils') {
|
|
418
|
+
flags.listCouncils = true;
|
|
331
419
|
} else if (arg === '-l' || arg === '--local') {
|
|
332
420
|
// -l/--local flag is always a boolean
|
|
333
421
|
flags.local = true;
|
|
@@ -497,26 +585,84 @@ AI PROVIDERS:
|
|
|
497
585
|
process.env.PAIR_REVIEW_YOLO = 'true';
|
|
498
586
|
}
|
|
499
587
|
|
|
588
|
+
// --model is ignored when --council is set: council voices use their own
|
|
589
|
+
// per-voice models, so a top-level --model has no effect on the analysis.
|
|
590
|
+
if (flags.council && flags.model) {
|
|
591
|
+
console.warn('Warning: --model is ignored when --council is set; council voices use their own per-voice models.');
|
|
592
|
+
}
|
|
593
|
+
|
|
500
594
|
// Apply provider config overrides (including yolo) for all code paths
|
|
501
595
|
// (interactive, headless, local). server.js calls this independently on
|
|
502
596
|
// startup, but headless paths (--ai-draft, --ai-review) never start the
|
|
503
597
|
// server, so we must also apply here.
|
|
504
598
|
applyConfigOverrides(config);
|
|
505
599
|
|
|
600
|
+
// --list-councils: print the saved councils and exit. Needs the database
|
|
601
|
+
// but no server, no PR/local context, and no single-port delegation.
|
|
602
|
+
if (flags.listCouncils) {
|
|
603
|
+
const listDb = await initializeDatabase(resolveDbName(config));
|
|
604
|
+
try {
|
|
605
|
+
await printCouncilList(listDb);
|
|
606
|
+
} finally {
|
|
607
|
+
try { listDb.close(); } catch { /* ignore */ }
|
|
608
|
+
}
|
|
609
|
+
process.exit(0);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// --council requires something to analyze: a PR identifier or --local.
|
|
613
|
+
// Reject early (before single-port delegation) so a contextless `--council`
|
|
614
|
+
// fails fast with a clear message instead of silently delegating to — or
|
|
615
|
+
// starting — the generic landing page with the council ignored.
|
|
616
|
+
//
|
|
617
|
+
// Exception: in GitHub Actions, `--ai-review --council <handle>` is a
|
|
618
|
+
// documented headless flow where the PR is auto-detected from the
|
|
619
|
+
// environment further below (detectPRFromGitHubEnvironment). At this point
|
|
620
|
+
// prArgs is still empty, so guarding on it alone would reject that valid CI
|
|
621
|
+
// invocation. Defer to the later `--ai-review` PR check, which reports a
|
|
622
|
+
// clear error if no PR can be detected.
|
|
623
|
+
if (
|
|
624
|
+
flags.council &&
|
|
625
|
+
prArgs.length === 0 &&
|
|
626
|
+
!flags.local &&
|
|
627
|
+
!(flags.aiReview && process.env.GITHUB_ACTIONS === 'true')
|
|
628
|
+
) {
|
|
629
|
+
throw new Error('--council flag requires a pull request number/URL or --local to run analysis');
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Resolve the council handle FAIL-FAST, before single-port delegation, so
|
|
633
|
+
// that (a) a bad handle errors out for every mode (interactive, headless,
|
|
634
|
+
// and delegated) rather than only on a cold start, and (b) the resolved id
|
|
635
|
+
// can be threaded into the delegated URL — the browser-side council
|
|
636
|
+
// auto-analysis is keyed on the `council` query param. Resolution needs the
|
|
637
|
+
// DB, so when --council is set we open the shared connection now and the
|
|
638
|
+
// downstream handlers reuse it (they re-resolve for their own purposes;
|
|
639
|
+
// that is a cheap in-memory match against the same connection).
|
|
640
|
+
let cliCouncil = null;
|
|
641
|
+
if (flags.council) {
|
|
642
|
+
db = await initializeDatabase(resolveDbName(config));
|
|
643
|
+
cliCouncil = await resolveCouncilHandle(db, flags.council);
|
|
644
|
+
}
|
|
645
|
+
|
|
506
646
|
// Single-port delegation: if a pair-review server is already running on the
|
|
507
647
|
// configured port, delegate to it (open browser URL) and exit immediately.
|
|
508
648
|
// Skipped for: headless modes (no browser), single_port: false (dev mode).
|
|
509
649
|
if (config.single_port !== false && !flags.aiReview && !flags.aiDraft) {
|
|
510
|
-
const delegated = await attemptDelegation(config, flags, prArgs
|
|
650
|
+
const delegated = await attemptDelegation(config, flags, prArgs, undefined, {
|
|
651
|
+
councilId: cliCouncil?.id || null
|
|
652
|
+
});
|
|
511
653
|
if (delegated) {
|
|
654
|
+
if (db) { try { db.close(); } catch { /* ignore */ } }
|
|
512
655
|
process.exit(0);
|
|
513
656
|
}
|
|
514
657
|
// Not delegated — no server running, proceed to start one
|
|
515
658
|
}
|
|
516
659
|
|
|
517
|
-
// Initialize database
|
|
518
|
-
|
|
519
|
-
|
|
660
|
+
// Initialize database (reuse the connection already opened for council
|
|
661
|
+
// resolution above, if any).
|
|
662
|
+
if (!db) {
|
|
663
|
+
console.log('Initializing database...');
|
|
664
|
+
db = await initializeDatabase(resolveDbName(config));
|
|
665
|
+
}
|
|
520
666
|
|
|
521
667
|
// Migrate existing worktrees to database (if any)
|
|
522
668
|
const path = require('path');
|
|
@@ -649,11 +795,20 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
|
|
|
649
795
|
await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
|
|
650
796
|
}
|
|
651
797
|
|
|
652
|
-
// Set model override if provided via CLI flag
|
|
653
|
-
|
|
798
|
+
// Set model override if provided via CLI flag. Skipped when --council is
|
|
799
|
+
// set: council voices carry their own per-voice models.
|
|
800
|
+
if (flags.model && !flags.council) {
|
|
654
801
|
process.env.PAIR_REVIEW_MODEL = flags.model;
|
|
655
802
|
}
|
|
656
803
|
|
|
804
|
+
// Resolve the council handle FAIL-FAST before starting the server, so a bad
|
|
805
|
+
// handle surfaces as a clean error (caught below) instead of after the
|
|
806
|
+
// browser has opened.
|
|
807
|
+
let councilSelection = null;
|
|
808
|
+
if (flags.council) {
|
|
809
|
+
councilSelection = await resolveCouncilHandle(db, flags.council);
|
|
810
|
+
}
|
|
811
|
+
|
|
657
812
|
// Start server and open browser to setup page
|
|
658
813
|
const port = await startServer(db, poolLifecycle);
|
|
659
814
|
|
|
@@ -663,9 +818,9 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
|
|
|
663
818
|
startPoolBackgroundFetches(db, config);
|
|
664
819
|
|
|
665
820
|
let url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
}
|
|
821
|
+
const shouldAnalyze = flags.ai || flags.council;
|
|
822
|
+
if (shouldAnalyze) url += '?analyze=true';
|
|
823
|
+
if (councilSelection) url += `&council=${councilSelection.id}`;
|
|
669
824
|
|
|
670
825
|
console.log(`Opening browser to: ${url}`);
|
|
671
826
|
await open(url);
|
|
@@ -742,6 +897,21 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
742
897
|
const parser = new PRArgumentParser(config);
|
|
743
898
|
prInfo = await parser.parsePRArguments(args);
|
|
744
899
|
|
|
900
|
+
// Resolve + validate the council FAIL-FAST, before any worktree/network
|
|
901
|
+
// work, so a bad handle or invalid config surfaces immediately.
|
|
902
|
+
let councilSelection = null;
|
|
903
|
+
if (flags.council) {
|
|
904
|
+
const council = await resolveCouncilHandle(db, flags.council);
|
|
905
|
+
const configType = council.type || 'advanced';
|
|
906
|
+
const councilConfig = normalizeCouncilConfig(council.config, configType);
|
|
907
|
+
// validateCouncilConfig returns an error string, or null when valid.
|
|
908
|
+
const validationError = validateCouncilConfig(councilConfig, configType);
|
|
909
|
+
if (validationError) {
|
|
910
|
+
throw new Error(`Invalid council "${council.name}": ${validationError}`);
|
|
911
|
+
}
|
|
912
|
+
councilSelection = { council, configType, councilConfig };
|
|
913
|
+
}
|
|
914
|
+
|
|
745
915
|
// Resolve host binding for the target repo (handles alt-host).
|
|
746
916
|
// Prefer `bindingRepository` (from url_pattern match) over the raw
|
|
747
917
|
// `${owner}/${repo}` since they can differ when one config entry
|
|
@@ -972,13 +1142,11 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
972
1142
|
const globalInstructions = config.globalInstructions || null;
|
|
973
1143
|
|
|
974
1144
|
// Run AI analysis
|
|
975
|
-
console.log('Running AI analysis (all 3 levels)...');
|
|
976
1145
|
const cliProvider = repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
|
|
977
1146
|
const model = flags.model || process.env.PAIR_REVIEW_MODEL || repoSettings?.default_model || config.default_model || config.model || 'opus';
|
|
978
1147
|
const providerLoadSkills = config.providers?.[cliProvider]?.load_skills;
|
|
979
1148
|
const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
|
|
980
1149
|
const providerOverrides = { load_skills: loadSkills };
|
|
981
|
-
const analyzer = new Analyzer(db, model, cliProvider, providerOverrides);
|
|
982
1150
|
|
|
983
1151
|
let analysisSummary = null;
|
|
984
1152
|
try {
|
|
@@ -986,7 +1154,33 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
986
1154
|
// githubClient is forwarded so the analyzer can pre-fetch existing PR review
|
|
987
1155
|
// comments when callers opt in via excludePrevious.github.
|
|
988
1156
|
logger.debug(`analyzer githubClient wired for ${prInfo.owner}/${prInfo.repo}#${prInfo.number}`);
|
|
989
|
-
|
|
1157
|
+
let analysisResult;
|
|
1158
|
+
if (councilSelection) {
|
|
1159
|
+
console.log(`Running council analysis: ${councilSelection.council.name} (${councilSelection.configType})...`);
|
|
1160
|
+
// Council voices can span multiple providers, so resolve a per-provider
|
|
1161
|
+
// load_skills map (tier-3 resolution per provider) the same way every
|
|
1162
|
+
// other council invoker does (pr.js / local.js / stack-analysis.js).
|
|
1163
|
+
// Passing only the shared `providerOverrides` would collapse every voice
|
|
1164
|
+
// onto the default provider's load_skills — see buildVoiceContext.
|
|
1165
|
+
const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
|
|
1166
|
+
buildCouncilProviderOverrides(config, repository, repoSettings);
|
|
1167
|
+
const analyzer = new Analyzer(db, 'council', 'council', councilProviderOverrides, councilProviderOverridesMap);
|
|
1168
|
+
analysisResult = await runHeadlessCouncilAnalysis(db, {
|
|
1169
|
+
analyzer,
|
|
1170
|
+
reviewId: review.id,
|
|
1171
|
+
council: councilSelection.council,
|
|
1172
|
+
configType: councilSelection.configType,
|
|
1173
|
+
councilConfig: councilSelection.councilConfig,
|
|
1174
|
+
worktreePath,
|
|
1175
|
+
prMetadata: storedPRData,
|
|
1176
|
+
instructions: { globalInstructions, repoInstructions, requestInstructions: null },
|
|
1177
|
+
githubClient
|
|
1178
|
+
});
|
|
1179
|
+
} else {
|
|
1180
|
+
console.log('Running AI analysis (all 3 levels)...');
|
|
1181
|
+
const analyzer = new Analyzer(db, model, cliProvider, providerOverrides);
|
|
1182
|
+
analysisResult = await analyzer.analyzeAllLevels(review.id, worktreePath, storedPRData, null, { globalInstructions, repoInstructions }, null, { githubClient });
|
|
1183
|
+
}
|
|
990
1184
|
analysisSummary = analysisResult.summary;
|
|
991
1185
|
console.log('AI analysis completed successfully');
|
|
992
1186
|
} catch (analysisError) {
|
|
@@ -1402,4 +1596,4 @@ if (require.main === module) {
|
|
|
1402
1596
|
main();
|
|
1403
1597
|
}
|
|
1404
1598
|
|
|
1405
|
-
module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
|
|
1599
|
+
module.exports = { main, parseArgs, detectPRFromGitHubEnvironment, printCouncilList };
|
|
@@ -19,7 +19,7 @@ const { normalizeRepository } = require('../utils/paths');
|
|
|
19
19
|
const { mergeInstructions } = require('../utils/instructions');
|
|
20
20
|
const { GitWorktreeManager } = require('../git/worktree');
|
|
21
21
|
const { GitHubClient } = require('../github/client');
|
|
22
|
-
const { getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
|
|
22
|
+
const { getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR, resolveRepoOptions, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
|
|
23
23
|
const { setupStackPR } = require('../setup/stack-setup');
|
|
24
24
|
const Analyzer = require('../ai/analyzer');
|
|
25
25
|
const { getProviderClass, createProvider } = require('../ai/provider');
|
|
@@ -166,6 +166,7 @@ const defaults = {
|
|
|
166
166
|
getGitHubToken,
|
|
167
167
|
resolveHostBinding,
|
|
168
168
|
resolveBindingRepositoryFromPR,
|
|
169
|
+
resolveRepoOptions,
|
|
169
170
|
setupStackPR,
|
|
170
171
|
Analyzer,
|
|
171
172
|
getProviderClass,
|
|
@@ -205,8 +206,23 @@ async function executeStackAnalysis(params) {
|
|
|
205
206
|
if (!state) return;
|
|
206
207
|
|
|
207
208
|
try {
|
|
209
|
+
// 0. Resolve the config-binding key once. It drives both the per-repo
|
|
210
|
+
// worktree config (just below) and the host binding (step 3). For
|
|
211
|
+
// monorepo-style `url_pattern` configs this differs from the PR
|
|
212
|
+
// identity `${owner}/${repo}`.
|
|
213
|
+
const bindingRepository = deps.resolveBindingRepositoryFromPR(owner, repo, config);
|
|
214
|
+
// Honor the repo's configured worktree options so stack worktrees match the
|
|
215
|
+
// non-stack path. This carries through:
|
|
216
|
+
// - worktreeConfig (worktree_name_template / worktree_directory) so they
|
|
217
|
+
// don't fall back to pair-review's default naming and location, and
|
|
218
|
+
// - the checkout script + timeout and sparse-checkout inheritance used
|
|
219
|
+
// during per-PR worktree creation (forwarded to createWorktreeForPR).
|
|
220
|
+
// These derive purely from file config, so DB repo_settings (pool config)
|
|
221
|
+
// are not needed here.
|
|
222
|
+
const { worktreeConfig, checkoutScript, checkoutTimeout } = deps.resolveRepoOptions(config, bindingRepository);
|
|
223
|
+
|
|
208
224
|
// 1. Resolve repositoryPath from trigger worktree
|
|
209
|
-
const worktreeManager = new deps.GitWorktreeManager(db);
|
|
225
|
+
const worktreeManager = new deps.GitWorktreeManager(db, worktreeConfig || {});
|
|
210
226
|
let repositoryPath;
|
|
211
227
|
try {
|
|
212
228
|
const owningRepoGit = await worktreeManager.resolveOwningRepo(triggerWorktreePath);
|
|
@@ -230,13 +246,13 @@ async function executeStackAnalysis(params) {
|
|
|
230
246
|
logger.warn(`Bulk git fetch failed, will fetch per-PR: ${fetchError.message}`);
|
|
231
247
|
}
|
|
232
248
|
|
|
233
|
-
// 3. Fetch all PR data from GitHub in parallel
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
249
|
+
// 3. Fetch all PR data from GitHub in parallel.
|
|
250
|
+
// `bindingRepository` (resolved in step 0) is the config-binding key. We use
|
|
251
|
+
// it — not the PR identity `${owner}/${repo}` — for host-binding lookups so
|
|
252
|
+
// alt-host stack analyses target the right host. The two differ for
|
|
253
|
+
// monorepo-style `url_pattern` configs (one `repos[...]` entry serves many
|
|
254
|
+
// captured owner/repo pairs). The PR identity is still used for DB rows and
|
|
255
|
+
// worktree identity.
|
|
240
256
|
const stackBinding = deps.resolveHostBinding(bindingRepository, config);
|
|
241
257
|
const githubToken = stackBinding.token;
|
|
242
258
|
const prDataMap = new Map();
|
|
@@ -275,7 +291,16 @@ async function executeStackAnalysis(params) {
|
|
|
275
291
|
|
|
276
292
|
const prInfo = { owner, repo, number: prNum };
|
|
277
293
|
const { path: perPRWorktreePath } = await worktreeManager.createWorktreeForPR(
|
|
278
|
-
prInfo, prData, repositoryPath
|
|
294
|
+
prInfo, prData, repositoryPath,
|
|
295
|
+
{
|
|
296
|
+
checkoutScript,
|
|
297
|
+
checkoutTimeout,
|
|
298
|
+
// No checkout script → inherit the trigger worktree's sparse-checkout
|
|
299
|
+
// layout instead of a full checkout from the repo root. When a script is
|
|
300
|
+
// configured it sets up sparse-checkout itself, so worktreeSourcePath is
|
|
301
|
+
// unused (and omitted) in that case.
|
|
302
|
+
...(checkoutScript ? {} : { worktreeSourcePath: triggerWorktreePath }),
|
|
303
|
+
}
|
|
279
304
|
);
|
|
280
305
|
worktreePathMap.set(prNum, perPRWorktreePath);
|
|
281
306
|
} catch (wtError) {
|
|
@@ -307,6 +332,7 @@ async function executeStackAnalysis(params) {
|
|
|
307
332
|
worktreePath: worktreePathMap.get(prNum),
|
|
308
333
|
analysisConfig, stackAnalysisId, state,
|
|
309
334
|
githubToken, binding: stackBinding, prData: prDataMap.get(prNum),
|
|
335
|
+
worktreeConfig, checkoutScript,
|
|
310
336
|
onAnalysisIdReady
|
|
311
337
|
}).then(result => {
|
|
312
338
|
state.prStatuses.set(prNum, {
|
|
@@ -346,6 +372,7 @@ async function executeStackAnalysis(params) {
|
|
|
346
372
|
async function analyzeStackPR(deps, db, config, {
|
|
347
373
|
owner, repo, repository, bindingRepository, prNum, worktreePath,
|
|
348
374
|
analysisConfig, stackAnalysisId, state, githubToken, binding, prData,
|
|
375
|
+
worktreeConfig, checkoutScript,
|
|
349
376
|
onAnalysisIdReady
|
|
350
377
|
}) {
|
|
351
378
|
// Build a GitHubClient for analyzer-side dedup pre-fetch. The stack
|
|
@@ -357,12 +384,17 @@ async function analyzeStackPR(deps, db, config, {
|
|
|
357
384
|
if (stackGithubClient) {
|
|
358
385
|
logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNum} (stack)`);
|
|
359
386
|
}
|
|
360
|
-
// 1. Setup PR (generates diff, stores metadata)
|
|
361
|
-
|
|
387
|
+
// 1. Setup PR (expands sparse-checkout, generates diff, stores metadata)
|
|
388
|
+
// Construct with the repo's resolved worktreeConfig for consistency with the
|
|
389
|
+
// creation manager. setupStackPR operates against the explicit worktreePath
|
|
390
|
+
// (diff generation + sparse-cone expansion), so the worktreeConfig naming /
|
|
391
|
+
// directory options are not exercised today — threading them through guards
|
|
392
|
+
// against silent latent regressions if that changes.
|
|
393
|
+
const worktreeManager = new deps.GitWorktreeManager(db, worktreeConfig || {});
|
|
362
394
|
await deps.setupStackPR({
|
|
363
395
|
db, owner, repo, prNumber: prNum,
|
|
364
396
|
githubToken, binding, bindingRepository,
|
|
365
|
-
worktreePath, worktreeManager, prData
|
|
397
|
+
worktreePath, worktreeManager, prData, checkoutScript
|
|
366
398
|
});
|
|
367
399
|
|
|
368
400
|
// 2. Fetch prMetadata from DB
|
package/src/setup/stack-setup.js
CHANGED
|
@@ -32,9 +32,12 @@ const logger = require('../utils/logger');
|
|
|
32
32
|
* @param {string} params.worktreePath - Path to the per-PR worktree
|
|
33
33
|
* @param {import('../git/worktree').GitWorktreeManager} params.worktreeManager - Worktree manager instance
|
|
34
34
|
* @param {Object} [params.prData] - Pre-fetched PR data from GitHub (skips API call when provided)
|
|
35
|
+
* @param {string|null} [params.checkoutScript] - Repo's configured checkout script, if any. When set,
|
|
36
|
+
* the script owns all sparse-checkout setup, so built-in sparse-cone expansion is skipped (mirrors
|
|
37
|
+
* the non-stack `pr-setup.js` contract).
|
|
35
38
|
* @returns {Promise<{ reviewId: number, prMetadata: Object, prData: Object, isNew: boolean }>}
|
|
36
39
|
*/
|
|
37
|
-
async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, bindingRepository, worktreePath, worktreeManager, prData: prefetchedPRData }) {
|
|
40
|
+
async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, bindingRepository, worktreePath, worktreeManager, prData: prefetchedPRData, checkoutScript }) {
|
|
38
41
|
// `bindingRepository` is accepted so callers (e.g. `executeStackAnalysis`)
|
|
39
42
|
// can thread the resolved config-binding key through to any downstream
|
|
40
43
|
// per-repo lookups added in this function. Currently unused inside this
|
|
@@ -56,13 +59,38 @@ async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, b
|
|
|
56
59
|
const prFiles = await githubClient.fetchPullRequestFiles(owner, repo, prNumber);
|
|
57
60
|
logger.info(`PR #${prNumber} has ${prFiles.length} changed files`);
|
|
58
61
|
|
|
59
|
-
// 3.
|
|
62
|
+
// 3. Expand sparse-checkout for PR-changed directories (mirrors pr-setup.js).
|
|
63
|
+
// Stack worktrees inherit the trigger worktree's sparse-checkout layout, which
|
|
64
|
+
// may omit directories a sibling PR touches. The SHA-based diff below reads
|
|
65
|
+
// commit objects (not the working tree) so it is unaffected, but the later
|
|
66
|
+
// file-context and codebase-context analysis steps DO read files from disk —
|
|
67
|
+
// an unexpanded cone would silently under-review those files. Expanding here
|
|
68
|
+
// ensures every PR-changed directory is present on disk.
|
|
69
|
+
//
|
|
70
|
+
// IMPORTANT: when a checkout_script is configured the script owns all
|
|
71
|
+
// sparse-checkout setup, so we must NOT auto-expand — doing so would override
|
|
72
|
+
// the cone the script just configured. This matches the pr-setup.js contract.
|
|
73
|
+
if (!checkoutScript && prFiles.length > 0) {
|
|
74
|
+
const isSparse = await worktreeManager.isSparseCheckoutEnabled(worktreePath);
|
|
75
|
+
if (isSparse) {
|
|
76
|
+
try {
|
|
77
|
+
const addedDirs = await worktreeManager.ensurePRDirectoriesInSparseCheckout(worktreePath, prFiles);
|
|
78
|
+
if (addedDirs.length > 0) {
|
|
79
|
+
logger.info(`Stack PR #${prNumber}: expanded sparse-checkout for: ${addedDirs.join(', ')}`);
|
|
80
|
+
}
|
|
81
|
+
} catch (sparseError) {
|
|
82
|
+
logger.warn(`Stack PR #${prNumber}: sparse-checkout expansion failed (non-fatal): ${sparseError.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 4. Generate diff in the worktree (SHA-based, works after checkout)
|
|
60
88
|
const diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
|
|
61
89
|
|
|
62
|
-
//
|
|
90
|
+
// 5. Get changed files with stats
|
|
63
91
|
const changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
|
|
64
92
|
|
|
65
|
-
//
|
|
93
|
+
// 6. Store via storePRData (creates/updates pr_metadata, reviews, worktrees records)
|
|
66
94
|
const prInfo = { owner, repo, number: prNumber };
|
|
67
95
|
const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath);
|
|
68
96
|
|
package/src/single-port.js
CHANGED
|
@@ -95,18 +95,24 @@ function notifyVersion(port, currentVersion, _deps) {
|
|
|
95
95
|
* @param {number} [context.number]
|
|
96
96
|
* @param {string} [context.localPath]
|
|
97
97
|
* @param {boolean} [context.analyze] - Whether to trigger auto-analysis
|
|
98
|
+
* @param {string} [context.councilId] - Resolved council id for council auto-analysis
|
|
98
99
|
* @returns {string} Full URL
|
|
99
100
|
*/
|
|
100
101
|
function buildDelegationUrl(port, mode, context = {}) {
|
|
101
102
|
const base = `http://localhost:${port}`;
|
|
102
103
|
if (mode === 'pr') {
|
|
103
104
|
let url = `${base}/pr/${context.owner}/${context.repo}/${context.number}`;
|
|
104
|
-
|
|
105
|
+
const query = [];
|
|
106
|
+
if (context.analyze) query.push('analyze=true');
|
|
107
|
+
if (context.councilId) query.push(`council=${encodeURIComponent(context.councilId)}`);
|
|
108
|
+
if (query.length) url += `?${query.join('&')}`;
|
|
105
109
|
return url;
|
|
106
110
|
}
|
|
107
111
|
if (mode === 'local') {
|
|
112
|
+
// The `?path=` segment is always present, so analyze/council append with `&`.
|
|
108
113
|
let url = `${base}/local?path=${encodeURIComponent(context.localPath)}`;
|
|
109
114
|
if (context.analyze) url += '&analyze=true';
|
|
115
|
+
if (context.councilId) url += `&council=${encodeURIComponent(context.councilId)}`;
|
|
110
116
|
return url;
|
|
111
117
|
}
|
|
112
118
|
return `${base}/`;
|
|
@@ -137,11 +143,21 @@ async function parsePRArgsForDelegation(prArgs, config = null, _deps) {
|
|
|
137
143
|
* @param {object} flags - Parsed CLI flags
|
|
138
144
|
* @param {string[]} prArgs - PR arguments from CLI
|
|
139
145
|
* @param {object} [_deps] - Dependency overrides for testing
|
|
146
|
+
* @param {object} [options] - Pre-resolved values from the caller
|
|
147
|
+
* @param {string|null} [options.councilId] - Resolved council id (the caller
|
|
148
|
+
* resolves `flags.council` against the DB before delegation, since this
|
|
149
|
+
* module has no DB access). When set, the delegated URL carries
|
|
150
|
+
* `&council=<id>` and auto-analysis is forced on.
|
|
140
151
|
* @returns {Promise<boolean>} true if delegated, false if should start fresh
|
|
141
152
|
*/
|
|
142
|
-
async function attemptDelegation(config, flags, prArgs, _deps) {
|
|
153
|
+
async function attemptDelegation(config, flags, prArgs, _deps, options = {}) {
|
|
143
154
|
const deps = { ...defaults, ..._deps };
|
|
144
155
|
const port = config.port;
|
|
156
|
+
const councilId = options.councilId || null;
|
|
157
|
+
// A council selection implies analysis: the browser-side council
|
|
158
|
+
// auto-analysis is gated on the `council` param + `analyze=true`, mirroring
|
|
159
|
+
// the cold-start URL built in handlePullRequest/handleLocalReview.
|
|
160
|
+
const analyze = !!(flags.ai || flags.council);
|
|
145
161
|
|
|
146
162
|
const result = await detectRunningServer(port, _deps);
|
|
147
163
|
|
|
@@ -164,10 +180,10 @@ async function attemptDelegation(config, flags, prArgs, _deps) {
|
|
|
164
180
|
if (flags.local) {
|
|
165
181
|
rejectUrlLikeLocalReviewPath(flags.localPath);
|
|
166
182
|
const targetPath = path.resolve(flags.localPath || process.cwd());
|
|
167
|
-
url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze
|
|
183
|
+
url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze, councilId });
|
|
168
184
|
} else if (prArgs.length > 0) {
|
|
169
185
|
const prInfo = await parsePRArgsForDelegation(prArgs, config, _deps);
|
|
170
|
-
url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze
|
|
186
|
+
url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze, councilId });
|
|
171
187
|
} else {
|
|
172
188
|
url = buildDelegationUrl(port, 'server');
|
|
173
189
|
}
|