first stab at integrating burndown chart for community plan

This commit is contained in:
Radek Stepan 2014-09-18 19:30:31 -07:00
parent 0cbaaf321e
commit eff268e1de
16 changed files with 1348 additions and 102 deletions

View File

@ -18,6 +18,7 @@ module.exports = (grunt) ->
src: [
'src/styles/fonts.styl'
'src/styles/icons.styl'
'src/styles/chart.styl'
'src/styles/app.styl'
]
dest: 'public/css/app.css'

View File

@ -9,8 +9,8 @@ GitHub Burndown Chart as a service. Public repos are free, for private access au
### MVP - Community Plan
- [x] show a list of projects and their milestones with progress & due date
- [ ] show burnchart for that project milestone
- [ ] show all issues as [one size](https://github.com/radekstepan/github-burndown-chart/issues/46)
- [x] show burnchart for that project milestone
- [x] show all issues as [one size](https://github.com/radekstepan/github-burndown-chart/issues/46)
- [x] use `localStorage` to save project names
### Extras

View File

@ -436,6 +436,22 @@ table {
}@keyframes spin{0%{-webkit-transform:rotate(0);-moz-transform:rotate(0);-o-transform:rotate(0);-ms-transform:rotate(0);transform:rotate(0)}
100%{-webkit-transform:rotate(360deg);-moz-transform:rotate(360deg);-o-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg)}
}
#chart{height:200px;position:relative;}
#chart #tooltip{position:absolute;top:0;left:0}
#chart svg path.line{fill:none;stroke-width:1px;clip-path:url("#clip");}
#chart svg path.line.actual{stroke:#64584c;stroke-width:3px}
#chart svg path.line.ideal{stroke:#cacaca;stroke-width:3px}
#chart svg path.line.trendline{stroke:#64584c;stroke-width:1.5px;stroke-dasharray:5,5}
#chart svg line.today{stroke:#cacaca;stroke-width:1px;shape-rendering:crispEdges;stroke-dasharray:5,5}
#chart svg circle{fill:#64584c;stroke:transparent;stroke-width:15px;cursor:pointer}
#chart svg .axis{shape-rendering:crispEdges;}
#chart svg .axis line{stroke:rgba(202,202,202,0.25);shape-rendering:crispEdges}
#chart svg .axis text{font-weight:bold;fill:#cacaca}
#chart svg .axis path{display:none}
.d3-tip{margin-top:-10px;font-size:11px;padding:8px 10px 7px 10px;text-align:center;background:rgba(0,0,0,0.75);color:#fff;-webkit-border-radius:3px;border-radius:3px;}
.d3-tip:after{width:100%;color:rgba(0,0,0,0.8);content:"\25BC";position:absolute}
.d3-tip.n:after{margin:-3px 0 0 0;top:100%;left:0}
body{color:#3e4457;font-family:'MuseoSans500Regular',sans-serif}
a{text-decoration:none;color:#aaafbf;cursor:pointer}
h1,h2,h3,p{margin:0}

View File

@ -29,6 +29,22 @@
}@keyframes spin{0%{-webkit-transform:rotate(0);-moz-transform:rotate(0);-o-transform:rotate(0);-ms-transform:rotate(0);transform:rotate(0)}
100%{-webkit-transform:rotate(360deg);-moz-transform:rotate(360deg);-o-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg)}
}
#chart{height:200px;position:relative;}
#chart #tooltip{position:absolute;top:0;left:0}
#chart svg path.line{fill:none;stroke-width:1px;clip-path:url("#clip");}
#chart svg path.line.actual{stroke:#64584c;stroke-width:3px}
#chart svg path.line.ideal{stroke:#cacaca;stroke-width:3px}
#chart svg path.line.trendline{stroke:#64584c;stroke-width:1.5px;stroke-dasharray:5,5}
#chart svg line.today{stroke:#cacaca;stroke-width:1px;shape-rendering:crispEdges;stroke-dasharray:5,5}
#chart svg circle{fill:#64584c;stroke:transparent;stroke-width:15px;cursor:pointer}
#chart svg .axis{shape-rendering:crispEdges;}
#chart svg .axis line{stroke:rgba(202,202,202,0.25);shape-rendering:crispEdges}
#chart svg .axis text{font-weight:bold;fill:#cacaca}
#chart svg .axis path{display:none}
.d3-tip{margin-top:-10px;font-size:11px;padding:8px 10px 7px 10px;text-align:center;background:rgba(0,0,0,0.75);color:#fff;-webkit-border-radius:3px;border-radius:3px;}
.d3-tip:after{width:100%;color:rgba(0,0,0,0.8);content:"\25BC";position:absolute}
.d3-tip.n:after{margin:-3px 0 0 0;top:100%;left:0}
body{color:#3e4457;font-family:'MuseoSans500Regular',sans-serif}
a{text-decoration:none;color:#aaafbf;cursor:pointer}
h1,h2,h3,p{margin:0}

View File

@ -40023,25 +40023,30 @@ if (typeof exports === 'object') {
});
// config.json
// config.coffee
root.require.register('burnchart/src/models/config.js', function(exports, require, module) {
module.exports = {
var Model;
Model = require('../utils/model');
module.exports = new Model({
"data": {
"firebase": "burnchart",
"provider": "github",
"fields": {
"milestone": [
"closed_issues",
"created_at",
"description",
"due_on",
"number",
"open_issues",
"title",
"updated_at"
]
"milestone": ["closed_issues", "created_at", "description", "due_on", "number", "open_issues", "title", "updated_at"]
},
"chart": {
"off_days": [6, 7],
"datetime": /^(\d{4}-\d{2}-\d{2})T(.*)/,
"size_label": /^size (\d+)$/,
"location": /^#!((\/[^\/]+){2,3})$/,
"points": 'ALL_ONE_SIZE'
}
};
}
});
});
// projects.coffee
@ -40049,6 +40054,8 @@ if (typeof exports === 'object') {
var Model, config, date, mediator, request, user;
config = require('../models/config');
mediator = require('../modules/mediator');
request = require('../modules/request');
@ -40057,8 +40064,6 @@ if (typeof exports === 'object') {
date = require('../utils/date');
config = require('./config');
user = require('./user');
module.exports = new Model({
@ -40088,7 +40093,7 @@ if (typeof exports === 'object') {
if (err) {
return done(err);
}
milestones = _.pluckMany(res, config.fields.milestone);
milestones = _.pluckMany(res, config.get('fields.milestone'));
_this.push('list', _.merge(repo, {
milestones: milestones
}));
@ -40123,19 +40128,216 @@ if (typeof exports === 'object') {
});
// chart.coffee
root.require.register('burnchart/src/modules/chart.js', function(exports, require, module) {
var config,
__indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
config = require('../models/config');
module.exports = {
'actual': function(collection, created_at, total, cb) {
var head, max, min, range, rest;
head = [
{
date: new Date(created_at),
points: total
}
];
min = +Infinity;
max = -Infinity;
rest = _.map(collection, function(issue) {
var closed_at, size;
size = issue.size, closed_at = issue.closed_at;
if (size < min) {
min = size;
}
if (size > max) {
max = size;
}
return _.extend({}, issue, {
date: new Date(closed_at),
points: total -= size
});
});
range = d3.scale.linear().domain([min, max]).range([5, 8]);
rest = _.map(rest, function(issue) {
issue.radius = range(issue.size);
return issue;
});
return cb(null, [].concat(head, rest));
},
'ideal': function(a, b, total, cb) {
var cutoff, d, days, length, m, now, once, velocity, y, _ref, _ref1;
if (b < a) {
_ref = [a, b], b = _ref[0], a = _ref[1];
}
_ref1 = _.map(a.match(config.get('chart.datetime'))[1].split('-'), function(v) {
return parseInt(v);
}), y = _ref1[0], m = _ref1[1], d = _ref1[2];
cutoff = new Date(b);
days = [];
length = 0;
(once = function(inc) {
var day, day_of;
day = new Date(y, m - 1, d + inc);
if (!(day_of = day.getDay())) {
day_of = 7;
}
if (__indexOf.call(config.get('chart.off_days'), day_of) >= 0) {
days.push({
date: day,
off_day: true
});
} else {
length += 1;
days.push({
date: day
});
}
if (!(day > cutoff)) {
return once(inc + 1);
}
})(0);
velocity = total / (length - 1);
days = _.map(days, function(day, i) {
day.points = total;
if (days[i] && !days[i].off_day) {
total -= velocity;
}
return day;
});
if ((now = new Date()) > cutoff) {
days.push({
date: now,
points: 0
});
}
return cb(null, days);
},
'trendline': function(actual, created_at, due_on) {
var a, b, b1, c1, e, fn, intercept, l, last, slope, start, values;
start = +actual[0].date;
values = _.map(actual, function(_arg) {
var date, points;
date = _arg.date, points = _arg.points;
return [+date - start, points];
});
last = actual[actual.length - 1];
values.push([+new Date() - start, last.points]);
b1 = 0;
e = 0;
c1 = 0;
a = (l = values.length) * _.reduce(values, function(sum, _arg) {
var a, b;
a = _arg[0], b = _arg[1];
b1 += a;
e += b;
c1 += Math.pow(a, 2);
return sum + (a * b);
}, 0);
slope = (a - (b1 * e)) / ((l * c1) - (Math.pow(b1, 2)));
intercept = (e - (slope * b1)) / l;
fn = function(x) {
return slope * x + intercept;
};
created_at = new Date(created_at);
due_on = due_on ? new Date(due_on) : new Date();
a = created_at - start;
b = due_on - start;
return [
{
date: created_at,
points: fn(a)
}, {
date: due_on,
points: fn(b)
}
];
},
'render': function(_arg, cb) {
var actual, height, ideal, line, m, mAxis, margin, svg, tooltip, trendline, width, x, xAxis, y, yAxis, _ref;
actual = _arg[0], ideal = _arg[1], trendline = _arg[2];
document.querySelector('#svg').innerHTML = '';
_ref = document.querySelector('#chart').getBoundingClientRect(), height = _ref.height, width = _ref.width;
margin = {
top: 30,
right: 30,
bottom: 40,
left: 50
};
width -= margin.left + margin.right;
height -= margin.top + margin.bottom;
x = d3.time.scale().range([0, width]);
y = d3.scale.linear().range([height, 0]);
xAxis = d3.svg.axis().scale(x).orient("bottom").tickSize(-height).tickFormat(function(d) {
return d.getDate();
}).tickPadding(10);
yAxis = d3.svg.axis().scale(y).orient("left").tickSize(-width).ticks(5).tickPadding(10);
line = d3.svg.line().interpolate("linear").x(function(d) {
return x(d.date);
}).y(function(d) {
return y(d.points);
});
x.domain([ideal[0].date, ideal[ideal.length - 1].date]);
y.domain([0, ideal[0].points]).nice();
svg = d3.select("#svg").append("svg").attr("width", width + margin.left + margin.right).attr("height", height + margin.top + margin.bottom).append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");
svg.append("g").attr("class", "x axis day").attr("transform", "translate(0," + height + ")").call(xAxis);
m = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
mAxis = xAxis.orient("top").tickSize(height).tickFormat(function(d) {
return m[d.getMonth()];
}).ticks(2);
svg.append("g").attr("class", "x axis month").attr("transform", "translate(0," + height + ")").call(mAxis);
svg.append("g").attr("class", "y axis").call(yAxis);
svg.append("svg:line").attr("class", "today").attr("x1", x(new Date())).attr("y1", 0).attr("x2", x(new Date())).attr("y2", height);
svg.append("path").attr("class", "ideal line").attr("d", line.interpolate("basis")(ideal));
svg.append("path").attr("class", "trendline line").attr("d", line.interpolate("linear")(trendline));
svg.append("path").attr("class", "actual line").attr("d", line.interpolate("linear").y(function(d) {
return y(d.points);
})(actual));
tooltip = d3.tip().attr('class', 'd3-tip').html(function(_arg1) {
var number, title;
number = _arg1.number, title = _arg1.title;
return "#" + number + ": " + title;
});
svg.call(tooltip);
svg.selectAll("a.issue").data(actual.slice(1)).enter().append('svg:a').attr("xlink:href", function(_arg1) {
var html_url;
html_url = _arg1.html_url;
return html_url;
}).attr("xlink:show", 'new').append('svg:circle').attr("cx", function(_arg1) {
var date;
date = _arg1.date;
return x(date);
}).attr("cy", function(_arg1) {
var points;
points = _arg1.points;
return y(points);
}).attr("r", function(_arg1) {
var radius;
radius = _arg1.radius;
return 5;
}).on('mouseover', tooltip.show).on('mouseout', tooltip.hide);
return cb(null);
}
};
});
// firebase.coffee
root.require.register('burnchart/src/modules/firebase.js', function(exports, require, module) {
var Class, config, user;
config = require('../models/config');
user = require('../models/user');
config = require('../models/config');
Class = (function() {
function Class() {
var _this = this;
this.client = new Firebase("https://" + config.firebase + ".firebaseio.com");
this.client = new Firebase("https://" + (config.get('firebase')) + ".firebaseio.com");
this.auth = new FirebaseSimpleLogin(this.client, function(err, obj) {
if (err || !obj) {
return _this.authCb(err);
@ -40151,7 +40353,7 @@ if (typeof exports === 'object') {
return cb('Client is not setup');
}
this.authCb = cb;
return this.auth.login(config.provider, {
return this.auth.login(config.get('provider'), {
'rememberMe': true,
'scope': 'public_repo'
});
@ -40173,6 +40375,76 @@ if (typeof exports === 'object') {
});
// issues.coffee
root.require.register('burnchart/src/modules/issues.js', function(exports, require, module) {
var config, request;
config = require('../models/config');
request = require('./request');
module.exports = {
'get_all': function(opts, cb) {
var one_status;
one_status = function(state, cb) {
var fetch_page, results;
results = [];
return (fetch_page = function(page) {
return request.allIssues(opts, {
state: state,
page: page
}, function(err, data) {
if (err) {
return cb(err);
}
if (!data.length) {
return cb(null, results);
}
results = results.concat(_.sortBy(data, 'closed_at'));
if (data.length < 100) {
return cb(null, results);
}
return fetch_page(page + 1);
});
})(1);
};
return async.parallel([_.partial(one_status, 'open'), _.partial(one_status, 'closed')], cb);
},
'filter': function(collection, regex, cb) {
var filtered, total;
total = 0;
switch (config.get('chart.points')) {
case 'ALL_ONE_SIZE':
total = collection.length;
filtered = _.map(collection, function(issue) {
issue.size = 1;
return issue;
});
break;
case 'LABELS':
filtered = _.filter(collection, function(issue) {
var labels;
if (!(labels = issue.labels)) {
return false;
}
issue.size = _.reduce(labels, function(sum, label) {
var matches;
if (!(matches = label.name.match(regex))) {
return sum;
}
return sum += parseInt(matches[1]);
}, 0);
total += issue.size;
return !!issue.size;
});
}
return cb(null, filtered, total);
}
};
});
// mediator.coffee
root.require.register('burnchart/src/modules/mediator.js', function(exports, require, module) {
@ -40180,6 +40452,124 @@ if (typeof exports === 'object') {
});
// milestones.coffee
root.require.register('burnchart/src/modules/milestones.js', function(exports, require, module) {
var request;
request = require('./request');
module.exports = function(repo, cb) {
var parse;
parse = function(data) {
if (data.description) {
data.description = marked(data.description).slice(3, -5);
}
return data;
};
if (repo.milestone) {
return request.oneMilestone(repo, repo.milestone, function(err, m) {
if (err) {
return cb(err);
}
if (m.open_issues + m.closed_issues === 0) {
return cb(null, "No issues for milestone `" + m.title + "`");
}
m = parse(m);
return cb(null, null, m);
});
} else {
return request.allMilestones(repo, function(err, data) {
var m;
if (err) {
return cb(err);
}
if (!data.length) {
return cb(null, "No open milestones for repo " + repo.path);
}
m = data[0];
m = _.rest(data, {
'due_on': null
});
m = m[0] ? m[0] : data[0];
if (m.open_issues + m.closed_issues === 0) {
return cb(null, "No issues for milestone `" + m.title + "`");
}
m = parse(m);
return cb(null, null, m);
});
}
};
});
// project.coffee
root.require.register('burnchart/src/modules/project.js', function(exports, require, module) {
var chart, issues, milestones;
milestones = require('./milestones');
issues = require('./issues');
chart = require('./chart');
module.exports = function(opts, cb) {
return async.waterfall([
function(cb) {
return milestones(opts, function(err, warn, milestone) {
if (err) {
return cb(err);
}
if (warn) {
return cb(warn);
}
opts.milestone = milestone;
return cb(null);
});
}, function(cb) {
return issues.get_all(opts, cb);
}, function(all, cb) {
return async.map(all, function(array, cb) {
return issues.filter(array, opts.size_label, function(err, filtered, total) {
return cb(err, [filtered, total]);
});
}, function(err, _arg) {
var closed, open;
open = _arg[0], closed = _arg[1];
if (err) {
return cb(err);
}
if (open[1] + closed[1] === 0) {
return cb('No matching issues found');
}
opts.issues = {
closed: {
'points': closed[1],
'data': closed[0]
},
open: {
'points': open[1],
'data': open[0]
}
};
return cb(null);
});
}, function(cb) {
var total;
total = opts.issues.open.points + opts.issues.closed.points;
return async.parallel([_.partial(chart.actual, opts.issues.closed.data, opts.milestone.created_at, total), _.partial(chart.ideal, opts.milestone.created_at, opts.milestone.due_on, total)], function(err, values) {
if (values[0].length) {
values.push(chart.trendline(values[0], opts.milestone.created_at, opts.milestone.due_on));
}
return chart.render(values, cb);
});
}
], cb);
};
});
// request.coffee
root.require.register('burnchart/src/modules/request.js', function(exports, require, module) {
@ -40210,8 +40600,6 @@ if (typeof exports === 'object') {
'repo': function(repo, cb) {
var data;
data = _.defaults({
'protocol': repo.protocol,
'host': repo.host,
'path': "/repos/" + repo.owner + "/" + repo.name,
'headers': headers(user.get('token'))
}, defaults.github);
@ -40220,8 +40608,6 @@ if (typeof exports === 'object') {
'allMilestones': function(repo, cb) {
var data;
data = _.defaults({
'protocol': repo.protocol,
'host': repo.host,
'path': "/repos/" + repo.owner + "/" + repo.name + "/milestones",
'query': {
'state': 'open',
@ -40233,9 +40619,8 @@ if (typeof exports === 'object') {
return request(data, cb);
},
'oneMilestone': function(repo, number, cb) {
return request({
'protocol': repo.protocol,
'host': repo.host,
var data;
data = _.defaults({
'path': "/repos/" + repo.owner + "/" + repo.name + "/milestones/" + number,
'query': {
'state': 'open',
@ -40243,18 +40628,19 @@ if (typeof exports === 'object') {
'direction': 'asc'
},
'headers': headers(user.get('token'))
}, cb);
}, defaults.github);
return request(data, cb);
},
'allIssues': function(repo, query, cb) {
return request({
'protocol': repo.protocol,
'host': repo.host,
var data;
data = _.defaults({
'path': "/repos/" + repo.owner + "/" + repo.name + "/issues",
'query': _.extend(query, {
'per_page': '100'
}),
'headers': headers(user.get('token'))
}, cb);
}, defaults.github);
return request(data, cb);
}
};
@ -40563,11 +40949,15 @@ if (typeof exports === 'object') {
// showChart.coffee
root.require.register('burnchart/src/views/pages/showChart.js', function(exports, require, module) {
var project;
project = require('../../modules/project');
module.exports = Ractive.extend({
'template': require('../../templates/pages/showChart'),
'adapt': [Ractive.adaptors.Ractive],
init: function() {
return console.log(this.get('route'));
return project(this.get('route'));
}
});

View File

@ -57,25 +57,30 @@
});
// config.json
// config.coffee
root.require.register('burnchart/src/models/config.js', function(exports, require, module) {
module.exports = {
var Model;
Model = require('../utils/model');
module.exports = new Model({
"data": {
"firebase": "burnchart",
"provider": "github",
"fields": {
"milestone": [
"closed_issues",
"created_at",
"description",
"due_on",
"number",
"open_issues",
"title",
"updated_at"
]
"milestone": ["closed_issues", "created_at", "description", "due_on", "number", "open_issues", "title", "updated_at"]
},
"chart": {
"off_days": [6, 7],
"datetime": /^(\d{4}-\d{2}-\d{2})T(.*)/,
"size_label": /^size (\d+)$/,
"location": /^#!((\/[^\/]+){2,3})$/,
"points": 'ALL_ONE_SIZE'
}
};
}
});
});
// projects.coffee
@ -83,6 +88,8 @@
var Model, config, date, mediator, request, user;
config = require('../models/config');
mediator = require('../modules/mediator');
request = require('../modules/request');
@ -91,8 +98,6 @@
date = require('../utils/date');
config = require('./config');
user = require('./user');
module.exports = new Model({
@ -122,7 +127,7 @@
if (err) {
return done(err);
}
milestones = _.pluckMany(res, config.fields.milestone);
milestones = _.pluckMany(res, config.get('fields.milestone'));
_this.push('list', _.merge(repo, {
milestones: milestones
}));
@ -157,19 +162,216 @@
});
// chart.coffee
root.require.register('burnchart/src/modules/chart.js', function(exports, require, module) {
var config,
__indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
config = require('../models/config');
module.exports = {
'actual': function(collection, created_at, total, cb) {
var head, max, min, range, rest;
head = [
{
date: new Date(created_at),
points: total
}
];
min = +Infinity;
max = -Infinity;
rest = _.map(collection, function(issue) {
var closed_at, size;
size = issue.size, closed_at = issue.closed_at;
if (size < min) {
min = size;
}
if (size > max) {
max = size;
}
return _.extend({}, issue, {
date: new Date(closed_at),
points: total -= size
});
});
range = d3.scale.linear().domain([min, max]).range([5, 8]);
rest = _.map(rest, function(issue) {
issue.radius = range(issue.size);
return issue;
});
return cb(null, [].concat(head, rest));
},
'ideal': function(a, b, total, cb) {
var cutoff, d, days, length, m, now, once, velocity, y, _ref, _ref1;
if (b < a) {
_ref = [a, b], b = _ref[0], a = _ref[1];
}
_ref1 = _.map(a.match(config.get('chart.datetime'))[1].split('-'), function(v) {
return parseInt(v);
}), y = _ref1[0], m = _ref1[1], d = _ref1[2];
cutoff = new Date(b);
days = [];
length = 0;
(once = function(inc) {
var day, day_of;
day = new Date(y, m - 1, d + inc);
if (!(day_of = day.getDay())) {
day_of = 7;
}
if (__indexOf.call(config.get('chart.off_days'), day_of) >= 0) {
days.push({
date: day,
off_day: true
});
} else {
length += 1;
days.push({
date: day
});
}
if (!(day > cutoff)) {
return once(inc + 1);
}
})(0);
velocity = total / (length - 1);
days = _.map(days, function(day, i) {
day.points = total;
if (days[i] && !days[i].off_day) {
total -= velocity;
}
return day;
});
if ((now = new Date()) > cutoff) {
days.push({
date: now,
points: 0
});
}
return cb(null, days);
},
'trendline': function(actual, created_at, due_on) {
var a, b, b1, c1, e, fn, intercept, l, last, slope, start, values;
start = +actual[0].date;
values = _.map(actual, function(_arg) {
var date, points;
date = _arg.date, points = _arg.points;
return [+date - start, points];
});
last = actual[actual.length - 1];
values.push([+new Date() - start, last.points]);
b1 = 0;
e = 0;
c1 = 0;
a = (l = values.length) * _.reduce(values, function(sum, _arg) {
var a, b;
a = _arg[0], b = _arg[1];
b1 += a;
e += b;
c1 += Math.pow(a, 2);
return sum + (a * b);
}, 0);
slope = (a - (b1 * e)) / ((l * c1) - (Math.pow(b1, 2)));
intercept = (e - (slope * b1)) / l;
fn = function(x) {
return slope * x + intercept;
};
created_at = new Date(created_at);
due_on = due_on ? new Date(due_on) : new Date();
a = created_at - start;
b = due_on - start;
return [
{
date: created_at,
points: fn(a)
}, {
date: due_on,
points: fn(b)
}
];
},
'render': function(_arg, cb) {
var actual, height, ideal, line, m, mAxis, margin, svg, tooltip, trendline, width, x, xAxis, y, yAxis, _ref;
actual = _arg[0], ideal = _arg[1], trendline = _arg[2];
document.querySelector('#svg').innerHTML = '';
_ref = document.querySelector('#chart').getBoundingClientRect(), height = _ref.height, width = _ref.width;
margin = {
top: 30,
right: 30,
bottom: 40,
left: 50
};
width -= margin.left + margin.right;
height -= margin.top + margin.bottom;
x = d3.time.scale().range([0, width]);
y = d3.scale.linear().range([height, 0]);
xAxis = d3.svg.axis().scale(x).orient("bottom").tickSize(-height).tickFormat(function(d) {
return d.getDate();
}).tickPadding(10);
yAxis = d3.svg.axis().scale(y).orient("left").tickSize(-width).ticks(5).tickPadding(10);
line = d3.svg.line().interpolate("linear").x(function(d) {
return x(d.date);
}).y(function(d) {
return y(d.points);
});
x.domain([ideal[0].date, ideal[ideal.length - 1].date]);
y.domain([0, ideal[0].points]).nice();
svg = d3.select("#svg").append("svg").attr("width", width + margin.left + margin.right).attr("height", height + margin.top + margin.bottom).append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");
svg.append("g").attr("class", "x axis day").attr("transform", "translate(0," + height + ")").call(xAxis);
m = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
mAxis = xAxis.orient("top").tickSize(height).tickFormat(function(d) {
return m[d.getMonth()];
}).ticks(2);
svg.append("g").attr("class", "x axis month").attr("transform", "translate(0," + height + ")").call(mAxis);
svg.append("g").attr("class", "y axis").call(yAxis);
svg.append("svg:line").attr("class", "today").attr("x1", x(new Date())).attr("y1", 0).attr("x2", x(new Date())).attr("y2", height);
svg.append("path").attr("class", "ideal line").attr("d", line.interpolate("basis")(ideal));
svg.append("path").attr("class", "trendline line").attr("d", line.interpolate("linear")(trendline));
svg.append("path").attr("class", "actual line").attr("d", line.interpolate("linear").y(function(d) {
return y(d.points);
})(actual));
tooltip = d3.tip().attr('class', 'd3-tip').html(function(_arg1) {
var number, title;
number = _arg1.number, title = _arg1.title;
return "#" + number + ": " + title;
});
svg.call(tooltip);
svg.selectAll("a.issue").data(actual.slice(1)).enter().append('svg:a').attr("xlink:href", function(_arg1) {
var html_url;
html_url = _arg1.html_url;
return html_url;
}).attr("xlink:show", 'new').append('svg:circle').attr("cx", function(_arg1) {
var date;
date = _arg1.date;
return x(date);
}).attr("cy", function(_arg1) {
var points;
points = _arg1.points;
return y(points);
}).attr("r", function(_arg1) {
var radius;
radius = _arg1.radius;
return 5;
}).on('mouseover', tooltip.show).on('mouseout', tooltip.hide);
return cb(null);
}
};
});
// firebase.coffee
root.require.register('burnchart/src/modules/firebase.js', function(exports, require, module) {
var Class, config, user;
config = require('../models/config');
user = require('../models/user');
config = require('../models/config');
Class = (function() {
function Class() {
var _this = this;
this.client = new Firebase("https://" + config.firebase + ".firebaseio.com");
this.client = new Firebase("https://" + (config.get('firebase')) + ".firebaseio.com");
this.auth = new FirebaseSimpleLogin(this.client, function(err, obj) {
if (err || !obj) {
return _this.authCb(err);
@ -185,7 +387,7 @@
return cb('Client is not setup');
}
this.authCb = cb;
return this.auth.login(config.provider, {
return this.auth.login(config.get('provider'), {
'rememberMe': true,
'scope': 'public_repo'
});
@ -207,6 +409,76 @@
});
// issues.coffee
root.require.register('burnchart/src/modules/issues.js', function(exports, require, module) {
var config, request;
config = require('../models/config');
request = require('./request');
module.exports = {
'get_all': function(opts, cb) {
var one_status;
one_status = function(state, cb) {
var fetch_page, results;
results = [];
return (fetch_page = function(page) {
return request.allIssues(opts, {
state: state,
page: page
}, function(err, data) {
if (err) {
return cb(err);
}
if (!data.length) {
return cb(null, results);
}
results = results.concat(_.sortBy(data, 'closed_at'));
if (data.length < 100) {
return cb(null, results);
}
return fetch_page(page + 1);
});
})(1);
};
return async.parallel([_.partial(one_status, 'open'), _.partial(one_status, 'closed')], cb);
},
'filter': function(collection, regex, cb) {
var filtered, total;
total = 0;
switch (config.get('chart.points')) {
case 'ALL_ONE_SIZE':
total = collection.length;
filtered = _.map(collection, function(issue) {
issue.size = 1;
return issue;
});
break;
case 'LABELS':
filtered = _.filter(collection, function(issue) {
var labels;
if (!(labels = issue.labels)) {
return false;
}
issue.size = _.reduce(labels, function(sum, label) {
var matches;
if (!(matches = label.name.match(regex))) {
return sum;
}
return sum += parseInt(matches[1]);
}, 0);
total += issue.size;
return !!issue.size;
});
}
return cb(null, filtered, total);
}
};
});
// mediator.coffee
root.require.register('burnchart/src/modules/mediator.js', function(exports, require, module) {
@ -214,6 +486,124 @@
});
// milestones.coffee
root.require.register('burnchart/src/modules/milestones.js', function(exports, require, module) {
var request;
request = require('./request');
module.exports = function(repo, cb) {
var parse;
parse = function(data) {
if (data.description) {
data.description = marked(data.description).slice(3, -5);
}
return data;
};
if (repo.milestone) {
return request.oneMilestone(repo, repo.milestone, function(err, m) {
if (err) {
return cb(err);
}
if (m.open_issues + m.closed_issues === 0) {
return cb(null, "No issues for milestone `" + m.title + "`");
}
m = parse(m);
return cb(null, null, m);
});
} else {
return request.allMilestones(repo, function(err, data) {
var m;
if (err) {
return cb(err);
}
if (!data.length) {
return cb(null, "No open milestones for repo " + repo.path);
}
m = data[0];
m = _.rest(data, {
'due_on': null
});
m = m[0] ? m[0] : data[0];
if (m.open_issues + m.closed_issues === 0) {
return cb(null, "No issues for milestone `" + m.title + "`");
}
m = parse(m);
return cb(null, null, m);
});
}
};
});
// project.coffee
root.require.register('burnchart/src/modules/project.js', function(exports, require, module) {
var chart, issues, milestones;
milestones = require('./milestones');
issues = require('./issues');
chart = require('./chart');
module.exports = function(opts, cb) {
return async.waterfall([
function(cb) {
return milestones(opts, function(err, warn, milestone) {
if (err) {
return cb(err);
}
if (warn) {
return cb(warn);
}
opts.milestone = milestone;
return cb(null);
});
}, function(cb) {
return issues.get_all(opts, cb);
}, function(all, cb) {
return async.map(all, function(array, cb) {
return issues.filter(array, opts.size_label, function(err, filtered, total) {
return cb(err, [filtered, total]);
});
}, function(err, _arg) {
var closed, open;
open = _arg[0], closed = _arg[1];
if (err) {
return cb(err);
}
if (open[1] + closed[1] === 0) {
return cb('No matching issues found');
}
opts.issues = {
closed: {
'points': closed[1],
'data': closed[0]
},
open: {
'points': open[1],
'data': open[0]
}
};
return cb(null);
});
}, function(cb) {
var total;
total = opts.issues.open.points + opts.issues.closed.points;
return async.parallel([_.partial(chart.actual, opts.issues.closed.data, opts.milestone.created_at, total), _.partial(chart.ideal, opts.milestone.created_at, opts.milestone.due_on, total)], function(err, values) {
if (values[0].length) {
values.push(chart.trendline(values[0], opts.milestone.created_at, opts.milestone.due_on));
}
return chart.render(values, cb);
});
}
], cb);
};
});
// request.coffee
root.require.register('burnchart/src/modules/request.js', function(exports, require, module) {
@ -244,8 +634,6 @@
'repo': function(repo, cb) {
var data;
data = _.defaults({
'protocol': repo.protocol,
'host': repo.host,
'path': "/repos/" + repo.owner + "/" + repo.name,
'headers': headers(user.get('token'))
}, defaults.github);
@ -254,8 +642,6 @@
'allMilestones': function(repo, cb) {
var data;
data = _.defaults({
'protocol': repo.protocol,
'host': repo.host,
'path': "/repos/" + repo.owner + "/" + repo.name + "/milestones",
'query': {
'state': 'open',
@ -267,9 +653,8 @@
return request(data, cb);
},
'oneMilestone': function(repo, number, cb) {
return request({
'protocol': repo.protocol,
'host': repo.host,
var data;
data = _.defaults({
'path': "/repos/" + repo.owner + "/" + repo.name + "/milestones/" + number,
'query': {
'state': 'open',
@ -277,18 +662,19 @@
'direction': 'asc'
},
'headers': headers(user.get('token'))
}, cb);
}, defaults.github);
return request(data, cb);
},
'allIssues': function(repo, query, cb) {
return request({
'protocol': repo.protocol,
'host': repo.host,
var data;
data = _.defaults({
'path': "/repos/" + repo.owner + "/" + repo.name + "/issues",
'query': _.extend(query, {
'per_page': '100'
}),
'headers': headers(user.get('token'))
}, cb);
}, defaults.github);
return request(data, cb);
}
};
@ -597,11 +983,15 @@
// showChart.coffee
root.require.register('burnchart/src/views/pages/showChart.js', function(exports, require, module) {
var project;
project = require('../../modules/project');
module.exports = Ractive.extend({
'template': require('../../templates/pages/showChart'),
'adapt': [Ractive.adaptors.Ractive],
init: function() {
return console.log(this.get('route'));
return project(this.get('route'));
}
});

32
src/models/config.coffee Normal file
View File

@ -0,0 +1,32 @@
Model = require '../utils/model'
module.exports = new Model
"data":
# Firebase app name.
"firebase": "burnchart"
# Data source provider.
"provider": "github"
# Fields to keep from GH responses.
"fields":
"milestone": [
"closed_issues"
"created_at"
"description"
"due_on"
"number"
"open_issues"
"title"
"updated_at"
]
# Chart configuration.
"chart":
"off_days": [ 6, 7 ]
# How do we parse GitHub dates?
"datetime": /^(\d{4}-\d{2}-\d{2})T(.*)/
# How does a size label look like?
"size_label": /^size (\d+)$/
# How do we specify which user/repo/(milestone) we want?
"location": /^#!((\/[^\/]+){2,3})$/
# Process all issues as one size.
"points": 'ALL_ONE_SIZE'

View File

@ -1,16 +0,0 @@
{
"firebase": "burnchart",
"provider": "github",
"fields": {
"milestone": [
"closed_issues",
"created_at",
"description",
"due_on",
"number",
"open_issues",
"title",
"updated_at"
]
}
}

View File

@ -1,8 +1,8 @@
config = require '../models/config'
mediator = require '../modules/mediator'
request = require '../modules/request'
Model = require '../utils/model'
date = require '../utils/date'
config = require './config'
user = require './user'
module.exports = new Model
@ -32,7 +32,7 @@ module.exports = new Model
return done err if err
# Pluck these fields for milestones.
milestones = _.pluckMany res, config.fields.milestone
milestones = _.pluckMany res, config.get('fields.milestone')
# Push to the stack.
@push 'list', _.merge repo, { milestones }

242
src/modules/chart.coffee Normal file
View File

@ -0,0 +1,242 @@
#!/usr/bin/env coffee
config = require '../models/config'
module.exports =
# A graph of closed issues.
'actual': (collection, created_at, total, cb) ->
head = [ {
date: new Date(created_at)
points: total
} ]
min = +Infinity ; max = -Infinity
# Generate the actual closes.
rest = _.map collection, (issue) ->
{ size, closed_at } = issue
# Determine the range.
min = size if size < min
max = size if size > max
# Dropping points remaining.
_.extend {}, issue,
date: new Date(closed_at)
points: total -= size
# Now add a radius in a range (will be used for a circle).
range = d3.scale.linear().domain([ min, max ]).range([ 5, 8 ])
rest = _.map rest, (issue) ->
issue.radius = range issue.size
issue
cb null, [].concat head, rest
# A graph of an ideal progression..
# `a`: milestone start date
# `b`: milestone end date
# `total`: total number of points (open & closed issues)
'ideal': (a, b, total, cb) ->
# Swap?
[ b, a ] = [ a, b ] if b < a
# We start here adding days to `d`.
[ y, m, d ] = _.map a.match(config.get('chart.datetime'))[1].split('-'), (v) -> parseInt v
# We want to end here.
cutoff = new Date(b)
# Go through the beginning to the end skipping off days.
days = [] ; length = 0
do once = (inc = 0) ->
# A new day.
day = new Date y, m - 1, d + inc
# Does this day count?
day_of = 7 if !day_of = day.getDay()
if day_of in config.get('chart.off_days')
days.push { date: day, off_day: yes }
else
length += 1
days.push { date: day }
# Go again?
once(inc + 1) unless day > cutoff
# Map points on the array of days now.
velocity = total / (length - 1)
days = _.map days, (day, i) ->
day.points = total
total -= velocity if days[i] and not days[i].off_day
day
# Do we need to make a link to right now?
days.push { date: now, points: 0 } if (now = new Date()) > cutoff
cb null, days
# Graph representing a trendling of actual issues.
'trendline': (actual, created_at, due_on) ->
start = +actual[0].date
# Values is a list of time from the start and points remaining.
values = _.map actual, ({ date, points }) ->
[ +date - start, points ]
# Now is an actual point too.
last = actual[actual.length - 1]
values.push [ + new Date() - start, last.points ]
# http://classroom.synonym.com/calculate-trendline-2709.html
b1 = 0 ; e = 0 ; c1 = 0
a = (l = values.length) * _.reduce(values, (sum, [ a, b ]) ->
b1 += a ; e += b
c1 += Math.pow(a, 2)
sum + (a * b)
, 0)
slope = (a - (b1 * e)) / ((l * c1) - (Math.pow(b1, 2)))
intercept = (e - (slope * b1)) / l
fn = (x) -> slope * x + intercept
# Milestone always has a creation date.
created_at = new Date created_at
# Due date can be empty.
due_on = if due_on then new Date(due_on) else new Date()
a = created_at - start
b = due_on - start
[
{
date: created_at
points: fn(a)
}, {
date: due_on
points: fn(b)
}
]
# The graph as a whole.
'render': ([ actual, ideal, trendline ], cb) ->
document.querySelector('#svg').innerHTML = ''
# Get available space.
{ height, width } = document.querySelector('#chart').getBoundingClientRect()
margin = { top: 30, right: 30, bottom: 40, left: 50 }
width -= margin.left + margin.right
height -= margin.top + margin.bottom
# Scales.
x = d3.time.scale().range([ 0, width ])
y = d3.scale.linear().range([ height, 0 ])
# Axes.
xAxis = d3.svg.axis().scale(x)
.orient("bottom")
# Show vertical lines...
.tickSize(-height)
# ...with day of the month...
.tickFormat( (d) -> d.getDate() )
# ...and give us a spacer.
.tickPadding(10)
yAxis = d3.svg.axis().scale(y)
.orient("left")
.tickSize(-width)
.ticks(5)
.tickPadding(10)
# Line generator.
line = d3.svg.line()
.interpolate("linear")
.x( (d) -> x(d.date) )
.y( (d) -> y(d.points) )
# Get the minimum and maximum date, and initial points.
x.domain([ ideal[0].date, ideal[ideal.length - 1].date ])
y.domain([ 0, ideal[0].points ]).nice()
# Add an SVG element with the desired dimensions and margin.
svg = d3.select("#svg").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
# Add the days x-axis.
svg.append("g")
.attr("class", "x axis day")
.attr("transform", "translate(0,#{height})")
.call(xAxis)
# Add the months x-axis.
m = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
]
mAxis = xAxis
.orient("top")
.tickSize(height)
.tickFormat( (d) -> m[d.getMonth()] )
.ticks(2)
svg.append("g")
.attr("class", "x axis month")
.attr("transform", "translate(0,#{height})")
.call(mAxis)
# Add the y-axis.
svg.append("g")
.attr("class", "y axis")
.call(yAxis)
# Add a line showing where we are now.
svg.append("svg:line")
.attr("class", "today")
.attr("x1", x(new Date()))
.attr("y1", 0)
.attr("x2", x(new Date()))
.attr("y2", height)
# Add the ideal line path.
svg.append("path")
.attr("class", "ideal line")
.attr("d", line.interpolate("basis")(ideal))
# Add the trendline path.
svg.append("path")
.attr("class", "trendline line")
.attr("d", line.interpolate("linear")(trendline))
# Add the actual line path.
svg.append("path")
.attr("class", "actual line")
.attr("d", line.interpolate("linear").y( (d) -> y(d.points) )(actual))
# Collect the tooltip here.
tooltip = d3.tip().attr('class', 'd3-tip').html ({ number, title }) ->
"##{number}: #{title}"
svg.call(tooltip)
# Show when we closed an issue.
svg.selectAll("a.issue")
.data(actual[1...]) # skip the starting point
.enter()
# A wrapping link.
.append('svg:a')
.attr("xlink:href", ({ html_url }) -> html_url )
.attr("xlink:show", 'new')
.append('svg:circle')
.attr("cx", ({ date }) -> x date )
.attr("cy", ({ points }) -> y points )
.attr("r", ({ radius }) -> 5 ) # fixed for now
.on('mouseover', tooltip.show)
.on('mouseout', tooltip.hide)
cb null

View File

@ -1,12 +1,12 @@
config = require '../models/config'
user = require '../models/user'
config = require '../models/config'
# Default "silent" callback for auth.
class Class
constructor: ->
# Setup a new client.
@client = new Firebase "https://#{config.firebase}.firebaseio.com"
@client = new Firebase "https://#{config.get('firebase')}.firebaseio.com"
# Check if we have a user in session.
@auth = new FirebaseSimpleLogin @client, (err, obj) =>
@ -26,7 +26,7 @@ class Class
@authCb = cb
# Login.
@auth.login config.provider,
@auth.login config.get('provider'),
'rememberMe': yes
'scope': 'public_repo'

67
src/modules/issues.coffee Normal file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env coffee
config = require '../models/config'
request = require './request'
module.exports =
# Used on an initial fetch of issues for a repo.
'get_all': (opts, cb) ->
# For each state...
one_status = (state, cb) ->
# Concat them here.
results = []
# One pageful fetch (next pages in series).
do fetch_page = (page = 1) ->
request.allIssues opts, { state, page }, (err, data) ->
# Errors?
return cb err if err
# Empty?
return cb null, results unless data.length
# Concat sorted (API does not sort on closed_at!).
results = results.concat _.sortBy data, 'closed_at'
# < 100 results?
return cb null, results if data.length < 100
# Fetch the next page then.
fetch_page page + 1
# For each `open` and `closed` issues in parallel.
async.parallel [
_.partial one_status, 'open'
_.partial one_status, 'closed'
], cb
# Filter an array of incoming issues based on a regex & save size on them.
'filter': (collection, regex, cb) ->
# The total size of all issues.
total = 0
# Which point counting mode are we in?
switch config.get 'chart.points'
# All issues are the same size
when 'ALL_ONE_SIZE'
total = collection.length
filtered = _.map collection, (issue) ->
issue.size = 1
issue
# Take the points size from issue label.
when 'LABELS'
filtered = _.filter collection, (issue) ->
# Skip if no labels exist.
return no unless labels = issue.labels
# Determine the total issue size from all labels.
issue.size = _.reduce labels, (sum, label) ->
# Not matching.
return sum unless matches = label.name.match(regex)
# Increase sum.
sum += parseInt matches[1]
, 0
# Increase the total.
total += issue.size
# Are we saving it?
!!issue.size
cb null, filtered, total

View File

@ -0,0 +1,43 @@
#!/usr/bin/env coffee
request = require './request'
# Get current/specified milestone for a repo.
module.exports = (repo, cb) ->
# Has description? Parse GFM.
parse = (data) ->
data.description = marked(data.description)[3...-5] if data.description
data
# Get a specific milestone.
if repo.milestone
request.oneMilestone repo, repo.milestone, (err, m) ->
# Errors?
return cb err if err
# Empty milestone?
if m.open_issues + m.closed_issues is 0
return cb null, "No issues for milestone `#{m.title}`"
# Parse GFM.
m = parse m
cb null, null, m
# Get the current milestone out of many.
else
request.allMilestones repo, (err, data) ->
# Errors?
return cb err if err
# Empty warning?
return cb null, "No open milestones for repo #{repo.path}" unless data.length
# The first milestone should be ending soonest.
m = data[0]
# Filter milestones without due date.
m = _.rest data, { 'due_on' : null }
# The first milestone should be ending soonest. Prefer milestones with due dates.
m = if m[0] then m[0] else data[0]
# Empty milestone?
if m.open_issues + m.closed_issues is 0
return cb null, "No issues for milestone `#{m.title}`"
# Parse GFM.
m = parse m
cb null, null, m

View File

@ -0,0 +1,67 @@
#!/usr/bin/env coffee
milestones = require './milestones'
issues = require './issues'
chart = require './chart'
# Setup a project and render it.
module.exports = (opts, cb) ->
# Resolve the milestone.
async.waterfall [ (cb) ->
milestones opts, (err, warn, milestone) ->
return cb err if err
return cb warn if warn
opts.milestone = milestone
cb null
# Get all issues.
(cb) ->
issues.get_all opts, cb
# Filter them to labeled ones.
(all, cb) ->
async.map all, (array, cb) ->
issues.filter array, opts.size_label, (err, filtered, total) ->
cb err, [ filtered, total ]
, (err, [ open, closed ]) ->
return cb err if err
# Empty?
return cb 'No matching issues found' if open[1] + closed[1] is 0
# Save the open/closed on us first.
opts.issues =
closed: { 'points': closed[1], 'data': closed[0] }
open: { 'points': open[1], 'data': open[0] }
cb null
# Create actual and ideal lines & render.
(cb) ->
total = opts.issues.open.points + opts.issues.closed.points
async.parallel [
_.partial(
chart.actual, # actual line
opts.issues.closed.data,
opts.milestone.created_at,
total
)
_.partial(
chart.ideal, # ideal line
opts.milestone.created_at,
opts.milestone.due_on,
total
)
], (err, values) ->
# Generate a trendline?
values.push(chart.trendline(
values[0],
opts.milestone.created_at,
opts.milestone.due_on
)) if values[0].length
# Render the chart.
chart.render values, cb
# Watch window resize from now on?
# window.onresize = doit if 'onresize' of window
], cb

View File

@ -20,8 +20,6 @@ module.exports =
# Get a repo.
'repo': (repo, cb) ->
data = _.defaults
'protocol': repo.protocol
'host': repo.host
'path': "/repos/#{repo.owner}/#{repo.name}"
'headers': headers user.get('token')
, defaults.github
@ -31,8 +29,6 @@ module.exports =
# Get all open milestones.
'allMilestones': (repo, cb) ->
data = _.defaults
'protocol': repo.protocol
'host': repo.host
'path': "/repos/#{repo.owner}/#{repo.name}/milestones"
'query': { 'state': 'open', 'sort': 'due_date', 'direction': 'asc' }
'headers': headers user.get('token')
@ -42,23 +38,23 @@ module.exports =
# Get one open milestone.
'oneMilestone': (repo, number, cb) ->
request
'protocol': repo.protocol
'host': repo.host
data = _.defaults
'path': "/repos/#{repo.owner}/#{repo.name}/milestones/#{number}"
'query': { 'state': 'open', 'sort': 'due_date', 'direction': 'asc' }
'headers': headers user.get('token')
, cb
, defaults.github
request data, cb
# Get all issues for a state.
'allIssues': (repo, query, cb) ->
request
'protocol': repo.protocol
'host': repo.host
data = _.defaults
'path': "/repos/#{repo.owner}/#{repo.name}/issues"
'query': _.extend query, { 'per_page': '100' }
'headers': headers user.get('token')
, cb
, defaults.github
request data, cb
# Make a request using SuperAgent.
request = ({ protocol, host, path, query, headers }, cb) ->

View File

@ -1,3 +1,5 @@
project = require '../../modules/project'
module.exports = Ractive.extend
'template': require '../../templates/pages/showChart'
@ -5,4 +7,4 @@ module.exports = Ractive.extend
'adapt': [ Ractive.adaptors.Ractive ]
init: ->
console.log @get 'route'
project @get 'route'