@towles/tool 0.0.66 → 0.0.67
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/package.json +1 -1
- package/src/commands/auto-claude/retry.test.ts +8 -11
- package/src/commands/auto-claude/retry.ts +4 -2
- package/src/lib/auto-claude/claude-cli.ts +35 -16
- package/src/lib/auto-claude/labels.test.ts +10 -14
- package/src/lib/auto-claude/labels.ts +22 -23
- package/src/lib/auto-claude/pipeline-execution.test.ts +33 -39
- package/src/lib/auto-claude/pipeline.ts +42 -20
- package/src/lib/auto-claude/run-claude.test.ts +40 -28
- package/src/lib/auto-claude/spawn-claude.ts +2 -0
- package/src/lib/auto-claude/steps/create-pr.ts +58 -55
- package/src/lib/auto-claude/steps/implement.ts +3 -1
- package/src/lib/auto-claude/steps/simple-steps.ts +7 -3
- package/src/lib/auto-claude/steps/steps.test.ts +58 -31
- package/src/lib/auto-claude/templates.test.ts +28 -29
- package/src/lib/auto-claude/templates.ts +21 -4
- package/src/lib/auto-claude/utils.ts +4 -1
- package/src/lib/graph/parser.test.ts +21 -25
- package/src/lib/graph/parser.ts +14 -5
- package/src/utils/git/gh-cli-wrapper.test.ts +16 -15
- package/src/utils/git/gh-cli-wrapper.ts +18 -5
package/package.json
CHANGED
|
@@ -3,23 +3,20 @@ import { join } from "node:path";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
|
|
5
5
|
import consola from "consola";
|
|
6
|
-
import { x } from "tinyexec";
|
|
7
6
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
8
7
|
|
|
8
|
+
import type { ExecSafeFn } from "../../lib/auto-claude/labels.js";
|
|
9
9
|
import { LABELS } from "../../lib/auto-claude/labels.js";
|
|
10
10
|
import { retryIssues } from "./retry.js";
|
|
11
11
|
|
|
12
12
|
// Suppress consola output during tests
|
|
13
13
|
consola.level = -999;
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
vi.mock("tinyexec", () => ({
|
|
17
|
-
x: vi.fn().mockResolvedValue({ stdout: "", exitCode: 0, stderr: "" }),
|
|
18
|
-
}));
|
|
15
|
+
const mockExecSafe: ExecSafeFn = vi.fn().mockResolvedValue({ stdout: "", ok: true });
|
|
19
16
|
|
|
20
17
|
function getGhEditCalls() {
|
|
21
18
|
return vi
|
|
22
|
-
.mocked(
|
|
19
|
+
.mocked(mockExecSafe)
|
|
23
20
|
.mock.calls.filter(
|
|
24
21
|
([cmd, args]) => cmd === "gh" && args?.[0] === "issue" && args?.[1] === "edit",
|
|
25
22
|
)
|
|
@@ -41,7 +38,7 @@ describe("retryIssues", () => {
|
|
|
41
38
|
},
|
|
42
39
|
];
|
|
43
40
|
|
|
44
|
-
const count = await retryIssues("owner/repo", "auto-claude", issues, false);
|
|
41
|
+
const count = await retryIssues("owner/repo", "auto-claude", issues, false, mockExecSafe);
|
|
45
42
|
|
|
46
43
|
expect(count).toBe(1);
|
|
47
44
|
const editCalls = getGhEditCalls();
|
|
@@ -70,7 +67,7 @@ describe("retryIssues", () => {
|
|
|
70
67
|
},
|
|
71
68
|
];
|
|
72
69
|
|
|
73
|
-
const count = await retryIssues("owner/repo", "auto-claude", issues, false);
|
|
70
|
+
const count = await retryIssues("owner/repo", "auto-claude", issues, false, mockExecSafe);
|
|
74
71
|
|
|
75
72
|
expect(count).toBe(2);
|
|
76
73
|
const editCalls = getGhEditCalls();
|
|
@@ -79,7 +76,7 @@ describe("retryIssues", () => {
|
|
|
79
76
|
});
|
|
80
77
|
|
|
81
78
|
it("returns 0 when given empty selection", async () => {
|
|
82
|
-
const count = await retryIssues("owner/repo", "auto-claude", [], false);
|
|
79
|
+
const count = await retryIssues("owner/repo", "auto-claude", [], false, mockExecSafe);
|
|
83
80
|
expect(count).toBe(0);
|
|
84
81
|
expect(getGhEditCalls().length).toBe(0);
|
|
85
82
|
});
|
|
@@ -103,7 +100,7 @@ describe("retryIssues", () => {
|
|
|
103
100
|
},
|
|
104
101
|
];
|
|
105
102
|
|
|
106
|
-
await retryIssues("owner/repo", "auto-claude", issues, true);
|
|
103
|
+
await retryIssues("owner/repo", "auto-claude", issues, true, mockExecSafe);
|
|
107
104
|
expect(existsSync(issueDir)).toBe(false);
|
|
108
105
|
} finally {
|
|
109
106
|
process.chdir(originalCwd);
|
|
@@ -129,7 +126,7 @@ describe("retryIssues", () => {
|
|
|
129
126
|
},
|
|
130
127
|
];
|
|
131
128
|
|
|
132
|
-
await retryIssues("owner/repo", "auto-claude", issues, false);
|
|
129
|
+
await retryIssues("owner/repo", "auto-claude", issues, false, mockExecSafe);
|
|
133
130
|
expect(existsSync(issueDir)).toBe(true);
|
|
134
131
|
} finally {
|
|
135
132
|
process.chdir(originalCwd);
|
|
@@ -9,6 +9,7 @@ import prompts from "prompts";
|
|
|
9
9
|
import { BaseCommand } from "../base.js";
|
|
10
10
|
import { initConfig } from "../../lib/auto-claude/index.js";
|
|
11
11
|
import { LABELS, removeLabel, setLabel } from "../../lib/auto-claude/labels.js";
|
|
12
|
+
import type { ExecSafeFn } from "../../lib/auto-claude/labels.js";
|
|
12
13
|
import { getIssues, isGithubCliInstalled } from "../../utils/git/gh-cli-wrapper.js";
|
|
13
14
|
import type { Issue } from "../../utils/git/gh-cli-wrapper.js";
|
|
14
15
|
|
|
@@ -21,14 +22,15 @@ export async function retryIssues(
|
|
|
21
22
|
triggerLabel: string,
|
|
22
23
|
selected: Issue[],
|
|
23
24
|
clean: boolean,
|
|
25
|
+
exec?: ExecSafeFn,
|
|
24
26
|
): Promise<number> {
|
|
25
27
|
for (const issue of selected) {
|
|
26
28
|
consola.info(`Retrying issue #${issue.number}: ${issue.title}`);
|
|
27
29
|
|
|
28
|
-
await removeLabel(repo, issue.number, LABELS.failed);
|
|
30
|
+
await removeLabel(repo, issue.number, LABELS.failed, exec);
|
|
29
31
|
consola.success(` Removed '${LABELS.failed}' label`);
|
|
30
32
|
|
|
31
|
-
await setLabel(repo, issue.number, triggerLabel);
|
|
33
|
+
await setLabel(repo, issue.number, triggerLabel, exec);
|
|
32
34
|
consola.success(` Added '${triggerLabel}' label`);
|
|
33
35
|
|
|
34
36
|
if (clean) {
|
|
@@ -5,7 +5,8 @@ import pc from "picocolors";
|
|
|
5
5
|
|
|
6
6
|
import { getConfig } from "./config.js";
|
|
7
7
|
import { sleep } from "./shell.js";
|
|
8
|
-
import { spawnClaude } from "./spawn-claude.js";
|
|
8
|
+
import { spawnClaude as defaultSpawnClaude } from "./spawn-claude.js";
|
|
9
|
+
import type { SpawnClaudeFn } from "./spawn-claude.js";
|
|
9
10
|
|
|
10
11
|
// ── Claude CLI ──
|
|
11
12
|
|
|
@@ -16,14 +17,26 @@ export interface ClaudeResult {
|
|
|
16
17
|
num_turns: number;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
export interface ClaudeLogger {
|
|
21
|
+
info: (...args: unknown[]) => void;
|
|
22
|
+
warn: (...args: unknown[]) => void;
|
|
23
|
+
error: (...args: unknown[]) => void;
|
|
24
|
+
success: (...args: unknown[]) => void;
|
|
25
|
+
log: (...args: unknown[]) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
const PROCESS_RETRIES = 3;
|
|
20
29
|
const PROCESS_RETRY_DELAY_MS = 5_000;
|
|
21
30
|
|
|
22
31
|
export async function runClaude(opts: {
|
|
23
32
|
promptFile: string;
|
|
24
33
|
maxTurns?: number;
|
|
34
|
+
spawnFn?: SpawnClaudeFn;
|
|
35
|
+
logger?: ClaudeLogger;
|
|
25
36
|
}): Promise<ClaudeResult> {
|
|
26
37
|
const cfg = getConfig();
|
|
38
|
+
const spawnFn = opts.spawnFn ?? defaultSpawnClaude;
|
|
39
|
+
const log = opts.logger ?? consola;
|
|
27
40
|
const args = [
|
|
28
41
|
"-p",
|
|
29
42
|
"--output-format",
|
|
@@ -37,23 +50,21 @@ export async function runClaude(opts: {
|
|
|
37
50
|
`@${opts.promptFile}`,
|
|
38
51
|
];
|
|
39
52
|
|
|
40
|
-
|
|
41
|
-
`${pc.dim("▶")} Calling Claude${opts.maxTurns ? ` (max ${opts.maxTurns} turns)` : ""}…`,
|
|
42
|
-
);
|
|
53
|
+
log.info(`${pc.dim("▶")} Calling Claude${opts.maxTurns ? ` (max ${opts.maxTurns} turns)` : ""}…`);
|
|
43
54
|
|
|
44
55
|
let lastError: Error | undefined;
|
|
45
56
|
for (let attempt = 1; attempt <= PROCESS_RETRIES; attempt++) {
|
|
46
57
|
try {
|
|
47
|
-
const result = await runClaudeStreaming(args);
|
|
48
|
-
|
|
58
|
+
const result = await runClaudeStreaming(args, spawnFn, log);
|
|
59
|
+
log.success(`Done — ${result.num_turns} turns, $${result.total_cost_usd.toFixed(4)}`);
|
|
49
60
|
if (result.result) {
|
|
50
|
-
|
|
61
|
+
log.log(result.result);
|
|
51
62
|
}
|
|
52
63
|
return result;
|
|
53
64
|
} catch (err) {
|
|
54
65
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
55
66
|
if (attempt < PROCESS_RETRIES) {
|
|
56
|
-
|
|
67
|
+
log.warn(
|
|
57
68
|
`Claude process failed (attempt ${attempt}/${PROCESS_RETRIES}), retrying in ${PROCESS_RETRY_DELAY_MS / 1000}s…`,
|
|
58
69
|
);
|
|
59
70
|
await sleep(PROCESS_RETRY_DELAY_MS);
|
|
@@ -63,9 +74,13 @@ export async function runClaude(opts: {
|
|
|
63
74
|
throw lastError ?? new Error("runClaude failed after all retries");
|
|
64
75
|
}
|
|
65
76
|
|
|
66
|
-
function runClaudeStreaming(
|
|
77
|
+
function runClaudeStreaming(
|
|
78
|
+
args: string[],
|
|
79
|
+
spawnFn: SpawnClaudeFn,
|
|
80
|
+
log: ClaudeLogger,
|
|
81
|
+
): Promise<ClaudeResult> {
|
|
67
82
|
return new Promise((resolve, reject) => {
|
|
68
|
-
const proc =
|
|
83
|
+
const proc = spawnFn(args);
|
|
69
84
|
let capturedResult: ClaudeResult | null = null;
|
|
70
85
|
let turnCount = 0;
|
|
71
86
|
|
|
@@ -80,7 +95,7 @@ function runClaudeStreaming(args: string[]): Promise<ClaudeResult> {
|
|
|
80
95
|
if (!line.trim()) return;
|
|
81
96
|
try {
|
|
82
97
|
const event = JSON.parse(line) as Record<string, unknown>;
|
|
83
|
-
handleStreamEvent(event, (turns) => {
|
|
98
|
+
handleStreamEvent(event, log, (turns) => {
|
|
84
99
|
turnCount = turns;
|
|
85
100
|
});
|
|
86
101
|
|
|
@@ -146,14 +161,18 @@ function toolDetail(block: Record<string, unknown>): string {
|
|
|
146
161
|
return "";
|
|
147
162
|
}
|
|
148
163
|
|
|
149
|
-
function logToolUse(block: Record<string, unknown
|
|
164
|
+
function logToolUse(block: Record<string, unknown>, log: ClaudeLogger): void {
|
|
150
165
|
const name = block.name;
|
|
151
166
|
if (typeof name === "string") {
|
|
152
|
-
|
|
167
|
+
log.info(` ${pc.dim("\u21B3")} ${name}${toolDetail(block)}`);
|
|
153
168
|
}
|
|
154
169
|
}
|
|
155
170
|
|
|
156
|
-
function handleStreamEvent(
|
|
171
|
+
function handleStreamEvent(
|
|
172
|
+
event: Record<string, unknown>,
|
|
173
|
+
log: ClaudeLogger,
|
|
174
|
+
onTurn: (count: number) => void,
|
|
175
|
+
): void {
|
|
157
176
|
// Only handle stream_event — assistant turn events duplicate the same tools
|
|
158
177
|
if (event.type === "stream_event" && typeof event.event === "object" && event.event !== null) {
|
|
159
178
|
const inner = event.event as Record<string, unknown>;
|
|
@@ -165,13 +184,13 @@ function handleStreamEvent(event: Record<string, unknown>, onTurn: (count: numbe
|
|
|
165
184
|
) {
|
|
166
185
|
const block = inner.content_block as Record<string, unknown>;
|
|
167
186
|
if (block.type === "tool_use") {
|
|
168
|
-
logToolUse(block);
|
|
187
|
+
logToolUse(block, log);
|
|
169
188
|
} else if (block.type === "thinking") {
|
|
170
189
|
const thinkingText =
|
|
171
190
|
typeof block.thinking === "string" && block.thinking.length > 0
|
|
172
191
|
? pc.dim(` ${truncate(block.thinking.split("\n")[0].trim(), 60)}`)
|
|
173
192
|
: "";
|
|
174
|
-
|
|
193
|
+
log.info(` ${pc.dim("\u21B3")} ${pc.italic("thinking")}${thinkingText}`);
|
|
175
194
|
}
|
|
176
195
|
}
|
|
177
196
|
}
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import { describe, expect, it, vi
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import type { ExecSafeFn } from "./labels";
|
|
4
4
|
import { ensureLabelsExist, LABELS, removeLabel, setLabel } from "./labels";
|
|
5
5
|
|
|
6
|
-
vi.
|
|
7
|
-
execSafe: vi.fn().mockResolvedValue({ stdout: "", ok: true }),
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
const mockedExecSafe = vi.mocked(execSafe);
|
|
6
|
+
const mockExecSafe: ExecSafeFn = vi.fn().mockResolvedValue({ stdout: "", ok: true });
|
|
11
7
|
|
|
12
8
|
describe("LABELS", () => {
|
|
13
9
|
it("has expected label values", () => {
|
|
@@ -28,11 +24,11 @@ describe("ensureLabelsExist", () => {
|
|
|
28
24
|
});
|
|
29
25
|
|
|
30
26
|
it("creates all labels with --force", async () => {
|
|
31
|
-
await ensureLabelsExist("owner/repo");
|
|
27
|
+
await ensureLabelsExist("owner/repo", mockExecSafe);
|
|
32
28
|
|
|
33
|
-
expect(
|
|
29
|
+
expect(mockExecSafe).toHaveBeenCalledTimes(4);
|
|
34
30
|
for (const label of Object.values(LABELS)) {
|
|
35
|
-
expect(
|
|
31
|
+
expect(mockExecSafe).toHaveBeenCalledWith("gh", [
|
|
36
32
|
"label",
|
|
37
33
|
"create",
|
|
38
34
|
label,
|
|
@@ -50,9 +46,9 @@ describe("setLabel", () => {
|
|
|
50
46
|
});
|
|
51
47
|
|
|
52
48
|
it("calls gh issue edit with --add-label", async () => {
|
|
53
|
-
await setLabel("owner/repo", 42, "auto-claude-in-progress");
|
|
49
|
+
await setLabel("owner/repo", 42, "auto-claude-in-progress", mockExecSafe);
|
|
54
50
|
|
|
55
|
-
expect(
|
|
51
|
+
expect(mockExecSafe).toHaveBeenCalledWith("gh", [
|
|
56
52
|
"issue",
|
|
57
53
|
"edit",
|
|
58
54
|
"42",
|
|
@@ -70,9 +66,9 @@ describe("removeLabel", () => {
|
|
|
70
66
|
});
|
|
71
67
|
|
|
72
68
|
it("calls gh issue edit with --remove-label", async () => {
|
|
73
|
-
await removeLabel("owner/repo", 42, "auto-claude-failed");
|
|
69
|
+
await removeLabel("owner/repo", 42, "auto-claude-failed", mockExecSafe);
|
|
74
70
|
|
|
75
|
-
expect(
|
|
71
|
+
expect(mockExecSafe).toHaveBeenCalledWith("gh", [
|
|
76
72
|
"issue",
|
|
77
73
|
"edit",
|
|
78
74
|
"42",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execSafe } from "../../utils/git/exec.js";
|
|
1
|
+
import { execSafe as defaultExecSafe } from "../../utils/git/exec.js";
|
|
2
2
|
|
|
3
3
|
// ── Label helpers ──
|
|
4
4
|
|
|
@@ -9,34 +9,33 @@ export const LABELS = {
|
|
|
9
9
|
success: "auto-claude-success",
|
|
10
10
|
} as const;
|
|
11
11
|
|
|
12
|
-
export
|
|
12
|
+
export type ExecSafeFn = (cmd: string, args: string[]) => Promise<{ stdout: string; ok: boolean }>;
|
|
13
|
+
|
|
14
|
+
export async function ensureLabelsExist(
|
|
15
|
+
repo: string,
|
|
16
|
+
exec: ExecSafeFn = defaultExecSafe,
|
|
17
|
+
): Promise<void> {
|
|
13
18
|
await Promise.all(
|
|
14
19
|
Object.values(LABELS).map((label) =>
|
|
15
|
-
|
|
20
|
+
exec("gh", ["label", "create", label, "--repo", repo, "--force"]),
|
|
16
21
|
),
|
|
17
22
|
);
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
export async function setLabel(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"--add-label",
|
|
28
|
-
label,
|
|
29
|
-
]);
|
|
25
|
+
export async function setLabel(
|
|
26
|
+
repo: string,
|
|
27
|
+
issueNumber: number,
|
|
28
|
+
label: string,
|
|
29
|
+
exec: ExecSafeFn = defaultExecSafe,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
await exec("gh", ["issue", "edit", String(issueNumber), "--repo", repo, "--add-label", label]);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
export async function removeLabel(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"--remove-label",
|
|
40
|
-
label,
|
|
41
|
-
]);
|
|
34
|
+
export async function removeLabel(
|
|
35
|
+
repo: string,
|
|
36
|
+
issueNumber: number,
|
|
37
|
+
label: string,
|
|
38
|
+
exec: ExecSafeFn = defaultExecSafe,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
await exec("gh", ["issue", "edit", String(issueNumber), "--repo", repo, "--remove-label", label]);
|
|
42
41
|
}
|
|
@@ -9,50 +9,26 @@ import { initConfig } from "./config";
|
|
|
9
9
|
import { ARTIFACTS } from "./prompt-templates/index";
|
|
10
10
|
import {
|
|
11
11
|
buildTestContext,
|
|
12
|
-
|
|
12
|
+
createMockClaudeProcess,
|
|
13
13
|
createTestRepoWithRemote,
|
|
14
14
|
errorClaudeJson,
|
|
15
15
|
successClaudeJson,
|
|
16
16
|
} from "./test-helpers";
|
|
17
17
|
import type { MockClaudeImpl, TestRepo } from "./test-helpers";
|
|
18
18
|
import type { IssueContext } from "./utils";
|
|
19
|
+
import type { SpawnClaudeFn } from "./spawn-claude";
|
|
20
|
+
import type { ExecSafeFn } from "./labels";
|
|
19
21
|
|
|
20
22
|
consola.level = -999;
|
|
21
23
|
|
|
22
|
-
let mockClaudeImpl: MockClaudeImpl = null;
|
|
23
|
-
vi.mock("./spawn-claude", () => createSpawnClaudeMock(() => mockClaudeImpl));
|
|
24
|
-
|
|
25
|
-
// Track gh calls for label assertions
|
|
26
|
-
let ghCalls: string[][] = [];
|
|
27
|
-
|
|
28
|
-
vi.mock("tinyexec", async (importOriginal) => {
|
|
29
|
-
const original = await importOriginal<typeof import("tinyexec")>();
|
|
30
|
-
return {
|
|
31
|
-
...original,
|
|
32
|
-
x: vi.fn(
|
|
33
|
-
async (
|
|
34
|
-
cmd: string,
|
|
35
|
-
args: string[],
|
|
36
|
-
opts?: Record<string, unknown>,
|
|
37
|
-
): Promise<{ stdout: string; exitCode: number }> => {
|
|
38
|
-
if (cmd === "gh") {
|
|
39
|
-
ghCalls.push(args);
|
|
40
|
-
// Return empty success for label/issue/pr commands
|
|
41
|
-
return { stdout: "[]", exitCode: 0 };
|
|
42
|
-
}
|
|
43
|
-
return original.x(cmd, args, opts as never) as unknown as Promise<{
|
|
44
|
-
stdout: string;
|
|
45
|
-
exitCode: number;
|
|
46
|
-
}>;
|
|
47
|
-
},
|
|
48
|
-
),
|
|
49
|
-
};
|
|
50
|
-
});
|
|
51
|
-
|
|
52
24
|
describe("runPipeline", () => {
|
|
53
25
|
let originalCwd: string;
|
|
54
26
|
let repo: TestRepo;
|
|
55
27
|
let ctx: IssueContext;
|
|
28
|
+
let mockClaudeImpl: MockClaudeImpl;
|
|
29
|
+
let ghCalls: string[][];
|
|
30
|
+
let mockSpawnFn: SpawnClaudeFn;
|
|
31
|
+
let mockExec: ExecSafeFn;
|
|
56
32
|
|
|
57
33
|
beforeEach(async () => {
|
|
58
34
|
originalCwd = process.cwd();
|
|
@@ -66,6 +42,24 @@ describe("runPipeline", () => {
|
|
|
66
42
|
ctx = buildTestContext(repo.dir);
|
|
67
43
|
mockClaudeImpl = null;
|
|
68
44
|
ghCalls = [];
|
|
45
|
+
|
|
46
|
+
mockSpawnFn = vi.fn((args: string[]) => {
|
|
47
|
+
if (mockClaudeImpl) {
|
|
48
|
+
const { stdout, exitCode } = mockClaudeImpl(args);
|
|
49
|
+
return createMockClaudeProcess(stdout, exitCode);
|
|
50
|
+
}
|
|
51
|
+
throw new Error("Unexpected spawnClaude call -- set mockClaudeImpl");
|
|
52
|
+
}) as SpawnClaudeFn;
|
|
53
|
+
|
|
54
|
+
mockExec = vi.fn(async (cmd: string, args: string[]) => {
|
|
55
|
+
if (cmd === "gh") {
|
|
56
|
+
ghCalls.push(args);
|
|
57
|
+
return { stdout: "[]", ok: true };
|
|
58
|
+
}
|
|
59
|
+
// Pass through non-gh commands to real exec
|
|
60
|
+
const { execSafe } = await import("../../utils/git/exec");
|
|
61
|
+
return execSafe(cmd, args);
|
|
62
|
+
}) as ExecSafeFn;
|
|
69
63
|
});
|
|
70
64
|
|
|
71
65
|
afterEach(() => {
|
|
@@ -78,7 +72,7 @@ describe("runPipeline", () => {
|
|
|
78
72
|
|
|
79
73
|
mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
|
|
80
74
|
|
|
81
|
-
await runPipeline(ctx);
|
|
75
|
+
await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
|
|
82
76
|
|
|
83
77
|
const ramblingsPath = join(ctx.issueDir, ARTIFACTS.initialRamblings);
|
|
84
78
|
expect(existsSync(ramblingsPath)).toBe(true);
|
|
@@ -97,7 +91,7 @@ describe("runPipeline", () => {
|
|
|
97
91
|
|
|
98
92
|
mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
|
|
99
93
|
|
|
100
|
-
await runPipeline(ctx);
|
|
94
|
+
await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
|
|
101
95
|
|
|
102
96
|
const content = readFileSync(ramblingsPath, "utf-8");
|
|
103
97
|
expect(content).toBe("# Existing ramblings");
|
|
@@ -116,7 +110,7 @@ describe("runPipeline", () => {
|
|
|
116
110
|
return { stdout: successClaudeJson(), exitCode: 0 };
|
|
117
111
|
};
|
|
118
112
|
|
|
119
|
-
await runPipeline(ctx, "plan");
|
|
113
|
+
await runPipeline(ctx, "plan", { spawnFn: mockSpawnFn, exec: mockExec });
|
|
120
114
|
|
|
121
115
|
expect(claudeCallCount).toBe(1);
|
|
122
116
|
});
|
|
@@ -126,7 +120,7 @@ describe("runPipeline", () => {
|
|
|
126
120
|
|
|
127
121
|
mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
|
|
128
122
|
|
|
129
|
-
await runPipeline(ctx);
|
|
123
|
+
await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
|
|
130
124
|
|
|
131
125
|
const currentBranch = execSync("git branch --show-current", {
|
|
132
126
|
cwd: repo.dir,
|
|
@@ -160,7 +154,7 @@ describe("runPipeline", () => {
|
|
|
160
154
|
return { stdout: successClaudeJson(), exitCode: 0 };
|
|
161
155
|
};
|
|
162
156
|
|
|
163
|
-
await runPipeline(ctx);
|
|
157
|
+
await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
|
|
164
158
|
|
|
165
159
|
expect(claudeCallCount).toBe(4);
|
|
166
160
|
|
|
@@ -209,7 +203,7 @@ describe("runPipeline", () => {
|
|
|
209
203
|
return { stdout: successClaudeJson(), exitCode: 0 };
|
|
210
204
|
};
|
|
211
205
|
|
|
212
|
-
await runPipeline(ctx);
|
|
206
|
+
await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
|
|
213
207
|
|
|
214
208
|
// 1 plan + 3 steps * 2 attempts = 7
|
|
215
209
|
expect(claudeCallCount).toBe(7);
|
|
@@ -245,7 +239,7 @@ describe("runPipeline", () => {
|
|
|
245
239
|
return { stdout: successClaudeJson(), exitCode: 0 };
|
|
246
240
|
};
|
|
247
241
|
|
|
248
|
-
await runPipeline(ctx);
|
|
242
|
+
await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
|
|
249
243
|
|
|
250
244
|
// 1 plan + 3 steps * 3 attempts (maxReviewRetries=2 → 3 total) = 10
|
|
251
245
|
expect(claudeCallCount).toBe(10);
|
|
@@ -280,7 +274,7 @@ describe("runPipeline", () => {
|
|
|
280
274
|
return { stdout: successClaudeJson(), exitCode: 0 };
|
|
281
275
|
};
|
|
282
276
|
|
|
283
|
-
await runPipeline(ctx, "implement");
|
|
277
|
+
await runPipeline(ctx, "implement", { spawnFn: mockSpawnFn, exec: mockExec });
|
|
284
278
|
|
|
285
279
|
expect(claudeCallCount).toBe(2);
|
|
286
280
|
});
|
|
@@ -8,16 +8,29 @@ import { createPr } from "./steps/create-pr.js";
|
|
|
8
8
|
import { stepImplement } from "./steps/implement.js";
|
|
9
9
|
import { stepPlan, stepReview, stepSimplify } from "./steps/simple-steps.js";
|
|
10
10
|
import { LABELS, ensureLabelsExist, removeLabel, setLabel } from "./labels.js";
|
|
11
|
+
import type { ExecSafeFn } from "./labels.js";
|
|
11
12
|
import { ensureDir, fileExists, readFile, writeFile } from "../../utils/fs.js";
|
|
12
13
|
import { execSafe, git } from "../../utils/git/exec.js";
|
|
13
14
|
import { ghRaw } from "../../utils/git/gh-cli-wrapper.js";
|
|
14
15
|
import { log } from "./utils.js";
|
|
15
16
|
import type { IssueContext } from "./utils.js";
|
|
17
|
+
import type { SpawnClaudeFn } from "./spawn-claude.js";
|
|
16
18
|
|
|
17
19
|
export { type StepName, STEP_NAMES } from "./prompt-templates/index.js";
|
|
18
20
|
|
|
19
|
-
export
|
|
21
|
+
export interface PipelineDeps {
|
|
22
|
+
spawnFn?: SpawnClaudeFn;
|
|
23
|
+
exec?: ExecSafeFn;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runPipeline(
|
|
27
|
+
ctx: IssueContext,
|
|
28
|
+
untilStep?: StepName,
|
|
29
|
+
deps?: PipelineDeps,
|
|
30
|
+
): Promise<void> {
|
|
20
31
|
const cfg = getConfig();
|
|
32
|
+
const exec = deps?.exec;
|
|
33
|
+
const spawnFn = deps?.spawnFn;
|
|
21
34
|
log(`Pipeline starting for ${ctx.repo}#${ctx.number}: ${ctx.title}`);
|
|
22
35
|
|
|
23
36
|
ensureDir(ctx.issueDir);
|
|
@@ -29,14 +42,14 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
|
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
// Label management
|
|
32
|
-
await ensureLabelsExist(ctx.repo);
|
|
33
|
-
await removeLabel(ctx.repo, ctx.number, cfg.triggerLabel);
|
|
34
|
-
await setLabel(ctx.repo, ctx.number, LABELS.inProgress);
|
|
45
|
+
await ensureLabelsExist(ctx.repo, exec);
|
|
46
|
+
await removeLabel(ctx.repo, ctx.number, cfg.triggerLabel, exec);
|
|
47
|
+
await setLabel(ctx.repo, ctx.number, LABELS.inProgress, exec);
|
|
35
48
|
|
|
36
49
|
try {
|
|
37
50
|
// Step 1: Plan (runs once)
|
|
38
|
-
if (!(await stepPlan(ctx))) {
|
|
39
|
-
await handleFailure(ctx, "plan");
|
|
51
|
+
if (!(await stepPlan(ctx, spawnFn))) {
|
|
52
|
+
await handleFailure(ctx, "plan", undefined, exec);
|
|
40
53
|
return;
|
|
41
54
|
}
|
|
42
55
|
if (untilStep === "plan") {
|
|
@@ -55,8 +68,8 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
|
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
// Implement
|
|
58
|
-
if (!(await stepImplement(ctx))) {
|
|
59
|
-
await handleFailure(ctx, "implement");
|
|
71
|
+
if (!(await stepImplement(ctx, spawnFn))) {
|
|
72
|
+
await handleFailure(ctx, "implement", undefined, exec);
|
|
60
73
|
return;
|
|
61
74
|
}
|
|
62
75
|
if (untilStep === "implement") {
|
|
@@ -65,8 +78,8 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
|
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
// Simplify
|
|
68
|
-
if (!(await stepSimplify(ctx))) {
|
|
69
|
-
await handleFailure(ctx, "simplify");
|
|
81
|
+
if (!(await stepSimplify(ctx, spawnFn))) {
|
|
82
|
+
await handleFailure(ctx, "simplify", undefined, exec);
|
|
70
83
|
return;
|
|
71
84
|
}
|
|
72
85
|
if (untilStep === "simplify") {
|
|
@@ -75,8 +88,8 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
|
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
// Review
|
|
78
|
-
if (!(await stepReview(ctx))) {
|
|
79
|
-
await handleFailure(ctx, "review");
|
|
91
|
+
if (!(await stepReview(ctx, spawnFn))) {
|
|
92
|
+
await handleFailure(ctx, "review", undefined, exec);
|
|
80
93
|
return;
|
|
81
94
|
}
|
|
82
95
|
if (untilStep === "review") {
|
|
@@ -86,10 +99,10 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
|
|
|
86
99
|
|
|
87
100
|
// Check review result
|
|
88
101
|
if (isReviewPass(ctx)) {
|
|
89
|
-
const prUrl = await createPr(ctx);
|
|
90
|
-
await removeLabel(ctx.repo, ctx.number, LABELS.inProgress);
|
|
91
|
-
await setLabel(ctx.repo, ctx.number, LABELS.success);
|
|
92
|
-
await setLabel(ctx.repo, ctx.number, LABELS.review);
|
|
102
|
+
const prUrl = await createPr(ctx, exec);
|
|
103
|
+
await removeLabel(ctx.repo, ctx.number, LABELS.inProgress, exec);
|
|
104
|
+
await setLabel(ctx.repo, ctx.number, LABELS.success, exec);
|
|
105
|
+
await setLabel(ctx.repo, ctx.number, LABELS.review, exec);
|
|
93
106
|
log(`Pipeline complete for ${ctx.repo}#${ctx.number} — ${prUrl}`);
|
|
94
107
|
return;
|
|
95
108
|
}
|
|
@@ -107,6 +120,7 @@ export async function runPipeline(ctx: IssueContext, untilStep?: StepName): Prom
|
|
|
107
120
|
ctx,
|
|
108
121
|
"review",
|
|
109
122
|
`auto-claude: review did not pass after ${maxRetries + 1} attempts. Labelled \`${LABELS.failed}\`.`,
|
|
123
|
+
exec,
|
|
110
124
|
);
|
|
111
125
|
} finally {
|
|
112
126
|
await checkoutMain();
|
|
@@ -125,11 +139,19 @@ function isReviewPass(ctx: IssueContext): boolean {
|
|
|
125
139
|
return firstLine === "PASS";
|
|
126
140
|
}
|
|
127
141
|
|
|
128
|
-
async function handleFailure(
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
async function handleFailure(
|
|
143
|
+
ctx: IssueContext,
|
|
144
|
+
stepName: string,
|
|
145
|
+
comment?: string,
|
|
146
|
+
exec?: ExecSafeFn,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
await removeLabel(ctx.repo, ctx.number, LABELS.inProgress, exec);
|
|
149
|
+
await setLabel(ctx.repo, ctx.number, LABELS.failed, exec);
|
|
131
150
|
if (comment) {
|
|
132
|
-
await ghRaw(
|
|
151
|
+
await ghRaw(
|
|
152
|
+
["issue", "comment", String(ctx.number), "--repo", ctx.repo, "--body", comment],
|
|
153
|
+
exec,
|
|
154
|
+
);
|
|
133
155
|
}
|
|
134
156
|
log(`Pipeline stopped at "${stepName}" for ${ctx.repo}#${ctx.number}`);
|
|
135
157
|
}
|