@142vip/changelog
Version:
基于Git提交信息,生成变更记录,输出Markdown格式的日志文件
524 lines (514 loc) • 18.7 kB
JavaScript
;
const changelog = require('@142vip/changelog');
const utils = require('@142vip/utils');
const ofetch = require('ofetch');
var GitCommitMessageType = /* @__PURE__ */ ((GitCommitMessageType2) => {
GitCommitMessageType2["PULL_REQUEST"] = "pull-request";
GitCommitMessageType2["ISSUE"] = "issue";
GitCommitMessageType2["HASH"] = "hash";
return GitCommitMessageType2;
})(GitCommitMessageType || {});
function formatReferences(references, baseUrl, github, type) {
const refs = references.filter((i) => {
if (type === "issues")
return i.type === GitCommitMessageType.ISSUE || i.type === GitCommitMessageType.PULL_REQUEST;
return i.type === GitCommitMessageType.HASH;
}).map((ref) => {
if (!github)
return ref.value;
if (ref.type === GitCommitMessageType.PULL_REQUEST || ref.type === GitCommitMessageType.ISSUE)
return `https://${baseUrl}/${github}/issues/${ref.value.slice(1)}`;
return `[<samp>(${ref.value.slice(0, 5)})</samp>](https://${baseUrl}/${github}/commit/${ref.value})`;
});
const referencesString = join(refs).trim();
if (type === "issues")
return referencesString && `in ${referencesString}`;
return referencesString;
}
function formatLine(commit, options) {
const prRefs = formatReferences(commit.references, options.baseUrl, options.repo, "issues");
const hashRefs = formatReferences(commit.references, options.baseUrl, options.repo, "hash");
let authors = join([
...new Set(commit.resolvedAuthors?.map((i) => i.login ? `@${i.login}` : `**${i.name}**`))
])?.trim();
if (authors)
authors = `by ${authors}`;
let refs = [
authors,
prRefs,
hashRefs
].filter((i) => i?.trim()).join(" ");
if (refs)
refs = ` - ${refs}`;
const description = options.capitalize ? capitalize(commit.description) : commit.description;
return [description, refs].filter((i) => i?.trim()).join(" ");
}
function formatTitle(name, emoji) {
if (!emoji) {
const emojisRE = /([\u2700-\u27BF\uE000-\uF8FF\u2011-\u26FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|\uD83E[\uDD10-\uDDFF])/g;
name = name.replace(emojisRE, "");
}
return `### ${name.trim()}`;
}
function formatSection(commits, options) {
if (!commits.length)
return [];
if (options.scopeName != null) {
commits = commits.filter((commit) => commit.scope === options.scopeName);
}
const lines = ["", formatTitle(options.sectionName, options.emoji), ""];
const scopes = utils.vipLodash.groupBy(commits, "scope");
const useScopeGroup = options.group;
if (options.scopeName != null) {
if (scopes[options.scopeName] == null) {
return [];
}
const commits2 = scopes[options.scopeName].reverse();
for (const commit of commits2) {
if (commit.type === "release") {
break;
}
lines.push(`- ${formatLine(commit, utils.vipLodash.pick(options, "baseUrl", "repo", "capitalize"))}`);
}
} else {
Object.keys(scopes).sort().forEach((scope) => {
let padding = "";
let prefix = "";
const scopeText = `**${options.scopeMap[scope] || scope}**`;
if (scope && (useScopeGroup === true || useScopeGroup === "multiple" && scopes[scope].length > 1)) {
lines.push(`- ${scopeText}:`);
padding = " ";
} else if (scope) {
prefix = `${scopeText}: `;
}
lines.push(
...scopes[scope].reverse().map((commit) => `${padding}- ${prefix}${formatLine(commit, utils.vipLodash.pick(options, "baseUrl", "repo", "capitalize"))}`)
);
});
}
return lines;
}
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function join(array, glue = ", ", finalGlue = " and ") {
if (!array || array.length === 0)
return "";
if (array.length === 1)
return array[0];
if (array.length === 2)
return array.join(finalGlue);
return `${array.slice(0, -1).join(glue)}${finalGlue}${array.slice(-1)}`;
}
function getNoSignificantChanges() {
return "\n**No Significant Changes**";
}
function getNPMVersionDescription(pkgName, pkgVersion) {
const npmURI = `https://www.npmjs.com/package/${pkgName}`;
return `
**Release New Version ${pkgVersion} [\u{1F449} View New Package On NPM](${npmURI})**`;
}
function getGithubVersionDescription({ baseUrl, repo, fromVersion, toVersion }) {
const url = `https://${baseUrl}/${repo}/compare/${fromVersion}...${toVersion}`;
return `
**Release New Version ${toVersion} [\u{1F449} View Changes On GitHub](${url})**`;
}
const MarkdownAPI = {
formatSection,
getNoSignificantChanges,
getNPMVersionDescription,
getGithubVersionDescription
};
async function getGitCommitDiff(options) {
if (options.to == null) {
options.to = "HEAD";
}
if (options.from != null) {
options.from = `${options.from}...`;
} else {
options.from = "";
}
const commitStr = utils.VipExecutor.execCommandSync(`git --no-pager log "${options.from}${options.to}" --pretty="----%n%s|%h|%an|%ae%n%b" --name-status`);
return commitStr.split("----\n").splice(1).map((line) => {
const [firstLine, ..._body] = line.split("\n");
const [
message,
shortHash,
authorName,
authorEmail
] = firstLine.split("|");
return {
message,
shortHash,
author: { name: authorName, email: authorEmail },
body: _body.join("\n")
};
});
}
const ConventionalCommitRegex = /(?<emoji>:.+:|(\uD83C[\uDF00-\uDFFF])|(\uD83D[\uDC00-\uDE4F\uDE80-\uDEFF])|[\u2600-\u2B55])?( *)(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i;
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gi;
const PullRequestRE = /\([ a-z]*(#\d+)\s*\)/g;
const IssueRE = /(#\d+)/g;
function parseGitCommits(commits, scopeMap) {
return commits.map((commit) => parseGitCommit(commit, scopeMap)).filter((v) => v != null);
}
function parseGitCommit(commit, scopeMap) {
const match = commit.message.match(ConventionalCommitRegex);
if (match == null || match.groups == null) {
return null;
}
const type = match.groups.type;
const hasBreakingBody = /breaking change:/i.test(commit.body);
let scope = match.groups.scope || "";
scope = scopeMap[scope] || scope;
const isBreaking = Boolean(match.groups.breaking || hasBreakingBody);
let description = match.groups.description;
const references = [];
for (const m of description.matchAll(PullRequestRE)) {
references.push({ type: GitCommitMessageType.PULL_REQUEST, value: m[1] });
}
for (const m of description.matchAll(IssueRE)) {
if (!references.some((i) => i.value === m[1])) {
references.push({ type: GitCommitMessageType.ISSUE, value: m[1] });
}
}
references.push({ type: GitCommitMessageType.HASH, value: commit.shortHash });
description = description.replace(PullRequestRE, "").trim();
const authors = [commit.author];
for (const match2 of commit.body.matchAll(CoAuthoredByRegex)) {
authors.push({
name: (match2.groups?.name ?? "").trim(),
email: (match2.groups?.email ?? "").trim()
});
}
return {
...commit,
authors,
description,
type,
scope,
references,
isBreaking
};
}
async function parseCommitsToMarkdownStr(commits, options) {
const lines = [];
if (options.titles.breakingChanges != null) {
const breaking = commits.filter((c) => c.isBreaking);
lines.push(
...MarkdownAPI.formatSection(breaking, {
emoji: options.emoji,
group: options.group,
scopeName: options.scopeName,
baseUrl: options.baseUrl,
repo: options.repo,
capitalize: options.capitalize,
scopeMap: options.scopeMap,
sectionName: options.titles.breakingChanges
})
);
}
let changes = commits.filter((c) => !c.isBreaking);
if (options.scopeName != null) {
const commitsInScopeName = [];
for (const commit of changes) {
if (commit.type === "release" && commit.scope === options.scopeName) {
break;
}
commitsInScopeName.push(commit);
}
changes = commitsInScopeName;
}
const group = utils.vipLodash.groupBy(changes, "type");
let commitTypes = Object.keys(options.types);
if (options.scopeName != null) {
commitTypes = commitTypes.filter((type) => type !== "release");
}
for (const type of commitTypes) {
if (options.scopeName != null && type === "release") {
break;
}
const commitsByType = group[type] || [];
const sections = MarkdownAPI.formatSection(commitsByType, {
emoji: options.emoji,
group: options.group,
scopeName: options.scopeName,
baseUrl: options.baseUrl,
repo: options.repo,
capitalize: options.capitalize,
scopeMap: options.scopeMap,
sectionName: options.types[type].title
});
lines.push(...sections);
}
if (!lines.length) {
lines.push(MarkdownAPI.getNoSignificantChanges());
} else {
const description = options.scopeName != null ? MarkdownAPI.getNPMVersionDescription(options.scopeName, options.name) : MarkdownAPI.getGithubVersionDescription({
baseUrl: options.baseUrl,
repo: options.repo,
fromVersion: options.from,
toVersion: options.name
});
lines.push(description);
}
return utils.VipGit.convertEmoji(lines.join("\n").trim(), true);
}
const GitCommitAPI = {
getGitCommitDiff,
parseGitCommits,
parseCommitsToMarkdownStr
};
function getHeaders(token) {
return {
accept: "application/vnd.github.v3+json",
authorization: `token ${token}`
};
}
async function getAuthorInfo(options, info) {
if (info.login)
return info;
if (!options.token)
return info;
try {
const data = await ofetch.$fetch(`https://${options.baseUrlApi}/search/users?q=${encodeURIComponent(info.email)}`, {
headers: getHeaders(options.token)
});
info.login = data.items[0].login;
} catch {
}
if (info.login)
return info;
if (info.commits.length) {
try {
const data = await ofetch.$fetch(`https://${options.baseUrlApi}/repos/${options.repo}/commits/${info.commits[0]}`, {
headers: getHeaders(options.token)
});
info.login = data.author.login;
} catch {
}
}
return info;
}
async function resolveAuthors(commits, options) {
const authorInfoMap = /* @__PURE__ */ new Map();
commits.forEach((commit) => {
commit.resolvedAuthors = commit.authors.map((a, idx) => {
if (!a.email || !a.name)
return null;
if (!authorInfoMap.has(a.email)) {
authorInfoMap.set(a.email, { commits: [], name: a.name, email: a.email });
}
const info = authorInfoMap.get(a.email);
if (idx === 0)
info.commits.push(commit.shortHash);
return info;
}).filter((v) => v != null);
});
const authors = Array.from(authorInfoMap.values());
if (options.token == null) {
return authors;
}
const resolved = await Promise.all(authors.map((info) => getAuthorInfo({
token: options.token,
baseUrlApi: options.baseUrlApi,
repo: options.repo
}, info)));
const loginSet = /* @__PURE__ */ new Set();
const nameSet = /* @__PURE__ */ new Set();
return resolved.sort((a, b) => (a.login || a.name).localeCompare(b.login || b.name)).filter((i) => {
if (i.login && loginSet.has(i.login))
return false;
if (i.login) {
loginSet.add(i.login);
} else {
if (nameSet.has(i.name))
return false;
nameSet.add(i.name);
}
return true;
});
}
async function isExistTag(tag, options) {
try {
await ofetch.$fetch(`https://${options.baseUrlApi}/repos/${options.repo}/git/ref/tags/${tag}`, {
headers: getHeaders(options.token)
});
return true;
} catch {
return false;
}
}
function generateReleaseUrl(markdown, config) {
const baseUrl = `https://${config.baseUrl}/${config.repo}/releases/new`;
const queryParams = utils.vipQs.stringify({
title: config.name || config.to,
body: markdown,
tag: config.to,
prerelease: config.prerelease
});
return `${baseUrl}?${queryParams}`;
}
async function createGithubRelease(options) {
let url = `https://${options.baseUrlApi}/repos/${options.repo}/releases`;
let method = utils.HttpMethod.POST;
const headers = {
accept: "application/vnd.github.v3+json",
authorization: `token ${options.token}`
};
try {
const exists = await ofetch.$fetch(`https://${options.baseUrlApi}/repos/${options.repo}/releases/tags/${options.tag}`, {
headers
});
if (exists.url) {
url = exists.url;
method = utils.HttpMethod.PATCH;
}
} catch {
}
const body = {
body: options.content,
name: options.name,
tag_name: options.tag,
// 草稿
draft: options.draft || false,
// 预发布
prerelease: options.prerelease || true
};
if (method === utils.HttpMethod.POST) {
utils.VipConsole.log(utils.VipColor.cyan("Creating Release Notes..."));
} else {
utils.VipConsole.log(utils.VipColor.cyan("Updating Release Notes..."));
}
const res = await ofetch.$fetch(url, {
method,
body: JSON.stringify(body),
headers
});
utils.VipConsole.log(utils.VipColor.green(`Released on ${res.html_url}`));
}
function printReleaseUrl(webUrl, success = true) {
const errMsg = success ? `
${utils.VipColor.yellow("\u4F7F\u7528\u4EE5\u4E0B\u94FE\u63A5\u624B\u52A8\u53D1\u5E03\u65B0\u7684\u7248\u672C\uFF1A")}
` : `
${utils.VipColor.red("\u65E0\u6CD5\u521B\u5EFA\u53D1\u5E03\u3002\u4F7F\u7528\u4EE5\u4E0B\u94FE\u63A5\u624B\u52A8\u521B\u5EFA\uFF1A")}
`;
utils.VipConsole.error(errMsg);
utils.vipLogger.logByBlank(`<${utils.VipColor.yellow(webUrl)}>`);
}
const GithubAPI = {
getAuthorInfo,
isExistTag,
generateReleaseUrl,
printReleaseUrl,
getHeaders,
resolveAuthors,
createGithubRelease
};
async function generateChangelogInfo(config) {
const rawCommits = await GitCommitAPI.getGitCommitDiff({ from: config.from, to: config.to });
const commits = GitCommitAPI.parseGitCommits(rawCommits, config.scopeMap);
if (config.contributors) {
const token = utils.VipNodeJS.getProcessEnv("GITHUB_TOKEN") || utils.VipNodeJS.getProcessEnv("TOKEN");
await GithubAPI.resolveAuthors(commits, {
token,
baseUrlApi: config.baseUrlApi,
repo: config.repo
});
}
const markdown = await GitCommitAPI.parseCommitsToMarkdownStr(commits, config);
const releaseUrl = GithubAPI.generateReleaseUrl(markdown, {
baseUrl: config.baseUrl,
name: config.name,
repo: config.repo,
to: config.to,
prerelease: config.prerelease
});
return { config, markdown, commits, releaseUrl };
}
async function upsertChangelogDoc(outputPath, markdown, releaseVersionName, markdownHeader) {
let changelogMD;
if (utils.VipNodeJS.existPath(outputPath)) {
utils.VipConsole.log(`Updating ${outputPath}`);
changelogMD = utils.VipNodeJS.readFileToStrByUTF8(outputPath);
} else {
utils.VipConsole.log(`Creating ${outputPath}`);
changelogMD = markdownHeader;
}
const newMd = `## ${releaseVersionName} (${utils.vipDayjs.formatCurrentDateToYMD()})
${markdown}`;
const lastEntry = changelogMD.match(/^##\s+(?:\S.*)?$/m);
if (lastEntry) {
changelogMD = `${changelogMD.slice(0, lastEntry.index)}${newMd}
${changelogMD.slice(lastEntry.index)}`;
} else {
changelogMD += `
${newMd}`;
}
utils.VipNodeJS.writeFileByUTF8(outputPath, changelogMD);
}
async function changelogCoreHandler(cliOptions) {
cliOptions.token = cliOptions.token || utils.VipNodeJS.getProcessEnv("GITHUB_TOKEN") || utils.VipNodeJS.getProcessEnv("TOKEN");
const releaseUrl = "";
try {
utils.vipLogger.println();
const changelogConfig = changelog.parseCliOptions(cliOptions);
utils.VipConsole.trace("changelogConfig:", changelogConfig);
const { markdown, commits, releaseUrl: releaseUrl2 } = await generateChangelogInfo(changelogConfig);
if (changelogConfig.scopeName != null) {
utils.VipConsole.log(`release: <${utils.VipColor.yellow(releaseUrl2)}>`);
}
utils.VipConsole.log(`${utils.VipColor.cyan(changelogConfig.from)} ${utils.VipColor.dim(" -> ")} ${utils.VipColor.blue(changelogConfig.to)} ${utils.VipColor.dim(` (${commits.length} commits)`)}`);
utils.vipLogger.println();
utils.VipConsole.log(utils.VipColor.dim("--------------"));
utils.vipLogger.logByBlank(markdown.replace(/ /g, ""));
utils.VipConsole.log(utils.VipColor.dim("--------------"));
if (changelogConfig.dryRun) {
if (changelogConfig.scopeName != null) {
utils.vipLogger.logByBlank(utils.VipColor.yellow("Monorepo\u6A21\u5F0F\u7684NPM\u5305\u53D1\u5E03\u3002\u4E0D\u89E6\u53D1github release\u53D1\u5E03\u5730\u5740"));
} else {
utils.VipConsole.log(utils.VipColor.yellow("\u8BD5\u8FD0\u884C\u3002\u5DF2\u8DF3\u8FC7\u7248\u672C\u53D1\u5E03\uFF01\uFF01"));
GithubAPI.printReleaseUrl(releaseUrl2);
}
utils.VipNodeJS.existSuccessProcess();
}
if (typeof changelogConfig.output === "string") {
await upsertChangelogDoc(changelogConfig.output, markdown, changelogConfig.name, changelogConfig.header);
}
if (!cliOptions.token) {
utils.VipConsole.error(utils.VipColor.red("\u672A\u627E\u5230 GitHub \u4EE4\u724C\uFF0C\u8BF7\u901A\u8FC7 GITHUB_TOKEN \u73AF\u5883\u53D8\u91CF\u6307\u5B9A\u3002\u5DF2\u8DF3\u8FC7\u7248\u672C\u53D1\u5E03\u3002"));
GithubAPI.printReleaseUrl(releaseUrl2);
utils.VipNodeJS.existErrorProcess();
return;
}
if (commits.length === 0 && utils.VipGit.isRepoShallow()) {
utils.VipConsole.error(utils.VipColor.yellow("\u5B58\u50A8\u5E93\u4F3C\u4E4E\u514B\u9686\u5F97\u5F88\u6D45\uFF0C\u8FD9\u4F7F\u5F97\u66F4\u6539\u65E5\u5FD7\u65E0\u6CD5\u751F\u6210\u3002\u60A8\u53EF\u80FD\u5E0C\u671B\u5728 CI \u914D\u7F6E\u4E2D\u6307\u5B9A 'fetch-depth\uFF1A 0'\u3002"));
GithubAPI.printReleaseUrl(releaseUrl2);
utils.VipNodeJS.existErrorProcess();
}
await GithubAPI.createGithubRelease({
token: cliOptions.token,
repo: changelogConfig.repo,
baseUrlApi: changelogConfig.baseUrlApi,
name: changelogConfig.name || changelogConfig.to,
tag: changelogConfig.to,
content: markdown
});
} catch (e) {
utils.VipConsole.error(utils.VipColor.red(String(e)));
if (e?.stack)
utils.VipConsole.error(utils.VipColor.dim(e.stack?.split("\n").slice(1).join("\n")));
GithubAPI.printReleaseUrl(releaseUrl, false);
utils.VipNodeJS.existErrorProcess();
}
}
const ChangelogAPI = {
generateChangelogInfo,
upsertChangelogDoc,
changelogCoreHandler
};
exports.ChangelogAPI = ChangelogAPI;
exports.GitCommitAPI = GitCommitAPI;
exports.GitCommitMessageType = GitCommitMessageType;
exports.GithubAPI = GithubAPI;
exports.MarkdownAPI = MarkdownAPI;