@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.66",
3
+ "version": "0.0.67",
4
4
  "description": "One off quality of life scripts that I use on a daily basis.",
5
5
  "homepage": "https://github.com/ChrisTowles/towles-tool#readme",
6
6
  "bugs": {
@@ -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
- // Mock tinyexec so setLabel/removeLabel (which use execSafe -> x) work
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(x)
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
- consola.info(
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
- consola.success(`Done — ${result.num_turns} turns, $${result.total_cost_usd.toFixed(4)}`);
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
- consola.log(result.result);
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
- consola.warn(
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(args: string[]): Promise<ClaudeResult> {
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 = spawnClaude(args);
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>): void {
164
+ function logToolUse(block: Record<string, unknown>, log: ClaudeLogger): void {
150
165
  const name = block.name;
151
166
  if (typeof name === "string") {
152
- consola.info(` ${pc.dim("\u21B3")} ${name}${toolDetail(block)}`);
167
+ log.info(` ${pc.dim("\u21B3")} ${name}${toolDetail(block)}`);
153
168
  }
154
169
  }
155
170
 
156
- function handleStreamEvent(event: Record<string, unknown>, onTurn: (count: number) => void): void {
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
- consola.info(` ${pc.dim("\u21B3")} ${pc.italic("thinking")}${thinkingText}`);
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, beforeEach } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- import { execSafe } from "../../utils/git/exec.js";
3
+ import type { ExecSafeFn } from "./labels";
4
4
  import { ensureLabelsExist, LABELS, removeLabel, setLabel } from "./labels";
5
5
 
6
- vi.mock("../../utils/git/exec.js", () => ({
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(mockedExecSafe).toHaveBeenCalledTimes(4);
29
+ expect(mockExecSafe).toHaveBeenCalledTimes(4);
34
30
  for (const label of Object.values(LABELS)) {
35
- expect(mockedExecSafe).toHaveBeenCalledWith("gh", [
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(mockedExecSafe).toHaveBeenCalledWith("gh", [
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(mockedExecSafe).toHaveBeenCalledWith("gh", [
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 async function ensureLabelsExist(repo: string): Promise<void> {
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
- execSafe("gh", ["label", "create", label, "--repo", repo, "--force"]),
20
+ exec("gh", ["label", "create", label, "--repo", repo, "--force"]),
16
21
  ),
17
22
  );
18
23
  }
19
24
 
20
- export async function setLabel(repo: string, issueNumber: number, label: string): Promise<void> {
21
- await execSafe("gh", [
22
- "issue",
23
- "edit",
24
- String(issueNumber),
25
- "--repo",
26
- repo,
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(repo: string, issueNumber: number, label: string): Promise<void> {
33
- await execSafe("gh", [
34
- "issue",
35
- "edit",
36
- String(issueNumber),
37
- "--repo",
38
- repo,
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
- createSpawnClaudeMock,
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 async function runPipeline(ctx: IssueContext, untilStep?: StepName): Promise<void> {
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(ctx: IssueContext, stepName: string, comment?: string): Promise<void> {
129
- await removeLabel(ctx.repo, ctx.number, LABELS.inProgress);
130
- await setLabel(ctx.repo, ctx.number, LABELS.failed);
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(["issue", "comment", String(ctx.number), "--repo", ctx.repo, "--body", comment]);
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
  }