From d45e601964aef8557d289a13c3b40a9f5b1431ac Mon Sep 17 00:00:00 2001
From: Adam Hegyi <ahegyi@gitlab.com>
Date: Mon, 6 May 2024 08:02:04 +0200
Subject: [PATCH] Expose VSA metrics via GraphQL

This change exposes the VSA aggregation metrics via GraphQL.

Changelog: added
---
 .../value_streams/stage_metrics_resolver.rb   |  56 ++++++++
 .../value_streams/stage_metrics_type.rb       |  55 ++++++++
 .../value_streams/stage_type.rb               |   6 +
 doc/api/graphql/reference/index.md            |  28 ++++
 .../cycle_analytics/base_query_builder.rb     |   2 -
 .../cycle_analytics/value_streams_spec.rb     | 121 ++++++++++++++++++
 .../cycle_analytics/base_query_builder.rb     |   1 +
 locale/gitlab.pot                             |  12 ++
 .../api/graphql/project/value_streams_spec.rb | 119 ++++++++++++++++-
 9 files changed, 397 insertions(+), 3 deletions(-)
 create mode 100644 app/graphql/resolvers/analytics/cycle_analytics/value_streams/stage_metrics_resolver.rb
 create mode 100644 app/graphql/types/analytics/cycle_analytics/value_streams/stage_metrics_type.rb

diff --git a/app/graphql/resolvers/analytics/cycle_analytics/value_streams/stage_metrics_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/value_streams/stage_metrics_resolver.rb
new file mode 100644
index 0000000000000..971cd64dbaf3e
--- /dev/null
+++ b/app/graphql/resolvers/analytics/cycle_analytics/value_streams/stage_metrics_resolver.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Resolvers
+  module Analytics
+    module CycleAnalytics
+      module ValueStreams
+        class StageMetricsResolver < BaseResolver
+          type ::Types::Analytics::CycleAnalytics::ValueStreams::StageMetricsType, null: true
+
+          argument :timeframe, Types::TimeframeInputType,
+            required: true,
+            description: 'Aggregation timeframe. Filters the issue or the merge request creation time for FOSS ' \
+                         'projects, and the end event timestamp for licensed projects or groups.'
+
+          argument :assignee_usernames, [GraphQL::Types::String],
+            required: false,
+            description: 'Usernames of users assigned to the issue or the merge request.'
+
+          argument :author_username, GraphQL::Types::String,
+            required: false,
+            description: 'Username of the author of the issue or the merge request.'
+
+          argument :milestone_title, GraphQL::Types::String,
+            required: false,
+            description: 'Milestone applied to the issue or the merge request.'
+
+          argument :label_names, [GraphQL::Types::String],
+            required: false,
+            description: 'Labels applied to the issue or the merge request.'
+
+          def resolve(**args)
+            formatted_args = args.to_hash
+            timeframe = args.delete(:timeframe)
+            formatted_args[:created_after] = timeframe[:start]
+            formatted_args[:created_before] = timeframe[:end]
+
+            if formatted_args[:assignee_usernames].present?
+              formatted_args[:assignee_username] =
+                formatted_args.delete(:assignee_usernames)
+            end
+
+            formatted_args[:label_name] = formatted_args.delete(:label_names) if formatted_args[:label_names].present?
+
+            params = Gitlab::Analytics::CycleAnalytics::RequestParams.new(
+              namespace: object.namespace,
+              current_user: current_user,
+              **formatted_args.compact
+            )
+
+            Gitlab::Analytics::CycleAnalytics::DataCollector.new(stage: object, params: params.to_data_collector_params)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/app/graphql/types/analytics/cycle_analytics/value_streams/stage_metrics_type.rb b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_metrics_type.rb
new file mode 100644
index 0000000000000..89ac96bd9035e
--- /dev/null
+++ b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_metrics_type.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Types
+  module Analytics
+    module CycleAnalytics
+      module ValueStreams
+        # rubocop: disable Graphql/AuthorizeTypes -- # Already authorized in parent value stream type.
+        class StageMetricsType < BaseObject
+          graphql_name 'ValueStreamStageMetrics'
+
+          field :average,
+            ::Types::Analytics::CycleAnalytics::MetricType,
+            description: 'Average duration in seconds.'
+
+          field :count,
+            ::Types::Analytics::CycleAnalytics::MetricType,
+            description: 'Limited item count. The backend counts maximum 1000 items, ' \
+                         'for free projects, and maximum 10,000 items for licensed ' \
+                         'projects or licensed groups.'
+
+          field :median,
+            ::Types::Analytics::CycleAnalytics::MetricType,
+            description: 'Median duration in seconds.'
+
+          def count
+            {
+              value: object.count,
+              identifier: 'value_stream_stage_count',
+              title: s_('CycleAnalytics|Item count')
+            }
+          end
+
+          def average
+            {
+              value: object.average.seconds,
+              identifier: 'value_stream_stage_average',
+              title: s_('CycleAnalytics|Average duration'),
+              unit: s_('CycleAnalytics|seconds')
+            }
+          end
+
+          def median
+            {
+              value: object.median.seconds,
+              identifier: 'value_stream_stage_median',
+              title: s_('CycleAnalytics|Median duration'),
+              unit: s_('CycleAnalytics|seconds')
+            }
+          end
+        end
+      end
+      # rubocop: enable Graphql/AuthorizeTypes
+    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 a30927c8566a1..94727d064514b 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
@@ -48,6 +48,12 @@ class StageType < BaseObject
             null: false,
             description: 'HTML description of the end event.'
 
+          field :metrics,
+            Types::Analytics::CycleAnalytics::ValueStreams::StageMetricsType,
+            null: false,
+            resolver: Resolvers::Analytics::CycleAnalytics::ValueStreams::StageMetricsResolver,
+            description: 'Aggregated metrics for the given stage'
+
           def start_event_identifier
             events_enum[object.start_event_identifier]
           end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 1bc4bcf821f2a..a3b136a8eddaa 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -30987,6 +30987,34 @@ Represents a recorded measurement (object count) for the requested group.
 | <a id="valuestreamstagestarteventidentifier"></a>`startEventIdentifier` | [`ValueStreamStageEvent!`](#valuestreamstageevent) | Start event identifier. |
 | <a id="valuestreamstagestarteventlabel"></a>`startEventLabel` | [`Label`](#label) | Label associated with start event. |
 
+#### Fields with arguments
+
+##### `ValueStreamStage.metrics`
+
+Aggregated metrics for the given stage.
+
+Returns [`ValueStreamStageMetrics!`](#valuestreamstagemetrics).
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="valuestreamstagemetricsassigneeusernames"></a>`assigneeUsernames` | [`[String!]`](#string) | Usernames of users assigned to the issue or the merge request. |
+| <a id="valuestreamstagemetricsauthorusername"></a>`authorUsername` | [`String`](#string) | Username of the author of the issue or the merge request. |
+| <a id="valuestreamstagemetricslabelnames"></a>`labelNames` | [`[String!]`](#string) | Labels applied to the issue or the merge request. |
+| <a id="valuestreamstagemetricsmilestonetitle"></a>`milestoneTitle` | [`String`](#string) | Milestone applied to the issue or the merge request. |
+| <a id="valuestreamstagemetricstimeframe"></a>`timeframe` | [`Timeframe!`](#timeframe) | Aggregation timeframe. Filters the issue or the merge request creation time for FOSS projects, and the end event timestamp for licensed projects or groups. |
+
+### `ValueStreamStageMetrics`
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="valuestreamstagemetricsaverage"></a>`average` | [`ValueStreamAnalyticsMetric`](#valuestreamanalyticsmetric) | Average duration in seconds. |
+| <a id="valuestreamstagemetricscount"></a>`count` | [`ValueStreamAnalyticsMetric`](#valuestreamanalyticsmetric) | Limited item count. The backend counts maximum 1000 items, for free projects, and maximum 10,000 items for licensed projects or licensed groups. |
+| <a id="valuestreamstagemetricsmedian"></a>`median` | [`ValueStreamAnalyticsMetric`](#valuestreamanalyticsmetric) | Median duration in seconds. |
+
 ### `VulnerabilitiesCountByDay`
 
 Represents the count of vulnerabilities by severity on a particular day. This data is retained for 365 days.
diff --git a/ee/lib/ee/gitlab/analytics/cycle_analytics/base_query_builder.rb b/ee/lib/ee/gitlab/analytics/cycle_analytics/base_query_builder.rb
index 1ebde179d66d2..56a6137720757 100644
--- a/ee/lib/ee/gitlab/analytics/cycle_analytics/base_query_builder.rb
+++ b/ee/lib/ee/gitlab/analytics/cycle_analytics/base_query_builder.rb
@@ -13,8 +13,6 @@ def build
   override :build_finder_params
   def build_finder_params(params)
     super.tap do |finder_params|
-      finder_params.merge!(params.slice(*::Gitlab::Analytics::CycleAnalytics::RequestParams::FINDER_PARAM_NAMES))
-
       finder_params[:project_ids] = Array(params[:project_ids])
     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 c1c36babb2deb..265fc693b303b 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
@@ -182,6 +182,125 @@ def perform_request
                 expect(graphql_data_at(resource_type.to_sym, :value_streams, :nodes, 0, :stages)).to be_empty
               end
             end
+
+            context 'when requesting aggregated metrics' do
+              let_it_be(:assignee) { create(:user) }
+              let_it_be(:current_time) { Time.current }
+              let_it_be(:milestone) { create(:milestone, group: resource.root_ancestor) }
+              let_it_be(:filter_label) { create(:group_label, group: resource.root_ancestor) }
+
+              let_it_be(:merge_request1) do
+                create(:merge_request, :unique_branches, source_project: project, created_at: current_time,
+                  assignees: [assignee]).tap do |mr|
+                  mr.metrics.update!(merged_at: current_time + 2.hours)
+                end
+              end
+
+              let_it_be(:merge_request2) do
+                create(:merge_request, :unique_branches, source_project: project,
+                  labels: [filter_label],
+                  milestone: milestone,
+                  created_at: current_time).tap do |mr|
+                  mr.metrics.update!(merged_at: current_time + 2.hours)
+                end
+              end
+
+              let_it_be(:merge_request3) do
+                create(:merge_request, :unique_branches, source_project: project, milestone: milestone,
+                  created_at: current_time).tap do |mr|
+                  mr.metrics.update!(merged_at: current_time + 2.hours)
+                end
+              end
+
+              let(:query) do
+                <<~QUERY
+                  query($fullPath: ID!, $valueStreamId: ID, $stageId: ID, $from: Date!, $to: Date!, $assigneeUsernames: [String!], $milestoneTitle: String, $labelNames: [String!]) {
+                    #{resource_type}(fullPath: $fullPath) {
+                      valueStreams(id: $valueStreamId) {
+                        nodes {
+                          stages(id: $stageId) {
+                            metrics(timeframe: { start: $from, end: $to }, assigneeUsernames: $assigneeUsernames, milestoneTitle: $milestoneTitle, labelNames: $labelNames) {
+                              count {
+                                value
+                              }
+                            }
+                          }
+                        }
+                      }
+                    }
+                  }
+                QUERY
+              end
+
+              before do
+                variables.merge!({
+                  from: (current_time - 10.days).to_date,
+                  to: (current_time + 10.days).to_date
+                })
+
+                Analytics::CycleAnalytics::DataLoaderService.new(group: resource.root_ancestor,
+                  model: MergeRequest).execute
+              end
+
+              subject(:record_count) do
+                graphql_data_at(resource_type.to_sym, :value_streams, :nodes, 0, :stages,
+                  0)['metrics']['count']['value']
+              end
+
+              it 'returns the correct count' do
+                perform_request
+
+                expect(record_count).to eq(3)
+              end
+
+              context 'when filtering for assignee' do
+                before do
+                  variables[:assigneeUsernames] = [assignee.username]
+                end
+
+                it 'returns the correct count' do
+                  perform_request
+
+                  expect(record_count).to eq(1)
+                end
+
+                context 'when assigneeUsernames is null' do
+                  before do
+                    variables[:assigneeUsernames] = nil
+                  end
+
+                  it 'returns the correct count' do
+                    perform_request
+
+                    expect(record_count).to eq(3)
+                  end
+                end
+              end
+
+              context 'when filtering for label' do
+                before do
+                  variables[:labelNames] = [filter_label.name]
+                end
+
+                it 'returns the correct count' do
+                  perform_request
+
+                  expect(record_count).to eq(1)
+                end
+              end
+
+              context 'when filtering for milestone title' do
+                before do
+                  variables[:milestoneTitle] = milestone.title
+                end
+
+                it 'returns the correct count' do
+                  perform_request
+
+                  expect(record_count).to eq(2)
+                end
+              end
+            end
           end
         end
       end
@@ -192,6 +311,7 @@ def perform_request
     let(:resource_type) { 'project' }
 
     let_it_be(:resource) { create(:project, group: create(:group)) }
+    let_it_be(:project) { resource }
     let_it_be(:namespace) { resource.project_namespace }
     let_it_be(:start_label) { create(:label, project: resource, title: 'Start Label') }
     let_it_be(:end_label) { create(:label, project: resource, title: 'End Label') }
@@ -216,6 +336,7 @@ def perform_request
     let(:resource_type) { 'group' }
 
     let_it_be(:resource) { create(:group) }
+    let_it_be(:project) { create(:project, group: resource) }
     let_it_be(:namespace) { resource }
     let_it_be(:start_label) { create(:group_label, group: resource, title: 'Start Label') }
     let_it_be(:end_label) { create(:group_label, group: resource, title: 'End Label') }
diff --git a/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb b/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb
index ca8b4a3a890f0..bcfe0b92b2837 100644
--- a/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb
+++ b/lib/gitlab/analytics/cycle_analytics/base_query_builder.rb
@@ -53,6 +53,7 @@ def build_finder_params(params)
 
             add_parent_model_params!(finder_params)
             add_time_range_params!(finder_params, params[:from], params[:to])
+            finder_params.merge!(params.slice(*::Gitlab::Analytics::CycleAnalytics::RequestParams::FINDER_PARAM_NAMES))
           end
         end
 
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index eee87ef782465..1f5ed5fbe7b56 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -15879,6 +15879,9 @@ msgstr[1] ""
 msgid "CycleAnalytics|'%{name}' is collecting the data. This can take a few minutes."
 msgstr ""
 
+msgid "CycleAnalytics|Average duration"
+msgstr ""
+
 msgid "CycleAnalytics|Average time to completion"
 msgstr ""
 
@@ -15903,9 +15906,15 @@ msgstr ""
 msgid "CycleAnalytics|If you have recently upgraded your GitLab license from a tier without this feature, it can take up to 30 minutes for data to collect and display."
 msgstr ""
 
+msgid "CycleAnalytics|Item count"
+msgstr ""
+
 msgid "CycleAnalytics|Lead time for changes"
 msgstr ""
 
+msgid "CycleAnalytics|Median duration"
+msgstr ""
+
 msgid "CycleAnalytics|New value stream…"
 msgstr ""
 
@@ -15959,6 +15968,9 @@ msgstr ""
 msgid "CycleAnalytics|project dropdown filter"
 msgstr ""
 
+msgid "CycleAnalytics|seconds"
+msgstr ""
+
 msgid "DAG visualization requires at least 3 dependent jobs."
 msgstr ""
 
diff --git a/spec/requests/api/graphql/project/value_streams_spec.rb b/spec/requests/api/graphql/project/value_streams_spec.rb
index 0805f386ed897..3da5838596e40 100644
--- a/spec/requests/api/graphql/project/value_streams_spec.rb
+++ b/spec/requests/api/graphql/project/value_streams_spec.rb
@@ -95,7 +95,7 @@ def stage_id(name)
     end
 
     before_all do
-      project.add_guest(user)
+      project.add_developer(user)
     end
 
     before do
@@ -156,6 +156,123 @@ def stage_id(name)
             expect(graphql_data_at(:project, :value_streams, :nodes, 0, :stages, :nodes)).to be_empty
           end
         end
+
+        context 'when requesting metrics' do
+          let_it_be(:current_time) { Time.current }
+          let_it_be(:author) { create(:user) }
+
+          let_it_be(:merge_request1) do
+            create(:merge_request, :unique_branches, source_project: project, created_at: '2024-02-01').tap do |mr|
+              mr.metrics.update!(latest_build_started_at: current_time,
+                latest_build_finished_at: current_time + 2.hours)
+            end
+          end
+
+          let_it_be(:merge_request2) do
+            create(:merge_request, :unique_branches, author: author, source_project: project,
+              created_at: '2024-02-01').tap do |mr|
+              mr.metrics.update!(latest_build_started_at: current_time,
+                latest_build_finished_at: current_time + 4.hours)
+            end
+          end
+
+          let_it_be(:merge_request3) do
+            create(:merge_request, :unique_branches, source_project: project, created_at: '2024-02-01').tap do |mr|
+              mr.metrics.update!(latest_build_started_at: current_time,
+                latest_build_finished_at: current_time + 5.hours)
+            end
+          end
+
+          let(:variables) do
+            {
+              fullPath: project.full_path,
+              stageId: stage_id,
+              from: '2024-01-01',
+              to: '2024-03-01'
+            }
+          end
+
+          let(:query) do
+            <<~QUERY
+              query($fullPath: ID!, $stageId: ID, $from: Date!, $to: Date!, $authorUsername: String) {
+                project(fullPath: $fullPath) {
+                  valueStreams {
+                    nodes {
+                      name
+                      stages(id: $stageId) {
+                        name
+
+                        metrics(timeframe: { start: $from, end: $to }, authorUsername: $authorUsername) {
+                          count {
+                            value
+                          }
+                          average {
+                            value
+                          }
+                          median {
+                            value
+                          }
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            QUERY
+          end
+
+          it 'returns aggregated metrics' do
+            metrics = graphql_data_at(:project, :value_streams, :nodes, 0, :stages, 0, :metrics)
+
+            expect(metrics).to eq({
+              'count' => {
+                'value' => 3
+              },
+              'average' => {
+                'value' => (2 + 4 + 5).hours.seconds.to_i / 3
+              },
+              'median' => {
+                'value' => 4.hours.seconds.to_i
+              }
+            })
+          end
+
+          context 'when user has no access' do
+            let(:user) { create(:user) }
+
+            it 'does not load metrics' do
+              expect(graphql_data_at(:project, :valueStreams)).to be_nil
+            end
+          end
+
+          context 'when filtering is applied' do
+            let(:variables) do
+              {
+                fullPath: project.full_path,
+                stageId: stage_id,
+                from: '2024-01-01',
+                to: '2024-03-01',
+                authorUsername: author.username
+              }
+            end
+
+            it 'returns the correct metrics' do
+              metrics = graphql_data_at(:project, :value_streams, :nodes, 0, :stages, 0, :metrics)
+
+              expect(metrics).to eq({
+                'count' => {
+                  'value' => 1
+                },
+                'average' => {
+                  'value' => 4.hours.seconds.to_i
+                },
+                'median' => {
+                  'value' => 4.hours.seconds.to_i
+                }
+              })
+            end
+          end
+        end
       end
     end
   end
-- 
GitLab