@theihtisham/ai-release-notes 1.0.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1493 -0
  3. package/__tests__/analyzer.test.js +63 -0
  4. package/__tests__/categorizer.test.js +93 -0
  5. package/__tests__/config.test.js +92 -0
  6. package/__tests__/formatter.test.js +63 -0
  7. package/__tests__/formatters.test.js +394 -0
  8. package/__tests__/migration.test.js +322 -0
  9. package/__tests__/semver.test.js +94 -0
  10. package/__tests__/tones.test.js +252 -0
  11. package/action.yml +113 -0
  12. package/index.js +73 -0
  13. package/jest.config.js +10 -0
  14. package/package.json +41 -0
  15. package/src/ai-writer.js +108 -0
  16. package/src/analyzer.js +232 -0
  17. package/src/analyzers/migration.js +355 -0
  18. package/src/categorizer.js +182 -0
  19. package/src/config.js +162 -0
  20. package/src/constants.js +137 -0
  21. package/src/contributor.js +144 -0
  22. package/src/diff-analyzer.js +202 -0
  23. package/src/formatter.js +336 -0
  24. package/src/formatters/discord.js +174 -0
  25. package/src/formatters/html.js +195 -0
  26. package/src/formatters/index.js +42 -0
  27. package/src/formatters/markdown.js +123 -0
  28. package/src/formatters/slack.js +176 -0
  29. package/src/formatters/twitter.js +242 -0
  30. package/src/formatters/types.js +48 -0
  31. package/src/generator.js +297 -0
  32. package/src/integrations/changelog.js +125 -0
  33. package/src/integrations/discord.js +96 -0
  34. package/src/integrations/github-release.js +75 -0
  35. package/src/integrations/indexer.js +119 -0
  36. package/src/integrations/slack.js +112 -0
  37. package/src/integrations/twitter.js +128 -0
  38. package/src/logger.js +52 -0
  39. package/src/prompts.js +210 -0
  40. package/src/rate-limiter.js +92 -0
  41. package/src/semver.js +129 -0
  42. package/src/tones/casual.js +114 -0
  43. package/src/tones/humorous.js +164 -0
  44. package/src/tones/index.js +38 -0
  45. package/src/tones/professional.js +125 -0
  46. package/src/tones/technical.js +164 -0
  47. package/src/tones/types.js +26 -0
@@ -0,0 +1,336 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const logger = require('./logger');
6
+ const { formatDiffStats } = require('./diff-analyzer');
7
+ const { formatContributorThanks } = require('./contributor');
8
+
9
+ /**
10
+ * Format release notes using a template.
11
+ * @param {Object} analysis - Analysis results
12
+ * @param {Object} diffSummary - Diff analysis results
13
+ * @param {string} version - Current version
14
+ * @param {string} previousVersion - Previous version
15
+ * @param {Object} config - Configuration object
16
+ * @returns {string} Formatted release notes
17
+ */
18
+ function formatReleaseNotes(analysis, diffSummary, version, previousVersion, config) {
19
+ try {
20
+ const template = loadTemplate(config.template);
21
+ const repoUrl = config.repoUrl || `https://github.com/${config.repoFullName || 'owner/repo'}`;
22
+
23
+ const variables = {
24
+ version: version || '0.0.0',
25
+ previous_version: previousVersion || '',
26
+ date: new Date().toISOString().split('T')[0],
27
+ summary: generateSummary(analysis, version),
28
+ categories: formatCategories(analysis.categories || {}),
29
+ breaking_changes: config.includeBreaking ? formatBreakingChanges(analysis.breaking || []) : '',
30
+ contributors: config.includeContributors ? formatContributors(analysis.contributors || []) : '',
31
+ diff_stats: config.includeDiffStats ? formatDiffStats(diffSummary) : '',
32
+ full_changelog_link: formatChangelogLink(repoUrl, previousVersion, version),
33
+ screenshots: config.includeScreenshots ? formatScreenshots(analysis.screenshots || []) : '',
34
+ bullet_points: formatBulletPoints(analysis.categories || {}),
35
+ linked_issues: formatLinkedIssues(analysis.linkedIssues || [], repoUrl),
36
+ impact_assessment: formatImpactAssessment(diffSummary),
37
+ action_items: generateActionItems(analysis, diffSummary),
38
+ known_issues: '',
39
+ };
40
+
41
+ let output = template;
42
+
43
+ // Replace all template variables
44
+ for (const [key, value] of Object.entries(variables)) {
45
+ const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
46
+ output = output.replace(regex, value || '');
47
+ }
48
+
49
+ // Clean up any remaining unresolved template variables
50
+ output = cleanUnresolvedVariables(output);
51
+
52
+ // Clean up excessive blank lines
53
+ output = output.replace(/\n{3,}/g, '\n\n').trim();
54
+
55
+ return output;
56
+ } catch (err) {
57
+ logger.error('Failed to format release notes', err);
58
+ return `## Release v${version || '0.0.0'}\n\nGenerated on ${new Date().toISOString().split('T')[0]}`;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Load a template by name or file path.
64
+ */
65
+ function loadTemplate(templateName) {
66
+ const builtInTemplates = ['default', 'minimal', 'detailed', 'enterprise', 'fun'];
67
+
68
+ if (builtInTemplates.includes(templateName)) {
69
+ const templatePath = path.join(__dirname, '..', 'templates', `${templateName}.md`);
70
+ try {
71
+ return fs.readFileSync(templatePath, 'utf-8');
72
+ } catch (err) {
73
+ logger.warn(`Built-in template "${templateName}" not found at ${templatePath}, using fallback`);
74
+ return '## What\'s Changed\n\n{{categories}}\n\n**Full Changelog**: {{full_changelog_link}}';
75
+ }
76
+ }
77
+
78
+ // Custom template file path
79
+ try {
80
+ const resolvedPath = path.resolve(templateName);
81
+ return fs.readFileSync(resolvedPath, 'utf-8');
82
+ } catch (err) {
83
+ logger.warn(`Custom template "${templateName}" not found, using default`);
84
+ try {
85
+ return fs.readFileSync(path.join(__dirname, '..', 'templates', 'default.md'), 'utf-8');
86
+ } catch (fallbackErr) {
87
+ return '## What\'s Changed\n\n{{categories}}\n\n**Full Changelog**: {{full_changelog_link}}';
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Generate a one-line summary of the release.
94
+ */
95
+ function generateSummary(analysis, version) {
96
+ if (!analysis || !analysis.stats) {
97
+ return `Release v${version} includes various improvements and fixes.`;
98
+ }
99
+
100
+ const parts = [];
101
+ const categoryCount = Object.keys(analysis.categories || {}).length;
102
+ const commitCount = analysis.stats.total_commits || 0;
103
+ const contributorCount = (analysis.contributors || []).length;
104
+
105
+ if (commitCount > 0) {
106
+ parts.push(`${commitCount} commit${commitCount !== 1 ? 's' : ''}`);
107
+ }
108
+ if (categoryCount > 0) {
109
+ parts.push(`${categoryCount} categor${categoryCount !== 1 ? 'ies' : 'y'} of changes`);
110
+ }
111
+ if (contributorCount > 0) {
112
+ parts.push(`${contributorCount} contributor${contributorCount !== 1 ? 's' : ''}`);
113
+ }
114
+
115
+ if (analysis.breaking && analysis.breaking.length > 0) {
116
+ parts.push(`${analysis.breaking.length} breaking change${analysis.breaking.length !== 1 ? 's' : ''}`);
117
+ }
118
+
119
+ if (parts.length === 0) {
120
+ return `Release v${version} includes various improvements and fixes.`;
121
+ }
122
+
123
+ return `Release v${version} brings ${parts.join(', ')}.`;
124
+ }
125
+
126
+ /**
127
+ * Format categorized changes as markdown sections.
128
+ */
129
+ function formatCategories(categories) {
130
+ if (!categories || Object.keys(categories).length === 0) {
131
+ return 'No categorized changes in this release.';
132
+ }
133
+
134
+ const lines = [];
135
+ for (const [category, changes] of Object.entries(categories)) {
136
+ lines.push(`### ${category}`);
137
+ lines.push('');
138
+ for (const change of changes) {
139
+ let line = `- ${change.description}`;
140
+ if (change.pr) {
141
+ line += ` (#${change.pr})`;
142
+ }
143
+ if (change.author) {
144
+ line += ` by @${change.author}`;
145
+ }
146
+ lines.push(line);
147
+ }
148
+ lines.push('');
149
+ }
150
+
151
+ return lines.join('\n').trim();
152
+ }
153
+
154
+ /**
155
+ * Format breaking changes section.
156
+ */
157
+ function formatBreakingChanges(breakingChanges) {
158
+ if (!breakingChanges || breakingChanges.length === 0) {
159
+ return '';
160
+ }
161
+
162
+ const lines = ['### 💥 Breaking Changes', ''];
163
+ for (const bc of breakingChanges) {
164
+ lines.push(`- **${bc.description}**`);
165
+ if (bc.scope) {
166
+ lines.push(` - Scope: \`${bc.scope}\``);
167
+ }
168
+ if (bc.migration_guide) {
169
+ lines.push(` - **Migration**: ${bc.migration_guide}`);
170
+ }
171
+ if (bc.pr) {
172
+ lines.push(` - PR: #${bc.pr}`);
173
+ }
174
+ }
175
+ lines.push('');
176
+
177
+ return lines.join('\n').trim();
178
+ }
179
+
180
+ /**
181
+ * Format contributors section.
182
+ */
183
+ function formatContributors(contributors) {
184
+ if (!contributors || contributors.length === 0) {
185
+ return '';
186
+ }
187
+
188
+ const lines = ['### 👥 Contributors', ''];
189
+ lines.push(formatContributorThanks(contributors));
190
+ lines.push('');
191
+
192
+ return lines.join('\n').trim();
193
+ }
194
+
195
+ /**
196
+ * Format bullet points for minimal template.
197
+ */
198
+ function formatBulletPoints(categories) {
199
+ if (!categories || Object.keys(categories).length === 0) {
200
+ return '- No changes in this release.';
201
+ }
202
+
203
+ const lines = [];
204
+ for (const changes of Object.values(categories)) {
205
+ for (const change of changes) {
206
+ let line = `- ${change.description}`;
207
+ if (change.pr) {
208
+ line += ` (#${change.pr})`;
209
+ }
210
+ lines.push(line);
211
+ }
212
+ }
213
+
214
+ return lines.join('\n');
215
+ }
216
+
217
+ /**
218
+ * Format the full changelog comparison link.
219
+ */
220
+ function formatChangelogLink(repoUrl, previousVersion, currentVersion) {
221
+ if (!previousVersion) {
222
+ return `${repoUrl}/releases/tag/v${currentVersion}`;
223
+ }
224
+ return `${repoUrl}/compare/v${previousVersion}...v${currentVersion}`;
225
+ }
226
+
227
+ /**
228
+ * Format screenshots section.
229
+ */
230
+ function formatScreenshots(screenshots) {
231
+ if (!screenshots || screenshots.length === 0) {
232
+ return '';
233
+ }
234
+
235
+ const lines = ['### Screenshots', ''];
236
+ for (const shot of screenshots) {
237
+ lines.push(`![${shot.alt || 'Screenshot'}](${shot.url})`);
238
+ }
239
+ return lines.join('\n').trim();
240
+ }
241
+
242
+ /**
243
+ * Format linked issues section.
244
+ */
245
+ function formatLinkedIssues(issues, repoUrl) {
246
+ if (!issues || issues.length === 0) {
247
+ return '';
248
+ }
249
+
250
+ const lines = ['### Linked Issues', ''];
251
+ for (const issue of issues) {
252
+ lines.push(`- [#${issue.number}](${repoUrl}/issues/${issue.number})`);
253
+ }
254
+ return lines.join('\n').trim();
255
+ }
256
+
257
+ /**
258
+ * Format impact assessment for enterprise template.
259
+ */
260
+ function formatImpactAssessment(diffSummary) {
261
+ if (!diffSummary || diffSummary.files_changed === 0) {
262
+ return 'No significant impact assessment available for this release.';
263
+ }
264
+
265
+ const lines = [];
266
+ lines.push(`- **Impact Level**: ${diffSummary.impact}`);
267
+ lines.push(`- **Files Changed**: ${diffSummary.files_changed}`);
268
+ lines.push(`- **Code Changes**: +${diffSummary.additions} / -${diffSummary.deletions}`);
269
+
270
+ if (diffSummary.affected_areas.length > 0) {
271
+ lines.push(`- **Affected Areas**: ${diffSummary.affected_areas.join(', ')}`);
272
+ }
273
+
274
+ if (diffSummary.potential_breaking.length > 0) {
275
+ lines.push('');
276
+ lines.push('**Potential Breaking Changes Detected:**');
277
+ for (const pb of diffSummary.potential_breaking) {
278
+ lines.push(`- ${pb.file}: ${pb.reason}`);
279
+ }
280
+ }
281
+
282
+ return lines.join('\n');
283
+ }
284
+
285
+ /**
286
+ * Generate action items for enterprise template.
287
+ */
288
+ function generateActionItems(analysis, diffSummary) {
289
+ const items = [];
290
+
291
+ if (analysis && analysis.breaking && analysis.breaking.length > 0) {
292
+ items.push('- **Required**: Review breaking changes and update configurations accordingly');
293
+ }
294
+
295
+ if (diffSummary && diffSummary.affected_areas) {
296
+ if (diffSummary.affected_areas.includes('Database')) {
297
+ items.push('- **Required**: Run database migrations before deploying');
298
+ }
299
+ if (diffSummary.affected_areas.includes('Configuration')) {
300
+ items.push('- **Required**: Review and update configuration files');
301
+ }
302
+ if (diffSummary.affected_areas.includes('Authentication')) {
303
+ items.push('- **Recommended**: Verify authentication flows after deployment');
304
+ }
305
+ }
306
+
307
+ if (items.length === 0) {
308
+ items.push('- No specific action items for this release');
309
+ }
310
+
311
+ return items.join('\n');
312
+ }
313
+
314
+ /**
315
+ * Clean up any remaining unresolved template variables.
316
+ * Replaces {{variable_name}} with empty string.
317
+ */
318
+ function cleanUnresolvedVariables(output) {
319
+ return output.replace(/\{\{[a-z_]+\}\}/gi, '');
320
+ }
321
+
322
+ module.exports = {
323
+ formatReleaseNotes,
324
+ loadTemplate,
325
+ generateSummary,
326
+ formatCategories,
327
+ formatBreakingChanges,
328
+ formatContributors,
329
+ formatBulletPoints,
330
+ formatChangelogLink,
331
+ formatScreenshots,
332
+ formatLinkedIssues,
333
+ formatImpactAssessment,
334
+ generateActionItems,
335
+ cleanUnresolvedVariables,
336
+ };
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Discord embed formatter.
5
+ * Produces Discord webhook-ready JSON with rich embeds,
6
+ * color coding by change type, and field-based layout.
7
+ */
8
+ class DiscordFormatter {
9
+ constructor() {
10
+ this.name = 'Discord';
11
+ this.formatName = 'discord';
12
+ }
13
+
14
+ /**
15
+ * Format release data as Discord webhook JSON string.
16
+ * @param {Object} data - ReleaseData object
17
+ * @returns {string} JSON string of Discord webhook payload
18
+ */
19
+ format(data) {
20
+ const embeds = [];
21
+
22
+ // Main release embed
23
+ const mainEmbed = {
24
+ title: `Release v${data.version}`,
25
+ url: data.previousVersion
26
+ ? `${data.repoUrl}/compare/v${data.previousVersion}...v${data.version}`
27
+ : `${data.repoUrl}/releases/tag/v${data.version}`,
28
+ color: this._getEmbedColor(data),
29
+ description: this._truncate(this._escapeDiscord(data.summary || `Release v${data.version}`), 2048),
30
+ fields: [],
31
+ footer: {
32
+ text: `Powered by AI Release Notes`,
33
+ },
34
+ timestamp: new Date().toISOString(),
35
+ };
36
+
37
+ // Add version and date fields
38
+ mainEmbed.fields.push({
39
+ name: 'Version',
40
+ value: `v${data.version}`,
41
+ inline: true,
42
+ });
43
+ mainEmbed.fields.push({
44
+ name: 'Date',
45
+ value: data.date,
46
+ inline: true,
47
+ });
48
+
49
+ embeds.push(mainEmbed);
50
+
51
+ // Breaking changes embed (red)
52
+ if (data.breaking && data.breaking.length > 0) {
53
+ const breakingEmbed = {
54
+ title: ':rotating_light: Breaking Changes',
55
+ color: 0xED4245, // Discord red
56
+ fields: [],
57
+ timestamp: new Date().toISOString(),
58
+ };
59
+
60
+ for (const bc of data.breaking.slice(0, 5)) {
61
+ let value = this._escapeDiscord(bc.description);
62
+ if (bc.scope) value += `\nScope: \`${bc.scope}\``;
63
+ if (bc.migration_guide) value += `\n**Migration:** ${this._escapeDiscord(bc.migration_guide)}`;
64
+ breakingEmbed.fields.push({
65
+ name: this._truncate(bc.scope || 'Breaking Change', 256),
66
+ value: this._truncate(value, 1024),
67
+ inline: false,
68
+ });
69
+ }
70
+
71
+ embeds.push(breakingEmbed);
72
+ }
73
+
74
+ // Category embeds with color coding
75
+ if (data.categories) {
76
+ const categoryEntries = Object.entries(data.categories);
77
+ // Discord allows max 10 embeds total
78
+ const maxCategoryEmbeds = Math.min(categoryEntries.length, 10 - embeds.length);
79
+
80
+ for (let i = 0; i < maxCategoryEmbeds; i++) {
81
+ const [category, changes] = categoryEntries[i];
82
+ const color = this._getCategoryColor(category);
83
+
84
+ const fieldList = changes.slice(0, 10).map(change => {
85
+ let line = ` - ${this._escapeDiscord(change.description)}`;
86
+ if (change.pr) line += ` [#${change.pr}](${data.repoUrl}/pull/${change.pr})`;
87
+ if (change.author) line += ` by @${change.author}`;
88
+ return line;
89
+ }).join('\n');
90
+
91
+ embeds.push({
92
+ title: category,
93
+ color: color,
94
+ description: this._truncate(fieldList, 2048),
95
+ timestamp: new Date().toISOString(),
96
+ });
97
+ }
98
+ }
99
+
100
+ // Contributors embed
101
+ if (data.contributors && data.contributors.length > 0) {
102
+ const names = data.contributors.slice(0, 10).map(c => {
103
+ const badge = c.is_first_time ? ' :star:' : '';
104
+ return `\`@${c.login}\` (${c.commits_count})${badge}`;
105
+ }).join(', ');
106
+ const more = data.contributors.length > 10 ? `\n...and ${data.contributors.length - 10} more` : '';
107
+
108
+ embeds.push({
109
+ title: ':busts_in_silhouette: Contributors',
110
+ color: 0x5865F2, // Discord blurple
111
+ description: this._truncate(names + more, 2048),
112
+ timestamp: new Date().toISOString(),
113
+ });
114
+ }
115
+
116
+ return JSON.stringify({
117
+ username: 'Release Notes',
118
+ embeds: embeds.slice(0, 10), // Discord hard limit
119
+ }, null, 2);
120
+ }
121
+
122
+ /**
123
+ * Get the main embed color based on release content.
124
+ * @param {Object} data
125
+ * @returns {number}
126
+ */
127
+ _getEmbedColor(data) {
128
+ if (data.breaking && data.breaking.length > 0) return 0xED4245; // Red
129
+ const cats = Object.keys(data.categories || {});
130
+ if (cats.some(c => c.toLowerCase().includes('feature'))) return 0x57F287; // Green
131
+ if (cats.some(c => c.toLowerCase().includes('fix'))) return 0x3498DB; // Blue
132
+ return 0x5865F2; // Blurple
133
+ }
134
+
135
+ /**
136
+ * Get color for a category type.
137
+ * @param {string} category
138
+ * @returns {number}
139
+ */
140
+ _getCategoryColor(category) {
141
+ const lower = category.toLowerCase();
142
+ if (lower.includes('feature') || lower.includes('rocket')) return 0x57F287;
143
+ if (lower.includes('fix') || lower.includes('bug')) return 0x3498DB;
144
+ if (lower.includes('breaking')) return 0xED4245;
145
+ if (lower.includes('perf')) return 0xFEE75C;
146
+ if (lower.includes('doc')) return 0xEB459E;
147
+ if (lower.includes('security') || lower.includes('lock')) return 0xED4245;
148
+ if (lower.includes('test')) return 0x99AAB5;
149
+ return 0x5865F2;
150
+ }
151
+
152
+ /**
153
+ * Escape Discord markdown special characters.
154
+ * @param {string} str
155
+ * @returns {string}
156
+ */
157
+ _escapeDiscord(str) {
158
+ if (!str) return '';
159
+ return String(str);
160
+ }
161
+
162
+ /**
163
+ * Truncate a string to a maximum length.
164
+ * @param {string} str
165
+ * @param {number} max
166
+ * @returns {string}
167
+ */
168
+ _truncate(str, max) {
169
+ if (!str) return '';
170
+ return str.length > max ? str.substring(0, max - 3) + '...' : str;
171
+ }
172
+ }
173
+
174
+ module.exports = { DiscordFormatter };
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * HTML formatter.
5
+ * Produces beautiful HTML with inline CSS, color-coded sections,
6
+ * and responsive design. Email/client ready.
7
+ */
8
+ class HTMLFormatter {
9
+ constructor() {
10
+ this.name = 'HTML';
11
+ this.formatName = 'html';
12
+ }
13
+
14
+ /**
15
+ * Format release data as HTML with inline CSS.
16
+ * @param {Object} data - ReleaseData object
17
+ * @returns {string} HTML string
18
+ */
19
+ format(data) {
20
+ const sections = [];
21
+
22
+ sections.push('<!DOCTYPE html>');
23
+ sections.push('<html lang="en">');
24
+ sections.push('<head>');
25
+ sections.push('<meta charset="UTF-8">');
26
+ sections.push('<meta name="viewport" content="width=device-width, initial-scale=1.0">');
27
+ sections.push(`<title>Release v${data.version}</title>`);
28
+ sections.push('<style>');
29
+ sections.push(this._getCSS());
30
+ sections.push('</style>');
31
+ sections.push('</head>');
32
+ sections.push('<body>');
33
+ sections.push('<div class="container">');
34
+
35
+ // Header
36
+ sections.push(`<h1 class="header">Release v${data.version}</h1>`);
37
+ sections.push(`<p class="date">${data.date}</p>`);
38
+ sections.push(`<p class="summary">${this._escapeHtml(data.summary || `Release v${data.version}`)}</p>`);
39
+
40
+ // Breaking changes
41
+ if (data.breaking && data.breaking.length > 0) {
42
+ sections.push('<div class="section breaking">');
43
+ sections.push('<h2 class="section-title breaking-title">Breaking Changes</h2>');
44
+ for (const bc of data.breaking) {
45
+ sections.push('<div class="item">');
46
+ sections.push(`<strong>${this._escapeHtml(bc.description)}</strong>`);
47
+ if (bc.scope) {
48
+ sections.push(`<span class="badge scope">Scope: ${this._escapeHtml(bc.scope)}</span>`);
49
+ }
50
+ if (bc.migration_guide) {
51
+ sections.push(`<p class="migration"><strong>Migration:</strong> ${this._escapeHtml(bc.migration_guide)}</p>`);
52
+ }
53
+ if (bc.pr) {
54
+ sections.push(`<a href="${data.repoUrl}/pull/${bc.pr}" class="link">PR #${bc.pr}</a>`);
55
+ }
56
+ sections.push('</div>');
57
+ }
58
+ sections.push('</div>');
59
+ }
60
+
61
+ // Categorized changes
62
+ if (data.categories) {
63
+ for (const [category, changes] of Object.entries(data.categories)) {
64
+ const cssClass = this._getCategoryClass(category);
65
+ sections.push(`<div class="section ${cssClass}">`);
66
+ sections.push(`<h2 class="section-title ${cssClass}-title">${this._escapeHtml(category)}</h2>`);
67
+ sections.push('<ul>');
68
+ for (const change of changes) {
69
+ let li = this._escapeHtml(change.description);
70
+ if (change.scope) {
71
+ li += ` <code class="scope-code">${this._escapeHtml(change.scope)}</code>`;
72
+ }
73
+ if (change.pr) {
74
+ li += ` <a href="${data.repoUrl}/pull/${change.pr}" class="link">#${change.pr}</a>`;
75
+ }
76
+ if (change.author) {
77
+ li += ` <span class="author">by @${this._escapeHtml(change.author)}</span>`;
78
+ }
79
+ sections.push(`<li>${li}</li>`);
80
+ }
81
+ sections.push('</ul>');
82
+ sections.push('</div>');
83
+ }
84
+ }
85
+
86
+ // Contributors
87
+ if (data.contributors && data.contributors.length > 0) {
88
+ sections.push('<div class="section contributors">');
89
+ sections.push('<h2 class="section-title contributors-title">Contributors</h2>');
90
+ sections.push('<div class="contributor-list">');
91
+ for (const c of data.contributors) {
92
+ const firstTimer = c.is_first_time ? ' <span class="first-timer">FIRST TIME</span>' : '';
93
+ sections.push(
94
+ `<span class="contributor">@${this._escapeHtml(c.login)} (${c.commits_count})${firstTimer}</span>`
95
+ );
96
+ }
97
+ sections.push('</div>');
98
+ sections.push('</div>');
99
+ }
100
+
101
+ // Footer
102
+ sections.push('<div class="footer">');
103
+ if (data.previousVersion) {
104
+ sections.push(`<a href="${data.repoUrl}/compare/v${data.previousVersion}...v${data.version}" class="changelog-link">Full Changelog</a>`);
105
+ }
106
+ sections.push(`<span>Generated on ${data.date}</span>`);
107
+ sections.push('</div>');
108
+
109
+ sections.push('</div>');
110
+ sections.push('</body>');
111
+ sections.push('</html>');
112
+
113
+ return sections.join('\n');
114
+ }
115
+
116
+ /**
117
+ * Get inline CSS styles.
118
+ * @returns {string}
119
+ */
120
+ _getCSS() {
121
+ return `
122
+ * { box-sizing: border-box; margin: 0; padding: 0; }
123
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif; background: #f6f8fa; color: #24292f; line-height: 1.6; padding: 20px; }
124
+ .container { max-width: 800px; margin: 0 auto; background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); padding: 32px; }
125
+ .header { font-size: 28px; font-weight: 700; color: #24292f; border-bottom: 2px solid #e1e4e8; padding-bottom: 12px; margin-bottom: 8px; }
126
+ .date { color: #656d76; font-size: 14px; margin-bottom: 12px; }
127
+ .summary { font-size: 16px; color: #24292f; margin-bottom: 24px; padding: 12px; background: #f6f8fa; border-radius: 6px; border-left: 4px solid #0969da; }
128
+ .section { margin-bottom: 24px; border-radius: 8px; padding: 16px; }
129
+ .section-title { font-size: 18px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px; }
130
+ .features { background: #f0fff4; border: 1px solid #2da44e; }
131
+ .features-title { color: #1a7f37; border-bottom: 1px solid #2da44e33; }
132
+ .fixes { background: #ddf4ff; border: 1px solid #0969da; }
133
+ .fixes-title { color: #0550ae; border-bottom: 1px solid #0969da33; }
134
+ .breaking { background: #ffebe9; border: 1px solid #cf222e; }
135
+ .breaking-title { color: #a40e26; border-bottom: 1px solid #cf222e33; }
136
+ .other { background: #f6f8fa; border: 1px solid #d0d7de; }
137
+ .other-title { color: #656d76; border-bottom: 1px solid #d0d7de; }
138
+ .performance { background: #fff8c5; border: 1px solid #bf8700; }
139
+ .performance-title { color: #9a6700; border-bottom: 1px solid #bf870033; }
140
+ .section ul { list-style: none; padding-left: 0; }
141
+ .section li { padding: 4px 0; padding-left: 20px; position: relative; }
142
+ .section li::before { content: ''; position: absolute; left: 0; top: 12px; width: 8px; height: 8px; border-radius: 50%; }
143
+ .features li::before { background: #2da44e; }
144
+ .fixes li::before { background: #0969da; }
145
+ .breaking li::before { background: #cf222e; }
146
+ .item { padding: 8px 12px; margin-bottom: 8px; background: #fff; border-radius: 6px; border: 1px solid #cf222e33; }
147
+ .migration { margin-top: 6px; font-size: 14px; color: #656d76; }
148
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; margin-left: 8px; }
149
+ .scope { background: #e1e4e8; color: #656d76; }
150
+ .scope-code { background: #e1e4e8; padding: 1px 6px; border-radius: 4px; font-size: 13px; }
151
+ .link { color: #0969da; text-decoration: none; }
152
+ .link:hover { text-decoration: underline; }
153
+ .author { color: #656d76; font-size: 14px; }
154
+ .contributors { background: #fafbfc; border: 1px solid #d0d7de; }
155
+ .contributors-title { color: #24292f; border-bottom: 1px solid #d0d7de; }
156
+ .contributor-list { display: flex; flex-wrap: wrap; gap: 8px; }
157
+ .contributor { display: inline-block; padding: 4px 12px; background: #f6f8fa; border-radius: 16px; font-size: 14px; border: 1px solid #d0d7de; }
158
+ .first-timer { background: #ffd33d; color: #24292f; padding: 1px 6px; border-radius: 8px; font-size: 11px; font-weight: 600; margin-left: 4px; }
159
+ .footer { text-align: center; padding-top: 16px; border-top: 1px solid #e1e4e8; color: #656d76; font-size: 13px; display: flex; justify-content: space-between; align-items: center; }
160
+ .changelog-link { color: #0969da; text-decoration: none; font-weight: 500; }
161
+ @media (max-width: 600px) { .container { padding: 16px; } .header { font-size: 22px; } }
162
+ `.replace(/\n\s+/g, ' ').trim();
163
+ }
164
+
165
+ /**
166
+ * Get a CSS class name for a category.
167
+ * @param {string} category
168
+ * @returns {string}
169
+ */
170
+ _getCategoryClass(category) {
171
+ const lower = category.toLowerCase();
172
+ if (lower.includes('feature') || lower.includes('rocket')) return 'features';
173
+ if (lower.includes('fix') || lower.includes('bug')) return 'fixes';
174
+ if (lower.includes('breaking')) return 'breaking';
175
+ if (lower.includes('perf') || lower.includes('performance') || lower.includes('speed')) return 'performance';
176
+ return 'other';
177
+ }
178
+
179
+ /**
180
+ * Escape HTML special characters.
181
+ * @param {string} str
182
+ * @returns {string}
183
+ */
184
+ _escapeHtml(str) {
185
+ if (!str) return '';
186
+ return String(str)
187
+ .replace(/&/g, '&amp;')
188
+ .replace(/</g, '&lt;')
189
+ .replace(/>/g, '&gt;')
190
+ .replace(/"/g, '&quot;')
191
+ .replace(/'/g, '&#039;');
192
+ }
193
+ }
194
+
195
+ module.exports = { HTMLFormatter };