postgresai 0.16.0-rc.4 → 0.16.0
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 +154 -0
- package/dist/bin/postgres-ai.js +2911 -255
- package/package.json +12 -3
- package/schemas/A002.schema.json +63 -0
- package/schemas/A003.schema.json +73 -0
- package/schemas/A004.schema.json +81 -0
- package/schemas/A007.schema.json +71 -0
- package/schemas/A013.schema.json +61 -0
- package/schemas/D001.schema.json +71 -0
- package/schemas/D004.schema.json +136 -0
- package/schemas/F001.schema.json +73 -0
- package/schemas/F002.schema.json +108 -0
- package/schemas/F003.schema.json +138 -0
- package/schemas/F004.schema.json +125 -0
- package/schemas/F005.schema.json +131 -0
- package/schemas/F009.schema.json +155 -0
- package/schemas/G001.schema.json +135 -0
- package/schemas/G003.schema.json +90 -0
- package/schemas/H001.schema.json +141 -0
- package/schemas/H002.schema.json +129 -0
- package/schemas/H004.schema.json +128 -0
- package/schemas/I001.schema.json +149 -0
- package/schemas/K001.schema.json +161 -0
- package/schemas/K003.schema.json +163 -0
- package/schemas/K004.schema.json +110 -0
- package/schemas/K005.schema.json +110 -0
- package/schemas/K006.schema.json +110 -0
- package/schemas/K007.schema.json +110 -0
- package/schemas/K008.schema.json +110 -0
- package/schemas/M001.schema.json +119 -0
- package/schemas/M002.schema.json +110 -0
- package/schemas/M003.schema.json +128 -0
- package/schemas/N001.schema.json +161 -0
- package/schemas/query.schema.json +62 -0
- package/CHANGELOG.md +0 -11
- package/bin/postgres-ai.ts +0 -5578
- package/bun.lock +0 -258
- package/bunfig.toml +0 -20
- package/lib/aas-onboard.ts +0 -251
- package/lib/auth-server.ts +0 -285
- package/lib/checkup-api.ts +0 -526
- package/lib/checkup-dictionary.ts +0 -103
- package/lib/checkup-summary.ts +0 -338
- package/lib/checkup.ts +0 -2261
- package/lib/config.ts +0 -171
- package/lib/init.ts +0 -1152
- package/lib/instances.ts +0 -245
- package/lib/issues.ts +0 -1060
- package/lib/mcp-server.ts +0 -667
- package/lib/metrics-loader.ts +0 -134
- package/lib/pkce.ts +0 -79
- package/lib/reports.ts +0 -373
- package/lib/storage.ts +0 -367
- package/lib/supabase.ts +0 -826
- package/lib/util.ts +0 -134
- package/packages/postgres-ai/README.md +0 -26
- package/packages/postgres-ai/bin/postgres-ai.js +0 -27
- package/packages/postgres-ai/package.json +0 -27
- package/scripts/embed-checkup-dictionary.ts +0 -115
- package/scripts/embed-metrics.ts +0 -160
- package/scripts/generate-release-notes.ts +0 -668
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +0 -139
- package/test/aas-onboard.test.ts +0 -301
- package/test/auth.test.ts +0 -287
- package/test/checkup.integration.test.ts +0 -413
- package/test/checkup.test.ts +0 -3626
- package/test/compose-cmd.test.ts +0 -120
- package/test/config-consistency.test.ts +0 -352
- package/test/init.integration.test.ts +0 -438
- package/test/init.test.ts +0 -1816
- package/test/issues.cli.test.ts +0 -1162
- package/test/issues.test.ts +0 -456
- package/test/mcp-server.test.ts +0 -2530
- package/test/monitoring.test.ts +0 -746
- package/test/permission-check-sql.test.ts +0 -116
- package/test/reports.cli.test.ts +0 -793
- package/test/reports.test.ts +0 -977
- package/test/schema-validation.test.ts +0 -231
- package/test/storage.test.ts +0 -935
- package/test/supabase.test.ts +0 -709
- package/test/targets-add-config.test.ts +0 -28
- package/test/test-utils.ts +0 -190
- package/test/upgrade.test.ts +0 -1056
- package/test/util.test.ts +0 -44
- package/tsconfig.json +0 -20
package/test/mcp-server.test.ts
DELETED
|
@@ -1,2530 +0,0 @@
|
|
|
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
|
-
storageBaseUrl: null,
|
|
79
|
-
orgId: null,
|
|
80
|
-
defaultProject: null,
|
|
81
|
-
projectName: null,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
const response = await handleToolCall(createRequest("list_issues"));
|
|
85
|
-
|
|
86
|
-
expect(response.isError).toBe(true);
|
|
87
|
-
expect(getResponseText(response)).toContain("API key is required");
|
|
88
|
-
|
|
89
|
-
readConfigSpy.mockRestore();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("uses API key from rootOpts when provided", async () => {
|
|
93
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
94
|
-
apiKey: null,
|
|
95
|
-
baseUrl: null,
|
|
96
|
-
storageBaseUrl: null,
|
|
97
|
-
orgId: null,
|
|
98
|
-
defaultProject: null,
|
|
99
|
-
projectName: null,
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// Mock fetch to verify API key is used
|
|
103
|
-
let capturedHeaders: Record<string, string> | undefined;
|
|
104
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
105
|
-
capturedHeaders = options?.headers as Record<string, string> | undefined;
|
|
106
|
-
return Promise.resolve(
|
|
107
|
-
new Response(JSON.stringify([]), {
|
|
108
|
-
status: 200,
|
|
109
|
-
headers: { "Content-Type": "application/json" },
|
|
110
|
-
})
|
|
111
|
-
);
|
|
112
|
-
}) as unknown as typeof fetch;
|
|
113
|
-
|
|
114
|
-
await handleToolCall(createRequest("list_issues"), { apiKey: "test-api-key" });
|
|
115
|
-
|
|
116
|
-
expect(capturedHeaders).toBeDefined();
|
|
117
|
-
expect(capturedHeaders!["access-token"]).toBe("test-api-key");
|
|
118
|
-
|
|
119
|
-
readConfigSpy.mockRestore();
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("falls back to config API key when rootOpts not provided", async () => {
|
|
123
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
124
|
-
apiKey: "config-api-key",
|
|
125
|
-
baseUrl: null,
|
|
126
|
-
storageBaseUrl: null,
|
|
127
|
-
orgId: null,
|
|
128
|
-
defaultProject: null,
|
|
129
|
-
projectName: null,
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
let capturedHeaders: Record<string, string> | undefined;
|
|
133
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
134
|
-
capturedHeaders = options?.headers as Record<string, string> | undefined;
|
|
135
|
-
return Promise.resolve(
|
|
136
|
-
new Response(JSON.stringify([]), {
|
|
137
|
-
status: 200,
|
|
138
|
-
headers: { "Content-Type": "application/json" },
|
|
139
|
-
})
|
|
140
|
-
);
|
|
141
|
-
}) as unknown as typeof fetch;
|
|
142
|
-
|
|
143
|
-
await handleToolCall(createRequest("list_issues"));
|
|
144
|
-
|
|
145
|
-
expect(capturedHeaders).toBeDefined();
|
|
146
|
-
expect(capturedHeaders!["access-token"]).toBe("config-api-key");
|
|
147
|
-
|
|
148
|
-
readConfigSpy.mockRestore();
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("uses PGAI_API_KEY env var as fallback", async () => {
|
|
152
|
-
process.env.PGAI_API_KEY = "env-api-key";
|
|
153
|
-
|
|
154
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
155
|
-
apiKey: null,
|
|
156
|
-
baseUrl: null,
|
|
157
|
-
storageBaseUrl: null,
|
|
158
|
-
orgId: null,
|
|
159
|
-
defaultProject: null,
|
|
160
|
-
projectName: null,
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
let capturedHeaders: Record<string, string> | undefined;
|
|
164
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
165
|
-
capturedHeaders = options?.headers as Record<string, string> | undefined;
|
|
166
|
-
return Promise.resolve(
|
|
167
|
-
new Response(JSON.stringify([]), {
|
|
168
|
-
status: 200,
|
|
169
|
-
headers: { "Content-Type": "application/json" },
|
|
170
|
-
})
|
|
171
|
-
);
|
|
172
|
-
}) as unknown as typeof fetch;
|
|
173
|
-
|
|
174
|
-
await handleToolCall(createRequest("list_issues"));
|
|
175
|
-
|
|
176
|
-
expect(capturedHeaders).toBeDefined();
|
|
177
|
-
expect(capturedHeaders!["access-token"]).toBe("env-api-key");
|
|
178
|
-
|
|
179
|
-
readConfigSpy.mockRestore();
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
describe("list_issues tool", () => {
|
|
184
|
-
test("successfully returns issues list as JSON", async () => {
|
|
185
|
-
const mockIssues = [
|
|
186
|
-
{ id: "issue-1", title: "First Issue" },
|
|
187
|
-
{ id: "issue-2", title: "Second Issue" },
|
|
188
|
-
];
|
|
189
|
-
|
|
190
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
191
|
-
apiKey: "test-key",
|
|
192
|
-
baseUrl: null,
|
|
193
|
-
storageBaseUrl: null,
|
|
194
|
-
orgId: null,
|
|
195
|
-
defaultProject: null,
|
|
196
|
-
projectName: null,
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
globalThis.fetch = mock(() =>
|
|
200
|
-
Promise.resolve(
|
|
201
|
-
new Response(JSON.stringify(mockIssues), {
|
|
202
|
-
status: 200,
|
|
203
|
-
headers: { "Content-Type": "application/json" },
|
|
204
|
-
})
|
|
205
|
-
)
|
|
206
|
-
) as unknown as typeof fetch;
|
|
207
|
-
|
|
208
|
-
const response = await handleToolCall(createRequest("list_issues"));
|
|
209
|
-
|
|
210
|
-
expect(response.isError).toBeUndefined();
|
|
211
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
212
|
-
expect(parsed).toHaveLength(2);
|
|
213
|
-
expect(parsed[0].title).toBe("First Issue");
|
|
214
|
-
|
|
215
|
-
readConfigSpy.mockRestore();
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("handles API errors gracefully", async () => {
|
|
219
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
220
|
-
apiKey: "test-key",
|
|
221
|
-
baseUrl: null,
|
|
222
|
-
storageBaseUrl: null,
|
|
223
|
-
orgId: null,
|
|
224
|
-
defaultProject: null,
|
|
225
|
-
projectName: null,
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
globalThis.fetch = mock(() =>
|
|
229
|
-
Promise.resolve(
|
|
230
|
-
new Response('{"message": "Unauthorized"}', {
|
|
231
|
-
status: 401,
|
|
232
|
-
headers: { "Content-Type": "application/json" },
|
|
233
|
-
})
|
|
234
|
-
)
|
|
235
|
-
) as unknown as typeof fetch;
|
|
236
|
-
|
|
237
|
-
const response = await handleToolCall(createRequest("list_issues"));
|
|
238
|
-
|
|
239
|
-
expect(response.isError).toBe(true);
|
|
240
|
-
expect(getResponseText(response)).toContain("401");
|
|
241
|
-
// Agent-facing remediation: invalid key must point at re-auth, not dead-end
|
|
242
|
-
expect(getResponseText(response)).toContain("Run 'postgresai auth'");
|
|
243
|
-
|
|
244
|
-
readConfigSpy.mockRestore();
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
describe("view_issue tool", () => {
|
|
249
|
-
test("returns error when issue_id is empty", async () => {
|
|
250
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
251
|
-
apiKey: "test-key",
|
|
252
|
-
baseUrl: null,
|
|
253
|
-
storageBaseUrl: null,
|
|
254
|
-
orgId: null,
|
|
255
|
-
defaultProject: null,
|
|
256
|
-
projectName: 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_id is whitespace only", async () => {
|
|
268
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
269
|
-
apiKey: "test-key",
|
|
270
|
-
baseUrl: null,
|
|
271
|
-
storageBaseUrl: null,
|
|
272
|
-
orgId: null,
|
|
273
|
-
defaultProject: null,
|
|
274
|
-
projectName: null,
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
const response = await handleToolCall(createRequest("view_issue", { issue_id: " " }));
|
|
278
|
-
|
|
279
|
-
expect(response.isError).toBe(true);
|
|
280
|
-
expect(getResponseText(response)).toBe("issue_id is required");
|
|
281
|
-
|
|
282
|
-
readConfigSpy.mockRestore();
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
test("returns error when issue not found", async () => {
|
|
286
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
287
|
-
apiKey: "test-key",
|
|
288
|
-
baseUrl: null,
|
|
289
|
-
storageBaseUrl: null,
|
|
290
|
-
orgId: null,
|
|
291
|
-
defaultProject: null,
|
|
292
|
-
projectName: null,
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
// Return null for issue (not found)
|
|
296
|
-
globalThis.fetch = mock(() =>
|
|
297
|
-
Promise.resolve(
|
|
298
|
-
new Response("null", {
|
|
299
|
-
status: 200,
|
|
300
|
-
headers: { "Content-Type": "application/json" },
|
|
301
|
-
})
|
|
302
|
-
)
|
|
303
|
-
) as unknown as typeof fetch;
|
|
304
|
-
|
|
305
|
-
const response = await handleToolCall(createRequest("view_issue", { issue_id: "nonexistent-id" }));
|
|
306
|
-
|
|
307
|
-
expect(response.isError).toBe(true);
|
|
308
|
-
expect(getResponseText(response)).toBe("Issue not found");
|
|
309
|
-
|
|
310
|
-
readConfigSpy.mockRestore();
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
test("successfully returns combined issue and comments", async () => {
|
|
314
|
-
const mockIssue = { id: "issue-1", title: "Test Issue" };
|
|
315
|
-
const mockComments = [{ id: "comment-1", content: "Test comment" }];
|
|
316
|
-
|
|
317
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
318
|
-
apiKey: "test-key",
|
|
319
|
-
baseUrl: null,
|
|
320
|
-
storageBaseUrl: null,
|
|
321
|
-
orgId: null,
|
|
322
|
-
defaultProject: null,
|
|
323
|
-
projectName: null,
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
let callCount = 0;
|
|
327
|
-
globalThis.fetch = mock((url: string) => {
|
|
328
|
-
callCount++;
|
|
329
|
-
// First call is for the issue, second is for comments
|
|
330
|
-
if (url.includes("issue_get") || callCount === 1) {
|
|
331
|
-
return Promise.resolve(
|
|
332
|
-
new Response(JSON.stringify(mockIssue), {
|
|
333
|
-
status: 200,
|
|
334
|
-
headers: { "Content-Type": "application/json" },
|
|
335
|
-
})
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
return Promise.resolve(
|
|
339
|
-
new Response(JSON.stringify(mockComments), {
|
|
340
|
-
status: 200,
|
|
341
|
-
headers: { "Content-Type": "application/json" },
|
|
342
|
-
})
|
|
343
|
-
);
|
|
344
|
-
}) as unknown as typeof fetch;
|
|
345
|
-
|
|
346
|
-
const response = await handleToolCall(createRequest("view_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }));
|
|
347
|
-
|
|
348
|
-
expect(response.isError).toBeUndefined();
|
|
349
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
350
|
-
expect(parsed.issue.title).toBe("Test Issue");
|
|
351
|
-
expect(parsed.comments).toHaveLength(1);
|
|
352
|
-
|
|
353
|
-
readConfigSpy.mockRestore();
|
|
354
|
-
});
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
describe("post_issue_comment tool", () => {
|
|
358
|
-
test("returns error when issue_id is empty", async () => {
|
|
359
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
360
|
-
apiKey: "test-key",
|
|
361
|
-
baseUrl: null,
|
|
362
|
-
storageBaseUrl: null,
|
|
363
|
-
orgId: null,
|
|
364
|
-
defaultProject: null,
|
|
365
|
-
projectName: null,
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
const response = await handleToolCall(
|
|
369
|
-
createRequest("post_issue_comment", { issue_id: "", content: "test" })
|
|
370
|
-
);
|
|
371
|
-
|
|
372
|
-
expect(response.isError).toBe(true);
|
|
373
|
-
expect(getResponseText(response)).toBe("issue_id is required");
|
|
374
|
-
|
|
375
|
-
readConfigSpy.mockRestore();
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
test("returns error when content is empty", async () => {
|
|
379
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
380
|
-
apiKey: "test-key",
|
|
381
|
-
baseUrl: null,
|
|
382
|
-
storageBaseUrl: null,
|
|
383
|
-
orgId: null,
|
|
384
|
-
defaultProject: null,
|
|
385
|
-
projectName: null,
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
const response = await handleToolCall(
|
|
389
|
-
createRequest("post_issue_comment", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", content: "" })
|
|
390
|
-
);
|
|
391
|
-
|
|
392
|
-
expect(response.isError).toBe(true);
|
|
393
|
-
// Error message updated to reflect that attachments alone are also valid input.
|
|
394
|
-
expect(getResponseText(response)).toBe("content or attachments is required");
|
|
395
|
-
|
|
396
|
-
readConfigSpy.mockRestore();
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
test("interprets escape sequences in content", async () => {
|
|
400
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
401
|
-
apiKey: "test-key",
|
|
402
|
-
baseUrl: null,
|
|
403
|
-
storageBaseUrl: null,
|
|
404
|
-
orgId: null,
|
|
405
|
-
defaultProject: null,
|
|
406
|
-
projectName: null,
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
let capturedBody: string | undefined;
|
|
410
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
411
|
-
capturedBody = options?.body as string;
|
|
412
|
-
return Promise.resolve(
|
|
413
|
-
new Response(JSON.stringify({ id: "comment-1" }), {
|
|
414
|
-
status: 200,
|
|
415
|
-
headers: { "Content-Type": "application/json" },
|
|
416
|
-
})
|
|
417
|
-
);
|
|
418
|
-
}) as unknown as typeof fetch;
|
|
419
|
-
|
|
420
|
-
await handleToolCall(
|
|
421
|
-
createRequest("post_issue_comment", {
|
|
422
|
-
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
423
|
-
content: "line1\\nline2\\ttab",
|
|
424
|
-
})
|
|
425
|
-
);
|
|
426
|
-
|
|
427
|
-
expect(capturedBody).toBeDefined();
|
|
428
|
-
const parsed = JSON.parse(capturedBody!);
|
|
429
|
-
expect(parsed.content).toBe("line1\nline2\ttab");
|
|
430
|
-
|
|
431
|
-
readConfigSpy.mockRestore();
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
test("successfully creates comment with parent_comment_id", async () => {
|
|
435
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
436
|
-
apiKey: "test-key",
|
|
437
|
-
baseUrl: null,
|
|
438
|
-
storageBaseUrl: null,
|
|
439
|
-
orgId: null,
|
|
440
|
-
defaultProject: null,
|
|
441
|
-
projectName: null,
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
let capturedBody: string | undefined;
|
|
445
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
446
|
-
capturedBody = options?.body as string;
|
|
447
|
-
return Promise.resolve(
|
|
448
|
-
new Response(JSON.stringify({ id: "comment-1", parent_comment_id: "parent-1" }), {
|
|
449
|
-
status: 200,
|
|
450
|
-
headers: { "Content-Type": "application/json" },
|
|
451
|
-
})
|
|
452
|
-
);
|
|
453
|
-
}) as unknown as typeof fetch;
|
|
454
|
-
|
|
455
|
-
const response = await handleToolCall(
|
|
456
|
-
createRequest("post_issue_comment", {
|
|
457
|
-
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
458
|
-
content: "Reply content",
|
|
459
|
-
parent_comment_id: "parent-1",
|
|
460
|
-
})
|
|
461
|
-
);
|
|
462
|
-
|
|
463
|
-
expect(response.isError).toBeUndefined();
|
|
464
|
-
expect(capturedBody).toBeDefined();
|
|
465
|
-
const parsed = JSON.parse(capturedBody!);
|
|
466
|
-
expect(parsed.parent_comment_id).toBe("parent-1");
|
|
467
|
-
|
|
468
|
-
readConfigSpy.mockRestore();
|
|
469
|
-
});
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
describe("create_issue tool", () => {
|
|
473
|
-
test("returns error when title is empty", async () => {
|
|
474
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
475
|
-
apiKey: "test-key",
|
|
476
|
-
baseUrl: null,
|
|
477
|
-
storageBaseUrl: null,
|
|
478
|
-
orgId: 1,
|
|
479
|
-
defaultProject: null,
|
|
480
|
-
projectName: null,
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
const response = await handleToolCall(createRequest("create_issue", { title: "" }));
|
|
484
|
-
|
|
485
|
-
expect(response.isError).toBe(true);
|
|
486
|
-
expect(getResponseText(response)).toBe("title is required");
|
|
487
|
-
|
|
488
|
-
readConfigSpy.mockRestore();
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
test("returns error when title is whitespace only", async () => {
|
|
492
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
493
|
-
apiKey: "test-key",
|
|
494
|
-
baseUrl: null,
|
|
495
|
-
storageBaseUrl: null,
|
|
496
|
-
orgId: 1,
|
|
497
|
-
defaultProject: null,
|
|
498
|
-
projectName: null,
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
const response = await handleToolCall(createRequest("create_issue", { title: " " }));
|
|
502
|
-
|
|
503
|
-
expect(response.isError).toBe(true);
|
|
504
|
-
expect(getResponseText(response)).toBe("title is required");
|
|
505
|
-
|
|
506
|
-
readConfigSpy.mockRestore();
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
test("returns error when org_id not provided and not in config", async () => {
|
|
510
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
511
|
-
apiKey: "test-key",
|
|
512
|
-
baseUrl: null,
|
|
513
|
-
storageBaseUrl: null,
|
|
514
|
-
orgId: null,
|
|
515
|
-
defaultProject: null,
|
|
516
|
-
projectName: null,
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
const response = await handleToolCall(createRequest("create_issue", { title: "Test Issue" }));
|
|
520
|
-
|
|
521
|
-
expect(response.isError).toBe(true);
|
|
522
|
-
expect(getResponseText(response)).toContain("org_id is required");
|
|
523
|
-
|
|
524
|
-
readConfigSpy.mockRestore();
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
test("falls back to config orgId when not provided in args", async () => {
|
|
528
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
529
|
-
apiKey: "test-key",
|
|
530
|
-
baseUrl: null,
|
|
531
|
-
storageBaseUrl: null,
|
|
532
|
-
orgId: 42,
|
|
533
|
-
defaultProject: null,
|
|
534
|
-
projectName: null,
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
let capturedBody: string | undefined;
|
|
538
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
539
|
-
capturedBody = options?.body as string;
|
|
540
|
-
return Promise.resolve(
|
|
541
|
-
new Response(JSON.stringify({ id: "new-issue" }), {
|
|
542
|
-
status: 200,
|
|
543
|
-
headers: { "Content-Type": "application/json" },
|
|
544
|
-
})
|
|
545
|
-
);
|
|
546
|
-
}) as unknown as typeof fetch;
|
|
547
|
-
|
|
548
|
-
await handleToolCall(createRequest("create_issue", { title: "Test Issue" }));
|
|
549
|
-
|
|
550
|
-
expect(capturedBody).toBeDefined();
|
|
551
|
-
const parsed = JSON.parse(capturedBody!);
|
|
552
|
-
expect(parsed.org_id).toBe(42);
|
|
553
|
-
|
|
554
|
-
readConfigSpy.mockRestore();
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
test("interprets escape sequences in title and description", async () => {
|
|
558
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
559
|
-
apiKey: "test-key",
|
|
560
|
-
baseUrl: null,
|
|
561
|
-
storageBaseUrl: null,
|
|
562
|
-
orgId: 1,
|
|
563
|
-
defaultProject: null,
|
|
564
|
-
projectName: null,
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
let capturedBody: string | undefined;
|
|
568
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
569
|
-
capturedBody = options?.body as string;
|
|
570
|
-
return Promise.resolve(
|
|
571
|
-
new Response(JSON.stringify({ id: "new-issue" }), {
|
|
572
|
-
status: 200,
|
|
573
|
-
headers: { "Content-Type": "application/json" },
|
|
574
|
-
})
|
|
575
|
-
);
|
|
576
|
-
}) as unknown as typeof fetch;
|
|
577
|
-
|
|
578
|
-
await handleToolCall(
|
|
579
|
-
createRequest("create_issue", {
|
|
580
|
-
title: "Title\\nwith newline",
|
|
581
|
-
description: "Desc\\twith tab",
|
|
582
|
-
})
|
|
583
|
-
);
|
|
584
|
-
|
|
585
|
-
expect(capturedBody).toBeDefined();
|
|
586
|
-
const parsed = JSON.parse(capturedBody!);
|
|
587
|
-
expect(parsed.title).toBe("Title\nwith newline");
|
|
588
|
-
expect(parsed.description).toBe("Desc\twith tab");
|
|
589
|
-
|
|
590
|
-
readConfigSpy.mockRestore();
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
test("successfully creates issue with all parameters", async () => {
|
|
594
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
595
|
-
apiKey: "test-key",
|
|
596
|
-
baseUrl: null,
|
|
597
|
-
storageBaseUrl: null,
|
|
598
|
-
orgId: null,
|
|
599
|
-
defaultProject: null,
|
|
600
|
-
projectName: null,
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
let capturedBody: string | undefined;
|
|
604
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
605
|
-
capturedBody = options?.body as string;
|
|
606
|
-
return Promise.resolve(
|
|
607
|
-
new Response(JSON.stringify({ id: "new-issue", title: "Test" }), {
|
|
608
|
-
status: 200,
|
|
609
|
-
headers: { "Content-Type": "application/json" },
|
|
610
|
-
})
|
|
611
|
-
);
|
|
612
|
-
}) as unknown as typeof fetch;
|
|
613
|
-
|
|
614
|
-
const response = await handleToolCall(
|
|
615
|
-
createRequest("create_issue", {
|
|
616
|
-
title: "Test Issue",
|
|
617
|
-
description: "Test description",
|
|
618
|
-
org_id: 123,
|
|
619
|
-
project_id: 456,
|
|
620
|
-
labels: ["bug", "urgent"],
|
|
621
|
-
})
|
|
622
|
-
);
|
|
623
|
-
|
|
624
|
-
expect(response.isError).toBeUndefined();
|
|
625
|
-
expect(capturedBody).toBeDefined();
|
|
626
|
-
const parsed = JSON.parse(capturedBody!);
|
|
627
|
-
expect(parsed.title).toBe("Test Issue");
|
|
628
|
-
expect(parsed.description).toBe("Test description");
|
|
629
|
-
expect(parsed.org_id).toBe(123);
|
|
630
|
-
expect(parsed.project_id).toBe(456);
|
|
631
|
-
expect(parsed.labels).toEqual(["bug", "urgent"]);
|
|
632
|
-
|
|
633
|
-
readConfigSpy.mockRestore();
|
|
634
|
-
});
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
describe("update_issue tool", () => {
|
|
638
|
-
test("returns error when issue_id is empty", async () => {
|
|
639
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
640
|
-
apiKey: "test-key",
|
|
641
|
-
baseUrl: null,
|
|
642
|
-
storageBaseUrl: null,
|
|
643
|
-
orgId: null,
|
|
644
|
-
defaultProject: null,
|
|
645
|
-
projectName: null,
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
const response = await handleToolCall(
|
|
649
|
-
createRequest("update_issue", { issue_id: "", title: "New Title" })
|
|
650
|
-
);
|
|
651
|
-
|
|
652
|
-
expect(response.isError).toBe(true);
|
|
653
|
-
expect(getResponseText(response)).toBe("issue_id is required");
|
|
654
|
-
|
|
655
|
-
readConfigSpy.mockRestore();
|
|
656
|
-
});
|
|
657
|
-
|
|
658
|
-
test("returns error when no update fields provided", async () => {
|
|
659
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
660
|
-
apiKey: "test-key",
|
|
661
|
-
baseUrl: null,
|
|
662
|
-
storageBaseUrl: null,
|
|
663
|
-
orgId: null,
|
|
664
|
-
defaultProject: null,
|
|
665
|
-
projectName: null,
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
const response = await handleToolCall(createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }));
|
|
669
|
-
|
|
670
|
-
expect(response.isError).toBe(true);
|
|
671
|
-
expect(getResponseText(response)).toContain("At least one field to update is required");
|
|
672
|
-
|
|
673
|
-
readConfigSpy.mockRestore();
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
test("returns error when status is not 0 or 1", async () => {
|
|
677
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
678
|
-
apiKey: "test-key",
|
|
679
|
-
baseUrl: null,
|
|
680
|
-
storageBaseUrl: null,
|
|
681
|
-
orgId: null,
|
|
682
|
-
defaultProject: null,
|
|
683
|
-
projectName: null,
|
|
684
|
-
});
|
|
685
|
-
|
|
686
|
-
const response = await handleToolCall(
|
|
687
|
-
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", status: 2 })
|
|
688
|
-
);
|
|
689
|
-
|
|
690
|
-
expect(response.isError).toBe(true);
|
|
691
|
-
expect(getResponseText(response)).toBe("status must be 0 (open) or 1 (closed)");
|
|
692
|
-
|
|
693
|
-
readConfigSpy.mockRestore();
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
test("returns error when status is negative", async () => {
|
|
697
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
698
|
-
apiKey: "test-key",
|
|
699
|
-
baseUrl: null,
|
|
700
|
-
storageBaseUrl: null,
|
|
701
|
-
orgId: null,
|
|
702
|
-
defaultProject: null,
|
|
703
|
-
projectName: null,
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
const response = await handleToolCall(
|
|
707
|
-
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", status: -1 })
|
|
708
|
-
);
|
|
709
|
-
|
|
710
|
-
expect(response.isError).toBe(true);
|
|
711
|
-
expect(getResponseText(response)).toBe("status must be 0 (open) or 1 (closed)");
|
|
712
|
-
|
|
713
|
-
readConfigSpy.mockRestore();
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
test("interprets escape sequences in title and description", async () => {
|
|
717
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
718
|
-
apiKey: "test-key",
|
|
719
|
-
baseUrl: null,
|
|
720
|
-
storageBaseUrl: null,
|
|
721
|
-
orgId: null,
|
|
722
|
-
defaultProject: null,
|
|
723
|
-
projectName: null,
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
let capturedBody: string | undefined;
|
|
727
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
728
|
-
capturedBody = options?.body as string;
|
|
729
|
-
return Promise.resolve(
|
|
730
|
-
new Response(JSON.stringify({ id: "issue-1" }), {
|
|
731
|
-
status: 200,
|
|
732
|
-
headers: { "Content-Type": "application/json" },
|
|
733
|
-
})
|
|
734
|
-
);
|
|
735
|
-
}) as unknown as typeof fetch;
|
|
736
|
-
|
|
737
|
-
await handleToolCall(
|
|
738
|
-
createRequest("update_issue", {
|
|
739
|
-
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
740
|
-
title: "Updated\\nTitle",
|
|
741
|
-
description: "Updated\\tDescription",
|
|
742
|
-
})
|
|
743
|
-
);
|
|
744
|
-
|
|
745
|
-
expect(capturedBody).toBeDefined();
|
|
746
|
-
const parsed = JSON.parse(capturedBody!);
|
|
747
|
-
expect(parsed.p_title).toBe("Updated\nTitle");
|
|
748
|
-
expect(parsed.p_description).toBe("Updated\tDescription");
|
|
749
|
-
|
|
750
|
-
readConfigSpy.mockRestore();
|
|
751
|
-
});
|
|
752
|
-
|
|
753
|
-
test("successfully updates with only title", async () => {
|
|
754
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
755
|
-
apiKey: "test-key",
|
|
756
|
-
baseUrl: null,
|
|
757
|
-
storageBaseUrl: null,
|
|
758
|
-
orgId: null,
|
|
759
|
-
defaultProject: null,
|
|
760
|
-
projectName: null,
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
globalThis.fetch = mock(() =>
|
|
764
|
-
Promise.resolve(
|
|
765
|
-
new Response(JSON.stringify({ id: "issue-1", title: "New Title" }), {
|
|
766
|
-
status: 200,
|
|
767
|
-
headers: { "Content-Type": "application/json" },
|
|
768
|
-
})
|
|
769
|
-
)
|
|
770
|
-
) as unknown as typeof fetch;
|
|
771
|
-
|
|
772
|
-
const response = await handleToolCall(
|
|
773
|
-
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", title: "New Title" })
|
|
774
|
-
);
|
|
775
|
-
|
|
776
|
-
expect(response.isError).toBeUndefined();
|
|
777
|
-
|
|
778
|
-
readConfigSpy.mockRestore();
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
test("successfully updates with only status", async () => {
|
|
782
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
783
|
-
apiKey: "test-key",
|
|
784
|
-
baseUrl: null,
|
|
785
|
-
storageBaseUrl: null,
|
|
786
|
-
orgId: null,
|
|
787
|
-
defaultProject: null,
|
|
788
|
-
projectName: null,
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
let capturedBody: string | undefined;
|
|
792
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
793
|
-
capturedBody = options?.body as string;
|
|
794
|
-
return Promise.resolve(
|
|
795
|
-
new Response(JSON.stringify({ id: "issue-1", status: 1 }), {
|
|
796
|
-
status: 200,
|
|
797
|
-
headers: { "Content-Type": "application/json" },
|
|
798
|
-
})
|
|
799
|
-
);
|
|
800
|
-
}) as unknown as typeof fetch;
|
|
801
|
-
|
|
802
|
-
const response = await handleToolCall(
|
|
803
|
-
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", status: 1 })
|
|
804
|
-
);
|
|
805
|
-
|
|
806
|
-
expect(response.isError).toBeUndefined();
|
|
807
|
-
expect(capturedBody).toBeDefined();
|
|
808
|
-
const parsed = JSON.parse(capturedBody!);
|
|
809
|
-
expect(parsed.p_status).toBe(1);
|
|
810
|
-
|
|
811
|
-
readConfigSpy.mockRestore();
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
test("successfully updates with only labels", async () => {
|
|
815
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
816
|
-
apiKey: "test-key",
|
|
817
|
-
baseUrl: null,
|
|
818
|
-
storageBaseUrl: null,
|
|
819
|
-
orgId: null,
|
|
820
|
-
defaultProject: null,
|
|
821
|
-
projectName: null,
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
let capturedBody: string | undefined;
|
|
825
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
826
|
-
capturedBody = options?.body as string;
|
|
827
|
-
return Promise.resolve(
|
|
828
|
-
new Response(JSON.stringify({ id: "issue-1", labels: ["new-label"] }), {
|
|
829
|
-
status: 200,
|
|
830
|
-
headers: { "Content-Type": "application/json" },
|
|
831
|
-
})
|
|
832
|
-
);
|
|
833
|
-
}) as unknown as typeof fetch;
|
|
834
|
-
|
|
835
|
-
const response = await handleToolCall(
|
|
836
|
-
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", labels: ["new-label"] })
|
|
837
|
-
);
|
|
838
|
-
|
|
839
|
-
expect(response.isError).toBeUndefined();
|
|
840
|
-
expect(capturedBody).toBeDefined();
|
|
841
|
-
const parsed = JSON.parse(capturedBody!);
|
|
842
|
-
expect(parsed.p_labels).toEqual(["new-label"]);
|
|
843
|
-
|
|
844
|
-
readConfigSpy.mockRestore();
|
|
845
|
-
});
|
|
846
|
-
|
|
847
|
-
test("accepts status=0 to reopen issue", async () => {
|
|
848
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
849
|
-
apiKey: "test-key",
|
|
850
|
-
baseUrl: null,
|
|
851
|
-
storageBaseUrl: null,
|
|
852
|
-
orgId: null,
|
|
853
|
-
defaultProject: null,
|
|
854
|
-
projectName: null,
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
let capturedBody: string | undefined;
|
|
858
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
859
|
-
capturedBody = options?.body as string;
|
|
860
|
-
return Promise.resolve(
|
|
861
|
-
new Response(JSON.stringify({ id: "issue-1", status: 0 }), {
|
|
862
|
-
status: 200,
|
|
863
|
-
headers: { "Content-Type": "application/json" },
|
|
864
|
-
})
|
|
865
|
-
);
|
|
866
|
-
}) as unknown as typeof fetch;
|
|
867
|
-
|
|
868
|
-
const response = await handleToolCall(
|
|
869
|
-
createRequest("update_issue", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", status: 0 })
|
|
870
|
-
);
|
|
871
|
-
|
|
872
|
-
expect(response.isError).toBeUndefined();
|
|
873
|
-
expect(capturedBody).toBeDefined();
|
|
874
|
-
const parsed = JSON.parse(capturedBody!);
|
|
875
|
-
expect(parsed.p_status).toBe(0);
|
|
876
|
-
|
|
877
|
-
readConfigSpy.mockRestore();
|
|
878
|
-
});
|
|
879
|
-
});
|
|
880
|
-
|
|
881
|
-
describe("update_issue_comment tool", () => {
|
|
882
|
-
test("returns error when comment_id is empty", async () => {
|
|
883
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
884
|
-
apiKey: "test-key",
|
|
885
|
-
baseUrl: null,
|
|
886
|
-
storageBaseUrl: null,
|
|
887
|
-
orgId: null,
|
|
888
|
-
defaultProject: null,
|
|
889
|
-
projectName: null,
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
const response = await handleToolCall(
|
|
893
|
-
createRequest("update_issue_comment", { comment_id: "", content: "new content" })
|
|
894
|
-
);
|
|
895
|
-
|
|
896
|
-
expect(response.isError).toBe(true);
|
|
897
|
-
expect(getResponseText(response)).toBe("comment_id is required");
|
|
898
|
-
|
|
899
|
-
readConfigSpy.mockRestore();
|
|
900
|
-
});
|
|
901
|
-
|
|
902
|
-
test("returns error when content is empty", async () => {
|
|
903
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
904
|
-
apiKey: "test-key",
|
|
905
|
-
baseUrl: null,
|
|
906
|
-
storageBaseUrl: null,
|
|
907
|
-
orgId: null,
|
|
908
|
-
defaultProject: null,
|
|
909
|
-
projectName: null,
|
|
910
|
-
});
|
|
911
|
-
|
|
912
|
-
const response = await handleToolCall(
|
|
913
|
-
createRequest("update_issue_comment", { comment_id: "comment-1", content: "" })
|
|
914
|
-
);
|
|
915
|
-
|
|
916
|
-
expect(response.isError).toBe(true);
|
|
917
|
-
expect(getResponseText(response)).toBe("content or attachments is required");
|
|
918
|
-
|
|
919
|
-
readConfigSpy.mockRestore();
|
|
920
|
-
});
|
|
921
|
-
|
|
922
|
-
test("interprets escape sequences in content", async () => {
|
|
923
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
924
|
-
apiKey: "test-key",
|
|
925
|
-
baseUrl: null,
|
|
926
|
-
storageBaseUrl: null,
|
|
927
|
-
orgId: null,
|
|
928
|
-
defaultProject: null,
|
|
929
|
-
projectName: null,
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
let capturedBody: string | undefined;
|
|
933
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
934
|
-
capturedBody = options?.body as string;
|
|
935
|
-
return Promise.resolve(
|
|
936
|
-
new Response(JSON.stringify({ id: "comment-1" }), {
|
|
937
|
-
status: 200,
|
|
938
|
-
headers: { "Content-Type": "application/json" },
|
|
939
|
-
})
|
|
940
|
-
);
|
|
941
|
-
}) as unknown as typeof fetch;
|
|
942
|
-
|
|
943
|
-
await handleToolCall(
|
|
944
|
-
createRequest("update_issue_comment", {
|
|
945
|
-
comment_id: "comment-1",
|
|
946
|
-
content: "updated\\ncontent\\twith escapes",
|
|
947
|
-
})
|
|
948
|
-
);
|
|
949
|
-
|
|
950
|
-
expect(capturedBody).toBeDefined();
|
|
951
|
-
const parsed = JSON.parse(capturedBody!);
|
|
952
|
-
expect(parsed.p_content).toBe("updated\ncontent\twith escapes");
|
|
953
|
-
|
|
954
|
-
readConfigSpy.mockRestore();
|
|
955
|
-
});
|
|
956
|
-
|
|
957
|
-
test("successfully updates comment", async () => {
|
|
958
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
959
|
-
apiKey: "test-key",
|
|
960
|
-
baseUrl: null,
|
|
961
|
-
storageBaseUrl: null,
|
|
962
|
-
orgId: null,
|
|
963
|
-
defaultProject: null,
|
|
964
|
-
projectName: null,
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
globalThis.fetch = mock(() =>
|
|
968
|
-
Promise.resolve(
|
|
969
|
-
new Response(JSON.stringify({ id: "comment-1", content: "Updated content" }), {
|
|
970
|
-
status: 200,
|
|
971
|
-
headers: { "Content-Type": "application/json" },
|
|
972
|
-
})
|
|
973
|
-
)
|
|
974
|
-
) as unknown as typeof fetch;
|
|
975
|
-
|
|
976
|
-
const response = await handleToolCall(
|
|
977
|
-
createRequest("update_issue_comment", {
|
|
978
|
-
comment_id: "comment-1",
|
|
979
|
-
content: "Updated content",
|
|
980
|
-
})
|
|
981
|
-
);
|
|
982
|
-
|
|
983
|
-
expect(response.isError).toBeUndefined();
|
|
984
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
985
|
-
expect(parsed.content).toBe("Updated content");
|
|
986
|
-
|
|
987
|
-
readConfigSpy.mockRestore();
|
|
988
|
-
});
|
|
989
|
-
});
|
|
990
|
-
|
|
991
|
-
describe("view_action_item tool", () => {
|
|
992
|
-
test("returns error when no IDs provided", async () => {
|
|
993
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
994
|
-
apiKey: "test-key",
|
|
995
|
-
baseUrl: null,
|
|
996
|
-
storageBaseUrl: null,
|
|
997
|
-
orgId: null,
|
|
998
|
-
defaultProject: null,
|
|
999
|
-
projectName: null,
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
const response = await handleToolCall(createRequest("view_action_item", {}));
|
|
1003
|
-
|
|
1004
|
-
expect(response.isError).toBe(true);
|
|
1005
|
-
expect(getResponseText(response)).toBe("action_item_id or action_item_ids is required");
|
|
1006
|
-
|
|
1007
|
-
readConfigSpy.mockRestore();
|
|
1008
|
-
});
|
|
1009
|
-
|
|
1010
|
-
test("returns error when action_item_id is empty", async () => {
|
|
1011
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1012
|
-
apiKey: "test-key",
|
|
1013
|
-
baseUrl: null,
|
|
1014
|
-
storageBaseUrl: null,
|
|
1015
|
-
orgId: null,
|
|
1016
|
-
defaultProject: null,
|
|
1017
|
-
projectName: null,
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
const response = await handleToolCall(createRequest("view_action_item", { action_item_id: "" }));
|
|
1021
|
-
|
|
1022
|
-
expect(response.isError).toBe(true);
|
|
1023
|
-
expect(getResponseText(response)).toBe("action_item_id or action_item_ids is required");
|
|
1024
|
-
|
|
1025
|
-
readConfigSpy.mockRestore();
|
|
1026
|
-
});
|
|
1027
|
-
|
|
1028
|
-
test("returns error when action_item_ids is empty array", async () => {
|
|
1029
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1030
|
-
apiKey: "test-key",
|
|
1031
|
-
baseUrl: null,
|
|
1032
|
-
storageBaseUrl: null,
|
|
1033
|
-
orgId: null,
|
|
1034
|
-
defaultProject: null,
|
|
1035
|
-
projectName: null,
|
|
1036
|
-
});
|
|
1037
|
-
|
|
1038
|
-
const response = await handleToolCall(createRequest("view_action_item", { action_item_ids: [] }));
|
|
1039
|
-
|
|
1040
|
-
expect(response.isError).toBe(true);
|
|
1041
|
-
expect(getResponseText(response)).toBe("action_item_id or action_item_ids is required");
|
|
1042
|
-
|
|
1043
|
-
readConfigSpy.mockRestore();
|
|
1044
|
-
});
|
|
1045
|
-
|
|
1046
|
-
test("returns error when action_item_id is not a valid UUID", async () => {
|
|
1047
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1048
|
-
apiKey: "test-key",
|
|
1049
|
-
baseUrl: null,
|
|
1050
|
-
storageBaseUrl: null,
|
|
1051
|
-
orgId: null,
|
|
1052
|
-
defaultProject: null,
|
|
1053
|
-
projectName: null,
|
|
1054
|
-
});
|
|
1055
|
-
|
|
1056
|
-
const response = await handleToolCall(createRequest("view_action_item", { action_item_id: "invalid-id-format" }));
|
|
1057
|
-
|
|
1058
|
-
expect(response.isError).toBe(true);
|
|
1059
|
-
expect(getResponseText(response)).toBe("actionItemId is required and must be a valid UUID");
|
|
1060
|
-
|
|
1061
|
-
readConfigSpy.mockRestore();
|
|
1062
|
-
});
|
|
1063
|
-
|
|
1064
|
-
test("returns error when action item not found", async () => {
|
|
1065
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1066
|
-
apiKey: "test-key",
|
|
1067
|
-
baseUrl: null,
|
|
1068
|
-
storageBaseUrl: null,
|
|
1069
|
-
orgId: null,
|
|
1070
|
-
defaultProject: null,
|
|
1071
|
-
projectName: null,
|
|
1072
|
-
});
|
|
1073
|
-
|
|
1074
|
-
globalThis.fetch = mock(() =>
|
|
1075
|
-
Promise.resolve(
|
|
1076
|
-
new Response("[]", {
|
|
1077
|
-
status: 200,
|
|
1078
|
-
headers: { "Content-Type": "application/json" },
|
|
1079
|
-
})
|
|
1080
|
-
)
|
|
1081
|
-
) as unknown as typeof fetch;
|
|
1082
|
-
|
|
1083
|
-
const response = await handleToolCall(createRequest("view_action_item", { action_item_id: "00000000-0000-0000-0000-000000000000" }));
|
|
1084
|
-
|
|
1085
|
-
expect(response.isError).toBe(true);
|
|
1086
|
-
expect(getResponseText(response)).toBe("Action item(s) not found");
|
|
1087
|
-
|
|
1088
|
-
readConfigSpy.mockRestore();
|
|
1089
|
-
});
|
|
1090
|
-
|
|
1091
|
-
test("successfully returns single action item details", async () => {
|
|
1092
|
-
const mockActionItem = {
|
|
1093
|
-
id: "11111111-1111-1111-1111-111111111111",
|
|
1094
|
-
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
1095
|
-
title: "Fix index",
|
|
1096
|
-
description: "Drop unused index",
|
|
1097
|
-
severity: 3,
|
|
1098
|
-
is_done: false,
|
|
1099
|
-
status: "waiting_for_approval",
|
|
1100
|
-
sql_action: "DROP INDEX CONCURRENTLY idx_unused;",
|
|
1101
|
-
configs: [{ parameter: "work_mem", value: "256MB" }],
|
|
1102
|
-
};
|
|
1103
|
-
|
|
1104
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1105
|
-
apiKey: "test-key",
|
|
1106
|
-
baseUrl: null,
|
|
1107
|
-
storageBaseUrl: null,
|
|
1108
|
-
orgId: null,
|
|
1109
|
-
defaultProject: null,
|
|
1110
|
-
projectName: null,
|
|
1111
|
-
});
|
|
1112
|
-
|
|
1113
|
-
globalThis.fetch = mock(() =>
|
|
1114
|
-
Promise.resolve(
|
|
1115
|
-
new Response(JSON.stringify([mockActionItem]), {
|
|
1116
|
-
status: 200,
|
|
1117
|
-
headers: { "Content-Type": "application/json" },
|
|
1118
|
-
})
|
|
1119
|
-
)
|
|
1120
|
-
) as unknown as typeof fetch;
|
|
1121
|
-
|
|
1122
|
-
const response = await handleToolCall(createRequest("view_action_item", { action_item_id: "11111111-1111-1111-1111-111111111111" }));
|
|
1123
|
-
|
|
1124
|
-
expect(response.isError).toBeUndefined();
|
|
1125
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
1126
|
-
expect(Array.isArray(parsed)).toBe(true);
|
|
1127
|
-
expect(parsed[0].title).toBe("Fix index");
|
|
1128
|
-
expect(parsed[0].sql_action).toBe("DROP INDEX CONCURRENTLY idx_unused;");
|
|
1129
|
-
expect(parsed[0].configs).toEqual([{ parameter: "work_mem", value: "256MB" }]);
|
|
1130
|
-
|
|
1131
|
-
readConfigSpy.mockRestore();
|
|
1132
|
-
});
|
|
1133
|
-
|
|
1134
|
-
test("successfully returns multiple action items", async () => {
|
|
1135
|
-
const mockActionItems = [
|
|
1136
|
-
{ id: "11111111-1111-1111-1111-111111111111", title: "Fix index", severity: 3 },
|
|
1137
|
-
{ id: "22222222-2222-2222-2222-222222222222", title: "Update config", severity: 2 },
|
|
1138
|
-
];
|
|
1139
|
-
|
|
1140
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1141
|
-
apiKey: "test-key",
|
|
1142
|
-
baseUrl: null,
|
|
1143
|
-
storageBaseUrl: null,
|
|
1144
|
-
orgId: null,
|
|
1145
|
-
defaultProject: null,
|
|
1146
|
-
projectName: null,
|
|
1147
|
-
});
|
|
1148
|
-
|
|
1149
|
-
let capturedUrl: string | undefined;
|
|
1150
|
-
globalThis.fetch = mock((url: string) => {
|
|
1151
|
-
capturedUrl = url;
|
|
1152
|
-
return Promise.resolve(
|
|
1153
|
-
new Response(JSON.stringify(mockActionItems), {
|
|
1154
|
-
status: 200,
|
|
1155
|
-
headers: { "Content-Type": "application/json" },
|
|
1156
|
-
})
|
|
1157
|
-
);
|
|
1158
|
-
}) as unknown as typeof fetch;
|
|
1159
|
-
|
|
1160
|
-
const response = await handleToolCall(createRequest("view_action_item", { action_item_ids: ["11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"] }));
|
|
1161
|
-
|
|
1162
|
-
expect(response.isError).toBeUndefined();
|
|
1163
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
1164
|
-
expect(parsed).toHaveLength(2);
|
|
1165
|
-
expect(parsed[0].title).toBe("Fix index");
|
|
1166
|
-
expect(parsed[1].title).toBe("Update config");
|
|
1167
|
-
// Verify the URL uses in.() syntax
|
|
1168
|
-
expect(capturedUrl).toContain("id=in.");
|
|
1169
|
-
|
|
1170
|
-
readConfigSpy.mockRestore();
|
|
1171
|
-
});
|
|
1172
|
-
});
|
|
1173
|
-
|
|
1174
|
-
describe("list_action_items tool", () => {
|
|
1175
|
-
test("returns error when issue_id is empty", async () => {
|
|
1176
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1177
|
-
apiKey: "test-key",
|
|
1178
|
-
baseUrl: null,
|
|
1179
|
-
storageBaseUrl: null,
|
|
1180
|
-
orgId: null,
|
|
1181
|
-
defaultProject: null,
|
|
1182
|
-
projectName: null,
|
|
1183
|
-
});
|
|
1184
|
-
|
|
1185
|
-
const response = await handleToolCall(createRequest("list_action_items", { issue_id: "" }));
|
|
1186
|
-
|
|
1187
|
-
expect(response.isError).toBe(true);
|
|
1188
|
-
expect(getResponseText(response)).toBe("issue_id is required");
|
|
1189
|
-
|
|
1190
|
-
readConfigSpy.mockRestore();
|
|
1191
|
-
});
|
|
1192
|
-
|
|
1193
|
-
test("returns error when issue_id is whitespace only", async () => {
|
|
1194
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1195
|
-
apiKey: "test-key",
|
|
1196
|
-
baseUrl: null,
|
|
1197
|
-
storageBaseUrl: null,
|
|
1198
|
-
orgId: null,
|
|
1199
|
-
defaultProject: null,
|
|
1200
|
-
projectName: null,
|
|
1201
|
-
});
|
|
1202
|
-
|
|
1203
|
-
const response = await handleToolCall(createRequest("list_action_items", { issue_id: " " }));
|
|
1204
|
-
|
|
1205
|
-
expect(response.isError).toBe(true);
|
|
1206
|
-
expect(getResponseText(response)).toBe("issue_id is required");
|
|
1207
|
-
|
|
1208
|
-
readConfigSpy.mockRestore();
|
|
1209
|
-
});
|
|
1210
|
-
|
|
1211
|
-
test("successfully returns action items list as JSON", async () => {
|
|
1212
|
-
const mockActionItems = [
|
|
1213
|
-
{ id: "action-1", title: "First Action", severity: 1 },
|
|
1214
|
-
{ id: "action-2", title: "Second Action", severity: 2 },
|
|
1215
|
-
];
|
|
1216
|
-
|
|
1217
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1218
|
-
apiKey: "test-key",
|
|
1219
|
-
baseUrl: null,
|
|
1220
|
-
storageBaseUrl: null,
|
|
1221
|
-
orgId: null,
|
|
1222
|
-
defaultProject: null,
|
|
1223
|
-
projectName: null,
|
|
1224
|
-
});
|
|
1225
|
-
|
|
1226
|
-
globalThis.fetch = mock(() =>
|
|
1227
|
-
Promise.resolve(
|
|
1228
|
-
new Response(JSON.stringify(mockActionItems), {
|
|
1229
|
-
status: 200,
|
|
1230
|
-
headers: { "Content-Type": "application/json" },
|
|
1231
|
-
})
|
|
1232
|
-
)
|
|
1233
|
-
) as unknown as typeof fetch;
|
|
1234
|
-
|
|
1235
|
-
const response = await handleToolCall(createRequest("list_action_items", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }));
|
|
1236
|
-
|
|
1237
|
-
expect(response.isError).toBeUndefined();
|
|
1238
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
1239
|
-
expect(parsed).toHaveLength(2);
|
|
1240
|
-
expect(parsed[0].title).toBe("First Action");
|
|
1241
|
-
|
|
1242
|
-
readConfigSpy.mockRestore();
|
|
1243
|
-
});
|
|
1244
|
-
});
|
|
1245
|
-
|
|
1246
|
-
describe("create_action_item tool", () => {
|
|
1247
|
-
test("returns error when issue_id is empty", async () => {
|
|
1248
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1249
|
-
apiKey: "test-key",
|
|
1250
|
-
baseUrl: null,
|
|
1251
|
-
storageBaseUrl: null,
|
|
1252
|
-
orgId: null,
|
|
1253
|
-
defaultProject: null,
|
|
1254
|
-
projectName: null,
|
|
1255
|
-
});
|
|
1256
|
-
|
|
1257
|
-
const response = await handleToolCall(
|
|
1258
|
-
createRequest("create_action_item", { issue_id: "", title: "Test" })
|
|
1259
|
-
);
|
|
1260
|
-
|
|
1261
|
-
expect(response.isError).toBe(true);
|
|
1262
|
-
expect(getResponseText(response)).toBe("issue_id is required");
|
|
1263
|
-
|
|
1264
|
-
readConfigSpy.mockRestore();
|
|
1265
|
-
});
|
|
1266
|
-
|
|
1267
|
-
test("returns error when title is empty", async () => {
|
|
1268
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1269
|
-
apiKey: "test-key",
|
|
1270
|
-
baseUrl: null,
|
|
1271
|
-
storageBaseUrl: null,
|
|
1272
|
-
orgId: null,
|
|
1273
|
-
defaultProject: null,
|
|
1274
|
-
projectName: null,
|
|
1275
|
-
});
|
|
1276
|
-
|
|
1277
|
-
const response = await handleToolCall(
|
|
1278
|
-
createRequest("create_action_item", { issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", title: "" })
|
|
1279
|
-
);
|
|
1280
|
-
|
|
1281
|
-
expect(response.isError).toBe(true);
|
|
1282
|
-
expect(getResponseText(response)).toBe("title is required");
|
|
1283
|
-
|
|
1284
|
-
readConfigSpy.mockRestore();
|
|
1285
|
-
});
|
|
1286
|
-
|
|
1287
|
-
test("successfully creates action item with minimal params", async () => {
|
|
1288
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1289
|
-
apiKey: "test-key",
|
|
1290
|
-
baseUrl: null,
|
|
1291
|
-
storageBaseUrl: null,
|
|
1292
|
-
orgId: null,
|
|
1293
|
-
defaultProject: null,
|
|
1294
|
-
projectName: null,
|
|
1295
|
-
});
|
|
1296
|
-
|
|
1297
|
-
let capturedBody: string | undefined;
|
|
1298
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1299
|
-
capturedBody = options?.body as string;
|
|
1300
|
-
return Promise.resolve(
|
|
1301
|
-
new Response(JSON.stringify("new-action-item-id"), {
|
|
1302
|
-
status: 200,
|
|
1303
|
-
headers: { "Content-Type": "application/json" },
|
|
1304
|
-
})
|
|
1305
|
-
);
|
|
1306
|
-
}) as unknown as typeof fetch;
|
|
1307
|
-
|
|
1308
|
-
const response = await handleToolCall(
|
|
1309
|
-
createRequest("create_action_item", {
|
|
1310
|
-
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
1311
|
-
title: "Fix the index",
|
|
1312
|
-
})
|
|
1313
|
-
);
|
|
1314
|
-
|
|
1315
|
-
expect(response.isError).toBeUndefined();
|
|
1316
|
-
expect(capturedBody).toBeDefined();
|
|
1317
|
-
const parsed = JSON.parse(capturedBody!);
|
|
1318
|
-
expect(parsed.issue_id).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
|
1319
|
-
expect(parsed.title).toBe("Fix the index");
|
|
1320
|
-
|
|
1321
|
-
readConfigSpy.mockRestore();
|
|
1322
|
-
});
|
|
1323
|
-
|
|
1324
|
-
test("successfully creates action item with all params", async () => {
|
|
1325
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1326
|
-
apiKey: "test-key",
|
|
1327
|
-
baseUrl: null,
|
|
1328
|
-
storageBaseUrl: null,
|
|
1329
|
-
orgId: null,
|
|
1330
|
-
defaultProject: null,
|
|
1331
|
-
projectName: null,
|
|
1332
|
-
});
|
|
1333
|
-
|
|
1334
|
-
let capturedBody: string | undefined;
|
|
1335
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1336
|
-
capturedBody = options?.body as string;
|
|
1337
|
-
return Promise.resolve(
|
|
1338
|
-
new Response(JSON.stringify("new-action-item-id"), {
|
|
1339
|
-
status: 200,
|
|
1340
|
-
headers: { "Content-Type": "application/json" },
|
|
1341
|
-
})
|
|
1342
|
-
);
|
|
1343
|
-
}) as unknown as typeof fetch;
|
|
1344
|
-
|
|
1345
|
-
const response = await handleToolCall(
|
|
1346
|
-
createRequest("create_action_item", {
|
|
1347
|
-
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
1348
|
-
title: "Fix the index",
|
|
1349
|
-
description: "Drop the unused index to improve performance",
|
|
1350
|
-
sql_action: "DROP INDEX CONCURRENTLY idx_unused;",
|
|
1351
|
-
configs: [{ parameter: "work_mem", value: "256MB" }],
|
|
1352
|
-
})
|
|
1353
|
-
);
|
|
1354
|
-
|
|
1355
|
-
expect(response.isError).toBeUndefined();
|
|
1356
|
-
expect(capturedBody).toBeDefined();
|
|
1357
|
-
const parsed = JSON.parse(capturedBody!);
|
|
1358
|
-
expect(parsed.issue_id).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
|
1359
|
-
expect(parsed.title).toBe("Fix the index");
|
|
1360
|
-
expect(parsed.description).toBe("Drop the unused index to improve performance");
|
|
1361
|
-
expect(parsed.sql_action).toBe("DROP INDEX CONCURRENTLY idx_unused;");
|
|
1362
|
-
expect(parsed.configs).toEqual([{ parameter: "work_mem", value: "256MB" }]);
|
|
1363
|
-
|
|
1364
|
-
readConfigSpy.mockRestore();
|
|
1365
|
-
});
|
|
1366
|
-
|
|
1367
|
-
test("interprets escape sequences in title and description", async () => {
|
|
1368
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1369
|
-
apiKey: "test-key",
|
|
1370
|
-
baseUrl: null,
|
|
1371
|
-
storageBaseUrl: null,
|
|
1372
|
-
orgId: null,
|
|
1373
|
-
defaultProject: null,
|
|
1374
|
-
projectName: null,
|
|
1375
|
-
});
|
|
1376
|
-
|
|
1377
|
-
let capturedBody: string | undefined;
|
|
1378
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1379
|
-
capturedBody = options?.body as string;
|
|
1380
|
-
return Promise.resolve(
|
|
1381
|
-
new Response(JSON.stringify("new-action-item-id"), {
|
|
1382
|
-
status: 200,
|
|
1383
|
-
headers: { "Content-Type": "application/json" },
|
|
1384
|
-
})
|
|
1385
|
-
);
|
|
1386
|
-
}) as unknown as typeof fetch;
|
|
1387
|
-
|
|
1388
|
-
await handleToolCall(
|
|
1389
|
-
createRequest("create_action_item", {
|
|
1390
|
-
issue_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
1391
|
-
title: "Title\\nwith newline",
|
|
1392
|
-
description: "Desc\\twith tab",
|
|
1393
|
-
})
|
|
1394
|
-
);
|
|
1395
|
-
|
|
1396
|
-
expect(capturedBody).toBeDefined();
|
|
1397
|
-
const parsed = JSON.parse(capturedBody!);
|
|
1398
|
-
expect(parsed.title).toBe("Title\nwith newline");
|
|
1399
|
-
expect(parsed.description).toBe("Desc\twith tab");
|
|
1400
|
-
|
|
1401
|
-
readConfigSpy.mockRestore();
|
|
1402
|
-
});
|
|
1403
|
-
});
|
|
1404
|
-
|
|
1405
|
-
describe("update_action_item tool", () => {
|
|
1406
|
-
test("returns error when action_item_id is empty", async () => {
|
|
1407
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1408
|
-
apiKey: "test-key",
|
|
1409
|
-
baseUrl: null,
|
|
1410
|
-
storageBaseUrl: null,
|
|
1411
|
-
orgId: null,
|
|
1412
|
-
defaultProject: null,
|
|
1413
|
-
projectName: null,
|
|
1414
|
-
});
|
|
1415
|
-
|
|
1416
|
-
const response = await handleToolCall(
|
|
1417
|
-
createRequest("update_action_item", { action_item_id: "", title: "New Title" })
|
|
1418
|
-
);
|
|
1419
|
-
|
|
1420
|
-
expect(response.isError).toBe(true);
|
|
1421
|
-
expect(getResponseText(response)).toBe("action_item_id is required");
|
|
1422
|
-
|
|
1423
|
-
readConfigSpy.mockRestore();
|
|
1424
|
-
});
|
|
1425
|
-
|
|
1426
|
-
test("returns error when no update fields provided", async () => {
|
|
1427
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1428
|
-
apiKey: "test-key",
|
|
1429
|
-
baseUrl: null,
|
|
1430
|
-
storageBaseUrl: null,
|
|
1431
|
-
orgId: null,
|
|
1432
|
-
defaultProject: null,
|
|
1433
|
-
projectName: null,
|
|
1434
|
-
});
|
|
1435
|
-
|
|
1436
|
-
const response = await handleToolCall(
|
|
1437
|
-
createRequest("update_action_item", { action_item_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" })
|
|
1438
|
-
);
|
|
1439
|
-
|
|
1440
|
-
expect(response.isError).toBe(true);
|
|
1441
|
-
expect(getResponseText(response)).toContain("At least one field to update is required");
|
|
1442
|
-
|
|
1443
|
-
readConfigSpy.mockRestore();
|
|
1444
|
-
});
|
|
1445
|
-
|
|
1446
|
-
test("returns error when status is invalid", async () => {
|
|
1447
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1448
|
-
apiKey: "test-key",
|
|
1449
|
-
baseUrl: null,
|
|
1450
|
-
storageBaseUrl: null,
|
|
1451
|
-
orgId: null,
|
|
1452
|
-
defaultProject: null,
|
|
1453
|
-
projectName: null,
|
|
1454
|
-
});
|
|
1455
|
-
|
|
1456
|
-
const response = await handleToolCall(
|
|
1457
|
-
createRequest("update_action_item", { action_item_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", status: "invalid_status" })
|
|
1458
|
-
);
|
|
1459
|
-
|
|
1460
|
-
expect(response.isError).toBe(true);
|
|
1461
|
-
expect(getResponseText(response)).toContain("status must be");
|
|
1462
|
-
|
|
1463
|
-
readConfigSpy.mockRestore();
|
|
1464
|
-
});
|
|
1465
|
-
|
|
1466
|
-
test("successfully updates with only title", async () => {
|
|
1467
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1468
|
-
apiKey: "test-key",
|
|
1469
|
-
baseUrl: null,
|
|
1470
|
-
storageBaseUrl: null,
|
|
1471
|
-
orgId: null,
|
|
1472
|
-
defaultProject: null,
|
|
1473
|
-
projectName: null,
|
|
1474
|
-
});
|
|
1475
|
-
|
|
1476
|
-
let capturedBody: string | undefined;
|
|
1477
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1478
|
-
capturedBody = options?.body as string;
|
|
1479
|
-
return Promise.resolve(
|
|
1480
|
-
new Response("", {
|
|
1481
|
-
status: 200,
|
|
1482
|
-
headers: { "Content-Type": "application/json" },
|
|
1483
|
-
})
|
|
1484
|
-
);
|
|
1485
|
-
}) as unknown as typeof fetch;
|
|
1486
|
-
|
|
1487
|
-
const response = await handleToolCall(
|
|
1488
|
-
createRequest("update_action_item", { action_item_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", title: "New Title" })
|
|
1489
|
-
);
|
|
1490
|
-
|
|
1491
|
-
expect(response.isError).toBeUndefined();
|
|
1492
|
-
expect(capturedBody).toBeDefined();
|
|
1493
|
-
const parsed = JSON.parse(capturedBody!);
|
|
1494
|
-
expect(parsed.action_item_id).toBe("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
|
1495
|
-
expect(parsed.title).toBe("New Title");
|
|
1496
|
-
|
|
1497
|
-
readConfigSpy.mockRestore();
|
|
1498
|
-
});
|
|
1499
|
-
|
|
1500
|
-
test("successfully updates is_done", async () => {
|
|
1501
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1502
|
-
apiKey: "test-key",
|
|
1503
|
-
baseUrl: null,
|
|
1504
|
-
storageBaseUrl: null,
|
|
1505
|
-
orgId: null,
|
|
1506
|
-
defaultProject: null,
|
|
1507
|
-
projectName: null,
|
|
1508
|
-
});
|
|
1509
|
-
|
|
1510
|
-
let capturedBody: string | undefined;
|
|
1511
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1512
|
-
capturedBody = options?.body as string;
|
|
1513
|
-
return Promise.resolve(
|
|
1514
|
-
new Response("", {
|
|
1515
|
-
status: 200,
|
|
1516
|
-
headers: { "Content-Type": "application/json" },
|
|
1517
|
-
})
|
|
1518
|
-
);
|
|
1519
|
-
}) as unknown as typeof fetch;
|
|
1520
|
-
|
|
1521
|
-
const response = await handleToolCall(
|
|
1522
|
-
createRequest("update_action_item", { action_item_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", is_done: true })
|
|
1523
|
-
);
|
|
1524
|
-
|
|
1525
|
-
expect(response.isError).toBeUndefined();
|
|
1526
|
-
expect(capturedBody).toBeDefined();
|
|
1527
|
-
const parsed = JSON.parse(capturedBody!);
|
|
1528
|
-
expect(parsed.is_done).toBe(true);
|
|
1529
|
-
|
|
1530
|
-
readConfigSpy.mockRestore();
|
|
1531
|
-
});
|
|
1532
|
-
|
|
1533
|
-
test("successfully updates status with status_reason", async () => {
|
|
1534
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1535
|
-
apiKey: "test-key",
|
|
1536
|
-
baseUrl: null,
|
|
1537
|
-
storageBaseUrl: null,
|
|
1538
|
-
orgId: null,
|
|
1539
|
-
defaultProject: null,
|
|
1540
|
-
projectName: null,
|
|
1541
|
-
});
|
|
1542
|
-
|
|
1543
|
-
let capturedBody: string | undefined;
|
|
1544
|
-
globalThis.fetch = mock((url: string, options?: RequestInit) => {
|
|
1545
|
-
capturedBody = options?.body as string;
|
|
1546
|
-
return Promise.resolve(
|
|
1547
|
-
new Response("", {
|
|
1548
|
-
status: 200,
|
|
1549
|
-
headers: { "Content-Type": "application/json" },
|
|
1550
|
-
})
|
|
1551
|
-
);
|
|
1552
|
-
}) as unknown as typeof fetch;
|
|
1553
|
-
|
|
1554
|
-
const response = await handleToolCall(
|
|
1555
|
-
createRequest("update_action_item", {
|
|
1556
|
-
action_item_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
1557
|
-
status: "approved",
|
|
1558
|
-
status_reason: "Looks good to me",
|
|
1559
|
-
})
|
|
1560
|
-
);
|
|
1561
|
-
|
|
1562
|
-
expect(response.isError).toBeUndefined();
|
|
1563
|
-
expect(capturedBody).toBeDefined();
|
|
1564
|
-
const parsed = JSON.parse(capturedBody!);
|
|
1565
|
-
expect(parsed.status).toBe("approved");
|
|
1566
|
-
expect(parsed.status_reason).toBe("Looks good to me");
|
|
1567
|
-
|
|
1568
|
-
readConfigSpy.mockRestore();
|
|
1569
|
-
});
|
|
1570
|
-
});
|
|
1571
|
-
|
|
1572
|
-
describe("unknown tool handling", () => {
|
|
1573
|
-
test("returns error for unknown tool name", async () => {
|
|
1574
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1575
|
-
apiKey: "test-key",
|
|
1576
|
-
baseUrl: null,
|
|
1577
|
-
storageBaseUrl: null,
|
|
1578
|
-
orgId: null,
|
|
1579
|
-
defaultProject: null,
|
|
1580
|
-
projectName: null,
|
|
1581
|
-
});
|
|
1582
|
-
|
|
1583
|
-
const response = await handleToolCall(createRequest("nonexistent_tool"));
|
|
1584
|
-
|
|
1585
|
-
expect(response.isError).toBe(true);
|
|
1586
|
-
expect(getResponseText(response)).toContain("Unknown tool: nonexistent_tool");
|
|
1587
|
-
|
|
1588
|
-
readConfigSpy.mockRestore();
|
|
1589
|
-
});
|
|
1590
|
-
});
|
|
1591
|
-
|
|
1592
|
-
describe("list_reports tool", () => {
|
|
1593
|
-
test("successfully returns reports list as JSON", async () => {
|
|
1594
|
-
const mockReports = [
|
|
1595
|
-
{ id: 1, org_id: 1, org_name: "TestOrg", project_id: 10, project_name: "prod-db", status: "completed" },
|
|
1596
|
-
];
|
|
1597
|
-
|
|
1598
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1599
|
-
apiKey: "test-key",
|
|
1600
|
-
baseUrl: null,
|
|
1601
|
-
storageBaseUrl: null,
|
|
1602
|
-
orgId: null,
|
|
1603
|
-
defaultProject: null,
|
|
1604
|
-
projectName: null,
|
|
1605
|
-
});
|
|
1606
|
-
|
|
1607
|
-
globalThis.fetch = mock(() =>
|
|
1608
|
-
Promise.resolve(
|
|
1609
|
-
new Response(JSON.stringify(mockReports), {
|
|
1610
|
-
status: 200,
|
|
1611
|
-
headers: { "Content-Type": "application/json" },
|
|
1612
|
-
})
|
|
1613
|
-
)
|
|
1614
|
-
) as unknown as typeof fetch;
|
|
1615
|
-
|
|
1616
|
-
const response = await handleToolCall(createRequest("list_reports"));
|
|
1617
|
-
|
|
1618
|
-
expect(response.isError).toBeUndefined();
|
|
1619
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
1620
|
-
expect(parsed).toHaveLength(1);
|
|
1621
|
-
expect(parsed[0].status).toBe("completed");
|
|
1622
|
-
|
|
1623
|
-
readConfigSpy.mockRestore();
|
|
1624
|
-
});
|
|
1625
|
-
|
|
1626
|
-
test("passes filters to API", async () => {
|
|
1627
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1628
|
-
apiKey: "test-key",
|
|
1629
|
-
baseUrl: null,
|
|
1630
|
-
storageBaseUrl: null,
|
|
1631
|
-
orgId: null,
|
|
1632
|
-
defaultProject: null,
|
|
1633
|
-
projectName: null,
|
|
1634
|
-
});
|
|
1635
|
-
|
|
1636
|
-
let capturedUrl: string | undefined;
|
|
1637
|
-
globalThis.fetch = mock((url: string) => {
|
|
1638
|
-
capturedUrl = url;
|
|
1639
|
-
return Promise.resolve(
|
|
1640
|
-
new Response(JSON.stringify([]), {
|
|
1641
|
-
status: 200,
|
|
1642
|
-
headers: { "Content-Type": "application/json" },
|
|
1643
|
-
})
|
|
1644
|
-
);
|
|
1645
|
-
}) as unknown as typeof fetch;
|
|
1646
|
-
|
|
1647
|
-
await handleToolCall(createRequest("list_reports", {
|
|
1648
|
-
project_id: 5,
|
|
1649
|
-
status: "completed",
|
|
1650
|
-
limit: 10,
|
|
1651
|
-
}));
|
|
1652
|
-
|
|
1653
|
-
expect(capturedUrl).toContain("project_id=eq.5");
|
|
1654
|
-
expect(capturedUrl).toContain("status=eq.completed");
|
|
1655
|
-
expect(capturedUrl).toContain("limit=10");
|
|
1656
|
-
|
|
1657
|
-
readConfigSpy.mockRestore();
|
|
1658
|
-
});
|
|
1659
|
-
|
|
1660
|
-
test("handles API errors gracefully", async () => {
|
|
1661
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1662
|
-
apiKey: "test-key",
|
|
1663
|
-
baseUrl: null,
|
|
1664
|
-
storageBaseUrl: null,
|
|
1665
|
-
orgId: null,
|
|
1666
|
-
defaultProject: null,
|
|
1667
|
-
projectName: null,
|
|
1668
|
-
});
|
|
1669
|
-
|
|
1670
|
-
globalThis.fetch = mock(() =>
|
|
1671
|
-
Promise.resolve(
|
|
1672
|
-
new Response('{"message": "Unauthorized"}', {
|
|
1673
|
-
status: 401,
|
|
1674
|
-
headers: { "Content-Type": "application/json" },
|
|
1675
|
-
})
|
|
1676
|
-
)
|
|
1677
|
-
) as unknown as typeof fetch;
|
|
1678
|
-
|
|
1679
|
-
const response = await handleToolCall(createRequest("list_reports"));
|
|
1680
|
-
|
|
1681
|
-
expect(response.isError).toBe(true);
|
|
1682
|
-
expect(getResponseText(response)).toContain("401");
|
|
1683
|
-
// Agent-facing remediation: invalid key must point at re-auth, not dead-end
|
|
1684
|
-
expect(getResponseText(response)).toContain("Run 'postgresai auth'");
|
|
1685
|
-
|
|
1686
|
-
readConfigSpy.mockRestore();
|
|
1687
|
-
});
|
|
1688
|
-
|
|
1689
|
-
test("passes before_date to API as created_at filter", async () => {
|
|
1690
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1691
|
-
apiKey: "test-key",
|
|
1692
|
-
baseUrl: null,
|
|
1693
|
-
storageBaseUrl: null,
|
|
1694
|
-
orgId: null,
|
|
1695
|
-
defaultProject: null,
|
|
1696
|
-
projectName: null,
|
|
1697
|
-
});
|
|
1698
|
-
|
|
1699
|
-
let capturedUrl: string | undefined;
|
|
1700
|
-
globalThis.fetch = mock((url: string) => {
|
|
1701
|
-
capturedUrl = url;
|
|
1702
|
-
return Promise.resolve(
|
|
1703
|
-
new Response(JSON.stringify([]), {
|
|
1704
|
-
status: 200,
|
|
1705
|
-
headers: { "Content-Type": "application/json" },
|
|
1706
|
-
})
|
|
1707
|
-
);
|
|
1708
|
-
}) as unknown as typeof fetch;
|
|
1709
|
-
|
|
1710
|
-
await handleToolCall(createRequest("list_reports", {
|
|
1711
|
-
before_date: "2025-01-15",
|
|
1712
|
-
}));
|
|
1713
|
-
|
|
1714
|
-
expect(capturedUrl).toContain("created_at=lt.2025-01-15");
|
|
1715
|
-
|
|
1716
|
-
readConfigSpy.mockRestore();
|
|
1717
|
-
});
|
|
1718
|
-
|
|
1719
|
-
test("fetches all reports when all=true", async () => {
|
|
1720
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1721
|
-
apiKey: "test-key",
|
|
1722
|
-
baseUrl: null,
|
|
1723
|
-
storageBaseUrl: null,
|
|
1724
|
-
orgId: null,
|
|
1725
|
-
defaultProject: null,
|
|
1726
|
-
projectName: null,
|
|
1727
|
-
});
|
|
1728
|
-
|
|
1729
|
-
const mockReports = [
|
|
1730
|
-
{ id: 10, org_id: 1, org_name: "O", project_id: 1, project_name: "P", status: "completed" },
|
|
1731
|
-
];
|
|
1732
|
-
|
|
1733
|
-
globalThis.fetch = mock(() =>
|
|
1734
|
-
Promise.resolve(
|
|
1735
|
-
new Response(JSON.stringify(mockReports), {
|
|
1736
|
-
status: 200,
|
|
1737
|
-
headers: { "Content-Type": "application/json" },
|
|
1738
|
-
})
|
|
1739
|
-
)
|
|
1740
|
-
) as unknown as typeof fetch;
|
|
1741
|
-
|
|
1742
|
-
const response = await handleToolCall(createRequest("list_reports", {
|
|
1743
|
-
all: true,
|
|
1744
|
-
}));
|
|
1745
|
-
|
|
1746
|
-
expect(response.isError).toBeUndefined();
|
|
1747
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
1748
|
-
expect(parsed).toHaveLength(1);
|
|
1749
|
-
expect(parsed[0].id).toBe(10);
|
|
1750
|
-
|
|
1751
|
-
readConfigSpy.mockRestore();
|
|
1752
|
-
});
|
|
1753
|
-
});
|
|
1754
|
-
|
|
1755
|
-
describe("list_report_files tool", () => {
|
|
1756
|
-
test("returns error when neither report_id nor check_id is provided", async () => {
|
|
1757
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1758
|
-
apiKey: "test-key",
|
|
1759
|
-
baseUrl: null,
|
|
1760
|
-
storageBaseUrl: null,
|
|
1761
|
-
orgId: null,
|
|
1762
|
-
defaultProject: null,
|
|
1763
|
-
projectName: null,
|
|
1764
|
-
});
|
|
1765
|
-
|
|
1766
|
-
const response = await handleToolCall(createRequest("list_report_files", {}));
|
|
1767
|
-
|
|
1768
|
-
expect(response.isError).toBe(true);
|
|
1769
|
-
expect(getResponseText(response)).toContain("Either report_id or check_id is required");
|
|
1770
|
-
|
|
1771
|
-
readConfigSpy.mockRestore();
|
|
1772
|
-
});
|
|
1773
|
-
|
|
1774
|
-
test("works with only check_id (no report_id)", async () => {
|
|
1775
|
-
const mockFiles = [
|
|
1776
|
-
{ id: 100, checkup_report_id: 1, filename: "H002.md", check_id: "H002", type: "md" },
|
|
1777
|
-
];
|
|
1778
|
-
|
|
1779
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1780
|
-
apiKey: "test-key",
|
|
1781
|
-
baseUrl: null,
|
|
1782
|
-
storageBaseUrl: null,
|
|
1783
|
-
orgId: null,
|
|
1784
|
-
defaultProject: null,
|
|
1785
|
-
projectName: null,
|
|
1786
|
-
});
|
|
1787
|
-
|
|
1788
|
-
let capturedUrl: string | undefined;
|
|
1789
|
-
globalThis.fetch = mock((url: string) => {
|
|
1790
|
-
capturedUrl = url;
|
|
1791
|
-
return Promise.resolve(
|
|
1792
|
-
new Response(JSON.stringify(mockFiles), {
|
|
1793
|
-
status: 200,
|
|
1794
|
-
headers: { "Content-Type": "application/json" },
|
|
1795
|
-
})
|
|
1796
|
-
);
|
|
1797
|
-
}) as unknown as typeof fetch;
|
|
1798
|
-
|
|
1799
|
-
const response = await handleToolCall(createRequest("list_report_files", {
|
|
1800
|
-
check_id: "H002",
|
|
1801
|
-
}));
|
|
1802
|
-
|
|
1803
|
-
expect(response.isError).toBeUndefined();
|
|
1804
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
1805
|
-
expect(parsed[0].filename).toBe("H002.md");
|
|
1806
|
-
expect(capturedUrl).toContain("check_id=eq.H002");
|
|
1807
|
-
expect(capturedUrl).not.toContain("checkup_report_id");
|
|
1808
|
-
|
|
1809
|
-
readConfigSpy.mockRestore();
|
|
1810
|
-
});
|
|
1811
|
-
|
|
1812
|
-
test("successfully returns report files", async () => {
|
|
1813
|
-
const mockFiles = [
|
|
1814
|
-
{ id: 100, checkup_report_id: 1, filename: "H002.md", check_id: "H002", type: "md" },
|
|
1815
|
-
];
|
|
1816
|
-
|
|
1817
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1818
|
-
apiKey: "test-key",
|
|
1819
|
-
baseUrl: null,
|
|
1820
|
-
storageBaseUrl: null,
|
|
1821
|
-
orgId: null,
|
|
1822
|
-
defaultProject: null,
|
|
1823
|
-
projectName: null,
|
|
1824
|
-
});
|
|
1825
|
-
|
|
1826
|
-
let capturedUrl: string | undefined;
|
|
1827
|
-
globalThis.fetch = mock((url: string) => {
|
|
1828
|
-
capturedUrl = url;
|
|
1829
|
-
return Promise.resolve(
|
|
1830
|
-
new Response(JSON.stringify(mockFiles), {
|
|
1831
|
-
status: 200,
|
|
1832
|
-
headers: { "Content-Type": "application/json" },
|
|
1833
|
-
})
|
|
1834
|
-
);
|
|
1835
|
-
}) as unknown as typeof fetch;
|
|
1836
|
-
|
|
1837
|
-
const response = await handleToolCall(createRequest("list_report_files", {
|
|
1838
|
-
report_id: 1,
|
|
1839
|
-
type: "md",
|
|
1840
|
-
check_id: "H002",
|
|
1841
|
-
}));
|
|
1842
|
-
|
|
1843
|
-
expect(response.isError).toBeUndefined();
|
|
1844
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
1845
|
-
expect(parsed[0].filename).toBe("H002.md");
|
|
1846
|
-
expect(capturedUrl).toContain("checkup_report_id=eq.1");
|
|
1847
|
-
expect(capturedUrl).toContain("type=eq.md");
|
|
1848
|
-
expect(capturedUrl).toContain("check_id=eq.H002");
|
|
1849
|
-
|
|
1850
|
-
readConfigSpy.mockRestore();
|
|
1851
|
-
});
|
|
1852
|
-
});
|
|
1853
|
-
|
|
1854
|
-
describe("get_report_data tool", () => {
|
|
1855
|
-
test("returns error when neither report_id nor check_id is provided", async () => {
|
|
1856
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1857
|
-
apiKey: "test-key",
|
|
1858
|
-
baseUrl: null,
|
|
1859
|
-
storageBaseUrl: null,
|
|
1860
|
-
orgId: null,
|
|
1861
|
-
defaultProject: null,
|
|
1862
|
-
projectName: null,
|
|
1863
|
-
});
|
|
1864
|
-
|
|
1865
|
-
const response = await handleToolCall(createRequest("get_report_data", {}));
|
|
1866
|
-
|
|
1867
|
-
expect(response.isError).toBe(true);
|
|
1868
|
-
expect(getResponseText(response)).toContain("Either report_id or check_id is required");
|
|
1869
|
-
|
|
1870
|
-
readConfigSpy.mockRestore();
|
|
1871
|
-
});
|
|
1872
|
-
|
|
1873
|
-
test("works with only check_id (no report_id)", async () => {
|
|
1874
|
-
const mockData = [
|
|
1875
|
-
{
|
|
1876
|
-
id: 100,
|
|
1877
|
-
checkup_report_id: 1,
|
|
1878
|
-
filename: "H002.md",
|
|
1879
|
-
check_id: "H002",
|
|
1880
|
-
type: "md",
|
|
1881
|
-
data: "# H002\n\nUnused indexes found.",
|
|
1882
|
-
},
|
|
1883
|
-
];
|
|
1884
|
-
|
|
1885
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1886
|
-
apiKey: "test-key",
|
|
1887
|
-
baseUrl: null,
|
|
1888
|
-
storageBaseUrl: null,
|
|
1889
|
-
orgId: null,
|
|
1890
|
-
defaultProject: null,
|
|
1891
|
-
projectName: null,
|
|
1892
|
-
});
|
|
1893
|
-
|
|
1894
|
-
let capturedUrl: string | undefined;
|
|
1895
|
-
globalThis.fetch = mock((url: string) => {
|
|
1896
|
-
capturedUrl = url;
|
|
1897
|
-
return Promise.resolve(
|
|
1898
|
-
new Response(JSON.stringify(mockData), {
|
|
1899
|
-
status: 200,
|
|
1900
|
-
headers: { "Content-Type": "application/json" },
|
|
1901
|
-
})
|
|
1902
|
-
);
|
|
1903
|
-
}) as unknown as typeof fetch;
|
|
1904
|
-
|
|
1905
|
-
const response = await handleToolCall(createRequest("get_report_data", {
|
|
1906
|
-
check_id: "H002",
|
|
1907
|
-
}));
|
|
1908
|
-
|
|
1909
|
-
expect(response.isError).toBeUndefined();
|
|
1910
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
1911
|
-
expect(parsed[0].data).toContain("# H002");
|
|
1912
|
-
expect(capturedUrl).toContain("check_id=eq.H002");
|
|
1913
|
-
expect(capturedUrl).not.toContain("checkup_report_id");
|
|
1914
|
-
|
|
1915
|
-
readConfigSpy.mockRestore();
|
|
1916
|
-
});
|
|
1917
|
-
|
|
1918
|
-
test("successfully returns report data with content", async () => {
|
|
1919
|
-
const mockData = [
|
|
1920
|
-
{
|
|
1921
|
-
id: 100,
|
|
1922
|
-
checkup_report_id: 1,
|
|
1923
|
-
filename: "H002.md",
|
|
1924
|
-
check_id: "H002",
|
|
1925
|
-
type: "md",
|
|
1926
|
-
data: "# H002\n\nUnused indexes found.",
|
|
1927
|
-
},
|
|
1928
|
-
];
|
|
1929
|
-
|
|
1930
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1931
|
-
apiKey: "test-key",
|
|
1932
|
-
baseUrl: null,
|
|
1933
|
-
storageBaseUrl: null,
|
|
1934
|
-
orgId: null,
|
|
1935
|
-
defaultProject: null,
|
|
1936
|
-
projectName: null,
|
|
1937
|
-
});
|
|
1938
|
-
|
|
1939
|
-
globalThis.fetch = mock(() =>
|
|
1940
|
-
Promise.resolve(
|
|
1941
|
-
new Response(JSON.stringify(mockData), {
|
|
1942
|
-
status: 200,
|
|
1943
|
-
headers: { "Content-Type": "application/json" },
|
|
1944
|
-
})
|
|
1945
|
-
)
|
|
1946
|
-
) as unknown as typeof fetch;
|
|
1947
|
-
|
|
1948
|
-
const response = await handleToolCall(createRequest("get_report_data", {
|
|
1949
|
-
report_id: 1,
|
|
1950
|
-
type: "md",
|
|
1951
|
-
check_id: "H002",
|
|
1952
|
-
}));
|
|
1953
|
-
|
|
1954
|
-
expect(response.isError).toBeUndefined();
|
|
1955
|
-
const parsed = JSON.parse(getResponseText(response));
|
|
1956
|
-
expect(parsed[0].data).toContain("# H002");
|
|
1957
|
-
|
|
1958
|
-
readConfigSpy.mockRestore();
|
|
1959
|
-
});
|
|
1960
|
-
|
|
1961
|
-
test("passes filters to API", async () => {
|
|
1962
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1963
|
-
apiKey: "test-key",
|
|
1964
|
-
baseUrl: null,
|
|
1965
|
-
storageBaseUrl: null,
|
|
1966
|
-
orgId: null,
|
|
1967
|
-
defaultProject: null,
|
|
1968
|
-
projectName: null,
|
|
1969
|
-
});
|
|
1970
|
-
|
|
1971
|
-
let capturedUrl: string | undefined;
|
|
1972
|
-
globalThis.fetch = mock((url: string) => {
|
|
1973
|
-
capturedUrl = url;
|
|
1974
|
-
return Promise.resolve(
|
|
1975
|
-
new Response(JSON.stringify([]), {
|
|
1976
|
-
status: 200,
|
|
1977
|
-
headers: { "Content-Type": "application/json" },
|
|
1978
|
-
})
|
|
1979
|
-
);
|
|
1980
|
-
}) as unknown as typeof fetch;
|
|
1981
|
-
|
|
1982
|
-
await handleToolCall(createRequest("get_report_data", {
|
|
1983
|
-
report_id: 42,
|
|
1984
|
-
type: "json",
|
|
1985
|
-
check_id: "F004",
|
|
1986
|
-
}));
|
|
1987
|
-
|
|
1988
|
-
expect(capturedUrl).toContain("checkup_report_id=eq.42");
|
|
1989
|
-
expect(capturedUrl).toContain("type=eq.json");
|
|
1990
|
-
expect(capturedUrl).toContain("check_id=eq.F004");
|
|
1991
|
-
|
|
1992
|
-
readConfigSpy.mockRestore();
|
|
1993
|
-
});
|
|
1994
|
-
});
|
|
1995
|
-
|
|
1996
|
-
describe("error propagation", () => {
|
|
1997
|
-
test("propagates API errors through MCP layer", async () => {
|
|
1998
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
1999
|
-
apiKey: "test-key",
|
|
2000
|
-
baseUrl: null,
|
|
2001
|
-
storageBaseUrl: null,
|
|
2002
|
-
orgId: 1,
|
|
2003
|
-
defaultProject: null,
|
|
2004
|
-
projectName: null,
|
|
2005
|
-
});
|
|
2006
|
-
|
|
2007
|
-
globalThis.fetch = mock(() =>
|
|
2008
|
-
Promise.resolve(
|
|
2009
|
-
new Response('{"message": "Internal Server Error"}', {
|
|
2010
|
-
status: 500,
|
|
2011
|
-
headers: { "Content-Type": "application/json" },
|
|
2012
|
-
})
|
|
2013
|
-
)
|
|
2014
|
-
) as unknown as typeof fetch;
|
|
2015
|
-
|
|
2016
|
-
const response = await handleToolCall(
|
|
2017
|
-
createRequest("create_issue", { title: "Test Issue" })
|
|
2018
|
-
);
|
|
2019
|
-
|
|
2020
|
-
expect(response.isError).toBe(true);
|
|
2021
|
-
expect(getResponseText(response)).toContain("500");
|
|
2022
|
-
|
|
2023
|
-
readConfigSpy.mockRestore();
|
|
2024
|
-
});
|
|
2025
|
-
|
|
2026
|
-
test("handles network errors gracefully", async () => {
|
|
2027
|
-
const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({
|
|
2028
|
-
apiKey: "test-key",
|
|
2029
|
-
baseUrl: null,
|
|
2030
|
-
storageBaseUrl: null,
|
|
2031
|
-
orgId: 1,
|
|
2032
|
-
defaultProject: null,
|
|
2033
|
-
projectName: null,
|
|
2034
|
-
});
|
|
2035
|
-
|
|
2036
|
-
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch;
|
|
2037
|
-
|
|
2038
|
-
const response = await handleToolCall(
|
|
2039
|
-
createRequest("create_issue", { title: "Test Issue" })
|
|
2040
|
-
);
|
|
2041
|
-
|
|
2042
|
-
expect(response.isError).toBe(true);
|
|
2043
|
-
expect(getResponseText(response)).toContain("Network error");
|
|
2044
|
-
|
|
2045
|
-
readConfigSpy.mockRestore();
|
|
2046
|
-
});
|
|
2047
|
-
});
|
|
2048
|
-
|
|
2049
|
-
describe("attachments parameter & file tools", () => {
|
|
2050
|
-
// Real-file approach (rather than fs mocking) — ESM module caching means
|
|
2051
|
-
// monkey-patching fs after `import * as fs from "fs"` does not affect the
|
|
2052
|
-
// already-resolved binding inside storage.ts. Real tmp files are simpler
|
|
2053
|
-
// and match how the existing storage tests work.
|
|
2054
|
-
const fs = require("fs") as typeof import("fs");
|
|
2055
|
-
const path = require("path") as typeof import("path");
|
|
2056
|
-
const os = require("os") as typeof import("os");
|
|
2057
|
-
|
|
2058
|
-
const createdDirs: string[] = [];
|
|
2059
|
-
|
|
2060
|
-
function mockTinyFile(name: string, body = "FAKE"): string {
|
|
2061
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pgai-mcp-attach-"));
|
|
2062
|
-
createdDirs.push(dir);
|
|
2063
|
-
const p = path.join(dir, name);
|
|
2064
|
-
fs.writeFileSync(p, body);
|
|
2065
|
-
return p;
|
|
2066
|
-
}
|
|
2067
|
-
|
|
2068
|
-
afterEach(() => {
|
|
2069
|
-
while (createdDirs.length > 0) {
|
|
2070
|
-
const d = createdDirs.pop();
|
|
2071
|
-
if (d) fs.rmSync(d, { recursive: true, force: true });
|
|
2072
|
-
}
|
|
2073
|
-
});
|
|
2074
|
-
|
|
2075
|
-
function configWithKey() {
|
|
2076
|
-
return spyOn(config, "readConfig").mockReturnValue({
|
|
2077
|
-
apiKey: "test-key",
|
|
2078
|
-
baseUrl: null,
|
|
2079
|
-
storageBaseUrl: null,
|
|
2080
|
-
orgId: 1,
|
|
2081
|
-
defaultProject: null,
|
|
2082
|
-
projectName: null,
|
|
2083
|
-
});
|
|
2084
|
-
}
|
|
2085
|
-
|
|
2086
|
-
test("upload_file tool returns url + ready-to-paste markdown link", async () => {
|
|
2087
|
-
const cfgSpy = configWithKey();
|
|
2088
|
-
const fakePath = mockTinyFile("shot.png");
|
|
2089
|
-
|
|
2090
|
-
globalThis.fetch = mock((_url: string, _init?: RequestInit) =>
|
|
2091
|
-
Promise.resolve(
|
|
2092
|
-
new Response(
|
|
2093
|
-
JSON.stringify({
|
|
2094
|
-
success: true,
|
|
2095
|
-
url: "/files/9/abc.png",
|
|
2096
|
-
metadata: { originalName: "shot.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
|
|
2097
|
-
requestId: "r1",
|
|
2098
|
-
}),
|
|
2099
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2100
|
-
)
|
|
2101
|
-
)
|
|
2102
|
-
) as unknown as typeof fetch;
|
|
2103
|
-
|
|
2104
|
-
const response = await handleToolCall(
|
|
2105
|
-
createRequest("upload_file", { path: fakePath }),
|
|
2106
|
-
{ apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
|
|
2107
|
-
);
|
|
2108
|
-
|
|
2109
|
-
expect(response.isError).toBeFalsy();
|
|
2110
|
-
const obj = JSON.parse(getResponseText(response));
|
|
2111
|
-
expect(obj.success).toBe(true);
|
|
2112
|
-
expect(obj.url).toBe("/files/9/abc.png");
|
|
2113
|
-
// Image extension renders inline.
|
|
2114
|
-
expect(obj.markdown).toBe("");
|
|
2115
|
-
|
|
2116
|
-
cfgSpy.mockRestore();
|
|
2117
|
-
});
|
|
2118
|
-
|
|
2119
|
-
test("upload_file requires path", async () => {
|
|
2120
|
-
const cfgSpy = configWithKey();
|
|
2121
|
-
const r = await handleToolCall(createRequest("upload_file", {}));
|
|
2122
|
-
expect(r.isError).toBe(true);
|
|
2123
|
-
expect(getResponseText(r)).toBe("path is required");
|
|
2124
|
-
cfgSpy.mockRestore();
|
|
2125
|
-
});
|
|
2126
|
-
|
|
2127
|
-
test("download_file tool requires url", async () => {
|
|
2128
|
-
const cfgSpy = configWithKey();
|
|
2129
|
-
const r = await handleToolCall(createRequest("download_file", {}));
|
|
2130
|
-
expect(r.isError).toBe(true);
|
|
2131
|
-
expect(getResponseText(r)).toBe("url is required");
|
|
2132
|
-
cfgSpy.mockRestore();
|
|
2133
|
-
});
|
|
2134
|
-
|
|
2135
|
-
test("post_issue_comment with attachments uploads and appends link", async () => {
|
|
2136
|
-
const cfgSpy = configWithKey();
|
|
2137
|
-
const fakePath = mockTinyFile("debug.png");
|
|
2138
|
-
|
|
2139
|
-
const calls: Array<{ url: string; method?: string; bodyJson?: unknown }> = [];
|
|
2140
|
-
globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
|
|
2141
|
-
const u = String(url);
|
|
2142
|
-
calls.push({
|
|
2143
|
-
url: u,
|
|
2144
|
-
method: init?.method,
|
|
2145
|
-
bodyJson: typeof init?.body === "string" ? JSON.parse(init.body as string) : undefined,
|
|
2146
|
-
});
|
|
2147
|
-
if (u.endsWith("/upload")) {
|
|
2148
|
-
return new Response(
|
|
2149
|
-
JSON.stringify({
|
|
2150
|
-
success: true,
|
|
2151
|
-
url: "/files/9/dbg.png",
|
|
2152
|
-
metadata: { originalName: "debug.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
|
|
2153
|
-
requestId: "r1",
|
|
2154
|
-
}),
|
|
2155
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2156
|
-
);
|
|
2157
|
-
}
|
|
2158
|
-
if (u.endsWith("/rpc/issue_comment_create")) {
|
|
2159
|
-
return new Response(
|
|
2160
|
-
JSON.stringify({
|
|
2161
|
-
id: "c1",
|
|
2162
|
-
issue_id: "i1",
|
|
2163
|
-
author_id: 1,
|
|
2164
|
-
parent_comment_id: null,
|
|
2165
|
-
content: "ignored",
|
|
2166
|
-
created_at: "",
|
|
2167
|
-
updated_at: "",
|
|
2168
|
-
data: null,
|
|
2169
|
-
}),
|
|
2170
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2171
|
-
);
|
|
2172
|
-
}
|
|
2173
|
-
return new Response("nope", { status: 404 });
|
|
2174
|
-
}) as unknown as typeof fetch;
|
|
2175
|
-
|
|
2176
|
-
const response = await handleToolCall(
|
|
2177
|
-
createRequest("post_issue_comment", {
|
|
2178
|
-
issue_id: "11111111-1111-1111-1111-111111111111",
|
|
2179
|
-
content: "see screenshot",
|
|
2180
|
-
attachments: [fakePath],
|
|
2181
|
-
}),
|
|
2182
|
-
{ apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
|
|
2183
|
-
);
|
|
2184
|
-
|
|
2185
|
-
expect(response.isError).toBeFalsy();
|
|
2186
|
-
|
|
2187
|
-
// Upload happened first, then comment-create with augmented content.
|
|
2188
|
-
expect(calls[0].url).toContain("/upload");
|
|
2189
|
-
const commentCall = calls.find((c) => c.url.endsWith("/rpc/issue_comment_create"));
|
|
2190
|
-
expect(commentCall).toBeTruthy();
|
|
2191
|
-
const body = commentCall!.bodyJson as { content: string };
|
|
2192
|
-
expect(body.content).toBe("see screenshot\n\n");
|
|
2193
|
-
|
|
2194
|
-
cfgSpy.mockRestore();
|
|
2195
|
-
});
|
|
2196
|
-
|
|
2197
|
-
test("post_issue_comment with only attachments (no content) is allowed", async () => {
|
|
2198
|
-
const cfgSpy = configWithKey();
|
|
2199
|
-
const fakePath = mockTinyFile("only.png");
|
|
2200
|
-
|
|
2201
|
-
const commentBodies: Array<{ content: string }> = [];
|
|
2202
|
-
globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
|
|
2203
|
-
const u = String(url);
|
|
2204
|
-
if (u.endsWith("/upload")) {
|
|
2205
|
-
return new Response(
|
|
2206
|
-
JSON.stringify({
|
|
2207
|
-
success: true,
|
|
2208
|
-
url: "/files/9/only.png",
|
|
2209
|
-
metadata: { originalName: "only.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
|
|
2210
|
-
requestId: "r",
|
|
2211
|
-
}),
|
|
2212
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2213
|
-
);
|
|
2214
|
-
}
|
|
2215
|
-
if (u.endsWith("/rpc/issue_comment_create")) {
|
|
2216
|
-
commentBodies.push(JSON.parse(String(init?.body)));
|
|
2217
|
-
return new Response(
|
|
2218
|
-
JSON.stringify({ id: "c1", issue_id: "i1", author_id: 1, parent_comment_id: null, content: "", created_at: "", updated_at: "", data: null }),
|
|
2219
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2220
|
-
);
|
|
2221
|
-
}
|
|
2222
|
-
return new Response("nope", { status: 404 });
|
|
2223
|
-
}) as unknown as typeof fetch;
|
|
2224
|
-
|
|
2225
|
-
const response = await handleToolCall(
|
|
2226
|
-
createRequest("post_issue_comment", {
|
|
2227
|
-
issue_id: "11111111-1111-1111-1111-111111111111",
|
|
2228
|
-
content: "",
|
|
2229
|
-
attachments: [fakePath],
|
|
2230
|
-
}),
|
|
2231
|
-
{ apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
|
|
2232
|
-
);
|
|
2233
|
-
|
|
2234
|
-
expect(response.isError).toBeFalsy();
|
|
2235
|
-
expect(commentBodies[0].content).toBe("");
|
|
2236
|
-
|
|
2237
|
-
cfgSpy.mockRestore();
|
|
2238
|
-
});
|
|
2239
|
-
|
|
2240
|
-
test("post_issue_comment with no content and no attachments is rejected", async () => {
|
|
2241
|
-
const cfgSpy = configWithKey();
|
|
2242
|
-
const r = await handleToolCall(
|
|
2243
|
-
createRequest("post_issue_comment", {
|
|
2244
|
-
issue_id: "11111111-1111-1111-1111-111111111111",
|
|
2245
|
-
content: "",
|
|
2246
|
-
})
|
|
2247
|
-
);
|
|
2248
|
-
expect(r.isError).toBe(true);
|
|
2249
|
-
expect(getResponseText(r)).toBe("content or attachments is required");
|
|
2250
|
-
cfgSpy.mockRestore();
|
|
2251
|
-
});
|
|
2252
|
-
|
|
2253
|
-
test("update_issue with attachments and no description fetches existing then appends", async () => {
|
|
2254
|
-
const cfgSpy = configWithKey();
|
|
2255
|
-
const fakePath = mockTinyFile("evidence.png");
|
|
2256
|
-
|
|
2257
|
-
const calls: Array<{ url: string; method?: string; body?: unknown }> = [];
|
|
2258
|
-
globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
|
|
2259
|
-
const u = String(url);
|
|
2260
|
-
calls.push({
|
|
2261
|
-
url: u,
|
|
2262
|
-
method: init?.method,
|
|
2263
|
-
body: typeof init?.body === "string" ? JSON.parse(init.body as string) : undefined,
|
|
2264
|
-
});
|
|
2265
|
-
if (u.includes("/issues?") && (init?.method ?? "GET") === "GET") {
|
|
2266
|
-
return new Response(
|
|
2267
|
-
JSON.stringify([
|
|
2268
|
-
{ id: "i1", title: "T", description: "Existing description", status: 0, created_at: "", action_items: [] },
|
|
2269
|
-
]),
|
|
2270
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2271
|
-
);
|
|
2272
|
-
}
|
|
2273
|
-
if (u.endsWith("/upload")) {
|
|
2274
|
-
return new Response(
|
|
2275
|
-
JSON.stringify({
|
|
2276
|
-
success: true,
|
|
2277
|
-
url: "/files/9/ev.png",
|
|
2278
|
-
metadata: { originalName: "evidence.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
|
|
2279
|
-
requestId: "r",
|
|
2280
|
-
}),
|
|
2281
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2282
|
-
);
|
|
2283
|
-
}
|
|
2284
|
-
if (u.endsWith("/rpc/issue_update")) {
|
|
2285
|
-
return new Response(
|
|
2286
|
-
JSON.stringify({ id: "i1", title: "T", description: "ignored", status: 0, updated_at: "" }),
|
|
2287
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2288
|
-
);
|
|
2289
|
-
}
|
|
2290
|
-
return new Response("nope", { status: 404 });
|
|
2291
|
-
}) as unknown as typeof fetch;
|
|
2292
|
-
|
|
2293
|
-
const response = await handleToolCall(
|
|
2294
|
-
createRequest("update_issue", {
|
|
2295
|
-
issue_id: "i1",
|
|
2296
|
-
attachments: [fakePath],
|
|
2297
|
-
}),
|
|
2298
|
-
{ apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
|
|
2299
|
-
);
|
|
2300
|
-
|
|
2301
|
-
expect(response.isError).toBeFalsy();
|
|
2302
|
-
const updateCall = calls.find((c) => c.url.endsWith("/rpc/issue_update"));
|
|
2303
|
-
expect(updateCall).toBeTruthy();
|
|
2304
|
-
// Order: GET issues -> POST upload -> POST update.
|
|
2305
|
-
const fetchIdx = calls.findIndex((c) => c.url.includes("/issues?"));
|
|
2306
|
-
const uploadIdx = calls.findIndex((c) => c.url.endsWith("/upload"));
|
|
2307
|
-
const updateIdx = calls.findIndex((c) => c.url.endsWith("/rpc/issue_update"));
|
|
2308
|
-
expect(fetchIdx).toBeGreaterThanOrEqual(0);
|
|
2309
|
-
expect(uploadIdx).toBeGreaterThan(fetchIdx);
|
|
2310
|
-
expect(updateIdx).toBeGreaterThan(uploadIdx);
|
|
2311
|
-
|
|
2312
|
-
const body = updateCall!.body as { p_description: string };
|
|
2313
|
-
expect(body.p_description).toBe(
|
|
2314
|
-
"Existing description\n\n"
|
|
2315
|
-
);
|
|
2316
|
-
|
|
2317
|
-
cfgSpy.mockRestore();
|
|
2318
|
-
});
|
|
2319
|
-
|
|
2320
|
-
test("update_issue with only attachments is treated as a valid update", async () => {
|
|
2321
|
-
const cfgSpy = configWithKey();
|
|
2322
|
-
const fakePath = mockTinyFile("ok.png");
|
|
2323
|
-
|
|
2324
|
-
globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
|
|
2325
|
-
const u = String(url);
|
|
2326
|
-
if (u.includes("/issues?")) {
|
|
2327
|
-
return new Response(JSON.stringify([{ id: "i1", title: "T", description: "old", status: 0 }]), {
|
|
2328
|
-
status: 200, headers: { "Content-Type": "application/json" },
|
|
2329
|
-
});
|
|
2330
|
-
}
|
|
2331
|
-
if (u.endsWith("/upload")) {
|
|
2332
|
-
return new Response(
|
|
2333
|
-
JSON.stringify({
|
|
2334
|
-
success: true, url: "/files/9/ok.png",
|
|
2335
|
-
metadata: { originalName: "ok.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
|
|
2336
|
-
requestId: "r",
|
|
2337
|
-
}),
|
|
2338
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2339
|
-
);
|
|
2340
|
-
}
|
|
2341
|
-
if (u.endsWith("/rpc/issue_update")) {
|
|
2342
|
-
return new Response(JSON.stringify({ id: "i1", title: "T", description: "ignored", status: 0, updated_at: "" }), {
|
|
2343
|
-
status: 200, headers: { "Content-Type": "application/json" },
|
|
2344
|
-
});
|
|
2345
|
-
}
|
|
2346
|
-
return new Response("nope", { status: 404 });
|
|
2347
|
-
}) as unknown as typeof fetch;
|
|
2348
|
-
|
|
2349
|
-
const response = await handleToolCall(
|
|
2350
|
-
createRequest("update_issue", { issue_id: "i1", attachments: [fakePath] }),
|
|
2351
|
-
{ apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
|
|
2352
|
-
);
|
|
2353
|
-
expect(response.isError).toBeFalsy();
|
|
2354
|
-
cfgSpy.mockRestore();
|
|
2355
|
-
});
|
|
2356
|
-
|
|
2357
|
-
test("update_issue with no fields including no attachments is rejected", async () => {
|
|
2358
|
-
const cfgSpy = configWithKey();
|
|
2359
|
-
const r = await handleToolCall(createRequest("update_issue", { issue_id: "i1" }));
|
|
2360
|
-
expect(r.isError).toBe(true);
|
|
2361
|
-
expect(getResponseText(r)).toContain("At least one field to update is required");
|
|
2362
|
-
// The error message now mentions attachments as a valid update field.
|
|
2363
|
-
expect(getResponseText(r)).toContain("attachments");
|
|
2364
|
-
cfgSpy.mockRestore();
|
|
2365
|
-
});
|
|
2366
|
-
|
|
2367
|
-
test("create_issue with attachments appends link to provided description", async () => {
|
|
2368
|
-
const cfgSpy = configWithKey();
|
|
2369
|
-
const fakePath = mockTinyFile("design.png");
|
|
2370
|
-
|
|
2371
|
-
const createBodies: Array<{ description?: string }> = [];
|
|
2372
|
-
globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
|
|
2373
|
-
const u = String(url);
|
|
2374
|
-
if (u.endsWith("/upload")) {
|
|
2375
|
-
return new Response(
|
|
2376
|
-
JSON.stringify({
|
|
2377
|
-
success: true, url: "/files/9/dz.png",
|
|
2378
|
-
metadata: { originalName: "design.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
|
|
2379
|
-
requestId: "r",
|
|
2380
|
-
}),
|
|
2381
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2382
|
-
);
|
|
2383
|
-
}
|
|
2384
|
-
if (u.endsWith("/rpc/issue_create")) {
|
|
2385
|
-
createBodies.push(JSON.parse(String(init?.body)));
|
|
2386
|
-
return new Response(
|
|
2387
|
-
JSON.stringify({ id: "i1", title: "T", description: "ignored", created_at: "", status: 0 }),
|
|
2388
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2389
|
-
);
|
|
2390
|
-
}
|
|
2391
|
-
return new Response("nope", { status: 404 });
|
|
2392
|
-
}) as unknown as typeof fetch;
|
|
2393
|
-
|
|
2394
|
-
const response = await handleToolCall(
|
|
2395
|
-
createRequest("create_issue", {
|
|
2396
|
-
title: "Dx",
|
|
2397
|
-
description: "see design",
|
|
2398
|
-
attachments: [fakePath],
|
|
2399
|
-
}),
|
|
2400
|
-
{ apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
|
|
2401
|
-
);
|
|
2402
|
-
expect(response.isError).toBeFalsy();
|
|
2403
|
-
expect(createBodies[0].description).toBe(
|
|
2404
|
-
"see design\n\n"
|
|
2405
|
-
);
|
|
2406
|
-
cfgSpy.mockRestore();
|
|
2407
|
-
});
|
|
2408
|
-
|
|
2409
|
-
test("update_issue_comment with attachments appends to content", async () => {
|
|
2410
|
-
const cfgSpy = configWithKey();
|
|
2411
|
-
const fakePath = mockTinyFile("after.png");
|
|
2412
|
-
|
|
2413
|
-
const bodies: Array<{ p_content?: string }> = [];
|
|
2414
|
-
globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
|
|
2415
|
-
const u = String(url);
|
|
2416
|
-
if (u.endsWith("/upload")) {
|
|
2417
|
-
return new Response(
|
|
2418
|
-
JSON.stringify({
|
|
2419
|
-
success: true, url: "/files/9/aft.png",
|
|
2420
|
-
metadata: { originalName: "after.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
|
|
2421
|
-
requestId: "r",
|
|
2422
|
-
}),
|
|
2423
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2424
|
-
);
|
|
2425
|
-
}
|
|
2426
|
-
if (u.endsWith("/rpc/issue_comment_update")) {
|
|
2427
|
-
bodies.push(JSON.parse(String(init?.body)));
|
|
2428
|
-
return new Response(JSON.stringify({ id: "c1", issue_id: "i1", content: "ignored", updated_at: "" }), {
|
|
2429
|
-
status: 200, headers: { "Content-Type": "application/json" },
|
|
2430
|
-
});
|
|
2431
|
-
}
|
|
2432
|
-
return new Response("nope", { status: 404 });
|
|
2433
|
-
}) as unknown as typeof fetch;
|
|
2434
|
-
|
|
2435
|
-
const response = await handleToolCall(
|
|
2436
|
-
createRequest("update_issue_comment", { comment_id: "c1", content: "now updated", attachments: [fakePath] }),
|
|
2437
|
-
{ apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
|
|
2438
|
-
);
|
|
2439
|
-
expect(response.isError).toBeFalsy();
|
|
2440
|
-
expect(bodies[0].p_content).toBe("now updated\n\n");
|
|
2441
|
-
cfgSpy.mockRestore();
|
|
2442
|
-
});
|
|
2443
|
-
|
|
2444
|
-
test("update_issue_comment with attachments-only (no content) sends just the markdown link", async () => {
|
|
2445
|
-
const cfgSpy = configWithKey();
|
|
2446
|
-
const fakePath = mockTinyFile("only.png");
|
|
2447
|
-
|
|
2448
|
-
const bodies: Array<{ p_content?: string }> = [];
|
|
2449
|
-
globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
|
|
2450
|
-
const u = String(url);
|
|
2451
|
-
if (u.endsWith("/upload")) {
|
|
2452
|
-
return new Response(
|
|
2453
|
-
JSON.stringify({
|
|
2454
|
-
success: true, url: "/files/9/only.png",
|
|
2455
|
-
metadata: { originalName: "only.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
|
|
2456
|
-
requestId: "r",
|
|
2457
|
-
}),
|
|
2458
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2459
|
-
);
|
|
2460
|
-
}
|
|
2461
|
-
if (u.endsWith("/rpc/issue_comment_update")) {
|
|
2462
|
-
bodies.push(JSON.parse(String(init?.body)));
|
|
2463
|
-
return new Response(JSON.stringify({ id: "c1", issue_id: "i1", content: "ignored", updated_at: "" }), {
|
|
2464
|
-
status: 200, headers: { "Content-Type": "application/json" },
|
|
2465
|
-
});
|
|
2466
|
-
}
|
|
2467
|
-
return new Response("nope", { status: 404 });
|
|
2468
|
-
}) as unknown as typeof fetch;
|
|
2469
|
-
|
|
2470
|
-
const response = await handleToolCall(
|
|
2471
|
-
createRequest("update_issue_comment", { comment_id: "c1", attachments: [fakePath] }),
|
|
2472
|
-
{ apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
|
|
2473
|
-
);
|
|
2474
|
-
expect(response.isError).toBeFalsy();
|
|
2475
|
-
expect(bodies).toHaveLength(1);
|
|
2476
|
-
expect(bodies[0].p_content).toBe("");
|
|
2477
|
-
cfgSpy.mockRestore();
|
|
2478
|
-
});
|
|
2479
|
-
|
|
2480
|
-
test("update_issue_comment without content and without attachments is rejected", async () => {
|
|
2481
|
-
const cfgSpy = configWithKey();
|
|
2482
|
-
const fetchSpy = mock(() => {
|
|
2483
|
-
throw new Error("should not be called");
|
|
2484
|
-
});
|
|
2485
|
-
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
2486
|
-
|
|
2487
|
-
const response = await handleToolCall(createRequest("update_issue_comment", { comment_id: "c1" }));
|
|
2488
|
-
expect(response.isError).toBe(true);
|
|
2489
|
-
expect(getResponseText(response)).toBe("content or attachments is required");
|
|
2490
|
-
expect(fetchSpy).not.toHaveBeenCalled();
|
|
2491
|
-
cfgSpy.mockRestore();
|
|
2492
|
-
});
|
|
2493
|
-
|
|
2494
|
-
test("create_issue with attachments and no description sets description to just the link", async () => {
|
|
2495
|
-
const cfgSpy = configWithKey();
|
|
2496
|
-
const fakePath = mockTinyFile("plan.png");
|
|
2497
|
-
|
|
2498
|
-
const createBodies: Array<{ description?: string }> = [];
|
|
2499
|
-
globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => {
|
|
2500
|
-
const u = String(url);
|
|
2501
|
-
if (u.endsWith("/upload")) {
|
|
2502
|
-
return new Response(
|
|
2503
|
-
JSON.stringify({
|
|
2504
|
-
success: true, url: "/files/9/plan.png",
|
|
2505
|
-
metadata: { originalName: "plan.png", size: 4, mimeType: "image/png", uploadedAt: "", duration: 0 },
|
|
2506
|
-
requestId: "r",
|
|
2507
|
-
}),
|
|
2508
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2509
|
-
);
|
|
2510
|
-
}
|
|
2511
|
-
if (u.endsWith("/rpc/issue_create")) {
|
|
2512
|
-
createBodies.push(JSON.parse(String(init?.body)));
|
|
2513
|
-
return new Response(
|
|
2514
|
-
JSON.stringify({ id: "i1", title: "T", description: "ignored", created_at: "", status: 0 }),
|
|
2515
|
-
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2516
|
-
);
|
|
2517
|
-
}
|
|
2518
|
-
return new Response("nope", { status: 404 });
|
|
2519
|
-
}) as unknown as typeof fetch;
|
|
2520
|
-
|
|
2521
|
-
const response = await handleToolCall(
|
|
2522
|
-
createRequest("create_issue", { title: "Dx", attachments: [fakePath] }),
|
|
2523
|
-
{ apiBaseUrl: "https://api.example.com", storageBaseUrl: "https://storage.example.com" }
|
|
2524
|
-
);
|
|
2525
|
-
expect(response.isError).toBeFalsy();
|
|
2526
|
-
expect(createBodies[0].description).toBe("");
|
|
2527
|
-
cfgSpy.mockRestore();
|
|
2528
|
-
});
|
|
2529
|
-
});
|
|
2530
|
-
});
|