UNPKG

@142vip/changelog

Version:

基于Git提交信息,生成变更记录,输出Markdown格式的日志文件

524 lines (514 loc) 18.7 kB
'use strict'; 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 = `&nbsp;-&nbsp; ${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(/&nbsp;/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;