@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,297 @@
1
+ 'use strict';
2
+
3
+ const github = require('@actions/github');
4
+ const logger = require('./logger');
5
+ const { analyzeCommits } = require('./analyzer');
6
+ const { analyzeDiff, formatDiffStats } = require('./diff-analyzer');
7
+ const { generateReleaseNotes: aiGenerate } = require('./ai-writer');
8
+ const { formatReleaseNotes, generateSummary } = require('./formatter');
9
+ const { postToIntegrations } = require('./integrations/indexer');
10
+ const { parseVersion, isPrerelease } = require('./semver');
11
+ const { createGitHubRateLimiter } = require('./rate-limiter');
12
+
13
+ /**
14
+ * Main orchestrator — generate release notes and post to integrations.
15
+ * @param {Object} config - Validated configuration object
16
+ * @returns {Object} { notes, url, version, summary }
17
+ */
18
+ async function generate(config) {
19
+ logger.info('Starting release notes generation');
20
+
21
+ const octokit = github.getOctokit(config.githubToken);
22
+ const context = github.context;
23
+ const rateLimiter = createGitHubRateLimiter();
24
+
25
+ // Set repo info on config for formatter
26
+ config.repoFullName = `${context.repo.owner}/${context.repo.repo}`;
27
+ config.repoUrl = `https://github.com/${config.repoFullName}`;
28
+
29
+ // Step 1: Determine version
30
+ const version = determineVersion(config, context);
31
+ logger.info(`Version: ${version}`);
32
+
33
+ // Step 2: Determine previous version
34
+ const previousVersion = await determinePreviousVersion(config, octokit, context, rateLimiter);
35
+ logger.info(`Previous version: ${previousVersion || 'none (first release)'}`);
36
+
37
+ // Step 3: Collect changes
38
+ const { commits, prs } = await collectChanges(
39
+ config, octokit, context, previousVersion, version, rateLimiter
40
+ );
41
+ logger.info(`Collected ${commits.length} commits and ${prs.length} PRs`);
42
+
43
+ // Step 4: Analyze changes
44
+ const analysis = analyzeCommits(commits, config);
45
+ logger.info(`Analysis: ${analysis.stats.total_commits} commits in ${Object.keys(analysis.categories).length} categories`);
46
+
47
+ // Step 5: Analyze diff (optional)
48
+ let diffSummary = null;
49
+ if (config.includeDiffStats) {
50
+ try {
51
+ await rateLimiter.wait();
52
+ diffSummary = await analyzeDiff(commits.slice(0, Math.min(commits.length, 50)), octokit, context);
53
+ } catch (err) {
54
+ logger.warn('Diff analysis failed, continuing without it', err);
55
+ diffSummary = { files_changed: 0, additions: 0, deletions: 0, impact: 'low', affected_areas: [], potential_breaking: [], files: [], files_added: 0, files_modified: 0, files_deleted: 0 };
56
+ }
57
+ }
58
+
59
+ // Step 6: Generate release notes (AI or template)
60
+ let notes;
61
+ if (config.hasAI) {
62
+ try {
63
+ const aiNotes = await aiGenerate(analysis, diffSummary, version, config);
64
+ if (aiNotes) {
65
+ notes = aiNotes;
66
+ logger.info('Using AI-generated release notes');
67
+ } else {
68
+ notes = formatReleaseNotes(analysis, diffSummary, version, previousVersion, config);
69
+ logger.info('AI failed or returned nothing — using template-based notes');
70
+ }
71
+ } catch (err) {
72
+ logger.warn('AI generation threw error — falling back to template', err);
73
+ notes = formatReleaseNotes(analysis, diffSummary, version, previousVersion, config);
74
+ }
75
+ } else {
76
+ notes = formatReleaseNotes(analysis, diffSummary, version, previousVersion, config);
77
+ logger.info('Using template-based release notes (no API key)');
78
+ }
79
+
80
+ // Generate summary
81
+ const summary = generateSummary(analysis, version);
82
+
83
+ // Step 7: Post to integrations
84
+ const releaseData = {
85
+ version,
86
+ notes,
87
+ summary,
88
+ url: '',
89
+ contributors: analysis.contributors || [],
90
+ };
91
+
92
+ if (!config.dryRun) {
93
+ try {
94
+ const integrationResults = await postToIntegrations(config, releaseData, octokit, context);
95
+ if (integrationResults.githubRelease && integrationResults.githubRelease.url) {
96
+ releaseData.url = integrationResults.githubRelease.url;
97
+ }
98
+ } catch (err) {
99
+ logger.error('Integration posting failed (non-fatal)', err);
100
+ }
101
+ } else {
102
+ logger.info('Dry run mode — skipping all integrations');
103
+ }
104
+
105
+ logger.info(`Release notes generated for v${version}`);
106
+
107
+ return {
108
+ notes: releaseData.notes,
109
+ url: releaseData.url,
110
+ version: version,
111
+ summary: summary,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Determine the current version based on config.
117
+ */
118
+ function determineVersion(config, context) {
119
+ switch (config.versionFrom) {
120
+ case 'tag': {
121
+ const ref = process.env.GITHUB_REF || '';
122
+ const tagMatch = ref.match(/refs\/tags\/v?(.+)/);
123
+ if (tagMatch) {
124
+ return tagMatch[1];
125
+ }
126
+ // Try to get from context payload
127
+ if (context.payload.release && context.payload.release.tag_name) {
128
+ return context.payload.release.tag_name.replace(/^v/, '');
129
+ }
130
+ logger.warn('Could not determine version from tag');
131
+ return '0.0.0';
132
+ }
133
+ case 'package-json': {
134
+ try {
135
+ const fs = require('fs');
136
+ const path = require('path');
137
+ const pkgPath = path.join(process.env.GITHUB_WORKSPACE || process.cwd(), 'package.json');
138
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
139
+ return pkg.version || '0.0.0';
140
+ } catch (err) {
141
+ logger.warn('Could not read version from package.json', err);
142
+ return '0.0.0';
143
+ }
144
+ }
145
+ case 'manual':
146
+ return config.version || '0.0.0';
147
+ default:
148
+ return '0.0.0';
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Determine the previous version/tag.
154
+ */
155
+ async function determinePreviousVersion(config, octokit, context, rateLimiter) {
156
+ // Manual override
157
+ if (config.previousTag) {
158
+ return config.previousTag.replace(/^v/, '');
159
+ }
160
+
161
+ // Auto-detect from GitHub releases
162
+ try {
163
+ await rateLimiter.wait();
164
+ const { data: releases } = await octokit.rest.repos.listReleases({
165
+ owner: context.repo.owner,
166
+ repo: context.repo.repo,
167
+ per_page: 10,
168
+ });
169
+
170
+ for (const release of releases) {
171
+ const tag = release.tag_name.replace(/^v/, '');
172
+ if (!isPrerelease(tag) || release.prerelease) {
173
+ // Skip prereleases for finding previous stable
174
+ if (!release.prerelease) {
175
+ return tag;
176
+ }
177
+ }
178
+ }
179
+
180
+ // If no stable release found, use the latest release of any type
181
+ if (releases.length > 0) {
182
+ return releases[0].tag_name.replace(/^v/, '');
183
+ }
184
+ } catch (err) {
185
+ logger.debug(`Could not auto-detect previous version: ${err.message}`);
186
+ }
187
+
188
+ return '';
189
+ }
190
+
191
+ /**
192
+ * Collect changes (commits and/or PRs) between versions.
193
+ */
194
+ async function collectChanges(config, octokit, context, previousVersion, currentVersion, rateLimiter) {
195
+ const owner = context.repo.owner;
196
+ const repo = context.repo.repo;
197
+ let commits = [];
198
+ let prs = [];
199
+
200
+ const commitMode = config.commitMode;
201
+
202
+ // Determine if we should use PR mode
203
+ let usePRs = false;
204
+ if (commitMode === 'pull-requests') {
205
+ usePRs = true;
206
+ } else if (commitMode === 'auto') {
207
+ // Check if there are PRs between the versions
208
+ try {
209
+ await rateLimiter.wait();
210
+ const base = previousVersion ? `v${previousVersion}` : undefined;
211
+ const head = `v${currentVersion}`;
212
+ if (base) {
213
+ const { data: compareData } = await octokit.rest.repos.compareCommits({
214
+ owner,
215
+ repo,
216
+ base,
217
+ head,
218
+ });
219
+ // If commits have PR numbers, prefer PR mode
220
+ const commitsWithPRs = (compareData.commits || []).filter(
221
+ c => c.commit.message.match(/\(#\d+\)/)
222
+ );
223
+ usePRs = commitsWithPRs.length > (compareData.commits || []).length * 0.3;
224
+ }
225
+ } catch (err) {
226
+ logger.debug(`Could not determine auto mode: ${err.message}`);
227
+ usePRs = false;
228
+ }
229
+ }
230
+
231
+ if (usePRs) {
232
+ // Collect merged PRs between versions
233
+ try {
234
+ await rateLimiter.wait();
235
+ const prQuery = previousVersion
236
+ ? `is:pr is:merged repo:${owner}/${repo} base:main merged:>="${new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}"`
237
+ : `is:pr is:merged repo:${owner}/${repo}`;
238
+
239
+ // Use search API for PRs
240
+ const { data: searchResult } = await octokit.rest.search.issuesAndPullRequests({
241
+ q: prQuery,
242
+ sort: 'updated',
243
+ order: 'desc',
244
+ per_page: Math.min(config.maxCommits, 100),
245
+ });
246
+
247
+ prs = searchResult.items || [];
248
+ commits = prs.map(pr => ({
249
+ sha: pr.merge_commit_sha || '',
250
+ message: pr.title || '',
251
+ author: pr.user || {},
252
+ url: pr.html_url,
253
+ _pr: pr.number,
254
+ }));
255
+ } catch (err) {
256
+ logger.warn(`PR collection failed, falling back to commits: ${err.message}`);
257
+ usePRs = false;
258
+ }
259
+ }
260
+
261
+ if (!usePRs) {
262
+ // Collect commits between versions
263
+ try {
264
+ if (previousVersion) {
265
+ await rateLimiter.wait();
266
+ const { data: compareData } = await octokit.rest.repos.compareCommits({
267
+ owner,
268
+ repo,
269
+ base: `v${previousVersion}`,
270
+ head: `v${currentVersion}`,
271
+ });
272
+ commits = (compareData.commits || []).slice(0, config.maxCommits);
273
+ } else {
274
+ // No previous version — get recent commits
275
+ await rateLimiter.wait();
276
+ const { data: commitList } = await octokit.rest.repos.listCommits({
277
+ owner,
278
+ repo,
279
+ per_page: Math.min(config.maxCommits, 100),
280
+ });
281
+ commits = commitList;
282
+ }
283
+ } catch (err) {
284
+ logger.warn(`Commit collection failed: ${err.message}`);
285
+ commits = [];
286
+ }
287
+ }
288
+
289
+ // Handle no changes
290
+ if (commits.length === 0 && prs.length === 0) {
291
+ logger.info('No changes found between versions');
292
+ }
293
+
294
+ return { commits, prs };
295
+ }
296
+
297
+ module.exports = { generate, determineVersion, determinePreviousVersion, collectChanges };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ const logger = require('../logger');
4
+
5
+ /**
6
+ * Update CHANGELOG.md with new release entry.
7
+ * @param {Object} octokit - Octokit instance
8
+ * @param {Object} context - GitHub Actions context
9
+ * @param {Object} config - Configuration object
10
+ * @param {string} notes - Release notes body
11
+ * @param {string} version - Version string
12
+ * @returns {Object} { success, message }
13
+ */
14
+ async function updateChangelog(octokit, context, config, notes, version) {
15
+ try {
16
+ const owner = context.repo.owner;
17
+ const repo = context.repo.repo;
18
+ const changelogPath = config.changelogPath || 'CHANGELOG.md';
19
+ const date = new Date().toISOString().split('T')[0];
20
+
21
+ logger.info(`Updating ${changelogPath} with v${version}`);
22
+
23
+ // Read existing changelog
24
+ let existingContent = '';
25
+ let sha = null;
26
+ try {
27
+ const { data } = await octokit.rest.repos.getContent({
28
+ owner,
29
+ repo,
30
+ path: changelogPath,
31
+ ref: context.payload.ref || 'HEAD',
32
+ });
33
+ existingContent = Buffer.from(data.content, 'base64').toString('utf-8');
34
+ sha = data.sha;
35
+ } catch (err) {
36
+ if (err.status === 404) {
37
+ logger.info('No existing CHANGELOG.md — creating new one');
38
+ existingContent = '# Changelog\n\n';
39
+ sha = null;
40
+ } else {
41
+ throw err;
42
+ }
43
+ }
44
+
45
+ // Build new entry
46
+ const newEntry = buildChangelogEntry(version, date, notes);
47
+
48
+ // Prepend new entry after the header
49
+ const updatedContent = prependEntry(existingContent, newEntry);
50
+
51
+ // Commit and push
52
+ const commitMessage = `docs(changelog): update for v${version}`;
53
+
54
+ await octokit.rest.repos.createOrUpdateFileContents({
55
+ owner,
56
+ repo,
57
+ path: changelogPath,
58
+ message: commitMessage,
59
+ content: Buffer.from(updatedContent).toString('base64'),
60
+ sha: sha,
61
+ branch: context.payload.ref ? context.payload.ref.replace('refs/heads/', '') : 'main',
62
+ });
63
+
64
+ logger.info(`Changelog updated: ${changelogPath}`);
65
+ return { success: true, message: `Updated ${changelogPath}` };
66
+ } catch (err) {
67
+ logger.error('Failed to update changelog', err);
68
+ return { success: false, message: err.message };
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Build a structured changelog entry from release notes.
74
+ */
75
+ function buildChangelogEntry(version, date, notes) {
76
+ const lines = [];
77
+ lines.push(`## [${version}] - ${date}`);
78
+ lines.push('');
79
+
80
+ if (notes) {
81
+ // Parse notes into structured sections
82
+ const noteLines = notes.split('\n');
83
+ for (const line of noteLines) {
84
+ // Skip h1 headers and full changelog links
85
+ if (line.match(/^# /)) continue;
86
+ if (line.match(/\*\*Full Changelog\*\*/)) continue;
87
+ lines.push(line);
88
+ }
89
+ }
90
+
91
+ lines.push('');
92
+ return lines.join('\n');
93
+ }
94
+
95
+ /**
96
+ * Prepend a new entry to the existing changelog content.
97
+ * Inserts after the first header (# Changelog) if present.
98
+ */
99
+ function prependEntry(existingContent, newEntry) {
100
+ const lines = existingContent.split('\n');
101
+ let insertIndex = 0;
102
+
103
+ // Find the first header
104
+ for (let i = 0; i < lines.length; i++) {
105
+ if (lines[i].match(/^#\s+/)) {
106
+ // Skip the header and any trailing blank lines
107
+ insertIndex = i + 1;
108
+ while (insertIndex < lines.length && lines[insertIndex].trim() === '') {
109
+ insertIndex++;
110
+ }
111
+ break;
112
+ }
113
+ }
114
+
115
+ // Insert new entry
116
+ lines.splice(insertIndex, 0, newEntry);
117
+
118
+ // Clean up excessive blank lines
119
+ let result = lines.join('\n');
120
+ result = result.replace(/\n{3,}/g, '\n\n');
121
+
122
+ return result;
123
+ }
124
+
125
+ module.exports = { updateChangelog, buildChangelogEntry, prependEntry };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const logger = require('../logger');
4
+
5
+ /**
6
+ * Post release notes to Discord via webhook.
7
+ * @param {string} webhookUrl - Discord webhook URL
8
+ * @param {Object} releaseData - { version, notes, summary, url, contributors }
9
+ * @returns {Object} { success, message }
10
+ */
11
+ async function postToDiscord(webhookUrl, releaseData) {
12
+ try {
13
+ if (!webhookUrl) {
14
+ logger.debug('No Discord webhook provided — skipping');
15
+ return { success: false, message: 'No webhook URL' };
16
+ }
17
+
18
+ const { version, summary, url, contributors } = releaseData;
19
+ const topChanges = extractTopChangesDiscord(releaseData.notes, 5);
20
+
21
+ const fields = [
22
+ {
23
+ name: 'Version',
24
+ value: `v${version}`,
25
+ inline: true,
26
+ },
27
+ {
28
+ name: 'Date',
29
+ value: new Date().toISOString().split('T')[0],
30
+ inline: true,
31
+ },
32
+ ];
33
+
34
+ if (contributors && contributors.length > 0) {
35
+ fields.push({
36
+ name: 'Contributors',
37
+ value: contributors.slice(0, 5).map(c => `@${c.login}`).join(', ') +
38
+ (contributors.length > 5 ? ` and ${contributors.length - 5} more` : ''),
39
+ inline: false,
40
+ });
41
+ }
42
+
43
+ let description = summary || `Version ${version} has been released!`;
44
+ if (topChanges.length > 0) {
45
+ description += '\n\n**Key Changes:**\n' + topChanges.map(c => ` - ${c}`).join('\n');
46
+ }
47
+
48
+ const embed = {
49
+ title: `New Release: v${version}`,
50
+ url: url || '',
51
+ color: 0x00ff00, // Green
52
+ description: description.substring(0, 2048), // Discord limit
53
+ fields: fields,
54
+ footer: {
55
+ text: 'Powered by AI Release Notes',
56
+ },
57
+ timestamp: new Date().toISOString(),
58
+ };
59
+
60
+ const response = await fetch(webhookUrl, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({
64
+ embeds: [embed],
65
+ }),
66
+ });
67
+
68
+ if (!response.ok) {
69
+ const text = await response.text();
70
+ throw new Error(`Discord API returned ${response.status}: ${text}`);
71
+ }
72
+
73
+ logger.info('Discord notification sent successfully');
74
+ return { success: true, message: 'Posted to Discord' };
75
+ } catch (err) {
76
+ logger.error('Failed to post to Discord', err);
77
+ return { success: false, message: err.message };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Extract top N changes from release notes for Discord.
83
+ */
84
+ function extractTopChangesDiscord(notes, count) {
85
+ if (!notes) return [];
86
+
87
+ const lines = notes.split('\n');
88
+ const bullets = lines
89
+ .filter(line => line.match(/^-\s+/))
90
+ .map(line => line.replace(/^-\s+/, '').trim())
91
+ .filter(line => line.length > 0 && line.length < 150);
92
+
93
+ return bullets.slice(0, count);
94
+ }
95
+
96
+ module.exports = { postToDiscord };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ const logger = require('../logger');
4
+ const { isPrerelease } = require('../semver');
5
+
6
+ /**
7
+ * Create or update a GitHub Release.
8
+ * @param {Object} octokit - Octokit instance
9
+ * @param {Object} context - GitHub Actions context
10
+ * @param {string} version - Version string
11
+ * @param {string} notes - Release notes body
12
+ * @param {Object} config - Configuration object
13
+ * @returns {Object} { url, created }
14
+ */
15
+ async function createRelease(octokit, context, version, notes, config) {
16
+ try {
17
+ const owner = context.repo.owner;
18
+ const repo = context.repo.repo;
19
+ const tag = `v${version}`;
20
+ const prerelease = isPrerelease(version);
21
+
22
+ logger.info(`Creating GitHub Release for ${tag} (prerelease=${prerelease})`);
23
+
24
+ // Check if release already exists for this tag
25
+ let existingRelease = null;
26
+ try {
27
+ const { data } = await octokit.rest.repos.getReleaseByTag({
28
+ owner,
29
+ repo,
30
+ tag,
31
+ });
32
+ existingRelease = data;
33
+ } catch (err) {
34
+ // 404 means no existing release — expected for new releases
35
+ if (err.status !== 404) {
36
+ logger.debug(`Error checking existing release: ${err.message}`);
37
+ }
38
+ }
39
+
40
+ if (existingRelease) {
41
+ // Update existing release
42
+ logger.info(`Updating existing release for ${tag}`);
43
+ const { data: updated } = await octokit.rest.repos.updateRelease({
44
+ owner,
45
+ repo,
46
+ release_id: existingRelease.id,
47
+ body: notes,
48
+ draft: false,
49
+ prerelease: prerelease,
50
+ });
51
+
52
+ logger.info(`Release updated: ${updated.html_url}`);
53
+ return { url: updated.html_url, created: false };
54
+ }
55
+
56
+ // Create new release
57
+ const { data: release } = await octokit.rest.repos.createRelease({
58
+ owner,
59
+ repo,
60
+ tag_name: tag,
61
+ name: tag,
62
+ body: notes,
63
+ draft: false,
64
+ prerelease: prerelease,
65
+ });
66
+
67
+ logger.info(`Release created: ${release.html_url}`);
68
+ return { url: release.html_url, created: true };
69
+ } catch (err) {
70
+ logger.error('Failed to create/update GitHub Release', err);
71
+ throw err;
72
+ }
73
+ }
74
+
75
+ module.exports = { createRelease };