Commit 5a4f5cd3 authored by Pavel Shutsin's avatar Pavel Shutsin

Add productivity analytics data endpoints

Data will be used to show productivity charts
parent 4b32a15a
# frozen_string_literal: true
class Analytics::ApplicationController < ApplicationController
include RoutableActions
layout 'analytics'
end
# frozen_string_literal: true
class Analytics::ProductivityAnalyticsController < Analytics::ApplicationController
before_action :load_group
before_action :load_project
before_action :check_feature_availability!
before_action :authorize_view_productivity_analytics!
include IssuableCollections
def show
respond_to do |format|
format.html
format.json do
metric = params.fetch('metric_type', ProductivityAnalytics::DEFAULT_TYPE)
data = case params['chart_type']
when 'scatterplot'
productivity_analytics.scatterplot_data(type: metric)
when 'histogram'
productivity_analytics.histogram_data(type: metric)
else
include_relations(paginate(productivity_analytics.merge_requests_extended)).map do |merge_request|
serializer.represent(merge_request, {}, ProductivityAnalyticsMergeRequestEntity)
end
end
render json: data, status: :ok
end
end
end
private
def paginate(merge_requests)
merge_requests.page(params[:page]).per(params[:per_page]).tap do |paginated_data|
response.set_header('X-Per-Page', paginated_data.limit_value.to_s)
response.set_header('X-Page', paginated_data.current_page.to_s)
response.set_header('X-Next-Page', paginated_data.next_page.to_s)
response.set_header('X-Prev-Page', paginated_data.prev_page.to_s)
response.set_header('X-Total', paginated_data.total_count.to_s)
response.set_header('X-Total-Pages', paginated_data.total_pages.to_s)
end
end
def authorize_view_productivity_analytics!
return render_403 unless can?(current_user, :view_productivity_analytics, @group || :global)
end
def check_feature_availability!
return render_404 unless ::License.feature_available?(:productivity_analytics)
return render_404 if @group && !@group.root_ancestor.feature_available?(:productivity_analytics)
end
def load_group
return unless params['group_id']
@group = find_routable!(Group, params['group_id'])
end
def load_project
return unless @group && params['project_id']
@project = find_routable!(@group.projects, params['project_id'])
end
def serializer
@serializer ||= BaseSerializer.new(current_user: current_user)
end
def finder_type
ProductivityAnalyticsFinder
end
def default_state
'merged'
end
def productivity_analytics
@productivity_analytics ||= ProductivityAnalytics.new(merge_requests: finder.execute, sort: params[:sort])
end
# rubocop: disable CodeReuse/ActiveRecord
def include_relations(paginated_mrs)
# Due to Rails bug: https://github.com/rails/rails/issues/34889 we can't use .includes statement
# to avoid N+1 call when we load custom columns.
# So we load relations manually here.
preloader = ActiveRecord::Associations::Preloader.new
preloader.preload(paginated_mrs, { author: [], target_project: { namespace: :route } })
paginated_mrs
end
# rubocop: enable CodeReuse/ActiveRecord
end
# frozen_string_literal: true
class ProductivityAnalyticsFinder < MergeRequestsFinder
def self.array_params
super.merge(days_to_merge: [])
end
def self.scalar_params
@scalar_params ||= super + [:merged_at_before, :merged_at_after]
end
def filter_items(_items)
items = by_days_to_merge(super)
by_merged_at(items)
end
private
def metrics_table
MergeRequest::Metrics.arel_table.alias(MergeRequest::Metrics.table_name)
end
# rubocop: disable CodeReuse/ActiveRecord
def by_days_to_merge(items)
return items unless params[:days_to_merge].present?
items.joins(:metrics).where("#{days_to_merge_column} IN (?)", params[:days_to_merge].flatten.map(&:to_i))
end
# rubocop: enable CodeReuse/ActiveRecord
def days_to_merge_column
"date_part('day',merge_request_metrics.merged_at - merge_requests.created_at)"
end
# rubocop: disable CodeReuse/ActiveRecord
def by_merged_at(items)
return items unless params[:merged_at_after] || params[:merged_at_before]
items = items.joins(:metrics)
items = items.where(metrics_table[:merged_at].gteq(merged_at_between[:from])) if merged_at_between[:from]
items = items.where(metrics_table[:merged_at].lteq(merged_at_between[:to])) if merged_at_between[:to]
items
end
# rubocop: enable CodeReuse/ActiveRecord
def merged_at_between
@merged_at_between ||= begin
if merged_at_period
{ from: Time.zone.now.ago(merged_at_period.days) }
else
{ from: params[:merged_at_after], to: params[:merged_at_before] }
end
end
end
def merged_at_period
matches = params[:merged_at_after]&.match(/^(?<days>\d+)days?$/)
matches && matches[:days].to_i
end
end
......@@ -83,6 +83,7 @@ class License < ApplicationRecord
object_storage
operations_dashboard
packages
productivity_analytics
project_aliases
protected_environments
reject_unsigned_commits
......
# frozen_string_literal: true
class ProductivityAnalytics
attr_reader :merge_requests, :sort
METRIC_COLUMNS = {
'days_to_merge' => "DATE_PART('day', merge_request_metrics.merged_at - merge_requests.created_at)",
'time_to_first_comment' => "DATE_PART('day', merge_request_metrics.first_comment_at - merge_requests.created_at)*24+DATE_PART('hour', merge_request_metrics.first_comment_at - merge_requests.created_at)",
'time_to_last_commit' => "DATE_PART('day', merge_request_metrics.last_commit_at - merge_request_metrics.first_comment_at)*24+DATE_PART('hour', merge_request_metrics.last_commit_at - merge_request_metrics.first_comment_at)",
'time_to_merge' => "DATE_PART('day', merge_request_metrics.merged_at - merge_request_metrics.last_commit_at)*24+DATE_PART('hour', merge_request_metrics.merged_at - merge_request_metrics.last_commit_at)",
'commits_count' => 'commits_count',
'loc_per_commit' => '(diff_size/commits_count)',
'files_touched' => 'modified_paths_size'
}.freeze
METRIC_TYPES = METRIC_COLUMNS.keys.freeze
DEFAULT_TYPE = 'days_to_merge'.freeze
def initialize(merge_requests:, sort: nil)
@merge_requests = merge_requests.joins(:metrics)
@sort = sort
end
def histogram_data(type:)
return unless column = METRIC_COLUMNS[type]
histogram_query(column).map do |data|
[data[:metric]&.to_i, data[:mr_count]]
end.to_h
end
def scatterplot_data(type:)
return unless column = METRIC_COLUMNS[type]
scatterplot_query(column).map do |data|
[data.id, { metric: data[:metric], merged_at: data[:merged_at] }]
end.to_h
end
def merge_requests_extended
columns = METRIC_COLUMNS.map do |type, column|
Arel::Nodes::As.new(Arel.sql(column), Arel.sql(type)).to_sql
end
columns.unshift(MergeRequest.arel_table[Arel.star])
mrs = merge_requests.select(columns)
mrs = mrs.reorder(custom_sorting) if custom_sorting
mrs
end
private
def histogram_query(column)
merge_requests.except(:select).select("#{column} as metric, count(*) as mr_count").group(column).reorder(nil)
end
def scatterplot_query(column)
merge_requests.except(:select).select("#{column} as metric, merge_requests.id, merge_request_metrics.merged_at").reorder("merge_request_metrics.merged_at ASC")
end
def custom_sorting
return unless sort
column, direction = sort.split(/_(asc|desc)$/i)
return unless column.in?(METRIC_TYPES)
Arel.sql("#{column} #{direction}")
end
end
......@@ -16,6 +16,8 @@ module EE
end
rule { support_bot }.prevent :use_quick_actions
rule { ~anonymous }.enable :view_productivity_analytics
end
end
end
......@@ -131,6 +131,8 @@ module EE
rule { ip_enforcement_prevents_access & ~owner }.policy do
prevent :read_group
end
rule { reporter }.enable :view_productivity_analytics
end
override :lookup_access_level!
......
# frozen_string_literal: true
class ProductivityAnalyticsMergeRequestEntity < IssuableEntity
ProductivityAnalytics::METRIC_TYPES.each do |type|
expose(type) { |mr| mr.attributes[type] }
end
expose :author_avatar_url do |merge_request|
merge_request.author&.avatar_url
end
expose :merge_request_url do |merge_request|
project_merge_request_url(merge_request.target_project, merge_request)
end
end
......@@ -6,28 +6,30 @@
= sprite_icon('log', size: 24)
.sidebar-context-title= _('Analytics')
%ul.sidebar-top-level-items
= nav_link(controller: :productivity_analytics) do
= link_to analytics_productivity_analytics_path, class: 'qa-sidebar-productivity-analytics' do
.nav-icon-container
= sprite_icon('comment')
%span.nav-item-name
= _('Productivity Analytics')
%ul.sidebar-sub-level-items.is-fly-out-only
= nav_link(controller: :productivity_analytics, html_options: { class: "fly-out-top-item qa-sidebar-productivity-analytics-fly-out" } ) do
= link_to analytics_productivity_analytics_path do
%strong.fly-out-top-item-name
= _('Productivity Analytics')
- if Feature.enabled?(:productivity_analytics)
= nav_link(controller: :productivity_analytics) do
= link_to analytics_productivity_analytics_path, class: 'qa-sidebar-productivity-analytics' do
.nav-icon-container
= sprite_icon('comment')
%span.nav-item-name
= _('Productivity Analytics')
%ul.sidebar-sub-level-items.is-fly-out-only
= nav_link(controller: :productivity_analytics, html_options: { class: "fly-out-top-item qa-sidebar-productivity-analytics-fly-out" } ) do
= link_to analytics_productivity_analytics_path do
%strong.fly-out-top-item-name
= _('Productivity Analytics')
= nav_link(controller: :cycle_analytics) do
= link_to analytics_cycle_analytics_path, class: 'qa-sidebar-cycle-analytics' do
.nav-icon-container
= sprite_icon('repeat')
%span.nav-item-name
= _('Cycle Analytics')
%ul.sidebar-sub-level-items.is-fly-out-only
= nav_link(controller: :cycle_analytics, html_options: { class: "fly-out-top-item qa-sidebar-cycle-analytics-fly-out" } ) do
= link_to analytics_cycle_analytics_path do
%strong.fly-out-top-item-name
= _('Cycle Analytics')
- if Feature.enabled?(:cycle_analytics)
= nav_link(controller: :cycle_analytics) do
= link_to analytics_cycle_analytics_path, class: 'qa-sidebar-cycle-analytics' do
.nav-icon-container
= sprite_icon('repeat')
%span.nav-item-name
= _('Cycle Analytics')
%ul.sidebar-sub-level-items.is-fly-out-only
= nav_link(controller: :cycle_analytics, html_options: { class: "fly-out-top-item qa-sidebar-cycle-analytics-fly-out" } ) do
= link_to analytics_cycle_analytics_path do
%strong.fly-out-top-item-name
= _('Cycle Analytics')
= render 'shared/sidebar_toggle_button'
---
title: add Productivity Analytics page with basic charts
merge_request: 14772
author:
type: added
# frozen_string_literal: true
namespace :analytics do
root to: redirect('-/analytics/productivity_analytics')
constraints(::Constraints::FeatureConstrainer.new(:productivity_analytics)) do
root to: redirect('-/analytics/productivity_analytics')
resource :productivity_analytics, only: :show
resource :cycle_analytics, only: :show
resource :productivity_analytics, only: :show
end
constraints(::Constraints::FeatureConstrainer.new(:cycle_analytics)) do
resource :cycle_analytics, only: :show
end
end
# frozen_string_literal: true
require './spec/support/sidekiq'
class Gitlab::Seeder::ProductivityAnalytics
def initialize(project)
@project = project
@user = User.admins.first
@issue_count = 100
end
def seed!
Sidekiq::Worker.skipping_transaction_check do
Sidekiq::Testing.inline! do
Timecop.travel 90.days.ago
issues = create_issues
print '.'
Timecop.travel 10.days.from_now
add_milestones_and_list_labels(issues)
print '.'
Timecop.travel 10.days.from_now
branches = mention_in_commits(issues)
print '.'
Timecop.travel 10.days.from_now
merge_requests = create_merge_requests_closing_issues(issues, branches)
print '.'
Timecop.travel 10.days.from_now
create_notes(merge_requests)
Timecop.travel 10.days.from_now
merge_merge_requests(merge_requests)
print '.'
end
end
print '.'
end
private
def create_issues
Array.new(@issue_count) do
issue_params = {
title: "Productivity Analytics: #{FFaker::Lorem.sentence(6)}",
description: FFaker::Lorem.sentence,
state: 'opened',
assignees: [@project.team.users.sample]
}
Timecop.travel rand(10).days.from_now do
Issues::CreateService.new(@project, @project.team.users.sample, issue_params).execute
end
end
end
def add_milestones_and_list_labels(issues)
issues.shuffle.map.with_index do |issue, index|
Timecop.travel 12.hours.from_now do
if index.even?
issue.update(milestone: @project.milestones.sample)
else
label_name = "#{FFaker::Product.brand}-#{FFaker::Product.brand}-#{rand(1000)}"
list_label = FactoryBot.create(:label, title: label_name, project: issue.project)
FactoryBot.create(:list, board: FactoryBot.create(:board, project: issue.project), label: list_label)
issue.update(labels: [list_label])
end
issue
end
end
end
def mention_in_commits(issues)
issues.map do |issue|
branch_name = filename = "#{FFaker::Product.brand}-#{FFaker::Product.brand}-#{rand(1000)}"
Timecop.travel 12.hours.from_now do
issue.project.repository.add_branch(@user, branch_name, 'master')
commit_sha = issue.project.repository.create_file(@user, filename, "content", message: "Commit for #{issue.to_reference}", branch_name: branch_name)
issue.project.repository.commit(commit_sha)
::Git::BranchPushService.new(
issue.project,
@user,
oldrev: issue.project.repository.commit("master").sha,
newrev: commit_sha,
ref: 'refs/heads/master'
).execute
end
branch_name
end
end
def create_merge_requests_closing_issues(issues, branches)
issues.zip(branches).map do |issue, branch|
opts = {
title: 'Productivity Analytics merge_request',
description: "Fixes #{issue.to_reference}",
source_branch: branch,
target_branch: 'master'
}
Timecop.travel issue.created_at do
MergeRequests::CreateService.new(issue.project, @user, opts).execute
end
end
end
def create_notes(merge_requests)
merge_requests.each do |merge_request|
Timecop.travel merge_request.created_at + rand(5).days do
Note.create!(
author: @user,
project: merge_request.project,
noteable: merge_request,
note: FFaker::Lorem.sentence(rand(5))
)
end
end
end
def merge_merge_requests(merge_requests)
merge_requests.each do |merge_request|
Timecop.travel rand(15).days.from_now do
MergeRequests::MergeService.new(merge_request.project, @user).execute(merge_request)
end
end
end
end
Gitlab::Seeder.quiet do
flag = 'SEED_PRODUCTIVITY_ANALYTICS'
if ENV[flag]
Project.find_each do |project|
# This seed naively assumes that every project has a repository, and every
# repository has a `master` branch, which may be the case for a pristine
# GDK seed, but is almost never true for a GDK that's actually had
# development performed on it.
next unless project.repository_exists? && project.repository.commit('master')
seeder = Gitlab::Seeder::ProductivityAnalytics.new(project)
seeder.seed!
puts "Productivity analytics seeded for project #{project.full_path}"
break
end
else
puts "Skipped. Use the `#{flag}` environment variable to enable."
end
end
......@@ -3,17 +3,125 @@
require 'spec_helper'
describe Analytics::ProductivityAnalyticsController do
let(:user) { create(:user) }
let(:current_user) { create(:user) }
before do
sign_in(user)
sign_in(current_user) if current_user
stub_licensed_features(productivity_analytics: true)
end
describe 'GET show' do
it 'renders `show` template' do
get :show
subject { get :show }
it 'checks for premium license' do
stub_licensed_features(productivity_analytics: false)
subject
expect(response.code).to eq '404'
end
it 'authorizes for ability to view analytics' do
expect(Ability).to receive(:allowed?).with(current_user, :view_productivity_analytics, :global).and_return(false)
subject
expect(response.code).to eq '403'
end
it 'renders show template' do
subject
expect(response).to render_template :show
end
end
describe 'GET show.json' do
subject { get :show, format: :json, params: params }
let(:params) { {} }
let(:analytics_mock) { instance_double('ProductivityAnalytics') }
before do
merge_requests = double
allow_any_instance_of(ProductivityAnalyticsFinder).to receive(:execute).and_return(merge_requests)
allow(ProductivityAnalytics)
.to receive(:new)
.with(merge_requests: merge_requests, sort: params[:sort])
.and_return(analytics_mock)
end
context 'with non-existing group_id' do
let(:params) { { group_id: 'SOMETHING_THAT_DOES_NOT_EXIST' } }
it 'renders 404' do
subject
expect(response.code).to eq '404'
end
end
context 'with non-existing project_id' do
let(:group) { create :group }
let(:params) { { group_id: group.full_path, project_id: 'SOMETHING_THAT_DOES_NOT_EXIST' } }
it 'renders 404' do
subject
expect(response.code).to eq '404'
end
end
context 'for list of MRs' do
let!(:merge_request ) { create :merge_request, :merged}
let(:serializer_mock) { instance_double('BaseSerializer') }
before do
allow(BaseSerializer).to receive(:new).with(current_user: current_user).and_return(serializer_mock)
allow(analytics_mock).to receive(:merge_requests_extended).and_return(MergeRequest.all)
allow(serializer_mock).to receive(:represent)
.with(merge_request, {}, ProductivityAnalyticsMergeRequestEntity)
.and_return('mr_representation')
end
it 'serializes whatever analytics returns with ProductivityAnalyticsMergeRequestEntity' do
subject
expect(response.body).to eq '["mr_representation"]'
end
it 'sets pagination headers' do
subject
expect(response.headers['X-Per-Page']).to eq '20'
expect(response.headers['X-Page']).to eq '1'
expect(response.headers['X-Next-Page']).to eq ''
expect(response.headers['X-Prev-Page']).to eq ''
expect(response.headers['X-Total']).to eq '1'
expect(response.headers['X-Total-Pages']).to eq '1'
end
end
context 'for scatterplot charts' do
let(:params) { { chart_type: 'scatterplot', metric_type: 'commits_count' } }
it 'renders whatever analytics returns for scatterplot' do
allow(analytics_mock).to receive(:scatterplot_data).with(type: 'commits_count').and_return('scatterplot_data')
subject
expect(response.body).to eq 'scatterplot_data'
end
end
context 'for histogram charts' do
let(:params) { { chart_type: 'histogram', metric_type: 'commits_count' } }
it 'renders whatever analytics returns for histogram' do
allow(analytics_mock).to receive(:histogram_data).with(type: 'commits_count').and_return('histogram_data')
subject
expect(response.body).to eq 'histogram_data'
end
end
end
end
......@@ -30,6 +30,19 @@ FactoryBot.modify do
merge_user { author }
end
trait :with_productivity_metrics do
transient do
metrics_data {}
end
after :build do |mr, evaluator|
next if evaluator.metrics_data.empty?
mr.build_metrics unless mr.metrics
mr.metrics.assign_attributes evaluator.metrics_data
end
end
transient do
approval_groups []
approval_users []
......
# frozen_string_literal: true
require 'spec_helper'
describe ProductivityAnalyticsFinder do
subject { described_class.new(current_user, search_params.merge(state: :merged)) }
let(:current_user) { create(:admin) }
let(:search_params) { {} }
describe '.array_params' do
subject { described_class.array_params }
it { is_expected.to include(:days_to_merge) }
end
describe '.scalar_params' do
subject { described_class.scalar_params }
it { is_expected.to include(:merged_at_before, :merged_at_after) }
end
describe '#execute' do
let(:long_mr) do
metrics_data = { merged_at: 1.day.ago }
create(:merge_request, :merged, :with_productivity_metrics, created_at: 31.days.ago, metrics_data: metrics_data)
end
let(:short_mr) do
metrics_data = { merged_at: 28.days.ago }
create(:merge_request, :merged, :with_productivity_metrics, created_at: 31.days.ago, metrics_data: metrics_data)
end
context 'allows to filter by days_to_merge' do
let(:search_params) { { days_to_merge: [30] } }
it 'returns all MRs with merged_at - created_at IN specified values' do
Timecop.freeze do
long_mr
short_mr
expect(subject.execute).to match_array([long_mr])
end
end
end
context 'allows to filter by merged_at' do
around do |example|
Timecop.freeze { example.run }
end
context 'with merged_at_after specified as timestamp' do
let(:search_params) do
{
merged_at_after: 25.days.ago.to_s
}
end
it 'returns all MRs with merged date later than specified timestamp' do
long_mr
short_mr
expect(subject.execute).to match_array([long_mr])
end
end
context 'with merged_at_after specified as days-range' do
let(:search_params) do
{
merged_at_after: '11days'
}
end
it 'returns all MRs with merged date later than Xdays ago' do
long_mr
short_mr
expect(subject.execute).to match_array([long_mr])
end
end
context 'with merged_at_after and merged_at_before specified' do
let(:search_params) do
{
merged_at_after: 30.days.ago.to_s,
merged_at_before: 20.days.ago.to_s
}
end
it 'returns all MRs with merged date later than specified timestamp' do
long_mr
short_mr
expect(subject.execute).to match_array([short_mr])
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ProductivityAnalytics do
subject(:analytics) { described_class.new(merge_requests: MergeRequest.all, sort: custom_sort) }
let(:custom_sort) { nil }
let(:long_mr) do
metrics_data = {
merged_at: 1.day.ago,
first_comment_at: 31.days.ago,
last_commit_at: 2.days.ago,
commits_count: 20,
diff_size: 310,
modified_paths_size: 15
}
create(:merge_request, :merged, :with_productivity_metrics, created_at: 31.days.ago, metrics_data: metrics_data)
end
let(:medium_mr) do
metrics_data = {
merged_at: 1.day.ago,
first_comment_at: 15.days.ago,
last_commit_at: 2.days.ago,
commits_count: 5,
diff_size: 84,
modified_paths_size: 3
}
create(:merge_request, :merged, :with_productivity_metrics, created_at: 15.days.ago, metrics_data: metrics_data)
end
let(:short_mr) do
metrics_data = {
merged_at: 28.days.ago,
first_comment_at: 30.days.ago,
last_commit_at: 28.days.ago,
commits_count: 1,
diff_size: 14,
modified_paths_size: 3
}
create(:merge_request, :merged, :with_productivity_metrics, created_at: 31.days.ago, metrics_data: metrics_data)
end
let(:short_mr_2) do
metrics_data = {
merged_at: 28.days.ago,
first_comment_at: 31.days.ago,
last_commit_at: 29.days.ago,
commits_count: 1,
diff_size: 5,
modified_paths_size: 1
}
create(:merge_request, :merged, :with_productivity_metrics, created_at: 31.days.ago, metrics_data: metrics_data)
end
before do
Timecop.freeze do
long_mr
medium_mr
short_mr
short_mr_2
end
end
describe '#histogram_data' do
subject { analytics.histogram_data(type: metric) }
context 'days_to_merge metric' do
let(:metric) { 'days_to_merge' }
it 'returns aggregated data per days to merge from MR creation date' do
expect(subject).to eq(3 => 2, 14 => 1, 30 => 1)
end
end
context 'time_to_first_comment metric' do
let(:metric) { 'time_to_first_comment' }
it 'returns aggregated data per hours from MR creation to first comment' do
expect(subject).to eq(0 => 3, 24 => 1)
end
end
context 'time_to_last_commit metric' do
let(:metric) { 'time_to_last_commit' }
it 'returns aggregated data per hours from first comment to last commit' do
expect(subject).to eq(13 * 24 => 1, 29 * 24 => 1, 2 * 24 => 2)
end
end
context 'time_to_merge metric' do
let(:metric) { 'time_to_merge' }
it 'returns aggregated data per hours from last commit to merge' do
expect(subject).to eq(24 => 3, 0 => 1)
end
end
context 'commits_count metric' do
let(:metric) { 'commits_count' }
it 'returns aggregated data per number of commits' do
expect(subject).to eq(1 => 2, 5 => 1, 20 => 1)
end
end
context 'loc_per_commit metric' do
let(:metric) { 'loc_per_commit' }
it 'returns aggregated data per number of LoC/commits_count' do
expect(subject).to eq(15 => 1, 16 => 1, 14 => 1, 5 => 1)
end
end
context 'files_touched metric' do
let(:metric) { 'files_touched' }
it 'returns aggregated data per number of modified files' do
expect(subject).to eq(15 => 1, 3 => 2, 1 => 1)
end
end
context 'for invalid metric' do
let(:metric) { 'something_invalid' }
it { is_expected.to eq nil }
end
end
# Test coverage depends on #histogram_data tests. We want to avoid duplication here, so test only for 1 metric.
describe '#scatterplot_data' do
subject { analytics.scatterplot_data(type: 'days_to_merge') }
it 'returns metric values for each MR' do
expect(subject).to match(
short_mr.id => { metric: 3, merged_at: be_like_time(short_mr.merged_at) },
short_mr_2.id => { metric: 3, merged_at: be_like_time(short_mr_2.merged_at) },
medium_mr.id => { metric: 14, merged_at: be_like_time(medium_mr.merged_at) },
long_mr.id => { metric: 30, merged_at: be_like_time(long_mr.merged_at) }
)
end
end
describe '#merge_requests_extended' do
subject { analytics.merge_requests_extended }
it 'returns MRs data with all the metrics calculated' do
expected_data = {
long_mr.id => {
'days_to_merge' => 30,
'time_to_first_comment' => 0,
'time_to_last_commit' => 29 * 24,
'time_to_merge' => 24,
'commits_count' => 20,
'loc_per_commit' => 15,
'files_touched' => 15
},
medium_mr.id => {
'days_to_merge' => 14,
'time_to_first_comment' => 0,
'time_to_last_commit' => 13 * 24,
'time_to_merge' => 24,
'commits_count' => 5,
'loc_per_commit' => 16,
'files_touched' => 3
},
short_mr.id => {
'days_to_merge' => 3,
'time_to_first_comment' => 24,
'time_to_last_commit' => 2 * 24,
'time_to_merge' => 0,
'commits_count' => 1,
'loc_per_commit' => 14,
'files_touched' => 3
},
short_mr_2.id => {
'days_to_merge' => 3,
'time_to_first_comment' => 0,
'time_to_last_commit' => 2 * 24,
'time_to_merge' => 24,
'commits_count' => 1,
'loc_per_commit' => 5,
'files_touched' => 1
}
}
expected_data.each do |mr_id, expected_attributes|
expect(subject.detect { |mr| mr.id == mr_id}.attributes).to include(expected_attributes)
end
end
context 'with custom sorting' do
let(:custom_sort) { 'loc_per_commit_asc' }
it 'reorders MRs according to custom sorting' do
expect(subject).to eq [short_mr_2, short_mr, long_mr, medium_mr]
end
context 'with unknown sorting' do
let(:custom_sort) { 'weird_stuff' }
it 'does not apply custom sorting' do
expect(subject).to eq [long_mr, medium_mr, short_mr, short_mr_2]
end
end
end
end
end
......@@ -31,4 +31,16 @@ describe GlobalPolicy do
it { expect(described_class.new(create(:admin), [user])).to be_allowed(:read_licenses) }
it { expect(described_class.new(create(:admin), [user])).to be_allowed(:destroy_licenses) }
describe 'view_productivity_analytics' do
context 'for admins' do
let(:current_user) { create(:admin) }
it { is_expected.to be_allowed(:view_productivity_analytics) }
end
context 'for non-admins' do
it { is_expected.not_to be_allowed(:view_productivity_analytics) }
end
end
end
......@@ -403,4 +403,22 @@ describe GroupPolicy do
end
end
end
describe 'view_productivity_analytics' do
%w[admin owner].each do |role|
context "for #{role}" do
let(:current_user) { public_send(role) }
it { is_expected.to be_allowed(:view_productivity_analytics) }
end
end
%w[maintainer developer reporter guest].each do |role|
context "for #{role}" do
let(:current_user) { public_send(role) }
it { is_expected.to be_disallowed(:view_productivity_analytics) }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ProductivityAnalyticsMergeRequestEntity do
subject { described_class.represent(merge_request).as_json.with_indifferent_access }
let(:merge_request) { create(:merge_request) }
before do
ProductivityAnalytics::METRIC_TYPES.each.with_index do |type, i|
allow(merge_request).to receive(type).and_return(i)
end
end
it 'exposes all additional metrics' do
expect(subject.keys).to include(*ProductivityAnalytics::METRIC_TYPES)
end
it 'exposes author_avatar_url' do
expect(subject[:author_avatar_url]).to eq merge_request.author.avatar_url
end
it 'exposes merge_request_url' do
expect(subject[:merge_request_url])
.to eq Gitlab::Routing.url_helpers.project_merge_request_url(merge_request.project, merge_request)
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment