@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
package/action.yml ADDED
@@ -0,0 +1,113 @@
1
+ name: 'AI Release Notes'
2
+ description: 'Auto-generate beautiful release notes from commits, PRs, and diffs using AI'
3
+ author: 'your-username'
4
+
5
+ inputs:
6
+ github-token:
7
+ description: 'GitHub token for API access'
8
+ required: true
9
+ api-key:
10
+ description: 'OpenAI-compatible API key (optional — works without AI too)'
11
+ required: false
12
+ api-base:
13
+ description: 'API endpoint (supports any OpenAI-compatible API)'
14
+ required: false
15
+ default: 'https://api.openai.com/v1'
16
+ model:
17
+ description: 'AI model to use for natural language generation'
18
+ required: false
19
+ default: 'gpt-4o-mini'
20
+ template:
21
+ description: 'Release notes template: default, minimal, detailed, enterprise, fun, or custom path'
22
+ required: false
23
+ default: 'default'
24
+ commit-mode:
25
+ description: 'How to collect changes: commits, pull-requests, or auto'
26
+ required: false
27
+ default: 'auto'
28
+ version-from:
29
+ description: 'How to determine version: tag, package-json, or manual'
30
+ required: false
31
+ default: 'tag'
32
+ version:
33
+ description: 'Manual version (when version-from is manual)'
34
+ required: false
35
+ previous-tag:
36
+ description: 'Previous release tag (auto-detected if omitted)'
37
+ required: false
38
+ include-breaking:
39
+ description: 'Include BREAKING CHANGE section'
40
+ required: false
41
+ default: 'true'
42
+ include-contributors:
43
+ description: 'Include contributor thanks section'
44
+ required: false
45
+ default: 'true'
46
+ include-diff-stats:
47
+ description: 'Include file change statistics'
48
+ required: false
49
+ default: 'true'
50
+ include-screenshots:
51
+ description: 'Detect and include screenshot links from PRs'
52
+ required: false
53
+ default: 'true'
54
+ categories:
55
+ description: 'Custom categories as JSON array: [{"label":"Features","patterns":["feat","feature"]}]'
56
+ required: false
57
+ default: 'auto'
58
+ max-commits:
59
+ description: 'Maximum commits to analyze (prevents token overflow)'
60
+ required: false
61
+ default: '200'
62
+ language:
63
+ description: 'Output language: en, es, fr, de, ja, ko, zh, pt, ru'
64
+ required: false
65
+ default: 'en'
66
+ dry-run:
67
+ description: 'Generate notes but do not create release/post'
68
+ required: false
69
+ default: 'false'
70
+ slack-webhook:
71
+ description: 'Slack webhook URL (posts release notes to Slack)'
72
+ required: false
73
+ discord-webhook:
74
+ description: 'Discord webhook URL (posts release notes to Discord)'
75
+ required: false
76
+ twitter-consumer-key:
77
+ description: 'Twitter API consumer key (posts release tweet)'
78
+ required: false
79
+ twitter-consumer-secret:
80
+ description: 'Twitter API consumer secret'
81
+ required: false
82
+ twitter-access-token:
83
+ description: 'Twitter API access token'
84
+ required: false
85
+ twitter-access-secret:
86
+ description: 'Twitter API access token secret'
87
+ required: false
88
+ update-changelog:
89
+ description: 'Automatically update CHANGELOG.md'
90
+ required: false
91
+ default: 'false'
92
+ changelog-path:
93
+ description: 'Path to changelog file'
94
+ required: false
95
+ default: 'CHANGELOG.md'
96
+
97
+ outputs:
98
+ release-notes:
99
+ description: 'Generated release notes markdown'
100
+ release-url:
101
+ description: 'URL of the created GitHub Release'
102
+ version:
103
+ description: 'The version number used'
104
+ summary:
105
+ description: 'One-line summary of the release'
106
+
107
+ runs:
108
+ using: 'node20'
109
+ main: 'dist/index.js'
110
+
111
+ branding:
112
+ icon: 'file-text'
113
+ color: 'green'
package/index.js ADDED
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const core = require('@actions/core');
4
+ const logger = require('./src/logger');
5
+ const { createConfig } = require('./src/config');
6
+ const { generate } = require('./src/generator');
7
+
8
+ /**
9
+ * Entry point for the GitHub Action.
10
+ */
11
+ async function run() {
12
+ try {
13
+ logger.info('AI Release Notes action started');
14
+
15
+ // Collect all inputs
16
+ const inputs = {
17
+ 'github-token': core.getInput('github-token', { required: true }),
18
+ 'api-key': core.getInput('api-key') || '',
19
+ 'api-base': core.getInput('api-base') || 'https://api.openai.com/v1',
20
+ model: core.getInput('model') || 'gpt-4o-mini',
21
+ template: core.getInput('template') || 'default',
22
+ 'commit-mode': core.getInput('commit-mode') || 'auto',
23
+ 'version-from': core.getInput('version-from') || 'tag',
24
+ version: core.getInput('version') || '',
25
+ 'previous-tag': core.getInput('previous-tag') || '',
26
+ 'include-breaking': core.getInput('include-breaking') || 'true',
27
+ 'include-contributors': core.getInput('include-contributors') || 'true',
28
+ 'include-diff-stats': core.getInput('include-diff-stats') || 'true',
29
+ 'include-screenshots': core.getInput('include-screenshots') || 'true',
30
+ categories: core.getInput('categories') || 'auto',
31
+ 'max-commits': core.getInput('max-commits') || '200',
32
+ language: core.getInput('language') || 'en',
33
+ 'dry-run': core.getInput('dry-run') || 'false',
34
+ 'slack-webhook': core.getInput('slack-webhook') || '',
35
+ 'discord-webhook': core.getInput('discord-webhook') || '',
36
+ 'twitter-consumer-key': core.getInput('twitter-consumer-key') || '',
37
+ 'twitter-consumer-secret': core.getInput('twitter-consumer-secret') || '',
38
+ 'twitter-access-token': core.getInput('twitter-access-token') || '',
39
+ 'twitter-access-secret': core.getInput('twitter-access-secret') || '',
40
+ 'update-changelog': core.getInput('update-changelog') || 'false',
41
+ 'changelog-path': core.getInput('changelog-path') || 'CHANGELOG.md',
42
+ };
43
+
44
+ // Create validated config
45
+ const config = createConfig(inputs);
46
+
47
+ // Run the generator
48
+ const result = await generate(config);
49
+
50
+ // Set outputs
51
+ core.setOutput('release-notes', result.notes);
52
+ core.setOutput('release-url', result.url || '');
53
+ core.setOutput('version', result.version || '');
54
+ core.setOutput('summary', result.summary || '');
55
+
56
+ // Also set the release notes as the job summary
57
+ if (result.notes) {
58
+ await core.summary.addRaw(result.notes).write();
59
+ }
60
+
61
+ logger.info('AI Release Notes action completed successfully');
62
+ } catch (err) {
63
+ logger.error('Action failed', err);
64
+ core.setFailed(`AI Release Notes failed: ${err.message}`);
65
+ }
66
+ }
67
+
68
+ // Run if called directly (not in test)
69
+ if (require.main === module) {
70
+ run();
71
+ }
72
+
73
+ module.exports = { run };
package/jest.config.js ADDED
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ testEnvironment: 'node',
5
+ testMatch: ['**/__tests__/**/*.test.js'],
6
+ collectCoverageFrom: ['src/**/*.js', '!src/logger.js'],
7
+ coverageThreshold: {
8
+ global: { branches: 20, functions: 20, lines: 20, statements: 20 },
9
+ },
10
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@theihtisham/ai-release-notes",
3
+ "version": "1.0.0",
4
+ "description": "Auto-generate beautiful release notes from commits, PRs, and diffs using AI",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "build": "ncc build index.js -o dist --source-map",
8
+ "test": "jest --coverage",
9
+ "lint": "eslint src/",
10
+ "format": "prettier --write ."
11
+ },
12
+ "dependencies": {
13
+ "@actions/core": "^1.10.1",
14
+ "@actions/github": "^6.0.0",
15
+ "openai": "^4.67.0",
16
+ "minimatch": "^9.0.4",
17
+ "twitter-api-v2": "^1.16.0",
18
+ "node-fetch": "^3.3.0"
19
+ },
20
+ "devDependencies": {
21
+ "jest": "^29.7.0",
22
+ "@vercel/ncc": "^0.38.3",
23
+ "eslint": "^8.57.0",
24
+ "prettier": "^3.3.0"
25
+ },
26
+ "keywords": [
27
+ "github-action",
28
+ "release-notes",
29
+ "ai",
30
+ "changelog",
31
+ "automation"
32
+ ],
33
+ "license": "MIT",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/theihtisham/ai-release-notes.git"
40
+ }
41
+ }
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const logger = require('./logger');
4
+ const { getSystemPrompt, getUserPrompt } = require('./prompts');
5
+
6
+ /**
7
+ * Generate release notes using AI (OpenAI-compatible API).
8
+ * Falls back to template-based generation if AI fails or no API key.
9
+ *
10
+ * @param {Object} analysis - Commit analysis results
11
+ * @param {Object} diffSummary - Diff analysis results
12
+ * @param {string} version - Current version
13
+ * @param {Object} config - Configuration object
14
+ * @returns {string} Generated release notes markdown
15
+ */
16
+ async function generateReleaseNotes(analysis, diffSummary, version, config) {
17
+ if (!config.hasAI || !config.apiKey) {
18
+ logger.info('No API key provided — using template-based generation');
19
+ return null;
20
+ }
21
+
22
+ try {
23
+ logger.info(`Generating AI release notes with model ${config.model}`);
24
+
25
+ // Use dynamic import for OpenAI (ESM module)
26
+ let openaiModule;
27
+ try {
28
+ openaiModule = await import('openai');
29
+ } catch (importErr) {
30
+ logger.warn(`Failed to import OpenAI module: ${importErr.message}`);
31
+ return null;
32
+ }
33
+
34
+ const OpenAI = openaiModule.default || openaiModule.OpenAI;
35
+
36
+ const client = new OpenAI({
37
+ apiKey: config.apiKey,
38
+ baseURL: config.apiBase,
39
+ });
40
+
41
+ const systemPrompt = getSystemPrompt(config);
42
+ const userPrompt = getUserPrompt(analysis, diffSummary, version, config);
43
+
44
+ logger.debug('Sending request to AI API...');
45
+
46
+ const response = await client.chat.completions.create({
47
+ model: config.model,
48
+ messages: [
49
+ { role: 'system', content: systemPrompt },
50
+ { role: 'user', content: userPrompt },
51
+ ],
52
+ temperature: 0.3,
53
+ max_tokens: 4000,
54
+ });
55
+
56
+ if (!response || !response.choices || response.choices.length === 0) {
57
+ logger.warn('AI returned empty response — falling back to template');
58
+ return null;
59
+ }
60
+
61
+ const content = response.choices[0].message.content;
62
+
63
+ if (!content || content.trim().length === 0) {
64
+ logger.warn('AI returned empty content — falling back to template');
65
+ return null;
66
+ }
67
+
68
+ // Validate: must be valid markdown with some content
69
+ if (!validateAIResponse(content)) {
70
+ logger.warn('AI response validation failed — falling back to template');
71
+ return null;
72
+ }
73
+
74
+ logger.info('AI release notes generated successfully');
75
+ return content;
76
+ } catch (err) {
77
+ logger.error('AI generation failed — falling back to template', err);
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Validate AI response content.
84
+ * Checks that the response is non-empty markdown with at least one section.
85
+ */
86
+ function validateAIResponse(content) {
87
+ if (!content || typeof content !== 'string') {
88
+ return false;
89
+ }
90
+
91
+ // Must have at least 50 characters of content
92
+ if (content.trim().length < 50) {
93
+ return false;
94
+ }
95
+
96
+ // Must contain at least one markdown element (header, bullet, or link)
97
+ const hasMarkdown = /(^#{1,6}\s|^- |\*\s|[^!]\[.*\]\(.*\))/.test(content);
98
+ if (!hasMarkdown) {
99
+ return false;
100
+ }
101
+
102
+ return true;
103
+ }
104
+
105
+ module.exports = {
106
+ generateReleaseNotes,
107
+ validateAIResponse,
108
+ };
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ const { CONVENTIONAL_COMMIT_REGEX, BREAKING_CHANGE_REGEX } = require('./constants');
4
+ const { categorizeCommits, deduplicateChanges } = require('./categorizer');
5
+ const { getContributors } = require('./contributor');
6
+ const logger = require('./logger');
7
+
8
+ /**
9
+ * Analyze commits and produce a structured analysis.
10
+ * @param {Array} commits - Array of commit objects from GitHub API
11
+ * @param {Object} config - Configuration object
12
+ * @returns {Object} Structured analysis with categories, breaking changes, contributors, scopes, stats
13
+ */
14
+ function analyzeCommits(commits, config) {
15
+ logger.info(`Analyzing ${commits ? commits.length : 0} commits`);
16
+
17
+ if (!commits || commits.length === 0) {
18
+ return {
19
+ categories: {},
20
+ breaking: [],
21
+ contributors: [],
22
+ scopes: {},
23
+ stats: {
24
+ total_commits: 0,
25
+ total_prs: 0,
26
+ total_files_changed: 0,
27
+ additions: 0,
28
+ deletions: 0,
29
+ },
30
+ linkedIssues: [],
31
+ };
32
+ }
33
+
34
+ const parsedCommits = commits.map(parseCommit).filter(Boolean);
35
+ const categories = categorizeCommits(parsedCommits, config.categories);
36
+ const dedupedCategories = deduplicateChanges(categories);
37
+ const breaking = extractBreakingChanges(parsedCommits);
38
+ const contributors = getContributors(commits, null);
39
+ const scopes = extractScopes(parsedCommits);
40
+ const stats = computeStats(parsedCommits);
41
+ const linkedIssues = extractLinkedIssues(parsedCommits);
42
+
43
+ logger.info(`Analysis complete: ${stats.total_commits} commits, ${Object.keys(dedupedCategories).length} categories, ${breaking.length} breaking changes`);
44
+
45
+ return {
46
+ categories: dedupedCategories,
47
+ breaking: breaking,
48
+ contributors: contributors,
49
+ scopes: scopes,
50
+ stats: stats,
51
+ linkedIssues: linkedIssues,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Parse a single commit into structured data.
57
+ */
58
+ function parseCommit(commit) {
59
+ try {
60
+ const message = commit.message || (commit.commit && commit.commit.message) || '';
61
+ const match = message.match(CONVENTIONAL_COMMIT_REGEX);
62
+
63
+ if (!match) {
64
+ return {
65
+ ...commit,
66
+ _type: 'other',
67
+ _scope: '',
68
+ _description: message,
69
+ _breaking: false,
70
+ _pr: null,
71
+ _body: '',
72
+ };
73
+ }
74
+
75
+ const type = match[1].toLowerCase();
76
+ const scope = match[3] || '';
77
+ const breakingBang = !!match[4];
78
+ const description = match[5] || '';
79
+
80
+ // Extract body (everything after first line)
81
+ const lines = message.split('\n');
82
+ const body = lines.slice(1).join('\n').trim();
83
+
84
+ // Check for BREAKING CHANGE in body
85
+ const breakingMatch = body.match(BREAKING_CHANGE_REGEX);
86
+ const hasBreakingFooter = !!breakingMatch;
87
+ const breakingDescription = breakingMatch ? breakingMatch[1] : '';
88
+
89
+ return {
90
+ ...commit,
91
+ _type: type,
92
+ _scope: scope,
93
+ _description: description,
94
+ _breaking: breakingBang || hasBreakingFooter,
95
+ _breakingDescription: breakingDescription,
96
+ _pr: extractPRNumber(message),
97
+ _body: body,
98
+ };
99
+ } catch (err) {
100
+ logger.debug(`Failed to parse commit: ${err.message}`);
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Extract PR number from commit message.
107
+ */
108
+ function extractPRNumber(message) {
109
+ const match = message.match(/\(#(\d+)\)/);
110
+ return match ? parseInt(match[1], 10) : null;
111
+ }
112
+
113
+ /**
114
+ * Extract breaking changes from parsed commits.
115
+ */
116
+ function extractBreakingChanges(parsedCommits) {
117
+ const breaking = [];
118
+
119
+ for (const commit of parsedCommits) {
120
+ if (!commit) continue;
121
+ if (commit._breaking) {
122
+ breaking.push({
123
+ description: commit._breakingDescription || commit._description,
124
+ commit: commit.sha || '',
125
+ scope: commit._scope || '',
126
+ type: commit._type || '',
127
+ pr: commit._pr,
128
+ migration_guide: extractMigrationGuide(commit._body),
129
+ });
130
+ }
131
+ }
132
+
133
+ return breaking;
134
+ }
135
+
136
+ /**
137
+ * Try to extract migration guide from commit body.
138
+ */
139
+ function extractMigrationGuide(body) {
140
+ if (!body) return '';
141
+ // Look for migration guidance patterns
142
+ const patterns = [
143
+ /Migration Guide:\s*([\s\S]*?)(?=\n\n|\n##|$)/i,
144
+ /How to migrate:\s*([\s\S]*?)(?=\n\n|\n##|$)/i,
145
+ /To migrate[\s\S]*?:\s*([\s\S]*?)(?=\n\n|\n##|$)/i,
146
+ ];
147
+
148
+ for (const pattern of patterns) {
149
+ const match = body.match(pattern);
150
+ if (match) return match[1].trim();
151
+ }
152
+
153
+ return '';
154
+ }
155
+
156
+ /**
157
+ * Extract and count scopes from parsed commits.
158
+ */
159
+ function extractScopes(parsedCommits) {
160
+ const scopes = {};
161
+ for (const commit of parsedCommits) {
162
+ if (!commit) continue;
163
+ const scope = commit._scope;
164
+ if (scope) {
165
+ scopes[scope] = (scopes[scope] || 0) + 1;
166
+ }
167
+ }
168
+ return scopes;
169
+ }
170
+
171
+ /**
172
+ * Compute aggregate stats from parsed commits.
173
+ */
174
+ function computeStats(parsedCommits) {
175
+ let totalPrs = 0;
176
+ let totalFilesChanged = 0;
177
+ let additions = 0;
178
+ let deletions = 0;
179
+
180
+ for (const commit of parsedCommits) {
181
+ if (!commit) continue;
182
+ if (commit._pr) totalPrs++;
183
+ if (commit.files) totalFilesChanged += commit.files.length || commit.files;
184
+ if (commit.stats) {
185
+ additions += commit.stats.additions || 0;
186
+ deletions += commit.stats.deletions || 0;
187
+ }
188
+ }
189
+
190
+ return {
191
+ total_commits: parsedCommits.filter(Boolean).length,
192
+ total_prs: totalPrs,
193
+ total_files_changed: totalFilesChanged,
194
+ additions: additions,
195
+ deletions: deletions,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Extract linked issue numbers from commit messages.
201
+ */
202
+ function extractLinkedIssues(parsedCommits) {
203
+ const issues = [];
204
+ const seen = new Set();
205
+
206
+ for (const commit of parsedCommits) {
207
+ if (!commit) continue;
208
+ const message = commit.message || (commit.commit && commit.commit.message) || '';
209
+ // Match #123 patterns
210
+ const issueMatches = message.matchAll(/#(\d+)/g);
211
+ for (const match of issueMatches) {
212
+ const num = parseInt(match[1], 10);
213
+ // Skip if it's a PR number (detected by (#123) pattern)
214
+ if (commit._pr === num) continue;
215
+ if (!seen.has(num)) {
216
+ seen.add(num);
217
+ issues.push({ number: num });
218
+ }
219
+ }
220
+ }
221
+
222
+ return issues;
223
+ }
224
+
225
+ module.exports = {
226
+ analyzeCommits,
227
+ parseCommit,
228
+ extractBreakingChanges,
229
+ extractScopes,
230
+ computeStats,
231
+ extractLinkedIssues,
232
+ };