diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..64980ed --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +.* +test +Gruntfile.js diff --git a/Gruntfile.js b/Gruntfile.js index ca99b3d..f3edc3e 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -19,17 +19,26 @@ module.exports = function (grunt) { tagName: 'v<%= version %>' } }, - changelog: { + + simplemocha: { options: { - dest: 'CHANGELOG.md' + ui: 'bdd', + reporter: 'dot' + }, + unit: { + src: [ + 'test/**/*.coffee' + ] } } }); grunt.loadNpmTasks('grunt-contrib-jshint'); grunt.loadNpmTasks('grunt-release'); + grunt.loadNpmTasks('grunt-simple-mocha'); grunt.loadTasks('tasks'); - grunt.registerTask('default', ['jshint']); + grunt.registerTask('default', ['jshint', 'test']); + grunt.registerTask('test', ['simplemocha']); }; diff --git a/lib/changelog.js b/lib/changelog.js new file mode 100755 index 0000000..9d779ce --- /dev/null +++ b/lib/changelog.js @@ -0,0 +1,232 @@ +// TODO(vojta): report errors, currently Q silence everything which really sucks +// TODO(vojta): use grunt logger +// TODO(vojta): nicer breaking changes (https://github.com/angular/angular.js/commit/07a58dd7669431d33b61f8c3213c31eff744d02a) +// TODO(vojta): ignore "Merge pull request..." messages, or ignore them in git log ? (--no-merges) + +var child = require('child_process'); +var util = require('util'); +var q = require('qq'); + + +var GIT_LOG_CMD = 'git log --grep="%s" -E --format=%s %s..HEAD'; +var GIT_TAG_CMD = 'git describe --tags --abbrev=0'; + +var EMPTY_COMPONENT = '$$'; +var MAX_SUBJECT_LENGTH = 80; + +var PATTERN = /^(\w*)(\(([\w\$\.\-\*]*)\))?\: (.*)$/; + +var warn = function() { + console.log('WARNING:', util.format.apply(null, arguments)); +}; + +var log = function() { + console.log(util.format.apply(null, arguments)); +}; + + +var parseRawCommit = function(raw) { + if (!raw) { + return null; + } + + var lines = raw.split('\n'); + var msg = {}, match; + + msg.hash = lines.shift(); + msg.subject = lines.shift(); + msg.closes = []; + msg.breaks = []; + + lines.forEach(function(line) { + match = line.match(/(?:Closes|Fixes)\s#(\d+)/); + if (match) { + msg.closes.push(parseInt(match[1], 10)); + } + }); + + match = raw.match(/BREAKING CHANGE:\s([\s\S]*)/); + if (match) { + msg.breaks.push(match[1]); + } + + + msg.body = lines.join('\n'); + match = msg.subject.match(PATTERN); + + if (!match || !match[1] || !match[4]) { + warn('Incorrect message: %s %s', msg.hash, msg.subject); + return null; + } + + if (match[4].length > MAX_SUBJECT_LENGTH) { + warn('Too long subject: %s %s', msg.hash, msg.subject); + match[4] = match[4].substr(0, MAX_SUBJECT_LENGTH); + } + + msg.type = match[1]; + msg.component = match[3]; + msg.subject = match[4]; + + return msg; +}; + + +var currentDate = function() { + var now = new Date(); + var pad = function(i) { + return ('0' + i).substr(-2); + }; + + return util.format('%d-%s-%s', now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())); +}; + + +var readGitLog = function(grep, from) { + log('Reading git log since', from); + + var deffered = q.defer(); + + child.exec(util.format(GIT_LOG_CMD, grep, '%H%n%s%n%b%n==END==', from), function(code, stdout) { + var commits = []; + + stdout.split('\n==END==\n').forEach(function(rawCommit) { + var commit = parseRawCommit(rawCommit); + if (commit) { + commits.push(commit); + } + }); + + log('Parsed %s commits', commits.length); + + deffered.resolve(commits); + }); + + return deffered.promise; +}; + + +var PATCH_HEADER_TPL = '\n### %s (%s)\n\n'; +var MINOR_HEADER_TPL = '\n## %s (%s)\n\n'; +var LINK_ISSUE = '[#%s](%s/issues/%s)'; +var LINK_COMMIT = '[%s](%s/commit/%s)'; + +var Writer = function(stream, githubRepo) { + + var linkToIssue = function(issue) { + return util.format(LINK_ISSUE, issue, githubRepo, issue); + }; + + var linkToCommit = function(hash) { + return util.format(LINK_COMMIT, hash.substr(0, 8), githubRepo, hash); + }; + + this.header = function(version) { + var header = version.split('.')[2] === '0' ? MINOR_HEADER_TPL : PATCH_HEADER_TPL; + stream.write(util.format(header, version, version, currentDate())); + }; + + this.section = function(title, section) { + var components = Object.getOwnPropertyNames(section).sort(); + + if (!components.length) { + return; + } + + stream.write(util.format('\n#### %s\n\n', title)); + + components.forEach(function(name) { + var prefix = '*'; + var nested = section[name].length > 1; + + if (name !== EMPTY_COMPONENT) { + if (nested) { + stream.write(util.format('* **%s:**\n', name)); + prefix = ' *'; + } else { + prefix = util.format('* **%s:**', name); + } + } + + section[name].forEach(function(commit) { + stream.write(util.format('%s %s (%s', prefix, commit.subject, linkToCommit(commit.hash))); + if (commit.closes.length) { + stream.write(', closes ' + commit.closes.map(linkToIssue).join(', ')); + } + stream.write(')\n'); + }); + }); + + stream.write('\n'); + }; +}; + +var writeChangelog = function(writer, commits, version) { + var sections = { + fix: {}, + feat: {}, + breaks: {} + }; + + commits.forEach(function(commit) { + var section = sections[commit.type]; + var component = commit.component || EMPTY_COMPONENT; + + if (section) { + section[component] = section[component] || []; + section[component].push(commit); + } + + commit.breaks.forEach(function(breakMsg) { + sections.breaks[EMPTY_COMPONENT] = sections.breaks[EMPTY_COMPONENT] || []; + + sections.breaks[EMPTY_COMPONENT].push({ + subject: breakMsg, + hash: commit.hash, + closes: [] + }); + }); + }); + + writer.header(version); + writer.section('Bug Fixes', sections.fix); + writer.section('Features', sections.feat); + writer.section('Breaking Changes', sections.breaks); +}; + + +var getPreviousTag = function() { + var deffered = q.defer(); + child.exec(GIT_TAG_CMD, function(code, stdout) { + if (code) { + deffered.reject('Cannot get the previous tag.'); + } + else { + deffered.resolve(stdout.replace('\n', '')); + } + }); + return deffered.promise; +}; + + +// PUBLIC API +exports.generate = function(githubRepo, version) { + var buffer = { + data: '', + write: function(str) { + this.data += str; + } + }; + var writer = new Writer(buffer, githubRepo); + + return getPreviousTag().then(function(tag) { + return readGitLog('^fix|^feat|BREAKING', tag).then(function(commits) { + writeChangelog(writer, commits, version); + return buffer.data; + }); + }); +}; + + +// publish for testing +exports.parseRawCommit = parseRawCommit; diff --git a/validate-commit-msg.js b/lib/validate-commit-msg.js old mode 100644 new mode 100755 similarity index 78% rename from validate-commit-msg.js rename to lib/validate-commit-msg.js index 2ab8962..2113841 --- a/validate-commit-msg.js +++ b/lib/validate-commit-msg.js @@ -10,11 +10,12 @@ */ var fs = require('fs'); var util = require('util'); +var path = require('path'); var MAX_LENGTH = 70; -var PATTERN = /^(?:fixup!\s*)?(\w*)(\(([\w\$\.\-\*/]*)\))?\: (.*)$/; -var IGNORED = /^WIP\:/; +var PATTERN = /^(\w*)(\(([\w\$\.\-\*]*)\))?\: (.*)$/; +var IGNORED = /^(WIP\:|Merge pull request)/; var TYPES = { feat: true, fix: true, @@ -22,14 +23,12 @@ var TYPES = { style: true, refactor: true, test: true, - chore: true, - revert: true + chore: true }; var error = function() { // gitx does not display it - // http://gitx.lighthouseapp.com/projects/17830/tickets/294-feature-display-hook-error-message-when-hook-fails // https://groups.google.com/group/gitx/browse_thread/thread/a03bcab60844b812 console.error('INVALID COMMIT MSG: ' + util.format.apply(null, arguments)); }; @@ -51,13 +50,11 @@ var validateMessage = function(message) { var match = PATTERN.exec(message); if (!match) { - error('does not match "(): " ! was: ' + message); + error('does not match "(): " !'); return false; } var type = match[1]; - var scope = match[3]; - var subject = match[4]; if (!TYPES.hasOwnProperty(type)) { error('"%s" is not allowed type !', type); @@ -82,14 +79,13 @@ var firstLineFromBuffer = function(buffer) { }; - // publish for testing exports.validateMessage = validateMessage; -// hacky start if not run by jasmine :-D -if (process.argv.join('').indexOf('jasmine-node') === -1) { +// lame test if run by git (so that it does not trigger during testing) +if (process.env.GIT_DIR) { var commitMsgFile = process.argv[2]; - var incorrectLogFile = commitMsgFile.replace('COMMIT_EDITMSG', 'logs/incorrect-commit-msgs'); + var incorrectLogFile = path.dirname(commitMsgFile) + '/logs/incorrect-commit-msgs'; fs.readFile(commitMsgFile, function(err, buffer) { var msg = firstLineFromBuffer(buffer); @@ -102,4 +98,4 @@ if (process.argv.join('').indexOf('jasmine-node') === -1) { process.exit(0); } }); -} \ No newline at end of file +} diff --git a/package.json b/package.json index d4e2ce6..39545a5 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,14 @@ "author": "Brian Ford", "license": "BSD", "dependencies": { - "shelljs": "~0.1.4" + "qq": "~0.3.5" }, "devDependencies": { "grunt": "~0.4.1", - "grunt-release": "~0.3.3" "grunt-contrib-jshint": "~0.6", + "grunt-release": "~0.3.3", + "grunt-simple-mocha": "~0.4.0", + "chai": "~1.7.2", + "sinon": "~1.7.3" } } diff --git a/tasks/changelog.js b/tasks/changelog.js index 2e513ac..5fe114d 100644 --- a/tasks/changelog.js +++ b/tasks/changelog.js @@ -1,182 +1,73 @@ -'use strict'; - -var fs = require('fs'); -var sh = require('shelljs'); - -module.exports = function (grunt) { - - // based on: https://github.com/angular-ui/bootstrap/commit/9a683eebccaeb6bfc742e6768562eb19ddd2216c - grunt.registerTask('changelog', 'generate a changelog from git metadata', function () { - - var done = grunt.task.current.async(); - - var options = this.options({ - // dest: 'CHANGELOG.md', - prepend: true, // false to append - enforce: false - }); - - var githubRepo; - var pkg = grunt.config('pkg'); - //If github repo isn't given, try to read it from the package file - if (!options.github && pkg) { - if (pkg.repository) { - githubRepo = pkg.repository.url; - } else if (pkg.homepage) { - //If it's a github page, but not a *.github.(io|com) page - if (pkg.homepage.indexOf('github.com') > -1 && - !pkg.homepage.match(/\.github\.(com|io)/)) { - githubRepo = pkg.homepage; - } +var exec = require('child_process').exec; +var changelog = require('../lib/changelog'); + + +// TODO: clean this up and write tests for it +var figureOutGithubRepo = function(githubRepo, pkg) { + + //If github repo isn't given, try to read it from the package file + if (!githubRepo && pkg) { + if (pkg.repository) { + githubRepo = pkg.repository.url; + } else if (pkg.homepage) { + //If it's a github page, but not a *.github.(io|com) page + if (pkg.homepage.indexOf('github.com') > -1 && + !pkg.homepage.match(/\.github\.(com|io)/)) { + githubRepo = pkg.homepage; } - } else { - githubRepo = options.github; } + } - // ensure commit convention, by using a commit hook - var gitHookFile = '.git/hooks/commit-msg'; - if(options.enforce) { - // if no commit hook is found, copy from template - if(!grunt.file.exists(gitHookFile)) { - grunt.file.copy(__dirname + '/../validate-commit-msg.js', gitHookFile); - // need to ensure the hook is executable - fs.chmodSync(gitHookFile, '0755'); - } - } + //User could set option eg 'github: "btford/grunt-conventional-changelog' + if (githubRepo.indexOf('github.com') === -1) { + githubRepo = 'http://github.com/' + githubRepo; + } - function fixGithubRepo(githubRepo) { - //User could set option eg 'github: "btford/grunt-conventional-changelog' - if (githubRepo.indexOf('github.com') === -1) { - githubRepo = 'http://github.com/' + githubRepo; - } - return githubRepo + githubRepo = githubRepo .replace(/^git:\/\//, 'http://') //get rid of git:// - .replace(/\/$/, '') //get rid of trailing slash + .replace(/\/$/, '') //get rid of trailing slash .replace(/\.git$/, ''); //get rid of trailing .git - } - if (githubRepo) { - githubRepo = fixGithubRepo(githubRepo); - } - - var template; - if (options.templateFile) { - template = grunt.file.read(options.templateFile); - } else { - template = fs.readFileSync(__dirname + '/../template/changelog.md', 'utf8'); - } - //If args are given, generate changelog from arg commits - var gitArgs = [ 'log', '--format=%H%n%s%n%b%n==END==' ]; - if (this.args.length > 0) { - var changeFrom = this.args[0], changeTo = this.args[1] || 'HEAD'; - gitArgs.push(changeFrom + '..' + changeTo); - - //Else, generate changelog from last tag to HEAD - } else { - //Based on: https://github.com/angular/angular.js/blob/master/changelog.js#L184 - //Use tags to find the last commit - var tagResult = sh.exec('git describe --tags --abbrev=0'); - if (tagResult.code !== 0) { - return done(true); - } - var lastTag = tagResult.output.trim(); - gitArgs.push(lastTag + '..HEAD'); - } - - //Run git to get the log we need - var logResult = sh.exec('git ' + gitArgs.join(' ')); - if (logResult.code !== 0) { - return done(false); - } - var gitlog = logResult.output.split('\n==END==\n').reverse(); - makeChangelog(gitlog); - - function makeChangelog(gitlog) { - - var changelog = {}; + return githubRepo; +}; - // based on: https://github.com/angular/angular.js/blob/master/changelog.js#L50 - // see also: https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/ - var COMMIT_MSG_REGEXP = /^(.*)\((.*)\)\:\s(.*)$/; - var BREAKING_CHANGE_REGEXP = /BREAKING CHANGE:([\s\S]*)/; - function addChange(changeType, changeScope, sha1, changeMessage) { - if (!changelog[changeType]) { - changelog[changeType] = {}; - } - if (!changelog[changeType][changeScope]) { - changelog[changeType][changeScope] = []; - } - changelog[changeType][changeScope].push({ - sha1: sha1, - msg: changeMessage - }); - } +module.exports = function (grunt) { - gitlog.forEach(function (logItem) { - var lines = logItem.split('\n'); - var sha1 = lines.shift().substr(0,8); //Only need first 7 chars - var subject = lines.shift(); + var DESC = 'Generate a changelog from git metadata.'; + grunt.registerTask('changelog', DESC, function () { - if (!subject) { - return; //this is a bad commit message - } + var done = this.async(); + var options = this.options({ + dest: 'CHANGELOG.md', + prepend: true, // false to append + github: null, // default from package.json + version: null, // default value from package.json + editor: null // 'sublime -w' + }); - var changeMatch, - changeType, - changeScope, - changeMessage; - if ( (changeMatch = subject.match(COMMIT_MSG_REGEXP)) ) { - //if it conforms to the changelog style - changeType = changeMatch[1]; - changeScope = changeMatch[2]; - changeMessage = changeMatch[3]; - } else { - //otherwise - changeType = changeScope = 'other'; - changeMessage = subject; - } + var pkg = grunt.config('pkg') || grunt.file.readJSON('package.json'); + var githubRepo = figureOutGithubRepo(options.github, pkg); + var newVersion = options.version || pkg.version; - addChange(changeType, changeScope, sha1, changeMessage); - var breakingMatch = logItem.match(BREAKING_CHANGE_REGEXP); - if (breakingMatch) { - var breakingMessage = breakingMatch[1]; - addChange('breaking', changeScope, sha1, breakingMessage); - } - }); + // generate changelog + changelog.generate(githubRepo, 'v' + newVersion).then(function(data) { + grunt.file.write(options.dest, data + grunt.file.read(options.dest)); - var newLog = grunt.template.process(template, { - data: { - changelog: changelog, - today: grunt.template.today('yyyy-mm-dd'), - version: options.version || grunt.config('pkg.version'), - helpers: { - //Generates a commit link if we have a repo, else it generates a plain text commit sha1 - commitLink: function(commit) { - if (githubRepo) { - return '[' + commit + '](' + githubRepo + '/commit/' + commit + ')'; - } else { - return commit; - } - } + if (options.editor) { + exec(options.editor + ' ' + options.dest, function(err) { + if (err) { + return grunt.fatal('Can not generate changelog.'); } - } - }); - if (options.dest) { - var log = grunt.file.exists(options.dest) ? - grunt.file.read(options.dest) : ''; - if (options.prepend) { - log = newLog + log; - } else { - log += newLog; - } - grunt.file.write(options.dest, log); + grunt.log.ok(options.dest + ' updated'); + done(); + }); } else { - console.log(newLog); + grunt.log.ok(options.dest + ' updated'); + done(); } - done(); - } + }); }); }; diff --git a/template/changelog.md b/template/changelog.md deleted file mode 100644 index 1688d9d..0000000 --- a/template/changelog.md +++ /dev/null @@ -1,22 +0,0 @@ -# <%= version%> (<%= today%>) - -<% if (_(changelog.feat).size() > 0) { %>## Features -<% _(changelog.feat).forEach(function(changes, scope) { %>### <%= scope%> -<% changes.forEach(function(change) { %> -* <%= change.msg%> (<%= helpers.commitLink(change.sha1) %>) -<% }) %> -<% }) %><% } %> - -<% if (_(changelog.fix).size() > 0) { %>## Bug fixes -<% _(changelog.fix).forEach(function(changes, scope) { %>### <%= scope%> -<% changes.forEach(function(change) { %> -* <%= change.msg%> (<%= helpers.commitLink(change.sha1) %>) -<% }) %> -<% }) %><% } %> - -<% if (_(changelog.breaking).size() > 0) { %>## Breaking Changes -<% _(changelog.breaking).forEach(function(changes, scope) { %>### <%= scope%> -<% changes.forEach(function(change) { %> - <%= change.msg%> -<% }) %> -<% }) %><% } %> diff --git a/test/lib/changelog.spec.coffee b/test/lib/changelog.spec.coffee new file mode 100644 index 0000000..3b433db --- /dev/null +++ b/test/lib/changelog.spec.coffee @@ -0,0 +1,49 @@ +describe 'changelog', -> + ch = require '../../lib/changelog' + + describe 'parseRawCommit', -> + + it 'should parse raw commit', -> + msg = ch.parseRawCommit( + '9b1aff905b638aa274a5fc8f88662df446d374bd\n' + + 'feat(scope): broadcast $destroy event on scope destruction\n' + + 'perf testing shows that in chrome this change adds 5-15% overhead\n' + + 'when destroying 10k nested scopes where each scope has a $destroy listener\n') + + expect(msg.type).to.equal 'feat' + expect(msg.component).to.equal 'scope' + expect(msg.hash).to.equal '9b1aff905b638aa274a5fc8f88662df446d374bd' + expect(msg.subject).to.equal 'broadcast $destroy event on scope destruction' + expect(msg.body).to.equal( + 'perf testing shows that in chrome this change adds 5-15% overhead\n' + + 'when destroying 10k nested scopes where each scope has a $destroy listener\n') + + + it 'should parse closed issues', -> + msg = ch.parseRawCommit( + '13f31602f396bc269076ab4d389cfd8ca94b20ba\n' + + 'feat(ng-list): Allow custom separator\n' + + 'bla bla bla\n\n' + + 'Closes #123\nCloses #25\nFixes #33\n') + + expect(msg.closes).to.deep.equal [123, 25, 33] + + + it 'should parse breaking changes', -> + msg = ch.parseRawCommit( + '13f31602f396bc269076ab4d389cfd8ca94b20ba\n' + + 'feat(ng-list): Allow custom separator\n' + + 'bla bla bla\n\n' + + 'BREAKING CHANGE: some breaking change\n') + + expect(msg.breaks).to.deep.equal ['some breaking change\n'] + + + it 'should parse a msg without scope', -> + msg = ch.parseRawCommit( + '13f31602f396bc269076ab4d389cfd8ca94b20ba\n' + + 'chore: some chore bullshit\n' + + 'bla bla bla\n\n' + + 'BREAKING CHANGE: some breaking change\n') + + expect(msg.type).to.equal 'chore' diff --git a/test/lib/validate-commit-msg.spec.coffee b/test/lib/validate-commit-msg.spec.coffee new file mode 100644 index 0000000..0e2a852 --- /dev/null +++ b/test/lib/validate-commit-msg.spec.coffee @@ -0,0 +1,65 @@ +describe 'validate-commit-msg', -> + m = require '../../lib/validate-commit-msg' + errors = [] + + VALID = true + INVALID = false + + + beforeEach -> + errors.length = 0 + sinon.stub console, 'error', (msg) -> + errors.push(msg.replace /\x1B\[\d+m/g, '') # uncolor + sinon.stub console, 'log' + + + describe 'validateMessage', -> + + it 'should be valid', -> + expect(m.validateMessage 'fix($compile): something').to.equal VALID + expect(m.validateMessage 'feat($location): something').to.equal VALID + expect(m.validateMessage 'docs($filter): something').to.equal VALID + expect(m.validateMessage 'style($http): something').to.equal VALID + expect(m.validateMessage 'refactor($httpBackend): something').to.equal VALID + expect(m.validateMessage 'test($resource): something').to.equal VALID + expect(m.validateMessage 'chore($controller): something').to.equal VALID + expect(m.validateMessage 'chore(foo-bar): something').to.equal VALID + expect(m.validateMessage 'chore(*): something').to.equal VALID + expect(errors).to.deep.equal [] + + + it 'should validate 70 characters length', -> + msg = 'fix($compile): something super mega extra giga tera long, maybe even longer... ' + + 'way over 80 characters' + + expect(m.validateMessage msg ).to.equal INVALID + expect(errors).to.deep.equal ['INVALID COMMIT MSG: is longer than 70 characters !'] + + + it 'should validate "(): " format', -> + msg = 'not correct format' + + expect(m.validateMessage msg).to.equal INVALID + expect(errors).to.deep.equal [ + 'INVALID COMMIT MSG: does not match "(): " !' + ] + + + it 'should validate type', -> + expect(m.validateMessage 'weird($filter): something').to.equal INVALID + expect(errors).to.deep.equal ['INVALID COMMIT MSG: "weird" is not allowed type !'] + + + it 'should allow empty scope', -> + expect(m.validateMessage 'fix: blablabla').to.equal VALID + + + it 'should allow dot in scope', -> + expect(m.validateMessage 'chore(mocks.$httpBackend): something').to.equal VALID + + + it 'should ignore msg prefixed with "WIP: "', -> + expect(m.validateMessage 'WIP: bullshit').to.equal VALID + + it 'should ignore "Merging PR" messages', -> + expect(m.validateMessage 'Merge pull request #333 from ahaurw01/feature').to.equal VALID diff --git a/test/mocha-globals.coffee b/test/mocha-globals.coffee new file mode 100644 index 0000000..2823e28 --- /dev/null +++ b/test/mocha-globals.coffee @@ -0,0 +1,12 @@ +sinon = require 'sinon' +chai = require 'chai' + +global.expect = chai.expect +global.sinon = null + + +beforeEach -> + global.sinon = sinon.sandbox.create() + +afterEach -> + global.sinon.restore()