calver-bump 0.1.4 → 0.1.5

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.5",
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,12 +144,13 @@ 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
156
  .filter((commit) => isConventionalCommit(commit.subject))
@@ -158,20 +158,23 @@ async function releaseNotes(cwd, options = {}) {
158
158
  .filter((commit) => !isCommitInChangelog(commit, options.existingChangelog ?? ''))
159
159
  .map((commit) => ({
160
160
  ...commit,
161
+ request: requestForCommit(commit, requestUrlBuilder),
161
162
  url: commitUrlBuilder ? commitUrlBuilder(commit.hash) : null,
162
163
  }));
164
+ const requests = uniqueRequests(commits.map((commit) => requestForCommit(commit, requestUrlBuilder)).filter(Boolean));
163
165
  return {
164
166
  previousTag: latestTag,
165
167
  compareUrlBuilder,
166
168
  changes: conventionalCommits.length > 0 ? conventionalCommits : ['No conventional commits in this release.'],
169
+ requests,
167
170
  };
168
171
  }
169
172
 
170
- function formatReleaseHeading({ version, date, previousTag, tag, compareUrlBuilder }) {
173
+ function formatReleaseHeading({ version, previousTag, tag, compareUrlBuilder }) {
171
174
  const label = previousTag && compareUrlBuilder
172
175
  ? `[${version}](${compareUrlBuilder(previousTag, tag)})`
173
176
  : version;
174
- return `## ${label} - ${isoDate(date)}`;
177
+ return `## ${label}`;
175
178
  }
176
179
 
177
180
  function isCommitInChangelog(commit, changelog) {
@@ -203,24 +206,41 @@ function formatReleaseNotes(changes) {
203
206
  .join('\n\n');
204
207
  }
205
208
 
209
+ function formatFullChangelog(requests) {
210
+ if (requests.length === 0) {
211
+ return '';
212
+ }
213
+ return `\n\n### Full Changelog\n\n${requests.map((request) => `- ${formatRequestEntry(request)}`).join('\n')}`;
214
+ }
215
+
206
216
  function conventionalType(subject) {
207
217
  return /^(?<type>[a-z]+)(\([^)]+\))?!?: .+/.exec(subject)?.groups.type;
208
218
  }
209
219
 
210
220
  function formatCommitEntry(commit) {
211
221
  const shortHash = commit.hash.slice(0, 7);
212
- const suffix = commit.url ? ` ([${shortHash}](${commit.url}))` : ` (${shortHash})`;
222
+ const suffix = commit.request
223
+ ? ` (${formatRequestLink(commit.request)})`
224
+ : commit.url ? ` ([${shortHash}](${commit.url}))` : ` (${shortHash})`;
213
225
  return `${commit.subject}${suffix}`;
214
226
  }
215
227
 
228
+ function formatRequestLink(request) {
229
+ return request.url ? `[${request.label}](${request.url})` : request.label;
230
+ }
231
+
232
+ function formatRequestEntry(request) {
233
+ return request.title ? `${formatRequestLink(request)} ${request.title}` : formatRequestLink(request);
234
+ }
235
+
216
236
  async function gitCommits(cwd, range) {
217
- const { stdout } = await git(cwd, ['log', '--pretty=format:%H%x00%s', ...range]);
237
+ const { stdout } = await git(cwd, ['log', '--pretty=format:%H%x00%s%x00%B%x1e', ...range]);
218
238
  return stdout
219
- .split('\n')
239
+ .split('\x1e')
220
240
  .filter(Boolean)
221
- .map((line) => {
222
- const [hash, subject] = line.split('\0');
223
- return { hash, subject };
241
+ .map((record) => {
242
+ const [hash, subject, body = ''] = record.replace(/^\n+|\n+$/g, '').split('\0');
243
+ return { hash, subject, body };
224
244
  });
225
245
  }
226
246
 
@@ -267,6 +287,78 @@ function buildCompareUrlBuilder(remote) {
267
287
  return null;
268
288
  }
269
289
 
290
+ function buildRequestUrlBuilder(remote) {
291
+ const parsed = parseGitRemote(remote);
292
+ if (!parsed) {
293
+ return null;
294
+ }
295
+
296
+ const baseUrl = `https://${parsed.host}/${parsed.repo}`;
297
+ if (parsed.host === 'github.com') {
298
+ return (request) => request.provider === 'github' ? `${baseUrl}/pull/${request.number}` : null;
299
+ }
300
+ if (parsed.host.includes('gitlab')) {
301
+ return (request) => request.provider === 'gitlab' ? `${baseUrl}/-/merge_requests/${request.number}` : null;
302
+ }
303
+
304
+ return null;
305
+ }
306
+
307
+ function requestForCommit(commit, requestUrlBuilder) {
308
+ const request = parseRequestReference(`${commit.subject}\n${commit.body ?? ''}`);
309
+ if (!request) {
310
+ return null;
311
+ }
312
+ return {
313
+ ...request,
314
+ title: requestTitleForCommit(commit),
315
+ url: requestUrlBuilder ? requestUrlBuilder(request) : null,
316
+ };
317
+ }
318
+
319
+ function requestTitleForCommit(commit) {
320
+ if (isConventionalCommit(commit.subject)) {
321
+ return commit.subject;
322
+ }
323
+
324
+ return (commit.body ?? '')
325
+ .split('\n')
326
+ .map((line) => line.trim())
327
+ .find((line) => line && !parseRequestReference(line) && !/^Merge\b/i.test(line)) ?? null;
328
+ }
329
+
330
+ function parseRequestReference(message) {
331
+ const gitlabMerge = /(?:^|\s)(?:See merge request\s+\S+!|!)(?<number>\d+)(?=\D|$)/i.exec(message);
332
+ if (gitlabMerge) {
333
+ return { provider: 'gitlab', number: gitlabMerge.groups.number, label: `!${gitlabMerge.groups.number}` };
334
+ }
335
+
336
+ const githubPull = /(?:Merge pull request\s+#|#)(?<number>\d+)(?=\D|$)/i.exec(message);
337
+ if (githubPull) {
338
+ return { provider: 'github', number: githubPull.groups.number, label: `#${githubPull.groups.number}` };
339
+ }
340
+
341
+ return null;
342
+ }
343
+
344
+ function uniqueRequests(requests) {
345
+ const seen = new Set();
346
+ const unique = [];
347
+ for (const request of requests) {
348
+ const key = `${request.provider}:${request.number}`;
349
+ if (seen.has(key)) {
350
+ const existing = unique.find((candidate) => `${candidate.provider}:${candidate.number}` === key);
351
+ if (existing && !existing.title && request.title) {
352
+ existing.title = request.title;
353
+ }
354
+ continue;
355
+ }
356
+ seen.add(key);
357
+ unique.push(request);
358
+ }
359
+ return unique;
360
+ }
361
+
270
362
  function parseGitRemote(remote) {
271
363
  const sshMatch = /^git@(?<host>[^:]+):(?<repo>.+?)(?:\.git)?$/.exec(remote);
272
364
  if (sshMatch) {
@@ -281,6 +373,28 @@ function parseGitRemote(remote) {
281
373
  return null;
282
374
  }
283
375
 
376
+ async function latestReleaseTag(cwd, changelog) {
377
+ const changelogTag = latestChangelogCompareTarget(changelog);
378
+ if (changelogTag && await tagExists(cwd, changelogTag)) {
379
+ return changelogTag;
380
+ }
381
+ return latestReachableTag(cwd);
382
+ }
383
+
384
+ function latestChangelogCompareTarget(changelog) {
385
+ const match = /^## \[[^\]]+\]\([^)]*\/(?:-\/)?compare\/[^)]*?\.{3}(?<tag>[^)\s]+)\)/m.exec(changelog);
386
+ return match?.groups.tag ?? null;
387
+ }
388
+
389
+ async function tagExists(cwd, tag) {
390
+ try {
391
+ await git(cwd, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}^{}`]);
392
+ return true;
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
284
398
  async function latestReachableTag(cwd) {
285
399
  try {
286
400
  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,72 @@ 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
+ 'fix(review): block rule-listed reviewers',
273
+ '-m',
274
+ 'See merge request platform/demo-app!77',
275
+ ], { cwd: repo });
276
+ await writeFile(path.join(repo, 'merge.txt'), 'merge\n');
277
+ execFileSync('git', ['add', 'merge.txt'], { cwd: repo });
278
+ execFileSync('git', [
279
+ 'commit',
280
+ '-m',
281
+ 'Merge branch feature/review into main',
282
+ '-m',
283
+ 'See merge request platform/demo-app!77',
284
+ ], { cwd: repo });
285
+
286
+ await runRelease({
287
+ cwd: repo,
288
+ date: new Date('2026-05-29T12:00:00-07:00'),
289
+ });
290
+
291
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
292
+ assert.match(
293
+ changelog,
294
+ /- fix\(review\): block rule-listed reviewers \(\[!77\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/merge_requests\/77\)\)/,
295
+ );
296
+ const fullChangelogEntries = changelog.match(/\[!77\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/merge_requests\/77\)/g) ?? [];
297
+ assert.equal(fullChangelogEntries.length, 2);
298
+ assert.match(
299
+ changelog,
300
+ /### Full Changelog\n\n- \[!77\]\(https:\/\/gitlab\.internal\.example\.com\/platform\/demo-app\/-\/merge_requests\/77\) fix\(review\): block rule-listed reviewers/,
236
301
  );
237
302
  });
238
303
 
@@ -252,8 +317,8 @@ test('runRelease prepends only commits since the previous CalVer tag on later re
252
317
  });
253
318
 
254
319
  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/);
320
+ const latestEntry = changelog.split('\n## 26.0529\n')[0];
321
+ assert.match(latestEntry, /## 26\.0529\.1/);
257
322
  assert.match(latestEntry, /- fix: second release only/);
258
323
  assert.doesNotMatch(latestEntry, /feat: initial app/);
259
324
  });
@@ -352,6 +417,40 @@ test('runRelease does not duplicate commits already present in an existing chang
352
417
  assert.doesNotMatch(latestEntry, /feat: already documented/);
353
418
  });
354
419
 
420
+ test('runRelease uses the previous changelog compare target for the new compare link', async () => {
421
+ const repo = await makeRepo();
422
+ execFileSync('git', ['remote', 'add', 'origin', 'git@gitlab.ops:pss/d2pass/d2p_next.git'], { cwd: repo });
423
+ execFileSync('git', ['tag', 'v1.34.0'], { cwd: repo });
424
+ await writeFile(path.join(repo, 'released.txt'), 'released\n');
425
+ execFileSync('git', ['add', 'released.txt'], { cwd: repo });
426
+ execFileSync('git', ['commit', '-m', 'feat: already documented release'], { cwd: repo });
427
+ const releaseHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
428
+ await writeFile(
429
+ path.join(repo, 'CHANGELOG.md'),
430
+ '# 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',
431
+ );
432
+ await writeFile(path.join(repo, 'unreleased.txt'), 'unreleased\n');
433
+ execFileSync('git', ['add', 'CHANGELOG.md', 'unreleased.txt'], { cwd: repo });
434
+ execFileSync('git', ['commit', '-m', 'fix: new release change'], { cwd: repo });
435
+ execFileSync('git', ['tag', 'v1.35.0', releaseHead], { cwd: repo });
436
+
437
+ await runRelease({
438
+ cwd: repo,
439
+ date: new Date('2026-06-02T12:00:00-07:00'),
440
+ tagPrefix: 'v',
441
+ types: ['feat', 'fix'],
442
+ });
443
+
444
+ const changelog = await readFile(path.join(repo, 'CHANGELOG.md'), 'utf8');
445
+ assert.match(
446
+ changelog,
447
+ /^# Changelog\n\n## \[26\.0602\]\(https:\/\/gitlab\.ops\/pss\/d2pass\/d2p_next\/-\/compare\/v1\.35\.0\.\.\.v26\.0602\)/,
448
+ );
449
+ const latestEntry = changelog.split('## [1.35.0]')[0];
450
+ assert.match(latestEntry, /- fix: new release change/);
451
+ assert.doesNotMatch(latestEntry, /feat: already documented release/);
452
+ });
453
+
355
454
  test('runRelease rolls back its release commit when tag creation fails', async () => {
356
455
  const repo = await makeRepo();
357
456
  execFileSync('git', ['tag', '26.0529'], { cwd: repo });