@logickernel/agileflow 0.14.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logickernel/agileflow",
3
- "version": "0.14.1",
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",
@@ -37,5 +40,8 @@
37
40
  "url": "git@code.logickernel.com:kernel/agileflow.git"
38
41
  },
39
42
  "author": "Víctor Valle <victor.valle@logickernel.com>",
40
- "license": "ISC"
43
+ "license": "ISC",
44
+ "devDependencies": {
45
+ "jest": "^30.2.0"
46
+ }
41
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');
@@ -17,7 +18,7 @@ async function pushTag(tagName, message, quiet = false) {
17
18
  const safeTag = String(tagName).replace(/"/g, '\\"');
18
19
 
19
20
  // Write message to a temp file to avoid shell escaping issues with special characters
20
- 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`);
21
22
  try {
22
23
  fs.writeFileSync(tempFile, message, 'utf8');
23
24
 
@@ -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
 
@@ -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/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);