Commit 2523320f authored by Adam Hegyi's avatar Adam Hegyi

Top labels endpoint for type of work chart

- Expose a new endpoint `top_labels`
- Query to look at the last 100 MR or Issue records to collect the most
commonly used GroupLabels.
parent 0a8d441b
...@@ -42,6 +42,22 @@ class Label < ApplicationRecord ...@@ -42,6 +42,22 @@ class Label < ApplicationRecord
scope :order_name_desc, -> { reorder(title: :desc) } scope :order_name_desc, -> { reorder(title: :desc) }
scope :subscribed_by, ->(user_id) { joins(:subscriptions).where(subscriptions: { user_id: user_id, subscribed: true }) } scope :subscribed_by, ->(user_id) { joins(:subscriptions).where(subscriptions: { user_id: user_id, subscribed: true }) }
scope :top_labels_by_target, -> (target_relation) {
label_id_column = arel_table[:id]
# Window aggregation to count labels
count_by_id = Arel::Nodes::Over.new(
Arel::Nodes::NamedFunction.new('count', [label_id_column]),
Arel::Nodes::Window.new.partition(label_id_column)
).as('count_by_id')
select(arel_table[Arel.star], count_by_id)
.joins(:label_links)
.merge(LabelLink.where(target: target_relation))
.reorder(count_by_id: :desc)
.distinct
}
def self.prioritized(project) def self.prioritized(project)
joins(:priorities) joins(:priorities)
.where(label_priorities: { project_id: project }) .where(label_priorities: { project_id: project })
......
...@@ -6,23 +6,31 @@ class Analytics::TasksByTypeController < Analytics::ApplicationController ...@@ -6,23 +6,31 @@ class Analytics::TasksByTypeController < Analytics::ApplicationController
before_action :load_group before_action :load_group
before_action -> { check_feature_availability!(:type_of_work_analytics) } before_action -> { check_feature_availability!(:type_of_work_analytics) }
before_action -> { authorize_view_by_action!(:view_type_of_work_charts) } before_action -> { authorize_view_by_action!(:view_type_of_work_charts) }
before_action :validate_label_ids before_action :validate_label_ids, only: :show
before_action :prepare_date_range before_action :prepare_date_range
def show def show
render json: Analytics::TasksByTypeLabelEntity.represent(counts_by_labels) render json: Analytics::TasksByTypeLabelEntity.represent(counts_by_labels)
end end
def top_labels
render json: LabelEntity.represent(tasks_by_type.top_labels)
end
private private
def counts_by_labels def counts_by_labels
tasks_by_type.counts_by_labels
end
def tasks_by_type
Gitlab::Analytics::TypeOfWork::TasksByType.new(group: @group, current_user: current_user, params: { Gitlab::Analytics::TypeOfWork::TasksByType.new(group: @group, current_user: current_user, params: {
subject: params[:subject], subject: params[:subject],
label_ids: Array(params[:label_ids]), label_ids: Array(params[:label_ids]),
project_ids: Array(params[:project_ids]), project_ids: Array(params[:project_ids]),
created_after: @created_after.to_time.utc.beginning_of_day, created_after: @created_after.to_time.utc.beginning_of_day,
created_before: @created_before.to_time.utc.end_of_day created_before: @created_before.to_time.utc.end_of_day
}).counts_by_labels })
end end
def validate_label_ids def validate_label_ids
......
...@@ -22,7 +22,9 @@ namespace :analytics do ...@@ -22,7 +22,9 @@ namespace :analytics do
constraints(::Constraints::FeatureConstrainer.new(Gitlab::Analytics::TASKS_BY_TYPE_CHART_FEATURE_FLAG)) do constraints(::Constraints::FeatureConstrainer.new(Gitlab::Analytics::TASKS_BY_TYPE_CHART_FEATURE_FLAG)) do
scope :type_of_work do scope :type_of_work do
resource :tasks_by_type, controller: :tasks_by_type, only: :show resource :tasks_by_type, controller: :tasks_by_type, only: :show do
get :top_labels
end
end end
end end
end end
...@@ -11,6 +11,8 @@ module Gitlab ...@@ -11,6 +11,8 @@ module Gitlab
Issue.to_s => IssuesFinder Issue.to_s => IssuesFinder
}.freeze }.freeze
TOP_LABELS_COUNT = 10
def initialize(group:, params:, current_user:) def initialize(group:, params:, current_user:)
@group = group @group = group
@params = params @params = params
...@@ -21,12 +23,26 @@ module Gitlab ...@@ -21,12 +23,26 @@ module Gitlab
format_result(query_result) format_result(query_result)
end end
# top N commonly used labels for Issues or MergeRequests ordered by usage
# rubocop: disable CodeReuse/ActiveRecord
def top_labels(limit = TOP_LABELS_COUNT)
targets = finder
.execute
.order_by(:id_desc)
.limit(100)
GroupLabel
.top_labels_by_target(targets)
.limit(limit)
end
# rubocop: enable CodeReuse/ActiveRecord
private private
attr_reader :group, :params, :finder attr_reader :group, :params, :finder
def finder_class def finder_class
FINDER_CLASSES.fetch(params[:subject], FINDER_CLASSES.each_key.first) FINDER_CLASSES.fetch(params[:subject], FINDER_CLASSES.each_value.first)
end end
def format_result(result) def format_result(result)
......
...@@ -19,29 +19,21 @@ describe Analytics::TasksByTypeController do ...@@ -19,29 +19,21 @@ describe Analytics::TasksByTypeController do
sign_in(user) sign_in(user)
end end
context 'when valid parameters are given' do shared_examples 'expects unprocessable_entity response' do
it 'succeeds' do it 'returns unprocessable_entity as response' do
subject
expect(response).to be_successful
expect(response).to match_response_schema('analytics/tasks_by_type', dir: 'ee')
end
it 'returns valid count' do
subject subject
date, count = json_response.first["series"].first expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(Date.parse(date)).to eq(issue.created_at.to_date)
expect(count).to eq(1)
end end
end end
shared_examples 'parameter validation' do
context 'when user access level is lower than reporter' do context 'when user access level is lower than reporter' do
before do before do
group.add_guest(user) group.add_guest(user)
end end
it do it 'returns forbidden as response' do
subject subject
expect(response).to have_gitlab_http_status(:forbidden) expect(response).to have_gitlab_http_status(:forbidden)
...@@ -72,44 +64,84 @@ describe Analytics::TasksByTypeController do ...@@ -72,44 +64,84 @@ describe Analytics::TasksByTypeController do
end end
end end
shared_examples 'expects unprocessable_entity response' do context 'when `created_after` parameter is invalid' do
it 'returns unprocessable_entity as resposne' do before do
subject params[:created_after] = 'invalid_date'
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end end
it_behaves_like 'expects unprocessable_entity response'
end end
context 'when `label_id` is missing' do context 'when `created_after` parameter is missing' do
before do before do
params.delete(:label_ids) params.delete(:created_after)
end end
it_behaves_like 'expects unprocessable_entity response' it_behaves_like 'expects unprocessable_entity response'
end end
context 'when `created_after` parameter is invalid' do context 'when `created_after` date is later than `created_before` date' do
before do before do
params[:created_after] = 'invalid_date' params[:created_after] = 1.year.ago.to_date
params[:created_before] = 2.years.ago.to_date
end end
it_behaves_like 'expects unprocessable_entity response' it_behaves_like 'expects unprocessable_entity response'
end end
end
context 'when `created_after` parameter is missing' do describe 'GET show' do
before do context 'when valid parameters are given' do
params.delete(:created_after) it 'succeeds' do
subject
expect(response).to be_successful
expect(response).to match_response_schema('analytics/tasks_by_type', dir: 'ee')
end end
it_behaves_like 'expects unprocessable_entity response' it 'returns valid count' do
subject
date, count = json_response.first["series"].first
expect(Date.parse(date)).to eq(issue.created_at.to_date)
expect(count).to eq(1)
end
end end
context 'when `created_after` date is later than "created_before" date' do context 'when `label_id` is missing' do
before do before do
params[:created_after] = 1.year.ago.to_date params.delete(:label_ids)
params[:created_before] = 2.years.ago.to_date
end end
it_behaves_like 'expects unprocessable_entity response' it_behaves_like 'expects unprocessable_entity response'
end end
it_behaves_like 'parameter validation'
end
describe 'GET top_labels' do
let(:params) { { group_id: group.full_path, created_after: 10.days.ago, subject: 'Issue' } }
subject { get :top_labels, params: params }
context 'when valid parameters are given' do
it 'succeeds' do
subject
expect(response).to be_successful
expect(response).to match_response_schema('analytics/tasks_by_type_top_labels', dir: 'ee')
end
it 'returns valid count' do
subject
label_item = json_response.first
expect(label_item['title']).to eq(label.title)
expect(json_response.count).to eq(1)
end
end
it_behaves_like 'parameter validation'
end
end end
{
"type": "array",
"items": {
"type": "object" ,
"required": ["id", "title", "color", "text_color"],
"properties": {
"label": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"title": {
"type": "string"
},
"color": {
"type": "string"
},
"text_color": {
"type": "string"
}
}
}
}
}
}
...@@ -8,6 +8,8 @@ describe Gitlab::Analytics::TypeOfWork::TasksByType do ...@@ -8,6 +8,8 @@ describe Gitlab::Analytics::TypeOfWork::TasksByType do
let_it_be(:other_group) { create(:group) } let_it_be(:other_group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group) } let_it_be(:subgroup) { create(:group, parent: group) }
let_it_be(:label) { create(:group_label, group: group) } let_it_be(:label) { create(:group_label, group: group) }
let_it_be(:other_label) { create(:group_label, group: group) }
let_it_be(:not_used_label) { create(:group_label, group: group) }
let_it_be(:label_for_subgroup) { create(:group_label, group: group) } let_it_be(:label_for_subgroup) { create(:group_label, group: group) }
let_it_be(:other_label) { create(:group_label, group: other_group) } let_it_be(:other_label) { create(:group_label, group: other_group) }
let_it_be(:project) { create(:project, group: group) } let_it_be(:project) { create(:project, group: group) }
...@@ -118,6 +120,34 @@ describe Gitlab::Analytics::TypeOfWork::TasksByType do ...@@ -118,6 +120,34 @@ describe Gitlab::Analytics::TypeOfWork::TasksByType do
end end
end end
shared_examples '#top_labels' do
let(:top_labels) { described_class.new(params).top_labels }
let!(:with_label) do
create(factory_name, {
:created_at => 3.days.ago,
:labels => [label, other_label],
project_attribute_name => project
})
end
let!(:with_other_label_only) do
create(factory_name, {
:created_at => 3.days.ago,
:labels => [other_label],
project_attribute_name => create(:project, group: group)
})
end
it 'sorts by descending order' do
expect(top_labels).to eq([other_label, label])
end
it 'limits the the size of the results' do
expect(described_class.new(params).top_labels(1)).to eq([other_label])
end
end
context 'when subject is `Issue`' do context 'when subject is `Issue`' do
let(:factory_name) { :labeled_issue } let(:factory_name) { :labeled_issue }
let(:project_attribute_name) { :project } let(:project_attribute_name) { :project }
...@@ -126,7 +156,8 @@ describe Gitlab::Analytics::TypeOfWork::TasksByType do ...@@ -126,7 +156,8 @@ describe Gitlab::Analytics::TypeOfWork::TasksByType do
params[:params][:subject] = Issue.to_s params[:params][:subject] = Issue.to_s
end end
include_examples '#counts_by_labels' it_behaves_like '#counts_by_labels'
it_behaves_like '#top_labels'
end end
context 'when subject is `MergeRequest`' do context 'when subject is `MergeRequest`' do
...@@ -137,6 +168,23 @@ describe Gitlab::Analytics::TypeOfWork::TasksByType do ...@@ -137,6 +168,23 @@ describe Gitlab::Analytics::TypeOfWork::TasksByType do
params[:params][:subject] = MergeRequest.to_s params[:params][:subject] = MergeRequest.to_s
end end
include_examples '#counts_by_labels' it_behaves_like '#counts_by_labels'
it_behaves_like '#top_labels'
end
context 'when unknown `subject` is given' do
before do
params[:params][:subject] = 'invalid'
create(:merge_request, {
created_at: 3.days.ago,
labels: [label],
source_project: project
})
end
it 'falls back to `MergeRequestFinder`' do
expect(subject.map(&:label)).to eq([label])
end
end end
end end
...@@ -59,7 +59,7 @@ describe Projects::DeploymentsController do ...@@ -59,7 +59,7 @@ describe Projects::DeploymentsController do
end end
end end
it 'returns a empty response 204 resposne' do it 'returns an empty 204 response' do
get :metrics, params: deployment_params(id: deployment.to_param) get :metrics, params: deployment_params(id: deployment.to_param)
expect(response).to have_gitlab_http_status(:no_content) expect(response).to have_gitlab_http_status(:no_content)
expect(response.body).to eq('') expect(response.body).to eq('')
......
...@@ -183,6 +183,31 @@ describe Label do ...@@ -183,6 +183,31 @@ describe Label do
end end
end end
describe '.top_labels_by_target' do
let(:label) { create(:label) }
let(:popular_label) { create(:label) }
let(:merge_request1) { create(:merge_request) }
let(:merge_request2) { create(:merge_request) }
before do
merge_request1.labels = [label, popular_label]
merge_request2.labels = [popular_label]
end
it 'returns distinct labels, ordered by usage in the given target relation' do
top_labels = described_class.top_labels_by_target(MergeRequest.all)
expect(top_labels).to match_array([popular_label, label])
end
it 'excludes labels that are not assigned to any records in the given target relation' do
merge_requests = MergeRequest.where(id: merge_request2.id)
top_labels = described_class.top_labels_by_target(merge_requests)
expect(top_labels).to match_array([popular_label])
end
end
describe '.optionally_subscribed_by' do describe '.optionally_subscribed_by' do
let!(:user) { create(:user) } let!(:user) { create(:user) }
let!(:label) { create(:label) } let!(:label) { create(:label) }
......
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