postgresai 0.14.0-beta.12 → 0.14.0-beta.13
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 +32 -0
- package/bin/postgres-ai.ts +928 -170
- package/dist/bin/postgres-ai.js +2095 -335
- package/lib/checkup.ts +69 -3
- package/lib/init.ts +76 -19
- package/lib/issues.ts +453 -7
- package/lib/mcp-server.ts +180 -3
- package/lib/metrics-embedded.ts +3 -3
- package/lib/supabase.ts +824 -0
- package/package.json +1 -1
- package/test/checkup.test.ts +240 -14
- package/test/config-consistency.test.ts +36 -0
- package/test/init.integration.test.ts +80 -71
- package/test/init.test.ts +266 -1
- package/test/issues.cli.test.ts +224 -0
- package/test/mcp-server.test.ts +551 -12
- package/test/supabase.test.ts +568 -0
- package/test/test-utils.ts +6 -0
package/test/init.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { describe, test, expect, beforeAll } from "bun:test";
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
2
|
import { resolve } from "path";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as os from "os";
|
|
3
5
|
|
|
4
6
|
// Import from source directly since we're using Bun
|
|
5
7
|
import * as init from "../lib/init";
|
|
@@ -150,6 +152,42 @@ describe("init module", () => {
|
|
|
150
152
|
expect(plan.steps.some((s: { optional?: boolean }) => s.optional)).toBe(true);
|
|
151
153
|
});
|
|
152
154
|
|
|
155
|
+
test("buildInitPlan skips role creation for supabase provider", async () => {
|
|
156
|
+
const plan = await init.buildInitPlan({
|
|
157
|
+
database: "mydb",
|
|
158
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
159
|
+
monitoringPassword: "pw",
|
|
160
|
+
includeOptionalPermissions: false,
|
|
161
|
+
provider: "supabase",
|
|
162
|
+
});
|
|
163
|
+
expect(plan.steps.some((s) => s.name === "01.role")).toBe(false);
|
|
164
|
+
expect(plan.steps.some((s) => s.name === "02.permissions")).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("buildInitPlan removes ALTER USER for supabase provider", async () => {
|
|
168
|
+
const plan = await init.buildInitPlan({
|
|
169
|
+
database: "mydb",
|
|
170
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
171
|
+
monitoringPassword: "pw",
|
|
172
|
+
includeOptionalPermissions: false,
|
|
173
|
+
provider: "supabase",
|
|
174
|
+
});
|
|
175
|
+
const permStep = plan.steps.find((s) => s.name === "02.permissions");
|
|
176
|
+
expect(permStep).toBeDefined();
|
|
177
|
+
expect(permStep!.sql.toLowerCase()).not.toMatch(/alter user/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("buildInitPlan includes role creation for unknown provider", async () => {
|
|
181
|
+
const plan = await init.buildInitPlan({
|
|
182
|
+
database: "mydb",
|
|
183
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
184
|
+
monitoringPassword: "pw",
|
|
185
|
+
includeOptionalPermissions: false,
|
|
186
|
+
provider: "some-custom-provider",
|
|
187
|
+
});
|
|
188
|
+
expect(plan.steps.some((s) => s.name === "01.role")).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
153
191
|
test("resolveAdminConnection accepts positional URI", () => {
|
|
154
192
|
const r = init.resolveAdminConnection({ conn: "postgresql://u:p@h:5432/d" });
|
|
155
193
|
expect(r.clientConfig.connectionString).toBeTruthy();
|
|
@@ -288,6 +326,91 @@ describe("init module", () => {
|
|
|
288
326
|
expect(calls[calls.length - 1].toLowerCase()).toBe("rollback;");
|
|
289
327
|
});
|
|
290
328
|
|
|
329
|
+
test("verifyInitSetup skips search_path check for supabase provider", async () => {
|
|
330
|
+
const calls: string[] = [];
|
|
331
|
+
const client = {
|
|
332
|
+
query: async (sql: string, params?: any) => {
|
|
333
|
+
calls.push(String(sql));
|
|
334
|
+
|
|
335
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
336
|
+
return { rowCount: 1, rows: [] };
|
|
337
|
+
}
|
|
338
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
339
|
+
return { rowCount: 1, rows: [] };
|
|
340
|
+
}
|
|
341
|
+
// Return empty rolconfig - would fail without provider=supabase
|
|
342
|
+
if (String(sql).includes("select rolconfig")) {
|
|
343
|
+
return { rowCount: 1, rows: [{ rolconfig: null }] };
|
|
344
|
+
}
|
|
345
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
346
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
347
|
+
}
|
|
348
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
349
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
350
|
+
}
|
|
351
|
+
if (String(sql).includes("pg_has_role")) {
|
|
352
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
353
|
+
}
|
|
354
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
355
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
356
|
+
}
|
|
357
|
+
if (String(sql).includes("to_regclass")) {
|
|
358
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
359
|
+
}
|
|
360
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
361
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
362
|
+
}
|
|
363
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
364
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// With provider=supabase, should pass even without search_path
|
|
372
|
+
const r = await init.verifyInitSetup({
|
|
373
|
+
client: client as any,
|
|
374
|
+
database: "mydb",
|
|
375
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
376
|
+
includeOptionalPermissions: false,
|
|
377
|
+
provider: "supabase",
|
|
378
|
+
});
|
|
379
|
+
expect(r.ok).toBe(true);
|
|
380
|
+
expect(r.missingRequired.length).toBe(0);
|
|
381
|
+
// Should not have queried for rolconfig since we skip search_path check
|
|
382
|
+
expect(calls.some((c) => c.includes("select rolconfig"))).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("buildInitPlan preserves comments when filtering ALTER USER", async () => {
|
|
386
|
+
const plan = await init.buildInitPlan({
|
|
387
|
+
database: "mydb",
|
|
388
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
389
|
+
monitoringPassword: "pw",
|
|
390
|
+
includeOptionalPermissions: false,
|
|
391
|
+
provider: "supabase",
|
|
392
|
+
});
|
|
393
|
+
const permStep = plan.steps.find((s) => s.name === "02.permissions");
|
|
394
|
+
expect(permStep).toBeDefined();
|
|
395
|
+
// Should have removed ALTER USER but kept comments
|
|
396
|
+
expect(permStep!.sql.toLowerCase()).not.toMatch(/^\s*alter\s+user/m);
|
|
397
|
+
// Should still have comment lines
|
|
398
|
+
expect(permStep!.sql).toMatch(/^--/m);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("validateProvider returns null for known providers", () => {
|
|
402
|
+
expect(init.validateProvider(undefined)).toBe(null);
|
|
403
|
+
expect(init.validateProvider("self-managed")).toBe(null);
|
|
404
|
+
expect(init.validateProvider("supabase")).toBe(null);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("validateProvider returns warning for unknown providers", () => {
|
|
408
|
+
const warning = init.validateProvider("unknown-provider");
|
|
409
|
+
expect(warning).not.toBe(null);
|
|
410
|
+
expect(warning).toMatch(/Unknown provider/);
|
|
411
|
+
expect(warning).toMatch(/unknown-provider/);
|
|
412
|
+
});
|
|
413
|
+
|
|
291
414
|
test("redactPasswordsInSql redacts password literals with embedded quotes", async () => {
|
|
292
415
|
const plan = await init.buildInitPlan({
|
|
293
416
|
database: "mydb",
|
|
@@ -317,6 +440,40 @@ describe("CLI commands", () => {
|
|
|
317
440
|
expect(r.stdout).toMatch(new RegExp(`grant connect on database "mydb" to "${DEFAULT_MONITORING_USER}"`, "i"));
|
|
318
441
|
});
|
|
319
442
|
|
|
443
|
+
test("cli: prepare-db --print-sql with --provider supabase skips role step", () => {
|
|
444
|
+
const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw", "--provider", "supabase"]);
|
|
445
|
+
expect(r.status).toBe(0);
|
|
446
|
+
expect(r.stdout).toMatch(/provider: supabase/);
|
|
447
|
+
// Should not have 01.role step
|
|
448
|
+
expect(r.stdout).not.toMatch(/-- 01\.role/);
|
|
449
|
+
// Should have 02.permissions step
|
|
450
|
+
expect(r.stdout).toMatch(/-- 02\.permissions/);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("cli: prepare-db warns about unknown provider", () => {
|
|
454
|
+
const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw", "--provider", "unknown-cloud"]);
|
|
455
|
+
expect(r.status).toBe(0);
|
|
456
|
+
// Should warn about unknown provider
|
|
457
|
+
expect(r.stderr).toMatch(/Unknown provider.*unknown-cloud/);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("cli: prepare-db --reset-password with supabase provider would have no role step", async () => {
|
|
461
|
+
// When using supabase provider, the role creation step is skipped.
|
|
462
|
+
// This means --reset-password (which only runs 01.role) would have no steps.
|
|
463
|
+
// The CLI should error in this case. We test the underlying plan logic here.
|
|
464
|
+
const plan = await (await import("../lib/init")).buildInitPlan({
|
|
465
|
+
database: "mydb",
|
|
466
|
+
monitoringUser: "mon",
|
|
467
|
+
monitoringPassword: "pw",
|
|
468
|
+
includeOptionalPermissions: false,
|
|
469
|
+
provider: "supabase",
|
|
470
|
+
});
|
|
471
|
+
// Simulate what --reset-password does: filter to only 01.role step
|
|
472
|
+
const resetPasswordSteps = plan.steps.filter((s) => s.name === "01.role");
|
|
473
|
+
// For supabase, this should be empty (role creation is skipped)
|
|
474
|
+
expect(resetPasswordSteps.length).toBe(0);
|
|
475
|
+
});
|
|
476
|
+
|
|
320
477
|
test("pgai wrapper forwards to postgresai CLI", () => {
|
|
321
478
|
const r = runPgai(["--help"]);
|
|
322
479
|
expect(r.status).toBe(0);
|
|
@@ -415,3 +572,111 @@ describe("CLI commands", () => {
|
|
|
415
572
|
expect(r.stderr).toMatch(/Cannot use --api-key with --demo mode/);
|
|
416
573
|
});
|
|
417
574
|
});
|
|
575
|
+
|
|
576
|
+
describe("imageTag priority behavior", () => {
|
|
577
|
+
// Tests for the imageTag priority: --tag flag > PGAI_TAG env var > pkg.version
|
|
578
|
+
// This verifies the fix that prevents stale .env PGAI_TAG from being used
|
|
579
|
+
|
|
580
|
+
let tempDir: string;
|
|
581
|
+
|
|
582
|
+
beforeAll(() => {
|
|
583
|
+
tempDir = fs.mkdtempSync(resolve(os.tmpdir(), "pgai-test-"));
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
afterAll(() => {
|
|
587
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
588
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("stale .env PGAI_TAG is NOT used - CLI version takes precedence", () => {
|
|
593
|
+
// Create a stale .env with an old tag value
|
|
594
|
+
const testDir = resolve(tempDir, "stale-tag-test");
|
|
595
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
596
|
+
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=beta\n");
|
|
597
|
+
// Create minimal docker-compose.yml so resolvePaths() finds it
|
|
598
|
+
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
599
|
+
|
|
600
|
+
// Run from the test directory (so resolvePaths finds docker-compose.yml)
|
|
601
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
602
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
603
|
+
const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
|
|
604
|
+
env: { ...process.env, PGAI_TAG: undefined },
|
|
605
|
+
cwd: testDir,
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// Read the .env that was written
|
|
609
|
+
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
610
|
+
|
|
611
|
+
// The .env should NOT contain the stale "beta" tag - it should use pkg.version
|
|
612
|
+
expect(envContent).not.toMatch(/PGAI_TAG=beta/);
|
|
613
|
+
// It should contain the CLI version (0.0.0-dev.0 in dev)
|
|
614
|
+
expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("--tag flag takes priority over pkg.version", () => {
|
|
618
|
+
const testDir = resolve(tempDir, "tag-flag-test");
|
|
619
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
620
|
+
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
621
|
+
|
|
622
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
623
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
624
|
+
const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--tag", "v1.2.3-custom", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
|
|
625
|
+
env: { ...process.env, PGAI_TAG: undefined },
|
|
626
|
+
cwd: testDir,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
630
|
+
expect(envContent).toMatch(/PGAI_TAG=v1\.2\.3-custom/);
|
|
631
|
+
|
|
632
|
+
// Verify stdout confirms the tag being used
|
|
633
|
+
const stdout = new TextDecoder().decode(result.stdout);
|
|
634
|
+
expect(stdout).toMatch(/Using image tag: v1\.2\.3-custom/);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("PGAI_TAG env var is intentionally ignored (Bun auto-loads .env)", () => {
|
|
638
|
+
// Note: We do NOT use process.env.PGAI_TAG because Bun auto-loads .env files,
|
|
639
|
+
// which would cause stale .env values to pollute the environment.
|
|
640
|
+
// Users should use --tag flag to override, not env vars.
|
|
641
|
+
const testDir = resolve(tempDir, "env-var-ignored-test");
|
|
642
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
643
|
+
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
644
|
+
|
|
645
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
646
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
647
|
+
const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
|
|
648
|
+
env: { ...process.env, PGAI_TAG: "v2.0.0-from-env" },
|
|
649
|
+
cwd: testDir,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
653
|
+
// PGAI_TAG env var should be IGNORED - uses pkg.version instead
|
|
654
|
+
expect(envContent).not.toMatch(/PGAI_TAG=v2\.0\.0-from-env/);
|
|
655
|
+
expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("existing registry and password are preserved while tag is updated", () => {
|
|
659
|
+
const testDir = resolve(tempDir, "preserve-test");
|
|
660
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
661
|
+
// Create .env with stale tag but valid registry and password
|
|
662
|
+
fs.writeFileSync(resolve(testDir, ".env"),
|
|
663
|
+
"PGAI_TAG=stale-tag\nPGAI_REGISTRY=my.registry.com\nGF_SECURITY_ADMIN_PASSWORD=secret123\n");
|
|
664
|
+
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
665
|
+
|
|
666
|
+
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
667
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
668
|
+
const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
|
|
669
|
+
env: { ...process.env, PGAI_TAG: undefined },
|
|
670
|
+
cwd: testDir,
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
674
|
+
|
|
675
|
+
// Tag should be updated (not stale-tag)
|
|
676
|
+
expect(envContent).not.toMatch(/PGAI_TAG=stale-tag/);
|
|
677
|
+
|
|
678
|
+
// But registry and password should be preserved
|
|
679
|
+
expect(envContent).toMatch(/PGAI_REGISTRY=my\.registry\.com/);
|
|
680
|
+
expect(envContent).toMatch(/GF_SECURITY_ADMIN_PASSWORD=secret123/);
|
|
681
|
+
});
|
|
682
|
+
});
|
package/test/issues.cli.test.ts
CHANGED
|
@@ -133,6 +133,43 @@ async function startFakeApi() {
|
|
|
133
133
|
);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// Action Items endpoints
|
|
137
|
+
if (req.method === "GET" && url.pathname.endsWith("/issue_action_items")) {
|
|
138
|
+
const issueIdParam = url.searchParams.get("issue_id");
|
|
139
|
+
const idParam = url.searchParams.get("id");
|
|
140
|
+
if (issueIdParam) {
|
|
141
|
+
// list_action_items
|
|
142
|
+
return new Response(
|
|
143
|
+
JSON.stringify([
|
|
144
|
+
{ id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", issue_id: issueIdParam.replace("eq.", ""), title: "Action 1", is_done: false, status: "waiting_for_approval" },
|
|
145
|
+
{ id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", issue_id: issueIdParam.replace("eq.", ""), title: "Action 2", is_done: true, status: "approved" },
|
|
146
|
+
]),
|
|
147
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (idParam) {
|
|
151
|
+
// view_action_item
|
|
152
|
+
const actionId = idParam.replace("eq.", "").replace("in.(", "").replace(")", "").split(",")[0];
|
|
153
|
+
return new Response(
|
|
154
|
+
JSON.stringify([
|
|
155
|
+
{ 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: [] },
|
|
156
|
+
]),
|
|
157
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_action_item_create")) {
|
|
163
|
+
return new Response(
|
|
164
|
+
JSON.stringify("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
|
165
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/issue_action_item_update")) {
|
|
170
|
+
return new Response("", { status: 200, headers: { "Content-Type": "application/json" } });
|
|
171
|
+
}
|
|
172
|
+
|
|
136
173
|
return new Response("not found", { status: 404 });
|
|
137
174
|
},
|
|
138
175
|
});
|
|
@@ -312,3 +349,190 @@ describe("CLI issues command group", () => {
|
|
|
312
349
|
});
|
|
313
350
|
});
|
|
314
351
|
|
|
352
|
+
describe("CLI action items commands", () => {
|
|
353
|
+
test("issues action-items fails fast when API key is missing", () => {
|
|
354
|
+
const r = runCli(["issues", "action-items", "00000000-0000-0000-0000-000000000000"], isolatedEnv());
|
|
355
|
+
expect(r.status).toBe(1);
|
|
356
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("issues action-items fails when issue_id is not a valid UUID", () => {
|
|
360
|
+
const r = runCli(["issues", "action-items", "invalid-id"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
|
|
361
|
+
expect(r.status).toBe(1);
|
|
362
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("issueId must be a valid UUID");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("issues action-items succeeds against a fake API", async () => {
|
|
366
|
+
const api = await startFakeApi();
|
|
367
|
+
try {
|
|
368
|
+
const r = await runCliAsync(
|
|
369
|
+
["issues", "action-items", "11111111-1111-1111-1111-111111111111"],
|
|
370
|
+
isolatedEnv({
|
|
371
|
+
PGAI_API_KEY: "test-key",
|
|
372
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
373
|
+
})
|
|
374
|
+
);
|
|
375
|
+
expect(r.status).toBe(0);
|
|
376
|
+
|
|
377
|
+
const out = JSON.parse(r.stdout.trim());
|
|
378
|
+
expect(Array.isArray(out)).toBe(true);
|
|
379
|
+
expect(out.length).toBe(2);
|
|
380
|
+
expect(out[0].title).toBe("Action 1");
|
|
381
|
+
} finally {
|
|
382
|
+
api.stop();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("issues view-action-item fails fast when API key is missing", () => {
|
|
387
|
+
const r = runCli(["issues", "view-action-item", "00000000-0000-0000-0000-000000000000"], isolatedEnv());
|
|
388
|
+
expect(r.status).toBe(1);
|
|
389
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("issues view-action-item fails when action_item_id is not a valid UUID", () => {
|
|
393
|
+
const r = runCli(["issues", "view-action-item", "invalid-id"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
|
|
394
|
+
expect(r.status).toBe(1);
|
|
395
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("actionItemId is required and must be a valid UUID");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("issues view-action-item succeeds against a fake API", async () => {
|
|
399
|
+
const api = await startFakeApi();
|
|
400
|
+
try {
|
|
401
|
+
const r = await runCliAsync(
|
|
402
|
+
["issues", "view-action-item", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"],
|
|
403
|
+
isolatedEnv({
|
|
404
|
+
PGAI_API_KEY: "test-key",
|
|
405
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
406
|
+
})
|
|
407
|
+
);
|
|
408
|
+
expect(r.status).toBe(0);
|
|
409
|
+
|
|
410
|
+
const out = JSON.parse(r.stdout.trim());
|
|
411
|
+
expect(out[0].title).toBe("Test Action");
|
|
412
|
+
expect(out[0].sql_action).toBe("SELECT 1;");
|
|
413
|
+
} finally {
|
|
414
|
+
api.stop();
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("issues create-action-item fails fast when API key is missing", () => {
|
|
419
|
+
const r = runCli(["issues", "create-action-item", "00000000-0000-0000-0000-000000000000", "Test title"], isolatedEnv());
|
|
420
|
+
expect(r.status).toBe(1);
|
|
421
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("issues create-action-item fails when issue_id is not a valid UUID", () => {
|
|
425
|
+
const r = runCli(["issues", "create-action-item", "invalid-id", "Test title"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
|
|
426
|
+
expect(r.status).toBe(1);
|
|
427
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("issueId must be a valid UUID");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("issues create-action-item succeeds against a fake API", async () => {
|
|
431
|
+
const api = await startFakeApi();
|
|
432
|
+
try {
|
|
433
|
+
const r = await runCliAsync(
|
|
434
|
+
["issues", "create-action-item", "11111111-1111-1111-1111-111111111111", "New action item", "--description", "Test description"],
|
|
435
|
+
isolatedEnv({
|
|
436
|
+
PGAI_API_KEY: "test-key",
|
|
437
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
438
|
+
})
|
|
439
|
+
);
|
|
440
|
+
expect(r.status).toBe(0);
|
|
441
|
+
|
|
442
|
+
const out = JSON.parse(r.stdout.trim());
|
|
443
|
+
expect(out.id).toBe("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
|
444
|
+
|
|
445
|
+
const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_action_item_create"));
|
|
446
|
+
expect(req).toBeTruthy();
|
|
447
|
+
expect(req!.headers["access-token"]).toBe("test-key");
|
|
448
|
+
expect(req!.bodyJson.issue_id).toBe("11111111-1111-1111-1111-111111111111");
|
|
449
|
+
expect(req!.bodyJson.title).toBe("New action item");
|
|
450
|
+
expect(req!.bodyJson.description).toBe("Test description");
|
|
451
|
+
} finally {
|
|
452
|
+
api.stop();
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("issues create-action-item interprets escape sequences", async () => {
|
|
457
|
+
const api = await startFakeApi();
|
|
458
|
+
try {
|
|
459
|
+
const r = await runCliAsync(
|
|
460
|
+
["issues", "create-action-item", "11111111-1111-1111-1111-111111111111", "Title\\nwith newline", "--description", "Desc\\twith tab"],
|
|
461
|
+
isolatedEnv({
|
|
462
|
+
PGAI_API_KEY: "test-key",
|
|
463
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
464
|
+
})
|
|
465
|
+
);
|
|
466
|
+
expect(r.status).toBe(0);
|
|
467
|
+
|
|
468
|
+
const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_action_item_create"));
|
|
469
|
+
expect(req).toBeTruthy();
|
|
470
|
+
expect(req!.bodyJson.title).toBe("Title\nwith newline");
|
|
471
|
+
expect(req!.bodyJson.description).toBe("Desc\twith tab");
|
|
472
|
+
} finally {
|
|
473
|
+
api.stop();
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("issues update-action-item fails fast when API key is missing", () => {
|
|
478
|
+
const r = runCli(["issues", "update-action-item", "00000000-0000-0000-0000-000000000000", "--done"], isolatedEnv());
|
|
479
|
+
expect(r.status).toBe(1);
|
|
480
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("issues update-action-item fails when action_item_id is not a valid UUID", () => {
|
|
484
|
+
const r = runCli(["issues", "update-action-item", "invalid-id", "--done"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
|
|
485
|
+
expect(r.status).toBe(1);
|
|
486
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("actionItemId must be a valid UUID");
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("issues update-action-item fails when no update fields provided", () => {
|
|
490
|
+
const r = runCli(["issues", "update-action-item", "00000000-0000-0000-0000-000000000000"], isolatedEnv({ PGAI_API_KEY: "test-key" }));
|
|
491
|
+
expect(r.status).toBe(1);
|
|
492
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("At least one update option is required");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("issues update-action-item succeeds with --done flag", async () => {
|
|
496
|
+
const api = await startFakeApi();
|
|
497
|
+
try {
|
|
498
|
+
const r = await runCliAsync(
|
|
499
|
+
["issues", "update-action-item", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "--done"],
|
|
500
|
+
isolatedEnv({
|
|
501
|
+
PGAI_API_KEY: "test-key",
|
|
502
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
503
|
+
})
|
|
504
|
+
);
|
|
505
|
+
expect(r.status).toBe(0);
|
|
506
|
+
|
|
507
|
+
const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_action_item_update"));
|
|
508
|
+
expect(req).toBeTruthy();
|
|
509
|
+
expect(req!.headers["access-token"]).toBe("test-key");
|
|
510
|
+
expect(req!.bodyJson.action_item_id).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
|
511
|
+
expect(req!.bodyJson.is_done).toBe(true);
|
|
512
|
+
} finally {
|
|
513
|
+
api.stop();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("issues update-action-item succeeds with --status flag", async () => {
|
|
518
|
+
const api = await startFakeApi();
|
|
519
|
+
try {
|
|
520
|
+
const r = await runCliAsync(
|
|
521
|
+
["issues", "update-action-item", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "--status", "approved", "--status-reason", "LGTM"],
|
|
522
|
+
isolatedEnv({
|
|
523
|
+
PGAI_API_KEY: "test-key",
|
|
524
|
+
PGAI_API_BASE_URL: api.baseUrl,
|
|
525
|
+
})
|
|
526
|
+
);
|
|
527
|
+
expect(r.status).toBe(0);
|
|
528
|
+
|
|
529
|
+
const req = api.requests.find((x) => x.pathname.endsWith("/rpc/issue_action_item_update"));
|
|
530
|
+
expect(req).toBeTruthy();
|
|
531
|
+
expect(req!.bodyJson.status).toBe("approved");
|
|
532
|
+
expect(req!.bodyJson.status_reason).toBe("LGTM");
|
|
533
|
+
} finally {
|
|
534
|
+
api.stop();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|