Commit 019ad0bc authored by Adam Hegyi's avatar Adam Hegyi Committed by Peter Leitzen

Cycle Analytics stages endpoint

This commit exposes a JSON endpoint to retrieve the available cycle
analytics stages and possible events for a given group.
parent e8790766
# frozen_string_literal: true
module Analytics
module CycleAnalytics
class StagesController < Analytics::ApplicationController
check_feature_flag Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG
before_action :load_group
before_action :authorize_access!
def index
result = stage_list_service.execute
if result.success?
render json: cycle_analytics_configuration(result.payload[:stages])
else
render json: { message: result.message }, status: result.http_status
end
end
private
def authorize_access!
render_403 unless can?(current_user, :read_group_cycle_analytics, @group)
end
def cycle_analytics_configuration(stages)
stage_presenters = stages.map { |s| StagePresenter.new(s) }
Analytics::CycleAnalytics::ConfigurationEntity.new(stages: stage_presenters)
end
def stage_list_service
Analytics::CycleAnalytics::StageListService.new(
parent: @group,
current_user: current_user
)
end
end
end
end
...@@ -3,7 +3,12 @@ ...@@ -3,7 +3,12 @@
module Analytics module Analytics
module CycleAnalytics module CycleAnalytics
class GroupStage < ApplicationRecord class GroupStage < ApplicationRecord
include Analytics::CycleAnalytics::Stage
validates :group, presence: true
belongs_to :group belongs_to :group
alias_attribute :parent, :group
end end
end end
end end
# frozen_string_literal: true
module Analytics
module CycleAnalytics
class StagePresenter < Gitlab::View::Presenter::Delegated
DEFAULT_STAGE_ATTRIBUTES = {
issue: {
title: -> { s_('CycleAnalyticsStage|Issue') },
description: -> { _('Time before an issue gets scheduled') }
}.freeze,
plan: {
title: -> { s_('CycleAnalyticsStage|Plan') },
description: -> { _('Time before an issue starts implementation') }
}.freeze,
code: {
title: -> { s_('CycleAnalyticsStage|Code') },
description: -> { _('Time until first merge request') }
}.freeze,
test: {
title: -> { s_('CycleAnalyticsStage|Test') },
description: -> { _('Total test time for all commits/merges') }
}.freeze,
review: {
title: -> { s_('CycleAnalyticsStage|Review') },
description: -> { _('Time between merge request creation and merge/close') }
}.freeze,
staging: {
title: -> { s_('CycleAnalyticsStage|Staging') },
description: -> { _('From merge request merge until deploy to production') }
}.freeze,
production: {
title: -> { s_('CycleAnalyticsStage|Production') },
description: -> { _('From issue creation until deploy to production') }
}.freeze
}.freeze
def title
extract_default_stage_attribute(:title) || name
end
def description
extract_default_stage_attribute(:description) || ''
end
def legend
''
end
private
def extract_default_stage_attribute(attribute)
DEFAULT_STAGE_ATTRIBUTES.dig(name.to_sym, attribute.to_sym)&.call
end
end
end
end
# frozen_string_literal: true
module Analytics
module CycleAnalytics
class ConfigurationEntity < Grape::Entity
include RequestAwareEntity
expose :events, using: Analytics::CycleAnalytics::EventEntity
expose :stages, using: Analytics::CycleAnalytics::StageEntity
private
def events
Gitlab::Analytics::CycleAnalytics::StageEvents.events
end
end
end
end
# frozen_string_literal: true
module Analytics
module CycleAnalytics
class EventEntity < Grape::Entity
expose :name
expose :identifier
expose :type
expose :can_be_start_event?, as: :can_be_start_event
expose :allowed_end_events
private
def type
'simple'
end
def can_be_start_event?
pairing_rules.has_key?(object)
end
def allowed_end_events
pairing_rules.fetch(object, []).map(&:identifier)
end
def pairing_rules
Gitlab::Analytics::CycleAnalytics::StageEvents.pairing_rules
end
end
end
end
# frozen_string_literal: true
module Analytics
module CycleAnalytics
class StageEntity < Grape::Entity
expose :title
expose :legend
expose :description
expose :id
expose :custom
expose :start_event_identifier, if: :custom?
expose :end_event_identifier, if: :custom?
def id
object.id || object.name
end
end
end
end
# frozen_string_literal: true
module Analytics
module CycleAnalytics
class StageListService
include Gitlab::Allowable
def initialize(parent:, current_user:)
@parent = parent
@current_user = current_user
end
def execute
return forbidden unless allowed?
success(build_default_stages)
end
private
attr_reader :parent, :current_user
def build_default_stages
Gitlab::Analytics::CycleAnalytics::DefaultStages.all.map do |params|
parent.cycle_analytics_stages.build(params)
end
end
def success(stages)
ServiceResponse.success(payload: { stages: stages })
end
def forbidden
ServiceResponse.error(message: 'Forbidden', http_status: :forbidden)
end
def allowed?
can?(current_user, :read_group_cycle_analytics, parent)
end
end
end
end
---
title: Initial endpoint for exposing Cycle Analytics stages for the new frontend
merge_request: 16240
author:
type: added
...@@ -9,5 +9,8 @@ namespace :analytics do ...@@ -9,5 +9,8 @@ namespace :analytics do
constraints(::Constraints::FeatureConstrainer.new(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG)) do constraints(::Constraints::FeatureConstrainer.new(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG)) do
resource :cycle_analytics, only: :show resource :cycle_analytics, only: :show
namespace :cycle_analytics do
resources :stages, only: [:index]
end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
describe Analytics::CycleAnalytics::StagesController do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:params) { { group_id: group.full_path } }
subject { get :index, params: params }
before do
stub_feature_flags(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG => true)
stub_licensed_features(cycle_analytics_for_groups: true)
group.add_reporter(user)
sign_in(user)
end
it 'succeeds' do
subject
expect(response).to be_successful
expect(response).to match_response_schema('analytics/cycle_analytics/stages', dir: 'ee')
end
it 'returns correct start events' do
subject
response_start_events = json_response['stages'].map { |s| s['start_event_identifier'] }
start_events = Gitlab::Analytics::CycleAnalytics::DefaultStages.all.map { |s| s['start_event_identifier'] }
expect(response_start_events).to eq(start_events)
end
it 'returns correct event names' do
subject
response_event_names = json_response['events'].map { |s| s['name'] }
event_names = Gitlab::Analytics::CycleAnalytics::StageEvents.events.map(&:name)
expect(response_event_names).to eq(event_names)
end
it 'succeeds for subgroups' do
subgroup = create(:group, parent: group)
params[:group_id] = subgroup.full_path
subject
expect(response).to be_successful
end
it 'renders 404 when group_id is not provided' do
params[:group_id] = nil
subject
expect(response).to have_gitlab_http_status(:not_found)
end
it 'renders 404 when group is missing' do
params[:group_id] = 'missing_group'
subject
expect(response).to have_gitlab_http_status(:not_found)
end
it 'renders 404 when feature flag is disabled' do
stub_feature_flags(Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG => false)
subject
expect(response).to have_gitlab_http_status(:not_found)
end
it 'renders 403 when user has no reporter access' do
GroupMember.where(user: user).delete_all
group.add_guest(user)
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'renders 403 when feature is not available for the group' do
stub_licensed_features(cycle_analytics_for_groups: false)
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'renders 403 based on the response of the service object' do
expect_any_instance_of(Analytics::CycleAnalytics::StageListService).to receive(:allowed?).and_return(false)
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
{
"type": "object",
"required": ["name", "identifier", "type", "can_be_start_event", "allowed_end_events"],
"properties": {
"name": {
"type": "string"
},
"identifier": {
"type": "string"
},
"type": {
"type": "string" ,
"enum": ["simple"]
},
"can_be_start_event": {
"type": "boolean"
},
"allowed_end_events": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
}
{
"type": "object",
"required": ["title", "legend", "description", "id", "custom"],
"properties": {
"title": {
"type": "string"
},
"legend": {
"type": "string"
},
"description": {
"type": "string"
},
"id": {
"oneOf": [
{ "type": "string" },
{ "type": "integer" }
]
},
"custom": {
"type": "boolean"
}
},
"additionalProperties": false
}
{
"type": "object",
"properties": {
"events": {
"type": "array" ,
"items": {
"$ref": "event.json"
}
},
"stages": {
"type": "array" ,
"items": {
"$ref": "stage.json"
}
}
},
"required": ["events", "stages"],
"additionalProperties": false
}
...@@ -6,4 +6,9 @@ describe Analytics::CycleAnalytics::GroupStage do ...@@ -6,4 +6,9 @@ describe Analytics::CycleAnalytics::GroupStage do
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:group) }
end end
it_behaves_like 'cycle analytics stage' do
let(:parent) { create(:group) }
let(:parent_name) { :group }
end
end end
# frozen_string_literal: true
require 'spec_helper'
describe Analytics::CycleAnalytics::StagePresenter do
let(:default_stage_params) { Gitlab::Analytics::CycleAnalytics::DefaultStages.params_for_issue_stage }
let(:default_stage) { Analytics::CycleAnalytics::ProjectStage.new(default_stage_params) }
let(:custom_stage) { Analytics::CycleAnalytics::ProjectStage.new(name: 'Hello') }
let(:old_issue_stage_implementation) { Gitlab::CycleAnalytics::IssueStage.new(options: {}) }
describe '#title' do
it 'returns the pre-defined title for the default stage' do
decorator = described_class.new(default_stage)
expect(decorator.title).to eq(old_issue_stage_implementation.title)
end
it 'returns the name attribute for a custom stage' do
decorator = described_class.new(custom_stage)
expect(decorator.title).to eq(custom_stage.name)
end
end
describe '#description' do
it 'returns the pre-defined description for the default stage' do
decorator = described_class.new(default_stage)
expect(decorator.description).to eq(old_issue_stage_implementation.description)
end
it 'returns empty string when custom stage is given' do
decorator = described_class.new(custom_stage)
expect(decorator.description).to eq('') # custom stages don't have description
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Analytics::CycleAnalytics::StageListService do
let(:group) { create(:group) }
let(:user) { create(:user) }
subject { described_class.new(parent: group, current_user: user) }
context 'succeeds' do
let(:stages) { subject.execute.payload[:stages] }
before do
stub_licensed_features(cycle_analytics_for_groups: true)
group.add_reporter(user)
end
it 'returns only the default stages' do
expect(stages.size).to eq(Gitlab::Analytics::CycleAnalytics::DefaultStages.all.size)
end
it 'provides the default stages as non-persisted objects' do
stage_ids = stages.map(&:id)
expect(stage_ids.all?(&:nil?)).to eq(true)
end
end
it 'returns forbidden response' do
result = subject.execute
expect(result).to be_error
expect(result.http_status).to eq(:forbidden)
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