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.
Files changed (85) hide show
  1. package/README.md +154 -0
  2. package/dist/bin/postgres-ai.js +2911 -255
  3. package/package.json +12 -3
  4. package/schemas/A002.schema.json +63 -0
  5. package/schemas/A003.schema.json +73 -0
  6. package/schemas/A004.schema.json +81 -0
  7. package/schemas/A007.schema.json +71 -0
  8. package/schemas/A013.schema.json +61 -0
  9. package/schemas/D001.schema.json +71 -0
  10. package/schemas/D004.schema.json +136 -0
  11. package/schemas/F001.schema.json +73 -0
  12. package/schemas/F002.schema.json +108 -0
  13. package/schemas/F003.schema.json +138 -0
  14. package/schemas/F004.schema.json +125 -0
  15. package/schemas/F005.schema.json +131 -0
  16. package/schemas/F009.schema.json +155 -0
  17. package/schemas/G001.schema.json +135 -0
  18. package/schemas/G003.schema.json +90 -0
  19. package/schemas/H001.schema.json +141 -0
  20. package/schemas/H002.schema.json +129 -0
  21. package/schemas/H004.schema.json +128 -0
  22. package/schemas/I001.schema.json +149 -0
  23. package/schemas/K001.schema.json +161 -0
  24. package/schemas/K003.schema.json +163 -0
  25. package/schemas/K004.schema.json +110 -0
  26. package/schemas/K005.schema.json +110 -0
  27. package/schemas/K006.schema.json +110 -0
  28. package/schemas/K007.schema.json +110 -0
  29. package/schemas/K008.schema.json +110 -0
  30. package/schemas/M001.schema.json +119 -0
  31. package/schemas/M002.schema.json +110 -0
  32. package/schemas/M003.schema.json +128 -0
  33. package/schemas/N001.schema.json +161 -0
  34. package/schemas/query.schema.json +62 -0
  35. package/CHANGELOG.md +0 -11
  36. package/bin/postgres-ai.ts +0 -5578
  37. package/bun.lock +0 -258
  38. package/bunfig.toml +0 -20
  39. package/lib/aas-onboard.ts +0 -251
  40. package/lib/auth-server.ts +0 -285
  41. package/lib/checkup-api.ts +0 -526
  42. package/lib/checkup-dictionary.ts +0 -103
  43. package/lib/checkup-summary.ts +0 -338
  44. package/lib/checkup.ts +0 -2261
  45. package/lib/config.ts +0 -171
  46. package/lib/init.ts +0 -1152
  47. package/lib/instances.ts +0 -245
  48. package/lib/issues.ts +0 -1060
  49. package/lib/mcp-server.ts +0 -667
  50. package/lib/metrics-loader.ts +0 -134
  51. package/lib/pkce.ts +0 -79
  52. package/lib/reports.ts +0 -373
  53. package/lib/storage.ts +0 -367
  54. package/lib/supabase.ts +0 -826
  55. package/lib/util.ts +0 -134
  56. package/packages/postgres-ai/README.md +0 -26
  57. package/packages/postgres-ai/bin/postgres-ai.js +0 -27
  58. package/packages/postgres-ai/package.json +0 -27
  59. package/scripts/embed-checkup-dictionary.ts +0 -115
  60. package/scripts/embed-metrics.ts +0 -160
  61. package/scripts/generate-release-notes.ts +0 -668
  62. package/test/PERMISSION_CHECK_TEST_SUMMARY.md +0 -139
  63. package/test/aas-onboard.test.ts +0 -301
  64. package/test/auth.test.ts +0 -287
  65. package/test/checkup.integration.test.ts +0 -413
  66. package/test/checkup.test.ts +0 -3626
  67. package/test/compose-cmd.test.ts +0 -120
  68. package/test/config-consistency.test.ts +0 -352
  69. package/test/init.integration.test.ts +0 -438
  70. package/test/init.test.ts +0 -1816
  71. package/test/issues.cli.test.ts +0 -1162
  72. package/test/issues.test.ts +0 -456
  73. package/test/mcp-server.test.ts +0 -2530
  74. package/test/monitoring.test.ts +0 -746
  75. package/test/permission-check-sql.test.ts +0 -116
  76. package/test/reports.cli.test.ts +0 -793
  77. package/test/reports.test.ts +0 -977
  78. package/test/schema-validation.test.ts +0 -231
  79. package/test/storage.test.ts +0 -935
  80. package/test/supabase.test.ts +0 -709
  81. package/test/targets-add-config.test.ts +0 -28
  82. package/test/test-utils.ts +0 -190
  83. package/test/upgrade.test.ts +0 -1056
  84. package/test/util.test.ts +0 -44
  85. 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
- });