Commit 45fb12bc authored by Shinya Maeda's avatar Shinya Maeda Committed by Mayra Cabrera

DORA metrics API

This commit adds the DORA metrics APIs to
fetch deployment frequency and lead time for changes.
parent e705da85
...@@ -31,8 +31,8 @@ module Dora ...@@ -31,8 +31,8 @@ module Dora
return error(_('The start date must be ealier than the end date.'), :bad_request) return error(_('The start date must be ealier than the end date.'), :bad_request)
end end
unless project? unless project? || group?
return error(_('Container must be a project.'), :bad_request) return error(_('Container must be a project or a group.'), :bad_request)
end end
unless ::Dora::DailyMetrics::AVAILABLE_INTERVALS.include?(interval) unless ::Dora::DailyMetrics::AVAILABLE_INTERVALS.include?(interval)
...@@ -58,13 +58,32 @@ module Dora ...@@ -58,13 +58,32 @@ module Dora
end end
def environments def environments
Environment.for_project(container).for_tier(environment_tier) Environment.for_project(target_projects).for_tier(environment_tier)
end
def target_projects
if project?
[container]
elsif group?
# The actor definitely has read permission in all subsequent projects of the group by the following reasons:
# - DORA metrics can be read by reporter (or above) at project-level.
# - With `read_dora4_analytics` permission check, we make sure that the
# user is at-least reporter role at group-level.
# - In the subsequent projects, the assigned role at the group-level
# can't be lowered. For example, if the user is reporter at group-level,
# the user can be developer in subsequent projects, but can't be guest.
container.all_projects
end
end end
def project? def project?
container.is_a?(Project) container.is_a?(Project)
end end
def group?
container.is_a?(Group)
end
def start_date def start_date
params[:start_date] || 3.months.ago.to_date params[:start_date] || 3.months.ago.to_date
end end
......
...@@ -5,6 +5,30 @@ module API ...@@ -5,6 +5,30 @@ module API
class Metrics < ::API::Base class Metrics < ::API::Base
feature_category :continuous_delivery feature_category :continuous_delivery
helpers do
params :dora_metrics_params do
requires :metric, type: String, desc: 'The metric type.'
optional :start_date, type: Date, desc: 'Date range to start from.'
optional :end_date, type: Date, desc: 'Date range to end at.'
optional :interval, type: String, desc: "The bucketing interval."
optional :environment_tier, type: String, desc: "The tier of the environment."
end
def fetch!(container)
not_found! unless ::Feature.enabled?(:dora_daily_metrics, container, default_enabled: :yaml)
result = ::Dora::AggregateMetricsService
.new(container: container, current_user: current_user, params: declared_params(include_missing: false))
.execute
if result[:status] == :success
present result[:data]
else
render_api_error!(result[:message], result[:http_status])
end
end
end
params do params do
requires :id, type: String, desc: 'The ID of the project' requires :id, type: String, desc: 'The ID of the project'
end end
...@@ -12,11 +36,7 @@ module API ...@@ -12,11 +36,7 @@ module API
namespace ':id/dora/metrics' do namespace ':id/dora/metrics' do
desc 'Fetch the project-level DORA metrics' desc 'Fetch the project-level DORA metrics'
params do params do
requires :metric, type: String, desc: 'The metric type.' use :dora_metrics_params
optional :start_date, type: Date, desc: 'Date range to start from.'
optional :end_date, type: Date, desc: 'Date range to end at.'
optional :interval, type: String, desc: "The bucketing interval."
optional :environment_tier, type: String, desc: "The tier of the environment."
end end
get do get do
fetch!(user_project) fetch!(user_project)
...@@ -24,18 +44,17 @@ module API ...@@ -24,18 +44,17 @@ module API
end end
end end
helpers do params do
def fetch!(container) requires :id, type: String, desc: 'The ID of the group'
not_found! unless ::Feature.enabled?(:dora_daily_metrics, container, default_enabled: :yaml) end
resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
result = ::Dora::AggregateMetricsService namespace ':id/dora/metrics' do
.new(container: container, current_user: current_user, params: declared_params(include_missing: false)) desc 'Fetch the group-level DORA metrics'
.execute params do
use :dora_metrics_params
if result[:status] == :success end
present result[:data] get do
else fetch!(user_group)
render_api_error!(result[:message], result[:http_status])
end end
end end
end end
......
...@@ -62,4 +62,65 @@ RSpec.describe API::Dora::Metrics do ...@@ -62,4 +62,65 @@ RSpec.describe API::Dora::Metrics do
end end
end end
end end
describe 'GET /groups/:id/dora/metrics' do
subject { get api(url, user), params: params }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:production) { create(:environment, :production, project: project) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:guest) { create(:user) }
let(:url) { "/groups/#{group.id}/dora/metrics" }
let(:params) { { metric: :deployment_frequency } }
let(:user) { maintainer }
around do |example|
freeze_time do
example.run
end
end
before_all do
group.add_maintainer(maintainer)
group.add_guest(guest)
create(:dora_daily_metrics, deployment_frequency: 1, environment: production, date: 1.day.ago.to_date)
create(:dora_daily_metrics, deployment_frequency: 2, environment: production, date: Time.current.to_date)
end
before do
stub_licensed_features(dora4_analytics: true)
end
it 'returns data' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq([{ 1.day.ago.to_date.to_s => 1 },
{ Time.current.to_date.to_s => 2 }])
end
context 'when user is guest' do
let(:user) { guest }
it 'returns authorization error' do
subject
expect(response).to have_gitlab_http_status(:unauthorized)
expect(json_response['message']).to eq('You do not have permission to access dora metrics.')
end
end
context 'when dora_daily_metrics feature flag is disabled' do
before do
stub_feature_flags(dora_daily_metrics: false)
end
it 'returns not found' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end end
...@@ -22,6 +22,71 @@ RSpec.describe Dora::AggregateMetricsService do ...@@ -22,6 +22,71 @@ RSpec.describe Dora::AggregateMetricsService do
end end
end end
shared_examples_for 'correct validations' do
context 'when data range is too wide' do
let(:extra_params) { { start_date: 1.year.ago.to_date } }
it_behaves_like 'request failure' do
let(:message) { "Date range must be shorter than #{described_class::MAX_RANGE} days." }
let(:http_status) { :bad_request }
end
end
context 'when start date is later than end date' do
let(:extra_params) { { end_date: 1.year.ago.to_date } }
it_behaves_like 'request failure' do
let(:message) { 'The start date must be ealier than the end date.' }
let(:http_status) { :bad_request }
end
end
context 'when interval is invalid' do
let(:extra_params) { { interval: 'unknown' } }
it_behaves_like 'request failure' do
let(:message) { "The interval must be one of #{::Dora::DailyMetrics::AVAILABLE_INTERVALS.join(',')}." }
let(:http_status) { :bad_request }
end
end
context 'when metric is invalid' do
let(:extra_params) { { metric: 'unknown' } }
it_behaves_like 'request failure' do
let(:message) { "The metric must be one of #{::Dora::DailyMetrics::AVAILABLE_METRICS.join(',')}." }
let(:http_status) { :bad_request }
end
end
context 'when params is empty' do
let(:params) { {} }
it_behaves_like 'request failure' do
let(:message) { "The metric must be one of #{::Dora::DailyMetrics::AVAILABLE_METRICS.join(',')}." }
let(:http_status) { :bad_request }
end
end
context 'when environment tier is invalid' do
let(:extra_params) { { environment_tier: 'unknown' } }
it_behaves_like 'request failure' do
let(:message) { "The environment tier must be one of #{Environment.tiers.keys.join(',')}." }
let(:http_status) { :bad_request }
end
end
context 'when guest user' do
let(:user) { guest }
it_behaves_like 'request failure' do
let(:message) { 'You do not have permission to access dora metrics.' }
let(:http_status) { :unauthorized }
end
end
end
context 'when container is project' do context 'when container is project' do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:production) { create(:environment, :production, project: project) } let_it_be(:production) { create(:environment, :production, project: project) }
...@@ -45,6 +110,8 @@ RSpec.describe Dora::AggregateMetricsService do ...@@ -45,6 +110,8 @@ RSpec.describe Dora::AggregateMetricsService do
stub_licensed_features(dora4_analytics: true) stub_licensed_features(dora4_analytics: true)
end end
it_behaves_like 'correct validations'
it 'returns the aggregated data' do it 'returns the aggregated data' do
expect(subject[:status]).to eq(:success) expect(subject[:status]).to eq(:success)
expect(subject[:data]).to eq([{ Time.current.to_date.to_s => 2 }]) expect(subject[:data]).to eq([{ Time.current.to_date.to_s => 2 }])
...@@ -76,81 +143,66 @@ RSpec.describe Dora::AggregateMetricsService do ...@@ -76,81 +143,66 @@ RSpec.describe Dora::AggregateMetricsService do
expect(subject[:data]).to eq([{ Time.current.to_date.to_s => 1 }]) expect(subject[:data]).to eq([{ Time.current.to_date.to_s => 1 }])
end end
end end
end
context 'when data range is too wide' do context 'when container is a group' do
let(:extra_params) { { start_date: 1.year.ago.to_date } } let_it_be(:group) { create(:group) }
let_it_be(:project_1) { create(:project, group: group) }
it_behaves_like 'request failure' do let_it_be(:project_2) { create(:project, group: group) }
let(:message) { "Date range must be shorter than #{described_class::MAX_RANGE} days." } let_it_be(:production_1) { create(:environment, :production, project: project_1) }
let(:http_status) { :bad_request } let_it_be(:production_2) { create(:environment, :production, project: project_2) }
end let_it_be(:maintainer) { create(:user) }
end let_it_be(:guest) { create(:user) }
let(:container) { group }
context 'when start date is later than end date' do let(:user) { maintainer }
let(:extra_params) { { end_date: 1.year.ago.to_date } } let(:params) { { metric: 'deployment_frequency' }.merge(extra_params) }
let(:extra_params) { {} }
it_behaves_like 'request failure' do
let(:message) { 'The start date must be ealier than the end date.' }
let(:http_status) { :bad_request }
end
end
context 'when interval is invalid' do before_all do
let(:extra_params) { { interval: 'unknown' } } group.add_maintainer(maintainer)
group.add_guest(guest)
it_behaves_like 'request failure' do create(:dora_daily_metrics, deployment_frequency: 2, environment: production_1)
let(:message) { "The interval must be one of #{::Dora::DailyMetrics::AVAILABLE_INTERVALS.join(',')}." } create(:dora_daily_metrics, deployment_frequency: 1, environment: production_2)
let(:http_status) { :bad_request }
end
end end
context 'when metric is invalid' do before do
let(:extra_params) { { metric: 'unknown' } } stub_licensed_features(dora4_analytics: true)
it_behaves_like 'request failure' do
let(:message) { "The metric must be one of #{::Dora::DailyMetrics::AVAILABLE_METRICS.join(',')}." }
let(:http_status) { :bad_request }
end
end end
context 'when params is empty' do it_behaves_like 'correct validations'
let(:params) { {} }
it_behaves_like 'request failure' do it 'returns the aggregated data' do
let(:message) { "The metric must be one of #{::Dora::DailyMetrics::AVAILABLE_METRICS.join(',')}." } expect(subject[:status]).to eq(:success)
let(:http_status) { :bad_request } expect(subject[:data]).to eq([{ Time.current.to_date.to_s => 3 }])
end
end end
context 'when environment tier is invalid' do context 'when interval is monthly' do
let(:extra_params) { { environment_tier: 'unknown' } } let(:extra_params) { { interval: Dora::DailyMetrics::INTERVAL_MONTHLY } }
it_behaves_like 'request failure' do it 'returns the aggregated data' do
let(:message) { "The environment tier must be one of #{Environment.tiers.keys.join(',')}." } expect(subject[:status]).to eq(:success)
let(:http_status) { :bad_request } expect(subject[:data]).to eq([{ Time.current.beginning_of_month.to_date.to_s => 3 }])
end end
end end
context 'when guest user' do context 'when interval is all' do
let(:user) { guest } let(:extra_params) { { interval: Dora::DailyMetrics::INTERVAL_ALL } }
it_behaves_like 'request failure' do it 'returns the aggregated data' do
let(:message) { 'You do not have permission to access dora metrics.' } expect(subject[:status]).to eq(:success)
let(:http_status) { :unauthorized } expect(subject[:data]).to eq(3)
end end
end end
end end
context 'when container is group' do context 'when container is nil' do
let_it_be(:group) { create(:group) } let(:container) { nil }
let_it_be(:maintainer) { create(:user) } let(:user) { nil }
let_it_be(:guest) { create(:user) } let(:params) { {} }
let(:container) { group }
let(:user) { maintainer }
let(:params) { { metric: 'deployment_frequency' } }
it_behaves_like 'request failure' do it_behaves_like 'request failure' do
let(:message) { 'Container must be a project.' } let(:message) { 'Container must be a project or a group.' }
let(:http_status) { :bad_request } let(:http_status) { :bad_request }
end end
end end
......
...@@ -8021,7 +8021,7 @@ msgstr "" ...@@ -8021,7 +8021,7 @@ msgstr ""
msgid "Container does not exist" msgid "Container does not exist"
msgstr "" msgstr ""
msgid "Container must be a project." msgid "Container must be a project or a group."
msgstr "" msgstr ""
msgid "Container registry images" msgid "Container registry images"
......
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