@arii/boomtick-mcp 0.3.7 → 0.4.1
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/.mcp/schemas/github.create_issue.json +13 -0
- package/.mcp/schemas/github.create_pull_request.json +13 -0
- package/CHANGELOG.md +14 -0
- package/dist/config.js +43 -17
- package/dist/config.test.js +130 -0
- package/dist/lib/error_utils.test.js +23 -0
- package/dist/lib/result.test.js +45 -0
- package/dist/mcp/server.test.js +217 -0
- package/dist/tools/contract.js +28 -2
- package/dist/tools/github.create_issue.js +2 -0
- package/dist/tools/github.create_issue.test.js +39 -3
- package/dist/tools/github.create_pull_request.js +11 -13
- package/dist/tools/github.create_pull_request.test.js +93 -0
- package/dist/tools/jules/shared.test.js +58 -0
- package/dist/tools/repo.get_package_scripts.test.js +4 -0
- package/package.json +1 -1
- package/src/config.test.ts +165 -0
- package/src/config.ts +47 -17
- package/src/lib/error_utils.test.ts +27 -0
- package/src/lib/result.test.ts +48 -0
- package/src/mcp/server.test.ts +273 -0
- package/src/tools/contract.ts +28 -2
- package/src/tools/github.create_issue.test.ts +43 -3
- package/src/tools/github.create_issue.ts +2 -1
- package/src/tools/github.create_pull_request.test.ts +105 -0
- package/src/tools/github.create_pull_request.ts +14 -13
- package/src/tools/jules/shared.test.ts +69 -0
- package/src/tools/repo.get_package_scripts.test.ts +6 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { BoomtickMCPServer } from "./server.js";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
import fs from "fs/promises";
|
|
5
|
+
import * as healthModule from "./tools.js";
|
|
6
|
+
import * as routeMapModule from "../tools/repo.get_route_map.js";
|
|
7
|
+
import * as prDiffModule from "../tools/github.get_pr_diff.js";
|
|
8
|
+
import * as ciLogsModule from "../tools/repo.read_ci_logs.js";
|
|
9
|
+
import * as lighthouseModule from "../tools/repo.run_lighthouse.js";
|
|
10
|
+
import * as playwrightModule from "../tools/repo.run_playwright.js";
|
|
11
|
+
|
|
12
|
+
vi.mock("fs/promises");
|
|
13
|
+
vi.mock("../config.js", () => ({
|
|
14
|
+
config: {
|
|
15
|
+
repoPath: "/mock/repo",
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
describe("BoomtickMCPServer", () => {
|
|
20
|
+
let mcpServer: BoomtickMCPServer;
|
|
21
|
+
let serverInstance: any;
|
|
22
|
+
let requestHandlers: Map<string, Function>;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.resetAllMocks();
|
|
26
|
+
requestHandlers = new Map();
|
|
27
|
+
|
|
28
|
+
mcpServer = new BoomtickMCPServer();
|
|
29
|
+
serverInstance = (mcpServer as any).server;
|
|
30
|
+
|
|
31
|
+
// Retrieve registered request handlers from SDK Server instance
|
|
32
|
+
// SDK Server stores request handlers in server._requestHandlers or server.requestHandlers
|
|
33
|
+
const handlers = serverInstance._requestHandlers || serverInstance.requestHandlers;
|
|
34
|
+
if (handlers) {
|
|
35
|
+
for (const [key, handler] of handlers.entries()) {
|
|
36
|
+
requestHandlers.set(key, handler);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should instantiate server with correct name and capabilities", () => {
|
|
42
|
+
expect(serverInstance).toBeDefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("ListPrompts", () => {
|
|
46
|
+
it("should return prompts list", async () => {
|
|
47
|
+
const handler = requestHandlers.get("prompts/list");
|
|
48
|
+
expect(handler).toBeDefined();
|
|
49
|
+
const result = await handler!({ method: "prompts/list" });
|
|
50
|
+
expect(result.prompts).toBeDefined();
|
|
51
|
+
expect(Array.isArray(result.prompts)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("GetPrompt", () => {
|
|
56
|
+
it("should read prompt file and return message", async () => {
|
|
57
|
+
const handler = requestHandlers.get("prompts/get");
|
|
58
|
+
expect(handler).toBeDefined();
|
|
59
|
+
|
|
60
|
+
vi.mocked(fs.readFile).mockResolvedValue("You are a helpful agent.");
|
|
61
|
+
|
|
62
|
+
const result = await handler!({
|
|
63
|
+
method: "prompts/get",
|
|
64
|
+
params: { name: "test-agent" },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(fs.readFile).toHaveBeenCalledWith("/mock/repo/mcp/src/agents/test-agent.prompt.md", "utf-8");
|
|
68
|
+
expect(result).toEqual({
|
|
69
|
+
messages: [
|
|
70
|
+
{
|
|
71
|
+
role: "user",
|
|
72
|
+
content: { type: "text", text: "You are a helpful agent." },
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should prevent path traversal attacks", async () => {
|
|
79
|
+
const handler = requestHandlers.get("prompts/get");
|
|
80
|
+
|
|
81
|
+
await expect(
|
|
82
|
+
handler!({
|
|
83
|
+
method: "prompts/get",
|
|
84
|
+
params: { name: "../../../etc/passwd" },
|
|
85
|
+
})
|
|
86
|
+
).rejects.toThrow("Path traversal detected");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should throw when prompt file does not exist", async () => {
|
|
90
|
+
const handler = requestHandlers.get("prompts/get");
|
|
91
|
+
vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found"));
|
|
92
|
+
|
|
93
|
+
await expect(
|
|
94
|
+
handler!({
|
|
95
|
+
method: "prompts/get",
|
|
96
|
+
params: { name: "nonexistent" },
|
|
97
|
+
})
|
|
98
|
+
).rejects.toThrow("Prompt not found: nonexistent");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("ListResources", () => {
|
|
103
|
+
it("should return resources list", async () => {
|
|
104
|
+
const handler = requestHandlers.get("resources/list");
|
|
105
|
+
expect(handler).toBeDefined();
|
|
106
|
+
const result = await handler!({ method: "resources/list" });
|
|
107
|
+
expect(result.resources).toBeDefined();
|
|
108
|
+
expect(Array.isArray(result.resources)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("ReadResource", () => {
|
|
113
|
+
it("should handle repo://package-json", async () => {
|
|
114
|
+
const handler = requestHandlers.get("resources/read");
|
|
115
|
+
vi.mocked(fs.readFile).mockResolvedValue('{"name": "test"}');
|
|
116
|
+
|
|
117
|
+
const result = await handler!({
|
|
118
|
+
method: "resources/read",
|
|
119
|
+
params: { uri: "repo://package-json" },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result).toEqual({
|
|
123
|
+
contents: [{ uri: "repo://package-json", mimeType: "application/json", text: '{"name": "test"}' }],
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should handle repo://routes", async () => {
|
|
128
|
+
const handler = requestHandlers.get("resources/read");
|
|
129
|
+
vi.spyOn(routeMapModule, "getRouteMapHandler").mockResolvedValue({ routes: ["/"] } as any);
|
|
130
|
+
|
|
131
|
+
const result = await handler!({
|
|
132
|
+
method: "resources/read",
|
|
133
|
+
params: { uri: "repo://routes" },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result.contents[0].uri).toBe("repo://routes");
|
|
137
|
+
expect(JSON.parse(result.contents[0].text)).toEqual({ routes: ["/"] });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should handle repo://design-tokens", async () => {
|
|
141
|
+
const handler = requestHandlers.get("resources/read");
|
|
142
|
+
vi.mocked(fs.readFile).mockResolvedValue("export const tokens = {};");
|
|
143
|
+
|
|
144
|
+
const result = await handler!({
|
|
145
|
+
method: "resources/read",
|
|
146
|
+
params: { uri: "repo://design-tokens" },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(result).toEqual({
|
|
150
|
+
contents: [{ uri: "repo://design-tokens", mimeType: "text/typescript", text: "export const tokens = {};" }],
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should handle repo://diff/:prNumber", async () => {
|
|
155
|
+
const handler = requestHandlers.get("resources/read");
|
|
156
|
+
vi.spyOn(prDiffModule, "getPrDiffHandler").mockResolvedValue({ diffText: "diff --git a b" } as any);
|
|
157
|
+
|
|
158
|
+
const result = await handler!({
|
|
159
|
+
method: "resources/read",
|
|
160
|
+
params: { uri: "repo://diff/42" },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(result).toEqual({
|
|
164
|
+
contents: [{ uri: "repo://diff/42", mimeType: "text/plain", text: "diff --git a b" }],
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("should handle repo://ci/:prNumber", async () => {
|
|
169
|
+
const handler = requestHandlers.get("resources/read");
|
|
170
|
+
vi.spyOn(ciLogsModule, "readCiLogsHandler").mockResolvedValue({ status: "success" } as any);
|
|
171
|
+
|
|
172
|
+
const result = await handler!({
|
|
173
|
+
method: "resources/read",
|
|
174
|
+
params: { uri: "repo://ci/42" },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result.contents[0].uri).toBe("repo://ci/42");
|
|
178
|
+
expect(JSON.parse(result.contents[0].text)).toEqual({ status: "success" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should handle repo://lighthouse/:branch", async () => {
|
|
182
|
+
const handler = requestHandlers.get("resources/read");
|
|
183
|
+
vi.spyOn(lighthouseModule, "runLighthouseHandler").mockResolvedValue({ score: 100 } as any);
|
|
184
|
+
|
|
185
|
+
const result = await handler!({
|
|
186
|
+
method: "resources/read",
|
|
187
|
+
params: { uri: "repo://lighthouse/feature-x" },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(result.contents[0].uri).toBe("repo://lighthouse/feature-x");
|
|
191
|
+
expect(JSON.parse(result.contents[0].text)).toEqual({ score: 100 });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("should handle repo://playwright/:branch", async () => {
|
|
195
|
+
const handler = requestHandlers.get("resources/read");
|
|
196
|
+
vi.spyOn(playwrightModule, "runPlaywrightHandler").mockResolvedValue({ passed: true } as any);
|
|
197
|
+
|
|
198
|
+
const result = await handler!({
|
|
199
|
+
method: "resources/read",
|
|
200
|
+
params: { uri: "repo://playwright/feature-x" },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(result.contents[0].uri).toBe("repo://playwright/feature-x");
|
|
204
|
+
expect(JSON.parse(result.contents[0].text)).toEqual({ passed: true });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should throw when resource is unknown", async () => {
|
|
208
|
+
const handler = requestHandlers.get("resources/read");
|
|
209
|
+
|
|
210
|
+
await expect(
|
|
211
|
+
handler!({
|
|
212
|
+
method: "resources/read",
|
|
213
|
+
params: { uri: "repo://unknown" },
|
|
214
|
+
})
|
|
215
|
+
).rejects.toThrow("Resource not found: repo://unknown");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("ListTools", () => {
|
|
220
|
+
it("should return tools list", async () => {
|
|
221
|
+
const handler = requestHandlers.get("tools/list");
|
|
222
|
+
expect(handler).toBeDefined();
|
|
223
|
+
const result = await handler!({ method: "tools/list" });
|
|
224
|
+
expect(result.tools).toBeDefined();
|
|
225
|
+
expect(Array.isArray(result.tools)).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("CallTool", () => {
|
|
230
|
+
it("should execute registered tool handler successfully", async () => {
|
|
231
|
+
const handler = requestHandlers.get("tools/call");
|
|
232
|
+
vi.spyOn(healthModule, "healthHandler").mockResolvedValue({ status: "ok" } as any);
|
|
233
|
+
|
|
234
|
+
const result = await handler!({
|
|
235
|
+
method: "tools/call",
|
|
236
|
+
params: { name: "boomtick.health", arguments: {} },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(result).toEqual({
|
|
240
|
+
content: [{ type: "text", text: JSON.stringify({ status: "ok" }, null, 2) }],
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("should return error result for unknown tool", async () => {
|
|
245
|
+
const handler = requestHandlers.get("tools/call");
|
|
246
|
+
|
|
247
|
+
const result = await handler!({
|
|
248
|
+
method: "tools/call",
|
|
249
|
+
params: { name: "nonexistent.tool", arguments: {} },
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(result).toEqual({
|
|
253
|
+
content: [{ type: "text", text: "Tool not found: nonexistent.tool" }],
|
|
254
|
+
isError: true,
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("should return error result when tool execution throws error", async () => {
|
|
259
|
+
const handler = requestHandlers.get("tools/call");
|
|
260
|
+
vi.spyOn(healthModule, "healthHandler").mockRejectedValue(new Error("Health check failed"));
|
|
261
|
+
|
|
262
|
+
const result = await handler!({
|
|
263
|
+
method: "tools/call",
|
|
264
|
+
params: { name: "boomtick.health", arguments: {} },
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(result).toEqual({
|
|
268
|
+
content: [{ type: "text", text: "Health check failed" }],
|
|
269
|
+
isError: true,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
});
|
package/src/tools/contract.ts
CHANGED
|
@@ -1228,7 +1228,7 @@ export const CreateBranchInputJsonSchema = {
|
|
|
1228
1228
|
"type": "object"
|
|
1229
1229
|
} as const;
|
|
1230
1230
|
|
|
1231
|
-
export const CreateIssueInputSchema = z.object({ "title": z.string().min(1), "body": z.union([z.string().min(1), z.null()]).default(null), "file": z.union([z.string().min(1), z.null()]).default(null) });
|
|
1231
|
+
export const CreateIssueInputSchema = z.object({ "title": z.string().min(1), "body": z.union([z.string().min(1), z.null()]).default(null), "file": z.union([z.string().min(1), z.null()]).default(null), "repo": z.union([z.string(), z.null()]).describe("The target repository override (e.g. org/repo).").default(null) });
|
|
1232
1232
|
|
|
1233
1233
|
export type CreateIssueInput = z.infer<typeof CreateIssueInputSchema>;
|
|
1234
1234
|
|
|
@@ -1264,6 +1264,19 @@ export const CreateIssueInputJsonSchema = {
|
|
|
1264
1264
|
],
|
|
1265
1265
|
"default": null,
|
|
1266
1266
|
"title": "File"
|
|
1267
|
+
},
|
|
1268
|
+
"repo": {
|
|
1269
|
+
"anyOf": [
|
|
1270
|
+
{
|
|
1271
|
+
"type": "string"
|
|
1272
|
+
},
|
|
1273
|
+
{
|
|
1274
|
+
"type": "null"
|
|
1275
|
+
}
|
|
1276
|
+
],
|
|
1277
|
+
"default": null,
|
|
1278
|
+
"description": "The target repository override (e.g. org/repo).",
|
|
1279
|
+
"title": "Repo"
|
|
1267
1280
|
}
|
|
1268
1281
|
},
|
|
1269
1282
|
"required": [
|
|
@@ -1397,7 +1410,7 @@ export const CreateJulesSessionInputJsonSchema = {
|
|
|
1397
1410
|
"type": "object"
|
|
1398
1411
|
} as const;
|
|
1399
1412
|
|
|
1400
|
-
export const CreatePullRequestInputSchema = z.object({ "title": z.string().describe("PR Title."), "body": z.string().describe("Description of changes."), "head": z.string().describe("The branch containing changes to merge."), "base": z.string().describe("The target branch to merge into.").default("main"), "draft": z.boolean().describe("Whether to create the PR as a draft.").default(false) });
|
|
1413
|
+
export const CreatePullRequestInputSchema = z.object({ "title": z.string().describe("PR Title."), "body": z.string().describe("Description of changes."), "head": z.string().describe("The branch containing changes to merge."), "base": z.string().describe("The target branch to merge into.").default("main"), "draft": z.boolean().describe("Whether to create the PR as a draft.").default(false), "repo": z.union([z.string(), z.null()]).describe("The target repository override (e.g. org/repo).").default(null) });
|
|
1401
1414
|
|
|
1402
1415
|
export type CreatePullRequestInput = z.infer<typeof CreatePullRequestInputSchema>;
|
|
1403
1416
|
|
|
@@ -1429,6 +1442,19 @@ export const CreatePullRequestInputJsonSchema = {
|
|
|
1429
1442
|
"description": "Whether to create the PR as a draft.",
|
|
1430
1443
|
"title": "Draft",
|
|
1431
1444
|
"type": "boolean"
|
|
1445
|
+
},
|
|
1446
|
+
"repo": {
|
|
1447
|
+
"anyOf": [
|
|
1448
|
+
{
|
|
1449
|
+
"type": "string"
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
"type": "null"
|
|
1453
|
+
}
|
|
1454
|
+
],
|
|
1455
|
+
"default": null,
|
|
1456
|
+
"description": "The target repository override (e.g. org/repo).",
|
|
1457
|
+
"title": "Repo"
|
|
1432
1458
|
}
|
|
1433
1459
|
},
|
|
1434
1460
|
"required": [
|
|
@@ -26,12 +26,52 @@ describe("github.create_issue", () => {
|
|
|
26
26
|
command: "td-cli gh create-issue --title 'Test Issue' --body 'Test Body'"
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
const result = await createIssueHandler({ title: "Test Issue", body: "Test Body", file: null });
|
|
29
|
+
const result = await createIssueHandler({ title: "Test Issue", body: "Test Body", file: null, repo: null });
|
|
30
30
|
expect(result.status).toBe("success");
|
|
31
31
|
expect(result.issue?.number).toBe(123);
|
|
32
32
|
expect(result.issue?.title).toBe("Test Issue");
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
it("should append --repo flag when repo parameter is provided", async () => {
|
|
36
|
+
const mockResponse = {
|
|
37
|
+
status: "success",
|
|
38
|
+
issue: {
|
|
39
|
+
number: 456,
|
|
40
|
+
title: "Cross Repo Issue",
|
|
41
|
+
html_url: "https://github.com/org/other-repo/issues/456",
|
|
42
|
+
state: "open"
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
vi.mocked(shell.runCommand).mockResolvedValue({
|
|
47
|
+
stdout: JSON.stringify(mockResponse),
|
|
48
|
+
stderr: "",
|
|
49
|
+
exitCode: 0,
|
|
50
|
+
durationMs: 10,
|
|
51
|
+
command: "td-cli gh create-issue --title 'Cross Repo Issue' --body 'Body' --repo 'org/other-repo'"
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const result = await createIssueHandler({
|
|
55
|
+
title: "Cross Repo Issue",
|
|
56
|
+
body: "Body",
|
|
57
|
+
file: null,
|
|
58
|
+
repo: "org/other-repo"
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(shell.runCommand).toHaveBeenCalledWith("td-cli", [
|
|
62
|
+
"gh",
|
|
63
|
+
"create-issue",
|
|
64
|
+
"--title",
|
|
65
|
+
"Cross Repo Issue",
|
|
66
|
+
"--body",
|
|
67
|
+
"Body",
|
|
68
|
+
"--repo",
|
|
69
|
+
"org/other-repo"
|
|
70
|
+
]);
|
|
71
|
+
expect(result.status).toBe("success");
|
|
72
|
+
expect(result.issue?.number).toBe(456);
|
|
73
|
+
});
|
|
74
|
+
|
|
35
75
|
it("should throw error on command failure", async () => {
|
|
36
76
|
vi.mocked(shell.runCommand).mockResolvedValue({
|
|
37
77
|
stdout: "",
|
|
@@ -41,7 +81,7 @@ describe("github.create_issue", () => {
|
|
|
41
81
|
command: "td-cli gh create-issue"
|
|
42
82
|
});
|
|
43
83
|
|
|
44
|
-
await expect(createIssueHandler({ title: "Test Issue", body: "Test Body", file: null })).rejects.toThrow("Failed to create issue: Auth failed");
|
|
84
|
+
await expect(createIssueHandler({ title: "Test Issue", body: "Test Body", file: null, repo: null })).rejects.toThrow("Failed to create issue: Auth failed");
|
|
45
85
|
});
|
|
46
86
|
|
|
47
87
|
it("should handle error status from CLI output", async () => {
|
|
@@ -58,6 +98,6 @@ describe("github.create_issue", () => {
|
|
|
58
98
|
command: "td-cli gh create-issue"
|
|
59
99
|
});
|
|
60
100
|
|
|
61
|
-
await expect(createIssueHandler({ title: "Test Issue", body: "Test Body", file: null })).rejects.toThrow("Failed to create issue: Repo not found");
|
|
101
|
+
await expect(createIssueHandler({ title: "Test Issue", body: "Test Body", file: null, repo: null })).rejects.toThrow("Failed to create issue: Repo not found");
|
|
62
102
|
});
|
|
63
103
|
});
|
|
@@ -5,12 +5,13 @@ import { CreateIssueInputSchema, CreateIssueResponseSchema } from "./contract.js
|
|
|
5
5
|
|
|
6
6
|
export { CreateIssueInputSchema };
|
|
7
7
|
|
|
8
|
-
export async function createIssueHandler(args: z.
|
|
8
|
+
export async function createIssueHandler(args: z.input<typeof CreateIssueInputSchema>) {
|
|
9
9
|
const params = CreateIssueInputSchema.parse(args);
|
|
10
10
|
|
|
11
11
|
const cmdArgs = ["gh", "create-issue", "--title", params.title];
|
|
12
12
|
if (params.body) cmdArgs.push("--body", params.body);
|
|
13
13
|
if (params.file) cmdArgs.push("--file", params.file);
|
|
14
|
+
if (params.repo) cmdArgs.push("--repo", params.repo);
|
|
14
15
|
|
|
15
16
|
const result = await runCommand("td-cli", cmdArgs);
|
|
16
17
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { createPullRequestHandler } from "./github.create_pull_request.js";
|
|
3
|
+
import * as shell from "../lib/shell.js";
|
|
4
|
+
|
|
5
|
+
vi.mock("../lib/shell.js", () => ({
|
|
6
|
+
runCommand: vi.fn()
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe("github.create_pull_request", () => {
|
|
10
|
+
it("should create a pull request successfully", async () => {
|
|
11
|
+
const mockResponse = {
|
|
12
|
+
status: "success",
|
|
13
|
+
pr: {
|
|
14
|
+
number: 789,
|
|
15
|
+
html_url: "https://github.com/owner/repo/pull/789"
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
vi.mocked(shell.runCommand).mockResolvedValue({
|
|
20
|
+
stdout: JSON.stringify(mockResponse),
|
|
21
|
+
stderr: "",
|
|
22
|
+
exitCode: 0,
|
|
23
|
+
durationMs: 10,
|
|
24
|
+
command: "td-cli gh create-pr --title 'PR' --body 'Body' --head 'feature' --base 'main'"
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const result = await createPullRequestHandler({
|
|
28
|
+
title: "PR",
|
|
29
|
+
body: "Body",
|
|
30
|
+
head: "feature",
|
|
31
|
+
base: "main",
|
|
32
|
+
draft: false
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(result.success).toBe(true);
|
|
36
|
+
expect(result.url).toBe("https://github.com/owner/repo/pull/789");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should append --repo flag when repo parameter is provided", async () => {
|
|
40
|
+
const mockResponse = {
|
|
41
|
+
status: "success",
|
|
42
|
+
pr: {
|
|
43
|
+
number: 999,
|
|
44
|
+
html_url: "https://github.com/org/other-repo/pull/999"
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
vi.mocked(shell.runCommand).mockResolvedValue({
|
|
49
|
+
stdout: JSON.stringify(mockResponse),
|
|
50
|
+
stderr: "",
|
|
51
|
+
exitCode: 0,
|
|
52
|
+
durationMs: 10,
|
|
53
|
+
command: "td-cli gh create-pr --title 'PR' --body 'Body' --head 'feature' --base 'main' --repo 'org/other-repo'"
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = await createPullRequestHandler({
|
|
57
|
+
title: "PR",
|
|
58
|
+
body: "Body",
|
|
59
|
+
head: "feature",
|
|
60
|
+
base: "main",
|
|
61
|
+
draft: false,
|
|
62
|
+
repo: "org/other-repo"
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(shell.runCommand).toHaveBeenCalledWith("td-cli", [
|
|
66
|
+
"gh",
|
|
67
|
+
"create-pr",
|
|
68
|
+
"--title",
|
|
69
|
+
"PR",
|
|
70
|
+
"--body",
|
|
71
|
+
"Body",
|
|
72
|
+
"--head",
|
|
73
|
+
"feature",
|
|
74
|
+
"--base",
|
|
75
|
+
"main",
|
|
76
|
+
"--repo",
|
|
77
|
+
"org/other-repo"
|
|
78
|
+
]);
|
|
79
|
+
expect(result.success).toBe(true);
|
|
80
|
+
expect(result.url).toBe("https://github.com/org/other-repo/pull/999");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should throw error on CLI error response", async () => {
|
|
84
|
+
const mockResponse = {
|
|
85
|
+
status: "error",
|
|
86
|
+
message: "Branch not found"
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
vi.mocked(shell.runCommand).mockResolvedValue({
|
|
90
|
+
stdout: JSON.stringify(mockResponse),
|
|
91
|
+
stderr: "",
|
|
92
|
+
exitCode: 0,
|
|
93
|
+
durationMs: 10,
|
|
94
|
+
command: "td-cli gh create-pr"
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await expect(createPullRequestHandler({
|
|
98
|
+
title: "PR",
|
|
99
|
+
body: "Body",
|
|
100
|
+
head: "feature",
|
|
101
|
+
base: "main",
|
|
102
|
+
draft: false
|
|
103
|
+
})).rejects.toThrow("Failed to create pull request: Branch not found");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { runCommand } from "../lib/shell.js";
|
|
3
|
+
import { CreatePullRequestInputSchema } from "./contract.js";
|
|
3
4
|
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
base: z.string().optional().default("main"),
|
|
9
|
-
draft: z.boolean().optional().default(false),
|
|
10
|
-
});
|
|
5
|
+
export { CreatePullRequestInputSchema };
|
|
6
|
+
|
|
7
|
+
export async function createPullRequestHandler(args: z.input<typeof CreatePullRequestInputSchema>) {
|
|
8
|
+
const params = CreatePullRequestInputSchema.parse(args);
|
|
11
9
|
|
|
12
|
-
export async function createPullRequestHandler(args: z.infer<typeof CreatePullRequestInputSchema>) {
|
|
13
10
|
const tdArgs = [
|
|
14
11
|
"gh",
|
|
15
12
|
"create-pr",
|
|
16
|
-
"--title",
|
|
17
|
-
"--body",
|
|
18
|
-
"--head",
|
|
19
|
-
"--base",
|
|
13
|
+
"--title", params.title,
|
|
14
|
+
"--body", params.body,
|
|
15
|
+
"--head", params.head,
|
|
16
|
+
"--base", params.base
|
|
20
17
|
];
|
|
21
18
|
|
|
22
|
-
if (
|
|
19
|
+
if (params.draft) {
|
|
23
20
|
tdArgs.push("--draft");
|
|
24
21
|
}
|
|
25
22
|
|
|
23
|
+
if (params.repo) {
|
|
24
|
+
tdArgs.push("--repo", params.repo);
|
|
25
|
+
}
|
|
26
|
+
|
|
26
27
|
const result = await runCommand("td-cli", tdArgs);
|
|
27
28
|
|
|
28
29
|
if (result.exitCode !== 0) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseJulesSession } from "./shared.js";
|
|
3
|
+
|
|
4
|
+
describe("jules/shared", () => {
|
|
5
|
+
describe("parseJulesSession", () => {
|
|
6
|
+
it("should strip 'sessions/' prefix from session name to form ID", () => {
|
|
7
|
+
const input = {
|
|
8
|
+
name: "sessions/12345",
|
|
9
|
+
state: "IN_PROGRESS",
|
|
10
|
+
};
|
|
11
|
+
const result = parseJulesSession(input);
|
|
12
|
+
expect(result.id).toBe("12345");
|
|
13
|
+
expect(result.status).toBe("IN_PROGRESS");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should use raw name as ID if 'sessions/' prefix is absent", () => {
|
|
17
|
+
const input = {
|
|
18
|
+
name: "custom-session-id",
|
|
19
|
+
state: "SUCCEEDED",
|
|
20
|
+
};
|
|
21
|
+
expect(parseJulesSession(input).id).toBe("custom-session-id");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should default name/id to empty string if missing", () => {
|
|
25
|
+
expect(parseJulesSession({}).id).toBe("");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should map states to COMPLETED, FAILED, IN_PROGRESS, or fallback PENDING", () => {
|
|
29
|
+
expect(parseJulesSession({ state: "SUCCEEDED" }).status).toBe("COMPLETED");
|
|
30
|
+
expect(parseJulesSession({ state: "COMPLETED" }).status).toBe("COMPLETED");
|
|
31
|
+
|
|
32
|
+
expect(parseJulesSession({ state: "FAILED" }).status).toBe("FAILED");
|
|
33
|
+
expect(parseJulesSession({ state: "CANCELLED" }).status).toBe("FAILED");
|
|
34
|
+
expect(parseJulesSession({ state: "TERMINATED" }).status).toBe("FAILED");
|
|
35
|
+
|
|
36
|
+
expect(parseJulesSession({ state: "IN_PROGRESS" }).status).toBe("IN_PROGRESS");
|
|
37
|
+
|
|
38
|
+
expect(parseJulesSession({ state: "UNKNOWN_STATE" }).status).toBe("PENDING");
|
|
39
|
+
expect(parseJulesSession({}).status).toBe("PENDING");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should extract pullRequestUrl from outputs array when present", () => {
|
|
43
|
+
const input = {
|
|
44
|
+
name: "sessions/abc",
|
|
45
|
+
outputs: [
|
|
46
|
+
{ type: "text", text: "logs" },
|
|
47
|
+
{ pullRequest: { url: "https://github.com/arii/boomtick/pull/100" } },
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
const session = parseJulesSession(input);
|
|
51
|
+
expect(session.pullRequestUrl).toBe("https://github.com/arii/boomtick/pull/100");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should return undefined pullRequestUrl if missing or invalid outputs", () => {
|
|
55
|
+
expect(parseJulesSession({ outputs: [] }).pullRequestUrl).toBeUndefined();
|
|
56
|
+
expect(parseJulesSession({ outputs: [{ other: "data" }] }).pullRequestUrl).toBeUndefined();
|
|
57
|
+
expect(parseJulesSession({ outputs: "not-an-array" }).pullRequestUrl).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should parse createTime or fallback to current date", () => {
|
|
61
|
+
const timeStr = "2025-01-01T12:00:00Z";
|
|
62
|
+
const sessionWithTime = parseJulesSession({ createTime: timeStr });
|
|
63
|
+
expect(sessionWithTime.createdAt).toEqual(new Date(timeStr));
|
|
64
|
+
|
|
65
|
+
const sessionWithoutTime = parseJulesSession({});
|
|
66
|
+
expect(sessionWithoutTime.createdAt).toBeInstanceOf(Date);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -30,6 +30,12 @@ describe("repo.get_package_scripts", () => {
|
|
|
30
30
|
await expect(getPackageScriptsHandler({})).rejects.toThrow("Failed to read package.json: File not found");
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
it("should throw error when package.json is malformed", async () => {
|
|
34
|
+
vi.mocked(fs.readFile).mockResolvedValue("{ invalid json ");
|
|
35
|
+
|
|
36
|
+
await expect(getPackageScriptsHandler({})).rejects.toThrow(/Failed to read package.json: Malformed package.json:/);
|
|
37
|
+
});
|
|
38
|
+
|
|
33
39
|
it("should filter scripts by pattern", async () => {
|
|
34
40
|
const mockPkg = {
|
|
35
41
|
scripts: {
|