diff --git a/Gemfile b/Gemfile
index a596b99..a242ea2 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 0af3d51..c14d647 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 ab5fc1b..0767b82 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 0000000..b129619
--- /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 0000000..12dfe4d
--- /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 = $('
', {
+ class: 'person'
+ style: 'display: block;'
+ })
+ author_name = $('' + author.author + '
')
+ author_commit_info_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 0000000..e7a120f
--- /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 0000000..8f81631
--- /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 85e43ed..b1a2342 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 0000000..32b17d8
--- /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 0000000..2a74409
--- /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 ec3da96..399bcf5 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 0000000..b7b2738
--- /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 c802c60..0a1e537 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 0000000..1f303f6
--- /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 0000000..784e08c
--- /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 0000000..e69de29
--- /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 0000000..8d2e203
--- /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 0000000..367f0af
--- /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 0000000..b888176
--- /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 0000000..9bfa261
--- /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 0000000..986a4c1
--- /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 0000000..c97b322
--- /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 0000000..f9c70da
--- /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
--
libgit2 0.21.2