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.
- package/README.md +93 -40
- package/bin/happys.mjs +158 -16
- package/docs/codex-mcp-resume.md +130 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
- package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
- package/docs/happy-development.md +3 -4
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +5 -1
- package/scripts/auth.mjs +32 -10
- package/scripts/build.mjs +55 -8
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +198 -50
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +6 -4
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +8 -3
- package/scripts/install.mjs +207 -69
- package/scripts/lint.mjs +24 -4
- package/scripts/migrate.mjs +3 -12
- package/scripts/mobile.mjs +88 -104
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +908 -0
- package/scripts/review_pr.mjs +353 -0
- package/scripts/run.mjs +101 -21
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +189 -68
- package/scripts/setup_pr.mjs +586 -38
- package/scripts/stack.mjs +990 -196
- package/scripts/stack_archive_cmd.test.mjs +91 -0
- package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
- package/scripts/stack_env_cmd.test.mjs +87 -0
- package/scripts/stack_happy_cmd.test.mjs +126 -0
- package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
- package/scripts/stack_monorepo_defaults.test.mjs +62 -0
- package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
- package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
- package/scripts/stack_shorthand_cmd.test.mjs +55 -0
- package/scripts/stack_wt_list.test.mjs +128 -0
- package/scripts/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +395 -39
- package/scripts/typecheck.mjs +24 -4
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/auth/login_ux.mjs +32 -13
- package/scripts/utils/auth/sources.mjs +26 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +43 -4
- package/scripts/utils/cli/cwd_scope.mjs +136 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +75 -0
- package/scripts/utils/cli/prereqs.test.mjs +34 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/cli/wizard.mjs +17 -9
- package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
- package/scripts/utils/dev/daemon.mjs +61 -4
- package/scripts/utils/dev/expo_dev.mjs +430 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/dev/server.mjs +36 -42
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/edison/git_roots.mjs +29 -0
- package/scripts/utils/edison/git_roots.test.mjs +36 -0
- package/scripts/utils/env/env.mjs +7 -3
- package/scripts/utils/env/env_file.mjs +4 -2
- package/scripts/utils/env/env_file.test.mjs +44 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/expo/expo.mjs +20 -1
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/worktrees.mjs +80 -25
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/mobile/config.mjs +31 -0
- package/scripts/utils/mobile/dev_client_links.mjs +60 -0
- package/scripts/utils/mobile/identifiers.mjs +47 -0
- package/scripts/utils/mobile/identifiers.test.mjs +42 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/net/ports.mjs +9 -1
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +50 -3
- package/scripts/utils/paths/paths.mjs +159 -40
- package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
- package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
- package/scripts/utils/proc/commands.mjs +2 -3
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +176 -22
- package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
- package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
- package/scripts/utils/proc/proc.mjs +136 -4
- package/scripts/utils/proc/proc.test.mjs +77 -0
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/chunks.mjs +55 -0
- package/scripts/utils/review/chunks.test.mjs +51 -0
- package/scripts/utils/review/findings.mjs +165 -0
- package/scripts/utils/review/findings.test.mjs +85 -0
- package/scripts/utils/review/head_slice.mjs +153 -0
- package/scripts/utils/review/head_slice.test.mjs +91 -0
- package/scripts/utils/review/instructions/deep.md +20 -0
- package/scripts/utils/review/runners/coderabbit.mjs +61 -0
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +61 -0
- package/scripts/utils/review/runners/codex.test.mjs +35 -0
- package/scripts/utils/review/slices.mjs +140 -0
- package/scripts/utils/review/slices.test.mjs +32 -0
- package/scripts/utils/review/targets.mjs +24 -0
- package/scripts/utils/review/targets.test.mjs +36 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
- package/scripts/utils/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
- package/scripts/utils/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/prisma_import.mjs +37 -0
- package/scripts/utils/server/prisma_import.test.mjs +70 -0
- package/scripts/utils/server/ui_env.mjs +14 -0
- package/scripts/utils/server/ui_env.test.mjs +46 -0
- package/scripts/utils/server/urls.mjs +14 -4
- package/scripts/utils/server/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/service/autostart_darwin.mjs +42 -2
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +2 -2
- package/scripts/utils/stack/editor_workspace.mjs +6 -6
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +2 -1
- package/scripts/utils/stack/startup.mjs +120 -13
- package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
- package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
- package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
- package/scripts/utils/stack/stop.mjs +15 -4
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +755 -179
- package/scripts/worktrees_archive_cmd.test.mjs +245 -0
- package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
- package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
- package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
- 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
|
|
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
|
|
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
|
|
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
|
+
}
|