@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
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { OUTPUT_FORMATS, isValidFormat } = require('./types');
|
|
4
|
+
const { MarkdownFormatter } = require('./markdown');
|
|
5
|
+
const { HTMLFormatter } = require('./html');
|
|
6
|
+
const { SlackFormatter } = require('./slack');
|
|
7
|
+
const { DiscordFormatter } = require('./discord');
|
|
8
|
+
const { TwitterFormatter } = require('./twitter');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Formatter factory. Returns the appropriate formatter for a given output format.
|
|
12
|
+
* @param {string} format - Output format identifier ('markdown', 'html', 'slack', 'discord', 'twitter')
|
|
13
|
+
* @returns {Object} Formatter instance with name, format, and format() method
|
|
14
|
+
* @throws {Error} If format is not recognized
|
|
15
|
+
*/
|
|
16
|
+
function getFormatter(format) {
|
|
17
|
+
switch (format) {
|
|
18
|
+
case 'markdown':
|
|
19
|
+
return new MarkdownFormatter();
|
|
20
|
+
case 'html':
|
|
21
|
+
return new HTMLFormatter();
|
|
22
|
+
case 'slack':
|
|
23
|
+
return new SlackFormatter();
|
|
24
|
+
case 'discord':
|
|
25
|
+
return new DiscordFormatter();
|
|
26
|
+
case 'twitter':
|
|
27
|
+
return new TwitterFormatter();
|
|
28
|
+
default:
|
|
29
|
+
throw new Error(`Unknown output format: "${format}". Valid formats: ${OUTPUT_FORMATS.join(', ')}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
getFormatter,
|
|
35
|
+
OUTPUT_FORMATS,
|
|
36
|
+
isValidFormat,
|
|
37
|
+
MarkdownFormatter,
|
|
38
|
+
HTMLFormatter,
|
|
39
|
+
SlackFormatter,
|
|
40
|
+
DiscordFormatter,
|
|
41
|
+
TwitterFormatter,
|
|
42
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { formatDiffStats } = require('../diff-analyzer');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GitHub-flavored Markdown formatter.
|
|
7
|
+
* Produces rich markdown with emojis, collapsible sections, code blocks,
|
|
8
|
+
* and contributor acknowledgments.
|
|
9
|
+
*/
|
|
10
|
+
class MarkdownFormatter {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.name = 'Markdown';
|
|
13
|
+
this.formatName = 'markdown';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format release data as GitHub-flavored markdown.
|
|
18
|
+
* @param {Object} data - ReleaseData object
|
|
19
|
+
* @returns {string} Formatted markdown string
|
|
20
|
+
*/
|
|
21
|
+
format(data) {
|
|
22
|
+
const lines = [];
|
|
23
|
+
|
|
24
|
+
// Title and summary
|
|
25
|
+
lines.push(`## What's Changed in v${data.version}`);
|
|
26
|
+
lines.push('');
|
|
27
|
+
lines.push(`> ${data.summary || `Release v${data.version}`}`);
|
|
28
|
+
lines.push('');
|
|
29
|
+
|
|
30
|
+
// Breaking changes (prominent, red alert)
|
|
31
|
+
if (data.breaking && data.breaking.length > 0) {
|
|
32
|
+
lines.push('### :rotating_light: Breaking Changes');
|
|
33
|
+
lines.push('');
|
|
34
|
+
lines.push('<details>');
|
|
35
|
+
lines.push('<summary><strong>Click to view breaking changes and migration guide</strong></summary>');
|
|
36
|
+
lines.push('');
|
|
37
|
+
for (const bc of data.breaking) {
|
|
38
|
+
lines.push(`- **${bc.description}**`);
|
|
39
|
+
if (bc.scope) {
|
|
40
|
+
lines.push(` - Scope: \`${bc.scope}\``);
|
|
41
|
+
}
|
|
42
|
+
if (bc.migration_guide) {
|
|
43
|
+
lines.push(` - **Migration**: ${bc.migration_guide}`);
|
|
44
|
+
}
|
|
45
|
+
if (bc.pr) {
|
|
46
|
+
lines.push(` - PR: [#${bc.pr}](${data.repoUrl}/pull/${bc.pr})`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
lines.push('');
|
|
50
|
+
lines.push('</details>');
|
|
51
|
+
lines.push('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Categorized changes
|
|
55
|
+
if (data.categories && Object.keys(data.categories).length > 0) {
|
|
56
|
+
for (const [category, changes] of Object.entries(data.categories)) {
|
|
57
|
+
lines.push(`### ${category}`);
|
|
58
|
+
lines.push('');
|
|
59
|
+
for (const change of changes) {
|
|
60
|
+
let line = `- ${change.description}`;
|
|
61
|
+
if (change.scope) {
|
|
62
|
+
line += ` [\`${change.scope}\`]`;
|
|
63
|
+
}
|
|
64
|
+
if (change.pr) {
|
|
65
|
+
line += ` ([#${change.pr}](${data.repoUrl}/pull/${change.pr}))`;
|
|
66
|
+
}
|
|
67
|
+
if (change.author) {
|
|
68
|
+
line += ` by @${change.author}`;
|
|
69
|
+
}
|
|
70
|
+
lines.push(line);
|
|
71
|
+
}
|
|
72
|
+
lines.push('');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Diff statistics (collapsible)
|
|
77
|
+
if (data.diffSummary && data.diffSummary.files_changed > 0) {
|
|
78
|
+
lines.push('<details>');
|
|
79
|
+
lines.push('<summary>:bar_chart: Diff Statistics</summary>');
|
|
80
|
+
lines.push('');
|
|
81
|
+
lines.push(formatDiffStats(data.diffSummary));
|
|
82
|
+
lines.push('');
|
|
83
|
+
lines.push('</details>');
|
|
84
|
+
lines.push('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Linked issues (collapsible)
|
|
88
|
+
if (data.linkedIssues && data.linkedIssues.length > 0) {
|
|
89
|
+
lines.push('<details>');
|
|
90
|
+
lines.push(`<summary>:link: Linked Issues (${data.linkedIssues.length})</summary>`);
|
|
91
|
+
lines.push('');
|
|
92
|
+
for (const issue of data.linkedIssues) {
|
|
93
|
+
lines.push(`- [#${issue.number}](${data.repoUrl}/issues/${issue.number})`);
|
|
94
|
+
}
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push('</details>');
|
|
97
|
+
lines.push('');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Contributors
|
|
101
|
+
if (data.contributors && data.contributors.length > 0) {
|
|
102
|
+
lines.push('### :busts_in_silhouette: Contributors');
|
|
103
|
+
lines.push('');
|
|
104
|
+
const logins = data.contributors.map(c => {
|
|
105
|
+
const badge = c.is_first_time ? ' :tada:' : '';
|
|
106
|
+
return `- @${c.login} (${c.commits_count} commit${c.commits_count !== 1 ? 's' : ''})${badge}`;
|
|
107
|
+
});
|
|
108
|
+
lines.push(logins.join('\n'));
|
|
109
|
+
lines.push('');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Full changelog link
|
|
113
|
+
if (data.previousVersion) {
|
|
114
|
+
lines.push(`**Full Changelog**: ${data.repoUrl}/compare/v${data.previousVersion}...v${data.version}`);
|
|
115
|
+
} else {
|
|
116
|
+
lines.push(`**Full Changelog**: ${data.repoUrl}/releases/tag/v${data.version}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { MarkdownFormatter };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Slack Block Kit formatter.
|
|
5
|
+
* Produces JSON with Slack Block Kit sections, dividers, fields,
|
|
6
|
+
* context blocks, and mention support.
|
|
7
|
+
*/
|
|
8
|
+
class SlackFormatter {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.name = 'Slack';
|
|
11
|
+
this.formatName = 'slack';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Format release data as Slack Block Kit JSON string.
|
|
16
|
+
* @param {Object} data - ReleaseData object
|
|
17
|
+
* @returns {string} JSON string of Slack Block Kit payload
|
|
18
|
+
*/
|
|
19
|
+
format(data) {
|
|
20
|
+
const blocks = [];
|
|
21
|
+
|
|
22
|
+
// Header block
|
|
23
|
+
blocks.push({
|
|
24
|
+
type: 'header',
|
|
25
|
+
text: {
|
|
26
|
+
type: 'plain_text',
|
|
27
|
+
text: `Release v${data.version}`,
|
|
28
|
+
emoji: true,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Summary section
|
|
33
|
+
blocks.push({
|
|
34
|
+
type: 'section',
|
|
35
|
+
text: {
|
|
36
|
+
type: 'mrkdwn',
|
|
37
|
+
text: `:rocket: *${this._escapeSlack(data.summary || `Release v${data.version}`)}*`,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Date context
|
|
42
|
+
blocks.push({
|
|
43
|
+
type: 'context',
|
|
44
|
+
elements: [
|
|
45
|
+
{
|
|
46
|
+
type: 'mrkdwn',
|
|
47
|
+
text: `:calendar: ${data.date}`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Breaking changes (alert)
|
|
53
|
+
if (data.breaking && data.breaking.length > 0) {
|
|
54
|
+
blocks.push({ type: 'divider' });
|
|
55
|
+
blocks.push({
|
|
56
|
+
type: 'section',
|
|
57
|
+
text: {
|
|
58
|
+
type: 'mrkdwn',
|
|
59
|
+
text: `:rotating_light: *Breaking Changes*`,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const breakingText = data.breaking.map(bc => {
|
|
64
|
+
let line = `> *${this._escapeSlack(bc.description)}*`;
|
|
65
|
+
if (bc.scope) line += ` \`${this._escapeSlack(bc.scope)}\``;
|
|
66
|
+
if (bc.migration_guide) line += `\n> _Migration: ${this._escapeSlack(bc.migration_guide)}_`;
|
|
67
|
+
return line;
|
|
68
|
+
}).join('\n');
|
|
69
|
+
|
|
70
|
+
blocks.push({
|
|
71
|
+
type: 'section',
|
|
72
|
+
text: {
|
|
73
|
+
type: 'mrkdwn',
|
|
74
|
+
text: breakingText,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Categorized changes
|
|
80
|
+
if (data.categories) {
|
|
81
|
+
for (const [category, changes] of Object.entries(data.categories)) {
|
|
82
|
+
blocks.push({ type: 'divider' });
|
|
83
|
+
|
|
84
|
+
// Category header
|
|
85
|
+
blocks.push({
|
|
86
|
+
type: 'section',
|
|
87
|
+
text: {
|
|
88
|
+
type: 'mrkdwn',
|
|
89
|
+
text: `*${this._escapeSlack(category)}*`,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Changes as fields (max 10 per block, max 5 visible)
|
|
94
|
+
const fields = [];
|
|
95
|
+
for (const change of changes.slice(0, 10)) {
|
|
96
|
+
let text = this._escapeSlack(change.description);
|
|
97
|
+
if (change.pr) text += ` <${data.repoUrl}/pull/${change.pr}|#${change.pr}>`;
|
|
98
|
+
if (change.author) text += ` _@${change.author}_`;
|
|
99
|
+
fields.push({
|
|
100
|
+
type: 'mrkdwn',
|
|
101
|
+
text: text.length > 200 ? text.substring(0, 197) + '...' : text,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Slack supports max 10 fields per block
|
|
106
|
+
for (let i = 0; i < fields.length; i += 10) {
|
|
107
|
+
blocks.push({
|
|
108
|
+
type: 'section',
|
|
109
|
+
fields: fields.slice(i, i + 10),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Contributors context
|
|
116
|
+
if (data.contributors && data.contributors.length > 0) {
|
|
117
|
+
blocks.push({ type: 'divider' });
|
|
118
|
+
const names = data.contributors.slice(0, 8).map(c => {
|
|
119
|
+
const suffix = c.is_first_time ? ' :tada:' : '';
|
|
120
|
+
return `@${c.login}${suffix}`;
|
|
121
|
+
}).join(', ');
|
|
122
|
+
const more = data.contributors.length > 8 ? ` and ${data.contributors.length - 8} more` : '';
|
|
123
|
+
|
|
124
|
+
blocks.push({
|
|
125
|
+
type: 'context',
|
|
126
|
+
elements: [
|
|
127
|
+
{
|
|
128
|
+
type: 'mrkdwn',
|
|
129
|
+
text: `:busts_in_silhouette: Contributors: ${names}${more}`,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Full changelog button
|
|
136
|
+
blocks.push({ type: 'divider' });
|
|
137
|
+
const actions = [];
|
|
138
|
+
if (data.previousVersion) {
|
|
139
|
+
actions.push({
|
|
140
|
+
type: 'button',
|
|
141
|
+
text: {
|
|
142
|
+
type: 'plain_text',
|
|
143
|
+
text: 'View Full Changelog',
|
|
144
|
+
},
|
|
145
|
+
url: `${data.repoUrl}/compare/v${data.previousVersion}...v${data.version}`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
actions.push({
|
|
149
|
+
type: 'button',
|
|
150
|
+
text: {
|
|
151
|
+
type: 'plain_text',
|
|
152
|
+
text: 'View Repository',
|
|
153
|
+
},
|
|
154
|
+
url: data.repoUrl,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
blocks.push({
|
|
158
|
+
type: 'actions',
|
|
159
|
+
elements: actions,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return JSON.stringify({ blocks }, null, 2);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Escape Slack mrkdwn special characters.
|
|
167
|
+
* @param {string} str
|
|
168
|
+
* @returns {string}
|
|
169
|
+
*/
|
|
170
|
+
_escapeSlack(str) {
|
|
171
|
+
if (!str) return '';
|
|
172
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { SlackFormatter };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Twitter/X thread formatter.
|
|
5
|
+
* Breaks release notes into a tweet thread with 280-character limit per tweet,
|
|
6
|
+
* numbered tweets (1/N, 2/N, etc.), and hashtag suggestions.
|
|
7
|
+
*/
|
|
8
|
+
class TwitterFormatter {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.name = 'Twitter';
|
|
11
|
+
this.formatName = 'twitter';
|
|
12
|
+
this.MAX_TWEET_LENGTH = 280;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Format release data as a Twitter thread (array of tweet objects).
|
|
17
|
+
* @param {Object} data - ReleaseData object
|
|
18
|
+
* @returns {string} JSON string of tweet array
|
|
19
|
+
*/
|
|
20
|
+
format(data) {
|
|
21
|
+
const tweets = [];
|
|
22
|
+
|
|
23
|
+
// Tweet 1: Announcement
|
|
24
|
+
let announcement = `v${data.version} is here! `;
|
|
25
|
+
if (data.summary && data.summary.length < 150) {
|
|
26
|
+
announcement += data.summary;
|
|
27
|
+
} else {
|
|
28
|
+
announcement += this._shortSummary(data);
|
|
29
|
+
}
|
|
30
|
+
announcement = this._trimToLength(announcement, this.MAX_TWEET_LENGTH);
|
|
31
|
+
tweets.push(announcement);
|
|
32
|
+
|
|
33
|
+
// Tweet 2: Features (if any)
|
|
34
|
+
const featureCategories = this._getCategoriesByType(data, 'feature');
|
|
35
|
+
if (featureCategories.length > 0) {
|
|
36
|
+
const featureTweets = this._formatCategoryThread(
|
|
37
|
+
featureCategories,
|
|
38
|
+
':rocket: New in v' + data.version + ':'
|
|
39
|
+
);
|
|
40
|
+
tweets.push(...featureTweets);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Tweet: Bug fixes (if any)
|
|
44
|
+
const fixCategories = this._getCategoriesByType(data, 'fix');
|
|
45
|
+
if (fixCategories.length > 0) {
|
|
46
|
+
const fixTweets = this._formatCategoryThread(
|
|
47
|
+
fixCategories,
|
|
48
|
+
':wrench: Bug fixes in v' + data.version + ':'
|
|
49
|
+
);
|
|
50
|
+
tweets.push(...fixTweets);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Tweet: Breaking changes (if any)
|
|
54
|
+
if (data.breaking && data.breaking.length > 0) {
|
|
55
|
+
const breakingTexts = [];
|
|
56
|
+
for (const bc of data.breaking) {
|
|
57
|
+
let text = `- ${bc.description}`;
|
|
58
|
+
if (bc.migration_guide) {
|
|
59
|
+
text += ` | Migration: ${bc.migration_guide}`;
|
|
60
|
+
}
|
|
61
|
+
breakingTexts.push(text);
|
|
62
|
+
}
|
|
63
|
+
const breakingTweets = this._splitIntoTweets(
|
|
64
|
+
breakingTexts,
|
|
65
|
+
':rotating_light: BREAKING CHANGES in v' + data.version + ':'
|
|
66
|
+
);
|
|
67
|
+
tweets.push(...breakingTweets);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Tweet: Other categories
|
|
71
|
+
const otherCategories = this._getOtherCategories(data);
|
|
72
|
+
for (const [cat, changes] of otherCategories) {
|
|
73
|
+
const items = changes.map(c => `- ${c.description}`);
|
|
74
|
+
const catTweets = this._splitIntoTweets(items, `${cat}:`);
|
|
75
|
+
tweets.push(...catTweets);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Tweet: Contributors
|
|
79
|
+
if (data.contributors && data.contributors.length > 0) {
|
|
80
|
+
const names = data.contributors.slice(0, 10).map(c => `@${c.login}`).join(', ');
|
|
81
|
+
const more = data.contributors.length > 10 ? ` +${data.contributors.length - 10} more` : '';
|
|
82
|
+
const firstTimers = data.contributors.filter(c => c.is_first_time);
|
|
83
|
+
let contributorTweet = `Contributors: ${names}${more}`;
|
|
84
|
+
if (firstTimers.length > 0) {
|
|
85
|
+
contributorTweet += `\nFirst-time: ${firstTimers.map(c => `@${c.login}`).join(', ')}`;
|
|
86
|
+
}
|
|
87
|
+
tweets.push(this._trimToLength(contributorTweet, this.MAX_TWEET_LENGTH));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Tweet: Link + hashtags
|
|
91
|
+
let linkTweet = '';
|
|
92
|
+
if (data.previousVersion) {
|
|
93
|
+
linkTweet = `Full changelog: ${data.repoUrl}/compare/v${data.previousVersion}...v${data.version}`;
|
|
94
|
+
} else {
|
|
95
|
+
linkTweet = `${data.repoUrl}`;
|
|
96
|
+
}
|
|
97
|
+
const hashtags = this._suggestHashtags(data);
|
|
98
|
+
linkTweet += `\n\n${hashtags}`;
|
|
99
|
+
tweets.push(this._trimToLength(linkTweet, this.MAX_TWEET_LENGTH));
|
|
100
|
+
|
|
101
|
+
// Number all tweets
|
|
102
|
+
const total = tweets.length;
|
|
103
|
+
const numbered = tweets.map((tweet, i) => {
|
|
104
|
+
const prefix = `${i + 1}/${total} `;
|
|
105
|
+
if ((prefix + tweet).length <= this.MAX_TWEET_LENGTH) {
|
|
106
|
+
return prefix + tweet;
|
|
107
|
+
}
|
|
108
|
+
// If prefix would overflow, truncate the tweet
|
|
109
|
+
return prefix + tweet.substring(0, this.MAX_TWEET_LENGTH - prefix.length);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return JSON.stringify(numbered, null, 2);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get a short summary for the announcement tweet.
|
|
117
|
+
* @param {Object} data
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
_shortSummary(data) {
|
|
121
|
+
const parts = [];
|
|
122
|
+
if (data.categories) {
|
|
123
|
+
const catCount = Object.keys(data.categories).length;
|
|
124
|
+
if (catCount > 0) parts.push(`${catCount} categories of changes`);
|
|
125
|
+
}
|
|
126
|
+
if (data.contributors && data.contributors.length > 0) {
|
|
127
|
+
parts.push(`${data.contributors.length} contributors`);
|
|
128
|
+
}
|
|
129
|
+
if (data.breaking && data.breaking.length > 0) {
|
|
130
|
+
parts.push(`${data.breaking.length} breaking change${data.breaking.length > 1 ? 's' : ''}`);
|
|
131
|
+
}
|
|
132
|
+
return parts.length > 0 ? parts.join(', ') + '.' : 'Various improvements and fixes.';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get categories that match a type keyword.
|
|
137
|
+
* @param {Object} data
|
|
138
|
+
* @param {string} type
|
|
139
|
+
* @returns {Array<{description: string}>}
|
|
140
|
+
*/
|
|
141
|
+
_getCategoriesByType(data, type) {
|
|
142
|
+
const results = [];
|
|
143
|
+
if (!data.categories) return results;
|
|
144
|
+
for (const [category, changes] of Object.entries(data.categories)) {
|
|
145
|
+
if (category.toLowerCase().includes(type)) {
|
|
146
|
+
results.push(...changes);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return results;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get categories that are not features or fixes.
|
|
154
|
+
* @param {Object} data
|
|
155
|
+
* @returns {Array}
|
|
156
|
+
*/
|
|
157
|
+
_getOtherCategories(data) {
|
|
158
|
+
if (!data.categories) return [];
|
|
159
|
+
const results = [];
|
|
160
|
+
for (const [category, changes] of Object.entries(data.categories)) {
|
|
161
|
+
const lower = category.toLowerCase();
|
|
162
|
+
if (!lower.includes('feature') && !lower.includes('fix') && !lower.includes('bug')) {
|
|
163
|
+
results.push([category, changes]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Format a set of changes as a thread with a header.
|
|
171
|
+
* @param {Array} changes
|
|
172
|
+
* @param {string} header
|
|
173
|
+
* @returns {string[]}
|
|
174
|
+
*/
|
|
175
|
+
_formatCategoryThread(changes, header) {
|
|
176
|
+
const items = changes.map(c => {
|
|
177
|
+
let text = `- ${c.description}`;
|
|
178
|
+
if (c.pr) text += ` (#${c.pr})`;
|
|
179
|
+
return text;
|
|
180
|
+
});
|
|
181
|
+
return this._splitIntoTweets(items, header);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Split a list of text items into tweets respecting the character limit.
|
|
186
|
+
* @param {string[]} items
|
|
187
|
+
* @param {string} header
|
|
188
|
+
* @returns {string[]}
|
|
189
|
+
*/
|
|
190
|
+
_splitIntoTweets(items, header) {
|
|
191
|
+
const tweets = [];
|
|
192
|
+
let current = header + '\n';
|
|
193
|
+
|
|
194
|
+
for (const item of items) {
|
|
195
|
+
if ((current + '\n' + item).length > this.MAX_TWEET_LENGTH) {
|
|
196
|
+
tweets.push(current.trim());
|
|
197
|
+
current = header + ' (cont.)\n';
|
|
198
|
+
}
|
|
199
|
+
current += item + '\n';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (current.trim().length > header.length) {
|
|
203
|
+
tweets.push(current.trim());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return tweets;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Trim a string to a maximum length, respecting word boundaries.
|
|
211
|
+
* @param {string} str
|
|
212
|
+
* @param {number} max
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
_trimToLength(str, max) {
|
|
216
|
+
if (!str || str.length <= max) return str || '';
|
|
217
|
+
return str.substring(0, max - 1) + '...';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Suggest hashtags based on release content.
|
|
222
|
+
* @param {Object} data
|
|
223
|
+
* @returns {string}
|
|
224
|
+
*/
|
|
225
|
+
_suggestHashtags(data) {
|
|
226
|
+
const tags = ['#Release', '#Changelog'];
|
|
227
|
+
|
|
228
|
+
if (data.breaking && data.breaking.length > 0) {
|
|
229
|
+
tags.push('#BreakingChanges');
|
|
230
|
+
}
|
|
231
|
+
if (data.categories) {
|
|
232
|
+
const catNames = Object.keys(data.categories).map(c => c.toLowerCase());
|
|
233
|
+
if (catNames.some(c => c.includes('feature'))) tags.push('#NewFeatures');
|
|
234
|
+
if (catNames.some(c => c.includes('security'))) tags.push('#Security');
|
|
235
|
+
if (catNames.some(c => c.includes('perf'))) tags.push('#Performance');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return tags.join(' ');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { TwitterFormatter };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {'markdown'|'html'|'slack'|'discord'|'twitter'} OutputFormat
|
|
5
|
+
* Supported output formats for release notes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} ReleaseData
|
|
10
|
+
* @property {string} version - Release version (e.g. "2.1.0")
|
|
11
|
+
* @property {string} previousVersion - Previous version (e.g. "2.0.0")
|
|
12
|
+
* @property {string} date - Release date (ISO format YYYY-MM-DD)
|
|
13
|
+
* @property {string} summary - One-line summary of the release
|
|
14
|
+
* @property {Object<string, Array<{description: string, type?: string, scope?: string, pr?: number|null, author?: string, breaking?: boolean}>>} categories - Categorized changes
|
|
15
|
+
* @property {Array<{description: string, scope?: string, type?: string, pr?: number|null, migration_guide?: string}>} breaking - Breaking changes
|
|
16
|
+
* @property {Array<{login: string, name?: string, commits_count: number, is_first_time?: boolean}>} contributors - Contributors
|
|
17
|
+
* @property {Object|null} diffSummary - Diff analysis summary
|
|
18
|
+
* @property {string} repoUrl - Repository URL
|
|
19
|
+
* @property {string} repoFullName - Repository full name (owner/repo)
|
|
20
|
+
* @property {Array<{number: number}>} linkedIssues - Linked issue numbers
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} Formatter
|
|
25
|
+
* @property {string} name - Human-readable formatter name
|
|
26
|
+
* @property {OutputFormat} format - The format identifier
|
|
27
|
+
* @property {(data: ReleaseData) => string} format - Convert release data to formatted string
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* List of all supported output formats.
|
|
32
|
+
* @type {OutputFormat[]}
|
|
33
|
+
*/
|
|
34
|
+
const OUTPUT_FORMATS = ['markdown', 'html', 'slack', 'discord', 'twitter'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if a format string is valid.
|
|
38
|
+
* @param {string} format - Format to validate
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
function isValidFormat(format) {
|
|
42
|
+
return OUTPUT_FORMATS.includes(format);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
OUTPUT_FORMATS,
|
|
47
|
+
isValidFormat,
|
|
48
|
+
};
|