diff --git a/package.json b/package.json index 81544e9a2..d863b648d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "karma-phantomjs-launcher": "*", "karma-firefox-launcher": "*", "karma-junit-reporter": "0.1.0", + "jasmine-node": "*", + "qq": "*", "jasmine-node": "*" } } diff --git a/scripts/changelog/changelog.js b/scripts/changelog/changelog.js new file mode 100755 index 000000000..2177100a3 --- /dev/null +++ b/scripts/changelog/changelog.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node + +// TODO(vojta): pre-commit hook for validating messages +// TODO(vojta): report errors, currently Q silence everything which really sucks + +var child = require('child_process'); +var fs = require('fs'); +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 HEADER_TPL = '\n# %s (%s)\n\n'; +var LINK_ISSUE = '[#%s](https://github.com/angular/angular.dart/issues/%s)'; +var LINK_COMMIT = '[%s](https://github.com/angular/angular.dart/commit/%s)'; + +var EMPTY_COMPONENT = '$$'; + + +var warn = function() { + console.log('WARNING:', 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])); + }); + + match = raw.match(/BREAKING CHANGE:([\s\S]*)/); + if (match) { + msg.breaking = match[1]; + } + + + msg.body = lines.join('\n'); + match = msg.subject.match(/^(.*)\((.*)\)\:\s(.*)$/); + + if (!match || !match[1] || !match[3]) { + warn('Incorrect message: %s %s', msg.hash, msg.subject); + return null; + } + + msg.type = match[1]; + msg.component = match[2]; + msg.subject = match[3]; + + return msg; +}; + + +var linkToIssue = function(issue) { + return util.format(LINK_ISSUE, issue, issue); +}; + + +var linkToCommit = function(hash) { + return util.format(LINK_COMMIT, hash.substr(0, 8), hash); +}; + + +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 printSection = function(stream, title, section, printCommitLinks) { + printCommitLinks = printCommitLinks === undefined ? true : printCommitLinks; + 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) { + if (printCommitLinks) { + stream.write(util.format('%s %s\n (%s', prefix, commit.subject, linkToCommit(commit.hash))); + if (commit.closes.length) { + stream.write(',\n ' + commit.closes.map(linkToIssue).join(', ')); + } + stream.write(')\n'); + } else { + stream.write(util.format('%s %s', prefix, commit.subject)); + } + }); + }); + + stream.write('\n'); +}; + + +var readGitLog = function(grep, from) { + var deferred = q.defer(); + + // TODO(vojta): if it's slow, use spawn and stream it instead + child.exec(util.format(GIT_LOG_CMD, grep, '%H%n%s%n%b%n==END==', from), function(code, stdout, stderr) { + var commits = []; + + stdout.split('\n==END==\n').forEach(function(rawCommit) { + var commit = parseRawCommit(rawCommit); + if (commit) commits.push(commit); + }); + + deferred.resolve(commits); + }); + + return deferred.promise; +}; + + +var writeChangelog = function(stream, commits, version) { + var sections = { + fix: {}, + feat: {}, + perf: {}, + breaks: {} + }; + + sections.breaks[EMPTY_COMPONENT] = []; + + 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); + } + + if (commit.breaking) { + sections.breaks[component] = sections.breaks[component] || []; + sections.breaks[component].push({ + subject: util.format("due to %s,\n %s", linkToCommit(commit.hash), commit.breaking), + hash: commit.hash, + closes: [] + }); + }; + }); + + stream.write(util.format(HEADER_TPL, version, version, currentDate())); + printSection(stream, 'Bug Fixes', sections.fix); + printSection(stream, 'Features', sections.feat); + printSection(stream, 'Performance Improvements', sections.perf); + printSection(stream, 'Breaking Changes', sections.breaks, false); +} + + +var getPreviousTag = function() { + var deferred = q.defer(); + child.exec(GIT_TAG_CMD, function(code, stdout, stderr) { + if (code) deferred.reject('Cannot get the previous tag.'); + else deferred.resolve(stdout.replace('\n', '')); + }); + return deferred.promise; +}; + + +var generate = function(version, file) { + getPreviousTag().then(function(tag) { + console.log('Reading git log since', tag); + readGitLog('^fix|^feat|^perf|BREAKING', tag).then(function(commits) { + console.log('Parsed', commits.length, 'commits'); + console.log('Generating changelog to', file || 'stdout', '(', version, ')'); + writeChangelog(file ? fs.createWriteStream(file) : process.stdout, commits, version); + }); + }); +}; + + +// publish for testing +exports.parseRawCommit = parseRawCommit; + +// hacky start if not run by jasmine :-D +if (process.argv.join('').indexOf('jasmine-node') === -1) { + generate(process.argv[2], process.argv[3]); +} diff --git a/scripts/changelog/changelog.spec.js b/scripts/changelog/changelog.spec.js new file mode 100644 index 000000000..8543dc210 --- /dev/null +++ b/scripts/changelog/changelog.spec.js @@ -0,0 +1,43 @@ +describe('changelog.js', function() { + var ch = require('changelog.js'); + + describe('parseRawCommit', function() { + it('should parse raw commit', function() { + var 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).toBe('feat'); + expect(msg.hash).toBe('9b1aff905b638aa274a5fc8f88662df446d374bd'); + expect(msg.subject).toBe('broadcast $destroy event on scope destruction'); + expect(msg.body).toBe('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.component).toBe('scope'); + }); + + + it('should parse closed issues', function() { + var msg = ch.parseRawCommit( + '13f31602f396bc269076ab4d389cfd8ca94b20ba\n' + + 'feat(ng-list): Allow custom separator\n' + + 'bla bla bla\n\n' + + 'Closes #123\nCloses #25\n'); + + expect(msg.closes).toEqual([123, 25]); + }); + + + it('should parse breaking changes', function() { + var msg = ch.parseRawCommit( + '13f31602f396bc269076ab4d389cfd8ca94b20ba\n' + + 'feat(ng-list): Allow custom separator\n' + + 'bla bla bla\n\n' + + 'BREAKING CHANGE: first breaking change\nsomething else\n' + + 'another line with more info\n'); + + expect(msg.breaking).toEqual(' first breaking change\nsomething else\nanother line with more info\n'); + }); + }); +}); diff --git a/scripts/travis/build.sh b/scripts/travis/build.sh index fa9e77353..9e22b96f4 100755 --- a/scripts/travis/build.sh +++ b/scripts/travis/build.sh @@ -6,6 +6,8 @@ set -e ./scripts/generate-expressions.sh ./scripts/analyze.sh +./node_modules/jasmine-node/bin/jasmine-node ./scripts/changelog/ + ./node_modules/jasmine-node/bin/jasmine-node playback_middleware/spec/ && node "node_modules/karma/bin/karma" start karma.conf \ --reporters=junit,dots --port=8765 --runner-port=8766 \