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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { groupAuthorCommits } from './authors.js';
|
|
2
|
+
import { readBitbucketProfile, readGitHubProfile } from './profile.js';
|
|
3
|
+
import { getCommitEndpoint, getRepositoryToken, parseRepository, } from './repository.js';
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 5_000;
|
|
5
|
+
const MAX_CONCURRENT_REQUESTS = 3;
|
|
6
|
+
const MAX_ATTEMPTS_PER_AUTHOR = 2;
|
|
7
|
+
function readProfile(data, repository) {
|
|
8
|
+
return repository.host === 'github.com'
|
|
9
|
+
? readGitHubProfile(data)
|
|
10
|
+
: readBitbucketProfile(data);
|
|
11
|
+
}
|
|
12
|
+
async function requestProfile(repository, hash, token) {
|
|
13
|
+
try {
|
|
14
|
+
const headers = new Headers({ accept: 'application/json' });
|
|
15
|
+
if (token)
|
|
16
|
+
headers.set('authorization', `Bearer ${token}`);
|
|
17
|
+
const response = await fetch(getCommitEndpoint(repository, hash), {
|
|
18
|
+
headers,
|
|
19
|
+
redirect: 'error',
|
|
20
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
21
|
+
});
|
|
22
|
+
if ([401, 403, 429].includes(response.status))
|
|
23
|
+
return { stop: true };
|
|
24
|
+
if (!response.ok)
|
|
25
|
+
return { stop: false };
|
|
26
|
+
return {
|
|
27
|
+
profile: readProfile(await response.json(), repository),
|
|
28
|
+
stop: false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { stop: false };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function resolveContributors(commits, repositoryUrl, onProgress) {
|
|
36
|
+
const repository = parseRepository(repositoryUrl);
|
|
37
|
+
if (!repository)
|
|
38
|
+
return {};
|
|
39
|
+
const supportedRepository = repository;
|
|
40
|
+
const authors = groupAuthorCommits(commits);
|
|
41
|
+
const profiles = {};
|
|
42
|
+
const token = getRepositoryToken(repository);
|
|
43
|
+
let next = 0;
|
|
44
|
+
let stopped = false;
|
|
45
|
+
const progress = {
|
|
46
|
+
completed: 0,
|
|
47
|
+
total: authors.length,
|
|
48
|
+
resolved: 0,
|
|
49
|
+
requests: 0,
|
|
50
|
+
};
|
|
51
|
+
const report = () => onProgress?.({ ...progress });
|
|
52
|
+
report();
|
|
53
|
+
async function resolveAuthor(hashes) {
|
|
54
|
+
for (const hash of hashes.slice(0, MAX_ATTEMPTS_PER_AUTHOR)) {
|
|
55
|
+
if (stopped)
|
|
56
|
+
return;
|
|
57
|
+
progress.requests++;
|
|
58
|
+
report();
|
|
59
|
+
const result = await requestProfile(supportedRepository, hash, token);
|
|
60
|
+
if (result.stop) {
|
|
61
|
+
if (!stopped)
|
|
62
|
+
console.error('Contributor lookup stopped: authentication failed or API rate limit reached. Using Git author names for unresolved contributors.');
|
|
63
|
+
stopped = true;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (result.profile) {
|
|
67
|
+
progress.resolved++;
|
|
68
|
+
for (const authorHash of hashes)
|
|
69
|
+
profiles[authorHash] = result.profile;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function worker() {
|
|
75
|
+
while (!stopped) {
|
|
76
|
+
const hashes = authors[next++];
|
|
77
|
+
if (!hashes)
|
|
78
|
+
return;
|
|
79
|
+
await resolveAuthor(hashes);
|
|
80
|
+
progress.completed++;
|
|
81
|
+
report();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT_REQUESTS, authors.length) }, worker));
|
|
85
|
+
return profiles;
|
|
86
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export class GitError extends Error {
|
|
3
|
+
exitCode;
|
|
4
|
+
constructor(message, exitCode = null) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'GitError';
|
|
7
|
+
this.exitCode = exitCode;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export async function git(args, cwd) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const child = spawn('git', args, {
|
|
13
|
+
cwd,
|
|
14
|
+
shell: false,
|
|
15
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
16
|
+
env: Object.assign({}, process.env, { ['GIT_TERMINAL_PROMPT']: '0' }),
|
|
17
|
+
});
|
|
18
|
+
let stdout = '';
|
|
19
|
+
let stderr = '';
|
|
20
|
+
child.stdout.setEncoding('utf8');
|
|
21
|
+
child.stderr.setEncoding('utf8');
|
|
22
|
+
child.stdout.on('data', (chunk) => {
|
|
23
|
+
stdout += chunk;
|
|
24
|
+
});
|
|
25
|
+
child.stderr.on('data', (chunk) => {
|
|
26
|
+
stderr += chunk;
|
|
27
|
+
});
|
|
28
|
+
const context = `git ${args.map((arg) => JSON.stringify(arg)).join(' ')} in ${cwd ?? process.cwd()}`;
|
|
29
|
+
child.on('error', (error) => {
|
|
30
|
+
reject(new GitError(`Could not run ${context}: ${error.message}. Check that Git is installed and the working directory exists.`));
|
|
31
|
+
});
|
|
32
|
+
child.on('close', (code, signal) => {
|
|
33
|
+
if (code === 0)
|
|
34
|
+
resolve(stdout);
|
|
35
|
+
else
|
|
36
|
+
reject(new GitError(`${context} failed (${signal ? `signal ${signal}` : `exit ${code}`}): ${stderr.trim() || 'no diagnostic output'}`, code));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { git } from './command.js';
|
|
2
|
+
export async function readCommits(range, cwd) {
|
|
3
|
+
const format = '%H%x00%aN%x00%aE%x00%s%x00%b';
|
|
4
|
+
const fieldsPerCommit = 5;
|
|
5
|
+
const output = await git([
|
|
6
|
+
'log',
|
|
7
|
+
'--reverse',
|
|
8
|
+
'--topo-order',
|
|
9
|
+
'--no-merges',
|
|
10
|
+
'-z',
|
|
11
|
+
`--format=${format}`,
|
|
12
|
+
range,
|
|
13
|
+
'--',
|
|
14
|
+
], cwd);
|
|
15
|
+
if (!output)
|
|
16
|
+
return [];
|
|
17
|
+
const fields = output.split('\0');
|
|
18
|
+
if (fields.pop() !== '' || fields.length % fieldsPerCommit !== 0) {
|
|
19
|
+
throw new Error('Git returned an invalid NUL-delimited commit log.');
|
|
20
|
+
}
|
|
21
|
+
const commits = [];
|
|
22
|
+
for (let index = 0; index < fields.length; index += fieldsPerCommit) {
|
|
23
|
+
const [hash, name, email, subject, body] = fields.slice(index, index + fieldsPerCommit);
|
|
24
|
+
commits.push({ hash, name, email, subject, body });
|
|
25
|
+
}
|
|
26
|
+
return commits;
|
|
27
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readCommits } from './commits.js';
|
|
2
|
+
import { readGitMetadata, readTagsInHistory, resolveBoundary, validateTagChain, } from './refs.js';
|
|
3
|
+
async function readReleaseRanges(tags, cwd, boundary) {
|
|
4
|
+
const releases = [];
|
|
5
|
+
for (let index = 0; index < tags.length; index++) {
|
|
6
|
+
const current = tags[index];
|
|
7
|
+
const previous = tags[index + 1] ?? (index === tags.length - 1 ? boundary : undefined);
|
|
8
|
+
releases.push({
|
|
9
|
+
tag: current.tag,
|
|
10
|
+
...(previous ? { previousTag: previous.tag } : {}),
|
|
11
|
+
commits: await readCommits(previous ? `${previous.hash}..${current.hash}` : current.hash, cwd),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return releases;
|
|
15
|
+
}
|
|
16
|
+
export async function getLatestReleaseTag(cwd) {
|
|
17
|
+
const metadata = await readGitMetadata(cwd);
|
|
18
|
+
const tags = await readTagsInHistory(metadata, cwd, undefined, true);
|
|
19
|
+
await validateTagChain(tags, cwd);
|
|
20
|
+
return tags[0]?.tag;
|
|
21
|
+
}
|
|
22
|
+
export async function getReleases(cwd, afterTag) {
|
|
23
|
+
const metadata = await readGitMetadata(cwd);
|
|
24
|
+
const boundary = afterTag === undefined
|
|
25
|
+
? undefined
|
|
26
|
+
: await resolveBoundary(afterTag, metadata, cwd);
|
|
27
|
+
const tags = await readTagsInHistory(metadata, cwd, boundary);
|
|
28
|
+
await validateTagChain(tags, cwd, boundary);
|
|
29
|
+
const base = tags[0] ?? boundary;
|
|
30
|
+
const hasUnreleasedRange = base ? metadata.head !== base.hash : true;
|
|
31
|
+
const releases = hasUnreleasedRange || afterTag !== undefined
|
|
32
|
+
? [
|
|
33
|
+
{
|
|
34
|
+
tag: 'Unreleased',
|
|
35
|
+
...(base ? { previousTag: base.tag } : {}),
|
|
36
|
+
commits: await readCommits(base ? `${base.hash}..${metadata.head}` : metadata.head, cwd),
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
: [];
|
|
40
|
+
return [...releases, ...(await readReleaseRanges(tags, cwd, boundary))];
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type VersionTag = {
|
|
2
|
+
tag: string;
|
|
3
|
+
hash: string;
|
|
4
|
+
};
|
|
5
|
+
export type GitMetadata = {
|
|
6
|
+
head: string;
|
|
7
|
+
tagsByHash: Map<string, string[]>;
|
|
8
|
+
};
|
|
9
|
+
export declare function readGitMetadata(cwd?: string): Promise<GitMetadata>;
|
|
10
|
+
export declare function resolveBoundary(afterTag: string, metadata: GitMetadata, cwd?: string): Promise<VersionTag>;
|
|
11
|
+
export declare function readTagsInHistory(metadata: GitMetadata, cwd?: string, boundary?: VersionTag, decoratedOnly?: boolean): Promise<VersionTag[]>;
|
|
12
|
+
export declare function validateTagChain(tags: VersionTag[], cwd?: string, boundary?: VersionTag): Promise<void>;
|
package/lib/git/refs.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { GitError, git } from './command.js';
|
|
2
|
+
import { isVersionTag } from './tags.js';
|
|
3
|
+
async function requireFullHistory(cwd) {
|
|
4
|
+
if ((await git(['rev-parse', '--is-shallow-repository'], cwd)).trim() === 'true') {
|
|
5
|
+
throw new Error('Cannot generate releases from a shallow repository. Fetch the full history with git fetch --unshallow (including tags), then retry.');
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
async function readTagRefs(cwd) {
|
|
9
|
+
const output = await git([
|
|
10
|
+
'for-each-ref',
|
|
11
|
+
'--format=%(refname:strip=2)%00%(objecttype)%00%(objectname)%00%(*objectname)%00',
|
|
12
|
+
'refs/tags/',
|
|
13
|
+
], cwd);
|
|
14
|
+
const tagsByHash = new Map();
|
|
15
|
+
const fields = output.split('\0');
|
|
16
|
+
if (fields.at(-1) === '')
|
|
17
|
+
fields.pop();
|
|
18
|
+
for (let index = 0; index + 3 < fields.length; index += 4) {
|
|
19
|
+
const [rawTag, rawType, rawObjectHash, rawPeeledHash] = fields.slice(index, index + 4);
|
|
20
|
+
const tag = rawTag.trim();
|
|
21
|
+
const type = rawType.trim();
|
|
22
|
+
const hash = type === 'tag' ? rawPeeledHash.trim() : rawObjectHash.trim();
|
|
23
|
+
if (!tag || !hash || !isVersionTag(tag))
|
|
24
|
+
continue;
|
|
25
|
+
const names = tagsByHash.get(hash) ?? [];
|
|
26
|
+
names.push(tag);
|
|
27
|
+
tagsByHash.set(hash, names);
|
|
28
|
+
}
|
|
29
|
+
return tagsByHash;
|
|
30
|
+
}
|
|
31
|
+
export async function readGitMetadata(cwd) {
|
|
32
|
+
await requireFullHistory(cwd);
|
|
33
|
+
const [head, tagsByHash] = await Promise.all([
|
|
34
|
+
git(['rev-parse', '--verify', 'HEAD^{commit}'], cwd).then((value) => value.trim()),
|
|
35
|
+
readTagRefs(cwd),
|
|
36
|
+
]);
|
|
37
|
+
return { head, tagsByHash };
|
|
38
|
+
}
|
|
39
|
+
async function isAncestor(older, newer, cwd) {
|
|
40
|
+
try {
|
|
41
|
+
await git(['merge-base', '--is-ancestor', older, newer], cwd);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error instanceof GitError && error.exitCode === 1)
|
|
46
|
+
return false;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function resolveBoundary(afterTag, metadata, cwd) {
|
|
51
|
+
if (!isVersionTag(afterTag))
|
|
52
|
+
throw new Error(`Invalid version tag: ${afterTag}`);
|
|
53
|
+
const found = [...metadata.tagsByHash].find(([, names]) => names.includes(afterTag));
|
|
54
|
+
if (!found)
|
|
55
|
+
throw new Error(`Release boundary tag not found: ${afterTag}`);
|
|
56
|
+
const boundary = { tag: afterTag, hash: found[0] };
|
|
57
|
+
if (!(await isAncestor(boundary.hash, metadata.head, cwd))) {
|
|
58
|
+
throw new Error(`Release boundary tag is not reachable from HEAD: ${afterTag}`);
|
|
59
|
+
}
|
|
60
|
+
return boundary;
|
|
61
|
+
}
|
|
62
|
+
export async function readTagsInHistory(metadata, cwd, boundary, decoratedOnly = false) {
|
|
63
|
+
const args = ['rev-list', '--topo-order'];
|
|
64
|
+
if (decoratedOnly)
|
|
65
|
+
args.push('--simplify-by-decoration');
|
|
66
|
+
args.push(metadata.head);
|
|
67
|
+
if (boundary)
|
|
68
|
+
args.push(`^${boundary.hash}`);
|
|
69
|
+
const output = await git(args, cwd);
|
|
70
|
+
const tags = [];
|
|
71
|
+
const encountered = new Set();
|
|
72
|
+
const hashes = output.trim().split('\n');
|
|
73
|
+
if (boundary)
|
|
74
|
+
hashes.push(boundary.hash);
|
|
75
|
+
for (const hash of hashes) {
|
|
76
|
+
if (!hash || encountered.has(hash))
|
|
77
|
+
continue;
|
|
78
|
+
encountered.add(hash);
|
|
79
|
+
const names = metadata.tagsByHash.get(hash);
|
|
80
|
+
if (!names?.length)
|
|
81
|
+
continue;
|
|
82
|
+
if (names.length > 1) {
|
|
83
|
+
throw new Error(`Ambiguous release tags point to the same commit: ${names.join(', ')}.`);
|
|
84
|
+
}
|
|
85
|
+
if (hash !== boundary?.hash)
|
|
86
|
+
tags.push({ tag: names[0], hash });
|
|
87
|
+
}
|
|
88
|
+
return tags;
|
|
89
|
+
}
|
|
90
|
+
export async function validateTagChain(tags, cwd, boundary) {
|
|
91
|
+
for (let index = 1; index < tags.length; index++) {
|
|
92
|
+
if (!(await isAncestor(tags[index].hash, tags[index - 1].hash, cwd))) {
|
|
93
|
+
throw new Error(`Ambiguous release tags: ${tags[index - 1].tag} and ${tags[index].tag} are not ancestors of one another. Release tags must form a single ancestry chain.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (boundary &&
|
|
97
|
+
tags.length &&
|
|
98
|
+
!(await isAncestor(boundary.hash, tags.at(-1).hash, cwd))) {
|
|
99
|
+
throw new Error(`Ambiguous release tags: ${boundary.tag} and ${tags.at(-1).tag} are not ancestors of one another. Release tags must form a single ancestry chain.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getRepositoryUrl(cwd?: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { GitError, git } from './command.js';
|
|
2
|
+
const remoteWhitespace = /\s/;
|
|
3
|
+
const supportedRemoteUrlScheme = /^(https|ssh):\/\//i;
|
|
4
|
+
const scpStyleRemote = /^(?:[^@/:]+@)?([^/:]+):(.+)$/;
|
|
5
|
+
const surroundingPathSlashes = /^\/+|\/+$/g;
|
|
6
|
+
const gitPathSuffix = /\.git$/;
|
|
7
|
+
const repositoryPathWithAtLeastTwoSegments = /^[^/]+(?:\/[^/]+)+$/;
|
|
8
|
+
const queryOrFragmentMarker = /[?#]/;
|
|
9
|
+
const supportedRepositoryHosts = ['github.com', 'gitlab.com', 'bitbucket.org'];
|
|
10
|
+
async function readOriginRemote(cwd) {
|
|
11
|
+
try {
|
|
12
|
+
return (await git(['config', '--get', 'remote.origin.url'], cwd)).trim();
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error instanceof GitError && error.exitCode === 1)
|
|
16
|
+
return undefined;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function parseRemoteHostAndPath(remote) {
|
|
21
|
+
if (supportedRemoteUrlScheme.test(remote)) {
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(remote);
|
|
24
|
+
if (url.search || url.hash)
|
|
25
|
+
return undefined;
|
|
26
|
+
return { host: url.hostname.toLowerCase(), path: url.pathname };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const match = scpStyleRemote.exec(remote);
|
|
33
|
+
if (!match)
|
|
34
|
+
return undefined;
|
|
35
|
+
const [, host, path] = match;
|
|
36
|
+
return { host: host.toLowerCase(), path };
|
|
37
|
+
}
|
|
38
|
+
function isValidRepositoryPath(path) {
|
|
39
|
+
const hasTraversalSegment = path
|
|
40
|
+
.split('/')
|
|
41
|
+
.some((segment) => segment === '.' || segment === '..');
|
|
42
|
+
return (repositoryPathWithAtLeastTwoSegments.test(path) &&
|
|
43
|
+
!queryOrFragmentMarker.test(path) &&
|
|
44
|
+
!hasTraversalSegment);
|
|
45
|
+
}
|
|
46
|
+
export async function getRepositoryUrl(cwd) {
|
|
47
|
+
const remote = await readOriginRemote(cwd);
|
|
48
|
+
if (!remote || remoteWhitespace.test(remote))
|
|
49
|
+
return undefined;
|
|
50
|
+
const repository = parseRemoteHostAndPath(remote);
|
|
51
|
+
if (!repository || !supportedRepositoryHosts.includes(repository.host))
|
|
52
|
+
return undefined;
|
|
53
|
+
const path = repository.path
|
|
54
|
+
.replace(surroundingPathSlashes, '')
|
|
55
|
+
.replace(gitPathSuffix, '');
|
|
56
|
+
if (!isValidRepositoryPath(path))
|
|
57
|
+
return undefined;
|
|
58
|
+
return `https://${repository.host}/${path}`;
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isVersionTag(tag: string): boolean;
|
package/lib/git/tags.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const numericIdentifier = /^(0|[1-9]\d*)$/;
|
|
2
|
+
const allowedIdentifierCharacters = /^[A-Za-z0-9-]+$/;
|
|
3
|
+
const onlyDigits = /^\d+$/;
|
|
4
|
+
function validPrerelease(value) {
|
|
5
|
+
return value
|
|
6
|
+
.split('.')
|
|
7
|
+
.every((identifier) => allowedIdentifierCharacters.test(identifier) &&
|
|
8
|
+
(!onlyDigits.test(identifier) || numericIdentifier.test(identifier)));
|
|
9
|
+
}
|
|
10
|
+
function validBuild(value) {
|
|
11
|
+
return value
|
|
12
|
+
.split('.')
|
|
13
|
+
.every((identifier) => allowedIdentifierCharacters.test(identifier));
|
|
14
|
+
}
|
|
15
|
+
export function isVersionTag(tag) {
|
|
16
|
+
const version = tag.startsWith('v') ? tag.slice(1) : tag;
|
|
17
|
+
const [release, build, extraBuild] = version.split('+');
|
|
18
|
+
if (extraBuild !== undefined || (build !== undefined && !validBuild(build)))
|
|
19
|
+
return false;
|
|
20
|
+
const prereleaseStart = release.indexOf('-');
|
|
21
|
+
const core = prereleaseStart < 0 ? release : release.slice(0, prereleaseStart);
|
|
22
|
+
const parts = core.split('.');
|
|
23
|
+
if (parts.length !== 3 ||
|
|
24
|
+
!parts.every((part) => numericIdentifier.test(part)))
|
|
25
|
+
return false;
|
|
26
|
+
return (prereleaseStart < 0 || validPrerelease(release.slice(prereleaseStart + 1)));
|
|
27
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
-
export
|
|
1
|
+
export { renderMarkdown } from './changelog/render.js';
|
|
2
|
+
export { parseCommit, recommendedBump } from './commits/parse.js';
|
|
3
|
+
export { resolveContributors } from './contributors/resolve.js';
|
|
4
|
+
export { getLatestReleaseTag, getReleases } from './git/history.js';
|
|
5
|
+
export { getRepositoryUrl } from './git/remote.js';
|
|
6
|
+
export type { Commit, ContributorProfile, Options, ParsedCommit, Release, } from './types.js';
|
|
7
|
+
export type { VersionPlan } from './version/plan.js';
|
|
8
|
+
export { planVersion } from './version/plan.js';
|
package/lib/index.js
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
o[k2] = m[k];
|
|
8
|
-
}));
|
|
9
|
-
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
10
|
-
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
11
|
-
};
|
|
12
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
__exportStar(require("./core/markdown"), exports);
|
|
1
|
+
export { renderMarkdown } from './changelog/render.js';
|
|
2
|
+
export { parseCommit, recommendedBump } from './commits/parse.js';
|
|
3
|
+
export { resolveContributors } from './contributors/resolve.js';
|
|
4
|
+
export { getLatestReleaseTag, getReleases } from './git/history.js';
|
|
5
|
+
export { getRepositoryUrl } from './git/remote.js';
|
|
6
|
+
export { planVersion } from './version/plan.js';
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface Commit {
|
|
2
|
+
hash: string;
|
|
3
|
+
name: string;
|
|
4
|
+
email: string;
|
|
5
|
+
subject: string;
|
|
6
|
+
body: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ParsedCommit extends Commit {
|
|
9
|
+
type: string;
|
|
10
|
+
scope?: string;
|
|
11
|
+
description: string;
|
|
12
|
+
breaking: boolean;
|
|
13
|
+
breakingNotes: string[];
|
|
14
|
+
pr?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface Release {
|
|
17
|
+
tag: string;
|
|
18
|
+
previousTag?: string;
|
|
19
|
+
commits: Commit[];
|
|
20
|
+
}
|
|
21
|
+
export interface ContributorProfile {
|
|
22
|
+
username: string;
|
|
23
|
+
url: string;
|
|
24
|
+
}
|
|
25
|
+
export interface Options {
|
|
26
|
+
cwd?: string;
|
|
27
|
+
repositoryUrl?: string;
|
|
28
|
+
includeNonConventional?: boolean;
|
|
29
|
+
contributors?: Readonly<Record<string, ContributorProfile>>;
|
|
30
|
+
output?: string;
|
|
31
|
+
}
|
package/lib/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const defaultBumpCommand = "npm version {version} --no-git-tag-version --ignore-scripts";
|
|
2
|
+
export declare function validateBumpCommand(command: string): void;
|
|
3
|
+
export declare function formatBumpCommand(command: string, version: string): string;
|
|
4
|
+
/** Custom commands are explicitly trusted shell input, never read from Git. */
|
|
5
|
+
export declare function runBumpCommand(version: string, cwd: string, command?: string): Promise<void>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { parseObject } from './json.js';
|
|
5
|
+
export const defaultBumpCommand = 'npm version {version} --no-git-tag-version --ignore-scripts';
|
|
6
|
+
export function validateBumpCommand(command) {
|
|
7
|
+
if (!command.trim() || !command.includes('{version}')) {
|
|
8
|
+
throw new Error('--bump-command must contain the {version} placeholder');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function formatBumpCommand(command, version) {
|
|
12
|
+
validateBumpCommand(command);
|
|
13
|
+
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) {
|
|
14
|
+
throw new Error('Bump command requires a stable semver version');
|
|
15
|
+
}
|
|
16
|
+
return command.replaceAll('{version}', version);
|
|
17
|
+
}
|
|
18
|
+
/** Custom commands are explicitly trusted shell input, never read from Git. */
|
|
19
|
+
export async function runBumpCommand(version, cwd, command) {
|
|
20
|
+
const formatted = formatBumpCommand(command ?? defaultBumpCommand, version);
|
|
21
|
+
console.error(`Running bump command: ${formatted}`);
|
|
22
|
+
await new Promise((resolveCommand, reject) => {
|
|
23
|
+
// Avoid shell interpretation for the built-in command.
|
|
24
|
+
const child = command === undefined
|
|
25
|
+
? spawn('npm', ['version', version, '--no-git-tag-version', '--ignore-scripts'], {
|
|
26
|
+
cwd,
|
|
27
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
28
|
+
shell: process.platform === 'win32',
|
|
29
|
+
})
|
|
30
|
+
: spawn(formatted, {
|
|
31
|
+
cwd,
|
|
32
|
+
shell: true,
|
|
33
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
34
|
+
});
|
|
35
|
+
// Keep stdout reserved for changelog previews.
|
|
36
|
+
child.stdout?.pipe(process.stderr, { end: false });
|
|
37
|
+
child.stderr?.pipe(process.stderr, { end: false });
|
|
38
|
+
child.on('error', (error) => {
|
|
39
|
+
reject(new Error(`Could not start bump command: ${error.message}. Check repository files before retrying.`, { cause: error }));
|
|
40
|
+
});
|
|
41
|
+
child.on('close', (code, signal) => {
|
|
42
|
+
if (code === 0)
|
|
43
|
+
resolveCommand();
|
|
44
|
+
else
|
|
45
|
+
reject(new Error(`Bump command failed (${signal ?? `exit ${code}`}). Repository files may have changed; inspect them before retrying.`));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
const manifest = parseObject(await readFile(resolve(cwd, 'package.json'), 'utf8'), resolve(cwd, 'package.json'));
|
|
49
|
+
if (manifest.version !== version) {
|
|
50
|
+
throw new Error(`Bump command did not set package.json version to ${version} (found ${String(manifest.version)}). Repository files may have changed; inspect them before retrying.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type JsonObject = Record<string, unknown>;
|
|
2
|
+
export declare function isObject(value: unknown): value is JsonObject;
|
|
3
|
+
export declare function parseObject(text: string, path: string): JsonObject;
|
|
4
|
+
export declare function formatJson(value: JsonObject, original: string): string;
|
|
5
|
+
export declare function readOptionalFile(path: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
const jsonIndentPattern = /(?:\r?\n)([\t ]+)"/;
|
|
3
|
+
const newlinePattern = /\n/g;
|
|
4
|
+
export function isObject(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
export function parseObject(text, path) {
|
|
8
|
+
let value;
|
|
9
|
+
try {
|
|
10
|
+
value = JSON.parse(text);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error(`Invalid JSON in ${path}. Fix the file before planning a version bump.`);
|
|
14
|
+
}
|
|
15
|
+
if (!isObject(value)) {
|
|
16
|
+
throw new Error(`Expected a JSON object in ${path}. Fix the file before planning a version bump.`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
export function formatJson(value, original) {
|
|
21
|
+
const newline = original.includes('\r\n') ? '\r\n' : '\n';
|
|
22
|
+
const indent = original.match(jsonIndentPattern)?.[1];
|
|
23
|
+
const content = JSON.stringify(value, null, indent).replace(newlinePattern, newline);
|
|
24
|
+
return content + (original.endsWith('\n') ? newline : '');
|
|
25
|
+
}
|
|
26
|
+
export async function readOptionalFile(path) {
|
|
27
|
+
try {
|
|
28
|
+
return await readFile(path, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === 'ENOENT')
|
|
32
|
+
return undefined;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Commit } from '../types.js';
|
|
2
|
+
export interface VersionPlan {
|
|
3
|
+
current: string;
|
|
4
|
+
next?: string;
|
|
5
|
+
bump?: 'major' | 'minor' | 'patch';
|
|
6
|
+
files: {
|
|
7
|
+
path: string;
|
|
8
|
+
content: string;
|
|
9
|
+
}[];
|
|
10
|
+
}
|
|
11
|
+
export declare function planVersion(commits: Commit[], latestTag: string | undefined, cwd?: string): Promise<VersionPlan>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { recommendedBump } from '../commits/parse.js';
|
|
4
|
+
import { parseObject } from './json.js';
|
|
5
|
+
const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
6
|
+
const tagPrefixPattern = /^v/;
|
|
7
|
+
function readCurrentVersion(manifest) {
|
|
8
|
+
const current = manifest.version;
|
|
9
|
+
if (typeof current !== 'string' || !stableVersionPattern.test(current)) {
|
|
10
|
+
throw new Error('package.json version must be stable semver (for example 1.2.3); prerelease and build versions are unsupported.');
|
|
11
|
+
}
|
|
12
|
+
return current;
|
|
13
|
+
}
|
|
14
|
+
function validateLatestTag(latestTag, current) {
|
|
15
|
+
if (latestTag !== undefined &&
|
|
16
|
+
latestTag.replace(tagPrefixPattern, '') !== current) {
|
|
17
|
+
throw new Error(`Latest tag ${JSON.stringify(latestTag)} does not match package.json version ${current}. ` +
|
|
18
|
+
'Tag the current release or restore package.json and its lockfile to the latest tagged version before retrying; refusing a repeated inferred bump.');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function bumpVersion(current, bump) {
|
|
22
|
+
const parts = current.split('.').map((part) => BigInt(part));
|
|
23
|
+
const bumpIndex = { major: 0, minor: 1, patch: 2 }[bump];
|
|
24
|
+
parts[bumpIndex] += BigInt(1);
|
|
25
|
+
for (let index = bumpIndex + 1; index < parts.length; index++)
|
|
26
|
+
parts[index] = BigInt(0);
|
|
27
|
+
return parts.join('.');
|
|
28
|
+
}
|
|
29
|
+
export async function planVersion(commits, latestTag, cwd = process.cwd()) {
|
|
30
|
+
const packagePath = resolve(cwd, 'package.json');
|
|
31
|
+
const packageText = await readFile(packagePath, 'utf8');
|
|
32
|
+
const manifest = parseObject(packageText, packagePath);
|
|
33
|
+
const current = readCurrentVersion(manifest);
|
|
34
|
+
validateLatestTag(latestTag, current);
|
|
35
|
+
const bump = recommendedBump(commits);
|
|
36
|
+
if (!bump)
|
|
37
|
+
return { current, files: [] };
|
|
38
|
+
const next = bumpVersion(current, bump);
|
|
39
|
+
// Metadata and lockfiles are owned by the configured package-manager command.
|
|
40
|
+
return { current, next, bump, files: [] };
|
|
41
|
+
}
|