Commit 71d67e6557acb1ce3beeec2c2c6deb35015bd8bb

Authored by Karlo Soriano
Committed by karlo57
1 parent b9d989dc

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
Gemfile
... ... @@ -107,6 +107,12 @@ gem 'tinder', '~> 1.9.2'
107 107 # HipChat integration
108 108 gem "hipchat", "~> 0.9.0"
109 109  
  110 +# d3
  111 +gem "d3_rails", "~> 3.1.4"
  112 +
  113 +# underscore-rails
  114 +gem "underscore-rails", "~> 1.4.4"
  115 +
110 116 group :assets do
111 117 gem "sass-rails"
112 118 gem "coffee-rails"
... ... @@ -177,6 +183,7 @@ group :development, :test do
177 183 gem 'poltergeist', '~> 1.3.0'
178 184  
179 185 gem 'spork', '~> 1.0rc'
  186 + gem 'jasmine'
180 187 end
181 188  
182 189 group :test do
... ...
Gemfile.lock
... ... @@ -69,6 +69,8 @@ GEM
69 69 celluloid (0.14.0)
70 70 timers (>= 1.0.0)
71 71 charlock_holmes (0.6.9.4)
  72 + childprocess (0.3.9)
  73 + ffi (~> 1.0, >= 1.0.11)
72 74 chosen-rails (0.9.8)
73 75 railties (~> 3.0)
74 76 thor (~> 0.14)
... ... @@ -92,6 +94,8 @@ GEM
92 94 simplecov (>= 0.7)
93 95 thor
94 96 crack (0.3.2)
  97 + d3_rails (3.1.4)
  98 + railties (>= 3.1.0)
95 99 daemons (1.1.9)
96 100 database_cleaner (1.0.1)
97 101 debug_inspector (0.0.2)
... ... @@ -216,6 +220,12 @@ GEM
216 220 multi_xml (>= 0.5.2)
217 221 httpauth (0.2.0)
218 222 i18n (0.6.1)
  223 + jasmine (1.3.2)
  224 + jasmine-core (~> 1.3.1)
  225 + rack (~> 1.0)
  226 + rspec (>= 1.3.1)
  227 + selenium-webdriver (>= 0.1.3)
  228 + jasmine-core (1.3.1)
219 229 journey (1.0.4)
220 230 jquery-atwho-rails (0.3.0)
221 231 jquery-rails (2.1.3)
... ... @@ -392,6 +402,7 @@ GEM
392 402 rspec-mocks (~> 2.13.0)
393 403 ruby-progressbar (1.0.2)
394 404 rubyntlm (0.1.1)
  405 + rubyzip (0.9.9)
395 406 sanitize (2.0.3)
396 407 nokogiri (>= 1.4.4, < 1.6)
397 408 sass (3.2.9)
... ... @@ -408,6 +419,11 @@ GEM
408 419 select2-rails (3.3.1)
409 420 sass-rails (>= 3.2)
410 421 thor (~> 0.14)
  422 + selenium-webdriver (2.32.1)
  423 + childprocess (>= 0.2.5)
  424 + multi_json (~> 1.0)
  425 + rubyzip
  426 + websocket (~> 1.0.4)
411 427 settingslogic (2.0.9)
412 428 sexp_processor (4.2.1)
413 429 shoulda-matchers (2.1.0)
... ... @@ -482,6 +498,7 @@ GEM
482 498 uglifier (2.0.1)
483 499 execjs (>= 0.3.0)
484 500 multi_json (~> 1.0, >= 1.0.2)
  501 + underscore-rails (1.4.4)
485 502 virtus (0.5.4)
486 503 backports (~> 2.6.1)
487 504 descendants_tracker (~> 0.0.1)
... ... @@ -490,6 +507,7 @@ GEM
490 507 webmock (1.11.0)
491 508 addressable (>= 2.2.7)
492 509 crack (>= 0.3.2)
  510 + websocket (1.0.7)
493 511 xpath (2.0.0)
494 512 nokogiri (~> 1.3)
495 513 yajl-ruby (1.1.0)
... ... @@ -510,6 +528,7 @@ DEPENDENCIES
510 528 coffee-rails
511 529 colored
512 530 coveralls
  531 + d3_rails (~> 3.1.4)
513 532 database_cleaner
514 533 devise
515 534 email_spec
... ... @@ -536,6 +555,7 @@ DEPENDENCIES
536 555 haml-rails
537 556 hipchat (~> 0.9.0)
538 557 httparty
  558 + jasmine
539 559 jquery-atwho-rails (= 0.3.0)
540 560 jquery-rails (= 2.1.3)
541 561 jquery-turbolinks
... ... @@ -586,4 +606,5 @@ DEPENDENCIES
586 606 tinder (~> 1.9.2)
587 607 turbolinks
588 608 uglifier
  609 + underscore-rails (~> 1.4.4)
589 610 webmock
... ...
app/assets/javascripts/application.js
... ... @@ -27,3 +27,5 @@
27 27 //= require branch-graph
28 28 //= require ace-src-noconflict/ace
29 29 //= require_tree .
  30 +//= require d3
  31 +//= require underscore
... ...
app/assets/javascripts/stat_graph.js.coffee 0 → 100644
... ... @@ -0,0 +1,6 @@
  1 +class window.StatGraph
  2 + @log: {}
  3 + @get_log: ->
  4 + @log
  5 + @set_log: (data) ->
  6 + @log = data
... ...
app/assets/javascripts/stat_graph_contributors.js.coffee 0 → 100644
... ... @@ -0,0 +1,61 @@
  1 +class window.ContributorsStatGraph
  2 + init: (log) ->
  3 + @parsed_log = ContributorsStatGraphUtil.parse_log(log)
  4 + @set_current_field("commits")
  5 + total_commits = ContributorsStatGraphUtil.get_total_data(@parsed_log, @field)
  6 + author_commits = ContributorsStatGraphUtil.get_author_data(@parsed_log, @field)
  7 + @add_master_graph(total_commits)
  8 + @add_authors_graph(author_commits)
  9 + @change_date_header()
  10 + add_master_graph: (total_data) ->
  11 + @master_graph = new ContributorsMasterGraph(total_data)
  12 + @master_graph.draw()
  13 + add_authors_graph: (author_data) ->
  14 + @authors = []
  15 + _.each(author_data, (d) =>
  16 + author_header = @create_author_header(d)
  17 + $(".contributors-list").append(author_header)
  18 + @authors[d.author] = author_graph = new ContributorsAuthorGraph(d.dates)
  19 + author_graph.draw()
  20 + )
  21 + format_author_commit_info: (author) ->
  22 + author.commits + " commits " + author.additions + " ++ / " + author.deletions + " --"
  23 + create_author_header: (author) ->
  24 + list_item = $('<li/>', {
  25 + class: 'person'
  26 + style: 'display: block;'
  27 + })
  28 + author_name = $('<h4>' + author.author + '</h4>')
  29 + author_commit_info_span = $('<span/>', {
  30 + class: 'commits'
  31 + })
  32 + author_commit_info = @format_author_commit_info(author)
  33 + author_commit_info_span.text(author_commit_info)
  34 + list_item.append(author_name)
  35 + list_item.append(author_commit_info_span)
  36 + list_item
  37 + redraw_master: ->
  38 + total_data = ContributorsStatGraphUtil.get_total_data(@parsed_log, @field)
  39 + @master_graph.set_data(total_data)
  40 + @master_graph.redraw()
  41 + redraw_authors: ->
  42 + $("ol").html("")
  43 + x_domain = ContributorsGraph.prototype.x_domain
  44 + author_commits = ContributorsStatGraphUtil.get_author_data(@parsed_log, @field, x_domain)
  45 + _.each(author_commits, (d) =>
  46 + @redraw_author_commit_info(d)
  47 + $(@authors[d.author].list_item).appendTo("ol")
  48 + @authors[d.author].set_data(d.dates)
  49 + @authors[d.author].redraw()
  50 + )
  51 + set_current_field: (field) ->
  52 + @field = field
  53 + change_date_header: ->
  54 + x_domain = ContributorsGraph.prototype.x_domain
  55 + print_date_format = d3.time.format("%B %e %Y");
  56 + print = print_date_format(x_domain[0]) + " - " + print_date_format(x_domain[1]);
  57 + $("#date_header").text(print);
  58 + redraw_author_commit_info: (author) ->
  59 + author_list_item = $(@authors[author.author].list_item)
  60 + author_commit_info = @format_author_commit_info(author)
  61 + author_list_item.find("span").text(author_commit_info)
0 62 \ No newline at end of file
... ...
app/assets/javascripts/stat_graph_contributors_graph.js.coffee 0 → 100644
... ... @@ -0,0 +1,166 @@
  1 +class window.ContributorsGraph
  2 + MARGIN:
  3 + top: 20
  4 + right: 20
  5 + bottom: 30
  6 + left: 50
  7 + x_domain: null
  8 + y_domain: null
  9 + dates: []
  10 + @set_x_domain: (data) =>
  11 + @prototype.x_domain = data
  12 + @set_y_domain: (data) =>
  13 + @prototype.y_domain = [0, d3.max(data, (d) ->
  14 + d.commits = d.commits ? d.additions ? d.deletions
  15 + )]
  16 + @init_x_domain: (data) =>
  17 + @prototype.x_domain = d3.extent(data, (d) ->
  18 + d.date
  19 + )
  20 + @init_y_domain: (data) =>
  21 + @prototype.y_domain = [0, d3.max(data, (d) ->
  22 + d.commits = d.commits ? d.additions ? d.deletions
  23 + )]
  24 + @init_domain: (data) =>
  25 + @init_x_domain(data)
  26 + @init_y_domain(data)
  27 + @set_dates: (data) =>
  28 + @prototype.dates = data
  29 + set_x_domain: ->
  30 + @x.domain(@x_domain)
  31 + set_y_domain: ->
  32 + @y.domain(@y_domain)
  33 + set_domain: ->
  34 + @set_x_domain()
  35 + @set_y_domain()
  36 + create_scale: (width, height) ->
  37 + @x = d3.time.scale().range([0, width]).clamp(true)
  38 + @y = d3.scale.linear().range([height, 0]).nice()
  39 + draw_x_axis: ->
  40 + @svg.append("g").attr("class", "x axis").attr("transform", "translate(0, #{@height})")
  41 + .call(@x_axis);
  42 + draw_y_axis: ->
  43 + @svg.append("g").attr("class", "y axis").call(@y_axis)
  44 + set_data: (data) ->
  45 + @data = data
  46 +
  47 +class window.ContributorsMasterGraph extends ContributorsGraph
  48 + constructor: (@data) ->
  49 + @width = 1100
  50 + @height = 125
  51 + @x = null
  52 + @y = null
  53 + @x_axis = null
  54 + @y_axis = null
  55 + @area = null
  56 + @svg = null
  57 + @brush = null
  58 + @x_max_domain = null
  59 + process_dates: (data) ->
  60 + dates = @get_dates(data)
  61 + @parse_dates(data)
  62 + ContributorsGraph.set_dates(dates)
  63 + get_dates: (data) ->
  64 + _.pluck(data, 'date')
  65 + parse_dates: (data) ->
  66 + parseDate = d3.time.format("%Y-%m-%d").parse
  67 + data.forEach((d) ->
  68 + d.date = parseDate(d.date)
  69 + )
  70 + create_scale: ->
  71 + super @width, @height
  72 + create_axes: ->
  73 + @x_axis = d3.svg.axis().scale(@x).orient("bottom")
  74 + @y_axis = d3.svg.axis().scale(@y).orient("left")
  75 + create_svg: ->
  76 + @svg = d3.select("#contributors-master").append("svg")
  77 + .attr("width", @width + @MARGIN.left + @MARGIN.right)
  78 + .attr("height", @height + @MARGIN.top + @MARGIN.bottom)
  79 + .attr("class", "tint-box")
  80 + .append("g")
  81 + .attr("transform", "translate(" + @MARGIN.left + "," + @MARGIN.top + ")")
  82 + create_area: (x, y) ->
  83 + @area = d3.svg.area().x((d) ->
  84 + x(d.date)
  85 + ).y0(@height).y1((d) ->
  86 + y(d.commits = d.commits ? d.additions ? d.deletions)
  87 + ).interpolate("basis")
  88 + create_brush: ->
  89 + @brush = d3.svg.brush().x(@x).on("brushend", @update_content);
  90 + draw_path: (data) ->
  91 + @svg.append("path").datum(data).attr("class", "area").attr("d", @area);
  92 + add_brush: ->
  93 + @svg.append("g").attr("class", "selection").call(@brush).selectAll("rect").attr("height", @height);
  94 + update_content: =>
  95 + ContributorsGraph.set_x_domain(if @brush.empty() then @x_max_domain else @brush.extent())
  96 + $("#brush_change").trigger('change')
  97 + draw: ->
  98 + @process_dates(@data)
  99 + @create_scale()
  100 + @create_axes()
  101 + ContributorsGraph.init_domain(@data)
  102 + @x_max_domain = @x_domain
  103 + @set_domain()
  104 + @create_area(@x, @y)
  105 + @create_svg()
  106 + @create_brush()
  107 + @draw_path(@data)
  108 + @draw_x_axis()
  109 + @draw_y_axis()
  110 + @add_brush()
  111 + redraw: ->
  112 + @process_dates(@data)
  113 + ContributorsGraph.set_y_domain(@data)
  114 + @set_y_domain()
  115 + @svg.select("path").datum(@data)
  116 + @svg.select("path").attr("d", @area)
  117 + @svg.select(".y.axis").call(@y_axis)
  118 +
  119 +class window.ContributorsAuthorGraph extends ContributorsGraph
  120 + constructor: (@data) ->
  121 + @width = 490
  122 + @height = 130
  123 + @x = null
  124 + @y = null
  125 + @x_axis = null
  126 + @y_axis = null
  127 + @area = null
  128 + @svg = null
  129 + @list_item = null
  130 + create_scale: ->
  131 + super @width, @height
  132 + create_axes: ->
  133 + @x_axis = d3.svg.axis().scale(@x).orient("bottom").tickFormat(d3.time.format("%m/%d"));
  134 + @y_axis = d3.svg.axis().scale(@y).orient("left")
  135 + create_area: (x, y) ->
  136 + @area = d3.svg.area().x((d) ->
  137 + parseDate = d3.time.format("%Y-%m-%d").parse
  138 + x(parseDate(d))
  139 + ).y0(@height).y1((d) =>
  140 + if @data[d]? then y(@data[d]) else y(0)
  141 + ).interpolate("basis")
  142 + create_svg: ->
  143 + @list_item = d3.selectAll(".person")[0].pop()
  144 + @svg = d3.select(@list_item).append("svg")
  145 + .attr("width", @width + @MARGIN.left + @MARGIN.right)
  146 + .attr("height", @height + @MARGIN.top + @MARGIN.bottom)
  147 + .attr("class", "spark")
  148 + .append("g")
  149 + .attr("transform", "translate(" + @MARGIN.left + "," + @MARGIN.top + ")")
  150 + draw_path: (data) ->
  151 + @svg.append("path").datum(data).attr("class", "area-contributor").attr("d", @area);
  152 + draw: ->
  153 + @create_scale()
  154 + @create_axes()
  155 + @set_domain()
  156 + @create_area(@x, @y)
  157 + @create_svg()
  158 + @draw_path(@dates)
  159 + @draw_x_axis()
  160 + @draw_y_axis()
  161 + redraw: ->
  162 + @set_domain()
  163 + @svg.select("path").datum(@dates)
  164 + @svg.select("path").attr("d", @area)
  165 + @svg.select(".x.axis").call(@x_axis)
  166 + @svg.select(".y.axis").call(@y_axis)
... ...
app/assets/javascripts/stat_graph_contributors_util.js.coffee 0 → 100644
... ... @@ -0,0 +1,91 @@
  1 +window.ContributorsStatGraphUtil =
  2 + parse_log: (log) ->
  3 + total = {}
  4 + by_author = {}
  5 + for entry in log
  6 + @add_date(entry.date, total) unless total[entry.date]?
  7 + @add_author(entry.author, by_author) unless by_author[entry.author]?
  8 + @add_date(entry.date, by_author[entry.author]) unless by_author[entry.author][entry.date]
  9 + @store_data(entry, total[entry.date], by_author[entry.author][entry.date])
  10 + total = _.toArray(total)
  11 + by_author = _.toArray(by_author)
  12 + total: total, by_author: by_author
  13 +
  14 + add_date: (date, collection) ->
  15 + collection[date] = {}
  16 + collection[date].date = date
  17 +
  18 + add_author: (author, by_author) ->
  19 + by_author[author] = {}
  20 + by_author[author].author = author
  21 +
  22 + store_data: (entry, total, by_author) ->
  23 + @store_commits(total, by_author)
  24 + @store_additions(entry, total, by_author)
  25 + @store_deletions(entry, total, by_author)
  26 +
  27 + store_commits: (total, by_author) ->
  28 + @add(total, "commits", 1)
  29 + @add(by_author, "commits", 1)
  30 +
  31 + add: (collection, field, value) ->
  32 + collection[field] ?= 0
  33 + collection[field] += value
  34 +
  35 + store_additions: (entry, total, by_author) ->
  36 + entry.additions ?= 0
  37 + @add(total, "additions", entry.additions)
  38 + @add(by_author, "additions", entry.additions)
  39 +
  40 + store_deletions: (entry, total, by_author) ->
  41 + entry.deletions ?= 0
  42 + @add(total, "deletions", entry.deletions)
  43 + @add(by_author, "deletions", entry.deletions)
  44 +
  45 + get_total_data: (parsed_log, field) ->
  46 + log = parsed_log.total
  47 + total_data = @pick_field(log, field)
  48 + _.sortBy(total_data, (d) ->
  49 + d.date
  50 + )
  51 + pick_field: (log, field) ->
  52 + total_data = []
  53 + _.each(log, (d) ->
  54 + total_data.push(_.pick(d, [field, 'date']))
  55 + )
  56 + total_data
  57 +
  58 + get_author_data: (parsed_log, field, date_range = null) ->
  59 + log = parsed_log.by_author
  60 + author_data = []
  61 +
  62 + _.each(log, (log_entry) =>
  63 + parsed_log_entry = @parse_log_entry(log_entry, field, date_range)
  64 + if not _.isEmpty(parsed_log_entry.dates)
  65 + author_data.push(parsed_log_entry)
  66 + )
  67 +
  68 + _.sortBy(author_data, (d) ->
  69 + d[field]
  70 + ).reverse()
  71 +
  72 + parse_log_entry: (log_entry, field, date_range) ->
  73 + parsed_entry = {}
  74 + parsed_entry.author = log_entry.author
  75 + parsed_entry.dates = {}
  76 + parsed_entry.commits = parsed_entry.additions = parsed_entry.deletions = 0
  77 + _.each(_.omit(log_entry, 'author'), (value, key) =>
  78 + if @in_range(value.date, date_range)
  79 + parsed_entry.dates[value.date] = value[field]
  80 + parsed_entry.commits += value.commits
  81 + parsed_entry.additions += value.additions
  82 + parsed_entry.deletions += value.deletions
  83 + )
  84 + return parsed_entry
  85 +
  86 + in_range: (date, date_range) ->
  87 + if date_range is null || date_range[0] <= new Date(date) <= date_range[1]
  88 + true
  89 + else
  90 + false
  91 +
0 92 \ No newline at end of file
... ...
app/assets/stylesheets/application.scss
... ... @@ -37,6 +37,7 @@
37 37 @import "sections/wiki.scss";
38 38 @import "sections/wall.scss";
39 39 @import "sections/dashboard.scss";
  40 +@import "sections/stat_graph.scss";
40 41  
41 42 @import "highlight/white.scss";
42 43 @import "highlight/dark.scss";
... ...
app/assets/stylesheets/sections/stat_graph.scss 0 → 100644
... ... @@ -0,0 +1,56 @@
  1 +.tint-box {
  2 + border-radius: 6px;
  3 + background: #f3f3f3;
  4 + position: relative;
  5 + margin-bottom: 10px;
  6 +}
  7 +
  8 +.area {
  9 + fill: #1db34f;
  10 + fill-opacity: 0.5;
  11 +}
  12 +
  13 +.axis {
  14 + fill: #aaa;
  15 + font-size: 10px;
  16 +}
  17 +
  18 +#contributors .person {
  19 + -moz-box-sizing: border-box;
  20 + box-sizing: border-box;
  21 + float: left;
  22 + border-radius: 2px;
  23 + margin: 10px;
  24 + border: 1px solid #ddd;
  25 +}
  26 +
  27 +.contributors-list {
  28 + margin: 0 0 10px 0;
  29 + list-style: none;
  30 + padding: 0;
  31 +}
  32 +
  33 +#contributors .person .spark {
  34 + display: block;
  35 + background: #f7f7f7;
  36 +}
  37 +
  38 +#contributors .person .area-contributor {
  39 + fill: #f17f49;
  40 +}
  41 +
  42 +.selection rect {
  43 + fill: #333;
  44 + fill-opacity: 0.1;
  45 + stroke: #333;
  46 + stroke-width: 1px;
  47 + stroke-opacity: 0.4;
  48 + shape-rendering: crispedges;
  49 + stroke-dasharray: 3 3;
  50 +}
  51 +
  52 +.right{
  53 + float: right;
  54 + display: inline-block;
  55 + margin-top: 5px;
  56 +}
... ...
app/controllers/stat_graph_controller.rb 0 → 100644
... ... @@ -0,0 +1,14 @@
  1 +class StatGraphController < ProjectResourceController
  2 +
  3 + # Authorize
  4 + before_filter :authorize_read_project!
  5 + before_filter :authorize_code_access!
  6 + before_filter :require_non_empty_project
  7 +
  8 + def show
  9 + @repo = @project.repository
  10 + @stats = Gitlab::GitStats.new(@repo.raw, @repo.root_ref)
  11 + @log = @stats.parsed_log.to_json
  12 + end
  13 +
  14 +end
0 15 \ No newline at end of file
... ...
app/views/layouts/nav/_project.html.haml
... ... @@ -11,6 +11,8 @@
11 11 = link_to "Commits", project_commits_path(@project, @ref || @repository.root_ref)
12 12 = nav_link(controller: %w(graph)) do
13 13 = link_to "Network", project_graph_path(@project, @ref || @repository.root_ref)
  14 + = nav_link(controller: %w(stat_graph)) do
  15 + = link_to "Graphs", project_stat_graph_path(@project, @ref || @repository.root_ref)
14 16  
15 17 - if @project.issues_enabled
16 18 = nav_link(controller: %w(issues milestones labels)) do
... ...
app/views/stat_graph/show.html.haml 0 → 100644
... ... @@ -0,0 +1,29 @@
  1 +.header.clearfix
  2 + .right
  3 + %select
  4 + %option{:value => "commits"} Commits
  5 + %option{:value => "additions"} Additions
  6 + %option{:value => "deletions"} Deletions
  7 + %h3#date_header
  8 + %input#brush_change{:type => "hidden"}
  9 +
  10 +.graphs
  11 + #contributors-master
  12 + #contributors.clearfix
  13 + %ol.contributors-list.clearfix
  14 +
  15 +:javascript
  16 + controller = new ContributorsStatGraph
  17 + controller.init(#{@log})
  18 +
  19 + $("select").change( function () {
  20 + var field = $(this).val()
  21 + controller.set_current_field(field)
  22 + controller.redraw_master()
  23 + controller.redraw_authors()
  24 + })
  25 +
  26 + $("#brush_change").change( function () {
  27 + controller.change_date_header()
  28 + controller.redraw_authors()
  29 + })
0 30 \ No newline at end of file
... ...
config/routes.rb
... ... @@ -190,6 +190,7 @@ Gitlab::Application.routes.draw do
190 190 resources :compare, only: [:index, :create]
191 191 resources :blame, only: [:show], constraints: {id: /.+/}
192 192 resources :graph, only: [:show], constraints: {id: /(?:[^.]|\.(?!json$))+/, format: /json/}
  193 + resources :stat_graph, only: [:show], constraints: {id: /(?:[^.]|\.(?!json$))+/, format: /json/}
193 194 match "/compare/:from...:to" => "compare#show", as: "compare", via: [:get, :post], constraints: {from: /.+/, to: /.+/}
194 195  
195 196 scope module: :projects do
... ...
lib/gitlab/git_stats.rb 0 → 100644
... ... @@ -0,0 +1,20 @@
  1 +require 'gitlab/git_stats_log_parser'
  2 +
  3 +module Gitlab
  4 + class GitStats
  5 + attr_accessor :repo, :ref
  6 +
  7 + def initialize repo, ref
  8 + @repo, @ref = repo, ref
  9 + end
  10 +
  11 + def log
  12 + args = ['--format=%aN%x0a%ad', '--date=short', '--shortstat', '--no-merges']
  13 + repo.git.run(nil, 'log', nil, {}, args)
  14 + end
  15 +
  16 + def parsed_log
  17 + LogParser.parse_log(log)
  18 + end
  19 + end
  20 +end
... ...
lib/gitlab/git_stats_log_parser.rb 0 → 100644
... ... @@ -0,0 +1,32 @@
  1 +class LogParser
  2 + #Parses the log file into a collection of commits
  3 + #Data model: {author, date, additions, deletions}
  4 + def self.parse_log log_from_git
  5 + log = log_from_git.split("\n")
  6 +
  7 + i = 0
  8 + collection = []
  9 + entry = {}
  10 +
  11 + while i <= log.size do
  12 + pos = i % 4
  13 + case pos
  14 + when 0
  15 + unless i == 0
  16 + collection.push(entry)
  17 + entry = {}
  18 + end
  19 + entry[:author] = log[i].to_s
  20 + when 1
  21 + entry[:date] = log[i].to_s
  22 + when 3
  23 + changes = log[i].split(",")
  24 + entry[:additions] = changes[1].to_i unless changes[1].nil?
  25 + entry[:deletions] = changes[2].to_i unless changes[2].nil?
  26 + end
  27 + i += 1
  28 + end
  29 +
  30 + collection
  31 + end
  32 +end
0 33 \ No newline at end of file
... ...
spec/javascripts/helpers/.gitkeep 0 → 100644
spec/javascripts/stat_graph_contributors_graph_spec.js 0 → 100644
... ... @@ -0,0 +1,125 @@
  1 +describe("ContributorsGraph", function () {
  2 + describe("#set_x_domain", function () {
  3 + it("set the x_domain", function () {
  4 + ContributorsGraph.set_x_domain(20)
  5 + expect(ContributorsGraph.prototype.x_domain).toEqual(20)
  6 + })
  7 + })
  8 +
  9 + describe("#set_y_domain", function () {
  10 + it("sets the y_domain", function () {
  11 + ContributorsGraph.set_y_domain([{commits: 30}])
  12 + expect(ContributorsGraph.prototype.y_domain).toEqual([0, 30])
  13 + })
  14 + })
  15 +
  16 + describe("#init_x_domain", function () {
  17 + it("sets the initial x_domain", function () {
  18 + ContributorsGraph.init_x_domain([{date: "2013-01-31"}, {date: "2012-01-31"}])
  19 + expect(ContributorsGraph.prototype.x_domain).toEqual(["2012-01-31", "2013-01-31"])
  20 + })
  21 + })
  22 +
  23 + describe("#init_y_domain", function () {
  24 + it("sets the initial y_domain", function () {
  25 + ContributorsGraph.init_y_domain([{commits: 30}])
  26 + expect(ContributorsGraph.prototype.y_domain).toEqual([0, 30])
  27 + })
  28 + })
  29 +
  30 + describe("#init_domain", function () {
  31 + it("calls init_x_domain and init_y_domain", function () {
  32 + spyOn(ContributorsGraph, "init_x_domain")
  33 + spyOn(ContributorsGraph, "init_y_domain")
  34 + ContributorsGraph.init_domain()
  35 + expect(ContributorsGraph.init_x_domain).toHaveBeenCalled()
  36 + expect(ContributorsGraph.init_y_domain).toHaveBeenCalled()
  37 + })
  38 + })
  39 +
  40 + describe("#set_dates", function () {
  41 + it("sets the dates", function () {
  42 + ContributorsGraph.set_dates("2013-12-01")
  43 + expect(ContributorsGraph.prototype.dates).toEqual("2013-12-01")
  44 + })
  45 + })
  46 +
  47 + describe("#set_x_domain", function () {
  48 + it("sets the instance's x domain using the prototype's x_domain", function () {
  49 + ContributorsGraph.prototype.x_domain = 20
  50 + var instance = new ContributorsGraph()
  51 + instance.x = d3.time.scale().range([0, 100]).clamp(true)
  52 + spyOn(instance.x, 'domain')
  53 + instance.set_x_domain()
  54 + expect(instance.x.domain).toHaveBeenCalledWith(20)
  55 + })
  56 + })
  57 +
  58 + describe("#set_y_domain", function () {
  59 + it("sets the instance's y domain using the prototype's y_domain", function () {
  60 + ContributorsGraph.prototype.y_domain = 30
  61 + var instance = new ContributorsGraph()
  62 + instance.y = d3.scale.linear().range([100, 0]).nice()
  63 + spyOn(instance.y, 'domain')
  64 + instance.set_y_domain()
  65 + expect(instance.y.domain).toHaveBeenCalledWith(30)
  66 + })
  67 + })
  68 +
  69 + describe("#set_domain", function () {
  70 + it("calls set_x_domain and set_y_domain", function () {
  71 + var instance = new ContributorsGraph()
  72 + spyOn(instance, 'set_x_domain')
  73 + spyOn(instance, 'set_y_domain')
  74 + instance.set_domain()
  75 + expect(instance.set_x_domain).toHaveBeenCalled()
  76 + expect(instance.set_y_domain).toHaveBeenCalled()
  77 + })
  78 + })
  79 +
  80 + describe("#set_data", function () {
  81 + it("sets the data", function () {
  82 + var instance = new ContributorsGraph()
  83 + instance.set_data("20")
  84 + expect(instance.data).toEqual("20")
  85 + })
  86 + })
  87 +})
  88 +
  89 +describe("ContributorsMasterGraph", function () {
  90 +
  91 + describe("#process_dates", function () {
  92 + it("gets and parses dates", function () {
  93 + var graph = new ContributorsMasterGraph()
  94 + var data = 'random data here'
  95 + spyOn(graph, 'parse_dates')
  96 + spyOn(graph, 'get_dates').andReturn("get")
  97 + spyOn(ContributorsGraph,'set_dates').andCallThrough()
  98 + graph.process_dates(data)
  99 + expect(graph.parse_dates).toHaveBeenCalledWith(data)
  100 + expect(graph.get_dates).toHaveBeenCalledWith(data)
  101 + expect(ContributorsGraph.set_dates).toHaveBeenCalledWith("get")
  102 + })
  103 + })
  104 +
  105 + describe("#get_dates", function () {
  106 + it("plucks the date field from data collection", function () {
  107 + var graph = new ContributorsMasterGraph()
  108 + var data = [{date: "2013-01-01"}, {date: "2012-12-15"}]
  109 + expect(graph.get_dates(data)).toEqual(["2013-01-01", "2012-12-15"])
  110 + })
  111 + })
  112 +
  113 + describe("#parse_dates", function () {
  114 + it("parses the dates", function () {
  115 + var graph = new ContributorsMasterGraph()
  116 + var parseDate = d3.time.format("%Y-%m-%d").parse
  117 + var data = [{date: "2013-01-01"}, {date: "2012-12-15"}]
  118 + var correct = [{date: parseDate(data[0].date)}, {date: parseDate(data[1].date)}]
  119 + graph.parse_dates(data)
  120 + expect(data).toEqual(correct)
  121 + })
  122 + })
  123 +
  124 +
  125 +})
... ...
spec/javascripts/stat_graph_contributors_util_spec.js 0 → 100644
... ... @@ -0,0 +1,200 @@
  1 +describe("ContributorsStatGraphUtil", function () {
  2 +
  3 + describe("#parse_log", function () {
  4 + it("returns a correctly parsed log", function () {
  5 + var fake_log = [
  6 + {author: "Karlo Soriano", date: "2013-05-09", additions: 471},
  7 + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 6, deletions: 1},
  8 + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 19, deletions: 3},
  9 + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 29, deletions: 3}]
  10 +
  11 + var correct_parsed_log = {
  12 + total: [
  13 + {date: "2013-05-09", additions: 471, deletions: 0, commits: 1},
  14 + {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}],
  15 + by_author:
  16 + [
  17 + {
  18 + author: "Karlo Soriano",
  19 + "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}
  20 + },
  21 + {
  22 + author: "Dmitriy Zaporozhets",
  23 + "2013-05-08": {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}
  24 + }
  25 + ]
  26 + }
  27 + expect(ContributorsStatGraphUtil.parse_log(fake_log)).toEqual(correct_parsed_log)
  28 + })
  29 + })
  30 +
  31 + describe("#store_data", function () {
  32 +
  33 + var fake_entry = {author: "Karlo Soriano", date: "2013-05-09", additions: 471}
  34 + var fake_total = {}
  35 + var fake_by_author = {}
  36 +
  37 + it("calls #store_commits", function () {
  38 + spyOn(ContributorsStatGraphUtil, 'store_commits')
  39 + ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author)
  40 + expect(ContributorsStatGraphUtil.store_commits).toHaveBeenCalled()
  41 + })
  42 +
  43 + it("calls #store_additions", function () {
  44 + spyOn(ContributorsStatGraphUtil, 'store_additions')
  45 + ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author)
  46 + expect(ContributorsStatGraphUtil.store_additions).toHaveBeenCalled()
  47 + })
  48 +
  49 + it("calls #store_deletions", function () {
  50 + spyOn(ContributorsStatGraphUtil, 'store_deletions')
  51 + ContributorsStatGraphUtil.store_data(fake_entry, fake_total, fake_by_author)
  52 + expect(ContributorsStatGraphUtil.store_deletions).toHaveBeenCalled()
  53 + })
  54 +
  55 + })
  56 +
  57 + describe("#store_commits", function () {
  58 + var fake_total = "fake_total"
  59 + var fake_by_author = "fake_by_author"
  60 +
  61 + it("calls #add twice with arguments fake_total and fake_by_author respectively", function () {
  62 + spyOn(ContributorsStatGraphUtil, 'add')
  63 + ContributorsStatGraphUtil.store_commits(fake_total, fake_by_author)
  64 + expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "commits", 1], ["fake_by_author", "commits", 1]])
  65 + })
  66 + })
  67 +
  68 + describe("#add", function () {
  69 + it("adds 1 to current test_field in collection", function () {
  70 + var fake_collection = {test_field: 10}
  71 + ContributorsStatGraphUtil.add(fake_collection, "test_field", 1)
  72 + expect(fake_collection.test_field).toEqual(11)
  73 + })
  74 +
  75 + it("inits and adds 1 if test_field in collection is not defined", function () {
  76 + var fake_collection = {}
  77 + ContributorsStatGraphUtil.add(fake_collection, "test_field", 1)
  78 + expect(fake_collection.test_field).toEqual(1)
  79 + })
  80 + })
  81 +
  82 + describe("#store_additions", function () {
  83 + var fake_entry = {additions: 10}
  84 + var fake_total= "fake_total"
  85 + var fake_by_author = "fake_by_author"
  86 + it("calls #add twice with arguments fake_total and fake_by_author respectively", function () {
  87 + spyOn(ContributorsStatGraphUtil, 'add')
  88 + ContributorsStatGraphUtil.store_additions(fake_entry, fake_total, fake_by_author)
  89 + expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "additions", 10], ["fake_by_author", "additions", 10]])
  90 + })
  91 + })
  92 +
  93 + describe("#store_deletions", function () {
  94 + var fake_entry = {deletions: 10}
  95 + var fake_total= "fake_total"
  96 + var fake_by_author = "fake_by_author"
  97 + it("calls #add twice with arguments fake_total and fake_by_author respectively", function () {
  98 + spyOn(ContributorsStatGraphUtil, 'add')
  99 + ContributorsStatGraphUtil.store_deletions(fake_entry, fake_total, fake_by_author)
  100 + expect(ContributorsStatGraphUtil.add.argsForCall).toEqual([["fake_total", "deletions", 10], ["fake_by_author", "deletions", 10]])
  101 + })
  102 + })
  103 +
  104 + describe("#add_date", function () {
  105 + it("adds a date field to the collection", function () {
  106 + var fake_date = "2013-10-02"
  107 + var fake_collection = {}
  108 + ContributorsStatGraphUtil.add_date(fake_date, fake_collection)
  109 + expect(fake_collection[fake_date].date).toEqual("2013-10-02")
  110 + })
  111 + })
  112 +
  113 + describe("#add_author", function () {
  114 + it("adds an author field to the collection", function () {
  115 + var fake_author = "Author"
  116 + var fake_collection = {}
  117 + ContributorsStatGraphUtil.add_author(fake_author, fake_collection)
  118 + expect(fake_collection[fake_author].author).toEqual("Author")
  119 + })
  120 + })
  121 +
  122 + describe("#get_total_data", function () {
  123 + it("returns the collection sorted via specified field", function () {
  124 + var fake_parsed_log = {
  125 + total: [{date: "2013-05-09", additions: 471, deletions: 0, commits: 1},
  126 + {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}],
  127 + by_author:[
  128 + {
  129 + author: "Karlo Soriano",
  130 + "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}
  131 + },
  132 + {
  133 + author: "Dmitriy Zaporozhets",
  134 + "2013-05-08": {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}
  135 + }
  136 + ]};
  137 + var correct_total_data = [{date: "2013-05-08", commits: 3},
  138 + {date: "2013-05-09", commits: 1}];
  139 + expect(ContributorsStatGraphUtil.get_total_data(fake_parsed_log, "commits")).toEqual(correct_total_data)
  140 + })
  141 + })
  142 +
  143 + describe("#pick_field", function () {
  144 + it("returns the collection with only the specified field and date", function () {
  145 + var fake_parsed_log_total = [{date: "2013-05-09", additions: 471, deletions: 0, commits: 1},
  146 + {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}];
  147 + ContributorsStatGraphUtil.pick_field(fake_parsed_log_total, "commits")
  148 + var correct_pick_field_data = [{date: "2013-05-09", commits: 1},{date: "2013-05-08", commits: 3}];
  149 + expect(ContributorsStatGraphUtil.pick_field(fake_parsed_log_total, "commits")).toEqual(correct_pick_field_data)
  150 + })
  151 + })
  152 +
  153 + describe("#get_author_data", function () {
  154 + it("returns the log by author sorted by specified field", function () {
  155 + var fake_parsed_log = {
  156 + total: [{date: "2013-05-09", additions: 471, deletions: 0, commits: 1},
  157 + {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}],
  158 + by_author:[
  159 + {
  160 + author: "Karlo Soriano",
  161 + "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}
  162 + },
  163 + {
  164 + author: "Dmitriy Zaporozhets",
  165 + "2013-05-08": {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}
  166 + }
  167 + ]}
  168 + var correct_author_data = [{author:"Dmitriy Zaporozhets",dates:{"2013-05-08":3},deletions:7,additions:54,"commits":3},
  169 + {author:"Karlo Soriano",dates:{"2013-05-09":1},deletions:0,additions:471,commits:1}]
  170 + expect(ContributorsStatGraphUtil.get_author_data(fake_parsed_log, "commits")).toEqual(correct_author_data)
  171 + })
  172 + })
  173 +
  174 + describe("#parse_log_entry", function () {
  175 + it("adds the corresponding info from the log entry to the author", function () {
  176 + var fake_log_entry = { author: "Karlo Soriano",
  177 + "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}
  178 + }
  179 + var correct_parsed_log = {author:"Karlo Soriano",dates:{"2013-05-09":1},deletions:0,additions:471,commits:1}
  180 + expect(ContributorsStatGraphUtil.parse_log_entry(fake_log_entry, 'commits', null)).toEqual(correct_parsed_log)
  181 + })
  182 + })
  183 +
  184 + describe("#in_range", function () {
  185 + var date = "2013-05-09"
  186 + it("returns true if date_range is null", function () {
  187 + expect(ContributorsStatGraphUtil.in_range(date, null)).toEqual(true)
  188 + })
  189 + it("returns true if date is in range", function () {
  190 + var date_range = [new Date("2013-01-01"), new Date("2013-12-12")]
  191 + expect(ContributorsStatGraphUtil.in_range(date, date_range)).toEqual(true)
  192 + })
  193 + it("returns false if date is not in range", function () {
  194 + var date_range = [new Date("1999-12-01"), new Date("2000-12-01")]
  195 + expect(ContributorsStatGraphUtil.in_range(date, date_range)).toEqual(false)
  196 + })
  197 + })
  198 +
  199 +
  200 +})
0 201 \ No newline at end of file
... ...
spec/javascripts/stat_graph_spec.js 0 → 100644
... ... @@ -0,0 +1,17 @@
  1 +describe("StatGraph", function () {
  2 +
  3 + describe("#get_log", function () {
  4 + it("returns log", function () {
  5 + StatGraph.log = "test";
  6 + expect(StatGraph.get_log()).toBe("test");
  7 + });
  8 + });
  9 +
  10 + describe("#set_log", function () {
  11 + it("sets the log", function () {
  12 + StatGraph.set_log("test");
  13 + expect(StatGraph.log).toBe("test");
  14 + })
  15 + })
  16 +
  17 +});
0 18 \ No newline at end of file
... ...
spec/javascripts/support/jasmine.yml 0 → 100644
... ... @@ -0,0 +1,76 @@
  1 +# src_files
  2 +#
  3 +# Return an array of filepaths relative to src_dir to include before jasmine specs.
  4 +# Default: []
  5 +#
  6 +# EXAMPLE:
  7 +#
  8 +# src_files:
  9 +# - lib/source1.js
  10 +# - lib/source2.js
  11 +# - dist/**/*.js
  12 +#
  13 +src_files:
  14 + - assets/application.js
  15 +
  16 +# stylesheets
  17 +#
  18 +# Return an array of stylesheet filepaths relative to src_dir to include before jasmine specs.
  19 +# Default: []
  20 +#
  21 +# EXAMPLE:
  22 +#
  23 +# stylesheets:
  24 +# - css/style.css
  25 +# - stylesheets/*.css
  26 +#
  27 +stylesheets:
  28 + - stylesheets/**/*.css
  29 +
  30 +# helpers
  31 +#
  32 +# Return an array of filepaths relative to spec_dir to include before jasmine specs.
  33 +# Default: ["helpers/**/*.js"]
  34 +#
  35 +# EXAMPLE:
  36 +#
  37 +# helpers:
  38 +# - helpers/**/*.js
  39 +#
  40 +helpers:
  41 + - helpers/**/*.js
  42 +
  43 +# spec_files
  44 +#
  45 +# Return an array of filepaths relative to spec_dir to include.
  46 +# Default: ["**/*[sS]pec.js"]
  47 +#
  48 +# EXAMPLE:
  49 +#
  50 +# spec_files:
  51 +# - **/*[sS]pec.js
  52 +#
  53 +spec_files:
  54 + - '**/*[sS]pec.js'
  55 +
  56 +# src_dir
  57 +#
  58 +# Source directory path. Your src_files must be returned relative to this path. Will use root if left blank.
  59 +# Default: project root
  60 +#
  61 +# EXAMPLE:
  62 +#
  63 +# src_dir: public
  64 +#
  65 +src_dir:
  66 +
  67 +# spec_dir
  68 +#
  69 +# Spec directory path. Your spec_files must be returned relative to this path.
  70 +# Default: spec/javascripts
  71 +#
  72 +# EXAMPLE:
  73 +#
  74 +# spec_dir: spec/javascripts
  75 +#
  76 +spec_dir: spec/javascripts
... ...
spec/javascripts/support/jasmine_helper.rb 0 → 100644
... ... @@ -0,0 +1,11 @@
  1 +#Use this file to set/override Jasmine configuration options
  2 +#You can remove it if you don't need it.
  3 +#This file is loaded *after* jasmine.yml is interpreted.
  4 +#
  5 +#Example: using a different boot file.
  6 +#Jasmine.configure do |config|
  7 +# @config.boot_dir = '/absolute/path/to/boot_dir'
  8 +# @config.boot_files = lambda { ['/absolute/path/to/boot_dir/file.js'] }
  9 +#end
  10 +#
  11 +
... ...
spec/lib/gitlab/git_stats_log_parser_spec.rb 0 → 100644
... ... @@ -0,0 +1,37 @@
  1 +require 'spec_helper'
  2 +require 'gitlab/git_stats_log_parser'
  3 +
  4 +
  5 +describe LogParser do
  6 +
  7 + describe "#self.parse_log" do
  8 + context "log_from_git is a valid log" do
  9 + it "returns the correct log" do
  10 + fake_log = "Karlo Soriano
  11 +2013-05-09
  12 +
  13 + 14 files changed, 471 insertions(+)
  14 +Dmitriy Zaporozhets
  15 +2013-05-08
  16 +
  17 + 1 file changed, 6 insertions(+), 1 deletion(-)
  18 +Dmitriy Zaporozhets
  19 +2013-05-08
  20 +
  21 + 6 files changed, 19 insertions(+), 3 deletions(-)
  22 +Dmitriy Zaporozhets
  23 +2013-05-08
  24 +
  25 + 3 files changed, 29 insertions(+), 3 deletions(-)";
  26 +
  27 + lp = LogParser.parse_log(fake_log)
  28 + lp.should eq([
  29 + {author: "Karlo Soriano", date: "2013-05-09", additions: 471},
  30 + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 6, deletions: 1},
  31 + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 19, deletions: 3},
  32 + {author: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 29, deletions: 3}])
  33 + end
  34 + end
  35 + end
  36 +
  37 +end
0 38 \ No newline at end of file
... ...
spec/lib/gitlab/git_stats_spec.rb 0 → 100644
... ... @@ -0,0 +1,36 @@
  1 +require 'spec_helper'
  2 +
  3 +describe Gitlab::GitStats do
  4 +
  5 + describe "#parsed_log" do
  6 + let(:stats) { Gitlab::GitStats.new(nil, nil) }
  7 + before(:each) do
  8 + stats.stub(:log).and_return("anything")
  9 + end
  10 +
  11 + context "LogParser#parse_log returns 'test'" do
  12 + it "returns 'test'" do
  13 + LogParser.stub(:parse_log).and_return("test")
  14 + stats.parsed_log.should eq("test")
  15 + end
  16 + end
  17 + end
  18 +
  19 + describe "#log" do
  20 + let(:repo) { Repository.new(nil, nil) }
  21 + let(:gs) { Gitlab::GitStats.new(repo.raw, repo.root_ref) }
  22 +
  23 + before(:each) do
  24 + repo.stub(:raw).and_return(nil)
  25 + repo.stub(:root_ref).and_return(nil)
  26 + repo.raw.stub(:git)
  27 + end
  28 +
  29 + context "repo.git.run returns 'test'" do
  30 + it "returns 'test'" do
  31 + repo.raw.git.stub(:run).and_return("test")
  32 + gs.log.should eq("test")
  33 + end
  34 + end
  35 + end
  36 +end
0 37 \ No newline at end of file
... ...