diff --git a/app/graphql/resolvers/analytics/cycle_analytics/stages_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/stages_resolver.rb index 7f82c3dbadad60d218049f699bf3c41646d22484..b02061a091badf082cdf72cb86187e1ed48ba8ed 100644 --- a/app/graphql/resolvers/analytics/cycle_analytics/stages_resolver.rb +++ b/app/graphql/resolvers/analytics/cycle_analytics/stages_resolver.rb @@ -6,8 +6,10 @@ module CycleAnalytics class StagesResolver < BaseResolver type [Types::Analytics::CycleAnalytics::ValueStreams::StageType], null: true - def resolve - list_stages({ value_stream: object }) + argument :id, ID, required: false, description: 'Value stream stage id.' + + def resolve(id: nil) + list_stages(stage_params(id: id).merge(value_stream: object)) end private @@ -23,6 +25,12 @@ def list_stages(list_service_params) def namespace object.project.project_namespace end + + def stage_params(id: nil) + list_params = {} + list_params[:stage_ids] = [::GitlabSchema.parse_gid(id).model_id] if id + list_params + end end end end diff --git a/app/graphql/resolvers/analytics/cycle_analytics/value_streams_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/value_streams_resolver.rb index f3e3da86169676813f57438feb89ac22eef51697..3b71b575c483203c01636975971ebabcee4cae60 100644 --- a/app/graphql/resolvers/analytics/cycle_analytics/value_streams_resolver.rb +++ b/app/graphql/resolvers/analytics/cycle_analytics/value_streams_resolver.rb @@ -6,11 +6,20 @@ module CycleAnalytics class ValueStreamsResolver < BaseResolver type Types::Analytics::CycleAnalytics::ValueStreamType.connection_type, null: true - def resolve - # FOSS only have default value stream available - [ - ::Analytics::CycleAnalytics::ValueStream.build_default_value_stream(object.project_namespace) - ] + argument :id, ID, required: false, description: 'Value stream id.' + + # ignore id in FOSS + def resolve(id: nil) + ::Analytics::CycleAnalytics::ValueStreams::ListService + .new(**service_params(id: id)) + .execute + .payload[:value_streams] + end + + private + + def service_params(*) + { parent: object.project_namespace, current_user: current_user, params: {} } end end end diff --git a/app/graphql/types/analytics/cycle_analytics/value_streams/stage_type.rb b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_type.rb index c8fdf8513be32110d27973c3296f6901c0e477ef..a30927c8566a16038573538adf982f52b119af37 100644 --- a/app/graphql/types/analytics/cycle_analytics/value_streams/stage_type.rb +++ b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_type.rb @@ -8,6 +8,11 @@ module ValueStreams class StageType < BaseObject graphql_name 'ValueStreamStage' + field :id, + type: ::Types::GlobalIDType[::Analytics::CycleAnalytics::Stage], + null: false, + description: "ID of the value stream." + field :name, GraphQL::Types::String, null: false, @@ -33,6 +38,16 @@ class StageType < BaseObject null: false, description: 'End event identifier.' + field :start_event_html_description, + GraphQL::Types::String, + null: false, + description: 'HTML description of the start event.' + + field :end_event_html_description, + GraphQL::Types::String, + null: false, + description: 'HTML description of the end event.' + def start_event_identifier events_enum[object.start_event_identifier] end @@ -41,9 +56,21 @@ def end_event_identifier events_enum[object.end_event_identifier] end + def start_event_html_description + stage_entity.start_event_html_description + end + + def end_event_html_description + stage_entity.end_event_html_description + end + def events_enum Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum.with_indifferent_access end + + def stage_entity + @stage_entity ||= ::Analytics::CycleAnalytics::StageEntity.new(object) + end end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/models/analytics/cycle_analytics/stage.rb b/app/models/analytics/cycle_analytics/stage.rb index 93aac0c732eb0b4ccdbd1e14bddcc906a5e2be2e..04c75b38210bb203d2601c097841cf6db0be772c 100644 --- a/app/models/analytics/cycle_analytics/stage.rb +++ b/app/models/analytics/cycle_analytics/stage.rb @@ -21,6 +21,13 @@ class Stage < ApplicationRecord alias_attribute :parent_id, :group_id alias_attribute :value_stream_id, :group_value_stream_id + def to_global_id + return super if persisted? + + # Returns default name as the id for built in stages at the FOSS level + name + end + def self.distinct_stages_within_hierarchy(namespace) # Looking up the whole hierarchy including all kinds (type) of Namespace records. # We're doing a custom traversal_ids query because: diff --git a/app/services/analytics/cycle_analytics/stages/list_service.rb b/app/services/analytics/cycle_analytics/stages/list_service.rb index 1cd7d3f5c6dd1c1eac25a39534ec41c3dbed27f9..3d8e99ab7f74d0884d153e94699d9e070397a46d 100644 --- a/app/services/analytics/cycle_analytics/stages/list_service.rb +++ b/app/services/analytics/cycle_analytics/stages/list_service.rb @@ -7,7 +7,10 @@ class ListService < Analytics::CycleAnalytics::Stages::BaseService def execute return forbidden unless allowed? - success(build_default_stages) + stages = build_default_stages + # In FOSS, stages are not persisted, we match them by name + stages = stages.select { |stage| params[:stage_ids].include?(stage.name) } if filter_by_stage_ids? + success(stages) end private @@ -19,6 +22,10 @@ def allowed? def success(stages) ServiceResponse.success(payload: { stages: stages }) end + + def filter_by_stage_ids? + params[:stage_ids].present? + end end end end diff --git a/app/services/analytics/cycle_analytics/value_streams/list_service.rb b/app/services/analytics/cycle_analytics/value_streams/list_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..17b7728bd34308ab2126a3387ab2a3915b339ea0 --- /dev/null +++ b/app/services/analytics/cycle_analytics/value_streams/list_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + module ValueStreams + class ListService + include Gitlab::Allowable + + def initialize(parent:, current_user:, params: {}) + @parent = parent + @current_user = current_user + @params = params + end + + def execute + return forbidden unless can?(current_user, :read_cycle_analytics, parent.project) + + value_stream = ::Analytics::CycleAnalytics::ValueStream + .build_default_value_stream(parent) + + success([value_stream]) + end + + private + + attr_reader :parent, :current_user, :params + + def success(value_streams) + ServiceResponse.success(payload: { value_streams: value_streams }) + end + + def forbidden + ServiceResponse.error(message: 'Forbidden', payload: {}) + end + end + end + end +end + +Analytics::CycleAnalytics::ValueStreams::ListService.prepend_mod diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 9366fb07cb6b00f6d70a99a55795cc801771a656..c09bfcb7e517d6ad5b9b2d17f6b589f5694833e0 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -20957,7 +20957,6 @@ GPG signature for a signed commit. | <a id="grouptwofactorgraceperiod"></a>`twoFactorGracePeriod` | [`Int`](#int) | Time before two-factor authentication is enforced. | | <a id="groupuserpermissions"></a>`userPermissions` | [`GroupPermissions!`](#grouppermissions) | Permissions for the current user on the resource. | | <a id="groupvaluestreamanalytics"></a>`valueStreamAnalytics` | [`ValueStreamAnalytics`](#valuestreamanalytics) | Information about Value Stream Analytics within the group. | -| <a id="groupvaluestreams"></a>`valueStreams` | [`ValueStreamConnection`](#valuestreamconnection) | Value streams available to the group. (see [Connections](#connections)) | | <a id="groupvisibility"></a>`visibility` | [`String`](#string) | Visibility of the namespace. | | <a id="groupvulnerabilityscanners"></a>`vulnerabilityScanners` | [`VulnerabilityScannerConnection`](#vulnerabilityscannerconnection) | Vulnerability scanners reported on the project vulnerabilities of the group and its subgroups. (see [Connections](#connections)) | | <a id="groupweburl"></a>`webUrl` | [`String!`](#string) | Web URL of the group. | @@ -22026,6 +22025,22 @@ Returns [`ValueStreamDashboardCount`](#valuestreamdashboardcount). | <a id="groupvaluestreamdashboardusageoverviewidentifier"></a>`identifier` | [`ValueStreamDashboardMetric!`](#valuestreamdashboardmetric) | Type of counts to retrieve. | | <a id="groupvaluestreamdashboardusageoverviewtimeframe"></a>`timeframe` | [`Timeframe!`](#timeframe) | Counts recorded during this time frame, usually from beginning of the month until the end of the month (the system runs monthly aggregations). | +##### `Group.valueStreams` + +Value streams available to the group. + +Returns [`ValueStreamConnection`](#valuestreamconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#pagination-arguments): +`before: String`, `after: String`, `first: Int`, and `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="groupvaluestreamsid"></a>`id` | [`ID`](#id) | Value stream id. | + ##### `Group.vulnerabilities` Vulnerabilities reported on the projects in the group and its subgroups. @@ -26509,7 +26524,6 @@ Represents generic policy violation information. | <a id="projectuseraccessauthorizedagents"></a>`userAccessAuthorizedAgents` | [`ClusterAgentAuthorizationUserAccessConnection`](#clusteragentauthorizationuseraccessconnection) | Authorized cluster agents for the project through user_access keyword. (see [Connections](#connections)) | | <a id="projectuserpermissions"></a>`userPermissions` | [`ProjectPermissions!`](#projectpermissions) | Permissions for the current user on the resource. | | <a id="projectvaluestreamanalytics"></a>`valueStreamAnalytics` | [`ValueStreamAnalytics`](#valuestreamanalytics) | Information about Value Stream Analytics within the project. | -| <a id="projectvaluestreams"></a>`valueStreams` | [`ValueStreamConnection`](#valuestreamconnection) | Value streams available to the project. (see [Connections](#connections)) | | <a id="projectvisibility"></a>`visibility` | [`String`](#string) | Visibility of the project. | | <a id="projectvulnerabilityimages"></a>`vulnerabilityImages` | [`VulnerabilityContainerImageConnection`](#vulnerabilitycontainerimageconnection) | Container images reported on the project vulnerabilities. (see [Connections](#connections)) | | <a id="projectvulnerabilityscanners"></a>`vulnerabilityScanners` | [`VulnerabilityScannerConnection`](#vulnerabilityscannerconnection) | Vulnerability scanners reported on the project vulnerabilities. (see [Connections](#connections)) | @@ -27987,6 +28001,22 @@ four standard [pagination arguments](#pagination-arguments): | <a id="projecttimelogsstarttime"></a>`startTime` | [`Time`](#time) | List timelogs within a time range where the logged time is equal to or after startTime. | | <a id="projecttimelogsusername"></a>`username` | [`String`](#string) | List timelogs for a user. | +##### `Project.valueStreams` + +Value streams available to the project. + +Returns [`ValueStreamConnection`](#valuestreamconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#pagination-arguments): +`before: String`, `after: String`, `first: Int`, and `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="projectvaluestreamsid"></a>`id` | [`ID`](#id) | Value stream id. | + ##### `Project.visibleForks` Visible forks of the project. @@ -30491,7 +30521,20 @@ fields relate to interactions between the two entities. | <a id="valuestreamname"></a>`name` | [`String!`](#string) | Name of the value stream. | | <a id="valuestreamnamespace"></a>`namespace` | [`Namespace!`](#namespace) | Namespace the value stream belongs to. | | <a id="valuestreamproject"></a>`project` **{warning-solid}** | [`Project`](#project) | **Introduced** in GitLab 15.6. **Status**: Experiment. Project the value stream belongs to, returns empty if it belongs to a group. | -| <a id="valuestreamstages"></a>`stages` | [`[ValueStreamStage!]`](#valuestreamstage) | Value Stream stages. | + +#### Fields with arguments + +##### `ValueStream.stages` + +Value Stream stages. + +Returns [`[ValueStreamStage!]`](#valuestreamstage). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="valuestreamstagesid"></a>`id` | [`ID`](#id) | Value stream stage id. | ### `ValueStreamAnalytics` @@ -30543,10 +30586,13 @@ Represents a recorded measurement (object count) for the requested group. | Name | Type | Description | | ---- | ---- | ----------- | | <a id="valuestreamstagecustom"></a>`custom` | [`Boolean!`](#boolean) | Whether the stage is customized. | +| <a id="valuestreamstageendeventhtmldescription"></a>`endEventHtmlDescription` | [`String!`](#string) | HTML description of the end event. | | <a id="valuestreamstageendeventidentifier"></a>`endEventIdentifier` | [`ValueStreamStageEvent!`](#valuestreamstageevent) | End event identifier. | | <a id="valuestreamstageendeventlabel"></a>`endEventLabel` | [`Label`](#label) | Label associated with end event. | | <a id="valuestreamstagehidden"></a>`hidden` | [`Boolean!`](#boolean) | Whether the stage is hidden. | +| <a id="valuestreamstageid"></a>`id` | [`AnalyticsCycleAnalyticsStageID!`](#analyticscycleanalyticsstageid) | ID of the value stream. | | <a id="valuestreamstagename"></a>`name` | [`String!`](#string) | Name of the stage. | +| <a id="valuestreamstagestarteventhtmldescription"></a>`startEventHtmlDescription` | [`String!`](#string) | HTML description of the start event. | | <a id="valuestreamstagestarteventidentifier"></a>`startEventIdentifier` | [`ValueStreamStageEvent!`](#valuestreamstageevent) | Start event identifier. | | <a id="valuestreamstagestarteventlabel"></a>`startEventLabel` | [`Label`](#label) | Label associated with start event. | @@ -34762,6 +34808,12 @@ A `AlertManagementHttpIntegrationID` is a global ID. It is encoded as a string. An example `AlertManagementHttpIntegrationID` is: `"gid://gitlab/AlertManagement::HttpIntegration/1"`. +### `AnalyticsCycleAnalyticsStageID` + +A `AnalyticsCycleAnalyticsStageID` is a global ID. It is encoded as a string. + +An example `AnalyticsCycleAnalyticsStageID` is: `"gid://gitlab/Analytics::CycleAnalytics::Stage/1"`. + ### `AnalyticsCycleAnalyticsValueStreamID` A `AnalyticsCycleAnalyticsValueStreamID` is a global ID. It is encoded as a string. diff --git a/ee/app/graphql/ee/resolvers/analytics/cycle_analytics/stages_resolver.rb b/ee/app/graphql/ee/resolvers/analytics/cycle_analytics/stages_resolver.rb index c4cf416e64a639fbfe2b086268544839efd6dc4e..42564a37d2c873a06cd56664f62602f0e07505bb 100644 --- a/ee/app/graphql/ee/resolvers/analytics/cycle_analytics/stages_resolver.rb +++ b/ee/app/graphql/ee/resolvers/analytics/cycle_analytics/stages_resolver.rb @@ -8,16 +8,18 @@ module StagesResolver extend ::Gitlab::Utils::Override override :resolve - def resolve + def resolve(id: nil) return super unless ::Gitlab::Analytics::CycleAnalytics.licensed?(namespace) - BatchLoader::GraphQL.for(object.id).batch(key: object.class.name, cache: false) do |ids, loader, _| - stages = list_stages({ value_streams_ids: ids }) + BatchLoader::GraphQL.for(object.id).batch(key: object.class.name, + cache: false) do |value_stream_ids, loader, _| + list_params = stage_params(id: id).merge(value_streams_ids: value_stream_ids) + stages = list_stages(list_params) grouped_stages = stages.present? ? stages.group_by(&:value_stream_id) : {} - ids.each do |id| - loader.call(id, grouped_stages[id] || []) + value_stream_ids.each do |value_stream_id| + loader.call(value_stream_id, grouped_stages[value_stream_id] || []) end end end diff --git a/ee/app/graphql/ee/resolvers/analytics/cycle_analytics/value_streams_resolver.rb b/ee/app/graphql/ee/resolvers/analytics/cycle_analytics/value_streams_resolver.rb index 1ed110bdeb97c0951535a28e3771d5d9c362f63d..f5d58b3f77b2bd797c01b7f952838fdbb4da2740 100644 --- a/ee/app/graphql/ee/resolvers/analytics/cycle_analytics/value_streams_resolver.rb +++ b/ee/app/graphql/ee/resolvers/analytics/cycle_analytics/value_streams_resolver.rb @@ -8,18 +8,22 @@ module ValueStreamsResolver extend ::Gitlab::Utils::Override override :resolve - def resolve - unless ::Gitlab::Analytics::CycleAnalytics.licensed?(parent_namespace) - return if object.is_a?(Group) # Group value streams only exists on EE + def resolve(id: nil) + # FOSS VSA is not supported for groups + return if object.is_a?(Group) && !::Gitlab::Analytics::CycleAnalytics.licensed?(parent_namespace) - return super - end - - parent_namespace.value_streams.preload_associated_models.order_by_name_asc + super end private + override :service_params + def service_params(id: nil) + params = { parent: parent_namespace, current_user: current_user, params: {} } + params[:params][:value_stream_ids] = [::GitlabSchema.parse_gid(id).model_id] if id + params + end + def parent_namespace object.try(:project_namespace) || object end diff --git a/ee/app/services/ee/analytics/cycle_analytics/stages/list_service.rb b/ee/app/services/ee/analytics/cycle_analytics/stages/list_service.rb index 9f8e4a626c25bb28dc4b66096a5e81e4dd66d56d..018c87b8df9fd70da233d1c750a7910c4dc6dc2e 100644 --- a/ee/app/services/ee/analytics/cycle_analytics/stages/list_service.rb +++ b/ee/app/services/ee/analytics/cycle_analytics/stages/list_service.rb @@ -14,6 +14,7 @@ def execute stages = persisted_stages stages = filter_by_value_stream(stages) stages = filter_by_value_stream_ids(stages) + stages = filter_by_stage_ids(stages) success(stages.for_list) end @@ -33,7 +34,13 @@ def filter_by_value_stream(stages) def filter_by_value_stream_ids(stages) return stages unless filter_by_value_stream_ids? - stages.by_value_streams_ids(params[:value_streams_ids]) + stages.by_value_streams_ids(params[:value_stream_ids]) + end + + def filter_by_stage_ids(stages) + return stages unless filter_by_stage_ids? + + stages.id_in(params[:stage_ids]) end def filter_by_value_stream? @@ -41,7 +48,7 @@ def filter_by_value_stream? end def filter_by_value_stream_ids? - params[:value_streams_ids].present? + params[:value_stream_ids].present? end def allowed? diff --git a/ee/app/services/ee/analytics/cycle_analytics/value_streams/list_service.rb b/ee/app/services/ee/analytics/cycle_analytics/value_streams/list_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..a1c10794959048a695709513f7b099ce931b2145 --- /dev/null +++ b/ee/app/services/ee/analytics/cycle_analytics/value_streams/list_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module EE + module Analytics + module CycleAnalytics + module ValueStreams + module ListService + extend ::Gitlab::Utils::Override + + def execute + return forbidden unless ::Gitlab::Analytics::CycleAnalytics.allowed?(current_user, parent) + return super unless ::Gitlab::Analytics::CycleAnalytics.licensed?(parent) + + scope = parent.value_streams + scope = filter_by_value_stream_ids(scope) + scope = scope.preload_associated_models.order_by_name_asc + success(scope) + end + + private + + def filter_by_value_stream_ids(scope) + return scope unless params[:value_stream_ids].present? + + scope.id_in(params[:value_stream_ids]) + end + end + end + end + end +end diff --git a/ee/spec/requests/api/graphql/analytics/cycle_analytics/value_streams_spec.rb b/ee/spec/requests/api/graphql/analytics/cycle_analytics/value_streams_spec.rb index 1ff1ce03e2e4aa0981d9d3087d3cf5c7207f8ce0..c1c36babb2debba0cd356a796019ba5e0e2fb10f 100644 --- a/ee/spec/requests/api/graphql/analytics/cycle_analytics/value_streams_spec.rb +++ b/ee/spec/requests/api/graphql/analytics/cycle_analytics/value_streams_spec.rb @@ -7,15 +7,19 @@ let_it_be(:current_user) { create(:user) } + let(:variables) { { fullPath: resource.full_path } } + let(:query) do <<~QUERY - query($fullPath: ID!) { + query($fullPath: ID!, $valueStreamId: ID, $stageId: ID) { #{resource_type}(fullPath: $fullPath) { - valueStreams { + valueStreams(id: $valueStreamId) { nodes { name - stages { + stages(id: $stageId) { name + endEventHtmlDescription + startEventHtmlDescription startEventLabel { title } @@ -60,16 +64,38 @@ end it 'returns custom value streams' do - post_graphql(query, current_user: current_user, variables: { fullPath: resource.full_path }) + post_graphql(query, current_user: current_user, variables: variables) expect(graphql_data_at(resource_type.to_sym, :value_streams, :nodes)).to have_attributes(size: 2) expect(graphql_data_at(resource_type.to_sym, :value_streams, :nodes, 0, :name)).to eq('Custom 1') expect(graphql_data_at(resource_type.to_sym, :value_streams, :nodes, 1, :name)).to eq('Custom 2') end + context 'when specifying the value stream id argument' do + let(:value_stream) { value_streams.last } + let(:variables) { { fullPath: resource.full_path, valueStreamId: value_stream.to_gid.to_s } } + + before do + post_graphql(query, current_user: current_user, variables: variables) + end + + it 'returns only one value stream' do + expect(graphql_data_at(resource_type.to_sym, :value_streams, + :nodes)).to match([hash_including('name' => 'Custom 2')]) + end + + context 'when value stream id outside of the group is given' do + let(:value_stream) { create(:cycle_analytics_value_stream, name: 'outside') } + + it 'returns no data error' do + expect(graphql_data_at(resource_type.to_sym, :value_streams, :nodes)).to be_empty + end + end + end + context 'when value stream has stages' do def perform_request - post_graphql(query, current_user: current_user, variables: { fullPath: resource.full_path }) + post_graphql(query, current_user: current_user, variables: variables) end context 'with associated labels' do @@ -93,6 +119,17 @@ def perform_request expect(graphql_data_at(resource_type.to_sym, :value_streams, :nodes, 0, :stages, 0, :end_event_label, :title)).to eq('End Label') end + + it 'renders the html descriptions' do + perform_request + + expect(graphql_data_at(resource_type.to_sym, :value_streams, :nodes, 0, :stages, 0)).to match( + hash_including( + 'startEventHtmlDescription' => include("#{start_label.title}</span>"), + 'endEventHtmlDescription' => include("#{end_label.title}</span>") + ) + ) + end end it 'prevents n+1 queries' do @@ -108,6 +145,44 @@ def perform_request expect { perform_request }.to issue_same_number_of_queries_as(control) end + + context 'when specifying the stage id argument' do + let(:value_stream) { value_streams.first } + let!(:stage) { create(:cycle_analytics_stage, value_stream: value_stream, namespace: namespace) } + + let(:variables) do + { + fullPath: resource.full_path, + valueStreamId: value_stream.to_gid.to_s, + stageId: stage.to_gid.to_s + } + end + + before do + # should not show up in the results + create(:cycle_analytics_stage, value_stream: value_stream, namespace: namespace) + end + + it 'returns the queried stage' do + perform_request + + expect(graphql_data_at(resource_type.to_sym, :value_streams, :nodes, 0, :stages)).to match([ + hash_including('name' => stage.name) + ]) + end + + context 'when passing bogus stage id' do + before do + variables[:stageId] = create(:cycle_analytics_stage).to_gid.to_s + end + + it 'returns no stages' do + perform_request + + expect(graphql_data_at(resource_type.to_sym, :value_streams, :nodes, 0, :stages)).to be_empty + end + end + end end end end @@ -129,7 +204,7 @@ def perform_request end it 'returns default value stream' do - post_graphql(query, current_user: current_user, variables: { fullPath: resource.full_path }) + post_graphql(query, current_user: current_user, variables: variables) expect(graphql_data_at(:project, :value_streams, :nodes, 0, :name)).to eq('default') expect(graphql_data_at(:project, :value_streams)).to have_attributes(size: 1) @@ -149,7 +224,7 @@ def perform_request context 'when current user does not have permissions' do it 'does not return value streams' do - post_graphql(query, current_user: current_user, variables: { fullPath: resource.full_path }) + post_graphql(query, current_user: current_user, variables: variables) expect(graphql_data_at(:group, :value_streams)).to be_nil end diff --git a/ee/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb b/ee/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb index 4e43df6fdb559c833a0ea8011ff1bd0e6f966409..b41d371b5bcf55a31fbd5ce58c190f2588f312ff 100644 --- a/ee/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb +++ b/ee/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb @@ -67,5 +67,19 @@ it 'returns stages filtered by value streams' do expect(stages).to match_array([stage1, stage2]) end + + context 'when filtering stage ids' do + let(:stage_ids) { [stage1.id] } + + subject { described_class.new(parent: group, current_user: user, params: { value_stream_ids: [value_stream.id], stage_ids: [stage1.id] }).execute.payload[:stages] } + + it { is_expected.to eq([stage1]) } + + context 'when stage id does not belong to the value stream' do + let(:stage_ids) { [stage1.id, stage2.id] } + + it { is_expected.to eq([stage1]) } + end + end end end diff --git a/ee/spec/services/analytics/cycle_analytics/value_streams/list_service_spec.rb b/ee/spec/services/analytics/cycle_analytics/value_streams/list_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b24cf40c1d9bc6e211832ac7f1db3c3388ed5cd8 --- /dev/null +++ b/ee/spec/services/analytics/cycle_analytics/value_streams/list_service_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Analytics::CycleAnalytics::ValueStreams::ListService, feature_category: :value_stream_management do + let_it_be(:user) { create(:user) } + + let(:params) { {} } + let(:service) { described_class.new(parent: parent, params: params, current_user: user) } + + subject(:service_response) { service.execute } + + shared_examples 'value stream list service examples' do + context 'when the resource is licensed' do + before do + stub_licensed_features(licensed_feature_name => true) + end + + it 'returns the no value streams' do + expect(service_response).to be_success + expect(service_response.payload[:value_streams]).to be_empty + end + + context 'when value stream records are present' do + let_it_be(:value_stream1) { create(:cycle_analytics_value_stream, namespace: parent, name: 'bbb') } + let_it_be(:value_stream2) { create(:cycle_analytics_value_stream, namespace: parent, name: 'aaa') } + + it 'returns the value streams' do + expect(service_response).to be_success + expect(service_response.payload[:value_streams]).to match([ + have_attributes(name: value_stream2.name), + have_attributes(name: value_stream1.name) + ]) + end + + context 'when filtering by value stream ids' do + before do + params[:value_stream_ids] = [value_stream2.id] + end + + it 'returns the filtered value stream' do + expect(service_response).to be_success + expect(service_response.payload[:value_streams]).to match([ + have_attributes(name: value_stream2.name) + ]) + end + end + end + + context 'when the user is not allowed to access the service' do + let(:user) { create(:user) } + + it 'returns failed service response' do + expect(service_response).to be_error + end + end + end + end + + context 'when project namespace is given' do + let(:licensed_feature_name) { :cycle_analytics_for_projects } + + let_it_be(:parent) do + project = create(:project) + project.add_developer(user) + project.project_namespace + end + + it_behaves_like 'value stream list service examples' + + context 'when project is not licensed' do + it 'returns the default value stream' do + expect(service_response).to be_success + expect(service_response.payload[:value_streams]).to match([have_attributes(name: 'default')]) + end + end + end + + context 'when group is given' do + let(:licensed_feature_name) { :cycle_analytics_for_groups } + + let_it_be(:parent) { create(:group).tap { |g| g.add_developer(user) } } + + it_behaves_like 'value stream list service examples' + + context 'when group is not licensed' do + it 'returns failed service response' do + expect(service_response).to be_error + end + end + end +end diff --git a/spec/requests/api/graphql/project/value_streams_spec.rb b/spec/requests/api/graphql/project/value_streams_spec.rb index 01e937c1e4746d0e2f46636f8f1a513f793185b5..0805f386ed8975566a5599df7a6256d433b2f191 100644 --- a/spec/requests/api/graphql/project/value_streams_spec.rb +++ b/spec/requests/api/graphql/project/value_streams_spec.rb @@ -8,14 +8,18 @@ let_it_be(:project) { create(:project) } let_it_be(:user) { create(:user) } + let(:variables) { { fullPath: project.full_path } } + let(:query) do <<~QUERY query($fullPath: ID!) { project(fullPath: $fullPath) { valueStreams { nodes { + id name stages { + id name startEventIdentifier endEventIdentifier @@ -34,6 +38,8 @@ 'valueStreams' => { 'nodes' => [ { + 'id' => Gitlab::GlobalId.as_global_id('default', + model_name: Analytics::CycleAnalytics::ValueStream.to_s).to_s, 'name' => 'default', 'stages' => expected_stages } @@ -47,43 +53,53 @@ [ { 'name' => 'issue', + 'id' => stage_id('issue'), 'startEventIdentifier' => 'ISSUE_CREATED', 'endEventIdentifier' => 'ISSUE_STAGE_END' }, { 'name' => 'plan', + 'id' => stage_id('plan'), 'startEventIdentifier' => 'PLAN_STAGE_START', 'endEventIdentifier' => 'ISSUE_FIRST_MENTIONED_IN_COMMIT' }, { 'name' => 'code', + 'id' => stage_id('code'), 'startEventIdentifier' => 'CODE_STAGE_START', 'endEventIdentifier' => 'MERGE_REQUEST_CREATED' }, { 'name' => 'test', + 'id' => stage_id('test'), 'startEventIdentifier' => 'MERGE_REQUEST_LAST_BUILD_STARTED', 'endEventIdentifier' => 'MERGE_REQUEST_LAST_BUILD_FINISHED' }, { 'name' => 'review', + 'id' => stage_id('review'), 'startEventIdentifier' => 'MERGE_REQUEST_CREATED', 'endEventIdentifier' => 'MERGE_REQUEST_MERGED' }, { 'name' => 'staging', + 'id' => stage_id('staging'), 'startEventIdentifier' => 'MERGE_REQUEST_MERGED', 'endEventIdentifier' => 'MERGE_REQUEST_FIRST_DEPLOYED_TO_PRODUCTION' } ] end + def stage_id(name) + Gitlab::GlobalId.as_global_id(name, model_name: Analytics::CycleAnalytics::Stage.to_s).to_s + end + before_all do project.add_guest(user) end before do - post_graphql(query, current_user: user, variables: { fullPath: project.full_path }) + post_graphql(query, current_user: user, variables: variables) end it_behaves_like 'a working graphql query' @@ -91,11 +107,62 @@ it 'returns only `default` value stream' do expect(graphql_data).to eq(expected_value_stream) end + + context 'when specifying the value stream id argument' do + let(:variables) { { fullPath: project.full_path } } + + let(:query) do + <<~QUERY + query($fullPath: ID!, $stageId: ID) { + project(fullPath: $fullPath) { + valueStreams { + nodes { + name + stages(id: $stageId) { + name + } + } + } + } + } + QUERY + end + + it 'locates the default value stream' do + expect(graphql_data_at(:project, :value_streams, :nodes)).to match([hash_including('name' => 'default')]) + end + + context 'when specifying the stage id argument' do + let(:stage_id) { Gitlab::GlobalId.as_global_id('test', model_name: Analytics::CycleAnalytics::Stage.to_s).to_s } + let(:variables) { { fullPath: project.full_path, stageId: stage_id } } + + it 'returns only the test stage' do + expected_value_stream = hash_including( + 'name' => 'default', + 'stages' => [hash_including('name' => 'test')] + ) + + expect(graphql_data_at(:project, :value_streams, :nodes)).to match([expected_value_stream]) + end + + context 'when bogus stage id is given' do + let(:stage_id) do + Gitlab::GlobalId.as_global_id('bogus', model_name: Analytics::CycleAnalytics::Stage.to_s).to_s + end + + let(:variables) { { fullPath: project.full_path, stageId: stage_id } } + + it 'returns no data error' do + expect(graphql_data_at(:project, :value_streams, :nodes, 0, :stages, :nodes)).to be_empty + end + end + end + end end context 'when user does not have permission to read value streams' do before do - post_graphql(query, current_user: user, variables: { fullPath: project.full_path }) + post_graphql(query, current_user: user, variables: variables) end it 'returns nil' do diff --git a/spec/services/analytics/cycle_analytics/value_streams/list_service_spec.rb b/spec/services/analytics/cycle_analytics/value_streams/list_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c7891c4a44859ae6c4c2fdf5d242f85fb83a0e8c --- /dev/null +++ b/spec/services/analytics/cycle_analytics/value_streams/list_service_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Analytics::CycleAnalytics::ValueStreams::ListService, feature_category: :value_stream_management do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + + let(:service) { described_class.new(parent: project.project_namespace, current_user: user) } + + subject(:service_response) { service.execute } + + it 'returns the default value stream' do + project.add_developer(user) + expect(service_response).to be_success + expect(service_response.payload[:value_streams]).to match([have_attributes(name: 'default')]) + end + + context 'when the user is not part of the project' do + it 'fails' do + expect(service_response).to be_error + end + end +end