This commit is contained in:
neyric 2013-01-10 10:13:13 +01:00
Коммит 5bad8deaa0
14 изменённых файлов: 2606 добавлений и 0 удалений

2
.gitignore поставляемый Normal file
Просмотреть файл

@ -0,0 +1,2 @@
node_modules
config.js

34
README.md Normal file
Просмотреть файл

@ -0,0 +1,34 @@
# Github issues Gantt
![Gantt Diagram](/neyric/gh-issues-gantt/raw/master/screenshot.png "GitHub Issues Gantt")
## Installation
Requires Node.js
* Clone the git repository
* From within the repository, type npm install
## Configuration
* edit config.js
* edit public/config.js
## In GitHub
* You can set the duration of each ticket by adding a label "1D" (1 day), "2D" (2 days), ...
## TODO
* fix bug: when tickets overlap holidays
* Add a refresh button

6
config.example.js Normal file
Просмотреть файл

@ -0,0 +1,6 @@
module.exports = {
username: 'gh-username',
password: 'gh-password',
repo: 'user/repo'
};

9
package.json Normal file
Просмотреть файл

@ -0,0 +1,9 @@
{
"name": "gh-issues-gantt",
"version": "0.0.1",
"private": true,
"dependencies": {
"express": "3.0.6",
"request": "2.12.0"
}
}

26
public/config.example.js Normal file
Просмотреть файл

@ -0,0 +1,26 @@
var config = {
repo: 'user/repo',
weekDaysOff: [0,6],
colorByDev: {
"neyric": "ganttBlue",
"unassigned": "ganttRed"
},
holidays: {
"neyric": [
{ start: new Date(2013, 0, 11), end: new Date(2013, 0, 11), title: 'Déménagement'}
]
},
excludedMilestones: [
"Feature Paradize"
],
defaultDuration: 1 // in days
};

43
public/date-functions.js Normal file
Просмотреть файл

@ -0,0 +1,43 @@
/**
* Add 'amount' days to this date and return a new date object
*/
Date.prototype.AddDays = function(amount) {
var d = new Date(this.getTime());
d.setDate(this.getDate() + amount);
return d;
};
Date.prototype.weekNumber = function() {
var MaDate = this;
var mm = MaDate.getMonth(), jj = MaDate.getDate();
var annee = MaDate.getFullYear();
var NumSemaine = 0,
ListeMois = new Array(31,28,31,30,31,30,31,31,30,31,30,31);
if (annee %4 == 0 && annee %100 !=0 || annee %400 == 0) {ListeMois[1]=29};
var TotalJour=0;
for(cpt=0; cpt<mm; cpt++){TotalJour+=ListeMois[cpt];}
TotalJour+=jj;
DebutAn = new Date(annee,0,1);
var JourDebutAn;
JourDebutAn=DebutAn.getDay();
if(JourDebutAn==0){JourDebutAn=7};
TotalJour-=8-JourDebutAn;
NumSemaine = 1;
NumSemaine+=Math.floor(TotalJour/7);
if(TotalJour%7!=0){NumSemaine+=1};
return(NumSemaine);
}
Date.getToday = function() {
var now = new Date();
return now.getMidnight();
};
Date.prototype.getMidnight = function() {
return new Date( this.getFullYear(), this.getMonth(), this.getDate(), 0, 0, 0);
};

59
public/index.html Normal file
Просмотреть файл

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Github issues Gantt</title>
<meta charset="utf-8">
<link rel="stylesheet" href="http://taitems.github.com/jQuery.Gantt/css/style.css">
<link rel="stylesheet" href="http://twitter.github.com/bootstrap/assets/css/bootstrap.css">
<style type="text/css">
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 13px;
padding: 0 0 50px 0;
}
.contain { width: 1400px;margin: 0 auto;}
.fn-gantt .leftPanel .fn-label, .fn-gantt .leftPanel .name { width: 190px; }
.fn-gantt .leftPanel .desc { width: 195px; }
.fn-gantt .leftPanel { width: 385px; }
.fn-gantt .ganttYellow {
background-color: #FEEA77;
}
</style>
</head>
<body>
<div class="contain">
<h1>GitHub issues Gantt</h1>
<div id='msg' class="alert alert-block"></div>
<h2>By Milestone</h2>
<div class="gantt-milestone"></div>
<h2>By Dev</h2>
<div class="gantt-dev"></div>
</div>
<script src="/lib/jquery-1.7.2.min.js"></script>
<script src="/lib/jquery.fn.gantt.js"></script>
<script src="/lib/bootstrap-tooltip.js"></script>
<script src="/lib/bootstrap-popover.js"></script>
<!-- From server -->
<script src="/issues.js"></script>
<script src="/milestones.js"></script>
<script src="/config.js"></script>
<script src="/date-functions.js"></script>
<script src="/planning.js"></script>
<script>
$(function() {
"use strict";
Planning.init();
});
</script>
</body>
</html>

114
public/lib/bootstrap-popover.js поставляемый Normal file
Просмотреть файл

@ -0,0 +1,114 @@
/* ===========================================================
* bootstrap-popover.js v2.2.2
* http://twitter.github.com/bootstrap/javascript.html#popovers
* ===========================================================
* Copyright 2012 Twitter, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* =========================================================== */
!function ($) {
"use strict"; // jshint ;_;
/* POPOVER PUBLIC CLASS DEFINITION
* =============================== */
var Popover = function (element, options) {
this.init('popover', element, options)
}
/* NOTE: POPOVER EXTENDS BOOTSTRAP-TOOLTIP.js
========================================== */
Popover.prototype = $.extend({}, $.fn.tooltip.Constructor.prototype, {
constructor: Popover
, setContent: function () {
var $tip = this.tip()
, title = this.getTitle()
, content = this.getContent()
$tip.find('.popover-title')[this.options.html ? 'html' : 'text'](title)
$tip.find('.popover-content')[this.options.html ? 'html' : 'text'](content)
$tip.removeClass('fade top bottom left right in')
}
, hasContent: function () {
return this.getTitle() || this.getContent()
}
, getContent: function () {
var content
, $e = this.$element
, o = this.options
content = $e.attr('data-content')
|| (typeof o.content == 'function' ? o.content.call($e[0]) : o.content)
return content
}
, tip: function () {
if (!this.$tip) {
this.$tip = $(this.options.template)
}
return this.$tip
}
, destroy: function () {
this.hide().$element.off('.' + this.type).removeData(this.type)
}
})
/* POPOVER PLUGIN DEFINITION
* ======================= */
var old = $.fn.popover
$.fn.popover = function (option) {
return this.each(function () {
var $this = $(this)
, data = $this.data('popover')
, options = typeof option == 'object' && option
if (!data) $this.data('popover', (data = new Popover(this, options)))
if (typeof option == 'string') data[option]()
})
}
$.fn.popover.Constructor = Popover
$.fn.popover.defaults = $.extend({} , $.fn.tooltip.defaults, {
placement: 'right'
, trigger: 'click'
, content: ''
, template: '<div class="popover"><div class="arrow"></div><div class="popover-inner"><h3 class="popover-title"></h3><div class="popover-content"></div></div></div>'
})
/* POPOVER NO CONFLICT
* =================== */
$.fn.popover.noConflict = function () {
$.fn.popover = old
return this
}
}(window.jQuery);

287
public/lib/bootstrap-tooltip.js поставляемый Normal file
Просмотреть файл

@ -0,0 +1,287 @@
/* ===========================================================
* bootstrap-tooltip.js v2.2.2
* http://twitter.github.com/bootstrap/javascript.html#tooltips
* Inspired by the original jQuery.tipsy by Jason Frame
* ===========================================================
* Copyright 2012 Twitter, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ========================================================== */
!function ($) {
"use strict"; // jshint ;_;
/* TOOLTIP PUBLIC CLASS DEFINITION
* =============================== */
var Tooltip = function (element, options) {
this.init('tooltip', element, options)
}
Tooltip.prototype = {
constructor: Tooltip
, init: function (type, element, options) {
var eventIn
, eventOut
this.type = type
this.$element = $(element)
this.options = this.getOptions(options)
this.enabled = true
if (this.options.trigger == 'click') {
this.$element.on('click.' + this.type, this.options.selector, $.proxy(this.toggle, this))
} else if (this.options.trigger != 'manual') {
eventIn = this.options.trigger == 'hover' ? 'mouseenter' : 'focus'
eventOut = this.options.trigger == 'hover' ? 'mouseleave' : 'blur'
this.$element.on(eventIn + '.' + this.type, this.options.selector, $.proxy(this.enter, this))
this.$element.on(eventOut + '.' + this.type, this.options.selector, $.proxy(this.leave, this))
}
this.options.selector ?
(this._options = $.extend({}, this.options, { trigger: 'manual', selector: '' })) :
this.fixTitle()
}
, getOptions: function (options) {
options = $.extend({}, $.fn[this.type].defaults, options, this.$element.data())
if (options.delay && typeof options.delay == 'number') {
options.delay = {
show: options.delay
, hide: options.delay
}
}
return options
}
, enter: function (e) {
var self = $(e.currentTarget)[this.type](this._options).data(this.type)
if (!self.options.delay || !self.options.delay.show) return self.show()
clearTimeout(this.timeout)
self.hoverState = 'in'
this.timeout = setTimeout(function() {
if (self.hoverState == 'in') self.show()
}, self.options.delay.show)
}
, leave: function (e) {
var self = $(e.currentTarget)[this.type](this._options).data(this.type)
if (this.timeout) clearTimeout(this.timeout)
if (!self.options.delay || !self.options.delay.hide) return self.hide()
self.hoverState = 'out'
this.timeout = setTimeout(function() {
if (self.hoverState == 'out') self.hide()
}, self.options.delay.hide)
}
, show: function () {
var $tip
, inside
, pos
, actualWidth
, actualHeight
, placement
, tp
if (this.hasContent() && this.enabled) {
$tip = this.tip()
this.setContent()
if (this.options.animation) {
$tip.addClass('fade')
}
placement = typeof this.options.placement == 'function' ?
this.options.placement.call(this, $tip[0], this.$element[0]) :
this.options.placement
inside = /in/.test(placement)
$tip
.detach()
.css({ top: 0, left: 0, display: 'block' })
.insertAfter(this.$element)
pos = this.getPosition(inside)
actualWidth = $tip[0].offsetWidth
actualHeight = $tip[0].offsetHeight
switch (inside ? placement.split(' ')[1] : placement) {
case 'bottom':
tp = {top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2}
break
case 'top':
tp = {top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2}
break
case 'left':
tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth}
break
case 'right':
tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width}
break
}
$tip
.offset(tp)
.addClass(placement)
.addClass('in')
}
}
, setContent: function () {
var $tip = this.tip()
, title = this.getTitle()
$tip.find('.tooltip-inner')[this.options.html ? 'html' : 'text'](title)
$tip.removeClass('fade in top bottom left right')
}
, hide: function () {
var that = this
, $tip = this.tip()
$tip.removeClass('in')
function removeWithAnimation() {
var timeout = setTimeout(function () {
$tip.off($.support.transition.end).detach()
}, 500)
$tip.one($.support.transition.end, function () {
clearTimeout(timeout)
$tip.detach()
})
}
$.support.transition && this.$tip.hasClass('fade') ?
removeWithAnimation() :
$tip.detach()
return this
}
, fixTitle: function () {
var $e = this.$element
if ($e.attr('title') || typeof($e.attr('data-original-title')) != 'string') {
$e.attr('data-original-title', $e.attr('title') || '').removeAttr('title')
}
}
, hasContent: function () {
return this.getTitle()
}
, getPosition: function (inside) {
return $.extend({}, (inside ? {top: 0, left: 0} : this.$element.offset()), {
width: this.$element[0].offsetWidth
, height: this.$element[0].offsetHeight
})
}
, getTitle: function () {
var title
, $e = this.$element
, o = this.options
title = $e.attr('data-original-title')
|| (typeof o.title == 'function' ? o.title.call($e[0]) : o.title)
return title
}
, tip: function () {
return this.$tip = this.$tip || $(this.options.template)
}
, validate: function () {
if (!this.$element[0].parentNode) {
this.hide()
this.$element = null
this.options = null
}
}
, enable: function () {
this.enabled = true
}
, disable: function () {
this.enabled = false
}
, toggleEnabled: function () {
this.enabled = !this.enabled
}
, toggle: function (e) {
var self = $(e.currentTarget)[this.type](this._options).data(this.type)
self[self.tip().hasClass('in') ? 'hide' : 'show']()
}
, destroy: function () {
this.hide().$element.off('.' + this.type).removeData(this.type)
}
}
/* TOOLTIP PLUGIN DEFINITION
* ========================= */
var old = $.fn.tooltip
$.fn.tooltip = function ( option ) {
return this.each(function () {
var $this = $(this)
, data = $this.data('tooltip')
, options = typeof option == 'object' && option
if (!data) $this.data('tooltip', (data = new Tooltip(this, options)))
if (typeof option == 'string') data[option]()
})
}
$.fn.tooltip.Constructor = Tooltip
$.fn.tooltip.defaults = {
animation: true
, placement: 'top'
, selector: false
, template: '<div class="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>'
, trigger: 'hover'
, title: ''
, delay: 0
, html: false
}
/* TOOLTIP NO CONFLICT
* =================== */
$.fn.tooltip.noConflict = function () {
$.fn.tooltip = old
return this
}
}(window.jQuery);

4
public/lib/jquery-1.7.2.min.js поставляемый Normal file

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

1619
public/lib/jquery.fn.gantt.js поставляемый Normal file

Разница между файлами не показана из-за своего большого размера Загрузить разницу

319
public/planning.js Normal file
Просмотреть файл

@ -0,0 +1,319 @@
/*global $,milestones,issues,window,config*/
/**
* TODO:
* - Bug when tickets overlap holidays
* - Doc for duration labels
*/
var Planning = {
milestonesById: {},
developpers: {},
init: function() {
// Sort milestones by due date
this.sorted_milestones = milestones.sort( function (a,b){
return a.due_on > b.due_on ? 1 : -1;
});
// Index milestones by id & add milestone issues
milestones.forEach(function(milestone) {
milestone.issues = [];
this.milestonesById[milestone.id] = milestone;
}, this);
// Run the plannification algorithm
this.planner();
// Render the Gantt charts
$(".gantt-milestone").gantt({
source: this.getPlanningByMilestone(),
navigate: "scroll",
scale: "days",
maxScale: "months",
minScale: "days",
itemsPerPage: 100,
onItemClick: function(data) {
Planning.onItemClick(data);
}
});
$(".gantt-dev").gantt({
source: this.getPlanningForDevs(),
navigate: "scroll",
scale: "days",
maxScale: "months",
minScale: "days",
itemsPerPage: 100,
onItemClick: function(data) {
Planning.onItemClick(data);
}
});
},
onItemClick: function(data) {
var baseUrl = 'https://github.com/'+config.repo, url;
if(data.issue) { url = baseUrl + '/issues/'+data.issue; }
if(data.milestone) { url = baseUrl + '/issues?milestone='+data.milestone; }
if(data.milestone_release) { url = baseUrl + '/issues/milestones/'+data.milestone_release+'/edit'; }
if(url) { window.open(url,"_blank"); }
},
planner: function () {
// Dispatch issues in milestones
issues.forEach(function(issue){
if(!issue.milestone) {
$('#msg').append("<p><a href='https://github.com/"+config.repo+"/issues/"+issue.number+"' target='new'>issue #"+issue.number+"</a> has no milestone !</p>");
return;
}
this.milestonesById[issue.milestone.id].issues.push(issue);
}, this);
this.sorted_milestones.forEach(function(milestone) {
// assign tickets to each developper
milestone.issues.forEach(function(issue) {
// Developpeur et sa date de dispo
var dev = this.devNameFromIssue(issue);
if(!this.developpers[dev]) {
this.developpers[dev] = {
next: Date.getToday(),
issues: []
};
}
// Planification
this.planIssueForDev(dev, issue);
}, this);
}, this);
},
devNameFromIssue: function(issue) {
return issue.assignee ? issue.assignee.login : 'unassigned';
},
getDurationForIssue: function (issue) {
var numericLabels = issue.labels.filter(function(l){ return l.name.match(/^\d+D$/); }),
name;
if(numericLabels.length > 0) {
name = numericLabels[0].name;
return parseInt( name.substr(0, name.length-1), 10);
}
return config.defaultDuration;
},
planIssueForDev: function (dev, issue) {
// Planification
var durationInDays = this.getDurationForIssue(issue);
var start = this.developpers[dev].next;
// handle holidays
var devHolidays = config.holidays[dev];
if(devHolidays) {
devHolidays.forEach(function(holiday){
if(start >= holiday.start && start <= holiday.end) {
start = holiday.end.AddDays(1);
}
}, this);
}
// Skip start on week-end
while( config.weekDaysOff.indexOf(start.getDay()) != -1 ) {
start = start.AddDays(1);
}
var end = start.AddDays(durationInDays);
// if [start,end] overlap week-ends, count the numbers of days to add
if ( start.weekNumber() != end.weekNumber() ) {
var diffDays = (end.weekNumber()-start.weekNumber())*2;
end = end.AddDays(diffDays);
}
issue.planning = {
start: start,
end: end.AddDays(-1)
};
this.developpers[dev].next = end;
this.developpers[dev].issues.push(issue);
},
genItemFromIssue: function (issue) {
var dev = this.devNameFromIssue(issue);
return {
from: "/Date("+issue.planning.start.getTime()+")/",
to: "/Date("+issue.planning.end.getTime()+")/",
label: issue.title,
desc: '#'+issue.number + ': ' + issue.title,
customClass: config.colorByDev[dev] || "ganttRed",
dataObj: {
issue: issue.number
}
};
},
/**
* Generate the planning for devs :
*/
getPlanningForDevs: function () {
var planning = [];
for(var dev in this.developpers) {
// Regroupe les tickets par milestone
var hisMilestones = {};
for(var i = 0 ; i < this.developpers[dev].issues.length ; i++) {
var issue = this.developpers[dev].issues[i];
if(!hisMilestones[issue.milestone.title]) {
hisMilestones[issue.milestone.title] = [];
}
hisMilestones[issue.milestone.title].push( this.genItemFromIssue(issue));
}
// Génère le planning
var mIndex = 0;
for(var m in hisMilestones) {
if(config.excludedMilestones.indexOf(m) != -1) {
break;
}
planning.push({
name: mIndex === 0 ? dev : " ",
desc: m,
values: hisMilestones[m]
});
mIndex++;
}
if(config.holidays[dev]) {
var values = config.holidays[dev].map(function(holiday) {
return {
from: "/Date("+holiday.start.getTime()+")/",
to: "/Date("+holiday.end.getTime()+")/",
label: holiday.title || 'holiday',
desc: holiday.title || 'holiday',
customClass: config.colorByDev[dev] || "ganttRed"
};
}, this);
planning.push({
name: " ",
desc: "holidays",
values: values
});
}
}
return planning;
},
getPlanningByMilestone: function () {
// for each milestone get issues assigned dates and group by assignee
var planning = [];
for(var m = 0 ; m < this.sorted_milestones.length ; m++) {
var milestone = this.sorted_milestones[m];
if(config.excludedMilestones.indexOf(milestone.title) != -1) {
break;
}
var itsDevs = {};
var min = (new Date(2018, 0, 1)).getTime(), max = (new Date()).getTime();
milestone.issues.forEach(function(issue){
var dev = issue.assignee ? issue.assignee.login : 'unassigned';
if(!itsDevs[dev]) { itsDevs[dev] = []; }
itsDevs[dev].push( this.genItemFromIssue(issue));
if(issue.planning.start.getTime() < min)
min = issue.planning.start.getTime();
if(issue.planning.end.getTime() > max)
max = issue.planning.end.getTime();
}, this);
var releaseDate = (new Date(milestone.due_on)).getMidnight().getTime();
planning.push({
name: milestone.title,
desc: " ",
values: [{
from: "/Date("+min+")/",
to: "/Date("+max+")/",
label: milestone.title,
desc: milestone.title,
customClass: "ganttOrange",
dataObj: {
milestone: milestone.number
}
},
{
from: "/Date("+releaseDate+")/",
to: "/Date("+releaseDate+")/",
label: "★",
desc: "Due date for : "+milestone.title,
customClass: "ganttYellow",
dataObj: {
milestone_release: milestone.number
}
}
]
});
// Génère le planning
for(var d in itsDevs) {
planning.push({
name: " ",
desc: d,
values: itsDevs[d]
});
}
}
return planning;
}
};

Двоичные данные
screenshot.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 173 KiB

84
server.js Normal file
Просмотреть файл

@ -0,0 +1,84 @@
var express = require('express'),
request = require('request');
var app = express(),
config = require('./config.js'),
baseUrl = "https://api.github.com/repos/"+config.repo;
app.use(express["static"](__dirname + '/public'));
app.get('/', function(req, res){
res.sendfile(__dirname + '/public/index.html');
});
var memo_issues = null,
memo_milestones = null;
app.get('/issues.js', function(req, res) {
res.set('Content-Type', 'application/javascript');
var all_issues = [];
function fetchIssues (cb, url, tmpIssues) {
if(memo_issues) {
cb(null, memo_issues);
return;
}
var theUrl = url || baseUrl+"/issues?per_page=100&status=open";
console.log(theUrl);
request.get({
url: theUrl,
'auth': config.username+":"+config.password
}, function (error, response, body) {
var issues = (tmpIssues ? tmpIssues : []).concat(JSON.parse(body));
var links = {};
response.headers.link.split(', ').forEach(function(headLink){
var s = headLink.split('; ');
links[s[1]] = s[0].substr(1, s[0].length-2);
});
if(links['rel="next"']) {
fetchIssues(cb, links['rel="next"'], issues);
}
else {
memo_issues = issues;
cb(null, issues);
}
});
}
fetchIssues(function(err, issues) {
res.send("var issues = "+JSON.stringify(issues)+";");
});
});
app.get('/milestones.js', function(req, res) {
res.set('Content-Type', 'application/javascript');
if(memo_milestones) {
res.send("var milestones = "+memo_milestones+";");
return;
}
request.get({
url: baseUrl+"/milestones?per_page=100&status=open",
'auth': config.username+":"+config.password
}, function (error, response, body) {
memo_milestones = body;
res.send("var milestones = "+body+";");
});
});
app.listen(3000);
console.log('Listening on port 3000');