@logickernel/agileflow 0.1.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/src/utils.js ADDED
@@ -0,0 +1,376 @@
1
+ 'use strict';
2
+
3
+ const { runWithOutput, ensureGitRepo } = require('./git-utils');
4
+
5
+ // Conventional commit type configuration
6
+ const TYPE_ORDER = ['feat', 'fix', 'perf', 'refactor', 'style', 'test', 'docs', 'build', 'ci', 'chore', 'revert'];
7
+ const PATCH_TYPES = ['fix', 'perf', 'refactor', 'test', 'build', 'ci', 'revert'];
8
+ const SEMVER_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(-[a-zA-Z0-9.-]+)?$/;
9
+
10
+ /**
11
+ * Fetches tags from remote (non-destructive) if a remote is configured.
12
+ * This is a safe operation that doesn't modify the working directory.
13
+ * @returns {boolean} True if tags were fetched, false if using local tags only
14
+ */
15
+ function fetchTagsLocally() {
16
+ try {
17
+ const remotes = runWithOutput('git remote').trim();
18
+ if (!remotes) {
19
+ return false;
20
+ }
21
+ runWithOutput('git fetch --tags --prune --prune-tags');
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Validates that the current branch is the expected name.
30
+ * @param {string} expectedName - The expected name of the branch
31
+ * @throws {Error} If the current branch is not the expected name or in detached HEAD state
32
+ */
33
+ function validateBranchName(expectedName) {
34
+ const branch = runWithOutput('git branch --show-current').trim();
35
+ if (!branch) {
36
+ throw new Error('Repository is in a detached HEAD state. Please check out a branch and try again.');
37
+ }
38
+ if (branch !== expectedName) {
39
+ throw new Error(`Current branch is "${branch}", not "${expectedName}". Switch to ${expectedName} or use --branch ${branch}.`);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Gets all tags pointing to a specific commit.
45
+ * @param {string} commitSha - The commit SHA to check for tags
46
+ * @returns {Array<string>} Array of tag names, empty array if none
47
+ */
48
+ function getTagsForCommit(commitSha) {
49
+ try {
50
+ const output = runWithOutput(`git tag --points-at ${commitSha}`).trim();
51
+ return output ? output.split('\n').map(t => t.trim()).filter(Boolean) : [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Parses a conventional commit message.
59
+ * @param {string} message - The commit message to parse
60
+ * @returns {{type: string, breaking: boolean, scope: string, description: string}|null} Parsed commit or null if not conventional
61
+ */
62
+ function parseConventionalCommit(message) {
63
+ if (!message) return null;
64
+
65
+ // Get the first line (subject) of the commit message
66
+ const subject = message.split('\n')[0].trim();
67
+
68
+ // Conventional commit pattern: type[!]?(scope)?: description
69
+ const match = subject.match(/^(\w+)(!)?(?:\(([^)]+)\))?:\s+(.+)$/);
70
+ if (!match) return null;
71
+
72
+ return {
73
+ type: match[1].toLowerCase(),
74
+ breaking: Boolean(match[2]),
75
+ scope: match[3] ? String(match[3]).trim() : '',
76
+ description: String(match[4]).trim(),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Expands commit information by finding the latest version and filtering commits.
82
+ * Filters out commits that are older than the latest commit with a semver tag.
83
+ * Commits are expected to be ordered from newest to oldest.
84
+ * @param {Array<{hash: string, datetime: string, author: string, message: string, tags: Array<string>}>} commits - Array of commit objects
85
+ * @returns {{latestVersion: string|null, commits: Array}} Object with latestVersion and filtered commits array
86
+ */
87
+ function expandCommitInfo(commits) {
88
+ if (!commits?.length) {
89
+ return { latestVersion: null, commits: [] };
90
+ }
91
+
92
+ // Find the first commit (newest) that has a semver tag
93
+ const taggedIndex = commits.findIndex(commit =>
94
+ commit.tags?.some(tag => SEMVER_PATTERN.test(tag))
95
+ );
96
+
97
+ if (taggedIndex === -1) {
98
+ return { latestVersion: null, commits };
99
+ }
100
+
101
+ const latestVersion = commits[taggedIndex].tags.find(tag => SEMVER_PATTERN.test(tag));
102
+ return {
103
+ latestVersion,
104
+ commits: commits.slice(0, taggedIndex + 1),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Extracts issue reference from commit message (e.g., #123 or (#123)).
110
+ * @param {string} message - The commit message
111
+ * @returns {string|null} Issue reference like "(#123)" or null if not found
112
+ */
113
+ function extractIssueReference(message) {
114
+ const match = message?.match(/\(?#(\d+)\)?/);
115
+ return match ? `(#${match[1]})` : null;
116
+ }
117
+
118
+ /**
119
+ * Formats the first line of commit message for changelog, removing the type prefix.
120
+ * @param {string} subject - The first line of the commit message
121
+ * @param {Object} parsed - Parsed conventional commit info
122
+ * @param {string} fullMessage - Full commit message to check for BREAKING CHANGE:
123
+ * @returns {string} Formatted description
124
+ */
125
+ function formatChangelogDescription(subject, parsed, fullMessage) {
126
+ if (!parsed) {
127
+ // Not a conventional commit, use subject as-is
128
+ return subject;
129
+ }
130
+
131
+ let description = parsed.description;
132
+
133
+ // Add BREAKING prefix if it's a breaking change (check both ! and BREAKING CHANGE:)
134
+ const isBreaking = parsed.breaking || /BREAKING CHANGE:/i.test(fullMessage);
135
+ if (isBreaking) {
136
+ description = `BREAKING: ${description}`;
137
+ }
138
+
139
+ return description;
140
+ }
141
+
142
+ /**
143
+ * Parses a semver version string into its components.
144
+ * @param {string|null} version - Version string like "v1.2.3" or "v1.2.3-beta"
145
+ * @returns {{major: number, minor: number, patch: number}} Parsed version or {0,0,0} if invalid
146
+ */
147
+ function parseVersion(version) {
148
+ if (!version) return { major: 0, minor: 0, patch: 0 };
149
+ const match = version.match(SEMVER_PATTERN);
150
+ if (!match) return { major: 0, minor: 0, patch: 0 };
151
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
152
+ }
153
+
154
+ /**
155
+ * Determines the version bump type based on commit analysis.
156
+ * @param {{hasBreaking: boolean, hasFeat: boolean, hasPatchTypes: boolean}} analysis - Commit analysis
157
+ * @param {boolean} isPreOneZero - Whether current version is 0.x.x
158
+ * @returns {'major'|'minor'|'patch'|'none'} The version bump type
159
+ */
160
+ function determineVersionBumpType(analysis, isPreOneZero) {
161
+ const { hasBreaking, hasFeat, hasPatchTypes } = analysis;
162
+
163
+ if (isPreOneZero) {
164
+ if (hasBreaking || hasFeat) return 'minor';
165
+ if (hasPatchTypes) return 'patch';
166
+ } else {
167
+ if (hasBreaking) return 'major';
168
+ if (hasFeat) return 'minor';
169
+ if (hasPatchTypes) return 'patch';
170
+ }
171
+ return 'none';
172
+ }
173
+
174
+ /**
175
+ * Applies a version bump to the current version.
176
+ * @param {{major: number, minor: number, patch: number}} current - Current version
177
+ * @param {'major'|'minor'|'patch'|'none'} bump - Bump type
178
+ * @returns {string} Next version string like "v1.2.3"
179
+ */
180
+ function applyVersionBump(current, bump) {
181
+ let { major, minor, patch } = current;
182
+ switch (bump) {
183
+ case 'major': return `v${major + 1}.0.0`;
184
+ case 'minor': return `v${major}.${minor + 1}.0`;
185
+ case 'patch': return `v${major}.${minor}.${patch + 1}`;
186
+ default: return `v${major}.${minor}.${patch}`;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Analyzes commits to determine version bump requirements.
192
+ * @param {Array} commits - Array of commit objects
193
+ * @returns {{hasBreaking: boolean, hasFeat: boolean, hasPatchTypes: boolean, commitsByType: Object}}
194
+ */
195
+ function analyzeCommitsForVersioning(commits) {
196
+ const commitsByType = Object.fromEntries(TYPE_ORDER.map(t => [t, []]));
197
+ let hasBreaking = false, hasFeat = false, hasPatchTypes = false;
198
+
199
+ for (const commit of commits) {
200
+ const parsed = parseConventionalCommit(commit.message);
201
+ if (!parsed) continue;
202
+
203
+ const { type, breaking } = parsed;
204
+ const isBreaking = breaking || /BREAKING CHANGE:/i.test(commit.message);
205
+
206
+ if (isBreaking) hasBreaking = true;
207
+ if (type === 'feat') hasFeat = true;
208
+ else if (PATCH_TYPES.includes(type)) hasPatchTypes = true;
209
+
210
+ if (commitsByType[type]) {
211
+ commitsByType[type].push(commit);
212
+ }
213
+ }
214
+
215
+ return { hasBreaking, hasFeat, hasPatchTypes, commitsByType };
216
+ }
217
+
218
+ /**
219
+ * Generates changelog entries for a commit type section.
220
+ * @param {Array} commits - Commits of this type
221
+ * @returns {Array<string>} Changelog lines
222
+ */
223
+ function generateTypeChangelog(commits) {
224
+ const byScope = {};
225
+ const noScope = [];
226
+
227
+ for (const commit of commits) {
228
+ const parsed = parseConventionalCommit(commit.message);
229
+ if (!parsed) continue;
230
+
231
+ const subject = commit.message.split('\n')[0].trim();
232
+ const entry = {
233
+ scope: parsed.scope,
234
+ description: formatChangelogDescription(subject, parsed, commit.message),
235
+ issueRef: extractIssueReference(commit.message) || '',
236
+ };
237
+
238
+ if (parsed.scope) {
239
+ (byScope[parsed.scope] ??= []).push(entry);
240
+ } else {
241
+ noScope.push(entry);
242
+ }
243
+ }
244
+
245
+ const lines = [];
246
+ for (const entry of noScope) {
247
+ lines.push(`- ${entry.description}${entry.issueRef}`);
248
+ }
249
+ for (const scope of Object.keys(byScope).sort()) {
250
+ for (const entry of byScope[scope]) {
251
+ lines.push(`- **${scope}**: ${entry.description}${entry.issueRef}`);
252
+ }
253
+ }
254
+ return lines;
255
+ }
256
+
257
+ /**
258
+ * Calculates the next version and generates a changelog from expanded commit info.
259
+ * @param {{latestVersion: string|null, commits: Array}} expandedInfo - Output from expandCommitInfo
260
+ * @returns {{nextVersion: string, changelog: string}} Object with nextVersion and changelog
261
+ */
262
+ function calculateNextVersionAndChangelog(expandedInfo) {
263
+ const { latestVersion, commits } = expandedInfo;
264
+ const current = parseVersion(latestVersion);
265
+ const analysis = analyzeCommitsForVersioning(commits);
266
+
267
+ const bump = determineVersionBumpType(analysis, current.major === 0);
268
+ const nextVersion = applyVersionBump(current, bump);
269
+
270
+ // Generate changelog
271
+ const changelogLines = [];
272
+ for (const type of TYPE_ORDER) {
273
+ const typeCommits = analysis.commitsByType[type];
274
+ if (!typeCommits?.length) continue;
275
+
276
+ changelogLines.push(`### ${type}`);
277
+ changelogLines.push(...generateTypeChangelog(typeCommits));
278
+ changelogLines.push('');
279
+ }
280
+
281
+ // Remove trailing empty line
282
+ if (changelogLines.at(-1) === '') {
283
+ changelogLines.pop();
284
+ }
285
+
286
+ return { nextVersion, changelog: changelogLines.join('\n') };
287
+ }
288
+
289
+ /**
290
+ * Processes version information for a branch, returning version details and changelog.
291
+ * @param {string} branch - The branch to process
292
+ * @returns {Promise<{previousVersion: string|null, nextVersion: string, commits: Array, conventionalCommits: Object, changelog: string}>} Promise resolving to version info
293
+ */
294
+ async function processVersionInfo(branch) {
295
+ ensureGitRepo();
296
+ validateBranchName(branch);
297
+ fetchTagsLocally();
298
+
299
+ const allCommits = getAllBranchCommits(branch);
300
+ const expandedInfo = expandCommitInfo(allCommits);
301
+ const { latestVersion, commits } = expandedInfo;
302
+
303
+ // Group commits by conventional type
304
+ const conventionalCommits = Object.fromEntries(TYPE_ORDER.map(t => [t, []]));
305
+ for (const commit of commits) {
306
+ const parsed = parseConventionalCommit(commit.message);
307
+ if (parsed && conventionalCommits[parsed.type]) {
308
+ conventionalCommits[parsed.type].push({ ...commit, conventional: parsed });
309
+ }
310
+ }
311
+
312
+ const { nextVersion, changelog } = calculateNextVersionAndChangelog(expandedInfo);
313
+
314
+ return {
315
+ previousVersion: latestVersion,
316
+ nextVersion,
317
+ commits,
318
+ conventionalCommits,
319
+ changelog,
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Retrieves all commits in the history of the specified branch, including merged commits.
325
+ * Returns a simplified array with commit hash, datetime, author, commit message, and tags.
326
+ * @param {string} branch - The branch to get commits from
327
+ * @returns {Array<{hash: string, datetime: string, author: string, message: string, tags: Array<string>}>} Array of commit objects
328
+ */
329
+ function getAllBranchCommits(branch) {
330
+ try {
331
+ // Verify the branch exists
332
+ runWithOutput(`git rev-parse --verify ${branch}`);
333
+ } catch {
334
+ return [];
335
+ }
336
+
337
+ const RS = '\x1E'; // Record Separator
338
+ const COMMIT_SEP = `${RS}${RS}`;
339
+
340
+ try {
341
+ const logCmd = `git log --format=%H${RS}%ai${RS}%an${RS}%B${COMMIT_SEP} ${branch}`;
342
+ const output = runWithOutput(logCmd).trim();
343
+ if (!output) return [];
344
+
345
+ return output
346
+ .split(COMMIT_SEP)
347
+ .filter(block => block.trim())
348
+ .map(block => {
349
+ const parts = block.split(RS);
350
+ if (parts.length < 4) return null;
351
+ const hash = parts[0].trim();
352
+ return {
353
+ hash,
354
+ datetime: parts[1].trim(),
355
+ author: parts[2].trim(),
356
+ message: parts.slice(3).join(RS).trim(),
357
+ tags: getTagsForCommit(hash),
358
+ };
359
+ })
360
+ .filter(Boolean);
361
+ } catch {
362
+ return [];
363
+ }
364
+ }
365
+
366
+ module.exports = {
367
+ ensureGitRepo,
368
+ fetchTagsLocally,
369
+ validateBranchName,
370
+ getAllBranchCommits,
371
+ expandCommitInfo,
372
+ calculateNextVersionAndChangelog,
373
+ processVersionInfo,
374
+ parseConventionalCommit,
375
+ };
376
+
@@ -0,0 +1,66 @@
1
+ # AgileFlow GitLab CI Template
2
+ #
3
+ # This template provides a simplified, version-centric CI/CD pipeline that focuses on
4
+ # version management rather than environment-based deployments. The pipeline consists
5
+ # of 6 stages that work together to create a streamlined release process.
6
+ #
7
+ # Key Benefits:
8
+ # - Version-centric approach: All deployments, tests, and operations are performed
9
+ # on well-identified versions rather than branch-based environments
10
+ # - Simplified stages: Clear separation of concerns with minimal complexity
11
+ # - Automated versioning: Integrates with AgileFlow to automatically generate
12
+ # semantic versions based on commit history
13
+ # - Consistent deployments: All environments use the same version artifacts
14
+ #
15
+ # Stages Overview:
16
+ # 1. version - Generate semantic version and release notes
17
+ # 2. test - Run tests against source code before building
18
+ # 3. build - Build application artifacts and Docker images
19
+ # 4. deploy - Deploy to various environments using the generated version
20
+ # 5. e2e - Run end-to-end tests against deployed versions
21
+ # 6. clean - Cleanup temporary resources and artifacts
22
+ #
23
+ # Usage:
24
+ # Include this template in your .gitlab-ci.yml:
25
+ # include:
26
+ # - local: templates/AgileFlow.gitlab-ci.yml
27
+ #
28
+ # The agileflow job will automatically:
29
+ # - Calculate the next semantic version based on commit history
30
+ # - Generate comprehensive release notes from conventional commits
31
+ # - Create and push version tags to the repository
32
+ # - Make the VERSION variable available to subsequent stages
33
+
34
+ stages:
35
+ - version # Calculate the next semantic version based on commit history,
36
+ # if there's a tag, it will use that as the version.
37
+
38
+ # Although the version is available in the $VERSION variable in all scenarios,
39
+ # the rest of the stages are recommended to run when there's a version tag pushed.
40
+ # This way the deployment process is version-centric.
41
+ - test # Run tests against source code before building.
42
+ - build # Build the image, and push to the registry.
43
+ - deploy # Automatically deploy to DEV, manual deploy to STG and to PROD.
44
+ - e2e # Run end-to-end tests against the deployed version.
45
+ - clean # Cleanup temporary resources and artifacts.
46
+
47
+ # Example:
48
+ # Run the job only when there's a version tag pushed. The version tags starts with v and semver.
49
+ #
50
+ # stage: build
51
+ # rules:
52
+ # - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/
53
+
54
+ # Version Generation Stage
55
+ # This stage uses the AgileFlow tool to automatically generate semantic versions
56
+ # and release notes based on the main branch's commit history. The VERSION
57
+ # variable is made available as an artifact for use in subsequent stages.
58
+ agileflow:
59
+ stage: version
60
+ image: registry.logickernel.com/kernel/agileflow:0.9.0
61
+ only:
62
+ - main
63
+ script:
64
+ - agileflow gitlab-ci
65
+ tags:
66
+ - agileflow
@@ -0,0 +1,64 @@
1
+ # AgileFlow GitLab CI Template
2
+ #
3
+ # This template provides a simplified, version-centric CI/CD pipeline that focuses on
4
+ # version management rather than environment-based deployments. The pipeline consists
5
+ # of 6 stages that work together to create a streamlined release process.
6
+ #
7
+ # Key Benefits:
8
+ # - Version-centric approach: All deployments, tests, and operations are performed
9
+ # on well-identified versions rather than branch-based environments
10
+ # - Simplified stages: Clear separation of concerns with minimal complexity
11
+ # - Automated versioning: Integrates with AgileFlow to automatically generate
12
+ # semantic versions based on commit history
13
+ # - Consistent deployments: All environments use the same version artifacts
14
+ #
15
+ # Stages Overview:
16
+ # 1. version - Generate semantic version and release notes
17
+ # 2. test - Run tests against source code before building
18
+ # 3. build - Build application artifacts and Docker images
19
+ # 4. deploy - Deploy to various environments using the generated version
20
+ # 5. e2e - Run end-to-end tests against deployed versions
21
+ # 6. clean - Cleanup temporary resources and artifacts
22
+ #
23
+ # Usage:
24
+ # Include this template in your .gitlab-ci.yml:
25
+ # include:
26
+ # - local: templates/AgileFlow.gitlab-ci.yml
27
+ #
28
+ # The agileflow job will automatically:
29
+ # - Calculate the next semantic version based on commit history
30
+ # - Generate comprehensive release notes from conventional commits
31
+ # - Create and push version tags to the repository
32
+ # - Make the VERSION variable available to subsequent stages
33
+
34
+ stages:
35
+ - version # Calculate the next semantic version based on commit history,
36
+ # if there's a tag, it will use that as the version.
37
+
38
+ # Although the version is available in the $VERSION variable in all scenarios,
39
+ # the rest of the stages are recommended to run when there's a version tag pushed.
40
+ # This way the deployment process is version-centric.
41
+ - test # Run tests against source code before building.
42
+ - build # Build the image, and push to the registry.
43
+ - deploy # Automatically deploy to DEV, manual deploy to STG and to PROD.
44
+ - e2e # Run end-to-end tests against the deployed version.
45
+ - clean # Cleanup temporary resources and artifacts.
46
+
47
+ # Example:
48
+ # Run the job only when there's a version tag pushed. The version tags starts with v and semver.
49
+ #
50
+ # stage: build
51
+ # rules:
52
+ # - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/
53
+
54
+ # Version Generation Stage
55
+ # This stage uses the AgileFlow tool to automatically generate semantic versions
56
+ # and release notes based on the main branch's commit history. The VERSION
57
+ # variable is made available as an artifact for use in subsequent stages.
58
+ agileflow:
59
+ stage: version
60
+ image: registry.logickernel.com/kernel/agileflow:0.9.0
61
+ only:
62
+ - main
63
+ script:
64
+ - agileflow gitlab-ci