Commit cfb89d1b authored by Rémy Coutable's avatar Rémy Coutable

Introduce the Gitlab::Insights framework

Signed-off-by: default avatarRémy Coutable <remy@rymai.me>
parent e194d617
# frozen_string_literal: true
module Gitlab
module Insights
COLOR_SCHEME = {
red: '#e6194b',
green: '#3cb44b',
yellow: '#ffe119',
blue: '#4363d8',
orange: '#f58231',
purple: '#911eb4',
cyan: '#42d4f4',
magenta: '#f032e6',
lime: '#bfef45',
pink: '#fabebe',
teal: '#469990',
lavender: '#e6beff',
brown: '#9a6324',
beige: '#fffac8',
maroon: '#800000',
mint: '#aaffc3',
olive: '#808000',
apricot: '#ffd8b1'
}.freeze
UNCATEGORIZED = 'undefined'
UNCATEGORIZED_COLOR = "#808080"
TOP_COLOR = "#ff0000"
HIGH_COLOR = "#ff8800"
MEDIUM_COLOR = "#fff600"
LOW_COLOR = "#008000"
BUG_COLOR = "#ff0000"
SECURITY_COLOR = "#d9534f"
DEFAULT_COLOR = "#428bca"
LINE_COLOR = COLOR_SCHEME[:red]
STATIC_COLOR_MAP = {
UNCATEGORIZED => UNCATEGORIZED_COLOR,
"S1" => TOP_COLOR,
"S2" => HIGH_COLOR,
"S3" => MEDIUM_COLOR,
"S4" => LOW_COLOR,
"P1" => TOP_COLOR,
"P2" => HIGH_COLOR,
"P3" => MEDIUM_COLOR,
"P4" => LOW_COLOR,
"bug" => BUG_COLOR,
"security" => SECURITY_COLOR
}.freeze
end
end
## Gitlab::Insights
The goal of the `Gitlab::Insights::` classes is to:
1. Find the raw data (issuables),
1. Reduce them depending on certain conditions,
1. Serialize the reduced data into a payload that can be JSON'ed and used on the
frontend by the graphing library.
### Architecture diagram
```mermaid
graph TD
subgraph Gitlab::Insights::
A[Finders::] --> |"returns issuables Active Record (AR) relation"| B;
B[Reducers::] --> |reduces issuables AR relation into a hash of chart data| C
C[Serializers::] --> |serializes chart data to be consumable by the frontend and the charting library| D
D(JSON-compatible payload used by the frontend to build the chart)
end
```
#### Specific example
```mermaid
graph TD
subgraph Gitlab::Insights::
A[Finders::IssuableFinder] --> B;
B[Reducers::LabelCountPerPeriodReducer] --> C
C[Serializers::Chartjs::MultiSeriesSerializer] --> D
D(JSON-compatible payload used by the frontend to build the graph)
end
```
# frozen_string_literal: true
module Gitlab
module Insights
module Finders
class IssuableFinder
IssuableFinderError = Class.new(StandardError)
InvalidIssuableTypeError = Class.new(IssuableFinderError)
InvalidGroupByError = Class.new(IssuableFinderError)
InvalidPeriodLimitError = Class.new(IssuableFinderError)
InvalidEntityError = Class.new(IssuableFinderError)
FINDERS = {
issue: ::IssuesFinder,
merge_request: ::MergeRequestsFinder
}.with_indifferent_access.freeze
PERIODS = {
days: { default: 30 },
weeks: { default: 4 },
months: { default: 12 }
}.with_indifferent_access.freeze
def initialize(entity, current_user, opts)
@entity = entity
@current_user = current_user
@opts = opts
end
# Returns an Active Record relation of issuables.
def find
relation = finder
.new(current_user, finder_args)
.execute
relation = relation.preload(:labels) if opts.key?(:collection_labels) # rubocop:disable CodeReuse/ActiveRecord
relation
end
private
attr_reader :entity, :current_user, :opts
def finder
issuable_type = opts[:issuable_type].to_sym
FINDERS[issuable_type] ||
raise(InvalidIssuableTypeError, "Invalid `:issuable_type` option: `#{opts[:issuable_type]}`. Allowed values are #{FINDERS.keys}!")
end
def finder_args
{
state: opts[:issuable_state] || 'opened',
label_name: opts[:filter_labels],
sort: 'created_asc',
created_after: created_after_argument
}.merge(entity_key => entity.id)
end
def entity_key
case entity
when ::Project
:project_id
when ::Namespace
:group_id
else
raise InvalidEntityError, "Entity class `#{entity.class}` is not supported. Supported classes are Project and Namespace!"
end
end
def created_after_argument
return unless opts.key?(:group_by)
Time.zone.now.advance(period => -period_limit)
end
def period
@period ||=
begin
period = opts[:group_by].to_s.pluralize.to_sym
unless PERIODS.key?(period)
raise InvalidGroupByError, "Invalid `:group_by` option: `#{opts[:group_by]}`. Allowed values are #{PERIODS.keys}!"
end
period
end
end
def period_limit
@period_limit ||=
if opts.key?(:period_limit)
begin
Integer(opts[:period_limit])
rescue ArgumentError
raise InvalidPeriodLimitError, "Invalid `:period_limit` option: `#{opts[:period_limit]}`. Expected an integer!"
end
else
PERIODS.dig(period, :default)
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Insights
module Reducers
class BaseReducer
BaseReducerError = Class.new(StandardError)
def self.reduce(issuables, **args)
new(issuables, **args).reduce
end
def initialize(issuables)
@issuables = issuables
end
private_class_method :new
# Should return an insights hash.
def reduce
raise NotImplementedError
end
private
attr_reader :issuables
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Insights
module Reducers
class CountPerLabelReducer < BaseReducer
InvalidLabelsError = Class.new(BaseReducerError)
def initialize(issuables, labels:)
super(issuables)
@labels = Array(labels)
validate!
end
# Returns a hash { label => issuables_count }, e.g.
# {
# 'Manage' => 2,
# 'Plan' => 3,
# 'undefined' => 1
# }
def reduce
count_per_label
end
private
attr_reader :labels
def validate!
unless labels.any?
raise InvalidLabelsError, "Invalid value for `labels`: `#{labels}`. It must be a non-empty array!"
end
end
def count_per_label
issuables.each_with_object(initial_labels_count_hash) do |issuable, hash|
issuable_labels = issuable.labels.map(&:title)
detected_label = labels.detect { |label| issuable_labels.include?(label) }
hash[detected_label || Gitlab::Insights::UNCATEGORIZED] += 1
end
end
def initial_labels_count_hash
(labels + [Gitlab::Insights::UNCATEGORIZED]).each_with_object({}) do |label, hash|
hash[label] = 0
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Insights
module Reducers
class CountPerPeriodReducer < BaseReducer
InvalidPeriodError = Class.new(BaseReducerError)
InvalidPeriodFieldError = Class.new(BaseReducerError)
VALID_PERIOD = %w[day week month].freeze
VALID_PERIOD_FIELD = %i[created_at].freeze
def initialize(issuables, period:, period_field: :created_at)
super(issuables)
@period = period.to_s.singularize
@period_field = period_field
validate!
end
# Returns a hash { period => value_for_period(issuables) }, e.g.
# {
# 'January 2019' => 1,
# 'February 2019' => 1,
# 'March 2019' => 1
# }
def reduce
issuables_grouped_by_normalized_period.each_with_object({}) do |(period, issuables), hash|
hash[period.strftime(period_format)] = value_for_period(issuables)
end
end
private
attr_reader :period, :period_field
def validate!
unless VALID_PERIOD.include?(period)
raise InvalidPeriodError, "Invalid value for `period`: `#{period}`. Allowed values are #{VALID_PERIOD}!"
end
unless VALID_PERIOD_FIELD.include?(period_field)
raise InvalidPeriodFieldError, "Invalid value for `period_field`: `#{period_field}`. Allowed values are #{VALID_PERIOD_FIELD}!"
end
end
# Returns a hash { period => [array of issuables] }, e.g.
# {
# #<Tue, 01 Jan 2019 00:00:00 UTC +00:00> => [#<Issue id:1 namespace1/project1#1>],
# #<Fri, 01 Feb 2019 00:00:00 UTC +00:00> => [#<Issue id:2 namespace1/project1#2>],
# #<Fri, 01 Mar 2019 00:00:00 UTC +00:00> => [#<Issue id:3 namespace1/project1#3>]
# }
def issuables_grouped_by_normalized_period
issuables.group_by do |issuable|
issuable.public_send(period_field).public_send(period_normalizer) # rubocop:disable GitlabSecurity/PublicSend
end
end
def period_normalizer
:"beginning_of_#{period}"
end
def period_format
case period
when 'day'
'%d %b %y'
when 'week'
'%d %b %y'
when 'month'
'%B %Y'
end
end
# Can be overridden by subclasses.
#
# Returns the count of issuables.
def value_for_period(issuables)
issuables.size
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Insights
module Reducers
# #reduce returns a hash { period => { label1: issuable_count } }, e.g.
# {
# 'January 2019' => {
# 'Manage' => 2,
# 'Plan' => 3,
# 'undefined' => 1
# },
# 'February 2019' => {
# 'Manage' => 1,
# 'Plan' => 2,
# 'undefined' => 0
# }
# }
class LabelCountPerPeriodReducer < CountPerPeriodReducer
def initialize(issuables, labels:, period:, period_field: :created_at)
super(issuables, period: period, period_field: period_field)
@labels = labels
end
private
attr_reader :labels
# Returns a hash { label => issuables_count }, e.g.
# {
# 'Manage' => 2,
# 'Plan' => 3,
# 'undefined' => 1
# }
def value_for_period(issuables)
Gitlab::Insights::Reducers::CountPerLabelReducer.reduce(issuables, labels: labels)
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Insights
module Serializers
module Chartjs
class BarSerializer < Chartjs::BaseSerializer
private
# Ensure the input is of the form `Hash[Symbol|String, Integer]`.
def validate!(input)
valid = input.respond_to?(:values)
valid &&= input.values.all? { |value| value.respond_to?(:to_i) }
unless valid
raise WrongInsightsFormatError, "Expected `input` to be of the form `Hash[Symbol|String, Integer]`, #{input} given!"
end
end
# input - A hash of the form `Hash[Symbol|String, Integer]`, e.g.
# {
# Manage: 1,
# Plan: 1,
# undefined: 2
# }
#
# Returns the series data as a hash, e.g.
# {
# Manage: 1,
# Plan: 1,
# undefined: 2
# }
def build_series_data(input)
input
end
# series_data - A hash of the form `Hash[Symbol|String, Integer]`, e.g.
# {
# Manage: 1,
# Plan: 2
# }
#
# Returns a datasets array, e.g.
# [{ label: nil, data: [1, 2], borderColor: ['red', 'blue'] }]
def chart_datasets(series_data)
background_colors = series_data.keys.map { |name| generate_color_code(name) }
[dataset(nil, series_data.values, background_colors)]
end
end
end
end
end
end
# frozen_string_literal: true
require 'digest/md5'
module Gitlab
module Insights
module Serializers
module Chartjs
class BaseSerializer
BaseSerializerError = Class.new(StandardError)
WrongInsightsFormatError = Class.new(BaseSerializerError)
def self.present(input)
new(input).present
end
def initialize(input)
validate!(input)
@labels = input.keys
@insights = build_series_data(input)
end
private_class_method :new
# Return a Chartjs-compatible hash, e.g.
# {
# labels: ['January', 'February'],
# datasets: [
# { label: 'Manage', data: [1, 2], backgroundColor: 'red' },
# { label: 'Plan', data: [2, 1], backgroundColor: 'blue' }
# ]
# }
def present
chart_data(labels, insights)
end
private
attr_reader :labels, :insights
# Can be overridden by subclasses.
def validate!(input)
# no-op
end
# Can be overridden by subclasses.
def build_series_data(input)
raise NotImplementedError
end
# labels - The series labels, e.g. ['January', 'February'].
# raw_datasets - The datasets hash, e.g.
# {
# Manage: 1,
# Plan: 2
# }
# or
# {
# Manage: [1, 2],
# Plan: [2, 1]
# }
#
# Return a Chartjs-compatible hash, e.g.
# {
# labels: ['January', 'February'],
# datasets: [
# { label: 'Manage', data: [1, 2], backgroundColor: 'red' },
# { label: 'Plan', data: [2, 1], backgroundColor: 'blue' }
# ]
# }
def chart_data(labels, series_data)
{
labels: labels,
datasets: chart_datasets(series_data)
}.with_indifferent_access
end
# Can be overridden by subclasses.
#
# series_data - The series hash, e.g.
# {
# Manage: 1,
# Plan: 2
# }
# or
# {
# Manage: [1, 2],
# Plan: [2, 1]
# }
#
# Returns a ChartJS-compatible datasets array, e.g.
# [
# { label: 'Manage', data: [1, 2], backgroundColor: 'red' },
# { label: 'Plan', data: [2, 1], backgroundColor: 'blue' }
# ]
def chart_datasets(series_data)
series_data.map do |name, data|
dataset(name, data, generate_color_code(name))
end
end
# Can be overridden by subclasses.
#
# label - The serie's label.
# data - The serie's data array.
# color - The serie's color.
#
# Returns a serie dataset, e.g.
# { label: 'Manage', data: [1, 2], backgroundColor: 'red' }
def dataset(label, serie_data, color)
{
label: label,
data: serie_data,
backgroundColor: color
}.with_indifferent_access
end
def generate_color_code(label)
Gitlab::Insights::STATIC_COLOR_MAP[label] || "##{Digest::MD5.hexdigest(label.to_s)[0..5]}"
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Insights
module Serializers
module Chartjs
class LineSerializer < Chartjs::MultiSeriesSerializer
private
# Returns a serie dataset, e.g.
# { label: 'Manage', data: [1, 2], borderColor: 'red' }
def dataset(label, serie_data, color)
{
label: label,
data: serie_data,
borderColor: color
}.with_indifferent_access
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Insights
module Serializers
module Chartjs
class MultiSeriesSerializer < Chartjs::BaseSerializer
private
# Ensure the input is of the form `Hash[Symbol|String, Hash[Symbol|String, Integer]]`.
def validate!(input)
valid = input.respond_to?(:values)
valid &&= input.values.all? do |value|
value.respond_to?(:values) &&
value.values.respond_to?(:all?) &&
value.all? { |_key, count| count.respond_to?(:to_i) }
end
unless valid
raise WrongInsightsFormatError, "Expected `input` to be of the form `Hash[Symbol|String, Hash[Symbol|String, Integer]]`, #{input} given!"
end
end
# input - A hash of the form `Hash[Symbol|String, Hash[Symbol|String, Integer]]`, e.g.
# {
# 'January 2019' => {
# Manage: 1,
# Plan: 1,
# undefined: 2
# },
# 'February 2019' => {
# Manage: 0,
# Plan: 1,
# undefined: 0
# }
# }
#
# Returns the series data as a hash, e.g.
# {
# Manage: [1, 0],
# Plan: [1, 1],
# undefined: [2, 0]
# }
def build_series_data(input)
input.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(_, data), series_data|
data.each do |serie_name, count|
series_data[serie_name] << count
end
end
end
end
end
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :insights_issuables, class: Hash do
initialize_with do
{
Manage: 1,
Plan: 3,
Create: 2,
undefined: 1
}.with_indifferent_access
end
end
factory :insights_issuables_per_month, class: Hash do
initialize_with do
{
'January 2019' => {
Manage: 1,
Plan: 1,
Create: 1,
undefined: 0
}.with_indifferent_access,
'February 2019' => {
Manage: 0,
Plan: 1,
Create: 0,
undefined: 0
}.with_indifferent_access,
'March 2019' => {
Manage: 0,
Plan: 1,
Create: 1,
undefined: 1
}.with_indifferent_access
}
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Insights::Finders::IssuableFinder do
around do |example|
Timecop.freeze(Time.utc(2019, 3, 5)) { example.run }
end
def find(entity, opts)
described_class.new(entity, nil, opts).find
end
it 'raises an error for an invalid :issuable_type option' do
expect { find(nil, issuable_type: 'foo') }.to raise_error(described_class::InvalidIssuableTypeError, "Invalid `:issuable_type` option: `foo`. Allowed values are #{described_class::FINDERS.keys}!")
end
it 'raises an error for an invalid entity object' do
expect { find(build(:user), issuable_type: 'issue') }.to raise_error(described_class::InvalidEntityError, 'Entity class `User` is not supported. Supported classes are Project and Namespace!')
end
it 'raises an error for an invalid :group_by option' do
expect { find(nil, issuable_type: 'issue', group_by: 'foo') }.to raise_error(described_class::InvalidGroupByError, "Invalid `:group_by` option: `foo`. Allowed values are #{described_class::PERIODS.keys}!")
end
it 'raises an error for an invalid :period_limit option' do
expect { find(build(:user), issuable_type: 'issue', group_by: 'months', period_limit: 'many') }.to raise_error(described_class::InvalidPeriodLimitError, "Invalid `:period_limit` option: `many`. Expected an integer!")
end
shared_examples_for "insights issuable finder" do
let(:label_bug) { create(label_type, label_entity_association_key => entity, name: 'Bug') }
let(:label_manage) { create(label_type, label_entity_association_key => entity, name: 'Manage') }
let(:label_plan) { create(label_type, label_entity_association_key => entity, name: 'Plan') }
let(:label_create) { create(label_type, label_entity_association_key => entity, name: 'Create') }
let(:label_quality) { create(label_type, label_entity_association_key => entity, name: 'Quality') }
let(:extra_issuable_attrs) { [{}, {}, {}, {}, {}, {}] }
let!(:issuable0) { create(:"labeled_#{issuable_type}", :opened, created_at: Time.utc(2018, 2, 1), project_association_key => project, **extra_issuable_attrs[0]) }
let!(:issuable1) { create(:"labeled_#{issuable_type}", :opened, created_at: Time.utc(2018, 2, 1), labels: [label_bug, label_manage], project_association_key => project, **extra_issuable_attrs[1]) }
let!(:issuable2) { create(:"labeled_#{issuable_type}", :opened, created_at: Time.utc(2019, 2, 6), labels: [label_bug, label_plan], project_association_key => project, **extra_issuable_attrs[2]) }
let!(:issuable3) { create(:"labeled_#{issuable_type}", :opened, created_at: Time.utc(2019, 2, 20), labels: [label_bug, label_create], project_association_key => project, **extra_issuable_attrs[3]) }
let!(:issuable4) { create(:"labeled_#{issuable_type}", :opened, created_at: Time.utc(2019, 3, 5), labels: [label_bug, label_quality], project_association_key => project, **extra_issuable_attrs[4]) }
let(:opts) do
{
state: 'opened',
issuable_type: issuable_type,
filter_labels: [label_bug.title],
collection_labels: [label_manage.title, label_plan.title, label_create.title],
group_by: 'months'
}
end
subject { find(entity, opts) }
it 'avoids N + 1 queries' do
control_queries = ActiveRecord::QueryRecorder.new { subject.map { |issuable| issuable.labels.map(&:title) } }
create(:"labeled_#{issuable_type}", :opened, created_at: Time.utc(2019, 3, 5), labels: [label_bug], project_association_key => project, **extra_issuable_attrs[5])
expect { find(entity, opts).map { |issuable| issuable.labels.map(&:title) } }.not_to exceed_query_limit(control_queries)
end
context ':period_limit option' do
context 'with group_by: "day"' do
before do
opts.merge!(group_by: 'day')
end
it 'returns issuable created after 30 days ago' do
expect(subject.to_a).to eq([issuable2, issuable3, issuable4])
end
end
context 'with group_by: "day", period_limit: 1' do
before do
opts.merge!(group_by: 'day', period_limit: 1)
end
it 'returns issuable created after one day ago' do
expect(subject.to_a).to eq([issuable4])
end
end
context 'with group_by: "week"' do
before do
opts.merge!(group_by: 'week')
end
it 'returns issuable created after 4 weeks ago' do
expect(subject.to_a).to eq([issuable2, issuable3, issuable4])
end
end
context 'with group_by: "week", period_limit: 1' do
before do
opts.merge!(group_by: 'week', period_limit: 1)
end
it 'returns issuable created after one week ago' do
expect(subject.to_a).to eq([issuable4])
end
end
context 'with group_by: "month"' do
before do
opts.merge!(group_by: 'month')
end
it 'returns issuable created after 12 months ago' do
expect(subject.to_a).to eq([issuable2, issuable3, issuable4])
end
end
context 'with group_by: "month", period_limit: 1' do
before do
opts.merge!(group_by: 'month', period_limit: 1)
end
it 'returns issuable created after one month ago' do
expect(subject.to_a).to eq([issuable2, issuable3, issuable4])
end
end
end
end
context 'for a group' do
let(:entity) { create(:group) }
let(:project) { create(:project, :public, group: entity) }
let(:label_type) { :group_label }
let(:label_entity_association_key) { :group }
context 'issues' do
include_examples "insights issuable finder" do
let(:issuable_type) { 'issue' }
let(:project_association_key) { :project }
end
end
context 'merge requests' do
include_examples "insights issuable finder" do
let(:issuable_type) { 'merge_request' }
let(:project_association_key) { :source_project }
let(:extra_issuable_attrs) do
[
{ source_branch: "add_images_and_changes" },
{ source_branch: "improve/awesome" },
{ source_branch: "feature_conflict" },
{ source_branch: "markdown" },
{ source_branch: "feature_one" },
{ source_branch: "merged-target" }
]
end
end
end
end
context 'for a project' do
let(:project) { create(:project, :public) }
let(:entity) { project }
let(:label_type) { :label }
let(:label_entity_association_key) { :project }
context 'issues' do
include_examples "insights issuable finder" do
let(:issuable_type) { 'issue' }
let(:project_association_key) { :project }
end
end
context 'merge requests' do
include_examples "insights issuable finder" do
let(:issuable_type) { 'merge_request' }
let(:project_association_key) { :source_project }
let(:extra_issuable_attrs) do
[
{ source_branch: "add_images_and_changes" },
{ source_branch: "improve/awesome" },
{ source_branch: "feature_conflict" },
{ source_branch: "markdown" },
{ source_branch: "feature_one" },
{ source_branch: "merged-target" }
]
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Insights::Reducers::CountPerLabelReducer do
include_context 'Insights reducers context'
def find_issuables(project, opts)
Gitlab::Insights::Finders::IssuableFinder.new(project, nil, opts).find
end
def reduce(issuable_relation, labels)
described_class.reduce(issuable_relation, labels: labels)
end
let(:opts) do
{
state: 'opened',
issuable_type: 'issue',
filter_labels: [label_bug.title],
collection_labels: [label_manage.title, label_plan.title],
group_by: 'month',
period_limit: 2
}
end
let(:issuable_relation) { find_issuables(project, opts) }
subject { reduce(issuable_relation, opts[:collection_labels]) }
let(:expected) do
{
label_manage.title => 1,
label_plan.title => 1,
Gitlab::Insights::UNCATEGORIZED => 1
}
end
it 'raises an error for an unknown :issuable_type option' do
expect { reduce(issuable_relation, nil) }.to raise_error(described_class::InvalidLabelsError, "Invalid value for `labels`: `[]`. It must be a non-empty array!")
end
it 'returns issuables with only the needed fields' do
expect(subject).to eq(expected)
end
it 'avoids N + 1 queries' do
control_queries = ActiveRecord::QueryRecorder.new { subject }
create(:labeled_issue, :opened, labels: [label_bug], project: project)
expect { reduce(find_issuables(project, opts), opts[:collection_labels]) }.not_to exceed_query_limit(control_queries)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Insights::Reducers::CountPerPeriodReducer do
include_context 'Insights reducers context'
def find_issuables(project, opts)
Gitlab::Insights::Finders::IssuableFinder.new(project, nil, opts).find
end
def reduce(issuable_relation, period, period_field = :created_at)
described_class.reduce(issuable_relation, period: period, period_field: period_field)
end
let(:opts) do
{
state: 'opened',
issuable_type: 'issue',
filter_labels: [label_bug.title],
group_by: 'month',
period_limit: 3
}
end
let(:issuable_relation) { find_issuables(project, opts) }
subject { reduce(issuable_relation, opts[:group_by]) }
let(:expected) do
{
'January 2019' => 1,
'February 2019' => 1,
'March 2019' => 1
}
end
it 'raises an error for an unknown :period option' do
expect { reduce(issuable_relation, 'unknown') }.to raise_error(described_class::InvalidPeriodError, "Invalid value for `period`: `unknown`. Allowed values are #{described_class::VALID_PERIOD}!")
end
it 'raises an error for an unknown :period_field option' do
expect { reduce(issuable_relation, 'month', :foo) }.to raise_error(described_class::InvalidPeriodFieldError, "Invalid value for `period_field`: `foo`. Allowed values are #{described_class::VALID_PERIOD_FIELD}!")
end
it 'returns issuables with only the needed fields' do
expect(subject).to eq(expected)
end
it 'avoids N + 1 queries' do
control_queries = ActiveRecord::QueryRecorder.new { subject }
create(:labeled_issue, :opened, created_at: Time.utc(2019, 2, 5), labels: [label_bug], project: project)
expect { reduce(find_issuables(project, opts), opts[:group_by]) }.not_to exceed_query_limit(control_queries)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Insights::Reducers::LabelCountPerPeriodReducer do
include_context 'Insights reducers context'
def find_issuables(project, opts)
Gitlab::Insights::Finders::IssuableFinder.new(project, nil, opts).find
end
def reduce(issuable_relation, period, labels)
described_class.reduce(issuable_relation, period: period, labels: labels)
end
let(:opts) do
{
state: 'opened',
issuable_type: 'issue',
filter_labels: [label_bug.title],
collection_labels: [label_manage.title, label_plan.title],
group_by: 'month',
period_limit: 3
}
end
let(:issuable_relation) { find_issuables(project, opts) }
subject { reduce(issuable_relation, opts[:group_by], opts[:collection_labels]) }
let(:expected) do
{
'January 2019' => {
label_manage.title => 0,
label_plan.title => 0,
Gitlab::Insights::UNCATEGORIZED => 1
},
'February 2019' => {
label_manage.title => 1,
label_plan.title => 0,
Gitlab::Insights::UNCATEGORIZED => 0
},
'March 2019' => {
label_manage.title => 0,
label_plan.title => 1,
Gitlab::Insights::UNCATEGORIZED => 0
}
}
end
it 'returns issuables with only the needed fields' do
expect(subject).to eq(expected)
end
it 'avoids N + 1 queries' do
control_queries = ActiveRecord::QueryRecorder.new { subject }
create(:labeled_issue, :opened, created_at: Time.utc(2019, 2, 5), labels: [label_bug], project: project)
expect { reduce(find_issuables(project, opts), opts[:group_by], opts[:collection_labels]) }.not_to exceed_query_limit(control_queries)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Insights::Serializers::Chartjs::BarSerializer do
it 'returns the correct format' do
input = build(:insights_issuables)
expected = {
labels: %w[Manage Plan Create undefined],
datasets: [
{
label: nil,
data: [1, 3, 2, 1],
backgroundColor: %w[#34e34c #0b6cbd #686e69 #808080]
}
]
}.with_indifferent_access
expect(described_class.present(input)).to eq(expected)
end
describe 'wrong input formats' do
where(:input) do
[
[[]],
[[1, 2, 3]],
[{ a: :b }]
]
end
with_them do
it 'raises an error if the input is not in the correct format' do
expect { described_class.present(input) }.to raise_error(described_class::WrongInsightsFormatError, /Expected `input` to be of the form `Hash\[Symbol\|String, Integer\]`, .+ given!/)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Insights::Serializers::Chartjs::LineSerializer do
let(:input) { build(:insights_issuables_per_month) }
subject { described_class.present(input) }
it 'returns the correct format' do
expected = {
labels: ['January 2019', 'February 2019', 'March 2019'],
datasets: [
{
label: 'Manage',
data: [1, 0, 0],
borderColor: '#34e34c'
},
{
label: 'Plan',
data: [1, 1, 1],
borderColor: '#0b6cbd'
},
{
label: 'Create',
data: [1, 0, 1],
borderColor: '#686e69'
},
{
label: 'undefined',
data: [0, 0, 1],
borderColor: '#808080'
}
]
}.with_indifferent_access
expect(subject).to eq(expected)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Insights::Serializers::Chartjs::MultiSeriesSerializer do
it 'returns the correct format' do
input = build(:insights_issuables_per_month)
expected = {
labels: ['January 2019', 'February 2019', 'March 2019'],
datasets: [
{
label: 'Manage',
data: [1, 0, 0],
backgroundColor: '#34e34c'
},
{
label: 'Plan',
data: [1, 1, 1],
backgroundColor: '#0b6cbd'
},
{
label: 'Create',
data: [1, 0, 1],
backgroundColor: '#686e69'
},
{
label: 'undefined',
data: [0, 0, 1],
backgroundColor: '#808080'
}
]
}.with_indifferent_access
expect(described_class.present(input)).to eq(expected)
end
describe 'wrong input formats' do
where(:input) do
[
[[]],
[[1, 2, 3]],
[{ a: :b }],
[{ a: [:a, 'b'] }]
]
end
with_them do
it 'raises an error if the input is not in the correct format' do
expect { described_class.present(input) }.to raise_error(described_class::WrongInsightsFormatError, /Expected `input` to be of the form `Hash\[Symbol\|String, Hash\[Symbol\|String, Integer\]\]`, .+ given!/)
end
end
end
end
# frozen_string_literal: true
RSpec.shared_context 'Insights reducers context' do
around do |example|
Timecop.freeze(Time.utc(2019, 3, 5)) { example.run }
end
let(:project) { create(:project, :public) }
let(:label_bug) { create(:label, project: project, name: 'Bug') }
let(:label_manage) { create(:label, project: project, name: 'Manage') }
let(:label_plan) { create(:label, project: project, name: 'Plan') }
let!(:issuable0) { create(:labeled_issue, :opened, created_at: Time.utc(2019, 1, 5), project: project) }
let!(:issuable1) { create(:labeled_issue, :opened, created_at: Time.utc(2019, 1, 5), labels: [label_bug], project: project) }
let!(:issuable2) { create(:labeled_issue, :opened, created_at: Time.utc(2019, 2, 5), labels: [label_bug, label_manage, label_plan], project: project) }
let!(:issuable3) { create(:labeled_issue, :opened, created_at: Time.utc(2019, 3, 5), labels: [label_bug, label_plan], project: project) }
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