// TODO: Refactor this action const { execSync } = require("child_process"); /** * Gets the value of an input. The value is also trimmed. * * @param name name of the input to get * @param options optional. See InputOptions. * @returns string */ function getInput(name, options) { const val = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`] || ""; if (options && options.required && !val) { throw new Error(`Input required and not supplied: ${name}`); } return val.trim(); } const START_FROM = getInput("from"); const END_TO = getInput("to"); const INCLUDE_COMMIT_BODY = getInput("include-commit-body") === "true"; const INCLUDE_ABBREVIATED_COMMIT = getInput("include-abbreviated-commit") === "true"; /** * @typedef {Object} ICommit * @property {string | undefined} abbreviated_commit * @property {string | undefined} subject * @property {string | undefined} body */ /** * @typedef {ICommit & {type: string | undefined, scope: string | undefined}} ICommitExtended */ /** * Any unique string that is guaranteed not to be used in committee text. * Used to split data in the commit line * @type {string} */ const commitInnerSeparator = "~~~~"; /** * Any unique string that is guaranteed not to be used in committee text. * Used to split each commit line * @type {string} */ const commitOuterSeparator = "₴₴₴₴"; /** * Commit data to be obtained. * @type {Map} * * @see https://git-scm.com/docs/git-log#Documentation/git-log.txt-emnem */ const commitDataMap = new Map([ ["subject", "%s"], // Required ]); if (INCLUDE_COMMIT_BODY) { commitDataMap.set("body", "%b"); } if (INCLUDE_ABBREVIATED_COMMIT) { commitDataMap.set("abbreviated_commit", "%h"); } /** * The type used to group commits that do not comply with the convention * @type {string} */ const fallbackType = "other"; /** * List of all desired commit groups and in what order to display them. * @type {string[]} */ const supportedTypes = [ "feat", "fix", "perf", "refactor", "style", "docs", "test", "build", "ci", "chore", "revert", "deps", fallbackType, ]; /** * @param {string} commitString * @returns {ICommit} */ function parseCommit(commitString) { /** @type {ICommit} */ const commitDataObj = {}; const commitDataArray = commitString .split(commitInnerSeparator) .map((s) => s.trim()); for (const [key] of commitDataMap) { commitDataObj[key] = commitDataArray.shift(); } return commitDataObj; } /** * Returns an array of commits since the last git tag * @return {ICommit[]} */ function getCommits() { const format = Array.from(commitDataMap.values()).join(commitInnerSeparator) + commitOuterSeparator; const logs = String( execSync( `git --no-pager log ${START_FROM}..${END_TO} --pretty=format:"${format}" --reverse` ) ); return logs .trim() .split(commitOuterSeparator) .filter((r) => !!r.trim()) // Skip empty lines .map(parseCommit); } /** * * @param {ICommit} commit * @return {ICommitExtended} */ function setCommitTypeAndScope(commit) { const matchRE = new RegExp( `^(?:(${supportedTypes.join("|")})(?:\\((\\S+)\\))?:)?(.*)`, "i" ); let [, type, scope, clearSubject] = commit.subject.match(matchRE); /** * Additional rules for checking committees that do not comply with the convention, but for which it is possible to determine the type. */ // Commits like `revert something` if (type === undefined && commit.subject.startsWith("revert")) { type = "revert"; } return { ...commit, type: (type || fallbackType).toLowerCase().trim(), scope: (scope || "").toLowerCase().trim(), subject: (clearSubject || commit.subject).trim(), }; } class CommitGroup { constructor() { this.scopes = new Map(); this.commits = []; } /** * * @param {ICommitExtended[]} array * @param {ICommitExtended} commit */ static _pushOrMerge(array, commit) { const similarCommit = array.find((c) => c.subject === commit.subject); if (similarCommit) { if (commit.abbreviated_commit !== undefined) { similarCommit.abbreviated_commit += `, ${commit.abbreviated_commit}`; } } else { array.push(commit); } } /** * @param {ICommitExtended} commit */ push(commit) { if (!commit.scope) { CommitGroup._pushOrMerge(this.commits, commit); return; } const scope = this.scopes.get(commit.scope) || { commits: [] }; CommitGroup._pushOrMerge(scope.commits, commit); this.scopes.set(commit.scope, scope); } get isEmpty() { return this.commits.length === 0 && this.scopes.size === 0; } } /** * Groups all commits by type and scopes * @param {ICommit[]} commits * @returns {Map} */ function getGroupedCommits(commits) { const parsedCommits = commits.map(setCommitTypeAndScope); const types = new Map(supportedTypes.map((id) => [id, new CommitGroup()])); for (const parsedCommit of parsedCommits) { const typeId = parsedCommit.type; const type = types.get(typeId); type.push(parsedCommit); } return types; } /** * Return markdown list with commits * @param {ICommitExtended[]} commits * @param {string} pad * @returns {string} */ function getCommitsList(commits, pad = "") { let changelog = ""; for (const commit of commits) { changelog += `${pad}- ${commit.subject}.`; if (commit.abbreviated_commit !== undefined) { changelog += ` (${commit.abbreviated_commit})`; } changelog += "\r\n"; if (commit.body === undefined) { continue; } const body = commit.body.replace("[skip ci]", "").trim(); if (body !== "") { changelog += `${body .split(/\r*\n+/) .filter((s) => !!s.trim()) .map((s) => `${pad} ${s}`) .join("\r\n")}${"\r\n"}`; } } return changelog; } function replaceHeader(str) { switch (str) { case "feat": return "New Features"; case "fix": return "Bug Fixes"; case "docs": return "Documentation Changes"; case "build": return "Build System"; case "chore": return "Chores"; case "ci": return "Continuous Integration"; case "refactor": return "Refactors"; case "style": return "Code Style Changes"; case "test": return "Tests"; case "perf": return "Performance improvements"; case "revert": return "Reverts"; case "deps": return "Dependency updates"; case "other": return "Other Changes"; default: return str; } } /** * Return markdown string with changelog * @param {Map} groups */ function getChangeLog(groups) { let changelog = ""; for (const [typeId, group] of groups) { if (group.isEmpty) { continue; } changelog += `### ${replaceHeader(typeId)}${"\r\n"}`; for (const [scopeId, scope] of group.scopes) { if (scope.commits.length) { changelog += `- #### ${replaceHeader(scopeId)}${"\r\n"}`; changelog += getCommitsList(scope.commits, " "); } } if (group.commits.length) { changelog += getCommitsList(group.commits); } changelog += "\r\n" + "\r\n"; } return changelog.trim(); } function escapeData(s) { return String(s) .replace(/%/g, "%25") .replace(/\r/g, "%0D") .replace(/\n/g, "%0A"); } try { const commits = getCommits(); const grouped = getGroupedCommits(commits); const changelog = getChangeLog(grouped); process.stdout.write( "::set-output name=release-note::" + escapeData(changelog) + "\r\n" ); // require('fs').writeFileSync('../CHANGELOG.md', changelog, {encoding: 'utf-8'}) } catch (e) { console.error(e); process.exit(1); }