@in-the-loop-labs/pair-review 3.8.0 → 3.9.1

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.
@@ -0,0 +1,152 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared helpers for threading per-run CLI instructions into analysis.
4
+ *
5
+ * `resolveCliInstructions` reads `--instructions` / `--instructions-file`.
6
+ *
7
+ * `buildInteractiveAnalysisConfig` / `prepareInteractiveAnalysisConfig` bridge the
8
+ * CLI to the browser-side auto-analyze: interactive `--ai` / `--council` runs open
9
+ * the web UI and trigger analysis via the `?analyze=true` URL, which has no slot
10
+ * for custom instructions. To honor `--instructions` there too (instead of
11
+ * silently dropping it), the CLI resolves the full review config + instructions
12
+ * and threads a short id through the URL as `analysisConfigId`. The PR/local
13
+ * browser code already resolves that id before starting analysis (see
14
+ * `_fetchAutoAnalysisConfigFromUrl` in public/js/pr.js and the local auto-analyze
15
+ * in public/js/local.js).
16
+ *
17
+ * The resolution is split so the same config object serves two storage targets:
18
+ * - `buildInteractiveAnalysisConfig` — PURE, returns the resolved config object
19
+ * (no storage). Reused by the delegation path, which POSTs it to a server
20
+ * running in ANOTHER process (the in-memory store below is per-process).
21
+ * - `prepareInteractiveAnalysisConfig` — the cold-start wrapper that stashes the
22
+ * built config in the in-memory bulk-analysis-config store (same process that
23
+ * then starts the server) and returns the id.
24
+ *
25
+ * Living in its own module keeps both CLI entry points — `handlePullRequest`
26
+ * (src/main.js) and `handleLocalReview` (src/local-review.js) — able to import
27
+ * it without a require cycle through main.js.
28
+ */
29
+
30
+ const fs = require('fs');
31
+ const { resolveReviewConfig } = require('./review-config');
32
+ const { createBulkAnalysisConfig } = require('./routes/bulk-analysis-configs');
33
+
34
+ const MAX_INSTRUCTIONS_CHARS = 5000;
35
+
36
+ /**
37
+ * Resolve per-run custom instructions ("requestInstructions") from CLI flags.
38
+ *
39
+ * Precedence: `--instructions <text>` is used directly; otherwise
40
+ * `--instructions-file <path>` is read from disk. The result is trimmed and
41
+ * capped at 5000 chars (parity with the web analyze routes). Returns `null`
42
+ * when neither flag is supplied.
43
+ *
44
+ * @param {Object} flags - Parsed CLI flags
45
+ * @returns {Promise<string|null>}
46
+ * @throws {Error} On unreadable instructions file or when the text exceeds the cap.
47
+ */
48
+ async function resolveCliInstructions(flags) {
49
+ let text = null;
50
+
51
+ if (typeof flags.instructions === 'string') {
52
+ text = flags.instructions;
53
+ } else if (typeof flags.instructionsFile === 'string') {
54
+ try {
55
+ text = await fs.promises.readFile(flags.instructionsFile, 'utf8');
56
+ } catch (err) {
57
+ throw new Error(`Failed to read --instructions-file "${flags.instructionsFile}": ${err.message}`);
58
+ }
59
+ }
60
+
61
+ if (text == null) return null;
62
+
63
+ text = text.trim();
64
+ if (text.length === 0) return null;
65
+
66
+ if (text.length > MAX_INSTRUCTIONS_CHARS) {
67
+ throw new Error(
68
+ `Custom instructions exceed the ${MAX_INSTRUCTIONS_CHARS}-character limit (got ${text.length}).`
69
+ );
70
+ }
71
+
72
+ return text;
73
+ }
74
+
75
+ /**
76
+ * For an interactive `--ai` / `--council` run that ALSO carries
77
+ * `--instructions[-file]`, resolve the full review config (single provider/model
78
+ * or council) plus the instruction text into the analysis-config object the
79
+ * browser-side analyze code consumes. PURE: it performs no storage, so it serves
80
+ * both the in-process cold-start path (via `prepareInteractiveAnalysisConfig`)
81
+ * and the cross-process delegation path (which POSTs the object to the running
82
+ * server — see `storeAnalysisConfigRemote` in src/single-port.js).
83
+ *
84
+ * Returns `null` when no instructions were supplied, so callers keep their prior
85
+ * URL shape (bare `?analyze=true` + optional `&council=`) unchanged.
86
+ *
87
+ * The returned config is shape-compatible with what the browser-side analyze code
88
+ * consumes: a single pick becomes `{ provider, model, customInstructions }`; a
89
+ * council becomes an inline snapshot `{ isCouncil, configType, councilConfig,
90
+ * councilName, customInstructions }` (the snapshot forces the analysis route to
91
+ * use the exact resolved council rather than re-fetching by id).
92
+ *
93
+ * @param {Object} params
94
+ * @param {Object} params.db - Database instance
95
+ * @param {Object} params.config - Loaded global config
96
+ * @param {Object} params.flags - Parsed CLI flags (council/model/instructions)
97
+ * @param {string} params.repository - owner/repo (for repo-default resolution)
98
+ * @returns {Promise<Object|null>} The analysisConfig object, or null when no instructions.
99
+ */
100
+ async function buildInteractiveAnalysisConfig({ db, config, flags, repository }) {
101
+ const requestInstructions = await resolveCliInstructions(flags);
102
+ if (!requestInstructions) return null;
103
+
104
+ const reviewConfig = await resolveReviewConfig(
105
+ db,
106
+ repository,
107
+ { council: flags.council, model: flags.model },
108
+ config
109
+ );
110
+
111
+ return reviewConfig.type === 'council'
112
+ ? {
113
+ isCouncil: true,
114
+ configType: reviewConfig.configType,
115
+ councilConfig: reviewConfig.councilConfig,
116
+ councilName: reviewConfig.council.name || null,
117
+ customInstructions: requestInstructions
118
+ }
119
+ : {
120
+ provider: reviewConfig.provider,
121
+ model: reviewConfig.model,
122
+ customInstructions: requestInstructions
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Thin in-process wrapper around `buildInteractiveAnalysisConfig`: resolve the
128
+ * analysis config and, when present, stash it in the in-memory
129
+ * bulk-analysis-config store, returning the id to thread through the browser URL
130
+ * as `analysisConfigId`. Used by the cold-start handlers (`handlePullRequest`,
131
+ * `handleLocalReview`) that go on to start the server in THIS process, so the
132
+ * in-process store is the one the browser tab will read from.
133
+ *
134
+ * Returns `null` when no instructions were supplied (callers keep their prior URL
135
+ * shape unchanged).
136
+ *
137
+ * @param {Object} params - See `buildInteractiveAnalysisConfig`.
138
+ * @returns {Promise<string|null>} The analysisConfigId, or null when no instructions.
139
+ */
140
+ async function prepareInteractiveAnalysisConfig({ db, config, flags, repository }) {
141
+ const analysisConfig = await buildInteractiveAnalysisConfig({ db, config, flags, repository });
142
+ if (!analysisConfig) return null;
143
+
144
+ const { id } = createBulkAnalysisConfig(analysisConfig);
145
+ return id;
146
+ }
147
+
148
+ module.exports = {
149
+ resolveCliInstructions,
150
+ buildInteractiveAnalysisConfig,
151
+ prepareInteractiveAnalysisConfig
152
+ };
@@ -14,6 +14,7 @@ const execAsync = promisify(exec);
14
14
  const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope } = require('./local-scope');
15
15
  const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
16
16
  const { resolveCouncilHandle } = require('./councils/resolve-council');
17
+ const { prepareInteractiveAnalysisConfig } = require('./interactive-analysis-config');
17
18
  const { startServer } = require('./server');
18
19
  const { localReviewDiffs } = require('./routes/shared');
19
20
  const summaryGenerator = require('./ai/summary-generator');
@@ -703,9 +704,14 @@ async function generateLocalDiff(repoPath, options = {}) {
703
704
  * @param {Object} params.config - Loaded config
704
705
  * @param {string} params.repoPath - Path to the working tree (already validated)
705
706
  * @param {Object} [params.flags] - CLI flags / options
707
+ * @param {boolean} [params.startBackgroundJobs=true] - When false, skip the
708
+ * fire-and-forget summary/tour generation jobs. The interactive UI wants them
709
+ * (they populate the review while the human reads the diff), but a one-shot
710
+ * headless run does not: they would spend provider budget after the JSON
711
+ * result is already printed and keep the event loop alive (CLI hang).
706
712
  * @returns {Promise<{sessionId: number, repoPath: string, branch: string, repository: string, headSha: string, diff: string, stats: Object, digest: string|null, branchInfo: Object|null, scopeStart: string, scopeEnd: string, baseBranch: string|null}>}
707
713
  */
708
- async function setupLocalReviewSession({ db, config, repoPath, flags = {} }) {
714
+ async function setupLocalReviewSession({ db, config, repoPath, flags = {}, startBackgroundJobs = true }) {
709
715
  const headSha = await module.exports.getHeadSha(repoPath);
710
716
  console.log(`HEAD SHA: ${headSha}`);
711
717
 
@@ -837,25 +843,30 @@ async function setupLocalReviewSession({ db, config, repoPath, flags = {} }) {
837
843
  logger.warn(`Could not persist diff to database: ${persistError.message}`);
838
844
  }
839
845
 
840
- summaryGenerator.kickOffSummaryJob({
841
- db,
842
- config,
843
- reviewId: sessionId,
844
- diffText: diff,
845
- worktreePath: repoPath,
846
- reviewContext: { prTitle: branch },
847
- trigger: 'auto'
848
- })?.catch((err) => logger.warn(`Hunk summary job failed for review ${sessionId}: ${err.message}`));
849
-
850
- tourGenerator.kickOffTourJob({
851
- db,
852
- config,
853
- reviewId: sessionId,
854
- diffText: diff,
855
- worktreePath: repoPath,
856
- reviewContext: { prTitle: branch },
857
- trigger: 'auto'
858
- })?.catch((err) => logger.warn(`Tour job failed for review ${sessionId}: ${err.message}`));
846
+ // Fire-and-forget summary/tour generation. Skipped for headless one-shot runs
847
+ // (startBackgroundJobs:false) — they would spend provider budget after the
848
+ // result is reported and keep the event loop alive, hanging the CLI.
849
+ if (startBackgroundJobs) {
850
+ summaryGenerator.kickOffSummaryJob({
851
+ db,
852
+ config,
853
+ reviewId: sessionId,
854
+ diffText: diff,
855
+ worktreePath: repoPath,
856
+ reviewContext: { prTitle: branch },
857
+ trigger: 'auto'
858
+ })?.catch((err) => logger.warn(`Hunk summary job failed for review ${sessionId}: ${err.message}`));
859
+
860
+ tourGenerator.kickOffTourJob({
861
+ db,
862
+ config,
863
+ reviewId: sessionId,
864
+ diffText: diff,
865
+ worktreePath: repoPath,
866
+ reviewContext: { prTitle: branch },
867
+ trigger: 'auto'
868
+ })?.catch((err) => logger.warn(`Tour job failed for review ${sessionId}: ${err.message}`));
869
+ }
859
870
 
860
871
  return {
861
872
  sessionId,
@@ -928,7 +939,18 @@ async function handleLocalReview(targetPath, flags = {}) {
928
939
  let url = `http://localhost:${port}/local/${session.sessionId}`;
929
940
  const shouldAnalyze = flags.ai || flags.council;
930
941
  if (shouldAnalyze) url += '?analyze=true';
931
- if (councilSelection) url += `&council=${councilSelection.id}`;
942
+ // When the run carries --instructions, stash the resolved analysis config and
943
+ // thread its id so the browser-side auto-analyze honors the instructions
944
+ // instead of silently dropping them (mirrors handlePullRequest). The id
945
+ // encodes the council selection, so prefer it over the bare &council= param.
946
+ const analysisConfigId = shouldAnalyze
947
+ ? await prepareInteractiveAnalysisConfig({ db, config, flags, repository: session.repository })
948
+ : null;
949
+ if (analysisConfigId) {
950
+ url += `&analysisConfigId=${analysisConfigId}`;
951
+ } else if (councilSelection) {
952
+ url += `&council=${councilSelection.id}`;
953
+ }
932
954
  console.log(`\nOpening browser to: ${url}`);
933
955
  await open(url);
934
956