postgresai 0.15.0-dev.1 → 0.15.0-dev.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/reports.ts ADDED
@@ -0,0 +1,373 @@
1
+ import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util";
2
+
3
+ // ============================================================================
4
+ // Types
5
+ // ============================================================================
6
+
7
+ export interface CheckupReport {
8
+ id: number;
9
+ org_id: number;
10
+ org_name: string;
11
+ project_id: number;
12
+ project_name: string;
13
+ created_at: string;
14
+ created_formatted: string;
15
+ epoch: number;
16
+ status: string;
17
+ }
18
+
19
+ export interface CheckupReportFile {
20
+ id: number;
21
+ checkup_report_id: number;
22
+ filename: string;
23
+ check_id: string;
24
+ type: "json" | "md";
25
+ created_at: string;
26
+ created_formatted: string;
27
+ project_id: number;
28
+ project_name: string;
29
+ }
30
+
31
+ export interface CheckupReportFileData extends CheckupReportFile {
32
+ data: string;
33
+ }
34
+
35
+ // ============================================================================
36
+ // Date parsing
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Parse a date string in various formats into an ISO 8601 string.
41
+ * Supported formats:
42
+ * YYYY-MM-DD 2025-01-15
43
+ * YYYY-MM-DDTHH:mm:ss 2025-01-15T10:30:00
44
+ * YYYY-MM-DD HH:mm:ss 2025-01-15 10:30:00
45
+ * YYYY-MM-DD HH:mm 2025-01-15 10:30
46
+ * DD.MM.YYYY 15.01.2025
47
+ * DD.MM.YYYY HH:mm 15.01.2025 10:30
48
+ * DD.MM.YYYY HH:mm:ss 15.01.2025 10:30:00
49
+ */
50
+ export function parseFlexibleDate(input: string): string {
51
+ const s = input.trim();
52
+
53
+ // DD.MM.YYYY [HH:mm[:ss]]
54
+ const dotMatch = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
55
+ if (dotMatch) {
56
+ const [, dd, mm, yyyy, hh, min, ss] = dotMatch;
57
+ const iso = `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}T${(hh ?? "00").padStart(2, "0")}:${(min ?? "00").padStart(2, "0")}:${(ss ?? "00").padStart(2, "0")}Z`;
58
+ const d = new Date(iso);
59
+ if (isNaN(d.getTime())) throw new Error(`Invalid date: ${input}`);
60
+ return d.toISOString();
61
+ }
62
+
63
+ // YYYY-MM-DD[T ]HH:mm[:ss] or YYYY-MM-DD
64
+ const isoMatch = s.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/);
65
+ if (isoMatch) {
66
+ const [, yyyy, mm, dd, hh, min, ss] = isoMatch;
67
+ const iso = `${yyyy}-${mm}-${dd}T${hh ?? "00"}:${min ?? "00"}:${ss ?? "00"}Z`;
68
+ const d = new Date(iso);
69
+ if (isNaN(d.getTime())) throw new Error(`Invalid date: ${input}`);
70
+ return d.toISOString();
71
+ }
72
+
73
+ throw new Error(`Unrecognized date format: ${input}. Use YYYY-MM-DD or DD.MM.YYYY`);
74
+ }
75
+
76
+ // ============================================================================
77
+ // Params
78
+ // ============================================================================
79
+
80
+ export interface FetchReportsParams {
81
+ apiKey: string;
82
+ apiBaseUrl: string;
83
+ projectId?: number;
84
+ status?: string;
85
+ limit?: number;
86
+ beforeDate?: string;
87
+ /** @internal Used by fetchAllReports for keyset pagination */
88
+ beforeId?: number;
89
+ debug?: boolean;
90
+ }
91
+
92
+ export interface FetchReportFilesParams {
93
+ apiKey: string;
94
+ apiBaseUrl: string;
95
+ reportId?: number;
96
+ type?: "json" | "md";
97
+ checkId?: string;
98
+ debug?: boolean;
99
+ }
100
+
101
+ export interface FetchReportFileDataParams {
102
+ apiKey: string;
103
+ apiBaseUrl: string;
104
+ reportId?: number;
105
+ type?: "json" | "md";
106
+ checkId?: string;
107
+ debug?: boolean;
108
+ }
109
+
110
+ // ============================================================================
111
+ // API functions
112
+ // ============================================================================
113
+
114
+ export async function fetchReports(params: FetchReportsParams): Promise<CheckupReport[]> {
115
+ const { apiKey, apiBaseUrl, projectId, status, limit = 20, beforeDate, beforeId, debug } = params;
116
+ if (!apiKey) {
117
+ throw new Error("API key is required");
118
+ }
119
+
120
+ const base = normalizeBaseUrl(apiBaseUrl);
121
+ const url = new URL(`${base}/checkup_reports`);
122
+ url.searchParams.set("order", "id.desc");
123
+ url.searchParams.set("limit", String(limit));
124
+ if (typeof projectId === "number") {
125
+ url.searchParams.set("project_id", `eq.${projectId}`);
126
+ }
127
+ if (status) {
128
+ url.searchParams.set("status", `eq.${status}`);
129
+ }
130
+ if (beforeDate) {
131
+ url.searchParams.set("created_at", `lt.${beforeDate}`);
132
+ }
133
+ if (typeof beforeId === "number") {
134
+ url.searchParams.set("id", `lt.${beforeId}`);
135
+ }
136
+
137
+ const headers: Record<string, string> = {
138
+ "access-token": apiKey,
139
+ "Prefer": "return=representation",
140
+ "Content-Type": "application/json",
141
+ "Connection": "close",
142
+ };
143
+
144
+ if (debug) {
145
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
146
+ console.error(`Debug: Resolved API base URL: ${base}`);
147
+ console.error(`Debug: GET URL: ${url.toString()}`);
148
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
149
+ }
150
+
151
+ const response = await fetch(url.toString(), { method: "GET", headers });
152
+
153
+ if (debug) {
154
+ console.error(`Debug: Response status: ${response.status}`);
155
+ }
156
+
157
+ const data = await response.text();
158
+
159
+ if (response.ok) {
160
+ try {
161
+ return JSON.parse(data) as CheckupReport[];
162
+ } catch {
163
+ throw new Error(`Failed to parse reports response: ${data}`);
164
+ }
165
+ } else {
166
+ throw new Error(formatHttpError("Failed to fetch reports", response.status, data));
167
+ }
168
+ }
169
+
170
+ const MAX_ALL_REPORTS = 10000;
171
+
172
+ export async function fetchAllReports(params: Omit<FetchReportsParams, "beforeId" | "beforeDate">): Promise<CheckupReport[]> {
173
+ const pageSize = params.limit ?? 100;
174
+ const all: CheckupReport[] = [];
175
+ let beforeId: number | undefined;
176
+
177
+ while (true) {
178
+ const page = await fetchReports({ ...params, limit: pageSize, beforeId });
179
+ if (page.length === 0) break;
180
+ all.push(...page);
181
+ if (all.length >= MAX_ALL_REPORTS) {
182
+ console.warn(`Warning: reached maximum of ${MAX_ALL_REPORTS} reports, stopping pagination`);
183
+ break;
184
+ }
185
+ beforeId = page[page.length - 1].id;
186
+ if (page.length < pageSize) break;
187
+ }
188
+
189
+ return all;
190
+ }
191
+
192
+ export async function fetchReportFiles(params: FetchReportFilesParams): Promise<CheckupReportFile[]> {
193
+ const { apiKey, apiBaseUrl, reportId, type, checkId, debug } = params;
194
+ if (!apiKey) {
195
+ throw new Error("API key is required");
196
+ }
197
+ if (reportId === undefined && !checkId) {
198
+ throw new Error("Either reportId or checkId is required");
199
+ }
200
+
201
+ const base = normalizeBaseUrl(apiBaseUrl);
202
+ const url = new URL(`${base}/checkup_report_files`);
203
+ if (typeof reportId === "number") {
204
+ url.searchParams.set("checkup_report_id", `eq.${reportId}`);
205
+ }
206
+ url.searchParams.set("order", "id.asc");
207
+ if (type) {
208
+ url.searchParams.set("type", `eq.${type}`);
209
+ }
210
+ if (checkId) {
211
+ url.searchParams.set("check_id", `eq.${checkId}`);
212
+ }
213
+
214
+ const headers: Record<string, string> = {
215
+ "access-token": apiKey,
216
+ "Prefer": "return=representation",
217
+ "Content-Type": "application/json",
218
+ "Connection": "close",
219
+ };
220
+
221
+ if (debug) {
222
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
223
+ console.error(`Debug: Resolved API base URL: ${base}`);
224
+ console.error(`Debug: GET URL: ${url.toString()}`);
225
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
226
+ }
227
+
228
+ const response = await fetch(url.toString(), { method: "GET", headers });
229
+
230
+ if (debug) {
231
+ console.error(`Debug: Response status: ${response.status}`);
232
+ }
233
+
234
+ const data = await response.text();
235
+
236
+ if (response.ok) {
237
+ try {
238
+ return JSON.parse(data) as CheckupReportFile[];
239
+ } catch {
240
+ throw new Error(`Failed to parse report files response: ${data}`);
241
+ }
242
+ } else {
243
+ throw new Error(formatHttpError("Failed to fetch report files", response.status, data));
244
+ }
245
+ }
246
+
247
+ export async function fetchReportFileData(params: FetchReportFileDataParams): Promise<CheckupReportFileData[]> {
248
+ const { apiKey, apiBaseUrl, reportId, type, checkId, debug } = params;
249
+ if (!apiKey) {
250
+ throw new Error("API key is required");
251
+ }
252
+ if (reportId === undefined && !checkId) {
253
+ throw new Error("Either reportId or checkId is required");
254
+ }
255
+
256
+ const base = normalizeBaseUrl(apiBaseUrl);
257
+ const url = new URL(`${base}/checkup_report_file_data`);
258
+ if (typeof reportId === "number") {
259
+ url.searchParams.set("checkup_report_id", `eq.${reportId}`);
260
+ }
261
+ url.searchParams.set("order", "id.asc");
262
+ if (type) {
263
+ url.searchParams.set("type", `eq.${type}`);
264
+ }
265
+ if (checkId) {
266
+ url.searchParams.set("check_id", `eq.${checkId}`);
267
+ }
268
+
269
+ const headers: Record<string, string> = {
270
+ "access-token": apiKey,
271
+ "Prefer": "return=representation",
272
+ "Content-Type": "application/json",
273
+ "Connection": "close",
274
+ };
275
+
276
+ if (debug) {
277
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
278
+ console.error(`Debug: Resolved API base URL: ${base}`);
279
+ console.error(`Debug: GET URL: ${url.toString()}`);
280
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
281
+ }
282
+
283
+ const response = await fetch(url.toString(), { method: "GET", headers });
284
+
285
+ if (debug) {
286
+ console.error(`Debug: Response status: ${response.status}`);
287
+ }
288
+
289
+ const data = await response.text();
290
+
291
+ if (response.ok) {
292
+ try {
293
+ return JSON.parse(data) as CheckupReportFileData[];
294
+ } catch {
295
+ throw new Error(`Failed to parse report file data response: ${data}`);
296
+ }
297
+ } else {
298
+ throw new Error(formatHttpError("Failed to fetch report file data", response.status, data));
299
+ }
300
+ }
301
+
302
+ // ============================================================================
303
+ // Lightweight markdown terminal renderer
304
+ // ============================================================================
305
+
306
+ export function renderMarkdownForTerminal(md: string): string {
307
+ if (!md) return "";
308
+
309
+ const RESET = "\x1b[0m";
310
+ const BOLD = "\x1b[1m";
311
+ const BOLD_UNDERLINE = "\x1b[1;4m";
312
+ const DIM = "\x1b[2m";
313
+ const ITALIC = "\x1b[3m";
314
+ const CYAN = "\x1b[36m";
315
+
316
+ const lines = md.split("\n");
317
+ const output: string[] = [];
318
+ let inCodeBlock = false;
319
+
320
+ for (const line of lines) {
321
+ // Code block toggle
322
+ if (line.trimStart().startsWith("```")) {
323
+ inCodeBlock = !inCodeBlock;
324
+ if (inCodeBlock) {
325
+ output.push(`${DIM}${"─".repeat(40)}${RESET}`);
326
+ } else {
327
+ output.push(`${DIM}${"─".repeat(40)}${RESET}`);
328
+ }
329
+ continue;
330
+ }
331
+
332
+ // Inside code block — dim output
333
+ if (inCodeBlock) {
334
+ output.push(`${DIM} ${line}${RESET}`);
335
+ continue;
336
+ }
337
+
338
+ // Horizontal rule
339
+ if (/^-{3,}$/.test(line.trim()) || /^\*{3,}$/.test(line.trim()) || /^_{3,}$/.test(line.trim())) {
340
+ output.push(`${DIM}${"─".repeat(60)}${RESET}`);
341
+ continue;
342
+ }
343
+
344
+ // Headings
345
+ const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
346
+ if (headingMatch) {
347
+ const level = headingMatch[1].length;
348
+ const text = headingMatch[2];
349
+ if (level === 1) {
350
+ output.push(`${BOLD_UNDERLINE}${text}${RESET}`);
351
+ } else {
352
+ output.push(`${BOLD}${text}${RESET}`);
353
+ }
354
+ continue;
355
+ }
356
+
357
+ // Inline formatting
358
+ let formatted = line;
359
+ // Bold: **text** or __text__
360
+ formatted = formatted.replace(/\*\*(.+?)\*\*/g, `${BOLD}$1${RESET}`);
361
+ formatted = formatted.replace(/__(.+?)__/g, `${BOLD}$1${RESET}`);
362
+ // Italic: *text* (only single, not inside **)
363
+ formatted = formatted.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, `${ITALIC}$1${RESET}`);
364
+ // Italic: _text_ — only at word boundaries (not inside identifiers like foo_bar_baz)
365
+ formatted = formatted.replace(/(?<=^|[\s(])_([^\s_](?:.*?[^\s_])?)_(?=$|[\s),.:;!?])/g, `${ITALIC}$1${RESET}`);
366
+ // Inline code: `text`
367
+ formatted = formatted.replace(/`([^`]+)`/g, `${CYAN}$1${RESET}`);
368
+
369
+ output.push(formatted);
370
+ }
371
+
372
+ return output.join("\n");
373
+ }