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 @@
module Analytics
module CycleAnalytics
class ProjectStage < ApplicationRecord
include Analytics::CycleAnalytics::Stage
validates :project, presence: true
belongs_to :project
alias_attribute :parent, :project
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 ""
msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
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"
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
describe 'associations' do
it { is_expected.to belong_to(:project) }
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
# 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