@in-the-loop-labs/pair-review 4.0.0 → 4.1.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.
Files changed (43) hide show
  1. package/README.md +82 -7
  2. package/package.json +2 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +3 -4
  5. package/plugin-pair-loop/.claude-plugin/plugin.json +19 -0
  6. package/plugin-pair-loop/skills/loop/SKILL.md +233 -0
  7. package/public/js/index.js +87 -10
  8. package/public/js/local.js +19 -9
  9. package/public/js/pr.js +64 -36
  10. package/public/js/repo-links.js +11 -3
  11. package/public/js/utils/analyze-params.js +69 -0
  12. package/public/js/utils/provider-model.js +66 -1
  13. package/public/js/vendor/pierre-diffs-worker.js +16158 -0
  14. package/public/js/vendor/pierre-diffs.js +1880 -0
  15. package/public/local.html +1 -0
  16. package/public/pr.html +1 -0
  17. package/public/setup.html +35 -16
  18. package/src/ai/codex-provider.js +59 -11
  19. package/src/config.js +150 -28
  20. package/src/database.js +127 -3
  21. package/src/external/github-adapter.js +18 -3
  22. package/src/github/client.js +37 -0
  23. package/src/github/parser.js +41 -7
  24. package/src/interactive-analysis-config.js +2 -2
  25. package/src/links/repo-links.js +66 -28
  26. package/src/local-review.js +134 -5
  27. package/src/local-scope.js +38 -0
  28. package/src/main.js +199 -33
  29. package/src/routes/config.js +47 -12
  30. package/src/routes/external-comments.js +13 -1
  31. package/src/routes/github-collections.js +175 -13
  32. package/src/routes/local.js +11 -20
  33. package/src/routes/pr.js +63 -36
  34. package/src/routes/setup.js +63 -8
  35. package/src/routes/shared.js +85 -0
  36. package/src/routes/stack-analysis.js +39 -3
  37. package/src/server.js +74 -3
  38. package/src/setup/local-setup.js +23 -7
  39. package/src/setup/pr-setup.js +237 -39
  40. package/src/setup/stack-setup.js +7 -2
  41. package/src/single-port.js +73 -18
  42. package/src/utils/host-resolution.js +157 -0
  43. package/plugin-code-critic/skills/loop/SKILL.md +0 -373
@@ -11,7 +11,7 @@ const { fireHooks, hasHooks } = require('./hooks/hook-runner');
11
11
  const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('./hooks/payloads');
12
12
 
13
13
  const execAsync = promisify(exec);
14
- const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope } = require('./local-scope');
14
+ const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope, parseScopeArg } = require('./local-scope');
15
15
  const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
16
16
  const { resolveCouncilHandle } = require('./councils/resolve-council');
17
17
  const { prepareInteractiveAnalysisConfig } = require('./interactive-analysis-config');
@@ -21,6 +21,10 @@ const summaryGenerator = require('./ai/summary-generator');
21
21
  const tourGenerator = require('./ai/tour-generator');
22
22
  const { getShaAbbrevLength } = require('./git/sha-abbrev');
23
23
  const { GIT_DIFF_FLAGS, GIT_DIFF_FLAGS_ARRAY } = require('./git/diff-flags');
24
+ // Namespace import (not destructured) so detectBaseBranch is resolved off the
25
+ // module object at call time — this keeps it interceptable by vi.spyOn on the
26
+ // base-branch module in tests, unlike a destructured binding captured at load.
27
+ const baseBranchModule = require('./git/base-branch');
24
28
  const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({ default: open }) => open(...args));
25
29
 
26
30
  // Design note: This module uses execSync for git commands despite async function signatures.
@@ -694,6 +698,112 @@ async function generateLocalDiff(repoPath, options = {}) {
694
698
  };
695
699
  }
696
700
 
701
+ /**
702
+ * Resolve the effective scope and base branch for a local review session,
703
+ * applying an explicit `--scope`/`--base` (or delegated query) override on top
704
+ * of the persisted/default scope.
705
+ *
706
+ * Shared by the CLI seam (setupLocalReviewSession) and the web setup seam
707
+ * (src/setup/local-setup.js) so a scoped launch behaves identically whether it
708
+ * cold-starts a server or is delegated to a running one. Callers MUST have
709
+ * validated flags.scope/flags.base first (parseScopeArg + branch-name regex +
710
+ * "base requires branch scope"); an invalid flags.scope is treated as "no
711
+ * override" here rather than throwing.
712
+ *
713
+ * Precedence — scope: explicit override > persisted review scope > DEFAULT_SCOPE.
714
+ * Precedence — base: explicit --base > persisted local_base_branch >
715
+ * detectBaseBranch. The base is nulled for any non-branch scope so a stale value
716
+ * from a previously branch-scoped session never leaks into the diff/persist/return.
717
+ *
718
+ * @param {Object} params
719
+ * @param {Object|null} params.existingReview - Persisted review row, or null for a fresh session
720
+ * @param {Object} [params.flags] - { scope?, base? }
721
+ * @param {string} params.repoPath
722
+ * @param {string} params.branch - Current branch name
723
+ * @param {string} params.repository - owner/repo (for host binding / PR base lookup)
724
+ * @param {Object} params.config
725
+ * @returns {Promise<{scopeStart: string, scopeEnd: string, baseBranch: string|null, scopeOverridden: boolean}>}
726
+ */
727
+ async function resolveScopeAndBase({ existingReview, flags = {}, repoPath, branch, repository, config }) {
728
+ const scopeOverride = flags.scope ? parseScopeArg(flags.scope) : null;
729
+ let scopeStart, scopeEnd;
730
+ if (scopeOverride) {
731
+ ({ start: scopeStart, end: scopeEnd } = scopeOverride);
732
+ } else if (existingReview) {
733
+ ({ start: scopeStart, end: scopeEnd } = reviewScope(existingReview));
734
+ } else {
735
+ ({ start: scopeStart, end: scopeEnd } = DEFAULT_SCOPE);
736
+ }
737
+
738
+ let baseBranch = existingReview?.local_base_branch || null;
739
+ if (scopeOverride && includesBranch(scopeStart)) {
740
+ if (flags.base) {
741
+ baseBranch = flags.base;
742
+ } else if (!baseBranch) {
743
+ const branchBinding = repository ? resolveHostBinding(repository, config) : null;
744
+ const token = branchBinding?.token || getGitHubToken(config);
745
+ const detection = await baseBranchModule.detectBaseBranch(repoPath, branch, {
746
+ repository,
747
+ enableGraphite: config?.enable_graphite === true,
748
+ _deps: (token || branchBinding) ? {
749
+ getGitHubToken: () => token || '',
750
+ getHostBinding: () => branchBinding || null
751
+ } : undefined
752
+ });
753
+ if (!detection) {
754
+ throw new Error(
755
+ `Could not detect a base branch for scope '${scopeStart}..${scopeEnd}'. ` +
756
+ 'Pass --base <branch> to specify one explicitly.'
757
+ );
758
+ }
759
+ baseBranch = detection.baseBranch;
760
+ }
761
+ }
762
+
763
+ // A non-branch scope has no base branch. Drop any value seeded from a
764
+ // previously branch-scoped session so the stale base never reaches the diff,
765
+ // the persisted row, or the returned session — matching the web set-scope
766
+ // route, which persists null when leaving branch scope.
767
+ if (!includesBranch(scopeStart)) {
768
+ baseBranch = null;
769
+ }
770
+
771
+ return { scopeStart, scopeEnd, baseBranch, scopeOverridden: !!scopeOverride };
772
+ }
773
+
774
+ /**
775
+ * Persist an explicitly-overridden scope (and its resolved base) and auto-name
776
+ * the review when a branch scope is newly applied to an unnamed review. Call
777
+ * ONLY after generateScopedDiff succeeds, so a scope whose diff fails (e.g. a
778
+ * bad --base) never sticks. Shared by the CLI and web setup seams. Caller gates
779
+ * this on `scopeOverridden` — it always writes when invoked.
780
+ *
781
+ * @param {Object} params
782
+ * @param {Object} params.reviewRepo - ReviewRepository instance
783
+ * @param {number} params.sessionId
784
+ * @param {Object|null} params.existingReview
785
+ * @param {string} params.scopeStart
786
+ * @param {string} params.scopeEnd
787
+ * @param {string|null} params.baseBranch
788
+ * @param {string} params.branch - Current branch (stored as head branch for branch scopes)
789
+ * @param {string} params.repoPath
790
+ */
791
+ async function persistScopeSelection({ reviewRepo, sessionId, existingReview, scopeStart, scopeEnd, baseBranch, branch, repoPath }) {
792
+ await reviewRepo.updateLocalScope(sessionId, scopeStart, scopeEnd, baseBranch, branch);
793
+
794
+ // Keep CLI and web set-scope in sync: when a branch scope is newly applied to
795
+ // an as-yet-unnamed review, auto-name it from the first commit subject.
796
+ // Mirrors routes/local.js set-scope. A fresh session has no existing review,
797
+ // which counts as both unnamed and previously non-branch.
798
+ const oldScopeStart = existingReview ? reviewScope(existingReview).start : DEFAULT_SCOPE.start;
799
+ if (!existingReview?.name && includesBranch(scopeStart) && !includesBranch(oldScopeStart) && baseBranch) {
800
+ const firstSubject = await module.exports.getFirstCommitSubject(repoPath, baseBranch);
801
+ if (firstSubject) {
802
+ await reviewRepo.updateReview(sessionId, { name: firstSubject.slice(0, 200) });
803
+ }
804
+ }
805
+ }
806
+
697
807
  /**
698
808
  * Set up a local review session: resolve git state, persist the diff,
699
809
  * and enqueue the background summary job. Caller is responsible for
@@ -777,7 +887,11 @@ async function setupLocalReviewSession({ db, config, repoPath, flags = {}, start
777
887
  console.log(`Created new review session (ID: ${sessionId})`);
778
888
  }
779
889
 
780
- const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
890
+ // Resolve scope + base (explicit --scope/--base override > persisted > default)
891
+ // via the shared helper so the CLI and delegated web-setup seam stay identical.
892
+ const { scopeStart, scopeEnd, baseBranch, scopeOverridden } = await resolveScopeAndBase({
893
+ existingReview, flags, repoPath, branch, repository, config
894
+ });
781
895
 
782
896
  const hookEvent = existingReview ? 'review.loaded' : 'review.started';
783
897
  if (hasHooks(hookEvent, config)) {
@@ -790,11 +904,20 @@ async function setupLocalReviewSession({ db, config, repoPath, flags = {}, start
790
904
  fireHooks(hookEvent, payload, config);
791
905
  }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
792
906
  }
793
- const baseBranch = existingReview?.local_base_branch || null;
794
907
 
795
908
  console.log(`Generating diff for scope: ${scopeLabel(scopeStart, scopeEnd)}...`);
796
909
  const { diff, stats } = await module.exports.generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch);
797
910
 
911
+ // Persist an explicitly-requested scope (and its resolved base) so the web UI
912
+ // later opens with the same scope. Only after generateScopedDiff succeeds, so a
913
+ // scope whose diff fails (e.g. a bad --base) never sticks. Never touch persisted
914
+ // scope when no override was supplied.
915
+ if (scopeOverridden) {
916
+ await persistScopeSelection({
917
+ reviewRepo, sessionId, existingReview, scopeStart, scopeEnd, baseBranch, branch, repoPath
918
+ });
919
+ }
920
+
798
921
  let branchInfo = null;
799
922
  if (!includesBranch(scopeStart)) {
800
923
  const untrackedFiles = await getUntrackedFiles(repoPath);
@@ -932,6 +1055,11 @@ async function handleLocalReview(targetPath, flags = {}) {
932
1055
  if (flags.model && !flags.council) {
933
1056
  process.env.PAIR_REVIEW_MODEL = flags.model;
934
1057
  }
1058
+ // Mirror --provider too so the local web/UI routes (which read
1059
+ // PAIR_REVIEW_PROVIDER via getProvider(req)) honor it, matching --model above.
1060
+ if (flags.provider) {
1061
+ process.env.PAIR_REVIEW_PROVIDER = flags.provider;
1062
+ }
935
1063
 
936
1064
  console.log('Starting server...');
937
1065
  const port = await startServer(db);
@@ -1085,14 +1213,13 @@ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
1085
1213
  if (untrackedFiles && untrackedFiles.length > 0) return null;
1086
1214
 
1087
1215
  try {
1088
- const { detectBaseBranch } = require('./git/base-branch');
1089
1216
  const depsOverride = githubToken || hostBinding
1090
1217
  ? {
1091
1218
  getGitHubToken: () => githubToken || '',
1092
1219
  getHostBinding: () => hostBinding || null
1093
1220
  }
1094
1221
  : undefined;
1095
- const detection = await detectBaseBranch(repoPath, branch, {
1222
+ const detection = await baseBranchModule.detectBaseBranch(repoPath, branch, {
1096
1223
  repository,
1097
1224
  enableGraphite,
1098
1225
  _deps: depsOverride
@@ -1117,6 +1244,8 @@ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
1117
1244
  module.exports = {
1118
1245
  handleLocalReview,
1119
1246
  setupLocalReviewSession,
1247
+ resolveScopeAndBase,
1248
+ persistScopeSelection,
1120
1249
  findGitRoot,
1121
1250
  findMainGitRoot,
1122
1251
  getHeadSha,
@@ -15,6 +15,42 @@ function isValidScope(start, end) {
15
15
  return si !== -1 && ei !== -1 && si <= ei && si <= UNSTAGED_INDEX && ei >= UNSTAGED_INDEX;
16
16
  }
17
17
 
18
+ /**
19
+ * The full set of valid scope ranges, formatted as `start..end` strings.
20
+ * Computed from STOPS + isValidScope so it stays the single source of truth.
21
+ * Ordered by STOPS position (branch-first).
22
+ * @type {string[]}
23
+ */
24
+ const VALID_SCOPE_RANGES = (() => {
25
+ const ranges = [];
26
+ for (const start of STOPS) {
27
+ for (const end of STOPS) {
28
+ if (isValidScope(start, end)) ranges.push(`${start}..${end}`);
29
+ }
30
+ }
31
+ return ranges;
32
+ })();
33
+
34
+ /**
35
+ * Parse a `--scope` CLI argument of the form `<start>..<end>` into a scope
36
+ * object. Splits on `..`, trims each side, and delegates validation to
37
+ * isValidScope (the single source of truth). Single tokens, missing `..`,
38
+ * unknown stops, non-contiguous ranges, ranges excluding 'unstaged', and
39
+ * reversed ranges all return null.
40
+ *
41
+ * @param {string} value - Raw CLI value (e.g. 'branch..untracked')
42
+ * @returns {{ start: string, end: string }|null} Parsed scope, or null if invalid
43
+ */
44
+ function parseScopeArg(value) {
45
+ if (typeof value !== 'string') return null;
46
+ const parts = value.split('..');
47
+ if (parts.length !== 2) return null;
48
+ const start = parts[0].trim();
49
+ const end = parts[1].trim();
50
+ if (!isValidScope(start, end)) return null;
51
+ return { start, end };
52
+ }
53
+
18
54
  function normalizeScope(start, end) {
19
55
  if (isValidScope(start, end)) return { start, end };
20
56
  const si = STOPS.indexOf(start);
@@ -130,7 +166,9 @@ function scopeGitHints(start, end, baseBranch) {
130
166
  const LocalScope = {
131
167
  STOPS,
132
168
  DEFAULT_SCOPE,
169
+ VALID_SCOPE_RANGES,
133
170
  isValidScope,
171
+ parseScopeArg,
134
172
  normalizeScope,
135
173
  reviewScope,
136
174
  scopeIncludes,