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.
@@ -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![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
+ });