summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKarlo Soriano <dev+karlo@aelogica.com>2013-05-09 13:00:56 +0800
committerkarlo57 <karlo.karlo.karlo@gmail.com>2013-06-05 16:51:48 +0800
commit71d67e6557acb1ce3beeec2c2c6deb35015bd8bb (patch)
treec8e23f80ee62359d8db8574056ea69ac70eb4406
parentb9d989dc056a2a2b9316ff9aa06b57c736426871 (diff)
downloadgitlab-ce-71d67e6557acb1ce3beeec2c2c6deb35015bd8bb.tar.gz
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
-rw-r--r--Gemfile7
-rw-r--r--Gemfile.lock21
-rw-r--r--app/assets/javascripts/application.js2
-rw-r--r--app/assets/javascripts/stat_graph.js.coffee6
-rw-r--r--app/assets/javascripts/stat_graph_contributors.js.coffee61
-rw-r--r--app/assets/javascripts/stat_graph_contributors_graph.js.coffee166
-rw-r--r--app/assets/javascripts/stat_graph_contributors_util.js.coffee91
-rw-r--r--app/assets/stylesheets/application.scss1
-rw-r--r--app/assets/stylesheets/sections/stat_graph.scss56
-rw-r--r--app/controllers/stat_graph_controller.rb14
-rw-r--r--app/views/layouts/nav/_project.html.haml2
-rw-r--r--app/views/stat_graph/show.html.haml29
-rw-r--r--config/routes.rb1
-rw-r--r--lib/gitlab/git_stats.rb20
-rw-r--r--lib/gitlab/git_stats_log_parser.rb32
-rw-r--r--spec/javascripts/helpers/.gitkeep0
-rw-r--r--spec/javascripts/stat_graph_contributors_graph_spec.js125
-rw-r--r--spec/javascripts/stat_graph_contributors_util_spec.js200
-rw-r--r--spec/javascripts/stat_graph_spec.js17
-rw-r--r--spec/javascripts/support/jasmine.yml76
-rw-r--r--spec/javascripts/support/jasmine_helper.rb11
-rw-r--r--spec/lib/gitlab/git_stats_log_parser_spec.rb37
-rw-r--r--spec/lib/gitlab/git_stats_spec.rb36
23 files changed, 1011 insertions, 0 deletions
diff --git a/Gemfile b/Gemfile
index a596b99de48..a242ea2db30 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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
diff --git a/Gemfile.lock b/Gemfile.lock
index 0af3d516eec..c14d6474828 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index ab5fc1b860d..0767b82032d 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -27,3 +27,5 @@
//= require branch-graph
//= require ace-src-noconflict/ace
//= require_tree .
+//= require d3
+//= require underscore
diff --git a/app/assets/javascripts/stat_graph.js.coffee b/app/assets/javascripts/stat_graph.js.coffee
new file mode 100644
index 00000000000..b129619696f
--- /dev/null
+++ b/app/assets/javascripts/stat_graph.js.coffee
@@ -0,0 +1,6 @@
+class window.StatGraph
+ @log: {}
+ @get_log: ->
+ @log
+ @set_log: (data) ->
+ @log = data
diff --git a/app/assets/javascripts/stat_graph_contributors.js.coffee b/app/assets/javascripts/stat_graph_contributors.js.coffee
new file mode 100644
index 00000000000..12dfe4da841
--- /dev/null
+++ b/app/assets/javascripts/stat_graph_contributors.js.coffee
@@ -0,0 +1,61 @@
+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
diff --git a/app/assets/javascripts/stat_graph_contributors_graph.js.coffee b/app/assets/javascripts/stat_graph_contributors_graph.js.coffee
new file mode 100644
index 00000000000..e7a120fb572
--- /dev/null
+++ b/app/assets/javascripts/stat_graph_contributors_graph.js.coffee
@@ -0,0 +1,166 @@
+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)
diff --git a/app/assets/javascripts/stat_graph_contributors_util.js.coffee b/app/assets/javascripts/stat_graph_contributors_util.js.coffee
new file mode 100644
index 00000000000..8f816313db3
--- /dev/null
+++ b/app/assets/javascripts/stat_graph_contributors_util.js.coffee
@@ -0,0 +1,91 @@
+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
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 85e43ed0d35..b1a23427add 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -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";
diff --git a/app/assets/stylesheets/sections/stat_graph.scss b/app/assets/stylesheets/sections/stat_graph.scss
new file mode 100644
index 00000000000..32b17d85e76
--- /dev/null
+++ b/app/assets/stylesheets/sections/stat_graph.scss
@@ -0,0 +1,56 @@
+.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;
+}
diff --git a/app/controllers/stat_graph_controller.rb b/app/controllers/stat_graph_controller.rb
new file mode 100644
index 00000000000..2a74409809f
--- /dev/null
+++ b/app/controllers/stat_graph_controller.rb
@@ -0,0 +1,14 @@
+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
diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml
index ec3da964037..399bcf5de2e 100644
--- a/app/views/layouts/nav/_project.html.haml
+++ b/app/views/layouts/nav/_project.html.haml
@@ -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
diff --git a/app/views/stat_graph/show.html.haml b/app/views/stat_graph/show.html.haml
new file mode 100644
index 00000000000..b7b27387c01
--- /dev/null
+++ b/app/views/stat_graph/show.html.haml
@@ -0,0 +1,29 @@
+.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
diff --git a/config/routes.rb b/config/routes.rb
index c802c60382d..0a1e537412c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/lib/gitlab/git_stats.rb b/lib/gitlab/git_stats.rb
new file mode 100644
index 00000000000..1f303f6de64
--- /dev/null
+++ b/lib/gitlab/git_stats.rb
@@ -0,0 +1,20 @@
+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
diff --git a/lib/gitlab/git_stats_log_parser.rb b/lib/gitlab/git_stats_log_parser.rb
new file mode 100644
index 00000000000..784e08c27b9
--- /dev/null
+++ b/lib/gitlab/git_stats_log_parser.rb
@@ -0,0 +1,32 @@
+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
diff --git a/spec/javascripts/helpers/.gitkeep b/spec/javascripts/helpers/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/spec/javascripts/helpers/.gitkeep
diff --git a/spec/javascripts/stat_graph_contributors_graph_spec.js b/spec/javascripts/stat_graph_contributors_graph_spec.js
new file mode 100644
index 00000000000..8d2e2038a55
--- /dev/null
+++ b/spec/javascripts/stat_graph_contributors_graph_spec.js
@@ -0,0 +1,125 @@
+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()
+ spyOn(instance.y, 'domain')
+ instance.set_y_domain()
+ expect(instance.y.domain).toHaveBeenCalledWith(30)
+ })
+ })
+
+ describe("#set_domain", function () {
+ it("calls set_x_domain and set_y_domain", function () {
+ var instance = new ContributorsGraph()
+ spyOn(instance, 'set_x_domain')
+ spyOn(instance, 'set_y_domain')
+ instance.set_domain()
+ expect(instance.set_x_domain).toHaveBeenCalled()
+ expect(instance.set_y_domain).toHaveBeenCalled()
+ })
+ })
+
+ describe("#set_data", function () {
+ it("sets the data", function () {
+ var instance = new ContributorsGraph()
+ instance.set_data("20")
+ expect(instance.data).toEqual("20")
+ })
+ })
+})
+
+describe("ContributorsMasterGraph", function () {
+
+ describe("#process_dates", function () {
+ it("gets and parses dates", function () {
+ var graph = new ContributorsMasterGraph()
+ var data = 'random data here'
+ spyOn(graph, 'parse_dates')
+ spyOn(graph, 'get_dates').andReturn("get")
+ spyOn(ContributorsGraph,'set_dates').andCallThrough()
+ graph.process_dates(data)
+ expect(graph.parse_dates).toHaveBeenCalledWith(data)
+ expect(graph.get_dates).toHaveBeenCalledWith(data)
+ expect(ContributorsGraph.set_dates).toHaveBeenCalledWith("get")
+ })
+ })
+
+ describe("#get_dates", function () {
+ it("plucks the date field from data collection", function () {
+ var graph = new ContributorsMasterGraph()
+ var data = [{date: "2013-01-01"}, {date: "2012-12-15"}]
+ expect(graph.get_dates(data)).toEqual(["2013-01-01", "2012-12-15"])
+ })
+ })
+
+ describe("#parse_dates", function () {
+ it("parses the dates", function () {
+ var graph = new ContributorsMasterGraph()
+ var parseDate = d3.time.format("%Y-%m-%d").parse
+ var data = [{date: "2013-01-01"}, {date: "2012-12-15"}]
+ var correct = [{date: parseDate(data[0].date)}, {date: parseDate(data[1].date)}]
+ graph.parse_dates(data)
+ expect(data).toEqual(correct)
+ })
+ })
+
+
+})
diff --git a/spec/javascripts/stat_graph_contributors_util_spec.js b/spec/javascripts/stat_graph_contributors_util_spec.js
new file mode 100644
index 00000000000..367f0af05f8
--- /dev/null
+++ b/spec/javascripts/stat_graph_contributors_util_spec.js
@@ -0,0 +1,200 @@
+describe("ContributorsStatGraphUtil", function () {
+
+ describe("#parse_log", function () {
+ it("returns a correctly parsed log", function () {
+ var fake_log = [
+ {author: "Karlo Soriano", date: "2013-05-09", additions: 471},
+ {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 6, deletions: 1},
+ {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 19, deletions: 3},
+ {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 29, deletions: 3}]
+
+ var correct_parsed_log = {
+ total: [
+ {date: "2013-05-09", additions: 471, deletions: 0, commits: 1},
+ {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}],
+ by_author:
+ [
+ {
+ author: "Karlo Soriano",
+ "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}
+ },
+ {
+ author: "Dmitriy Zaporozhets",
+ "2013-05-08": {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}
+ }
+ ]
+ }
+ expect(ContributorsStatGraphUtil.parse_log(fake_log)).toEqual(correct_parsed_log)
+ })
+ })
+
+ describe("#store_data", function () {
+
+ var fake_entry = {author: "Karlo Soriano", date: "2013-05-09", additions: 471}
+ var fake_total = {}
+ var fake_by_author = {}
+
+ it("calls #store_commits", function () {
+ spyOn(ContributorsStatGraphUtil, 'store_commits')
+ ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author)
+ expect(ContributorsStatGraphUtil.store_commits).toHaveBeenCalled()
+ })
+
+ it("calls #store_additions", function () {
+ spyOn(ContributorsStatGraphUtil, 'store_additions')
+ ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author)
+ expect(ContributorsStatGraphUtil.store_additions).toHaveBeenCalled()
+ })
+
+ it("calls #store_deletions", function () {
+ spyOn(ContributorsStatGraphUtil, 'store_deletions')
+ ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author)
+ expect(ContributorsStatGraphUtil.store_deletions).toHaveBeenCalled()
+ })
+
+ })
+
+ describe("#store_commits", function () {
+ var fake_total = "fake_total"
+ var fake_by_author = "fake_by_author"
+
+ it("calls #add twice with arguments fake_total and fake_by_author respectively", function () {
+ spyOn(ContributorsStatGraphUtil, 'add')
+ ContributorsStatGraphUtil.store_commits(fake_total, fake_by_author)
+ expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "commits", 1], ["fake_by_author", "commits", 1]])
+ })
+ })
+
+ describe("#add", function () {
+ it("adds 1 to current test_field in collection", function () {
+ var fake_collection = {test_field: 10}
+ ContributorsStatGraphUtil.add(fake_collection, "test_field", 1)
+ expect(fake_collection.test_field).toEqual(11)
+ })
+
+ it("inits and adds 1 if test_field in collection is not defined", function () {
+ var fake_collection = {}
+ ContributorsStatGraphUtil.add(fake_collection, "test_field", 1)
+ expect(fake_collection.test_field).toEqual(1)
+ })
+ })
+
+ describe("#store_additions", function () {
+ var fake_entry = {additions: 10}
+ var fake_total= "fake_total"
+ var fake_by_author = "fake_by_author"
+ it("calls #add twice with arguments fake_total and fake_by_author respectively", function () {
+ spyOn(ContributorsStatGraphUtil, 'add')
+ ContributorsStatGraphUtil.store_additions(fake_entry, fake_total, fake_by_author)
+ expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "additions", 10], ["fake_by_author", "additions", 10]])
+ })
+ })
+
+ describe("#store_deletions", function () {
+ var fake_entry = {deletions: 10}
+ var fake_total= "fake_total"
+ var fake_by_author = "fake_by_author"
+ it("calls #add twice with arguments fake_total and fake_by_author respectively", function () {
+ spyOn(ContributorsStatGraphUtil, 'add')
+ ContributorsStatGraphUtil.store_deletions(fake_entry, fake_total, fake_by_author)
+ expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "deletions", 10], ["fake_by_author", "deletions", 10]])
+ })
+ })
+
+ describe("#add_date", function () {
+ it("adds a date field to the collection", function () {
+ var fake_date = "2013-10-02"
+ var fake_collection = {}
+ ContributorsStatGraphUtil.add_date(fake_date, fake_collection)
+ expect(fake_collection[fake_date].date).toEqual("2013-10-02")
+ })
+ })
+
+ describe("#add_author", function () {
+ it("adds an author field to the collection", function () {
+ var fake_author = "Author"
+ var fake_collection = {}
+ ContributorsStatGraphUtil.add_author(fake_author, fake_collection)
+ expect(fake_collection[fake_author].author).toEqual("Author")
+ })
+ })
+
+ describe("#get_total_data", function () {
+ it("returns the collection sorted via specified field", function () {
+ var fake_parsed_log = {
+ total: [{date: "2013-05-09", additions: 471, deletions: 0, commits: 1},
+ {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}],
+ by_author:[
+ {
+ author: "Karlo Soriano",
+ "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}
+ },
+ {
+ author: "Dmitriy Zaporozhets",
+ "2013-05-08": {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}
+ }
+ ]};
+ var correct_total_data = [{date: "2013-05-08", commits: 3},
+ {date: "2013-05-09", commits: 1}];
+ expect(ContributorsStatGraphUtil.get_total_data(fake_parsed_log, "commits")).toEqual(correct_total_data)
+ })
+ })
+
+ describe("#pick_field", function () {
+ it("returns the collection with only the specified field and date", function () {
+ var fake_parsed_log_total = [{date: "2013-05-09", additions: 471, deletions: 0, commits: 1},
+ {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}];
+ ContributorsStatGraphUtil.pick_field(fake_parsed_log_total, "commits")
+ var correct_pick_field_data = [{date: "2013-05-09", commits: 1},{date: "2013-05-08", commits: 3}];
+ expect(ContributorsStatGraphUtil.pick_field(fake_parsed_log_total, "commits")).toEqual(correct_pick_field_data)
+ })
+ })
+
+ describe("#get_author_data", function () {
+ it("returns the log by author sorted by specified field", function () {
+ var fake_parsed_log = {
+ total: [{date: "2013-05-09", additions: 471, deletions: 0, commits: 1},
+ {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}],
+ by_author:[
+ {
+ author: "Karlo Soriano",
+ "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}
+ },
+ {
+ author: "Dmitriy Zaporozhets",
+ "2013-05-08": {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}
+ }
+ ]}
+ var correct_author_data = [{author:"Dmitriy Zaporozhets",dates:{"2013-05-08":3},deletions:7,additions:54,"commits":3},
+ {author:"Karlo Soriano",dates:{"2013-05-09":1},deletions:0,additions:471,commits:1}]
+ expect(ContributorsStatGraphUtil.get_author_data(fake_parsed_log, "commits")).toEqual(correct_author_data)
+ })
+ })
+
+ describe("#parse_log_entry", function () {
+ it("adds the corresponding info from the log entry to the author", function () {
+ var fake_log_entry = { author: "Karlo Soriano",
+ "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}
+ }
+ var correct_parsed_log = {author:"Karlo Soriano",dates:{"2013-05-09":1},deletions:0,additions:471,commits:1}
+ expect(ContributorsStatGraphUtil.parse_log_entry(fake_log_entry, 'commits', null)).toEqual(correct_parsed_log)
+ })
+ })
+
+ describe("#in_range", function () {
+ var date = "2013-05-09"
+ it("returns true if date_range is null", function () {
+ expect(ContributorsStatGraphUtil.in_range(date, null)).toEqual(true)
+ })
+ it("returns true if date is in range", function () {
+ var date_range = [new Date("2013-01-01"), new Date("2013-12-12")]
+ expect(ContributorsStatGraphUtil.in_range(date, date_range)).toEqual(true)
+ })
+ it("returns false if date is not in range", function () {
+ var date_range = [new Date("1999-12-01"), new Date("2000-12-01")]
+ expect(ContributorsStatGraphUtil.in_range(date, date_range)).toEqual(false)
+ })
+ })
+
+
+}) \ No newline at end of file
diff --git a/spec/javascripts/stat_graph_spec.js b/spec/javascripts/stat_graph_spec.js
new file mode 100644
index 00000000000..b8881769ac1
--- /dev/null
+++ b/spec/javascripts/stat_graph_spec.js
@@ -0,0 +1,17 @@
+describe("StatGraph", function () {
+
+ describe("#get_log", function () {
+ it("returns log", function () {
+ StatGraph.log = "test";
+ expect(StatGraph.get_log()).toBe("test");
+ });
+ });
+
+ describe("#set_log", function () {
+ it("sets the log", function () {
+ StatGraph.set_log("test");
+ expect(StatGraph.log).toBe("test");
+ })
+ })
+
+}); \ No newline at end of file
diff --git a/spec/javascripts/support/jasmine.yml b/spec/javascripts/support/jasmine.yml
new file mode 100644
index 00000000000..9bfa261a356
--- /dev/null
+++ b/spec/javascripts/support/jasmine.yml
@@ -0,0 +1,76 @@
+# src_files
+#
+# Return an array of filepaths relative to src_dir to include before jasmine specs.
+# Default: []
+#
+# EXAMPLE:
+#
+# src_files:
+# - lib/source1.js
+# - lib/source2.js
+# - dist/**/*.js
+#
+src_files:
+ - assets/application.js
+
+# stylesheets
+#
+# Return an array of stylesheet filepaths relative to src_dir to include before jasmine specs.
+# Default: []
+#
+# EXAMPLE:
+#
+# stylesheets:
+# - css/style.css
+# - stylesheets/*.css
+#
+stylesheets:
+ - stylesheets/**/*.css
+
+# helpers
+#
+# Return an array of filepaths relative to spec_dir to include before jasmine specs.
+# Default: ["helpers/**/*.js"]
+#
+# EXAMPLE:
+#
+# helpers:
+# - helpers/**/*.js
+#
+helpers:
+ - helpers/**/*.js
+
+# spec_files
+#
+# Return an array of filepaths relative to spec_dir to include.
+# Default: ["**/*[sS]pec.js"]
+#
+# EXAMPLE:
+#
+# spec_files:
+# - **/*[sS]pec.js
+#
+spec_files:
+ - '**/*[sS]pec.js'
+
+# src_dir
+#
+# Source directory path. Your src_files must be returned relative to this path. Will use root if left blank.
+# Default: project root
+#
+# EXAMPLE:
+#
+# src_dir: public
+#
+src_dir:
+
+# spec_dir
+#
+# Spec directory path. Your spec_files must be returned relative to this path.
+# Default: spec/javascripts
+#
+# EXAMPLE:
+#
+# spec_dir: spec/javascripts
+#
+spec_dir: spec/javascripts
diff --git a/spec/javascripts/support/jasmine_helper.rb b/spec/javascripts/support/jasmine_helper.rb
new file mode 100644
index 00000000000..986a4c16f3e
--- /dev/null
+++ b/spec/javascripts/support/jasmine_helper.rb
@@ -0,0 +1,11 @@
+#Use this file to set/override Jasmine configuration options
+#You can remove it if you don't need it.
+#This file is loaded *after* jasmine.yml is interpreted.
+#
+#Example: using a different boot file.
+#Jasmine.configure do |config|
+# @config.boot_dir = '/absolute/path/to/boot_dir'
+# @config.boot_files = lambda { ['/absolute/path/to/boot_dir/file.js'] }
+#end
+#
+
diff --git a/spec/lib/gitlab/git_stats_log_parser_spec.rb b/spec/lib/gitlab/git_stats_log_parser_spec.rb
new file mode 100644
index 00000000000..c97b32275db
--- /dev/null
+++ b/spec/lib/gitlab/git_stats_log_parser_spec.rb
@@ -0,0 +1,37 @@
+require 'spec_helper'
+require 'gitlab/git_stats_log_parser'
+
+
+describe LogParser do
+
+ describe "#self.parse_log" do
+ context "log_from_git is a valid log" do
+ it "returns the correct log" do
+ fake_log = "Karlo Soriano
+2013-05-09
+
+ 14 files changed, 471 insertions(+)
+Dmitriy Zaporozhets
+2013-05-08
+
+ 1 file changed, 6 insertions(+), 1 deletion(-)
+Dmitriy Zaporozhets
+2013-05-08
+
+ 6 files changed, 19 insertions(+), 3 deletions(-)
+Dmitriy Zaporozhets
+2013-05-08
+
+ 3 files changed, 29 insertions(+), 3 deletions(-)";
+
+ lp = LogParser.parse_log(fake_log)
+ lp.should eq([
+ {author: "Karlo Soriano", date: "2013-05-09", additions: 471},
+ {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 6, deletions: 1},
+ {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 19, deletions: 3},
+ {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 29, deletions: 3}])
+ end
+ end
+ end
+
+end \ No newline at end of file
diff --git a/spec/lib/gitlab/git_stats_spec.rb b/spec/lib/gitlab/git_stats_spec.rb
new file mode 100644
index 00000000000..f9c70da2bd5
--- /dev/null
+++ b/spec/lib/gitlab/git_stats_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe Gitlab::GitStats do
+
+ describe "#parsed_log" do
+ let(:stats) { Gitlab::GitStats.new(nil, nil) }
+ before(:each) do
+ stats.stub(:log).and_return("anything")
+ end
+
+ context "LogParser#parse_log returns 'test'" do
+ it "returns 'test'" do
+ LogParser.stub(:parse_log).and_return("test")
+ stats.parsed_log.should eq("test")
+ end
+ end
+ end
+
+ describe "#log" do
+ let(:repo) { Repository.new(nil, nil) }
+ let(:gs) { Gitlab::GitStats.new(repo.raw, repo.root_ref) }
+
+ before(:each) do
+ repo.stub(:raw).and_return(nil)
+ repo.stub(:root_ref).and_return(nil)
+ repo.raw.stub(:git)
+ end
+
+ context "repo.git.run returns 'test'" do
+ it "returns 'test'" do
+ repo.raw.git.stub(:run).and_return("test")
+ gs.log.should eq("test")
+ end
+ end
+ end
+end \ No newline at end of file