@towles/tool 0.0.66 → 0.0.68
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/index.ts +41 -3
- 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/index.ts +1 -0
- 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
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { rmSync } from "node:fs";
|
|
1
|
+
import { rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
3
4
|
|
|
4
|
-
import { Flags } from "@oclif/core";
|
|
5
|
+
import { Args, Flags } from "@oclif/core";
|
|
5
6
|
import consola from "consola";
|
|
6
7
|
|
|
7
8
|
import { BaseCommand } from "../base.js";
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
initConfig,
|
|
15
16
|
log,
|
|
16
17
|
logBanner,
|
|
18
|
+
runClaude,
|
|
17
19
|
runPipeline,
|
|
18
20
|
sleep,
|
|
19
21
|
} from "../../lib/auto-claude/index.js";
|
|
@@ -24,7 +26,18 @@ export default class AutoClaude extends BaseCommand {
|
|
|
24
26
|
|
|
25
27
|
static override description = "Automated issue-to-PR pipeline using Claude Code";
|
|
26
28
|
|
|
29
|
+
static override args = {
|
|
30
|
+
prompt: Args.string({
|
|
31
|
+
description: "Run a single prompt (skips issue pipeline)",
|
|
32
|
+
required: false,
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
|
|
27
36
|
static override examples = [
|
|
37
|
+
{
|
|
38
|
+
description: "Run a single prompt",
|
|
39
|
+
command: '<%= config.bin %> auto-claude "Fix the login bug in auth.ts"',
|
|
40
|
+
},
|
|
28
41
|
{
|
|
29
42
|
description: "Process a specific issue",
|
|
30
43
|
command: "<%= config.bin %> auto-claude --issue 42",
|
|
@@ -49,6 +62,10 @@ export default class AutoClaude extends BaseCommand {
|
|
|
49
62
|
|
|
50
63
|
static override flags = {
|
|
51
64
|
...BaseCommand.baseFlags,
|
|
65
|
+
"max-turns": Flags.integer({
|
|
66
|
+
description: "Maximum conversation turns for prompt mode (default: 10)",
|
|
67
|
+
default: 10,
|
|
68
|
+
}),
|
|
52
69
|
issue: Flags.integer({
|
|
53
70
|
char: "i",
|
|
54
71
|
description: "Process a specific issue number",
|
|
@@ -88,8 +105,29 @@ export default class AutoClaude extends BaseCommand {
|
|
|
88
105
|
};
|
|
89
106
|
|
|
90
107
|
async run(): Promise<void> {
|
|
91
|
-
const { flags } = await this.parse(AutoClaude);
|
|
108
|
+
const { args, flags } = await this.parse(AutoClaude);
|
|
109
|
+
|
|
110
|
+
// Prompt mode: run a single prompt with structured output, skip issue pipeline
|
|
111
|
+
if (args.prompt) {
|
|
112
|
+
await initConfig({ model: flags.model });
|
|
113
|
+
|
|
114
|
+
const promptDir = join(tmpdir(), "tt-auto-claude");
|
|
115
|
+
mkdirSync(promptDir, { recursive: true });
|
|
116
|
+
const promptFile = join(promptDir, `prompt-${Date.now()}.md`);
|
|
117
|
+
writeFileSync(promptFile, args.prompt);
|
|
118
|
+
|
|
119
|
+
const result = await runClaude({
|
|
120
|
+
promptFile,
|
|
121
|
+
maxTurns: flags["max-turns"],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (result.is_error) {
|
|
125
|
+
this.error("Claude reported an error", { exit: 1 });
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
92
129
|
|
|
130
|
+
// Issue pipeline mode
|
|
93
131
|
const cfg = await initConfig({
|
|
94
132
|
triggerLabel: flags.label,
|
|
95
133
|
mainBranch: flags["main-branch"],
|
|
@@ -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,4 +1,5 @@
|
|
|
1
1
|
export { type AutoClaudeConfig, AutoClaudeConfigSchema, getConfig, initConfig } from "./config.js";
|
|
2
|
+
export { type ClaudeResult, runClaude } from "./claude-cli.js";
|
|
2
3
|
export { STEP_NAMES, runPipeline } from "./pipeline.js";
|
|
3
4
|
export type { StepName } from "./prompt-templates/index.js";
|
|
4
5
|
export { git } from "../../utils/git/exec.js";
|
|
@@ -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
|
});
|