diff --git a/.babelrc b/.babelrc index c13c5f62..831f20a8 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,4 @@ { - "presets": ["es2015"] + "presets": ["es2015"], + "plugins": ["transform-object-rest-spread"] } diff --git a/.eslintrc b/.eslintrc index 94609be5..021e0bba 100644 --- a/.eslintrc +++ b/.eslintrc @@ -35,6 +35,8 @@ "indent": ["error", 2] }, "env": { + "es6": true, + "jest": true, "node": true } } diff --git a/.gitignore b/.gitignore index 19281fb7..0c672457 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ lib node_modules *.swp +coverage +.changelog diff --git a/.npmignore b/.npmignore index 85de9cf9..13e057ff 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,2 @@ src +**/__mocks__/** diff --git a/README.md b/README.md index 69846c44..22360883 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,28 @@ You'll need a GitHub API [personal access token](https://github.com/settings/tok - `repo`: Your "org/repo" on GitHub - `cacheDir` [optional]: A place to stash GitHub API responses to avoid throttling - `labels`: GitHub issue/PR labels mapped to changelog section headers +- `ignoreCommitters` [optional]: list of commiters to ignore (exact or partial match). Useful for example to ignore commits from bot agents + +## CLI + +```bash +$ lerna-changelog +Usage: lerna-changelog [options] + +Options: + --tag-from A git tag that determines the lower bound of the range of commits + (defaults to last available) [string] + --tag-to A git tag that determines the upper bound of the range of commits + [string] + --version Show version number [boolean] + --help Show help [boolean] + +Examples: + lerna-changelog create a changelog for the changes + after the latest available tag + lerna-changelog --tag-from 0.1.0 create a changelog for the changes + --tag-to 0.3.0 in all tags within the given range +``` [lerna-homepage]: https://lernajs.io [hzoo-profile]: https://github.com/hzoo diff --git a/cli.js b/cli.js index bbc19a56..841e66ab 100755 --- a/cli.js +++ b/cli.js @@ -1,13 +1,35 @@ #!/usr/bin/env node var chalk = require("chalk"); -var lib = require("."); +var Changelog = require(".").Changelog; +var ConfigurationError = require(".").ConfigurationError; -var Changelog = lib.Changelog; -var ConfigurationError = lib.ConfigurationError; +var argv = require("yargs") + .usage("Usage: lerna-changelog [options]") + .options({ + "tag-from": { + type: "string", + desc: "A git tag that determines the lower bound of the range of commits (defaults to last available)" + }, + "tag-to": { + type: "string", + desc: "A git tag that determines the upper bound of the range of commits" + } + }) + .example( + "lerna-changelog", + "create a changelog for the changes after the latest available tag" + ) + .example( + "lerna-changelog --tag-from 0.1.0 --tag-to 0.3.0", + "create a changelog for the changes in all tags within the given range" + ) + .version() + .help() + .argv; try { - console.log((new Changelog()).createMarkdown()); + console.log((new Changelog(argv)).createMarkdown()); } catch (e) { if (e instanceof ConfigurationError) { console.log(chalk.red(e.message)); diff --git a/package.json b/package.json index 29311b21..47b3938a 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,10 @@ "lerna-changelog": "cli.js" }, "scripts": { - "build": "babel src -d lib", + "build": "npm run clean && babel src --out-dir lib --ignore src/__mocks__", "clean": "rimraf lib", - "test": "eslint index.js cli.js src/", + "lint": "eslint index.js cli.js src", + "test": "jest", "prepublish": "npm run build" }, "repository": { @@ -27,16 +28,22 @@ }, "homepage": "https://github.com/lerna/lerna-changelog#readme", "devDependencies": { - "babel-cli": "^6.9.0", - "babel-preset-es2015": "^6.9.0", - "eslint": "^2.10.2", - "rimraf": "^2.5.2" + "babel-cli": "^6.18.0", + "babel-eslint": "^7.1.1", + "babel-jest": "^18.0.0", + "babel-plugin-transform-object-rest-spread": "6.20.2", + "babel-preset-es2015": "^6.18.0", + "eslint": "^3.13.1", + "jest": "^18.1.0", + "lerna": "^2.0.0-beta.32", + "rimraf": "^2.5.4" }, "peerDependencies": { "lerna": "^2.0.0-beta.8" }, "dependencies": { "chalk": "^1.1.3", - "mkdirp": "^0.5.1" + "mkdirp": "^0.5.1", + "yargs": "^6.6.0" } } diff --git a/src/Changelog.js b/src/Changelog.js index 07085b3c..aa2e37e3 100644 --- a/src/Changelog.js +++ b/src/Changelog.js @@ -4,10 +4,17 @@ import RemoteRepo from "./RemoteRepo"; import execSync from "./execSync"; import ConfigurationError from "./ConfigurationError"; +const UNRELEASED_TAG = "___unreleased___"; +const COMMIT_FIX_REGEX = /(fix|close|resolve)(e?s|e?d)? [T#](\d+)/i; + export default class Changelog { - constructor(config) { + constructor(options = {}) { this.config = this.getConfig(); this.remote = new RemoteRepo(this.config); + + // CLI options + this.tagFrom = options["tag-from"]; + this.tagTo = options["tag-to"]; } getConfig() { @@ -28,89 +35,115 @@ export default class Changelog { } createMarkdown() { - const commitInfo = this.getCommitInfo(); - const committers = this.getCommitters(commitInfo); - const commitsByCategory = this.getCommitsByCategory(commitInfo); - const fixesRegex = /(fix|close|resolve)(e?s|e?d)? [T#](\d+)/i; - - let date = new Date().toISOString(); - - date = date.slice(0, date.indexOf("T")); - let markdown = "\n"; - markdown += "## Unreleased (" + date + ")"; - - progressBar.init(commitsByCategory.length); - - commitsByCategory.filter(category => { - return category.commits.length > 0; - }).forEach(category => { - progressBar.tick(category.heading); - - const commitsByPackage = {}; - - category.commits.forEach(commit => { - - // Array of unique packages. - var changedPackages = Object.keys( - execSync("git show -m --name-only --pretty='format:' --first-parent " + commit.commitSHA) - // turn into an array - .split("\n") - // extract base package name, and stuff into an object for deduping. - .reduce(function(obj, files) { - if (files.indexOf("packages/") === 0) { - obj[files.slice(9).split("/", 1)[0]] = true; - } - return obj; - }, {}) - ); - - const heading = changedPackages.length > 0 - ?"* "+changedPackages.map(pkg => "`" + pkg + "`").join(", ") - :"* Other"; // No changes to packages, but still relevant. - - if (!commitsByPackage[heading]) { - commitsByPackage[heading] = []; - } - - commitsByPackage[heading].push(commit); - }); + // Get all info about commits in a certain tags range + const commitsInfo = this.getCommitsInfo(); + const commitsByTag = this.getCommitsByTag(commitsInfo); - markdown += "\n"; - markdown += "\n"; - markdown += "#### " + category.heading; - - Object.keys(commitsByPackage).forEach(heading => { - markdown += "\n"+heading; - - commitsByPackage[heading].forEach(commit => { - - markdown += "\n * "; - - if (commit.number) { - var prUrl = this.remote.getBasePullRequestUrl() + commit.number; - markdown += "[#" + commit.number + "](" + prUrl + ") "; - } + Object.keys(commitsByTag).forEach(tag => { + const commitsForTag = commitsByTag[tag].commits; + const commitsByCategory = this.getCommitsByCategory(commitsForTag); + const committers = this.getCommitters(commitsForTag); + // Skip this iteration if there are no commits available for the tag + const hasCommitsForCurrentTag = commitsByCategory.some( + category => category.commits.length > 0 + ); + if (!hasCommitsForCurrentTag) return; + + const releaseTitle = tag === UNRELEASED_TAG ? "Unreleased" : tag; + markdown += "## " + releaseTitle + " (" + commitsByTag[tag].date + ")"; + + progressBar.init(commitsByCategory.length); + + commitsByCategory + .filter(category => category.commits.length > 0) + .forEach(category => { + progressBar.tick(category.heading); + + const commitsByPackage = category.commits.reduce( + (acc, commit) => { + // Array of unique packages. + const changedPackages = + this.getListOfUniquePackages(commit.commitSHA); + + const heading = changedPackages.length > 0 + ? "* " + changedPackages.map(pkg => "`" + pkg + "`").join(", ") + : "* Other"; + // No changes to packages, but still relevant. + const existingCommitsForHeading = acc[heading] || []; + return { + ...acc, + [heading]: existingCommitsForHeading.concat(commit) + }; + }, + {} + ); + + markdown += "\n"; + markdown += "\n"; + markdown += "#### " + category.heading; + + Object.keys(commitsByPackage).forEach(heading => { + markdown += "\n" + heading; + + commitsByPackage[heading].forEach(commit => { + markdown += "\n * "; + + if (commit.number) { + const prUrl = this.remote.getBasePullRequestUrl() + + commit.number; + markdown += "[#" + commit.number + "](" + prUrl + ") "; + } + + if (commit.title.match(COMMIT_FIX_REGEX)) { + commit.title = commit.title.replace( + COMMIT_FIX_REGEX, + "Closes [#$3](" + this.remote.getBaseIssueUrl() + "$3)" + ); + } + + markdown += commit.title + "." + " ([@" + commit.user.login + + "](" + + commit.user.html_url + + "))"; + }); + }); + }); - if (commit.title.match(fixesRegex)) { - commit.title = commit.title.replace(fixesRegex, "Closes [#$3](" + this.remote.getBaseIssueUrl() + "$3)"); - } + progressBar.terminate(); - markdown += commit.title + "." + " ([@" + commit.user.login + "](" + commit.user.html_url + "))"; - }); - }); + markdown += "\n\n#### Committers: " + committers.length + "\n"; + markdown += committers.map(commiter => "- " + commiter).join("\n"); + markdown += "\n\n\n"; }); - progressBar.terminate(); + return markdown.substring(0, markdown.length - 3); + } - markdown += "\n\n#### Committers: " + committers.length + "\n"; - markdown += committers.map(function(commiter) { - return "- " + commiter; - }).join("\n"); + getListOfUniquePackages(sha) { + return Object.keys( + // turn into an array + execSync( + "git show -m --name-only --pretty='format:' --first-parent " + sha + ) + .split("\n") + .reduce((acc, files) => { + if (files.indexOf("packages/") === 0) { + acc[files.slice(9).split("/", 1)[0]] = true; + } + return acc; + }, {}) + ); + } - return markdown; + getListOfTags() { + const tags = execSync("git tag"); + if (tags) { + return tags.split("\n"); + } + return []; } getLastTag() { @@ -118,17 +151,37 @@ export default class Changelog { } getListOfCommits() { - var lastTag = this.getLastTag(); - var commits = execSync("git log --oneline " + lastTag + "..").split("\n"); - return commits; + // Determine the tags range to get the commits for. Custom from/to can be + // provided via command-line options. + // Default is "from last tag". + const tagFrom = this.tagFrom || this.getLastTag(); + const tagTo = this.tagTo || ""; + const tagsRange = tagFrom + ".." + tagTo; + const commits = execSync( + // Prints ";;;" + // This format is used in `getCommitsInfo` for easily analize the commit. + 'git log --oneline --pretty="%h;%D;%s;%cd" --date=short ' + tagsRange + ); + if (commits) { + return commits.split("\n"); + } + return []; } getCommitters(commits) { - var committers = {} + const committers = {}; commits.forEach(commit => { const login = (commit.user||{}).login; - if (login && !committers[login]){ + // If a list of `ignoreCommitters` is provided in the lerna.json config + // check if the current committer should be kept or not. + const shouldKeepCommiter = login && ( + !this.config.ignoreCommitters || + !this.config.ignoreCommitters.some( + c => c === login || login.indexOf(c) > -1 + ) + ); + if (login && shouldKeepCommiter && !committers[login]) { const user = this.remote.getUserData(login); const userNameAndLink = `[${login}](${user.html_url})`; if (user.name) { @@ -142,74 +195,134 @@ export default class Changelog { return Object.keys(committers).map(k => committers[k]).sort(); } - getCommitInfo() { + getCommitsInfo() { const commits = this.getListOfCommits(); + const allTags = this.getListOfTags(); progressBar.init(commits.length); - var logs = commits.map(commit => { + const commitsInfo = commits.map(commit => { + // commit is formatted as following: + // ;;; + const parts = commit.split(";"); + const sha = parts[0]; + const _refs = parts[1]; + let tagsInCommit; + if (_refs.length > 1) { + // Since there might be multiple tags referenced by the same commit, + // we need to treat all of them as a list. + tagsInCommit = allTags.reduce((acc, tag) => { + if (_refs.indexOf(tag) < 0) + return acc + return acc.concat(tag) + }, []); + } + const message = parts[2]; + const date = parts[3]; - var sha = commit.slice(0, 7); - var message = commit.slice(8); - var response; progressBar.tick(sha); - var mergeCommit = message.match(/\(#(\d+)\)$/); + const mergeCommit = message.match(/\(#(\d+)\)$/); - if (message.indexOf("Merge pull request ") === 0) { - var start = message.indexOf("#") + 1; - var end = message.slice(start).indexOf(" "); - var issueNumber = message.slice(start, start + end); + const commitInfo = { + commitSHA: sha, + message: message, + // Note: Only merge commits or commits referencing an issue / PR + // will be kept in the changelog. + labels: [], + tags: tagsInCommit, + date + }; - response = this.remote.getIssueData(issueNumber); - response.commitSHA = sha; - response.mergeMessage = message; - return response; - } else if (mergeCommit) { - var issueNumber = mergeCommit[1]; - response = this.remote.getIssueData(issueNumber); + if (message.indexOf("Merge pull request ") === 0 || mergeCommit) { + let issueNumber; + if (message.indexOf("Merge pull request ") === 0) { + const start = message.indexOf("#") + 1; + const end = message.slice(start).indexOf(" "); + issueNumber = message.slice(start, start + end); + } else + issueNumber = mergeCommit[1]; + + const response = this.remote.getIssueData(issueNumber); response.commitSHA = sha; response.mergeMessage = message; - return response; + Object.assign(commitInfo, response); } - return { - commitSHA: sha, - message: message, - labels: [] - }; + return commitInfo; }); progressBar.terminate(); - return logs; + return commitsInfo; } - getCommitsByCategory(logs) { - var categories = this.remote.getLabels().map(label => { - var commits = []; + getCommitsByTag(commits) { + // Analyze the commits and group them by tag. + // This is useful to generate multiple release logs in case there are + // multiple release tags. + let currentTags = [UNRELEASED_TAG]; + return commits.reduce((acc, commit) => { + if (commit.tags) { + currentTags = commit.tags; + } - logs.forEach(function(log) { - var labels = log.labels.map(function(label) { - return label.name.toLowerCase(); - }); + // Tags referenced by commits are treated as a list. When grouping them, + // we split the commits referenced by multiple tags in their own group. + // This results in having one group of commits for each tag, even if + // the same commits are "duplicated" across the different tags + // referencing them. + const commitsForTags = currentTags.reduce((acc2, currentTag) => { + let existingCommitsForTag = []; + if ({}.hasOwnProperty.call(acc, currentTag)) { + existingCommitsForTag = acc[currentTag].commits; + } - if (labels.indexOf(label.toLowerCase()) >= 0) { - commits.push(log); + let releaseDate = this.getToday(); + if (currentTag !== UNRELEASED_TAG) { + releaseDate = acc[currentTag] ? acc[currentTag].date : commit.date; } - }); + + return { + ...acc2, + [currentTag]: { + date: releaseDate, + commits: existingCommitsForTag.concat(commit) + } + } + }, {}) + return { - heading: this.remote.getHeadingForLabel(label), - commits: commits + ...acc, + ...commitsForTags, }; - }); + }, {}); + } - return categories; + getCommitsByCategory(commits) { + return this.remote.getLabels().map( + label => ({ + heading: this.remote.getHeadingForLabel(label), + // Keep only the commits that have a matching label with the one + // provided in the lerna.json config. + commits: commits.reduce( + (acc, commit) => { + if ( + commit.labels.some( + l => l.name.toLowerCase() === label.toLowerCase() + ) + ) + return acc.concat(commit); + return acc; + }, + [] + ) + }) + ); } -} -function toTitleCase(str) { - return str.replace(/\w\S*/g, function(text){ - return text.charAt(0).toUpperCase() + text.substr(1).toLowerCase(); - }); + getToday() { + const date = new Date().toISOString(); + return date.slice(0, date.indexOf("T")); + } } diff --git a/src/GithubAPI.js b/src/GithubAPI.js index 27fbbad4..9fa69d84 100644 --- a/src/GithubAPI.js +++ b/src/GithubAPI.js @@ -7,12 +7,16 @@ export default class GithubAPI { const {repo} = config; this.repo = repo; this.cache = new ApiDataCache('github', config); - this.auth = process.env.GITHUB_AUTH; + this.auth = this.getAuthToken(); if (!this.auth) { throw new ConfigurationError("Must provide GITHUB_AUTH"); } } + getAuthToken() { + return process.env.GITHUB_AUTH; + } + getIssueData(issue) { return this._get('issue', issue); } @@ -36,6 +40,6 @@ export default class GithubAPI { user : `/users/${key}` }[type]; const url = "https://api.github.com" + path; - return execSync("curl -H 'Authorization: token " + process.env.GITHUB_AUTH + "' --silent " + url) + return execSync("curl -H 'Authorization: token " + process.env.GITHUB_AUTH + "' --silent --globoff " + url) } } diff --git a/src/__mocks__/ApiDataCache.js b/src/__mocks__/ApiDataCache.js new file mode 100644 index 00000000..d36c20fc --- /dev/null +++ b/src/__mocks__/ApiDataCache.js @@ -0,0 +1,15 @@ +let localCache; +export function __resetDefaults() { + localCache = undefined; +} +export function __setCache(cache) { + localCache = cache; +} + +class MockedApiDataCache { + get(type, key) { + return JSON.stringify(localCache[type][key]); + } +} + +export default MockedApiDataCache; diff --git a/src/__mocks__/Changelog.js b/src/__mocks__/Changelog.js new file mode 100644 index 00000000..7f8a3e90 --- /dev/null +++ b/src/__mocks__/Changelog.js @@ -0,0 +1,37 @@ +const Changelog = require.requireActual("../Changelog").default; + +const defaultConfig = { + rootPath: "../", + repo: "lerna/lerna-changelog", + labels: { + "Type: New Feature": ":rocket: New Feature", + "Type: Breaking Change": ":boom: Breaking Change", + "Type: Bug": ":bug: Bug Fix", + "Type: Enhancement": ":nail_care: Enhancement", + "Type: Documentation": ":memo: Documentation", + "Type: Maintenance": ":house: Maintenance" + }, + cacheDir: ".changelog" +}; + +let currentConfig = defaultConfig; +export function __resetDefaults() { + currentConfig = defaultConfig; +} +export function __getConfig() { + return currentConfig; +} +export function __setConfig(customConfig) { + currentConfig = { ...defaultConfig, ...customConfig }; +} + +class MockedChangelog extends Changelog { + getConfig() { + return currentConfig; + } + getToday() { + return "2099-01-01" + } +} + +export default MockedChangelog; diff --git a/src/__mocks__/GithubAPI.js b/src/__mocks__/GithubAPI.js new file mode 100644 index 00000000..4111bd86 --- /dev/null +++ b/src/__mocks__/GithubAPI.js @@ -0,0 +1,9 @@ +const GithubAPI = require.requireActual("../GithubAPI").default; + +class MockedGithubAPI extends GithubAPI { + getAuthToken() { + return "123"; + } +} + +export default MockedGithubAPI; diff --git a/src/__mocks__/execSync.js b/src/__mocks__/execSync.js new file mode 100644 index 00000000..0a714d68 --- /dev/null +++ b/src/__mocks__/execSync.js @@ -0,0 +1,37 @@ +let gitShowResult +let gitDescribeResult +let gitLogResult +let gitTagResult +export function __resetDefaults () { + gitShowResult = undefined + gitDescribeResult = undefined + gitLogResult = undefined + gitTagResult = undefined +} +export function __mockGitShow (result) { + gitShowResult = result; +} +export function __mockGitDescribe (result) { + gitDescribeResult = result; +} +export function __mockGitLog (result) { + gitLogResult = result; +} +export function __mockGitTag (result) { + gitTagResult = result; +} +export default function execSync(cmd) { + if (cmd.indexOf("git show") === 0) { + const sha = cmd.split("--first-parent")[1].trim(); + return gitShowResult[sha]; + } else if (cmd.indexOf("git describe") === 0) + return gitDescribeResult; + else if (cmd.indexOf("git log") === 0) + return gitLogResult; + else if (cmd.indexOf("git tag") === 0) + return gitTagResult; + else + throw new Error( + "Unknown exec command: " + cmd + ". Please make sure to mock it." + ); +} diff --git a/test/Changelog.spec.js b/test/Changelog.spec.js new file mode 100644 index 00000000..a024689d --- /dev/null +++ b/test/Changelog.spec.js @@ -0,0 +1,233 @@ +jest.mock("lerna/lib/Repository"); +jest.mock("lerna/lib/progressBar"); +jest.mock("../src/ApiDataCache"); +jest.mock("../src/Changelog"); +jest.mock("../src/GithubAPI"); +jest.mock("../src/execSync"); + +describe("contructor", () => { + const MockedChangelog = require("../src/Changelog").default; + const testConfig = require("../src/Changelog").__getConfig(); + + beforeEach(() => { + require("../src/Changelog").__resetDefaults(); + }) + + it("set config", () => { + const changelog = new MockedChangelog(); + expect(changelog.config).toEqual(testConfig); + }); + it("set remote", () => { + const changelog = new MockedChangelog(); + expect(changelog.remote).toBeDefined(); + }); + it("set cli options", () => { + const changelog = new MockedChangelog({ "tag-from": "1", "tag-to": "2" }); + expect(changelog.tagFrom).toBe("1"); + expect(changelog.tagTo).toBe("2"); + }); +}); + +describe("getCommitsInfo", () => { + beforeEach(() => { + require("../src/execSync").__resetDefaults(); + require("../src/ApiDataCache").__resetDefaults(); + }) + require("../src/execSync").__mockGitLog( + "a0000005;HEAD -> master, tag: v0.2.0, origin/master, origin/HEAD;chore(release): releasing component;2017-01-01\n" + + "a0000004;;Merge pull request #2 from my-feature;2017-01-01\n" + + "a0000003;;feat(module) Add new module (#2);2017-01-01\n" + + "a0000002;;refactor(module) Simplify implementation;2017-01-01\n" + + "a0000001;tag: v0.1.0;chore(release): releasing component;2017-01-01" + ); + require("../src/execSync").__mockGitTag( + "v0.2.0\n" + + "v0.1.1\n" + + "v0.1.0\n" + + "v0.0.1" + ); + const usersCache = { + "test-user": { + login: "test-user", + html_url: "https://github.com/test-user", + name: "Test User" + }, + }; + const issuesCache = { + 2: { + number: 2, + title: "This is the commit title for the issue (#2)", + labels: [ + { name: "Type: New Feature" }, + { name: "Status: In Progress" }, + ], + user: usersCache["test-user"], + } + }; + require("../src/ApiDataCache").__setCache({ + user: usersCache, + issue: issuesCache, + }); + const MockedChangelog = require("../src/Changelog").default; + const changelog = new MockedChangelog(); + const commitsInfo = changelog.getCommitsInfo(); + + it("parse commits with different tags", () => { + expect(commitsInfo).toEqual([ + { + commitSHA: "a0000005", + date: "2017-01-01", + labels: [], + message: "chore(release): releasing component", + tags: ["v0.2.0"], + }, + { + commitSHA: "a0000004", + date: "2017-01-01", + labels: [ + { name: "Type: New Feature" }, + { name: "Status: In Progress" }, + ], + mergeMessage: "Merge pull request #2 from my-feature", + message: "Merge pull request #2 from my-feature", + number: 2, + tags: undefined, + title: "This is the commit title for the issue (#2)", + user: { + html_url: "https://github.com/test-user", + login: "test-user", + name: "Test User", + }, + }, + { + commitSHA: "a0000003", + date: "2017-01-01", + labels: [ + { name: "Type: New Feature" }, + { name: "Status: In Progress" }, + ], + mergeMessage: "feat(module) Add new module (#2)", + message: "feat(module) Add new module (#2)", + number: 2, + tags: undefined, + title: "This is the commit title for the issue (#2)", + user: { + html_url: "https://github.com/test-user", + login: "test-user", + name: "Test User", + }, + }, + { + commitSHA: "a0000002", + date: "2017-01-01", + labels: [], + message: "refactor(module) Simplify implementation", + tags: undefined, + }, + { + commitSHA: "a0000001", + date: "2017-01-01", + labels: [], + message: "chore(release): releasing component", + tags: ["v0.1.0"], + }, + ]); + }); +}); + +describe("getCommitsByCategory", () => { + const MockedChangelog = require("../src/Changelog").default; + const changelog = new MockedChangelog(); + const testCommits = [ + { commitSHA: "a0000005", labels: [{ name: "Status: In Progress" }] }, + { commitSHA: "a0000004", labels: [{ name: "Type: Bug" }] }, + { + commitSHA: "a0000003", + labels: [ + { name: "Type: New Feature" }, + { name: "Status: In Progress" }, + ] + }, + { commitSHA: "a0000002", labels: [] }, + { commitSHA: "a0000001", labels: [] } + ] + const commitsByCategory = changelog.getCommitsByCategory(testCommits); + + it("group commits by category", () => { + expect(commitsByCategory).toEqual([ + { + commits: [ + { + commitSHA: "a0000003", + labels: [ + { name: "Type: New Feature" }, + { name: "Status: In Progress" } + ] + } + ], + heading: ":rocket: New Feature" + }, + { commits: [], heading: ":boom: Breaking Change" }, + { + commits: [ + { commitSHA: "a0000004", labels: [{ name: "Type: Bug" }] } + ], + heading: ":bug: Bug Fix" + }, + { commits: [], heading: ":nail_care: Enhancement" }, + { commits: [], heading: ":memo: Documentation" }, + { commits: [], heading: ":house: Maintenance" } + ]); + }) +}) + +describe("getCommitters", () => { + beforeEach(() => { + require("../src/ApiDataCache").__resetDefaults(); + }) + + const usersCache = { + "test-user": { + login: "test-user", + html_url: "https://github.com/test-user", + name: "Test User" + }, + "test-user-1": { + login: "test-user-1", + html_url: "https://github.com/test-user-1", + name: "Test User 1" + }, + "test-user-2": { + login: "test-user-2", + html_url: "https://github.com/test-user-2", + name: "Test User 2" + }, + "user-bot": { + login: "user-bot", + html_url: "https://github.com/user-bot", + name: "User Bot" + }, + }; + require("../src/ApiDataCache").__setCache({ + user: usersCache, + issue: {}, + }); + require("../src/Changelog").__setConfig({ ignoreCommitters: ["user-bot"] }); + const MockedChangelog = require("../src/Changelog").default; + const changelog = new MockedChangelog(); + + const testCommits = [ + { commitSHA: "a0000004", user: { login: "test-user-1" } }, + { commitSHA: "a0000003", user: { login: "test-user-2" } }, + { commitSHA: "a0000002", user: { login: "user-bot" } }, + { commitSHA: "a0000001" }, + ] + const committers = changelog.getCommitters(testCommits); + + it("get list of valid commiters", () => { + expect(committers).toEqual([ + "Test User 1 ([test-user-1](https://github.com/test-user-1))", + "Test User 2 ([test-user-2](https://github.com/test-user-2))" + ]); + }) +}) diff --git a/test/functional/__snapshots__/markdown-full.spec.js.snap b/test/functional/__snapshots__/markdown-full.spec.js.snap new file mode 100644 index 00000000..e9d787b8 --- /dev/null +++ b/test/functional/__snapshots__/markdown-full.spec.js.snap @@ -0,0 +1,96 @@ +exports[`createMarkdown multiple tags outputs correct changelog 1`] = ` +" +## a-new-hope@4.0.0 (1977-05-25) + +#### :rocket: New Feature +* \`a-new-hope\` + * [#1](https://github.com/lerna/lerna-changelog/pull/1) feat: May the force be with you. ([@luke](https://github.com/luke)) + +#### Committers: 1 +- Luke Skywalker ([luke](https://github.com/luke)) + + +## empire-strikes-back@5.0.0 (1977-05-25) + +#### :rocket: New Feature +* \`a-new-hope\` + * [#1](https://github.com/lerna/lerna-changelog/pull/1) feat: May the force be with you. ([@luke](https://github.com/luke)) + +#### Committers: 1 +- Luke Skywalker ([luke](https://github.com/luke)) + + +## return-of-the-jedi@6.0.0 (1977-05-25) + +#### :rocket: New Feature +* \`a-new-hope\` + * [#1](https://github.com/lerna/lerna-changelog/pull/1) feat: May the force be with you. ([@luke](https://github.com/luke)) + +#### Committers: 1 +- Luke Skywalker ([luke](https://github.com/luke))" +`; + +exports[`createMarkdown single tags outputs correct changelog 1`] = ` +" +## Unreleased (2099-01-01) + +#### :rocket: New Feature +* \`the-force-awakens\`, \`rogue-one\` + * [#7](https://github.com/lerna/lerna-changelog/pull/7) feat: that is not how the Force works!. ([@han-solo](https://github.com/han-solo)) + +#### :nail_care: Enhancement +* \`the-force-awakens\`, \`rogue-one\` + * [#7](https://github.com/lerna/lerna-changelog/pull/7) feat: that is not how the Force works!. ([@han-solo](https://github.com/han-solo)) + +#### Committers: 1 +- Han Solo ([han-solo](https://github.com/han-solo)) + + +## v6.0.0 (1983-05-25) + +#### :rocket: New Feature +* \`return-of-the-jedi\` + * [#5](https://github.com/lerna/lerna-changelog/pull/5) feat: I am your father. ([@vader](https://github.com/vader)) + +#### :bug: Bug Fix +* \`return-of-the-jedi\` + * [#4](https://github.com/lerna/lerna-changelog/pull/4) fix: RRRAARRWHHGWWR. ([@chewbacca](https://github.com/chewbacca)) + +#### :nail_care: Enhancement +* \`return-of-the-jedi\` + * [#6](https://github.com/lerna/lerna-changelog/pull/6) refactor: he is my brother. ([@princess-leia](https://github.com/princess-leia)) + +#### :house: Maintenance +* \`return-of-the-jedi\` + * [#4](https://github.com/lerna/lerna-changelog/pull/4) fix: RRRAARRWHHGWWR. ([@chewbacca](https://github.com/chewbacca)) + +#### Committers: 3 +- Chwebacca ([chewbacca](https://github.com/chewbacca)) +- Darth Vader ([vader](https://github.com/vader)) +- Princess Leia Organa ([princess-leia](https://github.com/princess-leia)) + + +## v5.0.0 (1980-05-17) + +#### :boom: Breaking Change +* \`empire-strikes-back\` + * [#2](https://github.com/lerna/lerna-changelog/pull/2) chore: Terminate her... immediately!. ([@gtarkin](https://github.com/gtarkin)) + +#### :bug: Bug Fix +* \`empire-strikes-back\` + * [#3](https://github.com/lerna/lerna-changelog/pull/3) fix: Get me the rebels base!. ([@vader](https://github.com/vader)) + +#### Committers: 2 +- Darth Vader ([vader](https://github.com/vader)) +- Governor Tarkin ([gtarkin](https://github.com/gtarkin)) + + +## v4.0.0 (1977-05-25) + +#### :rocket: New Feature +* \`a-new-hope\` + * [#1](https://github.com/lerna/lerna-changelog/pull/1) feat: May the force be with you. ([@luke](https://github.com/luke)) + +#### Committers: 1 +- Luke Skywalker ([luke](https://github.com/luke))" +`; diff --git a/test/functional/markdown-full.spec.js b/test/functional/markdown-full.spec.js new file mode 100644 index 00000000..2d834022 --- /dev/null +++ b/test/functional/markdown-full.spec.js @@ -0,0 +1,218 @@ +jest.mock("lerna/lib/Repository"); +jest.mock("lerna/lib/progressBar"); +jest.mock("../../src/ApiDataCache"); +jest.mock("../../src/Changelog"); +jest.mock("../../src/GithubAPI"); +jest.mock("../../src/execSync"); + +const listOfCommits = + "a0000015;;chore: making of episode viii;2015-12-18\n" + + "a0000014;;feat: infiltration (#7);2015-12-18\n" + + "a0000013;HEAD -> master, tag: v6.0.0, origin/master, origin/HEAD;chore(release): releasing component;1983-05-25\n" + + "a0000012;;Merge pull request #6 from return-of-the-jedi;1983-05-25\n" + + "a0000011;;feat: I am your father (#5);1983-05-25\n" + + "a0000010;;fix(han-solo): unfreezes (#4);1983-05-25\n" + + "a0000009;tag: v5.0.0;chore(release): releasing component;1980-05-17\n" + + "a0000008;;Merge pull request #3 from empire-strikes-back;1980-05-17\n" + + "a0000007;;fix: destroy rebels base;1980-05-17\n" + + "a0000006;;chore: the end of Alderaan (#2);1980-05-17\n" + + "a0000005;;refactor(death-star): add deflector shield;1980-05-17\n" + + "a0000004;tag: v4.0.0;chore(release): releasing component;1977-05-25\n" + + "a0000003;;Merge pull request #1 from star-wars;1977-05-25\n" + + "a0000002;tag: v0.1.0;chore(release): releasing component;1966-01-01\n" + + "a0000001;;fix: some random fix which will be ignored;1966-01-01"; + +const listOfTags = + "v6.0.0\n" + + "v5.0.0\n" + + "v4.0.0\n" + + "v3.0.0\n" + + "v2.0.0\n" + + "v1.0.0\n" + + "v0.1.0"; + +const listOfPackagesForEachCommit = { + a0000001: "packages/random/foo.js", + a0000002: "packages/random/package.json", + a0000003: "packages/a-new-hope/rebels.js", + a0000004: "packages/a-new-hope/package.json", + a0000005: "packages/empire-strikes-back/death-star.js", + a0000006: "packages/empire-strikes-back/death-star.js", + a0000007: "packages/empire-strikes-back/hoth.js", + a0000008: "packages/empire-strikes-back/hoth.js", + a0000009: "packages/empire-strikes-back/package.json", + a0000010: "packages/return-of-the-jedi/jabba-the-hutt.js", + a0000011: "packages/return-of-the-jedi/vader-luke.js", + a0000012: "packages/return-of-the-jedi/leia.js", + a0000013: "packages/return-of-the-jedi/package.json", + a0000014: + "packages/the-force-awakens/mission.js\n" + + "packages/rogue-one/mission.js", + a0000015: "packages/untitled/script.md", +}; + +const usersCache = { + luke: { + login: "luke", + html_url: "https://github.com/luke", + name: "Luke Skywalker" + }, + "princess-leia": { + login: "princess-leia", + html_url: "https://github.com/princess-leia", + name: "Princess Leia Organa" + }, + vader: { + login: "vader", + html_url: "https://github.com/vader", + name: "Darth Vader" + }, + gtarkin: { + login: "gtarkin", + html_url: "https://github.com/gtarkin", + name: "Governor Tarkin" + }, + "han-solo": { + login: "han-solo", + html_url: "https://github.com/han-solo", + name: "Han Solo" + }, + chewbacca: { + login: "chewbacca", + html_url: "https://github.com/chewbacca", + name: "Chwebacca" + }, + "rd-d2": { + login: "rd-d2", + html_url: "https://github.com/rd-d2", + name: "R2-D2" + }, + "c-3po": { + login: "c-3po", + html_url: "https://github.com/c-3po", + name: "C-3PO" + } +}; +const issuesCache = { + 1: { + number: 1, + title: "feat: May the force be with you", + labels: [ + { name: "Type: New Feature" }, + ], + user: usersCache.luke, + }, + 2: { + number: 2, + title: "chore: Terminate her... immediately!", + labels: [ + { name: "Type: Breaking Change" }, + ], + user: usersCache.gtarkin, + }, + 3: { + number: 3, + title: "fix: Get me the rebels base!", + labels: [ + { name: "Type: Bug" }, + ], + user: usersCache.vader, + }, + 4: { + number: 4, + title: "fix: RRRAARRWHHGWWR", + labels: [ + { name: "Type: Bug" }, + { name: "Type: Maintenance" }, + ], + user: usersCache.chewbacca, + }, + 5: { + number: 5, + title: "feat: I am your father", + labels: [ + { name: "Type: New Feature" }, + ], + user: usersCache.vader, + }, + 6: { + number: 6, + title: "refactor: he is my brother", + labels: [ + { name: "Type: Enhancement" }, + ], + user: usersCache["princess-leia"], + }, + 7: { + number: 7, + title: "feat: that is not how the Force works!", + labels: [ + { name: "Type: New Feature" }, + { name: "Type: Enhancement" }, + ], + user: usersCache["han-solo"], + }, +}; + + +describe.only("createMarkdown", () => { + beforeEach(() => { + require("../../src/execSync").__resetDefaults(); + require("../../src/ApiDataCache").__resetDefaults(); + }) + + describe("single tags", () => { + require("../../src/execSync").__mockGitShow(listOfPackagesForEachCommit); + require("../../src/execSync").__mockGitDescribe("v8.0.0"); + require("../../src/execSync").__mockGitLog(listOfCommits); + require("../../src/execSync").__mockGitTag(listOfTags); + require("../../src/ApiDataCache").__setCache({ + user: usersCache, + issue: issuesCache, + }) + const MockedChangelog = require("../../src/Changelog").default; + const changelog = new MockedChangelog(); + + const markdown = changelog.createMarkdown({ + "tag-from": "v4.0.0", + "tag-to": undefined, + }); + it("outputs correct changelog", () => { + expect(markdown).toMatchSnapshot() + }) + }) + + describe("multiple tags", () => { + require("../../src/execSync").__mockGitShow(listOfPackagesForEachCommit); + require("../../src/execSync").__mockGitDescribe("v8.0.0"); + require("../../src/execSync").__mockGitLog( + "a0000004;tag: a-new-hope@4.0.0, tag: empire-strikes-back@5.0.0, tag: return-of-the-jedi@6.0.0;chore(release): releasing component;1977-05-25\n" + + "a0000003;;Merge pull request #1 from star-wars;1977-05-25\n" + + "a0000002;tag: v0.1.0;chore(release): releasing component;1966-01-01\n" + + "a0000001;;fix: some random fix which will be ignored;1966-01-01" + ); + require("../../src/execSync").__mockGitTag( + "a-new-hope@4.0.0\n" + + "attack-of-the-clones@3.1.0\n" + + "empire-strikes-back@5.0.0\n" + + "return-of-the-jedi@6.0.0\n" + + "revenge-of-the-sith@3.0.0\n" + + "the-force-awakens@7.0.0\n" + + "the-phantom-menace@1.0.0" + ); + require("../../src/ApiDataCache").__setCache({ + user: usersCache, + issue: issuesCache, + }) + const MockedChangelog = require("../../src/Changelog").default; + const changelog = new MockedChangelog(); + + const markdown = changelog.createMarkdown({ + "tag-from": "v0.1.0", + "tag-to": undefined, + }); + it("outputs correct changelog", () => { + expect(markdown).toMatchSnapshot() + }) + }) +})