postgresai 0.14.0-dev.8 → 0.14.0-dev.81
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/README.md +161 -61
- package/bin/postgres-ai.ts +2596 -428
- package/bun.lock +258 -0
- package/bunfig.toml +20 -0
- package/dist/bin/postgres-ai.js +31277 -1575
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.extensions.sql +8 -0
- package/dist/sql/03.permissions.sql +38 -0
- package/dist/sql/04.optional_rds.sql +6 -0
- package/dist/sql/05.optional_self_managed.sql +8 -0
- package/dist/sql/06.helpers.sql +439 -0
- package/dist/sql/sql/01.role.sql +16 -0
- package/dist/sql/sql/02.extensions.sql +8 -0
- package/dist/sql/sql/03.permissions.sql +38 -0
- package/dist/sql/sql/04.optional_rds.sql +6 -0
- package/dist/sql/sql/05.optional_self_managed.sql +8 -0
- package/dist/sql/sql/06.helpers.sql +439 -0
- package/dist/sql/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/sql/uninit/03.role.sql +27 -0
- package/dist/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/uninit/03.role.sql +27 -0
- package/lib/auth-server.ts +124 -106
- package/lib/checkup-api.ts +386 -0
- package/lib/checkup-dictionary.ts +113 -0
- package/lib/checkup.ts +1512 -0
- package/lib/config.ts +6 -3
- package/lib/init.ts +655 -189
- package/lib/issues.ts +848 -193
- package/lib/mcp-server.ts +391 -91
- package/lib/metrics-loader.ts +127 -0
- package/lib/supabase.ts +824 -0
- package/lib/util.ts +61 -0
- package/package.json +22 -10
- package/packages/postgres-ai/README.md +26 -0
- package/packages/postgres-ai/bin/postgres-ai.js +27 -0
- package/packages/postgres-ai/package.json +27 -0
- package/scripts/embed-checkup-dictionary.ts +106 -0
- package/scripts/embed-metrics.ts +154 -0
- package/sql/01.role.sql +16 -0
- package/sql/02.extensions.sql +8 -0
- package/sql/03.permissions.sql +38 -0
- package/sql/04.optional_rds.sql +6 -0
- package/sql/05.optional_self_managed.sql +8 -0
- package/sql/06.helpers.sql +439 -0
- package/sql/uninit/01.helpers.sql +5 -0
- package/sql/uninit/02.permissions.sql +30 -0
- package/sql/uninit/03.role.sql +27 -0
- package/test/auth.test.ts +258 -0
- package/test/checkup.integration.test.ts +321 -0
- package/test/checkup.test.ts +1116 -0
- package/test/config-consistency.test.ts +36 -0
- package/test/init.integration.test.ts +508 -0
- package/test/init.test.ts +916 -0
- package/test/issues.cli.test.ts +538 -0
- package/test/issues.test.ts +456 -0
- package/test/mcp-server.test.ts +1527 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/supabase.test.ts +568 -0
- package/test/test-utils.ts +128 -0
- package/tsconfig.json +12 -20
- package/dist/bin/postgres-ai.d.ts +0 -3
- package/dist/bin/postgres-ai.d.ts.map +0 -1
- package/dist/bin/postgres-ai.js.map +0 -1
- package/dist/lib/auth-server.d.ts +0 -31
- package/dist/lib/auth-server.d.ts.map +0 -1
- package/dist/lib/auth-server.js +0 -263
- package/dist/lib/auth-server.js.map +0 -1
- package/dist/lib/config.d.ts +0 -45
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -181
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/init.d.ts +0 -64
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -399
- package/dist/lib/init.js.map +0 -1
- package/dist/lib/issues.d.ts +0 -75
- package/dist/lib/issues.d.ts.map +0 -1
- package/dist/lib/issues.js +0 -336
- package/dist/lib/issues.js.map +0 -1
- package/dist/lib/mcp-server.d.ts +0 -9
- package/dist/lib/mcp-server.d.ts.map +0 -1
- package/dist/lib/mcp-server.js +0 -168
- package/dist/lib/mcp-server.js.map +0 -1
- package/dist/lib/pkce.d.ts +0 -32
- package/dist/lib/pkce.d.ts.map +0 -1
- package/dist/lib/pkce.js +0 -101
- package/dist/lib/pkce.js.map +0 -1
- package/dist/lib/util.d.ts +0 -27
- package/dist/lib/util.d.ts.map +0 -1
- package/dist/lib/util.js +0 -46
- package/dist/lib/util.js.map +0 -1
- package/dist/package.json +0 -46
- package/test/init.integration.test.cjs +0 -269
- package/test/init.test.cjs +0 -76
|
@@ -0,0 +1,1527 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
|
2
|
+
import { handleToolCall, interpretEscapes, type McpToolRequest } from "../lib/mcp-server";
|
|
3
|
+
import * as config from "../lib/config";
|
|
4
|
+
import * as issues from "../lib/issues";
|
|
5
|
+
|
|
6
|
+
// Save originals for restoration
|
|
7
|
+
const originalFetch = globalThis.fetch;
|
|
8
|
+
const originalEnv = { ...process.env };
|
|
9
|
+
|
|
10
|
+
// Helper to create MCP tool request
|
|
11
|
+
function createRequest(name: string, args?: Record<string, unknown>): McpToolRequest {
|
|
12
|
+
return {
|
|
13
|
+
params: {
|
|
14
|
+
name,
|
|
15
|
+
arguments: args,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Helper to extract text from response
|
|
21
|
+
function getResponseText(response: { content: Array<{ text: string }> }): string {
|
|
22
|
+
return response.content[0]?.text || "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("MCP Server", () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
// Clear env vars that might interfere
|
|
28
|
+
delete process.env.PGAI_API_KEY;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
// Restore originals
|
|
33
|
+
globalThis.fetch = originalFetch;
|
|
34
|
+
process.env = { ...originalEnv };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("interpretEscapes", () => {
|
|
38
|
+
test("converts \\n to newline", () => {
|
|
39
|
+
expect(interpretEscapes("line1\\nline2")).toBe("line1\nline2");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("converts \\t to tab", () => {
|
|
43
|
+
expect(interpretEscapes("col1\\tcol2")).toBe("col1\tcol2");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("converts \\r to carriage return", () => {
|
|
47
|
+
expect(interpretEscapes("text\\rmore")).toBe("text\rmore");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('converts \\" to double quote', () => {
|
|
51
|
+
expect(interpretEscapes('say \\"hello\\"')).toBe('say "hello"');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("converts \\' to single quote", () => {
|
|
55
|
+
expect(interpretEscapes("it\\'s")).toBe("it's");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("handles multiple escape sequences", () => {
|
|
59
|
+
expect(interpretEscapes("line1\\nline2\\ttab\\nline3")).toBe("line1\nline2\ttab\nline3");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("handles empty string", () => {
|
|
63
|
+
expect(interpretEscapes("")).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("handles null/undefined gracefully", () => {
|
|
67
|
+
expect(interpretEscapes(null as unknown as string)).toBe("");
|
|
68
|
+
expect(interpretEscapes(undefined as unknown as string)).toBe("");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("API key validation", () => {
|
|
73
|
+
test("returns error when no API key available", async () => {
|
|
74
|
+
// Mock config to return no API key
|
|
75
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
76
|
+
apiKey: null,
|
|
77
|
+
baseUrl: null,
|
|
78
|
+
orgId: null,
|
|
79
|
+
defaultProject: null,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const response = await handleToolCall(createRequest("list_issues"));
|
|
83
|
+
|
|
84
|
+
expect(response.isError).toBe(true);
|
|
85
|
+
expect(getResponseText(response)).toContain("API key is required");
|
|
86
|
+
|
|
87
|
+
readConfigSpy.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("uses API key from rootOpts when provided", async () => {
|
|
91
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
92
|
+
apiKey: null,
|
|
93
|
+
baseUrl: null,
|
|
94
|
+
orgId: null,
|
|
95
|
+
defaultProject: null,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Mock fetch to verify API key is used
|
|
99
|
+
let capturedHeaders: HeadersInit | undefined;
|
|
100
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
101
|
+
capturedHeaders = options?.headers;
|
|
102
|
+
return Promise.resolve(
|
|
103
|
+
new Response(JSON.stringify([]), {
|
|
104
|
+
status: 200,
|
|
105
|
+
headers: { "Content-Type": "application/json" },
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await handleToolCall(createRequest("list_issues"), { apiKey: "test-api-key" });
|
|
111
|
+
|
|
112
|
+
expect(capturedHeaders).toBeDefined();
|
|
113
|
+
expect((capturedHeaders as Record<string, string>)["access-token"]).toBe("test-api-key");
|
|
114
|
+
|
|
115
|
+
readConfigSpy.mockRestore();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("falls back to config API key when rootOpts not provided", async () => {
|
|
119
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
120
|
+
apiKey: "config-api-key",
|
|
121
|
+
baseUrl: null,
|
|
122
|
+
orgId: null,
|
|
123
|
+
defaultProject: null,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
let capturedHeaders: HeadersInit | undefined;
|
|
127
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
128
|
+
capturedHeaders = options?.headers;
|
|
129
|
+
return Promise.resolve(
|
|
130
|
+
new Response(JSON.stringify([]), {
|
|
131
|
+
status: 200,
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await handleToolCall(createRequest("list_issues"));
|
|
138
|
+
|
|
139
|
+
expect(capturedHeaders).toBeDefined();
|
|
140
|
+
expect((capturedHeaders as Record<string, string>)["access-token"]).toBe("config-api-key");
|
|
141
|
+
|
|
142
|
+
readConfigSpy.mockRestore();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("uses PGAI_API_KEY env var as fallback", async () => {
|
|
146
|
+
process.env.PGAI_API_KEY = "env-api-key";
|
|
147
|
+
|
|
148
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
149
|
+
apiKey: null,
|
|
150
|
+
baseUrl: null,
|
|
151
|
+
orgId: null,
|
|
152
|
+
defaultProject: null,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
let capturedHeaders: HeadersInit | undefined;
|
|
156
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
157
|
+
capturedHeaders = options?.headers;
|
|
158
|
+
return Promise.resolve(
|
|
159
|
+
new Response(JSON.stringify([]), {
|
|
160
|
+
status: 200,
|
|
161
|
+
headers: { "Content-Type": "application/json" },
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await handleToolCall(createRequest("list_issues"));
|
|
167
|
+
|
|
168
|
+
expect(capturedHeaders).toBeDefined();
|
|
169
|
+
expect((capturedHeaders as Record<string, string>)["access-token"]).toBe("env-api-key");
|
|
170
|
+
|
|
171
|
+
readConfigSpy.mockRestore();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("list_issues tool", () => {
|
|
176
|
+
test("successfully returns issues list as JSON", async () => {
|
|
177
|
+
const mockIssues = [
|
|
178
|
+
{ id: "issue-1", title: "First Issue" },
|
|
179
|
+
{ id: "issue-2", title: "Second Issue" },
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
183
|
+
apiKey: "test-key",
|
|
184
|
+
baseUrl: null,
|
|
185
|
+
orgId: null,
|
|
186
|
+
defaultProject: null,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
globalThis.fetch = mock(() =>
|
|
190
|
+
Promise.resolve(
|
|
191
|
+
new Response(JSON.stringify(mockIssues), {
|
|
192
|
+
status: 200,
|
|
193
|
+
headers: { "Content-Type": "application/json" },
|
|
194
|
+
})
|
|
195
|
+
)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const response = await handleToolCall(createRequest("list_issues"));
|
|
199
|
+
|
|
200
|
+
expect(response.isError).toBeUndefined();
|
|
201
|
+
const parsed = JSON.parse(getResponseText(response));
|
|
202
|
+
expect(parsed).toHaveLength(2);
|
|
203
|
+
expect(parsed[0].title).toBe("First Issue");
|
|
204
|
+
|
|
205
|
+
readConfigSpy.mockRestore();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("handles API errors gracefully", async () => {
|
|
209
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
210
|
+
apiKey: "test-key",
|
|
211
|
+
baseUrl: null,
|
|
212
|
+
orgId: null,
|
|
213
|
+
defaultProject: null,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
globalThis.fetch = mock(() =>
|
|
217
|
+
Promise.resolve(
|
|
218
|
+
new Response('{"message": "Unauthorized"}', {
|
|
219
|
+
status: 401,
|
|
220
|
+
headers: { "Content-Type": "application/json" },
|
|
221
|
+
})
|
|
222
|
+
)
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const response = await handleToolCall(createRequest("list_issues"));
|
|
226
|
+
|
|
227
|
+
expect(response.isError).toBe(true);
|
|
228
|
+
expect(getResponseText(response)).toContain("401");
|
|
229
|
+
|
|
230
|
+
readConfigSpy.mockRestore();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("view_issue tool", () => {
|
|
235
|
+
test("returns error when issue_id is empty", async () => {
|
|
236
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
237
|
+
apiKey: "test-key",
|
|
238
|
+
baseUrl: null,
|
|
239
|
+
orgId: null,
|
|
240
|
+
defaultProject: null,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const response = await handleToolCall(createRequest("view_issue", { issue_id: "" }));
|
|
244
|
+
|
|
245
|
+
expect(response.isError).toBe(true);
|
|
246
|
+
expect(getResponseText(response)).toBe("issue_id is required");
|
|
247
|
+
|
|
248
|
+
readConfigSpy.mockRestore();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("returns error when issue_id is whitespace only", async () => {
|
|
252
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
253
|
+
apiKey: "test-key",
|
|
254
|
+
baseUrl: null,
|
|
255
|
+
orgId: null,
|
|
256
|
+
defaultProject: null,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const response = await handleToolCall(createRequest("view_issue", { issue_id: " " }));
|
|
260
|
+
|
|
261
|
+
expect(response.isError).toBe(true);
|
|
262
|
+
expect(getResponseText(response)).toBe("issue_id is required");
|
|
263
|
+
|
|
264
|
+
readConfigSpy.mockRestore();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("returns error when issue not found", async () => {
|
|
268
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
269
|
+
apiKey: "test-key",
|
|
270
|
+
baseUrl: null,
|
|
271
|
+
orgId: null,
|
|
272
|
+
defaultProject: null,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Return null for issue (not found)
|
|
276
|
+
globalThis.fetch = mock(() =>
|
|
277
|
+
Promise.resolve(
|
|
278
|
+
new Response("null", {
|
|
279
|
+
status: 200,
|
|
280
|
+
headers: { "Content-Type": "application/json" },
|
|
281
|
+
})
|
|
282
|
+
)
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const response = await handleToolCall(createRequest("view_issue", { issue_id: "nonexistent-id" }));
|
|
286
|
+
|
|
287
|
+
expect(response.isError).toBe(true);
|
|
288
|
+
expect(getResponseText(response)).toBe("Issue not found");
|
|
289
|
+
|
|
290
|
+
readConfigSpy.mockRestore();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("successfully returns combined issue and comments", async () => {
|
|
294
|
+
const mockIssue = { id: "issue-1", title: "Test Issue" };
|
|
295
|
+
const mockComments = [{ id: "comment-1", content: "Test comment" }];
|
|
296
|
+
|
|
297
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
298
|
+
apiKey: "test-key",
|
|
299
|
+
baseUrl: null,
|
|
300
|
+
orgId: null,
|
|
301
|
+
defaultProject: null,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
let callCount = 0;
|
|
305
|
+
globalThis.fetch = mock((url: string) => {
|
|
306
|
+
callCount++;
|
|
307
|
+
// First call is for the issue, second is for comments
|
|
308
|
+
if (url.includes("issue_get") || callCount === 1) {
|
|
309
|
+
return Promise.resolve(
|
|
310
|
+
new Response(JSON.stringify(mockIssue), {
|
|
311
|
+
status: 200,
|
|
312
|
+
headers: { "Content-Type": "application/json" },
|
|
313
|
+
})
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return Promise.resolve(
|
|
317
|
+
new Response(JSON.stringify(mockComments), {
|
|
318
|
+
status: 200,
|
|
319
|
+
headers: { "Content-Type": "application/json" },
|
|
320
|
+
})
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const response = await handleToolCall(createRequest("view_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }));
|
|
325
|
+
|
|
326
|
+
expect(response.isError).toBeUndefined();
|
|
327
|
+
const parsed = JSON.parse(getResponseText(response));
|
|
328
|
+
expect(parsed.issue.title).toBe("Test Issue");
|
|
329
|
+
expect(parsed.comments).toHaveLength(1);
|
|
330
|
+
|
|
331
|
+
readConfigSpy.mockRestore();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("post_issue_comment tool", () => {
|
|
336
|
+
test("returns error when issue_id is empty", async () => {
|
|
337
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
338
|
+
apiKey: "test-key",
|
|
339
|
+
baseUrl: null,
|
|
340
|
+
orgId: null,
|
|
341
|
+
defaultProject: null,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const response = await handleToolCall(
|
|
345
|
+
createRequest("post_issue_comment", { issue_id: "", content: "test" })
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
expect(response.isError).toBe(true);
|
|
349
|
+
expect(getResponseText(response)).toBe("issue_id is required");
|
|
350
|
+
|
|
351
|
+
readConfigSpy.mockRestore();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("returns error when content is empty", async () => {
|
|
355
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
356
|
+
apiKey: "test-key",
|
|
357
|
+
baseUrl: null,
|
|
358
|
+
orgId: null,
|
|
359
|
+
defaultProject: null,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const response = await handleToolCall(
|
|
363
|
+
createRequest("post_issue_comment", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", content: "" })
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
expect(response.isError).toBe(true);
|
|
367
|
+
expect(getResponseText(response)).toBe("content is required");
|
|
368
|
+
|
|
369
|
+
readConfigSpy.mockRestore();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("interprets escape sequences in content", async () => {
|
|
373
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
374
|
+
apiKey: "test-key",
|
|
375
|
+
baseUrl: null,
|
|
376
|
+
orgId: null,
|
|
377
|
+
defaultProject: null,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
let capturedBody: string | undefined;
|
|
381
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
382
|
+
capturedBody = options?.body as string;
|
|
383
|
+
return Promise.resolve(
|
|
384
|
+
new Response(JSON.stringify({ id: "comment-1" }), {
|
|
385
|
+
status: 200,
|
|
386
|
+
headers: { "Content-Type": "application/json" },
|
|
387
|
+
})
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await handleToolCall(
|
|
392
|
+
createRequest("post_issue_comment", {
|
|
393
|
+
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
394
|
+
content: "line1\\nline2\\ttab",
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
expect(capturedBody).toBeDefined();
|
|
399
|
+
const parsed = JSON.parse(capturedBody!);
|
|
400
|
+
expect(parsed.content).toBe("line1\nline2\ttab");
|
|
401
|
+
|
|
402
|
+
readConfigSpy.mockRestore();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("successfully creates comment with parent_comment_id", async () => {
|
|
406
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
407
|
+
apiKey: "test-key",
|
|
408
|
+
baseUrl: null,
|
|
409
|
+
orgId: null,
|
|
410
|
+
defaultProject: null,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
let capturedBody: string | undefined;
|
|
414
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
415
|
+
capturedBody = options?.body as string;
|
|
416
|
+
return Promise.resolve(
|
|
417
|
+
new Response(JSON.stringify({ id: "comment-1", parent_comment_id: "parent-1" }), {
|
|
418
|
+
status: 200,
|
|
419
|
+
headers: { "Content-Type": "application/json" },
|
|
420
|
+
})
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const response = await handleToolCall(
|
|
425
|
+
createRequest("post_issue_comment", {
|
|
426
|
+
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
427
|
+
content: "Reply content",
|
|
428
|
+
parent_comment_id: "parent-1",
|
|
429
|
+
})
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
expect(response.isError).toBeUndefined();
|
|
433
|
+
expect(capturedBody).toBeDefined();
|
|
434
|
+
const parsed = JSON.parse(capturedBody!);
|
|
435
|
+
expect(parsed.parent_comment_id).toBe("parent-1");
|
|
436
|
+
|
|
437
|
+
readConfigSpy.mockRestore();
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe("create_issue tool", () => {
|
|
442
|
+
test("returns error when title is empty", async () => {
|
|
443
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
444
|
+
apiKey: "test-key",
|
|
445
|
+
baseUrl: null,
|
|
446
|
+
orgId: 1,
|
|
447
|
+
defaultProject: null,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const response = await handleToolCall(createRequest("create_issue", { title: "" }));
|
|
451
|
+
|
|
452
|
+
expect(response.isError).toBe(true);
|
|
453
|
+
expect(getResponseText(response)).toBe("title is required");
|
|
454
|
+
|
|
455
|
+
readConfigSpy.mockRestore();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("returns error when title is whitespace only", async () => {
|
|
459
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
460
|
+
apiKey: "test-key",
|
|
461
|
+
baseUrl: null,
|
|
462
|
+
orgId: 1,
|
|
463
|
+
defaultProject: null,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const response = await handleToolCall(createRequest("create_issue", { title: " " }));
|
|
467
|
+
|
|
468
|
+
expect(response.isError).toBe(true);
|
|
469
|
+
expect(getResponseText(response)).toBe("title is required");
|
|
470
|
+
|
|
471
|
+
readConfigSpy.mockRestore();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("returns error when org_id not provided and not in config", async () => {
|
|
475
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
476
|
+
apiKey: "test-key",
|
|
477
|
+
baseUrl: null,
|
|
478
|
+
orgId: null,
|
|
479
|
+
defaultProject: null,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const response = await handleToolCall(createRequest("create_issue", { title: "Test Issue" }));
|
|
483
|
+
|
|
484
|
+
expect(response.isError).toBe(true);
|
|
485
|
+
expect(getResponseText(response)).toContain("org_id is required");
|
|
486
|
+
|
|
487
|
+
readConfigSpy.mockRestore();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("falls back to config orgId when not provided in args", async () => {
|
|
491
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
492
|
+
apiKey: "test-key",
|
|
493
|
+
baseUrl: null,
|
|
494
|
+
orgId: 42,
|
|
495
|
+
defaultProject: null,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
let capturedBody: string | undefined;
|
|
499
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
500
|
+
capturedBody = options?.body as string;
|
|
501
|
+
return Promise.resolve(
|
|
502
|
+
new Response(JSON.stringify({ id: "new-issue" }), {
|
|
503
|
+
status: 200,
|
|
504
|
+
headers: { "Content-Type": "application/json" },
|
|
505
|
+
})
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
await handleToolCall(createRequest("create_issue", { title: "Test Issue" }));
|
|
510
|
+
|
|
511
|
+
expect(capturedBody).toBeDefined();
|
|
512
|
+
const parsed = JSON.parse(capturedBody!);
|
|
513
|
+
expect(parsed.org_id).toBe(42);
|
|
514
|
+
|
|
515
|
+
readConfigSpy.mockRestore();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("interprets escape sequences in title and description", async () => {
|
|
519
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
520
|
+
apiKey: "test-key",
|
|
521
|
+
baseUrl: null,
|
|
522
|
+
orgId: 1,
|
|
523
|
+
defaultProject: null,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
let capturedBody: string | undefined;
|
|
527
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
528
|
+
capturedBody = options?.body as string;
|
|
529
|
+
return Promise.resolve(
|
|
530
|
+
new Response(JSON.stringify({ id: "new-issue" }), {
|
|
531
|
+
status: 200,
|
|
532
|
+
headers: { "Content-Type": "application/json" },
|
|
533
|
+
})
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
await handleToolCall(
|
|
538
|
+
createRequest("create_issue", {
|
|
539
|
+
title: "Title\\nwith newline",
|
|
540
|
+
description: "Desc\\twith tab",
|
|
541
|
+
})
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
expect(capturedBody).toBeDefined();
|
|
545
|
+
const parsed = JSON.parse(capturedBody!);
|
|
546
|
+
expect(parsed.title).toBe("Title\nwith newline");
|
|
547
|
+
expect(parsed.description).toBe("Desc\twith tab");
|
|
548
|
+
|
|
549
|
+
readConfigSpy.mockRestore();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("successfully creates issue with all parameters", async () => {
|
|
553
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
554
|
+
apiKey: "test-key",
|
|
555
|
+
baseUrl: null,
|
|
556
|
+
orgId: null,
|
|
557
|
+
defaultProject: null,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
let capturedBody: string | undefined;
|
|
561
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
562
|
+
capturedBody = options?.body as string;
|
|
563
|
+
return Promise.resolve(
|
|
564
|
+
new Response(JSON.stringify({ id: "new-issue", title: "Test" }), {
|
|
565
|
+
status: 200,
|
|
566
|
+
headers: { "Content-Type": "application/json" },
|
|
567
|
+
})
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const response = await handleToolCall(
|
|
572
|
+
createRequest("create_issue", {
|
|
573
|
+
title: "Test Issue",
|
|
574
|
+
description: "Test description",
|
|
575
|
+
org_id: 123,
|
|
576
|
+
project_id: 456,
|
|
577
|
+
labels: ["bug", "urgent"],
|
|
578
|
+
})
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
expect(response.isError).toBeUndefined();
|
|
582
|
+
expect(capturedBody).toBeDefined();
|
|
583
|
+
const parsed = JSON.parse(capturedBody!);
|
|
584
|
+
expect(parsed.title).toBe("Test Issue");
|
|
585
|
+
expect(parsed.description).toBe("Test description");
|
|
586
|
+
expect(parsed.org_id).toBe(123);
|
|
587
|
+
expect(parsed.project_id).toBe(456);
|
|
588
|
+
expect(parsed.labels).toEqual(["bug", "urgent"]);
|
|
589
|
+
|
|
590
|
+
readConfigSpy.mockRestore();
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
describe("update_issue tool", () => {
|
|
595
|
+
test("returns error when issue_id is empty", async () => {
|
|
596
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
597
|
+
apiKey: "test-key",
|
|
598
|
+
baseUrl: null,
|
|
599
|
+
orgId: null,
|
|
600
|
+
defaultProject: null,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const response = await handleToolCall(
|
|
604
|
+
createRequest("update_issue", { issue_id: "", title: "New Title" })
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
expect(response.isError).toBe(true);
|
|
608
|
+
expect(getResponseText(response)).toBe("issue_id is required");
|
|
609
|
+
|
|
610
|
+
readConfigSpy.mockRestore();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("returns error when no update fields provided", async () => {
|
|
614
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
615
|
+
apiKey: "test-key",
|
|
616
|
+
baseUrl: null,
|
|
617
|
+
orgId: null,
|
|
618
|
+
defaultProject: null,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const response = await handleToolCall(createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }));
|
|
622
|
+
|
|
623
|
+
expect(response.isError).toBe(true);
|
|
624
|
+
expect(getResponseText(response)).toContain("At least one field to update is required");
|
|
625
|
+
|
|
626
|
+
readConfigSpy.mockRestore();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("returns error when status is not 0 or 1", async () => {
|
|
630
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
631
|
+
apiKey: "test-key",
|
|
632
|
+
baseUrl: null,
|
|
633
|
+
orgId: null,
|
|
634
|
+
defaultProject: null,
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const response = await handleToolCall(
|
|
638
|
+
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", status: 2 })
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
expect(response.isError).toBe(true);
|
|
642
|
+
expect(getResponseText(response)).toBe("status must be 0 (open) or 1 (closed)");
|
|
643
|
+
|
|
644
|
+
readConfigSpy.mockRestore();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("returns error when status is negative", async () => {
|
|
648
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
649
|
+
apiKey: "test-key",
|
|
650
|
+
baseUrl: null,
|
|
651
|
+
orgId: null,
|
|
652
|
+
defaultProject: null,
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const response = await handleToolCall(
|
|
656
|
+
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", status: -1 })
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
expect(response.isError).toBe(true);
|
|
660
|
+
expect(getResponseText(response)).toBe("status must be 0 (open) or 1 (closed)");
|
|
661
|
+
|
|
662
|
+
readConfigSpy.mockRestore();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("interprets escape sequences in title and description", async () => {
|
|
666
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
667
|
+
apiKey: "test-key",
|
|
668
|
+
baseUrl: null,
|
|
669
|
+
orgId: null,
|
|
670
|
+
defaultProject: null,
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
let capturedBody: string | undefined;
|
|
674
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
675
|
+
capturedBody = options?.body as string;
|
|
676
|
+
return Promise.resolve(
|
|
677
|
+
new Response(JSON.stringify({ id: "issue-1" }), {
|
|
678
|
+
status: 200,
|
|
679
|
+
headers: { "Content-Type": "application/json" },
|
|
680
|
+
})
|
|
681
|
+
);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
await handleToolCall(
|
|
685
|
+
createRequest("update_issue", {
|
|
686
|
+
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
687
|
+
title: "Updated\\nTitle",
|
|
688
|
+
description: "Updated\\tDescription",
|
|
689
|
+
})
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
expect(capturedBody).toBeDefined();
|
|
693
|
+
const parsed = JSON.parse(capturedBody!);
|
|
694
|
+
expect(parsed.p_title).toBe("Updated\nTitle");
|
|
695
|
+
expect(parsed.p_description).toBe("Updated\tDescription");
|
|
696
|
+
|
|
697
|
+
readConfigSpy.mockRestore();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("successfully updates with only title", async () => {
|
|
701
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
702
|
+
apiKey: "test-key",
|
|
703
|
+
baseUrl: null,
|
|
704
|
+
orgId: null,
|
|
705
|
+
defaultProject: null,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
globalThis.fetch = mock(() =>
|
|
709
|
+
Promise.resolve(
|
|
710
|
+
new Response(JSON.stringify({ id: "issue-1", title: "New Title" }), {
|
|
711
|
+
status: 200,
|
|
712
|
+
headers: { "Content-Type": "application/json" },
|
|
713
|
+
})
|
|
714
|
+
)
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
const response = await handleToolCall(
|
|
718
|
+
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", title: "New Title" })
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
expect(response.isError).toBeUndefined();
|
|
722
|
+
|
|
723
|
+
readConfigSpy.mockRestore();
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("successfully updates with only status", async () => {
|
|
727
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
728
|
+
apiKey: "test-key",
|
|
729
|
+
baseUrl: null,
|
|
730
|
+
orgId: null,
|
|
731
|
+
defaultProject: null,
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
let capturedBody: string | undefined;
|
|
735
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
736
|
+
capturedBody = options?.body as string;
|
|
737
|
+
return Promise.resolve(
|
|
738
|
+
new Response(JSON.stringify({ id: "issue-1", status: 1 }), {
|
|
739
|
+
status: 200,
|
|
740
|
+
headers: { "Content-Type": "application/json" },
|
|
741
|
+
})
|
|
742
|
+
);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const response = await handleToolCall(
|
|
746
|
+
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", status: 1 })
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
expect(response.isError).toBeUndefined();
|
|
750
|
+
expect(capturedBody).toBeDefined();
|
|
751
|
+
const parsed = JSON.parse(capturedBody!);
|
|
752
|
+
expect(parsed.p_status).toBe(1);
|
|
753
|
+
|
|
754
|
+
readConfigSpy.mockRestore();
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("successfully updates with only labels", async () => {
|
|
758
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
759
|
+
apiKey: "test-key",
|
|
760
|
+
baseUrl: null,
|
|
761
|
+
orgId: null,
|
|
762
|
+
defaultProject: null,
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
let capturedBody: string | undefined;
|
|
766
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
767
|
+
capturedBody = options?.body as string;
|
|
768
|
+
return Promise.resolve(
|
|
769
|
+
new Response(JSON.stringify({ id: "issue-1", labels: ["new-label"] }), {
|
|
770
|
+
status: 200,
|
|
771
|
+
headers: { "Content-Type": "application/json" },
|
|
772
|
+
})
|
|
773
|
+
);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
const response = await handleToolCall(
|
|
777
|
+
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", labels: ["new-label"] })
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
expect(response.isError).toBeUndefined();
|
|
781
|
+
expect(capturedBody).toBeDefined();
|
|
782
|
+
const parsed = JSON.parse(capturedBody!);
|
|
783
|
+
expect(parsed.p_labels).toEqual(["new-label"]);
|
|
784
|
+
|
|
785
|
+
readConfigSpy.mockRestore();
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("accepts status=0 to reopen issue", async () => {
|
|
789
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
790
|
+
apiKey: "test-key",
|
|
791
|
+
baseUrl: null,
|
|
792
|
+
orgId: null,
|
|
793
|
+
defaultProject: null,
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
let capturedBody: string | undefined;
|
|
797
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
798
|
+
capturedBody = options?.body as string;
|
|
799
|
+
return Promise.resolve(
|
|
800
|
+
new Response(JSON.stringify({ id: "issue-1", status: 0 }), {
|
|
801
|
+
status: 200,
|
|
802
|
+
headers: { "Content-Type": "application/json" },
|
|
803
|
+
})
|
|
804
|
+
);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
const response = await handleToolCall(
|
|
808
|
+
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", status: 0 })
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
expect(response.isError).toBeUndefined();
|
|
812
|
+
expect(capturedBody).toBeDefined();
|
|
813
|
+
const parsed = JSON.parse(capturedBody!);
|
|
814
|
+
expect(parsed.p_status).toBe(0);
|
|
815
|
+
|
|
816
|
+
readConfigSpy.mockRestore();
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe("update_issue_comment tool", () => {
|
|
821
|
+
test("returns error when comment_id is empty", async () => {
|
|
822
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
823
|
+
apiKey: "test-key",
|
|
824
|
+
baseUrl: null,
|
|
825
|
+
orgId: null,
|
|
826
|
+
defaultProject: null,
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
const response = await handleToolCall(
|
|
830
|
+
createRequest("update_issue_comment", { comment_id: "", content: "new content" })
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
expect(response.isError).toBe(true);
|
|
834
|
+
expect(getResponseText(response)).toBe("comment_id is required");
|
|
835
|
+
|
|
836
|
+
readConfigSpy.mockRestore();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test("returns error when content is empty", async () => {
|
|
840
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
841
|
+
apiKey: "test-key",
|
|
842
|
+
baseUrl: null,
|
|
843
|
+
orgId: null,
|
|
844
|
+
defaultProject: null,
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const response = await handleToolCall(
|
|
848
|
+
createRequest("update_issue_comment", { comment_id: "comment-1", content: "" })
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
expect(response.isError).toBe(true);
|
|
852
|
+
expect(getResponseText(response)).toBe("content is required");
|
|
853
|
+
|
|
854
|
+
readConfigSpy.mockRestore();
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("interprets escape sequences in content", async () => {
|
|
858
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
859
|
+
apiKey: "test-key",
|
|
860
|
+
baseUrl: null,
|
|
861
|
+
orgId: null,
|
|
862
|
+
defaultProject: null,
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
let capturedBody: string | undefined;
|
|
866
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
867
|
+
capturedBody = options?.body as string;
|
|
868
|
+
return Promise.resolve(
|
|
869
|
+
new Response(JSON.stringify({ id: "comment-1" }), {
|
|
870
|
+
status: 200,
|
|
871
|
+
headers: { "Content-Type": "application/json" },
|
|
872
|
+
})
|
|
873
|
+
);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
await handleToolCall(
|
|
877
|
+
createRequest("update_issue_comment", {
|
|
878
|
+
comment_id: "comment-1",
|
|
879
|
+
content: "updated\\ncontent\\twith escapes",
|
|
880
|
+
})
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
expect(capturedBody).toBeDefined();
|
|
884
|
+
const parsed = JSON.parse(capturedBody!);
|
|
885
|
+
expect(parsed.p_content).toBe("updated\ncontent\twith escapes");
|
|
886
|
+
|
|
887
|
+
readConfigSpy.mockRestore();
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test("successfully updates comment", async () => {
|
|
891
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
892
|
+
apiKey: "test-key",
|
|
893
|
+
baseUrl: null,
|
|
894
|
+
orgId: null,
|
|
895
|
+
defaultProject: null,
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
globalThis.fetch = mock(() =>
|
|
899
|
+
Promise.resolve(
|
|
900
|
+
new Response(JSON.stringify({ id: "comment-1", content: "Updated content" }), {
|
|
901
|
+
status: 200,
|
|
902
|
+
headers: { "Content-Type": "application/json" },
|
|
903
|
+
})
|
|
904
|
+
)
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
const response = await handleToolCall(
|
|
908
|
+
createRequest("update_issue_comment", {
|
|
909
|
+
comment_id: "comment-1",
|
|
910
|
+
content: "Updated content",
|
|
911
|
+
})
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
expect(response.isError).toBeUndefined();
|
|
915
|
+
const parsed = JSON.parse(getResponseText(response));
|
|
916
|
+
expect(parsed.content).toBe("Updated content");
|
|
917
|
+
|
|
918
|
+
readConfigSpy.mockRestore();
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
describe("view_action_item tool", () => {
|
|
923
|
+
test("returns error when no IDs provided", async () => {
|
|
924
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
925
|
+
apiKey: "test-key",
|
|
926
|
+
baseUrl: null,
|
|
927
|
+
orgId: null,
|
|
928
|
+
defaultProject: null,
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
const response = await handleToolCall(createRequest("view_action_item", {}));
|
|
932
|
+
|
|
933
|
+
expect(response.isError).toBe(true);
|
|
934
|
+
expect(getResponseText(response)).toBe("action_item_id or action_item_ids is required");
|
|
935
|
+
|
|
936
|
+
readConfigSpy.mockRestore();
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
test("returns error when action_item_id is empty", async () => {
|
|
940
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
941
|
+
apiKey: "test-key",
|
|
942
|
+
baseUrl: null,
|
|
943
|
+
orgId: null,
|
|
944
|
+
defaultProject: null,
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
const response = await handleToolCall(createRequest("view_action_item", { action_item_id: "" }));
|
|
948
|
+
|
|
949
|
+
expect(response.isError).toBe(true);
|
|
950
|
+
expect(getResponseText(response)).toBe("action_item_id or action_item_ids is required");
|
|
951
|
+
|
|
952
|
+
readConfigSpy.mockRestore();
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
test("returns error when action_item_ids is empty array", async () => {
|
|
956
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
957
|
+
apiKey: "test-key",
|
|
958
|
+
baseUrl: null,
|
|
959
|
+
orgId: null,
|
|
960
|
+
defaultProject: null,
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
const response = await handleToolCall(createRequest("view_action_item", { action_item_ids: [] }));
|
|
964
|
+
|
|
965
|
+
expect(response.isError).toBe(true);
|
|
966
|
+
expect(getResponseText(response)).toBe("action_item_id or action_item_ids is required");
|
|
967
|
+
|
|
968
|
+
readConfigSpy.mockRestore();
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test("returns error when action_item_id is not a valid UUID", async () => {
|
|
972
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
973
|
+
apiKey: "test-key",
|
|
974
|
+
baseUrl: null,
|
|
975
|
+
orgId: null,
|
|
976
|
+
defaultProject: null,
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
const response = await handleToolCall(createRequest("view_action_item", { action_item_id: "invalid-id-format" }));
|
|
980
|
+
|
|
981
|
+
expect(response.isError).toBe(true);
|
|
982
|
+
expect(getResponseText(response)).toBe("actionItemId is required and must be a valid UUID");
|
|
983
|
+
|
|
984
|
+
readConfigSpy.mockRestore();
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
test("returns error when action item not found", async () => {
|
|
988
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
989
|
+
apiKey: "test-key",
|
|
990
|
+
baseUrl: null,
|
|
991
|
+
orgId: null,
|
|
992
|
+
defaultProject: null,
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
globalThis.fetch = mock(() =>
|
|
996
|
+
Promise.resolve(
|
|
997
|
+
new Response("[]", {
|
|
998
|
+
status: 200,
|
|
999
|
+
headers: { "Content-Type": "application/json" },
|
|
1000
|
+
})
|
|
1001
|
+
)
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
const response = await handleToolCall(createRequest("view_action_item", { action_item_id: "00000000-0000-0000-0000-000000000000" }));
|
|
1005
|
+
|
|
1006
|
+
expect(response.isError).toBe(true);
|
|
1007
|
+
expect(getResponseText(response)).toBe("Action item(s) not found");
|
|
1008
|
+
|
|
1009
|
+
readConfigSpy.mockRestore();
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test("successfully returns single action item details", async () => {
|
|
1013
|
+
const mockActionItem = {
|
|
1014
|
+
id: "11111111-1111-1111-1111-111111111111",
|
|
1015
|
+
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
1016
|
+
title: "Fix index",
|
|
1017
|
+
description: "Drop unused index",
|
|
1018
|
+
severity: 3,
|
|
1019
|
+
is_done: false,
|
|
1020
|
+
status: "waiting_for_approval",
|
|
1021
|
+
sql_action: "DROP INDEX CONCURRENTLY idx_unused;",
|
|
1022
|
+
configs: [{ parameter: "work_mem", value: "256MB" }],
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1026
|
+
apiKey: "test-key",
|
|
1027
|
+
baseUrl: null,
|
|
1028
|
+
orgId: null,
|
|
1029
|
+
defaultProject: null,
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
globalThis.fetch = mock(() =>
|
|
1033
|
+
Promise.resolve(
|
|
1034
|
+
new Response(JSON.stringify([mockActionItem]), {
|
|
1035
|
+
status: 200,
|
|
1036
|
+
headers: { "Content-Type": "application/json" },
|
|
1037
|
+
})
|
|
1038
|
+
)
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
const response = await handleToolCall(createRequest("view_action_item", { action_item_id: "11111111-1111-1111-1111-111111111111" }));
|
|
1042
|
+
|
|
1043
|
+
expect(response.isError).toBeUndefined();
|
|
1044
|
+
const parsed = JSON.parse(getResponseText(response));
|
|
1045
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
1046
|
+
expect(parsed[0].title).toBe("Fix index");
|
|
1047
|
+
expect(parsed[0].sql_action).toBe("DROP INDEX CONCURRENTLY idx_unused;");
|
|
1048
|
+
expect(parsed[0].configs).toEqual([{ parameter: "work_mem", value: "256MB" }]);
|
|
1049
|
+
|
|
1050
|
+
readConfigSpy.mockRestore();
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
test("successfully returns multiple action items", async () => {
|
|
1054
|
+
const mockActionItems = [
|
|
1055
|
+
{ id: "11111111-1111-1111-1111-111111111111", title: "Fix index", severity: 3 },
|
|
1056
|
+
{ id: "22222222-2222-2222-2222-222222222222", title: "Update config", severity: 2 },
|
|
1057
|
+
];
|
|
1058
|
+
|
|
1059
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1060
|
+
apiKey: "test-key",
|
|
1061
|
+
baseUrl: null,
|
|
1062
|
+
orgId: null,
|
|
1063
|
+
defaultProject: null,
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
let capturedUrl: string | undefined;
|
|
1067
|
+
globalThis.fetch = mock((url: string) => {
|
|
1068
|
+
capturedUrl = url;
|
|
1069
|
+
return Promise.resolve(
|
|
1070
|
+
new Response(JSON.stringify(mockActionItems), {
|
|
1071
|
+
status: 200,
|
|
1072
|
+
headers: { "Content-Type": "application/json" },
|
|
1073
|
+
})
|
|
1074
|
+
);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
const response = await handleToolCall(createRequest("view_action_item", { action_item_ids: ["11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"] }));
|
|
1078
|
+
|
|
1079
|
+
expect(response.isError).toBeUndefined();
|
|
1080
|
+
const parsed = JSON.parse(getResponseText(response));
|
|
1081
|
+
expect(parsed).toHaveLength(2);
|
|
1082
|
+
expect(parsed[0].title).toBe("Fix index");
|
|
1083
|
+
expect(parsed[1].title).toBe("Update config");
|
|
1084
|
+
// Verify the URL uses in.() syntax
|
|
1085
|
+
expect(capturedUrl).toContain("id=in.");
|
|
1086
|
+
|
|
1087
|
+
readConfigSpy.mockRestore();
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
describe("list_action_items tool", () => {
|
|
1092
|
+
test("returns error when issue_id is empty", async () => {
|
|
1093
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1094
|
+
apiKey: "test-key",
|
|
1095
|
+
baseUrl: null,
|
|
1096
|
+
orgId: null,
|
|
1097
|
+
defaultProject: null,
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
const response = await handleToolCall(createRequest("list_action_items", { issue_id: "" }));
|
|
1101
|
+
|
|
1102
|
+
expect(response.isError).toBe(true);
|
|
1103
|
+
expect(getResponseText(response)).toBe("issue_id is required");
|
|
1104
|
+
|
|
1105
|
+
readConfigSpy.mockRestore();
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
test("returns error when issue_id is whitespace only", async () => {
|
|
1109
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1110
|
+
apiKey: "test-key",
|
|
1111
|
+
baseUrl: null,
|
|
1112
|
+
orgId: null,
|
|
1113
|
+
defaultProject: null,
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
const response = await handleToolCall(createRequest("list_action_items", { issue_id: " " }));
|
|
1117
|
+
|
|
1118
|
+
expect(response.isError).toBe(true);
|
|
1119
|
+
expect(getResponseText(response)).toBe("issue_id is required");
|
|
1120
|
+
|
|
1121
|
+
readConfigSpy.mockRestore();
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
test("successfully returns action items list as JSON", async () => {
|
|
1125
|
+
const mockActionItems = [
|
|
1126
|
+
{ id: "action-1", title: "First Action", severity: 1 },
|
|
1127
|
+
{ id: "action-2", title: "Second Action", severity: 2 },
|
|
1128
|
+
];
|
|
1129
|
+
|
|
1130
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1131
|
+
apiKey: "test-key",
|
|
1132
|
+
baseUrl: null,
|
|
1133
|
+
orgId: null,
|
|
1134
|
+
defaultProject: null,
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
globalThis.fetch = mock(() =>
|
|
1138
|
+
Promise.resolve(
|
|
1139
|
+
new Response(JSON.stringify(mockActionItems), {
|
|
1140
|
+
status: 200,
|
|
1141
|
+
headers: { "Content-Type": "application/json" },
|
|
1142
|
+
})
|
|
1143
|
+
)
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
const response = await handleToolCall(createRequest("list_action_items", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }));
|
|
1147
|
+
|
|
1148
|
+
expect(response.isError).toBeUndefined();
|
|
1149
|
+
const parsed = JSON.parse(getResponseText(response));
|
|
1150
|
+
expect(parsed).toHaveLength(2);
|
|
1151
|
+
expect(parsed[0].title).toBe("First Action");
|
|
1152
|
+
|
|
1153
|
+
readConfigSpy.mockRestore();
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
describe("create_action_item tool", () => {
|
|
1158
|
+
test("returns error when issue_id is empty", async () => {
|
|
1159
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1160
|
+
apiKey: "test-key",
|
|
1161
|
+
baseUrl: null,
|
|
1162
|
+
orgId: null,
|
|
1163
|
+
defaultProject: null,
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
const response = await handleToolCall(
|
|
1167
|
+
createRequest("create_action_item", { issue_id: "", title: "Test" })
|
|
1168
|
+
);
|
|
1169
|
+
|
|
1170
|
+
expect(response.isError).toBe(true);
|
|
1171
|
+
expect(getResponseText(response)).toBe("issue_id is required");
|
|
1172
|
+
|
|
1173
|
+
readConfigSpy.mockRestore();
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
test("returns error when title is empty", async () => {
|
|
1177
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1178
|
+
apiKey: "test-key",
|
|
1179
|
+
baseUrl: null,
|
|
1180
|
+
orgId: null,
|
|
1181
|
+
defaultProject: null,
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
const response = await handleToolCall(
|
|
1185
|
+
createRequest("create_action_item", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", title: "" })
|
|
1186
|
+
);
|
|
1187
|
+
|
|
1188
|
+
expect(response.isError).toBe(true);
|
|
1189
|
+
expect(getResponseText(response)).toBe("title is required");
|
|
1190
|
+
|
|
1191
|
+
readConfigSpy.mockRestore();
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test("successfully creates action item with minimal params", async () => {
|
|
1195
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1196
|
+
apiKey: "test-key",
|
|
1197
|
+
baseUrl: null,
|
|
1198
|
+
orgId: null,
|
|
1199
|
+
defaultProject: null,
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
let capturedBody: string | undefined;
|
|
1203
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1204
|
+
capturedBody = options?.body as string;
|
|
1205
|
+
return Promise.resolve(
|
|
1206
|
+
new Response(JSON.stringify("new-action-item-id"), {
|
|
1207
|
+
status: 200,
|
|
1208
|
+
headers: { "Content-Type": "application/json" },
|
|
1209
|
+
})
|
|
1210
|
+
);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
const response = await handleToolCall(
|
|
1214
|
+
createRequest("create_action_item", {
|
|
1215
|
+
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
1216
|
+
title: "Fix the index",
|
|
1217
|
+
})
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1220
|
+
expect(response.isError).toBeUndefined();
|
|
1221
|
+
expect(capturedBody).toBeDefined();
|
|
1222
|
+
const parsed = JSON.parse(capturedBody!);
|
|
1223
|
+
expect(parsed.issue_id).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
|
1224
|
+
expect(parsed.title).toBe("Fix the index");
|
|
1225
|
+
|
|
1226
|
+
readConfigSpy.mockRestore();
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
test("successfully creates action item with all params", async () => {
|
|
1230
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1231
|
+
apiKey: "test-key",
|
|
1232
|
+
baseUrl: null,
|
|
1233
|
+
orgId: null,
|
|
1234
|
+
defaultProject: null,
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
let capturedBody: string | undefined;
|
|
1238
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1239
|
+
capturedBody = options?.body as string;
|
|
1240
|
+
return Promise.resolve(
|
|
1241
|
+
new Response(JSON.stringify("new-action-item-id"), {
|
|
1242
|
+
status: 200,
|
|
1243
|
+
headers: { "Content-Type": "application/json" },
|
|
1244
|
+
})
|
|
1245
|
+
);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
const response = await handleToolCall(
|
|
1249
|
+
createRequest("create_action_item", {
|
|
1250
|
+
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
1251
|
+
title: "Fix the index",
|
|
1252
|
+
description: "Drop the unused index to improve performance",
|
|
1253
|
+
sql_action: "DROP INDEX CONCURRENTLY idx_unused;",
|
|
1254
|
+
configs: [{ parameter: "work_mem", value: "256MB" }],
|
|
1255
|
+
})
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
expect(response.isError).toBeUndefined();
|
|
1259
|
+
expect(capturedBody).toBeDefined();
|
|
1260
|
+
const parsed = JSON.parse(capturedBody!);
|
|
1261
|
+
expect(parsed.issue_id).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
|
1262
|
+
expect(parsed.title).toBe("Fix the index");
|
|
1263
|
+
expect(parsed.description).toBe("Drop the unused index to improve performance");
|
|
1264
|
+
expect(parsed.sql_action).toBe("DROP INDEX CONCURRENTLY idx_unused;");
|
|
1265
|
+
expect(parsed.configs).toEqual([{ parameter: "work_mem", value: "256MB" }]);
|
|
1266
|
+
|
|
1267
|
+
readConfigSpy.mockRestore();
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
test("interprets escape sequences in title and description", async () => {
|
|
1271
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1272
|
+
apiKey: "test-key",
|
|
1273
|
+
baseUrl: null,
|
|
1274
|
+
orgId: null,
|
|
1275
|
+
defaultProject: null,
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
let capturedBody: string | undefined;
|
|
1279
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1280
|
+
capturedBody = options?.body as string;
|
|
1281
|
+
return Promise.resolve(
|
|
1282
|
+
new Response(JSON.stringify("new-action-item-id"), {
|
|
1283
|
+
status: 200,
|
|
1284
|
+
headers: { "Content-Type": "application/json" },
|
|
1285
|
+
})
|
|
1286
|
+
);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
await handleToolCall(
|
|
1290
|
+
createRequest("create_action_item", {
|
|
1291
|
+
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
1292
|
+
title: "Title\\nwith newline",
|
|
1293
|
+
description: "Desc\\twith tab",
|
|
1294
|
+
})
|
|
1295
|
+
);
|
|
1296
|
+
|
|
1297
|
+
expect(capturedBody).toBeDefined();
|
|
1298
|
+
const parsed = JSON.parse(capturedBody!);
|
|
1299
|
+
expect(parsed.title).toBe("Title\nwith newline");
|
|
1300
|
+
expect(parsed.description).toBe("Desc\twith tab");
|
|
1301
|
+
|
|
1302
|
+
readConfigSpy.mockRestore();
|
|
1303
|
+
});
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
describe("update_action_item tool", () => {
|
|
1307
|
+
test("returns error when action_item_id is empty", async () => {
|
|
1308
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1309
|
+
apiKey: "test-key",
|
|
1310
|
+
baseUrl: null,
|
|
1311
|
+
orgId: null,
|
|
1312
|
+
defaultProject: null,
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
const response = await handleToolCall(
|
|
1316
|
+
createRequest("update_action_item", { action_item_id: "", title: "New Title" })
|
|
1317
|
+
);
|
|
1318
|
+
|
|
1319
|
+
expect(response.isError).toBe(true);
|
|
1320
|
+
expect(getResponseText(response)).toBe("action_item_id is required");
|
|
1321
|
+
|
|
1322
|
+
readConfigSpy.mockRestore();
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
test("returns error when no update fields provided", async () => {
|
|
1326
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1327
|
+
apiKey: "test-key",
|
|
1328
|
+
baseUrl: null,
|
|
1329
|
+
orgId: null,
|
|
1330
|
+
defaultProject: null,
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
const response = await handleToolCall(
|
|
1334
|
+
createRequest("update_action_item", { action_item_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" })
|
|
1335
|
+
);
|
|
1336
|
+
|
|
1337
|
+
expect(response.isError).toBe(true);
|
|
1338
|
+
expect(getResponseText(response)).toContain("At least one field to update is required");
|
|
1339
|
+
|
|
1340
|
+
readConfigSpy.mockRestore();
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
test("returns error when status is invalid", async () => {
|
|
1344
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1345
|
+
apiKey: "test-key",
|
|
1346
|
+
baseUrl: null,
|
|
1347
|
+
orgId: null,
|
|
1348
|
+
defaultProject: null,
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
const response = await handleToolCall(
|
|
1352
|
+
createRequest("update_action_item", { action_item_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", status: "invalid_status" })
|
|
1353
|
+
);
|
|
1354
|
+
|
|
1355
|
+
expect(response.isError).toBe(true);
|
|
1356
|
+
expect(getResponseText(response)).toContain("status must be");
|
|
1357
|
+
|
|
1358
|
+
readConfigSpy.mockRestore();
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
test("successfully updates with only title", async () => {
|
|
1362
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1363
|
+
apiKey: "test-key",
|
|
1364
|
+
baseUrl: null,
|
|
1365
|
+
orgId: null,
|
|
1366
|
+
defaultProject: null,
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
let capturedBody: string | undefined;
|
|
1370
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1371
|
+
capturedBody = options?.body as string;
|
|
1372
|
+
return Promise.resolve(
|
|
1373
|
+
new Response("", {
|
|
1374
|
+
status: 200,
|
|
1375
|
+
headers: { "Content-Type": "application/json" },
|
|
1376
|
+
})
|
|
1377
|
+
);
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
const response = await handleToolCall(
|
|
1381
|
+
createRequest("update_action_item", { action_item_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", title: "New Title" })
|
|
1382
|
+
);
|
|
1383
|
+
|
|
1384
|
+
expect(response.isError).toBeUndefined();
|
|
1385
|
+
expect(capturedBody).toBeDefined();
|
|
1386
|
+
const parsed = JSON.parse(capturedBody!);
|
|
1387
|
+
expect(parsed.action_item_id).toBe("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
|
1388
|
+
expect(parsed.title).toBe("New Title");
|
|
1389
|
+
|
|
1390
|
+
readConfigSpy.mockRestore();
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
test("successfully updates is_done", async () => {
|
|
1394
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1395
|
+
apiKey: "test-key",
|
|
1396
|
+
baseUrl: null,
|
|
1397
|
+
orgId: null,
|
|
1398
|
+
defaultProject: null,
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
let capturedBody: string | undefined;
|
|
1402
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1403
|
+
capturedBody = options?.body as string;
|
|
1404
|
+
return Promise.resolve(
|
|
1405
|
+
new Response("", {
|
|
1406
|
+
status: 200,
|
|
1407
|
+
headers: { "Content-Type": "application/json" },
|
|
1408
|
+
})
|
|
1409
|
+
);
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
const response = await handleToolCall(
|
|
1413
|
+
createRequest("update_action_item", { action_item_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", is_done: true })
|
|
1414
|
+
);
|
|
1415
|
+
|
|
1416
|
+
expect(response.isError).toBeUndefined();
|
|
1417
|
+
expect(capturedBody).toBeDefined();
|
|
1418
|
+
const parsed = JSON.parse(capturedBody!);
|
|
1419
|
+
expect(parsed.is_done).toBe(true);
|
|
1420
|
+
|
|
1421
|
+
readConfigSpy.mockRestore();
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
test("successfully updates status with status_reason", async () => {
|
|
1425
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1426
|
+
apiKey: "test-key",
|
|
1427
|
+
baseUrl: null,
|
|
1428
|
+
orgId: null,
|
|
1429
|
+
defaultProject: null,
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
let capturedBody: string | undefined;
|
|
1433
|
+
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1434
|
+
capturedBody = options?.body as string;
|
|
1435
|
+
return Promise.resolve(
|
|
1436
|
+
new Response("", {
|
|
1437
|
+
status: 200,
|
|
1438
|
+
headers: { "Content-Type": "application/json" },
|
|
1439
|
+
})
|
|
1440
|
+
);
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
const response = await handleToolCall(
|
|
1444
|
+
createRequest("update_action_item", {
|
|
1445
|
+
action_item_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
1446
|
+
status: "approved",
|
|
1447
|
+
status_reason: "Looks good to me",
|
|
1448
|
+
})
|
|
1449
|
+
);
|
|
1450
|
+
|
|
1451
|
+
expect(response.isError).toBeUndefined();
|
|
1452
|
+
expect(capturedBody).toBeDefined();
|
|
1453
|
+
const parsed = JSON.parse(capturedBody!);
|
|
1454
|
+
expect(parsed.status).toBe("approved");
|
|
1455
|
+
expect(parsed.status_reason).toBe("Looks good to me");
|
|
1456
|
+
|
|
1457
|
+
readConfigSpy.mockRestore();
|
|
1458
|
+
});
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
describe("unknown tool handling", () => {
|
|
1462
|
+
test("returns error for unknown tool name", async () => {
|
|
1463
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1464
|
+
apiKey: "test-key",
|
|
1465
|
+
baseUrl: null,
|
|
1466
|
+
orgId: null,
|
|
1467
|
+
defaultProject: null,
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
const response = await handleToolCall(createRequest("nonexistent_tool"));
|
|
1471
|
+
|
|
1472
|
+
expect(response.isError).toBe(true);
|
|
1473
|
+
expect(getResponseText(response)).toContain("Unknown tool: nonexistent_tool");
|
|
1474
|
+
|
|
1475
|
+
readConfigSpy.mockRestore();
|
|
1476
|
+
});
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
describe("error propagation", () => {
|
|
1480
|
+
test("propagates API errors through MCP layer", async () => {
|
|
1481
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1482
|
+
apiKey: "test-key",
|
|
1483
|
+
baseUrl: null,
|
|
1484
|
+
orgId: 1,
|
|
1485
|
+
defaultProject: null,
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
globalThis.fetch = mock(() =>
|
|
1489
|
+
Promise.resolve(
|
|
1490
|
+
new Response('{"message": "Internal Server Error"}', {
|
|
1491
|
+
status: 500,
|
|
1492
|
+
headers: { "Content-Type": "application/json" },
|
|
1493
|
+
})
|
|
1494
|
+
)
|
|
1495
|
+
);
|
|
1496
|
+
|
|
1497
|
+
const response = await handleToolCall(
|
|
1498
|
+
createRequest("create_issue", { title: "Test Issue" })
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1501
|
+
expect(response.isError).toBe(true);
|
|
1502
|
+
expect(getResponseText(response)).toContain("500");
|
|
1503
|
+
|
|
1504
|
+
readConfigSpy.mockRestore();
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
test("handles network errors gracefully", async () => {
|
|
1508
|
+
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1509
|
+
apiKey: "test-key",
|
|
1510
|
+
baseUrl: null,
|
|
1511
|
+
orgId: 1,
|
|
1512
|
+
defaultProject: null,
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
globalThis.fetch = mock(() => Promise.reject(new Error("Network error")));
|
|
1516
|
+
|
|
1517
|
+
const response = await handleToolCall(
|
|
1518
|
+
createRequest("create_issue", { title: "Test Issue" })
|
|
1519
|
+
);
|
|
1520
|
+
|
|
1521
|
+
expect(response.isError).toBe(true);
|
|
1522
|
+
expect(getResponseText(response)).toContain("Network error");
|
|
1523
|
+
|
|
1524
|
+
readConfigSpy.mockRestore();
|
|
1525
|
+
});
|
|
1526
|
+
});
|
|
1527
|
+
});
|