Commit ef0cffc8 authored by Nathan Friend's avatar Nathan Friend

Add project-level DORA metrics to GraphQL endpoint

This update exposes DORA 4 metrics through the GraphQL endpoint. These
metrics were previously only accessible through the REST API.

Changelog: added
EE: true
parent 740f98bb
# frozen_string_literal: true
module Types
class DeploymentTierEnum < BaseEnum
graphql_name 'DeploymentTier'
description 'All environment deployment tiers.'
value 'PRODUCTION', description: 'Production.', value: :production
value 'STAGING', description: 'Staging.', value: :staging
value 'TESTING', description: 'Testing.', value: :testing
value 'DEVELOPMENT', description: 'Development.', value: :development
value 'OTHER', description: 'Other.', value: :other
end
end
......@@ -8563,6 +8563,37 @@ Aggregated summary of changes.
| <a id="discussionresolvedat"></a>`resolvedAt` | [`Time`](#time) | Timestamp of when the object was resolved. |
| <a id="discussionresolvedby"></a>`resolvedBy` | [`UserCore`](#usercore) | User who resolved the object. |
### `Dora`
A container for all information related to DORA metrics.
#### Fields with arguments
##### `Dora.metrics`
DORA metrics for the current group or project.
Returns [`[DoraMetric!]`](#dorametric).
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="dorametricsenddate"></a>`endDate` | [`Date`](#date) | Date range to end at. Default is the current date. |
| <a id="dorametricsenvironmenttier"></a>`environmentTier` | [`DeploymentTier`](#deploymenttier) | The deployment tier of the environments to return. Defaults to `PRODUCTION`. |
| <a id="dorametricsinterval"></a>`interval` | [`DoraMetricBucketingInterval`](#dorametricbucketinginterval) | How the metric should be aggregrated. Defaults to `DAILY`. In the case of ALL, the `date` field in the response will be `null`. |
| <a id="dorametricsmetric"></a>`metric` | [`DoraMetricType!`](#dorametrictype) | The type of metric to return. |
| <a id="dorametricsstartdate"></a>`startDate` | [`Date`](#date) | Date range to start from. Default is 3 months ago. |
### `DoraMetric`
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="dorametricdate"></a>`date` | [`String`](#string) | Date of the data point. |
| <a id="dorametricvalue"></a>`value` | [`Int`](#int) | Value of the data point. |
### `Environment`
Describes where code is deployed for a project.
......@@ -11308,6 +11339,7 @@ Represents vulnerability finding of a security report on the pipeline.
| <a id="projectdastsiteprofiles"></a>`dastSiteProfiles` | [`DastSiteProfileConnection`](#dastsiteprofileconnection) | DAST Site Profiles associated with the project. (see [Connections](#connections)) |
| <a id="projectdescription"></a>`description` | [`String`](#string) | Short description of the project. |
| <a id="projectdescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `description`. |
| <a id="projectdora"></a>`dora` | [`Dora`](#dora) | The project's DORA metrics. |
| <a id="projectforkscount"></a>`forksCount` | [`Int!`](#int) | Number of times the project has been forked. |
| <a id="projectfullpath"></a>`fullPath` | [`ID!`](#id) | Full path of the project. |
| <a id="projectgrafanaintegration"></a>`grafanaIntegration` | [`GrafanaIntegration`](#grafanaintegration) | Grafana integration details for the project. |
......@@ -14328,6 +14360,18 @@ Weight of the data visualization palette.
| <a id="datavisualizationweightenumweight_900"></a>`WEIGHT_900` | 900 weight. |
| <a id="datavisualizationweightenumweight_950"></a>`WEIGHT_950` | 950 weight. |
### `DeploymentTier`
All environment deployment tiers.
| Value | Description |
| ----- | ----------- |
| <a id="deploymenttierdevelopment"></a>`DEVELOPMENT` | Development. |
| <a id="deploymenttierother"></a>`OTHER` | Other. |
| <a id="deploymenttierproduction"></a>`PRODUCTION` | Production. |
| <a id="deploymenttierstaging"></a>`STAGING` | Staging. |
| <a id="deploymenttiertesting"></a>`TESTING` | Testing. |
### `DesignCollectionCopyState`
Copy state of a DesignCollection.
......@@ -14358,6 +14402,25 @@ Type of file the position refers to.
| <a id="diffpositiontypeimage"></a>`image` | An image. |
| <a id="diffpositiontypetext"></a>`text` | A text file. |
### `DoraMetricBucketingInterval`
All possible ways that DORA metrics can be aggregated.
| Value | Description |
| ----- | ----------- |
| <a id="dorametricbucketingintervalall"></a>`ALL` | All data points are combined into a single value. |
| <a id="dorametricbucketingintervaldaily"></a>`DAILY` | Data points are combined into chunks by day. |
| <a id="dorametricbucketingintervalmonthly"></a>`MONTHLY` | Data points are combined into chunks by month. |
### `DoraMetricType`
All supported DORA metric types.
| Value | Description |
| ----- | ----------- |
| <a id="dorametrictypedeployment_frequency"></a>`DEPLOYMENT_FREQUENCY` | Deployment frequency. |
| <a id="dorametrictypelead_time_for_changes"></a>`LEAD_TIME_FOR_CHANGES` | Lead time for changes. |
### `EntryType`
Type of a tree entry.
......
......@@ -181,6 +181,12 @@ module EE
null: true,
description: 'Network Policies of the project',
resolver: ::Resolvers::NetworkPolicyResolver
field :dora,
::Types::DoraType,
null: true,
method: :itself,
description: "The project's DORA metrics."
end
def api_fuzzing_ci_configuration
......
# frozen_string_literal: true
module Resolvers
class DoraMetricsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
authorizes_object!
authorize :read_dora4_analytics
type [::Types::DoraMetricType], null: true
alias_method :container, :object
argument :metric, Types::DoraMetricTypeEnum,
required: true,
description: 'The type of metric to return.'
argument :start_date, Types::DateType,
required: false,
description: 'Date range to start from. Default is 3 months ago.'
argument :end_date, Types::DateType,
required: false,
description: 'Date range to end at. Default is the current date.'
argument :interval, Types::DoraMetricBucketingIntervalEnum,
required: false,
description: 'How the metric should be aggregrated. Defaults to `DAILY`. In the case of ALL, the `date` field in the response will be `null`.'
argument :environment_tier, Types::DeploymentTierEnum,
required: false,
description: 'The deployment tier of the environments to return. Defaults to `PRODUCTION`.'
def resolve(params)
result = ::Dora::AggregateMetricsService
.new(container: container, current_user: current_user, params: params)
.execute
raise result[:message] unless result[:status] == :success
data = result[:data]
if data.is_a? Integer
# When interval=ALL, the service above returns a single Integer
# instead of an array of hashes, like it does otherwise.
# To keep the return value of this resolver consistent, we wrap
# it in the structure we expect.
#
# This can be removed if/when we update the service to always
# return a consistent shape:
# https://gitlab.com/gitlab-org/gitlab/-/issues/334821
[{ 'date' => nil, 'value' => data }]
else
data
end
end
end
end
# frozen_string_literal: true
module Types
class DoraMetricBucketingIntervalEnum < BaseEnum
graphql_name 'DoraMetricBucketingInterval'
description 'All possible ways that DORA metrics can be aggregated.'
value 'ALL', description: 'All data points are combined into a single value.', value: 'all'
value 'MONTHLY', description: 'Data points are combined into chunks by month.', value: 'monthly'
value 'DAILY', description: 'Data points are combined into chunks by day.', value: 'daily'
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class DoraMetricType < BaseObject
graphql_name 'DoraMetric'
field :date, GraphQL::STRING_TYPE, null: true,
description: 'Date of the data point.'
field :value, GraphQL::INT_TYPE, null: true,
description: 'Value of the data point.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
# frozen_string_literal: true
module Types
class DoraMetricTypeEnum < BaseEnum
graphql_name 'DoraMetricType'
description 'All supported DORA metric types.'
value 'DEPLOYMENT_FREQUENCY', description: 'Deployment frequency.', value: 'deployment_frequency'
value 'LEAD_TIME_FOR_CHANGES', description: 'Lead time for changes.', value: 'lead_time_for_changes'
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class DoraType < BaseObject
graphql_name 'Dora'
description 'A container for all information related to DORA metrics.'
field :metrics, [::Types::DoraMetricType], null: true,
resolver: ::Resolvers::DoraMetricsResolver,
description: 'DORA metrics for the current group or project.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::DoraMetricsResolver do
include GraphqlHelpers
let_it_be(:guest) { create(:user) }
let_it_be(:reporter) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:production) { create(:environment, :production, project: project) }
let_it_be(:staging) { create(:environment, :staging, project: project) }
let(:current_user) { reporter }
let(:args) { { metric: 'deployment_frequency' } }
around do |example|
travel_to '2021-05-01'.to_time do
example.run
end
end
before_all do
project.add_guest(guest)
project.add_reporter(reporter)
create(:dora_daily_metrics, deployment_frequency: 20, lead_time_for_changes_in_seconds: nil, environment: production, date: '2020-01-01')
create(:dora_daily_metrics, deployment_frequency: 19, lead_time_for_changes_in_seconds: nil, environment: production, date: '2021-01-01')
create(:dora_daily_metrics, deployment_frequency: 18, lead_time_for_changes_in_seconds: nil, environment: production, date: '2021-03-01')
create(:dora_daily_metrics, deployment_frequency: 17, lead_time_for_changes_in_seconds: 99, environment: production, date: '2021-04-01')
create(:dora_daily_metrics, deployment_frequency: 16, lead_time_for_changes_in_seconds: 98, environment: production, date: '2021-04-02')
create(:dora_daily_metrics, deployment_frequency: 15, lead_time_for_changes_in_seconds: 97, environment: production, date: '2021-04-03')
create(:dora_daily_metrics, deployment_frequency: 14, lead_time_for_changes_in_seconds: nil, environment: production, date: '2021-04-04')
create(:dora_daily_metrics, deployment_frequency: 13, lead_time_for_changes_in_seconds: nil, environment: production, date: '2021-04-05')
create(:dora_daily_metrics, deployment_frequency: 12, lead_time_for_changes_in_seconds: nil, environment: production, date: '2021-04-06')
create(:dora_daily_metrics, deployment_frequency: nil, lead_time_for_changes_in_seconds: nil, environment: production, date: '2021-04-07')
create(:dora_daily_metrics, deployment_frequency: 11, lead_time_for_changes_in_seconds: nil, environment: production, date: '2021-05-06')
create(:dora_daily_metrics, deployment_frequency: 10, lead_time_for_changes_in_seconds: nil, environment: staging, date: '2021-04-01')
create(:dora_daily_metrics, deployment_frequency: nil, lead_time_for_changes_in_seconds: 99, environment: staging, date: '2021-04-02')
end
before do
stub_licensed_features(dora4_analytics: true)
end
describe '#resolve' do
context 'when the current users does not have access to query DORA metrics' do
let(:current_user) { guest }
it 'returns no metrics' do
expect(resolve_metrics).to be_nil
end
end
context 'when DORA metrics are not licensed' do
before do
stub_licensed_features(dora4_analytics: false)
end
it 'returns no metrics' do
expect(resolve_metrics).to be_nil
end
end
end
context 'with metric: "deployment_frequency"' do
let(:args) { { metric: 'deployment_frequency' } }
it 'returns metrics from production for the last 3 months from the production environment, grouped by day' do
expect(resolve_metrics).to eq([
{ 'date' => '2021-03-01', 'value' => 18 },
{ 'date' => '2021-04-01', 'value' => 17 },
{ 'date' => '2021-04-02', 'value' => 16 },
{ 'date' => '2021-04-03', 'value' => 15 },
{ 'date' => '2021-04-04', 'value' => 14 },
{ 'date' => '2021-04-05', 'value' => 13 },
{ 'date' => '2021-04-06', 'value' => 12 },
{ 'date' => '2021-04-07', 'value' => nil }
])
end
end
context 'with interval: "daily"' do
let(:args) { { metric: 'deployment_frequency', interval: 'daily' } }
it 'returns the metrics grouped by day (the default)' do
expect(resolve_metrics).to eq([
{ 'date' => '2021-03-01', 'value' => 18 },
{ 'date' => '2021-04-01', 'value' => 17 },
{ 'date' => '2021-04-02', 'value' => 16 },
{ 'date' => '2021-04-03', 'value' => 15 },
{ 'date' => '2021-04-04', 'value' => 14 },
{ 'date' => '2021-04-05', 'value' => 13 },
{ 'date' => '2021-04-06', 'value' => 12 },
{ 'date' => '2021-04-07', 'value' => nil }
])
end
end
context 'with interval: "monthly"' do
let(:args) { { metric: 'deployment_frequency', interval: 'monthly' } }
it 'returns the metrics grouped by month' do
expect(resolve_metrics).to eq([
{ 'date' => '2021-03-01', 'value' => 18 },
{ 'date' => '2021-04-01', 'value' => 87 }
])
end
end
context 'with interval: "all"' do
let(:args) { { metric: 'deployment_frequency', interval: 'all' } }
it 'returns the metrics grouped into a single bucket with a nil date' do
expect(resolve_metrics).to eq([
{ 'date' => nil, 'value' => 105 }
])
end
end
context 'with a start_date' do
let(:args) { { metric: 'deployment_frequency', start_date: '2021-04-03'.to_datetime } }
it 'returns metrics for data on or after the provided date' do
expect(resolve_metrics).to eq([
{ 'date' => '2021-04-03', 'value' => 15 },
{ 'date' => '2021-04-04', 'value' => 14 },
{ 'date' => '2021-04-05', 'value' => 13 },
{ 'date' => '2021-04-06', 'value' => 12 },
{ 'date' => '2021-04-07', 'value' => nil }
])
end
end
context 'with an end_date' do
let(:args) { { metric: 'deployment_frequency', end_date: '2021-04-03'.to_datetime } }
it 'returns metrics for data on or before the provided date' do
expect(resolve_metrics).to eq([
{ 'date' => '2021-03-01', 'value' => 18 },
{ 'date' => '2021-04-01', 'value' => 17 },
{ 'date' => '2021-04-02', 'value' => 16 },
{ 'date' => '2021-04-03', 'value' => 15 }
])
end
end
context 'with both a start_date and an end_date' do
let(:args) { { metric: 'deployment_frequency', start_date: '2021-04-01'.to_datetime, end_date: '2021-04-03'.to_datetime } }
it 'returns metrics between the provided dates (inclusive)' do
expect(resolve_metrics).to eq([
{ 'date' => '2021-04-01', 'value' => 17 },
{ 'date' => '2021-04-02', 'value' => 16 },
{ 'date' => '2021-04-03', 'value' => 15 }
])
end
end
context 'when the requested date range is too large' do
let(:args) { { metric: 'deployment_frequency', start_date: '2020-01-01'.to_datetime, end_date: '2021-05-01'.to_datetime } }
it 'raises an error' do
expect { resolve_metrics }.to raise_error('Date range must be shorter than 92 days.')
end
end
context 'when the start date equal to or later than the end date' do
let(:args) { { metric: 'deployment_frequency', start_date: '2021-04-01'.to_datetime, end_date: '2021-03-01'.to_datetime } }
it 'raises an error' do
expect { resolve_metrics }.to raise_error('The start date must be ealier than the end date.')
end
end
context 'with no metric parameter' do
let(:args) { {} }
it 'raises an error' do
expect { resolve_metrics }.to raise_error(/wrong number of arguments/)
end
end
context 'with metric: "lead_time_for_changes"' do
let(:args) { { metric: 'lead_time_for_changes' } }
it 'returns lead time metrics' do
expect(resolve_metrics).to eq([
{ 'date' => '2021-03-01', 'value' => nil },
{ 'date' => '2021-04-01', 'value' => 99 },
{ 'date' => '2021-04-02', 'value' => 98 },
{ 'date' => '2021-04-03', 'value' => 97 },
{ 'date' => '2021-04-04', 'value' => nil },
{ 'date' => '2021-04-05', 'value' => nil },
{ 'date' => '2021-04-06', 'value' => nil },
{ 'date' => '2021-04-07', 'value' => nil }
])
end
end
context 'with environment_tier: "staging"' do
let(:args) { { metric: 'deployment_frequency', environment_tier: 'staging' } }
it 'returns metrics for the staging environment' do
expect(resolve_metrics).to eq([
{ 'date' => '2021-04-01', 'value' => 10 },
{ 'date' => '2021-04-02', 'value' => nil }
])
end
end
private
def resolve_metrics
context = { current_user: current_user }
resolve(described_class, obj: project, args: args, ctx: context)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::DoraMetricBucketingIntervalEnum do
it 'includes a value for each DORA bucketing interval type' do
expect(described_class.values).to match(
'ALL' => have_attributes(value: 'all'),
'MONTHLY' => have_attributes(value: 'monthly'),
'DAILY' => have_attributes(value: 'daily')
)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::DoraMetricTypeEnum do
it 'includes a value for each DORA metric type' do
expect(described_class.values).to match(
'DEPLOYMENT_FREQUENCY' => have_attributes(value: 'deployment_frequency'),
'LEAD_TIME_FOR_CHANGES' => have_attributes(value: 'lead_time_for_changes')
)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::DoraMetricType do
it 'has the expected fields' do
expect(described_class).to have_graphql_fields(:date, :value)
end
describe 'date field' do
subject { described_class.fields['date'] }
it { is_expected.to have_graphql_type(GraphQL::STRING_TYPE) }
end
describe 'value field' do
subject { described_class.fields['value'] }
it { is_expected.to have_graphql_type(GraphQL::INT_TYPE) }
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::DoraType do
it 'has the expected fields' do
expect(described_class).to have_graphql_fields(:metrics)
end
describe 'metrics field' do
subject { described_class.fields['metrics'] }
it { is_expected.to have_graphql_resolver(Resolvers::DoraMetricsResolver) }
end
end
......@@ -309,6 +309,12 @@ RSpec.describe GitlabSchema.types['Project'] do
end
end
describe 'dora field' do
subject { described_class.fields['dora'] }
it { is_expected.to have_graphql_type(Types::DoraType) }
end
private
def query_for_project(project)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Query.project(fullPath).dora.metrics' do
include GraphqlHelpers
let_it_be(:reporter) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:production) { create(:environment, :production, project: project) }
let(:query) do
graphql_query_for(:project, { fullPath: project.full_path },
<<~QUERY
dora {
metrics(metric: DEPLOYMENT_FREQUENCY) {
date
value
}
}
QUERY
)
end
let(:post_query) { post_graphql(query, current_user: reporter) }
let(:path_prefix) { %w[project dora metrics] }
let(:data) { graphql_data.dig(*path_prefix) }
around do |example|
travel_to '2021-01-08'.to_time do
example.run
end
end
before_all do
project.add_reporter(reporter)
create(:dora_daily_metrics, deployment_frequency: 3, environment: production, date: '2021-01-01')
create(:dora_daily_metrics, deployment_frequency: 3, environment: production, date: '2021-01-02')
create(:dora_daily_metrics, deployment_frequency: 2, environment: production, date: '2021-01-03')
create(:dora_daily_metrics, deployment_frequency: 2, environment: production, date: '2021-01-04')
create(:dora_daily_metrics, deployment_frequency: 1, environment: production, date: '2021-01-05')
create(:dora_daily_metrics, deployment_frequency: 1, environment: production, date: '2021-01-06')
create(:dora_daily_metrics, deployment_frequency: nil, environment: production, date: '2021-01-07')
end
before do
stub_licensed_features(dora4_analytics: true)
end
it 'returns the expected DORA metrics' do
post_query
expect(data).to eq(
[
{ 'value' => 3, 'date' => '2021-01-01' },
{ 'value' => 3, 'date' => '2021-01-02' },
{ 'value' => 2, 'date' => '2021-01-03' },
{ 'value' => 2, 'date' => '2021-01-04' },
{ 'value' => 1, 'date' => '2021-01-05' },
{ 'value' => 1, 'date' => '2021-01-06' },
{ 'value' => nil, 'date' => '2021-01-07' }
]
)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::DeploymentTierEnum do
it 'includes a value for each supported environment tier' do
expect(described_class.values).to match(
'PRODUCTION' => have_attributes(value: :production),
'STAGING' => have_attributes(value: :staging),
'TESTING' => have_attributes(value: :testing),
'DEVELOPMENT' => have_attributes(value: :development),
'OTHER' => have_attributes(value: :other)
)
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