postgresai 0.14.0-dev.70 → 0.14.0-dev.72
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/bin/postgres-ai.ts +403 -95
- package/dist/bin/postgres-ai.js +1126 -158
- package/lib/init.ts +76 -19
- package/lib/issues.ts +453 -7
- package/lib/mcp-server.ts +180 -3
- package/lib/metrics-embedded.ts +1 -1
- package/lib/supabase.ts +52 -0
- package/package.json +1 -1
- package/test/config-consistency.test.ts +36 -0
- package/test/init.integration.test.ts +78 -70
- package/test/init.test.ts +155 -0
- package/test/issues.cli.test.ts +224 -0
- package/test/mcp-server.test.ts +551 -12
package/lib/init.ts
CHANGED
|
@@ -7,6 +7,32 @@ import * as path from "path";
|
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_MONITORING_USER = "postgres_ai_mon";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Database provider type. Affects which prepare-db steps are executed.
|
|
12
|
+
* Known providers have specific behavior adjustments; unknown providers use default behavior.
|
|
13
|
+
* TODO: Consider auto-detecting provider from connection string or server version string.
|
|
14
|
+
* TODO: Consider making this more flexible via a config that specifies which steps/checks to skip.
|
|
15
|
+
*/
|
|
16
|
+
export type DbProvider = string;
|
|
17
|
+
|
|
18
|
+
/** Known providers with special handling. Unknown providers are treated as self-managed. */
|
|
19
|
+
export const KNOWN_PROVIDERS = ["self-managed", "supabase"] as const;
|
|
20
|
+
|
|
21
|
+
/** Providers where we skip role creation (users managed externally). */
|
|
22
|
+
const SKIP_ROLE_CREATION_PROVIDERS = ["supabase"];
|
|
23
|
+
|
|
24
|
+
/** Providers where we skip ALTER USER statements (restricted by provider). */
|
|
25
|
+
const SKIP_ALTER_USER_PROVIDERS = ["supabase"];
|
|
26
|
+
|
|
27
|
+
/** Providers where we skip search_path verification (not set via ALTER USER). */
|
|
28
|
+
const SKIP_SEARCH_PATH_CHECK_PROVIDERS = ["supabase"];
|
|
29
|
+
|
|
30
|
+
/** Check if a provider is known and return a warning message if not. */
|
|
31
|
+
export function validateProvider(provider: string | undefined): string | null {
|
|
32
|
+
if (!provider || KNOWN_PROVIDERS.includes(provider as any)) return null;
|
|
33
|
+
return `Unknown provider "${provider}". Known providers: ${KNOWN_PROVIDERS.join(", ")}. Treating as self-managed.`;
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
export type PgClientConfig = {
|
|
11
37
|
connectionString?: string;
|
|
12
38
|
host?: string;
|
|
@@ -458,10 +484,13 @@ export async function buildInitPlan(params: {
|
|
|
458
484
|
monitoringUser?: string;
|
|
459
485
|
monitoringPassword: string;
|
|
460
486
|
includeOptionalPermissions: boolean;
|
|
487
|
+
/** Provider type. Affects which steps are included. Defaults to "self-managed". */
|
|
488
|
+
provider?: DbProvider;
|
|
461
489
|
}): Promise<InitPlan> {
|
|
462
490
|
// NOTE: kept async for API stability / potential future async template loading.
|
|
463
491
|
const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
|
|
464
492
|
const database = params.database;
|
|
493
|
+
const provider = params.provider ?? "self-managed";
|
|
465
494
|
|
|
466
495
|
const qRole = quoteIdent(monitoringUser);
|
|
467
496
|
const qDb = quoteIdent(database);
|
|
@@ -475,12 +504,15 @@ export async function buildInitPlan(params: {
|
|
|
475
504
|
DB_IDENT: qDb,
|
|
476
505
|
};
|
|
477
506
|
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
507
|
+
// Some providers (e.g., Supabase) manage users externally - skip role creation.
|
|
508
|
+
// TODO: Make this more flexible by allowing users to specify which steps to skip via config.
|
|
509
|
+
if (!SKIP_ROLE_CREATION_PROVIDERS.includes(provider)) {
|
|
510
|
+
// Role creation/update is done in one template file.
|
|
511
|
+
// Always use a single DO block to avoid race conditions between "role exists?" checks and CREATE USER.
|
|
512
|
+
// We:
|
|
513
|
+
// - create role if missing (and handle duplicate_object in case another session created it concurrently),
|
|
514
|
+
// - then ALTER ROLE to ensure the password is set to the desired value.
|
|
515
|
+
const roleStmt = `do $$ begin
|
|
484
516
|
if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
|
|
485
517
|
begin
|
|
486
518
|
create user ${qRole} with password ${qPw};
|
|
@@ -491,12 +523,30 @@ export async function buildInitPlan(params: {
|
|
|
491
523
|
alter user ${qRole} with password ${qPw};
|
|
492
524
|
end $$;`;
|
|
493
525
|
|
|
494
|
-
|
|
495
|
-
|
|
526
|
+
const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
|
|
527
|
+
steps.push({ name: "01.role", sql: roleSql });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let permissionsSql = applyTemplate(loadSqlTemplate("02.permissions.sql"), vars);
|
|
531
|
+
|
|
532
|
+
// Some providers restrict ALTER USER - remove those statements.
|
|
533
|
+
// TODO: Make this more flexible by allowing users to specify which statements to skip via config.
|
|
534
|
+
if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
|
|
535
|
+
permissionsSql = permissionsSql
|
|
536
|
+
.split("\n")
|
|
537
|
+
.filter((line) => {
|
|
538
|
+
const trimmed = line.trim();
|
|
539
|
+
// Keep comments and empty lines
|
|
540
|
+
if (trimmed.startsWith("--") || trimmed === "") return true;
|
|
541
|
+
// Filter out ALTER USER statements (case-insensitive, flexible whitespace)
|
|
542
|
+
return !/^\s*alter\s+user\s+/i.test(line);
|
|
543
|
+
})
|
|
544
|
+
.join("\n");
|
|
545
|
+
}
|
|
496
546
|
|
|
497
547
|
steps.push({
|
|
498
548
|
name: "02.permissions",
|
|
499
|
-
sql:
|
|
549
|
+
sql: permissionsSql,
|
|
500
550
|
});
|
|
501
551
|
|
|
502
552
|
// Helper functions (SECURITY DEFINER) for plan analysis and table info
|
|
@@ -612,6 +662,8 @@ export async function verifyInitSetup(params: {
|
|
|
612
662
|
database: string;
|
|
613
663
|
monitoringUser: string;
|
|
614
664
|
includeOptionalPermissions: boolean;
|
|
665
|
+
/** Provider type. Affects which checks are performed. */
|
|
666
|
+
provider?: DbProvider;
|
|
615
667
|
}): Promise<VerifyInitResult> {
|
|
616
668
|
// Use a repeatable-read snapshot so all checks see a consistent view.
|
|
617
669
|
await params.client.query("begin isolation level repeatable read;");
|
|
@@ -621,6 +673,7 @@ export async function verifyInitSetup(params: {
|
|
|
621
673
|
|
|
622
674
|
const role = params.monitoringUser;
|
|
623
675
|
const db = params.database;
|
|
676
|
+
const provider = params.provider ?? "self-managed";
|
|
624
677
|
|
|
625
678
|
const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
626
679
|
const roleExists = (roleRes.rowCount ?? 0) > 0;
|
|
@@ -684,16 +737,20 @@ export async function verifyInitSetup(params: {
|
|
|
684
737
|
missingRequired.push("USAGE on schema public");
|
|
685
738
|
}
|
|
686
739
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
740
|
+
// Some providers don't allow setting search_path via ALTER USER - skip this check.
|
|
741
|
+
// TODO: Make this more flexible by allowing users to specify which checks to skip via config.
|
|
742
|
+
if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
|
|
743
|
+
const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
744
|
+
const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
|
|
745
|
+
const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
|
|
746
|
+
if (typeof spLine !== "string" || !spLine) {
|
|
747
|
+
missingRequired.push("role search_path is set");
|
|
748
|
+
} else {
|
|
749
|
+
// We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
|
|
750
|
+
const sp = spLine.toLowerCase();
|
|
751
|
+
if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
752
|
+
missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
|
|
753
|
+
}
|
|
697
754
|
}
|
|
698
755
|
}
|
|
699
756
|
|
package/lib/issues.ts
CHANGED
|
@@ -11,6 +11,17 @@ export const IssueStatus = {
|
|
|
11
11
|
CLOSED: 1,
|
|
12
12
|
} as const;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Represents a PostgreSQL configuration parameter change recommendation.
|
|
16
|
+
* Used in action items to suggest config tuning.
|
|
17
|
+
*/
|
|
18
|
+
export interface ConfigChange {
|
|
19
|
+
/** PostgreSQL configuration parameter name (e.g., 'work_mem', 'shared_buffers') */
|
|
20
|
+
parameter: string;
|
|
21
|
+
/** Recommended value for the parameter (e.g., '256MB', '4GB') */
|
|
22
|
+
value: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
export interface IssueActionItem {
|
|
15
26
|
id: string;
|
|
16
27
|
issue_id: string;
|
|
@@ -20,10 +31,27 @@ export interface IssueActionItem {
|
|
|
20
31
|
is_done: boolean;
|
|
21
32
|
done_by: number | null;
|
|
22
33
|
done_at: string | null;
|
|
34
|
+
status: string;
|
|
35
|
+
status_reason: string | null;
|
|
36
|
+
status_changed_by: number | null;
|
|
37
|
+
status_changed_at: string | null;
|
|
38
|
+
sql_action: string | null;
|
|
39
|
+
configs: ConfigChange[];
|
|
23
40
|
created_at: string;
|
|
24
41
|
updated_at: string;
|
|
25
42
|
}
|
|
26
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Summary of an action item (minimal fields for list views).
|
|
46
|
+
* Used in issue detail responses to provide quick overview of action items.
|
|
47
|
+
*/
|
|
48
|
+
export interface IssueActionItemSummary {
|
|
49
|
+
/** Action item ID (UUID) */
|
|
50
|
+
id: string;
|
|
51
|
+
/** Action item title */
|
|
52
|
+
title: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
27
55
|
export interface Issue {
|
|
28
56
|
id: string;
|
|
29
57
|
title: string;
|
|
@@ -59,15 +87,21 @@ export interface IssueComment {
|
|
|
59
87
|
|
|
60
88
|
export type IssueListItem = Pick<Issue, "id" | "title" | "status" | "created_at">;
|
|
61
89
|
|
|
62
|
-
export type IssueDetail = Pick<Issue, "id" | "title" | "description" | "status" | "created_at" | "author_display_name"
|
|
90
|
+
export type IssueDetail = Pick<Issue, "id" | "title" | "description" | "status" | "created_at" | "author_display_name"> & {
|
|
91
|
+
action_items: IssueActionItemSummary[];
|
|
92
|
+
};
|
|
63
93
|
export interface FetchIssuesParams {
|
|
64
94
|
apiKey: string;
|
|
65
95
|
apiBaseUrl: string;
|
|
96
|
+
orgId?: number;
|
|
97
|
+
status?: "open" | "closed";
|
|
98
|
+
limit?: number;
|
|
99
|
+
offset?: number;
|
|
66
100
|
debug?: boolean;
|
|
67
101
|
}
|
|
68
102
|
|
|
69
103
|
export async function fetchIssues(params: FetchIssuesParams): Promise<IssueListItem[]> {
|
|
70
|
-
const { apiKey, apiBaseUrl, debug } = params;
|
|
104
|
+
const { apiKey, apiBaseUrl, orgId, status, limit = 20, offset = 0, debug } = params;
|
|
71
105
|
if (!apiKey) {
|
|
72
106
|
throw new Error("API key is required");
|
|
73
107
|
}
|
|
@@ -75,6 +109,17 @@ export async function fetchIssues(params: FetchIssuesParams): Promise<IssueListI
|
|
|
75
109
|
const base = normalizeBaseUrl(apiBaseUrl);
|
|
76
110
|
const url = new URL(`${base}/issues`);
|
|
77
111
|
url.searchParams.set("select", "id,title,status,created_at");
|
|
112
|
+
url.searchParams.set("order", "id.desc");
|
|
113
|
+
url.searchParams.set("limit", String(limit));
|
|
114
|
+
url.searchParams.set("offset", String(offset));
|
|
115
|
+
if (typeof orgId === "number") {
|
|
116
|
+
url.searchParams.set("org_id", `eq.${orgId}`);
|
|
117
|
+
}
|
|
118
|
+
if (status === "open") {
|
|
119
|
+
url.searchParams.set("status", "eq.0");
|
|
120
|
+
} else if (status === "closed") {
|
|
121
|
+
url.searchParams.set("status", "eq.1");
|
|
122
|
+
}
|
|
78
123
|
|
|
79
124
|
const headers: Record<string, string> = {
|
|
80
125
|
"access-token": apiKey,
|
|
@@ -190,7 +235,7 @@ export async function fetchIssue(params: FetchIssueParams): Promise<IssueDetail
|
|
|
190
235
|
|
|
191
236
|
const base = normalizeBaseUrl(apiBaseUrl);
|
|
192
237
|
const url = new URL(`${base}/issues`);
|
|
193
|
-
url.searchParams.set("select", "id,title,description,status,created_at,author_display_name");
|
|
238
|
+
url.searchParams.set("select", "id,title,description,status,created_at,author_display_name,action_items");
|
|
194
239
|
url.searchParams.set("id", `eq.${issueId}`);
|
|
195
240
|
url.searchParams.set("limit", "1");
|
|
196
241
|
|
|
@@ -224,11 +269,23 @@ export async function fetchIssue(params: FetchIssueParams): Promise<IssueDetail
|
|
|
224
269
|
if (response.ok) {
|
|
225
270
|
try {
|
|
226
271
|
const parsed = JSON.parse(data);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return parsed as IssueDetail;
|
|
272
|
+
const rawIssue = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
273
|
+
if (!rawIssue) {
|
|
274
|
+
return null;
|
|
231
275
|
}
|
|
276
|
+
// Map action_items to summary (id, title only)
|
|
277
|
+
const actionItemsSummary: IssueActionItemSummary[] = Array.isArray(rawIssue.action_items)
|
|
278
|
+
? rawIssue.action_items.map((item: IssueActionItem) => ({ id: item.id, title: item.title }))
|
|
279
|
+
: [];
|
|
280
|
+
return {
|
|
281
|
+
id: rawIssue.id,
|
|
282
|
+
title: rawIssue.title,
|
|
283
|
+
description: rawIssue.description,
|
|
284
|
+
status: rawIssue.status,
|
|
285
|
+
created_at: rawIssue.created_at,
|
|
286
|
+
author_display_name: rawIssue.author_display_name,
|
|
287
|
+
action_items: actionItemsSummary,
|
|
288
|
+
} as IssueDetail;
|
|
232
289
|
} catch {
|
|
233
290
|
throw new Error(`Failed to parse issue response: ${data}`);
|
|
234
291
|
}
|
|
@@ -612,3 +669,392 @@ export async function updateIssueComment(params: UpdateIssueCommentParams): Prom
|
|
|
612
669
|
throw new Error(formatHttpError("Failed to update issue comment", response.status, data));
|
|
613
670
|
}
|
|
614
671
|
}
|
|
672
|
+
|
|
673
|
+
// ============================================================================
|
|
674
|
+
// Action Items API Functions
|
|
675
|
+
// ============================================================================
|
|
676
|
+
|
|
677
|
+
export interface FetchActionItemParams {
|
|
678
|
+
apiKey: string;
|
|
679
|
+
apiBaseUrl: string;
|
|
680
|
+
actionItemIds: string | string[];
|
|
681
|
+
debug?: boolean;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Fetch action item(s) by ID(s).
|
|
686
|
+
* Supports single ID or array of IDs.
|
|
687
|
+
*
|
|
688
|
+
* @param params - Fetch parameters
|
|
689
|
+
* @param params.apiKey - API authentication key
|
|
690
|
+
* @param params.apiBaseUrl - Base URL for the API
|
|
691
|
+
* @param params.actionItemIds - Single action item ID or array of IDs (UUIDs)
|
|
692
|
+
* @param params.debug - Enable debug logging
|
|
693
|
+
* @returns Array of action items matching the provided IDs
|
|
694
|
+
* @throws Error if API key is missing or no valid IDs provided
|
|
695
|
+
*
|
|
696
|
+
* @example
|
|
697
|
+
* // Fetch single action item
|
|
698
|
+
* const items = await fetchActionItem({ apiKey, apiBaseUrl, actionItemIds: "uuid-123" });
|
|
699
|
+
*
|
|
700
|
+
* @example
|
|
701
|
+
* // Fetch multiple action items
|
|
702
|
+
* const items = await fetchActionItem({ apiKey, apiBaseUrl, actionItemIds: ["uuid-1", "uuid-2"] });
|
|
703
|
+
*/
|
|
704
|
+
export async function fetchActionItem(params: FetchActionItemParams): Promise<IssueActionItem[]> {
|
|
705
|
+
const { apiKey, apiBaseUrl, actionItemIds, debug } = params;
|
|
706
|
+
if (!apiKey) {
|
|
707
|
+
throw new Error("API key is required");
|
|
708
|
+
}
|
|
709
|
+
// UUID format pattern for validation
|
|
710
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
711
|
+
// Normalize to array, filter out null/undefined, trim, and validate UUID format
|
|
712
|
+
const rawIds = Array.isArray(actionItemIds) ? actionItemIds : [actionItemIds];
|
|
713
|
+
const validIds = rawIds
|
|
714
|
+
.filter((id): id is string => id != null && typeof id === "string")
|
|
715
|
+
.map(id => id.trim())
|
|
716
|
+
.filter(id => id.length > 0 && uuidPattern.test(id));
|
|
717
|
+
if (validIds.length === 0) {
|
|
718
|
+
throw new Error("actionItemId is required and must be a valid UUID");
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const base = normalizeBaseUrl(apiBaseUrl);
|
|
722
|
+
const url = new URL(`${base}/issue_action_items`);
|
|
723
|
+
if (validIds.length === 1) {
|
|
724
|
+
url.searchParams.set("id", `eq.${validIds[0]}`);
|
|
725
|
+
} else {
|
|
726
|
+
// PostgREST IN syntax: id=in.(val1,val2,val3)
|
|
727
|
+
url.searchParams.set("id", `in.(${validIds.join(",")})`)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const headers: Record<string, string> = {
|
|
731
|
+
"access-token": apiKey,
|
|
732
|
+
"Prefer": "return=representation",
|
|
733
|
+
"Content-Type": "application/json",
|
|
734
|
+
"Connection": "close",
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
if (debug) {
|
|
738
|
+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
739
|
+
console.log(`Debug: Resolved API base URL: ${base}`);
|
|
740
|
+
console.log(`Debug: GET URL: ${url.toString()}`);
|
|
741
|
+
console.log(`Debug: Auth scheme: access-token`);
|
|
742
|
+
console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const response = await fetch(url.toString(), {
|
|
746
|
+
method: "GET",
|
|
747
|
+
headers,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
if (debug) {
|
|
751
|
+
console.log(`Debug: Response status: ${response.status}`);
|
|
752
|
+
console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const data = await response.text();
|
|
756
|
+
|
|
757
|
+
if (response.ok) {
|
|
758
|
+
try {
|
|
759
|
+
const parsed = JSON.parse(data);
|
|
760
|
+
if (Array.isArray(parsed)) {
|
|
761
|
+
return parsed as IssueActionItem[];
|
|
762
|
+
}
|
|
763
|
+
return parsed ? [parsed as IssueActionItem] : [];
|
|
764
|
+
} catch {
|
|
765
|
+
throw new Error(`Failed to parse action item response: ${data}`);
|
|
766
|
+
}
|
|
767
|
+
} else {
|
|
768
|
+
throw new Error(formatHttpError("Failed to fetch action item", response.status, data));
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export interface FetchActionItemsParams {
|
|
773
|
+
apiKey: string;
|
|
774
|
+
apiBaseUrl: string;
|
|
775
|
+
issueId: string;
|
|
776
|
+
debug?: boolean;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Fetch all action items for an issue.
|
|
781
|
+
*
|
|
782
|
+
* @param params - Fetch parameters
|
|
783
|
+
* @param params.apiKey - API authentication key
|
|
784
|
+
* @param params.apiBaseUrl - Base URL for the API
|
|
785
|
+
* @param params.issueId - Issue ID (UUID) to fetch action items for
|
|
786
|
+
* @param params.debug - Enable debug logging
|
|
787
|
+
* @returns Array of action items for the specified issue
|
|
788
|
+
* @throws Error if API key or issue ID is missing
|
|
789
|
+
*/
|
|
790
|
+
export async function fetchActionItems(params: FetchActionItemsParams): Promise<IssueActionItem[]> {
|
|
791
|
+
const { apiKey, apiBaseUrl, issueId, debug } = params;
|
|
792
|
+
if (!apiKey) {
|
|
793
|
+
throw new Error("API key is required");
|
|
794
|
+
}
|
|
795
|
+
if (!issueId) {
|
|
796
|
+
throw new Error("issueId is required");
|
|
797
|
+
}
|
|
798
|
+
// Validate UUID format to prevent PostgREST injection
|
|
799
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
800
|
+
if (!uuidPattern.test(issueId.trim())) {
|
|
801
|
+
throw new Error("issueId must be a valid UUID");
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const base = normalizeBaseUrl(apiBaseUrl);
|
|
805
|
+
const url = new URL(`${base}/issue_action_items`);
|
|
806
|
+
url.searchParams.set("issue_id", `eq.${issueId.trim()}`);
|
|
807
|
+
|
|
808
|
+
const headers: Record<string, string> = {
|
|
809
|
+
"access-token": apiKey,
|
|
810
|
+
"Prefer": "return=representation",
|
|
811
|
+
"Content-Type": "application/json",
|
|
812
|
+
"Connection": "close",
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
if (debug) {
|
|
816
|
+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
817
|
+
console.log(`Debug: Resolved API base URL: ${base}`);
|
|
818
|
+
console.log(`Debug: GET URL: ${url.toString()}`);
|
|
819
|
+
console.log(`Debug: Auth scheme: access-token`);
|
|
820
|
+
console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const response = await fetch(url.toString(), {
|
|
824
|
+
method: "GET",
|
|
825
|
+
headers,
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
if (debug) {
|
|
829
|
+
console.log(`Debug: Response status: ${response.status}`);
|
|
830
|
+
console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const data = await response.text();
|
|
834
|
+
|
|
835
|
+
if (response.ok) {
|
|
836
|
+
try {
|
|
837
|
+
return JSON.parse(data) as IssueActionItem[];
|
|
838
|
+
} catch {
|
|
839
|
+
throw new Error(`Failed to parse action items response: ${data}`);
|
|
840
|
+
}
|
|
841
|
+
} else {
|
|
842
|
+
throw new Error(formatHttpError("Failed to fetch action items", response.status, data));
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export interface CreateActionItemParams {
|
|
847
|
+
apiKey: string;
|
|
848
|
+
apiBaseUrl: string;
|
|
849
|
+
issueId: string;
|
|
850
|
+
title: string;
|
|
851
|
+
description?: string;
|
|
852
|
+
sqlAction?: string;
|
|
853
|
+
configs?: ConfigChange[];
|
|
854
|
+
debug?: boolean;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Create a new action item for an issue.
|
|
859
|
+
*
|
|
860
|
+
* @param params - Creation parameters
|
|
861
|
+
* @param params.apiKey - API authentication key
|
|
862
|
+
* @param params.apiBaseUrl - Base URL for the API
|
|
863
|
+
* @param params.issueId - Issue ID (UUID) to create action item for
|
|
864
|
+
* @param params.title - Action item title
|
|
865
|
+
* @param params.description - Optional detailed description
|
|
866
|
+
* @param params.sqlAction - Optional SQL command to execute
|
|
867
|
+
* @param params.configs - Optional configuration parameter changes
|
|
868
|
+
* @param params.debug - Enable debug logging
|
|
869
|
+
* @returns Created action item ID
|
|
870
|
+
* @throws Error if required fields are missing or API call fails
|
|
871
|
+
*/
|
|
872
|
+
export async function createActionItem(params: CreateActionItemParams): Promise<string> {
|
|
873
|
+
const { apiKey, apiBaseUrl, issueId, title, description, sqlAction, configs, debug } = params;
|
|
874
|
+
if (!apiKey) {
|
|
875
|
+
throw new Error("API key is required");
|
|
876
|
+
}
|
|
877
|
+
if (!issueId) {
|
|
878
|
+
throw new Error("issueId is required");
|
|
879
|
+
}
|
|
880
|
+
// Validate UUID format
|
|
881
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
882
|
+
if (!uuidPattern.test(issueId.trim())) {
|
|
883
|
+
throw new Error("issueId must be a valid UUID");
|
|
884
|
+
}
|
|
885
|
+
if (!title) {
|
|
886
|
+
throw new Error("title is required");
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const base = normalizeBaseUrl(apiBaseUrl);
|
|
890
|
+
const url = new URL(`${base}/rpc/issue_action_item_create`);
|
|
891
|
+
|
|
892
|
+
const bodyObj: Record<string, unknown> = {
|
|
893
|
+
issue_id: issueId,
|
|
894
|
+
title: title,
|
|
895
|
+
};
|
|
896
|
+
if (description !== undefined) {
|
|
897
|
+
bodyObj.description = description;
|
|
898
|
+
}
|
|
899
|
+
if (sqlAction !== undefined) {
|
|
900
|
+
bodyObj.sql_action = sqlAction;
|
|
901
|
+
}
|
|
902
|
+
if (configs !== undefined) {
|
|
903
|
+
bodyObj.configs = configs;
|
|
904
|
+
}
|
|
905
|
+
const body = JSON.stringify(bodyObj);
|
|
906
|
+
|
|
907
|
+
const headers: Record<string, string> = {
|
|
908
|
+
"access-token": apiKey,
|
|
909
|
+
"Prefer": "return=representation",
|
|
910
|
+
"Content-Type": "application/json",
|
|
911
|
+
"Connection": "close",
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
if (debug) {
|
|
915
|
+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
916
|
+
console.log(`Debug: Resolved API base URL: ${base}`);
|
|
917
|
+
console.log(`Debug: POST URL: ${url.toString()}`);
|
|
918
|
+
console.log(`Debug: Auth scheme: access-token`);
|
|
919
|
+
console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
920
|
+
console.log(`Debug: Request body: ${body}`);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const response = await fetch(url.toString(), {
|
|
924
|
+
method: "POST",
|
|
925
|
+
headers,
|
|
926
|
+
body,
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
if (debug) {
|
|
930
|
+
console.log(`Debug: Response status: ${response.status}`);
|
|
931
|
+
console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const data = await response.text();
|
|
935
|
+
|
|
936
|
+
if (response.ok) {
|
|
937
|
+
try {
|
|
938
|
+
return JSON.parse(data) as string;
|
|
939
|
+
} catch {
|
|
940
|
+
throw new Error(`Failed to parse create action item response: ${data}`);
|
|
941
|
+
}
|
|
942
|
+
} else {
|
|
943
|
+
throw new Error(formatHttpError("Failed to create action item", response.status, data));
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export interface UpdateActionItemParams {
|
|
948
|
+
apiKey: string;
|
|
949
|
+
apiBaseUrl: string;
|
|
950
|
+
actionItemId: string;
|
|
951
|
+
title?: string;
|
|
952
|
+
description?: string;
|
|
953
|
+
isDone?: boolean;
|
|
954
|
+
status?: string;
|
|
955
|
+
statusReason?: string;
|
|
956
|
+
sqlAction?: string;
|
|
957
|
+
configs?: ConfigChange[];
|
|
958
|
+
debug?: boolean;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Update an existing action item.
|
|
963
|
+
*
|
|
964
|
+
* @param params - Update parameters
|
|
965
|
+
* @param params.apiKey - API authentication key
|
|
966
|
+
* @param params.apiBaseUrl - Base URL for the API
|
|
967
|
+
* @param params.actionItemId - Action item ID (UUID) to update
|
|
968
|
+
* @param params.title - New title
|
|
969
|
+
* @param params.description - New description
|
|
970
|
+
* @param params.isDone - Mark as done/not done
|
|
971
|
+
* @param params.status - Approval status: 'waiting_for_approval', 'approved', 'rejected'
|
|
972
|
+
* @param params.statusReason - Reason for status change
|
|
973
|
+
* @param params.sqlAction - SQL command to execute
|
|
974
|
+
* @param params.configs - Configuration parameter changes
|
|
975
|
+
* @param params.debug - Enable debug logging
|
|
976
|
+
* @throws Error if required fields missing or no update fields provided
|
|
977
|
+
*/
|
|
978
|
+
export async function updateActionItem(params: UpdateActionItemParams): Promise<void> {
|
|
979
|
+
const { apiKey, apiBaseUrl, actionItemId, title, description, isDone, status, statusReason, sqlAction, configs, debug } = params;
|
|
980
|
+
if (!apiKey) {
|
|
981
|
+
throw new Error("API key is required");
|
|
982
|
+
}
|
|
983
|
+
if (!actionItemId) {
|
|
984
|
+
throw new Error("actionItemId is required");
|
|
985
|
+
}
|
|
986
|
+
// Validate UUID format
|
|
987
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
988
|
+
if (!uuidPattern.test(actionItemId.trim())) {
|
|
989
|
+
throw new Error("actionItemId must be a valid UUID");
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Check that at least one update field is provided
|
|
993
|
+
const hasUpdateField = title !== undefined || description !== undefined ||
|
|
994
|
+
isDone !== undefined || status !== undefined ||
|
|
995
|
+
statusReason !== undefined || sqlAction !== undefined || configs !== undefined;
|
|
996
|
+
if (!hasUpdateField) {
|
|
997
|
+
throw new Error("At least one field to update is required");
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const base = normalizeBaseUrl(apiBaseUrl);
|
|
1001
|
+
const url = new URL(`${base}/rpc/issue_action_item_update`);
|
|
1002
|
+
|
|
1003
|
+
const bodyObj: Record<string, unknown> = {
|
|
1004
|
+
action_item_id: actionItemId,
|
|
1005
|
+
};
|
|
1006
|
+
if (title !== undefined) {
|
|
1007
|
+
bodyObj.title = title;
|
|
1008
|
+
}
|
|
1009
|
+
if (description !== undefined) {
|
|
1010
|
+
bodyObj.description = description;
|
|
1011
|
+
}
|
|
1012
|
+
if (isDone !== undefined) {
|
|
1013
|
+
bodyObj.is_done = isDone;
|
|
1014
|
+
}
|
|
1015
|
+
if (status !== undefined) {
|
|
1016
|
+
bodyObj.status = status;
|
|
1017
|
+
}
|
|
1018
|
+
if (statusReason !== undefined) {
|
|
1019
|
+
bodyObj.status_reason = statusReason;
|
|
1020
|
+
}
|
|
1021
|
+
if (sqlAction !== undefined) {
|
|
1022
|
+
bodyObj.sql_action = sqlAction;
|
|
1023
|
+
}
|
|
1024
|
+
if (configs !== undefined) {
|
|
1025
|
+
bodyObj.configs = configs;
|
|
1026
|
+
}
|
|
1027
|
+
const body = JSON.stringify(bodyObj);
|
|
1028
|
+
|
|
1029
|
+
const headers: Record<string, string> = {
|
|
1030
|
+
"access-token": apiKey,
|
|
1031
|
+
"Prefer": "return=representation",
|
|
1032
|
+
"Content-Type": "application/json",
|
|
1033
|
+
"Connection": "close",
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
if (debug) {
|
|
1037
|
+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
1038
|
+
console.log(`Debug: Resolved API base URL: ${base}`);
|
|
1039
|
+
console.log(`Debug: POST URL: ${url.toString()}`);
|
|
1040
|
+
console.log(`Debug: Auth scheme: access-token`);
|
|
1041
|
+
console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
1042
|
+
console.log(`Debug: Request body: ${body}`);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const response = await fetch(url.toString(), {
|
|
1046
|
+
method: "POST",
|
|
1047
|
+
headers,
|
|
1048
|
+
body,
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
if (debug) {
|
|
1052
|
+
console.log(`Debug: Response status: ${response.status}`);
|
|
1053
|
+
console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (!response.ok) {
|
|
1057
|
+
const data = await response.text();
|
|
1058
|
+
throw new Error(formatHttpError("Failed to update action item", response.status, data));
|
|
1059
|
+
}
|
|
1060
|
+
}
|