Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Generate release notes
  • Loading branch information
fcrisci committed Feb 25, 2013
0 parents commit 32a369f
Show file tree
Hide file tree
Showing 11 changed files with 414 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
@@ -0,0 +1 @@
node_modules
1 change: 1 addition & 0 deletions .npmignore
@@ -0,0 +1 @@
node_modules
82 changes: 82 additions & 0 deletions README.md
@@ -0,0 +1,82 @@
## Release Notes

Generate release note pages from git commit history.

### Installation

It's preferable to install it globally through `npm`

npm install -g release-notes

### Usage

The basic usage is

cd <your_git_project>
release-notes <since>..<until> <template>

Where

* `<since>..<until>` specifies the range of commits as in `git log`, see [gitrevisions(7)](http://www.kernel.org/pub/software/scm/git/docs/gitrevisions.html)
* `<template>` is an [ejs](https://github.com/visionmedia/ejs) template file used to generate the release notes

Two templates are included as reference, `markdown` and `html`.

This are for instance the release notes generated from `joyent/node` running

release-notes v0.9.8..v0.9.9 html > changelog.html

<a href="https://github.com/ariatemplates/release-notes/raw/master/templates/node.png" target="_blank"><img src="https://github.com/ariatemplates/release-notes/raw/master/templates/node_thumb.png" alt="Node's release notes"></a>

#### Custom template

The second parameter of `release-notes` can be any path to a valid ejs template files.

The only available template local variable is `commits` that is an array of commits, each containing

* `sha1` commit hash (%H)
* `authorName` author name (%an)
* `authorEmail` author email (%ae)
* `authorDate` author date (%aD)
* `committerName` committer name (%cn)
* `committerEmail` committer email (%ce)
* `committerDate` committer date (%cD)
* `title` subject (%s)
* `messageLines` array of body lines (%b)


### Options

More advanced options are

* `p` or `path` Git project path, defaults to the current working path
* `b` or `branch` Git branch, defaults to `master`
* `t` or `title` Regular expression to parse the commit title (see next chapter)
* `m` or `meaning` Meaning of capturing block in title's regular expression
* `f` or `file` JSON Configuration file, better option when you don't want to pass all parameters to the command line, for an example see [options.json]()

#### Title Parsing

Some projects might have special naming conventions for the commit title.

The options `t` and `m` allow to specify this logic and extract additional information from the title.

For instance, [Aria Templates]() has the following convention

fix #123 Title of a bug fix commit
feat #234 Title of a cool new feature

In this case using

release-notes -t "^([a-z]+) #(\d+) (.*)$" -m type -m issue -m title v1.3.6..HEAD html

generates the additional fields on the commit object

* `type` first capturing block
* `issue` second capturing block
* `title` third capturing block (redefines the title)


Another project using similar conventions is [AngularJs](https://github.com/angular/angular.js), [commit message conventions](https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit#).

release-notes -t "^(\w*)(?:\(([\w\$\.]*)\))?\: (.*)$" -m type -m scope -m title v1.1.2..v1.1.3 markdown
103 changes: 103 additions & 0 deletions index.js
@@ -0,0 +1,103 @@
#!/usr/bin/env node
var argv = require("optimist").usage("release-notes [<options>] <since>..<until> <template>")
.options("f", {
"alias" : "file"
})
.options("p", {
"alias" : "path",
"default" : process.cwd()
})
.options("t", {
"alias" : "title",
"default" : "(.*)"
})
.options("m", {
"alias" : "meaning",
"default" : ['type']
})
.options("b", {
"alias" : "branch",
"default" : "master"
})
.describe({
"f" : "Configuration file",
"p" : "Git project path",
"t" : "Commit title regular expression",
"m" : "Meaning of capturing block in title's regular expression",
"b" : "Git branch, defaults to master"
})
.boolean("version")
.check(function (argv) {
if (argv._.length == 2) {
return true;
}
throw "Invalid parameters, please specify an interval and the template";
})
.argv;

var git = require("./lib/git");
var fs = require("fs");
var ejs = require("ejs");
var path = require("path");

var template = argv._[1];
if (!fs.existsSync(template)) {
// Template name?
if (template.match(/[a-z]+(\.ejs)?/)) {
template = path.resolve(__dirname, "./templates/" + path.basename(template, ".ejs") + ".ejs");
} else {
require("optimist").showHelp();
console.error("\nUnable to locate template file " + template);
process.exit(1);
}
}
fs.readFile(template, function (err, templateContent) {
if (err) {
require("optimist").showHelp();
console.error("\nUnable to locate template file " + argv._[1]);
process.exit(5);
} else {
getOptions(function (options) {
git.log({
branch : options.b,
range : argv._[0],
title : new RegExp(options.t),
meaning : Array.isArray(options.m) ? options.m : [options.m],
cwd : options.p
}, function (commits) {
var output = ejs.render(templateContent.toString(), {
commits : commits
});
process.stdout.write(output + "\n");
});
});
}
});

function getOptions (callback) {
if (argv.f) {
fs.readFile(argv.f, function (err, data) {
if (err) {
console.error("Unable to read configuration file\n" + err.message);
} else {
var options;
try {
var stored = JSON.parse(data);
options = {
b : stored.b || stored.branch || argv.b,
t : stored.t || stored.title || argv.t,
m : stored.m || stored.meaning || argv.m,
p : stored.p || stored.path || argv.p
};
} catch (ex) {
console.error("Invalid JSON in configuration file");
}
if (options) {
callback(options);
}
}
});
} else {
callback(argv);
}
}
100 changes: 100 additions & 0 deletions lib/git.js
@@ -0,0 +1,100 @@
exports.log = function (options, callback) {
var spawn = require("child_process").spawn;
var gitArgs = ["log", "--no-color", "--no-merges", "--branches=" + options.branch, "--format=" + formatOptions, options.range];
var gitLog = spawn("git", gitArgs, {
cwd : options.cwd,
stdio : ["ignore", "pipe", process.stderr]
});

var allCommits = "";
gitLog.stdout.on("data", function (data) {
allCommits += data;
});

gitLog.on("exit", function (code) {
if (code === 0) {
// Build the list of commits from git log
var commits = processCommits(allCommits.replace(/\r\n?|[\n\u2028\u2029]/g, "\n").replace(/^\uFEFF/, ''), options);
callback(commits);
}
});
};

var newCommit = "___";
var formatOptions = [
newCommit, "sha1:%H", "authorName:%an", "authorEmail:%ae", "authorDate:%aD",
"committerName:%cn", "committerEmail:%ce", "committerDate:%cD",
"title:%s", "%w(80,1,1)%b"
].join("%n");

function processCommits (commitMessages, options) {
// This return an object with the same properties described above
var stream = commitMessages.split("\n");
var commits = [];
var workingCommit;
stream.forEach(function (rawLine) {
var line = parseLine(rawLine);
if (line.type === "new") {
workingCommit = {
messageLines : []
};
commits.push(workingCommit);
} else if (line.type === "message") {
workingCommit.messageLines.push(line.message);
} else if (line.type === "title") {
var title = parseTitle(line.message, options);
for (var prop in title) {
workingCommit[prop] = title[prop];
}
if (!workingCommit.title) {
// The parser doesn't return a title
workingCommit.title = line.message;
}
} else {
workingCommit[line.type] = line.message;
}
});
return commits;
}

function parseLine (line) {
if (line === newCommit) {
return {
type : "new"
};
}

var match = line.match(/^([a-zA-Z]+1?)\s?:\s?(.*)$/i);

if (match) {
return {
type : match[1],
message : match[2].trim()
};
} else {
return {
type : "message",
message : line.substring(1) // padding
};
}
}

function parseTitle (title, options) {
var expression = options.title;
var names = options.meaning;

var match = title.match(expression);
if (!match) {
return {
title : title
};
} else {
var builtObject = {};
for (var i = 0; i < names.length; i += 1) {
var name = names[i];
var index = i + 1;
builtObject[name] = match[index];
}
return builtObject;
}
}
4 changes: 4 additions & 0 deletions options.json
@@ -0,0 +1,4 @@
{
"title" : "^([a-z]+) #(\\d+) (.*)$",
"meaning" : ["type", "issue", "title"]
}
22 changes: 22 additions & 0 deletions package.json
@@ -0,0 +1,22 @@
{
"author": "ariatemplates <contact@ariatemplates.com> (http://github.com/ariatemplates)",
"name": "release-notes",
"description": "Generate beautiful release notes from a git log.",
"keywords" : ["git", "log", "release notes", "compare", "version"],
"version": "0.0.1",
"dependencies": {
"optimist" : "0.3",
"ejs" : "0.8.x"
},
"contributors" : {
"name" : "Fabio Crisci",
"email" : "fabio@ariatemplates.com",
"url" : "https://github.com/piuccio"
},
"bin" : "./index.js",
"repository" : {
"type" : "git",
"url" : "https://github.com/ariatemplates/release-notes"
},
"preferGlobal" : true
}

0 comments on commit 32a369f

Please sign in to comment.