postgresai 0.16.0-rc.4 → 0.16.0
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 +154 -0
- package/dist/bin/postgres-ai.js +2911 -255
- package/package.json +12 -3
- package/schemas/A002.schema.json +63 -0
- package/schemas/A003.schema.json +73 -0
- package/schemas/A004.schema.json +81 -0
- package/schemas/A007.schema.json +71 -0
- package/schemas/A013.schema.json +61 -0
- package/schemas/D001.schema.json +71 -0
- package/schemas/D004.schema.json +136 -0
- package/schemas/F001.schema.json +73 -0
- package/schemas/F002.schema.json +108 -0
- package/schemas/F003.schema.json +138 -0
- package/schemas/F004.schema.json +125 -0
- package/schemas/F005.schema.json +131 -0
- package/schemas/F009.schema.json +155 -0
- package/schemas/G001.schema.json +135 -0
- package/schemas/G003.schema.json +90 -0
- package/schemas/H001.schema.json +141 -0
- package/schemas/H002.schema.json +129 -0
- package/schemas/H004.schema.json +128 -0
- package/schemas/I001.schema.json +149 -0
- package/schemas/K001.schema.json +161 -0
- package/schemas/K003.schema.json +163 -0
- package/schemas/K004.schema.json +110 -0
- package/schemas/K005.schema.json +110 -0
- package/schemas/K006.schema.json +110 -0
- package/schemas/K007.schema.json +110 -0
- package/schemas/K008.schema.json +110 -0
- package/schemas/M001.schema.json +119 -0
- package/schemas/M002.schema.json +110 -0
- package/schemas/M003.schema.json +128 -0
- package/schemas/N001.schema.json +161 -0
- package/schemas/query.schema.json +62 -0
- package/CHANGELOG.md +0 -11
- package/bin/postgres-ai.ts +0 -5578
- package/bun.lock +0 -258
- package/bunfig.toml +0 -20
- package/lib/aas-onboard.ts +0 -251
- package/lib/auth-server.ts +0 -285
- package/lib/checkup-api.ts +0 -526
- package/lib/checkup-dictionary.ts +0 -103
- package/lib/checkup-summary.ts +0 -338
- package/lib/checkup.ts +0 -2261
- package/lib/config.ts +0 -171
- package/lib/init.ts +0 -1152
- package/lib/instances.ts +0 -245
- package/lib/issues.ts +0 -1060
- package/lib/mcp-server.ts +0 -667
- package/lib/metrics-loader.ts +0 -134
- package/lib/pkce.ts +0 -79
- package/lib/reports.ts +0 -373
- package/lib/storage.ts +0 -367
- package/lib/supabase.ts +0 -826
- package/lib/util.ts +0 -134
- package/packages/postgres-ai/README.md +0 -26
- package/packages/postgres-ai/bin/postgres-ai.js +0 -27
- package/packages/postgres-ai/package.json +0 -27
- package/scripts/embed-checkup-dictionary.ts +0 -115
- package/scripts/embed-metrics.ts +0 -160
- package/scripts/generate-release-notes.ts +0 -668
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +0 -139
- package/test/aas-onboard.test.ts +0 -301
- package/test/auth.test.ts +0 -287
- package/test/checkup.integration.test.ts +0 -413
- package/test/checkup.test.ts +0 -3626
- package/test/compose-cmd.test.ts +0 -120
- package/test/config-consistency.test.ts +0 -352
- package/test/init.integration.test.ts +0 -438
- package/test/init.test.ts +0 -1816
- package/test/issues.cli.test.ts +0 -1162
- package/test/issues.test.ts +0 -456
- package/test/mcp-server.test.ts +0 -2530
- package/test/monitoring.test.ts +0 -746
- package/test/permission-check-sql.test.ts +0 -116
- package/test/reports.cli.test.ts +0 -793
- package/test/reports.test.ts +0 -977
- package/test/schema-validation.test.ts +0 -231
- package/test/storage.test.ts +0 -935
- package/test/supabase.test.ts +0 -709
- package/test/targets-add-config.test.ts +0 -28
- package/test/test-utils.ts +0 -190
- package/test/upgrade.test.ts +0 -1056
- package/test/util.test.ts +0 -44
- package/tsconfig.json +0 -20
|
@@ -1,668 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* Release Notes Generator
|
|
4
|
-
*
|
|
5
|
-
* Generates release notes from git commit history using conventional commits.
|
|
6
|
-
* Analyzes commits between two references (tags, commits, or branches).
|
|
7
|
-
* Fetches MR numbers from GitLab API and includes links.
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* bun run scripts/generate-release-notes.ts [options]
|
|
11
|
-
*
|
|
12
|
-
* Options:
|
|
13
|
-
* --since <ref> Start reference (tag, commit, branch). Default: auto-detect last tag
|
|
14
|
-
* --until <ref> End reference. Default: HEAD
|
|
15
|
-
* --version <ver> Version string for the release (e.g., "0.14.0")
|
|
16
|
-
* --format <fmt> Output format: markdown (default), json
|
|
17
|
-
* --output <file> Write to file instead of stdout
|
|
18
|
-
* --gitlab-project GitLab project path (default: postgres-ai/postgresai)
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { execFileSync } from "child_process";
|
|
22
|
-
import * as fs from "fs";
|
|
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
|
-
|
|
28
|
-
// Valid git ref pattern: alphanumeric, dots, hyphens, underscores, slashes, tildes, carets
|
|
29
|
-
const GIT_REF_PATTERN = /^[a-zA-Z0-9._~^/+-]+$/;
|
|
30
|
-
// Valid git SHA pattern: 7-40 hex characters
|
|
31
|
-
const GIT_SHA_PATTERN = /^[a-f0-9]{7,40}$/i;
|
|
32
|
-
|
|
33
|
-
function isValidGitRef(ref: string): boolean {
|
|
34
|
-
return GIT_REF_PATTERN.test(ref) && !ref.includes("..");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function isValidGitSha(sha: string): boolean {
|
|
38
|
-
return GIT_SHA_PATTERN.test(sha);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Conventional commit types and their display names
|
|
42
|
-
const COMMIT_TYPES: Record<string, { title: string; emoji: string; priority: number }> = {
|
|
43
|
-
feat: { title: "New Features", emoji: "🚀", priority: 1 },
|
|
44
|
-
fix: { title: "Bug Fixes", emoji: "🐛", priority: 2 },
|
|
45
|
-
perf: { title: "Performance Improvements", emoji: "⚡", priority: 3 },
|
|
46
|
-
refactor: { title: "Refactoring", emoji: "♻️", priority: 4 },
|
|
47
|
-
docs: { title: "Documentation", emoji: "📚", priority: 5 },
|
|
48
|
-
chore: { title: "Maintenance", emoji: "🔧", priority: 6 },
|
|
49
|
-
test: { title: "Testing", emoji: "🧪", priority: 7 },
|
|
50
|
-
ci: { title: "CI/CD", emoji: "🔄", priority: 8 },
|
|
51
|
-
build: { title: "Build System", emoji: "📦", priority: 9 },
|
|
52
|
-
style: { title: "Code Style", emoji: "💅", priority: 10 },
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
// Scopes to highlight (CLI, monitoring, etc.)
|
|
56
|
-
const KNOWN_SCOPES = ["cli", "monitoring", "reporter", "grafana", "mcp", "prepare-db", "checkup", "deps", "ci", "formula", "pgai", "dashboards"];
|
|
57
|
-
|
|
58
|
-
// Author name mappings for deduplication
|
|
59
|
-
const AUTHOR_ALIASES: Record<string, string> = {
|
|
60
|
-
"Nik Samokhvalov": "Nikolay Samokhvalov",
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
// Authors to exclude from contributors list (bots, AI assistants)
|
|
64
|
-
const EXCLUDED_AUTHORS = ["Claude", "dependabot[bot]", "github-actions[bot]"];
|
|
65
|
-
|
|
66
|
-
function normalizeAuthor(author: string): string {
|
|
67
|
-
return AUTHOR_ALIASES[author] || author;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function isExcludedAuthor(author: string): boolean {
|
|
71
|
-
return EXCLUDED_AUTHORS.some((excluded) => author.toLowerCase().includes(excluded.toLowerCase()));
|
|
72
|
-
}
|
|
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
|
-
|
|
91
|
-
interface ParsedCommit {
|
|
92
|
-
hash: string;
|
|
93
|
-
shortHash: string;
|
|
94
|
-
type: string;
|
|
95
|
-
scope: string | null;
|
|
96
|
-
subject: string;
|
|
97
|
-
body: string;
|
|
98
|
-
breaking: boolean;
|
|
99
|
-
date: string;
|
|
100
|
-
author: string;
|
|
101
|
-
mrNumber?: number;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
interface ReleaseNotes {
|
|
105
|
-
version: string;
|
|
106
|
-
date: string;
|
|
107
|
-
sinceRef: string;
|
|
108
|
-
untilRef: string;
|
|
109
|
-
commits: ParsedCommit[];
|
|
110
|
-
entries: ChangelogEntry[];
|
|
111
|
-
categories: Record<string, ChangelogEntry[]>;
|
|
112
|
-
breaking: ChangelogEntry[];
|
|
113
|
-
stats: {
|
|
114
|
-
total: number;
|
|
115
|
-
features: number;
|
|
116
|
-
fixes: number;
|
|
117
|
-
contributors: string[];
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function gitExec(args: string[]): string {
|
|
122
|
-
try {
|
|
123
|
-
const result = execFileSync("git", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
124
|
-
return result.trim();
|
|
125
|
-
} catch (err) {
|
|
126
|
-
return "";
|
|
127
|
-
}
|
|
128
|
-
}
|
|
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
|
-
|
|
179
|
-
function parseArgs(): { since: string; until: string; version: string; format: string; output: string | null } {
|
|
180
|
-
const args = process.argv.slice(2);
|
|
181
|
-
const result = { since: "", until: "HEAD", version: "", format: "markdown", output: null as string | null };
|
|
182
|
-
|
|
183
|
-
for (let i = 0; i < args.length; i++) {
|
|
184
|
-
const arg = args[i];
|
|
185
|
-
const next = args[i + 1];
|
|
186
|
-
switch (arg) {
|
|
187
|
-
case "--since":
|
|
188
|
-
result.since = next || "";
|
|
189
|
-
i++;
|
|
190
|
-
break;
|
|
191
|
-
case "--until":
|
|
192
|
-
result.until = next || "HEAD";
|
|
193
|
-
i++;
|
|
194
|
-
break;
|
|
195
|
-
case "--version":
|
|
196
|
-
result.version = next || "";
|
|
197
|
-
i++;
|
|
198
|
-
break;
|
|
199
|
-
case "--format":
|
|
200
|
-
result.format = next || "markdown";
|
|
201
|
-
i++;
|
|
202
|
-
break;
|
|
203
|
-
case "--output":
|
|
204
|
-
result.output = next || null;
|
|
205
|
-
i++;
|
|
206
|
-
break;
|
|
207
|
-
case "--help":
|
|
208
|
-
console.log(`
|
|
209
|
-
Release Notes Generator
|
|
210
|
-
|
|
211
|
-
Usage: bun run scripts/generate-release-notes.ts [options]
|
|
212
|
-
|
|
213
|
-
Options:
|
|
214
|
-
--since <ref> Start reference (tag, commit, or branch)
|
|
215
|
-
Default: auto-detect last release tag
|
|
216
|
-
--until <ref> End reference (tag, commit, or branch)
|
|
217
|
-
Default: HEAD
|
|
218
|
-
--version <ver> Version string for the release header
|
|
219
|
-
Default: derived from --until or current date
|
|
220
|
-
--format <fmt> Output format: markdown (default) or json
|
|
221
|
-
--output <file> Write to file instead of stdout
|
|
222
|
-
|
|
223
|
-
Examples:
|
|
224
|
-
# Generate notes for upcoming 0.14.0 release
|
|
225
|
-
bun run scripts/generate-release-notes.ts --version 0.14.0
|
|
226
|
-
|
|
227
|
-
# Generate notes between two commits
|
|
228
|
-
bun run scripts/generate-release-notes.ts --since abc123 --until def456
|
|
229
|
-
|
|
230
|
-
# Output as JSON
|
|
231
|
-
bun run scripts/generate-release-notes.ts --format json
|
|
232
|
-
`);
|
|
233
|
-
process.exit(0);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
return result;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function detectLastTag(): string {
|
|
240
|
-
// Try to find the last version tag
|
|
241
|
-
const tags = gitExec(["tag", "--sort=-version:refname"]).split("\n").filter(Boolean);
|
|
242
|
-
|
|
243
|
-
// Look for semantic version tags
|
|
244
|
-
for (const tag of tags) {
|
|
245
|
-
if (/^v?\d+\.\d+/.test(tag)) {
|
|
246
|
-
return tag;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Fallback: find a meaningful starting point from commit messages
|
|
251
|
-
const versionCommits = gitExec(["log", "--grep=prepare-for-0.14\\|0.13\\|release", "--format=%H"]).split("\n").filter(Boolean);
|
|
252
|
-
if (versionCommits.length > 0 && versionCommits[0]) {
|
|
253
|
-
return versionCommits[0];
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Last resort: 100 commits back
|
|
257
|
-
return "HEAD~100";
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function getCommitsBetween(since: string, until: string): string[] {
|
|
261
|
-
// Validate refs to prevent command injection
|
|
262
|
-
if (since && !isValidGitRef(since)) {
|
|
263
|
-
throw new Error(`Invalid git ref: ${since}`);
|
|
264
|
-
}
|
|
265
|
-
if (!isValidGitRef(until)) {
|
|
266
|
-
throw new Error(`Invalid git ref: ${until}`);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Get commit hashes between the two refs
|
|
270
|
-
const range = since ? `${since}..${until}` : until;
|
|
271
|
-
const output = gitExec(["log", range, "--format=%H", "--no-merges"]);
|
|
272
|
-
return output.split("\n").filter(Boolean);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function parseCommit(hash: string): ParsedCommit | null {
|
|
276
|
-
// Validate hash to prevent command injection
|
|
277
|
-
if (!isValidGitSha(hash)) {
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Use null byte as delimiter (unlikely to appear in commit messages)
|
|
282
|
-
const format = "%H%x00%h%x00%s%x00%b%x00%ad%x00%an";
|
|
283
|
-
const output = gitExec(["log", "-1", `--format=${format}`, "--date=short", hash]);
|
|
284
|
-
|
|
285
|
-
if (!output) return null;
|
|
286
|
-
|
|
287
|
-
const parts = output.split("\x00");
|
|
288
|
-
const [fullHash, shortHash, subject, body, date, author] = parts;
|
|
289
|
-
|
|
290
|
-
if (!subject) return null;
|
|
291
|
-
|
|
292
|
-
const trimmedBody = (body || "").trim();
|
|
293
|
-
|
|
294
|
-
// Parse conventional commit format: type(scope): subject
|
|
295
|
-
// Also handle: type: subject, type!: subject (breaking)
|
|
296
|
-
const match = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/);
|
|
297
|
-
|
|
298
|
-
let type = "other";
|
|
299
|
-
let scope: string | null = null;
|
|
300
|
-
let breaking = false;
|
|
301
|
-
let cleanSubject = subject;
|
|
302
|
-
|
|
303
|
-
if (match) {
|
|
304
|
-
type = match[1]?.toLowerCase() || "other";
|
|
305
|
-
scope = match[2] || null;
|
|
306
|
-
breaking = !!match[3] || trimmedBody.includes("BREAKING CHANGE");
|
|
307
|
-
cleanSubject = match[4] || subject;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Normalize type aliases
|
|
311
|
-
if (type === "feature") type = "feat";
|
|
312
|
-
if (type === "bugfix") type = "fix";
|
|
313
|
-
|
|
314
|
-
return {
|
|
315
|
-
hash: fullHash || hash,
|
|
316
|
-
shortHash: shortHash || hash.slice(0, 7),
|
|
317
|
-
type,
|
|
318
|
-
scope,
|
|
319
|
-
subject: cleanSubject,
|
|
320
|
-
body: trimmedBody,
|
|
321
|
-
breaking,
|
|
322
|
-
date: date || new Date().toISOString().split("T")[0] || "",
|
|
323
|
-
author: (author || "").trim(),
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function categorizeCommits(commits: ParsedCommit[]): Record<string, ParsedCommit[]> {
|
|
328
|
-
const categories: Record<string, ParsedCommit[]> = {};
|
|
329
|
-
|
|
330
|
-
for (const commit of commits) {
|
|
331
|
-
const type = COMMIT_TYPES[commit.type] ? commit.type : "other";
|
|
332
|
-
if (!categories[type]) {
|
|
333
|
-
categories[type] = [];
|
|
334
|
-
}
|
|
335
|
-
categories[type].push(commit);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
return categories;
|
|
339
|
-
}
|
|
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
|
-
|
|
462
|
-
function generateMarkdown(notes: ReleaseNotes): string {
|
|
463
|
-
const lines: string[] = [];
|
|
464
|
-
|
|
465
|
-
// Header
|
|
466
|
-
const dateStr = new Date().toISOString().split("T")[0];
|
|
467
|
-
lines.push(`# Release ${notes.version || "Notes"}`);
|
|
468
|
-
lines.push("");
|
|
469
|
-
lines.push(`**Release Date:** ${dateStr}`);
|
|
470
|
-
lines.push("");
|
|
471
|
-
|
|
472
|
-
// Stats summary
|
|
473
|
-
lines.push("## Summary");
|
|
474
|
-
lines.push("");
|
|
475
|
-
lines.push(`This release includes **${notes.stats.total}** changes:`);
|
|
476
|
-
lines.push(`- ${notes.stats.features} new features`);
|
|
477
|
-
lines.push(`- ${notes.stats.fixes} bug fixes`);
|
|
478
|
-
lines.push("");
|
|
479
|
-
|
|
480
|
-
// Breaking changes (if any)
|
|
481
|
-
if (notes.breaking.length > 0) {
|
|
482
|
-
lines.push("## ⚠️ Breaking Changes");
|
|
483
|
-
lines.push("");
|
|
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}`);
|
|
488
|
-
}
|
|
489
|
-
lines.push("");
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Categories sorted by priority
|
|
493
|
-
const sortedTypes = Object.keys(notes.categories).sort((a, b) => {
|
|
494
|
-
const pa = COMMIT_TYPES[a]?.priority ?? 99;
|
|
495
|
-
const pb = COMMIT_TYPES[b]?.priority ?? 99;
|
|
496
|
-
return pa - pb;
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
for (const type of sortedTypes) {
|
|
500
|
-
const entries = notes.categories[type];
|
|
501
|
-
if (!entries || entries.length === 0) continue;
|
|
502
|
-
|
|
503
|
-
const typeInfo = COMMIT_TYPES[type] || { title: "Other Changes", emoji: "📝", priority: 99 };
|
|
504
|
-
lines.push(`## ${typeInfo.emoji} ${typeInfo.title}`);
|
|
505
|
-
lines.push("");
|
|
506
|
-
|
|
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}:** `;
|
|
518
|
-
}
|
|
519
|
-
lines.push(`- ${scopeStr}${formatTitle(entry.title)}${mrLink}`);
|
|
520
|
-
}
|
|
521
|
-
lines.push("");
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Contributors
|
|
525
|
-
if (notes.stats.contributors.length > 0) {
|
|
526
|
-
lines.push("## Contributors");
|
|
527
|
-
lines.push("");
|
|
528
|
-
lines.push("Thank you to all contributors:");
|
|
529
|
-
lines.push("");
|
|
530
|
-
for (const contributor of notes.stats.contributors.sort()) {
|
|
531
|
-
lines.push(`- ${contributor}`);
|
|
532
|
-
}
|
|
533
|
-
lines.push("");
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
return lines.join("\n");
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
function generateJson(notes: ReleaseNotes): string {
|
|
540
|
-
return JSON.stringify(notes, null, 2);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
async function main() {
|
|
544
|
-
const args = parseArgs();
|
|
545
|
-
|
|
546
|
-
// Determine the range
|
|
547
|
-
const since = args.since || detectLastTag();
|
|
548
|
-
const until = args.until;
|
|
549
|
-
|
|
550
|
-
const log = (msg: string) => process.stderr.write(msg + "\n");
|
|
551
|
-
log(`Analyzing commits from ${since} to ${until}...`);
|
|
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
|
-
|
|
562
|
-
// Get and parse commits
|
|
563
|
-
const hashes = getCommitsBetween(since, until);
|
|
564
|
-
log(`Found ${hashes.length} commits to analyze`);
|
|
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
|
-
|
|
589
|
-
const commits: ParsedCommit[] = [];
|
|
590
|
-
for (const hash of hashes) {
|
|
591
|
-
const parsed = parseCommit(hash);
|
|
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
|
-
}
|
|
598
|
-
commits.push(parsed);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
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);
|
|
621
|
-
|
|
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);
|
|
630
|
-
|
|
631
|
-
const notes: ReleaseNotes = {
|
|
632
|
-
version: args.version || "",
|
|
633
|
-
date: new Date().toISOString().split("T")[0] || "",
|
|
634
|
-
sinceRef: since,
|
|
635
|
-
untilRef: until,
|
|
636
|
-
commits,
|
|
637
|
-
entries,
|
|
638
|
-
categories,
|
|
639
|
-
breaking,
|
|
640
|
-
stats: {
|
|
641
|
-
total: entries.length,
|
|
642
|
-
features: categories["feat"]?.length || 0,
|
|
643
|
-
fixes: categories["fix"]?.length || 0,
|
|
644
|
-
contributors,
|
|
645
|
-
},
|
|
646
|
-
};
|
|
647
|
-
|
|
648
|
-
// Generate output
|
|
649
|
-
let output: string;
|
|
650
|
-
if (args.format === "json") {
|
|
651
|
-
output = generateJson(notes);
|
|
652
|
-
} else {
|
|
653
|
-
output = generateMarkdown(notes);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// Write output
|
|
657
|
-
if (args.output) {
|
|
658
|
-
fs.writeFileSync(args.output, output, "utf8");
|
|
659
|
-
log(`Release notes written to: ${args.output}`);
|
|
660
|
-
} else {
|
|
661
|
-
console.log(output);
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
main().catch((err) => {
|
|
666
|
-
console.error("Error generating release notes:", err);
|
|
667
|
-
process.exit(1);
|
|
668
|
-
});
|