postgresai 0.15.0-dev.10 → 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 +79 -8
- package/bin/postgres-ai.ts +220 -141
- package/dist/bin/postgres-ai.js +4050 -694
- package/lib/checkup-dictionary.ts +0 -11
- package/lib/checkup.ts +14 -14
- package/lib/init.ts +1 -1
- package/lib/instances.ts +245 -0
- package/lib/mcp-server.ts +139 -18
- package/lib/metrics-loader.ts +3 -3
- package/lib/storage.ts +77 -1
- package/package.json +1 -1
- package/test/issues.cli.test.ts +395 -0
- package/test/mcp-server.test.ts +485 -2
- package/test/monitoring.test.ts +277 -0
- package/test/storage.test.ts +175 -1
package/lib/storage.ts
CHANGED
|
@@ -211,7 +211,10 @@ export async function downloadFile(params: DownloadFileParams): Promise<Download
|
|
|
211
211
|
// and restricting it to cwd would break legitimate use (e.g. -o /tmp/file.png).
|
|
212
212
|
if (!outputPath) {
|
|
213
213
|
const normalizedSave = path.normalize(saveTo);
|
|
214
|
-
|
|
214
|
+
const cwd = path.normalize(process.cwd());
|
|
215
|
+
// Append path.sep so that cwd "/home/u/proj" doesn't allow a sibling
|
|
216
|
+
// "/home/u/proj-evil/x" via plain prefix match.
|
|
217
|
+
if (normalizedSave !== cwd && !normalizedSave.startsWith(cwd + path.sep)) {
|
|
215
218
|
throw new Error("Derived output path escapes current directory; please specify --output");
|
|
216
219
|
}
|
|
217
220
|
}
|
|
@@ -289,3 +292,76 @@ export function buildMarkdownLink(fileUrl: string, storageBaseUrl: string, filen
|
|
|
289
292
|
}
|
|
290
293
|
return `[${safeName}](${fullUrl})`;
|
|
291
294
|
}
|
|
295
|
+
|
|
296
|
+
export interface UploadedAttachment {
|
|
297
|
+
path: string;
|
|
298
|
+
url: string;
|
|
299
|
+
markdown: string;
|
|
300
|
+
metadata: UploadFileMetadata;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface UploadAttachmentsParams {
|
|
304
|
+
apiKey: string;
|
|
305
|
+
storageBaseUrl: string;
|
|
306
|
+
attachmentPaths: string[];
|
|
307
|
+
debug?: boolean;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Upload a list of local files to storage and return one markdown link per file.
|
|
312
|
+
*
|
|
313
|
+
* Shared by both the CLI `--attach` flag and the MCP `attachments` parameter so
|
|
314
|
+
* that the two surfaces produce identical output. Uploads are sequential (not
|
|
315
|
+
* parallel) so that on failure of file N, the error from `uploadFile` (which
|
|
316
|
+
* includes the resolved path, e.g. `File not found: /abs/path`) pinpoints
|
|
317
|
+
* which file failed. Note: any files already uploaded successfully before
|
|
318
|
+
* the failure remain on the storage server; a retry of the same call will
|
|
319
|
+
* re-upload them.
|
|
320
|
+
*
|
|
321
|
+
* Returns an empty array if `attachmentPaths` is empty (callers don't have to
|
|
322
|
+
* guard).
|
|
323
|
+
*/
|
|
324
|
+
export async function uploadAttachments(params: UploadAttachmentsParams): Promise<UploadedAttachment[]> {
|
|
325
|
+
const { apiKey, storageBaseUrl, attachmentPaths, debug } = params;
|
|
326
|
+
if (!attachmentPaths || attachmentPaths.length === 0) {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
const out: UploadedAttachment[] = [];
|
|
330
|
+
for (const attachmentPath of attachmentPaths) {
|
|
331
|
+
const result = await uploadFile({
|
|
332
|
+
apiKey,
|
|
333
|
+
storageBaseUrl,
|
|
334
|
+
filePath: attachmentPath,
|
|
335
|
+
debug,
|
|
336
|
+
});
|
|
337
|
+
const markdown = buildMarkdownLink(result.url, storageBaseUrl, result.metadata.originalName);
|
|
338
|
+
out.push({
|
|
339
|
+
path: attachmentPath,
|
|
340
|
+
url: result.url,
|
|
341
|
+
markdown,
|
|
342
|
+
metadata: result.metadata,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Append uploaded-attachment markdown links to a body of content.
|
|
350
|
+
*
|
|
351
|
+
* - If `attachments` is empty, returns `content` unchanged.
|
|
352
|
+
* - If `content` is empty/whitespace, returns just the links.
|
|
353
|
+
* - Otherwise: `${content}\n\n${links joined by \n}`.
|
|
354
|
+
*
|
|
355
|
+
* One link per line keeps the renderer happy whether the surface is GFM
|
|
356
|
+
* (which collapses adjacent lines) or strict CommonMark.
|
|
357
|
+
*/
|
|
358
|
+
export function appendAttachmentsToContent(content: string, attachments: UploadedAttachment[]): string {
|
|
359
|
+
if (!attachments || attachments.length === 0) {
|
|
360
|
+
return content;
|
|
361
|
+
}
|
|
362
|
+
const links = attachments.map((a) => a.markdown).join("\n");
|
|
363
|
+
if (!content || !content.trim()) {
|
|
364
|
+
return links;
|
|
365
|
+
}
|
|
366
|
+
return `${content}\n\n${links}`;
|
|
367
|
+
}
|
package/package.json
CHANGED
package/test/issues.cli.test.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -765,3 +837,326 @@ describe("CLI set-storage-url command", () => {
|
|
|
765
837
|
expect(`${r.stdout}\n${r.stderr}`).toContain("invalid URL");
|
|
766
838
|
});
|
|
767
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
|
+
});
|