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 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. Creates or prepends a `CHANGELOG.md` entry from conventional commits since the nearest reachable tag.
22
- 4. Creates a release commit.
23
- 5. Creates an annotated git tag.
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
- Update files without creating a release commit or tag:
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
- - If tag creation fails after the release commit, the CLI rolls back its own release commit.
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.
@@ -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
- console.log(`Release version: ${result.version}`);
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 && !options.skipCommit) {
59
+ } else if (!options.dryRun) {
26
60
  console.log('');
27
61
  console.log('Next steps:');
28
- console.log('1. Review the release commit:');
29
- console.log(' git show --stat HEAD');
30
- console.log('2. Push the release commit and tag:');
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
- push: flag(args, '--push') ?? config.push ?? false,
45
- skipCommit: flag(args, '--skip-commit') ?? config.skipCommit ?? false,
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.1.7",
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": ">=20"
35
+ "node": "^22.0.0 || ^24.0.0"
23
36
  }
24
37
  }
@@ -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
+ }