Commit 7682f1e8 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch '12196-label-based-ca-events' into 'master'

Persist Label Based Stages in Cycle Analytics

See merge request gitlab-org/gitlab!19361
parents 16eec80d 3894febb
......@@ -11,6 +11,10 @@ module Analytics
alias_attribute :parent, :project
alias_attribute :parent_id, :project_id
delegate :group, to: :project
validate :validate_project_group_for_label_events, if: -> { start_event_label_based? || end_event_label_based? }
def self.relative_positioning_query_base(stage)
where(project_id: stage.project_id)
end
......@@ -18,6 +22,13 @@ module Analytics
def self.relative_positioning_parent_column
:project_id
end
private
# Project should belong to a group when the stage has Label based events since only GroupLabels are allowed.
def validate_project_group_for_label_events
errors.add(:project, s_('CycleAnalyticsStage|should be under a group')) unless project.group
end
end
end
end
......@@ -5,13 +5,20 @@ module Analytics
module Stage
extend ActiveSupport::Concern
include RelativePositioning
include Gitlab::Utils::StrongMemoize
included do
belongs_to :start_event_label, class_name: 'GroupLabel', optional: true
belongs_to :end_event_label, class_name: 'GroupLabel', optional: true
validates :name, presence: true
validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom?
validates :start_event_identifier, presence: true
validates :end_event_identifier, presence: true
validates :start_event_label, presence: true, if: :start_event_label_based?
validates :end_event_label, presence: true, if: :end_event_label_based?
validate :validate_stage_event_pairs
validate :validate_labels
enum start_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :start_event_identifier
enum end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :end_event_identifier
......@@ -30,19 +37,41 @@ module Analytics
end
def start_event
Gitlab::Analytics::CycleAnalytics::StageEvents[start_event_identifier].new(params_for_start_event)
strong_memoize(:start_event) do
Gitlab::Analytics::CycleAnalytics::StageEvents[start_event_identifier].new(params_for_start_event)
end
end
def end_event
Gitlab::Analytics::CycleAnalytics::StageEvents[end_event_identifier].new(params_for_end_event)
strong_memoize(:end_event) do
Gitlab::Analytics::CycleAnalytics::StageEvents[end_event_identifier].new(params_for_end_event)
end
end
def start_event_label_based?
start_event_identifier && start_event.label_based?
end
def end_event_label_based?
end_event_identifier && end_event.label_based?
end
def start_event_identifier=(identifier)
clear_memoization(:start_event)
super
end
def end_event_identifier=(identifier)
clear_memoization(:end_event)
super
end
def params_for_start_event
{}
start_event_label.present? ? { label: start_event_label } : {}
end
def params_for_end_event
{}
end_event_label.present? ? { label: end_event_label } : {}
end
def default_stage?
......@@ -70,13 +99,34 @@ module Analytics
return if start_event_identifier.nil? || end_event_identifier.nil?
unless pairing_rules.fetch(start_event.class, []).include?(end_event.class)
errors.add(:end_event, :not_allowed_for_the_given_start_event)
errors.add(:end_event, s_('CycleAnalytics|not allowed for the given start event'))
end
end
def pairing_rules
Gitlab::Analytics::CycleAnalytics::StageEvents.pairing_rules
end
def validate_labels
validate_label_within_group(:start_event_label, start_event_label_id) if start_event_label_id_changed?
validate_label_within_group(:end_event_label, end_event_label_id) if end_event_label_id_changed?
end
def validate_label_within_group(association_name, label_id)
return unless label_id
return unless group
unless label_available_for_group?(label_id)
errors.add(association_name, s_('CycleAnalyticsStage|is not available for the selected group'))
end
end
def label_available_for_group?(label_id)
LabelsFinder.new(nil, { group_id: group.id, include_ancestor_groups: true, only_group_labels: true })
.execute(skip_authorization: true)
.by_ids(label_id)
.exists?
end
end
end
end
......@@ -17,13 +17,25 @@ module EE
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueFirstAssociatedWithMilestone => 5,
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueFirstMentionedInCommit => 6,
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLastEdited => 7,
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLabelAdded => 8,
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLabelRemoved => 9,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestClosed => 105,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited => 106
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited => 106,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelAdded => 107,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelRemoved => 108
}.freeze
EE_EVENTS = EE_ENUM_MAPPING.keys.freeze
EE_PAIRING_RULES = {
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLabelAdded => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLabelAdded,
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLabelRemoved,
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueClosed
],
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLabelRemoved => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueClosed
],
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueCreated => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueClosed,
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueFirstAddedToBoard,
......@@ -50,38 +62,60 @@ module EE
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLastEdited
],
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueClosed => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLastEdited
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLastEdited,
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLabelAdded,
::Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLabelRemoved
],
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestCreated => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestClosed,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestFirstDeployedToProduction,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastBuildStarted,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastBuildFinished,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelAdded,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelRemoved
],
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestClosed => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestFirstDeployedToProduction,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelAdded,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelRemoved
],
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestFirstDeployedToProduction => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelAdded,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelRemoved
],
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastBuildStarted => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestClosed,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestFirstDeployedToProduction,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelAdded,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelRemoved
],
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastBuildFinished => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestClosed,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestFirstDeployedToProduction,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelAdded,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelRemoved
],
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestClosed,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestFirstDeployedToProduction,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastEdited,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelAdded,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelRemoved
],
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelAdded => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelAdded,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelRemoved
],
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelRemoved => [
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelAdded,
::Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelRemoved
]
}.freeze
......
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class IssueLabelAdded < LabelBasedStageEvent
def self.name
s_("CycleAnalyticsEvent|Issue label was added")
end
def self.identifier
:issue_label_added
end
def object_type
Issue
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class IssueLabelRemoved < LabelBasedStageEvent
def self.name
s_("CycleAnalyticsEvent|Issue label was removed")
end
def self.identifier
:issue_label_removed
end
def object_type
Issue
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
# Represents an event that is related to label creation or removal, this model requires a label provided by the user
class LabelBasedStageEvent < StageEvent
def label_based?
true
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class MergeRequestLabelAdded < LabelBasedStageEvent
def self.name
s_("CycleAnalyticsEvent|Merge Request label was added")
end
def self.identifier
:merge_request_label_added
end
def object_type
MergeRequest
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class MergeRequestLabelRemoved < LabelBasedStageEvent
def self.name
s_("CycleAnalyticsEvent|Merge Request label was removed")
end
def self.identifier
:merge_request_label_removed
end
def object_type
MergeRequest
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLabelAdded do
it_behaves_like 'cycle analytics event' do
let(:params) { { label: GroupLabel.new } }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Analytics::CycleAnalytics::StageEvents::IssueLabelRemoved do
it_behaves_like 'cycle analytics event' do
let(:params) { { label: GroupLabel.new } }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelAdded do
it_behaves_like 'cycle analytics event' do
let(:params) { { label: GroupLabel.new } }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLabelRemoved do
it_behaves_like 'cycle analytics event' do
let(:params) { { label: GroupLabel.new } }
end
end
......@@ -12,11 +12,18 @@ describe Analytics::CycleAnalytics::GroupStage do
let(:parent_name) { :group }
end
include_examples 'cycle analytics label based stage' do
let_it_be(:parent) { create(:group) }
let_it_be(:parent_in_subgroup) { create(:group, parent: parent) }
let_it_be(:group_label) { create(:group_label, group: parent) }
let_it_be(:parent_outside_of_group_label_scope) { create(:group) }
end
context 'relative positioning' do
it_behaves_like 'a class that supports relative positioning' do
let(:group) { create(:group) }
let(:parent) { create(:group) }
let(:factory) { :cycle_analytics_group_stage }
let(:default_params) { { group: group } }
let(:default_params) { { group: parent } }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Analytics::CycleAnalytics::ProjectStage do
include_examples 'cycle analytics label based stage' do
let_it_be(:group) { create(:group) }
let_it_be(:parent) { create(:project, group: group) }
let_it_be(:parent_in_subgroup) { create(:project, group: create(:group, parent: group)) }
let_it_be(:group_label) { create(:group_label, group: group) }
let_it_be(:parent_outside_of_group_label_scope) { create(:project, group: create(:group)) }
end
context 'project without group' do
it 'returns validation error when end event is label based' do
stage = described_class.new({
name: 'My Stage',
parent: create(:project),
start_event_identifier: :issue_closed,
end_event_identifier: :issue_label_added,
end_event_label: create(:group_label)
})
expect(stage).to be_invalid
expect(stage.errors[:project]).to include(s_('CycleAnalyticsStage|should be under a group'))
end
it 'returns validation error when start event is label based' do
stage = described_class.new({
name: 'My Stage',
parent: create(:project),
start_event_identifier: :issue_label_added,
start_event_label: create(:group_label),
end_event_identifier: :issue_closed
})
expect(stage).to be_invalid
expect(stage.errors[:project]).to include(s_('CycleAnalyticsStage|should be under a group'))
end
end
end
......@@ -35,6 +35,10 @@ module Gitlab
query
end
def label_based?
false
end
private
attr_reader :params
......
......@@ -5081,9 +5081,21 @@ msgstr ""
msgid "CycleAnalyticsEvent|Issue first mentioned in a commit"
msgstr ""
msgid "CycleAnalyticsEvent|Issue label was added"
msgstr ""
msgid "CycleAnalyticsEvent|Issue label was removed"
msgstr ""
msgid "CycleAnalyticsEvent|Issue last edited"
msgstr ""
msgid "CycleAnalyticsEvent|Merge Request label was added"
msgstr ""
msgid "CycleAnalyticsEvent|Merge Request label was removed"
msgstr ""
msgid "CycleAnalyticsEvent|Merge request closed"
msgstr ""
......@@ -5126,6 +5138,12 @@ msgstr ""
msgid "CycleAnalyticsStage|Test"
msgstr ""
msgid "CycleAnalyticsStage|is not available for the selected group"
msgstr ""
msgid "CycleAnalyticsStage|should be under a group"
msgstr ""
msgid "CycleAnalytics|%{projectName}"
msgid_plural "CycleAnalytics|%d projects selected"
msgstr[0] ""
......@@ -5145,6 +5163,9 @@ msgstr ""
msgid "CycleAnalytics|group dropdown filter"
msgstr ""
msgid "CycleAnalytics|not allowed for the given start event"
msgstr ""
msgid "CycleAnalytics|project dropdown filter"
msgstr ""
......
# frozen_string_literal: true
shared_examples_for 'cycle analytics event' do
let(:instance) { described_class.new({}) }
let(:params) { {} }
let(:instance) { described_class.new(params) }
it { expect(described_class.name).to be_a_kind_of(String) }
it { expect(described_class.identifier).to be_a_kind_of(Symbol) }
......
......@@ -10,6 +10,11 @@ shared_examples_for 'cycle analytics stage' do
}
end
describe 'associations' do
it { is_expected.to belong_to(:end_event_label) }
it { is_expected.to belong_to(:start_event_label) }
end
describe 'validation' do
it 'is valid' do
expect(described_class.new(valid_params)).to be_valid
......@@ -18,22 +23,22 @@ shared_examples_for 'cycle analytics stage' do
it 'validates presence of parent' do
stage = described_class.new(valid_params.except(:parent))
expect(stage).not_to be_valid
expect(stage.errors.details[parent_name]).to eq([{ error: :blank }])
expect(stage).to be_invalid
expect(stage.errors[parent_name]).to include("can't be blank")
end
it 'validates presence of start_event_identifier' do
stage = described_class.new(valid_params.except(:start_event_identifier))
expect(stage).not_to be_valid
expect(stage.errors.details[:start_event_identifier]).to eq([{ error: :blank }])
expect(stage).to be_invalid
expect(stage.errors[:start_event_identifier]).to include("can't be blank")
end
it 'validates presence of end_event_identifier' do
stage = described_class.new(valid_params.except(:end_event_identifier))
expect(stage).not_to be_valid
expect(stage.errors.details[:end_event_identifier]).to eq([{ error: :blank }])
expect(stage).to be_invalid
expect(stage.errors[:end_event_identifier]).to include("can't be blank")
end
it 'is invalid when end_event is not allowed for the given start_event' do
......@@ -43,8 +48,8 @@ shared_examples_for 'cycle analytics stage' do
)
stage = described_class.new(invalid_params)
expect(stage).not_to be_valid
expect(stage.errors.details[:end_event]).to eq([{ error: :not_allowed_for_the_given_start_event }])
expect(stage).to be_invalid
expect(stage.errors[:end_event]).to include(s_('CycleAnalytics|not allowed for the given start event'))
end
context 'disallows default stage names when creating custom stage' do
......@@ -105,3 +110,119 @@ shared_examples_for 'cycle analytics stage' do
end
end
end
shared_examples_for 'cycle analytics label based stage' do
context 'when creating label based event' do
context 'when the label id is not passed' do
it 'returns validation error when `start_event_label_id` is missing' do
stage = described_class.new({
name: 'My Stage',
parent: parent,
start_event_identifier: :issue_label_added,
end_event_identifier: :issue_closed
})
expect(stage).to be_invalid
expect(stage.errors[:start_event_label]).to include("can't be blank")
end
it 'returns validation error when `end_event_label_id` is missing' do
stage = described_class.new({
name: 'My Stage',
parent: parent,
start_event_identifier: :issue_closed,
end_event_identifier: :issue_label_added
})
expect(stage).to be_invalid
expect(stage.errors[:end_event_label]).to include("can't be blank")
end
end
context 'when group label is defined on the root group' do
it 'succeeds' do
stage = described_class.new({
name: 'My Stage',
parent: parent,
start_event_identifier: :issue_label_added,
start_event_label: group_label,
end_event_identifier: :issue_closed
})
expect(stage).to be_valid
end
end
context 'when subgroup is given' do
it 'succeeds' do
stage = described_class.new({
name: 'My Stage',
parent: parent_in_subgroup,
start_event_identifier: :issue_label_added,
start_event_label: group_label,
end_event_identifier: :issue_closed
})
expect(stage).to be_valid
end
end
context 'when label is defined for a different group' do
let(:error_message) { s_('CycleAnalyticsStage|is not available for the selected group') }
it 'returns validation for `start_event_label`' do
stage = described_class.new({
name: 'My Stage',
parent: parent_outside_of_group_label_scope,
start_event_identifier: :issue_label_added,
start_event_label: group_label,
end_event_identifier: :issue_closed
})
expect(stage).to be_invalid
expect(stage.errors[:start_event_label]).to include(error_message)
end
it 'returns validation for `end_event_label`' do
stage = described_class.new({
name: 'My Stage',
parent: parent_outside_of_group_label_scope,
start_event_identifier: :issue_closed,
end_event_identifier: :issue_label_added,
end_event_label: group_label
})
expect(stage).to be_invalid
expect(stage.errors[:end_event_label]).to include(error_message)
end
end
context 'when `ProjectLabel is given' do
let_it_be(:label) { create(:label) }
it 'raises error when `ProjectLabel` is given for `start_event_label`' do
params = {
name: 'My Stage',
parent: parent,
start_event_identifier: :issue_label_added,
start_event_label: label,
end_event_identifier: :issue_closed
}
expect { described_class.new(params) }.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
it 'raises error when `ProjectLabel` is given for `end_event_label`' do
params = {
name: 'My Stage',
parent: parent,
start_event_identifier: :issue_closed,
end_event_identifier: :issue_label_added,
end_event_label: label
}
expect { described_class.new(params) }.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
end
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