calver-bump 0.1.4 → 0.1.6

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
@@ -94,11 +94,14 @@ Project defaults can be stored in `.calverbumprc.json`:
94
94
  - The default `short` format is `YY.MMDD` for the first release of the day, then `YY.MMDD.1`, `YY.MMDD.2`, etc.
95
95
  - The optional `compact` format is `YYMMDD` for the first release of the day, then `YYMMDD.1`, `YYMMDD.2`, etc.
96
96
  - The optional `long` format is `YYYY.MM.DD` for the first release of the day, then `YYYY.MM.DD.1`, `YYYY.MM.DD.2`, etc.
97
+ - Changelog release headings use the CalVer version only and do not append a separate `YYYY-MM-DD` date.
97
98
  - Existing `v`-prefixed tags are considered when calculating the next sequence number.
98
99
  - Changelog ranges start from the nearest reachable tag, even when it is not a CalVer tag.
99
100
  - Changelog entries include conventional commit subjects only, such as `feat:`, `fix(scope):`, or `chore!:`.
100
101
  - Changelog entries are grouped into `Features`, `Fixes`, and `Other Changes`.
101
- - Changelog entries link to their commit hash for GitHub and GitLab-style `origin` remotes.
102
+ - 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
+ - Changelog entries fall back to commit hash links for GitHub and GitLab-style remotes when no pull/merge request reference is found.
104
+ - 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.
102
105
  - Later releases prepend only commits since the previous nearest reachable tag.
103
106
  - Release tags are annotated so `git push --follow-tags <remote> <branch>` pushes them.
104
107
  - The working tree must be clean before creating a real release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "calver-bump",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Release CLI for internal applications using readable CalVer versions.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -3,7 +3,7 @@ import { access, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
 
6
- import { isoDate, nextCalVer } from './calver.js';
6
+ import { nextCalVer } from './calver.js';
7
7
 
8
8
  const execFile = promisify(execFileCallback);
9
9
 
@@ -129,12 +129,11 @@ async function prependChangelog(cwd, version, date, options = {}) {
129
129
  const notes = await releaseNotes(cwd, { ...options, existingChangelog: existing });
130
130
  const heading = formatReleaseHeading({
131
131
  version,
132
- date,
133
132
  previousTag: notes.previousTag,
134
133
  tag: `${options.tagPrefix ?? ''}${version}`,
135
134
  compareUrlBuilder: notes.compareUrlBuilder,
136
135
  });
137
- const entry = `${heading}\n\n${formatReleaseNotes(notes.changes)}\n`;
136
+ const entry = `${heading}\n\n${formatReleaseNotes(notes.changes)}${formatFullChangelog(notes.requests)}\n`;
138
137
 
139
138
  const body = existing.trim().startsWith('# Changelog')
140
139
  ? existing.replace(/^# Changelog\s*/, `# Changelog\n\n${entry}\n`)
@@ -145,33 +144,38 @@ async function prependChangelog(cwd, version, date, options = {}) {
145
144
 
146
145
  async function releaseNotes(cwd, options = {}) {
147
146
  await fetchTags(cwd, options.remote ?? 'origin');
148
- const latestTag = await latestReachableTag(cwd);
147
+ const latestTag = await latestReleaseTag(cwd, options.existingChangelog ?? '');
149
148
  const range = latestTag ? [`${latestTag}..HEAD`] : [];
150
149
  const commits = await gitCommits(cwd, range);
151
150
  const remoteUrl = await getRemoteUrl(cwd, options.remote ?? 'origin');
152
151
  const commitUrlBuilder = remoteUrl ? buildCommitUrlBuilder(remoteUrl) : null;
153
152
  const compareUrlBuilder = remoteUrl ? buildCompareUrlBuilder(remoteUrl) : null;
153
+ const requestUrlBuilder = remoteUrl ? buildRequestUrlBuilder(remoteUrl) : null;
154
154
  const allowedTypes = options.types ?? ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'];
155
155
  const conventionalCommits = commits
156
+ .map((commit) => ({ ...commit, subject: conventionalSubjectForCommit(commit) ?? commit.subject }))
156
157
  .filter((commit) => isConventionalCommit(commit.subject))
157
158
  .filter((commit) => allowedTypes.includes(conventionalType(commit.subject)))
158
159
  .filter((commit) => !isCommitInChangelog(commit, options.existingChangelog ?? ''))
159
160
  .map((commit) => ({
160
161
  ...commit,
162
+ request: requestForCommit(commit, requestUrlBuilder),
161
163
  url: commitUrlBuilder ? commitUrlBuilder(commit.hash) : null,
162
164
  }));
165
+ const requests = uniqueRequests(commits.map((commit) => requestForCommit(commit, requestUrlBuilder)).filter(Boolean));
163
166
  return {
164
167
  previousTag: latestTag,
165
168
  compareUrlBuilder,
166
169
  changes: conventionalCommits.length > 0 ? conventionalCommits : ['No conventional commits in this release.'],
170
+ requests,
167
171
  };
168
172
  }
169
173
 
170
- function formatReleaseHeading({ version, date, previousTag, tag, compareUrlBuilder }) {
174
+ function formatReleaseHeading({ version, previousTag, tag, compareUrlBuilder }) {
171
175
  const label = previousTag && compareUrlBuilder
172
176
  ? `[${version}](${compareUrlBuilder(previousTag, tag)})`
173
177
  : version;
174
- return `## ${label} - ${isoDate(date)}`;
178
+ return `## ${label}`;
175
179
  }
176
180
 
177
181
  function isCommitInChangelog(commit, changelog) {
@@ -183,6 +187,16 @@ function isConventionalCommit(subject) {
183
187
  return /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .+/.test(subject);
184
188
  }
185
189
 
190
+ function conventionalSubjectForCommit(commit) {
191
+ return commitLines(commit).find((line) => isConventionalCommit(line)) ?? null;
192
+ }
193
+
194
+ function commitLines(commit) {
195
+ return [commit.subject, ...(commit.body ?? '').split('\n')]
196
+ .map((line) => line.trim())
197
+ .filter(Boolean);
198
+ }
199
+
186
200
  function formatReleaseNotes(changes) {
187
201
  if (changes.length === 1 && changes[0] === 'No conventional commits in this release.') {
188
202
  return `- ${changes[0]}`;
@@ -203,24 +217,41 @@ function formatReleaseNotes(changes) {
203
217
  .join('\n\n');
204
218
  }
205
219
 
220
+ function formatFullChangelog(requests) {
221
+ if (requests.length === 0) {
222
+ return '';
223
+ }
224
+ return `\n\n### Full Changelog\n\n${requests.map((request) => `- ${formatRequestEntry(request)}`).join('\n')}`;
225
+ }
226
+
206
227
  function conventionalType(subject) {
207
228
  return /^(?<type>[a-z]+)(\([^)]+\))?!?: .+/.exec(subject)?.groups.type;
208
229
  }
209
230
 
210
231
  function formatCommitEntry(commit) {
211
232
  const shortHash = commit.hash.slice(0, 7);
212
- const suffix = commit.url ? ` ([${shortHash}](${commit.url}))` : ` (${shortHash})`;
233
+ const suffix = commit.request
234
+ ? ` (${formatRequestLink(commit.request)})`
235
+ : commit.url ? ` ([${shortHash}](${commit.url}))` : ` (${shortHash})`;
213
236
  return `${commit.subject}${suffix}`;
214
237
  }
215
238
 
239
+ function formatRequestLink(request) {
240
+ return request.url ? `[${request.label}](${request.url})` : request.label;
241
+ }
242
+
243
+ function formatRequestEntry(request) {
244
+ return request.title ? `${formatRequestLink(request)} ${request.title}` : formatRequestLink(request);
245
+ }
246
+
216
247
  async function gitCommits(cwd, range) {
217
- const { stdout } = await git(cwd, ['log', '--pretty=format:%H%x00%s', ...range]);
248
+ const { stdout } = await git(cwd, ['log', '--pretty=format:%H%x00%s%x00%B%x1e', ...range]);
218
249
  return stdout
219
- .split('\n')
250
+ .split('\x1e')
220
251
  .filter(Boolean)
221
- .map((line) => {
222
- const [hash, subject] = line.split('\0');
223
- return { hash, subject };
252
+ .map((record) => {
253
+ const [hash, subject, body = ''] = record.replace(/^\n+|\n+$/g, '').split('\0');
254
+ return { hash, subject, body };
224
255
  });
225
256
  }
226
257
 
@@ -267,6 +298,77 @@ function buildCompareUrlBuilder(remote) {
267
298
  return null;
268
299
  }
269
300
 
301
+ function buildRequestUrlBuilder(remote) {
302
+ const parsed = parseGitRemote(remote);
303
+ if (!parsed) {
304
+ return null;
305
+ }
306
+
307
+ const baseUrl = `https://${parsed.host}/${parsed.repo}`;
308
+ if (parsed.host === 'github.com') {
309
+ return (request) => request.provider === 'github' ? `${baseUrl}/pull/${request.number}` : null;
310
+ }
311
+ if (parsed.host.includes('gitlab')) {
312
+ return (request) => request.provider === 'gitlab' ? `${baseUrl}/-/merge_requests/${request.number}` : null;
313
+ }
314
+
315
+ return null;
316
+ }
317
+
318
+ function requestForCommit(commit, requestUrlBuilder) {
319
+ const request = parseRequestReference(`${commit.subject}\n${commit.body ?? ''}`);
320
+ if (!request) {
321
+ return null;
322
+ }
323
+ return {
324
+ ...request,
325
+ title: requestTitleForCommit(commit),
326
+ url: requestUrlBuilder ? requestUrlBuilder(request) : null,
327
+ };
328
+ }
329
+
330
+ function requestTitleForCommit(commit) {
331
+ const conventionalSubject = conventionalSubjectForCommit(commit);
332
+ if (conventionalSubject) {
333
+ return conventionalSubject;
334
+ }
335
+
336
+ return commitLines(commit)
337
+ .find((line) => line && !parseRequestReference(line) && !/^Merge\b/i.test(line)) ?? null;
338
+ }
339
+
340
+ function parseRequestReference(message) {
341
+ const gitlabMerge = /(?:^|\s)(?:See merge request\s+\S+!|!)(?<number>\d+)(?=\D|$)/i.exec(message);
342
+ if (gitlabMerge) {
343
+ return { provider: 'gitlab', number: gitlabMerge.groups.number, label: `!${gitlabMerge.groups.number}` };
344
+ }
345
+
346
+ const githubPull = /(?:Merge pull request\s+#|#)(?<number>\d+)(?=\D|$)/i.exec(message);
347
+ if (githubPull) {
348
+ return { provider: 'github', number: githubPull.groups.number, label: `#${githubPull.groups.number}` };
349
+ }
350
+
351
+ return null;
352
+ }
353
+
354
+ function uniqueRequests(requests) {
355
+ const seen = new Set();
356
+ const unique = [];
357
+ for (const request of requests) {
358
+ const key = `${request.provider}:${request.number}`;
359
+ if (seen.has(key)) {
360
+ const existing = unique.find((candidate) => `${candidate.provider}:${candidate.number}` === key);
361
+ if (existing && !existing.title && request.title) {
362
+ existing.title = request.title;
363
+ }
364
+ continue;
365
+ }
366
+ seen.add(key);
367
+ unique.push(request);
368
+ }
369
+ return unique;
370
+ }
371
+
270
372
  function parseGitRemote(remote) {
271
373
  const sshMatch = /^git@(?<host>[^:]+):(?<repo>.+?)(?:\.git)?$/.exec(remote);
272
374
  if (sshMatch) {
@@ -281,6 +383,28 @@ function parseGitRemote(remote) {
281
383
  return null;
282
384
  }
283
385
 
386
+ async function latestReleaseTag(cwd, changelog) {
387
+ const changelogTag = latestChangelogCompareTarget(changelog);
388
+ if (changelogTag && await tagExists(cwd, changelogTag)) {
389
+ return changelogTag;
390
+ }
391
+ return latestReachableTag(cwd);
392
+ }
393
+
394
+ function latestChangelogCompareTarget(changelog) {
395
+ const match = /^## \[[^\]]+\]\([^)]*\/(?:-\/)?compare\/[^)]*?\.{3}(?<tag>[^)\s]+)\)/m.exec(changelog);
396
+ return match?.groups.tag ?? null;
397
+ }
398
+
399
+ async function tagExists(cwd, tag) {
400
+ try {
401
+ await git(cwd, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}^{}`]);
402
+ return true;
403
+ } catch {
404
+ return false;
405
+ }
406
+ }
407
+
284
408
  async function latestReachableTag(cwd) {
285
409
  try {
286
410
  const { stdout } = await git(cwd, ['describe', '--tags', '--abbrev=0']);
@@ -64,7 +64,7 @@ test('runRelease updates package.json, prepends changelog, commits, and tags', a
64
64
  assert.equal(pkg.version, '26.0529');
65
65
 
66
66
  const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
67
- assert.match(changelog, /^# Changelog\n\n## 26\.0529 - 2026-05-29\n\n### Features\n\n- feat: initial app/);
67
+ assert.match(changelog, /^# Changelog\n\n## 26\.0529\n\n### Features\n\n- feat: initial app/);
68
68
 
69
69
  const tag = execFileSync('git', ['tag', '--list', '26.0529'], {
70
70
  cwd: repo,
@@ -180,7 +180,7 @@ test('runRelease groups changelog entries by conventional commit type', async ()
180
180
  const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
181
181
  assert.match(
182
182
  changelog,
183
- /## 26\.0529 - 2026-05-29\n\n### Features\n\n- feat: add release grouping \([a-f0-9]{7}\)\n- feat: initial app \([a-f0-9]{7}\)\n\n### Fixes\n\n- fix\(auth\): repair token refresh \([a-f0-9]{7}\)/,
183
+ /## 26\.0529\n\n### Features\n\n- feat: add release grouping \([a-f0-9]{7}\)\n- feat: initial app \([a-f0-9]{7}\)\n\n### Fixes\n\n- fix\(auth\): repair token refresh \([a-f0-9]{7}\)/,
184
184
  );
185
185
  });
186
186
 
@@ -206,7 +206,7 @@ test('runRelease links each changelog entry to its commit on GitHub', async () =
206
206
  );
207
207
  assert.match(
208
208
  changelog,
209
- /^# Changelog\n\n## \[26\.0529\]\(https:\/\/github\.com\/msako\/demo-app\/compare\/v1\.0\.0\.\.\.26\.0529\) - 2026-05-29/,
209
+ /^# Changelog\n\n## \[26\.0529\]\(https:\/\/github\.com\/msako\/demo-app\/compare\/v1\.0\.0\.\.\.26\.0529\)/,
210
210
  );
211
211
  });
212
212
 
@@ -232,7 +232,74 @@ test('runRelease links changelog entries for private GitLab-style remotes', asyn
232
232
  );
233
233
  assert.match(
234
234
  changelog,
235
- /^# Changelog\n\n## \[26\.0529\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/compare\/v1\.0\.0\.\.\.26\.0529\) - 2026-05-29/,
235
+ /^# Changelog\n\n## \[26\.0529\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/compare\/v1\.0\.0\.\.\.26\.0529\)/,
236
+ );
237
+ });
238
+
239
+ test('runRelease links changelog entries to GitHub pull requests when commit subjects include PR numbers', async () => {
240
+ const repo = await makeRepo();
241
+ execFileSync('git', ['remote', 'add', 'origin', 'git@github.com:msako/demo-app.git'], { cwd: repo });
242
+ execFileSync('git', ['tag', 'v1.0.0'], { cwd: repo });
243
+ await writeFile(path.join(repo, 'feature.txt'), 'feature\n');
244
+ execFileSync('git', ['add', 'feature.txt'], { cwd: repo });
245
+ execFileSync('git', ['commit', '-m', 'feat: add pull request links (#42)'], { cwd: repo });
246
+
247
+ await runRelease({
248
+ cwd: repo,
249
+ date: new Date('2026-05-29T12:00:00-07:00'),
250
+ });
251
+
252
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
253
+ assert.match(
254
+ changelog,
255
+ /- feat: add pull request links \(#42\) \(\[#42\]\(https:\/\/github\.com\/msako\/demo-app\/pull\/42\)\)/,
256
+ );
257
+ assert.match(
258
+ changelog,
259
+ /### Full Changelog\n\n- \[#42\]\(https:\/\/github\.com\/msako\/demo-app\/pull\/42\) feat: add pull request links \(#42\)/,
260
+ );
261
+ });
262
+
263
+ test('runRelease links changelog entries to GitLab merge requests and dedupes the full changelog', async () => {
264
+ const repo = await makeRepo();
265
+ execFileSync('git', ['remote', 'add', 'origin', 'git@gitlab.internal.example.com:platform/demo-app.git'], { cwd: repo });
266
+ execFileSync('git', ['tag', 'v1.0.0'], { cwd: repo });
267
+ await writeFile(path.join(repo, 'fix.txt'), 'fix\n');
268
+ execFileSync('git', ['add', 'fix.txt'], { cwd: repo });
269
+ execFileSync('git', [
270
+ 'commit',
271
+ '-m',
272
+ 'Merge branch feature/review into main',
273
+ '-m',
274
+ 'fix(review): block rule-listed reviewers',
275
+ '-m',
276
+ 'See merge request platform/demo-app!77',
277
+ ], { cwd: repo });
278
+ await writeFile(path.join(repo, 'merge.txt'), 'merge\n');
279
+ execFileSync('git', ['add', 'merge.txt'], { cwd: repo });
280
+ execFileSync('git', [
281
+ 'commit',
282
+ '-m',
283
+ 'Merge branch feature/review into main',
284
+ '-m',
285
+ 'See merge request platform/demo-app!77',
286
+ ], { cwd: repo });
287
+
288
+ await runRelease({
289
+ cwd: repo,
290
+ date: new Date('2026-05-29T12:00:00-07:00'),
291
+ });
292
+
293
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
294
+ assert.match(
295
+ changelog,
296
+ /- fix\(review\): block rule-listed reviewers \(\[!77\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/merge_requests\/77\)\)/,
297
+ );
298
+ const fullChangelogEntries = changelog.match(/\[!77\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/merge_requests\/77\)/g) ?? [];
299
+ assert.equal(fullChangelogEntries.length, 2);
300
+ assert.match(
301
+ changelog,
302
+ /### Full Changelog\n\n- \[!77\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/merge_requests\/77\) fix\(review\): block rule-listed reviewers/,
236
303
  );
237
304
  });
238
305
 
@@ -252,8 +319,8 @@ test('runRelease prepends only commits since the previous CalVer tag on later re
252
319
  });
253
320
 
254
321
  const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
255
- const latestEntry = changelog.split('## 26.0529 - 2026-05-29')[0];
256
- assert.match(latestEntry, /## 26\.0529\.1 - 2026-05-29/);
322
+ const latestEntry = changelog.split('\n## 26.0529\n')[0];
323
+ assert.match(latestEntry, /## 26\.0529\.1/);
257
324
  assert.match(latestEntry, /- fix: second release only/);
258
325
  assert.doesNotMatch(latestEntry, /feat: initial app/);
259
326
  });
@@ -352,6 +419,40 @@ test('runRelease does not duplicate commits already present in an existing chang
352
419
  assert.doesNotMatch(latestEntry, /feat: already documented/);
353
420
  });
354
421
 
422
+ test('runRelease uses the previous changelog compare target for the new compare link', async () => {
423
+ const repo = await makeRepo();
424
+ execFileSync('git', ['remote', 'add', 'origin', 'git@gitlab.ops:pss/d2pass/d2p_next.git'], { cwd: repo });
425
+ execFileSync('git', ['tag', 'v1.34.0'], { cwd: repo });
426
+ await writeFile(path.join(repo, 'released.txt'), 'released\n');
427
+ execFileSync('git', ['add', 'released.txt'], { cwd: repo });
428
+ execFileSync('git', ['commit', '-m', 'feat: already documented release'], { cwd: repo });
429
+ const releaseHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
430
+ await writeFile(
431
+ path.join(repo, 'CHANGELOG.md'),
432
+ '# Changelog\n\n## [1.35.0](https://gitlab.ops/pss/d2pass/d2p_next/compare/v1.34.0...v1.35.0) (2026-06-01)\n\n### Features\n\n* **site-map:** already documented release\n',
433
+ );
434
+ await writeFile(path.join(repo, 'unreleased.txt'), 'unreleased\n');
435
+ execFileSync('git', ['add', 'CHANGELOG.md', 'unreleased.txt'], { cwd: repo });
436
+ execFileSync('git', ['commit', '-m', 'fix: new release change'], { cwd: repo });
437
+ execFileSync('git', ['tag', 'v1.35.0', releaseHead], { cwd: repo });
438
+
439
+ await runRelease({
440
+ cwd: repo,
441
+ date: new Date('2026-06-02T12:00:00-07:00'),
442
+ tagPrefix: 'v',
443
+ types: ['feat', 'fix'],
444
+ });
445
+
446
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
447
+ assert.match(
448
+ changelog,
449
+ /^# Changelog\n\n## \[26\.0602\]\(https:\/\/gitlab\.ops\/pss\/d2pass\/d2p_next\/-\/compare\/v1\.35\.0\.\.\.v26\.0602\)/,
450
+ );
451
+ const latestEntry = changelog.split('## [1.35.0]')[0];
452
+ assert.match(latestEntry, /- fix: new release change/);
453
+ assert.doesNotMatch(latestEntry, /feat: already documented release/);
454
+ });
455
+
355
456
  test('runRelease rolls back its release commit when tag creation fails', async () => {
356
457
  const repo = await makeRepo();
357
458
  execFileSync('git', ['tag', '26.0529'], { cwd: repo });