summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAdam Hegyi <ahegyi@gitlab.com>2019-08-06 13:39:56 +0200
committerAdam Hegyi <ahegyi@gitlab.com>2019-08-07 15:30:25 +0200
commitc18fa976321ba857c6bc6387f47636618c035c64 (patch)
tree50c909982482f4735cb5ae3a898f71b0fccd3309
parent0a514100a69554681d11d41b583a1515e2246774 (diff)
downloadgitlab-ce-new-cycle-analytics-stage-backend.tar.gz
New Cycle Analytics backendnew-cycle-analytics-stage-backend
This change lays the foundation for customizable cycle analytics stages. The main reason for the change is to extract the event definitions to separate objects (start_event, end_event) so that it could be easily customized later on. In this commit the default stages that we provide in CE are migrated to the new structure.
-rw-r--r--app/controllers/projects/cycle_analytics/records_controller.rb27
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb41
-rw-r--r--app/models/concerns/cycle_analytics/stage.rb66
-rw-r--r--app/models/cycle_analytics.rb7
-rw-r--r--app/models/cycle_analytics/project_stage.rb11
-rw-r--r--app/models/project.rb1
-rw-r--r--app/serializers/cycle_analytics/cycle_analytics_entity.rb14
-rw-r--r--app/serializers/cycle_analytics/event_entity.rb23
-rw-r--r--app/serializers/cycle_analytics/median_entity.rb13
-rw-r--r--app/serializers/cycle_analytics/stage_decorator.rb60
-rw-r--r--app/serializers/cycle_analytics/stage_entity.rb21
-rw-r--r--app/services/cycle_analytics/stage_find_service.rb26
-rw-r--r--app/services/cycle_analytics/stage_list_service.rb28
-rw-r--r--changelogs/unreleased/new-cycle-analytics-stage-backend.yml5
-rw-r--r--config/routes/project.rb7
-rw-r--r--db/migrate/20190716144222_create_cycle_analytics_project_stages.rb25
-rw-r--r--db/migrate/20190729062536_create_cycle_analytics_group_stages.rb25
-rw-r--r--db/schema.rb44
-rw-r--r--lib/gitlab/cycle_analytics/base_query_builder.rb67
-rw-r--r--lib/gitlab/cycle_analytics/build_records_fetcher.rb37
-rw-r--r--lib/gitlab/cycle_analytics/data_collector.rb35
-rw-r--r--lib/gitlab/cycle_analytics/default_stages.rb89
-rw-r--r--lib/gitlab/cycle_analytics/median.rb35
-rw-r--r--lib/gitlab/cycle_analytics/records_fetcher.rb100
-rw-r--r--lib/gitlab/cycle_analytics/stage_events.rb69
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/code_stage_start.rb31
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/issue_created.rb25
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/issue_first_mentioned_in_commit.rb29
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/issue_stage_end.rb32
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/merge_request_created.rb25
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/merge_request_first_deployed_to_production.rb29
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/merge_request_last_build_finished.rb29
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/merge_request_last_build_started.rb29
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/merge_request_merged.rb29
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/plan_stage_start.rb33
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/simple_stage_event.rb11
-rw-r--r--lib/gitlab/cycle_analytics/stage_events/stage_event.rb60
-rw-r--r--lib/gitlab/cycle_analytics/stage_query_helpers.rb29
-rw-r--r--locale/gitlab.pot24
-rw-r--r--spec/factories/cycle_analytics/project_stages.rb15
-rw-r--r--spec/lib/gitlab/cycle_analytics/code_stage_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/records_fetcher_spec.rb38
-rw-r--r--spec/lib/gitlab/cycle_analytics/shared_stage_spec.rb16
-rw-r--r--spec/lib/gitlab/cycle_analytics/test_stage_spec.rb19
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/models/cycle_analytics/project_stage_spec.rb18
-rw-r--r--spec/serializers/cycle_analytics/stage_decorator_spec.rb42
-rw-r--r--spec/services/cycle_analytics/stage_find_service_spec.rb20
-rw-r--r--spec/services/cycle_analytics/stage_list_service_spec.rb19
-rw-r--r--spec/support/shared_examples/cycle_analytics_stage_examples.rb37
52 files changed, 1521 insertions, 19 deletions
diff --git a/app/controllers/projects/cycle_analytics/records_controller.rb b/app/controllers/projects/cycle_analytics/records_controller.rb
new file mode 100644
index 00000000000..00433a453f7
--- /dev/null
+++ b/app/controllers/projects/cycle_analytics/records_controller.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Projects
+ module CycleAnalytics
+ class RecordsController < Projects::ApplicationController
+ include CycleAnalyticsParams
+
+ before_action :authorize_read_cycle_analytics!
+
+ def index
+ stage = ::CycleAnalytics::StageFindService.new(parent: project, id: params[:stage_id]).execute
+ data_collector = Gitlab::CycleAnalytics::DataCollector.new(stage, {
+ from: cycle_analytics_params[:start_date],
+ current_user: current_user
+ })
+
+ render json: data_collector.records_fetcher.serialized_records
+ end
+
+ def cycle_analytics_params
+ return {} unless params[:cycle_analytics].present?
+
+ params[:cycle_analytics].permit(:start_date)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index 2d46a71bf99..40948fd939a 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -11,22 +11,39 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
def show
@cycle_analytics = ::CycleAnalytics::ProjectLevel.new(@project, options: options(cycle_analytics_params))
- @cycle_analytics_no_data = @cycle_analytics.no_stats?
-
respond_to do |format|
- format.html
- format.json { render json: cycle_analytics_json }
+ format.html do
+ @cycle_analytics_no_data = @cycle_analytics.no_stats?
+
+ render :show
+ end
+ format.json do
+ if params[:new_api] # Use new API when FE is ready
+ stages = ::CycleAnalytics::StageListService.new(parent: project).execute
+ render json: ::CycleAnalytics::CycleAnalyticsEntity.new({
+ stages: stages,
+ summary: @cycle_analytics.summary,
+ permissions: @cycle_analytics.permissions(user: current_user)
+ })
+ else
+ render json: cycle_analytics_json
+ end
+ end
end
end
- private
-
- def cycle_analytics_params
- return {} unless params[:cycle_analytics].present?
+ def median
+ stage = ::CycleAnalytics::StageFindService.new(parent: project, id: params[:stage_id]).execute
+ data_collector = Gitlab::CycleAnalytics::DataCollector.new(stage, {
+ from: cycle_analytics_params[:start_date],
+ current_user: current_user
+ })
- params[:cycle_analytics].permit(:start_date)
+ render json: ::CycleAnalytics::MedianEntity.new(data_collector.median.seconds)
end
+ private
+
def cycle_analytics_json
{
summary: @cycle_analytics.summary,
@@ -35,6 +52,12 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
}
end
+ def cycle_analytics_params
+ return {} unless params[:cycle_analytics].present?
+
+ params[:cycle_analytics].permit(:start_date)
+ end
+
def whitelist_query_limiting
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42671')
end
diff --git a/app/models/concerns/cycle_analytics/stage.rb b/app/models/concerns/cycle_analytics/stage.rb
new file mode 100644
index 00000000000..63a0db451b1
--- /dev/null
+++ b/app/models/concerns/cycle_analytics/stage.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module CycleAnalytics
+ module Stage
+ extend ActiveSupport::Concern
+
+ included do
+ validates :name, presence: true
+ validate :validate_stage_event_pairs
+
+ enum start_event_identifier: Gitlab::CycleAnalytics::StageEvents.to_enum, _prefix: :start_event_identifier
+ enum end_event_identifier: Gitlab::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::CycleAnalytics::StageEvents[start_event_identifier].new(params_for_start_event)
+ end
+
+ def end_event
+ Gitlab::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
+
+ def matches_with_stage_params?(stage_params)
+ default_stage? &&
+ start_event_identifier == stage_params[:start_event_identifier] &&
+ end_event_identifier == stage_params[:end_event_identifier]
+ end
+
+ private
+
+ def validate_stage_event_pairs
+ return if start_event_identifier.nil? || end_event_identifier.nil?
+
+ unless Gitlab::CycleAnalytics::StageEvents.pairing_rules.fetch(start_event.class, []).include?(end_event.class)
+ errors.add(:end_event, :not_allowed_for_the_given_start_event)
+ end
+ end
+ end
+end
diff --git a/app/models/cycle_analytics.rb b/app/models/cycle_analytics.rb
new file mode 100644
index 00000000000..f64010e89f4
--- /dev/null
+++ b/app/models/cycle_analytics.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module CycleAnalytics
+ def self.table_name_prefix
+ 'cycle_analytics_'
+ end
+end
diff --git a/app/models/cycle_analytics/project_stage.rb b/app/models/cycle_analytics/project_stage.rb
new file mode 100644
index 00000000000..8f0edcf0ed6
--- /dev/null
+++ b/app/models/cycle_analytics/project_stage.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module CycleAnalytics
+ class ProjectStage < ApplicationRecord
+ include CycleAnalytics::Stage
+
+ belongs_to :project
+
+ alias_attribute :parent, :project
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 960795b73cb..4b8ca831ca7 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -284,6 +284,7 @@ class Project < ApplicationRecord
has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :remote_mirrors, inverse_of: :project
+ has_many :cycle_analytics_stages, class_name: 'CycleAnalytics::ProjectStage'
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
diff --git a/app/serializers/cycle_analytics/cycle_analytics_entity.rb b/app/serializers/cycle_analytics/cycle_analytics_entity.rb
new file mode 100644
index 00000000000..423ba63696d
--- /dev/null
+++ b/app/serializers/cycle_analytics/cycle_analytics_entity.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class CycleAnalytics::CycleAnalyticsEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :events, using: CycleAnalytics::EventEntity
+ expose :stages, using: CycleAnalytics::StageEntity
+ expose :summary
+ expose :permissions
+
+ def events
+ Gitlab::CycleAnalytics::StageEvents.events
+ end
+end
diff --git a/app/serializers/cycle_analytics/event_entity.rb b/app/serializers/cycle_analytics/event_entity.rb
new file mode 100644
index 00000000000..98de5d6768a
--- /dev/null
+++ b/app/serializers/cycle_analytics/event_entity.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class CycleAnalytics::EventEntity < Grape::Entity
+ expose :name
+ expose :identifier
+ expose :type
+ expose :can_be_start_event
+ expose :allowed_end_events
+
+ private
+
+ def type
+ 'simple'
+ end
+
+ def can_be_start_event
+ Gitlab::CycleAnalytics::StageEvents.pairing_rules.has_key?(object)
+ end
+
+ def allowed_end_events
+ Gitlab::CycleAnalytics::StageEvents.pairing_rules.fetch(object, []).map(&:identifier)
+ end
+end
diff --git a/app/serializers/cycle_analytics/median_entity.rb b/app/serializers/cycle_analytics/median_entity.rb
new file mode 100644
index 00000000000..08daa919115
--- /dev/null
+++ b/app/serializers/cycle_analytics/median_entity.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class CycleAnalytics::MedianEntity < Grape::Entity
+ include EntityDateHelper
+
+ expose :value
+
+ private
+
+ def value
+ object.nil? ? nil : distance_of_time_in_words(object)
+ end
+end
diff --git a/app/serializers/cycle_analytics/stage_decorator.rb b/app/serializers/cycle_analytics/stage_decorator.rb
new file mode 100644
index 00000000000..3f170cf7e59
--- /dev/null
+++ b/app/serializers/cycle_analytics/stage_decorator.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+class CycleAnalytics::StageDecorator < SimpleDelegator
+ DEFAULT_STAGE_ATTRIBUTES = {
+ issue: {
+ title: -> { s_('CycleAnalyticsStage|Issue') },
+ description: -> { _("Time before an issue gets scheduled") }
+ },
+ plan: {
+ title: -> { s_('CycleAnalyticsStage|Plan') },
+ description: -> { _("Time before an issue starts implementation") }
+ },
+ code: {
+ title: -> { s_('CycleAnalyticsStage|Code') },
+ description: -> { _("Time until first merge request") }
+ },
+ test: {
+ title: -> { s_('CycleAnalyticsStage|Test') },
+ description: -> { _("Total test time for all commits/merges") }
+ },
+ review: {
+ title: -> { s_('CycleAnalyticsStage|Review') },
+ description: -> { _("Time between merge request creation and merge/close") }
+ },
+ staging: {
+ title: -> { s_('CycleAnalyticsStage|Staging') },
+ description: -> { _("From merge request merge until deploy to production") }
+ },
+ production: {
+ title: -> { s_('CycleAnalyticsStage|Review') },
+ description: -> { _("From issue creation until deploy to production") }
+ }
+ }.freeze
+
+ def title
+ extract_default_stage_attribute(:title) || name
+ end
+
+ def description
+ extract_default_stage_attribute(:description) || ''
+ end
+
+ def legend
+ if matches_with_stage_params?(Gitlab::CycleAnalytics::DefaultStages.params_for_test_stage)
+ _("Related Jobs")
+ elsif matches_with_stage_params?(Gitlab::CycleAnalytics::DefaultStages.params_for_staging_stage)
+ _("Related Deployed Jobs")
+ elsif subject_model.eql?(Issue)
+ _("Related Issues")
+ elsif subject_model.eql?(MergeRequest)
+ _("Related Merged Requests")
+ end
+ end
+
+ private
+
+ def extract_default_stage_attribute(attribute)
+ DEFAULT_STAGE_ATTRIBUTES.dig(name.to_sym, attribute.to_sym)&.call
+ end
+end
diff --git a/app/serializers/cycle_analytics/stage_entity.rb b/app/serializers/cycle_analytics/stage_entity.rb
new file mode 100644
index 00000000000..39fc87dc05e
--- /dev/null
+++ b/app/serializers/cycle_analytics/stage_entity.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class CycleAnalytics::StageEntity < Grape::Entity
+ expose :name
+ expose :legend
+ expose :description
+ expose :id
+ expose :relative_position, as: :position
+ expose :hidden
+ expose :custom
+ expose :start_event_identifier
+ expose :end_event_identifier
+
+ def initialize(object, options = {})
+ super(CycleAnalytics::StageDecorator.new(object), options)
+ end
+
+ def id
+ object.id || object.name
+ end
+end
diff --git a/app/services/cycle_analytics/stage_find_service.rb b/app/services/cycle_analytics/stage_find_service.rb
new file mode 100644
index 00000000000..12e343d71be
--- /dev/null
+++ b/app/services/cycle_analytics/stage_find_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module CycleAnalytics
+ class StageFindService
+ def initialize(parent:, id:)
+ @parent = parent
+ @id = id
+ end
+
+ def execute
+ find_in_memory_stage_by_name!
+ end
+
+ private
+
+ attr_reader :parent, :id
+
+ def find_in_memory_stage_by_name!
+ raw_stage = Gitlab::CycleAnalytics::DefaultStages.all.find do |hash|
+ hash[:name].eql?(id.to_s)
+ end || raise(ActiveRecord::RecordNotFound)
+
+ parent.cycle_analytics_stages.build(raw_stage)
+ end
+ end
+end
diff --git a/app/services/cycle_analytics/stage_list_service.rb b/app/services/cycle_analytics/stage_list_service.rb
new file mode 100644
index 00000000000..b5dc1a66fa9
--- /dev/null
+++ b/app/services/cycle_analytics/stage_list_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module CycleAnalytics
+ class StageListService
+ def initialize(parent:, allowed_to_customize_stages: false)
+ @parent = parent
+ @allowed_to_customize_stages = allowed_to_customize_stages
+ end
+
+ def execute
+ if allowed_to_customize_stages
+ raise NotImplementedError # will be implemented in EE
+ else
+ build_default_stages
+ end
+ end
+
+ private
+
+ attr_reader :parent, :allowed_to_customize_stages
+
+ def build_default_stages
+ Gitlab::CycleAnalytics::DefaultStages.all.map do |params|
+ parent.cycle_analytics_stages.build(params)
+ end
+ end
+ end
+end
diff --git a/changelogs/unreleased/new-cycle-analytics-stage-backend.yml b/changelogs/unreleased/new-cycle-analytics-stage-backend.yml
new file mode 100644
index 00000000000..677a9d2d82b
--- /dev/null
+++ b/changelogs/unreleased/new-cycle-analytics-stage-backend.yml
@@ -0,0 +1,5 @@
+---
+title: New Cycle Analytics backend
+merge_request: 31535
+author:
+type: changed
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 3113cb172f7..21ccc854bf8 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -427,9 +427,14 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
- resource :cycle_analytics, only: [:show]
+ resource :cycle_analytics, only: [:show] do
+ get 'median/:stage_id' => 'cycle_analytics#median'
+ end
namespace :cycle_analytics do
+ resources :records, only: [] do
+ get '/:stage_id' => 'records#index', on: :collection
+ end
scope :events, controller: 'events' do
get :issue
get :plan
diff --git a/db/migrate/20190716144222_create_cycle_analytics_project_stages.rb b/db/migrate/20190716144222_create_cycle_analytics_project_stages.rb
new file mode 100644
index 00000000000..83a7dddedf0
--- /dev/null
+++ b/db/migrate/20190716144222_create_cycle_analytics_project_stages.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class CreateCycleAnalyticsProjectStages < ActiveRecord::Migration[5.2]
+ DOWNTIME = false
+ INDEX_NAME = 'index_cycle_analytics_stages_on_project_id_and_name'
+
+ def change
+ create_table :cycle_analytics_project_stages do |t|
+ t.references :project, null: false, foreign_key: { to_table: :projects, on_delete: :cascade }
+ t.string :name, null: false
+ t.boolean :hidden, default: false, null: false
+ t.boolean :custom, default: true, null: false
+ t.integer :relative_position
+ t.integer :start_event_identifier, null: false
+ t.integer :end_event_identifier, null: false
+ t.references :start_event_label, foreign_key: { to_table: :labels, on_delete: :cascade }
+ t.references :end_event_label, foreign_key: { to_table: :labels, on_delete: :cascade }
+
+ t.timestamps_with_timezone
+ end
+
+ add_index :cycle_analytics_project_stages, [:project_id, :name], unique: true, name: INDEX_NAME
+ add_index :cycle_analytics_project_stages, [:relative_position]
+ end
+end
diff --git a/db/migrate/20190729062536_create_cycle_analytics_group_stages.rb b/db/migrate/20190729062536_create_cycle_analytics_group_stages.rb
new file mode 100644
index 00000000000..9eed96c2be0
--- /dev/null
+++ b/db/migrate/20190729062536_create_cycle_analytics_group_stages.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class CreateCycleAnalyticsGroupStages < ActiveRecord::Migration[5.2]
+ DOWNTIME = false
+ INDEX_NAME = 'index_cycle_analytics_stages_on_group_id_and_name'
+
+ def change
+ create_table :cycle_analytics_group_stages do |t|
+ t.references :group, null: false, foreign_key: { to_table: :namespaces, on_delete: :cascade }
+ t.string :name, null: false
+ t.boolean :hidden, default: false, null: false
+ t.boolean :custom, default: true, null: false
+ t.integer :relative_position
+ t.integer :start_event_identifier, null: false
+ t.integer :end_event_identifier, null: false
+ t.references :start_event_label, foreign_key: { to_table: :labels, on_delete: :cascade }
+ t.references :end_event_label, foreign_key: { to_table: :labels, on_delete: :cascade }
+
+ t.timestamps_with_timezone
+ end
+
+ add_index :cycle_analytics_group_stages, [:group_id, :name], unique: true, name: INDEX_NAME
+ add_index :cycle_analytics_group_stages, [:relative_position]
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 828e36aa96c..8f8b38e9c0a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -1038,6 +1038,44 @@ ActiveRecord::Schema.define(version: 2019_08_02_235445) do
t.float "percentage_service_desk_issues", default: 0.0, null: false
end
+ create_table "cycle_analytics_group_stages", force: :cascade do |t|
+ t.bigint "group_id", null: false
+ t.string "name", null: false
+ t.boolean "hidden", default: false, null: false
+ t.boolean "custom", default: true, null: false
+ t.integer "relative_position"
+ t.integer "start_event_identifier", null: false
+ t.integer "end_event_identifier", null: false
+ t.bigint "start_event_label_id"
+ t.bigint "end_event_label_id"
+ t.datetime_with_timezone "created_at", null: false
+ t.datetime_with_timezone "updated_at", null: false
+ t.index ["end_event_label_id"], name: "index_cycle_analytics_group_stages_on_end_event_label_id"
+ t.index ["group_id", "name"], name: "index_cycle_analytics_stages_on_group_id_and_name", unique: true
+ t.index ["group_id"], name: "index_cycle_analytics_group_stages_on_group_id"
+ t.index ["relative_position"], name: "index_cycle_analytics_group_stages_on_relative_position"
+ t.index ["start_event_label_id"], name: "index_cycle_analytics_group_stages_on_start_event_label_id"
+ end
+
+ create_table "cycle_analytics_project_stages", force: :cascade do |t|
+ t.bigint "project_id", null: false
+ t.string "name", null: false
+ t.boolean "hidden", default: false, null: false
+ t.boolean "custom", default: true, null: false
+ t.integer "relative_position"
+ t.integer "start_event_identifier", null: false
+ t.integer "end_event_identifier", null: false
+ t.bigint "start_event_label_id"
+ t.bigint "end_event_label_id"
+ t.datetime_with_timezone "created_at", null: false
+ t.datetime_with_timezone "updated_at", null: false
+ t.index ["end_event_label_id"], name: "index_cycle_analytics_project_stages_on_end_event_label_id"
+ t.index ["project_id", "name"], name: "index_cycle_analytics_stages_on_project_id_and_name", unique: true
+ t.index ["project_id"], name: "index_cycle_analytics_project_stages_on_project_id"
+ t.index ["relative_position"], name: "index_cycle_analytics_project_stages_on_relative_position"
+ t.index ["start_event_label_id"], name: "index_cycle_analytics_project_stages_on_start_event_label_id"
+ end
+
create_table "dependency_proxy_blobs", id: :serial, force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false
t.text "file", null: false
@@ -3718,6 +3756,12 @@ ActiveRecord::Schema.define(version: 2019_08_02_235445) do
add_foreign_key "clusters_kubernetes_namespaces", "environments", on_delete: :nullify
add_foreign_key "clusters_kubernetes_namespaces", "projects", on_delete: :nullify
add_foreign_key "container_repositories", "projects"
+ add_foreign_key "cycle_analytics_group_stages", "labels", column: "end_event_label_id", on_delete: :cascade
+ add_foreign_key "cycle_analytics_group_stages", "labels", column: "start_event_label_id", on_delete: :cascade
+ add_foreign_key "cycle_analytics_group_stages", "namespaces", column: "group_id", on_delete: :cascade
+ add_foreign_key "cycle_analytics_project_stages", "labels", column: "end_event_label_id", on_delete: :cascade
+ add_foreign_key "cycle_analytics_project_stages", "labels", column: "start_event_label_id", on_delete: :cascade
+ add_foreign_key "cycle_analytics_project_stages", "projects", on_delete: :cascade
add_foreign_key "dependency_proxy_blobs", "namespaces", column: "group_id", name: "fk_db58bbc5d7", on_delete: :cascade
add_foreign_key "dependency_proxy_group_settings", "namespaces", column: "group_id", name: "fk_616ddd680a", on_delete: :cascade
add_foreign_key "deploy_keys_projects", "projects", name: "fk_58a901ca7e", on_delete: :cascade
diff --git a/lib/gitlab/cycle_analytics/base_query_builder.rb b/lib/gitlab/cycle_analytics/base_query_builder.rb
new file mode 100644
index 00000000000..7e10196c8ae
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/base_query_builder.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module CycleAnalytics
+ class BaseQueryBuilder
+ include Gitlab::CycleAnalytics::MetricsTables
+ include StageQueryHelpers
+
+ delegate :subject_model, to: :stage
+
+ PROJECT_QUERY_RULES = {
+ Project => {
+ Issue => ->(query) {
+ query.join(projects_table).on(issue_table[:project_id].eq(projects_table[:id]))
+ .where(issue_table[:project_id].eq(stage.parent.id))
+ },
+ MergeRequest => ->(query) {
+ query.join(projects_table).on(mr_table[:target_project_id].eq(projects_table[:id]))
+ .where(mr_table[:target_project_id].eq(stage.parent.id))
+ }
+ }
+ }.freeze
+
+ def initialize(stage:, params: {})
+ @stage = stage
+ @params = params
+ end
+
+ def apply
+ query = model_arel_table
+ query = filter_by_parent_model(query)
+ query = filter_by_time_range(query)
+ query = query.join(routes_table).on(projects_table[:namespace_id].eq(routes_table[:source_id]))
+ query = stage.start_event.apply_query_customization(query)
+ query = stage.end_event.apply_query_customization(query)
+ query = query.where(duration.gt(zero_interval))
+ query.where(routes_table[:source_type].eq('Namespace'))
+ end
+
+ private
+
+ attr_reader :stage, :params
+
+ def filter_by_parent_model(query)
+ instance_exec(query, &query_rules.fetch(stage.parent.class).fetch(subject_model))
+ end
+
+ def filter_by_time_range(query)
+ from = params[:from] || 30.days.ago
+ to = params[:to] || nil
+
+ query = query.where(model_arel_table[:created_at].gteq(from))
+ query = query.where(model_arel_table[:created_at].lteq(to)) if to
+ query
+ end
+
+ def model_arel_table
+ subject_model.arel_table
+ end
+
+ # EE will override this to include Group rules
+ def query_rules
+ PROJECT_QUERY_RULES
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/build_records_fetcher.rb b/lib/gitlab/cycle_analytics/build_records_fetcher.rb
new file mode 100644
index 00000000000..c57833e8b27
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/build_records_fetcher.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module CycleAnalytics
+ class BuildRecordsFetcher
+ include StageQueryHelpers
+ include Gitlab::CycleAnalytics::MetricsTables
+
+ def initialize(stage, query)
+ @stage = stage
+ @query = query
+ end
+
+ def serialized_records
+ AnalyticsBuildSerializer.new.represent(records.map { |e| e['build'] })
+ end
+
+ attr_reader :stage, :query
+
+ def records
+ q = query
+ .join(build_table).on(mr_metrics_table[:pipeline_id].eq(build_table[:commit_id]))
+ .project(*projections, round_duration_to_seconds.as('total_time'))
+ result = execute_query(q).to_a
+ Updater.update!(result, from: 'id', to: 'build', klass: ::Ci::Build)
+
+ result
+ end
+
+ def projections
+ [
+ build_table[:id]
+ ]
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/data_collector.rb b/lib/gitlab/cycle_analytics/data_collector.rb
new file mode 100644
index 00000000000..544fd17f3c5
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/data_collector.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module CycleAnalytics
+ # Arguments:
+ # stage - an instance of CycleAnalytics::ProjectStage or CycleAnalytics::GroupStage
+ # params:
+ # current_user: an instance of User
+ # from: DateTime
+ # to: DateTime
+ # project_ids: array of integers, optional, filtering projects within a group, used when the stage is a CycleAnalytics::GroupStage
+ class DataCollector
+ def initialize(stage, params = {})
+ @stage = stage
+ @params = params
+ end
+
+ def records_fetcher
+ RecordsFetcher.new(stage: stage, query: query, params: params)
+ end
+
+ def median
+ Median.new(stage: stage, query: query)
+ end
+
+ private
+
+ attr_reader :stage, :params
+
+ def query
+ BaseQueryBuilder.new(stage: stage, params: params).apply
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/default_stages.rb b/lib/gitlab/cycle_analytics/default_stages.rb
new file mode 100644
index 00000000000..d6edf3cb684
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/default_stages.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+module Gitlab
+ 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,
+ relative_position: 1,
+ start_event_identifier: :issue_created,
+ end_event_identifier: :issue_stage_end
+ }
+ 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
diff --git a/lib/gitlab/cycle_analytics/median.rb b/lib/gitlab/cycle_analytics/median.rb
new file mode 100644
index 00000000000..d8a9cb7f277
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/median.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module CycleAnalytics
+ class Median
+ include StageQueryHelpers
+
+ def initialize(stage:, query:)
+ @stage = stage
+ @query = query
+ end
+
+ def seconds
+ result = execute_query(query.project(median_duration_in_seconds.as('median'))).first || {}
+ result['median'] ? result['median'].to_i : nil
+ end
+
+ private
+
+ attr_reader :stage, :query
+
+ def percentile_cont
+ percentile_disc_ordering = Arel::Nodes::UnaryOperation.new(Arel::Nodes::SqlLiteral.new('ORDER BY'), duration)
+ Arel::Nodes::NamedFunction.new(
+ 'percentile_cont(0.5) WITHIN GROUP',
+ [percentile_disc_ordering]
+ )
+ end
+
+ def median_duration_in_seconds
+ Arel::Nodes::Extract.new(percentile_cont, :epoch)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/records_fetcher.rb b/lib/gitlab/cycle_analytics/records_fetcher.rb
new file mode 100644
index 00000000000..bdc5dc8776a
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/records_fetcher.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module CycleAnalytics
+ class RecordsFetcher
+ include StageQueryHelpers
+ include Gitlab::CycleAnalytics::MetricsTables
+
+ MAX_RECORDS = 50
+
+ FINDER_CLASS_MAPPING = {
+ Issue => IssuesFinder,
+ MergeRequest => MergeRequestsFinder
+ }.freeze
+
+ SERIALIZER_CLASS_MAPPING = {
+ Issue => AnalyticsIssueSerializer,
+ MergeRequest => AnalyticsMergeRequestSerializer
+ }.freeze
+
+ delegate :subject_model, to: :stage
+
+ def initialize(stage:, query:, params: {})
+ @stage = stage
+ @query = query
+ @params = params
+ end
+
+ def serialized_records
+ # Test and Staging stages should load Ci::Build records
+ if default_test_stage? || default_staging_stage?
+ BuildRecordsFetcher.new(stage, query, params).serialized_records
+ else
+ q = query
+ .join(finder_arel_query).on(finder_arel_query[:id].eq(subject_model.arel_table[:id]))
+ .order(stage.end_event.timestamp_projection.desc)
+ .take(MAX_RECORDS)
+ q = q.project(*projection_mapping[subject_model], round_duration_to_seconds.as('total_time'))
+ execute_query(q).to_a.map do |item|
+ SERIALIZER_CLASS_MAPPING.fetch(subject_model).new.represent(item)
+ end
+ end
+ end
+
+ # Casting ActiveRecord::Relation returned by the finder class to Arel so it can be joined with the main Arel query
+ def finder_arel_query
+ @finder_arel_query ||= begin
+ ar_relation = FINDER_CLASS_MAPPING.fetch(subject_model)
+ .new(params[:current_user], finder_params)
+ .execute
+ ar_relation = ar_relation.select(subject_model.arel_table[:id])
+ ar_relation.arel.as('finder_results')
+ end
+ end
+
+ private
+
+ attr_reader :stage, :query, :params
+
+ def projection_mapping
+ {
+ Issue => [
+ issue_table[:title],
+ issue_table[:iid],
+ issue_table[:id],
+ issue_table[:created_at],
+ issue_table[:author_id],
+ projects_table[:path].as('project_path'),
+ routes_table[:path].as('namespace_path')
+ ],
+ MergeRequest => [
+ mr_table[:title],
+ mr_table[:iid],
+ mr_table[:id],
+ mr_table[:created_at],
+ mr_table[:state],
+ mr_table[:author_id],
+ projects_table[:path].as('project_path'),
+ routes_table[:path].as('namespace_path')
+ ]
+ }
+ end
+
+ # EE will override this to include Group rules
+ def finder_params
+ {
+ Project => { project_id: stage.parent.id }
+ }.fetch(stage.parent.class)
+ end
+
+ def default_test_stage?
+ stage.matches_with_stage_params?(Gitlab::CycleAnalytics::DefaultStages.params_for_test_stage)
+ end
+
+ def default_staging_stage?
+ stage.matches_with_stage_params?(Gitlab::CycleAnalytics::DefaultStages.params_for_staging_stage)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_events.rb b/lib/gitlab/cycle_analytics/stage_events.rb
new file mode 100644
index 00000000000..c5c37df56d6
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module CycleAnalytics
+ module StageEvents
+ # Convention:
+ # Issue: < 100
+ # MergeRequest: >= 100 && < 1000
+ # Custom events for default stages: >= 1000
+ 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 => 1000,
+ StageEvents::IssueStageEnd => 1001,
+ StageEvents::PlanStageStart => 1002
+ }.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 AR 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
diff --git a/lib/gitlab/cycle_analytics/stage_events/code_stage_start.rb b/lib/gitlab/cycle_analytics/stage_events/code_stage_start.rb
new file mode 100644
index 00000000000..99882f1f839
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/code_stage_start.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Gitlab
+ 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
+
+ def timestamp_projection
+ issue_metrics_table[:first_mentioned_in_commit_at]
+ end
+
+ def apply_query_customization(query)
+ q = inner_join(query, mr_closing_issues_table[:merge_request_id])
+ q = inner_join(q, issue_metrics_table[:issue_id], mr_closing_issues_table[:issue_id])
+ q
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_events/issue_created.rb b/lib/gitlab/cycle_analytics/stage_events/issue_created.rb
new file mode 100644
index 00000000000..c57fbb58eeb
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/issue_created.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ 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
+
+ def timestamp_projection
+ issue_table[:created_at]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_events/issue_first_mentioned_in_commit.rb b/lib/gitlab/cycle_analytics/stage_events/issue_first_mentioned_in_commit.rb
new file mode 100644
index 00000000000..78d78e65266
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/issue_first_mentioned_in_commit.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ 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
+
+ def timestamp_projection
+ issue_metrics_table[:first_mentioned_in_commit_at]
+ end
+
+ def apply_query_customization(query)
+ inner_join(query, issue_metrics_table[:issue_id])
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_events/issue_stage_end.rb b/lib/gitlab/cycle_analytics/stage_events/issue_stage_end.rb
new file mode 100644
index 00000000000..f227b073173
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/issue_stage_end.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module CycleAnalytics
+ module StageEvents
+ class IssueStageEnd < SimpleStageEvent
+ def self.name
+ s_("CycleAnalyticsEvent|Issue first associated with a milestone or issue first added to a board")
+ end
+
+ def self.identifier
+ :issue_stage_end
+ end
+
+ def object_type
+ Issue
+ end
+
+ def timestamp_projection
+ Arel::Nodes::NamedFunction.new('COALESCE', [
+ issue_metrics_table[:first_associated_with_milestone_at],
+ issue_metrics_table[:first_added_to_board_at]
+ ])
+ end
+
+ def apply_query_customization(query)
+ inner_join(query, issue_metrics_table[:issue_id])
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_events/merge_request_created.rb b/lib/gitlab/cycle_analytics/stage_events/merge_request_created.rb
new file mode 100644
index 00000000000..e2237e2a7a4
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/merge_request_created.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ 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
+
+ def timestamp_projection
+ mr_table[:created_at]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_events/merge_request_first_deployed_to_production.rb b/lib/gitlab/cycle_analytics/stage_events/merge_request_first_deployed_to_production.rb
new file mode 100644
index 00000000000..16d5992a916
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/merge_request_first_deployed_to_production.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ 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
+
+ def timestamp_projection
+ mr_metrics_table[:first_deployed_to_production_at]
+ end
+
+ def apply_query_customization(query)
+ inner_join(query, mr_metrics_table[:merge_request_id])
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_events/merge_request_last_build_finished.rb b/lib/gitlab/cycle_analytics/stage_events/merge_request_last_build_finished.rb
new file mode 100644
index 00000000000..7e0656fb5d5
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/merge_request_last_build_finished.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ 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
+
+ def timestamp_projection
+ mr_metrics_table[:latest_build_finished_at]
+ end
+
+ def apply_query_customization(query)
+ inner_join(query, mr_metrics_table[:merge_request_id])
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_events/merge_request_last_build_started.rb b/lib/gitlab/cycle_analytics/stage_events/merge_request_last_build_started.rb
new file mode 100644
index 00000000000..ae2a01fc421
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/merge_request_last_build_started.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ 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
+
+ def timestamp_projection
+ mr_metrics_table[:latest_build_started_at]
+ end
+
+ def apply_query_customization(query)
+ inner_join(query, mr_metrics_table[:merge_request_id])
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_events/merge_request_merged.rb b/lib/gitlab/cycle_analytics/stage_events/merge_request_merged.rb
new file mode 100644
index 00000000000..fba8791ab2d
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/merge_request_merged.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ 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
+
+ def timestamp_projection
+ mr_metrics_table[:merged_at]
+ end
+
+ def apply_query_customization(query)
+ inner_join(query, mr_metrics_table[:merge_request_id])
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_events/plan_stage_start.rb b/lib/gitlab/cycle_analytics/stage_events/plan_stage_start.rb
new file mode 100644
index 00000000000..34462cb4bb0
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/plan_stage_start.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Gitlab
+ 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
+
+ def timestamp_projection
+ Arel::Nodes::NamedFunction.new('COALESCE', [
+ issue_metrics_table[:first_associated_with_milestone_at],
+ issue_metrics_table[:first_added_to_board_at]
+ ])
+ end
+
+ def apply_query_customization(query)
+ q = inner_join(query, issue_metrics_table[:issue_id])
+ q.where(issue_metrics_table[:first_added_to_board_at].not_eq(nil).or(issue_metrics_table[:first_associated_with_milestone_at].not_eq(nil))).where(issue_metrics_table[:first_mentioned_in_commit_at].not_eq(nil))
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_events/simple_stage_event.rb b/lib/gitlab/cycle_analytics/stage_events/simple_stage_event.rb
new file mode 100644
index 00000000000..9764a18ccdc
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/simple_stage_event.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Gitlab
+ 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
diff --git a/lib/gitlab/cycle_analytics/stage_events/stage_event.rb b/lib/gitlab/cycle_analytics/stage_events/stage_event.rb
new file mode 100644
index 00000000000..7bcaa8016d1
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_events/stage_event.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module CycleAnalytics
+ module StageEvents
+ # Base class for expressing an event.
+ class StageEvent
+ include Gitlab::CycleAnalytics::MetricsTables
+
+ def initialize(params)
+ @params = params
+ end
+
+ def self.name
+ raise NotImplementedError
+ end
+
+ # Each StageEvent must expose a timestamp or a timestamp like expression in order to build a range query.
+ # Example: get me all the Issue records between start event end end event
+ def timestamp_projection
+ raise NotImplementedError
+ end
+
+ # Optionally a StageEvent may apply additional filtering or join other tables on the base query.
+ def apply_query_customization(query)
+ query
+ end
+
+ private
+
+ attr_reader :params
+
+ # inner joins a table by a column, table won't be joined if it's already joined
+ #
+ # Example:
+ #
+ # INNER JOIN "table for right_column" ON left_column = right_column;
+ #
+ # Attributes:
+ #
+ # * +query+ - Arel query
+ # * +right_column+ - Column on the right side
+ # * +left_column+ - Defaults to the 'id' column
+ def inner_join(query, right_column, left_column = object_type.arel_table[:id])
+ return query if table_already_inner_joined?(query, right_column.relation)
+
+ query
+ .join(right_column.relation)
+ .on(left_column.eq(right_column))
+ end
+
+ def table_already_inner_joined?(query, table)
+ return false unless query.is_a?(Arel::SelectManager)
+
+ Array(query.source.right).any? { |join| join.left.eql?(table) }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_query_helpers.rb b/lib/gitlab/cycle_analytics/stage_query_helpers.rb
new file mode 100644
index 00000000000..c567cd7dc87
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_query_helpers.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module CycleAnalytics
+ module StageQueryHelpers
+ def execute_query(query)
+ # Extract raw sql and variable bindings from arel query
+ sql, binds = ActiveRecord::Base.connection.send(:to_sql_and_binds, query) # rubocop:disable GitlabSecurity/PublicSend
+
+ ActiveRecord::Base.connection.exec_query(sql, nil, binds).to_a
+ end
+
+ def zero_interval
+ Arel::Nodes::NamedFunction.new("CAST", [Arel.sql("'0' AS INTERVAL")])
+ end
+
+ def round_duration_to_seconds
+ Arel::Nodes::Extract.new(duration, :epoch)
+ end
+
+ def duration
+ Arel::Nodes::Subtraction.new(
+ stage.end_event.timestamp_projection,
+ stage.start_event.timestamp_projection
+ )
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 86317dd887f..5fa5d39e38c 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -3525,6 +3525,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 ""
diff --git a/spec/factories/cycle_analytics/project_stages.rb b/spec/factories/cycle_analytics/project_stages.rb
new file mode 100644
index 00000000000..a20ecd34f03
--- /dev/null
+++ b/spec/factories/cycle_analytics/project_stages.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :cycle_analytics_project_stage, class: CycleAnalytics::ProjectStage do
+ project
+ sequence(:name) { |n| "Stage ##{n}" }
+ hidden { false }
+ issue_stage
+
+ trait :issue_stage do
+ start_event_identifier { Gitlab::CycleAnalytics::StageEvents::IssueCreated.identifier }
+ end_event_identifier { Gitlab::CycleAnalytics::StageEvents::IssueStageEnd.identifier }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/code_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/code_stage_spec.rb
index dd1d9ac0f16..d6b35bb872c 100644
--- a/spec/lib/gitlab/cycle_analytics/code_stage_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/code_stage_spec.rb
@@ -12,7 +12,8 @@ describe Gitlab::CycleAnalytics::CodeStage do
let(:issue_3) { create(:issue, project: project, created_at: 60.minutes.ago) }
let(:mr_1) { create(:merge_request, source_project: project, created_at: 15.minutes.ago) }
let(:mr_2) { create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'A') }
- let(:stage) { described_class.new(options: { from: 2.days.ago, current_user: project.creator, project: project }) }
+ let(:from) { 2.days.ago }
+ let(:stage) { described_class.new(options: { from: from, current_user: project.creator, project: project }) }
before do
issue_1.metrics.update!(first_associated_with_milestone_at: 60.minutes.ago, first_mentioned_in_commit_at: 45.minutes.ago)
@@ -33,6 +34,11 @@ describe Gitlab::CycleAnalytics::CodeStage do
it 'counts median from issues with metrics' do
expect(stage.project_median).to eq(ISSUES_MEDIAN)
end
+
+ it_behaves_like 'using Gitlab::CycleAnalytics::DataCollector as backend' do
+ let(:expected_record_count) { 2 }
+ let(:expected_record_titles) { [mr_2.title, mr_1.title] }
+ end
end
describe '#events' do
diff --git a/spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb
index 4dd21239cde..cc0e8adb4b4 100644
--- a/spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb
@@ -10,7 +10,8 @@ describe Gitlab::CycleAnalytics::IssueStage do
let(:issue_2) { create(:issue, project: project, created_at: 60.minutes.ago) }
let(:issue_3) { create(:issue, project: project, created_at: 30.minutes.ago) }
let!(:issue_without_milestone) { create(:issue, project: project, created_at: 1.minute.ago) }
- let(:stage) { described_class.new(options: { from: 2.days.ago, current_user: project.creator, project: project }) }
+ let(:from) { 2.days.ago }
+ let(:stage) { described_class.new(options: { from: from, current_user: project.creator, project: project }) }
before do
issue_1.metrics.update!(first_associated_with_milestone_at: 60.minutes.ago )
@@ -28,6 +29,11 @@ describe Gitlab::CycleAnalytics::IssueStage do
it 'counts median from issues with metrics' do
expect(stage.project_median).to eq(ISSUES_MEDIAN)
end
+
+ it_behaves_like 'using Gitlab::CycleAnalytics::DataCollector as backend' do
+ let(:expected_record_count) { 3 }
+ let(:expected_record_titles) { [issue_3.title, issue_2.title, issue_1.title] }
+ end
end
describe '#events' do
diff --git a/spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb
index 98d2593de66..66a17cb8d78 100644
--- a/spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb
@@ -10,7 +10,8 @@ describe Gitlab::CycleAnalytics::PlanStage do
let!(:issue_2) { create(:issue, project: project, created_at: 60.minutes.ago) }
let!(:issue_3) { create(:issue, project: project, created_at: 30.minutes.ago) }
let!(:issue_without_milestone) { create(:issue, project: project, created_at: 1.minute.ago) }
- let(:stage) { described_class.new(options: { from: 2.days.ago, current_user: project.creator, project: project }) }
+ let(:from) { 2.days.ago }
+ let(:stage) { described_class.new(options: { from: from, current_user: project.creator, project: project }) }
before do
issue_1.metrics.update!(first_associated_with_milestone_at: 60.minutes.ago, first_mentioned_in_commit_at: 10.minutes.ago)
@@ -28,6 +29,11 @@ describe Gitlab::CycleAnalytics::PlanStage do
it 'counts median from issues with metrics' do
expect(stage.project_median).to eq(ISSUES_MEDIAN)
end
+
+ it_behaves_like 'using Gitlab::CycleAnalytics::DataCollector as backend' do
+ let(:expected_record_count) { 2 }
+ let(:expected_record_titles) { [issue_1.title, issue_2.title] }
+ end
end
describe '#events' do
diff --git a/spec/lib/gitlab/cycle_analytics/records_fetcher_spec.rb b/spec/lib/gitlab/cycle_analytics/records_fetcher_spec.rb
new file mode 100644
index 00000000000..b53ae05db09
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/records_fetcher_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Gitlab::CycleAnalytics::RecordsFetcher do
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ it "respect issue visibility rules, confidential issues won't be listed" do
+ project = create(:project, :empty_repo)
+ user = create(:user)
+
+ project.add_user(user, Gitlab::Access::GUEST)
+
+ issue1 = create(:issue, project: project)
+ issue2 = create(:issue, project: project, confidential: true)
+
+ issue1.metrics.update(first_added_to_board_at: 3.days.ago)
+ issue2.metrics.update(first_added_to_board_at: 3.days.ago)
+
+ issue1.metrics.update!(first_mentioned_in_commit_at: 2.days.ago)
+ issue2.metrics.update!(first_mentioned_in_commit_at: 2.days.ago)
+
+ stage = build(:cycle_analytics_project_stage, {
+ start_event_identifier: :plan_stage_start,
+ end_event_identifier: :issue_first_mentioned_in_commit,
+ project: project
+ })
+
+ data_collector = Gitlab::CycleAnalytics::DataCollector.new(stage, {
+ from: Time.new(2019),
+ current_user: user
+ })
+
+ expect(data_collector.records_fetcher.serialized_records.size).to eq(1)
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/shared_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/shared_stage_spec.rb
index cf95741908f..29373b01603 100644
--- a/spec/lib/gitlab/cycle_analytics/shared_stage_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/shared_stage_spec.rb
@@ -32,3 +32,19 @@ shared_examples 'base stage' do
expect(stage.events).not_to be_nil
end
end
+
+shared_examples 'using Gitlab::CycleAnalytics::DataCollector as backend' do
+ let(:stage_params) { Gitlab::CycleAnalytics::DefaultStages.send("params_for_#{stage_name}_stage").merge(project: project) }
+ let(:stage) { CycleAnalytics::ProjectStage.new(stage_params) }
+ let(:data_collector) { Gitlab::CycleAnalytics::DataCollector.new(stage, from: from, current_user: project.creator) }
+
+ it 'provides the same median value' do
+ expect(data_collector.median.seconds).to eq(ISSUES_MEDIAN)
+ end
+
+ it 'provides the same event records' do
+ records = data_collector.records_fetcher.serialized_records
+ expect(records.count).to eq(expected_record_count)
+ expect(records.map { |event| event[:title] }).to eq(expected_record_titles)
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/test_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/test_stage_spec.rb
index 9162686d17d..e4ebb9a3b90 100644
--- a/spec/lib/gitlab/cycle_analytics/test_stage_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/test_stage_spec.rb
@@ -6,20 +6,22 @@ require 'lib/gitlab/cycle_analytics/shared_stage_spec'
describe Gitlab::CycleAnalytics::TestStage do
let(:stage_name) { :test }
let(:project) { create(:project) }
- let(:stage) { described_class.new(options: { from: 2.days.ago, current_user: project.creator, project: project }) }
+ let(:from) { 2.days.ago }
+ let(:stage) { described_class.new(options: { from: from, current_user: project.creator, project: project }) }
it_behaves_like 'base stage'
describe '#median' do
+ let(:mr_1) { create(:merge_request, :closed, source_project: project, created_at: 60.minutes.ago) }
+ let(:mr_2) { create(:merge_request, :closed, source_project: project, created_at: 40.minutes.ago, source_branch: 'A') }
+ let(:mr_3) { create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'B') }
+ let(:mr_4) { create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'C') }
+ let(:mr_5) { create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'D') }
+
before do
issue_1 = create(:issue, project: project, created_at: 90.minutes.ago)
issue_2 = create(:issue, project: project, created_at: 60.minutes.ago)
issue_3 = create(:issue, project: project, created_at: 60.minutes.ago)
- mr_1 = create(:merge_request, :closed, source_project: project, created_at: 60.minutes.ago)
- mr_2 = create(:merge_request, :closed, source_project: project, created_at: 40.minutes.ago, source_branch: 'A')
- mr_3 = create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'B')
- mr_4 = create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'C')
- mr_5 = create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'D')
mr_1.metrics.update!(latest_build_started_at: 32.minutes.ago, latest_build_finished_at: 2.minutes.ago)
mr_2.metrics.update!(latest_build_started_at: 62.minutes.ago, latest_build_finished_at: 32.minutes.ago)
mr_3.metrics.update!(latest_build_started_at: nil, latest_build_finished_at: nil)
@@ -40,5 +42,10 @@ describe Gitlab::CycleAnalytics::TestStage do
it 'counts median from issues with metrics' do
expect(stage.project_median).to eq(ISSUES_MEDIAN)
end
+
+ it_behaves_like 'using Gitlab::CycleAnalytics::DataCollector as backend' do
+ let(:expected_record_count) { 2 }
+ let(:expected_record_titles) { [mr_1.title, mr_2.title] }
+ end
end
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index ada8c649ff6..a4d3383e28b 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -242,6 +242,7 @@ project:
- cluster_project
- cluster_ingresses
- creator
+- cycle_analytics_stages
- group
- namespace
- boards
diff --git a/spec/models/cycle_analytics/project_stage_spec.rb b/spec/models/cycle_analytics/project_stage_spec.rb
new file mode 100644
index 00000000000..1e157a81c9f
--- /dev/null
+++ b/spec/models/cycle_analytics/project_stage_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe CycleAnalytics::ProjectStage do
+ it 'default stages must be valid' do
+ project = create(:project)
+
+ Gitlab::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) }
+ end
+end
diff --git a/spec/serializers/cycle_analytics/stage_decorator_spec.rb b/spec/serializers/cycle_analytics/stage_decorator_spec.rb
new file mode 100644
index 00000000000..ce5c1740492
--- /dev/null
+++ b/spec/serializers/cycle_analytics/stage_decorator_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe CycleAnalytics::StageDecorator do
+ let(:params) { Gitlab::CycleAnalytics::DefaultStages.params_for_issue_stage }
+
+ it 'decorates default stage attributes with localized text' do
+ issue_stage = CycleAnalytics::ProjectStage.new(params)
+
+ decorator = described_class.new(issue_stage)
+
+ expect(decorator.title).to eq(described_class::DEFAULT_STAGE_ATTRIBUTES[:issue][:title].call)
+ expect(decorator.description).to eq(described_class::DEFAULT_STAGE_ATTRIBUTES[:issue][:description].call)
+ end
+
+ describe 'custom stage' do
+ let(:custom_stage) { CycleAnalytics::ProjectStage.new(params) }
+ let(:decorator) { described_class.new(custom_stage) }
+
+ before do
+ params[:name] = 'My Stage'
+ end
+
+ it 'uses name attribute for the title' do
+ expect(decorator.title).to eq(params[:name])
+ end
+
+ it 'uses empty string for description' do
+ expect(decorator.description).to eq('')
+ end
+ end
+
+ it 'infers legend from #subject_model' do
+ issue_stage = CycleAnalytics::ProjectStage.new(params)
+
+ expect(issue_stage.subject_model).to eq(Issue)
+
+ decorator = described_class.new(issue_stage)
+ expect(decorator.legend).to eq(_("Related Issues"))
+ end
+end
diff --git a/spec/services/cycle_analytics/stage_find_service_spec.rb b/spec/services/cycle_analytics/stage_find_service_spec.rb
new file mode 100644
index 00000000000..c072c4f4480
--- /dev/null
+++ b/spec/services/cycle_analytics/stage_find_service_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe CycleAnalytics::StageFindService do
+ it 'finds in-memory default stage' do
+ found_stage = described_class.new(parent: build(:project), id: 'code').execute # code (default) stage name
+
+ code_stage_params = Gitlab::CycleAnalytics::DefaultStages.params_for_code_stage
+ expect(found_stage.name).to eq(code_stage_params[:name])
+ expect(found_stage.start_event_identifier.to_sym).to eq(code_stage_params[:start_event_identifier])
+ expect(found_stage.end_event_identifier.to_sym).to eq(code_stage_params[:end_event_identifier])
+ end
+
+ it 'raises ActiveRecord::RecordNotFound when in-memory default stage cannot be found' do
+ expect do
+ described_class.new(parent: build(:project), id: 'UnknownDefaultStage').execute
+ end.to raise_error(ActiveRecord::RecordNotFound)
+ end
+end
diff --git a/spec/services/cycle_analytics/stage_list_service_spec.rb b/spec/services/cycle_analytics/stage_list_service_spec.rb
new file mode 100644
index 00000000000..17bc1880816
--- /dev/null
+++ b/spec/services/cycle_analytics/stage_list_service_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe CycleAnalytics::StageListService do
+ let(:project) { build(:project, :empty_repo) }
+
+ it 'returns the default stages as in-memory objects if customizable stages are not allowed' do
+ service = described_class.new(parent: project, allowed_to_customize_stages: false)
+
+ stages = service.execute
+
+ stage_names = stages.map(&:name)
+ expect(stage_names).to eq(Gitlab::CycleAnalytics::DefaultStages.all.map { |p| p[:name] })
+
+ stage_ids = stages.map(&:id)
+ expect(stage_ids.all?(&:nil?)).to eq(true)
+ end
+end
diff --git a/spec/support/shared_examples/cycle_analytics_stage_examples.rb b/spec/support/shared_examples/cycle_analytics_stage_examples.rb
new file mode 100644
index 00000000000..f16515fafcb
--- /dev/null
+++ b/spec/support/shared_examples/cycle_analytics_stage_examples.rb
@@ -0,0 +1,37 @@
+# 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 '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 to be queried from the start event' do
+ stage = described_class.new(valid_params)
+
+ expect(stage.subject_model).to eq(MergeRequest)
+ end
+ end
+end