@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,355 @@
1
+ 'use strict';
2
+
3
+ const { CONVENTIONAL_COMMIT_REGEX, BREAKING_CHANGE_REGEX } = require('../constants');
4
+ const logger = require('../logger');
5
+
6
+ /**
7
+ * @typedef {Object} BreakingChange
8
+ * @property {string} description - What broke
9
+ * @property {string} scope - Affected scope/module
10
+ * @property {string} type - Commit type (feat, fix, etc.)
11
+ * @property {string} severity - 'major' | 'minor' | 'patch'
12
+ * @property {string} commit - Commit SHA
13
+ * @property {number|null} pr - PR number
14
+ * @property {string} migration_guide - How to migrate
15
+ * @property {string} before_code - Code before the change
16
+ * @property {string} after_code - Code after the change
17
+ * @property {string[]} affected_apis - List of affected API signatures
18
+ */
19
+
20
+ /**
21
+ * Detect breaking changes from commit messages and code changes.
22
+ * Analyzes conventional commit prefixes (BREAKING CHANGE, !),
23
+ * semver major bumps, and API signature changes.
24
+ *
25
+ * @param {Array} commits - Array of commit objects
26
+ * @returns {BreakingChange[]} Array of detected breaking changes
27
+ */
28
+ function detectBreakingChanges(commits) {
29
+ if (!commits || commits.length === 0) {
30
+ return [];
31
+ }
32
+
33
+ const breakingChanges = [];
34
+
35
+ for (const commit of commits) {
36
+ const message = commit.message || (commit.commit && commit.commit.message) || '';
37
+
38
+ // Method 1: Conventional commit with ! suffix (e.g., feat!: ...)
39
+ const bangMatch = message.match(CONVENTIONAL_COMMIT_REGEX);
40
+ if (bangMatch && bangMatch[4]) {
41
+ breakingChanges.push(createBreakingChange(commit, bangMatch, message));
42
+ continue;
43
+ }
44
+
45
+ // Method 2: BREAKING CHANGE footer
46
+ const breakingFooterMatch = message.match(BREAKING_CHANGE_REGEX);
47
+ if (breakingFooterMatch) {
48
+ breakingChanges.push(createBreakingChange(commit, bangMatch, message, breakingFooterMatch[1]));
49
+ continue;
50
+ }
51
+
52
+ // Method 3: "breaking" keyword in commit type or message
53
+ const lowerMessage = message.toLowerCase();
54
+ if (lowerMessage.startsWith('breaking:') || lowerMessage.includes('breaking change')) {
55
+ breakingChanges.push(createBreakingChange(commit, null, message));
56
+ continue;
57
+ }
58
+
59
+ // Method 4: Detect API signature changes from keywords
60
+ const apiChange = detectAPISignatureChange(message);
61
+ if (apiChange) {
62
+ breakingChanges.push({
63
+ ...createBreakingChange(commit, null, message),
64
+ affected_apis: [apiChange],
65
+ });
66
+ }
67
+ }
68
+
69
+ logger.info(`Detected ${breakingChanges.length} breaking changes from ${commits.length} commits`);
70
+ return breakingChanges;
71
+ }
72
+
73
+ /**
74
+ * Create a breaking change object from a commit.
75
+ * @param {Object} commit
76
+ * @param {Array|null} match - Conventional commit regex match
77
+ * @param {string} message - Full commit message
78
+ * @param {string} [explicitDescription] - Explicit breaking change description
79
+ * @returns {BreakingChange}
80
+ */
81
+ function createBreakingChange(commit, match, message, explicitDescription) {
82
+ const body = message.split('\n').slice(1).join('\n').trim();
83
+ const type = match ? match[1].toLowerCase() : extractType(message);
84
+ const scope = match ? (match[3] || '') : '';
85
+ const description = explicitDescription || (match ? match[5] : message.split('\n')[0]);
86
+
87
+ return {
88
+ description: description,
89
+ scope: scope,
90
+ type: type,
91
+ severity: 'major',
92
+ commit: commit.sha || '',
93
+ pr: extractPRNumber(message),
94
+ migration_guide: extractMigrationGuide(body),
95
+ before_code: extractBeforeCode(body),
96
+ after_code: extractAfterCode(body),
97
+ affected_apis: extractAffectedAPIs(body),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Generate a step-by-step migration guide for breaking changes.
103
+ *
104
+ * @param {BreakingChange[]} changes - Array of breaking changes
105
+ * @returns {string} Markdown migration guide
106
+ */
107
+ function generateMigrationGuide(changes) {
108
+ if (!changes || changes.length === 0) {
109
+ return 'No breaking changes detected. No migration required.';
110
+ }
111
+
112
+ const lines = [];
113
+
114
+ lines.push('# Migration Guide');
115
+ lines.push('');
116
+ lines.push(`This guide covers ${changes.length} breaking change${changes.length > 1 ? 's' : ''} that require action before upgrading.`);
117
+ lines.push('');
118
+
119
+ for (let i = 0; i < changes.length; i++) {
120
+ const change = changes[i];
121
+ const num = i + 1;
122
+
123
+ lines.push(`## ${num}. ${change.description}`);
124
+ lines.push('');
125
+
126
+ // Scope info
127
+ if (change.scope) {
128
+ lines.push(`**Scope:** \`${change.scope}\``);
129
+ lines.push('');
130
+ }
131
+
132
+ // Severity
133
+ lines.push(`**Severity:** ${change.severity || 'major'}`);
134
+ lines.push('');
135
+
136
+ // Before/After code examples
137
+ if (change.before_code || change.after_code) {
138
+ lines.push('### Before');
139
+ lines.push('```javascript');
140
+ lines.push(change.before_code || `// Old usage of ${change.scope || 'the API'}`);
141
+ lines.push('```');
142
+ lines.push('');
143
+
144
+ lines.push('### After');
145
+ lines.push('```javascript');
146
+ lines.push(change.after_code || `// New usage of ${change.scope || 'the API'}`);
147
+ lines.push('```');
148
+ lines.push('');
149
+ }
150
+
151
+ // Migration steps
152
+ if (change.migration_guide) {
153
+ lines.push('### Migration Steps');
154
+ lines.push('');
155
+ const steps = change.migration_guide.split('\n').filter(s => s.trim());
156
+ for (const step of steps) {
157
+ lines.push(step.startsWith('-') || step.startsWith('*') ? step : `- ${step}`);
158
+ }
159
+ lines.push('');
160
+ } else {
161
+ lines.push('### Migration Steps');
162
+ lines.push('');
163
+ lines.push(`1. Review the changes to \`${change.scope || 'the affected module'}\``);
164
+ lines.push('2. Update your code to match the new API');
165
+ lines.push('3. Run your test suite to verify compatibility');
166
+ lines.push('4. Deploy to a staging environment before production');
167
+ lines.push('');
168
+ }
169
+
170
+ // Affected APIs
171
+ if (change.affected_apis && change.affected_apis.length > 0) {
172
+ lines.push('### Affected APIs');
173
+ lines.push('');
174
+ for (const api of change.affected_apis) {
175
+ lines.push(`- \`${api}\``);
176
+ }
177
+ lines.push('');
178
+ }
179
+
180
+ // PR reference
181
+ if (change.pr) {
182
+ lines.push(`_See PR #${change.pr} for full details._`);
183
+ lines.push('');
184
+ }
185
+ }
186
+
187
+ // Deprecation timeline
188
+ lines.push('## Deprecation Timeline');
189
+ lines.push('');
190
+ lines.push('- **Immediately**: Update your code before deploying this version');
191
+ lines.push('- **Next minor release**: Old APIs may be removed entirely');
192
+ lines.push('- **Next major release**: No backward compatibility guaranteed');
193
+ lines.push('');
194
+
195
+ return lines.join('\n');
196
+ }
197
+
198
+ /**
199
+ * Generate package-specific upgrade commands.
200
+ *
201
+ * @param {string} fromVersion - Current version
202
+ * @param {string} toVersion - Target version
203
+ * @param {string} [packageName] - Package name (optional, defaults to generic)
204
+ * @returns {string} Upgrade commands for npm, yarn, and pnpm
205
+ */
206
+ function generateUpgradeCommand(fromVersion, toVersion, packageName) {
207
+ const pkg = packageName || 'your-package';
208
+ const lines = [];
209
+
210
+ lines.push(`# Upgrade from v${fromVersion} to v${toVersion}`);
211
+ lines.push('');
212
+
213
+ lines.push('## npm');
214
+ lines.push(`npm install ${pkg}@${toVersion}`);
215
+ lines.push('');
216
+
217
+ lines.push('## yarn');
218
+ lines.push(`yarn add ${pkg}@${toVersion}`);
219
+ lines.push('');
220
+
221
+ lines.push('## pnpm');
222
+ lines.push(`pnpm add ${pkg}@${toVersion}`);
223
+ lines.push('');
224
+
225
+ // Add helpful notes
226
+ const fromParts = fromVersion.split('.').map(Number);
227
+ const toParts = toVersion.split('.').map(Number);
228
+
229
+ if (toParts[0] > fromParts[0]) {
230
+ lines.push('> **Note:** This is a major version upgrade. Please review the migration guide before upgrading.');
231
+ } else if (toParts[1] > fromParts[1]) {
232
+ lines.push('> **Note:** This is a minor version upgrade with new features. Backward compatible.');
233
+ } else {
234
+ lines.push('> **Note:** This is a patch upgrade with bug fixes. Safe to upgrade.');
235
+ }
236
+
237
+ return lines.join('\n');
238
+ }
239
+
240
+ // --- Helper functions ---
241
+
242
+ /**
243
+ * Extract commit type from message.
244
+ */
245
+ function extractType(message) {
246
+ if (!message) return 'other';
247
+ const match = message.match(CONVENTIONAL_COMMIT_REGEX);
248
+ return match ? match[1].toLowerCase() : 'other';
249
+ }
250
+
251
+ /**
252
+ * Extract PR number from commit message.
253
+ */
254
+ function extractPRNumber(message) {
255
+ if (!message) return null;
256
+ const match = message.match(/\(#(\d+)\)/);
257
+ return match ? parseInt(match[1], 10) : null;
258
+ }
259
+
260
+ /**
261
+ * Extract migration guide from commit body.
262
+ */
263
+ function extractMigrationGuide(body) {
264
+ if (!body) return '';
265
+ const patterns = [
266
+ /Migration Guide:\s*([\s\S]*?)(?=\n\n|\n##|$)/i,
267
+ /How to migrate:\s*([\s\S]*?)(?=\n\n|\n##|$)/i,
268
+ /To migrate[\s\S]*?:\s*([\s\S]*?)(?=\n\n|\n##|$)/i,
269
+ /MIGRATION:\s*([\s\S]*?)(?=\n\n|\n##|$)/i,
270
+ ];
271
+
272
+ for (const pattern of patterns) {
273
+ const match = body.match(pattern);
274
+ if (match) return match[1].trim();
275
+ }
276
+
277
+ return '';
278
+ }
279
+
280
+ /**
281
+ * Extract "before" code from commit body.
282
+ */
283
+ function extractBeforeCode(body) {
284
+ if (!body) return '';
285
+ const match = body.match(/```(?:javascript|js|ts|typescript)?\s*\n([\s\S]*?)```/i);
286
+ return match ? match[1].trim() : '';
287
+ }
288
+
289
+ /**
290
+ * Extract "after" code from commit body.
291
+ * Looks for the second code block.
292
+ */
293
+ function extractAfterCode(body) {
294
+ if (!body) return '';
295
+ const matches = body.matchAll(/```(?:javascript|js|ts|typescript)?\s*\n([\s\S]*?)```/gi);
296
+ const blocks = [...matches];
297
+ return blocks.length >= 2 ? blocks[1][1].trim() : '';
298
+ }
299
+
300
+ /**
301
+ * Extract affected API signatures from commit body.
302
+ */
303
+ function extractAffectedAPIs(body) {
304
+ if (!body) return [];
305
+ const apis = [];
306
+ const patterns = [
307
+ /`([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*\s*\([^)]*\))`/g,
308
+ /affected API:\s*`([^`]+)`/gi,
309
+ /changed:\s*`([^`]+)`/gi,
310
+ ];
311
+
312
+ for (const pattern of patterns) {
313
+ let match;
314
+ while ((match = pattern.exec(body)) !== null) {
315
+ apis.push(match[1]);
316
+ }
317
+ }
318
+
319
+ return apis;
320
+ }
321
+
322
+ /**
323
+ * Detect API signature changes from commit message keywords.
324
+ */
325
+ function detectAPISignatureChange(message) {
326
+ if (!message) return null;
327
+
328
+ const patterns = [
329
+ /remove\s+([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)\s*(?:method|function|parameter|argument|option|property|export|endpoint|route|API)/i,
330
+ /rename\s+([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)\s*(?:to|->|→)/i,
331
+ /deprecat(?:e|ed|ing)\s+([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)/i,
332
+ /changed\s+(?:signature|type|return type)\s+(?:of\s+)?([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)/i,
333
+ ];
334
+
335
+ for (const pattern of patterns) {
336
+ const match = message.match(pattern);
337
+ if (match) return match[1];
338
+ }
339
+
340
+ return null;
341
+ }
342
+
343
+ module.exports = {
344
+ detectBreakingChanges,
345
+ generateMigrationGuide,
346
+ generateUpgradeCommand,
347
+ createBreakingChange,
348
+ extractType,
349
+ extractPRNumber,
350
+ extractMigrationGuide,
351
+ extractBeforeCode,
352
+ extractAfterCode,
353
+ extractAffectedAPIs,
354
+ detectAPISignatureChange,
355
+ };
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ const { CONVENTIONAL_COMMIT_REGEX } = require('./constants');
4
+ const logger = require('./logger');
5
+
6
+ /**
7
+ * Categorize commits into configured categories using conventional commit patterns.
8
+ * @param {Array} commits - Array of commit objects
9
+ * @param {Array} categories - Array of category definitions with label and patterns
10
+ * @returns {Object} Map of category labels to arrays of changes
11
+ */
12
+ function categorizeCommits(commits, categories) {
13
+ const categorized = {};
14
+
15
+ // Initialize all categories as empty arrays
16
+ for (const cat of categories) {
17
+ categorized[cat.label] = [];
18
+ }
19
+ categorized['🔄 Other Changes'] = [];
20
+
21
+ if (!commits || commits.length === 0) {
22
+ logger.debug('No commits to categorize');
23
+ return categorized;
24
+ }
25
+
26
+ for (const commit of commits) {
27
+ try {
28
+ const message = commit.message || (commit.commit && commit.commit.message) || '';
29
+ const result = categorizeSingle(message, categories, commit);
30
+ const targetCategory = result.category || '🔄 Other Changes';
31
+ if (!categorized[targetCategory]) {
32
+ categorized[targetCategory] = [];
33
+ }
34
+ categorized[targetCategory].push(result.change);
35
+ } catch (err) {
36
+ logger.warn(`Failed to categorize commit: ${err.message}`);
37
+ categorized['🔄 Other Changes'].push({
38
+ description: commit.message || 'Unknown change',
39
+ commit: commit.sha || '',
40
+ });
41
+ }
42
+ }
43
+
44
+ // Remove empty categories
45
+ for (const key of Object.keys(categorized)) {
46
+ if (categorized[key].length === 0) {
47
+ delete categorized[key];
48
+ }
49
+ }
50
+
51
+ return categorized;
52
+ }
53
+
54
+ /**
55
+ * Categorize a single commit message.
56
+ */
57
+ function categorizeSingle(message, categories, commit) {
58
+ const match = message.match(CONVENTIONAL_COMMIT_REGEX);
59
+
60
+ if (match) {
61
+ const type = match[1].toLowerCase();
62
+ const scope = match[3] || '';
63
+ const breaking = !!match[4];
64
+ const description = match[5] || message;
65
+
66
+ // Find matching category
67
+ let matchedCategory = null;
68
+ for (const cat of categories) {
69
+ if (cat.patterns.some(p => p.toLowerCase() === type)) {
70
+ matchedCategory = cat.label;
71
+ break;
72
+ }
73
+ }
74
+
75
+ if (!matchedCategory) {
76
+ matchedCategory = '🔄 Other Changes';
77
+ }
78
+
79
+ return {
80
+ category: matchedCategory,
81
+ change: {
82
+ type: type,
83
+ scope: scope,
84
+ description: description,
85
+ breaking: breaking,
86
+ pr: extractPRNumber(message),
87
+ sha: commit.sha || '',
88
+ author: commit.author?.login || (commit.author && commit.author.login) || '',
89
+ commit: commit,
90
+ },
91
+ };
92
+ }
93
+
94
+ // Fallback: keyword matching against category patterns
95
+ const lowerMessage = message.toLowerCase();
96
+ for (const cat of categories) {
97
+ for (const pattern of cat.patterns) {
98
+ if (lowerMessage.includes(pattern.toLowerCase())) {
99
+ return {
100
+ category: cat.label,
101
+ change: {
102
+ type: pattern,
103
+ scope: '',
104
+ description: message,
105
+ breaking: false,
106
+ pr: extractPRNumber(message),
107
+ sha: commit.sha || '',
108
+ author: commit.author?.login || (commit.author && commit.author.login) || '',
109
+ commit: commit,
110
+ },
111
+ };
112
+ }
113
+ }
114
+ }
115
+
116
+ // Default: Other Changes
117
+ return {
118
+ category: '🔄 Other Changes',
119
+ change: {
120
+ type: 'other',
121
+ scope: '',
122
+ description: message,
123
+ breaking: false,
124
+ pr: extractPRNumber(message),
125
+ sha: commit.sha || '',
126
+ author: commit.author?.login || (commit.author && commit.author.login) || '',
127
+ commit: commit,
128
+ },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Extract PR number from commit message (e.g., (#123))
134
+ */
135
+ function extractPRNumber(message) {
136
+ const match = message.match(/\(#(\d+)\)/);
137
+ return match ? parseInt(match[1], 10) : null;
138
+ }
139
+
140
+ /**
141
+ * Deduplicate changes that appear in both commits and PRs.
142
+ * Keeps the richer data source (PR > commit).
143
+ */
144
+ function deduplicateChanges(categorized) {
145
+ const seen = new Map();
146
+
147
+ for (const [category, changes] of Object.entries(categorized)) {
148
+ const deduped = [];
149
+ for (const change of changes) {
150
+ const key = change.description.toLowerCase().trim();
151
+ if (seen.has(key)) {
152
+ // Merge: prefer the one with more data
153
+ const existing = seen.get(key);
154
+ const merged = {
155
+ ...existing,
156
+ ...change,
157
+ pr: change.pr || existing.pr,
158
+ scope: change.scope || existing.scope,
159
+ author: change.author || existing.author,
160
+ };
161
+ const idx = deduped.findIndex(c => c.description.toLowerCase().trim() === key);
162
+ if (idx !== -1) {
163
+ deduped[idx] = merged;
164
+ }
165
+ seen.set(key, merged);
166
+ } else {
167
+ deduped.push(change);
168
+ seen.set(key, change);
169
+ }
170
+ }
171
+ categorized[category] = deduped;
172
+ }
173
+
174
+ return categorized;
175
+ }
176
+
177
+ module.exports = {
178
+ categorizeCommits,
179
+ categorizeSingle,
180
+ extractPRNumber,
181
+ deduplicateChanges,
182
+ };
package/src/config.js ADDED
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ DEFAULTS,
7
+ DEFAULT_CATEGORIES,
8
+ BUILT_IN_TEMPLATES,
9
+ VALID_COMMIT_MODES,
10
+ VALID_VERSION_SOURCES,
11
+ VALID_LANGUAGES,
12
+ } = require('./constants');
13
+ const logger = require('./logger');
14
+
15
+ class ConfigError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'ConfigError';
19
+ }
20
+ }
21
+
22
+ function createConfig(inputs) {
23
+ const errors = [];
24
+
25
+ // Required: github-token
26
+ if (!inputs['github-token'] || inputs['github-token'].trim() === '') {
27
+ errors.push('github-token is required and cannot be empty');
28
+ }
29
+
30
+ // Validate template
31
+ const template = inputs.template || DEFAULTS.TEMPLATE;
32
+ if (!BUILT_IN_TEMPLATES.includes(template)) {
33
+ const templatePath = path.resolve(template);
34
+ if (!fs.existsSync(templatePath)) {
35
+ errors.push(
36
+ `Invalid template "${template}". Must be one of: ${BUILT_IN_TEMPLATES.join(', ')}, or a valid file path to a custom template.`
37
+ );
38
+ }
39
+ }
40
+
41
+ // Validate commit-mode
42
+ const commitMode = inputs['commit-mode'] || DEFAULTS.COMMIT_MODE;
43
+ if (!VALID_COMMIT_MODES.includes(commitMode)) {
44
+ errors.push(
45
+ `Invalid commit-mode "${commitMode}". Must be one of: ${VALID_COMMIT_MODES.join(', ')}`
46
+ );
47
+ }
48
+
49
+ // Validate version-from
50
+ const versionFrom = inputs['version-from'] || DEFAULTS.VERSION_FROM;
51
+ if (!VALID_VERSION_SOURCES.includes(versionFrom)) {
52
+ errors.push(
53
+ `Invalid version-from "${versionFrom}". Must be one of: ${VALID_VERSION_SOURCES.join(', ')}`
54
+ );
55
+ }
56
+
57
+ // Validate version is provided when version-from is manual
58
+ if (versionFrom === 'manual' && (!inputs.version || inputs.version.trim() === '')) {
59
+ errors.push('version is required when version-from is "manual"');
60
+ }
61
+
62
+ // Validate language
63
+ const language = inputs.language || DEFAULTS.LANGUAGE;
64
+ if (!VALID_LANGUAGES.includes(language)) {
65
+ errors.push(
66
+ `Invalid language "${language}". Must be one of: ${VALID_LANGUAGES.join(', ')}`
67
+ );
68
+ }
69
+
70
+ // Validate max-commits
71
+ const maxCommits = parseInt(inputs['max-commits'] || String(DEFAULTS.MAX_COMMITS), 10);
72
+ if (isNaN(maxCommits) || maxCommits < 1 || maxCommits > 1000) {
73
+ errors.push('max-commits must be a number between 1 and 1000');
74
+ }
75
+
76
+ // Validate custom categories
77
+ let categories = DEFAULT_CATEGORIES;
78
+ const categoriesInput = inputs.categories;
79
+ if (categoriesInput && categoriesInput !== 'auto') {
80
+ try {
81
+ const parsed = JSON.parse(categoriesInput);
82
+ if (!Array.isArray(parsed)) {
83
+ errors.push('categories must be a JSON array');
84
+ } else {
85
+ for (const cat of parsed) {
86
+ if (!cat.label || typeof cat.label !== 'string') {
87
+ errors.push('Each category must have a "label" string property');
88
+ break;
89
+ }
90
+ if (!Array.isArray(cat.patterns) || cat.patterns.length === 0) {
91
+ errors.push('Each category must have a "patterns" array with at least one pattern');
92
+ break;
93
+ }
94
+ }
95
+ if (errors.length === 0 || !errors.some(e => e.includes('categories'))) {
96
+ categories = parsed;
97
+ }
98
+ }
99
+ } catch (e) {
100
+ errors.push(`Invalid categories JSON: ${e.message}`);
101
+ }
102
+ }
103
+
104
+ // Validate Twitter credentials — if any provided, all four are required
105
+ const twitterKeys = [
106
+ inputs['twitter-consumer-key'],
107
+ inputs['twitter-consumer-secret'],
108
+ inputs['twitter-access-token'],
109
+ inputs['twitter-access-secret'],
110
+ ];
111
+ const providedTwitterKeys = twitterKeys.filter(k => k && k.trim() !== '');
112
+ if (providedTwitterKeys.length > 0 && providedTwitterKeys.length < 4) {
113
+ errors.push(
114
+ 'If any Twitter credential is provided, all four are required: twitter-consumer-key, twitter-consumer-secret, twitter-access-token, twitter-access-secret'
115
+ );
116
+ }
117
+
118
+ if (errors.length > 0) {
119
+ throw new ConfigError('Configuration errors:\n' + errors.map(e => ` - ${e}`).join('\n'));
120
+ }
121
+
122
+ const config = {
123
+ githubToken: inputs['github-token'],
124
+ apiKey: inputs['api-key'] || '',
125
+ apiBase: inputs['api-base'] || 'https://api.openai.com/v1',
126
+ model: inputs.model || 'gpt-4o-mini',
127
+ template: template,
128
+ commitMode: commitMode,
129
+ versionFrom: versionFrom,
130
+ version: inputs.version || '',
131
+ previousTag: inputs['previous-tag'] || '',
132
+ includeBreaking: (inputs['include-breaking'] || 'true') === 'true',
133
+ includeContributors: (inputs['include-contributors'] || 'true') === 'true',
134
+ includeDiffStats: (inputs['include-diff-stats'] || 'true') === 'true',
135
+ includeScreenshots: (inputs['include-screenshots'] || 'true') === 'true',
136
+ categories: categories,
137
+ maxCommits: maxCommits,
138
+ language: language,
139
+ dryRun: (inputs['dry-run'] || 'false') === 'true',
140
+ // Integrations
141
+ slackWebhook: inputs['slack-webhook'] || '',
142
+ discordWebhook: inputs['discord-webhook'] || '',
143
+ twitterConsumerKey: inputs['twitter-consumer-key'] || '',
144
+ twitterConsumerSecret: inputs['twitter-consumer-secret'] || '',
145
+ twitterAccessToken: inputs['twitter-access-token'] || '',
146
+ twitterAccessSecret: inputs['twitter-access-secret'] || '',
147
+ // Changelog
148
+ updateChangelog: (inputs['update-changelog'] || 'false') === 'true',
149
+ changelogPath: inputs['changelog-path'] || 'CHANGELOG.md',
150
+ // Derived
151
+ hasTwitter: providedTwitterKeys.length === 4,
152
+ hasSlack: !!(inputs['slack-webhook'] && inputs['slack-webhook'].trim()),
153
+ hasDiscord: !!(inputs['discord-webhook'] && inputs['discord-webhook'].trim()),
154
+ hasAI: !!(inputs['api-key'] && inputs['api-key'].trim()),
155
+ };
156
+
157
+ logger.info(`Configuration loaded: template=${config.template}, mode=${config.commitMode}, version-from=${config.versionFrom}, language=${config.language}, ai=${config.hasAI}`);
158
+
159
+ return Object.freeze(config);
160
+ }
161
+
162
+ module.exports = { createConfig, ConfigError };