Merge pull request #439 from abf/rosa-build:435-statistics-by-issues

#435: Issues && PullRequests statistics
This commit is contained in:
avokhmin 2014-10-23 21:33:23 +04:00
commit bfb72a942e
21 changed files with 465 additions and 82 deletions

View File

@ -1,9 +1,10 @@
RosaABF.controller 'StatisticsController', ['$scope', '$http', ($scope, $http) -> RosaABF.controller 'StatisticsController', ['$scope', '$http', '$timeout', ($scope, $http, $timeout) ->
$scope.users_or_groups = null
$scope.range = 'last_30_days' $scope.range = 'last_30_days'
$scope.range_start = $('#range_start').attr('value') $scope.range_start = $('#range_start').attr('value')
$scope.range_end = $('#range_end').attr('value') $scope.range_end = $('#range_end').attr('value')
$scope.loading = true $scope.loading = false
$scope.statistics = {} $scope.statistics = {}
$scope.statistics_path = '/statistics' $scope.statistics_path = '/statistics'
@ -12,22 +13,24 @@ RosaABF.controller 'StatisticsController', ['$scope', '$http', ($scope, $http) -
'77, 169, 68', '77, 169, 68',
'241, 128, 73', '241, 128, 73',
'174, 199, 232', '174, 199, 232',
'255, 187, 120', # '255, 187, 120',
'152, 223, 138', # '152, 223, 138',
'214, 39, 40', # '214, 39, 40',
'31, 119, 180' # '31, 119, 180'
] ]
$scope.charts = {} $scope.charts = {}
$('#users_or_groups').on 'autocompleteselect', (e) ->
$timeout($scope.update, 100)
$scope.init = -> $scope.init = ->
$('#range-form .date_picker').datepicker $('#statistics-form .date_picker').datepicker
'dateFormat': 'yy-mm-dd' 'dateFormat': 'yy-mm-dd'
maxDate: 0 maxDate: 0
minDate: -366 minDate: -366
showButtonPanel: true showButtonPanel: true
$scope.rangeChange() $scope.update()
true true
$scope.prepareRange = -> $scope.prepareRange = ->
@ -39,18 +42,27 @@ RosaABF.controller 'StatisticsController', ['$scope', '$http', ($scope, $http) -
$scope.range_start = $scope.range_end $scope.range_start = $scope.range_end
$scope.range_end = tmp $scope.range_end = tmp
$scope.prepareUsersOrGroups = ->
if $scope.users_or_groups
items = _.uniq $('#users_or_groups').val().replace(/\s/g, '').split(/,/)
items = _.reject items, (i) ->
_.isEmpty(i)
$scope.users_or_groups = _.first(items, 3).join(', ') + ', '
$scope.rangeChange = -> $scope.update = ->
return if $scope.loading
$scope.loading = true $scope.loading = true
$scope.statistics = {} $scope.statistics = {}
$scope.prepareRange() $scope.prepareRange()
$scope.prepareUsersOrGroups()
$('.doughnut-legend').remove() $('.doughnut-legend').remove()
params = params =
range: $scope.range range: $scope.range
range_start: $scope.range_start range_start: $scope.range_start
range_end: $scope.range_end range_end: $scope.range_end
users_or_groups: $scope.users_or_groups
format: 'json' format: 'json'
$http.get($scope.statistics_path, params: params).success (results) -> $http.get($scope.statistics_path, params: params).success (results) ->
@ -102,7 +114,7 @@ RosaABF.controller 'StatisticsController', ['$scope', '$http', ($scope, $http) -
d.y d.y
dataset = dataset =
fillColor: "rgba(#{ $scope.colors[index] }, 0.5)" fillColor: "rgba(#{ $scope.colors[index] }, 0.1)"
strokeColor: "rgba(#{ $scope.colors[index] }, 1)" strokeColor: "rgba(#{ $scope.colors[index] }, 1)"
pointColor: "rgba(#{ $scope.colors[index] }, 1)" pointColor: "rgba(#{ $scope.colors[index] }, 1)"
pointStrokeColor: "#fff" pointStrokeColor: "#fff"
@ -137,15 +149,15 @@ RosaABF.controller 'StatisticsController', ['$scope', '$http', ($scope, $http) -
$scope.initPullRequestsChart = -> $scope.initPullRequestsChart = ->
$scope.dateChart '#pull_requests_chart', [ $scope.dateChart '#pull_requests_chart', [
$scope.statistics.pull_requests.open, $scope.statistics.pull_requests.open,
$scope.statistics.pull_requests.merged
$scope.statistics.pull_requests.closed, $scope.statistics.pull_requests.closed,
$scope.statistics.pull_requests.approved
] ]
$scope.initIssuesChart = -> $scope.initIssuesChart = ->
$scope.dateChart '#issues_chart', [ $scope.dateChart '#issues_chart', [
$scope.statistics.issues.open, $scope.statistics.issues.open,
$scope.statistics.issues.closed, $scope.statistics.issues.reopen,
$scope.statistics.issues.approved $scope.statistics.issues.closed
] ]
] ]

View File

@ -49,10 +49,11 @@ class Api::V1::PullRequestsController < Api::V1::BaseController
@pull = @project.pull_requests.new @pull = @project.pull_requests.new
@pull.build_issue title: pull_params[:title], body: pull_params[:body] @pull.build_issue title: pull_params[:title], body: pull_params[:body]
@pull.from_project = @project @pull.from_project = @project
@pull.to_ref, @pull.from_ref = pull_params[:to_ref], pull_params[:from_ref] @pull.to_ref, @pull.from_ref = pull_params[:to_ref], pull_params[:from_ref]
@pull.issue.assignee_id = pull_params[:assignee_id] if can?(:write, @project) @pull.issue.assignee_id = pull_params[:assignee_id] if can?(:write, @project)
@pull.issue.user, @pull.issue.project = current_user, @project @pull.issue.user, @pull.issue.project = current_user, @project
@pull.issue.new_pull_request = true
render_validation_error(@pull, "#{@pull.class.name} has not been created") && return unless @pull.valid? render_validation_error(@pull, "#{@pull.class.name} has not been created") && return unless @pull.valid?
@pull.save # set pull id @pull.save # set pull id

View File

@ -4,6 +4,13 @@ class AutocompletesController < ApplicationController
autocomplete :group, :uname autocomplete :group, :uname
autocomplete :user, :uname autocomplete :user, :uname
def autocomplete_user_or_group
results = []
results << User.opened.search(params[:term]).search_order.limit(5).pluck(:uname)
results << Group.search(params[:term]).search_order.limit(5).pluck(:uname)
render json: results.flatten.sort.map{ |r| { label: r } }
end
def autocomplete_extra_build_list def autocomplete_extra_build_list
bl = BuildList.for_extra_build_lists(params[:term], current_ability, save_to_platform).first bl = BuildList.for_extra_build_lists(params[:term], current_ability, save_to_platform).first
results << { :id => bl.id, results << { :id => bl.id,

View File

@ -39,8 +39,9 @@ class Projects::PullRequestsController < Projects::BaseController
@pull = to_project.pull_requests.new pull_params @pull = to_project.pull_requests.new pull_params
@pull.issue.assignee_id = (params[:issue] || {})[:assignee_id] if can?(:write, to_project) @pull.issue.assignee_id = (params[:issue] || {})[:assignee_id] if can?(:write, to_project)
@pull.issue.user, @pull.issue.project, @pull.from_project = current_user, to_project, @project @pull.issue.user, @pull.issue.project, @pull.from_project = current_user, to_project, @project
@pull.from_project_owner_uname = @pull.from_project.owner.uname @pull.from_project_owner_uname = @pull.from_project.owner.uname
@pull.from_project_name = @pull.from_project.name @pull.from_project_name = @pull.from_project.name
@pull.issue.new_pull_request = true
if @pull.valid? # FIXME more clean/clever logics if @pull.valid? # FIXME more clean/clever logics
@pull.save # set pull id @pull.save # set pull id

View File

@ -16,7 +16,12 @@ class StatisticsController < ApplicationController
format.html format.html
format.json do format.json do
init_variables init_variables
render json: StatisticPresenter.new(range_start: @range_start, range_end: @range_end, unit: @unit) render json: StatisticPresenter.new(
range_start: @range_start,
range_end: @range_end,
unit: @unit,
users_or_groups: params[:users_or_groups]
)
end end
end end
end end

View File

@ -1,16 +1,38 @@
class Issue < ActiveRecord::Base class Issue < ActiveRecord::Base
include Feed::Issue include Feed::Issue
STATUSES = ['open', 'closed']
STATUSES = [
STATUS_OPEN = 'open',
STATUS_REOPEN = 'reopen',
STATUS_CLOSED = 'closed'
]
HASH_TAG_REGEXP = /([a-zA-Z0-9\-_]*\/)?([a-zA-Z0-9\-_]*)?#([0-9]+)/
belongs_to :project belongs_to :project
belongs_to :user belongs_to :user
belongs_to :assignee, class_name: 'User', foreign_key: 'assignee_id' belongs_to :assignee,
belongs_to :closer, class_name: 'User', foreign_key: 'closed_by' class_name: 'User',
foreign_key: 'assignee_id'
belongs_to :closer,
class_name: 'User',
foreign_key: 'closed_by'
has_many :comments,
as: :commentable,
dependent: :destroy
has_many :subscribes,
as: :subscribeable,
dependent: :destroy
has_many :labelings,
dependent: :destroy
has_many :labels,
-> { uniq },
through: :labelings
has_many :comments, as: :commentable, dependent: :destroy
has_many :subscribes, as: :subscribeable, dependent: :destroy
has_many :labelings, dependent: :destroy
has_many :labels, -> { uniq }, through: :labelings
has_one :pull_request#, dependent: :destroy has_one :pull_request#, dependent: :destroy
validates :title, :body, :project_id, presence: true validates :title, :body, :project_id, presence: true
@ -19,21 +41,28 @@ class Issue < ActiveRecord::Base
after_create :subscribe_users after_create :subscribe_users
after_update :subscribe_issue_assigned_user after_update :subscribe_issue_assigned_user
before_create :update_statistic
before_update :update_statistic
attr_accessible :labelings_attributes, :title, :body, :assignee_id attr_accessible :labelings_attributes, :title, :body, :assignee_id
accepts_nested_attributes_for :labelings, allow_destroy: true accepts_nested_attributes_for :labelings, allow_destroy: true
scope :opened, -> { where(status: 'open') } scope :opened, -> { where(status: [STATUS_OPEN, STATUS_REOPEN]) }
scope :closed, -> { where(status: 'closed') } scope :closed, -> { where(status: STATUS_CLOSED) }
scope :needed_checking, -> { where(issues: {status: ['open', 'blocked', 'ready', 'already']}) } scope :needed_checking, -> { where(issues: { status: %w(open reopen blocked ready already) }) }
scope :not_closed_or_merged, -> { needed_checking } scope :not_closed_or_merged, -> { needed_checking }
scope :closed_or_merged, -> { where(issues: {status: ['closed', 'merged']}) } scope :closed_or_merged, -> { where(issues: { status: %w(closed merged) }) }
# Using mb_chars for correct transform to lowercase ('Русский Текст'.downcase => "Русский Текст") # Using mb_chars for correct transform to lowercase ('Русский Текст'.downcase => "Русский Текст")
scope :search, ->(q) { where("#{table_name}.title ILIKE ?", "%#{q.mb_chars.downcase}%") if q.present? } scope :search, ->(q) {
where("#{table_name}.title ILIKE ?", "%#{q.mb_chars.downcase}%") if q.present?
}
scope :without_pull_requests, -> { scope :without_pull_requests, -> {
where('NOT EXISTS (select null from pull_requests as pr where pr.issue_id = issues.id)') where('NOT EXISTS (select null from pull_requests as pr where pr.issue_id = issues.id)')
} }
attr_accessor :new_pull_request
def assign_uname def assign_uname
assignee.uname if assignee assignee.uname if assignee
end end
@ -43,24 +72,24 @@ class Issue < ActiveRecord::Base
end end
def subscribe_creator(creator_id) def subscribe_creator(creator_id)
if !self.subscribes.exists?(user_id: creator_id) unless self.subscribes.exists?(user_id: creator_id)
self.subscribes.create(user_id: creator_id) self.subscribes.create(user_id: creator_id)
end end
end end
def closed? def closed?
closed_by && closed_at && status == 'closed' closed_by && closed_at && status == STATUS_CLOSED
end end
def set_close(closed_by) def set_close(closed_by)
self.closed_at = Time.now.utc self.closed_at = Time.now.utc
self.closer = closed_by self.closer = closed_by
self.status = 'closed' self.status = STATUS_CLOSED
end end
def set_open def set_open
self.closed_at = self.closed_by = nil self.closed_at = self.closed_by = nil
self.status = 'open' self.status = STATUS_REOPEN
end end
def collect_recipients def collect_recipients
@ -69,12 +98,12 @@ class Issue < ActiveRecord::Base
recipients recipients
end end
def self.find_by_hash_tag hash_tag, current_ability, project def self.find_by_hash_tag(hash_tag, current_ability, project)
hash_tag =~ /([a-zA-Z0-9\-_]*\/)?([a-zA-Z0-9\-_]*)?#([0-9]+)/ hash_tag =~ HASH_TAG_REGEXP
owner_uname = Regexp.last_match[1].presence || Regexp.last_match[2].presence || project.owner.uname owner_uname = Regexp.last_match[1].presence || Regexp.last_match[2].presence || project.owner.uname
project_name = Regexp.last_match[1] ? Regexp.last_match[2] : project.name project_name = Regexp.last_match[1] ? Regexp.last_match[2] : project.name
serial_id = Regexp.last_match[3] serial_id = Regexp.last_match[3]
project = Project.find_by_owner_and_name(owner_uname.chomp('/'), project_name) project = Project.find_by_owner_and_name(owner_uname.chomp('/'), project_name)
return nil unless project return nil unless project
return nil unless current_ability.can? :show, project return nil unless current_ability.can? :show, project
project.issues.where(serial_id: serial_id).first project.issues.where(serial_id: serial_id).first
@ -82,6 +111,16 @@ class Issue < ActiveRecord::Base
protected protected
def update_statistic
key = (pull_request || new_pull_request) ? Statistic::KEY_PULL_REQUEST : Statistic::KEY_ISSUE
Statistic.statsd_increment(
activity_at: Time.now,
key: "#{key}.#{status}",
project_id: project_id,
user_id: closed_by || user_id,
) if new_record? || status_changed?
end
def set_serial_id def set_serial_id
self.serial_id = self.project.issues.count self.serial_id = self.project.issues.count
self.save! self.save!

View File

@ -1,10 +1,40 @@
class PullRequest < ActiveRecord::Base class PullRequest < ActiveRecord::Base
STATUSES = %w(ready already blocked merged closed) STATUSES = [
belongs_to :issue, autosave: true, dependent: :destroy, touch: true, validate: true STATUS_OPEN = 'open',
belongs_to :to_project, class_name: 'Project', foreign_key: 'to_project_id' STATUS_READY = 'ready',
belongs_to :from_project, class_name: 'Project', foreign_key: 'from_project_id' STATUS_ALREADY = 'already',
delegate :user, :user_id, :title, :body, :serial_id, :assignee, :status, :to_param, STATUS_BLOCKED = 'blocked',
:created_at, :updated_at, :comments, :status=, to: :issue, allow_nil: true STATUS_MERGED = 'merged',
STATUS_CLOSED = 'closed'
]
belongs_to :issue,
autosave: true,
dependent: :destroy,
touch: true,
validate: true
belongs_to :to_project,
class_name: 'Project',
foreign_key: 'to_project_id'
belongs_to :from_project,
class_name: 'Project',
foreign_key: 'from_project_id'
delegate :user,
:user_id,
:title,
:body,
:serial_id,
:assignee,
:status,
:to_param,
:created_at,
:updated_at,
:comments,
:status=,
to: :issue, allow_nil: true
validates :from_project, :to_project, presence: true validates :from_project, :to_project, presence: true
validate :uniq_merge, if: ->(pull) { pull.to_project.present? } validate :uniq_merge, if: ->(pull) { pull.to_project.present? }
@ -19,9 +49,9 @@ class PullRequest < ActiveRecord::Base
accepts_nested_attributes_for :issue accepts_nested_attributes_for :issue
attr_accessible :issue_attributes, :to_ref, :from_ref attr_accessible :issue_attributes, :to_ref, :from_ref
scope :needed_checking, -> { includes(:issue).where(issues: {status: ['open', 'blocked', 'ready']}) } scope :needed_checking, -> { includes(:issue).where(issues: { status: [STATUS_OPEN, STATUS_BLOCKED, STATUS_READY] }) }
scope :not_closed_or_merged, -> { needed_checking } scope :not_closed_or_merged, -> { needed_checking }
scope :closed_or_merged, -> { where(issues: {status: ['closed', 'merged']}) } scope :closed_or_merged, -> { where(issues: { status: [STATUS_CLOSED, STATUS_MERGED] }) }
state_machine :status, initial: :open do state_machine :status, initial: :open do
event :ready do event :ready do
@ -53,8 +83,8 @@ class PullRequest < ActiveRecord::Base
FileUtils.mv path(old_from_project_name), path, force: true if old_from_project_name FileUtils.mv path(old_from_project_name), path, force: true if old_from_project_name
return unless Dir.exists?(path) return unless Dir.exists?(path)
Dir.chdir(path) do Dir.chdir(path) do
system 'git', 'remote', 'set-url', 'origin', to_project.path system 'git', 'remote', 'set-url', 'origin', to_project.path
system 'git', 'remote', 'set-url', 'head', from_project.path if cross_pull? system 'git', 'remote', 'set-url', 'head', from_project.path if cross_pull?
end end
end end
later :update_relations, queue: :middle later :update_relations, queue: :middle
@ -87,7 +117,7 @@ class PullRequest < ActiveRecord::Base
new_status == 'already' ? (ready; merging) : send(new_status) new_status == 'already' ? (ready; merging) : send(new_status)
self.update_inline_comments self.update_inline_comments
else else
self.status = new_status == 'block' ? 'blocked' : new_status self.status = new_status == 'block' ? STATUS_BLOCKED : new_status
end end
end end
@ -140,7 +170,7 @@ class PullRequest < ActiveRecord::Base
def set_user_and_time user def set_user_and_time user
issue.closed_at = Time.now.utc issue.closed_at = Time.now.utc
issue.closer = user issue.closer = user
end end
def self.check_ref(record, attr, value) def self.check_ref(record, attr, value)
@ -163,8 +193,7 @@ class PullRequest < ActiveRecord::Base
end end
def repo def repo
return @repo if @repo.present? #&& !id_changed? @repo ||= Grit::Repo.new(path)
@repo = Grit::Repo.new path
end end
def from_commit def from_commit
@ -243,6 +272,6 @@ class PullRequest < ActiveRecord::Base
def set_add_data def set_add_data
self.from_project_owner_uname = from_project.owner.uname self.from_project_owner_uname = from_project.owner.uname
self.from_project_name = from_project.name self.from_project_name = from_project.name
end end
end end

View File

@ -5,7 +5,15 @@ class Statistic < ActiveRecord::Base
KEY_BUILD_LIST_BUILD_STARTED = "#{KEY_BUILD_LIST}.#{BuildList::BUILD_STARTED}", KEY_BUILD_LIST_BUILD_STARTED = "#{KEY_BUILD_LIST}.#{BuildList::BUILD_STARTED}",
KEY_BUILD_LIST_SUCCESS = "#{KEY_BUILD_LIST}.#{BuildList::SUCCESS}", KEY_BUILD_LIST_SUCCESS = "#{KEY_BUILD_LIST}.#{BuildList::SUCCESS}",
KEY_BUILD_LIST_BUILD_ERROR = "#{KEY_BUILD_LIST}.#{BuildList::BUILD_ERROR}", KEY_BUILD_LIST_BUILD_ERROR = "#{KEY_BUILD_LIST}.#{BuildList::BUILD_ERROR}",
KEY_BUILD_LIST_BUILD_PUBLISHED = "#{KEY_BUILD_LIST}.#{BuildList::BUILD_PUBLISHED}" KEY_BUILD_LIST_BUILD_PUBLISHED = "#{KEY_BUILD_LIST}.#{BuildList::BUILD_PUBLISHED}",
KEY_ISSUE = 'issue',
KEY_ISSUES_OPEN = "#{KEY_ISSUE}.#{Issue::STATUS_OPEN}",
KEY_ISSUES_REOPEN = "#{KEY_ISSUE}.#{Issue::STATUS_REOPEN}",
KEY_ISSUES_CLOSED = "#{KEY_ISSUE}.#{Issue::STATUS_CLOSED}",
KEY_PULL_REQUEST = 'pull_request',
KEY_PULL_REQUESTS_OPEN = "#{KEY_PULL_REQUEST}.#{PullRequest::STATUS_OPEN}",
KEY_PULL_REQUESTS_MERGED = "#{KEY_PULL_REQUEST}.#{PullRequest::STATUS_MERGED}",
KEY_PULL_REQUESTS_CLOSED = "#{KEY_PULL_REQUEST}.#{PullRequest::STATUS_CLOSED}",
] ]
belongs_to :user belongs_to :user
@ -41,14 +49,38 @@ class Statistic < ActiveRecord::Base
:counter, :counter,
:activity_at :activity_at
scope :for_period, -> (start_date, end_date) { where(activity_at: (start_date..end_date)) } scope :for_period, -> (start_date, end_date) {
where(activity_at: (start_date..end_date))
}
scope :for_users, -> (user_ids) {
where(user_id: user_ids) if user_ids.present?
}
scope :for_groups, -> (group_ids) {
where(["project_id = ANY (
ARRAY (
SELECT target_id
FROM relations
INNER JOIN projects ON projects.id = relations.target_id
WHERE relations.target_type = 'Project' AND
projects.owner_type = 'Group' AND
relations.actor_type = 'Group' AND
relations.actor_id IN (:groups)
)
)", { groups: group_ids }
]) if group_ids.present?
}
scope :build_lists_started, -> { where(key: KEY_BUILD_LIST_BUILD_STARTED) } scope :build_lists_started, -> { where(key: KEY_BUILD_LIST_BUILD_STARTED) }
scope :build_lists_success, -> { where(key: KEY_BUILD_LIST_SUCCESS) } scope :build_lists_success, -> { where(key: KEY_BUILD_LIST_SUCCESS) }
scope :build_lists_error, -> { where(key: KEY_BUILD_LIST_BUILD_ERROR) } scope :build_lists_error, -> { where(key: KEY_BUILD_LIST_BUILD_ERROR) }
scope :build_lists_published, -> { where(key: KEY_BUILD_LIST_BUILD_PUBLISHED) } scope :build_lists_published, -> { where(key: KEY_BUILD_LIST_BUILD_PUBLISHED) }
scope :commits, -> { where(key: KEY_COMMIT) } scope :commits, -> { where(key: KEY_COMMIT) }
scope :issues_open, -> { where(key: KEY_ISSUES_OPEN) }
scope :issues_reopen, -> { where(key: KEY_ISSUES_REOPEN) }
scope :issues_closed, -> { where(key: KEY_ISSUES_CLOSED) }
scope :pull_requests_open, -> { where(key: KEY_PULL_REQUESTS_OPEN) }
scope :pull_requests_merged, -> { where(key: KEY_PULL_REQUESTS_MERGED) }
scope :pull_requests_closed, -> { where(key: KEY_PULL_REQUESTS_CLOSED) }
def self.now_statsd_increment(activity_at: nil, user_id: nil, project_id: nil, key: nil, counter: 1) def self.now_statsd_increment(activity_at: nil, user_id: nil, project_id: nil, key: nil, counter: 1)

View File

@ -1,11 +1,12 @@
class StatisticPresenter < ApplicationPresenter class StatisticPresenter < ApplicationPresenter
attr_accessor :range_start, :range_end, :unit attr_accessor :range_start, :range_end, :unit, :users_or_groups
def initialize(range_start: nil, range_end: nil, unit: nil) def initialize(range_start: nil, range_end: nil, unit: nil, users_or_groups: nil)
@range_start = range_start @range_start = range_start
@range_end = range_end @range_end = range_end
@unit = unit @unit = unit
@users_or_groups = users_or_groups.to_s.split(/,/).map(&:strip).select(&:present?).first(3)
end end
def as_json(options = nil) def as_json(options = nil)
@ -24,18 +25,69 @@ class StatisticPresenter < ApplicationPresenter
commits: { commits: {
chart: prepare_collection(commits_chart), chart: prepare_collection(commits_chart),
commits_count: commits_chart.sum(&:count) commits_count: commits_chart.sum(&:count)
},
issues: {
open: prepare_collection(issues_open),
reopen: prepare_collection(issues_reopen),
closed: prepare_collection(issues_closed),
open_count: issues_open.sum(&:count),
reopen_count: issues_reopen.sum(&:count),
closed_count: issues_closed.sum(&:count)
},
pull_requests: {
open: prepare_collection(pull_requests_open),
merged: prepare_collection(pull_requests_merged),
closed: prepare_collection(pull_requests_closed),
open_count: pull_requests_open.sum(&:count),
merged_count: pull_requests_merged.sum(&:count),
closed_count: pull_requests_closed.sum(&:count)
} }
} }
end end
private private
def user_ids
@user_ids ||= User.where(uname: users_or_groups).pluck(:id)
end
def group_ids
@group_ids ||= Group.where(uname: users_or_groups).pluck(:id)
end
def scope def scope
@scope ||= Statistic.for_period(range_start, range_end). @scope ||= Statistic.for_period(range_start, range_end).
for_users(user_ids).for_groups(group_ids).
select("SUM(counter) as count, date_trunc('#{ unit }', activity_at) as activity_at"). select("SUM(counter) as count, date_trunc('#{ unit }', activity_at) as activity_at").
group("date_trunc('#{ unit }', activity_at)").order('activity_at') group("date_trunc('#{ unit }', activity_at)").order('activity_at')
end end
def issues_open
@issues_open ||= scope.issues_open.to_a
end
def issues_reopen
@issues_reopen ||= scope.issues_reopen.to_a
end
def issues_closed
@issues_closed ||= scope.issues_closed.to_a
end
def pull_requests_open
@pull_requests_open ||= scope.pull_requests_open.to_a
end
def pull_requests_merged
@pull_requests_merged ||= scope.pull_requests_merged.to_a
end
def pull_requests_closed
@pull_requests_closed ||= scope.pull_requests_closed.to_a
end
def commits_chart def commits_chart
@commits_chart ||= scope.commits.to_a @commits_chart ||= scope.commits.to_a
end end

View File

@ -4,11 +4,19 @@
%h3.text-info %h3.text-info
= t('.header') = t('.header')
= form_tag '#', class: 'form-inline alert alert-info centered', id: 'range-form' do %form#statistics-form.form-inline.alert.alert-info.centered
%label.control-label %label.control-label
= t('.range_label') = t('.range_label')
= select_tag 'range', statistics_range_options, id: 'range_select', class: 'select input-medium', ng_model: 'range', ng_change: 'rangeChange()' = select_tag 'range', statistics_range_options, id: 'range_select', class: 'select input-medium', ng_model: 'range', ng_change: 'update()', ng_disabled: 'loading'
%span{ ng_show: "range == 'custom'" } %span{ ng_show: "range == 'custom'" }
= text_field_tag :range_start, Date.today - 1.month, class: 'date_picker input-medium', placeholder: t('.range_start_placeholder'), ng_model: 'range_start', ng_change: 'rangeChange()', readonly: true = text_field_tag :range_start, Date.today - 1.month, class: 'date_picker input-medium', placeholder: t('.range_start_placeholder'), ng_model: 'range_start', ng_change: 'update()', readonly: true, size: 10, ng_disabled: 'loading'
= t('.range_separator') = t('.range_separator')
= text_field_tag :range_end, Date.today, class: 'date_picker input-medium', placeholder: t('.range_end_placeholder'), ng_model: 'range_end', ng_change: 'rangeChange()', readonly: true = text_field_tag :range_end, Date.today, class: 'date_picker input-medium', placeholder: t('.range_end_placeholder'), ng_model: 'range_end', ng_change: 'update()', readonly: true, size: 10, ng_disabled: 'loading'
%label.control-label
= t('.users_or_groups_label')
= autocomplete_field_tag 'users_or_groups', nil,autocomplete_user_or_group_autocompletes_path, multiple: true, 'data-delimiter' => ', ', ng_model: 'users_or_groups', placeholder: t('.users_or_groups_placeholder'), ng_disabled: 'loading'
%a{ href: '#', ng_click: 'update()' }
%b
= t('.refresh')

View File

@ -0,0 +1,48 @@
.row
.span12
%h3.text-info
= t('.header')
.row
.span8
.graph-wrapper
%h3
%span.graph-key-color1
= t('.open_title')
%span.graph-key-color2
= t('.reopen_title')
%span.graph-key-color3
= t('.closed_title')
.centered.graph-loading{ ng_show: 'loading' }
= image_tag 'loading-large.gif'
.no-data{ ng_hide: 'loading || statistics.issues' }
= t('.no_data')
%canvas#issues_chart{ ng_show: 'statistics.issues' }
.span3
.panel-wrapper
%h3
= t('.total_open')
.panel-data
= image_tag 'loading-small.gif', ng_show: 'loading'
.no-data{ ng_hide: 'loading || statistics.issues.open_count >= 0' }
= t('.no_data')
{{ statistics.issues.open_count | number }}
.panel-wrapper
%h3
= t('.total_reopen')
.panel-data
= image_tag 'loading-small.gif', ng_show: 'loading'
.no-data{ ng_hide: 'loading || statistics.issues.reopen_count >= 0' }
= t('.no_data')
{{ statistics.issues.reopen_count | number }}
.panel-wrapper
%h3
= t('.total_closed')
.panel-data
= image_tag 'loading-small.gif', ng_show: 'loading'
.no-data{ ng_hide: 'loading || statistics.issues.closed_count >= 0' }
= t('.no_data')
{{ statistics.issues.closed_count | number }}

View File

@ -0,0 +1,48 @@
.row
.span12
%h3.text-info
= t('.header')
.row
.span8
.graph-wrapper
%h3
%span.graph-key-color1
= t('.open_title')
%span.graph-key-color2
= t('.merged_title')
%span.graph-key-color3
= t('.closed_title')
.centered.graph-loading{ ng_show: 'loading' }
= image_tag 'loading-large.gif'
.no-data{ ng_hide: 'loading || statistics.pull_requests' }
= t('.no_data')
%canvas#pull_requests_chart{ ng_show: 'statistics.pull_requests' }
.span3
.panel-wrapper
%h3
= t('.total_open')
.panel-data
= image_tag 'loading-small.gif', ng_show: 'loading'
.no-data{ ng_hide: 'loading || statistics.pull_requests.open_count >= 0' }
= t('.no_data')
{{ statistics.pull_requests.open_count | number }}
.panel-wrapper
%h3
= t('.total_merged')
.panel-data
= image_tag 'loading-small.gif', ng_show: 'loading'
.no-data{ ng_hide: 'loading || statistics.pull_requests.merged_count >= 0' }
= t('.no_data')
{{ statistics.pull_requests.merged_count | number }}
.panel-wrapper
%h3
= t('.total_closed')
.panel-data
= image_tag 'loading-small.gif', ng_show: 'loading'
.no-data{ ng_hide: 'loading || statistics.pull_requests.closed_count >= 0' }
= t('.no_data')
{{ statistics.pull_requests.closed_count | number }}

View File

@ -5,4 +5,6 @@
= render 'filter' = render 'filter'
= render 'build_lists' = render 'build_lists'
= render 'commits' = render 'commits'
= render 'issues'
= render 'pull_requests'

View File

@ -10,6 +10,10 @@ en:
range_separator: " and " range_separator: " and "
range_end_placeholder: "Select an end date" range_end_placeholder: "Select an end date"
users_or_groups_label: "for users or groups"
refresh: "refresh"
users_or_groups_placeholder: "Enter a nickname here."
build_lists: build_lists:
header: "Build lists" header: "Build lists"
build_started_title: "Build started" build_started_title: "Build started"
@ -29,6 +33,28 @@ en:
total_commits: "Total commits" total_commits: "Total commits"
no_data: "No data available" no_data: "No data available"
issues:
header: "Issues"
open_title: "Open"
reopen_title: "Reopen"
closed_title: "Closed"
total_open: "Total opened"
total_reopen: "Total reopened"
total_closed: "Total closed"
no_data: "No data available"
pull_requests:
header: "Pull Requests"
open_title: "Open"
merged_title: "Merged"
closed_title: "Closed"
total_open: "Total open"
total_merged: "Total merged"
total_closed: "Total closed"
no_data: "No data available"
helper: helper:
period: period:
twenty_four_hours: "the last 24 hours" twenty_four_hours: "the last 24 hours"

View File

@ -10,6 +10,10 @@ ru:
range_separator: " и " range_separator: " и "
range_end_placeholder: "Выберите конечную дату" range_end_placeholder: "Выберите конечную дату"
users_or_groups_label: "для пользователей или групп"
refresh: "обновить"
users_or_groups_placeholder: "Введите никнейм здесь."
build_lists: build_lists:
header: "Сборочные листы" header: "Сборочные листы"
build_started_title: "Cобирается" build_started_title: "Cобирается"
@ -29,6 +33,28 @@ ru:
total_commits: "Всего коммитов" total_commits: "Всего коммитов"
no_data: "Нет данных" no_data: "Нет данных"
issues:
header: "Задачи"
open_title: "Открыто"
reopen_title: "Переоткрыто"
closed_title: "Закрыто"
total_open: "Всего открыто"
total_reopen: "Всего переоткрыто"
total_closed: "Всего закрыто"
no_data: "Нет данных"
pull_requests:
header: "Пулл реквесты"
open_title: "Открыто"
merged_title: "Принято"
closed_title: "Закрыто"
total_open: "Всего открыто"
total_merged: "Всего принято"
total_closed: "Всего закрыто"
no_data: "Нет данных"
helper: helper:
period: period:
twenty_four_hours: "последние 24 часа" twenty_four_hours: "последние 24 часа"

View File

@ -233,6 +233,7 @@ Rosa::Application.routes.draw do
get :autocomplete_extra_build_list get :autocomplete_extra_build_list
get :autocomplete_extra_mass_build get :autocomplete_extra_mass_build
get :autocomplete_extra_repositories get :autocomplete_extra_repositories
get :autocomplete_user_or_group
end end
end end

View File

@ -1,9 +1,9 @@
FactoryGirl.define do FactoryGirl.define do
factory :pull_request do factory :pull_request do
title { FactoryGirl.generate(:string) } association :issue, factory: :issue
body { FactoryGirl.generate(:string) } association :from_project, factory: :project
association :project, factory: :project association :to_project, factory: :project
association :user, factory: :user from_ref { FactoryGirl.generate(:string) }
association :assignee, factory: :user to_ref { FactoryGirl.generate(:string) }
end end
end end

View File

@ -12,4 +12,10 @@ describe BuildListObserver do
expect(build_list.started_at).to_not be_nil expect(build_list.started_at).to_not be_nil
end end
it 'updates styatistics' do
expect do
build_list
end.to change(Statistic, :count).by(1)
end
end end

View File

@ -16,6 +16,16 @@ describe Issue do
stub_symlink_methods stub_symlink_methods
any_instance_of(Project, versions: ['v1.0', 'v2.0']) any_instance_of(Project, versions: ['v1.0', 'v2.0'])
end end
context '#update_statistic' do
it 'updates styatistics' do
expect do
FactoryGirl.create(:issue)
end.to change(Statistic, :count).by(1)
end
end
context 'for project admin user' do context 'for project admin user' do
before(:each) do before(:each) do
set_data set_data

View File

@ -14,9 +14,10 @@ def set_data_for_pull
end end
describe PullRequest do describe PullRequest do
before { stub_symlink_methods }
context 'for owner user' do context 'for owner user' do
before do before do
stub_symlink_methods
@user = FactoryGirl.create(:user) @user = FactoryGirl.create(:user)
set_data_for_pull set_data_for_pull
@pull = @project.pull_requests.new(issue_attributes: {title: 'test', body: 'testing'}) @pull = @project.pull_requests.new(issue_attributes: {title: 'test', body: 'testing'})
@ -127,6 +128,20 @@ describe PullRequest do
it { should belong_to(:to_project) } it { should belong_to(:to_project) }
it { should belong_to(:from_project) } it { should belong_to(:from_project) }
context '#update_statistic' do
let(:issue) { FactoryGirl.build(:issue) }
let(:pull_request) { FactoryGirl.build(:pull_request, issue: issue) }
it 'updates styatistics' do
allow(PullRequest).to receive(:check_ref).and_return(true)
issue.new_pull_request = true
expect do
pull_request.save
end.to change(Statistic, :count).by(1)
expect(Statistic.last.key).to eq "#{Statistic::KEY_PULL_REQUEST}.#{Issue::STATUS_OPEN}"
end
end
after do after do
FileUtils.rm_rf(APP_CONFIG['root_path']) FileUtils.rm_rf(APP_CONFIG['root_path'])
FileUtils.rm_rf File.join(Rails.root, "tmp", Rails.env, "pull_requests") FileUtils.rm_rf File.join(Rails.root, "tmp", Rails.env, "pull_requests")

View File

@ -42,4 +42,19 @@ describe Statistic do
end end
end end
context '#for_groups' do
it 'returns projects by group ids' do
group1 = FactoryGirl.create(:group)
group2 = FactoryGirl.create(:group)
project1 = FactoryGirl.create(:project, owner: group1)
project2 = FactoryGirl.create(:project, owner: group2)
FactoryGirl.create(:statistic, project: project1)
FactoryGirl.create(:statistic, project: project2)
expect(Statistic.for_groups([group1.id])).to have(1).item
expect(Statistic.for_groups([group1.id, group2])).to have(2).items
end
end
end end