├── .gitignore ├── package.json ├── LICENSE.md ├── .jshintrc ├── commit-stream.js ├── README.md └── changelog-maker.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "changelog-maker", 3 | "version": "1.2.6", 4 | "description": "A git log to CHANGELOG.md tool", 5 | "main": "changelog-maker.js", 6 | "bin": { 7 | "changelog-maker": "./changelog-maker.js" 8 | }, 9 | "author": "Rod (http://r.va.gg/)", 10 | "license": "MIT", 11 | "dependencies": { 12 | "after": "~0.8.1", 13 | "bl": "~0.9.4", 14 | "chalk": "~0.5.1", 15 | "ghauth": "~2.0.0", 16 | "ghissues": "~1.0.0", 17 | "list-stream": "~1.0.0", 18 | "minimist": "~1.1.0", 19 | "split2": "~0.2.1", 20 | "strip-ansi": "~2.0.1", 21 | "through2": "~0.6.3" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/rvagg/changelog-maker.git" 26 | }, 27 | "preferGlobal": true 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright (c) 2015 Rod Vagg 5 | --------------------------- 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ ] 3 | , "bitwise": false 4 | , "camelcase": false 5 | , "curly": false 6 | , "eqeqeq": false 7 | , "forin": false 8 | , "immed": false 9 | , "latedef": false 10 | , "noarg": true 11 | , "noempty": true 12 | , "nonew": true 13 | , "plusplus": false 14 | , "quotmark": true 15 | , "regexp": false 16 | , "undef": true 17 | , "unused": true 18 | , "strict": false 19 | , "trailing": true 20 | , "maxlen": 120 21 | , "asi": true 22 | , "boss": true 23 | , "debug": true 24 | , "eqnull": true 25 | , "esnext": true 26 | , "evil": true 27 | , "expr": true 28 | , "funcscope": false 29 | , "globalstrict": false 30 | , "iterator": false 31 | , "lastsemic": true 32 | , "laxbreak": true 33 | , "laxcomma": true 34 | , "loopfunc": true 35 | , "multistr": false 36 | , "onecase": false 37 | , "proto": false 38 | , "regexdash": false 39 | , "scripturl": true 40 | , "smarttabs": false 41 | , "shadow": false 42 | , "sub": true 43 | , "supernew": false 44 | , "validthis": true 45 | , "browser": true 46 | , "couch": false 47 | , "devel": false 48 | , "dojo": false 49 | , "mootools": false 50 | , "node": true 51 | , "nonstandard": true 52 | , "prototypejs": false 53 | , "rhino": false 54 | , "worker": true 55 | , "wsh": false 56 | , "nomen": false 57 | , "onevar": false 58 | , "passfail": false 59 | } -------------------------------------------------------------------------------- /commit-stream.js: -------------------------------------------------------------------------------- 1 | const through2 = require('through2') 2 | , stripAnsi = require('strip-ansi') 3 | 4 | 5 | module.exports = commitStream 6 | 7 | 8 | function commitStream (ghUser, ghProject) { 9 | var commit 10 | 11 | return through2.obj(onLine, onEnd) 12 | 13 | function addLine (line) { 14 | line = stripAnsi(line) 15 | 16 | if (!line) 17 | return 18 | 19 | var old, m 20 | 21 | if (/^commit \w+$/.test(line)) { 22 | old = commit 23 | commit = { 24 | sha: line.split(' ')[1] 25 | } 26 | } else if (m = line.match(/^Author: ([^<]+) <([^>]+)>$/)) { 27 | if (!commit) 28 | throw new Error('wut?') 29 | commit.author = { name: m[1], email: m[2] } 30 | } else if (m = line.match(/^\s+Reviewed[- ]?By:?\s*([^<]+) <([^>]+)>\s*$/i)) { 31 | if (!commit.reviewers) 32 | commit.reviewers = [] 33 | commit.reviewers.push({ name: m[1], email: m[2] }) 34 | } else if (m = line.match(/^\s+PR(?:[- ]?URL)?:?\s*(.+)\s*$/i)) { 35 | commit.prUrl = m[1] 36 | if (m = commit.prUrl.match(/^\s*#?(\d+)\s*$/)) 37 | commit.prUrl = `https://github.com/${ghUser}/${ghProject}/pull/${m[1]}` 38 | if (m = commit.prUrl.match(/^(https?:\/\/.+\/([^\/]+)\/([^\/]+))\/\w+\/(\d+)$/i)) { 39 | commit.ghIssue = +m[4] 40 | commit.ghUser = m[2] 41 | commit.ghProject = m[3] 42 | } 43 | } else if (/^ /.test(line) && (line = line.trim()).length) { 44 | if (!commit.summary) { 45 | commit.summary = line 46 | } else { 47 | if (!commit.description) 48 | commit.description = [] 49 | commit.description.push(line) 50 | } 51 | } 52 | 53 | return old 54 | } 55 | 56 | function onLine (line, _, callback) { 57 | var commit = addLine(line) 58 | if (commit) 59 | this.push(commit) 60 | callback() 61 | } 62 | 63 | function onEnd (callback) { 64 | this.push(commit) 65 | callback() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # changelog-maker 2 | 3 | **A git log to CHANGELOG.md tool** 4 | 5 | [![npm](https://nodei.co/npm/changelog-maker.png?downloads=true&downloadRank=true)](https://nodei.co/npm/changelog-maker/) 6 | [![npm](https://nodei.co/npm-dl/changelog-maker.png?months=6&height=3)](https://nodei.co/npm/changelog-maker/) 7 | 8 | ***Note: changelog-maker can currently only be run with [io.js](https://iojs.org/) installed.*** 9 | 10 | ## Eh? 11 | 12 | **changelog-maker** is a formalisation of the [io.js](https://github.com/iojs/io.js) CHANGELOG.md entry process but flexible enough to be used on other repositories. 13 | 14 | **changelog-maker** will look at the git log of the current directory, pulling entries since the last tag. Commits with just a version number in the summary are removed, as are commits prior to, and including summaries that say `working on ` (this is an io.js / Node ism). 15 | 16 | After collecting the list of commits, any that have `PR-URL: ` in them are looked up on GitHub and the labels of the pull request are collected, specifically looking for labels that start with `semver` (the assumption is that `semver-minor`, `semver-major` labels are used to indicate non-patch version bumps). 17 | 18 | Finally, the list is formatted as Markdown and printed to stdout. 19 | 20 | Each commit will come out something like this (on one line): 21 | 22 | ``` 23 | * [[`20f8e7f17a`](https://github.com/iojs/io.js/commit/20f8e7f17a)] - 24 | **test**: remove flaky test functionality (Rod Vagg) 25 | [#812](https://github.com/iojs/io.js/pull/812) 26 | ``` 27 | 28 | Note: 29 | 30 | * When running `changelog-maker` on the command-line, the default GitHub repo is iojs/io.js, you can change this by supplying the user/org as the first argument and project as the second. e.g `changelog-maker joyent/node`. 31 | * Commit links will go to the assumed repo (default: iojs/io.js) 32 | * If a commit summary starts with a word, followed by a `:`, this is treated as a special label and rendered in bold 33 | * Commits that have `semver*` labels on the pull request referred to in their `PR-URL` have those labels printed out at the start of the summary, in bold, upper cased. 34 | * Pull request URLs come from the `PR-URL` data, if it matches the assumed repo (default: iojs/io.js) then just a `#` followed by the number, if another repo then a full `user/project#number`. 35 | 36 | When printing to a console some special behaviours are invoked: 37 | 38 | * Commits with a summary that starts with `doc:` are rendered in grey 39 | * Commits that have a `semver*` label on the pull request referred to in their `PR-URL` are rendered in bold green 40 | 41 | ## Usage 42 | 43 | **`changelog-maker [--simple] [--group] [--start-ref=] [--end-ref=] [github-user[, github-project]]`** 44 | 45 | * `github-user` and `github-project` should point to the GitHub repository that can be used to find the `PR-URL` data if just an issue number is provided and will also impact how the PR-URL issue numbers are displayed 46 | * `--simple` will print a simple form, not with additional Markdown cruft 47 | * `--group` will reorder commits so that they are listed in groups where the `xyz:` prefix of the commit message defines the group. Commits are listed in original order _within_ group. 48 | * `--start-ref=` will use the given git `` as a starting point rather than the _last tag_. The `` can be anything commit-ish including a commit sha, tag, branch name. If you specify a `--start-ref` argument the commit log will not be pruned so that version commits and `working on ` commits are left in the list. 49 | * `--end-ref=` will use the given git `` as a end-point rather than the _now_. The `` can be anything commit-ish including a commit sha, tag, branch name. 50 | 51 | ## License 52 | 53 | **changelog-maker** is Copyright (c) 2015 Rod Vagg [@rvagg](https://twitter.com/rvagg) and licenced under the MIT licence. All rights not explicitly granted in the MIT license are reserved. See the included LICENSE.md file for more details. 54 | -------------------------------------------------------------------------------- /changelog-maker.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const gitcmd = 'git log --pretty=full --since="{{sincecmd}}" --until="{{untilcmd}}"' 4 | , commitdatecmd = '$(git show -s --format=%ad `{{refcmd}}`)' 5 | , untilcmd = '' 6 | , refcmd = 'git rev-list --max-count=1 {{ref}}' 7 | , defaultRef = '--tags' 8 | 9 | 10 | const spawn = require('child_process').spawn 11 | , bl = require('bl') 12 | , split2 = require('split2') 13 | , list = require('list-stream') 14 | , after = require('after') 15 | , ghauth = require('ghauth') 16 | , ghissues = require('ghissues') 17 | , chalk = require('chalk') 18 | , argv = require('minimist')(process.argv.slice(2)) 19 | 20 | , commitStream = require('./commit-stream') 21 | 22 | , ghUser = argv._[0] || 'iojs' 23 | , ghProject = argv._[1] || 'io.js' 24 | , authOptions = { 25 | configName : 'changelog-maker' 26 | , scopes : [] 27 | } 28 | 29 | 30 | function replace (s, m) { 31 | Object.keys(m).forEach(function (k) { 32 | s = s.replace(new RegExp('\\{\\{' + k + '\\}\\}', 'g'), m[k]) 33 | }) 34 | return s 35 | } 36 | 37 | 38 | function organiseCommits (list) { 39 | if (argv['start-ref']) 40 | return list 41 | 42 | // filter commits to those _before_ 'working on ...' 43 | var started = false 44 | return list.filter(function (commit) { 45 | if (started) 46 | return false 47 | 48 | if ((/working on v?[\d\.]+/i).test(commit.summary)) 49 | started = true 50 | else if ((/^v?[\d\.]+$/).test(commit.summary)) 51 | started = true 52 | 53 | return !started 54 | }) 55 | } 56 | 57 | 58 | function commitTags (list, callback) { 59 | var sublist = list.filter(function (commit) { 60 | return typeof commit.ghIssue == 'number' && commit.ghUser && commit.ghProject 61 | }) 62 | 63 | if (!sublist.length) 64 | return setImmediate(callback) 65 | 66 | ghauth(authOptions, function (err, authData) { 67 | if (err) 68 | return callback(err) 69 | 70 | var done = after(sublist.length, callback) 71 | 72 | sublist.forEach(function (commit) { 73 | function onFetch (err, issue) { 74 | if (err) { 75 | console.error(`Error fetching issue #${commit.ghIssue}: ${err.message}`) 76 | return done() 77 | } 78 | 79 | if (issue.labels) 80 | commit.labels = issue.labels.map(function (label) { return label.name }) 81 | done() 82 | } 83 | 84 | ghissues.get(authData, commit.ghUser, commit.ghProject, commit.ghIssue, onFetch) 85 | }) 86 | }) 87 | } 88 | 89 | 90 | function commitToGroup (commit) { 91 | return (/^[\w,]+:/.test(commit.summary) && commit.summary.split(':')[0]) || null 92 | } 93 | 94 | 95 | function cleanMarkdown (txt) { 96 | // just escape '[' & ']' 97 | return txt.replace(/([\[\]])/g, '\\$1') 98 | } 99 | 100 | 101 | function commitToOutput (commit) { 102 | var semver = commit.labels && commit.labels.filter(function (l) { return l.indexOf('semver') > -1 }) || false 103 | , group = commitToGroup(commit) || '' 104 | , summaryOut = !group ? commit.summary : commit.summary.substr(group.length + 2) 105 | , shaOut = `[\`${commit.sha.substr(0,10)}\`](https://github.com/${ghUser}/${ghProject}/commit/${commit.sha.substr(0,10)})` 106 | , labelOut = (semver.length ? '(' + semver.join(', ').toUpperCase() + ') ' : '') + group 107 | , prUrlMatch = commit.prUrl && commit.prUrl.match(/^https?:\/\/.+\/([^\/]+\/[^\/]+)\/\w+\/\d+$/i) 108 | , out 109 | 110 | if (argv.simple) { 111 | if (labelOut.length) 112 | summaryOut = `${labelOut}: ${summaryOut}` 113 | out = `* [${commit.sha.substr(0,10)}] - ${summaryOut} (${commit.author.name})` 114 | 115 | if (prUrlMatch) 116 | out += ` ${prUrlMatch[1] != ghUser + '/' + ghProject ? prUrlMatch[1] : ''}#${commit.ghIssue || commit.prUrl}` 117 | } else { 118 | if (labelOut.length) 119 | summaryOut = `**${labelOut}**: ${summaryOut}` 120 | out = `* [${shaOut}] - ${cleanMarkdown(summaryOut)} (${commit.author.name})` 121 | 122 | if (prUrlMatch) 123 | out += ` [${prUrlMatch[1] != ghUser + '/' + ghProject ? prUrlMatch[1] : ''}#${commit.ghIssue || commit.prUrl}](${commit.prUrl})` 124 | } 125 | 126 | return semver.length 127 | ? chalk.green(chalk.bold(out)) 128 | : group == 'doc' ? chalk.grey(out) : out 129 | } 130 | 131 | 132 | function groupCommits (list) { 133 | var groups = list.reduce(function (groups, commit) { 134 | var group = commitToGroup(commit) || '*' 135 | if (!groups[group]) 136 | groups[group] = [] 137 | groups[group].push(commit) 138 | return groups 139 | }, {}) 140 | 141 | return Object.keys(groups).sort().reduce(function (p, group) { 142 | return p.concat(groups[group]) 143 | }, []) 144 | } 145 | 146 | 147 | function printCommits (list) { 148 | var out = `${list.join('\n')} \n` 149 | 150 | if (!process.stdout.isTTY) 151 | out = chalk.stripColor(out) 152 | 153 | process.stdout.write(out) 154 | } 155 | 156 | 157 | function onCommitList (err, list) { 158 | if (err) 159 | throw err 160 | 161 | list = organiseCommits(list) 162 | 163 | commitTags(list, function (err) { 164 | if (err) 165 | throw err 166 | 167 | if (argv.group) 168 | list = groupCommits(list) 169 | 170 | list = list.map(commitToOutput) 171 | 172 | printCommits(list) 173 | }) 174 | } 175 | 176 | 177 | var _startrefcmd = replace(refcmd, { ref: argv['start-ref'] || defaultRef }) 178 | , _endrefcmd = argv['end-ref'] && replace(refcmd, { ref: argv['end-ref'] }) 179 | , _sincecmd = replace(commitdatecmd, { refcmd: _startrefcmd }) 180 | , _untilcmd = argv['end-ref'] ? replace(commitdatecmd, { refcmd: _endrefcmd }) : untilcmd 181 | , _gitcmd = replace(gitcmd, { sincecmd: _sincecmd, untilcmd: _untilcmd }) 182 | , child = spawn('bash', [ '-c', _gitcmd ]) 183 | 184 | child.stdout.pipe(split2()).pipe(commitStream(ghUser, ghProject)).pipe(list.obj(onCommitList)) 185 | 186 | child.stderr.pipe(bl(function (err, _data) { 187 | if (err) 188 | throw err 189 | 190 | if (_data.length) 191 | process.stderr.write(_data) 192 | })) 193 | 194 | child.on('close', function (code) { 195 | if (code) 196 | throw new Error(`git command [${gitcmd}] exited with code ${code}`) 197 | }) 198 | --------------------------------------------------------------------------------