@soybeanjs/changelog 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 +32 -0
- package/dist/index.cjs +560 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.mjs +550 -0
- package/package.json +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @soybeanjs/changelog
|
|
2
|
+
|
|
3
|
+
generate changelog by git tags and commits
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm i -D @soybeanjs/changelog
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {
|
|
15
|
+
getChangelogMarkdown,
|
|
16
|
+
getTotalChangelogMarkdown,
|
|
17
|
+
generateChangelog,
|
|
18
|
+
generateTotalChangelog,
|
|
19
|
+
} from "@soybeanjs/changelog";
|
|
20
|
+
|
|
21
|
+
// get the changelog markdown by two git tags
|
|
22
|
+
getChangelogMarkdown();
|
|
23
|
+
|
|
24
|
+
// get the changelog markdown by the total git tags
|
|
25
|
+
getTotalChangelogMarkdown();
|
|
26
|
+
|
|
27
|
+
// generate the changelog markdown by two git tags
|
|
28
|
+
generateChangelog();
|
|
29
|
+
|
|
30
|
+
// generate the changelog markdown by the total git tags
|
|
31
|
+
generateTotalChangelog();
|
|
32
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const cliProgress = require('cli-progress');
|
|
4
|
+
const promises = require('fs/promises');
|
|
5
|
+
const ofetch = require('ofetch');
|
|
6
|
+
const dayjs = require('dayjs');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const convertGitmoji = require('convert-gitmoji');
|
|
9
|
+
|
|
10
|
+
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; }
|
|
11
|
+
|
|
12
|
+
const cliProgress__default = /*#__PURE__*/_interopDefaultCompat(cliProgress);
|
|
13
|
+
const dayjs__default = /*#__PURE__*/_interopDefaultCompat(dayjs);
|
|
14
|
+
|
|
15
|
+
async function execCommand(cmd, args, options) {
|
|
16
|
+
const { execa } = await import('execa');
|
|
17
|
+
const res = await execa(cmd, args, options);
|
|
18
|
+
return res?.stdout?.trim() || "";
|
|
19
|
+
}
|
|
20
|
+
function notNullish(v) {
|
|
21
|
+
return v !== null && v !== void 0;
|
|
22
|
+
}
|
|
23
|
+
function partition(array, ...filters) {
|
|
24
|
+
const result = new Array(filters.length + 1).fill(null).map(() => []);
|
|
25
|
+
array.forEach((e, idx, arr) => {
|
|
26
|
+
let i = 0;
|
|
27
|
+
for (const filter of filters) {
|
|
28
|
+
if (filter(e, idx, arr)) {
|
|
29
|
+
result[i].push(e);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
i += 1;
|
|
33
|
+
}
|
|
34
|
+
result[i].push(e);
|
|
35
|
+
});
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
function groupBy(items, key, groups = {}) {
|
|
39
|
+
for (const item of items) {
|
|
40
|
+
const v = item[key];
|
|
41
|
+
groups[v] = groups[v] || [];
|
|
42
|
+
groups[v].push(item);
|
|
43
|
+
}
|
|
44
|
+
return groups;
|
|
45
|
+
}
|
|
46
|
+
function capitalize(str) {
|
|
47
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
48
|
+
}
|
|
49
|
+
function join(array, glue = ", ", finalGlue = " and ") {
|
|
50
|
+
if (!array || array.length === 0)
|
|
51
|
+
return "";
|
|
52
|
+
if (array.length === 1)
|
|
53
|
+
return array[0];
|
|
54
|
+
if (array.length === 2)
|
|
55
|
+
return array.join(finalGlue);
|
|
56
|
+
return `${array.slice(0, -1).join(glue)}${finalGlue}${array.slice(-1)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const VERSION_REG = /v\d+\.\d+\.\d+(-(beta|alpha)\.\d+)?/;
|
|
60
|
+
const VERSION_REG_OF_MARKDOWN = /## \[v\d+\.\d+\.\d+(-(beta|alpha)\.\d+)?]/g;
|
|
61
|
+
|
|
62
|
+
async function getTotalGitTags() {
|
|
63
|
+
const tagStr = await execCommand("git", ["--no-pager", "tag", "-l", "--sort=creatordate"]);
|
|
64
|
+
const tags = tagStr.split("\n");
|
|
65
|
+
return tags;
|
|
66
|
+
}
|
|
67
|
+
async function getTagDateMap() {
|
|
68
|
+
const tagDateStr = await execCommand("git", [
|
|
69
|
+
"--no-pager",
|
|
70
|
+
"log",
|
|
71
|
+
"--tags",
|
|
72
|
+
"--simplify-by-decoration",
|
|
73
|
+
"--pretty=format:%ci %d"
|
|
74
|
+
]);
|
|
75
|
+
const TAG_MARK = "tag: ";
|
|
76
|
+
const map = /* @__PURE__ */ new Map();
|
|
77
|
+
const dates = tagDateStr.split("\n").filter((item) => item.includes(TAG_MARK));
|
|
78
|
+
dates.forEach((item) => {
|
|
79
|
+
const [dateStr, tagStr] = item.split(TAG_MARK);
|
|
80
|
+
const date = dayjs__default(dateStr).format("YYYY-MM-DD");
|
|
81
|
+
const tag = tagStr.match(VERSION_REG)?.[0];
|
|
82
|
+
if (tag && date) {
|
|
83
|
+
map.set(tag.trim(), date);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return map;
|
|
87
|
+
}
|
|
88
|
+
function getFromToTags(tags) {
|
|
89
|
+
const result = [];
|
|
90
|
+
tags.forEach((tag, index) => {
|
|
91
|
+
if (index < tags.length - 1) {
|
|
92
|
+
result.push({ from: tag, to: tags[index + 1] });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
async function getLastGitTag(delta = 0) {
|
|
98
|
+
const tags = await getTotalGitTags();
|
|
99
|
+
return tags[tags.length + delta - 1];
|
|
100
|
+
}
|
|
101
|
+
async function getGitMainBranchName() {
|
|
102
|
+
const main = await execCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
103
|
+
return main;
|
|
104
|
+
}
|
|
105
|
+
async function getCurrentGitBranch() {
|
|
106
|
+
const tag = await execCommand("git", ["tag", "--points-at", "HEAD"]);
|
|
107
|
+
const main = getGitMainBranchName();
|
|
108
|
+
return tag || main;
|
|
109
|
+
}
|
|
110
|
+
async function getGitHubRepo() {
|
|
111
|
+
const url = await execCommand("git", ["config", "--get", "remote.origin.url"]);
|
|
112
|
+
const match = url.match(/github\.com[/:]([\w\d._-]+?)\/([\w\d._-]+?)(\.git)?$/i);
|
|
113
|
+
if (!match) {
|
|
114
|
+
throw new Error(`Can not parse GitHub repo from url ${url}`);
|
|
115
|
+
}
|
|
116
|
+
return `${match[1]}/${match[2]}`;
|
|
117
|
+
}
|
|
118
|
+
function getFirstGitCommit() {
|
|
119
|
+
return execCommand("git", ["rev-list", "--max-parents=0", "HEAD"]);
|
|
120
|
+
}
|
|
121
|
+
async function getGitDiff(from, to = "HEAD") {
|
|
122
|
+
const rawGit = await execCommand("git", [
|
|
123
|
+
"--no-pager",
|
|
124
|
+
"log",
|
|
125
|
+
`${from ? `${from}...` : ""}${to}`,
|
|
126
|
+
'--pretty="----%n%s|%h|%an|%ae%n%b"',
|
|
127
|
+
"--name-status"
|
|
128
|
+
]);
|
|
129
|
+
const rwaGitLines = rawGit.split("----\n").splice(1);
|
|
130
|
+
const gitCommits = rwaGitLines.map((line) => {
|
|
131
|
+
const [firstLine, ...body] = line.split("\n");
|
|
132
|
+
const [message, shortHash, authorName, authorEmail] = firstLine.split("|");
|
|
133
|
+
const gitCommmit = {
|
|
134
|
+
message,
|
|
135
|
+
shortHash,
|
|
136
|
+
author: { name: authorName, email: authorEmail },
|
|
137
|
+
body: body.join("\n")
|
|
138
|
+
};
|
|
139
|
+
return gitCommmit;
|
|
140
|
+
});
|
|
141
|
+
return gitCommits;
|
|
142
|
+
}
|
|
143
|
+
function parseGitCommit(commit) {
|
|
144
|
+
const ConventionalCommitRegex = /(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i;
|
|
145
|
+
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim;
|
|
146
|
+
const PullRequestRE = /\([a-z]*(#\d+)\s*\)/gm;
|
|
147
|
+
const IssueRE = /(#\d+)/gm;
|
|
148
|
+
const match = commit.message.match(ConventionalCommitRegex);
|
|
149
|
+
if (!match?.groups) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const type = match.groups.type;
|
|
153
|
+
const scope = match.groups.scope || "";
|
|
154
|
+
const isBreaking = Boolean(match.groups.breaking);
|
|
155
|
+
let description = match.groups.description;
|
|
156
|
+
const references = [];
|
|
157
|
+
for (const m of description.matchAll(PullRequestRE)) {
|
|
158
|
+
references.push({ type: "pull-request", value: m[1] });
|
|
159
|
+
}
|
|
160
|
+
for (const m of description.matchAll(IssueRE)) {
|
|
161
|
+
if (!references.some((i) => i.value === m[1])) {
|
|
162
|
+
references.push({ type: "issue", value: m[1] });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
references.push({ value: commit.shortHash, type: "hash" });
|
|
166
|
+
description = description.replace(PullRequestRE, "").trim();
|
|
167
|
+
const authors = [commit.author];
|
|
168
|
+
const matchs = commit.body.matchAll(CoAuthoredByRegex);
|
|
169
|
+
for (const $match of matchs) {
|
|
170
|
+
const { name = "", email = "" } = $match.groups || {};
|
|
171
|
+
const author = {
|
|
172
|
+
name: name.trim(),
|
|
173
|
+
email: email.trim()
|
|
174
|
+
};
|
|
175
|
+
authors.push(author);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
...commit,
|
|
179
|
+
authors,
|
|
180
|
+
resolvedAuthors: [],
|
|
181
|
+
description,
|
|
182
|
+
type,
|
|
183
|
+
scope,
|
|
184
|
+
references,
|
|
185
|
+
isBreaking
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async function getGitCommits(from, to = "HEAD") {
|
|
189
|
+
const rwaGitCommits = await getGitDiff(from, to);
|
|
190
|
+
const commits = rwaGitCommits.map((commit) => parseGitCommit(commit)).filter(notNullish);
|
|
191
|
+
return commits;
|
|
192
|
+
}
|
|
193
|
+
function getHeaders(githubToken) {
|
|
194
|
+
return {
|
|
195
|
+
accept: "application/vnd.github.v3+json",
|
|
196
|
+
authorization: `token ${githubToken}`
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async function getResolvedAuthorLogin(github, commitHashes, email) {
|
|
200
|
+
let login = "";
|
|
201
|
+
try {
|
|
202
|
+
const data = await ofetch.ofetch(`https://ungh.cc/users/find/${email}`);
|
|
203
|
+
login = data?.user?.username || "";
|
|
204
|
+
} catch {
|
|
205
|
+
}
|
|
206
|
+
if (login) {
|
|
207
|
+
return login;
|
|
208
|
+
}
|
|
209
|
+
const { repo, token } = github;
|
|
210
|
+
if (!token) {
|
|
211
|
+
return login;
|
|
212
|
+
}
|
|
213
|
+
if (commitHashes.length) {
|
|
214
|
+
try {
|
|
215
|
+
const data = await ofetch.ofetch(`https://api.github.com/repos/${repo}/commits/${commitHashes[0]}`, {
|
|
216
|
+
headers: getHeaders(token)
|
|
217
|
+
});
|
|
218
|
+
login = data?.author?.login || "";
|
|
219
|
+
} catch (e) {
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (login) {
|
|
223
|
+
return login;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const data = await ofetch.ofetch(`https://api.github.com/search/users?q=${encodeURIComponent(email)}`, {
|
|
227
|
+
headers: getHeaders(token)
|
|
228
|
+
});
|
|
229
|
+
login = data.items[0].login;
|
|
230
|
+
} catch (e) {
|
|
231
|
+
}
|
|
232
|
+
return login;
|
|
233
|
+
}
|
|
234
|
+
async function getGitCommitsAndResolvedAuthors(commits, github, resolvedLogins) {
|
|
235
|
+
const resultCommits = [];
|
|
236
|
+
const map = /* @__PURE__ */ new Map();
|
|
237
|
+
for await (const commit of commits) {
|
|
238
|
+
const resolvedAuthors = [];
|
|
239
|
+
for await (const [index, author] of Object.entries(commit.authors)) {
|
|
240
|
+
const { email, name } = author;
|
|
241
|
+
if (email && name) {
|
|
242
|
+
const commitHashes = [];
|
|
243
|
+
if (index === "0") {
|
|
244
|
+
commitHashes.push(commit.shortHash);
|
|
245
|
+
}
|
|
246
|
+
const resolvedAuthor = {
|
|
247
|
+
name,
|
|
248
|
+
email,
|
|
249
|
+
commits: commitHashes,
|
|
250
|
+
login: ""
|
|
251
|
+
};
|
|
252
|
+
if (!resolvedLogins?.has(email)) {
|
|
253
|
+
const login = await getResolvedAuthorLogin(github, commitHashes, email);
|
|
254
|
+
resolvedAuthor.login = login;
|
|
255
|
+
resolvedLogins?.set(email, login);
|
|
256
|
+
} else {
|
|
257
|
+
const login = resolvedLogins?.get(email) || "";
|
|
258
|
+
resolvedAuthor.login = login;
|
|
259
|
+
}
|
|
260
|
+
resolvedAuthors.push(resolvedAuthor);
|
|
261
|
+
if (!map.has(email)) {
|
|
262
|
+
map.set(email, resolvedAuthor);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const resultCommit = { ...commit, resolvedAuthors };
|
|
267
|
+
resultCommits.push(resultCommit);
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
commits: resultCommits,
|
|
271
|
+
contributors: Array.from(map.values())
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function createDefaultOptions() {
|
|
276
|
+
const cwd = process.cwd();
|
|
277
|
+
const options = {
|
|
278
|
+
cwd,
|
|
279
|
+
types: {
|
|
280
|
+
feat: "\u{1F680} Features",
|
|
281
|
+
fix: "\u{1F41E} Bug Fixes",
|
|
282
|
+
perf: "\u{1F525} Performance",
|
|
283
|
+
refactor: "\u{1F485} Refactors",
|
|
284
|
+
docs: "\u{1F4D6} Documentation",
|
|
285
|
+
build: "\u{1F4E6} Build",
|
|
286
|
+
types: "\u{1F30A} Types",
|
|
287
|
+
chore: "\u{1F3E1} Chore",
|
|
288
|
+
examples: "\u{1F3C0} Examples",
|
|
289
|
+
test: "\u2705 Tests",
|
|
290
|
+
style: "\u{1F3A8} Styles",
|
|
291
|
+
ci: "\u{1F916} CI"
|
|
292
|
+
},
|
|
293
|
+
github: {
|
|
294
|
+
repo: "",
|
|
295
|
+
token: process.env.GITHUB_TOKEN || ""
|
|
296
|
+
},
|
|
297
|
+
from: "",
|
|
298
|
+
to: "",
|
|
299
|
+
tags: [],
|
|
300
|
+
tagDateMap: /* @__PURE__ */ new Map(),
|
|
301
|
+
capitalize: true,
|
|
302
|
+
emoji: true,
|
|
303
|
+
titles: {
|
|
304
|
+
breakingChanges: "\u{1F6A8} Breaking Changes"
|
|
305
|
+
},
|
|
306
|
+
output: "CHANGELOG.md",
|
|
307
|
+
regenerate: false,
|
|
308
|
+
newVersion: ""
|
|
309
|
+
};
|
|
310
|
+
return options;
|
|
311
|
+
}
|
|
312
|
+
async function getVersionFromPkgJson(cwd) {
|
|
313
|
+
let newVersion = "";
|
|
314
|
+
try {
|
|
315
|
+
const pkgJson = await promises.readFile(`${cwd}/package.json`, "utf-8");
|
|
316
|
+
const pkg = JSON.parse(pkgJson);
|
|
317
|
+
newVersion = pkg?.version || "";
|
|
318
|
+
} catch {
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
newVersion
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async function createOptions(options) {
|
|
325
|
+
var _a;
|
|
326
|
+
const opts = createDefaultOptions();
|
|
327
|
+
Object.assign(opts, options);
|
|
328
|
+
const { newVersion } = await getVersionFromPkgJson(opts.cwd);
|
|
329
|
+
(_a = opts.github).repo || (_a.repo = await getGitHubRepo());
|
|
330
|
+
opts.newVersion || (opts.newVersion = `v${newVersion}`);
|
|
331
|
+
opts.from || (opts.from = await getLastGitTag());
|
|
332
|
+
opts.to || (opts.to = await getCurrentGitBranch());
|
|
333
|
+
if (opts.to === opts.from) {
|
|
334
|
+
const lastTag = await getLastGitTag(-1);
|
|
335
|
+
const firstCommit = await getFirstGitCommit();
|
|
336
|
+
opts.from = lastTag || firstCommit;
|
|
337
|
+
}
|
|
338
|
+
opts.tags = await getTotalGitTags();
|
|
339
|
+
opts.tagDateMap = await getTagDateMap();
|
|
340
|
+
return opts;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function formatReferences(references, githubRepo, type) {
|
|
344
|
+
const refs = references.filter((i) => {
|
|
345
|
+
if (type === "issues")
|
|
346
|
+
return i.type === "issue" || i.type === "pull-request";
|
|
347
|
+
return i.type === "hash";
|
|
348
|
+
}).map((ref) => {
|
|
349
|
+
if (!githubRepo)
|
|
350
|
+
return ref.value;
|
|
351
|
+
if (ref.type === "pull-request" || ref.type === "issue")
|
|
352
|
+
return `https://github.com/${githubRepo}/issues/${ref.value.slice(1)}`;
|
|
353
|
+
return `[<samp>(${ref.value.slice(0, 5)})</samp>](https://github.com/${githubRepo}/commit/${ref.value})`;
|
|
354
|
+
});
|
|
355
|
+
const referencesString = join(refs).trim();
|
|
356
|
+
if (type === "issues")
|
|
357
|
+
return referencesString && `in ${referencesString}`;
|
|
358
|
+
return referencesString;
|
|
359
|
+
}
|
|
360
|
+
function formatLine(commit, options) {
|
|
361
|
+
const prRefs = formatReferences(commit.references, options.github.repo, "issues");
|
|
362
|
+
const hashRefs = formatReferences(commit.references, options.github.repo, "hash");
|
|
363
|
+
let authors = join([...new Set(commit.resolvedAuthors.map((i) => i.login ? `@${i.login}` : `**${i.name}**`))]).trim();
|
|
364
|
+
if (authors) {
|
|
365
|
+
authors = `by ${authors}`;
|
|
366
|
+
}
|
|
367
|
+
let refs = [authors, prRefs, hashRefs].filter((i) => i?.trim()).join(" ");
|
|
368
|
+
if (refs) {
|
|
369
|
+
refs = ` - ${refs}`;
|
|
370
|
+
}
|
|
371
|
+
const description = options.capitalize ? capitalize(commit.description) : commit.description;
|
|
372
|
+
return [description, refs].filter((i) => i?.trim()).join(" ");
|
|
373
|
+
}
|
|
374
|
+
function formatTitle(name, options) {
|
|
375
|
+
const emojisRE = /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g;
|
|
376
|
+
let formatName = name.trim();
|
|
377
|
+
if (!options.emoji) {
|
|
378
|
+
formatName = name.replace(emojisRE, "").trim();
|
|
379
|
+
}
|
|
380
|
+
return `### ${formatName}`;
|
|
381
|
+
}
|
|
382
|
+
function formatSection(commits, sectionName, options) {
|
|
383
|
+
if (!commits.length)
|
|
384
|
+
return [];
|
|
385
|
+
const lines = ["", formatTitle(sectionName, options), ""];
|
|
386
|
+
const scopes = groupBy(commits, "scope");
|
|
387
|
+
let useScopeGroup = true;
|
|
388
|
+
if (!Object.entries(scopes).some(([k, v]) => k && v.length > 1)) {
|
|
389
|
+
useScopeGroup = false;
|
|
390
|
+
}
|
|
391
|
+
Object.keys(scopes).sort().forEach((scope) => {
|
|
392
|
+
let padding = "";
|
|
393
|
+
let prefix = "";
|
|
394
|
+
const scopeText = `**${scope}**`;
|
|
395
|
+
if (scope && useScopeGroup) {
|
|
396
|
+
lines.push(`- ${scopeText}:`);
|
|
397
|
+
padding = " ";
|
|
398
|
+
} else if (scope) {
|
|
399
|
+
prefix = `${scopeText}: `;
|
|
400
|
+
}
|
|
401
|
+
lines.push(...scopes[scope].reverse().map((commit) => `${padding}- ${prefix}${formatLine(commit, options)}`));
|
|
402
|
+
});
|
|
403
|
+
return lines;
|
|
404
|
+
}
|
|
405
|
+
function getUserGithub(userName) {
|
|
406
|
+
const githubUrl = `https://github.com/${userName}`;
|
|
407
|
+
return githubUrl;
|
|
408
|
+
}
|
|
409
|
+
function getGitUserAvatar(userName) {
|
|
410
|
+
const githubUrl = getUserGithub(userName);
|
|
411
|
+
const avatarUrl = `${githubUrl}.png?size=48`;
|
|
412
|
+
return avatarUrl;
|
|
413
|
+
}
|
|
414
|
+
function createContributorLine(contributors) {
|
|
415
|
+
let loginLine = "";
|
|
416
|
+
let unloginLine = "";
|
|
417
|
+
contributors.forEach((contributor, index) => {
|
|
418
|
+
const { name, email, login } = contributor;
|
|
419
|
+
if (!login) {
|
|
420
|
+
let line = `[${name}](mailto:${email})`;
|
|
421
|
+
if (index < contributors.length - 1) {
|
|
422
|
+
line += ", ";
|
|
423
|
+
}
|
|
424
|
+
unloginLine += line;
|
|
425
|
+
} else {
|
|
426
|
+
const githubUrl = getUserGithub(login);
|
|
427
|
+
const avatar = getGitUserAvatar(login);
|
|
428
|
+
loginLine += `[](${githubUrl}) `;
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
return `${loginLine}
|
|
432
|
+
${unloginLine}`;
|
|
433
|
+
}
|
|
434
|
+
function generateMarkdown(params) {
|
|
435
|
+
const { commits, options, showTitle, contributors } = params;
|
|
436
|
+
const lines = [];
|
|
437
|
+
const url = `https://github.com/${options.github.repo}/compare/${options.from}...${options.to}`;
|
|
438
|
+
if (showTitle) {
|
|
439
|
+
const isNewVersion = !VERSION_REG.test(options.to);
|
|
440
|
+
const version = isNewVersion ? options.newVersion : options.to;
|
|
441
|
+
const date = isNewVersion ? dayjs__default().format("YY-MM-DD") : options.tagDateMap.get(options.to);
|
|
442
|
+
let title = `## [${version}](${url})`;
|
|
443
|
+
if (date) {
|
|
444
|
+
title += ` (${date})`;
|
|
445
|
+
}
|
|
446
|
+
lines.push(title);
|
|
447
|
+
}
|
|
448
|
+
const [breaking, changes] = partition(commits, (c) => c.isBreaking);
|
|
449
|
+
const group = groupBy(changes, "type");
|
|
450
|
+
lines.push(...formatSection(breaking, options.titles.breakingChanges, options));
|
|
451
|
+
for (const type of Object.keys(options.types)) {
|
|
452
|
+
const items = group[type] || [];
|
|
453
|
+
lines.push(...formatSection(items, options.types[type], options));
|
|
454
|
+
}
|
|
455
|
+
if (!lines.length) {
|
|
456
|
+
lines.push("*No significant changes*");
|
|
457
|
+
}
|
|
458
|
+
if (!showTitle) {
|
|
459
|
+
lines.push("", `##### [View changes on GitHub](${url})`);
|
|
460
|
+
}
|
|
461
|
+
if (showTitle) {
|
|
462
|
+
lines.push("", "### \u2764\uFE0F Contributors", "");
|
|
463
|
+
const contributorLine = createContributorLine(contributors);
|
|
464
|
+
lines.push(contributorLine);
|
|
465
|
+
}
|
|
466
|
+
const md = convertGitmoji.convert(lines.join("\n").trim(), true);
|
|
467
|
+
return md;
|
|
468
|
+
}
|
|
469
|
+
async function isVersionInMarkdown(version, mdPath) {
|
|
470
|
+
let isIn = false;
|
|
471
|
+
const md = await promises.readFile(mdPath, "utf8");
|
|
472
|
+
if (md) {
|
|
473
|
+
const matches = md.match(VERSION_REG_OF_MARKDOWN);
|
|
474
|
+
if (matches?.length) {
|
|
475
|
+
const versionInMarkdown = `## [${version}]`;
|
|
476
|
+
isIn = matches.includes(versionInMarkdown);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return isIn;
|
|
480
|
+
}
|
|
481
|
+
async function writeMarkdown(md, mdPath, regenerate = false) {
|
|
482
|
+
let changelogMD;
|
|
483
|
+
const changelogPrefix = "# Changelog";
|
|
484
|
+
if (!regenerate && fs.existsSync(mdPath)) {
|
|
485
|
+
changelogMD = await promises.readFile(mdPath, "utf8");
|
|
486
|
+
if (!changelogMD.startsWith(changelogPrefix)) {
|
|
487
|
+
changelogMD = `${changelogPrefix}
|
|
488
|
+
|
|
489
|
+
${changelogMD}`;
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
changelogMD = `${changelogPrefix}
|
|
493
|
+
|
|
494
|
+
`;
|
|
495
|
+
}
|
|
496
|
+
const lastEntry = changelogMD.match(/^###?\s+.*$/m);
|
|
497
|
+
if (lastEntry) {
|
|
498
|
+
changelogMD = `${changelogMD.slice(0, lastEntry.index) + md}
|
|
499
|
+
|
|
500
|
+
${changelogMD.slice(lastEntry.index)}`;
|
|
501
|
+
} else {
|
|
502
|
+
changelogMD += `
|
|
503
|
+
${md}
|
|
504
|
+
|
|
505
|
+
`;
|
|
506
|
+
}
|
|
507
|
+
await promises.writeFile(mdPath, changelogMD);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function getChangelogMarkdown(options) {
|
|
511
|
+
const opts = await createOptions(options);
|
|
512
|
+
const gitCommits = await getGitCommits(opts.from, opts.to);
|
|
513
|
+
const { commits, contributors } = await getGitCommitsAndResolvedAuthors(gitCommits, opts.github);
|
|
514
|
+
const markdown = generateMarkdown({ commits, options: opts, showTitle: true, contributors });
|
|
515
|
+
return markdown;
|
|
516
|
+
}
|
|
517
|
+
async function getTotalChangelogMarkdown(options, showProgress = true) {
|
|
518
|
+
const opts = await createOptions(options);
|
|
519
|
+
let bar = null;
|
|
520
|
+
if (showProgress) {
|
|
521
|
+
bar = new cliProgress__default.SingleBar(
|
|
522
|
+
{ format: "generate total changelog: [{bar}] {percentage}% | ETA: {eta}s | {value}/{total}" },
|
|
523
|
+
cliProgress__default.Presets.shades_classic
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
const tags = getFromToTags(opts.tags);
|
|
527
|
+
bar?.start(tags.length, 0);
|
|
528
|
+
let markdown = "";
|
|
529
|
+
const resolvedLogins = /* @__PURE__ */ new Map();
|
|
530
|
+
for await (const [index, tag] of tags.entries()) {
|
|
531
|
+
const { from, to } = tag;
|
|
532
|
+
const gitCommits = await getGitCommits(from, to);
|
|
533
|
+
const { commits, contributors } = await getGitCommitsAndResolvedAuthors(gitCommits, opts.github, resolvedLogins);
|
|
534
|
+
const nextMd = generateMarkdown({ commits, options: { ...opts, from, to }, showTitle: true, contributors });
|
|
535
|
+
markdown = `${nextMd}
|
|
536
|
+
|
|
537
|
+
${markdown}`;
|
|
538
|
+
bar?.update(index + 1);
|
|
539
|
+
}
|
|
540
|
+
bar?.stop();
|
|
541
|
+
return markdown;
|
|
542
|
+
}
|
|
543
|
+
async function generateChangelog(options) {
|
|
544
|
+
const opts = await createOptions(options);
|
|
545
|
+
const existContent = await isVersionInMarkdown(opts.to, opts.output);
|
|
546
|
+
if (!opts.regenerate && existContent)
|
|
547
|
+
return;
|
|
548
|
+
const markdown = await getChangelogMarkdown(opts);
|
|
549
|
+
await writeMarkdown(markdown, opts.output, opts.regenerate);
|
|
550
|
+
}
|
|
551
|
+
async function generateTotalChangelog(options, showProgress = true) {
|
|
552
|
+
const opts = await createOptions(options);
|
|
553
|
+
const markdown = await getTotalChangelogMarkdown(opts, showProgress);
|
|
554
|
+
await writeMarkdown(markdown, opts.output, true);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
exports.generateChangelog = generateChangelog;
|
|
558
|
+
exports.generateTotalChangelog = generateTotalChangelog;
|
|
559
|
+
exports.getChangelogMarkdown = getChangelogMarkdown;
|
|
560
|
+
exports.getTotalChangelogMarkdown = getTotalChangelogMarkdown;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* github config
|
|
3
|
+
*/
|
|
4
|
+
interface GithubConfig {
|
|
5
|
+
/**
|
|
6
|
+
* the github repository name
|
|
7
|
+
* @example soybeanjs/changelog
|
|
8
|
+
*/
|
|
9
|
+
repo: string;
|
|
10
|
+
/**
|
|
11
|
+
* the github token
|
|
12
|
+
*/
|
|
13
|
+
token: string;
|
|
14
|
+
}
|
|
15
|
+
interface ChangelogOption {
|
|
16
|
+
/**
|
|
17
|
+
* the directory of the project
|
|
18
|
+
* @default process.cwd()
|
|
19
|
+
*/
|
|
20
|
+
cwd: string;
|
|
21
|
+
/**
|
|
22
|
+
* the commit scope types
|
|
23
|
+
*/
|
|
24
|
+
types: Record<string, string>;
|
|
25
|
+
/**
|
|
26
|
+
* github config
|
|
27
|
+
*/
|
|
28
|
+
github: GithubConfig;
|
|
29
|
+
/**
|
|
30
|
+
* the commit hash or tag
|
|
31
|
+
*/
|
|
32
|
+
from: string;
|
|
33
|
+
/**
|
|
34
|
+
* the commit hash or tag
|
|
35
|
+
*/
|
|
36
|
+
to: string;
|
|
37
|
+
/**
|
|
38
|
+
* the whole commit tags
|
|
39
|
+
*/
|
|
40
|
+
tags: string[];
|
|
41
|
+
/**
|
|
42
|
+
* the commit tag and date map
|
|
43
|
+
*/
|
|
44
|
+
tagDateMap: Map<string, string>;
|
|
45
|
+
/**
|
|
46
|
+
* Whether to capitalize the first letter of the commit type
|
|
47
|
+
*/
|
|
48
|
+
capitalize: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Use emojis in section titles
|
|
51
|
+
* @default true
|
|
52
|
+
*/
|
|
53
|
+
emoji: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* the section titles
|
|
56
|
+
*/
|
|
57
|
+
titles: {
|
|
58
|
+
/**
|
|
59
|
+
* the title of breaking changes section
|
|
60
|
+
*/
|
|
61
|
+
breakingChanges: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* the output file path of the changelog
|
|
65
|
+
*/
|
|
66
|
+
output: string;
|
|
67
|
+
/**
|
|
68
|
+
* Whether to regenerate the changelog if it already exists
|
|
69
|
+
* @example the changelog already exists the content of v0.0.1, but you want to regenerate it
|
|
70
|
+
*/
|
|
71
|
+
regenerate: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* version from package.json, with preffix "v"
|
|
74
|
+
* @description if the options "to" is not specified, the version will be used
|
|
75
|
+
*/
|
|
76
|
+
newVersion: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* get the changelog markdown by two git tags
|
|
81
|
+
* @param options the changelog options
|
|
82
|
+
*/
|
|
83
|
+
declare function getChangelogMarkdown(options?: Partial<ChangelogOption>): Promise<string>;
|
|
84
|
+
/**
|
|
85
|
+
* get the changelog markdown by the total git tags
|
|
86
|
+
* @param options the changelog options
|
|
87
|
+
* @param showProgress whither show the progress bar
|
|
88
|
+
*/
|
|
89
|
+
declare function getTotalChangelogMarkdown(options?: Partial<ChangelogOption>, showProgress?: boolean): Promise<string>;
|
|
90
|
+
/**
|
|
91
|
+
* generate the changelog markdown by two git tags
|
|
92
|
+
* @param options the changelog options
|
|
93
|
+
*/
|
|
94
|
+
declare function generateChangelog(options?: Partial<ChangelogOption>): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* generate the changelog markdown by the total git tags
|
|
97
|
+
* @param options the changelog options
|
|
98
|
+
* @param showProgress whither show the progress bar
|
|
99
|
+
*/
|
|
100
|
+
declare function generateTotalChangelog(options?: Partial<ChangelogOption>, showProgress?: boolean): Promise<void>;
|
|
101
|
+
|
|
102
|
+
export { ChangelogOption, generateChangelog, generateTotalChangelog, getChangelogMarkdown, getTotalChangelogMarkdown };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import cliProgress from 'cli-progress';
|
|
2
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
3
|
+
import { ofetch } from 'ofetch';
|
|
4
|
+
import dayjs from 'dayjs';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import { convert } from 'convert-gitmoji';
|
|
7
|
+
|
|
8
|
+
async function execCommand(cmd, args, options) {
|
|
9
|
+
const { execa } = await import('execa');
|
|
10
|
+
const res = await execa(cmd, args, options);
|
|
11
|
+
return res?.stdout?.trim() || "";
|
|
12
|
+
}
|
|
13
|
+
function notNullish(v) {
|
|
14
|
+
return v !== null && v !== void 0;
|
|
15
|
+
}
|
|
16
|
+
function partition(array, ...filters) {
|
|
17
|
+
const result = new Array(filters.length + 1).fill(null).map(() => []);
|
|
18
|
+
array.forEach((e, idx, arr) => {
|
|
19
|
+
let i = 0;
|
|
20
|
+
for (const filter of filters) {
|
|
21
|
+
if (filter(e, idx, arr)) {
|
|
22
|
+
result[i].push(e);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
i += 1;
|
|
26
|
+
}
|
|
27
|
+
result[i].push(e);
|
|
28
|
+
});
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
function groupBy(items, key, groups = {}) {
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
const v = item[key];
|
|
34
|
+
groups[v] = groups[v] || [];
|
|
35
|
+
groups[v].push(item);
|
|
36
|
+
}
|
|
37
|
+
return groups;
|
|
38
|
+
}
|
|
39
|
+
function capitalize(str) {
|
|
40
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
41
|
+
}
|
|
42
|
+
function join(array, glue = ", ", finalGlue = " and ") {
|
|
43
|
+
if (!array || array.length === 0)
|
|
44
|
+
return "";
|
|
45
|
+
if (array.length === 1)
|
|
46
|
+
return array[0];
|
|
47
|
+
if (array.length === 2)
|
|
48
|
+
return array.join(finalGlue);
|
|
49
|
+
return `${array.slice(0, -1).join(glue)}${finalGlue}${array.slice(-1)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const VERSION_REG = /v\d+\.\d+\.\d+(-(beta|alpha)\.\d+)?/;
|
|
53
|
+
const VERSION_REG_OF_MARKDOWN = /## \[v\d+\.\d+\.\d+(-(beta|alpha)\.\d+)?]/g;
|
|
54
|
+
|
|
55
|
+
async function getTotalGitTags() {
|
|
56
|
+
const tagStr = await execCommand("git", ["--no-pager", "tag", "-l", "--sort=creatordate"]);
|
|
57
|
+
const tags = tagStr.split("\n");
|
|
58
|
+
return tags;
|
|
59
|
+
}
|
|
60
|
+
async function getTagDateMap() {
|
|
61
|
+
const tagDateStr = await execCommand("git", [
|
|
62
|
+
"--no-pager",
|
|
63
|
+
"log",
|
|
64
|
+
"--tags",
|
|
65
|
+
"--simplify-by-decoration",
|
|
66
|
+
"--pretty=format:%ci %d"
|
|
67
|
+
]);
|
|
68
|
+
const TAG_MARK = "tag: ";
|
|
69
|
+
const map = /* @__PURE__ */ new Map();
|
|
70
|
+
const dates = tagDateStr.split("\n").filter((item) => item.includes(TAG_MARK));
|
|
71
|
+
dates.forEach((item) => {
|
|
72
|
+
const [dateStr, tagStr] = item.split(TAG_MARK);
|
|
73
|
+
const date = dayjs(dateStr).format("YYYY-MM-DD");
|
|
74
|
+
const tag = tagStr.match(VERSION_REG)?.[0];
|
|
75
|
+
if (tag && date) {
|
|
76
|
+
map.set(tag.trim(), date);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return map;
|
|
80
|
+
}
|
|
81
|
+
function getFromToTags(tags) {
|
|
82
|
+
const result = [];
|
|
83
|
+
tags.forEach((tag, index) => {
|
|
84
|
+
if (index < tags.length - 1) {
|
|
85
|
+
result.push({ from: tag, to: tags[index + 1] });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
async function getLastGitTag(delta = 0) {
|
|
91
|
+
const tags = await getTotalGitTags();
|
|
92
|
+
return tags[tags.length + delta - 1];
|
|
93
|
+
}
|
|
94
|
+
async function getGitMainBranchName() {
|
|
95
|
+
const main = await execCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
96
|
+
return main;
|
|
97
|
+
}
|
|
98
|
+
async function getCurrentGitBranch() {
|
|
99
|
+
const tag = await execCommand("git", ["tag", "--points-at", "HEAD"]);
|
|
100
|
+
const main = getGitMainBranchName();
|
|
101
|
+
return tag || main;
|
|
102
|
+
}
|
|
103
|
+
async function getGitHubRepo() {
|
|
104
|
+
const url = await execCommand("git", ["config", "--get", "remote.origin.url"]);
|
|
105
|
+
const match = url.match(/github\.com[/:]([\w\d._-]+?)\/([\w\d._-]+?)(\.git)?$/i);
|
|
106
|
+
if (!match) {
|
|
107
|
+
throw new Error(`Can not parse GitHub repo from url ${url}`);
|
|
108
|
+
}
|
|
109
|
+
return `${match[1]}/${match[2]}`;
|
|
110
|
+
}
|
|
111
|
+
function getFirstGitCommit() {
|
|
112
|
+
return execCommand("git", ["rev-list", "--max-parents=0", "HEAD"]);
|
|
113
|
+
}
|
|
114
|
+
async function getGitDiff(from, to = "HEAD") {
|
|
115
|
+
const rawGit = await execCommand("git", [
|
|
116
|
+
"--no-pager",
|
|
117
|
+
"log",
|
|
118
|
+
`${from ? `${from}...` : ""}${to}`,
|
|
119
|
+
'--pretty="----%n%s|%h|%an|%ae%n%b"',
|
|
120
|
+
"--name-status"
|
|
121
|
+
]);
|
|
122
|
+
const rwaGitLines = rawGit.split("----\n").splice(1);
|
|
123
|
+
const gitCommits = rwaGitLines.map((line) => {
|
|
124
|
+
const [firstLine, ...body] = line.split("\n");
|
|
125
|
+
const [message, shortHash, authorName, authorEmail] = firstLine.split("|");
|
|
126
|
+
const gitCommmit = {
|
|
127
|
+
message,
|
|
128
|
+
shortHash,
|
|
129
|
+
author: { name: authorName, email: authorEmail },
|
|
130
|
+
body: body.join("\n")
|
|
131
|
+
};
|
|
132
|
+
return gitCommmit;
|
|
133
|
+
});
|
|
134
|
+
return gitCommits;
|
|
135
|
+
}
|
|
136
|
+
function parseGitCommit(commit) {
|
|
137
|
+
const ConventionalCommitRegex = /(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i;
|
|
138
|
+
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim;
|
|
139
|
+
const PullRequestRE = /\([a-z]*(#\d+)\s*\)/gm;
|
|
140
|
+
const IssueRE = /(#\d+)/gm;
|
|
141
|
+
const match = commit.message.match(ConventionalCommitRegex);
|
|
142
|
+
if (!match?.groups) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const type = match.groups.type;
|
|
146
|
+
const scope = match.groups.scope || "";
|
|
147
|
+
const isBreaking = Boolean(match.groups.breaking);
|
|
148
|
+
let description = match.groups.description;
|
|
149
|
+
const references = [];
|
|
150
|
+
for (const m of description.matchAll(PullRequestRE)) {
|
|
151
|
+
references.push({ type: "pull-request", value: m[1] });
|
|
152
|
+
}
|
|
153
|
+
for (const m of description.matchAll(IssueRE)) {
|
|
154
|
+
if (!references.some((i) => i.value === m[1])) {
|
|
155
|
+
references.push({ type: "issue", value: m[1] });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
references.push({ value: commit.shortHash, type: "hash" });
|
|
159
|
+
description = description.replace(PullRequestRE, "").trim();
|
|
160
|
+
const authors = [commit.author];
|
|
161
|
+
const matchs = commit.body.matchAll(CoAuthoredByRegex);
|
|
162
|
+
for (const $match of matchs) {
|
|
163
|
+
const { name = "", email = "" } = $match.groups || {};
|
|
164
|
+
const author = {
|
|
165
|
+
name: name.trim(),
|
|
166
|
+
email: email.trim()
|
|
167
|
+
};
|
|
168
|
+
authors.push(author);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
...commit,
|
|
172
|
+
authors,
|
|
173
|
+
resolvedAuthors: [],
|
|
174
|
+
description,
|
|
175
|
+
type,
|
|
176
|
+
scope,
|
|
177
|
+
references,
|
|
178
|
+
isBreaking
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
async function getGitCommits(from, to = "HEAD") {
|
|
182
|
+
const rwaGitCommits = await getGitDiff(from, to);
|
|
183
|
+
const commits = rwaGitCommits.map((commit) => parseGitCommit(commit)).filter(notNullish);
|
|
184
|
+
return commits;
|
|
185
|
+
}
|
|
186
|
+
function getHeaders(githubToken) {
|
|
187
|
+
return {
|
|
188
|
+
accept: "application/vnd.github.v3+json",
|
|
189
|
+
authorization: `token ${githubToken}`
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
async function getResolvedAuthorLogin(github, commitHashes, email) {
|
|
193
|
+
let login = "";
|
|
194
|
+
try {
|
|
195
|
+
const data = await ofetch(`https://ungh.cc/users/find/${email}`);
|
|
196
|
+
login = data?.user?.username || "";
|
|
197
|
+
} catch {
|
|
198
|
+
}
|
|
199
|
+
if (login) {
|
|
200
|
+
return login;
|
|
201
|
+
}
|
|
202
|
+
const { repo, token } = github;
|
|
203
|
+
if (!token) {
|
|
204
|
+
return login;
|
|
205
|
+
}
|
|
206
|
+
if (commitHashes.length) {
|
|
207
|
+
try {
|
|
208
|
+
const data = await ofetch(`https://api.github.com/repos/${repo}/commits/${commitHashes[0]}`, {
|
|
209
|
+
headers: getHeaders(token)
|
|
210
|
+
});
|
|
211
|
+
login = data?.author?.login || "";
|
|
212
|
+
} catch (e) {
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (login) {
|
|
216
|
+
return login;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const data = await ofetch(`https://api.github.com/search/users?q=${encodeURIComponent(email)}`, {
|
|
220
|
+
headers: getHeaders(token)
|
|
221
|
+
});
|
|
222
|
+
login = data.items[0].login;
|
|
223
|
+
} catch (e) {
|
|
224
|
+
}
|
|
225
|
+
return login;
|
|
226
|
+
}
|
|
227
|
+
async function getGitCommitsAndResolvedAuthors(commits, github, resolvedLogins) {
|
|
228
|
+
const resultCommits = [];
|
|
229
|
+
const map = /* @__PURE__ */ new Map();
|
|
230
|
+
for await (const commit of commits) {
|
|
231
|
+
const resolvedAuthors = [];
|
|
232
|
+
for await (const [index, author] of Object.entries(commit.authors)) {
|
|
233
|
+
const { email, name } = author;
|
|
234
|
+
if (email && name) {
|
|
235
|
+
const commitHashes = [];
|
|
236
|
+
if (index === "0") {
|
|
237
|
+
commitHashes.push(commit.shortHash);
|
|
238
|
+
}
|
|
239
|
+
const resolvedAuthor = {
|
|
240
|
+
name,
|
|
241
|
+
email,
|
|
242
|
+
commits: commitHashes,
|
|
243
|
+
login: ""
|
|
244
|
+
};
|
|
245
|
+
if (!resolvedLogins?.has(email)) {
|
|
246
|
+
const login = await getResolvedAuthorLogin(github, commitHashes, email);
|
|
247
|
+
resolvedAuthor.login = login;
|
|
248
|
+
resolvedLogins?.set(email, login);
|
|
249
|
+
} else {
|
|
250
|
+
const login = resolvedLogins?.get(email) || "";
|
|
251
|
+
resolvedAuthor.login = login;
|
|
252
|
+
}
|
|
253
|
+
resolvedAuthors.push(resolvedAuthor);
|
|
254
|
+
if (!map.has(email)) {
|
|
255
|
+
map.set(email, resolvedAuthor);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const resultCommit = { ...commit, resolvedAuthors };
|
|
260
|
+
resultCommits.push(resultCommit);
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
commits: resultCommits,
|
|
264
|
+
contributors: Array.from(map.values())
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function createDefaultOptions() {
|
|
269
|
+
const cwd = process.cwd();
|
|
270
|
+
const options = {
|
|
271
|
+
cwd,
|
|
272
|
+
types: {
|
|
273
|
+
feat: "\u{1F680} Features",
|
|
274
|
+
fix: "\u{1F41E} Bug Fixes",
|
|
275
|
+
perf: "\u{1F525} Performance",
|
|
276
|
+
refactor: "\u{1F485} Refactors",
|
|
277
|
+
docs: "\u{1F4D6} Documentation",
|
|
278
|
+
build: "\u{1F4E6} Build",
|
|
279
|
+
types: "\u{1F30A} Types",
|
|
280
|
+
chore: "\u{1F3E1} Chore",
|
|
281
|
+
examples: "\u{1F3C0} Examples",
|
|
282
|
+
test: "\u2705 Tests",
|
|
283
|
+
style: "\u{1F3A8} Styles",
|
|
284
|
+
ci: "\u{1F916} CI"
|
|
285
|
+
},
|
|
286
|
+
github: {
|
|
287
|
+
repo: "",
|
|
288
|
+
token: process.env.GITHUB_TOKEN || ""
|
|
289
|
+
},
|
|
290
|
+
from: "",
|
|
291
|
+
to: "",
|
|
292
|
+
tags: [],
|
|
293
|
+
tagDateMap: /* @__PURE__ */ new Map(),
|
|
294
|
+
capitalize: true,
|
|
295
|
+
emoji: true,
|
|
296
|
+
titles: {
|
|
297
|
+
breakingChanges: "\u{1F6A8} Breaking Changes"
|
|
298
|
+
},
|
|
299
|
+
output: "CHANGELOG.md",
|
|
300
|
+
regenerate: false,
|
|
301
|
+
newVersion: ""
|
|
302
|
+
};
|
|
303
|
+
return options;
|
|
304
|
+
}
|
|
305
|
+
async function getVersionFromPkgJson(cwd) {
|
|
306
|
+
let newVersion = "";
|
|
307
|
+
try {
|
|
308
|
+
const pkgJson = await readFile(`${cwd}/package.json`, "utf-8");
|
|
309
|
+
const pkg = JSON.parse(pkgJson);
|
|
310
|
+
newVersion = pkg?.version || "";
|
|
311
|
+
} catch {
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
newVersion
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async function createOptions(options) {
|
|
318
|
+
var _a;
|
|
319
|
+
const opts = createDefaultOptions();
|
|
320
|
+
Object.assign(opts, options);
|
|
321
|
+
const { newVersion } = await getVersionFromPkgJson(opts.cwd);
|
|
322
|
+
(_a = opts.github).repo || (_a.repo = await getGitHubRepo());
|
|
323
|
+
opts.newVersion || (opts.newVersion = `v${newVersion}`);
|
|
324
|
+
opts.from || (opts.from = await getLastGitTag());
|
|
325
|
+
opts.to || (opts.to = await getCurrentGitBranch());
|
|
326
|
+
if (opts.to === opts.from) {
|
|
327
|
+
const lastTag = await getLastGitTag(-1);
|
|
328
|
+
const firstCommit = await getFirstGitCommit();
|
|
329
|
+
opts.from = lastTag || firstCommit;
|
|
330
|
+
}
|
|
331
|
+
opts.tags = await getTotalGitTags();
|
|
332
|
+
opts.tagDateMap = await getTagDateMap();
|
|
333
|
+
return opts;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function formatReferences(references, githubRepo, type) {
|
|
337
|
+
const refs = references.filter((i) => {
|
|
338
|
+
if (type === "issues")
|
|
339
|
+
return i.type === "issue" || i.type === "pull-request";
|
|
340
|
+
return i.type === "hash";
|
|
341
|
+
}).map((ref) => {
|
|
342
|
+
if (!githubRepo)
|
|
343
|
+
return ref.value;
|
|
344
|
+
if (ref.type === "pull-request" || ref.type === "issue")
|
|
345
|
+
return `https://github.com/${githubRepo}/issues/${ref.value.slice(1)}`;
|
|
346
|
+
return `[<samp>(${ref.value.slice(0, 5)})</samp>](https://github.com/${githubRepo}/commit/${ref.value})`;
|
|
347
|
+
});
|
|
348
|
+
const referencesString = join(refs).trim();
|
|
349
|
+
if (type === "issues")
|
|
350
|
+
return referencesString && `in ${referencesString}`;
|
|
351
|
+
return referencesString;
|
|
352
|
+
}
|
|
353
|
+
function formatLine(commit, options) {
|
|
354
|
+
const prRefs = formatReferences(commit.references, options.github.repo, "issues");
|
|
355
|
+
const hashRefs = formatReferences(commit.references, options.github.repo, "hash");
|
|
356
|
+
let authors = join([...new Set(commit.resolvedAuthors.map((i) => i.login ? `@${i.login}` : `**${i.name}**`))]).trim();
|
|
357
|
+
if (authors) {
|
|
358
|
+
authors = `by ${authors}`;
|
|
359
|
+
}
|
|
360
|
+
let refs = [authors, prRefs, hashRefs].filter((i) => i?.trim()).join(" ");
|
|
361
|
+
if (refs) {
|
|
362
|
+
refs = ` - ${refs}`;
|
|
363
|
+
}
|
|
364
|
+
const description = options.capitalize ? capitalize(commit.description) : commit.description;
|
|
365
|
+
return [description, refs].filter((i) => i?.trim()).join(" ");
|
|
366
|
+
}
|
|
367
|
+
function formatTitle(name, options) {
|
|
368
|
+
const emojisRE = /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g;
|
|
369
|
+
let formatName = name.trim();
|
|
370
|
+
if (!options.emoji) {
|
|
371
|
+
formatName = name.replace(emojisRE, "").trim();
|
|
372
|
+
}
|
|
373
|
+
return `### ${formatName}`;
|
|
374
|
+
}
|
|
375
|
+
function formatSection(commits, sectionName, options) {
|
|
376
|
+
if (!commits.length)
|
|
377
|
+
return [];
|
|
378
|
+
const lines = ["", formatTitle(sectionName, options), ""];
|
|
379
|
+
const scopes = groupBy(commits, "scope");
|
|
380
|
+
let useScopeGroup = true;
|
|
381
|
+
if (!Object.entries(scopes).some(([k, v]) => k && v.length > 1)) {
|
|
382
|
+
useScopeGroup = false;
|
|
383
|
+
}
|
|
384
|
+
Object.keys(scopes).sort().forEach((scope) => {
|
|
385
|
+
let padding = "";
|
|
386
|
+
let prefix = "";
|
|
387
|
+
const scopeText = `**${scope}**`;
|
|
388
|
+
if (scope && useScopeGroup) {
|
|
389
|
+
lines.push(`- ${scopeText}:`);
|
|
390
|
+
padding = " ";
|
|
391
|
+
} else if (scope) {
|
|
392
|
+
prefix = `${scopeText}: `;
|
|
393
|
+
}
|
|
394
|
+
lines.push(...scopes[scope].reverse().map((commit) => `${padding}- ${prefix}${formatLine(commit, options)}`));
|
|
395
|
+
});
|
|
396
|
+
return lines;
|
|
397
|
+
}
|
|
398
|
+
function getUserGithub(userName) {
|
|
399
|
+
const githubUrl = `https://github.com/${userName}`;
|
|
400
|
+
return githubUrl;
|
|
401
|
+
}
|
|
402
|
+
function getGitUserAvatar(userName) {
|
|
403
|
+
const githubUrl = getUserGithub(userName);
|
|
404
|
+
const avatarUrl = `${githubUrl}.png?size=48`;
|
|
405
|
+
return avatarUrl;
|
|
406
|
+
}
|
|
407
|
+
function createContributorLine(contributors) {
|
|
408
|
+
let loginLine = "";
|
|
409
|
+
let unloginLine = "";
|
|
410
|
+
contributors.forEach((contributor, index) => {
|
|
411
|
+
const { name, email, login } = contributor;
|
|
412
|
+
if (!login) {
|
|
413
|
+
let line = `[${name}](mailto:${email})`;
|
|
414
|
+
if (index < contributors.length - 1) {
|
|
415
|
+
line += ", ";
|
|
416
|
+
}
|
|
417
|
+
unloginLine += line;
|
|
418
|
+
} else {
|
|
419
|
+
const githubUrl = getUserGithub(login);
|
|
420
|
+
const avatar = getGitUserAvatar(login);
|
|
421
|
+
loginLine += `[](${githubUrl}) `;
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
return `${loginLine}
|
|
425
|
+
${unloginLine}`;
|
|
426
|
+
}
|
|
427
|
+
function generateMarkdown(params) {
|
|
428
|
+
const { commits, options, showTitle, contributors } = params;
|
|
429
|
+
const lines = [];
|
|
430
|
+
const url = `https://github.com/${options.github.repo}/compare/${options.from}...${options.to}`;
|
|
431
|
+
if (showTitle) {
|
|
432
|
+
const isNewVersion = !VERSION_REG.test(options.to);
|
|
433
|
+
const version = isNewVersion ? options.newVersion : options.to;
|
|
434
|
+
const date = isNewVersion ? dayjs().format("YY-MM-DD") : options.tagDateMap.get(options.to);
|
|
435
|
+
let title = `## [${version}](${url})`;
|
|
436
|
+
if (date) {
|
|
437
|
+
title += ` (${date})`;
|
|
438
|
+
}
|
|
439
|
+
lines.push(title);
|
|
440
|
+
}
|
|
441
|
+
const [breaking, changes] = partition(commits, (c) => c.isBreaking);
|
|
442
|
+
const group = groupBy(changes, "type");
|
|
443
|
+
lines.push(...formatSection(breaking, options.titles.breakingChanges, options));
|
|
444
|
+
for (const type of Object.keys(options.types)) {
|
|
445
|
+
const items = group[type] || [];
|
|
446
|
+
lines.push(...formatSection(items, options.types[type], options));
|
|
447
|
+
}
|
|
448
|
+
if (!lines.length) {
|
|
449
|
+
lines.push("*No significant changes*");
|
|
450
|
+
}
|
|
451
|
+
if (!showTitle) {
|
|
452
|
+
lines.push("", `##### [View changes on GitHub](${url})`);
|
|
453
|
+
}
|
|
454
|
+
if (showTitle) {
|
|
455
|
+
lines.push("", "### \u2764\uFE0F Contributors", "");
|
|
456
|
+
const contributorLine = createContributorLine(contributors);
|
|
457
|
+
lines.push(contributorLine);
|
|
458
|
+
}
|
|
459
|
+
const md = convert(lines.join("\n").trim(), true);
|
|
460
|
+
return md;
|
|
461
|
+
}
|
|
462
|
+
async function isVersionInMarkdown(version, mdPath) {
|
|
463
|
+
let isIn = false;
|
|
464
|
+
const md = await readFile(mdPath, "utf8");
|
|
465
|
+
if (md) {
|
|
466
|
+
const matches = md.match(VERSION_REG_OF_MARKDOWN);
|
|
467
|
+
if (matches?.length) {
|
|
468
|
+
const versionInMarkdown = `## [${version}]`;
|
|
469
|
+
isIn = matches.includes(versionInMarkdown);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return isIn;
|
|
473
|
+
}
|
|
474
|
+
async function writeMarkdown(md, mdPath, regenerate = false) {
|
|
475
|
+
let changelogMD;
|
|
476
|
+
const changelogPrefix = "# Changelog";
|
|
477
|
+
if (!regenerate && existsSync(mdPath)) {
|
|
478
|
+
changelogMD = await readFile(mdPath, "utf8");
|
|
479
|
+
if (!changelogMD.startsWith(changelogPrefix)) {
|
|
480
|
+
changelogMD = `${changelogPrefix}
|
|
481
|
+
|
|
482
|
+
${changelogMD}`;
|
|
483
|
+
}
|
|
484
|
+
} else {
|
|
485
|
+
changelogMD = `${changelogPrefix}
|
|
486
|
+
|
|
487
|
+
`;
|
|
488
|
+
}
|
|
489
|
+
const lastEntry = changelogMD.match(/^###?\s+.*$/m);
|
|
490
|
+
if (lastEntry) {
|
|
491
|
+
changelogMD = `${changelogMD.slice(0, lastEntry.index) + md}
|
|
492
|
+
|
|
493
|
+
${changelogMD.slice(lastEntry.index)}`;
|
|
494
|
+
} else {
|
|
495
|
+
changelogMD += `
|
|
496
|
+
${md}
|
|
497
|
+
|
|
498
|
+
`;
|
|
499
|
+
}
|
|
500
|
+
await writeFile(mdPath, changelogMD);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function getChangelogMarkdown(options) {
|
|
504
|
+
const opts = await createOptions(options);
|
|
505
|
+
const gitCommits = await getGitCommits(opts.from, opts.to);
|
|
506
|
+
const { commits, contributors } = await getGitCommitsAndResolvedAuthors(gitCommits, opts.github);
|
|
507
|
+
const markdown = generateMarkdown({ commits, options: opts, showTitle: true, contributors });
|
|
508
|
+
return markdown;
|
|
509
|
+
}
|
|
510
|
+
async function getTotalChangelogMarkdown(options, showProgress = true) {
|
|
511
|
+
const opts = await createOptions(options);
|
|
512
|
+
let bar = null;
|
|
513
|
+
if (showProgress) {
|
|
514
|
+
bar = new cliProgress.SingleBar(
|
|
515
|
+
{ format: "generate total changelog: [{bar}] {percentage}% | ETA: {eta}s | {value}/{total}" },
|
|
516
|
+
cliProgress.Presets.shades_classic
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const tags = getFromToTags(opts.tags);
|
|
520
|
+
bar?.start(tags.length, 0);
|
|
521
|
+
let markdown = "";
|
|
522
|
+
const resolvedLogins = /* @__PURE__ */ new Map();
|
|
523
|
+
for await (const [index, tag] of tags.entries()) {
|
|
524
|
+
const { from, to } = tag;
|
|
525
|
+
const gitCommits = await getGitCommits(from, to);
|
|
526
|
+
const { commits, contributors } = await getGitCommitsAndResolvedAuthors(gitCommits, opts.github, resolvedLogins);
|
|
527
|
+
const nextMd = generateMarkdown({ commits, options: { ...opts, from, to }, showTitle: true, contributors });
|
|
528
|
+
markdown = `${nextMd}
|
|
529
|
+
|
|
530
|
+
${markdown}`;
|
|
531
|
+
bar?.update(index + 1);
|
|
532
|
+
}
|
|
533
|
+
bar?.stop();
|
|
534
|
+
return markdown;
|
|
535
|
+
}
|
|
536
|
+
async function generateChangelog(options) {
|
|
537
|
+
const opts = await createOptions(options);
|
|
538
|
+
const existContent = await isVersionInMarkdown(opts.to, opts.output);
|
|
539
|
+
if (!opts.regenerate && existContent)
|
|
540
|
+
return;
|
|
541
|
+
const markdown = await getChangelogMarkdown(opts);
|
|
542
|
+
await writeMarkdown(markdown, opts.output, opts.regenerate);
|
|
543
|
+
}
|
|
544
|
+
async function generateTotalChangelog(options, showProgress = true) {
|
|
545
|
+
const opts = await createOptions(options);
|
|
546
|
+
const markdown = await getTotalChangelogMarkdown(opts, showProgress);
|
|
547
|
+
await writeMarkdown(markdown, opts.output, true);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export { generateChangelog, generateTotalChangelog, getChangelogMarkdown, getTotalChangelogMarkdown };
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@soybeanjs/changelog",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "generate changelog form git tags and commits for github",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Soybean",
|
|
7
|
+
"email": "honghuangdc@gmail.com",
|
|
8
|
+
"url": "https://github.com/honghuangdc"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"homepage": "https://github.com/soybeanjs/changelog",
|
|
12
|
+
"repository": {
|
|
13
|
+
"url": "https://github.com/soybeanjs/changelog.git"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/soybeanjs/changelog/issues"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"registry": "https://registry.npmjs.org/"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"import": "./dist/index.mjs",
|
|
24
|
+
"require": "./dist/index.cjs",
|
|
25
|
+
"types": "./dist/index.d.ts"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.cjs",
|
|
29
|
+
"module": "./dist/index.mjs",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"cli-progress": "3.12.0",
|
|
36
|
+
"convert-gitmoji": "0.1.3",
|
|
37
|
+
"dayjs": "1.11.8",
|
|
38
|
+
"execa": "7.1.1",
|
|
39
|
+
"ofetch": "1.0.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@soybeanjs/cli": "0.5.2",
|
|
43
|
+
"@types/cli-progress": "3.11.0",
|
|
44
|
+
"@types/node": "20.2.5",
|
|
45
|
+
"eslint": "8.41.0",
|
|
46
|
+
"eslint-config-soybeanjs": "0.4.7",
|
|
47
|
+
"simple-git-hooks": "2.8.1",
|
|
48
|
+
"tsx": "3.12.7",
|
|
49
|
+
"typescript": "5.0.4",
|
|
50
|
+
"unbuild": "1.2.1"
|
|
51
|
+
},
|
|
52
|
+
"simple-git-hooks": {
|
|
53
|
+
"commit-msg": "pnpm soy git-commit-verify",
|
|
54
|
+
"pre-commit": "pnpm typecheck && pnpm soy lint-staged"
|
|
55
|
+
},
|
|
56
|
+
"github-token": "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",
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "pnpm typecheck && unbuild",
|
|
59
|
+
"lint": "eslint . --fix",
|
|
60
|
+
"format": "soy prettier-format",
|
|
61
|
+
"commit": "soy git-commit",
|
|
62
|
+
"cleanup": "soy cleanup",
|
|
63
|
+
"update-pkg": "soy update-pkg",
|
|
64
|
+
"publish-pkg": "pnpm -r publish --access public",
|
|
65
|
+
"typecheck": "tsc --noEmit",
|
|
66
|
+
"release": "soy release && pnpm build && pnpm publish-pkg"
|
|
67
|
+
}
|
|
68
|
+
}
|