Commit 71d67e65 authored by Karlo Soriano's avatar Karlo Soriano Committed by karlo57

Contributors graphs feature for GitLab

Created tests and refactored some code along the way

Added stat graph util spec, refactored code

finsihed up tests and refactors

finsihed up tests and refactors
parent b9d989dc
......@@ -107,6 +107,12 @@ gem 'tinder', '~> 1.9.2'
# HipChat integration
gem "hipchat", "~> 0.9.0"
# d3
gem "d3_rails", "~> 3.1.4"
# underscore-rails
gem "underscore-rails", "~> 1.4.4"
group :assets do
gem "sass-rails"
gem "coffee-rails"
......@@ -177,6 +183,7 @@ group :development, :test do
gem 'poltergeist', '~> 1.3.0'
gem 'spork', '~> 1.0rc'
gem 'jasmine'
end
group :test do
......
......@@ -69,6 +69,8 @@ GEM
celluloid (0.14.0)
timers (>= 1.0.0)
charlock_holmes (0.6.9.4)
childprocess (0.3.9)
ffi (~> 1.0, >= 1.0.11)
chosen-rails (0.9.8)
railties (~> 3.0)
thor (~> 0.14)
......@@ -92,6 +94,8 @@ GEM
simplecov (>= 0.7)
thor
crack (0.3.2)
d3_rails (3.1.4)
railties (>= 3.1.0)
daemons (1.1.9)
database_cleaner (1.0.1)
debug_inspector (0.0.2)
......@@ -216,6 +220,12 @@ GEM
multi_xml (>= 0.5.2)
httpauth (0.2.0)
i18n (0.6.1)
jasmine (1.3.2)
jasmine-core (~> 1.3.1)
rack (~> 1.0)
rspec (>= 1.3.1)
selenium-webdriver (>= 0.1.3)
jasmine-core (1.3.1)
journey (1.0.4)
jquery-atwho-rails (0.3.0)
jquery-rails (2.1.3)
......@@ -392,6 +402,7 @@ GEM
rspec-mocks (~> 2.13.0)
ruby-progressbar (1.0.2)
rubyntlm (0.1.1)
rubyzip (0.9.9)
sanitize (2.0.3)
nokogiri (>= 1.4.4, < 1.6)
sass (3.2.9)
......@@ -408,6 +419,11 @@ GEM
select2-rails (3.3.1)
sass-rails (>= 3.2)
thor (~> 0.14)
selenium-webdriver (2.32.1)
childprocess (>= 0.2.5)
multi_json (~> 1.0)
rubyzip
websocket (~> 1.0.4)
settingslogic (2.0.9)
sexp_processor (4.2.1)
shoulda-matchers (2.1.0)
......@@ -482,6 +498,7 @@ GEM
uglifier (2.0.1)
execjs (>= 0.3.0)
multi_json (~> 1.0, >= 1.0.2)
underscore-rails (1.4.4)
virtus (0.5.4)
backports (~> 2.6.1)
descendants_tracker (~> 0.0.1)
......@@ -490,6 +507,7 @@ GEM
webmock (1.11.0)
addressable (>= 2.2.7)
crack (>= 0.3.2)
websocket (1.0.7)
xpath (2.0.0)
nokogiri (~> 1.3)
yajl-ruby (1.1.0)
......@@ -510,6 +528,7 @@ DEPENDENCIES
coffee-rails
colored
coveralls
d3_rails (~> 3.1.4)
database_cleaner
devise
email_spec
......@@ -536,6 +555,7 @@ DEPENDENCIES
haml-rails
hipchat (~> 0.9.0)
httparty
jasmine
jquery-atwho-rails (= 0.3.0)
jquery-rails (= 2.1.3)
jquery-turbolinks
......@@ -586,4 +606,5 @@ DEPENDENCIES
tinder (~> 1.9.2)
turbolinks
uglifier
underscore-rails (~> 1.4.4)
webmock
......@@ -27,3 +27,5 @@
//= require branch-graph
//= require ace-src-noconflict/ace
//= require_tree .
//= require d3
//= require underscore
class window.StatGraph
@log: {}
@get_log: ->
@log
@set_log: (data) ->
@log = data
class window.ContributorsStatGraph
init: (log) ->
@parsed_log = ContributorsStatGraphUtil.parse_log(log)
@set_current_field("commits")
total_commits = ContributorsStatGraphUtil.get_total_data(@parsed_log, @field)
author_commits = ContributorsStatGraphUtil.get_author_data(@parsed_log, @field)
@add_master_graph(total_commits)
@add_authors_graph(author_commits)
@change_date_header()
add_master_graph: (total_data) ->
@master_graph = new ContributorsMasterGraph(total_data)
@master_graph.draw()
add_authors_graph: (author_data) ->
@authors = []
_.each(author_data, (d) =>
author_header = @create_author_header(d)
$(".contributors-list").append(author_header)
@authors[d.author] = author_graph = new ContributorsAuthorGraph(d.dates)
author_graph.draw()
)
format_author_commit_info: (author) ->
author.commits + " commits " + author.additions + " ++ / " + author.deletions + " --"
create_author_header: (author) ->
list_item = $('<li/>', {
class: 'person'
style: 'display: block;'
})
author_name = $('<h4>' + author.author + '</h4>')
author_commit_info_span = $('<span/>', {
class: 'commits'
})
author_commit_info = @format_author_commit_info(author)
author_commit_info_span.text(author_commit_info)
list_item.append(author_name)
list_item.append(author_commit_info_span)
list_item
redraw_master: ->
total_data = ContributorsStatGraphUtil.get_total_data(@parsed_log, @field)
@master_graph.set_data(total_data)
@master_graph.redraw()
redraw_authors: ->
$("ol").html("")
x_domain = ContributorsGraph.prototype.x_domain
author_commits = ContributorsStatGraphUtil.get_author_data(@parsed_log, @field, x_domain)
_.each(author_commits, (d) =>
@redraw_author_commit_info(d)
$(@authors[d.author].list_item).appendTo("ol")
@authors[d.author].set_data(d.dates)
@authors[d.author].redraw()
)
set_current_field: (field) ->
@field = field
change_date_header: ->
x_domain = ContributorsGraph.prototype.x_domain
print_date_format = d3.time.format("%B %e %Y");
print = print_date_format(x_domain[0]) + " - " + print_date_format(x_domain[1]);
$("#date_header").text(print);
redraw_author_commit_info: (author) ->
author_list_item = $(@authors[author.author].list_item)
author_commit_info = @format_author_commit_info(author)
author_list_item.find("span").text(author_commit_info)
\ No newline at end of file
class window.ContributorsGraph
MARGIN:
top: 20
right: 20
bottom: 30
left: 50
x_domain: null
y_domain: null
dates: []
@set_x_domain: (data) =>
@prototype.x_domain = data
@set_y_domain: (data) =>
@prototype.y_domain = [0, d3.max(data, (d) ->
d.commits = d.commits ? d.additions ? d.deletions
)]
@init_x_domain: (data) =>
@prototype.x_domain = d3.extent(data, (d) ->
d.date
)
@init_y_domain: (data) =>
@prototype.y_domain = [0, d3.max(data, (d) ->
d.commits = d.commits ? d.additions ? d.deletions
)]
@init_domain: (data) =>
@init_x_domain(data)
@init_y_domain(data)
@set_dates: (data) =>
@prototype.dates = data
set_x_domain: ->
@x.domain(@x_domain)
set_y_domain: ->
@y.domain(@y_domain)
set_domain: ->
@set_x_domain()
@set_y_domain()
create_scale: (width, height) ->
@x = d3.time.scale().range([0, width]).clamp(true)
@y = d3.scale.linear().range([height, 0]).nice()
draw_x_axis: ->
@svg.append("g").attr("class", "x axis").attr("transform", "translate(0, #{@height})")
.call(@x_axis);
draw_y_axis: ->
@svg.append("g").attr("class", "y axis").call(@y_axis)
set_data: (data) ->
@data = data
class window.ContributorsMasterGraph extends ContributorsGraph
constructor: (@data) ->
@width = 1100
@height = 125
@x = null
@y = null
@x_axis = null
@y_axis = null
@area = null
@svg = null
@brush = null
@x_max_domain = null
process_dates: (data) ->
dates = @get_dates(data)
@parse_dates(data)
ContributorsGraph.set_dates(dates)
get_dates: (data) ->
_.pluck(data, 'date')
parse_dates: (data) ->
parseDate = d3.time.format("%Y-%m-%d").parse
data.forEach((d) ->
d.date = parseDate(d.date)
)
create_scale: ->
super @width, @height
create_axes: ->
@x_axis = d3.svg.axis().scale(@x).orient("bottom")
@y_axis = d3.svg.axis().scale(@y).orient("left")
create_svg: ->
@svg = d3.select("#contributors-master").append("svg")
.attr("width", @width + @MARGIN.left + @MARGIN.right)
.attr("height", @height + @MARGIN.top + @MARGIN.bottom)
.attr("class", "tint-box")
.append("g")
.attr("transform", "translate(" + @MARGIN.left + "," + @MARGIN.top + ")")
create_area: (x, y) ->
@area = d3.svg.area().x((d) ->
x(d.date)
).y0(@height).y1((d) ->
y(d.commits = d.commits ? d.additions ? d.deletions)
).interpolate("basis")
create_brush: ->
@brush = d3.svg.brush().x(@x).on("brushend", @update_content);
draw_path: (data) ->
@svg.append("path").datum(data).attr("class", "area").attr("d", @area);
add_brush: ->
@svg.append("g").attr("class", "selection").call(@brush).selectAll("rect").attr("height", @height);
update_content: =>
ContributorsGraph.set_x_domain(if @brush.empty() then @x_max_domain else @brush.extent())
$("#brush_change").trigger('change')
draw: ->
@process_dates(@data)
@create_scale()
@create_axes()
ContributorsGraph.init_domain(@data)
@x_max_domain = @x_domain
@set_domain()
@create_area(@x, @y)
@create_svg()
@create_brush()
@draw_path(@data)
@draw_x_axis()
@draw_y_axis()
@add_brush()
redraw: ->
@process_dates(@data)
ContributorsGraph.set_y_domain(@data)
@set_y_domain()
@svg.select("path").datum(@data)
@svg.select("path").attr("d", @area)
@svg.select(".y.axis").call(@y_axis)
class window.ContributorsAuthorGraph extends ContributorsGraph
constructor: (@data) ->
@width = 490
@height = 130
@x = null
@y = null
@x_axis = null
@y_axis = null
@area = null
@svg = null
@list_item = null
create_scale: ->
super @width, @height
create_axes: ->
@x_axis = d3.svg.axis().scale(@x).orient("bottom").tickFormat(d3.time.format("%m/%d"));
@y_axis = d3.svg.axis().scale(@y).orient("left")
create_area: (x, y) ->
@area = d3.svg.area().x((d) ->
parseDate = d3.time.format("%Y-%m-%d").parse
x(parseDate(d))
).y0(@height).y1((d) =>
if @data[d]? then y(@data[d]) else y(0)
).interpolate("basis")
create_svg: ->
@list_item = d3.selectAll(".person")[0].pop()
@svg = d3.select(@list_item).append("svg")
.attr("width", @width + @MARGIN.left + @MARGIN.right)
.attr("height", @height + @MARGIN.top + @MARGIN.bottom)
.attr("class", "spark")
.append("g")
.attr("transform", "translate(" + @MARGIN.left + "," + @MARGIN.top + ")")
draw_path: (data) ->
@svg.append("path").datum(data).attr("class", "area-contributor").attr("d", @area);
draw: ->
@create_scale()
@create_axes()
@set_domain()
@create_area(@x, @y)
@create_svg()
@draw_path(@dates)
@draw_x_axis()
@draw_y_axis()
redraw: ->
@set_domain()
@svg.select("path").datum(@dates)
@svg.select("path").attr("d", @area)
@svg.select(".x.axis").call(@x_axis)
@svg.select(".y.axis").call(@y_axis)
window.ContributorsStatGraphUtil =
parse_log: (log) ->
total = {}
by_author = {}
for entry in log
@add_date(entry.date, total) unless total[entry.date]?
@add_author(entry.author, by_author) unless by_author[entry.author]?
@add_date(entry.date, by_author[entry.author]) unless by_author[entry.author][entry.date]
@store_data(entry, total[entry.date], by_author[entry.author][entry.date])
total = _.toArray(total)
by_author = _.toArray(by_author)
total: total, by_author: by_author
add_date: (date, collection) ->
collection[date] = {}
collection[date].date = date
add_author: (author, by_author) ->
by_author[author] = {}
by_author[author].author = author
store_data: (entry, total, by_author) ->
@store_commits(total, by_author)
@store_additions(entry, total, by_author)
@store_deletions(entry, total, by_author)
store_commits: (total, by_author) ->
@add(total, "commits", 1)
@add(by_author, "commits", 1)
add: (collection, field, value) ->
collection[field] ?= 0
collection[field] += value
store_additions: (entry, total, by_author) ->
entry.additions ?= 0
@add(total, "additions", entry.additions)
@add(by_author, "additions", entry.additions)
store_deletions: (entry, total, by_author) ->
entry.deletions ?= 0
@add(total, "deletions", entry.deletions)
@add(by_author, "deletions", entry.deletions)
get_total_data: (parsed_log, field) ->
log = parsed_log.total
total_data = @pick_field(log, field)
_.sortBy(total_data, (d) ->
d.date
)
pick_field: (log, field) ->
total_data = []
_.each(log, (d) ->
total_data.push(_.pick(d, [field, 'date']))
)
total_data
get_author_data: (parsed_log, field, date_range = null) ->
log = parsed_log.by_author
author_data = []
_.each(log, (log_entry) =>
parsed_log_entry = @parse_log_entry(log_entry, field, date_range)
if not _.isEmpty(parsed_log_entry.dates)
author_data.push(parsed_log_entry)
)
_.sortBy(author_data, (d) ->
d[field]
).reverse()
parse_log_entry: (log_entry, field, date_range) ->
parsed_entry = {}
parsed_entry.author = log_entry.author
parsed_entry.dates = {}
parsed_entry.commits = parsed_entry.additions = parsed_entry.deletions = 0
_.each(_.omit(log_entry, 'author'), (value, key) =>
if @in_range(value.date, date_range)
parsed_entry.dates[value.date] = value[field]
parsed_entry.commits += value.commits
parsed_entry.additions += value.additions
parsed_entry.deletions += value.deletions
)
return parsed_entry
in_range: (date, date_range) ->
if date_range is null || date_range[0] <= new Date(date) <= date_range[1]
true
else
false
\ No newline at end of file
......@@ -37,6 +37,7 @@
@import "sections/wiki.scss";
@import "sections/wall.scss";
@import "sections/dashboard.scss";
@import "sections/stat_graph.scss";
@import "highlight/white.scss";
@import "highlight/dark.scss";
......
.tint-box {
border-radius: 6px;
background: #f3f3f3;
position: relative;
margin-bottom: 10px;
}
.area {
fill: #1db34f;
fill-opacity: 0.5;
}
.axis {
fill: #aaa;
font-size: 10px;
}
#contributors .person {
-moz-box-sizing: border-box;
box-sizing: border-box;
float: left;
border-radius: 2px;
margin: 10px;
border: 1px solid #ddd;
}
.contributors-list {
margin: 0 0 10px 0;
list-style: none;
padding: 0;
}
#contributors .person .spark {
display: block;
background: #f7f7f7;
}
#contributors .person .area-contributor {
fill: #f17f49;
}
.selection rect {
fill: #333;
fill-opacity: 0.1;
stroke: #333;
stroke-width: 1px;
stroke-opacity: 0.4;
shape-rendering: crispedges;
stroke-dasharray: 3 3;
}
.right{
float: right;
display: inline-block;
margin-top: 5px;
}
class StatGraphController < ProjectResourceController
# Authorize
before_filter :authorize_read_project!
before_filter :authorize_code_access!
before_filter :require_non_empty_project
def show
@repo = @project.repository
@stats = Gitlab::GitStats.new(@repo.raw, @repo.root_ref)
@log = @stats.parsed_log.to_json
end
end
\ No newline at end of file
......@@ -11,6 +11,8 @@
= link_to "Commits", project_commits_path(@project, @ref || @repository.root_ref)
= nav_link(controller: %w(graph)) do
= link_to "Network", project_graph_path(@project, @ref || @repository.root_ref)
= nav_link(controller: %w(stat_graph)) do
= link_to "Graphs", project_stat_graph_path(@project, @ref || @repository.root_ref)
- if @project.issues_enabled
= nav_link(controller: %w(issues milestones labels)) do
......
.header.clearfix
.right
%select
%option{:value => "commits"} Commits
%option{:value => "additions"} Additions
%option{:value => "deletions"} Deletions
%h3#date_header
%input#brush_change{:type => "hidden"}
.graphs
#contributors-master
#contributors.clearfix
%ol.contributors-list.clearfix
:javascript
controller = new ContributorsStatGraph
controller.init(#{@log})
$("select").change( function () {
var field = $(this).val()
controller.set_current_field(field)
controller.redraw_master()
controller.redraw_authors()
})
$("#brush_change").change( function () {
controller.change_date_header()
controller.redraw_authors()
})
\ No newline at end of file
......@@ -190,6 +190,7 @@ Gitlab::Application.routes.draw do
resources :compare, only: [:index, :create]
resources :blame, only: [:show], constraints: {id: /.+/}
resources :graph, only: [:show], constraints: {id: /(?:[^.]|\.(?!json$))+/, format: /json/}
resources :stat_graph, only: [:show], constraints: {id: /(?:[^.]|\.(?!json$))+/, format: /json/}
match "/compare/:from...:to" => "compare#show", as: "compare", via: [:get, :post], constraints: {from: /.+/, to: /.+/}
scope module: :projects do
......
require 'gitlab/git_stats_log_parser'
module Gitlab
class GitStats
attr_accessor :repo, :ref
def initialize repo, ref
@repo, @ref = repo, ref
end
def log
args = ['--format=%aN%x0a%ad', '--date=short', '--shortstat', '--no-merges']
repo.git.run(nil, 'log', nil, {}, args)
end
def parsed_log
LogParser.parse_log(log)
end
end
end
class LogParser
#Parses the log file into a collection of commits
#Data model: {author, date, additions, deletions}
def self.parse_log log_from_git
log = log_from_git.split("\n")
i = 0
collection = []
entry = {}
while i <= log.size do
pos = i % 4
case pos
when 0
unless i == 0
collection.push(entry)
entry = {}
end
entry[:author] = log[i].to_s
when 1
entry[:date] = log[i].to_s
when 3
changes = log[i].split(",")
entry[:additions] = changes[1].to_i unless changes[1].nil?
entry[:deletions] = changes[2].to_i unless changes[2].nil?
end
i += 1
end
collection
end
end
\ No newline at end of file
describe("ContributorsGraph", function () {
describe("#set_x_domain", function () {
it("set the x_domain", function () {
ContributorsGraph.set_x_domain(20)
expect(ContributorsGraph.prototype.x_domain).toEqual(20)
})
})
describe("#set_y_domain", function () {
it("sets the y_domain", function () {
ContributorsGraph.set_y_domain([{commits: 30}])
expect(ContributorsGraph.prototype.y_domain).toEqual([0, 30])
})
})
describe("#init_x_domain", function () {
it("sets the initial x_domain", function () {
ContributorsGraph.init_x_domain([{date: "2013-01-31"}, {date: "2012-01-31"}])
expect(ContributorsGraph.prototype.x_domain).toEqual(["2012-01-31", "2013-01-31"])
})
})
describe("#init_y_domain", function () {
it("sets the initial y_domain", function () {
ContributorsGraph.init_y_domain([{commits: 30}])
expect(ContributorsGraph.prototype.y_domain).toEqual([0, 30])
})
})
describe("#init_domain", function () {
it("calls init_x_domain and init_y_domain", function () {
spyOn(ContributorsGraph, "init_x_domain")
spyOn(ContributorsGraph, "init_y_domain")
ContributorsGraph.init_domain()
expect(ContributorsGraph.init_x_domain).toHaveBeenCalled()
expect(ContributorsGraph.init_y_domain).toHaveBeenCalled()
})
})
describe("#set_dates", function () {
it("sets the dates", function () {
ContributorsGraph.set_dates("2013-12-01")
expect(ContributorsGraph.prototype.dates).toEqual("2013-12-01")
})
})
describe("#set_x_domain", function () {
it("sets the instance's x domain using the prototype's x_domain", function () {
ContributorsGraph.prototype.x_domain = 20
var instance = new ContributorsGraph()
instance.x = d3.time.scale().range([0, 100]).clamp(true)
spyOn(instance.x, 'domain')
instance.set_x_domain()
expect(instance.x.domain).toHaveBeenCalledWith(20)
})
})
describe("#set_y_domain", function () {
it("sets the instance's y domain using the prototype's y_domain", function () {
ContributorsGraph.prototype.y_domain = 30
var instance = new ContributorsGraph()
instance.y = d3.scale.linear().range([100, 0]).nice()