@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,394 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ getFormatter,
5
+ OUTPUT_FORMATS,
6
+ isValidFormat,
7
+ MarkdownFormatter,
8
+ HTMLFormatter,
9
+ SlackFormatter,
10
+ DiscordFormatter,
11
+ TwitterFormatter,
12
+ } = require('../src/formatters');
13
+
14
+ /**
15
+ * Create sample release data for testing.
16
+ */
17
+ function createSampleData(overrides = {}) {
18
+ return {
19
+ version: '2.1.0',
20
+ previousVersion: '2.0.0',
21
+ date: '2025-01-15',
22
+ summary: 'Release v2.1.0 brings 3 features, 2 bug fixes, and 1 breaking change.',
23
+ categories: {
24
+ '🚀 Features': [
25
+ { description: 'Added user avatar upload API', type: 'feat', scope: 'api', pr: 42, author: 'alice', breaking: false },
26
+ { description: 'Added dark mode toggle', type: 'feat', scope: 'ui', pr: 43, author: 'bob', breaking: false },
27
+ { description: 'Added pagination to list endpoints', type: 'feat', scope: 'api', pr: 44, author: 'charlie', breaking: false },
28
+ ],
29
+ '🐛 Bug Fixes': [
30
+ { description: 'Fixed login timeout on slow connections', type: 'fix', scope: 'auth', pr: 45, author: 'dave', breaking: false },
31
+ { description: 'Fixed memory leak in event listeners', type: 'fix', scope: 'core', pr: 46, author: 'eve', breaking: false },
32
+ ],
33
+ },
34
+ breaking: [
35
+ { description: 'Removed deprecated /api/v1/users endpoint', scope: 'api', type: 'feat', pr: 40, migration_guide: 'Use /api/v2/users instead' },
36
+ ],
37
+ contributors: [
38
+ { login: 'alice', name: 'Alice', commits_count: 3, is_first_time: false },
39
+ { login: 'bob', name: 'Bob', commits_count: 2, is_first_time: true },
40
+ { login: 'charlie', name: 'Charlie', commits_count: 1, is_first_time: false },
41
+ ],
42
+ diffSummary: {
43
+ files_changed: 12,
44
+ files_added: 3,
45
+ files_modified: 7,
46
+ files_deleted: 2,
47
+ additions: 450,
48
+ deletions: 120,
49
+ impact: 'medium',
50
+ affected_areas: ['API', 'UI'],
51
+ potential_breaking: [],
52
+ },
53
+ repoUrl: 'https://github.com/acme/app',
54
+ repoFullName: 'acme/app',
55
+ linkedIssues: [{ number: 100 }, { number: 101 }],
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ describe('formatters', () => {
61
+ describe('formatter factory', () => {
62
+ test('getFormatter returns MarkdownFormatter for markdown', () => {
63
+ const f = getFormatter('markdown');
64
+ expect(f).toBeInstanceOf(MarkdownFormatter);
65
+ expect(f.name).toBe('Markdown');
66
+ });
67
+
68
+ test('getFormatter returns HTMLFormatter for html', () => {
69
+ const f = getFormatter('html');
70
+ expect(f).toBeInstanceOf(HTMLFormatter);
71
+ expect(f.name).toBe('HTML');
72
+ });
73
+
74
+ test('getFormatter returns SlackFormatter for slack', () => {
75
+ const f = getFormatter('slack');
76
+ expect(f).toBeInstanceOf(SlackFormatter);
77
+ expect(f.name).toBe('Slack');
78
+ });
79
+
80
+ test('getFormatter returns DiscordFormatter for discord', () => {
81
+ const f = getFormatter('discord');
82
+ expect(f).toBeInstanceOf(DiscordFormatter);
83
+ expect(f.name).toBe('Discord');
84
+ });
85
+
86
+ test('getFormatter returns TwitterFormatter for twitter', () => {
87
+ const f = getFormatter('twitter');
88
+ expect(f).toBeInstanceOf(TwitterFormatter);
89
+ expect(f.name).toBe('Twitter');
90
+ });
91
+
92
+ test('getFormatter throws for unknown format', () => {
93
+ expect(() => getFormatter('pdf')).toThrow('Unknown output format');
94
+ });
95
+
96
+ test('OUTPUT_FORMATS contains all formats', () => {
97
+ expect(OUTPUT_FORMATS).toEqual(['markdown', 'html', 'slack', 'discord', 'twitter']);
98
+ });
99
+
100
+ test('isValidFormat validates correctly', () => {
101
+ expect(isValidFormat('markdown')).toBe(true);
102
+ expect(isValidFormat('html')).toBe(true);
103
+ expect(isValidFormat('pdf')).toBe(false);
104
+ expect(isValidFormat('')).toBe(false);
105
+ });
106
+ });
107
+
108
+ describe('MarkdownFormatter', () => {
109
+ test('formats basic release data', () => {
110
+ const data = createSampleData();
111
+ const formatter = new MarkdownFormatter();
112
+ const result = formatter.format(data);
113
+
114
+ expect(result).toContain("What's Changed in v2.1.0");
115
+ expect(result).toContain('Breaking Changes');
116
+ expect(result).toContain('Added user avatar upload API');
117
+ expect(result).toContain('Fixed login timeout');
118
+ expect(result).toContain('@alice');
119
+ expect(result).toContain('Full Changelog');
120
+ });
121
+
122
+ test('includes collapsible sections', () => {
123
+ const data = createSampleData();
124
+ const formatter = new MarkdownFormatter();
125
+ const result = formatter.format(data);
126
+
127
+ expect(result).toContain('<details>');
128
+ expect(result).toContain('</details>');
129
+ });
130
+
131
+ test('handles empty categories', () => {
132
+ const data = createSampleData({ categories: {}, breaking: [], contributors: [] });
133
+ const formatter = new MarkdownFormatter();
134
+ const result = formatter.format(data);
135
+
136
+ expect(result).toContain('v2.1.0');
137
+ expect(typeof result).toBe('string');
138
+ });
139
+
140
+ test('includes PR links', () => {
141
+ const data = createSampleData();
142
+ const formatter = new MarkdownFormatter();
143
+ const result = formatter.format(data);
144
+
145
+ expect(result).toContain('acme/app/pull/42');
146
+ });
147
+
148
+ test('includes first-time contributor badge', () => {
149
+ const data = createSampleData();
150
+ const formatter = new MarkdownFormatter();
151
+ const result = formatter.format(data);
152
+
153
+ expect(result).toContain(':tada:');
154
+ });
155
+ });
156
+
157
+ describe('HTMLFormatter', () => {
158
+ test('produces valid HTML', () => {
159
+ const data = createSampleData();
160
+ const formatter = new HTMLFormatter();
161
+ const result = formatter.format(data);
162
+
163
+ expect(result).toContain('<!DOCTYPE html>');
164
+ expect(result).toContain('<html');
165
+ expect(result).toContain('</html>');
166
+ expect(result).toContain('<style>');
167
+ });
168
+
169
+ test('color-codes sections', () => {
170
+ const data = createSampleData();
171
+ const formatter = new HTMLFormatter();
172
+ const result = formatter.format(data);
173
+
174
+ expect(result).toContain('features');
175
+ expect(result).toContain('fixes');
176
+ expect(result).toContain('breaking');
177
+ });
178
+
179
+ test('escapes HTML in descriptions', () => {
180
+ const data = createSampleData({
181
+ categories: { '🚀 Features': [{ description: 'Added <script>alert("xss")</script>', type: 'feat' }] },
182
+ });
183
+ const formatter = new HTMLFormatter();
184
+ const result = formatter.format(data);
185
+
186
+ expect(result).not.toContain('<script>');
187
+ expect(result).toContain('&lt;script&gt;');
188
+ });
189
+
190
+ test('includes contributors with first-timer badge', () => {
191
+ const data = createSampleData();
192
+ const formatter = new HTMLFormatter();
193
+ const result = formatter.format(data);
194
+
195
+ expect(result).toContain('@bob');
196
+ expect(result).toContain('FIRST TIME');
197
+ });
198
+
199
+ test('handles empty data gracefully', () => {
200
+ const data = createSampleData({ categories: {}, breaking: [], contributors: [] });
201
+ const formatter = new HTMLFormatter();
202
+ const result = formatter.format(data);
203
+
204
+ expect(result).toContain('v2.1.0');
205
+ expect(result).toContain('</html>');
206
+ });
207
+ });
208
+
209
+ describe('SlackFormatter', () => {
210
+ test('produces valid Slack Block Kit JSON', () => {
211
+ const data = createSampleData();
212
+ const formatter = new SlackFormatter();
213
+ const result = formatter.format(data);
214
+
215
+ const parsed = JSON.parse(result);
216
+ expect(parsed.blocks).toBeDefined();
217
+ expect(Array.isArray(parsed.blocks)).toBe(true);
218
+ expect(parsed.blocks.length).toBeGreaterThan(0);
219
+ });
220
+
221
+ test('includes header block', () => {
222
+ const data = createSampleData();
223
+ const formatter = new SlackFormatter();
224
+ const result = formatter.format(data);
225
+
226
+ const parsed = JSON.parse(result);
227
+ const header = parsed.blocks.find(b => b.type === 'header');
228
+ expect(header).toBeDefined();
229
+ expect(header.text.text).toContain('v2.1.0');
230
+ });
231
+
232
+ test('includes breaking changes section', () => {
233
+ const data = createSampleData();
234
+ const formatter = new SlackFormatter();
235
+ const result = formatter.format(data);
236
+
237
+ const parsed = JSON.parse(result);
238
+ const breakingBlock = parsed.blocks.find(b =>
239
+ b.text && b.text.text && b.text.text.includes('Breaking Changes')
240
+ );
241
+ expect(breakingBlock).toBeDefined();
242
+ });
243
+
244
+ test('includes action buttons', () => {
245
+ const data = createSampleData();
246
+ const formatter = new SlackFormatter();
247
+ const result = formatter.format(data);
248
+
249
+ const parsed = JSON.parse(result);
250
+ const actions = parsed.blocks.find(b => b.type === 'actions');
251
+ expect(actions).toBeDefined();
252
+ expect(actions.elements.length).toBeGreaterThan(0);
253
+ });
254
+
255
+ test('escapes special characters', () => {
256
+ const data = createSampleData({
257
+ summary: 'Release <v2.1.0> & "new features"',
258
+ });
259
+ const formatter = new SlackFormatter();
260
+ const result = formatter.format(data);
261
+
262
+ const parsed = JSON.parse(result);
263
+ const summaryBlock = parsed.blocks.find(b => b.type === 'section');
264
+ expect(summaryBlock.text.text).toContain('&lt;');
265
+ expect(summaryBlock.text.text).toContain('&amp;');
266
+ });
267
+ });
268
+
269
+ describe('DiscordFormatter', () => {
270
+ test('produces valid Discord webhook JSON', () => {
271
+ const data = createSampleData();
272
+ const formatter = new DiscordFormatter();
273
+ const result = formatter.format(data);
274
+
275
+ const parsed = JSON.parse(result);
276
+ expect(parsed.embeds).toBeDefined();
277
+ expect(Array.isArray(parsed.embeds)).toBe(true);
278
+ expect(parsed.embeds.length).toBeGreaterThan(0);
279
+ expect(parsed.username).toBe('Release Notes');
280
+ });
281
+
282
+ test('main embed has correct color for breaking changes', () => {
283
+ const data = createSampleData();
284
+ const formatter = new DiscordFormatter();
285
+ const result = formatter.format(data);
286
+
287
+ const parsed = JSON.parse(result);
288
+ // Red for breaking changes
289
+ expect(parsed.embeds[0].color).toBe(0xED4245);
290
+ });
291
+
292
+ test('main embed is green for features only', () => {
293
+ const data = createSampleData({ breaking: [] });
294
+ const formatter = new DiscordFormatter();
295
+ const result = formatter.format(data);
296
+
297
+ const parsed = JSON.parse(result);
298
+ expect(parsed.embeds[0].color).toBe(0x57F287);
299
+ });
300
+
301
+ test('includes contributor embed', () => {
302
+ const data = createSampleData();
303
+ const formatter = new DiscordFormatter();
304
+ const result = formatter.format(data);
305
+
306
+ const parsed = JSON.parse(result);
307
+ const contributorEmbed = parsed.embeds.find(e =>
308
+ e.title && e.title.includes('Contributors')
309
+ );
310
+ expect(contributorEmbed).toBeDefined();
311
+ });
312
+
313
+ test('respects Discord embed limit (max 10)', () => {
314
+ const categories = {};
315
+ for (let i = 0; i < 15; i++) {
316
+ categories[`Category ${i}`] = [{ description: `Change ${i}`, type: 'feat' }];
317
+ }
318
+ const data = createSampleData({ categories });
319
+ const formatter = new DiscordFormatter();
320
+ const result = formatter.format(data);
321
+
322
+ const parsed = JSON.parse(result);
323
+ expect(parsed.embeds.length).toBeLessThanOrEqual(10);
324
+ });
325
+ });
326
+
327
+ describe('TwitterFormatter', () => {
328
+ test('produces valid tweet thread JSON', () => {
329
+ const data = createSampleData();
330
+ const formatter = new TwitterFormatter();
331
+ const result = formatter.format(data);
332
+
333
+ const tweets = JSON.parse(result);
334
+ expect(Array.isArray(tweets)).toBe(true);
335
+ expect(tweets.length).toBeGreaterThan(0);
336
+ });
337
+
338
+ test('each tweet is within 280 characters', () => {
339
+ const data = createSampleData();
340
+ const formatter = new TwitterFormatter();
341
+ const result = formatter.format(data);
342
+
343
+ const tweets = JSON.parse(result);
344
+ for (const tweet of tweets) {
345
+ expect(tweet.length).toBeLessThanOrEqual(280);
346
+ }
347
+ });
348
+
349
+ test('tweets are numbered', () => {
350
+ const data = createSampleData();
351
+ const formatter = new TwitterFormatter();
352
+ const result = formatter.format(data);
353
+
354
+ const tweets = JSON.parse(result);
355
+ for (let i = 0; i < tweets.length; i++) {
356
+ expect(tweets[i]).toMatch(new RegExp(`^${i + 1}/${tweets.length}`));
357
+ }
358
+ });
359
+
360
+ test('first tweet announces the version', () => {
361
+ const data = createSampleData();
362
+ const formatter = new TwitterFormatter();
363
+ const result = formatter.format(data);
364
+
365
+ const tweets = JSON.parse(result);
366
+ expect(tweets[0]).toContain('v2.1.0');
367
+ });
368
+
369
+ test('includes hashtags', () => {
370
+ const data = createSampleData();
371
+ const formatter = new TwitterFormatter();
372
+ const result = formatter.format(data);
373
+
374
+ const tweets = JSON.parse(result);
375
+ const lastTweet = tweets[tweets.length - 1];
376
+ expect(lastTweet).toContain('#Release');
377
+ expect(lastTweet).toContain('#Changelog');
378
+ });
379
+
380
+ test('handles minimal data gracefully', () => {
381
+ const data = createSampleData({
382
+ categories: {},
383
+ breaking: [],
384
+ contributors: [],
385
+ summary: '',
386
+ });
387
+ const formatter = new TwitterFormatter();
388
+ const result = formatter.format(data);
389
+
390
+ const tweets = JSON.parse(result);
391
+ expect(tweets.length).toBeGreaterThan(0);
392
+ });
393
+ });
394
+ });