postgresai 0.15.0-dev.1 → 0.15.0-dev.11
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 +82 -9
- package/bin/postgres-ai.ts +813 -233
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +6193 -1059
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup-dictionary.ts +0 -11
- package/lib/checkup.ts +255 -24
- package/lib/config.ts +3 -0
- package/lib/init.ts +197 -5
- package/lib/instances.ts +245 -0
- package/lib/issues.ts +72 -72
- package/lib/mcp-server.ts +229 -18
- package/lib/metrics-loader.ts +6 -4
- package/lib/reports.ts +373 -0
- package/lib/storage.ts +367 -0
- package/lib/supabase.ts +8 -1
- package/lib/util.ts +7 -1
- package/package.json +4 -4
- package/scripts/embed-checkup-dictionary.ts +9 -0
- package/scripts/embed-metrics.ts +2 -0
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +139 -0
- package/test/checkup.test.ts +1316 -2
- package/test/compose-cmd.test.ts +120 -0
- package/test/config-consistency.test.ts +321 -5
- package/test/init.integration.test.ts +27 -28
- package/test/init.test.ts +534 -6
- package/test/issues.cli.test.ts +625 -1
- package/test/mcp-server.test.ts +944 -2
- package/test/monitoring.test.ts +355 -0
- package/test/permission-check-sql.test.ts +116 -0
- package/test/reports.cli.test.ts +793 -0
- package/test/reports.test.ts +977 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/storage.test.ts +935 -0
- package/test/test-utils.ts +51 -2
- package/test/upgrade.test.ts +422 -0
package/test/issues.cli.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
2
|
import { resolve } from "path";
|
|
3
|
-
import { mkdtempSync } from "fs";
|
|
3
|
+
import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "fs";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
5
|
|
|
6
6
|
function runCli(args: string[], env: Record<string, string> = {}) {
|
|
@@ -49,8 +49,12 @@ async function startFakeApi() {
|
|
|
49
49
|
headers: Record<string, string>;
|
|
50
50
|
bodyText: string;
|
|
51
51
|
bodyJson: any | null;
|
|
52
|
+
contentType?: string;
|
|
52
53
|
}> = [];
|
|
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
|
+
|
|
54
58
|
const server = Bun.serve({
|
|
55
59
|
hostname: "127.0.0.1",
|
|
56
60
|
port: 0,
|
|
@@ -59,6 +63,50 @@ async function startFakeApi() {
|
|
|
59
63
|
const headers: Record<string, string> = {};
|
|
60
64
|
for (const [k, v] of req.headers.entries()) headers[k.toLowerCase()] = v;
|
|
61
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
|
+
|
|
62
110
|
const bodyText = await req.text();
|
|
63
111
|
let bodyJson: any | null = null;
|
|
64
112
|
try {
|
|
@@ -73,6 +121,7 @@ async function startFakeApi() {
|
|
|
73
121
|
headers,
|
|
74
122
|
bodyText,
|
|
75
123
|
bodyJson,
|
|
124
|
+
contentType,
|
|
76
125
|
});
|
|
77
126
|
|
|
78
127
|
// Minimal fake PostgREST RPC endpoints used by our CLI.
|
|
@@ -133,6 +182,26 @@ async function startFakeApi() {
|
|
|
133
182
|
);
|
|
134
183
|
}
|
|
135
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
|
+
|
|
136
205
|
// Action Items endpoints
|
|
137
206
|
if (req.method === "GET" && url.pathname.endsWith("/issue_action_items")) {
|
|
138
207
|
const issueIdParam = url.searchParams.get("issue_id");
|
|
@@ -175,10 +244,13 @@ async function startFakeApi() {
|
|
|
175
244
|
});
|
|
176
245
|
|
|
177
246
|
const baseUrl = `http://${server.hostname}:${server.port}/api/general`;
|
|
247
|
+
const storageBaseUrl = `http://${server.hostname}:${server.port}/storage`;
|
|
178
248
|
|
|
179
249
|
return {
|
|
180
250
|
baseUrl,
|
|
251
|
+
storageBaseUrl,
|
|
181
252
|
requests,
|
|
253
|
+
uploads,
|
|
182
254
|
stop: () => server.stop(true),
|
|
183
255
|
};
|
|
184
256
|
}
|
|
@@ -536,3 +608,555 @@ describe("CLI action items commands", () => {
|
|
|
536
608
|
});
|
|
537
609
|
});
|
|
538
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`
|
|
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\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`
|
|
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
|
+
``
|
|
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`
|
|
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`
|
|
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`
|
|
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
|
+
});
|