@logickernel/agileflow 0.14.0 → 0.15.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/README.md CHANGED
@@ -165,12 +165,13 @@ After 1.0.0, AgileFlow continues automatic versioning with standard semantic ver
165
165
 
166
166
  AgileFlow analyzes commits since the last version tag to determine the appropriate version bump:
167
167
 
168
- | Commit Type | Example | 0.x.x | 1.0.0+ |
169
- |-------------|---------|-------|--------|
170
- | Breaking change | `feat!: redesign API` | **Minor** (0.1.0 → 0.2.0) | **Major** (1.0.0 → 2.0.0) |
171
- | Feature | `feat: add login` | **Minor** | **Minor** |
172
- | Fix | `fix: resolve crash` | **Patch** | **Patch** |
173
- | Everything else | `docs: update README` | No bump | No bump |
168
+ | Commit Type | Example | Changelog | 0.x.x | 1.0.0+ |
169
+ |-------------|---------|-----------|-------|--------|
170
+ | Breaking change | `feat!: redesign API` | Add entry | **Minor** (0.1.0 → 0.2.0) | **Major** (1.0.0 → 2.0.0) |
171
+ | Feature | `feat: add login` | Add entry | **Minor** | **Minor** |
172
+ | Fix | `fix: resolve crash` | Add entry | **Patch** | **Patch** |
173
+ | Chore | `chore: update dependencies` | **No** entry | No bump | No bump |
174
+ | Everything else | `docs: update README` | Add entry | No bump | No bump |
174
175
 
175
176
  ---
176
177
 
@@ -74,23 +74,21 @@ jobs:
74
74
  - name: Create version tag
75
75
  env:
76
76
  AGILEFLOW_TOKEN: ${{ secrets.AGILEFLOW_TOKEN }}
77
- run: npx @logickernel/agileflow github
77
+ run: agileflow github
78
78
  ```
79
79
 
80
80
  ### GitLab CI
81
81
 
82
82
  ```yaml
83
83
  agileflow:
84
- stage: version
85
- image: node:20-alpine
84
+ image: node:20
86
85
  script:
87
- - VERSION=$(npx @logickernel/agileflow gitlab --quiet)
88
- - echo "VERSION=$VERSION" >> version.env
89
- artifacts:
90
- reports:
91
- dotenv: version.env
92
- only:
93
- - main
86
+ - npm install -g @logickernel/agileflow
87
+ - agileflow gitlab
88
+ rules:
89
+ - if: '$CI_COMMIT_BRANCH == "main"'
90
+ tags:
91
+ - agileflow
94
92
  ```
95
93
 
96
94
  ---
@@ -39,10 +39,10 @@ flowchart TD
39
39
  A -- "no" --> B{Does it fix functionality?}
40
40
 
41
41
  B -- "yes" --> X[fix:]
42
- B -- "no" --> C{Is it work in progress?}
42
+ B -- "no" --> C{Is it worth an entry in the changelog?}
43
43
 
44
- C -- "yes" --> W[wip:]
45
- C -- "no" --> D[Choose best: docs, ci, style, chore, etc.]
44
+ C -- "yes" --> W[Choose best: docs, ci, style, etc.]
45
+ C -- "no" --> D[chore:]
46
46
 
47
47
  W --> E{Is it a breaking change?}
48
48
  F --> E
@@ -221,7 +221,17 @@ feat: add user authentication
221
221
  feat: added user authentication
222
222
  ```
223
223
 
224
- 5. Add Meaningful Scopes when applicable
224
+ 5. Use Chore for *work in progress*
225
+
226
+ ```bash
227
+ # ✅ Use chore so an entry is not added to the changelog
228
+ chore: add framework for form validation
229
+
230
+ # ❌ Used something else that will add a meaningless entry to the changelog
231
+ refactor: add framework for form validation
232
+ ```
233
+
234
+ 6. Add Meaningful Scopes when applicable
225
235
 
226
236
  ```bash
227
237
  # ✅ Helpful scope
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logickernel/agileflow",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Automatic semantic versioning and changelog generation based on conventional commits",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -14,9 +14,12 @@
14
14
  "README.md"
15
15
  ],
16
16
  "scripts": {
17
- "test": "echo \"Error: no test specified\" && exit 1",
17
+ "test": "jest",
18
18
  "prepack": "chmod +x bin/agileflow"
19
19
  },
20
+ "jest": {
21
+ "testEnvironment": "node"
22
+ },
20
23
  "keywords": [
21
24
  "semantic-versioning",
22
25
  "conventional-commits",
@@ -24,6 +27,7 @@
24
27
  "versioning",
25
28
  "git",
26
29
  "ci-cd",
30
+ "github",
27
31
  "gitlab",
28
32
  "automation",
29
33
  "release-management"
@@ -36,5 +40,8 @@
36
40
  "url": "git@code.logickernel.com:kernel/agileflow.git"
37
41
  },
38
42
  "author": "Víctor Valle <victor.valle@logickernel.com>",
39
- "license": "ISC"
43
+ "license": "ISC",
44
+ "devDependencies": {
45
+ "jest": "^30.2.0"
46
+ }
40
47
  }
package/src/git-push.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { execSync } = require('child_process');
4
+ const crypto = require('crypto');
4
5
  const fs = require('fs');
5
6
  const path = require('path');
6
7
  const os = require('os');
@@ -10,13 +11,14 @@ const os = require('os');
10
11
  * Uses native git commands - requires git credentials to be configured.
11
12
  * @param {string} tagName - The tag name (e.g., "v1.2.3")
12
13
  * @param {string} message - The tag message (changelog)
14
+ * @param {boolean} quiet - If true, suppress success message
13
15
  * @returns {Promise<void>}
14
16
  */
15
- async function pushTag(tagName, message) {
17
+ async function pushTag(tagName, message, quiet = false) {
16
18
  const safeTag = String(tagName).replace(/"/g, '\\"');
17
19
 
18
20
  // Write message to a temp file to avoid shell escaping issues with special characters
19
- const tempFile = path.join(os.tmpdir(), `agileflow-tag-${Date.now()}.txt`);
21
+ const tempFile = path.join(os.tmpdir(), `agileflow-tag-${crypto.randomBytes(8).toString('hex')}.txt`);
20
22
  try {
21
23
  fs.writeFileSync(tempFile, message, 'utf8');
22
24
 
@@ -25,6 +27,10 @@ async function pushTag(tagName, message) {
25
27
 
26
28
  // Push to origin
27
29
  execSync(`git push origin "${safeTag}"`, { stdio: 'pipe' });
30
+
31
+ if (!quiet) {
32
+ console.log(`Tag ${tagName} created and pushed successfully.`);
33
+ }
28
34
  } finally {
29
35
  // Clean up temp file
30
36
  try {
@@ -64,10 +64,15 @@ function makeRequest({ method, path, accessToken, body }) {
64
64
  },
65
65
  };
66
66
 
67
+ const MAX_RESPONSE_BYTES = 1024 * 1024; // 1 MB
67
68
  const req = https.request(options, (res) => {
68
69
  let data = '';
69
-
70
+
70
71
  res.on('data', (chunk) => {
72
+ if (data.length + chunk.length > MAX_RESPONSE_BYTES) {
73
+ req.destroy(new Error('GitHub API response exceeded size limit'));
74
+ return;
75
+ }
71
76
  data += chunk;
72
77
  });
73
78
 
@@ -122,9 +127,10 @@ function makeRequest({ method, path, accessToken, body }) {
122
127
  * Uses GITHUB_REPOSITORY and GITHUB_SHA from GitHub Actions environment.
123
128
  * @param {string} tagName - The tag name
124
129
  * @param {string} message - The tag message
130
+ * @param {boolean} quiet - If true, suppress success message
125
131
  * @returns {Promise<void>}
126
132
  */
127
- async function pushTag(tagName, message) {
133
+ async function pushTag(tagName, message, quiet = false) {
128
134
  const accessToken = process.env.AGILEFLOW_TOKEN;
129
135
  const repository = process.env.GITHUB_REPOSITORY;
130
136
  const commitSha = process.env.GITHUB_SHA;
@@ -148,6 +154,10 @@ async function pushTag(tagName, message) {
148
154
  }
149
155
 
150
156
  await createTagViaAPI(tagName, message || tagName, repository, accessToken, commitSha);
157
+
158
+ if (!quiet) {
159
+ console.log(`Tag ${tagName} created and pushed successfully.`);
160
+ }
151
161
  }
152
162
 
153
163
  module.exports = {
@@ -34,10 +34,15 @@ function createTagViaAPI(tagName, message, projectPath, serverHost, accessToken,
34
34
  },
35
35
  };
36
36
 
37
+ const MAX_RESPONSE_BYTES = 1024 * 1024; // 1 MB
37
38
  const req = https.request(options, (res) => {
38
39
  let data = '';
39
-
40
+
40
41
  res.on('data', (chunk) => {
42
+ if (data.length + chunk.length > MAX_RESPONSE_BYTES) {
43
+ req.destroy(new Error('GitLab API response exceeded size limit'));
44
+ return;
45
+ }
41
46
  data += chunk;
42
47
  });
43
48
 
package/src/index.js CHANGED
@@ -125,11 +125,7 @@ async function handlePushCommand(pushType, options) {
125
125
  console.log(`\nCreating tag ${info.newVersion}...`);
126
126
  }
127
127
 
128
- await pushModule.pushTag(info.newVersion, tagMessage);
129
-
130
- if (!options.quiet) {
131
- console.log(`Tag ${info.newVersion} created and pushed successfully.`);
132
- }
128
+ await pushModule.pushTag(info.newVersion, tagMessage, options.quiet);
133
129
  }
134
130
 
135
131
  async function main() {
package/src/utils.js CHANGED
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
2
 
3
3
  const { execSync } = require('child_process');
4
- const fs = require('fs');
5
4
 
6
5
  /**
7
6
  * Executes a shell command and returns the output.
@@ -44,8 +43,10 @@ function run(command, options = {}) {
44
43
  * @throws {Error} If the current directory is not a git repository
45
44
  */
46
45
  function ensureGitRepo() {
47
- if (!fs.existsSync('.git')) {
48
- throw new Error('Current directory is not a git repository (missing .git directory).');
46
+ try {
47
+ runWithOutput('git rev-parse --is-inside-work-tree');
48
+ } catch {
49
+ throw new Error('Current directory is not a git repository.');
49
50
  }
50
51
  }
51
52
 
@@ -105,16 +106,28 @@ function fetchTags() {
105
106
  }
106
107
 
107
108
  /**
108
- * Gets all tags pointing to a specific commit.
109
- * @param {string} commitSha - The commit SHA to check for tags
110
- * @returns {Array<string>} Array of tag names, empty array if none
109
+ * Builds a map of commit SHA → tag names for all tags in the repository.
110
+ * Uses a single git call instead of one per commit.
111
+ * @returns {Map<string, string[]>}
111
112
  */
112
- function getTagsForCommit(commitSha) {
113
+ function buildTagMap() {
113
114
  try {
114
- const output = runWithOutput(`git tag --points-at ${commitSha}`).trim();
115
- return output ? output.split('\n').map(t => t.trim()).filter(Boolean) : [];
115
+ const output = runWithOutput('git tag --format=%(refname:short)|%(*objectname)|%(objectname)').trim();
116
+ if (!output) return new Map();
117
+ const map = new Map();
118
+ for (const line of output.split('\n')) {
119
+ const [name, deref, obj] = line.split('|');
120
+ // Annotated tags dereference to the commit via %(*objectname);
121
+ // lightweight tags point directly via %(objectname).
122
+ const sha = (deref || obj || '').trim();
123
+ const tagName = (name || '').trim();
124
+ if (!sha || !tagName) continue;
125
+ if (!map.has(sha)) map.set(sha, []);
126
+ map.get(sha).push(tagName);
127
+ }
128
+ return map;
116
129
  } catch {
117
- return [];
130
+ return new Map();
118
131
  }
119
132
  }
120
133
 
@@ -126,12 +139,12 @@ function getTagsForCommit(commitSha) {
126
139
  function parseConventionalCommit(message) {
127
140
  if (!message) return null;
128
141
  const subject = message.split('\n')[0].trim();
129
- const match = subject.match(/^(\w+)(!)?(?:\(([^)]+)\))?:\s+(.+)$/);
142
+ const match = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s+(.+)$/);
130
143
  if (!match) return null;
131
144
  return {
132
145
  type: match[1].toLowerCase(),
133
- breaking: Boolean(match[2]),
134
- scope: match[3] ? String(match[3]).trim() : '',
146
+ breaking: Boolean(match[3]),
147
+ scope: match[2] ? String(match[2]).trim() : '',
135
148
  description: String(match[4]).trim(),
136
149
  };
137
150
  }
@@ -154,7 +167,15 @@ function expandCommitInfo(commits) {
154
167
  return { latestVersion: null, commits };
155
168
  }
156
169
 
157
- const latestVersion = commits[taggedIndex].tags.find(tag => SEMVER_PATTERN.test(tag));
170
+ const latestVersion = commits[taggedIndex].tags
171
+ .filter(tag => SEMVER_PATTERN.test(tag))
172
+ .sort((a, b) => {
173
+ const pa = parseVersion(a);
174
+ const pb = parseVersion(b);
175
+ if (pb.major !== pa.major) return pb.major - pa.major;
176
+ if (pb.minor !== pa.minor) return pb.minor - pa.minor;
177
+ return pb.patch - pa.patch;
178
+ })[0];
158
179
  // Exclude the tagged commit itself - only return commits since the tag
159
180
  return {
160
181
  latestVersion,
@@ -382,28 +403,29 @@ function calculateNextVersionAndChangelog(expandedInfo) {
382
403
  * @returns {Array<{hash: string, datetime: string, author: string, message: string, tags: Array<string>}>}
383
404
  */
384
405
  function getAllBranchCommits(branch) {
385
- // Try to resolve the branch (may be a local branch or remote branch like origin/main)
386
- let branchRef = branch;
406
+ // Resolve the branch to a SHA to avoid shell injection when the branch
407
+ // name originates from a CI environment variable.
408
+ let resolvedSha;
387
409
  try {
388
- runWithOutput(`git rev-parse --verify ${branch}`);
410
+ resolvedSha = runWithOutput(`git rev-parse --verify -- ${branch}`).trim();
389
411
  } catch {
390
412
  // Try with origin/ prefix (common in CI environments where local branch doesn't exist)
391
413
  try {
392
- runWithOutput(`git rev-parse --verify origin/${branch}`);
393
- branchRef = `origin/${branch}`;
414
+ resolvedSha = runWithOutput(`git rev-parse --verify -- origin/${branch}`).trim();
394
415
  } catch {
395
416
  return [];
396
417
  }
397
418
  }
398
-
419
+
420
+ const tagMap = buildTagMap();
399
421
  const RS = '\x1E';
400
422
  const COMMIT_SEP = `${RS}${RS}`;
401
-
423
+
402
424
  try {
403
- const logCmd = `git log --format=%H${RS}%ai${RS}%an${RS}%B${COMMIT_SEP} ${branchRef}`;
425
+ const logCmd = `git log --format=%H${RS}%ai${RS}%an${RS}%B${COMMIT_SEP} ${resolvedSha}`;
404
426
  const output = runWithOutput(logCmd).trim();
405
427
  if (!output) return [];
406
-
428
+
407
429
  return output
408
430
  .split(COMMIT_SEP)
409
431
  .filter(block => block.trim())
@@ -416,7 +438,7 @@ function getAllBranchCommits(branch) {
416
438
  datetime: parts[1].trim(),
417
439
  author: parts[2].trim(),
418
440
  message: parts.slice(3).join(RS).trim(),
419
- tags: getTagsForCommit(hash),
441
+ tags: tagMap.get(hash) || [],
420
442
  };
421
443
  })
422
444
  .filter(Boolean);