postgresai 0.14.0 → 0.15.0-dev.10
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 +3 -1
- package/bin/postgres-ai.ts +712 -108
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +2755 -572
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup.ts +465 -8
- package/lib/config.ts +7 -0
- package/lib/init.ts +196 -4
- package/lib/issues.ts +72 -72
- package/lib/mcp-server.ts +90 -0
- package/lib/metrics-loader.ts +6 -1
- package/lib/reports.ts +373 -0
- package/lib/storage.ts +291 -0
- package/lib/supabase.ts +8 -1
- package/lib/util.ts +7 -1
- package/package.json +4 -4
- package/scripts/embed-checkup-dictionary.ts +9 -0
- package/scripts/embed-metrics.ts +5 -0
- package/scripts/generate-release-notes.ts +283 -48
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +139 -0
- package/test/checkup.test.ts +1316 -2
- package/test/compose-cmd.test.ts +120 -0
- package/test/config-consistency.test.ts +321 -5
- package/test/init.integration.test.ts +27 -28
- package/test/init.test.ts +534 -6
- package/test/issues.cli.test.ts +230 -1
- package/test/mcp-server.test.ts +516 -0
- package/test/monitoring.test.ts +339 -0
- package/test/permission-check-sql.test.ts +116 -0
- package/test/reports.cli.test.ts +793 -0
- package/test/reports.test.ts +977 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/storage.test.ts +761 -0
- package/test/test-utils.ts +51 -2
- package/test/upgrade.test.ts +422 -0
package/lib/util.ts
CHANGED
|
@@ -70,15 +70,18 @@ export function maskSecret(secret: string): string {
|
|
|
70
70
|
export interface RootOptsLike {
|
|
71
71
|
apiBaseUrl?: string;
|
|
72
72
|
uiBaseUrl?: string;
|
|
73
|
+
storageBaseUrl?: string;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
export interface ConfigLike {
|
|
76
77
|
baseUrl?: string | null;
|
|
78
|
+
storageBaseUrl?: string | null;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
export interface ResolvedBaseUrls {
|
|
80
82
|
apiBaseUrl: string;
|
|
81
83
|
uiBaseUrl: string;
|
|
84
|
+
storageBaseUrl: string;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/**
|
|
@@ -105,17 +108,20 @@ export function normalizeBaseUrl(value: string): string {
|
|
|
105
108
|
export function resolveBaseUrls(
|
|
106
109
|
opts?: RootOptsLike,
|
|
107
110
|
cfg?: ConfigLike,
|
|
108
|
-
defaults: { apiBaseUrl?: string; uiBaseUrl?: string } = {}
|
|
111
|
+
defaults: { apiBaseUrl?: string; uiBaseUrl?: string; storageBaseUrl?: string } = {}
|
|
109
112
|
): ResolvedBaseUrls {
|
|
110
113
|
const defApi = defaults.apiBaseUrl || "https://postgres.ai/api/general/";
|
|
111
114
|
const defUi = defaults.uiBaseUrl || "https://console.postgres.ai";
|
|
115
|
+
const defStorage = defaults.storageBaseUrl || "https://postgres.ai/storage";
|
|
112
116
|
|
|
113
117
|
const apiCandidate = (opts?.apiBaseUrl || process.env.PGAI_API_BASE_URL || cfg?.baseUrl || defApi) as string;
|
|
114
118
|
const uiCandidate = (opts?.uiBaseUrl || process.env.PGAI_UI_BASE_URL || defUi) as string;
|
|
119
|
+
const storageCandidate = (opts?.storageBaseUrl || process.env.PGAI_STORAGE_BASE_URL || cfg?.storageBaseUrl || defStorage) as string;
|
|
115
120
|
|
|
116
121
|
return {
|
|
117
122
|
apiBaseUrl: normalizeBaseUrl(apiCandidate),
|
|
118
123
|
uiBaseUrl: normalizeBaseUrl(uiCandidate),
|
|
124
|
+
storageBaseUrl: normalizeBaseUrl(storageCandidate),
|
|
119
125
|
};
|
|
120
126
|
}
|
|
121
127
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "postgresai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0-dev.10",
|
|
4
4
|
"description": "postgres_ai CLI",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"private": false,
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"embed-metrics": "bun run scripts/embed-metrics.ts",
|
|
29
29
|
"embed-checkup-dictionary": "bun run scripts/embed-checkup-dictionary.ts",
|
|
30
30
|
"embed-all": "bun run embed-metrics && bun run embed-checkup-dictionary",
|
|
31
|
-
"build": "bun run embed-all && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r ./sql ./dist/sql",
|
|
31
|
+
"build": "bun run embed-all && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r ./sql ./dist/sql && cp ../instances.demo.yml ./instances.demo.yml",
|
|
32
32
|
"prepublishOnly": "npm run build",
|
|
33
33
|
"start": "bun ./bin/postgres-ai.ts --help",
|
|
34
34
|
"start:node": "node ./dist/bin/postgres-ai.js --help",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@modelcontextprotocol/sdk": "^1.20.2",
|
|
44
|
-
"commander": "^
|
|
44
|
+
"commander": "^14.0.3",
|
|
45
45
|
"js-yaml": "^4.1.0",
|
|
46
46
|
"pg": "^8.16.3"
|
|
47
47
|
},
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@types/pg": "^8.15.6",
|
|
52
52
|
"ajv": "^8.17.1",
|
|
53
53
|
"ajv-formats": "^3.0.1",
|
|
54
|
-
"typescript": "^
|
|
54
|
+
"typescript": "^6.0.2"
|
|
55
55
|
},
|
|
56
56
|
"publishConfig": {
|
|
57
57
|
"access": "public"
|
|
@@ -51,7 +51,16 @@ function generateTypeScript(data: CheckupDictionaryEntry[], sourceUrl: string):
|
|
|
51
51
|
return lines.join("\n");
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Allowed hosts for fetch requests to prevent SSRF
|
|
55
|
+
const ALLOWED_HOSTS = ["postgres.ai"];
|
|
56
|
+
|
|
54
57
|
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
|
|
58
|
+
// Validate URL against allowlist to prevent SSRF
|
|
59
|
+
const parsed = new URL(url);
|
|
60
|
+
if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
|
|
61
|
+
throw new Error(`Fetch blocked: host "${parsed.hostname}" is not in the allowlist`);
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
const controller = new AbortController();
|
|
56
65
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
57
66
|
|
package/scripts/embed-metrics.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Generates release notes from git commit history using conventional commits.
|
|
6
6
|
* Analyzes commits between two references (tags, commits, or branches).
|
|
7
|
+
* Fetches MR numbers from GitLab API and includes links.
|
|
7
8
|
*
|
|
8
9
|
* Usage:
|
|
9
10
|
* bun run scripts/generate-release-notes.ts [options]
|
|
@@ -14,11 +15,16 @@
|
|
|
14
15
|
* --version <ver> Version string for the release (e.g., "0.14.0")
|
|
15
16
|
* --format <fmt> Output format: markdown (default), json
|
|
16
17
|
* --output <file> Write to file instead of stdout
|
|
18
|
+
* --gitlab-project GitLab project path (default: postgres-ai/postgresai)
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import { execFileSync } from "child_process";
|
|
20
22
|
import * as fs from "fs";
|
|
21
23
|
|
|
24
|
+
// GitLab project configuration
|
|
25
|
+
const GITLAB_PROJECT = "postgres-ai/postgresai";
|
|
26
|
+
const GITLAB_MR_URL = `https://gitlab.com/${GITLAB_PROJECT}/-/merge_requests`;
|
|
27
|
+
|
|
22
28
|
// Valid git ref pattern: alphanumeric, dots, hyphens, underscores, slashes, tildes, carets
|
|
23
29
|
const GIT_REF_PATTERN = /^[a-zA-Z0-9._~^/+-]+$/;
|
|
24
30
|
// Valid git SHA pattern: 7-40 hex characters
|
|
@@ -65,6 +71,23 @@ function isExcludedAuthor(author: string): boolean {
|
|
|
65
71
|
return EXCLUDED_AUTHORS.some((excluded) => author.toLowerCase().includes(excluded.toLowerCase()));
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
interface GitLabMR {
|
|
75
|
+
iid: number;
|
|
76
|
+
title: string;
|
|
77
|
+
source_branch: string;
|
|
78
|
+
merged_at: string | null;
|
|
79
|
+
labels?: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ChangelogEntry {
|
|
83
|
+
mrNumber: number;
|
|
84
|
+
title: string;
|
|
85
|
+
type: string;
|
|
86
|
+
scope: string | null;
|
|
87
|
+
breaking: boolean;
|
|
88
|
+
authors: Set<string>;
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
interface ParsedCommit {
|
|
69
92
|
hash: string;
|
|
70
93
|
shortHash: string;
|
|
@@ -75,6 +98,7 @@ interface ParsedCommit {
|
|
|
75
98
|
breaking: boolean;
|
|
76
99
|
date: string;
|
|
77
100
|
author: string;
|
|
101
|
+
mrNumber?: number;
|
|
78
102
|
}
|
|
79
103
|
|
|
80
104
|
interface ReleaseNotes {
|
|
@@ -83,8 +107,9 @@ interface ReleaseNotes {
|
|
|
83
107
|
sinceRef: string;
|
|
84
108
|
untilRef: string;
|
|
85
109
|
commits: ParsedCommit[];
|
|
86
|
-
|
|
87
|
-
|
|
110
|
+
entries: ChangelogEntry[];
|
|
111
|
+
categories: Record<string, ChangelogEntry[]>;
|
|
112
|
+
breaking: ChangelogEntry[];
|
|
88
113
|
stats: {
|
|
89
114
|
total: number;
|
|
90
115
|
features: number;
|
|
@@ -102,6 +127,55 @@ function gitExec(args: string[]): string {
|
|
|
102
127
|
}
|
|
103
128
|
}
|
|
104
129
|
|
|
130
|
+
// Cache for GitLab MRs
|
|
131
|
+
let mrCache: GitLabMR[] | null = null;
|
|
132
|
+
|
|
133
|
+
async function fetchMergedMRs(sinceDate: string): Promise<GitLabMR[]> {
|
|
134
|
+
if (mrCache) return mrCache;
|
|
135
|
+
|
|
136
|
+
const mrs: GitLabMR[] = [];
|
|
137
|
+
const projectEncoded = encodeURIComponent(GITLAB_PROJECT);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
// Fetch merged MRs, paginate through all results
|
|
141
|
+
let page = 1;
|
|
142
|
+
const perPage = 100;
|
|
143
|
+
|
|
144
|
+
while (true) {
|
|
145
|
+
const url = `https://gitlab.com/api/v4/projects/${projectEncoded}/merge_requests?state=merged&per_page=${perPage}&page=${page}&updated_after=${sinceDate}`;
|
|
146
|
+
const response = await fetch(url);
|
|
147
|
+
|
|
148
|
+
if (!response.ok) break;
|
|
149
|
+
|
|
150
|
+
const pageMrs: GitLabMR[] = await response.json();
|
|
151
|
+
if (pageMrs.length === 0) break;
|
|
152
|
+
|
|
153
|
+
mrs.push(...pageMrs);
|
|
154
|
+
|
|
155
|
+
if (pageMrs.length < perPage) break;
|
|
156
|
+
page++;
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
// Silently fail - MR links are optional
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
mrCache = mrs;
|
|
163
|
+
return mrs;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildBranchToMRMap(mrs: GitLabMR[]): Map<string, GitLabMR> {
|
|
167
|
+
const map = new Map<string, GitLabMR>();
|
|
168
|
+
for (const mr of mrs) {
|
|
169
|
+
map.set(mr.source_branch, mr);
|
|
170
|
+
}
|
|
171
|
+
return map;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getMRNumberFromBranch(branch: string, mrMap: Map<string, GitLabMR>): number | undefined {
|
|
175
|
+
const mr = mrMap.get(branch);
|
|
176
|
+
return mr?.iid;
|
|
177
|
+
}
|
|
178
|
+
|
|
105
179
|
function parseArgs(): { since: string; until: string; version: string; format: string; output: string | null } {
|
|
106
180
|
const args = process.argv.slice(2);
|
|
107
181
|
const result = { since: "", until: "HEAD", version: "", format: "markdown", output: null as string | null };
|
|
@@ -264,6 +338,127 @@ function categorizeCommits(commits: ParsedCommit[]): Record<string, ParsedCommit
|
|
|
264
338
|
return categories;
|
|
265
339
|
}
|
|
266
340
|
|
|
341
|
+
function parseMRTitle(title: string): { type: string; scope: string | null; subject: string; breaking: boolean } {
|
|
342
|
+
// Parse conventional commit format from MR title: type(scope): subject
|
|
343
|
+
const match = title.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/);
|
|
344
|
+
|
|
345
|
+
if (match) {
|
|
346
|
+
let type = match[1]?.toLowerCase() || "other";
|
|
347
|
+
if (type === "feature") type = "feat";
|
|
348
|
+
if (type === "bugfix") type = "fix";
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
type,
|
|
352
|
+
scope: match[2] || null,
|
|
353
|
+
subject: match[4] || title,
|
|
354
|
+
breaking: !!match[3],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// If not conventional commit format, try to infer type from title
|
|
359
|
+
const lowerTitle = title.toLowerCase();
|
|
360
|
+
let inferredType = "other";
|
|
361
|
+
if (lowerTitle.startsWith("fix") || lowerTitle.includes("fix:") || lowerTitle.includes("bugfix")) {
|
|
362
|
+
inferredType = "fix";
|
|
363
|
+
} else if (lowerTitle.startsWith("feat") || lowerTitle.startsWith("add ") || lowerTitle.startsWith("implement")) {
|
|
364
|
+
inferredType = "feat";
|
|
365
|
+
} else if (lowerTitle.startsWith("docs") || lowerTitle.startsWith("doc:")) {
|
|
366
|
+
inferredType = "docs";
|
|
367
|
+
} else if (lowerTitle.startsWith("refactor")) {
|
|
368
|
+
inferredType = "refactor";
|
|
369
|
+
} else if (lowerTitle.startsWith("perf") || lowerTitle.includes("performance")) {
|
|
370
|
+
inferredType = "perf";
|
|
371
|
+
} else if (lowerTitle.startsWith("chore") || lowerTitle.startsWith("ci") || lowerTitle.startsWith("build")) {
|
|
372
|
+
inferredType = "chore";
|
|
373
|
+
} else if (lowerTitle.startsWith("test")) {
|
|
374
|
+
inferredType = "test";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
type: inferredType,
|
|
379
|
+
scope: null,
|
|
380
|
+
subject: title,
|
|
381
|
+
breaking: false,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
interface GroupResult {
|
|
386
|
+
entries: Map<number, ChangelogEntry>;
|
|
387
|
+
orphanedCommits: ParsedCommit[];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function groupCommitsByMR(commits: ParsedCommit[], mrMap: Map<string, GitLabMR>): GroupResult {
|
|
391
|
+
const entries = new Map<number, ChangelogEntry>();
|
|
392
|
+
const orphanedCommits: ParsedCommit[] = [];
|
|
393
|
+
|
|
394
|
+
for (const commit of commits) {
|
|
395
|
+
if (!commit.mrNumber) {
|
|
396
|
+
orphanedCommits.push(commit);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (entries.has(commit.mrNumber)) {
|
|
401
|
+
// Add author to existing entry
|
|
402
|
+
const entry = entries.get(commit.mrNumber)!;
|
|
403
|
+
if (commit.author && !isExcludedAuthor(commit.author)) {
|
|
404
|
+
entry.authors.add(normalizeAuthor(commit.author));
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
// Find MR data to get the title
|
|
408
|
+
let mrTitle = commit.subject;
|
|
409
|
+
for (const [, mr] of mrMap) {
|
|
410
|
+
if (mr.iid === commit.mrNumber) {
|
|
411
|
+
mrTitle = mr.title;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const parsed = parseMRTitle(mrTitle);
|
|
417
|
+
entries.set(commit.mrNumber, {
|
|
418
|
+
mrNumber: commit.mrNumber,
|
|
419
|
+
title: parsed.subject,
|
|
420
|
+
type: parsed.type,
|
|
421
|
+
scope: parsed.scope,
|
|
422
|
+
breaking: parsed.breaking,
|
|
423
|
+
authors: new Set(commit.author && !isExcludedAuthor(commit.author) ? [normalizeAuthor(commit.author)] : []),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { entries, orphanedCommits };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function categorizeEntries(entries: Map<number, ChangelogEntry>): Record<string, ChangelogEntry[]> {
|
|
432
|
+
const categories: Record<string, ChangelogEntry[]> = {};
|
|
433
|
+
|
|
434
|
+
for (const [, entry] of entries) {
|
|
435
|
+
const type = COMMIT_TYPES[entry.type] ? entry.type : "other";
|
|
436
|
+
if (!categories[type]) {
|
|
437
|
+
categories[type] = [];
|
|
438
|
+
}
|
|
439
|
+
categories[type].push(entry);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return categories;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function formatMRLink(mrNumber: number | undefined): string {
|
|
446
|
+
if (!mrNumber) return "";
|
|
447
|
+
// Use HTML link so copy-pasting to GitHub shows "!XXX" not the full URL
|
|
448
|
+
return ` (<a href="${GITLAB_MR_URL}/${mrNumber}">!${mrNumber}</a>)`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function formatTitle(title: string): string {
|
|
452
|
+
// Wrap CLI flags (--something) in backticks
|
|
453
|
+
let formatted = title.replace(/\s(--[\w-]+)/g, " `$1`");
|
|
454
|
+
// Wrap CLI commands like "postgresai init", "postgresai checkup" in backticks
|
|
455
|
+
// Only match known subcommands, not arbitrary words
|
|
456
|
+
formatted = formatted.replace(/\b(postgresai\s+(?:init|checkup|mon|auth|prepare-db|unprepare-db))\b/g, "`$1`");
|
|
457
|
+
// Wrap PostgreSQL technical terms
|
|
458
|
+
formatted = formatted.replace(/\b(pg_stat_statements|pg_statistic|pg_catalog)\b/g, "`$1`");
|
|
459
|
+
return formatted;
|
|
460
|
+
}
|
|
461
|
+
|
|
267
462
|
function generateMarkdown(notes: ReleaseNotes): string {
|
|
268
463
|
const lines: string[] = [];
|
|
269
464
|
|
|
@@ -284,11 +479,12 @@ function generateMarkdown(notes: ReleaseNotes): string {
|
|
|
284
479
|
|
|
285
480
|
// Breaking changes (if any)
|
|
286
481
|
if (notes.breaking.length > 0) {
|
|
287
|
-
lines.push("## Breaking Changes");
|
|
482
|
+
lines.push("## ⚠️ Breaking Changes");
|
|
288
483
|
lines.push("");
|
|
289
|
-
for (const
|
|
290
|
-
const scopeStr =
|
|
291
|
-
|
|
484
|
+
for (const entry of notes.breaking) {
|
|
485
|
+
const scopeStr = entry.scope ? `**${entry.scope}:** ` : "";
|
|
486
|
+
const mrLink = formatMRLink(entry.mrNumber);
|
|
487
|
+
lines.push(`- ${scopeStr}${entry.title}${mrLink}`);
|
|
292
488
|
}
|
|
293
489
|
lines.push("");
|
|
294
490
|
}
|
|
@@ -301,43 +497,28 @@ function generateMarkdown(notes: ReleaseNotes): string {
|
|
|
301
497
|
});
|
|
302
498
|
|
|
303
499
|
for (const type of sortedTypes) {
|
|
304
|
-
const
|
|
305
|
-
if (!
|
|
500
|
+
const entries = notes.categories[type];
|
|
501
|
+
if (!entries || entries.length === 0) continue;
|
|
306
502
|
|
|
307
503
|
const typeInfo = COMMIT_TYPES[type] || { title: "Other Changes", emoji: "📝", priority: 99 };
|
|
308
504
|
lines.push(`## ${typeInfo.emoji} ${typeInfo.title}`);
|
|
309
505
|
lines.push("");
|
|
310
506
|
|
|
311
|
-
//
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (b === "_general") return -1;
|
|
323
|
-
const aKnown = KNOWN_SCOPES.includes(a);
|
|
324
|
-
const bKnown = KNOWN_SCOPES.includes(b);
|
|
325
|
-
if (aKnown && !bKnown) return -1;
|
|
326
|
-
if (!aKnown && bKnown) return 1;
|
|
327
|
-
return a.localeCompare(b);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
for (const scope of scopes) {
|
|
331
|
-
const scopeCommits = byScope[scope] || [];
|
|
332
|
-
if (scope !== "_general" && scopeCommits.length > 0) {
|
|
333
|
-
lines.push(`### ${scope}`);
|
|
334
|
-
lines.push("");
|
|
335
|
-
}
|
|
336
|
-
for (const commit of scopeCommits) {
|
|
337
|
-
lines.push(`- ${commit.subject} (\`${commit.shortHash}\`)`);
|
|
507
|
+
// Flat list with scope prefix (no sub-headers)
|
|
508
|
+
for (const entry of entries) {
|
|
509
|
+
const mrLink = formatMRLink(entry.mrNumber);
|
|
510
|
+
// Format scope: uppercase CLI, MCP; title case others
|
|
511
|
+
let scopeStr = "";
|
|
512
|
+
if (entry.scope) {
|
|
513
|
+
const upperScopes = ["cli", "mcp", "api", "sql", "ci"];
|
|
514
|
+
const formattedScope = upperScopes.includes(entry.scope.toLowerCase())
|
|
515
|
+
? entry.scope.toUpperCase()
|
|
516
|
+
: entry.scope;
|
|
517
|
+
scopeStr = `**${formattedScope}:** `;
|
|
338
518
|
}
|
|
339
|
-
lines.push(
|
|
519
|
+
lines.push(`- ${scopeStr}${formatTitle(entry.title)}${mrLink}`);
|
|
340
520
|
}
|
|
521
|
+
lines.push("");
|
|
341
522
|
}
|
|
342
523
|
|
|
343
524
|
// Contributors
|
|
@@ -369,30 +550,83 @@ async function main() {
|
|
|
369
550
|
const log = (msg: string) => process.stderr.write(msg + "\n");
|
|
370
551
|
log(`Analyzing commits from ${since} to ${until}...`);
|
|
371
552
|
|
|
553
|
+
// Get the date of the since ref for MR filtering
|
|
554
|
+
const sinceDate = gitExec(["log", "-1", "--format=%aI", since]) || "2024-01-01T00:00:00Z";
|
|
555
|
+
|
|
556
|
+
// Fetch MRs from GitLab API
|
|
557
|
+
log("Fetching MR data from GitLab...");
|
|
558
|
+
const mrs = await fetchMergedMRs(sinceDate);
|
|
559
|
+
const mrBranchMap = buildBranchToMRMap(mrs);
|
|
560
|
+
log(`Found ${mrs.length} merged MRs`);
|
|
561
|
+
|
|
372
562
|
// Get and parse commits
|
|
373
563
|
const hashes = getCommitsBetween(since, until);
|
|
374
564
|
log(`Found ${hashes.length} commits to analyze`);
|
|
375
565
|
|
|
566
|
+
// Build a map of commit hash to branch name from merge commits
|
|
567
|
+
const commitToBranch = new Map<string, string>();
|
|
568
|
+
const mergeLog = gitExec(["log", `${since}..${until}`, "--merges", "--format=%H|%P|%s"]);
|
|
569
|
+
for (const line of mergeLog.split("\n").filter(Boolean)) {
|
|
570
|
+
const [mergeHash, parents, subject] = line.split("|");
|
|
571
|
+
const branchMatch = subject?.match(/Merge branch '([^']+)'/);
|
|
572
|
+
if (branchMatch && parents) {
|
|
573
|
+
const branch = branchMatch[1];
|
|
574
|
+
// The second parent is the merged branch
|
|
575
|
+
const mergedParent = parents.split(" ")[1];
|
|
576
|
+
if (mergedParent && branch) {
|
|
577
|
+
// Get all commits that are ancestors of mergedParent but not of first parent
|
|
578
|
+
const firstParent = parents.split(" ")[0];
|
|
579
|
+
if (firstParent) {
|
|
580
|
+
const branchCommits = gitExec(["log", "--format=%H", `${firstParent}..${mergedParent}`]);
|
|
581
|
+
for (const hash of branchCommits.split("\n").filter(Boolean)) {
|
|
582
|
+
commitToBranch.set(hash, branch);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
376
589
|
const commits: ParsedCommit[] = [];
|
|
377
590
|
for (const hash of hashes) {
|
|
378
591
|
const parsed = parseCommit(hash);
|
|
379
592
|
if (parsed) {
|
|
593
|
+
// Try to find MR number from branch name
|
|
594
|
+
const branch = commitToBranch.get(hash);
|
|
595
|
+
if (branch) {
|
|
596
|
+
parsed.mrNumber = getMRNumberFromBranch(branch, mrBranchMap);
|
|
597
|
+
}
|
|
380
598
|
commits.push(parsed);
|
|
381
599
|
}
|
|
382
600
|
}
|
|
383
601
|
|
|
384
|
-
//
|
|
385
|
-
const
|
|
386
|
-
const
|
|
602
|
+
// Group commits by MR and build changelog entries
|
|
603
|
+
const { entries: entriesMap, orphanedCommits } = groupCommitsByMR(commits, mrBranchMap);
|
|
604
|
+
const entries = Array.from(entriesMap.values());
|
|
605
|
+
log(`Grouped into ${entries.length} changelog entries`);
|
|
606
|
+
|
|
607
|
+
// Warn about orphaned commits (no MR association found)
|
|
608
|
+
if (orphanedCommits.length > 0) {
|
|
609
|
+
log(`Warning: ${orphanedCommits.length} commits have no MR association:`);
|
|
610
|
+
for (const commit of orphanedCommits.slice(0, 10)) {
|
|
611
|
+
log(` - ${commit.shortHash}: ${commit.subject}`);
|
|
612
|
+
}
|
|
613
|
+
if (orphanedCommits.length > 10) {
|
|
614
|
+
log(` ... and ${orphanedCommits.length - 10} more`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Categorize entries by type
|
|
619
|
+
const categories = categorizeEntries(entriesMap as Map<number, ChangelogEntry>);
|
|
620
|
+
const breaking = entries.filter((e) => e.breaking);
|
|
387
621
|
|
|
388
|
-
//
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
622
|
+
// Collect all contributors from entries
|
|
623
|
+
const contributorSet = new Set<string>();
|
|
624
|
+
for (const entry of entries) {
|
|
625
|
+
for (const author of entry.authors) {
|
|
626
|
+
contributorSet.add(author);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const contributors = Array.from(contributorSet);
|
|
396
630
|
|
|
397
631
|
const notes: ReleaseNotes = {
|
|
398
632
|
version: args.version || "",
|
|
@@ -400,10 +634,11 @@ async function main() {
|
|
|
400
634
|
sinceRef: since,
|
|
401
635
|
untilRef: until,
|
|
402
636
|
commits,
|
|
637
|
+
entries,
|
|
403
638
|
categories,
|
|
404
639
|
breaking,
|
|
405
640
|
stats: {
|
|
406
|
-
total:
|
|
641
|
+
total: entries.length,
|
|
407
642
|
features: categories["feat"]?.length || 0,
|
|
408
643
|
fixes: categories["fix"]?.length || 0,
|
|
409
644
|
contributors,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Permission Check Test Summary
|
|
2
|
+
|
|
3
|
+
## Changes Made
|
|
4
|
+
|
|
5
|
+
Changed all references from `public.pg_statistic` to `postgres_ai.pg_statistic` in:
|
|
6
|
+
- `cli/lib/init.ts` - Permission check SQL query
|
|
7
|
+
- `cli/test/init.test.ts` - All test expectations (28 occurrences)
|
|
8
|
+
|
|
9
|
+
## Key Fix: Safe Schema Checking
|
|
10
|
+
|
|
11
|
+
**Before (883fa95):**
|
|
12
|
+
```sql
|
|
13
|
+
exists (
|
|
14
|
+
select from pg_views
|
|
15
|
+
where schemaname = 'public' and viewname = 'pg_statistic'
|
|
16
|
+
) as granted
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**After (6db79f6) - INCORRECT, caused crashes:**
|
|
20
|
+
```sql
|
|
21
|
+
to_regclass('postgres_ai.pg_statistic') is not null as granted
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Current (this fix):**
|
|
25
|
+
```sql
|
|
26
|
+
case
|
|
27
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
28
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
29
|
+
end as granted
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Why this fix matters
|
|
33
|
+
|
|
34
|
+
**Issue with bare `to_regclass()`:**
|
|
35
|
+
- Returns NULL when the schema doesn't exist ✓
|
|
36
|
+
- Returns NULL when the view doesn't exist ✓
|
|
37
|
+
- **Throws error** when the schema exists but user lacks USAGE privilege ✗
|
|
38
|
+
|
|
39
|
+
**Fix:**
|
|
40
|
+
- Check `has_schema_privilege()` first to avoid the permission error
|
|
41
|
+
- Returns NULL safely in all cases where we can't check the view
|
|
42
|
+
- Prevents crashes when postgres_ai schema exists but user lacks USAGE
|
|
43
|
+
|
|
44
|
+
## Test Results
|
|
45
|
+
|
|
46
|
+
### Unit Tests ✅
|
|
47
|
+
```
|
|
48
|
+
✓ 95 tests passed across 3 files
|
|
49
|
+
- 84 tests in init.test.ts (including 9 checkCurrentUserPermissions tests)
|
|
50
|
+
- 2 tests in config-consistency.test.ts
|
|
51
|
+
- 9 tests in permission-check-sql.test.ts
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Expected Behavior by Scenario
|
|
55
|
+
|
|
56
|
+
| Scenario | User Permissions | postgres_ai Schema | Expected Result |
|
|
57
|
+
|----------|-----------------|-------------------|-----------------|
|
|
58
|
+
| 1. Superuser | superuser + postgres_ai.pg_statistic | ✓ Exists | ✅ PASS (clean) |
|
|
59
|
+
| 2. pg_monitor, no schema access | pg_monitor only | ✗ No USAGE | ✅ PASS (warning) |
|
|
60
|
+
| 3. No pg_monitor | minimal permissions | ✗ Doesn't exist | ✅ PASS (error + fix SQL) |
|
|
61
|
+
| 8. After prepare-db | pg_monitor + postgres_ai grants | ✓ Exists + SELECT | ✅ PASS (clean) |
|
|
62
|
+
|
|
63
|
+
### SQL Behavior Verification
|
|
64
|
+
|
|
65
|
+
**Scenario 2 & 3: Schema doesn't exist or no USAGE**
|
|
66
|
+
```sql
|
|
67
|
+
-- Check privilege first, then to_regclass (no crash)
|
|
68
|
+
case
|
|
69
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
70
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
71
|
+
end → NULL
|
|
72
|
+
|
|
73
|
+
-- SELECT check is skipped (returns NULL, not treated as missing optional)
|
|
74
|
+
case
|
|
75
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
76
|
+
when to_regclass('postgres_ai.pg_statistic') is null then null
|
|
77
|
+
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
|
|
78
|
+
end → NULL
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Scenario 1 & 8: Schema exists with proper grants**
|
|
82
|
+
```sql
|
|
83
|
+
-- User has USAGE, to_regclass returns OID (view is visible)
|
|
84
|
+
case
|
|
85
|
+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
|
|
86
|
+
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
87
|
+
end → TRUE
|
|
88
|
+
|
|
89
|
+
-- SELECT check is performed
|
|
90
|
+
has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select') → TRUE/FALSE
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Integration Test Limitations
|
|
94
|
+
|
|
95
|
+
Integration tests cannot run due to locale configuration issues with `initdb`:
|
|
96
|
+
```
|
|
97
|
+
error: initdb: error: invalid locale settings; check LANG and LC_* environment variables
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
However, unit tests provide comprehensive coverage of the permission check logic, including:
|
|
101
|
+
- All permission scenarios (granted, denied, skipped)
|
|
102
|
+
- Multiple missing permissions
|
|
103
|
+
- Error propagation
|
|
104
|
+
- Fix command generation
|
|
105
|
+
- Message formatting
|
|
106
|
+
|
|
107
|
+
## Schema Consistency
|
|
108
|
+
|
|
109
|
+
The change ensures consistency across the codebase:
|
|
110
|
+
- ✅ `cli/lib/init.ts` - now checks postgres_ai.pg_statistic
|
|
111
|
+
- ✅ `cli/lib/supabase.ts` - already checks postgres_ai.pg_statistic
|
|
112
|
+
- ✅ `cli/sql/03.permissions.sql` - creates postgres_ai.pg_statistic
|
|
113
|
+
- ✅ `config/target-db/init.sql` - creates postgres_ai.pg_statistic
|
|
114
|
+
- ✅ `config/pgwatch-prometheus/metrics.yml` - references postgres_ai.pg_statistic
|
|
115
|
+
|
|
116
|
+
## Commits
|
|
117
|
+
|
|
118
|
+
1. **955cff2** - `fix: change public.pg_statistic to postgres_ai.pg_statistic`
|
|
119
|
+
- Updated permission check queries
|
|
120
|
+
- Updated all test expectations
|
|
121
|
+
|
|
122
|
+
2. **6db79f6** - `fix: use to_regclass() for safe postgres_ai.pg_statistic check`
|
|
123
|
+
- Replaced pg_views query with to_regclass()
|
|
124
|
+
- ⚠️ This introduced a bug: crashes when schema exists but user lacks USAGE
|
|
125
|
+
|
|
126
|
+
3. **[current]** - `fix: wrap to_regclass() with has_schema_privilege() check`
|
|
127
|
+
- Fixed crash when postgres_ai schema exists but user lacks USAGE privilege
|
|
128
|
+
- Added privilege check before calling to_regclass() in all locations
|
|
129
|
+
- Updated in: init.ts (3 places) and supabase.ts (1 place)
|
|
130
|
+
|
|
131
|
+
## Verification Command
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Run all permission-related tests
|
|
135
|
+
bun test test/init.test.ts test/config-consistency.test.ts test/permission-check-sql.test.ts
|
|
136
|
+
|
|
137
|
+
# Verify no public.pg_statistic references remain (except in comments)
|
|
138
|
+
git grep -n 'public\.pg_statistic' cli/
|
|
139
|
+
```
|