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.
Files changed (85) hide show
  1. package/README.md +154 -0
  2. package/dist/bin/postgres-ai.js +2911 -255
  3. package/package.json +12 -3
  4. package/schemas/A002.schema.json +63 -0
  5. package/schemas/A003.schema.json +73 -0
  6. package/schemas/A004.schema.json +81 -0
  7. package/schemas/A007.schema.json +71 -0
  8. package/schemas/A013.schema.json +61 -0
  9. package/schemas/D001.schema.json +71 -0
  10. package/schemas/D004.schema.json +136 -0
  11. package/schemas/F001.schema.json +73 -0
  12. package/schemas/F002.schema.json +108 -0
  13. package/schemas/F003.schema.json +138 -0
  14. package/schemas/F004.schema.json +125 -0
  15. package/schemas/F005.schema.json +131 -0
  16. package/schemas/F009.schema.json +155 -0
  17. package/schemas/G001.schema.json +135 -0
  18. package/schemas/G003.schema.json +90 -0
  19. package/schemas/H001.schema.json +141 -0
  20. package/schemas/H002.schema.json +129 -0
  21. package/schemas/H004.schema.json +128 -0
  22. package/schemas/I001.schema.json +149 -0
  23. package/schemas/K001.schema.json +161 -0
  24. package/schemas/K003.schema.json +163 -0
  25. package/schemas/K004.schema.json +110 -0
  26. package/schemas/K005.schema.json +110 -0
  27. package/schemas/K006.schema.json +110 -0
  28. package/schemas/K007.schema.json +110 -0
  29. package/schemas/K008.schema.json +110 -0
  30. package/schemas/M001.schema.json +119 -0
  31. package/schemas/M002.schema.json +110 -0
  32. package/schemas/M003.schema.json +128 -0
  33. package/schemas/N001.schema.json +161 -0
  34. package/schemas/query.schema.json +62 -0
  35. package/CHANGELOG.md +0 -11
  36. package/bin/postgres-ai.ts +0 -5578
  37. package/bun.lock +0 -258
  38. package/bunfig.toml +0 -20
  39. package/lib/aas-onboard.ts +0 -251
  40. package/lib/auth-server.ts +0 -285
  41. package/lib/checkup-api.ts +0 -526
  42. package/lib/checkup-dictionary.ts +0 -103
  43. package/lib/checkup-summary.ts +0 -338
  44. package/lib/checkup.ts +0 -2261
  45. package/lib/config.ts +0 -171
  46. package/lib/init.ts +0 -1152
  47. package/lib/instances.ts +0 -245
  48. package/lib/issues.ts +0 -1060
  49. package/lib/mcp-server.ts +0 -667
  50. package/lib/metrics-loader.ts +0 -134
  51. package/lib/pkce.ts +0 -79
  52. package/lib/reports.ts +0 -373
  53. package/lib/storage.ts +0 -367
  54. package/lib/supabase.ts +0 -826
  55. package/lib/util.ts +0 -134
  56. package/packages/postgres-ai/README.md +0 -26
  57. package/packages/postgres-ai/bin/postgres-ai.js +0 -27
  58. package/packages/postgres-ai/package.json +0 -27
  59. package/scripts/embed-checkup-dictionary.ts +0 -115
  60. package/scripts/embed-metrics.ts +0 -160
  61. package/scripts/generate-release-notes.ts +0 -668
  62. package/test/PERMISSION_CHECK_TEST_SUMMARY.md +0 -139
  63. package/test/aas-onboard.test.ts +0 -301
  64. package/test/auth.test.ts +0 -287
  65. package/test/checkup.integration.test.ts +0 -413
  66. package/test/checkup.test.ts +0 -3626
  67. package/test/compose-cmd.test.ts +0 -120
  68. package/test/config-consistency.test.ts +0 -352
  69. package/test/init.integration.test.ts +0 -438
  70. package/test/init.test.ts +0 -1816
  71. package/test/issues.cli.test.ts +0 -1162
  72. package/test/issues.test.ts +0 -456
  73. package/test/mcp-server.test.ts +0 -2530
  74. package/test/monitoring.test.ts +0 -746
  75. package/test/permission-check-sql.test.ts +0 -116
  76. package/test/reports.cli.test.ts +0 -793
  77. package/test/reports.test.ts +0 -977
  78. package/test/schema-validation.test.ts +0 -231
  79. package/test/storage.test.ts +0 -935
  80. package/test/supabase.test.ts +0 -709
  81. package/test/targets-add-config.test.ts +0 -28
  82. package/test/test-utils.ts +0 -190
  83. package/test/upgrade.test.ts +0 -1056
  84. package/test/util.test.ts +0 -44
  85. package/tsconfig.json +0 -20
@@ -1,1162 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import { resolve } from "path";
3
- import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "fs";
4
- import { tmpdir } from "os";
5
-
6
- function runCli(args: string[], env: Record<string, string> = {}) {
7
- const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
8
- const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
9
- const result = Bun.spawnSync([bunBin, cliPath, ...args], {
10
- env: { ...process.env, ...env },
11
- });
12
- return {
13
- status: result.exitCode,
14
- stdout: new TextDecoder().decode(result.stdout),
15
- stderr: new TextDecoder().decode(result.stderr),
16
- };
17
- }
18
-
19
- async function runCliAsync(args: string[], env: Record<string, string> = {}) {
20
- const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
21
- const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
22
- const proc = Bun.spawn([bunBin, cliPath, ...args], {
23
- env: { ...process.env, ...env },
24
- stdout: "pipe",
25
- stderr: "pipe",
26
- });
27
- const [status, stdout, stderr] = await Promise.all([
28
- proc.exited,
29
- new Response(proc.stdout).text(),
30
- new Response(proc.stderr).text(),
31
- ]);
32
- return { status, stdout, stderr };
33
- }
34
-
35
- function isolatedEnv(extra: Record<string, string> = {}) {
36
- // Ensure tests do not depend on any real user config on the machine running them.
37
- const cfgHome = mkdtempSync(resolve(tmpdir(), "postgresai-cli-test-"));
38
- return {
39
- XDG_CONFIG_HOME: cfgHome,
40
- HOME: cfgHome,
41
- ...extra,
42
- };
43
- }
44
-
45
- async function startFakeApi() {
46
- const requests: Array<{
47
- method: string;
48
- pathname: string;
49
- headers: Record<string, string>;
50
- bodyText: string;
51
- bodyJson: any | null;
52
- contentType?: string;
53
- }> = [];
54
-
55
- // For storage tests: tracks file uploads served at /storage/upload.
56
- const uploads: Array<{ filename: string; size: number; mimeType: string; url: string }> = [];
57
-
58
- const server = Bun.serve({
59
- hostname: "127.0.0.1",
60
- port: 0,
61
- async fetch(req) {
62
- const url = new URL(req.url);
63
- const headers: Record<string, string> = {};
64
- for (const [k, v] of req.headers.entries()) headers[k.toLowerCase()] = v;
65
-
66
- // /storage/upload is multipart/form-data, not JSON. Branch on content type.
67
- const contentType = headers["content-type"] || "";
68
- const isMultipart = contentType.startsWith("multipart/form-data");
69
-
70
- // Storage upload endpoint — return a deterministic /files/N/<idx>_<name> URL.
71
- if (req.method === "POST" && url.pathname === "/storage/upload" && isMultipart) {
72
- const form = await req.formData();
73
- const file = form.get("file") as File | null;
74
- if (!file) return new Response("missing file", { status: 400 });
75
- const buf = new Uint8Array(await file.arrayBuffer());
76
- const idx = uploads.length;
77
- const fileUrl = `/files/test/${idx}_${file.name}`;
78
- uploads.push({ filename: file.name, size: buf.length, mimeType: file.type, url: fileUrl });
79
- requests.push({ method: req.method, pathname: url.pathname, headers, bodyText: "", bodyJson: null, contentType });
80
- return new Response(
81
- JSON.stringify({
82
- success: true,
83
- url: fileUrl,
84
- metadata: {
85
- originalName: file.name,
86
- size: buf.length,
87
- mimeType: file.type,
88
- uploadedAt: "2025-01-01T00:00:00.000Z",
89
- duration: 1,
90
- },
91
- requestId: `req-upload-${idx}`,
92
- }),
93
- { status: 200, headers: { "Content-Type": "application/json" } }
94
- );
95
- }
96
-
97
- // Storage download endpoint — return whatever bytes were uploaded.
98
- if (req.method === "GET" && url.pathname.startsWith("/storage/files/")) {
99
- // Strip "/storage" so the lookup matches what we stored.
100
- const fileUrl = url.pathname.replace(/^\/storage/, "");
101
- const found = uploads.find((u) => u.url === fileUrl);
102
- if (!found) return new Response("not found", { status: 404 });
103
- // Re-upload-ish path: we don't keep bytes, but for test purposes return a known body.
104
- return new Response("FAKE_DOWNLOADED_BYTES", {
105
- status: 200,
106
- headers: { "Content-Type": found.mimeType || "application/octet-stream" },
107
- });
108
- }
109
-
110
- const bodyText = await req.text();
111
- let bodyJson: any | null = null;
112
- try {
113
- bodyJson = bodyText ? JSON.parse(bodyText) : null;
114
- } catch {
115
- bodyJson = null;
116
- }
117
-
118
- requests.push({
119
- method: req.method,
120
- pathname: url.pathname,
121
- headers,
122
- bodyText,
123
- bodyJson,
124
- contentType,
125
- });
126
-
127
- // Minimal fake PostgREST RPC endpoints used by our CLI.
128
- if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_create")) {
129
- return new Response(
130
- JSON.stringify({
131
- id: "issue-1",
132
- title: bodyJson?.title ?? "",
133
- description: bodyJson?.description ?? null,
134
- created_at: "2025-01-01T00:00:00Z",
135
- status: 0,
136
- project_id: bodyJson?.project_id ?? null,
137
- labels: bodyJson?.labels ?? null,
138
- }),
139
- { status: 200, headers: { "Content-Type": "application/json" } }
140
- );
141
- }
142
-
143
- if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_update")) {
144
- return new Response(
145
- JSON.stringify({
146
- id: bodyJson?.p_id ?? "issue-1",
147
- title: bodyJson?.p_title ?? "unchanged",
148
- description: bodyJson?.p_description ?? null,
149
- status: bodyJson?.p_status ?? 0,
150
- updated_at: "2025-01-02T00:00:00Z",
151
- labels: bodyJson?.p_labels ?? null,
152
- }),
153
- { status: 200, headers: { "Content-Type": "application/json" } }
154
- );
155
- }
156
-
157
- if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_comment_update")) {
158
- return new Response(
159
- JSON.stringify({
160
- id: bodyJson?.p_id ?? "comment-1",
161
- issue_id: "issue-1",
162
- content: bodyJson?.p_content ?? "",
163
- updated_at: "2025-01-02T00:00:00Z",
164
- }),
165
- { status: 200, headers: { "Content-Type": "application/json" } }
166
- );
167
- }
168
-
169
- if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_comment_create")) {
170
- return new Response(
171
- JSON.stringify({
172
- id: "comment-1",
173
- issue_id: bodyJson?.issue_id ?? "issue-1",
174
- author_id: 1,
175
- parent_comment_id: bodyJson?.parent_comment_id ?? null,
176
- content: bodyJson?.content ?? "",
177
- created_at: "2025-01-01T00:00:00Z",
178
- updated_at: "2025-01-01T00:00:00Z",
179
- data: null,
180
- }),
181
- { status: 200, headers: { "Content-Type": "application/json" } }
182
- );
183
- }
184
-
185
- // GET /issues — used by `issues update --attach` to fetch existing description.
186
- if (req.method === "GET" && url.pathname.endsWith("/issues")) {
187
- const idParam = url.searchParams.get("id") || "";
188
- const issueId = idParam.replace("eq.", "");
189
- return new Response(
190
- JSON.stringify([
191
- {
192
- id: issueId,
193
- title: "Existing title",
194
- description: "Existing description body",
195
- status: 0,
196
- created_at: "2025-01-01T00:00:00Z",
197
- author_display_name: "tester",
198
- action_items: [],
199
- },
200
- ]),
201
- { status: 200, headers: { "Content-Type": "application/json" } }
202
- );
203
- }
204
-
205
- // Action Items endpoints
206
- if (req.method === "GET" && url.pathname.endsWith("/issue_action_items")) {
207
- const issueIdParam = url.searchParams.get("issue_id");
208
- const idParam = url.searchParams.get("id");
209
- if (issueIdParam) {
210
- // list_action_items
211
- return new Response(
212
- JSON.stringify([
213
- { id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", issue_id: issueIdParam.replace("eq.", ""), title: "Action 1", is_done: false, status: "waiting_for_approval" },
214
- { id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", issue_id: issueIdParam.replace("eq.", ""), title: "Action 2", is_done: true, status: "approved" },
215
- ]),
216
- { status: 200, headers: { "Content-Type": "application/json" } }
217
- );
218
- }
219
- if (idParam) {
220
- // view_action_item
221
- const actionId = idParam.replace("eq.", "").replace("in.(", "").replace(")", "").split(",")[0];
222
- return new Response(
223
- JSON.stringify([
224
- { id: actionId, issue_id: "11111111-1111-1111-1111-111111111111", title: "Test Action", description: "Test description", is_done: false, status: "waiting_for_approval", sql_action: "SELECT 1;", configs: [] },
225
- ]),
226
- { status: 200, headers: { "Content-Type": "application/json" } }
227
- );
228
- }
229
- }
230
-
231
- if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_action_item_create")) {
232
- return new Response(
233
- JSON.stringify("cccccccc-cccc-cccc-cccc-cccccccccccc"),
234
- { status: 200, headers: { "Content-Type": "application/json" } }
235
- );
236
- }
237
-
238
- if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_action_item_update")) {
239
- return new Response("", { status: 200, headers: { "Content-Type": "application/json" } });
240
- }
241
-
242
- return new Response("not found", { status: 404 });
243
- },
244
- });
245
-
246
- const baseUrl = `http://${server.hostname}:${server.port}/api/general`;
247
- const storageBaseUrl = `http://${server.hostname}:${server.port}/storage`;
248
-
249
- return {
250
- baseUrl,
251
- storageBaseUrl,
252
- requests,
253
- uploads,
254
- stop: () => server.stop(true),
255
- };
256
- }
257
-
258
- describe("CLI issues command group", () => {
259
- test("issues help exposes the canonical subcommands and no legacy names", () => {
260
- const r = runCli(["issues", "--help"], isolatedEnv());
261
- expect(r.status).toBe(0);
262
-
263
- const out = `${r.stdout}\n${r.stderr}`;
264
-
265
- // Canonical subcommands
266
- expect(out).toContain("create [options] <title>");
267
- expect(out).toContain("update [options] <issueId>");
268
- expect(out).toContain("update-comment [options] <commentId> <content>");
269
- expect(out).toContain("post-comment [options] <issueId> <content>");
270
-
271
- // Legacy / removed names
272
- expect(out).not.toContain("create-issue");
273
- expect(out).not.toContain("update-issue");
274
- expect(out).not.toContain("update-issue-comment");
275
- expect(out).not.toContain("post_comment");
276
- expect(out).not.toContain("create_issue");
277
- expect(out).not.toContain("update_issue");
278
- expect(out).not.toContain("update_issue_comment");
279
- });
280
-
281
- test("issues create fails fast when API key is missing", () => {
282
- const r = runCli(["issues", "create", "Test issue"], isolatedEnv());
283
- expect(r.status).toBe(1);
284
- expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
285
- });
286
-
287
- test("issues create fails fast when org id is missing (no config fallback)", () => {
288
- const r = runCli(["issues", "create", "Test issue"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
289
- expect(r.status).toBe(1);
290
- expect(`${r.stdout}\n${r.stderr}`).toContain("org_id is required");
291
- });
292
-
293
- test("issues update fails fast when API key is missing", () => {
294
- const r = runCli(["issues", "update", "00000000-0000-0000-0000-000000000000", "--title", "New title"], isolatedEnv());
295
- expect(r.status).toBe(1);
296
- expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
297
- });
298
-
299
- test("issues update-comment fails fast when API key is missing", () => {
300
- const r = runCli(["issues", "update-comment", "00000000-0000-0000-0000-000000000000", "hello"], isolatedEnv());
301
- expect(r.status).toBe(1);
302
- expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
303
- });
304
-
305
- test("issues post-comment fails fast when API key is missing", () => {
306
- const r = runCli(["issues", "post-comment", "00000000-0000-0000-0000-000000000000", "hello"], isolatedEnv());
307
- expect(r.status).toBe(1);
308
- expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
309
- });
310
-
311
- test("issues create succeeds against a fake API and sends the expected request", async () => {
312
- const api = await startFakeApi();
313
- try {
314
- const r = await runCliAsync(
315
- ["issues", "create", "Hello", "--org-id", "123", "--description", "line1\\nline2", "--label", "a", "--label", "b"],
316
- isolatedEnv({
317
- PGAI_API_KEY: "test-key",
318
- PGAI_API_BASE_URL: api.baseUrl,
319
- })
320
- );
321
- expect(r.status).toBe(0);
322
-
323
- const out = JSON.parse(r.stdout.trim());
324
- expect(out.id).toBe("issue-1");
325
- expect(out.title).toBe("Hello");
326
- expect(out.description).toBe("line1\nline2");
327
- expect(out.labels).toEqual(["a", "b"]);
328
-
329
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_create"));
330
- expect(req).toBeTruthy();
331
- expect(req!.headers["access-token"]).toBe("test-key");
332
- expect(req!.method).toBe("POST");
333
- expect(req!.bodyJson.org_id).toBe(123);
334
- expect(req!.bodyJson.title).toBe("Hello");
335
- expect(req!.bodyJson.description).toBe("line1\nline2");
336
- expect(req!.bodyJson.labels).toEqual(["a", "b"]);
337
- } finally {
338
- api.stop();
339
- }
340
- });
341
-
342
- test("issues update succeeds against a fake API (including status mapping)", async () => {
343
- const api = await startFakeApi();
344
- try {
345
- const r = await runCliAsync(
346
- ["issues", "update", "issue-1", "--title", "New title", "--status", "closed"],
347
- isolatedEnv({
348
- PGAI_API_KEY: "test-key",
349
- PGAI_API_BASE_URL: api.baseUrl,
350
- })
351
- );
352
- expect(r.status).toBe(0);
353
-
354
- const out = JSON.parse(r.stdout.trim());
355
- expect(out.id).toBe("issue-1");
356
- expect(out.title).toBe("New title");
357
- expect(out.status).toBe(1);
358
-
359
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_update"));
360
- expect(req).toBeTruthy();
361
- expect(req!.headers["access-token"]).toBe("test-key");
362
- expect(req!.bodyJson.p_id).toBe("issue-1");
363
- expect(req!.bodyJson.p_title).toBe("New title");
364
- expect(req!.bodyJson.p_status).toBe(1);
365
- } finally {
366
- api.stop();
367
- }
368
- });
369
-
370
- test("issues update-comment succeeds against a fake API and decodes escapes", async () => {
371
- const api = await startFakeApi();
372
- try {
373
- const r = await runCliAsync(
374
- ["issues", "update-comment", "comment-1", "hello\\nworld"],
375
- isolatedEnv({
376
- PGAI_API_KEY: "test-key",
377
- PGAI_API_BASE_URL: api.baseUrl,
378
- })
379
- );
380
- expect(r.status).toBe(0);
381
-
382
- const out = JSON.parse(r.stdout.trim());
383
- expect(out.id).toBe("comment-1");
384
- expect(out.content).toBe("hello\nworld");
385
-
386
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_update"));
387
- expect(req).toBeTruthy();
388
- expect(req!.headers["access-token"]).toBe("test-key");
389
- expect(req!.bodyJson.p_id).toBe("comment-1");
390
- expect(req!.bodyJson.p_content).toBe("hello\nworld");
391
- } finally {
392
- api.stop();
393
- }
394
- });
395
-
396
- test("issues post-comment succeeds against a fake API and decodes escapes", async () => {
397
- const api = await startFakeApi();
398
- try {
399
- const r = await runCliAsync(
400
- ["issues", "post-comment", "issue-1", "hello\\nworld"],
401
- isolatedEnv({
402
- PGAI_API_KEY: "test-key",
403
- PGAI_API_BASE_URL: api.baseUrl,
404
- })
405
- );
406
- expect(r.status).toBe(0);
407
-
408
- const out = JSON.parse(r.stdout.trim());
409
- expect(out.id).toBe("comment-1");
410
- expect(out.issue_id).toBe("issue-1");
411
- expect(out.content).toBe("hello\nworld");
412
-
413
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
414
- expect(req).toBeTruthy();
415
- expect(req!.headers["access-token"]).toBe("test-key");
416
- expect(req!.bodyJson.issue_id).toBe("issue-1");
417
- expect(req!.bodyJson.content).toBe("hello\nworld");
418
- } finally {
419
- api.stop();
420
- }
421
- });
422
- });
423
-
424
- describe("CLI action items commands", () => {
425
- test("issues action-items fails fast when API key is missing", () => {
426
- const r = runCli(["issues", "action-items", "00000000-0000-0000-0000-000000000000"], isolatedEnv());
427
- expect(r.status).toBe(1);
428
- expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
429
- });
430
-
431
- test("issues action-items fails when issue_id is not a valid UUID", () => {
432
- const r = runCli(["issues", "action-items", "invalid-id"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
433
- expect(r.status).toBe(1);
434
- expect(`${r.stdout}\n${r.stderr}`).toContain("issueId must be a valid UUID");
435
- });
436
-
437
- test("issues action-items succeeds against a fake API", async () => {
438
- const api = await startFakeApi();
439
- try {
440
- const r = await runCliAsync(
441
- ["issues", "action-items", "11111111-1111-1111-1111-111111111111"],
442
- isolatedEnv({
443
- PGAI_API_KEY: "test-key",
444
- PGAI_API_BASE_URL: api.baseUrl,
445
- })
446
- );
447
- expect(r.status).toBe(0);
448
-
449
- const out = JSON.parse(r.stdout.trim());
450
- expect(Array.isArray(out)).toBe(true);
451
- expect(out.length).toBe(2);
452
- expect(out[0].title).toBe("Action 1");
453
- } finally {
454
- api.stop();
455
- }
456
- });
457
-
458
- test("issues view-action-item fails fast when API key is missing", () => {
459
- const r = runCli(["issues", "view-action-item", "00000000-0000-0000-0000-000000000000"], isolatedEnv());
460
- expect(r.status).toBe(1);
461
- expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
462
- });
463
-
464
- test("issues view-action-item fails when action_item_id is not a valid UUID", () => {
465
- const r = runCli(["issues", "view-action-item", "invalid-id"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
466
- expect(r.status).toBe(1);
467
- expect(`${r.stdout}\n${r.stderr}`).toContain("actionItemId is required and must be a valid UUID");
468
- });
469
-
470
- test("issues view-action-item succeeds against a fake API", async () => {
471
- const api = await startFakeApi();
472
- try {
473
- const r = await runCliAsync(
474
- ["issues", "view-action-item", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"],
475
- isolatedEnv({
476
- PGAI_API_KEY: "test-key",
477
- PGAI_API_BASE_URL: api.baseUrl,
478
- })
479
- );
480
- expect(r.status).toBe(0);
481
-
482
- const out = JSON.parse(r.stdout.trim());
483
- expect(out[0].title).toBe("Test Action");
484
- expect(out[0].sql_action).toBe("SELECT 1;");
485
- } finally {
486
- api.stop();
487
- }
488
- });
489
-
490
- test("issues create-action-item fails fast when API key is missing", () => {
491
- const r = runCli(["issues", "create-action-item", "00000000-0000-0000-0000-000000000000", "Test title"], isolatedEnv());
492
- expect(r.status).toBe(1);
493
- expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
494
- });
495
-
496
- test("issues create-action-item fails when issue_id is not a valid UUID", () => {
497
- const r = runCli(["issues", "create-action-item", "invalid-id", "Test title"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
498
- expect(r.status).toBe(1);
499
- expect(`${r.stdout}\n${r.stderr}`).toContain("issueId must be a valid UUID");
500
- });
501
-
502
- test("issues create-action-item succeeds against a fake API", async () => {
503
- const api = await startFakeApi();
504
- try {
505
- const r = await runCliAsync(
506
- ["issues", "create-action-item", "11111111-1111-1111-1111-111111111111", "New action item", "--description", "Test description"],
507
- isolatedEnv({
508
- PGAI_API_KEY: "test-key",
509
- PGAI_API_BASE_URL: api.baseUrl,
510
- })
511
- );
512
- expect(r.status).toBe(0);
513
-
514
- const out = JSON.parse(r.stdout.trim());
515
- expect(out.id).toBe("cccccccc-cccc-cccc-cccc-cccccccccccc");
516
-
517
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_action_item_create"));
518
- expect(req).toBeTruthy();
519
- expect(req!.headers["access-token"]).toBe("test-key");
520
- expect(req!.bodyJson.issue_id).toBe("11111111-1111-1111-1111-111111111111");
521
- expect(req!.bodyJson.title).toBe("New action item");
522
- expect(req!.bodyJson.description).toBe("Test description");
523
- } finally {
524
- api.stop();
525
- }
526
- });
527
-
528
- test("issues create-action-item interprets escape sequences", async () => {
529
- const api = await startFakeApi();
530
- try {
531
- const r = await runCliAsync(
532
- ["issues", "create-action-item", "11111111-1111-1111-1111-111111111111", "Title\\nwith newline", "--description", "Desc\\twith tab"],
533
- isolatedEnv({
534
- PGAI_API_KEY: "test-key",
535
- PGAI_API_BASE_URL: api.baseUrl,
536
- })
537
- );
538
- expect(r.status).toBe(0);
539
-
540
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_action_item_create"));
541
- expect(req).toBeTruthy();
542
- expect(req!.bodyJson.title).toBe("Title\nwith newline");
543
- expect(req!.bodyJson.description).toBe("Desc\twith tab");
544
- } finally {
545
- api.stop();
546
- }
547
- });
548
-
549
- test("issues update-action-item fails fast when API key is missing", () => {
550
- const r = runCli(["issues", "update-action-item", "00000000-0000-0000-0000-000000000000", "--done"], isolatedEnv());
551
- expect(r.status).toBe(1);
552
- expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
553
- });
554
-
555
- test("issues update-action-item fails when action_item_id is not a valid UUID", () => {
556
- const r = runCli(["issues", "update-action-item", "invalid-id", "--done"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
557
- expect(r.status).toBe(1);
558
- expect(`${r.stdout}\n${r.stderr}`).toContain("actionItemId must be a valid UUID");
559
- });
560
-
561
- test("issues update-action-item fails when no update fields provided", () => {
562
- const r = runCli(["issues", "update-action-item", "00000000-0000-0000-0000-000000000000"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
563
- expect(r.status).toBe(1);
564
- expect(`${r.stdout}\n${r.stderr}`).toContain("At least one update option is required");
565
- });
566
-
567
- test("issues update-action-item succeeds with --done flag", async () => {
568
- const api = await startFakeApi();
569
- try {
570
- const r = await runCliAsync(
571
- ["issues", "update-action-item", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "--done"],
572
- isolatedEnv({
573
- PGAI_API_KEY: "test-key",
574
- PGAI_API_BASE_URL: api.baseUrl,
575
- })
576
- );
577
- expect(r.status).toBe(0);
578
-
579
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_action_item_update"));
580
- expect(req).toBeTruthy();
581
- expect(req!.headers["access-token"]).toBe("test-key");
582
- expect(req!.bodyJson.action_item_id).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
583
- expect(req!.bodyJson.is_done).toBe(true);
584
- } finally {
585
- api.stop();
586
- }
587
- });
588
-
589
- test("issues update-action-item succeeds with --status flag", async () => {
590
- const api = await startFakeApi();
591
- try {
592
- const r = await runCliAsync(
593
- ["issues", "update-action-item", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "--status", "approved", "--status-reason", "LGTM"],
594
- isolatedEnv({
595
- PGAI_API_KEY: "test-key",
596
- PGAI_API_BASE_URL: api.baseUrl,
597
- })
598
- );
599
- expect(r.status).toBe(0);
600
-
601
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_action_item_update"));
602
- expect(req).toBeTruthy();
603
- expect(req!.bodyJson.status).toBe("approved");
604
- expect(req!.bodyJson.status_reason).toBe("LGTM");
605
- } finally {
606
- api.stop();
607
- }
608
- });
609
- });
610
-
611
- async function startFakeStorageServer() {
612
- const requests: Array<{
613
- method: string;
614
- pathname: string;
615
- headers: Record<string, string>;
616
- }> = [];
617
-
618
- const server = Bun.serve({
619
- hostname: "127.0.0.1",
620
- port: 0,
621
- async fetch(req) {
622
- const url = new URL(req.url);
623
- const headers: Record<string, string> = {};
624
- for (const [k, v] of req.headers.entries()) headers[k.toLowerCase()] = v;
625
-
626
- // Consume body to avoid warnings
627
- await req.arrayBuffer().catch(() => {});
628
-
629
- requests.push({
630
- method: req.method,
631
- pathname: url.pathname,
632
- headers,
633
- });
634
-
635
- // Upload endpoint
636
- if (req.method === "POST" && url.pathname === "/upload") {
637
- if (!headers["access-token"]) {
638
- return new Response(
639
- JSON.stringify({ code: "INVALID_API_TOKEN", message: "Missing token" }),
640
- { status: 401, headers: { "Content-Type": "application/json" } }
641
- );
642
- }
643
-
644
- return new Response(
645
- JSON.stringify({
646
- success: true,
647
- url: "/files/123/1707500000000_test-uuid.png",
648
- metadata: {
649
- originalName: "test-file.png",
650
- size: 16,
651
- mimeType: "image/png",
652
- uploadedAt: "2025-02-09T12:00:00.000Z",
653
- duration: 50,
654
- },
655
- requestId: "req-test-123",
656
- }),
657
- { status: 200, headers: { "Content-Type": "application/json" } }
658
- );
659
- }
660
-
661
- // Download endpoint
662
- if (req.method === "GET" && url.pathname.startsWith("/files/")) {
663
- if (!headers["access-token"]) {
664
- return new Response(
665
- JSON.stringify({ code: "INVALID_API_TOKEN", message: "Missing token" }),
666
- { status: 401, headers: { "Content-Type": "application/json" } }
667
- );
668
- }
669
-
670
- return new Response(Buffer.from("fake-file-content"), {
671
- status: 200,
672
- headers: { "Content-Type": "image/png" },
673
- });
674
- }
675
-
676
- return new Response("not found", { status: 404 });
677
- },
678
- });
679
-
680
- const storageBaseUrl = `http://${server.hostname}:${server.port}`;
681
-
682
- return {
683
- storageBaseUrl,
684
- requests,
685
- stop: () => server.stop(true),
686
- };
687
- }
688
-
689
- describe("CLI issues files commands", () => {
690
- test("issues files upload fails fast when API key is missing", () => {
691
- const r = runCli(["issues", "files", "upload", "/tmp/test.png"], isolatedEnv());
692
- expect(r.status).toBe(1);
693
- expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
694
- });
695
-
696
- test("issues files upload fails when file does not exist", () => {
697
- const r = runCli(
698
- ["issues", "files", "upload", "/tmp/nonexistent-file-99999.png"],
699
- isolatedEnv({ PGAI_API_KEY: "test-key" })
700
- );
701
- expect(r.status).toBe(1);
702
- expect(`${r.stdout}\n${r.stderr}`).toContain("File not found");
703
- });
704
-
705
- test("issues files upload succeeds and shows URL and markdown", async () => {
706
- const storage = await startFakeStorageServer();
707
- const tmpFile = resolve(mkdtempSync(resolve(tmpdir(), "upload-test-")), "screenshot.png");
708
- writeFileSync(tmpFile, "fake-png-content");
709
-
710
- try {
711
- const r = await runCliAsync(
712
- ["issues", "files", "upload", tmpFile],
713
- isolatedEnv({
714
- PGAI_API_KEY: "test-key",
715
- PGAI_STORAGE_BASE_URL: storage.storageBaseUrl,
716
- })
717
- );
718
-
719
- expect(r.status).toBe(0);
720
- const out = `${r.stdout}\n${r.stderr}`;
721
- expect(out).toContain("URL:");
722
- expect(out).toContain("/files/123/");
723
- expect(out).toContain("Markdown:");
724
- expect(out).toContain("![");
725
-
726
- const uploadReq = storage.requests.find((x) => x.pathname === "/upload");
727
- expect(uploadReq).toBeTruthy();
728
- expect(uploadReq!.headers["access-token"]).toBe("test-key");
729
- expect(uploadReq!.method).toBe("POST");
730
- } finally {
731
- storage.stop();
732
- }
733
- });
734
-
735
- test("issues files upload --json returns structured JSON", async () => {
736
- const storage = await startFakeStorageServer();
737
- const tmpFile = resolve(mkdtempSync(resolve(tmpdir(), "upload-json-test-")), "data.txt");
738
- writeFileSync(tmpFile, "test-content");
739
-
740
- try {
741
- const r = await runCliAsync(
742
- ["issues", "files", "upload", tmpFile, "--json"],
743
- isolatedEnv({
744
- PGAI_API_KEY: "test-key",
745
- PGAI_STORAGE_BASE_URL: storage.storageBaseUrl,
746
- })
747
- );
748
-
749
- expect(r.status).toBe(0);
750
- const out = JSON.parse(r.stdout.trim());
751
- expect(out.success).toBe(true);
752
- expect(out.url).toContain("/files/");
753
- expect(out.metadata.originalName).toBe("test-file.png");
754
- expect(out.metadata.mimeType).toBe("image/png");
755
- } finally {
756
- storage.stop();
757
- }
758
- });
759
-
760
- test("issues files download fails fast when API key is missing", () => {
761
- const r = runCli(["issues", "files", "download", "/files/123/test.png"], isolatedEnv());
762
- expect(r.status).toBe(1);
763
- expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
764
- });
765
-
766
- test("issues files download succeeds and saves file", async () => {
767
- const storage = await startFakeStorageServer();
768
- const tmpOutDir = mkdtempSync(resolve(tmpdir(), "download-test-"));
769
- const outputPath = resolve(tmpOutDir, "downloaded.png");
770
-
771
- try {
772
- const r = await runCliAsync(
773
- ["issues", "files", "download", "/files/123/image.png", "-o", outputPath],
774
- isolatedEnv({
775
- PGAI_API_KEY: "test-key",
776
- PGAI_STORAGE_BASE_URL: storage.storageBaseUrl,
777
- })
778
- );
779
-
780
- expect(r.status).toBe(0);
781
- const out = `${r.stdout}\n${r.stderr}`;
782
- expect(out).toContain("Saved:");
783
- expect(out).not.toContain("Size:");
784
- expect(out).not.toContain("Type:");
785
-
786
- expect(existsSync(outputPath)).toBe(true);
787
- expect(readFileSync(outputPath).toString()).toBe("fake-file-content");
788
-
789
- const downloadReq = storage.requests.find((x) => x.pathname.startsWith("/files/"));
790
- expect(downloadReq).toBeTruthy();
791
- expect(downloadReq!.headers["access-token"]).toBe("test-key");
792
- expect(downloadReq!.method).toBe("GET");
793
- } finally {
794
- storage.stop();
795
- }
796
- });
797
-
798
- test("issues help shows files subcommand", () => {
799
- const r = runCli(["issues", "--help"], isolatedEnv());
800
- expect(r.status).toBe(0);
801
- const out = `${r.stdout}\n${r.stderr}`;
802
- expect(out).toContain("files");
803
- });
804
-
805
- test("issues files help shows upload and download", () => {
806
- const r = runCli(["issues", "files", "--help"], isolatedEnv());
807
- expect(r.status).toBe(0);
808
- const out = `${r.stdout}\n${r.stderr}`;
809
- expect(out).toContain("upload");
810
- expect(out).toContain("download");
811
- });
812
- });
813
-
814
- describe("CLI set-storage-url command", () => {
815
- test("saves valid URL to config", () => {
816
- const env = isolatedEnv();
817
- const r = runCli(["set-storage-url", "https://v2.postgres.ai/storage"], env);
818
- expect(r.status).toBe(0);
819
- expect(`${r.stdout}\n${r.stderr}`).toContain("Storage URL saved: https://v2.postgres.ai/storage");
820
-
821
- // Verify persisted in config
822
- const cfgPath = resolve(env.XDG_CONFIG_HOME, "postgresai", "config.json");
823
- const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
824
- expect(cfg.storageBaseUrl).toBe("https://v2.postgres.ai/storage");
825
- });
826
-
827
- test("normalizes trailing slash", () => {
828
- const env = isolatedEnv();
829
- const r = runCli(["set-storage-url", "https://example.com/storage/"], env);
830
- expect(r.status).toBe(0);
831
- expect(`${r.stdout}\n${r.stderr}`).toContain("Storage URL saved: https://example.com/storage");
832
- });
833
-
834
- test("rejects invalid URL", () => {
835
- const r = runCli(["set-storage-url", "not-a-url"], isolatedEnv());
836
- expect(r.status).toBe(1);
837
- expect(`${r.stdout}\n${r.stderr}`).toContain("invalid URL");
838
- });
839
- });
840
-
841
- describe("CLI issues --attach flag", () => {
842
- // Helper: write a small image-named tmp file (not a real PNG, but the .png
843
- // extension is what the CLI / storage helper read for MIME + markdown form).
844
- function writeTmpFile(name: string, body = "X"): string {
845
- const dir = mkdtempSync(resolve(tmpdir(), "pgai-attach-"));
846
- const p = resolve(dir, name);
847
- writeFileSync(p, body);
848
- return p;
849
- }
850
-
851
- test("post-comment --attach uploads then appends image markdown to comment", async () => {
852
- const api = await startFakeApi();
853
- const png = writeTmpFile("pic.png", "PNGBYTES");
854
- try {
855
- const r = await runCliAsync(
856
- [
857
- "issues",
858
- "post-comment",
859
- "11111111-1111-1111-1111-111111111111",
860
- "see attached",
861
- "--attach",
862
- png,
863
- "--json",
864
- ],
865
- isolatedEnv({
866
- PGAI_API_KEY: "test-key",
867
- PGAI_API_BASE_URL: api.baseUrl,
868
- PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
869
- })
870
- );
871
-
872
- expect(r.status).toBe(0);
873
- // Upload happened first.
874
- expect(api.uploads).toHaveLength(1);
875
- expect(api.uploads[0].filename).toBe("pic.png");
876
-
877
- // Comment-create request was sent with augmented content.
878
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
879
- expect(req).toBeTruthy();
880
- expect(req!.bodyJson.content).toBe(
881
- `see attached\n\n![pic.png](${api.storageBaseUrl}${api.uploads[0].url})`
882
- );
883
- // Request order: upload before comment.
884
- const uploadIdx = api.requests.findIndex((x) => x.pathname === "/storage/upload");
885
- const commentIdx = api.requests.findIndex((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
886
- expect(uploadIdx).toBeGreaterThanOrEqual(0);
887
- expect(commentIdx).toBeGreaterThan(uploadIdx);
888
- } finally {
889
- api.stop();
890
- }
891
- });
892
-
893
- test("post-comment --attach with multiple files appends one link per line preserving order", async () => {
894
- const api = await startFakeApi();
895
- const png = writeTmpFile("a.png", "P");
896
- const log = writeTmpFile("b.log", "L");
897
- try {
898
- const r = await runCliAsync(
899
- [
900
- "issues",
901
- "post-comment",
902
- "11111111-1111-1111-1111-111111111111",
903
- "ctx",
904
- "--attach",
905
- png,
906
- "--attach",
907
- log,
908
- "--json",
909
- ],
910
- isolatedEnv({
911
- PGAI_API_KEY: "test-key",
912
- PGAI_API_BASE_URL: api.baseUrl,
913
- PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
914
- })
915
- );
916
- expect(r.status).toBe(0);
917
-
918
- expect(api.uploads.map((u) => u.filename)).toEqual(["a.png", "b.log"]);
919
-
920
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
921
- expect(req).toBeTruthy();
922
- const expected =
923
- `ctx\n\n![a.png](${api.storageBaseUrl}/files/test/0_a.png)\n` +
924
- `[b.log](${api.storageBaseUrl}/files/test/1_b.log)`;
925
- expect(req!.bodyJson.content).toBe(expected);
926
- } finally {
927
- api.stop();
928
- }
929
- });
930
-
931
- test("create --attach appends markdown link to description", async () => {
932
- const api = await startFakeApi();
933
- const png = writeTmpFile("diagram.png", "PNG");
934
- try {
935
- const r = await runCliAsync(
936
- [
937
- "issues",
938
- "create",
939
- "Reproduces under load",
940
- "--org-id",
941
- "1",
942
- "--description",
943
- "see chart",
944
- "--attach",
945
- png,
946
- "--json",
947
- ],
948
- isolatedEnv({
949
- PGAI_API_KEY: "test-key",
950
- PGAI_API_BASE_URL: api.baseUrl,
951
- PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
952
- })
953
- );
954
- expect(r.status).toBe(0);
955
-
956
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_create"));
957
- expect(req).toBeTruthy();
958
- expect(req!.bodyJson.description).toBe(
959
- `see chart\n\n![diagram.png](${api.storageBaseUrl}/files/test/0_diagram.png)`
960
- );
961
- } finally {
962
- api.stop();
963
- }
964
- });
965
-
966
- test("create --attach without --description sets description to just the link", async () => {
967
- const api = await startFakeApi();
968
- const png = writeTmpFile("only.png", "P");
969
- try {
970
- const r = await runCliAsync(
971
- [
972
- "issues",
973
- "create",
974
- "tinier example",
975
- "--org-id",
976
- "1",
977
- "--attach",
978
- png,
979
- "--json",
980
- ],
981
- isolatedEnv({
982
- PGAI_API_KEY: "test-key",
983
- PGAI_API_BASE_URL: api.baseUrl,
984
- PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
985
- })
986
- );
987
- expect(r.status).toBe(0);
988
-
989
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_create"));
990
- expect(req).toBeTruthy();
991
- expect(req!.bodyJson.description).toBe(
992
- `![only.png](${api.storageBaseUrl}/files/test/0_only.png)`
993
- );
994
- } finally {
995
- api.stop();
996
- }
997
- });
998
-
999
- test("update --attach without --description fetches existing description and appends", async () => {
1000
- const api = await startFakeApi();
1001
- const png = writeTmpFile("evidence.png", "P");
1002
- try {
1003
- const r = await runCliAsync(
1004
- [
1005
- "issues",
1006
- "update",
1007
- "issue-1",
1008
- "--attach",
1009
- png,
1010
- "--json",
1011
- ],
1012
- isolatedEnv({
1013
- PGAI_API_KEY: "test-key",
1014
- PGAI_API_BASE_URL: api.baseUrl,
1015
- PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
1016
- })
1017
- );
1018
- expect(r.status).toBe(0);
1019
-
1020
- // GET /issues happens first to read the existing description, then upload, then update.
1021
- const seq = api.requests.map((x) => `${x.method} ${x.pathname}`);
1022
- const fetchIdx = seq.indexOf("GET /api/general/issues");
1023
- const uploadIdx = seq.indexOf("POST /storage/upload");
1024
- const updateIdx = seq.indexOf("POST /api/general/rpc/issue_update");
1025
- expect(fetchIdx).toBeGreaterThanOrEqual(0);
1026
- expect(uploadIdx).toBeGreaterThan(fetchIdx);
1027
- expect(updateIdx).toBeGreaterThan(uploadIdx);
1028
-
1029
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_update"));
1030
- expect(req).toBeTruthy();
1031
- expect(req!.bodyJson.p_description).toBe(
1032
- `Existing description body\n\n![evidence.png](${api.storageBaseUrl}/files/test/0_evidence.png)`
1033
- );
1034
- } finally {
1035
- api.stop();
1036
- }
1037
- });
1038
-
1039
- test("update --attach with --description appends to the new description (no fetch)", async () => {
1040
- const api = await startFakeApi();
1041
- const png = writeTmpFile("e2.png", "P");
1042
- try {
1043
- const r = await runCliAsync(
1044
- [
1045
- "issues",
1046
- "update",
1047
- "issue-1",
1048
- "--description",
1049
- "Rewritten body",
1050
- "--attach",
1051
- png,
1052
- "--json",
1053
- ],
1054
- isolatedEnv({
1055
- PGAI_API_KEY: "test-key",
1056
- PGAI_API_BASE_URL: api.baseUrl,
1057
- PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
1058
- })
1059
- );
1060
- expect(r.status).toBe(0);
1061
-
1062
- // No GET /issues happened — we already have the new description.
1063
- const fetched = api.requests.find((x) => x.method === "GET" && x.pathname.endsWith("/issues"));
1064
- expect(fetched).toBeFalsy();
1065
-
1066
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_update"));
1067
- expect(req).toBeTruthy();
1068
- expect(req!.bodyJson.p_description).toBe(
1069
- `Rewritten body\n\n![e2.png](${api.storageBaseUrl}/files/test/0_e2.png)`
1070
- );
1071
- } finally {
1072
- api.stop();
1073
- }
1074
- });
1075
-
1076
- test("update-comment --attach appends markdown link to comment content", async () => {
1077
- const api = await startFakeApi();
1078
- const png = writeTmpFile("after.png", "P");
1079
- try {
1080
- const r = await runCliAsync(
1081
- [
1082
- "issues",
1083
- "update-comment",
1084
- "comment-1",
1085
- "now with a screenshot",
1086
- "--attach",
1087
- png,
1088
- "--json",
1089
- ],
1090
- isolatedEnv({
1091
- PGAI_API_KEY: "test-key",
1092
- PGAI_API_BASE_URL: api.baseUrl,
1093
- PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
1094
- })
1095
- );
1096
- expect(r.status).toBe(0);
1097
-
1098
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_update"));
1099
- expect(req).toBeTruthy();
1100
- expect(req!.bodyJson.p_content).toBe(
1101
- `now with a screenshot\n\n![after.png](${api.storageBaseUrl}/files/test/0_after.png)`
1102
- );
1103
- } finally {
1104
- api.stop();
1105
- }
1106
- });
1107
-
1108
- test("--attach with a missing file fails fast and never sends the comment-create request", async () => {
1109
- const api = await startFakeApi();
1110
- try {
1111
- const r = await runCliAsync(
1112
- [
1113
- "issues",
1114
- "post-comment",
1115
- "11111111-1111-1111-1111-111111111111",
1116
- "should not be posted",
1117
- "--attach",
1118
- "/tmp/this-path-does-not-exist-xyz123.png",
1119
- ],
1120
- isolatedEnv({
1121
- PGAI_API_KEY: "test-key",
1122
- PGAI_API_BASE_URL: api.baseUrl,
1123
- PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
1124
- })
1125
- );
1126
- expect(r.status).toBe(1);
1127
- expect(`${r.stdout}\n${r.stderr}`).toMatch(/File not found/);
1128
- // No comment-create request reached the server — we bailed before posting.
1129
- const commentReq = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
1130
- expect(commentReq).toBeFalsy();
1131
- } finally {
1132
- api.stop();
1133
- }
1134
- });
1135
-
1136
- test("post-comment without --attach still works (regression check)", async () => {
1137
- const api = await startFakeApi();
1138
- try {
1139
- const r = await runCliAsync(
1140
- [
1141
- "issues",
1142
- "post-comment",
1143
- "11111111-1111-1111-1111-111111111111",
1144
- "plain comment",
1145
- "--json",
1146
- ],
1147
- isolatedEnv({
1148
- PGAI_API_KEY: "test-key",
1149
- PGAI_API_BASE_URL: api.baseUrl,
1150
- PGAI_STORAGE_BASE_URL: api.storageBaseUrl,
1151
- })
1152
- );
1153
- expect(r.status).toBe(0);
1154
- expect(api.uploads).toHaveLength(0);
1155
- const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_comment_create"));
1156
- expect(req).toBeTruthy();
1157
- expect(req!.bodyJson.content).toBe("plain comment");
1158
- } finally {
1159
- api.stop();
1160
- }
1161
- });
1162
- });