@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,92 @@
1
+ 'use strict';
2
+
3
+ const logger = require('./logger');
4
+
5
+ /**
6
+ * Simple rate limiter for API calls.
7
+ * Tracks calls per time window and enforces delays when approaching limits.
8
+ */
9
+ class RateLimiter {
10
+ constructor(options = {}) {
11
+ this.maxRequests = options.maxRequests || 60;
12
+ this.windowMs = options.windowMs || 60 * 1000; // 1 minute
13
+ this.minDelay = options.minDelay || 100; // 100ms between calls
14
+ this.calls = [];
15
+ }
16
+
17
+ /**
18
+ * Wait before making the next API call if needed.
19
+ * Call this before each API request.
20
+ */
21
+ async wait() {
22
+ const now = Date.now();
23
+
24
+ // Clean up old calls outside the window
25
+ this.calls = this.calls.filter(time => now - time < this.windowMs);
26
+
27
+ // If at limit, wait until oldest call expires
28
+ if (this.calls.length >= this.maxRequests) {
29
+ const oldestCall = this.calls[0];
30
+ const waitTime = this.windowMs - (now - oldestCall) + 100;
31
+ if (waitTime > 0) {
32
+ logger.debug(`Rate limit approaching โ€” waiting ${waitTime}ms`);
33
+ await this.sleep(waitTime);
34
+ }
35
+ // Clean again after waiting
36
+ this.calls = this.calls.filter(time => Date.now() - time < this.windowMs);
37
+ }
38
+
39
+ // Enforce minimum delay between calls
40
+ if (this.calls.length > 0) {
41
+ const lastCall = this.calls[this.calls.length - 1];
42
+ const elapsed = Date.now() - lastCall;
43
+ if (elapsed < this.minDelay) {
44
+ await this.sleep(this.minDelay - elapsed);
45
+ }
46
+ }
47
+
48
+ // Record this call
49
+ this.calls.push(Date.now());
50
+ }
51
+
52
+ /**
53
+ * Get the number of calls made in the current window.
54
+ */
55
+ getCallCount() {
56
+ const now = Date.now();
57
+ this.calls = this.calls.filter(time => now - time < this.windowMs);
58
+ return this.calls.length;
59
+ }
60
+
61
+ /**
62
+ * Reset the rate limiter.
63
+ */
64
+ reset() {
65
+ this.calls = [];
66
+ }
67
+
68
+ /**
69
+ * Sleep for a given number of milliseconds.
70
+ */
71
+ sleep(ms) {
72
+ return new Promise(resolve => setTimeout(resolve, ms));
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Create a rate limiter configured for GitHub API.
78
+ * GitHub allows ~60 requests per minute for unauthenticated,
79
+ * and ~5000 per hour for authenticated (but we conservatively limit per minute).
80
+ */
81
+ function createGitHubRateLimiter() {
82
+ return new RateLimiter({
83
+ maxRequests: 60,
84
+ windowMs: 60 * 1000,
85
+ minDelay: 100,
86
+ });
87
+ }
88
+
89
+ module.exports = {
90
+ RateLimiter,
91
+ createGitHubRateLimiter,
92
+ };
package/src/semver.js ADDED
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ const logger = require('./logger');
4
+
5
+ /**
6
+ * Parse a version string or tag into components.
7
+ * Handles: v1.2.3, 1.2.3, v1.2.3-beta.1, v1.2.3-alpha, etc.
8
+ */
9
+ function parseVersion(tag) {
10
+ if (!tag || typeof tag !== 'string') {
11
+ return null;
12
+ }
13
+
14
+ const cleaned = tag.replace(/^v/, '').trim();
15
+ const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)(?:[-.]([\w.]+))?$/i);
16
+
17
+ if (!match) {
18
+ logger.debug(`Could not parse version from: ${tag}`);
19
+ return null;
20
+ }
21
+
22
+ return {
23
+ major: parseInt(match[1], 10),
24
+ minor: parseInt(match[2], 10),
25
+ patch: parseInt(match[3], 10),
26
+ prerelease: match[4] || null,
27
+ raw: tag,
28
+ clean: cleaned,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Compare two version strings or parsed version objects.
34
+ * Returns: -1 if a < b, 0 if a === b, 1 if a > b
35
+ */
36
+ function compareVersions(a, b) {
37
+ const vA = typeof a === 'string' ? parseVersion(a) : a;
38
+ const vB = typeof b === 'string' ? parseVersion(b) : b;
39
+
40
+ if (!vA && !vB) return 0;
41
+ if (!vA) return -1;
42
+ if (!vB) return 1;
43
+
44
+ if (vA.major !== vB.major) return vA.major > vB.major ? 1 : -1;
45
+ if (vA.minor !== vB.minor) return vA.minor > vB.minor ? 1 : -1;
46
+ if (vA.patch !== vB.patch) return vA.patch > vB.patch ? 1 : -1;
47
+
48
+ // No prerelease > prerelease
49
+ if (!vA.prerelease && vB.prerelease) return 1;
50
+ if (vA.prerelease && !vB.prerelease) return -1;
51
+ if (vA.prerelease && vB.prerelease) {
52
+ if (vA.prerelease < vB.prerelease) return -1;
53
+ if (vA.prerelease > vB.prerelease) return 1;
54
+ }
55
+
56
+ return 0;
57
+ }
58
+
59
+ /**
60
+ * Determine the next version based on commit analysis.
61
+ * Breaking changes -> major bump
62
+ * Features -> minor bump
63
+ * Fixes only -> patch bump
64
+ */
65
+ function getNextVersion(commits, currentVersion) {
66
+ const parsed = typeof currentVersion === 'string' ? parseVersion(currentVersion) : currentVersion;
67
+ if (!parsed) {
68
+ logger.warn(`Cannot determine next version: invalid current version "${currentVersion}"`);
69
+ return currentVersion;
70
+ }
71
+
72
+ let hasBreaking = false;
73
+ let hasFeature = false;
74
+ let hasFix = false;
75
+
76
+ for (const commit of commits) {
77
+ const message = (commit.message || commit.commit?.message || '').toLowerCase();
78
+ const type = commit._type || '';
79
+
80
+ if (
81
+ message.includes('breaking change') ||
82
+ message.includes('breaking:') ||
83
+ type === 'breaking'
84
+ ) {
85
+ hasBreaking = true;
86
+ }
87
+ if (type === 'feat' || message.match(/^feat[(!:]/)) {
88
+ hasFeature = true;
89
+ }
90
+ if (type === 'fix' || message.match(/^fix[(!:]/)) {
91
+ hasFix = true;
92
+ }
93
+ }
94
+
95
+ let nextMajor = parsed.major;
96
+ let nextMinor = parsed.minor;
97
+ let nextPatch = parsed.patch;
98
+
99
+ if (hasBreaking) {
100
+ nextMajor += 1;
101
+ nextMinor = 0;
102
+ nextPatch = 0;
103
+ } else if (hasFeature) {
104
+ nextMinor += 1;
105
+ nextPatch = 0;
106
+ } else if (hasFix) {
107
+ nextPatch += 1;
108
+ }
109
+
110
+ const nextVersion = `${nextMajor}.${nextMinor}.${nextPatch}`;
111
+ logger.info(`Next version determined: ${nextVersion} (breaking=${hasBreaking}, feature=${hasFeature}, fix=${hasFix})`);
112
+ return nextVersion;
113
+ }
114
+
115
+ /**
116
+ * Check if a version tag indicates a prerelease.
117
+ */
118
+ function isPrerelease(tag) {
119
+ if (!tag || typeof tag !== 'string') return false;
120
+ const cleaned = tag.replace(/^v/, '').trim();
121
+ return /[-.](alpha|beta|rc|pre|dev|canary|next|experimental|nightly)[-.]?\d*/i.test(cleaned);
122
+ }
123
+
124
+ module.exports = {
125
+ parseVersion,
126
+ compareVersions,
127
+ getNextVersion,
128
+ isPrerelease,
129
+ };
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Casual tone adapter.
5
+ * Developer-friendly, conversational, emoji-rich, approachable.
6
+ */
7
+ class CasualTone {
8
+ constructor() {
9
+ this.name = 'casual';
10
+ }
11
+
12
+ /**
13
+ * Apply casual tone to release data.
14
+ * @param {Object} data - ReleaseData object
15
+ * @returns {Object} Modified release data with casual tone
16
+ */
17
+ apply(data) {
18
+ const result = { ...data };
19
+
20
+ // Transform summary
21
+ result.summary = this._casualSummary(data);
22
+
23
+ // Transform categories
24
+ if (data.categories) {
25
+ result.categories = {};
26
+ for (const [category, changes] of Object.entries(data.categories)) {
27
+ const newCategory = this._casualCategoryName(category);
28
+ result.categories[newCategory] = changes.map(c => ({
29
+ ...c,
30
+ description: this._casualDescription(c.description, c.type),
31
+ }));
32
+ }
33
+ }
34
+
35
+ // Transform breaking changes
36
+ if (data.breaking) {
37
+ result.breaking = data.breaking.map(bc => ({
38
+ ...bc,
39
+ description: this._casualBreaking(bc.description),
40
+ }));
41
+ }
42
+
43
+ return result;
44
+ }
45
+
46
+ /**
47
+ * Generate a casual summary.
48
+ */
49
+ _casualSummary(data) {
50
+ const version = data.version || '0.0.0';
51
+ const parts = [`Here's what's new in v${version}!`];
52
+
53
+ const catCount = Object.keys(data.categories || {}).length;
54
+ if (catCount > 3) {
55
+ parts.push(` This release is packed with ${catCount} types of changes.`);
56
+ } else if (catCount > 0) {
57
+ parts.push(` We've got a bunch of goodies for you.`);
58
+ }
59
+
60
+ if (data.breaking && data.breaking.length > 0) {
61
+ parts.push(` Heads up: there ${data.breaking.length === 1 ? 'is' : 'are'} ${data.breaking.length} breaking change${data.breaking.length > 1 ? 's' : ''}!`);
62
+ }
63
+
64
+ if (data.contributors && data.contributors.length > 0) {
65
+ parts.push(` Huge thanks to ${data.contributors.length} contributor${data.contributors.length > 1 ? 's' : ''}!`);
66
+ }
67
+
68
+ return parts.join('');
69
+ }
70
+
71
+ /**
72
+ * Map category names to casual names with emojis.
73
+ */
74
+ _casualCategoryName(category) {
75
+ const map = {
76
+ '๐Ÿš€ Features': ':rocket: Cool New Stuff',
77
+ '๐Ÿ› Bug Fixes': ':bug: Squashed Bugs',
78
+ '๐Ÿ’ฅ Breaking Changes': ':boom: Heads Up - Breaking Changes!',
79
+ 'โšก Performance': ':zap: Speed Boosts',
80
+ 'โ™ป๏ธ Refactoring': ':recycle: Under the Hood',
81
+ '๐Ÿ“ Documentation': ':memo: Docs & Guides',
82
+ '๐ŸŽจ Style': ':art: Pretty Code',
83
+ '๐Ÿงช Tests': ':test_tube: Test Improvements',
84
+ '๐Ÿ”ง Chore': ':wrench: Housekeeping',
85
+ '๐Ÿ”’ Security': ':lock: Security Fixes',
86
+ 'New Features': ':rocket: Cool New Stuff',
87
+ 'Bug Fixes and Resolutions': ':bug: Squashed Bugs',
88
+ 'Performance Improvements': ':zap: Speed Boosts',
89
+ };
90
+ return map[category] || `:sparkles: ${category}`;
91
+ }
92
+
93
+ /**
94
+ * Make a description more casual.
95
+ */
96
+ _casualDescription(desc, type) {
97
+ if (!desc) return desc;
98
+ // Add exclamation for features
99
+ if (type === 'feat' && !desc.endsWith('!') && !desc.endsWith('.')) {
100
+ return desc + '!';
101
+ }
102
+ return desc;
103
+ }
104
+
105
+ /**
106
+ * Casual breaking change description.
107
+ */
108
+ _casualBreaking(desc) {
109
+ if (!desc) return desc;
110
+ return `Watch out! ${desc}`;
111
+ }
112
+ }
113
+
114
+ module.exports = { CasualTone };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Humorous tone adapter.
5
+ * Fun, witty descriptions with pop culture references.
6
+ * Memorable and shareable.
7
+ */
8
+ class HumorousTone {
9
+ constructor() {
10
+ this.name = 'humorous';
11
+ this._celebrations = [
12
+ 'Hold onto your keyboards!',
13
+ 'Drumroll please...',
14
+ 'Spoiler alert: it\'s awesome.',
15
+ 'Cue the confetti!',
16
+ 'The release you\'ve been waiting for (probably).',
17
+ ];
18
+ this._fixPhrases = [
19
+ 'We fixed it. You\'re welcome.',
20
+ 'The bug met its maker.',
21
+ 'Squashed like the creepy-crawly it was.',
22
+ 'Take that, bug!',
23
+ 'Another one bites the dust.',
24
+ ];
25
+ }
26
+
27
+ /**
28
+ * Apply humorous tone to release data.
29
+ * @param {Object} data - ReleaseData object
30
+ * @returns {Object} Modified release data with humorous tone
31
+ */
32
+ apply(data) {
33
+ const result = { ...data };
34
+
35
+ // Transform summary
36
+ result.summary = this._humorousSummary(data);
37
+
38
+ // Transform categories
39
+ if (data.categories) {
40
+ result.categories = {};
41
+ for (const [category, changes] of Object.entries(data.categories)) {
42
+ const newCategory = this._humorousCategoryName(category);
43
+ result.categories[newCategory] = changes.map((c, i) => ({
44
+ ...c,
45
+ description: this._humorousDescription(c.description, c.type, i),
46
+ }));
47
+ }
48
+ }
49
+
50
+ // Transform breaking changes
51
+ if (data.breaking) {
52
+ result.breaking = data.breaking.map(bc => ({
53
+ ...bc,
54
+ description: this._humorousBreaking(bc.description),
55
+ migration_guide: bc.migration_guide
56
+ ? `Don't panic! ${bc.migration_guide}`
57
+ : 'Update your code. We believe in you.',
58
+ }));
59
+ }
60
+
61
+ return result;
62
+ }
63
+
64
+ /**
65
+ * Generate a humorous summary.
66
+ */
67
+ _humorousSummary(data) {
68
+ const version = data.version || '0.0.0';
69
+ const opener = this._celebrations[Math.floor(Math.random() * this._celebrations.length)];
70
+
71
+ const parts = [`${opener} v${version} has landed.`];
72
+
73
+ if (data.breaking && data.breaking.length > 0) {
74
+ parts.push(` Yes, there ${data.breaking.length === 1 ? 'is a' : 'are'} breaking change${data.breaking.length > 1 ? 's' : ''}. No pain, no gain, right?`);
75
+ }
76
+
77
+ const catCount = Object.keys(data.categories || {}).length;
78
+ if (catCount > 0) {
79
+ parts.push(` ${catCount} categor${catCount !== 1 ? 'ies' : 'y'} of stuff that's different now.`);
80
+ }
81
+
82
+ if (data.contributors && data.contributors.length > 0) {
83
+ if (data.contributors.length === 1) {
84
+ parts.push(` One brave soul made this happen.`);
85
+ } else {
86
+ parts.push(` ${data.contributors.length} amazing humans made this happen.`);
87
+ }
88
+ }
89
+
90
+ return parts.join('');
91
+ }
92
+
93
+ /**
94
+ * Map category names to humorous names.
95
+ */
96
+ _humorousCategoryName(category) {
97
+ const map = {
98
+ '๐Ÿš€ Features': ':rocket: Things That Are New and Shiny',
99
+ '๐Ÿ› Bug Fixes': ':bug: Bugs We Sent to Bug Heaven',
100
+ '๐Ÿ’ฅ Breaking Changes': ':boom: Things We Broke (Sorry, Not Sorry)',
101
+ 'โšก Performance': ':zap: We Made It Go Brrr',
102
+ 'โ™ป๏ธ Refactoring': ':recycle: Code We Rewrote for Fun',
103
+ '๐Ÿ“ Documentation': ':memo: Words About Code',
104
+ '๐ŸŽจ Style': ':art: Making Code Pretty Again',
105
+ '๐Ÿงช Tests': ':test_tube: We Actually Test Things',
106
+ '๐Ÿ”ง Chore': ':wrench: Things Nobody Sees But Everyone Needs',
107
+ '๐Ÿ”’ Security': ':lock: Fort Knox Mode',
108
+ 'New Features': ':rocket: Things That Are New and Shiny',
109
+ 'Bug Fixes and Resolutions': ':bug: Bugs We Sent to Bug Heaven',
110
+ 'Performance Improvements': ':zap: We Made It Go Brrr',
111
+ 'Code Quality Improvements': ':recycle: Code We Rewrote for Fun',
112
+ 'Documentation Updates': ':memo: Words About Code',
113
+ 'Test Coverage': ':test_tube: We Actually Test Things',
114
+ 'Security Enhancements': ':lock: Fort Knox Mode',
115
+ };
116
+ return map[category] || `:sparkles: ${category}`;
117
+ }
118
+
119
+ /**
120
+ * Make a description more humorous.
121
+ */
122
+ _humorousDescription(desc, type, index) {
123
+ if (!desc) return desc;
124
+
125
+ if (type === 'fix') {
126
+ const phrase = this._fixPhrases[index % this._fixPhrases.length];
127
+ return `${desc} - ${phrase}`;
128
+ }
129
+
130
+ if (type === 'feat') {
131
+ const additions = [
132
+ ' (you\'re gonna love this)',
133
+ ' (finally!)',
134
+ ' (yes, really)',
135
+ ' (the people demanded it)',
136
+ ' (you\'re welcome)',
137
+ ];
138
+ return desc + additions[index % additions.length];
139
+ }
140
+
141
+ if (type === 'perf') {
142
+ return `${desc} - it\'s fast now. Like, really fast.`;
143
+ }
144
+
145
+ return desc;
146
+ }
147
+
148
+ /**
149
+ * Humorous breaking change description.
150
+ */
151
+ _humorousBreaking(desc) {
152
+ if (!desc) return desc;
153
+ const prefixes = [
154
+ 'Plot twist:',
155
+ 'In plot twist news:',
156
+ 'Breaking news (literally):',
157
+ 'Change of plans:',
158
+ ];
159
+ const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
160
+ return `${prefix} ${desc}`;
161
+ }
162
+ }
163
+
164
+ module.exports = { HumorousTone };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const { TONES, isValidTone } = require('./types');
4
+ const { ProfessionalTone } = require('./professional');
5
+ const { CasualTone } = require('./casual');
6
+ const { HumorousTone } = require('./humorous');
7
+ const { TechnicalTone } = require('./technical');
8
+
9
+ /**
10
+ * Tone factory. Returns the appropriate tone adapter for a given tone name.
11
+ * @param {string} tone - Tone identifier ('professional', 'casual', 'humorous', 'technical')
12
+ * @returns {Object} Tone adapter instance with name and apply() method
13
+ * @throws {Error} If tone is not recognized
14
+ */
15
+ function getTone(tone) {
16
+ switch (tone) {
17
+ case 'professional':
18
+ return new ProfessionalTone();
19
+ case 'casual':
20
+ return new CasualTone();
21
+ case 'humorous':
22
+ return new HumorousTone();
23
+ case 'technical':
24
+ return new TechnicalTone();
25
+ default:
26
+ throw new Error(`Unknown tone: "${tone}". Valid tones: ${TONES.join(', ')}`);
27
+ }
28
+ }
29
+
30
+ module.exports = {
31
+ getTone,
32
+ TONES,
33
+ isValidTone,
34
+ ProfessionalTone,
35
+ CasualTone,
36
+ HumorousTone,
37
+ TechnicalTone,
38
+ };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Professional tone adapter.
5
+ * Enterprise/corporate language with formal headers and structured sections.
6
+ */
7
+ class ProfessionalTone {
8
+ constructor() {
9
+ this.name = 'professional';
10
+ }
11
+
12
+ /**
13
+ * Apply professional tone to release data.
14
+ * @param {Object} data - ReleaseData object
15
+ * @returns {Object} Modified release data with professional tone
16
+ */
17
+ apply(data) {
18
+ const result = { ...data };
19
+
20
+ // Transform summary
21
+ result.summary = this._professionalSummary(data);
22
+
23
+ // Transform categories
24
+ if (data.categories) {
25
+ result.categories = {};
26
+ for (const [category, changes] of Object.entries(data.categories)) {
27
+ const newCategory = this._professionalCategoryName(category);
28
+ result.categories[newCategory] = changes.map(c => ({
29
+ ...c,
30
+ description: this._professionalDescription(c.description, c.type),
31
+ }));
32
+ }
33
+ }
34
+
35
+ // Transform breaking changes
36
+ if (data.breaking) {
37
+ result.breaking = data.breaking.map(bc => ({
38
+ ...bc,
39
+ description: this._professionalBreaking(bc.description),
40
+ migration_guide: bc.migration_guide
41
+ ? this._professionalMigration(bc.migration_guide)
42
+ : undefined,
43
+ }));
44
+ }
45
+
46
+ return result;
47
+ }
48
+
49
+ /**
50
+ * Generate a professional summary.
51
+ */
52
+ _professionalSummary(data) {
53
+ const parts = [];
54
+ const version = data.version || '0.0.0';
55
+
56
+ if (data.breaking && data.breaking.length > 0) {
57
+ parts.push(`We are pleased to announce the release of version ${version}, which includes ${data.breaking.length} breaking change${data.breaking.length > 1 ? 's' : ''}`);
58
+ } else if (Object.keys(data.categories || {}).length > 0) {
59
+ const catCount = Object.keys(data.categories).length;
60
+ parts.push(`We are pleased to announce the release of version ${version}, comprising ${catCount} categor${catCount !== 1 ? 'ies' : 'y'} of improvements`);
61
+ } else {
62
+ parts.push(`We are pleased to announce the release of version ${version}`);
63
+ }
64
+
65
+ if (data.contributors && data.contributors.length > 0) {
66
+ parts.push(`with contributions from ${data.contributors.length} developer${data.contributors.length > 1 ? 's' : ''}`);
67
+ }
68
+
69
+ parts.push('.');
70
+ return parts.join(' ');
71
+ }
72
+
73
+ /**
74
+ * Map emoji category names to professional names.
75
+ */
76
+ _professionalCategoryName(category) {
77
+ const map = {
78
+ '๐Ÿš€ Features': 'New Features',
79
+ '๐Ÿ› Bug Fixes': 'Bug Fixes and Resolutions',
80
+ '๐Ÿ’ฅ Breaking Changes': 'Breaking Changes',
81
+ 'โšก Performance': 'Performance Improvements',
82
+ 'โ™ป๏ธ Refactoring': 'Code Quality Improvements',
83
+ '๐Ÿ“ Documentation': 'Documentation Updates',
84
+ '๐ŸŽจ Style': 'Code Style and Formatting',
85
+ '๐Ÿงช Tests': 'Test Coverage',
86
+ '๐Ÿ”ง Chore': 'Maintenance and Infrastructure',
87
+ '๐Ÿ”’ Security': 'Security Enhancements',
88
+ };
89
+ return map[category] || category.replace(/[^\w\s&]/g, '').trim();
90
+ }
91
+
92
+ /**
93
+ * Make a description more professional.
94
+ */
95
+ _professionalDescription(desc, type) {
96
+ if (!desc) return desc;
97
+ // Capitalize first letter, ensure period at end if appropriate
98
+ let result = desc.charAt(0).toUpperCase() + desc.slice(1);
99
+ if (type === 'feat' && !result.startsWith('Implemented') && !result.startsWith('Added') && !result.startsWith('Introduced')) {
100
+ return result;
101
+ }
102
+ if (type === 'fix' && !result.startsWith('Resolved') && !result.startsWith('Fixed') && !result.startsWith('Addressed')) {
103
+ return result;
104
+ }
105
+ return result;
106
+ }
107
+
108
+ /**
109
+ * Professional breaking change description.
110
+ */
111
+ _professionalBreaking(desc) {
112
+ if (!desc) return desc;
113
+ return `This release introduces a change to ${desc.charAt(0).toLowerCase() + desc.slice(1)}`;
114
+ }
115
+
116
+ /**
117
+ * Professional migration guide.
118
+ */
119
+ _professionalMigration(guide) {
120
+ if (!guide) return guide;
121
+ return guide;
122
+ }
123
+ }
124
+
125
+ module.exports = { ProfessionalTone };