@in-the-loop-labs/pair-review 3.7.2 → 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 +188 -2
- 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 +28 -8
- package/public/js/pr.js +92 -7
- package/public/js/utils/provider-model.js +9 -2
- package/public/setup.html +8 -0
- package/src/ai/claude-provider.js +31 -30
- 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 +14 -0
- package/src/ai/opencode-provider.js +15 -1
- package/src/ai/pi-provider.js +4 -3
- package/src/ai/provider.js +294 -38
- package/src/chat/chat-providers.js +30 -9
- package/src/councils/headless-council.js +128 -0
- package/src/councils/resolve-council.js +167 -0
- 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 +54 -23
- package/src/main.js +1125 -25
- 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 +131 -5
- package/src/utils/logger.js +25 -4
package/src/main.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
const fs = require('fs');
|
|
3
|
-
const {
|
|
4
|
-
const {
|
|
3
|
+
const { v4: uuidv4 } = require('uuid');
|
|
4
|
+
const { loadConfig, getConfigDir, getGitHubToken, resolveHostBinding, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills, buildCouncilProviderOverrides } = require('./config');
|
|
5
|
+
const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository, CouncilRepository, CommentRepository, AnalysisRunRepository } = require('./database');
|
|
5
6
|
const { PRArgumentParser } = require('./github/parser');
|
|
6
7
|
const { GitHubClient } = require('./github/client');
|
|
7
8
|
const { GitWorktreeManager } = require('./git/worktree');
|
|
@@ -10,7 +11,16 @@ const { WorktreePoolLifecycle } = require('./git/worktree-pool-lifecycle');
|
|
|
10
11
|
const { startServer } = require('./server');
|
|
11
12
|
const Analyzer = require('./ai/analyzer');
|
|
12
13
|
const { applyConfigOverrides } = require('./ai');
|
|
13
|
-
const { handleLocalReview, findMainGitRoot } = require('./local-review');
|
|
14
|
+
const { handleLocalReview, findMainGitRoot, setupLocalReviewSession, findGitRoot, findMergeBase, getRepositoryName } = require('./local-review');
|
|
15
|
+
const { resolveCliInstructions, prepareInteractiveAnalysisConfig } = require('./interactive-analysis-config');
|
|
16
|
+
const { resolveCouncilHandle, getCouncilLastUsedRepos, shortId } = require('./councils/resolve-council');
|
|
17
|
+
const { runHeadlessCouncilAnalysis } = require('./councils/headless-council');
|
|
18
|
+
const { normalizeCouncilConfig, validateCouncilConfig } = require('./routes/councils');
|
|
19
|
+
const { resolveReviewConfig } = require('./review-config');
|
|
20
|
+
const { redirectConsoleToStderr } = require('./mcp-stdio');
|
|
21
|
+
const { getChangedFiles } = require('./routes/executable-analysis');
|
|
22
|
+
const { safeParseJson } = require('./utils/safe-parse-json');
|
|
23
|
+
const { includesBranch } = require('./local-scope');
|
|
14
24
|
const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
|
|
15
25
|
const { fireReviewStartedHook } = require('./hooks/payloads');
|
|
16
26
|
const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
|
|
@@ -149,6 +159,31 @@ OPTIONS:
|
|
|
149
159
|
review on GitHub (headless mode)
|
|
150
160
|
--ai-review Run AI analysis and submit review to GitHub (CI mode)
|
|
151
161
|
Auto-detects PR in GitHub Actions environment
|
|
162
|
+
--headless Run AI analysis, store it in the local database, and
|
|
163
|
+
report the results to stdout/stderr, then exit. No
|
|
164
|
+
server, no browser, no GitHub post. Works with a
|
|
165
|
+
<pr> argument or --local.
|
|
166
|
+
--json With --headless, emit the completed run plus its
|
|
167
|
+
consolidated final suggestions as a single JSON
|
|
168
|
+
document on a clean stdout. For any outcome of the
|
|
169
|
+
headless run — success, zero findings, or an error
|
|
170
|
+
raised while running it — stdout carries a JSON
|
|
171
|
+
document with an "ok" field; on failure that envelope
|
|
172
|
+
is { "ok": false, "error": ... } and the exit code is
|
|
173
|
+
non-zero. Pre-flight config/startup failures are the
|
|
174
|
+
exception: they exit non-zero with a stderr message
|
|
175
|
+
and no JSON envelope, so branch on the exit code
|
|
176
|
+
first. stderr is quiet by default (only
|
|
177
|
+
warnings/errors); add --debug for verbose progress
|
|
178
|
+
logs. Only valid together with --headless.
|
|
179
|
+
--instructions <text> Per-run custom instructions for the analysis.
|
|
180
|
+
Applies to any mode that runs analysis: --headless,
|
|
181
|
+
--ai, --ai-draft, --ai-review, or --council (with
|
|
182
|
+
--ai/--council the instructions are carried into the
|
|
183
|
+
browser-triggered analysis). Default: none.
|
|
184
|
+
--instructions-file <path>
|
|
185
|
+
Read per-run custom instructions from a file.
|
|
186
|
+
Mutually exclusive with --instructions.
|
|
152
187
|
--configure Show setup instructions and configuration options
|
|
153
188
|
-d, --debug Enable verbose debug logging
|
|
154
189
|
--debug-stream Log AI provider streaming events (tool calls, text chunks)
|
|
@@ -162,8 +197,11 @@ OPTIONS:
|
|
|
162
197
|
also: fable-5-xhigh, fable-5-high, opus-4.8-xhigh,
|
|
163
198
|
opus-4.8-high, opus-4.7-xhigh, opus-4.7-high,
|
|
164
199
|
opus-4.6-high, opus-4.6-1m, sonnet-4.6
|
|
165
|
-
(opus is Opus 4.
|
|
200
|
+
(opus is Opus 4.8 XHigh, the default)
|
|
166
201
|
or use provider-specific models with Gemini/Codex
|
|
202
|
+
--council <handle> Run analysis with a saved council (multi-voice). Handle is a
|
|
203
|
+
council name, name-slug, or id (prefix). See --list-councils.
|
|
204
|
+
--list-councils List saved councils (handles to use with --council) and exit
|
|
167
205
|
--use-checkout Use current directory instead of creating worktree
|
|
168
206
|
(automatic in GitHub Actions)
|
|
169
207
|
--yolo Allow AI providers full system access (skip read-only
|
|
@@ -179,6 +217,11 @@ EXAMPLES:
|
|
|
179
217
|
pair-review --local # Review uncommitted local changes
|
|
180
218
|
pair-review 123 --ai # Auto-run AI analysis
|
|
181
219
|
pair-review --ai-review # CI mode: auto-detect PR, submit review
|
|
220
|
+
pair-review 123 --ai-draft --council security-review # Headless council draft
|
|
221
|
+
pair-review --local --headless --json # Analyze local changes, emit JSON, exit
|
|
222
|
+
pair-review 123 --headless # Analyze a PR, print a summary, exit
|
|
223
|
+
pair-review --local --headless --council security # Headless council analysis
|
|
224
|
+
pair-review --list-councils # List saved councils and their handles
|
|
182
225
|
pair-review --register # Register URL scheme handler
|
|
183
226
|
pair-review --register --command "node bin/pair-review.js" # Custom command
|
|
184
227
|
|
|
@@ -207,6 +250,74 @@ MORE INFO:
|
|
|
207
250
|
`);
|
|
208
251
|
}
|
|
209
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Print the list of saved councils (handles, names, types, and "last used"
|
|
255
|
+
* metadata) as an aligned table, then exit guidance. Used by --list-councils.
|
|
256
|
+
*
|
|
257
|
+
* @param {Object} db - Database instance
|
|
258
|
+
*/
|
|
259
|
+
async function printCouncilList(db) {
|
|
260
|
+
const councils = await new CouncilRepository(db).list();
|
|
261
|
+
|
|
262
|
+
if (!councils || councils.length === 0) {
|
|
263
|
+
console.log('No councils found. Create one in the web UI under Analysis settings.');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const repoMap = await getCouncilLastUsedRepos(db);
|
|
268
|
+
|
|
269
|
+
const rows = councils.map(c => {
|
|
270
|
+
const entry = repoMap.get(c.id);
|
|
271
|
+
const lastTs = entry?.last_started || c.last_used_at;
|
|
272
|
+
const lastUsed = lastTs ? String(lastTs).slice(0, 10) : 'never';
|
|
273
|
+
let lastUsedWith = '—';
|
|
274
|
+
if (entry) {
|
|
275
|
+
lastUsedWith = `${entry.repository}${entry.pr_number ? `#${entry.pr_number}` : ''}`;
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
handle: shortId(c.id),
|
|
279
|
+
name: c.name || '',
|
|
280
|
+
type: c.type || '',
|
|
281
|
+
lastUsed,
|
|
282
|
+
lastUsedWith
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const headers = {
|
|
287
|
+
handle: 'HANDLE',
|
|
288
|
+
name: 'NAME',
|
|
289
|
+
type: 'TYPE',
|
|
290
|
+
lastUsed: 'LAST USED',
|
|
291
|
+
lastUsedWith: 'LAST USED WITH'
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const widths = {};
|
|
295
|
+
for (const key of Object.keys(headers)) {
|
|
296
|
+
widths[key] = Math.max(
|
|
297
|
+
headers[key].length,
|
|
298
|
+
...rows.map(r => String(r[key]).length)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const formatRow = r =>
|
|
303
|
+
[
|
|
304
|
+
String(r.handle).padEnd(widths.handle),
|
|
305
|
+
String(r.name).padEnd(widths.name),
|
|
306
|
+
String(r.type).padEnd(widths.type),
|
|
307
|
+
String(r.lastUsed).padEnd(widths.lastUsed),
|
|
308
|
+
String(r.lastUsedWith).padEnd(widths.lastUsedWith)
|
|
309
|
+
].join(' ');
|
|
310
|
+
|
|
311
|
+
console.log(formatRow(headers));
|
|
312
|
+
for (const r of rows) {
|
|
313
|
+
console.log(formatRow(r));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
console.log('');
|
|
317
|
+
console.log('Pass a handle with --council, e.g.:');
|
|
318
|
+
console.log(' pair-review <pr> --ai-draft --council ' + shortId(councils[0].id));
|
|
319
|
+
}
|
|
320
|
+
|
|
210
321
|
/**
|
|
211
322
|
* Print version and exit
|
|
212
323
|
*/
|
|
@@ -272,6 +383,10 @@ const KNOWN_FLAGS = new Set([
|
|
|
272
383
|
'--ai',
|
|
273
384
|
'--ai-draft',
|
|
274
385
|
'--ai-review',
|
|
386
|
+
'--headless',
|
|
387
|
+
'--json',
|
|
388
|
+
'--instructions',
|
|
389
|
+
'--instructions-file',
|
|
275
390
|
'--configure',
|
|
276
391
|
'-d', '--debug',
|
|
277
392
|
'--debug-stream',
|
|
@@ -279,6 +394,8 @@ const KNOWN_FLAGS = new Set([
|
|
|
279
394
|
'-l', '--local',
|
|
280
395
|
'--mcp',
|
|
281
396
|
'--model',
|
|
397
|
+
'--council',
|
|
398
|
+
'--list-councils',
|
|
282
399
|
'--register',
|
|
283
400
|
'--unregister',
|
|
284
401
|
'--command',
|
|
@@ -316,6 +433,29 @@ function parseArgs(args) {
|
|
|
316
433
|
flags.aiDraft = true;
|
|
317
434
|
} else if (arg === '--ai-review') {
|
|
318
435
|
flags.aiReview = true;
|
|
436
|
+
} else if (arg === '--headless') {
|
|
437
|
+
flags.headless = true;
|
|
438
|
+
} else if (arg === '--json') {
|
|
439
|
+
flags.json = true;
|
|
440
|
+
} else if (arg === '--instructions') {
|
|
441
|
+
// Next argument is free-text instructions. Unlike --model/--council, the
|
|
442
|
+
// value may legitimately begin with '-' (free text), so we only require
|
|
443
|
+
// that a token follows — we do NOT apply the !startsWith('-') guard.
|
|
444
|
+
if (i + 1 < args.length) {
|
|
445
|
+
flags.instructions = args[i + 1];
|
|
446
|
+
i++; // Skip next argument since we consumed it
|
|
447
|
+
} else {
|
|
448
|
+
throw new Error('--instructions flag requires a text value (e.g., --instructions "focus on error handling")');
|
|
449
|
+
}
|
|
450
|
+
} else if (arg === '--instructions-file') {
|
|
451
|
+
// Next argument is a file path. A path won't legitimately start with '-',
|
|
452
|
+
// so apply the same guard as --model to catch a missing value early.
|
|
453
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
454
|
+
flags.instructionsFile = args[i + 1];
|
|
455
|
+
i++; // Skip next argument since we consumed it
|
|
456
|
+
} else {
|
|
457
|
+
throw new Error('--instructions-file flag requires a file path (e.g., --instructions-file ./review-notes.txt)');
|
|
458
|
+
}
|
|
319
459
|
} else if (arg === '--use-checkout') {
|
|
320
460
|
flags.useCheckout = true;
|
|
321
461
|
} else if (arg === '--yolo') {
|
|
@@ -328,6 +468,16 @@ function parseArgs(args) {
|
|
|
328
468
|
} else {
|
|
329
469
|
throw new Error('--model flag requires a model name (e.g., --model sonnet)');
|
|
330
470
|
}
|
|
471
|
+
} else if (arg === '--council') {
|
|
472
|
+
// Next argument is the council handle (name, name-slug, or id prefix)
|
|
473
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
474
|
+
flags.council = args[i + 1];
|
|
475
|
+
i++; // Skip next argument since we consumed it
|
|
476
|
+
} else {
|
|
477
|
+
throw new Error('--council flag requires a council handle (e.g., --council my-council)');
|
|
478
|
+
}
|
|
479
|
+
} else if (arg === '--list-councils') {
|
|
480
|
+
flags.listCouncils = true;
|
|
331
481
|
} else if (arg === '-l' || arg === '--local') {
|
|
332
482
|
// -l/--local flag is always a boolean
|
|
333
483
|
flags.local = true;
|
|
@@ -471,11 +621,33 @@ AI PROVIDERS:
|
|
|
471
621
|
process.exit(0);
|
|
472
622
|
}
|
|
473
623
|
|
|
624
|
+
// In headless --json mode, stdout is reserved for the single JSON document,
|
|
625
|
+
// so lock it down as EARLY as possible — before loadConfig() (which on a
|
|
626
|
+
// genuine first run creates the config dir and can log) and before the
|
|
627
|
+
// first-run welcome box below, both of which would otherwise corrupt the
|
|
628
|
+
// JSON. Peek the raw args here: neither flag takes a value, parseArgs() still
|
|
629
|
+
// runs afterwards for full validation, and redirectConsoleToStderr() is
|
|
630
|
+
// idempotent (it only reassigns console.* / the logger stream), so the later
|
|
631
|
+
// gated call is unnecessary once this fires.
|
|
632
|
+
//
|
|
633
|
+
// Quiet by default: --headless --json is consumed mostly by coding agents,
|
|
634
|
+
// whose shell tools capture stderr into context — so we DROP progress
|
|
635
|
+
// narration rather than relocating it to stderr (warnings/errors still emit).
|
|
636
|
+
// --debug/-d restores the full verbose stderr stream for humans diagnosing a
|
|
637
|
+
// run. parseArgs() hasn't run yet, so peek raw args for the debug flags too.
|
|
638
|
+
const isHeadlessJson = args.includes('--headless') && args.includes('--json');
|
|
639
|
+
if (isHeadlessJson) {
|
|
640
|
+
const wantsDebug = args.includes('-d') || args.includes('--debug');
|
|
641
|
+
redirectConsoleToStderr({ quiet: !wantsDebug });
|
|
642
|
+
}
|
|
643
|
+
|
|
474
644
|
// Load configuration
|
|
475
645
|
const { config, isFirstRun } = await loadConfig();
|
|
476
646
|
|
|
477
|
-
// Show welcome message on first run (after early-exit flags are handled
|
|
478
|
-
|
|
647
|
+
// Show welcome message on first run (after early-exit flags are handled
|
|
648
|
+
// above). Suppressed in headless --json mode so nothing reaches stdout but
|
|
649
|
+
// the JSON document.
|
|
650
|
+
if (isFirstRun && !isHeadlessJson) {
|
|
479
651
|
showWelcomeMessage();
|
|
480
652
|
}
|
|
481
653
|
|
|
@@ -483,6 +655,40 @@ AI PROVIDERS:
|
|
|
483
655
|
// single-port delegation can skip DB entirely)
|
|
484
656
|
const { prArgs, flags } = parseArgs(args);
|
|
485
657
|
|
|
658
|
+
// Headless-mode flag validation (fail fast, before any DB/server work).
|
|
659
|
+
// --instructions and --instructions-file are mutually exclusive.
|
|
660
|
+
if (flags.instructions && flags.instructionsFile) {
|
|
661
|
+
throw new Error('--instructions and --instructions-file are mutually exclusive; provide only one.');
|
|
662
|
+
}
|
|
663
|
+
// --json only makes sense as the machine-readable sink for --headless.
|
|
664
|
+
if (flags.json && !flags.headless) {
|
|
665
|
+
throw new Error('--json requires --headless (it emits the headless run as JSON).');
|
|
666
|
+
}
|
|
667
|
+
// --headless needs something to analyze: a PR identifier or --local.
|
|
668
|
+
if (flags.headless && prArgs.length === 0 && !flags.local) {
|
|
669
|
+
throw new Error('--headless flag requires a pull request number/URL or --local to run analysis.');
|
|
670
|
+
}
|
|
671
|
+
// --instructions[-file] only takes effect in a mode that actually runs
|
|
672
|
+
// analysis. Plain interactive mode never auto-analyzes, so reject the
|
|
673
|
+
// orphaned case with a clear message rather than silently dropping the text
|
|
674
|
+
// (the failure mode this flag's parity is meant to avoid). The consuming
|
|
675
|
+
// modes are: --headless / --ai-draft / --ai-review (consume directly) and
|
|
676
|
+
// --ai / --council (threaded to the browser via analysisConfigId).
|
|
677
|
+
if (
|
|
678
|
+
(flags.instructions || flags.instructionsFile) &&
|
|
679
|
+
!(flags.headless || flags.aiDraft || flags.aiReview || flags.ai || flags.council)
|
|
680
|
+
) {
|
|
681
|
+
throw new Error(
|
|
682
|
+
'--instructions/--instructions-file require a mode that runs analysis: ' +
|
|
683
|
+
'--headless, --ai, --ai-draft, --ai-review, or --council.'
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// NOTE: in headless --json mode, stdout was already redirected to stderr
|
|
688
|
+
// above (before loadConfig + the first-run welcome) — see `isHeadlessJson`.
|
|
689
|
+
// No redirect is repeated here: it would be too late to matter and is
|
|
690
|
+
// redundant given the early call.
|
|
691
|
+
|
|
486
692
|
// Apply debug_stream from config if not already enabled by CLI flag
|
|
487
693
|
if (!flags.debugStream && config.debug_stream) {
|
|
488
694
|
flags.debugStream = true;
|
|
@@ -497,26 +703,108 @@ AI PROVIDERS:
|
|
|
497
703
|
process.env.PAIR_REVIEW_YOLO = 'true';
|
|
498
704
|
}
|
|
499
705
|
|
|
706
|
+
// --model is ignored when --council is set: council voices use their own
|
|
707
|
+
// per-voice models, so a top-level --model has no effect on the analysis.
|
|
708
|
+
if (flags.council && flags.model) {
|
|
709
|
+
console.warn('Warning: --model is ignored when --council is set; council voices use their own per-voice models.');
|
|
710
|
+
}
|
|
711
|
+
|
|
500
712
|
// Apply provider config overrides (including yolo) for all code paths
|
|
501
713
|
// (interactive, headless, local). server.js calls this independently on
|
|
502
714
|
// startup, but headless paths (--ai-draft, --ai-review) never start the
|
|
503
715
|
// server, so we must also apply here.
|
|
504
716
|
applyConfigOverrides(config);
|
|
505
717
|
|
|
718
|
+
// --list-councils: print the saved councils and exit. Needs the database
|
|
719
|
+
// but no server, no PR/local context, and no single-port delegation.
|
|
720
|
+
if (flags.listCouncils) {
|
|
721
|
+
const listDb = await initializeDatabase(resolveDbName(config));
|
|
722
|
+
try {
|
|
723
|
+
await printCouncilList(listDb);
|
|
724
|
+
} finally {
|
|
725
|
+
try { listDb.close(); } catch { /* ignore */ }
|
|
726
|
+
}
|
|
727
|
+
process.exit(0);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// --council requires something to analyze: a PR identifier or --local.
|
|
731
|
+
// Reject early (before single-port delegation) so a contextless `--council`
|
|
732
|
+
// fails fast with a clear message instead of silently delegating to — or
|
|
733
|
+
// starting — the generic landing page with the council ignored.
|
|
734
|
+
//
|
|
735
|
+
// Exception: in GitHub Actions, `--ai-review --council <handle>` is a
|
|
736
|
+
// documented headless flow where the PR is auto-detected from the
|
|
737
|
+
// environment further below (detectPRFromGitHubEnvironment). At this point
|
|
738
|
+
// prArgs is still empty, so guarding on it alone would reject that valid CI
|
|
739
|
+
// invocation. Defer to the later `--ai-review` PR check, which reports a
|
|
740
|
+
// clear error if no PR can be detected.
|
|
741
|
+
if (
|
|
742
|
+
flags.council &&
|
|
743
|
+
prArgs.length === 0 &&
|
|
744
|
+
!flags.local &&
|
|
745
|
+
!(flags.aiReview && process.env.GITHUB_ACTIONS === 'true')
|
|
746
|
+
) {
|
|
747
|
+
throw new Error('--council flag requires a pull request number/URL or --local to run analysis');
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Resolve the council handle FAIL-FAST, before single-port delegation, so
|
|
751
|
+
// that (a) a bad handle errors out for every mode (interactive, headless,
|
|
752
|
+
// and delegated) rather than only on a cold start, and (b) the resolved id
|
|
753
|
+
// can be threaded into the delegated URL — the browser-side council
|
|
754
|
+
// auto-analysis is keyed on the `council` query param. Resolution needs the
|
|
755
|
+
// DB, so when --council is set we open the shared connection now and the
|
|
756
|
+
// downstream handlers reuse it (they re-resolve for their own purposes;
|
|
757
|
+
// that is a cheap in-memory match against the same connection).
|
|
758
|
+
let cliCouncil = null;
|
|
759
|
+
if (flags.council) {
|
|
760
|
+
db = await initializeDatabase(resolveDbName(config));
|
|
761
|
+
cliCouncil = await resolveCouncilHandle(db, flags.council);
|
|
762
|
+
}
|
|
763
|
+
|
|
506
764
|
// Single-port delegation: if a pair-review server is already running on the
|
|
507
765
|
// configured port, delegate to it (open browser URL) and exit immediately.
|
|
508
766
|
// Skipped for: headless modes (no browser), single_port: false (dev mode).
|
|
509
|
-
|
|
510
|
-
|
|
767
|
+
// --headless never delegates/opens a browser — it runs analysis locally and
|
|
768
|
+
// reports to stdout/stderr.
|
|
769
|
+
if (config.single_port !== false && !flags.aiReview && !flags.aiDraft && !flags.headless) {
|
|
770
|
+
// Carry --instructions across delegation. When an analyzing mode
|
|
771
|
+
// (--ai/--council) ALSO supplies per-run instructions and a server is
|
|
772
|
+
// already running, attemptDelegation resolves the analysis config and POSTs
|
|
773
|
+
// it to that (separate) process, threading the returned id as
|
|
774
|
+
// `analysisConfigId`. That handoff needs an open DB (repo-default/council
|
|
775
|
+
// resolution) and, for local mode, the repository name — PR mode derives it
|
|
776
|
+
// from the args inside attemptDelegation. Without instructions this is a
|
|
777
|
+
// no-op and the prior councilId/analyze URL shape is preserved.
|
|
778
|
+
const wantsInstructionHandoff =
|
|
779
|
+
(flags.instructions || flags.instructionsFile) && (flags.ai || flags.council);
|
|
780
|
+
if (wantsInstructionHandoff && !db) {
|
|
781
|
+
db = await initializeDatabase(resolveDbName(config));
|
|
782
|
+
}
|
|
783
|
+
let localRepository = null;
|
|
784
|
+
if (wantsInstructionHandoff && flags.local) {
|
|
785
|
+
const localTarget = require('path').resolve(flags.localPath || process.cwd());
|
|
786
|
+
const localRoot = await findGitRoot(localTarget);
|
|
787
|
+
localRepository = await getRepositoryName(localRoot);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const delegated = await attemptDelegation(config, flags, prArgs, undefined, {
|
|
791
|
+
councilId: cliCouncil?.id || null,
|
|
792
|
+
db,
|
|
793
|
+
localRepository
|
|
794
|
+
});
|
|
511
795
|
if (delegated) {
|
|
796
|
+
if (db) { try { db.close(); } catch { /* ignore */ } }
|
|
512
797
|
process.exit(0);
|
|
513
798
|
}
|
|
514
799
|
// Not delegated — no server running, proceed to start one
|
|
515
800
|
}
|
|
516
801
|
|
|
517
|
-
// Initialize database
|
|
518
|
-
|
|
519
|
-
|
|
802
|
+
// Initialize database (reuse the connection already opened for council
|
|
803
|
+
// resolution above, if any).
|
|
804
|
+
if (!db) {
|
|
805
|
+
console.log('Initializing database...');
|
|
806
|
+
db = await initializeDatabase(resolveDbName(config));
|
|
807
|
+
}
|
|
520
808
|
|
|
521
809
|
// Migrate existing worktrees to database (if any)
|
|
522
810
|
const path = require('path');
|
|
@@ -533,6 +821,14 @@ AI PROVIDERS:
|
|
|
533
821
|
const poolLifecycle = new WorktreePoolLifecycle(db, config);
|
|
534
822
|
await poolLifecycle.resetAndRehydrate();
|
|
535
823
|
|
|
824
|
+
// Headless analysis mode: run analysis (single or council), store in the DB,
|
|
825
|
+
// report results to stdout/stderr, and exit. No server, no browser, no GitHub
|
|
826
|
+
// post. Works with a PR arg or --local. Handled before the local/PR split.
|
|
827
|
+
if (flags.headless) {
|
|
828
|
+
await handleHeadlessAnalysis(prArgs, config, db, flags, poolLifecycle);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
536
832
|
// Check for local mode (review uncommitted local changes)
|
|
537
833
|
if (flags.local) {
|
|
538
834
|
// Resolve localPath, defaulting to cwd if not provided
|
|
@@ -598,8 +894,33 @@ AI PROVIDERS:
|
|
|
598
894
|
console.log('No pull request specified. Starting server...');
|
|
599
895
|
await startServerOnly(config, poolLifecycle);
|
|
600
896
|
}
|
|
601
|
-
|
|
897
|
+
|
|
602
898
|
} catch (error) {
|
|
899
|
+
// In headless --json mode the consumer (typically a coding agent) parses
|
|
900
|
+
// stdout as JSON. A prose error on stderr + empty stdout forces it to scrape
|
|
901
|
+
// English to learn what broke, so emit a structured failure envelope on
|
|
902
|
+
// stdout instead — one stream, JSON for success and failure alike. Read
|
|
903
|
+
// process.argv directly: `args`/`flags` are block-scoped to the try and
|
|
904
|
+
// unavailable here, and the throw may even predate parseArgs(). Exit stays
|
|
905
|
+
// non-zero so exit-code consumers are unaffected.
|
|
906
|
+
const argv = process.argv.slice(2);
|
|
907
|
+
const jsonMode = argv.includes('--headless') && argv.includes('--json');
|
|
908
|
+
if (jsonMode) {
|
|
909
|
+
// Canonical source for local-mode detection is parseArgs' -l/--local
|
|
910
|
+
// handling; we re-check argv here only because `flags` is block-scoped to
|
|
911
|
+
// the try and may not exist yet if the throw predates parseArgs. Keep any
|
|
912
|
+
// future local-mode alias in sync with that handling. Mode is best-effort
|
|
913
|
+
// (agents branch on `ok`, not `mode`).
|
|
914
|
+
const mode = (argv.includes('--local') || argv.includes('-l')) ? 'local' : 'pr';
|
|
915
|
+
// stdout may be a pipe (e.g. `pair-review … | jq`); a synchronous
|
|
916
|
+
// process.exit() can truncate a buffered write, so exit only once the
|
|
917
|
+
// envelope has flushed. The non-JSON branch below keeps the immediate exit.
|
|
918
|
+
process.stdout.write(
|
|
919
|
+
JSON.stringify(buildHeadlessErrorJson({ mode, error }), null, 2) + '\n',
|
|
920
|
+
() => process.exit(1)
|
|
921
|
+
);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
603
924
|
console.error(`\n❌ Error: ${error.message}\n`);
|
|
604
925
|
process.exit(1);
|
|
605
926
|
}
|
|
@@ -649,11 +970,20 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
|
|
|
649
970
|
await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
|
|
650
971
|
}
|
|
651
972
|
|
|
652
|
-
// Set model override if provided via CLI flag
|
|
653
|
-
|
|
973
|
+
// Set model override if provided via CLI flag. Skipped when --council is
|
|
974
|
+
// set: council voices carry their own per-voice models.
|
|
975
|
+
if (flags.model && !flags.council) {
|
|
654
976
|
process.env.PAIR_REVIEW_MODEL = flags.model;
|
|
655
977
|
}
|
|
656
978
|
|
|
979
|
+
// Resolve the council handle FAIL-FAST before starting the server, so a bad
|
|
980
|
+
// handle surfaces as a clean error (caught below) instead of after the
|
|
981
|
+
// browser has opened.
|
|
982
|
+
let councilSelection = null;
|
|
983
|
+
if (flags.council) {
|
|
984
|
+
councilSelection = await resolveCouncilHandle(db, flags.council);
|
|
985
|
+
}
|
|
986
|
+
|
|
657
987
|
// Start server and open browser to setup page
|
|
658
988
|
const port = await startServer(db, poolLifecycle);
|
|
659
989
|
|
|
@@ -663,8 +993,21 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
|
|
|
663
993
|
startPoolBackgroundFetches(db, config);
|
|
664
994
|
|
|
665
995
|
let url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
|
|
666
|
-
|
|
667
|
-
|
|
996
|
+
const shouldAnalyze = flags.ai || flags.council;
|
|
997
|
+
if (shouldAnalyze) url += '?analyze=true';
|
|
998
|
+
// When the run carries --instructions, stash the resolved analysis config
|
|
999
|
+
// (provider/model or council snapshot + instructions) and thread its id so
|
|
1000
|
+
// the browser-side auto-analyze honors the instructions instead of silently
|
|
1001
|
+
// dropping them. The id already encodes the council selection, so prefer it
|
|
1002
|
+
// over the bare &council= param; fall back to &council= otherwise.
|
|
1003
|
+
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
1004
|
+
const analysisConfigId = shouldAnalyze
|
|
1005
|
+
? await prepareInteractiveAnalysisConfig({ db, config, flags, repository })
|
|
1006
|
+
: null;
|
|
1007
|
+
if (analysisConfigId) {
|
|
1008
|
+
url += `&analysisConfigId=${analysisConfigId}`;
|
|
1009
|
+
} else if (councilSelection) {
|
|
1010
|
+
url += `&council=${councilSelection.id}`;
|
|
668
1011
|
}
|
|
669
1012
|
|
|
670
1013
|
console.log(`Opening browser to: ${url}`);
|
|
@@ -742,6 +1085,21 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
742
1085
|
const parser = new PRArgumentParser(config);
|
|
743
1086
|
prInfo = await parser.parsePRArguments(args);
|
|
744
1087
|
|
|
1088
|
+
// Resolve + validate the council FAIL-FAST, before any worktree/network
|
|
1089
|
+
// work, so a bad handle or invalid config surfaces immediately. The actual
|
|
1090
|
+
// council selection used for analysis is produced below by
|
|
1091
|
+
// resolveReviewConfig; this is a pre-flight validation only.
|
|
1092
|
+
if (flags.council) {
|
|
1093
|
+
const council = await resolveCouncilHandle(db, flags.council);
|
|
1094
|
+
const configType = council.type || 'advanced';
|
|
1095
|
+
const councilConfig = normalizeCouncilConfig(council.config, configType);
|
|
1096
|
+
// validateCouncilConfig returns an error string, or null when valid.
|
|
1097
|
+
const validationError = validateCouncilConfig(councilConfig, configType);
|
|
1098
|
+
if (validationError) {
|
|
1099
|
+
throw new Error(`Invalid council "${council.name}": ${validationError}`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
745
1103
|
// Resolve host binding for the target repo (handles alt-host).
|
|
746
1104
|
// Prefer `bindingRepository` (from url_pattern match) over the raw
|
|
747
1105
|
// `${owner}/${repo}` since they can differ when one config entry
|
|
@@ -971,14 +1329,28 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
971
1329
|
const repoInstructions = repoSettings?.default_instructions || null;
|
|
972
1330
|
const globalInstructions = config.globalInstructions || null;
|
|
973
1331
|
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
const
|
|
977
|
-
const
|
|
978
|
-
|
|
1332
|
+
// Resolve per-run custom instructions from --instructions/--instructions-file.
|
|
1333
|
+
// Default stays null, preserving prior --ai-draft/--ai-review behavior.
|
|
1334
|
+
const requestInstructions = await resolveCliInstructions(flags);
|
|
1335
|
+
const instructions = { globalInstructions, repoInstructions, requestInstructions };
|
|
1336
|
+
|
|
1337
|
+
// Resolve the review configuration (council vs single provider/model) via the
|
|
1338
|
+
// shared resolver, so --ai-draft/--ai-review honor repo default councils too.
|
|
1339
|
+
// The early councilSelection above already fail-fast validated an explicit
|
|
1340
|
+
// --council; resolveReviewConfig re-resolves the same handle (cheap in-memory
|
|
1341
|
+
// match) and unifies single/council selection with the headless path.
|
|
1342
|
+
const reviewConfig = await resolveReviewConfig(
|
|
1343
|
+
db,
|
|
1344
|
+
repository,
|
|
1345
|
+
{ council: flags.council, model: flags.model },
|
|
1346
|
+
config
|
|
1347
|
+
);
|
|
1348
|
+
|
|
1349
|
+
// Single-provider runs honor the repo/global tier-3 load_skills resolution.
|
|
1350
|
+
const cliProvider = reviewConfig.type === 'single' ? reviewConfig.provider : null;
|
|
1351
|
+
const providerLoadSkills = cliProvider ? config.providers?.[cliProvider]?.load_skills : undefined;
|
|
979
1352
|
const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
|
|
980
1353
|
const providerOverrides = { load_skills: loadSkills };
|
|
981
|
-
const analyzer = new Analyzer(db, model, cliProvider, providerOverrides);
|
|
982
1354
|
|
|
983
1355
|
let analysisSummary = null;
|
|
984
1356
|
try {
|
|
@@ -986,7 +1358,18 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
986
1358
|
// githubClient is forwarded so the analyzer can pre-fetch existing PR review
|
|
987
1359
|
// comments when callers opt in via excludePrevious.github.
|
|
988
1360
|
logger.debug(`analyzer githubClient wired for ${prInfo.owner}/${prInfo.repo}#${prInfo.number}`);
|
|
989
|
-
const analysisResult = await
|
|
1361
|
+
const { result: analysisResult } = await runHeadlessAnalysis(db, config, {
|
|
1362
|
+
reviewId: review.id,
|
|
1363
|
+
worktreePath,
|
|
1364
|
+
prMetadata: storedPRData,
|
|
1365
|
+
changedFiles: null,
|
|
1366
|
+
repository,
|
|
1367
|
+
repoSettings,
|
|
1368
|
+
instructions,
|
|
1369
|
+
reviewConfig,
|
|
1370
|
+
providerOverrides,
|
|
1371
|
+
githubClient
|
|
1372
|
+
});
|
|
990
1373
|
analysisSummary = analysisResult.summary;
|
|
991
1374
|
console.log('AI analysis completed successfully');
|
|
992
1375
|
} catch (analysisError) {
|
|
@@ -1227,6 +1610,716 @@ Found ${validSuggestions.length} suggestion${validSuggestions.length === 1 ? ''
|
|
|
1227
1610
|
}
|
|
1228
1611
|
}
|
|
1229
1612
|
|
|
1613
|
+
/**
|
|
1614
|
+
* Shared single-vs-council analysis dispatch for headless runs.
|
|
1615
|
+
*
|
|
1616
|
+
* Both `performHeadlessReview` (GitHub-submit modes) and `handleHeadlessAnalysis`
|
|
1617
|
+
* (analysis-only headless mode) call this so the single/council branching — and
|
|
1618
|
+
* the instruction object threaded into every analyzer path — lives in exactly one
|
|
1619
|
+
* place. It only RUNS analysis and returns the run id + analyzer result; it never
|
|
1620
|
+
* submits to GitHub or releases pool worktrees (callers own that).
|
|
1621
|
+
*
|
|
1622
|
+
* The SAME `instructions` object ({ globalInstructions, repoInstructions,
|
|
1623
|
+
* requestInstructions }) is passed to both branches, centralizing the
|
|
1624
|
+
* three-analyzer-paths instruction hazard (analyzeAllLevels vs the two council
|
|
1625
|
+
* paths inside runHeadlessCouncilAnalysis).
|
|
1626
|
+
*
|
|
1627
|
+
* @param {Object} db - Database instance
|
|
1628
|
+
* @param {Object} config - Application configuration
|
|
1629
|
+
* @param {Object} params
|
|
1630
|
+
* @param {number} params.reviewId - Review id comments are stored under
|
|
1631
|
+
* @param {string} params.worktreePath - Checked-out worktree / local repo path
|
|
1632
|
+
* @param {Object} params.prMetadata - PR/local metadata (head_sha, base_sha, etc.)
|
|
1633
|
+
* @param {Array|null} params.changedFiles - Changed-file list (local mode) or null (PR mode computes it)
|
|
1634
|
+
* @param {string} params.repository - owner/repo
|
|
1635
|
+
* @param {Object|null} params.repoSettings - Repo settings row
|
|
1636
|
+
* @param {Object} params.instructions - { globalInstructions, repoInstructions, requestInstructions }
|
|
1637
|
+
* @param {Object} params.reviewConfig - Result of resolveReviewConfig (single|council)
|
|
1638
|
+
* @param {Object} params.providerOverrides - Single-provider overrides (e.g. { load_skills })
|
|
1639
|
+
* @param {Object} [params.githubClient] - GitHub client (PR mode) or undefined (local)
|
|
1640
|
+
* @returns {Promise<{ runId: string, result: Object }>}
|
|
1641
|
+
*/
|
|
1642
|
+
async function runHeadlessAnalysis(db, config, {
|
|
1643
|
+
reviewId,
|
|
1644
|
+
worktreePath,
|
|
1645
|
+
prMetadata,
|
|
1646
|
+
changedFiles,
|
|
1647
|
+
repository,
|
|
1648
|
+
repoSettings,
|
|
1649
|
+
instructions,
|
|
1650
|
+
reviewConfig,
|
|
1651
|
+
providerOverrides,
|
|
1652
|
+
githubClient
|
|
1653
|
+
}) {
|
|
1654
|
+
if (reviewConfig.type === 'council') {
|
|
1655
|
+
console.log(`Running council analysis: ${reviewConfig.council.name} (${reviewConfig.configType})...`);
|
|
1656
|
+
// Council voices can span multiple providers, so resolve a per-provider
|
|
1657
|
+
// load_skills map (tier-3 resolution per provider) the same way every other
|
|
1658
|
+
// council invoker does (pr.js / local.js / stack-analysis.js). Passing only
|
|
1659
|
+
// the shared providerOverrides would collapse every voice onto the default
|
|
1660
|
+
// provider's load_skills — see buildVoiceContext.
|
|
1661
|
+
const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
|
|
1662
|
+
buildCouncilProviderOverrides(config, repository, repoSettings);
|
|
1663
|
+
const analyzer = new Analyzer(db, 'council', 'council', councilProviderOverrides, councilProviderOverridesMap);
|
|
1664
|
+
const result = await runHeadlessCouncilAnalysis(db, {
|
|
1665
|
+
analyzer,
|
|
1666
|
+
reviewId,
|
|
1667
|
+
council: reviewConfig.council,
|
|
1668
|
+
configType: reviewConfig.configType,
|
|
1669
|
+
councilConfig: reviewConfig.councilConfig,
|
|
1670
|
+
worktreePath,
|
|
1671
|
+
prMetadata,
|
|
1672
|
+
changedFiles,
|
|
1673
|
+
instructions,
|
|
1674
|
+
githubClient
|
|
1675
|
+
});
|
|
1676
|
+
return { runId: result.runId, result };
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
console.log('Running AI analysis (all 3 levels)...');
|
|
1680
|
+
const runId = uuidv4();
|
|
1681
|
+
const analyzer = new Analyzer(db, reviewConfig.model, reviewConfig.provider, providerOverrides);
|
|
1682
|
+
const result = await analyzer.analyzeAllLevels(
|
|
1683
|
+
reviewId,
|
|
1684
|
+
worktreePath,
|
|
1685
|
+
prMetadata,
|
|
1686
|
+
null,
|
|
1687
|
+
instructions,
|
|
1688
|
+
changedFiles,
|
|
1689
|
+
{ githubClient, runId }
|
|
1690
|
+
);
|
|
1691
|
+
return { runId, result };
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/**
|
|
1695
|
+
* Prepare a PR for headless analysis-only mode (no GitHub submit).
|
|
1696
|
+
*
|
|
1697
|
+
* This is the analysis-only twin of the prep block inside `performHeadlessReview`.
|
|
1698
|
+
* It is intentionally a STANDALONE function rather than an extraction from
|
|
1699
|
+
* `performHeadlessReview`: the latter's prep and submit blocks share many locals
|
|
1700
|
+
* and that function is CI-critical, so duplicating the minimal prep here (the
|
|
1701
|
+
* documented fallback in the plan) keeps the submit path byte-identical and
|
|
1702
|
+
* untouched. The prep mirrors `performHeadlessReview` (parse → token/binding →
|
|
1703
|
+
* client → fetch PR → worktree/pool acquire → diff → storePRData → metadata →
|
|
1704
|
+
* review → repoSettings → reviewConfig), minus anything only the submit path
|
|
1705
|
+
* needs.
|
|
1706
|
+
*
|
|
1707
|
+
* @param {Object} db - Database instance
|
|
1708
|
+
* @param {Object} config - Application configuration
|
|
1709
|
+
* @param {Object} flags - Parsed CLI flags
|
|
1710
|
+
* @param {Array<string>} prArgs - PR identifier args
|
|
1711
|
+
* @param {import('./git/worktree-pool-lifecycle').WorktreePoolLifecycle} [externalPoolLifecycle]
|
|
1712
|
+
* - The session's already-rehydrated pool lifecycle. Reused for pool
|
|
1713
|
+
* acquisition (mirrors `performHeadlessReview`'s `externalPoolLifecycle`
|
|
1714
|
+
* convention) so the in-memory usage tracker populated by
|
|
1715
|
+
* `resetAndRehydrate()` is the one acquisitions go through. Falls back to a
|
|
1716
|
+
* fresh instance only when not supplied.
|
|
1717
|
+
* @returns {Promise<Object>} { githubClient, worktreePath, storedPRData, review,
|
|
1718
|
+
* repository, repoSettings, poolWorktreeId, poolLifecycle, reviewConfig,
|
|
1719
|
+
* providerOverrides }
|
|
1720
|
+
*/
|
|
1721
|
+
async function preparePrHeadless(db, config, flags, prArgs, externalPoolLifecycle = null) {
|
|
1722
|
+
const parser = new PRArgumentParser(config);
|
|
1723
|
+
const prInfo = await parser.parsePRArguments(prArgs);
|
|
1724
|
+
|
|
1725
|
+
// Fail-fast council validation (mirrors performHeadlessReview).
|
|
1726
|
+
if (flags.council) {
|
|
1727
|
+
const council = await resolveCouncilHandle(db, flags.council);
|
|
1728
|
+
const configType = council.type || 'advanced';
|
|
1729
|
+
const councilConfig = normalizeCouncilConfig(council.config, configType);
|
|
1730
|
+
const validationError = validateCouncilConfig(councilConfig, configType);
|
|
1731
|
+
if (validationError) {
|
|
1732
|
+
throw new Error(`Invalid council "${council.name}": ${validationError}`);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
const repositoryForBinding = prInfo.bindingRepository
|
|
1737
|
+
|| normalizeRepository(prInfo.owner, prInfo.repo);
|
|
1738
|
+
const headlessBinding = resolveHostBinding(repositoryForBinding, config);
|
|
1739
|
+
if (!headlessBinding.token) {
|
|
1740
|
+
throw new Error(buildMissingTokenError({
|
|
1741
|
+
owner: prInfo.owner,
|
|
1742
|
+
repo: prInfo.repo,
|
|
1743
|
+
bindingRepository: repositoryForBinding,
|
|
1744
|
+
apiHost: headlessBinding.apiHost
|
|
1745
|
+
}));
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
console.log(`Processing pull request #${prInfo.number} from ${prInfo.owner}/${prInfo.repo} in headless mode`);
|
|
1749
|
+
|
|
1750
|
+
const githubClient = new GitHubClient(headlessBinding);
|
|
1751
|
+
const repoExists = await githubClient.repositoryExists(prInfo.owner, prInfo.repo);
|
|
1752
|
+
if (!repoExists) {
|
|
1753
|
+
throw new Error(`Repository ${prInfo.owner}/${prInfo.repo} not found or not accessible`);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
console.log('Fetching pull request data from GitHub...');
|
|
1757
|
+
const prData = await githubClient.fetchPullRequest(prInfo.owner, prInfo.repo, prInfo.number);
|
|
1758
|
+
|
|
1759
|
+
let worktreePath;
|
|
1760
|
+
let diff;
|
|
1761
|
+
let changedFiles;
|
|
1762
|
+
let poolWorktreeId = null;
|
|
1763
|
+
let poolLifecycle = null;
|
|
1764
|
+
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
1765
|
+
|
|
1766
|
+
// Everything from here on can acquire a pool worktree and then do work that
|
|
1767
|
+
// may throw (diff generation, storePRData, metadata query, resolveReviewConfig).
|
|
1768
|
+
// The caller (handleHeadlessAnalysis) releases the pool slot in a finally, but
|
|
1769
|
+
// ONLY once it holds the returned prep object — a throw before that return would
|
|
1770
|
+
// leak the acquired worktree. So on the throwing path we release it here before
|
|
1771
|
+
// re-throwing. `poolWorktreeId` is null until acquire succeeds, so the guarded
|
|
1772
|
+
// release is a no-op when the throw predates acquisition (no double-release: the
|
|
1773
|
+
// caller's finally runs only on the success path that returns below).
|
|
1774
|
+
try {
|
|
1775
|
+
if (flags.useCheckout) {
|
|
1776
|
+
worktreePath = process.cwd();
|
|
1777
|
+
const isMatchingRepo = await parser.isMatchingRepository(worktreePath, prInfo.owner, prInfo.repo);
|
|
1778
|
+
if (!isMatchingRepo) {
|
|
1779
|
+
throw new Error(
|
|
1780
|
+
`--use-checkout requires running from a checkout of ${prInfo.owner}/${prInfo.repo}, ` +
|
|
1781
|
+
`but current directory does not match. Either cd to the correct repository or remove --use-checkout.`
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
await registerRepositoryLocation(db, worktreePath, prInfo.owner, prInfo.repo);
|
|
1785
|
+
console.log(`Using current checkout at ${worktreePath}`);
|
|
1786
|
+
|
|
1787
|
+
console.log('Generating unified diff from checkout...');
|
|
1788
|
+
const git = simpleGit(worktreePath);
|
|
1789
|
+
try {
|
|
1790
|
+
await fetchNoTags(git, ['origin', prData.base_sha]);
|
|
1791
|
+
} catch (fetchError) {
|
|
1792
|
+
try {
|
|
1793
|
+
await git.raw(['cat-file', '-t', prData.base_sha]);
|
|
1794
|
+
} catch {
|
|
1795
|
+
throw new Error(`Base SHA ${prData.base_sha} is not available locally and fetch failed: ${fetchError.message}`);
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
diff = await git.diff([
|
|
1799
|
+
`${prData.base_sha}...${prData.head_sha}`,
|
|
1800
|
+
'--unified=3',
|
|
1801
|
+
...GIT_DIFF_FLAGS_ARRAY
|
|
1802
|
+
]);
|
|
1803
|
+
const diffSummary = await git.diffSummary([
|
|
1804
|
+
`${prData.base_sha}...${prData.head_sha}`,
|
|
1805
|
+
...GIT_DIFF_SUMMARY_FLAGS_ARRAY
|
|
1806
|
+
]);
|
|
1807
|
+
const gitattributes = await getGeneratedFilePatterns(worktreePath);
|
|
1808
|
+
changedFiles = diffSummary.files.map(file => {
|
|
1809
|
+
const resolvedFile = resolveRenamedFile(file.file);
|
|
1810
|
+
const isRenamed = resolvedFile !== file.file;
|
|
1811
|
+
const result = {
|
|
1812
|
+
file: resolvedFile,
|
|
1813
|
+
insertions: file.insertions,
|
|
1814
|
+
deletions: file.deletions,
|
|
1815
|
+
changes: file.changes,
|
|
1816
|
+
binary: file.binary || false,
|
|
1817
|
+
generated: gitattributes.isGenerated(resolvedFile)
|
|
1818
|
+
};
|
|
1819
|
+
if (isRenamed) {
|
|
1820
|
+
result.renamed = true;
|
|
1821
|
+
result.renamedFrom = resolveRenamedFileOld(file.file);
|
|
1822
|
+
}
|
|
1823
|
+
return result;
|
|
1824
|
+
});
|
|
1825
|
+
} else {
|
|
1826
|
+
const currentDir = parser.getCurrentDirectory();
|
|
1827
|
+
const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
|
|
1828
|
+
|
|
1829
|
+
let repositoryPath;
|
|
1830
|
+
let worktreeSourcePath;
|
|
1831
|
+
let checkoutScript;
|
|
1832
|
+
let checkoutTimeout;
|
|
1833
|
+
let worktreeConfig = null;
|
|
1834
|
+
let poolSize = 0;
|
|
1835
|
+
let resetScript = null;
|
|
1836
|
+
if (isMatchingRepo) {
|
|
1837
|
+
repositoryPath = currentDir;
|
|
1838
|
+
await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
|
|
1839
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1840
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
1841
|
+
const resolved = resolveRepoOptions(config, repositoryForBinding, repoSettings);
|
|
1842
|
+
checkoutScript = resolved.checkoutScript;
|
|
1843
|
+
checkoutTimeout = resolved.checkoutTimeout;
|
|
1844
|
+
worktreeConfig = resolved.worktreeConfig;
|
|
1845
|
+
poolSize = resolved.poolSize || 0;
|
|
1846
|
+
resetScript = resolved.resetScript || null;
|
|
1847
|
+
} else {
|
|
1848
|
+
console.log(`Current directory is not a checkout of ${prInfo.owner}/${prInfo.repo}, locating repository...`);
|
|
1849
|
+
const result = await findRepositoryPath({
|
|
1850
|
+
db,
|
|
1851
|
+
owner: prInfo.owner,
|
|
1852
|
+
repo: prInfo.repo,
|
|
1853
|
+
repository,
|
|
1854
|
+
bindingRepository: repositoryForBinding,
|
|
1855
|
+
prNumber: prInfo.number,
|
|
1856
|
+
config,
|
|
1857
|
+
cloneUrl: prData?.repository?.clone_url,
|
|
1858
|
+
onProgress: (progress) => {
|
|
1859
|
+
if (progress.message) {
|
|
1860
|
+
console.log(progress.message);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
});
|
|
1864
|
+
repositoryPath = result.repositoryPath;
|
|
1865
|
+
worktreeSourcePath = result.worktreeSourcePath;
|
|
1866
|
+
checkoutScript = result.checkoutScript;
|
|
1867
|
+
checkoutTimeout = result.checkoutTimeout;
|
|
1868
|
+
worktreeConfig = result.worktreeConfig;
|
|
1869
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1870
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
1871
|
+
const { poolSize: resolvedPoolSize } = resolvePoolConfig(config, repositoryForBinding, repoSettings);
|
|
1872
|
+
poolSize = resolvedPoolSize || 0;
|
|
1873
|
+
resetScript = config ? getRepoResetScript(config, repositoryForBinding) : null;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
|
|
1877
|
+
if (poolSize > 0) {
|
|
1878
|
+
console.log('Acquiring pool worktree...');
|
|
1879
|
+
poolLifecycle = externalPoolLifecycle || new WorktreePoolLifecycle(db, config);
|
|
1880
|
+
const result = await poolLifecycle.acquireForPR(
|
|
1881
|
+
{ owner: prInfo.owner, repo: prInfo.repo, prNumber: prInfo.number, repository },
|
|
1882
|
+
prData,
|
|
1883
|
+
repositoryPath,
|
|
1884
|
+
{ worktreeSourcePath, checkoutScript, checkoutTimeout, resetScript, worktreeConfig, poolSize }
|
|
1885
|
+
);
|
|
1886
|
+
worktreePath = result.worktreePath;
|
|
1887
|
+
poolWorktreeId = result.worktreeId;
|
|
1888
|
+
console.log('Pool worktree acquired');
|
|
1889
|
+
} else {
|
|
1890
|
+
console.log('Setting up git worktree...');
|
|
1891
|
+
({ path: worktreePath } = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, {
|
|
1892
|
+
worktreeSourcePath,
|
|
1893
|
+
checkoutScript,
|
|
1894
|
+
checkoutTimeout
|
|
1895
|
+
}));
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
console.log('Generating unified diff...');
|
|
1899
|
+
diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
|
|
1900
|
+
changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
console.log('Storing pull request data...');
|
|
1904
|
+
const { isNewReview, reviewId: storedReviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
|
|
1905
|
+
skipWorktreeRecord: !!flags.useCheckout
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
if (poolWorktreeId && poolLifecycle) {
|
|
1909
|
+
await poolLifecycle.setReviewOwner(poolWorktreeId, storedReviewId);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
if (isNewReview) {
|
|
1913
|
+
fireReviewStartedHook({
|
|
1914
|
+
reviewId: storedReviewId, prNumber: prInfo.number,
|
|
1915
|
+
owner: prInfo.owner, repo: prInfo.repo, prData, config,
|
|
1916
|
+
}).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const prMetadata = await queryOne(db, `
|
|
1920
|
+
SELECT id, pr_data FROM pr_metadata
|
|
1921
|
+
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
1922
|
+
`, [prInfo.number, repository]);
|
|
1923
|
+
|
|
1924
|
+
if (!prMetadata) {
|
|
1925
|
+
throw new Error('Failed to retrieve stored PR metadata');
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
const storedPRData = JSON.parse(prMetadata.pr_data);
|
|
1929
|
+
|
|
1930
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1931
|
+
const { review } = await reviewRepo.getOrCreate({ prNumber: prInfo.number, repository });
|
|
1932
|
+
|
|
1933
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1934
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
1935
|
+
|
|
1936
|
+
const reviewConfig = await resolveReviewConfig(
|
|
1937
|
+
db,
|
|
1938
|
+
repository,
|
|
1939
|
+
{ council: flags.council, model: flags.model },
|
|
1940
|
+
config
|
|
1941
|
+
);
|
|
1942
|
+
|
|
1943
|
+
const cliProvider = reviewConfig.type === 'single' ? reviewConfig.provider : null;
|
|
1944
|
+
const providerLoadSkills = cliProvider ? config.providers?.[cliProvider]?.load_skills : undefined;
|
|
1945
|
+
const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
|
|
1946
|
+
const providerOverrides = { load_skills: loadSkills };
|
|
1947
|
+
|
|
1948
|
+
return {
|
|
1949
|
+
githubClient,
|
|
1950
|
+
worktreePath,
|
|
1951
|
+
storedPRData,
|
|
1952
|
+
review,
|
|
1953
|
+
repository,
|
|
1954
|
+
repoSettings,
|
|
1955
|
+
poolWorktreeId,
|
|
1956
|
+
poolLifecycle,
|
|
1957
|
+
reviewConfig,
|
|
1958
|
+
providerOverrides
|
|
1959
|
+
};
|
|
1960
|
+
} catch (prepErr) {
|
|
1961
|
+
// Release the acquired pool worktree (if any) before propagating — the
|
|
1962
|
+
// caller never received a prep object, so its finally won't run. Guarded on
|
|
1963
|
+
// poolWorktreeId so a throw before acquisition is a no-op. Release errors are
|
|
1964
|
+
// logged, not allowed to mask the original failure.
|
|
1965
|
+
if (poolWorktreeId && poolLifecycle) {
|
|
1966
|
+
try {
|
|
1967
|
+
await poolLifecycle.releaseAfterHeadless(poolWorktreeId);
|
|
1968
|
+
logger.info(`Released pool worktree ${poolWorktreeId} after headless prep failure`);
|
|
1969
|
+
} catch (releaseErr) {
|
|
1970
|
+
logger.error(`Failed to release pool worktree ${poolWorktreeId} after prep failure: ${releaseErr.message}`);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
throw prepErr;
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* Record a completed, zero-suggestion analysis run for an empty-scope headless
|
|
1979
|
+
* local review (the analyzer is never invoked). Mirrors the web routes'
|
|
1980
|
+
* empty-scope guard but, lacking an HTTP response, persists a normal-shaped run
|
|
1981
|
+
* so `buildHeadlessJson` emits the standard `{ run, suggestions: [], count: 0 }`
|
|
1982
|
+
* envelope and the command still exits 0 ("zero suggestions is still success").
|
|
1983
|
+
* The run is attributed to whatever `reviewConfig` resolved (single or council).
|
|
1984
|
+
*
|
|
1985
|
+
* @param {Object} db - Database instance
|
|
1986
|
+
* @param {Object} params
|
|
1987
|
+
* @param {number} params.reviewId - Review id the run belongs to
|
|
1988
|
+
* @param {Object} params.reviewConfig - resolveReviewConfig result (single|council)
|
|
1989
|
+
* @param {Object} params.instructions - { globalInstructions, repoInstructions, requestInstructions }
|
|
1990
|
+
* @param {string|null} params.headSha - Head SHA recorded on the run
|
|
1991
|
+
* @returns {Promise<string>} The created run id
|
|
1992
|
+
*/
|
|
1993
|
+
async function recordEmptyScopeRun(db, { reviewId, reviewConfig, instructions, headSha }) {
|
|
1994
|
+
const runId = uuidv4();
|
|
1995
|
+
const runRepo = new AnalysisRunRepository(db);
|
|
1996
|
+
const isCouncil = reviewConfig.type === 'council';
|
|
1997
|
+
await runRepo.create({
|
|
1998
|
+
id: runId,
|
|
1999
|
+
reviewId,
|
|
2000
|
+
provider: isCouncil ? 'council' : reviewConfig.provider,
|
|
2001
|
+
model: isCouncil ? reviewConfig.council.id : reviewConfig.model,
|
|
2002
|
+
tier: null,
|
|
2003
|
+
globalInstructions: instructions?.globalInstructions || null,
|
|
2004
|
+
repoInstructions: instructions?.repoInstructions || null,
|
|
2005
|
+
requestInstructions: instructions?.requestInstructions || null,
|
|
2006
|
+
headSha: headSha || null,
|
|
2007
|
+
configType: isCouncil ? reviewConfig.configType : 'single',
|
|
2008
|
+
levelsConfig: null
|
|
2009
|
+
});
|
|
2010
|
+
await runRepo.update(runId, {
|
|
2011
|
+
status: 'completed',
|
|
2012
|
+
summary: 'No changes found in the selected scope — analysis skipped.',
|
|
2013
|
+
totalSuggestions: 0,
|
|
2014
|
+
filesAnalyzed: 0
|
|
2015
|
+
});
|
|
2016
|
+
return runId;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* Headless analysis-only handler: run analysis (single or council), store it in
|
|
2021
|
+
* the DB, report the consolidated run to stdout/stderr, and exit. No server, no
|
|
2022
|
+
* browser, no GitHub post. Works with a PR arg or --local.
|
|
2023
|
+
*
|
|
2024
|
+
* @param {Array<string>} prArgs - PR identifier args (empty for --local)
|
|
2025
|
+
* @param {Object} config - Application configuration
|
|
2026
|
+
* @param {Object} db - Database instance
|
|
2027
|
+
* @param {Object} flags - Parsed CLI flags
|
|
2028
|
+
* @param {import('./git/worktree-pool-lifecycle').WorktreePoolLifecycle} poolLifecycle
|
|
2029
|
+
* - The session's rehydrated pool lifecycle. Forwarded to `preparePrHeadless`
|
|
2030
|
+
* for PR-mode pool acquisition so the rehydrated in-memory tracker is used;
|
|
2031
|
+
* local mode has no worktree/pool and ignores it.
|
|
2032
|
+
*/
|
|
2033
|
+
async function handleHeadlessAnalysis(prArgs, config, db, flags, poolLifecycle) {
|
|
2034
|
+
// NOTE: In --json mode, redirectConsoleToStderr() is already called in main()
|
|
2035
|
+
// BEFORE the DB-init/migration/pool-rehydrate block, so stdout is clean by the
|
|
2036
|
+
// time we get here (those earlier steps log to stdout and would otherwise
|
|
2037
|
+
// precede and corrupt the JSON document). No redirect is needed in this
|
|
2038
|
+
// handler — it would be too late and is redundant.
|
|
2039
|
+
|
|
2040
|
+
// Resolve per-run custom instructions; a bad --instructions-file path fails
|
|
2041
|
+
// fast with a clear operational error (propagates to main()'s catch → exit 1).
|
|
2042
|
+
const requestInstructions = await resolveCliInstructions(flags);
|
|
2043
|
+
|
|
2044
|
+
const globalInstructions = config.globalInstructions || null;
|
|
2045
|
+
|
|
2046
|
+
let runId;
|
|
2047
|
+
let mode;
|
|
2048
|
+
|
|
2049
|
+
if (flags.local) {
|
|
2050
|
+
mode = 'local';
|
|
2051
|
+
|
|
2052
|
+
// Fail-fast council validation (mirror the PR path; local mode otherwise
|
|
2053
|
+
// lacks an explicit pre-flight check here).
|
|
2054
|
+
if (flags.council) {
|
|
2055
|
+
const council = await resolveCouncilHandle(db, flags.council);
|
|
2056
|
+
const configType = council.type || 'advanced';
|
|
2057
|
+
const councilConfig = normalizeCouncilConfig(council.config, configType);
|
|
2058
|
+
const validationError = validateCouncilConfig(councilConfig, configType);
|
|
2059
|
+
if (validationError) {
|
|
2060
|
+
throw new Error(`Invalid council "${council.name}": ${validationError}`);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
const resolvedPath = require('path').resolve(flags.localPath || process.cwd());
|
|
2065
|
+
console.log(`Finding git repository root from ${resolvedPath}...`);
|
|
2066
|
+
const repoPath = await findGitRoot(resolvedPath);
|
|
2067
|
+
console.log(`Found git repository at: ${repoPath}`);
|
|
2068
|
+
|
|
2069
|
+
// Headless is a one-shot run: suppress the interactive summary/tour jobs so
|
|
2070
|
+
// they don't spend provider budget after the result is reported or keep the
|
|
2071
|
+
// event loop alive (CLI hang). See setupLocalReviewSession.
|
|
2072
|
+
const session = await setupLocalReviewSession({ db, config, repoPath, flags, startBackgroundJobs: false });
|
|
2073
|
+
|
|
2074
|
+
const repository = session.repository;
|
|
2075
|
+
const repoSettings = await new RepoSettingsRepository(db).getRepoSettings(repository);
|
|
2076
|
+
const repoInstructions = repoSettings?.default_instructions || null;
|
|
2077
|
+
|
|
2078
|
+
// Build local analysis metadata (mirror src/routes/local.js).
|
|
2079
|
+
const hasBranch = includesBranch(session.scopeStart);
|
|
2080
|
+
let analysisBaseSha = session.headSha;
|
|
2081
|
+
if (hasBranch && session.baseBranch) {
|
|
2082
|
+
try {
|
|
2083
|
+
analysisBaseSha = await findMergeBase(repoPath, session.baseBranch);
|
|
2084
|
+
} catch {
|
|
2085
|
+
// Fall back to HEAD
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
const localMetadata = {
|
|
2089
|
+
id: session.sessionId,
|
|
2090
|
+
repository,
|
|
2091
|
+
title: hasBranch
|
|
2092
|
+
? `Branch changes: ${session.baseBranch}..HEAD`
|
|
2093
|
+
: `Local changes in ${repository}`,
|
|
2094
|
+
description: hasBranch
|
|
2095
|
+
? `Reviewing committed changes on branch against ${session.baseBranch}`
|
|
2096
|
+
: `Reviewing uncommitted changes in ${repoPath}`,
|
|
2097
|
+
base_sha: analysisBaseSha,
|
|
2098
|
+
head_sha: session.headSha,
|
|
2099
|
+
// Scope context the analyzer's executable-provider path consults FIRST when
|
|
2100
|
+
// rebuilding the local diff (src/ai/analyzer.js → generateDiffForExecutable /
|
|
2101
|
+
// getChangedFiles check scopeStart/scopeEnd before base_sha/head_sha). Without
|
|
2102
|
+
// these, unstaged-/untracked-only and mixed scopes (where base_sha === head_sha
|
|
2103
|
+
// === HEAD) fall through to the SHA branch and produce an empty/partial diff.
|
|
2104
|
+
// Matches the interactive local council metadata (src/routes/local.js).
|
|
2105
|
+
base_branch: session.baseBranch || null,
|
|
2106
|
+
head_branch: session.branch || null,
|
|
2107
|
+
scopeStart: session.scopeStart,
|
|
2108
|
+
scopeEnd: session.scopeEnd,
|
|
2109
|
+
reviewType: 'local'
|
|
2110
|
+
};
|
|
2111
|
+
|
|
2112
|
+
const reviewConfig = await resolveReviewConfig(
|
|
2113
|
+
db,
|
|
2114
|
+
repository,
|
|
2115
|
+
{ council: flags.council, model: flags.model },
|
|
2116
|
+
config
|
|
2117
|
+
);
|
|
2118
|
+
const cliProvider = reviewConfig.type === 'single' ? reviewConfig.provider : null;
|
|
2119
|
+
const providerLoadSkills = cliProvider ? config.providers?.[cliProvider]?.load_skills : undefined;
|
|
2120
|
+
const loadSkills = resolveLoadSkills(config, repository, repoSettings, providerLoadSkills);
|
|
2121
|
+
const providerOverrides = { load_skills: loadSkills };
|
|
2122
|
+
|
|
2123
|
+
const instructions = { globalInstructions, repoInstructions, requestInstructions };
|
|
2124
|
+
|
|
2125
|
+
// Empty-scope detection is driven by the SESSION DIFF, not a second
|
|
2126
|
+
// changed-file enumeration. setupLocalReviewSession already generated the
|
|
2127
|
+
// scoped diff via generateScopedDiff, which THROWS on a hard git failure and
|
|
2128
|
+
// returns '' only when the scope is genuinely empty — so an empty diff is
|
|
2129
|
+
// authoritative, while an operational git error has already aborted the run.
|
|
2130
|
+
// (getChangedFiles, by contrast, swallows git errors and returns [], which
|
|
2131
|
+
// would let a failure masquerade as "no changes" and exit 0.)
|
|
2132
|
+
const isEmptyScope = !session.diff || session.diff.trim().length === 0;
|
|
2133
|
+
|
|
2134
|
+
if (isEmptyScope) {
|
|
2135
|
+
// Empty-scope guard, mirroring the web routes' rejectIfEmptyScope (which
|
|
2136
|
+
// returns 409). Headless has no HTTP response, so instead emit a normal-
|
|
2137
|
+
// shaped, completed run with zero suggestions: emitHeadlessResult then
|
|
2138
|
+
// produces the standard { mode, run, suggestions: [], count: 0 } envelope
|
|
2139
|
+
// and exits 0 (zero suggestions is still success). Skipping the analyzer
|
|
2140
|
+
// avoids spending provider tokens on a guaranteed-empty result.
|
|
2141
|
+
runId = await recordEmptyScopeRun(db, {
|
|
2142
|
+
reviewId: session.sessionId,
|
|
2143
|
+
reviewConfig,
|
|
2144
|
+
instructions,
|
|
2145
|
+
headSha: session.headSha
|
|
2146
|
+
});
|
|
2147
|
+
} else {
|
|
2148
|
+
// Scope is non-empty (diff present), so the analyzer needs the scope-aware
|
|
2149
|
+
// changed-file list for suggestion validation. Use the throwing variant:
|
|
2150
|
+
// since we KNOW the scope is non-empty, a [] from a swallowed git error
|
|
2151
|
+
// would be a lie — surface it as a non-zero exit instead.
|
|
2152
|
+
const changedFiles = await getChangedFiles(repoPath, {
|
|
2153
|
+
scopeStart: session.scopeStart,
|
|
2154
|
+
scopeEnd: session.scopeEnd,
|
|
2155
|
+
baseBranch: session.baseBranch || null,
|
|
2156
|
+
}, { throwOnError: true });
|
|
2157
|
+
|
|
2158
|
+
({ runId } = await runHeadlessAnalysis(db, config, {
|
|
2159
|
+
reviewId: session.sessionId,
|
|
2160
|
+
worktreePath: repoPath,
|
|
2161
|
+
prMetadata: localMetadata,
|
|
2162
|
+
changedFiles,
|
|
2163
|
+
repository,
|
|
2164
|
+
repoSettings,
|
|
2165
|
+
instructions,
|
|
2166
|
+
reviewConfig,
|
|
2167
|
+
providerOverrides,
|
|
2168
|
+
githubClient: undefined
|
|
2169
|
+
}));
|
|
2170
|
+
}
|
|
2171
|
+
} else {
|
|
2172
|
+
mode = 'pr';
|
|
2173
|
+
// Forward the session's rehydrated pool lifecycle so pool acquisition goes
|
|
2174
|
+
// through the instance whose in-memory tracker resetAndRehydrate() populated
|
|
2175
|
+
// (mirrors performHeadlessReview's externalPoolLifecycle convention).
|
|
2176
|
+
const prep = await preparePrHeadless(db, config, flags, prArgs, poolLifecycle);
|
|
2177
|
+
try {
|
|
2178
|
+
const repoInstructions = prep.repoSettings?.default_instructions || null;
|
|
2179
|
+
const instructions = { globalInstructions, repoInstructions, requestInstructions };
|
|
2180
|
+
({ runId } = await runHeadlessAnalysis(db, config, {
|
|
2181
|
+
reviewId: prep.review.id,
|
|
2182
|
+
worktreePath: prep.worktreePath,
|
|
2183
|
+
prMetadata: prep.storedPRData,
|
|
2184
|
+
changedFiles: null,
|
|
2185
|
+
repository: prep.repository,
|
|
2186
|
+
repoSettings: prep.repoSettings,
|
|
2187
|
+
instructions,
|
|
2188
|
+
reviewConfig: prep.reviewConfig,
|
|
2189
|
+
providerOverrides: prep.providerOverrides,
|
|
2190
|
+
githubClient: prep.githubClient
|
|
2191
|
+
}));
|
|
2192
|
+
} finally {
|
|
2193
|
+
// Release the pool worktree (mirror performHeadlessReview's finally) so a
|
|
2194
|
+
// pool slot isn't leaked per headless run. Local mode has no pool.
|
|
2195
|
+
if (prep.poolWorktreeId && prep.poolLifecycle) {
|
|
2196
|
+
try {
|
|
2197
|
+
await prep.poolLifecycle.releaseAfterHeadless(prep.poolWorktreeId);
|
|
2198
|
+
logger.info(`Released pool worktree ${prep.poolWorktreeId} after headless analysis`);
|
|
2199
|
+
} catch (releaseErr) {
|
|
2200
|
+
logger.error(`Failed to release pool worktree ${prep.poolWorktreeId}: ${releaseErr.message}`);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
await emitHeadlessResult(db, { runId, mode, flags });
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* Build the machine-readable JSON document for a completed headless run.
|
|
2211
|
+
*
|
|
2212
|
+
* Returns the run metadata (with `level_outcomes` / `levels_config` parsed) plus
|
|
2213
|
+
* the run's consolidated final suggestions (orchestrated layer; per-suggestion
|
|
2214
|
+
* `reasoning` parsed) and a count. Exported for unit testing.
|
|
2215
|
+
*
|
|
2216
|
+
* @param {Object} db - Database instance
|
|
2217
|
+
* @param {string} runId - Analysis run id
|
|
2218
|
+
* @param {'pr'|'local'} mode - Review mode (passthrough)
|
|
2219
|
+
* @returns {Promise<Object>} { ok, mode, run, suggestions, count }
|
|
2220
|
+
*/
|
|
2221
|
+
async function buildHeadlessJson(db, runId, mode) {
|
|
2222
|
+
const run = await new AnalysisRunRepository(db).getById(runId, { includeDiff: false });
|
|
2223
|
+
if (run) {
|
|
2224
|
+
run.level_outcomes = safeParseJson(run.level_outcomes);
|
|
2225
|
+
run.levels_config = safeParseJson(run.levels_config);
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
const rawSuggestions = await new CommentRepository(db).getFinalSuggestionsByRunId(runId);
|
|
2229
|
+
const suggestions = rawSuggestions.map(s => ({
|
|
2230
|
+
...s,
|
|
2231
|
+
reasoning: safeParseJson(s.reasoning)
|
|
2232
|
+
}));
|
|
2233
|
+
|
|
2234
|
+
return {
|
|
2235
|
+
// `ok` lets a machine consumer branch on success/failure with a single field;
|
|
2236
|
+
// see buildHeadlessErrorJson for the failure shape.
|
|
2237
|
+
ok: true,
|
|
2238
|
+
mode,
|
|
2239
|
+
run,
|
|
2240
|
+
suggestions,
|
|
2241
|
+
count: suggestions.length
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/**
|
|
2246
|
+
* Build the machine-readable JSON document for a FAILED headless run.
|
|
2247
|
+
*
|
|
2248
|
+
* Mirrors buildHeadlessJson's envelope so a --headless --json consumer parses one
|
|
2249
|
+
* stream and branches on `ok`: success → { ok: true, ... }, failure → this. The
|
|
2250
|
+
* process still exits non-zero, so exit-code-based consumers are unaffected.
|
|
2251
|
+
* Exported for unit testing.
|
|
2252
|
+
*
|
|
2253
|
+
* @param {Object} params
|
|
2254
|
+
* @param {'pr'|'local'} params.mode - Review mode (best-effort, derived from args)
|
|
2255
|
+
* @param {Error} params.error - The thrown error
|
|
2256
|
+
* @returns {Object} { ok: false, mode, error: { message } }
|
|
2257
|
+
*/
|
|
2258
|
+
function buildHeadlessErrorJson({ mode, error }) {
|
|
2259
|
+
return {
|
|
2260
|
+
ok: false,
|
|
2261
|
+
mode,
|
|
2262
|
+
error: {
|
|
2263
|
+
message: error && error.message ? error.message : String(error)
|
|
2264
|
+
}
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
/**
|
|
2269
|
+
* Emit the result of a headless run.
|
|
2270
|
+
*
|
|
2271
|
+
* In --json mode, writes the buildHeadlessJson document to stdout (via
|
|
2272
|
+
* process.stdout.write, since console.log is remapped to stderr in JSON mode).
|
|
2273
|
+
* Otherwise prints a human-readable summary to stdout. A successful run with zero
|
|
2274
|
+
* suggestions is still success — operational errors propagate to main()'s catch.
|
|
2275
|
+
*
|
|
2276
|
+
* @param {Object} db - Database instance
|
|
2277
|
+
* @param {Object} params
|
|
2278
|
+
* @param {string} params.runId - Analysis run id
|
|
2279
|
+
* @param {'pr'|'local'} params.mode - Review mode
|
|
2280
|
+
* @param {Object} params.flags - Parsed CLI flags
|
|
2281
|
+
*/
|
|
2282
|
+
async function emitHeadlessResult(db, { runId, mode, flags }) {
|
|
2283
|
+
const doc = await buildHeadlessJson(db, runId, mode);
|
|
2284
|
+
|
|
2285
|
+
if (flags.json) {
|
|
2286
|
+
process.stdout.write(JSON.stringify(doc, null, 2) + '\n');
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
const run = doc.run || {};
|
|
2291
|
+
const lines = [];
|
|
2292
|
+
lines.push('');
|
|
2293
|
+
lines.push('Headless analysis complete');
|
|
2294
|
+
lines.push(` Run ID: ${runId}`);
|
|
2295
|
+
lines.push(` Mode: ${mode}`);
|
|
2296
|
+
if (run.config_type === 'council' || run.provider === 'council') {
|
|
2297
|
+
lines.push(` Council: ${run.model || '(unknown)'}`);
|
|
2298
|
+
} else {
|
|
2299
|
+
lines.push(` Provider: ${run.provider || '(unknown)'}`);
|
|
2300
|
+
lines.push(` Model: ${run.model || '(unknown)'}`);
|
|
2301
|
+
}
|
|
2302
|
+
lines.push(` Config type: ${run.config_type || 'standard'}`);
|
|
2303
|
+
lines.push(` Status: ${run.status || '(unknown)'}`);
|
|
2304
|
+
if (run.summary) {
|
|
2305
|
+
lines.push('');
|
|
2306
|
+
lines.push('Summary:');
|
|
2307
|
+
lines.push(run.summary);
|
|
2308
|
+
}
|
|
2309
|
+
lines.push('');
|
|
2310
|
+
lines.push(`Suggestions: ${doc.count}`);
|
|
2311
|
+
for (const s of doc.suggestions) {
|
|
2312
|
+
const line = s.line_end && s.line_end !== s.line_start
|
|
2313
|
+
? `${s.line_start}-${s.line_end}`
|
|
2314
|
+
: `${s.line_start}`;
|
|
2315
|
+
const sev = s.severity ? `/${s.severity}` : '';
|
|
2316
|
+
lines.push(` ${s.file}:${line} [${s.type || 'comment'}${sev}] ${s.title || ''}`.trimEnd());
|
|
2317
|
+
}
|
|
2318
|
+
lines.push('');
|
|
2319
|
+
|
|
2320
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
2321
|
+
}
|
|
2322
|
+
|
|
1230
2323
|
/**
|
|
1231
2324
|
* Handle draft mode review workflow
|
|
1232
2325
|
* Runs AI analysis and submits draft review to GitHub without opening browser
|
|
@@ -1386,7 +2479,14 @@ function gracefulShutdown(signal) {
|
|
|
1386
2479
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
1387
2480
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
1388
2481
|
|
|
1389
|
-
// Handle uncaught exceptions
|
|
2482
|
+
// Handle uncaught exceptions.
|
|
2483
|
+
//
|
|
2484
|
+
// Note: in --headless --json mode, errors that ESCAPE main()'s try (floating
|
|
2485
|
+
// promises, timer/child-process callbacks) reach here and are reported as prose
|
|
2486
|
+
// on stderr, not the JSON failure envelope. The headless flow awaits its full
|
|
2487
|
+
// analysis, so this is a narrow gap; a JSON envelope here would need an
|
|
2488
|
+
// emit-once guard to avoid a second document on stdout during post-success
|
|
2489
|
+
// teardown.
|
|
1390
2490
|
process.on('uncaughtException', (error) => {
|
|
1391
2491
|
console.error('Uncaught exception:', error);
|
|
1392
2492
|
process.exit(1);
|
|
@@ -1402,4 +2502,4 @@ if (require.main === module) {
|
|
|
1402
2502
|
main();
|
|
1403
2503
|
}
|
|
1404
2504
|
|
|
1405
|
-
module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
|
|
2505
|
+
module.exports = { main, parseArgs, detectPRFromGitHubEnvironment, printCouncilList, runHeadlessAnalysis, recordEmptyScopeRun, buildHeadlessJson, buildHeadlessErrorJson, resolveCliInstructions };
|