happy-stacks 0.3.0 → 0.5.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.
Files changed (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -1,13 +1,28 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createWriteStream } from 'node:fs';
3
+
4
+ function nextLineBreakIndex(s) {
5
+ const n = s.indexOf('\n');
6
+ const r = s.indexOf('\r');
7
+ if (n < 0) return r;
8
+ if (r < 0) return n;
9
+ return Math.min(n, r);
10
+ }
11
+
12
+ function consumeLineBreak(buf) {
13
+ if (buf.startsWith('\r\n')) return buf.slice(2);
14
+ if (buf.startsWith('\n') || buf.startsWith('\r')) return buf.slice(1);
15
+ return buf;
16
+ }
2
17
 
3
18
  function writeWithPrefix(stream, prefix, bufState, chunk) {
4
19
  const s = chunk.toString();
5
20
  bufState.buf += s;
6
21
  while (true) {
7
- const idx = bufState.buf.indexOf('\n');
22
+ const idx = nextLineBreakIndex(bufState.buf);
8
23
  if (idx < 0) break;
9
24
  const line = bufState.buf.slice(0, idx);
10
- bufState.buf = bufState.buf.slice(idx + 1);
25
+ bufState.buf = consumeLineBreak(bufState.buf.slice(idx));
11
26
  stream.write(`${prefix}${line}\n`);
12
27
  }
13
28
  }
@@ -66,9 +81,25 @@ export function killProcessTree(child, signal) {
66
81
  }
67
82
 
68
83
  export async function run(cmd, args, options = {}) {
69
- const { timeoutMs, ...spawnOptions } = options ?? {};
84
+ const { timeoutMs, input, ...spawnOptions } = options ?? {};
70
85
  await new Promise((resolvePromise, rejectPromise) => {
71
- const proc = spawn(cmd, args, { stdio: 'inherit', shell: false, ...spawnOptions });
86
+ const baseStdio = spawnOptions.stdio ?? 'inherit';
87
+ const stdio =
88
+ input != null
89
+ ? Array.isArray(baseStdio)
90
+ ? ['pipe', baseStdio[1] ?? 'inherit', baseStdio[2] ?? 'inherit']
91
+ : ['pipe', baseStdio, baseStdio]
92
+ : baseStdio;
93
+
94
+ const proc = spawn(cmd, args, { stdio, shell: false, ...spawnOptions });
95
+ if (input != null && proc.stdin) {
96
+ try {
97
+ proc.stdin.write(String(input));
98
+ proc.stdin.end();
99
+ } catch {
100
+ // ignore
101
+ }
102
+ }
72
103
  const t =
73
104
  Number.isFinite(timeoutMs) && timeoutMs > 0
74
105
  ? setTimeout(() => {
@@ -133,3 +164,104 @@ export async function runCapture(cmd, args, options = {}) {
133
164
  });
134
165
  }
135
166
 
167
+ export async function runCaptureResult(cmd, args, options = {}) {
168
+ const { timeoutMs, streamLabel, teeFile, teeLabel, ...spawnOptions } = options ?? {};
169
+ const startedAt = Date.now();
170
+ return await new Promise((resolvePromise) => {
171
+ const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...spawnOptions });
172
+ let out = '';
173
+ let err = '';
174
+ const label = String(streamLabel ?? '').trim();
175
+ const shouldStream = Boolean(label);
176
+ const outState = { buf: '' };
177
+ const errState = { buf: '' };
178
+ const prefix = shouldStream ? `[${label}] ` : '';
179
+
180
+ const teePath = String(teeFile ?? '').trim();
181
+ const shouldTee = Boolean(teePath);
182
+ const teeOutState = { buf: '' };
183
+ const teeErrState = { buf: '' };
184
+ const teePrefix = (() => {
185
+ const t = String(teeLabel ?? '').trim();
186
+ if (t) return `[${t}] `;
187
+ if (label) return `[${label}] `;
188
+ return '';
189
+ })();
190
+ const teeStream = shouldTee ? createWriteStream(teePath, { flags: 'a' }) : null;
191
+
192
+ function resolveWith(res) {
193
+ if (shouldStream) {
194
+ flushPrefixed(process.stdout, prefix, outState);
195
+ flushPrefixed(process.stderr, prefix, errState);
196
+ }
197
+ if (shouldTee && teeStream) {
198
+ flushPrefixed(teeStream, teePrefix, teeOutState);
199
+ flushPrefixed(teeStream, teePrefix, teeErrState);
200
+ try {
201
+ teeStream.end();
202
+ } catch {
203
+ // ignore
204
+ }
205
+ }
206
+ resolvePromise(res);
207
+ } const t =
208
+ Number.isFinite(timeoutMs) && timeoutMs > 0
209
+ ? setTimeout(() => {
210
+ try {
211
+ proc.kill('SIGKILL');
212
+ } catch {
213
+ // ignore
214
+ }
215
+ resolveWith({
216
+ ok: false,
217
+ exitCode: null,
218
+ signal: null,
219
+ out,
220
+ err,
221
+ timedOut: true,
222
+ startedAt,
223
+ finishedAt: Date.now(),
224
+ durationMs: Date.now() - startedAt,
225
+ });
226
+ }, timeoutMs)
227
+ : null;
228
+ proc.stdout?.on('data', (d) => {
229
+ out += d.toString();
230
+ if (shouldStream) writeWithPrefix(process.stdout, prefix, outState, d);
231
+ if (shouldTee && teeStream) writeWithPrefix(teeStream, teePrefix, teeOutState, d);
232
+ });
233
+ proc.stderr?.on('data', (d) => {
234
+ err += d.toString();
235
+ if (shouldStream) writeWithPrefix(process.stderr, prefix, errState, d);
236
+ if (shouldTee && teeStream) writeWithPrefix(teeStream, teePrefix, teeErrState, d);
237
+ });
238
+ proc.on('error', (e) => {
239
+ if (t) clearTimeout(t);
240
+ resolveWith({
241
+ ok: false,
242
+ exitCode: null,
243
+ signal: null,
244
+ out,
245
+ err: err + (err.endsWith('\n') || !err ? '' : '\n') + String(e) + '\n',
246
+ timedOut: false,
247
+ startedAt,
248
+ finishedAt: Date.now(),
249
+ durationMs: Date.now() - startedAt,
250
+ });
251
+ });
252
+ proc.on('close', (code, signal) => {
253
+ if (t) clearTimeout(t);
254
+ resolveWith({
255
+ ok: code === 0,
256
+ exitCode: code,
257
+ signal: signal ?? null,
258
+ out,
259
+ err,
260
+ timedOut: false,
261
+ startedAt,
262
+ finishedAt: Date.now(),
263
+ durationMs: Date.now() - startedAt,
264
+ });
265
+ });
266
+ });
267
+ }
@@ -0,0 +1,77 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { runCaptureResult } from './proc.mjs';
8
+
9
+ test('runCaptureResult captures stdout/stderr', async () => {
10
+ const res = await runCaptureResult(process.execPath, ['-e', 'console.log("hello"); console.error("oops")'], {
11
+ env: process.env,
12
+ });
13
+ assert.equal(res.ok, true);
14
+ assert.equal(res.exitCode, 0);
15
+ assert.match(res.out, /hello/);
16
+ assert.match(res.err, /oops/);
17
+ });
18
+
19
+ test('runCaptureResult streams output when streamLabel is set (without affecting captured output)', async () => {
20
+ const stdoutWrites = [];
21
+ const stderrWrites = [];
22
+
23
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
24
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
25
+
26
+ // Capture streaming output without polluting the test runner output.
27
+ // eslint-disable-next-line no-console
28
+ process.stdout.write = (chunk) => {
29
+ stdoutWrites.push(String(chunk));
30
+ return true;
31
+ };
32
+ // eslint-disable-next-line no-console
33
+ process.stderr.write = (chunk) => {
34
+ stderrWrites.push(String(chunk));
35
+ return true;
36
+ };
37
+
38
+ try {
39
+ const res = await runCaptureResult(process.execPath, ['-e', 'console.log("hello"); console.error("oops")'], {
40
+ env: process.env,
41
+ streamLabel: 'proc-test',
42
+ });
43
+ assert.equal(res.ok, true);
44
+ assert.equal(res.exitCode, 0);
45
+ assert.match(res.out, /hello/);
46
+ assert.match(res.err, /oops/);
47
+
48
+ const streamedOut = stdoutWrites.join('');
49
+ const streamedErr = stderrWrites.join('');
50
+ assert.match(streamedOut, /\[proc-test\] hello/);
51
+ assert.match(streamedErr, /\[proc-test\] oops/);
52
+ } finally {
53
+ process.stdout.write = origStdoutWrite;
54
+ process.stderr.write = origStderrWrite;
55
+ }
56
+ });
57
+
58
+ test('runCaptureResult can tee streamed output to a file', async () => {
59
+ const teeFile = join(tmpdir(), `happy-proc-tee-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
60
+ try {
61
+ const res = await runCaptureResult(process.execPath, ['-e', 'console.log("hello"); console.error("oops")'], {
62
+ env: process.env,
63
+ teeFile,
64
+ teeLabel: 'tee-test',
65
+ });
66
+ assert.equal(res.ok, true);
67
+ const raw = readFileSync(teeFile, 'utf-8');
68
+ assert.match(raw, /\[tee-test\] hello/);
69
+ assert.match(raw, /\[tee-test\] oops/);
70
+ } finally {
71
+ try {
72
+ rmSync(teeFile, { force: true });
73
+ } catch {
74
+ // ignore
75
+ }
76
+ }
77
+ });
@@ -0,0 +1,74 @@
1
+ import { inferRemoteNameForOwner, parseGithubOwner } from '../git/worktrees.mjs';
2
+ import { gitCapture, gitOk, normalizeRemoteName, resolveRemoteDefaultBranch, ensureRemoteRefAvailable } from '../git/git.mjs';
3
+
4
+ async function currentBranchName({ cwd }) {
5
+ const branch = (await gitCapture({ cwd, args: ['branch', '--show-current'] }).catch(() => '')).trim();
6
+ return branch;
7
+ }
8
+
9
+ function branchOwnerPrefix(branch) {
10
+ const b = String(branch ?? '').trim();
11
+ if (!b || !b.includes('/')) return '';
12
+ return b.split('/')[0] ?? '';
13
+ }
14
+
15
+ async function inferRemoteFromBranchOwner({ cwd }) {
16
+ const branch = await currentBranchName({ cwd });
17
+ const owner = branchOwnerPrefix(branch);
18
+ if (!owner) return '';
19
+
20
+ // Confirm this "owner" is plausible (matches at least one remote's GitHub owner).
21
+ for (const remoteName of ['upstream', 'origin', 'fork']) {
22
+ try {
23
+ const url = (await gitCapture({ cwd, args: ['remote', 'get-url', remoteName] })).trim();
24
+ const parsedOwner = parseGithubOwner(url);
25
+ if (parsedOwner && parsedOwner === owner) {
26
+ return remoteName;
27
+ }
28
+ } catch {
29
+ // ignore
30
+ }
31
+ }
32
+
33
+ // Fall back to the generic inference helper (it checks remotes in priority order).
34
+ return await inferRemoteNameForOwner({ repoDir: cwd, owner });
35
+ }
36
+
37
+ export async function resolveBaseRef({
38
+ cwd,
39
+ baseRefOverride = '',
40
+ baseRemoteOverride = '',
41
+ baseBranchOverride = '',
42
+ stackRemoteFallback = '',
43
+ } = {}) {
44
+ const repoDir = String(cwd ?? '').trim();
45
+ if (!repoDir) {
46
+ throw new Error('[review] missing cwd for base resolution');
47
+ }
48
+
49
+ if (!(await gitOk({ cwd: repoDir, args: ['rev-parse', '--is-inside-work-tree'] }))) {
50
+ throw new Error(`[review] not a git repository: ${repoDir}`);
51
+ }
52
+
53
+ const explicitRef = String(baseRefOverride ?? '').trim();
54
+ if (explicitRef) {
55
+ return { baseRef: explicitRef, remote: '', branch: '' };
56
+ }
57
+
58
+ const stackFallback = String(stackRemoteFallback ?? '').trim();
59
+ const inferredRemote = await inferRemoteFromBranchOwner({ cwd: repoDir });
60
+ const rawRemote = String(baseRemoteOverride ?? '').trim() || inferredRemote || stackFallback || 'upstream';
61
+ const remote = await normalizeRemoteName({ cwd: repoDir, remote: rawRemote });
62
+
63
+ const branch = String(baseBranchOverride ?? '').trim() || (await resolveRemoteDefaultBranch({ cwd: repoDir, remote }));
64
+ const ok = await ensureRemoteRefAvailable({ cwd: repoDir, remote, branch });
65
+ if (!ok) {
66
+ throw new Error(
67
+ `[review] unable to resolve base ref refs/remotes/${remote}/${branch} in ${repoDir}\n` +
68
+ `[review] hint: ensure remote "${remote}" exists and has a configured HEAD/default branch (or pass --base-ref).`
69
+ );
70
+ }
71
+
72
+ return { baseRef: `${remote}/${branch}`, remote, branch };
73
+ }
74
+
@@ -0,0 +1,54 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { runCapture } from '../proc/proc.mjs';
7
+ import { resolveBaseRef } from './base_ref.mjs';
8
+
9
+ async function runGit(cwd, args) {
10
+ await runCapture('git', args, { cwd });
11
+ }
12
+
13
+ async function makeRepoWithRemoteHead() {
14
+ const root = await mkdtemp(join(tmpdir(), 'hs-review-base-ref-'));
15
+ const remote = join(root, 'remote.git');
16
+ const local = join(root, 'local');
17
+
18
+ await runGit(root, ['init', '--bare', remote]);
19
+ await runGit(root, ['init', '-b', 'main', local]);
20
+ await runGit(local, ['config', 'user.email', 'test@example.com']);
21
+ await runGit(local, ['config', 'user.name', 'Test User']);
22
+ await writeFile(join(local, 'file.txt'), 'hello\n', 'utf-8');
23
+ await runGit(local, ['add', '.']);
24
+ await runGit(local, ['commit', '-m', 'initial']);
25
+ await runGit(local, ['remote', 'add', 'upstream', remote]);
26
+ await runGit(local, ['push', '-u', 'upstream', 'main']);
27
+ // Ensure refs/remotes/upstream/HEAD exists.
28
+ await runGit(local, ['remote', 'set-head', 'upstream', '--auto']);
29
+
30
+ return { root, local };
31
+ }
32
+
33
+ test('resolveBaseRef uses explicit --base-ref override', async () => {
34
+ const { root, local } = await makeRepoWithRemoteHead();
35
+ try {
36
+ const res = await resolveBaseRef({ cwd: local, baseRefOverride: 'upstream/main' });
37
+ assert.equal(res.baseRef, 'upstream/main');
38
+ } finally {
39
+ await rm(root, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test('resolveBaseRef infers default branch from refs/remotes/<remote>/HEAD', async () => {
44
+ const { root, local } = await makeRepoWithRemoteHead();
45
+ try {
46
+ const res = await resolveBaseRef({ cwd: local, baseRemoteOverride: 'upstream' });
47
+ assert.equal(res.baseRef, 'upstream/main');
48
+ assert.equal(res.remote, 'upstream');
49
+ assert.equal(res.branch, 'main');
50
+ } finally {
51
+ await rm(root, { recursive: true, force: true });
52
+ }
53
+ });
54
+
@@ -0,0 +1,55 @@
1
+ export async function planCommitChunks({ baseCommit, commits, maxFiles, countFilesBetween }) {
2
+ if (!Array.isArray(commits)) throw new Error('[review] planCommitChunks: commits must be an array');
3
+ const max = Number(maxFiles);
4
+ if (!Number.isFinite(max) || max <= 0) throw new Error('[review] planCommitChunks: maxFiles must be a positive number');
5
+ if (typeof countFilesBetween !== 'function') throw new Error('[review] planCommitChunks: countFilesBetween must be a function');
6
+
7
+ const list = commits.map((c) => String(c ?? '').trim()).filter(Boolean);
8
+ if (!list.length) return [];
9
+
10
+ const chunks = [];
11
+ let base = String(baseCommit ?? '').trim();
12
+ if (!base) throw new Error('[review] planCommitChunks: baseCommit is required');
13
+ let startIndex = 0;
14
+
15
+ while (startIndex < list.length) {
16
+ let lo = startIndex;
17
+ let hi = list.length - 1;
18
+ let bestIndex = -1;
19
+ let bestCount = -1;
20
+
21
+ while (lo <= hi) {
22
+ const mid = Math.floor((lo + hi) / 2);
23
+ const head = list[mid];
24
+ // eslint-disable-next-line no-await-in-loop
25
+ const n = await countFilesBetween({ base, head });
26
+ if (!Number.isFinite(n) || n < 0) throw new Error('[review] planCommitChunks: countFilesBetween returned invalid count');
27
+
28
+ if (n <= max) {
29
+ bestIndex = mid;
30
+ bestCount = n;
31
+ lo = mid + 1;
32
+ } else {
33
+ hi = mid - 1;
34
+ }
35
+ }
36
+
37
+ // If even the smallest chunk exceeds the limit, emit a single over-limit chunk so the caller can decide what to do.
38
+ if (bestIndex === -1) {
39
+ const head = list[startIndex];
40
+ // eslint-disable-next-line no-await-in-loop
41
+ const n = await countFilesBetween({ base, head });
42
+ chunks.push({ base, head, fileCount: n, overLimit: true });
43
+ base = head;
44
+ startIndex += 1;
45
+ continue;
46
+ }
47
+
48
+ const head = list[bestIndex];
49
+ chunks.push({ base, head, fileCount: bestCount, overLimit: false });
50
+ base = head;
51
+ startIndex = bestIndex + 1;
52
+ }
53
+
54
+ return chunks;
55
+ }
@@ -0,0 +1,51 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { planCommitChunks } from './chunks.mjs';
4
+
5
+ test('planCommitChunks greedily selects the largest end commit within maxFiles', async () => {
6
+ const commits = ['c1', 'c2', 'c3', 'c4'];
7
+
8
+ const counts = new Map([
9
+ ['BASE->c1', 1],
10
+ ['BASE->c2', 3],
11
+ ['BASE->c3', 4],
12
+ ['BASE->c4', 7],
13
+ ['c2->c3', 2],
14
+ ['c2->c4', 5],
15
+ ['c3->c4', 2],
16
+ ]);
17
+
18
+ const chunks = await planCommitChunks({
19
+ baseCommit: 'BASE',
20
+ commits,
21
+ maxFiles: 3,
22
+ countFilesBetween: async ({ base, head }) => counts.get(`${base}->${head}`),
23
+ });
24
+
25
+ assert.deepEqual(chunks, [
26
+ { base: 'BASE', head: 'c2', fileCount: 3, overLimit: false },
27
+ { base: 'c2', head: 'c3', fileCount: 2, overLimit: false },
28
+ { base: 'c3', head: 'c4', fileCount: 2, overLimit: false },
29
+ ]);
30
+ });
31
+
32
+ test('planCommitChunks marks overLimit when a single step exceeds maxFiles', async () => {
33
+ const commits = ['c1', 'c2'];
34
+
35
+ const counts = new Map([
36
+ ['BASE->c1', 10],
37
+ ['c1->c2', 2],
38
+ ]);
39
+
40
+ const chunks = await planCommitChunks({
41
+ baseCommit: 'BASE',
42
+ commits,
43
+ maxFiles: 3,
44
+ countFilesBetween: async ({ base, head }) => counts.get(`${base}->${head}`),
45
+ });
46
+
47
+ assert.deepEqual(chunks, [
48
+ { base: 'BASE', head: 'c1', fileCount: 10, overLimit: true },
49
+ { base: 'c1', head: 'c2', fileCount: 2, overLimit: false },
50
+ ]);
51
+ });
@@ -0,0 +1,165 @@
1
+ function parseLineRange(raw) {
2
+ const s = String(raw ?? '').trim();
3
+ // Common CodeRabbit format: "17 to 31"
4
+ const m = s.match(/^(\d+)\s+to\s+(\d+)$/i);
5
+ if (m) return { start: Number(m[1]), end: Number(m[2]) };
6
+ const n = s.match(/^(\d+)$/);
7
+ if (n) {
8
+ const v = Number(n[1]);
9
+ return { start: v, end: v };
10
+ }
11
+ return null;
12
+ }
13
+
14
+ export function parseCodeRabbitPlainOutput(text) {
15
+ const lines = String(text ?? '').split('\n');
16
+ const findings = [];
17
+
18
+ let current = null;
19
+ let mode = null; // 'comment' | 'prompt' | null
20
+
21
+ function flush() {
22
+ if (!current) return;
23
+ const comment = (current._commentLines ?? []).join('\n').trim();
24
+ const prompt = (current._promptLines ?? []).join('\n').trim();
25
+ const title =
26
+ current.title ??
27
+ comment
28
+ .split('\n')
29
+ .map((l) => l.trim())
30
+ .filter(Boolean)[0] ??
31
+ '';
32
+
33
+ findings.push({
34
+ reviewer: 'coderabbit',
35
+ file: current.file ?? '',
36
+ lines: current.lines ?? null,
37
+ type: current.type ?? '',
38
+ title,
39
+ comment,
40
+ prompt: prompt || null,
41
+ });
42
+ current = null;
43
+ mode = null;
44
+ }
45
+
46
+ for (let i = 0; i < lines.length; i += 1) {
47
+ const line = lines[i];
48
+ const trimmed = line.trimEnd();
49
+
50
+ if (trimmed.startsWith('============================================================================')) {
51
+ flush();
52
+ continue;
53
+ }
54
+ if (trimmed.startsWith('File: ')) {
55
+ flush();
56
+ current = { _commentLines: [], _promptLines: [] };
57
+ current.file = trimmed.slice('File: '.length).trim();
58
+ continue;
59
+ }
60
+ if (!current) continue;
61
+
62
+ if (trimmed.startsWith('Line: ')) {
63
+ const range = parseLineRange(trimmed.slice('Line: '.length).trim());
64
+ current.lines = range;
65
+ continue;
66
+ }
67
+ if (trimmed.startsWith('Type: ')) {
68
+ current.type = trimmed.slice('Type: '.length).trim();
69
+ continue;
70
+ }
71
+ if (trimmed === 'Comment:') {
72
+ mode = 'comment';
73
+ continue;
74
+ }
75
+ if (trimmed === 'Prompt for AI Agent:') {
76
+ mode = 'prompt';
77
+ continue;
78
+ }
79
+
80
+ if (mode === 'comment') {
81
+ // Title is first non-empty comment line.
82
+ if (!current.title && trimmed.trim()) current.title = trimmed.trim();
83
+ current._commentLines.push(trimmed);
84
+ } else if (mode === 'prompt') {
85
+ current._promptLines.push(trimmed);
86
+ }
87
+ }
88
+
89
+ flush();
90
+ // Drop empty placeholders
91
+ return findings.filter((f) => f.file && f.title);
92
+ }
93
+
94
+ export function parseCodexReviewText(reviewText) {
95
+ const s = String(reviewText ?? '');
96
+ const marker = '===FINDINGS_JSON===';
97
+ const idx = s.indexOf(marker);
98
+ if (idx < 0) return [];
99
+ const jsonText = s.slice(idx + marker.length).trim();
100
+ if (!jsonText) return [];
101
+
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(jsonText);
105
+ } catch {
106
+ return [];
107
+ }
108
+ if (!Array.isArray(parsed)) return [];
109
+ return parsed
110
+ .map((x) => ({
111
+ reviewer: 'codex',
112
+ severity: x?.severity ?? null,
113
+ file: x?.file ?? null,
114
+ title: x?.title ?? null,
115
+ recommendation: x?.recommendation ?? null,
116
+ needsDiscussion: Boolean(x?.needsDiscussion),
117
+ }))
118
+ .filter((x) => x.file && x.title);
119
+ }
120
+
121
+ export function formatTriageMarkdown({ runLabel, baseRef, findings }) {
122
+ const items = Array.isArray(findings) ? findings : [];
123
+ const header = [
124
+ `# Review triage: ${runLabel}`,
125
+ '',
126
+ `- Base ref: ${baseRef ?? ''}`,
127
+ `- Findings: ${items.length}`,
128
+ '',
129
+ '## Mandatory workflow',
130
+ '',
131
+ 'For each finding below:',
132
+ '1) Open the referenced file/lines in the *validation worktree* (committed-only).',
133
+ '2) Decide if it is a real bug/risk/correctness gap, already fixed, expected behavior, or style preference.',
134
+ '3) Record a final decision + rationale here (`apply` / `adjust` / `defer`).',
135
+ '4) If `apply/adjust`: implement in the main worktree as a clean commit (no unrelated changes), then sync that commit to validation.',
136
+ '',
137
+ 'Notes:',
138
+ '- Treat reviewer output as suggestions; verify against best practices and codebase invariants before applying.',
139
+ '- Avoid brittle tests that assert on wording/phrasing/config; test observable behavior.',
140
+ '',
141
+ ].join('\n');
142
+
143
+ const body = items
144
+ .map((f) => {
145
+ const lines = f.lines?.start ? `${f.lines.start}-${f.lines.end ?? f.lines.start}` : '';
146
+ const meta = [
147
+ `- [ ] \`${f.id ?? ''}\` reviewer=\`${f.reviewer ?? ''}\`${f.severity ? ` severity=\`${f.severity}\`` : ''}${
148
+ f.type ? ` type=\`${f.type}\`` : ''
149
+ } \`${f.file ?? ''}\`${lines ? ` (lines ${lines})` : ''}: ${f.title ?? ''}`,
150
+ f.sourceLog ? ` - Source log: \`${f.sourceLog}\`` : null,
151
+ ' - Final decision: **TBD** (apply|adjust|defer)',
152
+ ' - Verified in validation worktree: **TBD**',
153
+ ' - Rationale: **TBD**',
154
+ ' - Action taken: **TBD**',
155
+ ' - Commit: **TBD**',
156
+ ' - Needs discussion: **TBD**',
157
+ ];
158
+ if (f.comment) meta.push(` - Reviewer detail: ${String(f.comment).split('\n')[0].trim()}`);
159
+ if (f.recommendation) meta.push(` - Reviewer suggested fix: ${String(f.recommendation).split('\n')[0].trim()}`);
160
+ return meta.filter(Boolean).join('\n');
161
+ })
162
+ .join('\n\n');
163
+
164
+ return `${header}${body ? `${body}\n` : ''}`;
165
+ }