@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
|
@@ -1,22 +1,21 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
1
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
3
2
|
|
|
4
3
|
import { resolveTemplate } from "./templates";
|
|
5
|
-
import type { TokenValues } from "./templates";
|
|
4
|
+
import type { TemplateFsDeps, TokenValues } from "./templates";
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const mockedWriteFileSync = vi.mocked(writeFileSync);
|
|
15
|
-
const mockedMkdirSync = vi.mocked(mkdirSync);
|
|
6
|
+
function createMockFs(): TemplateFsDeps {
|
|
7
|
+
return {
|
|
8
|
+
readFileSync: vi.fn().mockReturnValue(""),
|
|
9
|
+
writeFileSync: vi.fn(),
|
|
10
|
+
mkdirSync: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
16
13
|
|
|
17
14
|
describe("resolveTemplate", () => {
|
|
15
|
+
let mockFs: TemplateFsDeps;
|
|
16
|
+
|
|
18
17
|
beforeEach(() => {
|
|
19
|
-
|
|
18
|
+
mockFs = createMockFs();
|
|
20
19
|
});
|
|
21
20
|
|
|
22
21
|
const tokens: TokenValues = {
|
|
@@ -26,13 +25,13 @@ describe("resolveTemplate", () => {
|
|
|
26
25
|
};
|
|
27
26
|
|
|
28
27
|
it("replaces all token placeholders in template", () => {
|
|
29
|
-
|
|
28
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue(
|
|
30
29
|
"Scope: {{SCOPE_PATH}}\nDir: {{ISSUE_DIR}}\nBranch: {{MAIN_BRANCH}}",
|
|
31
30
|
);
|
|
32
31
|
|
|
33
|
-
resolveTemplate("plan.md", tokens, "/tmp/issues/42");
|
|
32
|
+
resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
|
|
34
33
|
|
|
35
|
-
const writtenContent =
|
|
34
|
+
const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
|
|
36
35
|
expect(writtenContent).toContain("/home/user/project");
|
|
37
36
|
expect(writtenContent).toContain("/tmp/issues/42");
|
|
38
37
|
expect(writtenContent).toContain("main");
|
|
@@ -44,28 +43,28 @@ describe("resolveTemplate", () => {
|
|
|
44
43
|
...tokens,
|
|
45
44
|
REVIEW_FEEDBACK: "Needs more tests",
|
|
46
45
|
};
|
|
47
|
-
|
|
46
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue("Feedback: {{REVIEW_FEEDBACK}}");
|
|
48
47
|
|
|
49
|
-
resolveTemplate("review.md", tokensWithFeedback, "/tmp/issues/42");
|
|
48
|
+
resolveTemplate("review.md", tokensWithFeedback, "/tmp/issues/42", mockFs);
|
|
50
49
|
|
|
51
|
-
const writtenContent =
|
|
50
|
+
const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
|
|
52
51
|
expect(writtenContent).toContain("Needs more tests");
|
|
53
52
|
});
|
|
54
53
|
|
|
55
54
|
it("creates output directory recursively", () => {
|
|
56
|
-
|
|
55
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue("template content");
|
|
57
56
|
|
|
58
|
-
resolveTemplate("plan.md", tokens, "/tmp/issues/42");
|
|
57
|
+
resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
|
|
59
58
|
|
|
60
|
-
expect(
|
|
59
|
+
expect(mockFs.mkdirSync).toHaveBeenCalledWith("/tmp/issues/42", { recursive: true });
|
|
61
60
|
});
|
|
62
61
|
|
|
63
62
|
it("writes resolved template to issue dir", () => {
|
|
64
|
-
|
|
63
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue("simple content");
|
|
65
64
|
|
|
66
|
-
resolveTemplate("plan.md", tokens, "/tmp/issues/42");
|
|
65
|
+
resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
|
|
67
66
|
|
|
68
|
-
expect(
|
|
67
|
+
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
|
69
68
|
"/tmp/issues/42/plan.md",
|
|
70
69
|
"simple content",
|
|
71
70
|
"utf-8",
|
|
@@ -73,19 +72,19 @@ describe("resolveTemplate", () => {
|
|
|
73
72
|
});
|
|
74
73
|
|
|
75
74
|
it("returns relative path from cwd", () => {
|
|
76
|
-
|
|
75
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue("content");
|
|
77
76
|
|
|
78
|
-
const result = resolveTemplate("plan.md", tokens, "/tmp/issues/42");
|
|
77
|
+
const result = resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
|
|
79
78
|
// Should be a relative path (not starting with /)
|
|
80
79
|
expect(result).not.toMatch(/^\/tmp/);
|
|
81
80
|
});
|
|
82
81
|
|
|
83
82
|
it("handles multiple occurrences of the same token", () => {
|
|
84
|
-
|
|
83
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue("{{MAIN_BRANCH}} and {{MAIN_BRANCH}} again");
|
|
85
84
|
|
|
86
|
-
resolveTemplate("plan.md", tokens, "/tmp/issues/42");
|
|
85
|
+
resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
|
|
87
86
|
|
|
88
|
-
const writtenContent =
|
|
87
|
+
const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
|
|
89
88
|
expect(writtenContent).toBe("main and main again");
|
|
90
89
|
});
|
|
91
90
|
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync as defaultMkdirSync,
|
|
3
|
+
readFileSync as defaultReadFileSync,
|
|
4
|
+
writeFileSync as defaultWriteFileSync,
|
|
5
|
+
} from "node:fs";
|
|
2
6
|
import { dirname, join, relative } from "node:path";
|
|
3
7
|
import { fileURLToPath } from "node:url";
|
|
4
8
|
|
|
@@ -14,21 +18,34 @@ export interface TokenValues {
|
|
|
14
18
|
REVIEW_FEEDBACK?: string;
|
|
15
19
|
}
|
|
16
20
|
|
|
21
|
+
export interface TemplateFsDeps {
|
|
22
|
+
readFileSync: typeof defaultReadFileSync;
|
|
23
|
+
writeFileSync: typeof defaultWriteFileSync;
|
|
24
|
+
mkdirSync: typeof defaultMkdirSync;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const defaultFsDeps: TemplateFsDeps = {
|
|
28
|
+
readFileSync: defaultReadFileSync,
|
|
29
|
+
writeFileSync: defaultWriteFileSync,
|
|
30
|
+
mkdirSync: defaultMkdirSync,
|
|
31
|
+
};
|
|
32
|
+
|
|
17
33
|
export function resolveTemplate(
|
|
18
34
|
templateName: string,
|
|
19
35
|
tokens: TokenValues,
|
|
20
36
|
issueDir: string,
|
|
37
|
+
fs: TemplateFsDeps = defaultFsDeps,
|
|
21
38
|
): string {
|
|
22
39
|
const templatePath = join(TEMPLATES_DIR, templateName);
|
|
23
|
-
let template = readFileSync(templatePath, "utf-8");
|
|
40
|
+
let template = fs.readFileSync(templatePath, "utf-8") as string;
|
|
24
41
|
|
|
25
42
|
for (const [key, value] of Object.entries(tokens)) {
|
|
26
43
|
template = template.replaceAll(`{{${key}}}`, value);
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
const resolvedPath = join(issueDir, templateName);
|
|
30
|
-
mkdirSync(dirname(resolvedPath), { recursive: true });
|
|
31
|
-
writeFileSync(resolvedPath, template, "utf-8");
|
|
47
|
+
fs.mkdirSync(dirname(resolvedPath), { recursive: true });
|
|
48
|
+
fs.writeFileSync(resolvedPath, template, "utf-8");
|
|
32
49
|
|
|
33
50
|
return relative(process.cwd(), resolvedPath);
|
|
34
51
|
}
|
|
@@ -11,6 +11,7 @@ import { getConfig } from "./config.js";
|
|
|
11
11
|
import { ARTIFACTS } from "./prompt-templates/index.js";
|
|
12
12
|
import { resolveTemplate } from "./templates.js";
|
|
13
13
|
|
|
14
|
+
import type { SpawnClaudeFn } from "./spawn-claude.js";
|
|
14
15
|
import type { TokenValues } from "./templates.js";
|
|
15
16
|
|
|
16
17
|
export { ensureDir, fileExists, readFile, writeFile } from "../../utils/fs.js";
|
|
@@ -157,6 +158,7 @@ export interface StepRunnerOptions {
|
|
|
157
158
|
artifactPath: string;
|
|
158
159
|
templateName: string;
|
|
159
160
|
artifactValidator?: (path: string) => boolean;
|
|
161
|
+
spawnFn?: SpawnClaudeFn;
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
/**
|
|
@@ -167,7 +169,7 @@ export interface StepRunnerOptions {
|
|
|
167
169
|
* the same pattern.
|
|
168
170
|
*/
|
|
169
171
|
export async function runStepWithArtifact(opts: StepRunnerOptions): Promise<boolean> {
|
|
170
|
-
const { stepName, ctx, artifactPath, templateName, artifactValidator } = opts;
|
|
172
|
+
const { stepName, ctx, artifactPath, templateName, artifactValidator, spawnFn } = opts;
|
|
171
173
|
|
|
172
174
|
const isValid = artifactValidator ?? fileExists;
|
|
173
175
|
if (isValid(artifactPath)) {
|
|
@@ -183,6 +185,7 @@ export async function runStepWithArtifact(opts: StepRunnerOptions): Promise<bool
|
|
|
183
185
|
const result = await runClaude({
|
|
184
186
|
promptFile,
|
|
185
187
|
maxTurns: getConfig().maxTurns,
|
|
188
|
+
spawnFn,
|
|
186
189
|
});
|
|
187
190
|
|
|
188
191
|
if (result.is_error) {
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
1
|
import { describe, expect, it, vi } from "vitest";
|
|
3
2
|
|
|
3
|
+
import type { ReadFileFn } from "./parser";
|
|
4
4
|
import { calculateCutoffMs, filterByDays, parseJsonl, quickTokenCount } from "./parser";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
readFileSync: vi.fn(),
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
const mockedReadFileSync = vi.mocked(fs.readFileSync);
|
|
6
|
+
const mockReadFileSync: ReadFileFn = vi.fn();
|
|
11
7
|
|
|
12
8
|
// ── Pure functions (no mocking needed) ──
|
|
13
9
|
|
|
@@ -67,38 +63,38 @@ describe("filterByDays", () => {
|
|
|
67
63
|
});
|
|
68
64
|
});
|
|
69
65
|
|
|
70
|
-
// ── parseJsonl and quickTokenCount (
|
|
66
|
+
// ── parseJsonl and quickTokenCount (use injected readFile) ──
|
|
71
67
|
|
|
72
68
|
describe("parseJsonl", () => {
|
|
73
69
|
it("parses valid JSONL lines", () => {
|
|
74
|
-
|
|
70
|
+
vi.mocked(mockReadFileSync).mockReturnValue(
|
|
75
71
|
'{"type":"user","sessionId":"s1","timestamp":"2025-01-01T00:00:00Z"}\n{"type":"assistant","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}\n',
|
|
76
72
|
);
|
|
77
|
-
const entries = parseJsonl("/fake/path.jsonl");
|
|
73
|
+
const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
|
|
78
74
|
expect(entries).toHaveLength(2);
|
|
79
75
|
expect(entries[0].type).toBe("user");
|
|
80
76
|
expect(entries[1].type).toBe("assistant");
|
|
81
77
|
});
|
|
82
78
|
|
|
83
79
|
it("skips empty lines", () => {
|
|
84
|
-
|
|
80
|
+
vi.mocked(mockReadFileSync).mockReturnValue(
|
|
85
81
|
'{"type":"user","sessionId":"s1","timestamp":"t"}\n\n\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
|
|
86
82
|
);
|
|
87
|
-
const entries = parseJsonl("/fake/path.jsonl");
|
|
83
|
+
const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
|
|
88
84
|
expect(entries).toHaveLength(2);
|
|
89
85
|
});
|
|
90
86
|
|
|
91
87
|
it("skips invalid JSON lines", () => {
|
|
92
|
-
|
|
88
|
+
vi.mocked(mockReadFileSync).mockReturnValue(
|
|
93
89
|
'{"type":"user","sessionId":"s1","timestamp":"t"}\nnot-json\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
|
|
94
90
|
);
|
|
95
|
-
const entries = parseJsonl("/fake/path.jsonl");
|
|
91
|
+
const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
|
|
96
92
|
expect(entries).toHaveLength(2);
|
|
97
93
|
});
|
|
98
94
|
|
|
99
95
|
it("returns empty array for empty file", () => {
|
|
100
|
-
|
|
101
|
-
const entries = parseJsonl("/fake/path.jsonl");
|
|
96
|
+
vi.mocked(mockReadFileSync).mockReturnValue("");
|
|
97
|
+
const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
|
|
102
98
|
expect(entries).toHaveLength(0);
|
|
103
99
|
});
|
|
104
100
|
});
|
|
@@ -113,8 +109,8 @@ describe("quickTokenCount", () => {
|
|
|
113
109
|
message: { usage: { input_tokens: 200, output_tokens: 75 } },
|
|
114
110
|
}),
|
|
115
111
|
].join("\n");
|
|
116
|
-
|
|
117
|
-
expect(quickTokenCount("/fake/path.jsonl")).toBe(425);
|
|
112
|
+
vi.mocked(mockReadFileSync).mockReturnValue(lines);
|
|
113
|
+
expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(425);
|
|
118
114
|
});
|
|
119
115
|
|
|
120
116
|
it("skips entries without usage", () => {
|
|
@@ -122,29 +118,29 @@ describe("quickTokenCount", () => {
|
|
|
122
118
|
JSON.stringify({ message: { content: "text" } }),
|
|
123
119
|
JSON.stringify({ message: { usage: { input_tokens: 100, output_tokens: 50 } } }),
|
|
124
120
|
].join("\n");
|
|
125
|
-
|
|
126
|
-
expect(quickTokenCount("/fake/path.jsonl")).toBe(150);
|
|
121
|
+
vi.mocked(mockReadFileSync).mockReturnValue(lines);
|
|
122
|
+
expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(150);
|
|
127
123
|
});
|
|
128
124
|
|
|
129
125
|
it("returns 0 for unreadable files", () => {
|
|
130
|
-
|
|
126
|
+
vi.mocked(mockReadFileSync).mockImplementation(() => {
|
|
131
127
|
throw new Error("ENOENT");
|
|
132
128
|
});
|
|
133
|
-
expect(quickTokenCount("/missing/file.jsonl")).toBe(0);
|
|
129
|
+
expect(quickTokenCount("/missing/file.jsonl", mockReadFileSync)).toBe(0);
|
|
134
130
|
});
|
|
135
131
|
|
|
136
132
|
it("handles entries with partial usage (only input_tokens)", () => {
|
|
137
133
|
const lines = JSON.stringify({
|
|
138
134
|
message: { usage: { input_tokens: 100 } },
|
|
139
135
|
});
|
|
140
|
-
|
|
141
|
-
expect(quickTokenCount("/fake/path.jsonl")).toBe(100);
|
|
136
|
+
vi.mocked(mockReadFileSync).mockReturnValue(lines);
|
|
137
|
+
expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(100);
|
|
142
138
|
});
|
|
143
139
|
|
|
144
140
|
it("skips invalid JSON lines gracefully", () => {
|
|
145
|
-
|
|
141
|
+
vi.mocked(mockReadFileSync).mockReturnValue(
|
|
146
142
|
'{"message":{"usage":{"input_tokens":50,"output_tokens":50}}}\nbadline\n',
|
|
147
143
|
);
|
|
148
|
-
expect(quickTokenCount("/fake/path.jsonl")).toBe(100);
|
|
144
|
+
expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(100);
|
|
149
145
|
});
|
|
150
146
|
});
|
package/src/lib/graph/parser.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { readFileSync as defaultReadFileSync } from "node:fs";
|
|
2
|
+
|
|
2
3
|
import type { JournalEntry } from "./types.js";
|
|
3
4
|
|
|
5
|
+
export type ReadFileFn = (path: string, encoding: BufferEncoding) => string;
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* Calculate cutoff timestamp for days filtering.
|
|
6
9
|
* Returns 0 if days <= 0 (no filtering).
|
|
@@ -22,8 +25,11 @@ export function filterByDays<T extends { mtime: number }>(items: T[], days: numb
|
|
|
22
25
|
/**
|
|
23
26
|
* Parse JSONL file into JournalEntry array.
|
|
24
27
|
*/
|
|
25
|
-
export function parseJsonl(
|
|
26
|
-
|
|
28
|
+
export function parseJsonl(
|
|
29
|
+
filePath: string,
|
|
30
|
+
readFile: ReadFileFn = defaultReadFileSync,
|
|
31
|
+
): JournalEntry[] {
|
|
32
|
+
const content = readFile(filePath, "utf-8");
|
|
27
33
|
const entries: JournalEntry[] = [];
|
|
28
34
|
|
|
29
35
|
for (const line of content.split("\n")) {
|
|
@@ -41,9 +47,12 @@ export function parseJsonl(filePath: string): JournalEntry[] {
|
|
|
41
47
|
/**
|
|
42
48
|
* Quick token count from a JSONL file without full parsing.
|
|
43
49
|
*/
|
|
44
|
-
export function quickTokenCount(
|
|
50
|
+
export function quickTokenCount(
|
|
51
|
+
filePath: string,
|
|
52
|
+
readFile: ReadFileFn = defaultReadFileSync,
|
|
53
|
+
): number {
|
|
45
54
|
try {
|
|
46
|
-
const content =
|
|
55
|
+
const content = readFile(filePath, "utf-8");
|
|
47
56
|
let total = 0;
|
|
48
57
|
for (const line of content.split("\n")) {
|
|
49
58
|
if (!line.trim()) continue;
|
|
@@ -1,35 +1,32 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { x } from "tinyexec";
|
|
3
|
-
import { getIssues, isGithubCliInstalled } from "./gh-cli-wrapper";
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}));
|
|
3
|
+
import type { XFn } from "./gh-cli-wrapper";
|
|
4
|
+
import { getIssues, isGithubCliInstalled } from "./gh-cli-wrapper";
|
|
8
5
|
|
|
9
|
-
const mockX = vi.
|
|
6
|
+
const mockX: XFn = vi.fn().mockResolvedValue({ stdout: "[]", stderr: "", exitCode: 0 });
|
|
10
7
|
|
|
11
8
|
describe("gh-cli-wrapper", () => {
|
|
12
9
|
beforeEach(() => {
|
|
13
10
|
vi.clearAllMocks();
|
|
14
|
-
mockX.mockResolvedValue({ stdout: "[]"
|
|
11
|
+
vi.mocked(mockX).mockResolvedValue({ stdout: "[]", stderr: "", exitCode: 0 });
|
|
15
12
|
});
|
|
16
13
|
|
|
17
14
|
describe("getIssues", () => {
|
|
18
15
|
it("passes --label flag when label provided", async () => {
|
|
19
|
-
await getIssues({ cwd: ".", label: "auto-claude" });
|
|
16
|
+
await getIssues({ cwd: ".", label: "auto-claude", exec: mockX });
|
|
20
17
|
|
|
21
18
|
expect(mockX).toHaveBeenCalledWith("gh", expect.arrayContaining(["--label", "auto-claude"]));
|
|
22
19
|
});
|
|
23
20
|
|
|
24
21
|
it("does not pass --label flag when label not provided", async () => {
|
|
25
|
-
await getIssues({ cwd: "." });
|
|
22
|
+
await getIssues({ cwd: ".", exec: mockX });
|
|
26
23
|
|
|
27
|
-
const args = mockX.mock.calls[0]![1] as string[];
|
|
24
|
+
const args = vi.mocked(mockX).mock.calls[0]![1] as string[];
|
|
28
25
|
expect(args).not.toContain("--label");
|
|
29
26
|
});
|
|
30
27
|
|
|
31
28
|
it("passes --assignee @me flag when assignedToMe is true", async () => {
|
|
32
|
-
await getIssues({ cwd: ".", assignedToMe: true });
|
|
29
|
+
await getIssues({ cwd: ".", assignedToMe: true, exec: mockX });
|
|
33
30
|
|
|
34
31
|
expect(mockX).toHaveBeenCalledWith("gh", expect.arrayContaining(["--assignee", "@me"]));
|
|
35
32
|
});
|
|
@@ -37,16 +34,20 @@ describe("gh-cli-wrapper", () => {
|
|
|
37
34
|
|
|
38
35
|
describe("isGithubCliInstalled", () => {
|
|
39
36
|
it("returns true when gh CLI outputs expected string", async () => {
|
|
40
|
-
mockX.mockResolvedValue({
|
|
37
|
+
vi.mocked(mockX).mockResolvedValue({
|
|
38
|
+
stdout: "gh version 2.0.0 (https://github.com/cli/cli)",
|
|
39
|
+
stderr: "",
|
|
40
|
+
exitCode: 0,
|
|
41
|
+
});
|
|
41
42
|
|
|
42
|
-
const result = await isGithubCliInstalled();
|
|
43
|
+
const result = await isGithubCliInstalled(mockX);
|
|
43
44
|
expect(result).toBe(true);
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
it("returns false when gh CLI is not available", async () => {
|
|
47
|
-
mockX.mockRejectedValue(new Error("command not found"));
|
|
48
|
+
vi.mocked(mockX).mockRejectedValue(new Error("command not found"));
|
|
48
49
|
|
|
49
|
-
const result = await isGithubCliInstalled();
|
|
50
|
+
const result = await isGithubCliInstalled(mockX);
|
|
50
51
|
expect(result).toBe(false);
|
|
51
52
|
});
|
|
52
53
|
});
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import stripAnsi from "strip-ansi";
|
|
2
2
|
import { x } from "tinyexec";
|
|
3
|
+
import type { Output } from "tinyexec";
|
|
3
4
|
|
|
4
5
|
import { execSafe } from "./exec.js";
|
|
5
6
|
|
|
6
|
-
export
|
|
7
|
+
export type XFn = (
|
|
8
|
+
cmd: string,
|
|
9
|
+
args?: string[],
|
|
10
|
+
opts?: Record<string, unknown>,
|
|
11
|
+
) => PromiseLike<Output>;
|
|
12
|
+
|
|
13
|
+
export async function isGithubCliInstalled(exec: XFn = x as XFn): Promise<boolean> {
|
|
7
14
|
try {
|
|
8
|
-
const proc = await
|
|
15
|
+
const proc = await exec("gh", ["--version"]);
|
|
9
16
|
return proc.stdout.includes("https://github.com/cli/cli");
|
|
10
17
|
} catch {
|
|
11
18
|
// gh CLI not installed or not accessible
|
|
@@ -18,8 +25,12 @@ export async function gh<T = unknown>(args: string[]): Promise<T> {
|
|
|
18
25
|
return JSON.parse(result.stdout.trim()) as T;
|
|
19
26
|
}
|
|
20
27
|
|
|
21
|
-
export async function ghRaw(
|
|
22
|
-
|
|
28
|
+
export async function ghRaw(
|
|
29
|
+
args: string[],
|
|
30
|
+
exec?: (cmd: string, args: string[]) => Promise<{ stdout: string; ok: boolean }>,
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
const execFn = exec ?? execSafe;
|
|
33
|
+
const result = await execFn("gh", args);
|
|
23
34
|
return result.stdout;
|
|
24
35
|
}
|
|
25
36
|
|
|
@@ -37,9 +48,11 @@ export async function getIssues({
|
|
|
37
48
|
assignedToMe,
|
|
38
49
|
cwd,
|
|
39
50
|
label,
|
|
51
|
+
exec = x as XFn,
|
|
40
52
|
}: {
|
|
41
53
|
assignedToMe?: boolean;
|
|
42
54
|
cwd: string;
|
|
55
|
+
exec?: XFn;
|
|
43
56
|
label?: string;
|
|
44
57
|
}): Promise<Issue[]> {
|
|
45
58
|
const args = ["issue", "list", "--json", "labels,number,title,state"];
|
|
@@ -52,7 +65,7 @@ export async function getIssues({
|
|
|
52
65
|
args.push("--label", label);
|
|
53
66
|
}
|
|
54
67
|
|
|
55
|
-
const result = await
|
|
68
|
+
const result = await exec("gh", args);
|
|
56
69
|
// Setting NO_COLOR=1 didn't remove colors so had to use stripAnsi
|
|
57
70
|
const stripped = stripAnsi(result.stdout);
|
|
58
71
|
|