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,26 +5,15 @@ module API ...@@ -5,26 +5,15 @@ module API
class Metrics < ::API::Base class Metrics < ::API::Base
feature_category :continuous_delivery feature_category :continuous_delivery
params do helpers do
requires :id, type: String, desc: 'The ID of the project' params :dora_metrics_params do
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
namespace ':id/dora/metrics' do
desc 'Fetch the project-level DORA metrics'
params do
requires :metric, type: String, desc: 'The metric type.' requires :metric, type: String, desc: 'The metric type.'
optional :start_date, type: Date, desc: 'Date range to start from.' optional :start_date, type: Date, desc: 'Date range to start from.'
optional :end_date, type: Date, desc: 'Date range to end at.' optional :end_date, type: Date, desc: 'Date range to end at.'
optional :interval, type: String, desc: "The bucketing interval." optional :interval, type: String, desc: "The bucketing interval."
optional :environment_tier, type: String, desc: "The tier of the environment." optional :environment_tier, type: String, desc: "The tier of the environment."
end end
get do
fetch!(user_project)
end
end
end
helpers do
def fetch!(container) def fetch!(container)
not_found! unless ::Feature.enabled?(:dora_daily_metrics, container, default_enabled: :yaml) not_found! unless ::Feature.enabled?(:dora_daily_metrics, container, default_enabled: :yaml)
...@@ -39,6 +28,36 @@ module API ...@@ -39,6 +28,36 @@ module API
end end
end end
end end
params do
requires :id, type: String, desc: 'The ID of the project'
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
namespace ':id/dora/metrics' do
desc 'Fetch the project-level DORA metrics'
params do
use :dora_metrics_params
end
get do
fetch!(user_project)
end
end
end
params do
requires :id, type: String, desc: 'The ID of the group'
end
resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
namespace ':id/dora/metrics' do
desc 'Fetch the group-level DORA metrics'
params do
use :dora_metrics_params
end
get do
fetch!(user_group)
end
end
end
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
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 end
context 'when start date is later than end date' do context 'when container is a group' do
let(:extra_params) { { end_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) { 'The start date must be ealier than the end date.' } 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 }
let(:user) { maintainer }
let(:params) { { metric: 'deployment_frequency' }.merge(extra_params) }
let(:extra_params) { {} }
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
let(:extra_params) { { metric: 'unknown' } }
it_behaves_like 'request failure' do before do
let(:message) { "The metric must be one of #{::Dora::DailyMetrics::AVAILABLE_METRICS.join(',')}." } stub_licensed_features(dora4_analytics: true)
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