@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,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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
+ };