githublogen 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/cli.cjs +68 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.mjs +61 -0
- package/dist/index.cjs +420 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.mjs +401 -0
- package/package.json +86 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# githublogen
|
|
2
|
+
|
|
3
|
+
Generate changelog for GitHub releases from [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), powered by [changelogithub](https://github.com/antfu/changelogithub).
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
In GitHub Actions:
|
|
8
|
+
|
|
9
|
+
```yml
|
|
10
|
+
# .github/workflows/release.yml
|
|
11
|
+
|
|
12
|
+
name: Release
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: write
|
|
16
|
+
|
|
17
|
+
on:
|
|
18
|
+
push:
|
|
19
|
+
tags:
|
|
20
|
+
- "v*"
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
release:
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v3
|
|
27
|
+
with:
|
|
28
|
+
fetch-depth: 0
|
|
29
|
+
|
|
30
|
+
- uses: actions/setup-node@v3
|
|
31
|
+
with:
|
|
32
|
+
node-version: 16.x
|
|
33
|
+
|
|
34
|
+
- run: npx githublogen
|
|
35
|
+
env:
|
|
36
|
+
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
It will be trigged whenever you push a tag to GitHub that starts with `v`.
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
You can put a configuration file in the project root, named as `githublogen.config.{json,ts,js,mjs,cjs}`, `.githublogenrc` or use the `githublogen` field in `package.json`.
|
|
44
|
+
|
|
45
|
+
## Preview Locally
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx githublogen --dry
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs/promises');
|
|
5
|
+
const kolorist = require('kolorist');
|
|
6
|
+
const cac = require('cac');
|
|
7
|
+
const index = require('./index.cjs');
|
|
8
|
+
require('ohmyfetch');
|
|
9
|
+
require('convert-gitmoji');
|
|
10
|
+
|
|
11
|
+
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; }
|
|
12
|
+
|
|
13
|
+
const fs__default = /*#__PURE__*/_interopDefaultCompat(fs);
|
|
14
|
+
const cac__default = /*#__PURE__*/_interopDefaultCompat(cac);
|
|
15
|
+
|
|
16
|
+
const version = "0.0.1";
|
|
17
|
+
|
|
18
|
+
const cli = cac__default("githublogen");
|
|
19
|
+
cli.version(version).option("-t, --token <path>", "GitHub Token").option("--from <ref>", "From tag").option("--to <ref>", "To tag").option("--github <path>", "GitHub Repository, e.g. soybeanjs/githublogen").option("--name <name>", "Name of the release").option("--contributors", "Show contributors section").option("--prerelease", "Mark release as prerelease").option("-d, --draft", "Mark release as draft").option("--output <path>", "Output to file instead of sending to GitHub").option("--capitalize", "Should capitalize for each comment message").option("--emoji", "Use emojis in section titles", { default: true }).option("--group", "Nest commit messages under their scopes").option("--dry", "Dry run").help();
|
|
20
|
+
cli.command("").action(async (args) => {
|
|
21
|
+
args.token = args.token || process.env.GITHUB_TOKEN;
|
|
22
|
+
try {
|
|
23
|
+
console.log();
|
|
24
|
+
console.log(kolorist.dim(`${kolorist.bold("github")}logen `) + kolorist.dim(`v${version}`));
|
|
25
|
+
const { config, md, commits } = await index.generate(args);
|
|
26
|
+
console.log(kolorist.cyan(config.from) + kolorist.dim(" -> ") + kolorist.blue(config.to) + kolorist.dim(` (${commits.length} commits)`));
|
|
27
|
+
console.log(kolorist.dim("--------------"));
|
|
28
|
+
console.log();
|
|
29
|
+
console.log(md.replace(/ /g, ""));
|
|
30
|
+
console.log();
|
|
31
|
+
console.log(kolorist.dim("--------------"));
|
|
32
|
+
if (config.dry) {
|
|
33
|
+
console.log(kolorist.yellow("Dry run. Release skipped."));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!config.token) {
|
|
37
|
+
console.error(kolorist.red("No GitHub token found, specify it via GITHUB_TOKEN env. Release skipped."));
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (typeof config.output === "string") {
|
|
42
|
+
await fs__default.writeFile(config.output, md, "utf-8");
|
|
43
|
+
console.log(kolorist.yellow(`Saved to ${config.output}`));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!await index.hasTagOnGitHub(config.to, config)) {
|
|
47
|
+
console.error(kolorist.yellow(`Current ref "${kolorist.bold(config.to)}" is not available as tags on GitHub. Release skipped.`));
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!commits.length && await index.isRepoShallow()) {
|
|
52
|
+
console.error(
|
|
53
|
+
kolorist.yellow(
|
|
54
|
+
"The repo seems to be clone shallowly, which make changelog failed to generate. You might want to specify `fetch-depth: 0` in your CI config."
|
|
55
|
+
)
|
|
56
|
+
);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await index.sendRelease(config, md);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.error(kolorist.red(String(e)));
|
|
63
|
+
if (e?.stack)
|
|
64
|
+
console.error(kolorist.dim(e.stack?.split("\n").slice(1).join("\n")));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
cli.parse();
|
package/dist/cli.d.ts
ADDED
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { dim, bold, cyan, blue, yellow, red } from 'kolorist';
|
|
4
|
+
import cac from 'cac';
|
|
5
|
+
import { generate, hasTagOnGitHub, isRepoShallow, sendRelease } from './index.mjs';
|
|
6
|
+
import 'ohmyfetch';
|
|
7
|
+
import 'convert-gitmoji';
|
|
8
|
+
|
|
9
|
+
const version = "0.0.1";
|
|
10
|
+
|
|
11
|
+
const cli = cac("githublogen");
|
|
12
|
+
cli.version(version).option("-t, --token <path>", "GitHub Token").option("--from <ref>", "From tag").option("--to <ref>", "To tag").option("--github <path>", "GitHub Repository, e.g. soybeanjs/githublogen").option("--name <name>", "Name of the release").option("--contributors", "Show contributors section").option("--prerelease", "Mark release as prerelease").option("-d, --draft", "Mark release as draft").option("--output <path>", "Output to file instead of sending to GitHub").option("--capitalize", "Should capitalize for each comment message").option("--emoji", "Use emojis in section titles", { default: true }).option("--group", "Nest commit messages under their scopes").option("--dry", "Dry run").help();
|
|
13
|
+
cli.command("").action(async (args) => {
|
|
14
|
+
args.token = args.token || process.env.GITHUB_TOKEN;
|
|
15
|
+
try {
|
|
16
|
+
console.log();
|
|
17
|
+
console.log(dim(`${bold("github")}logen `) + dim(`v${version}`));
|
|
18
|
+
const { config, md, commits } = await generate(args);
|
|
19
|
+
console.log(cyan(config.from) + dim(" -> ") + blue(config.to) + dim(` (${commits.length} commits)`));
|
|
20
|
+
console.log(dim("--------------"));
|
|
21
|
+
console.log();
|
|
22
|
+
console.log(md.replace(/ /g, ""));
|
|
23
|
+
console.log();
|
|
24
|
+
console.log(dim("--------------"));
|
|
25
|
+
if (config.dry) {
|
|
26
|
+
console.log(yellow("Dry run. Release skipped."));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!config.token) {
|
|
30
|
+
console.error(red("No GitHub token found, specify it via GITHUB_TOKEN env. Release skipped."));
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (typeof config.output === "string") {
|
|
35
|
+
await fs.writeFile(config.output, md, "utf-8");
|
|
36
|
+
console.log(yellow(`Saved to ${config.output}`));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!await hasTagOnGitHub(config.to, config)) {
|
|
40
|
+
console.error(yellow(`Current ref "${bold(config.to)}" is not available as tags on GitHub. Release skipped.`));
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!commits.length && await isRepoShallow()) {
|
|
45
|
+
console.error(
|
|
46
|
+
yellow(
|
|
47
|
+
"The repo seems to be clone shallowly, which make changelog failed to generate. You might want to specify `fetch-depth: 0` in your CI config."
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
await sendRelease(config, md);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error(red(String(e)));
|
|
56
|
+
if (e?.stack)
|
|
57
|
+
console.error(dim(e.stack?.split("\n").slice(1).join("\n")));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
cli.parse();
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ohmyfetch = require('ohmyfetch');
|
|
4
|
+
const kolorist = require('kolorist');
|
|
5
|
+
const convertGitmoji = require('convert-gitmoji');
|
|
6
|
+
|
|
7
|
+
function partition(array, ...filters) {
|
|
8
|
+
const result = new Array(filters.length + 1).fill(null).map(() => []);
|
|
9
|
+
array.forEach((e, idx, arr) => {
|
|
10
|
+
let i = 0;
|
|
11
|
+
for (const filter of filters) {
|
|
12
|
+
if (filter(e, idx, arr)) {
|
|
13
|
+
result[i].push(e);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
i += 1;
|
|
17
|
+
}
|
|
18
|
+
result[i].push(e);
|
|
19
|
+
});
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
function notNullish(v) {
|
|
23
|
+
return v !== null && v !== void 0;
|
|
24
|
+
}
|
|
25
|
+
function groupBy(items, key, groups = {}) {
|
|
26
|
+
for (const item of items) {
|
|
27
|
+
const v = item[key];
|
|
28
|
+
groups[v] = groups[v] || [];
|
|
29
|
+
groups[v].push(item);
|
|
30
|
+
}
|
|
31
|
+
return groups;
|
|
32
|
+
}
|
|
33
|
+
function capitalize(str) {
|
|
34
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
35
|
+
}
|
|
36
|
+
function join(array, glue = ", ", finalGlue = " and ") {
|
|
37
|
+
if (!array || array.length === 0)
|
|
38
|
+
return "";
|
|
39
|
+
if (array.length === 1)
|
|
40
|
+
return array[0];
|
|
41
|
+
if (array.length === 2)
|
|
42
|
+
return array.join(finalGlue);
|
|
43
|
+
return `${array.slice(0, -1).join(glue)}${finalGlue}${array.slice(-1)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function sendRelease(options, content) {
|
|
47
|
+
const headers = getHeaders(options);
|
|
48
|
+
let url = `https://api.github.com/repos/${options.github}/releases`;
|
|
49
|
+
let method = "POST";
|
|
50
|
+
try {
|
|
51
|
+
const exists = await ohmyfetch.$fetch(`https://api.github.com/repos/${options.github}/releases/tags/${options.to}`, {
|
|
52
|
+
headers
|
|
53
|
+
});
|
|
54
|
+
if (exists.url) {
|
|
55
|
+
url = exists.url;
|
|
56
|
+
method = "PATCH";
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
}
|
|
60
|
+
const body = {
|
|
61
|
+
body: content,
|
|
62
|
+
draft: options.draft || false,
|
|
63
|
+
name: options.name || options.to,
|
|
64
|
+
prerelease: options.prerelease,
|
|
65
|
+
tag_name: options.to
|
|
66
|
+
};
|
|
67
|
+
const webUrl = `https://github.com/${options.github}/releases/new?title=${encodeURIComponent(
|
|
68
|
+
String(body.name)
|
|
69
|
+
)}&body=${encodeURIComponent(String(body.body))}&tag=${encodeURIComponent(String(options.to))}&prerelease=${options.prerelease}`;
|
|
70
|
+
try {
|
|
71
|
+
console.log(kolorist.cyan(method === "POST" ? "Creating release notes..." : "Updating release notes..."));
|
|
72
|
+
const res = await ohmyfetch.$fetch(url, {
|
|
73
|
+
method,
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
headers
|
|
76
|
+
});
|
|
77
|
+
console.log(kolorist.green(`Released on ${res.html_url}`));
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.log();
|
|
80
|
+
console.error(kolorist.red("Failed to create the release. Using the following link to create it manually:"));
|
|
81
|
+
console.error(kolorist.yellow(webUrl));
|
|
82
|
+
console.log();
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function getHeaders(options) {
|
|
87
|
+
return {
|
|
88
|
+
accept: "application/vnd.github.v3+json",
|
|
89
|
+
authorization: `token ${options.token}`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async function resolveAuthorInfo(options, info) {
|
|
93
|
+
if (info.login)
|
|
94
|
+
return info;
|
|
95
|
+
if (!options.token)
|
|
96
|
+
return info;
|
|
97
|
+
const authorInfo = { ...info };
|
|
98
|
+
try {
|
|
99
|
+
const data = await ohmyfetch.$fetch(`https://api.github.com/search/users?q=${encodeURIComponent(info.email)}`, {
|
|
100
|
+
headers: getHeaders(options)
|
|
101
|
+
});
|
|
102
|
+
authorInfo.login = data.items[0].login;
|
|
103
|
+
} catch {
|
|
104
|
+
}
|
|
105
|
+
if (info.login)
|
|
106
|
+
return info;
|
|
107
|
+
if (info.commits.length) {
|
|
108
|
+
try {
|
|
109
|
+
const data = await ohmyfetch.$fetch(`https://api.github.com/repos/${options.github}/commits/${info.commits[0]}`, {
|
|
110
|
+
headers: getHeaders(options)
|
|
111
|
+
});
|
|
112
|
+
authorInfo.login = data.author.login;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return authorInfo;
|
|
117
|
+
}
|
|
118
|
+
async function resolveAuthors(commits, options) {
|
|
119
|
+
const map = /* @__PURE__ */ new Map();
|
|
120
|
+
commits.forEach((commit) => {
|
|
121
|
+
commit.resolvedAuthors = commit.authors.map((a, idx) => {
|
|
122
|
+
if (!a.email || !a.name)
|
|
123
|
+
return null;
|
|
124
|
+
if (!map.has(a.email)) {
|
|
125
|
+
map.set(a.email, {
|
|
126
|
+
commits: [],
|
|
127
|
+
name: a.name,
|
|
128
|
+
email: a.email
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const info = map.get(a.email);
|
|
132
|
+
if (idx === 0)
|
|
133
|
+
info.commits.push(commit.shortHash);
|
|
134
|
+
return info;
|
|
135
|
+
}).filter(notNullish);
|
|
136
|
+
});
|
|
137
|
+
const authors = Array.from(map.values());
|
|
138
|
+
const resolved = await Promise.all(authors.map((info) => resolveAuthorInfo(options, info)));
|
|
139
|
+
const loginSet = /* @__PURE__ */ new Set();
|
|
140
|
+
const nameSet = /* @__PURE__ */ new Set();
|
|
141
|
+
return resolved.sort((a, b) => (a.login || a.name).localeCompare(b.login || b.name)).filter((i) => {
|
|
142
|
+
if (i.login && loginSet.has(i.login))
|
|
143
|
+
return false;
|
|
144
|
+
if (i.login) {
|
|
145
|
+
loginSet.add(i.login);
|
|
146
|
+
} else {
|
|
147
|
+
if (nameSet.has(i.name))
|
|
148
|
+
return false;
|
|
149
|
+
nameSet.add(i.name);
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
async function hasTagOnGitHub(tag, options) {
|
|
155
|
+
try {
|
|
156
|
+
await ohmyfetch.$fetch(`https://api.github.com/repos/${options.github}/git/ref/tags/${tag}`, {
|
|
157
|
+
headers: getHeaders(options)
|
|
158
|
+
});
|
|
159
|
+
return true;
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function getGitHubRepo() {
|
|
166
|
+
const url = await execCommand("git", ["config", "--get", "remote.origin.url"]);
|
|
167
|
+
const match = url.match(/github\.com[\/:]([\w\d._-]+?)\/([\w\d._-]+?)(\.git)?$/i);
|
|
168
|
+
if (!match)
|
|
169
|
+
throw new Error(`Can not parse GitHub repo from url ${url}`);
|
|
170
|
+
return `${match[1]}/${match[2]}`;
|
|
171
|
+
}
|
|
172
|
+
async function getCurrentGitBranch() {
|
|
173
|
+
const result1 = await execCommand("git", ["tag", "--points-at", "HEAD"]);
|
|
174
|
+
const result2 = await execCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
175
|
+
return result1 || result2;
|
|
176
|
+
}
|
|
177
|
+
async function isRepoShallow() {
|
|
178
|
+
return (await execCommand("git", ["rev-parse", "--is-shallow-repository"])).trim() === "true";
|
|
179
|
+
}
|
|
180
|
+
async function getLastGitTag(delta = 0) {
|
|
181
|
+
const tags = await execCommand("git", ["--no-pager", "tag", "-l", "--sort=creatordate"]).then((r) => r.split("\n"));
|
|
182
|
+
return tags[tags.length + delta - 1];
|
|
183
|
+
}
|
|
184
|
+
async function isRefGitTag(to) {
|
|
185
|
+
const { execa } = await import('execa');
|
|
186
|
+
try {
|
|
187
|
+
await execa("git", ["show-ref", "--verify", `refs/tags/${to}`], { reject: true });
|
|
188
|
+
return true;
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function getFirstGitCommit() {
|
|
194
|
+
return execCommand("git", ["rev-list", "--max-parents=0", "HEAD"]);
|
|
195
|
+
}
|
|
196
|
+
function isPrerelease(version) {
|
|
197
|
+
return !/^[^.]*[\d.]+$/.test(version);
|
|
198
|
+
}
|
|
199
|
+
async function execCommand(cmd, args) {
|
|
200
|
+
const { execa } = await import('execa');
|
|
201
|
+
const res = await execa(cmd, args);
|
|
202
|
+
return res.stdout.trim();
|
|
203
|
+
}
|
|
204
|
+
async function getGitDiff(from, to = "HEAD") {
|
|
205
|
+
const r = await execCommand("git", [
|
|
206
|
+
"--no-pager",
|
|
207
|
+
"log",
|
|
208
|
+
`${from ? `${from}...` : ""}${to}`,
|
|
209
|
+
'--pretty="----%n%s|%h|%an|%ae%n%b"',
|
|
210
|
+
"--name-status"
|
|
211
|
+
]);
|
|
212
|
+
return r.split("----\n").splice(1).map((line) => {
|
|
213
|
+
const [firstLine, ..._body] = line.split("\n");
|
|
214
|
+
const [message, shortHash, authorName, authorEmail] = firstLine.split("|");
|
|
215
|
+
const $r = {
|
|
216
|
+
message,
|
|
217
|
+
shortHash,
|
|
218
|
+
author: { name: authorName, email: authorEmail },
|
|
219
|
+
body: _body.join("\n")
|
|
220
|
+
};
|
|
221
|
+
return $r;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const emojisRE = /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g;
|
|
226
|
+
function formatReferences(references, github, type) {
|
|
227
|
+
const refs = references.filter((i) => {
|
|
228
|
+
if (type === "issues")
|
|
229
|
+
return i.type === "issue" || i.type === "pull-request";
|
|
230
|
+
return i.type === "hash";
|
|
231
|
+
}).map((ref) => {
|
|
232
|
+
if (!github)
|
|
233
|
+
return ref.value;
|
|
234
|
+
if (ref.type === "pull-request" || ref.type === "issue")
|
|
235
|
+
return `https://github.com/${github}/issues/${ref.value.slice(1)}`;
|
|
236
|
+
return `[<samp>(${ref.value.slice(0, 5)})</samp>](https://github.com/${github}/commit/${ref.value})`;
|
|
237
|
+
});
|
|
238
|
+
const referencesString = join(refs).trim();
|
|
239
|
+
if (type === "issues")
|
|
240
|
+
return referencesString && `in ${referencesString}`;
|
|
241
|
+
return referencesString;
|
|
242
|
+
}
|
|
243
|
+
function formatLine(commit, options) {
|
|
244
|
+
const prRefs = formatReferences(commit.references, options.github, "issues");
|
|
245
|
+
const hashRefs = formatReferences(commit.references, options.github, "hash");
|
|
246
|
+
let authors = join([
|
|
247
|
+
...new Set(commit.resolvedAuthors?.map((i) => i.login ? `@${i.login}` : `**${i.name}**`))
|
|
248
|
+
])?.trim();
|
|
249
|
+
if (authors)
|
|
250
|
+
authors = `by ${authors}`;
|
|
251
|
+
let refs = [authors, prRefs, hashRefs].filter((i) => i?.trim()).join(" ");
|
|
252
|
+
if (refs)
|
|
253
|
+
refs = ` - ${refs}`;
|
|
254
|
+
const description = options.capitalize ? capitalize(commit.description) : commit.description;
|
|
255
|
+
return [description, refs].filter((i) => i?.trim()).join(" ");
|
|
256
|
+
}
|
|
257
|
+
function formatTitle(name, options) {
|
|
258
|
+
let formatName = name.trim();
|
|
259
|
+
if (!options.emoji) {
|
|
260
|
+
formatName = name.replace(emojisRE, "").trim();
|
|
261
|
+
}
|
|
262
|
+
return `### ${formatName}`;
|
|
263
|
+
}
|
|
264
|
+
function formatSection(commits, sectionName, options) {
|
|
265
|
+
if (!commits.length)
|
|
266
|
+
return [];
|
|
267
|
+
const lines = ["", formatTitle(sectionName, options), ""];
|
|
268
|
+
const scopes = groupBy(commits, "scope");
|
|
269
|
+
let useScopeGroup = options.group;
|
|
270
|
+
if (!Object.entries(scopes).some(([k, v]) => k && v.length > 1))
|
|
271
|
+
useScopeGroup = false;
|
|
272
|
+
Object.keys(scopes).sort().forEach((scope) => {
|
|
273
|
+
let padding = "";
|
|
274
|
+
let prefix = "";
|
|
275
|
+
const scopeText = `**${options.scopeMap[scope] || scope}**`;
|
|
276
|
+
if (scope && (useScopeGroup === true || useScopeGroup === "multiple" && scopes[scope].length > 1)) {
|
|
277
|
+
lines.push(`- ${scopeText}:`);
|
|
278
|
+
padding = " ";
|
|
279
|
+
} else if (scope) {
|
|
280
|
+
prefix = `${scopeText}: `;
|
|
281
|
+
}
|
|
282
|
+
lines.push(...scopes[scope].reverse().map((commit) => `${padding}- ${prefix}${formatLine(commit, options)}`));
|
|
283
|
+
});
|
|
284
|
+
return lines;
|
|
285
|
+
}
|
|
286
|
+
function generateMarkdown(commits, options) {
|
|
287
|
+
const lines = [];
|
|
288
|
+
const [breaking, changes] = partition(commits, (c) => c.isBreaking);
|
|
289
|
+
const group = groupBy(changes, "type");
|
|
290
|
+
lines.push(...formatSection(breaking, options.titles.breakingChanges, options));
|
|
291
|
+
for (const type of Object.keys(options.types)) {
|
|
292
|
+
const items = group[type] || [];
|
|
293
|
+
lines.push(...formatSection(items, options.types[type].title, options));
|
|
294
|
+
}
|
|
295
|
+
if (!lines.length)
|
|
296
|
+
lines.push("*No significant changes*");
|
|
297
|
+
const url = `https://github.com/${options.github}/compare/${options.from}...${options.to}`;
|
|
298
|
+
lines.push("", `##### [View changes on GitHub](${url})`);
|
|
299
|
+
return convertGitmoji.convert(lines.join("\n").trim(), true);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function defineConfig(config) {
|
|
303
|
+
return config;
|
|
304
|
+
}
|
|
305
|
+
const defaultConfig = {
|
|
306
|
+
scopeMap: {},
|
|
307
|
+
types: {
|
|
308
|
+
feat: { title: "\u{1F680} Features" },
|
|
309
|
+
fix: { title: "\u{1F41E} Bug Fixes" },
|
|
310
|
+
perf: { title: "\u{1F525} Performance" },
|
|
311
|
+
refactor: { title: "\u{1F485} Refactors" },
|
|
312
|
+
docs: { title: "\u{1F4D6} Documentation" },
|
|
313
|
+
build: { title: "\u{1F4E6} Build" },
|
|
314
|
+
types: { title: "\u{1F30A} Types" },
|
|
315
|
+
chore: { title: "\u{1F3E1} Chore" },
|
|
316
|
+
examples: { title: "\u{1F3C0} Examples" },
|
|
317
|
+
test: { title: "\u2705 Tests" },
|
|
318
|
+
style: { title: "\u{1F3A8} Styles" },
|
|
319
|
+
ci: { title: "\u{1F916} CI" }
|
|
320
|
+
},
|
|
321
|
+
titles: {
|
|
322
|
+
breakingChanges: "\u{1F6A8} Breaking Changes"
|
|
323
|
+
},
|
|
324
|
+
contributors: true,
|
|
325
|
+
capitalize: true,
|
|
326
|
+
group: true
|
|
327
|
+
};
|
|
328
|
+
async function resolveConfig(options) {
|
|
329
|
+
const { loadConfig } = await import('c12');
|
|
330
|
+
const config = await loadConfig({
|
|
331
|
+
name: "githublogen",
|
|
332
|
+
defaults: defaultConfig,
|
|
333
|
+
overrides: options
|
|
334
|
+
}).then((r) => r.config || defaultConfig);
|
|
335
|
+
config.from = config.from || await getLastGitTag();
|
|
336
|
+
config.to = config.to || await getCurrentGitBranch();
|
|
337
|
+
config.github = config.github || await getGitHubRepo();
|
|
338
|
+
config.prerelease = config.prerelease ?? isPrerelease(config.to);
|
|
339
|
+
if (config.to === config.from)
|
|
340
|
+
config.from = await getLastGitTag(-1) || await getFirstGitCommit();
|
|
341
|
+
return config;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const ConventionalCommitRegex = /(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i;
|
|
345
|
+
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim;
|
|
346
|
+
const PullRequestRE = /\([a-z]*(#\d+)\s*\)/gm;
|
|
347
|
+
const IssueRE = /(#\d+)/gm;
|
|
348
|
+
function parseGitCommit(commit, config) {
|
|
349
|
+
const match = commit.message.match(ConventionalCommitRegex);
|
|
350
|
+
if (!match?.groups) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
const type = match.groups.type;
|
|
354
|
+
let scope = match.groups.scope || "";
|
|
355
|
+
scope = config.scopeMap[scope] || scope;
|
|
356
|
+
const isBreaking = Boolean(match.groups.breaking);
|
|
357
|
+
let description = match.groups.description;
|
|
358
|
+
const references = [];
|
|
359
|
+
for (const m of description.matchAll(PullRequestRE)) {
|
|
360
|
+
references.push({ type: "pull-request", value: m[1] });
|
|
361
|
+
}
|
|
362
|
+
for (const m of description.matchAll(IssueRE)) {
|
|
363
|
+
if (!references.some((i) => i.value === m[1])) {
|
|
364
|
+
references.push({ type: "issue", value: m[1] });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
references.push({ value: commit.shortHash, type: "hash" });
|
|
368
|
+
description = description.replace(PullRequestRE, "").trim();
|
|
369
|
+
const authors = [commit.author];
|
|
370
|
+
const matchs = commit.body.matchAll(CoAuthoredByRegex);
|
|
371
|
+
for (const $match of matchs) {
|
|
372
|
+
const { name = "", email = "" } = $match.groups || {};
|
|
373
|
+
const author = {
|
|
374
|
+
name: name.trim(),
|
|
375
|
+
email: email.trim()
|
|
376
|
+
};
|
|
377
|
+
authors.push(author);
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
...commit,
|
|
381
|
+
authors,
|
|
382
|
+
description,
|
|
383
|
+
type,
|
|
384
|
+
scope,
|
|
385
|
+
references,
|
|
386
|
+
isBreaking
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function parseCommits(commits, config) {
|
|
390
|
+
return commits.map((commit) => parseGitCommit(commit, config)).filter(notNullish);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function generate(options) {
|
|
394
|
+
const resolved = await resolveConfig(options);
|
|
395
|
+
const rawCommits = await getGitDiff(resolved.from, resolved.to);
|
|
396
|
+
const commits = parseCommits(rawCommits, resolved);
|
|
397
|
+
if (resolved.contributors)
|
|
398
|
+
await resolveAuthors(commits, resolved);
|
|
399
|
+
const md = generateMarkdown(commits, resolved);
|
|
400
|
+
return { config: resolved, md, commits };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
exports.defineConfig = defineConfig;
|
|
404
|
+
exports.generate = generate;
|
|
405
|
+
exports.generateMarkdown = generateMarkdown;
|
|
406
|
+
exports.getCurrentGitBranch = getCurrentGitBranch;
|
|
407
|
+
exports.getFirstGitCommit = getFirstGitCommit;
|
|
408
|
+
exports.getGitDiff = getGitDiff;
|
|
409
|
+
exports.getGitHubRepo = getGitHubRepo;
|
|
410
|
+
exports.getLastGitTag = getLastGitTag;
|
|
411
|
+
exports.hasTagOnGitHub = hasTagOnGitHub;
|
|
412
|
+
exports.isPrerelease = isPrerelease;
|
|
413
|
+
exports.isRefGitTag = isRefGitTag;
|
|
414
|
+
exports.isRepoShallow = isRepoShallow;
|
|
415
|
+
exports.parseCommits = parseCommits;
|
|
416
|
+
exports.parseGitCommit = parseGitCommit;
|
|
417
|
+
exports.resolveAuthorInfo = resolveAuthorInfo;
|
|
418
|
+
exports.resolveAuthors = resolveAuthors;
|
|
419
|
+
exports.resolveConfig = resolveConfig;
|
|
420
|
+
exports.sendRelease = sendRelease;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
type SemverBumpType = 'major' | 'premajor' | 'minor' | 'preminor' | 'patch' | 'prepatch' | 'prerelease';
|
|
2
|
+
interface ChangelogConfig {
|
|
3
|
+
cwd: string;
|
|
4
|
+
types: Record<string, {
|
|
5
|
+
title: string;
|
|
6
|
+
semver?: SemverBumpType;
|
|
7
|
+
}>;
|
|
8
|
+
scopeMap: Record<string, string>;
|
|
9
|
+
github: string;
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
newVersion?: string;
|
|
13
|
+
output: string | boolean;
|
|
14
|
+
}
|
|
15
|
+
interface GitCommitAuthor {
|
|
16
|
+
name: string;
|
|
17
|
+
email: string;
|
|
18
|
+
}
|
|
19
|
+
interface RawGitCommit {
|
|
20
|
+
message: string;
|
|
21
|
+
body: string;
|
|
22
|
+
shortHash: string;
|
|
23
|
+
author: GitCommitAuthor;
|
|
24
|
+
}
|
|
25
|
+
interface Reference {
|
|
26
|
+
type: 'hash' | 'issue' | 'pull-request';
|
|
27
|
+
value: string;
|
|
28
|
+
}
|
|
29
|
+
interface GitCommit extends RawGitCommit {
|
|
30
|
+
description: string;
|
|
31
|
+
type: string;
|
|
32
|
+
scope: string;
|
|
33
|
+
references: Reference[];
|
|
34
|
+
authors: GitCommitAuthor[];
|
|
35
|
+
isBreaking: boolean;
|
|
36
|
+
}
|
|
37
|
+
interface Commit extends GitCommit {
|
|
38
|
+
resolvedAuthors?: AuthorInfo[];
|
|
39
|
+
}
|
|
40
|
+
interface ChangelogOptions extends Partial<ChangelogConfig> {
|
|
41
|
+
/**
|
|
42
|
+
* Dry run. Skip releasing to GitHub.
|
|
43
|
+
*/
|
|
44
|
+
dry?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Whether to include contributors in release notes.
|
|
47
|
+
*
|
|
48
|
+
* @default true
|
|
49
|
+
*/
|
|
50
|
+
contributors?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Name of the release
|
|
53
|
+
*/
|
|
54
|
+
name?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Mark the release as a draft
|
|
57
|
+
*/
|
|
58
|
+
draft?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Mark the release as prerelease
|
|
61
|
+
*/
|
|
62
|
+
prerelease?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* GitHub Token
|
|
65
|
+
*/
|
|
66
|
+
token?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Custom titles
|
|
69
|
+
*/
|
|
70
|
+
titles?: {
|
|
71
|
+
breakingChanges?: string;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Capitalize commit messages
|
|
75
|
+
* @default true
|
|
76
|
+
*/
|
|
77
|
+
capitalize?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Nest commit messages under their scopes
|
|
80
|
+
* @default true
|
|
81
|
+
*/
|
|
82
|
+
group?: boolean | 'multiple';
|
|
83
|
+
/**
|
|
84
|
+
* Use emojis in section titles
|
|
85
|
+
* @default true
|
|
86
|
+
*/
|
|
87
|
+
emoji?: boolean;
|
|
88
|
+
}
|
|
89
|
+
type ResolvedChangelogOptions = Required<ChangelogOptions>;
|
|
90
|
+
interface AuthorInfo {
|
|
91
|
+
commits: string[];
|
|
92
|
+
login?: string;
|
|
93
|
+
email: string;
|
|
94
|
+
name: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
declare function sendRelease(options: ChangelogOptions, content: string): Promise<void>;
|
|
98
|
+
declare function resolveAuthorInfo(options: ChangelogOptions, info: AuthorInfo): Promise<AuthorInfo>;
|
|
99
|
+
declare function resolveAuthors(commits: Commit[], options: ChangelogOptions): Promise<AuthorInfo[]>;
|
|
100
|
+
declare function hasTagOnGitHub(tag: string, options: ChangelogOptions): Promise<boolean>;
|
|
101
|
+
|
|
102
|
+
declare function getGitHubRepo(): Promise<string>;
|
|
103
|
+
declare function getCurrentGitBranch(): Promise<string>;
|
|
104
|
+
declare function isRepoShallow(): Promise<boolean>;
|
|
105
|
+
declare function getLastGitTag(delta?: number): Promise<string>;
|
|
106
|
+
declare function isRefGitTag(to: string): Promise<boolean>;
|
|
107
|
+
declare function getFirstGitCommit(): Promise<string>;
|
|
108
|
+
declare function isPrerelease(version: string): boolean;
|
|
109
|
+
declare function getGitDiff(from: string | undefined, to?: string): Promise<RawGitCommit[]>;
|
|
110
|
+
|
|
111
|
+
declare function generateMarkdown(commits: Commit[], options: ResolvedChangelogOptions): string;
|
|
112
|
+
|
|
113
|
+
declare function generate(options: ChangelogOptions): Promise<{
|
|
114
|
+
config: Required<ChangelogOptions>;
|
|
115
|
+
md: string;
|
|
116
|
+
commits: GitCommit[];
|
|
117
|
+
}>;
|
|
118
|
+
|
|
119
|
+
declare function defineConfig(config: ChangelogOptions): ChangelogOptions;
|
|
120
|
+
declare function resolveConfig(options: ChangelogOptions): Promise<Required<ChangelogOptions>>;
|
|
121
|
+
|
|
122
|
+
declare function parseGitCommit(commit: RawGitCommit, config: ChangelogConfig): GitCommit | null;
|
|
123
|
+
declare function parseCommits(commits: RawGitCommit[], config: ChangelogConfig): GitCommit[];
|
|
124
|
+
|
|
125
|
+
export { AuthorInfo, ChangelogConfig, ChangelogOptions, Commit, GitCommit, GitCommitAuthor, RawGitCommit, Reference, ResolvedChangelogOptions, SemverBumpType, defineConfig, generate, generateMarkdown, getCurrentGitBranch, getFirstGitCommit, getGitDiff, getGitHubRepo, getLastGitTag, hasTagOnGitHub, isPrerelease, isRefGitTag, isRepoShallow, parseCommits, parseGitCommit, resolveAuthorInfo, resolveAuthors, resolveConfig, sendRelease };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { $fetch } from 'ohmyfetch';
|
|
2
|
+
import { cyan, green, red, yellow } from 'kolorist';
|
|
3
|
+
import { convert } from 'convert-gitmoji';
|
|
4
|
+
|
|
5
|
+
function partition(array, ...filters) {
|
|
6
|
+
const result = new Array(filters.length + 1).fill(null).map(() => []);
|
|
7
|
+
array.forEach((e, idx, arr) => {
|
|
8
|
+
let i = 0;
|
|
9
|
+
for (const filter of filters) {
|
|
10
|
+
if (filter(e, idx, arr)) {
|
|
11
|
+
result[i].push(e);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
i += 1;
|
|
15
|
+
}
|
|
16
|
+
result[i].push(e);
|
|
17
|
+
});
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
function notNullish(v) {
|
|
21
|
+
return v !== null && v !== void 0;
|
|
22
|
+
}
|
|
23
|
+
function groupBy(items, key, groups = {}) {
|
|
24
|
+
for (const item of items) {
|
|
25
|
+
const v = item[key];
|
|
26
|
+
groups[v] = groups[v] || [];
|
|
27
|
+
groups[v].push(item);
|
|
28
|
+
}
|
|
29
|
+
return groups;
|
|
30
|
+
}
|
|
31
|
+
function capitalize(str) {
|
|
32
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
33
|
+
}
|
|
34
|
+
function join(array, glue = ", ", finalGlue = " and ") {
|
|
35
|
+
if (!array || array.length === 0)
|
|
36
|
+
return "";
|
|
37
|
+
if (array.length === 1)
|
|
38
|
+
return array[0];
|
|
39
|
+
if (array.length === 2)
|
|
40
|
+
return array.join(finalGlue);
|
|
41
|
+
return `${array.slice(0, -1).join(glue)}${finalGlue}${array.slice(-1)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function sendRelease(options, content) {
|
|
45
|
+
const headers = getHeaders(options);
|
|
46
|
+
let url = `https://api.github.com/repos/${options.github}/releases`;
|
|
47
|
+
let method = "POST";
|
|
48
|
+
try {
|
|
49
|
+
const exists = await $fetch(`https://api.github.com/repos/${options.github}/releases/tags/${options.to}`, {
|
|
50
|
+
headers
|
|
51
|
+
});
|
|
52
|
+
if (exists.url) {
|
|
53
|
+
url = exists.url;
|
|
54
|
+
method = "PATCH";
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
}
|
|
58
|
+
const body = {
|
|
59
|
+
body: content,
|
|
60
|
+
draft: options.draft || false,
|
|
61
|
+
name: options.name || options.to,
|
|
62
|
+
prerelease: options.prerelease,
|
|
63
|
+
tag_name: options.to
|
|
64
|
+
};
|
|
65
|
+
const webUrl = `https://github.com/${options.github}/releases/new?title=${encodeURIComponent(
|
|
66
|
+
String(body.name)
|
|
67
|
+
)}&body=${encodeURIComponent(String(body.body))}&tag=${encodeURIComponent(String(options.to))}&prerelease=${options.prerelease}`;
|
|
68
|
+
try {
|
|
69
|
+
console.log(cyan(method === "POST" ? "Creating release notes..." : "Updating release notes..."));
|
|
70
|
+
const res = await $fetch(url, {
|
|
71
|
+
method,
|
|
72
|
+
body: JSON.stringify(body),
|
|
73
|
+
headers
|
|
74
|
+
});
|
|
75
|
+
console.log(green(`Released on ${res.html_url}`));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.log();
|
|
78
|
+
console.error(red("Failed to create the release. Using the following link to create it manually:"));
|
|
79
|
+
console.error(yellow(webUrl));
|
|
80
|
+
console.log();
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function getHeaders(options) {
|
|
85
|
+
return {
|
|
86
|
+
accept: "application/vnd.github.v3+json",
|
|
87
|
+
authorization: `token ${options.token}`
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async function resolveAuthorInfo(options, info) {
|
|
91
|
+
if (info.login)
|
|
92
|
+
return info;
|
|
93
|
+
if (!options.token)
|
|
94
|
+
return info;
|
|
95
|
+
const authorInfo = { ...info };
|
|
96
|
+
try {
|
|
97
|
+
const data = await $fetch(`https://api.github.com/search/users?q=${encodeURIComponent(info.email)}`, {
|
|
98
|
+
headers: getHeaders(options)
|
|
99
|
+
});
|
|
100
|
+
authorInfo.login = data.items[0].login;
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
if (info.login)
|
|
104
|
+
return info;
|
|
105
|
+
if (info.commits.length) {
|
|
106
|
+
try {
|
|
107
|
+
const data = await $fetch(`https://api.github.com/repos/${options.github}/commits/${info.commits[0]}`, {
|
|
108
|
+
headers: getHeaders(options)
|
|
109
|
+
});
|
|
110
|
+
authorInfo.login = data.author.login;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return authorInfo;
|
|
115
|
+
}
|
|
116
|
+
async function resolveAuthors(commits, options) {
|
|
117
|
+
const map = /* @__PURE__ */ new Map();
|
|
118
|
+
commits.forEach((commit) => {
|
|
119
|
+
commit.resolvedAuthors = commit.authors.map((a, idx) => {
|
|
120
|
+
if (!a.email || !a.name)
|
|
121
|
+
return null;
|
|
122
|
+
if (!map.has(a.email)) {
|
|
123
|
+
map.set(a.email, {
|
|
124
|
+
commits: [],
|
|
125
|
+
name: a.name,
|
|
126
|
+
email: a.email
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const info = map.get(a.email);
|
|
130
|
+
if (idx === 0)
|
|
131
|
+
info.commits.push(commit.shortHash);
|
|
132
|
+
return info;
|
|
133
|
+
}).filter(notNullish);
|
|
134
|
+
});
|
|
135
|
+
const authors = Array.from(map.values());
|
|
136
|
+
const resolved = await Promise.all(authors.map((info) => resolveAuthorInfo(options, info)));
|
|
137
|
+
const loginSet = /* @__PURE__ */ new Set();
|
|
138
|
+
const nameSet = /* @__PURE__ */ new Set();
|
|
139
|
+
return resolved.sort((a, b) => (a.login || a.name).localeCompare(b.login || b.name)).filter((i) => {
|
|
140
|
+
if (i.login && loginSet.has(i.login))
|
|
141
|
+
return false;
|
|
142
|
+
if (i.login) {
|
|
143
|
+
loginSet.add(i.login);
|
|
144
|
+
} else {
|
|
145
|
+
if (nameSet.has(i.name))
|
|
146
|
+
return false;
|
|
147
|
+
nameSet.add(i.name);
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async function hasTagOnGitHub(tag, options) {
|
|
153
|
+
try {
|
|
154
|
+
await $fetch(`https://api.github.com/repos/${options.github}/git/ref/tags/${tag}`, {
|
|
155
|
+
headers: getHeaders(options)
|
|
156
|
+
});
|
|
157
|
+
return true;
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function getGitHubRepo() {
|
|
164
|
+
const url = await execCommand("git", ["config", "--get", "remote.origin.url"]);
|
|
165
|
+
const match = url.match(/github\.com[\/:]([\w\d._-]+?)\/([\w\d._-]+?)(\.git)?$/i);
|
|
166
|
+
if (!match)
|
|
167
|
+
throw new Error(`Can not parse GitHub repo from url ${url}`);
|
|
168
|
+
return `${match[1]}/${match[2]}`;
|
|
169
|
+
}
|
|
170
|
+
async function getCurrentGitBranch() {
|
|
171
|
+
const result1 = await execCommand("git", ["tag", "--points-at", "HEAD"]);
|
|
172
|
+
const result2 = await execCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
173
|
+
return result1 || result2;
|
|
174
|
+
}
|
|
175
|
+
async function isRepoShallow() {
|
|
176
|
+
return (await execCommand("git", ["rev-parse", "--is-shallow-repository"])).trim() === "true";
|
|
177
|
+
}
|
|
178
|
+
async function getLastGitTag(delta = 0) {
|
|
179
|
+
const tags = await execCommand("git", ["--no-pager", "tag", "-l", "--sort=creatordate"]).then((r) => r.split("\n"));
|
|
180
|
+
return tags[tags.length + delta - 1];
|
|
181
|
+
}
|
|
182
|
+
async function isRefGitTag(to) {
|
|
183
|
+
const { execa } = await import('execa');
|
|
184
|
+
try {
|
|
185
|
+
await execa("git", ["show-ref", "--verify", `refs/tags/${to}`], { reject: true });
|
|
186
|
+
return true;
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function getFirstGitCommit() {
|
|
192
|
+
return execCommand("git", ["rev-list", "--max-parents=0", "HEAD"]);
|
|
193
|
+
}
|
|
194
|
+
function isPrerelease(version) {
|
|
195
|
+
return !/^[^.]*[\d.]+$/.test(version);
|
|
196
|
+
}
|
|
197
|
+
async function execCommand(cmd, args) {
|
|
198
|
+
const { execa } = await import('execa');
|
|
199
|
+
const res = await execa(cmd, args);
|
|
200
|
+
return res.stdout.trim();
|
|
201
|
+
}
|
|
202
|
+
async function getGitDiff(from, to = "HEAD") {
|
|
203
|
+
const r = await execCommand("git", [
|
|
204
|
+
"--no-pager",
|
|
205
|
+
"log",
|
|
206
|
+
`${from ? `${from}...` : ""}${to}`,
|
|
207
|
+
'--pretty="----%n%s|%h|%an|%ae%n%b"',
|
|
208
|
+
"--name-status"
|
|
209
|
+
]);
|
|
210
|
+
return r.split("----\n").splice(1).map((line) => {
|
|
211
|
+
const [firstLine, ..._body] = line.split("\n");
|
|
212
|
+
const [message, shortHash, authorName, authorEmail] = firstLine.split("|");
|
|
213
|
+
const $r = {
|
|
214
|
+
message,
|
|
215
|
+
shortHash,
|
|
216
|
+
author: { name: authorName, email: authorEmail },
|
|
217
|
+
body: _body.join("\n")
|
|
218
|
+
};
|
|
219
|
+
return $r;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const emojisRE = /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g;
|
|
224
|
+
function formatReferences(references, github, type) {
|
|
225
|
+
const refs = references.filter((i) => {
|
|
226
|
+
if (type === "issues")
|
|
227
|
+
return i.type === "issue" || i.type === "pull-request";
|
|
228
|
+
return i.type === "hash";
|
|
229
|
+
}).map((ref) => {
|
|
230
|
+
if (!github)
|
|
231
|
+
return ref.value;
|
|
232
|
+
if (ref.type === "pull-request" || ref.type === "issue")
|
|
233
|
+
return `https://github.com/${github}/issues/${ref.value.slice(1)}`;
|
|
234
|
+
return `[<samp>(${ref.value.slice(0, 5)})</samp>](https://github.com/${github}/commit/${ref.value})`;
|
|
235
|
+
});
|
|
236
|
+
const referencesString = join(refs).trim();
|
|
237
|
+
if (type === "issues")
|
|
238
|
+
return referencesString && `in ${referencesString}`;
|
|
239
|
+
return referencesString;
|
|
240
|
+
}
|
|
241
|
+
function formatLine(commit, options) {
|
|
242
|
+
const prRefs = formatReferences(commit.references, options.github, "issues");
|
|
243
|
+
const hashRefs = formatReferences(commit.references, options.github, "hash");
|
|
244
|
+
let authors = join([
|
|
245
|
+
...new Set(commit.resolvedAuthors?.map((i) => i.login ? `@${i.login}` : `**${i.name}**`))
|
|
246
|
+
])?.trim();
|
|
247
|
+
if (authors)
|
|
248
|
+
authors = `by ${authors}`;
|
|
249
|
+
let refs = [authors, prRefs, hashRefs].filter((i) => i?.trim()).join(" ");
|
|
250
|
+
if (refs)
|
|
251
|
+
refs = ` - ${refs}`;
|
|
252
|
+
const description = options.capitalize ? capitalize(commit.description) : commit.description;
|
|
253
|
+
return [description, refs].filter((i) => i?.trim()).join(" ");
|
|
254
|
+
}
|
|
255
|
+
function formatTitle(name, options) {
|
|
256
|
+
let formatName = name.trim();
|
|
257
|
+
if (!options.emoji) {
|
|
258
|
+
formatName = name.replace(emojisRE, "").trim();
|
|
259
|
+
}
|
|
260
|
+
return `### ${formatName}`;
|
|
261
|
+
}
|
|
262
|
+
function formatSection(commits, sectionName, options) {
|
|
263
|
+
if (!commits.length)
|
|
264
|
+
return [];
|
|
265
|
+
const lines = ["", formatTitle(sectionName, options), ""];
|
|
266
|
+
const scopes = groupBy(commits, "scope");
|
|
267
|
+
let useScopeGroup = options.group;
|
|
268
|
+
if (!Object.entries(scopes).some(([k, v]) => k && v.length > 1))
|
|
269
|
+
useScopeGroup = false;
|
|
270
|
+
Object.keys(scopes).sort().forEach((scope) => {
|
|
271
|
+
let padding = "";
|
|
272
|
+
let prefix = "";
|
|
273
|
+
const scopeText = `**${options.scopeMap[scope] || scope}**`;
|
|
274
|
+
if (scope && (useScopeGroup === true || useScopeGroup === "multiple" && scopes[scope].length > 1)) {
|
|
275
|
+
lines.push(`- ${scopeText}:`);
|
|
276
|
+
padding = " ";
|
|
277
|
+
} else if (scope) {
|
|
278
|
+
prefix = `${scopeText}: `;
|
|
279
|
+
}
|
|
280
|
+
lines.push(...scopes[scope].reverse().map((commit) => `${padding}- ${prefix}${formatLine(commit, options)}`));
|
|
281
|
+
});
|
|
282
|
+
return lines;
|
|
283
|
+
}
|
|
284
|
+
function generateMarkdown(commits, options) {
|
|
285
|
+
const lines = [];
|
|
286
|
+
const [breaking, changes] = partition(commits, (c) => c.isBreaking);
|
|
287
|
+
const group = groupBy(changes, "type");
|
|
288
|
+
lines.push(...formatSection(breaking, options.titles.breakingChanges, options));
|
|
289
|
+
for (const type of Object.keys(options.types)) {
|
|
290
|
+
const items = group[type] || [];
|
|
291
|
+
lines.push(...formatSection(items, options.types[type].title, options));
|
|
292
|
+
}
|
|
293
|
+
if (!lines.length)
|
|
294
|
+
lines.push("*No significant changes*");
|
|
295
|
+
const url = `https://github.com/${options.github}/compare/${options.from}...${options.to}`;
|
|
296
|
+
lines.push("", `##### [View changes on GitHub](${url})`);
|
|
297
|
+
return convert(lines.join("\n").trim(), true);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function defineConfig(config) {
|
|
301
|
+
return config;
|
|
302
|
+
}
|
|
303
|
+
const defaultConfig = {
|
|
304
|
+
scopeMap: {},
|
|
305
|
+
types: {
|
|
306
|
+
feat: { title: "\u{1F680} Features" },
|
|
307
|
+
fix: { title: "\u{1F41E} Bug Fixes" },
|
|
308
|
+
perf: { title: "\u{1F525} Performance" },
|
|
309
|
+
refactor: { title: "\u{1F485} Refactors" },
|
|
310
|
+
docs: { title: "\u{1F4D6} Documentation" },
|
|
311
|
+
build: { title: "\u{1F4E6} Build" },
|
|
312
|
+
types: { title: "\u{1F30A} Types" },
|
|
313
|
+
chore: { title: "\u{1F3E1} Chore" },
|
|
314
|
+
examples: { title: "\u{1F3C0} Examples" },
|
|
315
|
+
test: { title: "\u2705 Tests" },
|
|
316
|
+
style: { title: "\u{1F3A8} Styles" },
|
|
317
|
+
ci: { title: "\u{1F916} CI" }
|
|
318
|
+
},
|
|
319
|
+
titles: {
|
|
320
|
+
breakingChanges: "\u{1F6A8} Breaking Changes"
|
|
321
|
+
},
|
|
322
|
+
contributors: true,
|
|
323
|
+
capitalize: true,
|
|
324
|
+
group: true
|
|
325
|
+
};
|
|
326
|
+
async function resolveConfig(options) {
|
|
327
|
+
const { loadConfig } = await import('c12');
|
|
328
|
+
const config = await loadConfig({
|
|
329
|
+
name: "githublogen",
|
|
330
|
+
defaults: defaultConfig,
|
|
331
|
+
overrides: options
|
|
332
|
+
}).then((r) => r.config || defaultConfig);
|
|
333
|
+
config.from = config.from || await getLastGitTag();
|
|
334
|
+
config.to = config.to || await getCurrentGitBranch();
|
|
335
|
+
config.github = config.github || await getGitHubRepo();
|
|
336
|
+
config.prerelease = config.prerelease ?? isPrerelease(config.to);
|
|
337
|
+
if (config.to === config.from)
|
|
338
|
+
config.from = await getLastGitTag(-1) || await getFirstGitCommit();
|
|
339
|
+
return config;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const ConventionalCommitRegex = /(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i;
|
|
343
|
+
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim;
|
|
344
|
+
const PullRequestRE = /\([a-z]*(#\d+)\s*\)/gm;
|
|
345
|
+
const IssueRE = /(#\d+)/gm;
|
|
346
|
+
function parseGitCommit(commit, config) {
|
|
347
|
+
const match = commit.message.match(ConventionalCommitRegex);
|
|
348
|
+
if (!match?.groups) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
const type = match.groups.type;
|
|
352
|
+
let scope = match.groups.scope || "";
|
|
353
|
+
scope = config.scopeMap[scope] || scope;
|
|
354
|
+
const isBreaking = Boolean(match.groups.breaking);
|
|
355
|
+
let description = match.groups.description;
|
|
356
|
+
const references = [];
|
|
357
|
+
for (const m of description.matchAll(PullRequestRE)) {
|
|
358
|
+
references.push({ type: "pull-request", value: m[1] });
|
|
359
|
+
}
|
|
360
|
+
for (const m of description.matchAll(IssueRE)) {
|
|
361
|
+
if (!references.some((i) => i.value === m[1])) {
|
|
362
|
+
references.push({ type: "issue", value: m[1] });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
references.push({ value: commit.shortHash, type: "hash" });
|
|
366
|
+
description = description.replace(PullRequestRE, "").trim();
|
|
367
|
+
const authors = [commit.author];
|
|
368
|
+
const matchs = commit.body.matchAll(CoAuthoredByRegex);
|
|
369
|
+
for (const $match of matchs) {
|
|
370
|
+
const { name = "", email = "" } = $match.groups || {};
|
|
371
|
+
const author = {
|
|
372
|
+
name: name.trim(),
|
|
373
|
+
email: email.trim()
|
|
374
|
+
};
|
|
375
|
+
authors.push(author);
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
...commit,
|
|
379
|
+
authors,
|
|
380
|
+
description,
|
|
381
|
+
type,
|
|
382
|
+
scope,
|
|
383
|
+
references,
|
|
384
|
+
isBreaking
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
function parseCommits(commits, config) {
|
|
388
|
+
return commits.map((commit) => parseGitCommit(commit, config)).filter(notNullish);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function generate(options) {
|
|
392
|
+
const resolved = await resolveConfig(options);
|
|
393
|
+
const rawCommits = await getGitDiff(resolved.from, resolved.to);
|
|
394
|
+
const commits = parseCommits(rawCommits, resolved);
|
|
395
|
+
if (resolved.contributors)
|
|
396
|
+
await resolveAuthors(commits, resolved);
|
|
397
|
+
const md = generateMarkdown(commits, resolved);
|
|
398
|
+
return { config: resolved, md, commits };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export { defineConfig, generate, generateMarkdown, getCurrentGitBranch, getFirstGitCommit, getGitDiff, getGitHubRepo, getLastGitTag, hasTagOnGitHub, isPrerelease, isRefGitTag, isRepoShallow, parseCommits, parseGitCommit, resolveAuthorInfo, resolveAuthors, resolveConfig, sendRelease };
|
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "githublogen",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "SoybeanJS",
|
|
8
|
+
"email": "honghuangdc@gmail.com",
|
|
9
|
+
"url": "https://github.com/soybeanjs"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"homepage": "https://github.com/soybeanjs/githublogen#readme",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/soybeanjs/githublogen.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": "https://github.com/soybeanjs/githublogen/issues",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"registry": "https://registry.npmjs.org/"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"github",
|
|
23
|
+
"release",
|
|
24
|
+
"releases",
|
|
25
|
+
"conventional",
|
|
26
|
+
"changelog",
|
|
27
|
+
"log"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"require": "./dist/index.cjs",
|
|
33
|
+
"import": "./dist/index.mjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"main": "./dist/index.mjs",
|
|
37
|
+
"module": "./dist/index.mjs",
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"bin": {
|
|
40
|
+
"githublogen": "./dist/cli.mjs"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist"
|
|
44
|
+
],
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=12.0.0"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"c12": "1.4.1",
|
|
50
|
+
"cac": "6.7.14",
|
|
51
|
+
"convert-gitmoji": "0.1.3",
|
|
52
|
+
"execa": "7.1.1",
|
|
53
|
+
"kolorist": "1.8.0",
|
|
54
|
+
"ohmyfetch": "0.4.21"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@soybeanjs/cli": "0.2.11",
|
|
58
|
+
"@types/node": "20.2.5",
|
|
59
|
+
"bumpp": "9.1.0",
|
|
60
|
+
"eslint": "8.41.0",
|
|
61
|
+
"eslint-config-soybeanjs": "0.4.6",
|
|
62
|
+
"lint-staged": "13.2.2",
|
|
63
|
+
"simple-git-hooks": "2.8.1",
|
|
64
|
+
"typescript": "5.0.4",
|
|
65
|
+
"unbuild": "1.2.1"
|
|
66
|
+
},
|
|
67
|
+
"simple-git-hooks": {
|
|
68
|
+
"commit-msg": "pnpm soy git-commit-verify",
|
|
69
|
+
"pre-commit": "pnpm lint-staged"
|
|
70
|
+
},
|
|
71
|
+
"lint-staged": {
|
|
72
|
+
"*.{js,mjs,jsx,ts,mts,tsx,json,vue,svelte}": "eslint . --fix",
|
|
73
|
+
"*.!{js,mjs,jsx,ts,mts,tsx,json,vue,svelte}": "format"
|
|
74
|
+
},
|
|
75
|
+
"scripts": {
|
|
76
|
+
"build": "unbuild",
|
|
77
|
+
"lint": "eslint . --fix",
|
|
78
|
+
"format": "soy prettier-format",
|
|
79
|
+
"commit": "soy git-commit",
|
|
80
|
+
"cleanup": "soy cleanup",
|
|
81
|
+
"update-pkg": "soy update-pkg",
|
|
82
|
+
"update-version": "bumpp --commit --push --tag",
|
|
83
|
+
"publish-pkg": "pnpm -r publish --access public",
|
|
84
|
+
"release": "pnpm update-version && pnpm publish-pkg"
|
|
85
|
+
}
|
|
86
|
+
}
|