@logickernel/agileflow 0.17.1 → 0.21.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
@@ -61,6 +61,9 @@ on:
61
61
  push:
62
62
  branches: [main]
63
63
 
64
+ permissions:
65
+ contents: write
66
+
64
67
  jobs:
65
68
  version:
66
69
  runs-on: ubuntu-latest
@@ -74,12 +77,10 @@ jobs:
74
77
  node-version: '20'
75
78
 
76
79
  - name: Create version tag
77
- env:
78
- AGILEFLOW_TOKEN: ${{ secrets.AGILEFLOW_TOKEN }}
79
80
  run: npx @logickernel/agileflow github
80
81
  ```
81
82
 
82
- Requires an `AGILEFLOW_TOKEN` secret — a Personal Access Token with `Contents: Read and write` permission.
83
+ AgileFlow automatically uses the built-in `GITHUB_TOKEN` — no secrets or custom tokens needed. Just grant `contents: write` permission in the workflow. You can also use a [Personal Access Token](./docs/guides/github-actions.md) if your organization restricts `GITHUB_TOKEN` permissions.
83
84
 
84
85
  ### GitLab CI
85
86
 
@@ -4,23 +4,7 @@ Two workflows: one that runs AgileFlow on push to main and creates a version tag
4
4
 
5
5
  ---
6
6
 
7
- ## Step 1: Create an access token
8
-
9
- 1. Go to **Settings → Developer settings → Personal access tokens → Fine-grained tokens**
10
- 2. Click **Generate new token**
11
- 3. Set:
12
- - **Name**: `AgileFlow`
13
- - **Repository access**: your repository
14
- - **Permissions**: `Contents: Read and write`
15
- 4. Copy the token
16
-
17
- ## Step 2: Add the token as a secret
18
-
19
- 1. In your repository: **Settings → Secrets and variables → Actions**
20
- 2. Click **New repository secret**
21
- 3. Name: `AGILEFLOW_TOKEN`, value: your token
22
-
23
- ## Step 3: Create the versioning workflow
7
+ ## Step 1: Create the versioning workflow
24
8
 
25
9
  `.github/workflows/version.yml`:
26
10
 
@@ -30,6 +14,9 @@ on:
30
14
  push:
31
15
  branches: [main]
32
16
 
17
+ permissions:
18
+ contents: write
19
+
33
20
  jobs:
34
21
  version:
35
22
  runs-on: ubuntu-latest
@@ -43,14 +30,31 @@ jobs:
43
30
  node-version: '20'
44
31
 
45
32
  - name: Create version tag
46
- env:
47
- AGILEFLOW_TOKEN: ${{ secrets.AGILEFLOW_TOKEN }}
48
33
  run: npx @logickernel/agileflow github
49
34
  ```
50
35
 
36
+ That's it. AgileFlow automatically picks up the `GITHUB_TOKEN` that GitHub Actions provides. The `permissions: contents: write` block grants it permission to create tags.
37
+
51
38
  `fetch-depth: 0` is required — without it, AgileFlow can only see a shallow clone and cannot find the last version tag.
52
39
 
53
- ## Step 4: Create the release workflow
40
+ ### Using a Personal Access Token instead
41
+
42
+ If your organization restricts `GITHUB_TOKEN` permissions or you need cross-repository tagging, use a PAT:
43
+
44
+ 1. Go to **Settings → Developer settings → Personal access tokens → Fine-grained tokens**
45
+ 2. Click **Generate new token**
46
+ 3. Set:
47
+ - **Name**: `AgileFlow`
48
+ - **Repository access**: your repository
49
+ - **Permissions**: `Contents: Read and write`
50
+ 4. Copy the token
51
+ 5. In your repository: **Settings → Secrets and variables → Actions**
52
+ 6. Click **New repository secret**
53
+ 7. Name: `AGILEFLOW_TOKEN`, value: your token
54
+
55
+ AgileFlow checks `AGILEFLOW_TOKEN` first, then falls back to `GITHUB_TOKEN`. When `AGILEFLOW_TOKEN` is set, no `permissions` block is needed.
56
+
57
+ ## Step 3: Create the release workflow
54
58
 
55
59
  `.github/workflows/release.yml`:
56
60
 
@@ -99,9 +103,9 @@ If no bump is needed (all commits are `chore`, `docs`, etc.), AgileFlow exits wi
99
103
 
100
104
  ## Troubleshooting
101
105
 
102
- **"AGILEFLOW_TOKEN not set"** — The secret is missing or not passed via `env:`. Verify the secret exists and the `env:` block is present in the step.
106
+ **"No authentication token found"** — Neither `AGILEFLOW_TOKEN` nor `GITHUB_TOKEN` is available. If running in GitHub Actions, ensure the workflow has `permissions: contents: write`. If using a PAT, verify the `AGILEFLOW_TOKEN` secret exists.
103
107
 
104
- **"Resource not accessible by integration" / 403** — The token lacks `Contents: Read and write` permission. Regenerate with the correct scope.
108
+ **"Resource not accessible by integration" / 403** — The token lacks `contents: write` permission. Add `permissions: contents: write` to your workflow, or regenerate your PAT with the correct scope.
105
109
 
106
110
  **"Bad credentials" / 401** — The token has expired or was revoked. Regenerate and update the secret.
107
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logickernel/agileflow",
3
- "version": "0.17.1",
3
+ "version": "0.21.0",
4
4
  "description": "Automatic semantic versioning and changelog generation based on conventional commits",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -131,17 +131,20 @@ function makeRequest({ method, path, accessToken, body }) {
131
131
  * @returns {Promise<void>}
132
132
  */
133
133
  async function pushTag(tagName, message, remote = 'origin') {
134
- const accessToken = process.env.AGILEFLOW_TOKEN;
134
+ const accessToken = process.env.AGILEFLOW_TOKEN || process.env.GITHUB_TOKEN;
135
135
  const repository = process.env.GITHUB_REPOSITORY;
136
136
  const commitSha = process.env.GITHUB_SHA;
137
-
137
+
138
138
  if (!accessToken) {
139
139
  throw new Error(
140
- `AGILEFLOW_TOKEN environment variable is required but not set.\n\n` +
141
- `To fix this:\n` +
142
- `1. Create a Personal Access Token with "contents: write" permission\n` +
143
- `2. Add it as a repository secret named AGILEFLOW_TOKEN\n` +
144
- `3. In your workflow, add: env: AGILEFLOW_TOKEN: \${{ secrets.AGILEFLOW_TOKEN }}`
140
+ `No authentication token found.\n\n` +
141
+ `AgileFlow looks for AGILEFLOW_TOKEN or GITHUB_TOKEN (in that order).\n\n` +
142
+ `In GitHub Actions, GITHUB_TOKEN is available automatically just add\n` +
143
+ `permissions to your workflow:\n` +
144
+ ` permissions:\n` +
145
+ ` contents: write\n\n` +
146
+ `Or use a Personal Access Token with "contents: write" permission\n` +
147
+ `and store it as a secret named AGILEFLOW_TOKEN.`
145
148
  );
146
149
  }
147
150
 
package/src/utils.js CHANGED
@@ -224,9 +224,57 @@ function parseConventionalCommit(message) {
224
224
  }
225
225
 
226
226
  /**
227
- * Expands commit information by finding the latest version and filtering commits.
228
- * @param {Array} commits - Array of commit objects (newest to oldest)
229
- * @returns {{latestVersion: string|null, commits: Array}} Filtered commits since last version
227
+ * Finds the highest semver version tag in a list of commits.
228
+ * @param {Array} commits - The commits to scan
229
+ * @returns {{latestVersion: string|null, taggedCommitHash: string|null}}
230
+ */
231
+ function findLatestVersionTag(commits) {
232
+ if (!commits?.length) {
233
+ return { latestVersion: null, taggedCommitHash: null };
234
+ }
235
+
236
+ let bestHash = null;
237
+ let bestVersion = null;
238
+
239
+ for (let i = 0; i < commits.length; i++) {
240
+ const semverTags = commits[i].tags?.filter(tag => SEMVER_PATTERN.test(tag));
241
+ if (!semverTags?.length) continue;
242
+
243
+ const highest = semverTags.sort((a, b) => {
244
+ const pa = parseVersion(a);
245
+ const pb = parseVersion(b);
246
+ if (pb.major !== pa.major) return pb.major - pa.major;
247
+ if (pb.minor !== pa.minor) return pb.minor - pa.minor;
248
+ return pb.patch - pa.patch;
249
+ })[0];
250
+
251
+ if (!bestVersion) {
252
+ bestVersion = highest;
253
+ bestHash = commits[i].hash;
254
+ } else {
255
+ const pBest = parseVersion(bestVersion);
256
+ const pCand = parseVersion(highest);
257
+ const isHigher =
258
+ pCand.major > pBest.major ||
259
+ (pCand.major === pBest.major && pCand.minor > pBest.minor) ||
260
+ (pCand.major === pBest.major && pCand.minor === pBest.minor && pCand.patch > pBest.patch);
261
+ if (isHigher) {
262
+ bestVersion = highest;
263
+ bestHash = commits[i].hash;
264
+ }
265
+ }
266
+ }
267
+
268
+ return { latestVersion: bestVersion, taggedCommitHash: bestHash };
269
+ }
270
+
271
+ /**
272
+ * Expands commit info by finding the latest version tag and returning
273
+ * only commits since that tag. Uses positional slicing from a date-sorted
274
+ * commit list — for accurate results with merge commits, prefer using
275
+ * findLatestVersionTag + getCommitsSinceTag (as processVersionInfo does).
276
+ * @param {Array} commits - All commits (date-sorted, newest first)
277
+ * @returns {{latestVersion: string|null, commits: Array}}
230
278
  */
231
279
  function expandCommitInfo(commits) {
232
280
  if (!commits?.length) {
@@ -548,6 +596,45 @@ function getAllBranchCommits(branch) {
548
596
  }
549
597
  }
550
598
 
599
+ /**
600
+ * Retrieves commits reachable from the branch but not from the given tag,
601
+ * using git's native range selection so that date ordering cannot cause
602
+ * commits from merged branches to be missed.
603
+ * @param {string} tagHash - The commit hash of the version tag
604
+ * @param {string} branchSha - The resolved SHA of the branch tip
605
+ * @returns {Array<{hash: string, datetime: string, author: string, message: string, tags: Array<string>}>}
606
+ */
607
+ function getCommitsSinceTag(tagHash, branchSha) {
608
+ const tagMap = buildTagMap();
609
+ const RS = '\x1E';
610
+ const COMMIT_SEP = `${RS}${RS}`;
611
+
612
+ try {
613
+ const logCmd = `git log --format=%H${RS}%ai${RS}%an${RS}%B${COMMIT_SEP} ${tagHash}..${branchSha}`;
614
+ const output = runWithOutput(logCmd).trim();
615
+ if (!output) return [];
616
+
617
+ return output
618
+ .split(COMMIT_SEP)
619
+ .filter(block => block.trim())
620
+ .map(block => {
621
+ const parts = block.split(RS);
622
+ if (parts.length < 4) return null;
623
+ const hash = parts[0].trim();
624
+ return {
625
+ hash,
626
+ datetime: parts[1].trim(),
627
+ author: parts[2].trim(),
628
+ message: parts.slice(3).join(RS).trim(),
629
+ tags: tagMap.get(hash) || [],
630
+ };
631
+ })
632
+ .filter(Boolean);
633
+ } catch {
634
+ return [];
635
+ }
636
+ }
637
+
551
638
  /**
552
639
  * Processes version information for the current branch.
553
640
  * @returns {Promise<{currentVersion: string|null, newVersion: string|null, commits: Array, changelog: string}>}
@@ -556,12 +643,25 @@ async function processVersionInfo() {
556
643
  ensureGitRepo();
557
644
  const branch = getCurrentBranch();
558
645
  fetchTags();
559
-
646
+
560
647
  const allCommits = getAllBranchCommits(branch);
561
- const expandedInfo = expandCommitInfo(allCommits);
562
- const { latestVersion, commits } = expandedInfo;
648
+ const { latestVersion, taggedCommitHash } = findLatestVersionTag(allCommits);
649
+
650
+ let commits;
651
+ if (taggedCommitHash) {
652
+ // Use git's range selection (tag..HEAD) to correctly identify all commits
653
+ // since the tag, regardless of commit date ordering. This fixes an issue
654
+ // where commits from merged feature branches could be missed because their
655
+ // dates are older than the tagged commit.
656
+ const branchSha = allCommits[0]?.hash;
657
+ commits = branchSha ? getCommitsSinceTag(taggedCommitHash, branchSha) : [];
658
+ } else {
659
+ commits = allCommits;
660
+ }
661
+
662
+ const expandedInfo = { latestVersion, commits };
563
663
  const { newVersion, changelog } = calculateNextVersionAndChangelog(expandedInfo);
564
-
664
+
565
665
  return {
566
666
  currentVersion: latestVersion,
567
667
  newVersion,
@@ -577,6 +677,8 @@ module.exports = {
577
677
  getCurrentBranch,
578
678
  fetchTags,
579
679
  getAllBranchCommits,
680
+ findLatestVersionTag,
681
+ getCommitsSinceTag,
580
682
  expandCommitInfo,
581
683
  calculateNextVersionAndChangelog,
582
684
  processVersionInfo,