changelog-machine 1.1.0 → 2.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/LICENSE.md +1 -1
- package/README.md +133 -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 +35 -0
- package/lib/cli/index.d.ts +1 -1
- package/lib/cli/index.js +44 -39
- package/lib/cli/options.d.ts +13 -0
- package/lib/cli/options.js +48 -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/json.d.ts +5 -0
- package/lib/version/json.js +35 -0
- package/lib/version/lockfiles.d.ts +3 -0
- package/lib/version/lockfiles.js +44 -0
- package/lib/version/plan.d.ts +11 -0
- package/lib/version/plan.js +51 -0
- package/package.json +34 -42
- 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/lib/cli/files.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
async function fileMode(path) {
|
|
5
|
+
try {
|
|
6
|
+
const info = await stat(path);
|
|
7
|
+
if (!info.isFile())
|
|
8
|
+
throw new Error(`Not a regular file: ${path}`);
|
|
9
|
+
return info.mode;
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error.code !== 'ENOENT')
|
|
13
|
+
throw error;
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function apply(files) {
|
|
18
|
+
const staged = [];
|
|
19
|
+
try {
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
await mkdir(dirname(file.path), { recursive: true });
|
|
22
|
+
const mode = await fileMode(file.path);
|
|
23
|
+
const temporary = `${file.path}.${randomUUID()}.tmp`;
|
|
24
|
+
staged.push({ path: file.path, temporary });
|
|
25
|
+
await writeFile(temporary, file.content, { flag: 'wx', mode });
|
|
26
|
+
}
|
|
27
|
+
for (const file of staged)
|
|
28
|
+
await rename(file.temporary, file.path);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await Promise.all(staged.map((file) => rm(file.temporary, { force: true })));
|
|
32
|
+
}
|
|
33
|
+
for (const file of files)
|
|
34
|
+
console.error(`Updated ${file.path}`);
|
|
35
|
+
}
|
package/lib/cli/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export declare function run(args?: string[]): Promise<void>;
|
package/lib/cli/index.js
CHANGED
|
@@ -1,43 +1,48 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
-
};
|
|
14
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
const minimist_1 = __importDefault(require("minimist"));
|
|
16
|
-
const default_1 = require("../core/default");
|
|
17
|
-
const printHelp_1 = require("./commands/printHelp");
|
|
18
|
-
const printMarkdown_1 = require("./commands/printMarkdown");
|
|
19
|
-
const printVersion_1 = require("./commands/printVersion");
|
|
20
|
-
const options_1 = require("./config/options");
|
|
21
|
-
const readConfigFile_1 = require("./config/readConfigFile");
|
|
22
|
-
const async_1 = require("../core/async");
|
|
23
|
-
const run = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
24
|
-
if (!(0, async_1.checkPackageJsonExists)()) {
|
|
25
|
-
console.error('Error: package.json does not exist.');
|
|
26
|
-
process.exit(1);
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parseChangelog } from '../changelog/update.js';
|
|
3
|
+
import { getLatestReleaseTag, getReleases } from '../git/history.js';
|
|
4
|
+
import { apply } from './files.js';
|
|
5
|
+
import { parseFlags, showInformation } from './options.js';
|
|
6
|
+
import { changelogPath, planBump, planChangelog } from './plans.js';
|
|
7
|
+
import { withProgress } from './progress.js';
|
|
8
|
+
async function readChangelog(path) {
|
|
9
|
+
try {
|
|
10
|
+
return await readFile(path, 'utf8');
|
|
27
11
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code === 'ENOENT')
|
|
14
|
+
return undefined;
|
|
15
|
+
throw error;
|
|
32
16
|
}
|
|
33
|
-
|
|
34
|
-
|
|
17
|
+
}
|
|
18
|
+
export async function run(args = process.argv.slice(2)) {
|
|
19
|
+
const flags = parseFlags(args);
|
|
20
|
+
if (await showInformation(flags))
|
|
35
21
|
return;
|
|
22
|
+
const cwd = process.cwd();
|
|
23
|
+
const existing = flags.full
|
|
24
|
+
? undefined
|
|
25
|
+
: await readChangelog(changelogPath(flags, cwd));
|
|
26
|
+
const boundary = existing === undefined ? undefined : parseChangelog(existing).boundaryTag;
|
|
27
|
+
const bumpTag = flags.bump ? await getLatestReleaseTag(cwd) : undefined;
|
|
28
|
+
const bump = flags.bump
|
|
29
|
+
? await planBump(await getReleases(cwd, bumpTag), cwd, bumpTag)
|
|
30
|
+
: undefined;
|
|
31
|
+
if (flags.bump && !bump?.next)
|
|
32
|
+
return;
|
|
33
|
+
let releases = await withProgress('Reading Git releases', () => getReleases(cwd, boundary));
|
|
34
|
+
const latestTag = releases.find((release) => release.tag !== 'Unreleased')?.tag ??
|
|
35
|
+
releases.find((release) => release.tag === 'Unreleased')?.previousTag ??
|
|
36
|
+
boundary;
|
|
37
|
+
if (bump?.next) {
|
|
38
|
+
releases = releases.map((release) => release.tag === 'Unreleased'
|
|
39
|
+
? { ...release, tag: `v${bump.next}`, previousTag: latestTag }
|
|
40
|
+
: release);
|
|
36
41
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
const files = [
|
|
43
|
+
...(bump?.files ?? []),
|
|
44
|
+
...(await planChangelog(releases, flags, cwd, existing)),
|
|
45
|
+
];
|
|
46
|
+
if (!flags['dry-run'])
|
|
47
|
+
await apply(files);
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type Flags = ReturnType<typeof parseFlags>;
|
|
2
|
+
export declare function parseFlags(args: string[]): {
|
|
3
|
+
full?: boolean | undefined;
|
|
4
|
+
bump?: boolean | undefined;
|
|
5
|
+
'dry-run'?: boolean | undefined;
|
|
6
|
+
output?: string | undefined;
|
|
7
|
+
'repo-url'?: string | undefined;
|
|
8
|
+
'resolve-contributors'?: boolean | undefined;
|
|
9
|
+
'include-non-conventional'?: boolean | undefined;
|
|
10
|
+
help?: boolean | undefined;
|
|
11
|
+
version?: boolean | undefined;
|
|
12
|
+
};
|
|
13
|
+
export declare function showInformation(flags: Flags): Promise<boolean>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
const help = `Usage: changelog-machine [options]
|
|
4
|
+
|
|
5
|
+
Updates missing releases and refreshes Unreleased in CHANGELOG.md by default.
|
|
6
|
+
Creates a complete changelog when the file does not exist.
|
|
7
|
+
|
|
8
|
+
--full Replace the entire CHANGELOG.md from Git history
|
|
9
|
+
--bump Infer version; update package metadata and changelog
|
|
10
|
+
--dry-run Preview without writing any files
|
|
11
|
+
--output <path> Changelog destination (default: CHANGELOG.md)
|
|
12
|
+
--repo-url <url> Repository URL for links
|
|
13
|
+
--resolve-contributors Look up verified hosting profiles (uses network)
|
|
14
|
+
--include-non-conventional Include other commits under Other Changes
|
|
15
|
+
--help Show help
|
|
16
|
+
--version Show this tool's version
|
|
17
|
+
|
|
18
|
+
Requires Node 24+ and Git. Never fetches, commits, tags, pushes or publishes.
|
|
19
|
+
`;
|
|
20
|
+
export function parseFlags(args) {
|
|
21
|
+
return parseArgs({
|
|
22
|
+
args,
|
|
23
|
+
options: {
|
|
24
|
+
full: { type: 'boolean' },
|
|
25
|
+
bump: { type: 'boolean' },
|
|
26
|
+
'dry-run': { type: 'boolean' },
|
|
27
|
+
output: { type: 'string' },
|
|
28
|
+
'repo-url': { type: 'string' },
|
|
29
|
+
'resolve-contributors': { type: 'boolean' },
|
|
30
|
+
'include-non-conventional': { type: 'boolean' },
|
|
31
|
+
help: { type: 'boolean', short: 'h' },
|
|
32
|
+
version: { type: 'boolean', short: 'v' },
|
|
33
|
+
},
|
|
34
|
+
allowPositionals: false,
|
|
35
|
+
}).values;
|
|
36
|
+
}
|
|
37
|
+
export async function showInformation(flags) {
|
|
38
|
+
if (flags.help) {
|
|
39
|
+
process.stdout.write(help);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (flags.version) {
|
|
43
|
+
const pkg = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
44
|
+
console.log(pkg.version);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Release } from '../types.js';
|
|
2
|
+
import type { VersionPlan } from '../version/plan.js';
|
|
3
|
+
import type { FilePlan } from './files.js';
|
|
4
|
+
import type { Flags } from './options.js';
|
|
5
|
+
export declare function planBump(releases: Release[], cwd: string, latestTag?: string): Promise<VersionPlan>;
|
|
6
|
+
export declare function changelogPath(flags: Flags, cwd: string): string;
|
|
7
|
+
export declare function planChangelog(releases: Release[], flags: Flags, cwd: string, existing?: string): Promise<FilePlan[]>;
|
package/lib/cli/plans.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { renderMarkdown } from '../changelog/render.js';
|
|
3
|
+
import { mergeChangelog, parseChangelog } from '../changelog/update.js';
|
|
4
|
+
import { parseCommit } from '../commits/parse.js';
|
|
5
|
+
import { resolveContributors } from '../contributors/resolve.js';
|
|
6
|
+
import { getRepositoryUrl } from '../git/remote.js';
|
|
7
|
+
import { planVersion } from '../version/plan.js';
|
|
8
|
+
import { withProgress } from './progress.js';
|
|
9
|
+
export async function planBump(releases, cwd, latestTag) {
|
|
10
|
+
const unreleased = releases.find((release) => release.tag === 'Unreleased');
|
|
11
|
+
const plan = await planVersion(unreleased?.commits ?? [], latestTag, cwd);
|
|
12
|
+
console.error(plan.next
|
|
13
|
+
? `${plan.current} → ${plan.next} (${plan.bump})`
|
|
14
|
+
: `No version bump (${plan.current})`);
|
|
15
|
+
return plan;
|
|
16
|
+
}
|
|
17
|
+
async function repositoryUrl(flags, cwd) {
|
|
18
|
+
const repository = flags['repo-url'] ?? (await getRepositoryUrl(cwd));
|
|
19
|
+
if (repository) {
|
|
20
|
+
const url = new URL(repository);
|
|
21
|
+
if (url.protocol !== 'https:' ||
|
|
22
|
+
url.username ||
|
|
23
|
+
url.password ||
|
|
24
|
+
url.search ||
|
|
25
|
+
url.hash) {
|
|
26
|
+
throw new Error('--repo-url must be an HTTPS repository URL without credentials, query or fragment');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return repository;
|
|
30
|
+
}
|
|
31
|
+
function changelogReleases(releases, flags) {
|
|
32
|
+
return releases.filter((release) => {
|
|
33
|
+
if (release.tag !== 'Unreleased')
|
|
34
|
+
return true;
|
|
35
|
+
return release.commits.some((commit) => parseCommit(commit, flags['include-non-conventional']));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function changelogPath(flags, cwd) {
|
|
39
|
+
const path = resolve(cwd, flags.output ?? 'CHANGELOG.md');
|
|
40
|
+
if (['package.json', 'package-lock.json', 'npm-shrinkwrap.json'].some((name) => path === resolve(cwd, name))) {
|
|
41
|
+
throw new Error('Changelog output must not overwrite package metadata');
|
|
42
|
+
}
|
|
43
|
+
return path;
|
|
44
|
+
}
|
|
45
|
+
export async function planChangelog(releases, flags, cwd, existing) {
|
|
46
|
+
const selectedReleases = changelogReleases(releases, flags);
|
|
47
|
+
const url = await repositoryUrl(flags, cwd);
|
|
48
|
+
const commits = selectedReleases
|
|
49
|
+
.flatMap((release) => release.commits)
|
|
50
|
+
.filter((commit) => parseCommit(commit, flags['include-non-conventional']));
|
|
51
|
+
const contributors = flags['resolve-contributors']
|
|
52
|
+
? await withProgress('Resolving contributor profiles', (update) => resolveContributors(commits, url, (progress) => update(`Contributors: ${progress.completed}/${progress.total} checked, ${progress.resolved} resolved, ${progress.requests} requests`)))
|
|
53
|
+
: undefined;
|
|
54
|
+
const markdown = renderMarkdown(selectedReleases, {
|
|
55
|
+
repositoryUrl: url,
|
|
56
|
+
contributors,
|
|
57
|
+
includeNonConventional: flags['include-non-conventional'],
|
|
58
|
+
});
|
|
59
|
+
const updated = mergeChangelog(existing === undefined ? undefined : parseChangelog(existing), markdown);
|
|
60
|
+
if (flags['dry-run']) {
|
|
61
|
+
process.stdout.write(updated);
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return updated === existing
|
|
65
|
+
? []
|
|
66
|
+
: [{ path: changelogPath(flags, cwd), content: updated }];
|
|
67
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { clearLine, cursorTo } from 'node:readline';
|
|
2
|
+
const frames = ['|', '/', '-', '\\'];
|
|
3
|
+
const refreshIntervalMs = 100;
|
|
4
|
+
const displayDelayMs = 200;
|
|
5
|
+
export async function withProgress(label, task, output = process.stderr) {
|
|
6
|
+
if (!output.isTTY)
|
|
7
|
+
return task(() => { });
|
|
8
|
+
const update = (message) => {
|
|
9
|
+
label = message;
|
|
10
|
+
};
|
|
11
|
+
let frame = 0;
|
|
12
|
+
let visible = false;
|
|
13
|
+
let animation;
|
|
14
|
+
const render = () => {
|
|
15
|
+
cursorTo(output, 0);
|
|
16
|
+
clearLine(output, 0);
|
|
17
|
+
output.write(`${frames[frame++ % frames.length]} ${label}`);
|
|
18
|
+
visible = true;
|
|
19
|
+
};
|
|
20
|
+
const delay = setTimeout(() => {
|
|
21
|
+
render();
|
|
22
|
+
animation = setInterval(render, refreshIntervalMs);
|
|
23
|
+
animation.unref();
|
|
24
|
+
}, displayDelayMs);
|
|
25
|
+
delay.unref();
|
|
26
|
+
try {
|
|
27
|
+
return await task(update);
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
clearTimeout(delay);
|
|
31
|
+
clearInterval(animation);
|
|
32
|
+
if (visible) {
|
|
33
|
+
cursorTo(output, 0);
|
|
34
|
+
clearLine(output, 0);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { isVersionTag } from '../git/tags.js';
|
|
2
|
+
const conventionalHeaderPattern = /^([A-Za-z][A-Za-z0-9-]*)(?:\(([^()\r\n]+)\))?(!)?:\s+(.+)$/;
|
|
3
|
+
const breakingNotePattern = /^BREAKING[ -]CHANGE:\s*(.*)$/;
|
|
4
|
+
const breakingFooterPattern = /^BREAKING[ -]CHANGE:/m;
|
|
5
|
+
const footerTokenPattern = /^[A-Za-z][A-Za-z0-9-]*/;
|
|
6
|
+
const footerSeparatorPattern = /^(?::\s|\s+#)/;
|
|
7
|
+
const pullRequestSuffixPattern = /\s+\(#(\d+)\)$/;
|
|
8
|
+
const lineBreakPattern = /\r?\n/;
|
|
9
|
+
function isFooter(line) {
|
|
10
|
+
const token = footerTokenPattern.exec(line);
|
|
11
|
+
return Boolean(token && footerSeparatorPattern.test(line.slice(token[0].length)));
|
|
12
|
+
}
|
|
13
|
+
function breakingNotes(body) {
|
|
14
|
+
const notes = [];
|
|
15
|
+
let noteLines;
|
|
16
|
+
const finishNote = () => {
|
|
17
|
+
if (noteLines) {
|
|
18
|
+
const note = noteLines.join('\n').trim();
|
|
19
|
+
if (note)
|
|
20
|
+
notes.push(note);
|
|
21
|
+
}
|
|
22
|
+
noteLines = undefined;
|
|
23
|
+
};
|
|
24
|
+
for (const line of body.split(lineBreakPattern)) {
|
|
25
|
+
const breakingMatch = breakingNotePattern.exec(line);
|
|
26
|
+
if (breakingMatch) {
|
|
27
|
+
finishNote();
|
|
28
|
+
noteLines = [breakingMatch[1]];
|
|
29
|
+
}
|
|
30
|
+
else if (isFooter(line)) {
|
|
31
|
+
finishNote();
|
|
32
|
+
}
|
|
33
|
+
else if (noteLines) {
|
|
34
|
+
noteLines.push(line);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
finishNote();
|
|
38
|
+
return notes;
|
|
39
|
+
}
|
|
40
|
+
function isReleaseCommit(type, scope, description) {
|
|
41
|
+
const version = description.startsWith('release ')
|
|
42
|
+
? description.slice('release '.length)
|
|
43
|
+
: description;
|
|
44
|
+
const releaseType = type === 'release';
|
|
45
|
+
const releaseChore = type === 'chore' &&
|
|
46
|
+
(scope === 'release' || description.startsWith('release '));
|
|
47
|
+
return (releaseType || releaseChore) && isVersionTag(version);
|
|
48
|
+
}
|
|
49
|
+
export function parseCommit(commit, includeNonConventional = false) {
|
|
50
|
+
const headerMatch = conventionalHeaderPattern.exec(commit.subject.trim());
|
|
51
|
+
if (!headerMatch && !includeNonConventional)
|
|
52
|
+
return undefined;
|
|
53
|
+
let description = headerMatch ? headerMatch[4] : commit.subject.trim();
|
|
54
|
+
const pullRequestMatch = pullRequestSuffixPattern.exec(description);
|
|
55
|
+
if (pullRequestMatch)
|
|
56
|
+
description = description.slice(0, pullRequestMatch.index).trimEnd();
|
|
57
|
+
if (headerMatch &&
|
|
58
|
+
isReleaseCommit(headerMatch[1].toLowerCase(), headerMatch[2]?.toLowerCase(), description))
|
|
59
|
+
return undefined;
|
|
60
|
+
const notes = breakingNotes(commit.body);
|
|
61
|
+
return {
|
|
62
|
+
...commit,
|
|
63
|
+
type: headerMatch ? headerMatch[1].toLowerCase() : 'other',
|
|
64
|
+
...(headerMatch?.[2] ? { scope: headerMatch[2] } : {}),
|
|
65
|
+
description,
|
|
66
|
+
breaking: Boolean(headerMatch?.[3]) ||
|
|
67
|
+
notes.length > 0 ||
|
|
68
|
+
breakingFooterPattern.test(commit.body),
|
|
69
|
+
breakingNotes: notes,
|
|
70
|
+
...(pullRequestMatch ? { pr: pullRequestMatch[1] } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function recommendedBump(commits) {
|
|
74
|
+
let bump;
|
|
75
|
+
for (const commit of commits) {
|
|
76
|
+
const parsed = parseCommit(commit);
|
|
77
|
+
if (!parsed)
|
|
78
|
+
continue;
|
|
79
|
+
if (parsed.breaking)
|
|
80
|
+
return 'major';
|
|
81
|
+
if (parsed.type === 'feat')
|
|
82
|
+
bump = 'minor';
|
|
83
|
+
else if ((parsed.type === 'fix' || parsed.type === 'perf') && !bump)
|
|
84
|
+
bump = 'patch';
|
|
85
|
+
}
|
|
86
|
+
return bump;
|
|
87
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const validHash = /^[a-f\d]{7,64}$/i;
|
|
2
|
+
function authorIdentity(commit) {
|
|
3
|
+
if (commit.email?.trim())
|
|
4
|
+
return `email:${commit.email.trim().toLowerCase()}`;
|
|
5
|
+
if (commit.name?.trim())
|
|
6
|
+
return `name:${commit.name.trim()}`;
|
|
7
|
+
return `commit:${commit.hash}`;
|
|
8
|
+
}
|
|
9
|
+
export function groupAuthorCommits(commits) {
|
|
10
|
+
const authors = new Map();
|
|
11
|
+
for (const commit of commits) {
|
|
12
|
+
if (!validHash.test(commit.hash))
|
|
13
|
+
continue;
|
|
14
|
+
const identity = authorIdentity(commit);
|
|
15
|
+
const hashes = authors.get(identity) ?? new Set();
|
|
16
|
+
hashes.add(commit.hash);
|
|
17
|
+
authors.set(identity, hashes);
|
|
18
|
+
}
|
|
19
|
+
return [...authors.values()].map((hashes) => [...hashes]);
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const emailAddressPattern = /[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+(?:@|%40)[A-Z0-9-]+(?:(?:\.|%2e)[A-Z0-9-]+)+/gi;
|
|
2
|
+
const unknownContributor = 'Unknown contributor';
|
|
3
|
+
export function redactEmails(value) {
|
|
4
|
+
return value.replace(emailAddressPattern, '[redacted email]');
|
|
5
|
+
}
|
|
6
|
+
export function authorName(value) {
|
|
7
|
+
const name = value.trim();
|
|
8
|
+
return !name || redactEmails(name) !== name ? unknownContributor : name;
|
|
9
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ContributorProfile } from '../types.js';
|
|
2
|
+
export type { ContributorProfile } from '../types.js';
|
|
3
|
+
export declare function validateProfile(username: unknown, urlValue: unknown, host: string): ContributorProfile | undefined;
|
|
4
|
+
export declare function readGitHubProfile(data: unknown): ContributorProfile | undefined;
|
|
5
|
+
export declare function readBitbucketProfile(data: unknown): ContributorProfile | undefined;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function objectValue(value) {
|
|
2
|
+
return value !== null && typeof value === 'object'
|
|
3
|
+
? value
|
|
4
|
+
: undefined;
|
|
5
|
+
}
|
|
6
|
+
export function validateProfile(username, urlValue, host) {
|
|
7
|
+
if (typeof username !== 'string' ||
|
|
8
|
+
!username.trim() ||
|
|
9
|
+
username.includes('@') ||
|
|
10
|
+
/[<>\r\n]/.test(username))
|
|
11
|
+
return undefined;
|
|
12
|
+
if (host === 'github.com' && /\s/.test(username))
|
|
13
|
+
return undefined;
|
|
14
|
+
if (typeof urlValue !== 'string')
|
|
15
|
+
return undefined;
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(urlValue);
|
|
18
|
+
const path = decodeURIComponent(url.pathname);
|
|
19
|
+
if (url.protocol !== 'https:' ||
|
|
20
|
+
url.hostname.toLowerCase() !== host ||
|
|
21
|
+
url.username ||
|
|
22
|
+
url.password ||
|
|
23
|
+
url.port ||
|
|
24
|
+
url.search ||
|
|
25
|
+
url.hash ||
|
|
26
|
+
!path ||
|
|
27
|
+
path === '/' ||
|
|
28
|
+
path.includes('@') ||
|
|
29
|
+
/%40/i.test(url.pathname))
|
|
30
|
+
return undefined;
|
|
31
|
+
return { username: username.trim(), url: url.href };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function readGitHubProfile(data) {
|
|
38
|
+
const author = objectValue(objectValue(data)?.author);
|
|
39
|
+
return validateProfile(author?.login, author?.html_url, 'github.com');
|
|
40
|
+
}
|
|
41
|
+
export function readBitbucketProfile(data) {
|
|
42
|
+
const response = objectValue(data);
|
|
43
|
+
const author = objectValue(response?.author);
|
|
44
|
+
const user = objectValue(author?.user);
|
|
45
|
+
if (!user)
|
|
46
|
+
return undefined;
|
|
47
|
+
const links = objectValue(user.links);
|
|
48
|
+
const html = objectValue(links?.html);
|
|
49
|
+
const username = user.nickname ?? user.display_name;
|
|
50
|
+
return validateProfile(username, html?.href, 'bitbucket.org');
|
|
51
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface Repository {
|
|
2
|
+
host: 'github.com' | 'bitbucket.org';
|
|
3
|
+
path: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function parseRepository(repositoryUrl?: string): Repository | undefined;
|
|
6
|
+
export declare function getCommitEndpoint(repository: Repository, hash: string): string;
|
|
7
|
+
export declare function getRepositoryToken(repository: Repository): string | undefined;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function parseRepository(repositoryUrl) {
|
|
2
|
+
if (!repositoryUrl)
|
|
3
|
+
return undefined;
|
|
4
|
+
try {
|
|
5
|
+
const url = new URL(repositoryUrl);
|
|
6
|
+
if (url.protocol !== 'https:' ||
|
|
7
|
+
url.username ||
|
|
8
|
+
url.password ||
|
|
9
|
+
url.port ||
|
|
10
|
+
url.search ||
|
|
11
|
+
url.hash)
|
|
12
|
+
return undefined;
|
|
13
|
+
const host = url.hostname.toLowerCase();
|
|
14
|
+
if (host !== 'github.com' && host !== 'bitbucket.org')
|
|
15
|
+
return undefined;
|
|
16
|
+
const path = url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '');
|
|
17
|
+
const segments = path.split('/');
|
|
18
|
+
if (segments.length !== 2 ||
|
|
19
|
+
segments.some((part) => !/^[a-z\d_.-]+$/i.test(part) || part === '.' || part === '..'))
|
|
20
|
+
return undefined;
|
|
21
|
+
return { host, path };
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function getCommitEndpoint(repository, hash) {
|
|
28
|
+
return repository.host === 'github.com'
|
|
29
|
+
? `https://api.github.com/repos/${repository.path}/commits/${hash}`
|
|
30
|
+
: `https://api.bitbucket.org/2.0/repositories/${repository.path}/commit/${hash}`;
|
|
31
|
+
}
|
|
32
|
+
export function getRepositoryToken(repository) {
|
|
33
|
+
return repository.host === 'github.com'
|
|
34
|
+
? process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
|
35
|
+
: process.env.BITBUCKET_TOKEN;
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AuthorCommit } from './authors.js';
|
|
2
|
+
import type { ContributorProfile } from './profile.js';
|
|
3
|
+
export type { ContributorProfile } from './profile.js';
|
|
4
|
+
export interface ContributorProgress {
|
|
5
|
+
completed: number;
|
|
6
|
+
total: number;
|
|
7
|
+
resolved: number;
|
|
8
|
+
requests: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function resolveContributors(commits: readonly AuthorCommit[], repositoryUrl?: string, onProgress?: (progress: ContributorProgress) => void): Promise<Record<string, ContributorProfile>>;
|
|
@@ -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
|
+
}
|