@logickernel/agileflow 0.2.1 → 0.4.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 (3) hide show
  1. package/package.json +2 -2
  2. package/src/index.js +68 -23
  3. package/src/utils.js +78 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logickernel/agileflow",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Automatic semantic versioning and changelog generation based on conventional commits",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -34,6 +34,6 @@
34
34
  "type": "git",
35
35
  "url": "git@code.logickernel.com:kernel/agileflow.git"
36
36
  },
37
- "author": "",
37
+ "author": "Víctor H. Valle <victor.valle@logickernel.com>",
38
38
  "license": "ISC"
39
39
  }
package/src/index.js CHANGED
@@ -12,9 +12,9 @@ Usage:
12
12
 
13
13
  Commands:
14
14
  <none> Prints the current version, next version, commits, and changelog
15
- push Push a semantic version tag to the remote repository (native git)
16
- gitlab Push a semantic version tag via GitLab API (for GitLab CI)
17
- github Push a semantic version tag via GitHub API (for GitHub Actions)
15
+ push Push a semantic version tag to the remote repository
16
+ gitlab Create a semantic version tag via GitLab API (for GitLab CI)
17
+ github Create a semantic version tag via GitHub API (for GitHub Actions)
18
18
 
19
19
  Options:
20
20
  --quiet Only output the next version (or empty if no bump)
@@ -26,11 +26,32 @@ For more information, visit: https://code.logickernel.com/tools/agileflow
26
26
  }
27
27
 
28
28
  /**
29
- * Parses command line arguments.
29
+ * Valid options that can be passed to commands.
30
+ */
31
+ const VALID_OPTIONS = ['--quiet', '--help', '-h', '--version', '-v'];
32
+
33
+ /**
34
+ * Valid commands.
35
+ */
36
+ const VALID_COMMANDS = ['push', 'gitlab', 'github'];
37
+
38
+ /**
39
+ * Parses command line arguments and validates them.
30
40
  * @param {Array<string>} args - Command line arguments
31
41
  * @returns {{quiet: boolean}}
42
+ * @throws {Error} If invalid options are found
32
43
  */
33
44
  function parseArgs(args) {
45
+ // Check for invalid options
46
+ for (const arg of args) {
47
+ if (arg.startsWith('--') && !VALID_OPTIONS.includes(arg)) {
48
+ throw new Error(`Unknown option: ${arg}`);
49
+ }
50
+ if (arg.startsWith('-') && !arg.startsWith('--') && !VALID_OPTIONS.includes(arg)) {
51
+ throw new Error(`Unknown option: ${arg}`);
52
+ }
53
+ }
54
+
34
55
  return {
35
56
  quiet: args.includes('--quiet'),
36
57
  };
@@ -38,24 +59,32 @@ function parseArgs(args) {
38
59
 
39
60
  /**
40
61
  * Displays version info to the console.
41
- * @param {{currentVersion: string|null, nextVersion: string|null, commits: Array, changelog: string}} info
42
- * @param {boolean} quiet - Only output the next version
62
+ * @param {{currentVersion: string|null, newVersion: string|null, commits: Array, changelog: string}} info
63
+ * @param {boolean} quiet - Only output the new version
43
64
  */
44
65
  function displayVersionInfo(info, quiet) {
45
- const { currentVersion, nextVersion, commits, changelog } = info;
66
+ const { currentVersion, newVersion, commits, changelog } = info;
46
67
 
47
68
  if (quiet) {
48
- if (nextVersion) {
49
- console.log(nextVersion);
69
+ if (newVersion) {
70
+ console.log(newVersion);
50
71
  }
51
72
  return;
52
73
  }
53
74
 
54
- console.log(`Current version: ${currentVersion || 'none'}`);
55
- console.log(`Next version: ${nextVersion || 'no bump needed'}`);
56
- console.log(`Commits since current version: ${commits.length}`);
75
+
76
+ // List commits
77
+ console.log(`Commits since current version (${commits.length}):`);
78
+ for (const commit of commits) {
79
+ const subject = commit.message.split('\n')[0].trim();
80
+ const shortHash = commit.hash.substring(0, 7);
81
+ console.log(` ${shortHash} ${subject}`);
82
+ }
83
+
84
+ console.log(`\nCurrent version: ${currentVersion || 'none'}`);
85
+ console.log(`New version: ${newVersion || 'no bump needed'}`);
57
86
  if (changelog) {
58
- console.log(`\nChangelog:\n${changelog}`);
87
+ console.log(`\nChangelog:\n\n${changelog}`);
59
88
  }
60
89
  }
61
90
 
@@ -71,10 +100,7 @@ async function handlePushCommand(pushType, options) {
71
100
  displayVersionInfo(info, options.quiet);
72
101
 
73
102
  // Skip push if no version bump needed
74
- if (!info.nextVersion) {
75
- if (!options.quiet) {
76
- console.log('\nNo version bump needed. Skipping tag creation.');
77
- }
103
+ if (!info.newVersion) {
78
104
  return;
79
105
  }
80
106
 
@@ -93,22 +119,32 @@ async function handlePushCommand(pushType, options) {
93
119
  }
94
120
 
95
121
  // Create tag message from changelog
96
- const tagMessage = info.changelog || info.nextVersion;
122
+ const tagMessage = info.changelog || info.newVersion;
97
123
 
98
124
  if (!options.quiet) {
99
- console.log(`\nCreating tag ${info.nextVersion}...`);
125
+ console.log(`\nCreating tag ${info.newVersion}...`);
100
126
  }
101
127
 
102
- await pushModule.pushTag(info.nextVersion, tagMessage);
128
+ await pushModule.pushTag(info.newVersion, tagMessage);
103
129
 
104
130
  if (!options.quiet) {
105
- console.log(`Tag ${info.nextVersion} created and pushed successfully.`);
131
+ console.log(`Tag ${info.newVersion} created and pushed successfully.`);
106
132
  }
107
133
  }
108
134
 
109
135
  async function main() {
110
136
  const [, , cmd, ...rest] = process.argv;
111
- const options = parseArgs(cmd ? [cmd, ...rest] : rest);
137
+
138
+ let options;
139
+ try {
140
+ options = parseArgs(cmd ? [cmd, ...rest] : rest);
141
+ } catch (err) {
142
+ console.error(`Error: ${err.message}`);
143
+ console.error();
144
+ printHelp();
145
+ process.exit(1);
146
+ return;
147
+ }
112
148
 
113
149
  // Handle help
114
150
  if (cmd === '-h' || cmd === '--help' || cmd === 'help') {
@@ -129,13 +165,22 @@ async function main() {
129
165
  }
130
166
 
131
167
  // Unknown command (not an option)
132
- if (cmd && !cmd.startsWith('--')) {
168
+ if (cmd && !cmd.startsWith('--') && !cmd.startsWith('-')) {
133
169
  console.error(`Error: Unknown command "${cmd}"`);
134
170
  console.error();
135
171
  printHelp();
136
172
  process.exit(1);
137
173
  }
138
174
 
175
+ // Invalid option (starts with -- but not valid)
176
+ if (cmd && cmd.startsWith('--') && !VALID_OPTIONS.includes(cmd)) {
177
+ console.error(`Error: Unknown option "${cmd}"`);
178
+ console.error();
179
+ printHelp();
180
+ process.exit(1);
181
+ return;
182
+ }
183
+
139
184
  // Default: show version info
140
185
  const info = await processVersionInfo();
141
186
  displayVersionInfo(info, options.quiet);
package/src/utils.js CHANGED
@@ -67,6 +67,21 @@ const TYPE_ORDER = ['feat', 'fix', 'perf', 'refactor', 'style', 'test', 'docs',
67
67
  const PATCH_TYPES = ['fix', 'perf', 'refactor', 'test', 'build', 'ci', 'revert'];
68
68
  const SEMVER_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(-[a-zA-Z0-9.-]+)?$/;
69
69
 
70
+ // Friendly header names for changelog
71
+ const TYPE_HEADERS = {
72
+ feat: 'Features:',
73
+ fix: 'Fixes:',
74
+ perf: 'Performance:',
75
+ refactor: 'Refactors:',
76
+ style: 'Style:',
77
+ test: 'Tests:',
78
+ docs: 'Documentation:',
79
+ build: 'Build:',
80
+ ci: 'CI:',
81
+ chore: 'Chores:',
82
+ revert: 'Reverts:',
83
+ };
84
+
70
85
  /**
71
86
  * Fetches tags from remote (non-destructive) if a remote is configured.
72
87
  * @returns {boolean} True if tags were fetched, false if using local tags only
@@ -155,13 +170,16 @@ function extractIssueReference(message) {
155
170
  * @param {string} subject - First line of commit message
156
171
  * @param {Object} parsed - Parsed conventional commit info
157
172
  * @param {string} fullMessage - Full commit message
173
+ * @param {boolean} isBreakingSection - Whether this is for the breaking changes section
158
174
  * @returns {string} Formatted description
159
175
  */
160
- function formatChangelogDescription(subject, parsed, fullMessage) {
176
+ function formatChangelogDescription(subject, parsed, fullMessage, isBreakingSection = false) {
161
177
  if (!parsed) return subject;
162
178
  let description = parsed.description;
163
179
  const isBreaking = parsed.breaking || /BREAKING CHANGE:/i.test(fullMessage);
164
- if (isBreaking) {
180
+
181
+ // Only add BREAKING prefix if not in breaking changes section
182
+ if (isBreaking && !isBreakingSection) {
165
183
  description = `BREAKING: ${description}`;
166
184
  }
167
185
  return description;
@@ -214,13 +232,25 @@ function applyVersionBump(current, bump) {
214
232
  }
215
233
  }
216
234
 
235
+ /**
236
+ * Checks if a commit is a breaking change.
237
+ * @param {Object} commit - Commit object
238
+ * @param {Object} parsed - Parsed conventional commit info
239
+ * @returns {boolean}
240
+ */
241
+ function isBreakingChange(commit, parsed) {
242
+ if (!parsed) return false;
243
+ return parsed.breaking || /BREAKING CHANGE:/i.test(commit.message);
244
+ }
245
+
217
246
  /**
218
247
  * Analyzes commits to determine version bump requirements.
219
248
  * @param {Array} commits - Array of commit objects
220
- * @returns {{hasBreaking: boolean, hasFeat: boolean, hasPatchTypes: boolean, commitsByType: Object}}
249
+ * @returns {{hasBreaking: boolean, hasFeat: boolean, hasPatchTypes: boolean, commitsByType: Object, breakingCommits: Array}}
221
250
  */
222
251
  function analyzeCommitsForVersioning(commits) {
223
252
  const commitsByType = Object.fromEntries(TYPE_ORDER.map(t => [t, []]));
253
+ const breakingCommits = [];
224
254
  let hasBreaking = false, hasFeat = false, hasPatchTypes = false;
225
255
 
226
256
  for (const commit of commits) {
@@ -228,26 +258,42 @@ function analyzeCommitsForVersioning(commits) {
228
258
  if (!parsed) continue;
229
259
 
230
260
  const { type, breaking } = parsed;
231
- const isBreaking = breaking || /BREAKING CHANGE:/i.test(commit.message);
232
-
233
- if (isBreaking) hasBreaking = true;
234
- if (type === 'feat') hasFeat = true;
235
- else if (PATCH_TYPES.includes(type)) hasPatchTypes = true;
261
+ const isBreaking = isBreakingChange(commit, parsed);
236
262
 
237
- if (commitsByType[type]) {
238
- commitsByType[type].push(commit);
263
+ if (isBreaking) {
264
+ hasBreaking = true;
265
+ breakingCommits.push(commit);
266
+ } else {
267
+ // Only add to type sections if not breaking
268
+ if (type === 'feat') hasFeat = true;
269
+ else if (PATCH_TYPES.includes(type)) hasPatchTypes = true;
270
+
271
+ if (commitsByType[type]) {
272
+ commitsByType[type].push(commit);
273
+ }
239
274
  }
240
275
  }
241
276
 
242
- return { hasBreaking, hasFeat, hasPatchTypes, commitsByType };
277
+ return { hasBreaking, hasFeat, hasPatchTypes, commitsByType, breakingCommits };
278
+ }
279
+
280
+ /**
281
+ * Capitalizes the first letter of a string.
282
+ * @param {string} str - The string to capitalize
283
+ * @returns {string} Capitalized string
284
+ */
285
+ function capitalize(str) {
286
+ if (!str) return str;
287
+ return str.charAt(0).toUpperCase() + str.slice(1);
243
288
  }
244
289
 
245
290
  /**
246
291
  * Generates changelog entries for a commit type section.
247
292
  * @param {Array} commits - Commits of this type
293
+ * @param {boolean} isBreakingSection - Whether this is for the breaking changes section
248
294
  * @returns {Array<string>} Changelog lines
249
295
  */
250
- function generateTypeChangelog(commits) {
296
+ function generateTypeChangelog(commits, isBreakingSection = false) {
251
297
  const byScope = {};
252
298
  const noScope = [];
253
299
 
@@ -258,7 +304,7 @@ function generateTypeChangelog(commits) {
258
304
  const subject = commit.message.split('\n')[0].trim();
259
305
  const entry = {
260
306
  scope: parsed.scope,
261
- description: formatChangelogDescription(subject, parsed, commit.message),
307
+ description: formatChangelogDescription(subject, parsed, commit.message, isBreakingSection),
262
308
  issueRef: extractIssueReference(commit.message) || '',
263
309
  };
264
310
 
@@ -272,21 +318,21 @@ function generateTypeChangelog(commits) {
272
318
  const lines = [];
273
319
  for (const entry of noScope) {
274
320
  const ref = entry.issueRef ? ` ${entry.issueRef}` : '';
275
- lines.push(`- ${entry.description}${ref}`);
321
+ lines.push(`- ${capitalize(entry.description)}${ref}`);
276
322
  }
277
323
  for (const scope of Object.keys(byScope).sort()) {
278
324
  for (const entry of byScope[scope]) {
279
325
  const ref = entry.issueRef ? ` ${entry.issueRef}` : '';
280
- lines.push(`- **${scope}**: ${entry.description}${ref}`);
326
+ lines.push(`- ${scope}: ${capitalize(entry.description)}${ref}`);
281
327
  }
282
328
  }
283
329
  return lines;
284
330
  }
285
331
 
286
332
  /**
287
- * Calculates the next version and generates a changelog.
333
+ * Calculates the new version and generates a changelog.
288
334
  * @param {{latestVersion: string|null, commits: Array}} expandedInfo
289
- * @returns {{nextVersion: string|null, changelog: string}}
335
+ * @returns {{newVersion: string|null, changelog: string}}
290
336
  */
291
337
  function calculateNextVersionAndChangelog(expandedInfo) {
292
338
  const { latestVersion, commits } = expandedInfo;
@@ -294,15 +340,24 @@ function calculateNextVersionAndChangelog(expandedInfo) {
294
340
  const analysis = analyzeCommitsForVersioning(commits);
295
341
 
296
342
  const bump = determineVersionBumpType(analysis, current.major === 0);
297
- const nextVersion = applyVersionBump(current, bump);
343
+ const newVersion = applyVersionBump(current, bump);
298
344
 
299
345
  // Generate changelog
300
346
  const changelogLines = [];
347
+
348
+ // Add breaking changes section first if any
349
+ if (analysis.breakingCommits.length > 0) {
350
+ changelogLines.push('BREAKING CHANGES:');
351
+ changelogLines.push(...generateTypeChangelog(analysis.breakingCommits, true));
352
+ changelogLines.push('');
353
+ }
354
+
355
+ // Add regular type sections
301
356
  for (const type of TYPE_ORDER) {
302
357
  const typeCommits = analysis.commitsByType[type];
303
358
  if (!typeCommits?.length) continue;
304
359
 
305
- changelogLines.push(`### ${type}`);
360
+ changelogLines.push(TYPE_HEADERS[type] || `${capitalize(type)}:`);
306
361
  changelogLines.push(...generateTypeChangelog(typeCommits));
307
362
  changelogLines.push('');
308
363
  }
@@ -311,7 +366,7 @@ function calculateNextVersionAndChangelog(expandedInfo) {
311
366
  changelogLines.pop();
312
367
  }
313
368
 
314
- return { nextVersion, changelog: changelogLines.join('\n') };
369
+ return { newVersion, changelog: changelogLines.join('\n') };
315
370
  }
316
371
 
317
372
  /**
@@ -357,7 +412,7 @@ function getAllBranchCommits(branch) {
357
412
 
358
413
  /**
359
414
  * Processes version information for the current branch.
360
- * @returns {Promise<{currentVersion: string|null, nextVersion: string|null, commits: Array, changelog: string}>}
415
+ * @returns {Promise<{currentVersion: string|null, newVersion: string|null, commits: Array, changelog: string}>}
361
416
  */
362
417
  async function processVersionInfo() {
363
418
  ensureGitRepo();
@@ -367,11 +422,11 @@ async function processVersionInfo() {
367
422
  const allCommits = getAllBranchCommits(branch);
368
423
  const expandedInfo = expandCommitInfo(allCommits);
369
424
  const { latestVersion, commits } = expandedInfo;
370
- const { nextVersion, changelog } = calculateNextVersionAndChangelog(expandedInfo);
425
+ const { newVersion, changelog } = calculateNextVersionAndChangelog(expandedInfo);
371
426
 
372
427
  return {
373
428
  currentVersion: latestVersion,
374
- nextVersion,
429
+ newVersion,
375
430
  commits,
376
431
  changelog,
377
432
  };