@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.
- package/LICENSE +21 -0
- package/README.md +1493 -0
- package/__tests__/analyzer.test.js +63 -0
- package/__tests__/categorizer.test.js +93 -0
- package/__tests__/config.test.js +92 -0
- package/__tests__/formatter.test.js +63 -0
- package/__tests__/formatters.test.js +394 -0
- package/__tests__/migration.test.js +322 -0
- package/__tests__/semver.test.js +94 -0
- package/__tests__/tones.test.js +252 -0
- package/action.yml +113 -0
- package/index.js +73 -0
- package/jest.config.js +10 -0
- package/package.json +41 -0
- package/src/ai-writer.js +108 -0
- package/src/analyzer.js +232 -0
- package/src/analyzers/migration.js +355 -0
- package/src/categorizer.js +182 -0
- package/src/config.js +162 -0
- package/src/constants.js +137 -0
- package/src/contributor.js +144 -0
- package/src/diff-analyzer.js +202 -0
- package/src/formatter.js +336 -0
- package/src/formatters/discord.js +174 -0
- package/src/formatters/html.js +195 -0
- package/src/formatters/index.js +42 -0
- package/src/formatters/markdown.js +123 -0
- package/src/formatters/slack.js +176 -0
- package/src/formatters/twitter.js +242 -0
- package/src/formatters/types.js +48 -0
- package/src/generator.js +297 -0
- package/src/integrations/changelog.js +125 -0
- package/src/integrations/discord.js +96 -0
- package/src/integrations/github-release.js +75 -0
- package/src/integrations/indexer.js +119 -0
- package/src/integrations/slack.js +112 -0
- package/src/integrations/twitter.js +128 -0
- package/src/logger.js +52 -0
- package/src/prompts.js +210 -0
- package/src/rate-limiter.js +92 -0
- package/src/semver.js +129 -0
- package/src/tones/casual.js +114 -0
- package/src/tones/humorous.js +164 -0
- package/src/tones/index.js +38 -0
- package/src/tones/professional.js +125 -0
- package/src/tones/technical.js +164 -0
- package/src/tones/types.js +26 -0
package/src/formatter.js
ADDED
|
@@ -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(``);
|
|
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, '&')
|
|
188
|
+
.replace(/</g, '<')
|
|
189
|
+
.replace(/>/g, '>')
|
|
190
|
+
.replace(/"/g, '"')
|
|
191
|
+
.replace(/'/g, ''');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = { HTMLFormatter };
|