@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.
- package/LICENSE +21 -0
- package/README.md +1493 -0
- package/__tests__/analyzer.test.js +63 -0
- package/__tests__/categorizer.test.js +93 -0
- package/__tests__/config.test.js +92 -0
- package/__tests__/formatter.test.js +63 -0
- package/__tests__/formatters.test.js +394 -0
- package/__tests__/migration.test.js +322 -0
- package/__tests__/semver.test.js +94 -0
- package/__tests__/tones.test.js +252 -0
- package/action.yml +113 -0
- package/index.js +73 -0
- package/jest.config.js +10 -0
- package/package.json +41 -0
- package/src/ai-writer.js +108 -0
- package/src/analyzer.js +232 -0
- package/src/analyzers/migration.js +355 -0
- package/src/categorizer.js +182 -0
- package/src/config.js +162 -0
- package/src/constants.js +137 -0
- package/src/contributor.js +144 -0
- package/src/diff-analyzer.js +202 -0
- package/src/formatter.js +336 -0
- package/src/formatters/discord.js +174 -0
- package/src/formatters/html.js +195 -0
- package/src/formatters/index.js +42 -0
- package/src/formatters/markdown.js +123 -0
- package/src/formatters/slack.js +176 -0
- package/src/formatters/twitter.js +242 -0
- package/src/formatters/types.js +48 -0
- package/src/generator.js +297 -0
- package/src/integrations/changelog.js +125 -0
- package/src/integrations/discord.js +96 -0
- package/src/integrations/github-release.js +75 -0
- package/src/integrations/indexer.js +119 -0
- package/src/integrations/slack.js +112 -0
- package/src/integrations/twitter.js +128 -0
- package/src/logger.js +52 -0
- package/src/prompts.js +210 -0
- package/src/rate-limiter.js +92 -0
- package/src/semver.js +129 -0
- package/src/tones/casual.js +114 -0
- package/src/tones/humorous.js +164 -0
- package/src/tones/index.js +38 -0
- package/src/tones/professional.js +125 -0
- package/src/tones/technical.js +164 -0
- 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 };
|