Commit 60e33885 authored by Adam Hegyi's avatar Adam Hegyi Committed by Mayra Cabrera

Implement validation logic to ProjectStage

- Introducting StageEvents to define the available events
- Define the event pairing rules, since some events are not compatible
- Express default Cycle Analytics stages with the event structure
parent 0a94aac8
...@@ -3,7 +3,12 @@ ...@@ -3,7 +3,12 @@
module Analytics module Analytics
module CycleAnalytics module CycleAnalytics
class ProjectStage < ApplicationRecord class ProjectStage < ApplicationRecord
include Analytics::CycleAnalytics::Stage
validates :project, presence: true
belongs_to :project belongs_to :project
alias_attribute :parent, :project
end end
end end
end end
# frozen_string_literal: true
module Analytics
module CycleAnalytics
module Stage
extend ActiveSupport::Concern
included do
validates :name, presence: true
validates :start_event_identifier, presence: true
validates :end_event_identifier, presence: true
validate :validate_stage_event_pairs
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
alias_attribute :custom_stage?, :custom
end
def parent=(_)
raise NotImplementedError
end
def parent
raise NotImplementedError
end
def start_event
Gitlab::Analytics::CycleAnalytics::StageEvents[start_event_identifier].new(params_for_start_event)
end
def end_event
Gitlab::Analytics::CycleAnalytics::StageEvents[end_event_identifier].new(params_for_end_event)
end
def params_for_start_event
{}
end
def params_for_end_event
{}
end
def default_stage?
!custom
end
# The model that is going to be queried, Issue or MergeRequest
def subject_model
start_event.object_type
end
private
def validate_stage_event_pairs
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)
end
end
def pairing_rules
Gitlab::Analytics::CycleAnalytics::StageEvents.pairing_rules
end
end
end
end
# frozen_string_literal: true
# This module represents the default Cycle Analytics stages that are currently provided by CE
# Each method returns a hash that can be used to build a new stage object.
#
# Example:
#
# params = Gitlab::Analytics::CycleAnalytics::DefaultStages.params_for_issue_stage
# Analytics::CycleAnalytics::ProjectStage.new(params)
module Gitlab
module Analytics
module CycleAnalytics
module DefaultStages
def self.all
[
params_for_issue_stage,
params_for_plan_stage,
params_for_code_stage,
params_for_test_stage,
params_for_review_stage,
params_for_staging_stage,
params_for_production_stage
]
end
def self.params_for_issue_stage
{
name: 'issue',
custom: false, # this stage won't be customizable, we provide it as it is
relative_position: 1, # when opening the CycleAnalytics page in CE, this stage will be the first item
start_event_identifier: :issue_created, # IssueCreated class is used as start event
end_event_identifier: :issue_stage_end # IssueStageEnd class is used as end event
}
end
def self.params_for_plan_stage
{
name: 'plan',
custom: false,
relative_position: 2,
start_event_identifier: :plan_stage_start,
end_event_identifier: :issue_first_mentioned_in_commit
}
end
def self.params_for_code_stage
{
name: 'code',
custom: false,
relative_position: 3,
start_event_identifier: :code_stage_start,
end_event_identifier: :merge_request_created
}
end
def self.params_for_test_stage
{
name: 'test',
custom: false,
relative_position: 4,
start_event_identifier: :merge_request_last_build_started,
end_event_identifier: :merge_request_last_build_finished
}
end
def self.params_for_review_stage
{
name: 'review',
custom: false,
relative_position: 5,
start_event_identifier: :merge_request_created,
end_event_identifier: :merge_request_merged
}
end
def self.params_for_staging_stage
{
name: 'staging',
custom: false,
relative_position: 6,
start_event_identifier: :merge_request_merged,
end_event_identifier: :merge_request_first_deployed_to_production
}
end
def self.params_for_production_stage
{
name: 'production',
custom: false,
relative_position: 7,
start_event_identifier: :merge_request_merged,
end_event_identifier: :merge_request_first_deployed_to_production
}
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
# Convention:
# Issue: < 100
# MergeRequest: >= 100 && < 1000
# Custom events for default stages: >= 1000 (legacy)
ENUM_MAPPING = {
StageEvents::IssueCreated => 1,
StageEvents::IssueFirstMentionedInCommit => 2,
StageEvents::MergeRequestCreated => 100,
StageEvents::MergeRequestFirstDeployedToProduction => 101,
StageEvents::MergeRequestLastBuildFinished => 102,
StageEvents::MergeRequestLastBuildStarted => 103,
StageEvents::MergeRequestMerged => 104,
StageEvents::CodeStageStart => 1_000,
StageEvents::IssueStageEnd => 1_001,
StageEvents::PlanStageStart => 1_002
}.freeze
EVENTS = ENUM_MAPPING.keys.freeze
# Defines which start_event and end_event pairs are allowed
PAIRING_RULES = {
StageEvents::PlanStageStart => [
StageEvents::IssueFirstMentionedInCommit
],
StageEvents::CodeStageStart => [
StageEvents::MergeRequestCreated
],
StageEvents::IssueCreated => [
StageEvents::IssueStageEnd
],
StageEvents::MergeRequestCreated => [
StageEvents::MergeRequestMerged
],
StageEvents::MergeRequestLastBuildStarted => [
StageEvents::MergeRequestLastBuildFinished
],
StageEvents::MergeRequestMerged => [
StageEvents::MergeRequestFirstDeployedToProduction
]
}.freeze
def [](identifier)
events.find { |e| e.identifier.to_s.eql?(identifier.to_s) } || raise(KeyError)
end
# hash for defining ActiveRecord enum: identifier => number
def to_enum
ENUM_MAPPING.each_with_object({}) { |(k, v), hash| hash[k.identifier] = v }
end
# will be overridden in EE with custom events
def pairing_rules
PAIRING_RULES
end
# will be overridden in EE with custom events
def events
EVENTS
end
module_function :[], :to_enum, :pairing_rules, :events
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class CodeStageStart < SimpleStageEvent
def self.name
s_("CycleAnalyticsEvent|Issue first mentioned in a commit")
end
def self.identifier
:code_stage_start
end
def object_type
MergeRequest
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class IssueCreated < SimpleStageEvent
def self.name
s_("CycleAnalyticsEvent|Issue created")
end
def self.identifier
:issue_created
end
def object_type
Issue
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class IssueFirstMentionedInCommit < SimpleStageEvent
def self.name
s_("CycleAnalyticsEvent|Issue first mentioned in a commit")
end
def self.identifier
:issue_first_mentioned_in_commit
end
def object_type
Issue
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class IssueStageEnd < SimpleStageEvent
def self.name
PlanStageStart.name
end
def self.identifier
:issue_stage_end
end
def object_type
Issue
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class MergeRequestCreated < SimpleStageEvent
def self.name
s_("CycleAnalyticsEvent|Merge request created")
end
def self.identifier
:merge_request_created
end
def object_type
MergeRequest
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class MergeRequestFirstDeployedToProduction < SimpleStageEvent
def self.name
s_("CycleAnalyticsEvent|Merge request first deployed to production")
end
def self.identifier
:merge_request_first_deployed_to_production
end
def object_type
MergeRequest
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class MergeRequestLastBuildFinished < SimpleStageEvent
def self.name
s_("CycleAnalyticsEvent|Merge request last build finish time")
end
def self.identifier
:merge_request_last_build_finished
end
def object_type
MergeRequest
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class MergeRequestLastBuildStarted < SimpleStageEvent
def self.name
s_("CycleAnalyticsEvent|Merge request last build start time")
end
def self.identifier
:merge_request_last_build_started
end
def object_type
MergeRequest
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class MergeRequestMerged < SimpleStageEvent
def self.name
s_("CycleAnalyticsEvent|Merge request merged")
end
def self.identifier
:merge_request_merged
end
def object_type
MergeRequest
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
class PlanStageStart < SimpleStageEvent
def self.name
s_("CycleAnalyticsEvent|Issue first associated with a milestone or issue first added to a board")
end
def self.identifier
:plan_stage_start
end
def object_type
Issue
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
# Represents a simple event that usually refers to one database column and does not require additional user input
class SimpleStageEvent < StageEvent
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Analytics
module CycleAnalytics
module StageEvents
# Base class for expressing an event that can be used for a stage.
class StageEvent
def initialize(params)
@params = params
end
def self.name
raise NotImplementedError
end
def self.identifier
raise NotImplementedError
end
def object_type
raise NotImplementedError
end
end
end
end
end
end
...@@ -3602,6 +3602,30 @@ msgstr "" ...@@ -3602,6 +3602,30 @@ msgstr ""
msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
msgstr "" msgstr ""
msgid "CycleAnalyticsEvent|Issue created"
msgstr ""
msgid "CycleAnalyticsEvent|Issue first associated with a milestone or issue first added to a board"
msgstr ""
msgid "CycleAnalyticsEvent|Issue first mentioned in a commit"
msgstr ""
msgid "CycleAnalyticsEvent|Merge request created"
msgstr ""
msgid "CycleAnalyticsEvent|Merge request first deployed to production"
msgstr ""
msgid "CycleAnalyticsEvent|Merge request last build finish time"
msgstr ""
msgid "CycleAnalyticsEvent|Merge request last build start time"
msgstr ""
msgid "CycleAnalyticsEvent|Merge request merged"
msgstr ""
msgid "CycleAnalyticsStage|Code" msgid "CycleAnalyticsStage|Code"
msgstr "" msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Analytics::CycleAnalytics::StageEvents::StageEvent do
it { expect(described_class).to respond_to(:name) }
it { expect(described_class).to respond_to(:identifier) }
it { expect(described_class.new({})).to respond_to(:object_type) }
end
...@@ -6,4 +6,18 @@ describe Analytics::CycleAnalytics::ProjectStage do ...@@ -6,4 +6,18 @@ describe Analytics::CycleAnalytics::ProjectStage do
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
end end
it 'default stages must be valid' do
project = create(:project)
Gitlab::Analytics::CycleAnalytics::DefaultStages.all.each do |params|
stage = described_class.new(params.merge(project: project))
expect(stage).to be_valid
end
end
it_behaves_like "cycle analytics stage" do
let(:parent) { create(:project) }
let(:parent_name) { :project }
end
end end
# frozen_string_literal: true
shared_examples_for 'cycle analytics stage' do
let(:valid_params) do
{
name: 'My Stage',
parent: parent,
start_event_identifier: :merge_request_created,
end_event_identifier: :merge_request_merged
}
end
describe 'validation' do
it 'is valid' do
expect(described_class.new(valid_params)).to be_valid
end
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 }])
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 }])
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 }])
end
it 'is invalid when end_event is not allowed for the given start_event' do
invalid_params = valid_params.merge(
start_event_identifier: :merge_request_merged,
end_event_identifier: :merge_request_created
)
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 }])
end
end
describe '#subject_model' do
it 'infers the model from the start event' do
stage = described_class.new(valid_params)
expect(stage.subject_model).to eq(MergeRequest)
end
end
describe '#start_event' do
it 'builds start_event object based on start_event_identifier' do
stage = described_class.new(start_event_identifier: 'merge_request_created')
expect(stage.start_event).to be_a_kind_of(Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestCreated)
end
end
describe '#end_event' do
it 'builds end_event object based on end_event_identifier' do
stage = described_class.new(end_event_identifier: 'merge_request_merged')
expect(stage.end_event).to be_a_kind_of(Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged)
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