changelog-machine 1.1.0 → 2.1.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/LICENSE.md +1 -1
- package/README.md +149 -75
- package/bin/cli.js +5 -1
- package/lib/changelog/contributors.d.ts +2 -0
- package/lib/changelog/contributors.js +31 -0
- package/lib/changelog/links.d.ts +31 -0
- package/lib/changelog/links.js +83 -0
- package/lib/changelog/render.d.ts +2 -0
- package/lib/changelog/render.js +69 -0
- package/lib/changelog/text.d.ts +5 -0
- package/lib/changelog/text.js +23 -0
- package/lib/changelog/update.d.ts +7 -0
- package/lib/changelog/update.js +119 -0
- package/lib/cli/files.d.ts +5 -0
- package/lib/cli/files.js +37 -0
- package/lib/cli/index.d.ts +1 -1
- package/lib/cli/index.js +68 -39
- package/lib/cli/options.d.ts +14 -0
- package/lib/cli/options.js +57 -0
- package/lib/cli/plans.d.ts +7 -0
- package/lib/cli/plans.js +67 -0
- package/lib/cli/progress.d.ts +2 -0
- package/lib/cli/progress.js +37 -0
- package/lib/commits/parse.d.ts +3 -0
- package/lib/commits/parse.js +87 -0
- package/lib/contributors/authors.d.ts +6 -0
- package/lib/contributors/authors.js +20 -0
- package/lib/contributors/privacy.d.ts +2 -0
- package/lib/contributors/privacy.js +9 -0
- package/lib/contributors/profile.d.ts +5 -0
- package/lib/contributors/profile.js +51 -0
- package/lib/contributors/repository.d.ts +7 -0
- package/lib/contributors/repository.js +36 -0
- package/lib/contributors/resolve.d.ts +10 -0
- package/lib/contributors/resolve.js +86 -0
- package/lib/git/command.d.ts +5 -0
- package/lib/git/command.js +39 -0
- package/lib/git/commits.d.ts +2 -0
- package/lib/git/commits.js +27 -0
- package/lib/git/history.d.ts +3 -0
- package/lib/git/history.js +41 -0
- package/lib/git/refs.d.ts +12 -0
- package/lib/git/refs.js +101 -0
- package/lib/git/remote.d.ts +1 -0
- package/lib/git/remote.js +59 -0
- package/lib/git/tags.d.ts +1 -0
- package/lib/git/tags.js +27 -0
- package/lib/index.d.ts +8 -1
- package/lib/index.js +6 -13
- package/lib/types.d.ts +31 -0
- package/lib/types.js +1 -0
- package/lib/version/command.d.ts +5 -0
- package/lib/version/command.js +52 -0
- package/lib/version/json.d.ts +5 -0
- package/lib/version/json.js +35 -0
- package/lib/version/plan.d.ts +11 -0
- package/lib/version/plan.js +41 -0
- package/package.json +33 -41
- package/lib/cli/commands/printHelp.d.ts +0 -2
- package/lib/cli/commands/printHelp.js +0 -15
- package/lib/cli/commands/printMarkdown.d.ts +0 -3
- package/lib/cli/commands/printMarkdown.js +0 -26
- package/lib/cli/commands/printVersion.d.ts +0 -2
- package/lib/cli/commands/printVersion.js +0 -12
- package/lib/cli/config/options.d.ts +0 -3
- package/lib/cli/config/options.js +0 -13
- package/lib/cli/config/readConfigFile.d.ts +0 -3
- package/lib/cli/config/readConfigFile.js +0 -25
- package/lib/core/async.d.ts +0 -9
- package/lib/core/async.js +0 -32
- package/lib/core/default.d.ts +0 -6
- package/lib/core/default.js +0 -9
- package/lib/core/git.d.ts +0 -8
- package/lib/core/git.js +0 -88
- package/lib/core/markdown.d.ts +0 -8
- package/lib/core/markdown.js +0 -85
- package/lib/core/test-helpers.d.ts +0 -6
- package/lib/core/test-helpers.js +0 -14
- package/lib/models/commit.d.ts +0 -6
- package/lib/models/commit.js +0 -2
- package/lib/models/config.d.ts +0 -30
- package/lib/models/config.js +0 -2
- package/lib/models/index.d.ts +0 -3
- package/lib/models/index.js +0 -15
- package/lib/models/tagGroup.d.ts +0 -7
- package/lib/models/tagGroup.js +0 -2
package/LICENSE.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
The MIT License (MIT)
|
|
2
|
-
Copyright (c)
|
|
2
|
+
Copyright (c) 2026 Philipp Kief
|
|
3
3
|
|
|
4
4
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
5
5
|
|
package/README.md
CHANGED
|
@@ -1,108 +1,182 @@
|
|
|
1
|
-
|
|
2
|
-
<br>
|
|
3
|
-
<img src="https://github.com/PKief/changelog-machine/raw/main/logo.png" alt="logo" width="200">
|
|
4
|
-
<br><br>
|
|
5
|
-
Changelog Machine
|
|
6
|
-
<br>
|
|
7
|
-
<br>
|
|
8
|
-
</h1>
|
|
1
|
+
# Changelog Machine
|
|
9
2
|
|
|
10
|
-
|
|
3
|
+
Conventional changelogs and version bumps, with **zero production dependencies**.
|
|
4
|
+
Requires **Node 24+** and **Git**.
|
|
11
5
|
|
|
12
|
-
##
|
|
6
|
+
## Quick start
|
|
13
7
|
|
|
14
|
-
|
|
8
|
+
Run inside your repository:
|
|
15
9
|
|
|
16
|
-
```
|
|
17
|
-
npx changelog-machine
|
|
10
|
+
```sh
|
|
11
|
+
npx changelog-machine # Create or update CHANGELOG.md
|
|
12
|
+
npx changelog-machine --dry-run # Preview changes
|
|
13
|
+
npx changelog-machine --full # Rebuild from scratch
|
|
14
|
+
npx changelog-machine --bump --dry-run # Preview the next version
|
|
15
|
+
npx changelog-machine --bump # Update package metadata and release changelog
|
|
18
16
|
```
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
- **Default:** add newer releases, refresh `Unreleased`, preserve older sections.
|
|
19
|
+
- **Missing file:** generate the entire history.
|
|
20
|
+
- **`--full`:** replace all content, including manual edits.
|
|
21
|
+
- No automatic Git fetches, commits, tags, pushes, or publishing.
|
|
21
22
|
|
|
22
|
-
##
|
|
23
|
+
## Options
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
| Option | Purpose |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `--full` | Rebuild the entire changelog |
|
|
28
|
+
| `--bump` | Infer and apply a version bump |
|
|
29
|
+
| `--bump-command <command>` | Override the metadata update command; requires `--bump` and `{version}` |
|
|
30
|
+
| `--dry-run` | Preview without writing |
|
|
31
|
+
| `--output <path>` | Set the changelog destination |
|
|
32
|
+
| `--repo-url <url>` | Override repository links with an HTTPS URL |
|
|
33
|
+
| `--resolve-contributors` | Look up verified contributor profiles |
|
|
34
|
+
| `--include-non-conventional` | Include other commits under Other Changes |
|
|
35
|
+
| `--help`, `-h` | Show usage |
|
|
36
|
+
| `--version`, `-v` | Show the tool version |
|
|
25
37
|
|
|
26
|
-
|
|
27
|
-
npx changelog-machine --config changelog.config.json
|
|
28
|
-
```
|
|
38
|
+
Long operations show progress in interactive terminals. Redirected output stays clean.
|
|
29
39
|
|
|
30
|
-
|
|
40
|
+
## Changelog format
|
|
31
41
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
| `--config` | `-c` | Path to the configuration file |
|
|
35
|
-
| `--version` | `-v` | Print version |
|
|
36
|
-
| `--help` | `-h` | Print command instructions |
|
|
42
|
+
```md
|
|
43
|
+
# Changelog
|
|
37
44
|
|
|
38
|
-
|
|
45
|
+
## v2.0.0
|
|
39
46
|
|
|
40
|
-
|
|
47
|
+
[compare changes](https://github.com/example/project/compare/v1.0.0...v2.0.0)
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
{
|
|
44
|
-
"title": "Changelog",
|
|
45
|
-
"description": "Some description text",
|
|
46
|
-
"repositoryUrl": "https://github.com/PKief/vscode-material-icon-theme",
|
|
47
|
-
"blacklistPattern": "Release|^\\d+\\.\\d+\\.\\d+$",
|
|
48
|
-
"filePath": "CHANGELOG.md"
|
|
49
|
-
}
|
|
50
|
-
```
|
|
49
|
+
### Enhancements
|
|
51
50
|
|
|
52
|
-
|
|
51
|
+
- **icons:** Add new icons ([#42](https://github.com/example/project/pull/42))
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
| ------------------ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
56
|
-
| `title` | Title of the changelog | "Changelog" |
|
|
57
|
-
| `description` | Description under the title | "All notable changes to this project will be documented in this file. Dates are displayed in UTC." |
|
|
58
|
-
| `filePath` | Path of the output file | "CHANGELOG.md" |
|
|
59
|
-
| `repositoryUrl` | Links to the repository for further information | |
|
|
60
|
-
| `blacklistPattern` | Regex to remove commit messages | |
|
|
53
|
+
### Contributors
|
|
61
54
|
|
|
62
|
-
|
|
55
|
+
- Philipp Kief
|
|
56
|
+
```
|
|
63
57
|
|
|
64
|
-
|
|
58
|
+
- Releases: **newest first**. Commits within categories: **oldest first**.
|
|
59
|
+
- Messages start uppercase; scopes and Markdown are preserved.
|
|
60
|
+
- Separate sections for breaking changes, features, fixes, and other commit types.
|
|
61
|
+
- Release bookkeeping commits are excluded; merge commits are skipped.
|
|
62
|
+
- GitHub, GitLab, and Bitbucket links are supported.
|
|
63
|
+
- Contributor names honor `.mailmap`; recognizable emails are redacted.
|
|
65
64
|
|
|
66
|
-
|
|
65
|
+
Use `--full` to apply formatting changes to older sections.
|
|
67
66
|
|
|
68
|
-
|
|
67
|
+
## Incremental updates
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
- Adjust preview script for folder icons [`6a00111`](https://github.com/PKief/vscode-material-icon-theme/commit/6a00111)
|
|
73
|
-
- Fix compare link [`fa45abf`](https://github.com/PKief/vscode-material-icon-theme/commit/fa45abf)
|
|
74
|
-
- Filter out release commits [`e3468ca`](https://github.com/PKief/vscode-material-icon-theme/commit/e3468ca)
|
|
69
|
+
The newest documented tag is the update boundary. Only newer commit ranges are
|
|
70
|
+
loaded; older sections stay untouched. Unchanged files are not rewritten.
|
|
75
71
|
|
|
76
|
-
|
|
72
|
+
- Supports `1.2.3` / `v1.2.3` tags, including prereleases and build metadata.
|
|
73
|
+
- Uses Git ancestry, not tag dates; avoids quadratic tag comparisons.
|
|
74
|
+
- Requires `# Changelog` and `## <tag>` / `## Unreleased` headings.
|
|
75
|
+
- Invalid formatting, ambiguous tags, or missing boundaries fail safely.
|
|
76
|
+
- Does not backfill gaps between older documented releases.
|
|
77
|
+
- Historical emails remain untouched; `--full` redacts generated history.
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
Shallow clone? Run `git fetch --unshallow --tags` first.
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
## Contributor profiles
|
|
81
82
|
|
|
82
|
-
```
|
|
83
|
-
|
|
83
|
+
```sh
|
|
84
|
+
changelog-machine --resolve-contributors
|
|
84
85
|
```
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
Adds verified profile links next to Git author names. Network access is **opt-in**.
|
|
87
88
|
|
|
89
|
+
| Provider | Resolution | Optional token |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| GitHub | Associated commit author | `GH_TOKEN` or `GITHUB_TOKEN` |
|
|
92
|
+
| Bitbucket | Associated user | `BITBUCKET_TOKEN` (bearer token) |
|
|
93
|
+
| GitLab | Git author name only | — |
|
|
94
|
+
|
|
95
|
+
Unresolved users fall back to Git author names, never emails. Email-only names
|
|
96
|
+
become `Unknown contributor`. No username guessing or email searches.
|
|
97
|
+
|
|
98
|
+
Lookups are grouped by mailmapped author identity and cached within a run. One
|
|
99
|
+
successful request covers all that author's commits; unresolved authors get at
|
|
100
|
+
most two attempts. Requests use five-second timeouts and three concurrent workers.
|
|
101
|
+
Rate limits stop queued requests with one warning; enriched results may vary.
|
|
102
|
+
|
|
103
|
+
## Version bumps
|
|
104
|
+
|
|
105
|
+
The highest-impact commit since the latest release wins:
|
|
106
|
+
|
|
107
|
+
| Commit | Bump |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `!`, `BREAKING CHANGE:`, or `BREAKING-CHANGE:` | Major, even on `0.x` |
|
|
110
|
+
| `feat:` | Minor |
|
|
111
|
+
| `fix:` or `perf:` | Patch |
|
|
112
|
+
| Other types | None |
|
|
113
|
+
|
|
114
|
+
- Calculates the exact next version, then runs `npm version <next-version> --no-git-tag-version --ignore-scripts` by default. npm owns `package.json` and its lockfiles; changelog-machine does not parse or restrict lockfile formats. npm must be available on `PATH` unless a custom command is supplied.
|
|
115
|
+
- Writes the release changelog with `## v<next-version>` only after the command succeeds and `package.json` contains the planned version. No Git tag is needed to prepare that section.
|
|
116
|
+
- The default command disables lifecycle scripts and Git commit/tag creation.
|
|
117
|
+
- Requires the latest tag to match the package version; repeated bumps fail.
|
|
118
|
+
- Without tags, analyzes all commits against the current package version.
|
|
119
|
+
- Prerelease promotion and workspace version orchestration are unsupported. Non-npm metadata and lockfiles can be maintained by a custom command.
|
|
120
|
+
- `--bump --full` rebuilds historical sections too. Without `--full`, older sections remain unchanged. `--output` also works with bumps; `--dry-run` previews without writing. No release-worthy commits means no file changes.
|
|
121
|
+
- After a successful bump, commit and create the matching version tag before another incremental update or bump.
|
|
122
|
+
|
|
123
|
+
### Custom package managers
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
changelog-machine --bump \
|
|
127
|
+
--bump-command='bun pm version {version} --no-git-tag-version --ignore-scripts'
|
|
88
128
|
```
|
|
89
|
-
yarn add --dev changelog-machine
|
|
90
|
-
```
|
|
91
129
|
|
|
92
|
-
|
|
130
|
+
Use `{version}` for the exact calculated version, not a relative bump such as `patch`. Every occurrence is replaced. Commands run in the repository directory through the system shell, with output sent to stderr. Shell syntax and executable availability depend on your platform.
|
|
131
|
+
|
|
132
|
+
`--bump-command` is **trusted, arbitrary shell input**. You are responsible for making it update `package.json` and the relevant lockfiles without commits, tags, pushes, publishing, or lifecycle side effects. npm does not maintain Bun, pnpm, or Yarn lockfiles; use a command or wrapper script appropriate to your package manager. The tool verifies the resulting package version, but cannot verify arbitrary lockfile contents or prevent custom-command side effects.
|
|
133
|
+
|
|
134
|
+
`--dry-run` prints the planned command to stderr and changelog to stdout without executing the command. No release-worthy commits means no command execution.
|
|
135
|
+
|
|
136
|
+
Changelog destinations are validated and temporary files staged before executing the command. Command failures or version mismatches leave the existing changelog untouched. Metadata changes made by a command are **not rolled back**; inspect repository changes before retrying. A failure while replacing the staged changelog can also leave metadata updated without the changelog; updates are not a multi-file transaction.
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
## API
|
|
93
140
|
|
|
94
141
|
```ts
|
|
95
|
-
import {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
142
|
+
import {
|
|
143
|
+
getReleases,
|
|
144
|
+
getRepositoryUrl,
|
|
145
|
+
renderMarkdown,
|
|
146
|
+
resolveContributors,
|
|
147
|
+
} from 'changelog-machine';
|
|
148
|
+
|
|
149
|
+
const releases = await getReleases();
|
|
150
|
+
const repositoryUrl = await getRepositoryUrl();
|
|
151
|
+
const markdown = renderMarkdown(releases, { repositoryUrl });
|
|
152
|
+
|
|
153
|
+
// Optional network enrichment
|
|
154
|
+
const contributors = await resolveContributors(
|
|
155
|
+
releases.flatMap(release => release.commits),
|
|
156
|
+
repositoryUrl,
|
|
157
|
+
);
|
|
158
|
+
const enriched = renderMarkdown(releases, { repositoryUrl, contributors });
|
|
108
159
|
```
|
|
160
|
+
|
|
161
|
+
Also exports `parseCommit`, `recommendedBump`, `planVersion`, and TypeScript types. `planVersion` is read-only: it calculates `current`, `next`, and `bump`; its `files` array is now empty because metadata updates are delegated to a command.
|
|
162
|
+
Pass a boundary tag to `getReleases(cwd, tag)` for incremental history.
|
|
163
|
+
The API does not write files. Explicit GitLab profiles can be supplied to the renderer.
|
|
164
|
+
|
|
165
|
+
**ESM-only.** Replaces the old `printMarkdown` API and JSON configuration; requires a major release.
|
|
166
|
+
|
|
167
|
+
## Development and publishing
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
npm ci
|
|
171
|
+
npm run build
|
|
172
|
+
npm test
|
|
173
|
+
npm run lint
|
|
174
|
+
npm pack --dry-run
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- Tests live beside source as `name.test.ts` and run directly with Node's native TypeScript support; no compiled test directory is needed.
|
|
178
|
+
- `npm test` type-checks first, then runs Node's test runner. Node strips types but does not type-check.
|
|
179
|
+
- Root TypeScript config covers source and tests; build config excludes tests. Source imports use `.js`, matching published output. A test-only Node loader resolves those imports to `.ts` while executing source directly.
|
|
180
|
+
- TypeScript 7 is development-only; npm ships compiled JavaScript and types.
|
|
181
|
+
- `package.json`'s `files` allowlist replaces `.npmignore`; `prepack` builds automatically.
|
|
182
|
+
- Use **Actions → Release → Run workflow** on the default branch, or `gh workflow run release.yml --ref main`. No version input is needed: conventional commits determine the bump. The workflow updates package metadata and changelog, commits and pushes a version tag, publishes to npm, and creates a GitHub Release. With no release-worthy commits, it makes no changes. Requires npm trusted publishing configured for this repository and `release.yml`, plus permission for the workflow token to push to the default branch; branch-protection rules may block that push. Tag pushes no longer trigger publishing separately. If npm publication fails after the release commit/tag was pushed, recover manually rather than expecting another click to repeat the release.
|
package/bin/cli.js
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { authorName } from '../contributors/privacy.js';
|
|
2
|
+
import { validateProfile } from '../contributors/profile.js';
|
|
3
|
+
import { plainText } from './text.js';
|
|
4
|
+
function profileForCommit(commit, options) {
|
|
5
|
+
const profile = options.contributors?.[commit.hash];
|
|
6
|
+
if (!profile || !options.repositoryUrl)
|
|
7
|
+
return undefined;
|
|
8
|
+
try {
|
|
9
|
+
const host = new URL(options.repositoryUrl).hostname;
|
|
10
|
+
if (!['github.com', 'bitbucket.org', 'gitlab.com'].includes(host))
|
|
11
|
+
return undefined;
|
|
12
|
+
return validateProfile(profile.username, profile.url, host);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function contributorLines(commits, options) {
|
|
19
|
+
const contributors = new Map();
|
|
20
|
+
for (const commit of commits) {
|
|
21
|
+
const name = authorName(commit.name);
|
|
22
|
+
const profile = profileForCommit(commit, options);
|
|
23
|
+
const key = profile?.url ?? name;
|
|
24
|
+
const label = profile
|
|
25
|
+
? `${plainText(name)} ([@${plainText(profile.username)}](${profile.url}))`
|
|
26
|
+
: plainText(name);
|
|
27
|
+
if (!contributors.has(key))
|
|
28
|
+
contributors.set(key, `- ${label}`);
|
|
29
|
+
}
|
|
30
|
+
return [...contributors.values()].sort();
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ParsedCommit } from '../types.js';
|
|
2
|
+
declare const repositoryRoutes: {
|
|
3
|
+
'github.com': {
|
|
4
|
+
compare: string;
|
|
5
|
+
commit: string;
|
|
6
|
+
issue: string;
|
|
7
|
+
pullRequest: string;
|
|
8
|
+
};
|
|
9
|
+
'gitlab.com': {
|
|
10
|
+
compare: string;
|
|
11
|
+
commit: string;
|
|
12
|
+
issue: string;
|
|
13
|
+
pullRequest: string;
|
|
14
|
+
};
|
|
15
|
+
'bitbucket.org': {
|
|
16
|
+
compare: string;
|
|
17
|
+
commit: string;
|
|
18
|
+
issue: string;
|
|
19
|
+
pullRequest: string;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
type RepositoryHost = keyof typeof repositoryRoutes;
|
|
23
|
+
export type Repository = {
|
|
24
|
+
base: string;
|
|
25
|
+
host: RepositoryHost;
|
|
26
|
+
};
|
|
27
|
+
export declare function repositoryBase(value?: string): Repository | undefined;
|
|
28
|
+
export declare function compareUrl(repository: Repository, baseTag: string, targetTag: string): string;
|
|
29
|
+
export declare function linkIssueReferences(message: string, repository?: Repository): string;
|
|
30
|
+
export declare function commitReference(commit: ParsedCommit, repository?: Repository): string;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { encodeCharacter, encodeSegment, plainText } from './text.js';
|
|
2
|
+
const trailingSlashesPattern = /\/+$/;
|
|
3
|
+
const gitSuffixPattern = /\.git$/;
|
|
4
|
+
const urlUnsafeCharactersPattern = /[\s()<>\[\]]/g;
|
|
5
|
+
const repositoryRoutes = {
|
|
6
|
+
'github.com': {
|
|
7
|
+
compare: '/compare/',
|
|
8
|
+
commit: '/commit/',
|
|
9
|
+
issue: '/issues/',
|
|
10
|
+
pullRequest: '/pull/',
|
|
11
|
+
},
|
|
12
|
+
'gitlab.com': {
|
|
13
|
+
compare: '/-/compare/',
|
|
14
|
+
commit: '/-/commit/',
|
|
15
|
+
issue: '/-/issues/',
|
|
16
|
+
pullRequest: '/-/merge_requests/',
|
|
17
|
+
},
|
|
18
|
+
'bitbucket.org': {
|
|
19
|
+
compare: '/branches/compare/',
|
|
20
|
+
commit: '/commits/',
|
|
21
|
+
issue: '/issues/',
|
|
22
|
+
pullRequest: '/pull-requests/',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export function repositoryBase(value) {
|
|
26
|
+
if (!value)
|
|
27
|
+
return undefined;
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(value);
|
|
30
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
31
|
+
return undefined;
|
|
32
|
+
if (url.username || url.password)
|
|
33
|
+
return undefined;
|
|
34
|
+
const host = url.hostname;
|
|
35
|
+
if (host !== 'github.com' &&
|
|
36
|
+
host !== 'gitlab.com' &&
|
|
37
|
+
host !== 'bitbucket.org')
|
|
38
|
+
return undefined;
|
|
39
|
+
url.search = '';
|
|
40
|
+
url.hash = '';
|
|
41
|
+
const base = url
|
|
42
|
+
.toString()
|
|
43
|
+
.replace(trailingSlashesPattern, '')
|
|
44
|
+
.replace(gitSuffixPattern, '')
|
|
45
|
+
.replace(urlUnsafeCharactersPattern, encodeCharacter);
|
|
46
|
+
return { base, host };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function compareUrl(repository, baseTag, targetTag) {
|
|
53
|
+
const base = encodeSegment(baseTag);
|
|
54
|
+
const target = encodeSegment(targetTag);
|
|
55
|
+
const comparison = repository.host === 'bitbucket.org'
|
|
56
|
+
? `${target}..${base}`
|
|
57
|
+
: `${base}...${target}`;
|
|
58
|
+
return `${repository.base}${repositoryRoutes[repository.host].compare}${comparison}`;
|
|
59
|
+
}
|
|
60
|
+
const inlineCodePattern = /(`+)[\s\S]*?\1/;
|
|
61
|
+
const markdownLinkPattern = /!?\[[^\]\n]*\]\([^\n]*?\)/;
|
|
62
|
+
const plainUrlPattern = /https?:\/\/[^\s)]+/;
|
|
63
|
+
const issueNumberPattern = /(?<![\w/#\\])#([1-9]\d*)\b/;
|
|
64
|
+
const protectedMarkdownOrIssue = new RegExp([inlineCodePattern, markdownLinkPattern, plainUrlPattern, issueNumberPattern]
|
|
65
|
+
.map((pattern) => pattern.source)
|
|
66
|
+
.join('|'), 'g');
|
|
67
|
+
export function linkIssueReferences(message, repository) {
|
|
68
|
+
if (!repository)
|
|
69
|
+
return message;
|
|
70
|
+
const issueUrl = `${repository.base}${repositoryRoutes[repository.host].issue}`;
|
|
71
|
+
return message.replace(protectedMarkdownOrIssue, (match, _code, issue) => issue ? `[#${issue}](${issueUrl}${issue})` : match);
|
|
72
|
+
}
|
|
73
|
+
export function commitReference(commit, repository) {
|
|
74
|
+
const label = commit.pr
|
|
75
|
+
? `#${commit.pr}`
|
|
76
|
+
: plainText(commit.hash.slice(0, 8));
|
|
77
|
+
if (!repository)
|
|
78
|
+
return `(${label})`;
|
|
79
|
+
const routes = repositoryRoutes[repository.host];
|
|
80
|
+
const path = commit.pr ? routes.pullRequest : routes.commit;
|
|
81
|
+
const identifier = encodeSegment(commit.pr ?? commit.hash);
|
|
82
|
+
return `([${label}](${repository.base}${path}${identifier}))`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { parseCommit } from '../commits/parse.js';
|
|
2
|
+
import { redactEmails } from '../contributors/privacy.js';
|
|
3
|
+
import { contributorLines } from './contributors.js';
|
|
4
|
+
import { commitReference, compareUrl, linkIssueReferences, repositoryBase, } from './links.js';
|
|
5
|
+
import { capitalizeMessage, plainText, safeText } from './text.js';
|
|
6
|
+
const groups = [
|
|
7
|
+
['breaking', 'Breaking Changes'],
|
|
8
|
+
['feat', 'Enhancements'],
|
|
9
|
+
['fix', 'Fixes'],
|
|
10
|
+
['perf', 'Performance'],
|
|
11
|
+
['docs', 'Documentation'],
|
|
12
|
+
['refactor', 'Refactors'],
|
|
13
|
+
['test', 'Tests'],
|
|
14
|
+
['build', 'Build'],
|
|
15
|
+
['ci', 'CI'],
|
|
16
|
+
['chore', 'Chores'],
|
|
17
|
+
['revert', 'Reverts'],
|
|
18
|
+
['other', 'Other Changes'],
|
|
19
|
+
];
|
|
20
|
+
const noteLineBreakPattern = /\r?\n/;
|
|
21
|
+
function entry(commit, repository) {
|
|
22
|
+
const scope = commit.scope ? `**${plainText(commit.scope)}:** ` : '';
|
|
23
|
+
const reference = commitReference(commit, repository);
|
|
24
|
+
const lines = [
|
|
25
|
+
`- ${scope}${linkIssueReferences(safeText(capitalizeMessage(commit.description)), repository)} ${reference}`,
|
|
26
|
+
];
|
|
27
|
+
for (const note of commit.breakingNotes) {
|
|
28
|
+
const parts = linkIssueReferences(safeText(capitalizeMessage(note)), repository).split(noteLineBreakPattern);
|
|
29
|
+
lines.push(` - ${parts[0]}`, ...parts.slice(1).map((line) => ` ${line}`));
|
|
30
|
+
}
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
33
|
+
export function renderMarkdown(releases, options = {}) {
|
|
34
|
+
const repository = repositoryBase(options.repositoryUrl);
|
|
35
|
+
const lines = ['# Changelog'];
|
|
36
|
+
for (const release of releases) {
|
|
37
|
+
lines.push('', `## ${plainText(release.tag)}`);
|
|
38
|
+
if (repository && release.previousTag) {
|
|
39
|
+
lines.push('', `[compare changes](${compareUrl(repository, release.previousTag, release.tag)})`);
|
|
40
|
+
}
|
|
41
|
+
const commits = release.commits
|
|
42
|
+
.map((commit) => parseCommit(commit, options.includeNonConventional))
|
|
43
|
+
.filter((commit) => commit !== undefined);
|
|
44
|
+
const grouped = new Map();
|
|
45
|
+
for (const commit of commits) {
|
|
46
|
+
const key = commit.breaking
|
|
47
|
+
? 'breaking'
|
|
48
|
+
: groups.some(([type]) => type === commit.type)
|
|
49
|
+
? commit.type
|
|
50
|
+
: 'other';
|
|
51
|
+
const list = grouped.get(key) ?? [];
|
|
52
|
+
list.push(commit);
|
|
53
|
+
grouped.set(key, list);
|
|
54
|
+
}
|
|
55
|
+
for (const [key, title] of groups) {
|
|
56
|
+
const entries = grouped.get(key);
|
|
57
|
+
if (!entries?.length)
|
|
58
|
+
continue;
|
|
59
|
+
lines.push('', `### ${title}`, '');
|
|
60
|
+
for (const commit of entries)
|
|
61
|
+
lines.push(...entry(commit, repository));
|
|
62
|
+
}
|
|
63
|
+
const contributors = contributorLines(commits, options);
|
|
64
|
+
if (contributors.length) {
|
|
65
|
+
lines.push('', '### Contributors', '', ...contributors);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return redactEmails(`${lines.join('\n')}\n`);
|
|
69
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function capitalizeMessage(value: string): string;
|
|
2
|
+
export declare function safeText(value: string): string;
|
|
3
|
+
export declare function plainText(value: string): string;
|
|
4
|
+
export declare function encodeCharacter(character: string): string;
|
|
5
|
+
export declare function encodeSegment(value: string): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const markdownPunctuationPattern = /[\\`*_{}\[\]()#!|]/g;
|
|
2
|
+
const textLineBreakPattern = /[\r\n]+/g;
|
|
3
|
+
const segmentUnsafeCharactersPattern = /[!'()*]/g;
|
|
4
|
+
export function capitalizeMessage(value) {
|
|
5
|
+
return value.replace(/\p{L}/u, (letter) => letter.toUpperCase());
|
|
6
|
+
}
|
|
7
|
+
export function safeText(value) {
|
|
8
|
+
return value
|
|
9
|
+
.replaceAll('&', '&')
|
|
10
|
+
.replaceAll('<', '<')
|
|
11
|
+
.replaceAll('>', '>');
|
|
12
|
+
}
|
|
13
|
+
export function plainText(value) {
|
|
14
|
+
return safeText(value)
|
|
15
|
+
.replace(markdownPunctuationPattern, '\\$&')
|
|
16
|
+
.replace(textLineBreakPattern, ' ');
|
|
17
|
+
}
|
|
18
|
+
export function encodeCharacter(character) {
|
|
19
|
+
return `%${character.charCodeAt(0).toString(16).toUpperCase()}`;
|
|
20
|
+
}
|
|
21
|
+
export function encodeSegment(value) {
|
|
22
|
+
return encodeURIComponent(value).replace(segmentUnsafeCharactersPattern, encodeCharacter);
|
|
23
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ExistingChangelog {
|
|
2
|
+
boundaryTag?: string;
|
|
3
|
+
prefix: string;
|
|
4
|
+
history: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function parseChangelog(content: string): ExistingChangelog;
|
|
7
|
+
export declare function mergeChangelog(existing: ExistingChangelog | undefined, generated: string): string;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { isVersionTag } from '../git/tags.js';
|
|
2
|
+
function headings(content) {
|
|
3
|
+
const result = [];
|
|
4
|
+
let offset = 0;
|
|
5
|
+
let fence;
|
|
6
|
+
for (const line of content.match(/[^\n]*(?:\n|$)/g) ?? []) {
|
|
7
|
+
if (!line)
|
|
8
|
+
continue;
|
|
9
|
+
const text = line.replace(/\r?\n$/, '');
|
|
10
|
+
const marker = text.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
11
|
+
if (fence) {
|
|
12
|
+
if (marker &&
|
|
13
|
+
marker[1][0] === fence.character &&
|
|
14
|
+
marker[1].length >= fence.length) {
|
|
15
|
+
fence = undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else if (marker) {
|
|
19
|
+
fence = { character: marker[1][0], length: marker[1].length };
|
|
20
|
+
}
|
|
21
|
+
else if (/^##(?:\s|$)/.test(text)) {
|
|
22
|
+
const heading = text.match(/^##\s+(.+?)\s*#*\s*$/);
|
|
23
|
+
result.push({ title: heading?.[1].trim() ?? '', start: offset });
|
|
24
|
+
}
|
|
25
|
+
offset += line.length;
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
function formatError(detail) {
|
|
30
|
+
return new Error(`Cannot update changelog: ${detail}. Use --full to regenerate it.`);
|
|
31
|
+
}
|
|
32
|
+
function removeSection(content, sectionHeadings, index) {
|
|
33
|
+
const start = sectionHeadings[index].start;
|
|
34
|
+
const end = sectionHeadings[index + 1]?.start ?? content.length;
|
|
35
|
+
return content.slice(0, start) + content.slice(end);
|
|
36
|
+
}
|
|
37
|
+
function validateTitle(content) {
|
|
38
|
+
if (!/^# Changelog(?:\r?\n|$)/.test(content)) {
|
|
39
|
+
throw formatError('expected a # Changelog title');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function validateSections(sectionHeadings) {
|
|
43
|
+
let foundRelease = false;
|
|
44
|
+
let foundUnreleased = false;
|
|
45
|
+
const tags = new Set();
|
|
46
|
+
for (const heading of sectionHeadings) {
|
|
47
|
+
if (heading.title === 'Unreleased') {
|
|
48
|
+
if (foundUnreleased)
|
|
49
|
+
throw formatError('duplicate Unreleased section');
|
|
50
|
+
if (foundRelease)
|
|
51
|
+
throw formatError('Unreleased section must precede tagged releases');
|
|
52
|
+
foundUnreleased = true;
|
|
53
|
+
}
|
|
54
|
+
else if (!isVersionTag(heading.title)) {
|
|
55
|
+
throw formatError(`unrecognized release heading "${heading.title}"`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
if (tags.has(heading.title))
|
|
59
|
+
throw formatError(`duplicate release tag "${heading.title}"`);
|
|
60
|
+
tags.add(heading.title);
|
|
61
|
+
foundRelease = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function removeUnreleased(content, sectionHeadings) {
|
|
66
|
+
const index = sectionHeadings.findIndex((heading) => heading.title === 'Unreleased');
|
|
67
|
+
return index < 0 ? content : removeSection(content, sectionHeadings, index);
|
|
68
|
+
}
|
|
69
|
+
export function parseChangelog(content) {
|
|
70
|
+
validateTitle(content);
|
|
71
|
+
const sectionHeadings = headings(content);
|
|
72
|
+
validateSections(sectionHeadings);
|
|
73
|
+
const firstReleaseIndex = sectionHeadings.findIndex((heading) => isVersionTag(heading.title));
|
|
74
|
+
const firstRelease = sectionHeadings[firstReleaseIndex];
|
|
75
|
+
const prefixEnd = firstRelease?.start ?? content.length;
|
|
76
|
+
const prefix = removeUnreleased(content.slice(0, prefixEnd), sectionHeadings.filter((heading) => heading.start < prefixEnd));
|
|
77
|
+
if (!firstRelease)
|
|
78
|
+
return { prefix, history: '' };
|
|
79
|
+
return {
|
|
80
|
+
boundaryTag: firstRelease.title,
|
|
81
|
+
prefix,
|
|
82
|
+
history: content.slice(firstRelease.start),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function joinAtBoundary(left, right) {
|
|
86
|
+
if (!left)
|
|
87
|
+
return right;
|
|
88
|
+
if (!right)
|
|
89
|
+
return left;
|
|
90
|
+
return `${left.replace(/\n+$/, '')}\n\n${right.replace(/^\n+/, '')}`;
|
|
91
|
+
}
|
|
92
|
+
function generatedSections(content, sectionHeadings) {
|
|
93
|
+
const firstSection = sectionHeadings[0]?.start;
|
|
94
|
+
if (firstSection === undefined)
|
|
95
|
+
return '';
|
|
96
|
+
return content.slice(firstSection);
|
|
97
|
+
}
|
|
98
|
+
export function mergeChangelog(existing, generated) {
|
|
99
|
+
validateTitle(generated);
|
|
100
|
+
const generatedHeadings = headings(generated);
|
|
101
|
+
validateSections(generatedHeadings);
|
|
102
|
+
const parsed = parseChangelog(generated);
|
|
103
|
+
const generatedPrefix = generated.slice(0, generatedHeadings[0]?.start ?? generated.length);
|
|
104
|
+
const prefix = existing?.prefix ?? generatedPrefix;
|
|
105
|
+
let freshSections = generatedSections(generated, generatedHeadings);
|
|
106
|
+
if (existing?.boundaryTag) {
|
|
107
|
+
const duplicate = generatedHeadings.findIndex((heading) => heading.title === existing.boundaryTag);
|
|
108
|
+
if (duplicate >= 0) {
|
|
109
|
+
const firstSection = generatedHeadings[0]?.start;
|
|
110
|
+
freshSections =
|
|
111
|
+
firstSection === undefined ||
|
|
112
|
+
generatedHeadings[duplicate].start <= firstSection
|
|
113
|
+
? ''
|
|
114
|
+
: generated.slice(firstSection, generatedHeadings[duplicate].start);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const history = joinAtBoundary(freshSections, existing?.history ?? '');
|
|
118
|
+
return joinAtBoundary(prefix, history) || parsed.prefix;
|
|
119
|
+
}
|