happy-stacks 0.6.12 → 0.6.13
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/docs/commit-audits/happy/_tools/generate-plans.mjs +453 -0
- package/docs/commit-audits/happy/_tools/generate-pr-assignment.mjs +430 -0
- package/docs/commit-audits/happy/_tools/init-pr-assignment-working.mjs +107 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +1849 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +747 -1
- package/docs/commit-audits/happy/leeroy-wip.commit-index.json +11740 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-index.tsv +252 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +18 -11
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1236 -92
- package/docs/commit-audits/happy/leeroy-wip.maintainers-overview.draft.md +448 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-assignment.draft.tsv +252 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-assignment.working.tsv +288 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-catalog.draft.md +245 -0
- package/docs/commit-audits/happy/leeroy-wip.pr-stack-plan.draft.md +350 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-deferred-fragments.tsv +65 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-ledger.tsv +56 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-process.md +240 -0
- package/docs/commit-audits/happy/leeroy-wip.rewrite-status.tsv +39 -0
- package/docs/commit-audits/happy/leeroy-wip.split-plan.draft.md +93 -0
- package/docs/commit-audits/happy/leeroy-wip.topic-buckets.md +76 -0
- package/docs/commit-audits/happy/pr-desc.extraction-ledger.tsv +279 -0
- package/docs/commit-audits/happy/pr-desc.original.md +0 -0
- package/docs/commit-audits/happy/pr-desc.post-audit-extraction-ledger.tsv +54 -0
- package/docs/commit-audits/happy/pr-desc.working-document.md +536 -0
- package/docs/happy-development.md +18 -1
- package/docs/isolated-linux-vm.md +23 -1
- package/docs/stacks.md +21 -1
- package/package.json +1 -1
- package/scripts/auth.mjs +46 -8
- package/scripts/daemon.mjs +44 -21
- package/scripts/doctor.mjs +2 -2
- package/scripts/doctor_cmd.test.mjs +67 -0
- package/scripts/happy.mjs +18 -5
- package/scripts/provision/linux-ubuntu-review-pr.sh +5 -1
- package/scripts/provision/macos-lima-happy-vm.sh +34 -2
- package/scripts/review.mjs +347 -124
- package/scripts/review_pr.mjs +78 -2
- package/scripts/run.mjs +2 -1
- package/scripts/stack.mjs +265 -19
- package/scripts/stack_daemon_cmd.test.mjs +196 -0
- package/scripts/stack_happy_cmd.test.mjs +103 -0
- package/scripts/utils/cli/prereqs.mjs +12 -1
- package/scripts/utils/dev/daemon.mjs +3 -1
- package/scripts/utils/proc/pm.mjs +1 -1
- package/scripts/utils/review/detached_worktree.mjs +61 -0
- package/scripts/utils/review/detached_worktree.test.mjs +62 -0
- package/scripts/utils/review/findings.mjs +133 -20
- package/scripts/utils/review/findings.test.mjs +88 -1
- package/scripts/utils/review/runners/augment.mjs +71 -0
- package/scripts/utils/review/runners/augment.test.mjs +42 -0
- package/scripts/utils/review/runners/coderabbit.mjs +54 -10
- package/scripts/utils/review/runners/coderabbit.test.mjs +15 -48
- package/scripts/utils/review/sliced_runner.mjs +39 -0
- package/scripts/utils/review/sliced_runner.test.mjs +47 -0
- package/scripts/utils/review/tool_home_seed.mjs +99 -0
- package/scripts/utils/review/tool_home_seed.test.mjs +113 -0
- package/scripts/utils/stack/cli_identities.mjs +29 -0
- package/scripts/utils/stack/startup.mjs +45 -7
- package/scripts/worktrees.mjs +8 -5
|
@@ -64,6 +64,93 @@ test('parseCodexReviewText extracts findings JSON trailer', () => {
|
|
|
64
64
|
assert.equal(findings[0].severity, 'major');
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
test('parseCodexReviewText extracts findings JSON trailer even when fenced', () => {
|
|
68
|
+
const review = [
|
|
69
|
+
'All good.',
|
|
70
|
+
'',
|
|
71
|
+
'===FINDINGS_JSON===',
|
|
72
|
+
'```json',
|
|
73
|
+
JSON.stringify(
|
|
74
|
+
[
|
|
75
|
+
{
|
|
76
|
+
severity: 'minor',
|
|
77
|
+
file: 'cli/src/foo.ts',
|
|
78
|
+
title: 'Prefer explicit return type',
|
|
79
|
+
recommendation: 'Add an explicit return type for clarity.',
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
null,
|
|
83
|
+
2
|
|
84
|
+
),
|
|
85
|
+
'```',
|
|
86
|
+
].join('\n');
|
|
87
|
+
|
|
88
|
+
const findings = parseCodexReviewText(review);
|
|
89
|
+
assert.equal(findings.length, 1);
|
|
90
|
+
assert.equal(findings[0].file, 'cli/src/foo.ts');
|
|
91
|
+
assert.equal(findings[0].severity, 'minor');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('parseCodexReviewText extracts findings JSON trailer when lines are log-prefixed', () => {
|
|
95
|
+
const label = '[monorepo:augment:4/39] ';
|
|
96
|
+
const review = [
|
|
97
|
+
`${label}some preamble`,
|
|
98
|
+
`${label}===FINDINGS_JSON===`,
|
|
99
|
+
`${label}\`\`\`json`,
|
|
100
|
+
`${label}[`,
|
|
101
|
+
`${label} {`,
|
|
102
|
+
`${label} \"severity\": \"major\",`,
|
|
103
|
+
`${label} \"file\": \"cli/src/x.ts\",`,
|
|
104
|
+
`${label} \"title\": \"Fix thing\",`,
|
|
105
|
+
`${label} \"recommendation\": \"Do it.\",`,
|
|
106
|
+
`${label} \"needsDiscussion\": false`,
|
|
107
|
+
`${label} }`,
|
|
108
|
+
`${label}]`,
|
|
109
|
+
`${label}\`\`\``,
|
|
110
|
+
`${label}`,
|
|
111
|
+
`${label}Request ID: abc`,
|
|
112
|
+
].join('\n');
|
|
113
|
+
|
|
114
|
+
const findings = parseCodexReviewText(review);
|
|
115
|
+
assert.equal(findings.length, 1);
|
|
116
|
+
assert.equal(findings[0].file, 'cli/src/x.ts');
|
|
117
|
+
assert.equal(findings[0].severity, 'major');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('parseCodexReviewText falls back to parsing [P#] bullet lines', () => {
|
|
121
|
+
const review = [
|
|
122
|
+
'[monorepo:codex:2/21] Review comment:',
|
|
123
|
+
'[monorepo:codex:2/21] - [P1] Fix thing one — /Users/me/repo/.project/review-worktrees/codex-2-of-21-abc/cli/src/foo.ts:10-12',
|
|
124
|
+
'[monorepo:codex:2/21] - [P3] Fix thing two — /Users/me/repo/.project/review-worktrees/codex-2-of-21-abc/expo-app/sources/bar.tsx:7',
|
|
125
|
+
].join('\n');
|
|
126
|
+
|
|
127
|
+
const findings = parseCodexReviewText(review);
|
|
128
|
+
assert.equal(findings.length, 2);
|
|
129
|
+
assert.equal(findings[0].file, 'cli/src/foo.ts');
|
|
130
|
+
assert.deepEqual(findings[0].lines, { start: 10, end: 12 });
|
|
131
|
+
assert.equal(findings[0].severity, 'blocker');
|
|
132
|
+
assert.equal(findings[0].title, 'Fix thing one');
|
|
133
|
+
assert.equal(findings[1].file, 'expo-app/sources/bar.tsx');
|
|
134
|
+
assert.deepEqual(findings[1].lines, { start: 7, end: 7 });
|
|
135
|
+
assert.equal(findings[1].severity, 'minor');
|
|
136
|
+
assert.equal(findings[1].title, 'Fix thing two');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('parseCodexReviewText falls back when marker exists but JSON is missing/invalid', () => {
|
|
140
|
+
const review = [
|
|
141
|
+
'instructions...',
|
|
142
|
+
'===FINDINGS_JSON===',
|
|
143
|
+
'this is not json',
|
|
144
|
+
'[monorepo:codex:2/21] - [P2] Fix thing — /Users/me/repo/.project/review-worktrees/codex-2-of-21-abc/server/src/x.ts:1-2',
|
|
145
|
+
].join('\n');
|
|
146
|
+
|
|
147
|
+
const findings = parseCodexReviewText(review);
|
|
148
|
+
assert.equal(findings.length, 1);
|
|
149
|
+
assert.equal(findings[0].file, 'server/src/x.ts');
|
|
150
|
+
assert.deepEqual(findings[0].lines, { start: 1, end: 2 });
|
|
151
|
+
assert.equal(findings[0].severity, 'major');
|
|
152
|
+
});
|
|
153
|
+
|
|
67
154
|
test('formatTriageMarkdown includes required workflow fields', () => {
|
|
68
155
|
const md = formatTriageMarkdown({
|
|
69
156
|
runLabel: 'review-123',
|
|
@@ -78,8 +165,8 @@ test('formatTriageMarkdown includes required workflow fields', () => {
|
|
|
78
165
|
},
|
|
79
166
|
],
|
|
80
167
|
});
|
|
168
|
+
assert.match(md, /Trust checklist/i);
|
|
81
169
|
assert.match(md, /Final decision: \*\*TBD\*\*/);
|
|
82
170
|
assert.match(md, /Verified in validation worktree:/);
|
|
83
171
|
assert.match(md, /Commit:/);
|
|
84
172
|
});
|
|
85
|
-
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { runCaptureResult } from '../../proc/proc.mjs';
|
|
2
|
+
|
|
3
|
+
export function detectAugmentAuthError({ stdout, stderr }) {
|
|
4
|
+
const combined = `${stdout ?? ''}\n${stderr ?? ''}`;
|
|
5
|
+
return combined.includes('Authentication failed') && combined.includes("Run 'auggie login'");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function buildAugmentReviewArgs({
|
|
9
|
+
prompt,
|
|
10
|
+
workspaceRoot,
|
|
11
|
+
cacheDir,
|
|
12
|
+
model,
|
|
13
|
+
rulesFiles = [],
|
|
14
|
+
retryTimeoutSec,
|
|
15
|
+
maxTurns,
|
|
16
|
+
} = {}) {
|
|
17
|
+
const args = ['--print', '--quiet', '--dont-save-session', '--ask', '--output-format', 'text'];
|
|
18
|
+
|
|
19
|
+
const wr = String(workspaceRoot ?? '').trim();
|
|
20
|
+
if (wr) args.push('--workspace-root', wr);
|
|
21
|
+
|
|
22
|
+
const cd = String(cacheDir ?? '').trim();
|
|
23
|
+
if (cd) args.push('--augment-cache-dir', cd);
|
|
24
|
+
|
|
25
|
+
const m = String(model ?? '').trim();
|
|
26
|
+
if (m) args.push('--model', m);
|
|
27
|
+
|
|
28
|
+
const rt = String(retryTimeoutSec ?? '').trim();
|
|
29
|
+
if (rt) args.push('--retry-timeout', rt);
|
|
30
|
+
|
|
31
|
+
const mt = String(maxTurns ?? '').trim();
|
|
32
|
+
if (mt) args.push('--max-turns', mt);
|
|
33
|
+
|
|
34
|
+
for (const rf of Array.isArray(rulesFiles) ? rulesFiles : []) {
|
|
35
|
+
const p = String(rf ?? '').trim();
|
|
36
|
+
if (!p) continue;
|
|
37
|
+
args.push('--rules', p);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const p = String(prompt ?? '').trim();
|
|
41
|
+
if (!p) throw new Error('[review] augment: missing prompt');
|
|
42
|
+
args.push(p);
|
|
43
|
+
return args;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runAugmentReview({
|
|
47
|
+
repoDir,
|
|
48
|
+
prompt,
|
|
49
|
+
env,
|
|
50
|
+
streamLabel,
|
|
51
|
+
teeFile,
|
|
52
|
+
teeLabel,
|
|
53
|
+
cacheDir,
|
|
54
|
+
model,
|
|
55
|
+
rulesFiles = [],
|
|
56
|
+
retryTimeoutSec = 60 * 60 * 2,
|
|
57
|
+
maxTurns,
|
|
58
|
+
} = {}) {
|
|
59
|
+
const args = buildAugmentReviewArgs({
|
|
60
|
+
prompt,
|
|
61
|
+
workspaceRoot: repoDir,
|
|
62
|
+
cacheDir,
|
|
63
|
+
model,
|
|
64
|
+
rulesFiles,
|
|
65
|
+
retryTimeoutSec,
|
|
66
|
+
maxTurns,
|
|
67
|
+
});
|
|
68
|
+
const res = await runCaptureResult('auggie', args, { cwd: repoDir, env: env ?? {}, streamLabel, teeFile, teeLabel });
|
|
69
|
+
return { ...res, stdout: res.out, stderr: res.err };
|
|
70
|
+
}
|
|
71
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { buildAugmentReviewArgs, detectAugmentAuthError } from './augment.mjs';
|
|
5
|
+
|
|
6
|
+
test('detectAugmentAuthError matches 401 login guidance', () => {
|
|
7
|
+
const stdout = "❌ Authentication failed: HTTP error: 401 Unauthorized\n Run 'auggie login' to authenticate first.";
|
|
8
|
+
assert.equal(detectAugmentAuthError({ stdout, stderr: '' }), true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('buildAugmentReviewArgs builds auggie --print args with optional settings', () => {
|
|
12
|
+
const args = buildAugmentReviewArgs({
|
|
13
|
+
prompt: 'Review the code',
|
|
14
|
+
workspaceRoot: '/repo',
|
|
15
|
+
cacheDir: '/cache',
|
|
16
|
+
model: 'gpt-5.2',
|
|
17
|
+
rulesFiles: ['/rules/a.md', '/rules/b.md'],
|
|
18
|
+
retryTimeoutSec: 123,
|
|
19
|
+
maxTurns: 9,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
assert.equal(args[0], '--print');
|
|
23
|
+
assert.ok(args.includes('--quiet'));
|
|
24
|
+
assert.ok(args.includes('--dont-save-session'));
|
|
25
|
+
assert.ok(args.includes('--ask'));
|
|
26
|
+
assert.ok(args.includes('--workspace-root'));
|
|
27
|
+
assert.ok(args.includes('/repo'));
|
|
28
|
+
assert.ok(args.includes('--augment-cache-dir'));
|
|
29
|
+
assert.ok(args.includes('/cache'));
|
|
30
|
+
assert.ok(args.includes('--model'));
|
|
31
|
+
assert.ok(args.includes('gpt-5.2'));
|
|
32
|
+
assert.ok(args.includes('--retry-timeout'));
|
|
33
|
+
assert.ok(args.includes('123'));
|
|
34
|
+
assert.ok(args.includes('--max-turns'));
|
|
35
|
+
assert.ok(args.includes('9'));
|
|
36
|
+
|
|
37
|
+
const joined = args.join(' ');
|
|
38
|
+
assert.match(joined, /--rules \/rules\/a\.md/);
|
|
39
|
+
assert.match(joined, /--rules \/rules\/b\.md/);
|
|
40
|
+
assert.equal(args.at(-1), 'Review the code');
|
|
41
|
+
});
|
|
42
|
+
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runCaptureResult } from '../../proc/proc.mjs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { appendFile } from 'node:fs/promises';
|
|
3
4
|
|
|
4
5
|
function normalizeType(raw) {
|
|
5
6
|
const t = String(raw ?? '').trim().toLowerCase();
|
|
@@ -8,6 +9,19 @@ function normalizeType(raw) {
|
|
|
8
9
|
throw new Error(`[review] invalid coderabbit type: ${raw} (expected: all|committed|uncommitted)`);
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
export function parseCodeRabbitRateLimitRetryMs(text) {
|
|
13
|
+
const s = String(text ?? '');
|
|
14
|
+
const m = s.match(/Rate limit exceeded,\s*please try after\s+(\d+)\s+minutes?\s+and\s+(\d+)\s+seconds?/i);
|
|
15
|
+
if (!m) return null;
|
|
16
|
+
const minutes = Number(m[1]);
|
|
17
|
+
const seconds = Number(m[2]);
|
|
18
|
+
if (!Number.isFinite(minutes) || minutes < 0) return null;
|
|
19
|
+
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
20
|
+
// Add +1s padding to avoid retrying too early.
|
|
21
|
+
const totalSeconds = minutes * 60 + seconds + 1;
|
|
22
|
+
return Math.max(1000, totalSeconds * 1000);
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
export function buildCodeRabbitReviewArgs({ repoDir, baseRef, baseCommit, type, configFiles }) {
|
|
12
26
|
const args = ['review', '--plain', '--no-color', '--type', normalizeType(type), '--cwd', repoDir];
|
|
13
27
|
const base = String(baseRef ?? '').trim();
|
|
@@ -27,8 +41,15 @@ export function buildCodeRabbitEnv({ env, homeDir }) {
|
|
|
27
41
|
const dir = String(homeDir ?? '').trim();
|
|
28
42
|
if (!dir) return merged;
|
|
29
43
|
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
// IMPORTANT:
|
|
45
|
+
// Do not override HOME/USERPROFILE here.
|
|
46
|
+
//
|
|
47
|
+
// CodeRabbit uses OS credential storage (e.g. macOS Keychain). If HOME is pointed at
|
|
48
|
+
// an isolated directory (like .project/coderabbit-home), the underlying keychain
|
|
49
|
+
// lookup can fail with "Keychain Not Found" and auth will not work in the wrapper.
|
|
50
|
+
//
|
|
51
|
+
// We still isolate CodeRabbit's on-disk config/cache under the provided homeDir via
|
|
52
|
+
// CODERABBIT_HOME + XDG dirs.
|
|
32
53
|
merged.CODERABBIT_HOME = join(dir, '.coderabbit');
|
|
33
54
|
merged.XDG_CONFIG_HOME = join(dir, '.config');
|
|
34
55
|
merged.XDG_CACHE_HOME = join(dir, '.cache');
|
|
@@ -50,12 +71,35 @@ export async function runCodeRabbitReview({
|
|
|
50
71
|
}) {
|
|
51
72
|
const homeDir = (env?.HAPPY_STACKS_CODERABBIT_HOME_DIR ?? env?.HAPPY_LOCAL_CODERABBIT_HOME_DIR ?? '').toString().trim();
|
|
52
73
|
const args = buildCodeRabbitReviewArgs({ repoDir, baseRef, baseCommit, type, configFiles });
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
const maxAttempts = 50;
|
|
75
|
+
let last = null;
|
|
76
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
77
|
+
// eslint-disable-next-line no-await-in-loop
|
|
78
|
+
const res = await runCaptureResult('coderabbit', args, {
|
|
79
|
+
cwd: repoDir,
|
|
80
|
+
env: buildCodeRabbitEnv({ env, homeDir }),
|
|
81
|
+
streamLabel,
|
|
82
|
+
teeFile,
|
|
83
|
+
teeLabel,
|
|
84
|
+
});
|
|
85
|
+
last = res;
|
|
86
|
+
if (res.ok) return { ...res, stdout: res.out, stderr: res.err };
|
|
87
|
+
|
|
88
|
+
const retryMs = parseCodeRabbitRateLimitRetryMs(`${res.out ?? ''}\n${res.err ?? ''}`);
|
|
89
|
+
if (!retryMs) return { ...res, stdout: res.out, stderr: res.err };
|
|
90
|
+
|
|
91
|
+
const seconds = Math.ceil(retryMs / 1000);
|
|
92
|
+
const msg = `[review] coderabbit rate limited; retrying in ${seconds}s (attempt ${attempt}/${maxAttempts})\n`;
|
|
93
|
+
try {
|
|
94
|
+
if (teeFile) await appendFile(teeFile, msg);
|
|
95
|
+
} catch {
|
|
96
|
+
// ignore
|
|
97
|
+
}
|
|
98
|
+
// eslint-disable-next-line no-console
|
|
99
|
+
console.warn(msg.trimEnd());
|
|
100
|
+
// eslint-disable-next-line no-await-in-loop
|
|
101
|
+
await new Promise((r) => setTimeout(r, retryMs));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { ...last, stdout: last?.out ?? '', stderr: last?.err ?? '' };
|
|
61
105
|
}
|
|
@@ -1,59 +1,26 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
3
|
|
|
5
|
-
import {
|
|
4
|
+
import { parseCodeRabbitRateLimitRetryMs } from './coderabbit.mjs';
|
|
6
5
|
|
|
7
|
-
test('
|
|
8
|
-
|
|
9
|
-
const args = buildCodeRabbitReviewArgs({ repoDir, baseRef: 'upstream/main', type: undefined, configFiles: [] });
|
|
10
|
-
assert.deepEqual(args, ['review', '--plain', '--no-color', '--type', 'committed', '--cwd', repoDir, '--base', 'upstream/main']);
|
|
6
|
+
test('parseCodeRabbitRateLimitRetryMs returns null when no rate limit message is present', () => {
|
|
7
|
+
assert.equal(parseCodeRabbitRateLimitRetryMs('Review completed ✔'), null);
|
|
11
8
|
});
|
|
12
9
|
|
|
13
|
-
test('
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
assert.deepEqual(args, ['review', '--plain', '--no-color', '--type', 'committed', '--cwd', repoDir, '--base-commit', 'abc123']);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test('buildCodeRabbitReviewArgs rejects providing both baseRef and baseCommit', () => {
|
|
20
|
-
assert.throws(
|
|
21
|
-
() => buildCodeRabbitReviewArgs({ repoDir: '/tmp/repo', baseRef: 'upstream/main', baseCommit: 'abc123', type: 'committed', configFiles: [] }),
|
|
22
|
-
/mutually exclusive/
|
|
10
|
+
test('parseCodeRabbitRateLimitRetryMs parses the suggested retry delay', () => {
|
|
11
|
+
const ms = parseCodeRabbitRateLimitRetryMs(
|
|
12
|
+
'[2026-01-25T22:29:41.623Z] ❌ ERROR: Error: Rate limit exceeded, please try after 3 minutes and 2 seconds'
|
|
23
13
|
);
|
|
14
|
+
assert.ok(ms);
|
|
15
|
+
// Allow +1s padding.
|
|
16
|
+
assert.equal(ms, (3 * 60 + 2 + 1) * 1000);
|
|
24
17
|
});
|
|
25
18
|
|
|
26
|
-
test('
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
configFiles: ['/tmp/a.md', '/tmp/b.md'],
|
|
33
|
-
});
|
|
34
|
-
assert.deepEqual(args, [
|
|
35
|
-
'review',
|
|
36
|
-
'--plain',
|
|
37
|
-
'--no-color',
|
|
38
|
-
'--type',
|
|
39
|
-
'committed',
|
|
40
|
-
'--cwd',
|
|
41
|
-
repoDir,
|
|
42
|
-
'--base',
|
|
43
|
-
'upstream/main',
|
|
44
|
-
'--config',
|
|
45
|
-
'/tmp/a.md',
|
|
46
|
-
'/tmp/b.md',
|
|
47
|
-
]);
|
|
19
|
+
test('parseCodeRabbitRateLimitRetryMs supports seconds-only windows', () => {
|
|
20
|
+
const ms = parseCodeRabbitRateLimitRetryMs(
|
|
21
|
+
'[2026-01-26T00:27:23.067Z] ❌ ERROR: Error: Rate limit exceeded, please try after 0 minutes and 31 seconds'
|
|
22
|
+
);
|
|
23
|
+
assert.ok(ms);
|
|
24
|
+
assert.equal(ms, (31 + 1) * 1000);
|
|
48
25
|
});
|
|
49
26
|
|
|
50
|
-
test('buildCodeRabbitEnv overrides HOME/XDG paths when a homeDir is provided', () => {
|
|
51
|
-
const env = buildCodeRabbitEnv({ env: { PATH: '/bin' }, homeDir: '/tmp/cr-home' });
|
|
52
|
-
assert.equal(env.PATH, '/bin');
|
|
53
|
-
assert.equal(env.HOME, '/tmp/cr-home');
|
|
54
|
-
assert.equal(env.CODERABBIT_HOME, join('/tmp/cr-home', '.coderabbit'));
|
|
55
|
-
assert.equal(env.XDG_CONFIG_HOME, join('/tmp/cr-home', '.config'));
|
|
56
|
-
assert.equal(env.XDG_CACHE_HOME, join('/tmp/cr-home', '.cache'));
|
|
57
|
-
assert.equal(env.XDG_STATE_HOME, join('/tmp/cr-home', '.local', 'state'));
|
|
58
|
-
assert.equal(env.XDG_DATA_HOME, join('/tmp/cr-home', '.local', 'share'));
|
|
59
|
-
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { runWithConcurrencyLimit } from '../proc/parallel.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run a list of "slice jobs" with:
|
|
5
|
+
* - a mandatory sequential first job (preflight)
|
|
6
|
+
* - parallel execution for the remainder (bounded by `limit`)
|
|
7
|
+
* - stable, input-order results
|
|
8
|
+
* - optional early-abort after the first job (e.g. auth/credits missing)
|
|
9
|
+
*/
|
|
10
|
+
export async function runSlicedJobs({ items, limit = 1, run, shouldAbortEarly } = {}) {
|
|
11
|
+
const list = Array.isArray(items) ? items : [];
|
|
12
|
+
if (!list.length) return [];
|
|
13
|
+
if (typeof run !== 'function') throw new Error('[review] runSlicedJobs: missing run()');
|
|
14
|
+
|
|
15
|
+
const concurrency = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 1;
|
|
16
|
+
|
|
17
|
+
const out = [];
|
|
18
|
+
// Always run the first item sequentially so we can fail fast on auth/credits problems
|
|
19
|
+
// before spinning up many long-running review jobs.
|
|
20
|
+
// eslint-disable-next-line no-await-in-loop
|
|
21
|
+
const firstRes = await run(list[0]);
|
|
22
|
+
out.push(firstRes);
|
|
23
|
+
if (typeof shouldAbortEarly === 'function' && shouldAbortEarly(firstRes)) {
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (list.length === 1) return out;
|
|
28
|
+
|
|
29
|
+
const rest = list.slice(1);
|
|
30
|
+
const restRes = await runWithConcurrencyLimit({
|
|
31
|
+
items: rest,
|
|
32
|
+
limit: concurrency,
|
|
33
|
+
fn: async (item) => await run(item),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Preserve input order.
|
|
37
|
+
return [...out, ...restRes];
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { runSlicedJobs } from './sliced_runner.mjs';
|
|
4
|
+
|
|
5
|
+
test('runSlicedJobs preserves order and respects concurrency', async () => {
|
|
6
|
+
const items = Array.from({ length: 7 }, (_, i) => ({ index: i + 1 }));
|
|
7
|
+
|
|
8
|
+
let active = 0;
|
|
9
|
+
let maxActive = 0;
|
|
10
|
+
|
|
11
|
+
const results = await runSlicedJobs({
|
|
12
|
+
items,
|
|
13
|
+
limit: 2,
|
|
14
|
+
run: async (item) => {
|
|
15
|
+
active += 1;
|
|
16
|
+
maxActive = Math.max(maxActive, active);
|
|
17
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
18
|
+
active -= 1;
|
|
19
|
+
return { index: item.index };
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
assert.equal(maxActive, 2);
|
|
24
|
+
assert.deepEqual(
|
|
25
|
+
results.map((r) => r.index),
|
|
26
|
+
items.map((i) => i.index)
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('runSlicedJobs can abort early after the first item', async () => {
|
|
31
|
+
const items = Array.from({ length: 5 }, (_, i) => ({ index: i + 1 }));
|
|
32
|
+
const seen = [];
|
|
33
|
+
|
|
34
|
+
const results = await runSlicedJobs({
|
|
35
|
+
items,
|
|
36
|
+
limit: 3,
|
|
37
|
+
run: async (item) => {
|
|
38
|
+
seen.push(item.index);
|
|
39
|
+
return { index: item.index, abort: item.index === 1 };
|
|
40
|
+
},
|
|
41
|
+
shouldAbortEarly: (res) => Boolean(res?.abort),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
assert.deepEqual(seen, [1]);
|
|
45
|
+
assert.deepEqual(results.map((r) => r.index), [1]);
|
|
46
|
+
});
|
|
47
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { copyFile, cp, mkdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
async function mergeCopyDir({ srcDir, destDir }) {
|
|
6
|
+
if (!existsSync(srcDir)) return;
|
|
7
|
+
await mkdir(destDir, { recursive: true });
|
|
8
|
+
await cp(srcDir, destDir, { recursive: true, force: false, errorOnExist: false });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function copyFileIfNewer({ srcFile, destFile }) {
|
|
12
|
+
if (!existsSync(srcFile)) return;
|
|
13
|
+
try {
|
|
14
|
+
const srcStat = await stat(srcFile);
|
|
15
|
+
let destStat = null;
|
|
16
|
+
try {
|
|
17
|
+
destStat = await stat(destFile);
|
|
18
|
+
} catch {
|
|
19
|
+
destStat = null;
|
|
20
|
+
}
|
|
21
|
+
if (!destStat || srcStat.mtimeMs > destStat.mtimeMs) {
|
|
22
|
+
await mkdir(join(destFile, '..'), { recursive: true });
|
|
23
|
+
await copyFile(srcFile, destFile);
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// best-effort
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort: seed CodeRabbit auth/config into an isolated home directory.
|
|
32
|
+
*
|
|
33
|
+
* We do not read or print any auth contents; we only copy the on-disk state when present.
|
|
34
|
+
*/
|
|
35
|
+
export async function seedCodeRabbitHomeFromRealHome({ realHomeDir, isolatedHomeDir } = {}) {
|
|
36
|
+
const real = String(realHomeDir ?? '').trim();
|
|
37
|
+
const isolated = String(isolatedHomeDir ?? '').trim();
|
|
38
|
+
if (!real || !isolated || real === isolated) return;
|
|
39
|
+
|
|
40
|
+
// Common CodeRabbit state locations:
|
|
41
|
+
// - ~/.coderabbit/
|
|
42
|
+
// - ~/.config/coderabbit/ (XDG config)
|
|
43
|
+
// - ~/.cache/coderabbit/ (XDG cache)
|
|
44
|
+
// - ~/.local/share/coderabbit/ (XDG data)
|
|
45
|
+
// - ~/.local/state/coderabbit/ (XDG state)
|
|
46
|
+
//
|
|
47
|
+
// We merge-copy without overwriting existing files so a user can explicitly
|
|
48
|
+
// auth in the isolated dir and we won't clobber it.
|
|
49
|
+
await mergeCopyDir({ srcDir: join(real, '.coderabbit'), destDir: join(isolated, '.coderabbit') });
|
|
50
|
+
await mergeCopyDir({ srcDir: join(real, '.config', 'coderabbit'), destDir: join(isolated, '.config', 'coderabbit') });
|
|
51
|
+
await mergeCopyDir({ srcDir: join(real, '.cache', 'coderabbit'), destDir: join(isolated, '.cache', 'coderabbit') });
|
|
52
|
+
await mergeCopyDir({ srcDir: join(real, '.local', 'share', 'coderabbit'), destDir: join(isolated, '.local', 'share', 'coderabbit') });
|
|
53
|
+
await mergeCopyDir({ srcDir: join(real, '.local', 'state', 'coderabbit'), destDir: join(isolated, '.local', 'state', 'coderabbit') });
|
|
54
|
+
|
|
55
|
+
// If the user re-authenticated recently, refresh auth.json even when it already exists.
|
|
56
|
+
await copyFileIfNewer({
|
|
57
|
+
srcFile: join(real, '.coderabbit', 'auth.json'),
|
|
58
|
+
destFile: join(isolated, '.coderabbit', 'auth.json'),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Best-effort: seed Codex auth/config into an isolated CODEX_HOME directory.
|
|
64
|
+
*
|
|
65
|
+
* Codex stores auth/config under `CODEX_HOME` (default: ~/.codex). In stack review runs we
|
|
66
|
+
* use a per-repo isolated home (e.g. .project/codex-home) to avoid polluting ~/.codex and
|
|
67
|
+
* to keep sandboxed runs self-contained.
|
|
68
|
+
*
|
|
69
|
+
* We do not read or print any auth contents; we only copy the on-disk state when present.
|
|
70
|
+
*/
|
|
71
|
+
export async function seedCodexHomeFromRealHome({ realHomeDir, isolatedHomeDir } = {}) {
|
|
72
|
+
const real = String(realHomeDir ?? '').trim();
|
|
73
|
+
const isolated = String(isolatedHomeDir ?? '').trim();
|
|
74
|
+
if (!real || !isolated || real === isolated) return;
|
|
75
|
+
|
|
76
|
+
// Codex uses CODEX_HOME/{auth.json,config.toml,...}
|
|
77
|
+
await copyFileIfNewer({ srcFile: join(real, '.codex', 'auth.json'), destFile: join(isolated, 'auth.json') });
|
|
78
|
+
await copyFileIfNewer({ srcFile: join(real, '.codex', 'config.toml'), destFile: join(isolated, 'config.toml') });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Best-effort: seed Auggie (Augment CLI) auth/config into an isolated cache directory.
|
|
83
|
+
*
|
|
84
|
+
* Auggie uses `~/.augment` by default (see `--augment-cache-dir`), and supports
|
|
85
|
+
* providing session auth via `AUGMENT_SESSION_AUTH` (same format as `~/.augment/session.json`).
|
|
86
|
+
*
|
|
87
|
+
* We do not read or print any auth contents; we only copy the on-disk state when present.
|
|
88
|
+
*/
|
|
89
|
+
export async function seedAugmentHomeFromRealHome({ realHomeDir, isolatedHomeDir } = {}) {
|
|
90
|
+
const real = String(realHomeDir ?? '').trim();
|
|
91
|
+
const isolated = String(isolatedHomeDir ?? '').trim();
|
|
92
|
+
if (!real || !isolated || real === isolated) return;
|
|
93
|
+
|
|
94
|
+
// Copy ~/.augment/* into the isolated cache dir.
|
|
95
|
+
await mergeCopyDir({ srcDir: join(real, '.augment'), destDir: isolated });
|
|
96
|
+
|
|
97
|
+
// Refresh session.json when it is newer.
|
|
98
|
+
await copyFileIfNewer({ srcFile: join(real, '.augment', 'session.json'), destFile: join(isolated, 'session.json') });
|
|
99
|
+
}
|