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
This commit is contained in:
parent
b9d989dc05
commit
71d67e6557
7
Gemfile
7
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
|
||||
|
|
|
|||
21
Gemfile.lock
21
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
|
||||
|
|
|
|||
|
|
@ -27,3 +27,5 @@
|
|||
//= require branch-graph
|
||||
//= require ace-src-noconflict/ace
|
||||
//= require_tree .
|
||||
//= require d3
|
||||
//= require underscore
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
class window.StatGraph
|
||||
@log: {}
|
||||
@get_log: ->
|
||||
@log
|
||||
@set_log: (data) ->
|
||||
@log = data
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
|
@ -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");
|
||||
})
|
||||
})
|
||||
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
#
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue