calver-bump 0.1.7 → 1.0.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 +74 -7
- package/bin/calver-bump.js +151 -10
- package/package.json +15 -2
- package/src/changelog.js +267 -0
- package/src/files.js +83 -0
- package/src/git.js +82 -0
- package/src/index.js +85 -404
- package/test/calver.test.js +0 -69
- package/test/cli.test.js +0 -109
- package/test/release.test.js +0 -568
package/README.md
CHANGED
|
@@ -18,9 +18,9 @@ Example:
|
|
|
18
18
|
|
|
19
19
|
1. Bumps `package.json` to the next CalVer version.
|
|
20
20
|
2. Updates `package-lock.json` or `npm-shrinkwrap.json` when present.
|
|
21
|
-
3.
|
|
22
|
-
4. Creates a
|
|
23
|
-
5.
|
|
21
|
+
3. Warns when `pnpm-lock.yaml` or `yarn.lock` is present, because those lockfiles do not store the root package version consistently.
|
|
22
|
+
4. Creates or prepends a `CHANGELOG.md` entry from conventional commits since the nearest reachable tag.
|
|
23
|
+
5. Prints the git commands needed to commit, tag, and push the reviewed release.
|
|
24
24
|
|
|
25
25
|
## Usage
|
|
26
26
|
|
|
@@ -28,12 +28,26 @@ Example:
|
|
|
28
28
|
npx calver-bump
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
By default, `calver-bump` updates files only. Review `CHANGELOG.md`, then run the printed git commands when the release notes are correct.
|
|
32
|
+
|
|
31
33
|
Preview the planned release without writing files:
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
36
|
npx calver-bump --dry-run
|
|
35
37
|
```
|
|
36
38
|
|
|
39
|
+
Print help:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx calver-bump --help
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Print the installed `calver-bump` package version:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx calver-bump --version
|
|
49
|
+
```
|
|
50
|
+
|
|
37
51
|
Use compact CalVer instead:
|
|
38
52
|
|
|
39
53
|
```bash
|
|
@@ -70,7 +84,43 @@ Only include selected conventional commit types:
|
|
|
70
84
|
npx calver-bump --types feat,fix,perf
|
|
71
85
|
```
|
|
72
86
|
|
|
73
|
-
|
|
87
|
+
Use an explicit changelog base tag:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npx calver-bump --from v1.35.0
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Use local tags only without fetching from the configured remote:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npx calver-bump --no-fetch
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Update only `package.json` and supported npm lockfiles:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx calver-bump --version-only
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Update only `CHANGELOG.md`:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npx calver-bump --changelog-only
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Create a release commit after updating files:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npx calver-bump --commit
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Create a release commit and annotated tag after updating files:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npx calver-bump --tag
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Explicitly update files without creating a release commit or tag:
|
|
74
124
|
|
|
75
125
|
```bash
|
|
76
126
|
npx calver-bump --skip-commit
|
|
@@ -85,10 +135,22 @@ Project defaults can be stored in `.calverbumprc.json`:
|
|
|
85
135
|
"format": "short",
|
|
86
136
|
"tagPrefix": "v",
|
|
87
137
|
"remote": "origin",
|
|
88
|
-
"types": ["feat", "fix", "perf"]
|
|
138
|
+
"types": ["feat", "fix", "perf"],
|
|
139
|
+
"changelogSections": {
|
|
140
|
+
"perf": "Performance",
|
|
141
|
+
"security": "Security"
|
|
142
|
+
}
|
|
89
143
|
}
|
|
90
144
|
```
|
|
91
145
|
|
|
146
|
+
## Runtime support
|
|
147
|
+
|
|
148
|
+
`calver-bump` supports Node.js LTS lines only. The current supported runtime range is Node.js 22 and Node.js 24.
|
|
149
|
+
|
|
150
|
+
## Publishing
|
|
151
|
+
|
|
152
|
+
The package includes a manual GitHub Actions publish workflow. Run the `Publish` workflow from GitHub after the package version has been bumped, tests pass, and the npm automation token is available as `NPM_TOKEN`.
|
|
153
|
+
|
|
92
154
|
## Notes
|
|
93
155
|
|
|
94
156
|
- The default `short` format is `YY.MMDD` for the first release of the day, then `YY.MMDD.1`, `YY.MMDD.2`, etc.
|
|
@@ -98,11 +160,16 @@ Project defaults can be stored in `.calverbumprc.json`:
|
|
|
98
160
|
- Existing `v`-prefixed tags are considered when calculating the next sequence number.
|
|
99
161
|
- Changelog ranges start from the nearest reachable tag, even when it is not a CalVer tag.
|
|
100
162
|
- Changelog entries include conventional commit subjects only, such as `feat:`, `fix(scope):`, or `chore!:`.
|
|
101
|
-
- Changelog entries are grouped into `Features`, `Fixes`, and `Other Changes
|
|
163
|
+
- Changelog entries are grouped into `Features`, `Fixes`, and `Other Changes` by default. Use `changelogSections` to assign additional conventional commit types to named sections.
|
|
102
164
|
- Changelog entries link to GitHub pull requests or GitLab merge requests when the local git message includes references such as `#123`, `Merge pull request #123`, `!123`, or `See merge request group/project!123`.
|
|
103
165
|
- Changelog entries fall back to commit hash links for GitHub and GitLab-style remotes when no pull/merge request reference is found.
|
|
104
166
|
- Release entries include a `Full Changelog` section with a deduped list of pull/merge requests found in the release range, including the local commit title when available.
|
|
105
167
|
- Later releases prepend only commits since the previous nearest reachable tag.
|
|
168
|
+
- Plain `calver-bump` does not commit or tag by default; it prints the follow-up `git add`, `git commit`, `git tag`, and `git push --follow-tags` commands.
|
|
169
|
+
- Use `--commit` to create only the release commit.
|
|
170
|
+
- Use `--tag` to create the release commit and annotated tag.
|
|
171
|
+
- Use `--push` to create the release commit, create the annotated tag, and push both.
|
|
106
172
|
- Release tags are annotated so `git push --follow-tags <remote> <branch>` pushes them.
|
|
107
173
|
- The working tree must be clean before creating a real release.
|
|
108
|
-
-
|
|
174
|
+
- Existing release tags are rejected before files are written.
|
|
175
|
+
- If tag creation fails after the release commit, the CLI undoes its own commit and leaves the file changes in the working tree for inspection or recovery.
|
package/bin/calver-bump.js
CHANGED
|
@@ -8,28 +8,60 @@ import { assertFormat } from '../src/calver.js';
|
|
|
8
8
|
|
|
9
9
|
const execFile = promisify(execFileCallback);
|
|
10
10
|
const args = process.argv.slice(2);
|
|
11
|
+
const KNOWN_OPTIONS = new Set([
|
|
12
|
+
'--changelog-only',
|
|
13
|
+
'--commit',
|
|
14
|
+
'--dry-run',
|
|
15
|
+
'--format',
|
|
16
|
+
'--from',
|
|
17
|
+
'--help',
|
|
18
|
+
'--no-fetch',
|
|
19
|
+
'--push',
|
|
20
|
+
'--remote',
|
|
21
|
+
'--skip-commit',
|
|
22
|
+
'--tag',
|
|
23
|
+
'--tag-prefix',
|
|
24
|
+
'--types',
|
|
25
|
+
'--version',
|
|
26
|
+
'--version-only',
|
|
27
|
+
]);
|
|
11
28
|
|
|
12
29
|
try {
|
|
30
|
+
if (flag(args, '--help')) {
|
|
31
|
+
console.log(helpText());
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
if (flag(args, '--version')) {
|
|
35
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
36
|
+
console.log(pkg.version);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
13
40
|
const options = await releaseOptions(args);
|
|
14
41
|
assertFormat(options.format);
|
|
15
42
|
const result = await runRelease(options);
|
|
16
|
-
|
|
43
|
+
printResult(result, options);
|
|
44
|
+
if (result.warnings.length > 0) {
|
|
45
|
+
console.log('');
|
|
46
|
+
console.log('Warnings:');
|
|
47
|
+
for (const warning of result.warnings) {
|
|
48
|
+
console.log(`- ${warning}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
17
51
|
for (const action of result.actions) {
|
|
18
52
|
console.log(`- ${action}`);
|
|
19
53
|
}
|
|
20
|
-
if (options.push && !options.dryRun) {
|
|
54
|
+
if (options.push && !options.dryRun && createsTag(options)) {
|
|
21
55
|
const pushArgs = ['push', '--follow-tags', result.remote, result.branch];
|
|
22
56
|
console.log('');
|
|
23
57
|
console.log(`Running: git ${pushArgs.join(' ')}`);
|
|
24
58
|
await execFile('git', pushArgs, { encoding: 'utf8' });
|
|
25
|
-
} else if (!options.dryRun
|
|
59
|
+
} else if (!options.dryRun) {
|
|
26
60
|
console.log('');
|
|
27
61
|
console.log('Next steps:');
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
console.log(` git push --follow-tags ${result.remote} ${result.branch}`);
|
|
32
|
-
console.log('3. Trigger or verify your deployment pipeline.');
|
|
62
|
+
for (const instruction of nextStepInstructions(result, options)) {
|
|
63
|
+
console.log(instruction);
|
|
64
|
+
}
|
|
33
65
|
}
|
|
34
66
|
} catch (error) {
|
|
35
67
|
console.error(error.message);
|
|
@@ -37,16 +69,36 @@ try {
|
|
|
37
69
|
}
|
|
38
70
|
|
|
39
71
|
async function releaseOptions(args) {
|
|
72
|
+
rejectUnknownOptions(args);
|
|
40
73
|
const config = await readConfig();
|
|
74
|
+
const versionOnly = flag(args, '--version-only') ?? config.versionOnly ?? false;
|
|
75
|
+
const changelogOnly = flag(args, '--changelog-only') ?? config.changelogOnly ?? false;
|
|
76
|
+
const skipCommit = flag(args, '--skip-commit') ?? config.skipCommit ?? false;
|
|
77
|
+
const push = flag(args, '--push') ?? config.push ?? false;
|
|
78
|
+
const tag = push || (flag(args, '--tag') ?? config.tag ?? false);
|
|
79
|
+
const commit = tag || (flag(args, '--commit') ?? config.commit ?? false);
|
|
80
|
+
if (versionOnly && changelogOnly) {
|
|
81
|
+
throw new Error('--version-only cannot be combined with --changelog-only.');
|
|
82
|
+
}
|
|
83
|
+
if (skipCommit && (commit || tag || push)) {
|
|
84
|
+
throw new Error('--skip-commit cannot be combined with --commit, --tag, or --push.');
|
|
85
|
+
}
|
|
41
86
|
return {
|
|
42
87
|
...config,
|
|
43
88
|
dryRun: flag(args, '--dry-run') ?? config.dryRun ?? false,
|
|
44
|
-
|
|
45
|
-
|
|
89
|
+
noFetch: flag(args, '--no-fetch') ?? config.noFetch ?? false,
|
|
90
|
+
commit,
|
|
91
|
+
tag,
|
|
92
|
+
push,
|
|
93
|
+
skipCommit,
|
|
94
|
+
versionOnly,
|
|
95
|
+
changelogOnly,
|
|
96
|
+
from: value(args, '--from') ?? config.from,
|
|
46
97
|
format: value(args, '--format') ?? config.format ?? 'short',
|
|
47
98
|
remote: value(args, '--remote') ?? config.remote ?? 'origin',
|
|
48
99
|
tagPrefix: value(args, '--tag-prefix') ?? config.tagPrefix ?? '',
|
|
49
100
|
types: parseTypes(value(args, '--types')) ?? config.types,
|
|
101
|
+
changelogSections: config.changelogSections,
|
|
50
102
|
};
|
|
51
103
|
}
|
|
52
104
|
|
|
@@ -80,3 +132,92 @@ function value(args, name) {
|
|
|
80
132
|
function parseTypes(types) {
|
|
81
133
|
return types?.split(',').map((type) => type.trim()).filter(Boolean);
|
|
82
134
|
}
|
|
135
|
+
|
|
136
|
+
function rejectUnknownOptions(args) {
|
|
137
|
+
for (const arg of args) {
|
|
138
|
+
if (arg.startsWith('--') && !KNOWN_OPTIONS.has(arg)) {
|
|
139
|
+
throw new Error(`Unknown option ${arg}. Run calver-bump --help for usage.`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function printResult(result, options) {
|
|
145
|
+
console.log(`Release version: ${result.version}`);
|
|
146
|
+
console.log(`Git tag: ${result.createdTag ?? '(not created)'}`);
|
|
147
|
+
console.log(`Branch: ${result.branch}`);
|
|
148
|
+
console.log(`Remote: ${result.remote}`);
|
|
149
|
+
if (!options.versionOnly) {
|
|
150
|
+
console.log(`Changelog range: ${result.range}`);
|
|
151
|
+
console.log(`Previous tag: ${result.previousTag ?? '(none)'}`);
|
|
152
|
+
}
|
|
153
|
+
if (result.files.length > 0) {
|
|
154
|
+
console.log(`Files: ${result.files.join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
if (options.noFetch) {
|
|
157
|
+
console.log('Tag fetch: skipped (--no-fetch)');
|
|
158
|
+
}
|
|
159
|
+
console.log('');
|
|
160
|
+
console.log(options.dryRun ? 'Planned actions:' : 'Completed actions:');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function createsCommit(options) {
|
|
164
|
+
return Boolean(options.commit || options.tag || options.push) && !options.skipCommit && !options.versionOnly && !options.changelogOnly;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function createsTag(options) {
|
|
168
|
+
return Boolean(options.tag || options.push) && createsCommit(options);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function nextStepInstructions(result, options) {
|
|
172
|
+
if (options.versionOnly || options.changelogOnly) {
|
|
173
|
+
return ['Review the generated file changes.'];
|
|
174
|
+
}
|
|
175
|
+
if (!createsCommit(options)) {
|
|
176
|
+
return [
|
|
177
|
+
'Review CHANGELOG.md, then run:',
|
|
178
|
+
`git add ${result.files.join(' ')}`,
|
|
179
|
+
`git commit -m "chore(release): ${result.version}"`,
|
|
180
|
+
`git tag -a ${result.tag} -m "Release ${result.version}"`,
|
|
181
|
+
`git push --follow-tags ${result.remote} ${result.branch}`,
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
if (!createsTag(options)) {
|
|
185
|
+
return [
|
|
186
|
+
'Review the release commit, then run:',
|
|
187
|
+
'git show --stat HEAD',
|
|
188
|
+
`git tag -a ${result.tag} -m "Release ${result.version}"`,
|
|
189
|
+
`git push --follow-tags ${result.remote} ${result.branch}`,
|
|
190
|
+
];
|
|
191
|
+
}
|
|
192
|
+
return [
|
|
193
|
+
'Review the release commit:',
|
|
194
|
+
'git show --stat HEAD',
|
|
195
|
+
'Push the release commit and tag:',
|
|
196
|
+
`git push --follow-tags ${result.remote} ${result.branch}`,
|
|
197
|
+
'Trigger or verify your deployment pipeline.',
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function helpText() {
|
|
202
|
+
return `Usage: calver-bump [options]
|
|
203
|
+
|
|
204
|
+
Options:
|
|
205
|
+
--dry-run Preview the release without writing files.
|
|
206
|
+
--format <name> Version format: short, compact, or long.
|
|
207
|
+
--tag-prefix <prefix> Prefix the git tag without changing package.json.
|
|
208
|
+
--types <list> Comma-separated conventional commit types to include.
|
|
209
|
+
--from <tag> Use an explicit changelog base tag.
|
|
210
|
+
--no-fetch Use local tags only; do not fetch remote tags.
|
|
211
|
+
--version-only Update package.json and supported npm lockfiles only.
|
|
212
|
+
--changelog-only Update CHANGELOG.md only.
|
|
213
|
+
--commit Create the release commit after updating files.
|
|
214
|
+
--tag Create the release commit and annotated tag.
|
|
215
|
+
--skip-commit Explicitly update files without creating a release commit or tag.
|
|
216
|
+
--push Create, tag, and push the release.
|
|
217
|
+
--remote <name> Remote used for fetch, links, and push.
|
|
218
|
+
--version Print the calver-bump package version.
|
|
219
|
+
--help Show this help.
|
|
220
|
+
|
|
221
|
+
Configuration:
|
|
222
|
+
Project defaults can be stored in .calverbumprc.json.`;
|
|
223
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "calver-bump",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Release CLI for internal applications using readable CalVer versions.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/msako/calver-bump.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/msako/calver-bump#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/msako/calver-bump/issues"
|
|
12
|
+
},
|
|
5
13
|
"main": "src/index.js",
|
|
6
14
|
"bin": {
|
|
7
15
|
"calver-bump": "bin/calver-bump.js"
|
|
8
16
|
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"src",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
9
22
|
"scripts": {
|
|
10
23
|
"test": "node --test"
|
|
11
24
|
},
|
|
@@ -19,6 +32,6 @@
|
|
|
19
32
|
"license": "MIT",
|
|
20
33
|
"type": "module",
|
|
21
34
|
"engines": {
|
|
22
|
-
"node": "
|
|
35
|
+
"node": "^22.0.0 || ^24.0.0"
|
|
23
36
|
}
|
|
24
37
|
}
|
package/src/changelog.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { fetchTags, getRemoteUrl, gitCommits, latestReachableTag, tagExists } from './git.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'];
|
|
4
|
+
const DEFAULT_CHANGELOG_SECTIONS = {
|
|
5
|
+
feat: 'Features',
|
|
6
|
+
fix: 'Fixes',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export async function releaseNotes(cwd, options = {}) {
|
|
10
|
+
if (!options.noFetch) {
|
|
11
|
+
await fetchTags(cwd, options.remote ?? 'origin');
|
|
12
|
+
}
|
|
13
|
+
const latestTag = options.from ?? await latestReleaseTag(cwd, options.existingChangelog ?? '');
|
|
14
|
+
const range = latestTag ? [`${latestTag}..HEAD`] : [];
|
|
15
|
+
const commits = await gitCommits(cwd, range);
|
|
16
|
+
const remoteUrl = await getRemoteUrl(cwd, options.remote ?? 'origin');
|
|
17
|
+
const commitUrlBuilder = remoteUrl ? buildCommitUrlBuilder(remoteUrl) : null;
|
|
18
|
+
const compareUrlBuilder = remoteUrl ? buildCompareUrlBuilder(remoteUrl) : null;
|
|
19
|
+
const requestUrlBuilder = remoteUrl ? buildRequestUrlBuilder(remoteUrl) : null;
|
|
20
|
+
const allowedTypes = options.types ?? DEFAULT_TYPES;
|
|
21
|
+
const conventionalCommits = dedupeConventionalChanges(commits
|
|
22
|
+
.map((commit) => ({ ...commit, subject: conventionalSubjectForCommit(commit) ?? commit.subject }))
|
|
23
|
+
.filter((commit) => isConventionalCommit(commit.subject))
|
|
24
|
+
.filter((commit) => allowedTypes.includes(conventionalType(commit.subject)))
|
|
25
|
+
.filter((commit) => !isCommitInChangelog(commit, options.existingChangelog ?? ''))
|
|
26
|
+
.map((commit) => ({
|
|
27
|
+
...commit,
|
|
28
|
+
request: requestForCommit(commit, requestUrlBuilder),
|
|
29
|
+
url: commitUrlBuilder ? commitUrlBuilder(commit.hash) : null,
|
|
30
|
+
})));
|
|
31
|
+
const requests = uniqueRequests(commits.map((commit) => requestForCommit(commit, requestUrlBuilder)).filter(Boolean));
|
|
32
|
+
return {
|
|
33
|
+
previousTag: latestTag,
|
|
34
|
+
range: latestTag ? `${latestTag}..HEAD` : 'HEAD',
|
|
35
|
+
compareUrlBuilder,
|
|
36
|
+
changes: conventionalCommits.length > 0 ? conventionalCommits : ['No conventional commits in this release.'],
|
|
37
|
+
requests,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatReleaseHeading({ version, previousTag, tag, compareUrlBuilder }) {
|
|
42
|
+
return `## ${version}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatReleaseNotes(changes, sectionConfig = {}) {
|
|
46
|
+
if (changes.length === 1 && changes[0] === 'No conventional commits in this release.') {
|
|
47
|
+
return `- ${changes[0]}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sectionMap = { ...DEFAULT_CHANGELOG_SECTIONS, ...sectionConfig };
|
|
51
|
+
const grouped = new Map();
|
|
52
|
+
const other = [];
|
|
53
|
+
for (const change of changes) {
|
|
54
|
+
const heading = sectionMap[conventionalType(change.subject)];
|
|
55
|
+
if (!heading) {
|
|
56
|
+
other.push(change);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
grouped.set(heading, [...(grouped.get(heading) ?? []), change]);
|
|
60
|
+
}
|
|
61
|
+
const sections = [...grouped.entries()];
|
|
62
|
+
if (other.length > 0) {
|
|
63
|
+
sections.push(['Other Changes', other]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return sections
|
|
67
|
+
.filter(([, entries]) => entries.length > 0)
|
|
68
|
+
.map(([heading, entries]) => `### ${heading}\n\n${entries.map((entry) => `- ${formatCommitEntry(entry)}`).join('\n')}`)
|
|
69
|
+
.join('\n\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatFullChangelog(requests, compareUrl = null) {
|
|
73
|
+
const entries = [
|
|
74
|
+
...requests.map((request) => formatRequestEntry(request)),
|
|
75
|
+
...(compareUrl ? [`[Compare changes](${compareUrl})`] : []),
|
|
76
|
+
];
|
|
77
|
+
if (entries.length === 0) {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
return `\n\n### Full Changelog\n\n${entries.map((entry) => `- ${entry}`).join('\n')}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function latestReleaseTag(cwd, changelog) {
|
|
84
|
+
const changelogTag = latestChangelogCompareTarget(changelog);
|
|
85
|
+
if (changelogTag && await tagExists(cwd, changelogTag)) {
|
|
86
|
+
return changelogTag;
|
|
87
|
+
}
|
|
88
|
+
return latestReachableTag(cwd);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function latestChangelogCompareTarget(changelog) {
|
|
92
|
+
const match = /\[[^\]]+\]\([^)]*\/(?:-\/)?compare\/[^)]*?\.{3}(?<tag>[^)\s]+)\)/m.exec(changelog);
|
|
93
|
+
return match?.groups.tag ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isCommitInChangelog(commit, changelog) {
|
|
97
|
+
if (!changelog) return false;
|
|
98
|
+
return changelog.includes(commit.hash) || changelog.includes(commit.hash.slice(0, 7));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isConventionalCommit(subject) {
|
|
102
|
+
return /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+/.test(subject);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function conventionalSubjectForCommit(commit) {
|
|
106
|
+
return commitLines(commit).find((line) => isConventionalCommit(line)) ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function commitLines(commit) {
|
|
110
|
+
return [commit.subject, ...(commit.body ?? '').split('\n')]
|
|
111
|
+
.map((line) => line.trim())
|
|
112
|
+
.filter(Boolean);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function dedupeConventionalChanges(changes) {
|
|
116
|
+
const deduped = [];
|
|
117
|
+
for (const change of changes) {
|
|
118
|
+
const existingIndex = deduped.findIndex((candidate) => candidate.subject === change.subject);
|
|
119
|
+
if (existingIndex < 0) {
|
|
120
|
+
deduped.push(change);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!deduped[existingIndex].request && change.request) {
|
|
124
|
+
deduped[existingIndex] = change;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return deduped;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function conventionalType(subject) {
|
|
131
|
+
return /^(?<type>[a-z]+)(\([^)]+\))?!?: .+/.exec(subject)?.groups.type;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatCommitEntry(commit) {
|
|
135
|
+
const shortHash = commit.hash.slice(0, 7);
|
|
136
|
+
const suffix = commit.request
|
|
137
|
+
? ` (${formatRequestLink(commit.request)})`
|
|
138
|
+
: commit.url ? ` ([${shortHash}](${commit.url}))` : ` (${shortHash})`;
|
|
139
|
+
return `${commit.subject}${suffix}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatRequestLink(request) {
|
|
143
|
+
return request.url ? `[${request.label}](${request.url})` : request.label;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function formatRequestEntry(request) {
|
|
147
|
+
return request.title ? `${formatRequestLink(request)} ${request.title}` : formatRequestLink(request);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildCommitUrlBuilder(remote) {
|
|
151
|
+
const parsed = parseGitRemote(remote);
|
|
152
|
+
if (!parsed) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const baseUrl = `https://${parsed.host}/${parsed.repo}`;
|
|
157
|
+
if (parsed.host === 'github.com') {
|
|
158
|
+
return (hash) => `${baseUrl}/commit/${hash}`;
|
|
159
|
+
}
|
|
160
|
+
if (parsed.host.includes('gitlab')) {
|
|
161
|
+
return (hash) => `${baseUrl}/-/commit/${hash}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildCompareUrlBuilder(remote) {
|
|
168
|
+
const parsed = parseGitRemote(remote);
|
|
169
|
+
if (!parsed) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const baseUrl = `https://${parsed.host}/${parsed.repo}`;
|
|
174
|
+
if (parsed.host === 'github.com') {
|
|
175
|
+
return (from, to) => `${baseUrl}/compare/${from}...${to}`;
|
|
176
|
+
}
|
|
177
|
+
if (parsed.host.includes('gitlab')) {
|
|
178
|
+
return (from, to) => `${baseUrl}/-/compare/${from}...${to}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildRequestUrlBuilder(remote) {
|
|
185
|
+
const parsed = parseGitRemote(remote);
|
|
186
|
+
if (!parsed) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const baseUrl = `https://${parsed.host}/${parsed.repo}`;
|
|
191
|
+
if (parsed.host === 'github.com') {
|
|
192
|
+
return (request) => request.provider === 'github' ? `${baseUrl}/pull/${request.number}` : null;
|
|
193
|
+
}
|
|
194
|
+
if (parsed.host.includes('gitlab')) {
|
|
195
|
+
return (request) => request.provider === 'gitlab' ? `${baseUrl}/-/merge_requests/${request.number}` : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function requestForCommit(commit, requestUrlBuilder) {
|
|
202
|
+
const request = parseRequestReference(`${commit.subject}\n${commit.body ?? ''}`);
|
|
203
|
+
if (!request) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
...request,
|
|
208
|
+
title: requestTitleForCommit(commit),
|
|
209
|
+
url: requestUrlBuilder ? requestUrlBuilder(request) : null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function requestTitleForCommit(commit) {
|
|
214
|
+
const conventionalSubject = conventionalSubjectForCommit(commit);
|
|
215
|
+
if (conventionalSubject) {
|
|
216
|
+
return conventionalSubject;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return commitLines(commit)
|
|
220
|
+
.find((line) => line && !parseRequestReference(line) && !/^Merge\b/i.test(line)) ?? null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseRequestReference(message) {
|
|
224
|
+
const gitlabMerge = /(?:^|\s)(?:See merge request\s+\S+!|!)(?<number>\d+)(?=\D|$)/i.exec(message);
|
|
225
|
+
if (gitlabMerge) {
|
|
226
|
+
return { provider: 'gitlab', number: gitlabMerge.groups.number, label: `!${gitlabMerge.groups.number}` };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const githubPull = /(?:Merge pull request\s+#|#)(?<number>\d+)(?=\D|$)/i.exec(message);
|
|
230
|
+
if (githubPull) {
|
|
231
|
+
return { provider: 'github', number: githubPull.groups.number, label: `#${githubPull.groups.number}` };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function uniqueRequests(requests) {
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
const unique = [];
|
|
240
|
+
for (const request of requests) {
|
|
241
|
+
const key = `${request.provider}:${request.number}`;
|
|
242
|
+
if (seen.has(key)) {
|
|
243
|
+
const existing = unique.find((candidate) => `${candidate.provider}:${candidate.number}` === key);
|
|
244
|
+
if (existing && !existing.title && request.title) {
|
|
245
|
+
existing.title = request.title;
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
seen.add(key);
|
|
250
|
+
unique.push(request);
|
|
251
|
+
}
|
|
252
|
+
return unique;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseGitRemote(remote) {
|
|
256
|
+
const sshMatch = /^git@(?<host>[^:]+):(?<repo>.+?)(?:\.git)?$/.exec(remote);
|
|
257
|
+
if (sshMatch) {
|
|
258
|
+
return sshMatch.groups;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const httpsMatch = /^https:\/\/(?<host>[^/]+)\/(?<repo>.+?)(?:\.git)?$/.exec(remote);
|
|
262
|
+
if (httpsMatch) {
|
|
263
|
+
return httpsMatch.groups;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return null;
|
|
267
|
+
}
|