Commit 27aa2c6c authored by Michael Kozono's avatar Michael Kozono

Merge branch 'api-support-for-deployment-frequency' into 'master'

Adds API support for Project Deployment Frequency

See merge request gitlab-org/gitlab!48265
parents cd3242a8 be88cb17
......@@ -45,6 +45,12 @@ class Deployment < ApplicationRecord
scope :older_than, -> (deployment) { where('deployments.id < ?', deployment.id) }
scope :with_deployable, -> { joins('INNER JOIN ci_builds ON ci_builds.id = deployments.deployable_id').preload(:deployable) }
scope :finished_between, -> (start_date, end_date = nil) do
selected = where('deployments.finished_at >= ?', start_date)
selected = selected.where('deployments.finished_at < ?', end_date) if end_date
selected
end
FINISHED_STATUSES = %i[success failed canceled].freeze
state_machine :status, initial: :created do
......
---
title: Add database index on deployments
merge_request: 48265
author:
type: added
# frozen_string_literal: true
class AddDeploymentsFinderByFinishedAtIndex < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = "index_deployments_on_project_and_finished"
disable_ddl_transaction!
def up
add_concurrent_index :deployments,
[:project_id, :finished_at],
where: 'status = 2',
name: INDEX_NAME
end
def down
remove_concurrent_index :deployments,
[:project_id, :finished_at],
where: 'status = 2',
name: INDEX_NAME
end
end
a4d82ca9610a1426bb026c43a00791bcdae38d49ed3ca59285d5a752124a7f20
\ No newline at end of file
......@@ -21232,6 +21232,8 @@ CREATE INDEX index_deployments_on_id_and_status_and_created_at ON deployments US
CREATE INDEX index_deployments_on_id_where_cluster_id_present ON deployments USING btree (id) WHERE (cluster_id IS NOT NULL);
CREATE INDEX index_deployments_on_project_and_finished ON deployments USING btree (project_id, finished_at) WHERE (status = 2);
CREATE INDEX index_deployments_on_project_id_and_id ON deployments USING btree (project_id, id DESC);
CREATE UNIQUE INDEX index_deployments_on_project_id_and_iid ON deployments USING btree (project_id, iid);
......
---
stage: Release
group: Release
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
type: reference, api
---
# Project Analytics API **(ULTIMATE ONLY)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/279039) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.7.
All methods require reporter authorization.
## List project deployment frequencies
Get a list of all project aliases:
```plaintext
GET /projects/:id/analytics/deployment_frequency?environment=:environment&from=:from&to=:to&interval=:interval
```
| Attribute | Type | Required | Description |
|--------------|--------|----------|-----------------------|
| `id` | string | yes | The ID of the project |
| Parameter | Type | Required | Description |
|--------------|--------|----------|-----------------------|
| `environment`| string | yes | The name of the environment to filter by |
| `from` | string | yes | Datetime range to start from, inclusive, ISO 8601 format (`YYYY-MM-DDTHH:MM:SSZ`) |
| `to` | string | no | Datetime range to end at, exclusive, ISO 8601 format (`YYYY-MM-DDTHH:MM:SSZ`) |
| `interval` | string | no | The bucketing interval (`all`, `monthly`, `daily`) |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/:id/analytics/deployment_frequency?from=:from&to=:to&interval=:interval"
```
Example response:
```json
[
{
"from": "2017-01-01",
"to": "2017-01-02",
"value": 106
},
{
"from": "2017-01-02",
"to": "2017-01-03",
"value": 55
}
]
```
......@@ -374,6 +374,16 @@ project `https://gitlab.com/gitlab-org/gitlab`), the repository can be cloned
using the alias (e.g `git clone git@gitlab.com:gitlab.git` instead of
`git clone git@gitlab.com:gitlab-org/gitlab.git`).
## Project activity analytics overview **(ULTIMATE ONLY)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/279039) in GitLab [Ultimate](https://about.gitlab.com/pricing/) 13.7 as a [Beta feature](https://about.gitlab.com/handbook/product/gitlab-the-product/#beta).
Project details include the following analytics:
- Deployment Frequency
For more information, see [Project Analytics API](../../api/project_analytics.md).
## Project APIs
There are numerous [APIs](../../api/README.md) to use with your projects:
......@@ -394,3 +404,4 @@ There are numerous [APIs](../../api/README.md) to use with your projects:
- [Traffic](../../api/project_statistics.md)
- [Variables](../../api/project_level_variables.md)
- [Aliases](../../api/project_aliases.md)
- [Analytics](../../api/project_analytics.md)
# frozen_string_literal: true
module Analytics
class DeploymentsFinder
def initialize(project:, environment_name:, from:, to: nil)
@project = project
@environment_name = environment_name
@from = from
@to = to
end
attr_reader :project, :environment_name, :from, :to
def execute
filter_deployments(project.deployments)
end
private
def filter_deployments(all_deployments)
deployments = filter_by_time(all_deployments)
deployments = filter_by_success(deployments)
deployments = filter_by_environment_name(deployments)
# rubocop: disable CodeReuse/ActiveRecord
deployments = deployments.order('finished_at')
# rubocop: enable CodeReuse/ActiveRecord
deployments
end
def filter_by_time(deployments)
deployments.finished_between(from, to)
end
def filter_by_success(deployments)
deployments.success
end
def filter_by_environment_name(deployments)
deployments.for_environment_name(environment_name)
end
end
end
......@@ -132,27 +132,29 @@ class License < ApplicationRecord
EEP_FEATURES.freeze
EEU_FEATURES = EEP_FEATURES + %i[
api_fuzzing
auto_rollback
cilium_alerts
container_scanning
coverage_fuzzing
credentials_inventory
cilium_alerts
dast
dependency_scanning
devops_adoption
enforce_pat_expiration
enterprise_templates
api_fuzzing
environment_alerts
group_level_compliance_dashboard
incident_management
insights
issuable_health_status
license_scanning
personal_access_token_expiration_policy
enforce_pat_expiration
project_activity_analytics
prometheus_alerts
pseudonymizer
quality_management
release_evidence_test_artifacts
environment_alerts
report_approver_rules
requirements
sast
......@@ -164,7 +166,6 @@ class License < ApplicationRecord
subepics
threat_monitoring
vulnerability_auto_fix
quality_management
]
EEU_FEATURES.freeze
......
......@@ -54,6 +54,11 @@ module EE
::Gitlab::CurrentSettings.prevent_merge_requests_committers_approval
end
with_scope :subject
condition(:project_activity_analytics_available) do
@subject.feature_available?(:project_activity_analytics)
end
condition(:project_merge_request_analytics_available) do
@subject.feature_available?(:project_merge_request_analytics)
end
......@@ -351,6 +356,9 @@ module EE
rule { can?(:read_merge_request) & code_review_analytics_enabled }.enable :read_code_review_analytics
rule { reporter & project_activity_analytics_available }
.enable :read_project_activity_analytics
rule { reporter & project_merge_request_analytics_available }
.enable :read_project_merge_request_analytics
......
---
title: Adds API support for Project Deployment Frequency
merge_request: 48265
author:
type: added
# frozen_string_literal: true
module API
module Analytics
class ProjectDeploymentFrequency < ::API::Base
include Gitlab::Utils::StrongMemoize
include PaginationParams
QUARTER_DAYS = 3.months / 1.day
DEPLOYMENT_FREQUENCY_INTERVAL_ALL = 'all'.freeze
DEPLOYMENT_FREQUENCY_INTERVAL_MONTHLY = 'monthly'.freeze
DEPLOYMENT_FREQUENCY_INTERVAL_DAILY = 'daily'.freeze
DEPLOYMENT_FREQUENCY_DEFAULT_INTERVAL = DEPLOYMENT_FREQUENCY_INTERVAL_ALL
VALID_INTERVALS = [
DEPLOYMENT_FREQUENCY_INTERVAL_ALL,
DEPLOYMENT_FREQUENCY_INTERVAL_MONTHLY,
DEPLOYMENT_FREQUENCY_INTERVAL_DAILY
].freeze
feature_category :planning_analytics
before do
authenticate!
end
helpers do
def environment_name
params[:environment]
end
def start_date
params[:from]
end
def end_date
params[:to] || DateTime.current
end
def days_between
(end_date - start_date).to_i
end
def interval
params[:interval] || DEPLOYMENT_FREQUENCY_DEFAULT_INTERVAL
end
def deployments
strong_memoize(:deployments) do
::Analytics::DeploymentsFinder.new(
project: user_project,
environment_name: environment_name,
from: start_date,
to: end_date
).execute
end
end
def deployments_grouped
strong_memoize(:deployments_grouped) do
case interval
when DEPLOYMENT_FREQUENCY_INTERVAL_ALL
{ start_date => deployments }
when DEPLOYMENT_FREQUENCY_INTERVAL_MONTHLY
deployments.group_by { |d| d.finished_at.beginning_of_month }
when DEPLOYMENT_FREQUENCY_INTERVAL_DAILY
deployments.group_by { |d| d.finished_at.to_date }
end
end
end
def deployments_grouped_end_date(deployments_grouped_start_date)
case interval
when DEPLOYMENT_FREQUENCY_INTERVAL_ALL
end_date
when DEPLOYMENT_FREQUENCY_INTERVAL_MONTHLY
deployments_grouped_start_date + 1.month
when DEPLOYMENT_FREQUENCY_INTERVAL_DAILY
deployments_grouped_start_date + 1.day
end
end
def deployment_frequencies
strong_memoize(:deployment_frequencies) do
deployments_grouped.map do |grouped_start_date, grouped_deploys|
{
value: grouped_deploys.count,
from: grouped_start_date,
to: deployments_grouped_end_date(grouped_start_date)
}
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/analytics' do
desc 'List analytics for the project'
params do
requires :environment, type: String, desc: 'Name of the environment to filter by'
requires :from, type: DateTime, desc: 'Datetime to start from, inclusive'
optional :to, type: DateTime, desc: 'Datetime to end at, exclusive'
optional :interval, type: String, desc: 'Interval to roll-up data by', values: VALID_INTERVALS
use :pagination
end
get 'deployment_frequency' do
bad_request!("Parameter `to` is before the `from` date") if start_date > end_date
bad_request!("Date range is greater than #{QUARTER_DAYS} days") if days_between > QUARTER_DAYS
authorize! :read_project_activity_analytics, user_project
present paginate(::Kaminari.paginate_array(deployment_frequencies)),
with: EE::API::Entities::Analytics::DeploymentFrequency
end
end
end
end
end
end
......@@ -42,6 +42,7 @@ module EE
mount ::API::VisualReviewDiscussions
mount ::API::Analytics::CodeReviewAnalytics
mount ::API::Analytics::GroupActivityAnalytics
mount ::API::Analytics::ProjectDeploymentFrequency
mount ::API::ProtectedEnvironments
mount ::API::ResourceWeightEvents
mount ::API::ResourceIterationEvents
......
# frozen_string_literal: true
module EE
module API
module Entities
module Analytics
class DeploymentFrequency < Grape::Entity
expose :value
expose :from
expose :to
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Analytics::DeploymentsFinder do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:prod) { create(:environment, project: project, name: "prod") }
let_it_be(:dev) { create(:environment, project: project, name: "dev") }
let_it_be(:other_project) { create(:project, :repository) }
let_it_be(:start_time) { DateTime.new(2017) }
let_it_be(:end_time) { DateTime.new(2019) }
def make_deployment(finished_at, env)
create(:deployment,
status: :success,
project: project,
environment: env,
finished_at: finished_at)
end
let_it_be(:deployment_2016) { make_deployment(DateTime.new(2016), prod) }
let_it_be(:deployment_2017) { make_deployment(DateTime.new(2017), prod) }
let_it_be(:deployment_2018) { make_deployment(DateTime.new(2018), prod) }
let_it_be(:dev_deployment_2018) { make_deployment(DateTime.new(2018), dev) }
let_it_be(:deployment_2019) { make_deployment(DateTime.new(2019), prod) }
let_it_be(:deployment_2020) { make_deployment(DateTime.new(2020), prod) }
describe '#execute' do
it 'returns successful deployments for the given project and datetime range' do
travel_to(start_time) do
create(:deployment, status: :running, project: project, environment: prod)
create(:deployment, status: :failed, project: project, environment: prod)
create(:deployment, status: :canceled, project: project, environment: prod)
create(:deployment, status: :skipped, project: project, environment: prod)
create(:deployment, status: :success, project: other_project, environment: prod)
create(:deployment, status: :success, project: other_project, environment: prod)
end
expect(described_class.new(
project: project,
environment_name: prod.name,
from: start_time,
to: end_time
).execute).to contain_exactly(deployment_2017, deployment_2018)
expect(described_class.new(
project: project,
environment_name: prod.name,
from: start_time
).execute).to contain_exactly(
deployment_2017,
deployment_2018,
deployment_2019,
deployment_2020
)
expect(described_class.new(
project: project,
environment_name: dev.name,
from: start_time
).execute).to contain_exactly(dev_deployment_2018)
end
end
end
......@@ -1221,6 +1221,26 @@ RSpec.describe ProjectPolicy do
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
context 'when project activity analytics is available' do
let(:current_user) { developer }
before do
stub_licensed_features(project_activity_analytics: true)
end
it { is_expected.to be_allowed(:read_project_activity_analytics) }
end
context 'when project activity analytics is not available' do
let(:current_user) { developer }
before do
stub_licensed_features(project_activity_analytics: false)
end
it { is_expected.not_to be_allowed(:read_project_activity_analytics) }
end
describe ':read_code_review_analytics' do
let(:project) { private_project }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Analytics::ProjectDeploymentFrequency do
let_it_be(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, :repository, namespace: group) }
let_it_be(:prod) { create(:environment, project: project, name: "prod") }
let_it_be(:dev) { create(:environment, project: project, name: "dev") }
let_it_be(:anonymous_user) { create(:user) }
let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } }
def make_deployment(finished_at, env)
create(:deployment,
status: :success,
project: project,
environment: env,
finished_at: finished_at)
end
let_it_be(:deployment_2020_01_01) { make_deployment(DateTime.new(2020, 1, 1), prod) }
let_it_be(:deployment_2020_01_02) { make_deployment(DateTime.new(2020, 1, 2), prod) }
let_it_be(:deployment_2020_01_03) { make_deployment(DateTime.new(2020, 1, 3), dev) }
let_it_be(:deployment_2020_01_04) { make_deployment(DateTime.new(2020, 1, 4), prod) }
let_it_be(:deployment_2020_01_05) { make_deployment(DateTime.new(2020, 1, 5), prod) }
let_it_be(:deployment_2020_02_01) { make_deployment(DateTime.new(2020, 2, 1), prod) }
let_it_be(:deployment_2020_02_02) { make_deployment(DateTime.new(2020, 2, 2), prod) }
let_it_be(:deployment_2020_02_03) { make_deployment(DateTime.new(2020, 2, 3), dev) }
let_it_be(:deployment_2020_02_04) { make_deployment(DateTime.new(2020, 2, 4), prod) }
let_it_be(:deployment_2020_02_05) { make_deployment(DateTime.new(2020, 2, 5), prod) }
let_it_be(:deployment_2020_03_01) { make_deployment(DateTime.new(2020, 3, 1), prod) }
let_it_be(:deployment_2020_03_02) { make_deployment(DateTime.new(2020, 3, 2), prod) }
let_it_be(:deployment_2020_03_03) { make_deployment(DateTime.new(2020, 3, 3), dev) }
let_it_be(:deployment_2020_03_04) { make_deployment(DateTime.new(2020, 3, 4), prod) }
let_it_be(:deployment_2020_03_05) { make_deployment(DateTime.new(2020, 3, 5), prod) }
let_it_be(:deployment_2020_04_01) { make_deployment(DateTime.new(2020, 4, 1), prod) }
let_it_be(:deployment_2020_04_02) { make_deployment(DateTime.new(2020, 4, 2), prod) }
let_it_be(:deployment_2020_04_03) { make_deployment(DateTime.new(2020, 4, 3), dev) }
let_it_be(:deployment_2020_04_04) { make_deployment(DateTime.new(2020, 4, 4), prod) }
let_it_be(:deployment_2020_04_05) { make_deployment(DateTime.new(2020, 4, 5), prod) }
let(:project_activity_analytics_enabled) { true }
let(:current_user) { reporter }
let(:params) { { from: Time.now, to: Time.now, interval: "all", environment: prod.name } }
let(:path) { api("/projects/#{project.id}/analytics/deployment_frequency", current_user) }
let(:request) { get path, params: params }
let(:request_time) { nil }
before do
stub_licensed_features(project_activity_analytics: project_activity_analytics_enabled)
if request_time
travel_to(request_time) { request }
else
request
end
end
context 'when user has access to the project' do
it 'returns `ok`' do
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'with params: from 2017 to 2019' do
let(:params) { { environment: prod.name, from: DateTime.new(2017), to: DateTime.new(2019) } }
it 'returns `bad_request` with expected message' do
expect(response.parsed_body).to eq({
"message" => "400 (Bad request) \"Date range is greater than 91 days\" not given"
})
end
end
context 'with params: from 2019 to 2017' do
let(:params) do
{ environment: prod.name, from: DateTime.new(2019), to: DateTime.new(2017) }
end
it 'returns `bad_request` with expected message' do
expect(response.parsed_body).to eq({
"message" => "400 (Bad request) \"Parameter `to` is before the `from` date\" not given"
})
end
end
context 'with params: from 2020/04/01 to request time' do
let(:request_time) { DateTime.new(2020, 4, 4) }
let(:params) { { environment: prod.name, from: DateTime.new(2020, 4, 2) } }
it 'returns the expected deployment frequencies' do
expect(response.parsed_body).to eq([{
"from" => "2020-04-02T00:00:00.000+00:00",
"to" => "2020-04-04T00:00:00.000+00:00",
"value" => 1
}])
end
end
context 'with params: from 2020/02/01 to 2020/04/01 by all' do
let(:params) do
{
environment: prod.name,
from: DateTime.new(2020, 2, 1),
to: DateTime.new(2020, 4, 1),
interval: "all"
}
end
it 'returns the expected deployment frequencies' do
expect(response.parsed_body).to eq([{
"from" => "2020-02-01T00:00:00.000+00:00",
"to" => "2020-04-01T00:00:00.000+00:00",
"value" => 8
}])
end
end
context 'with params: from 2020/02/01 to 2020/04/01 by month' do
let(:params) do
{
environment: prod.name,
from: DateTime.new(2020, 2, 1),
to: DateTime.new(2020, 4, 1),
interval: "monthly"
}
end
it 'returns the expected deployment frequencies' do
expect(response.parsed_body).to eq([
{ "from" => "2020-02-01T00:00:00.000Z", "to" => "2020-03-01T00:00:00.000Z", "value" => 4 },
{ "from" => "2020-03-01T00:00:00.000Z", "to" => "2020-04-01T00:00:00.000Z", "value" => 4 }
])
end
end
context 'with params: from 2017 to 2019 by day' do
let(:params) do
{
environment: prod.name,
from: DateTime.new(2020, 2, 1),
to: DateTime.new(2020, 4, 1),
interval: "daily"
}
end
it 'returns the expected deployment frequencies' do
expect(response.parsed_body).to eq([
{ "from" => "2020-02-01", "to" => "2020-02-02", "value" => 1 },
{ "from" => "2020-02-02", "to" => "2020-02-03", "value" => 1 },
{ "from" => "2020-02-04", "to" => "2020-02-05", "value" => 1 },
{ "from" => "2020-02-05", "to" => "2020-02-06", "value" => 1 },
{ "from" => "2020-03-01", "to" => "2020-03-02", "value" => 1 },
{ "from" => "2020-03-02", "to" => "2020-03-03", "value" => 1 },
{ "from" => "2020-03-04", "to" => "2020-03-05", "value" => 1 },
{ "from" => "2020-03-05", "to" => "2020-03-06", "value" => 1 }
])
end
end
context 'with params: invalid interval' do
let(:params) do
{
environment: prod.name,
from: DateTime.new(2020, 1),
to: DateTime.new(2020, 2),
interval: "invalid"
}
end
it 'returns `bad_request`' do
expect(response).to have_gitlab_http_status(:bad_request)
end
end
context 'with params: missing from' do
let(:params) { { environment: prod.name, to: DateTime.new(2019), interval: "all" } }
it 'returns `bad_request`' do
expect(response).to have_gitlab_http_status(:bad_request)
end
end
context 'when user does not have access to the project' do
let(:current_user) { anonymous_user }
it 'returns `not_found`' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when feature is not available in plan' do
let(:project_activity_analytics_enabled) { false }
context 'when user has access to the project' do
it 'returns `forbidden`' do
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'when user does not have access to the project' do
let(:current_user) { anonymous_user }
it 'returns `not_found`' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
......@@ -378,6 +378,22 @@ RSpec.describe Deployment do
end
end
describe 'finished_between' do
subject { described_class.finished_between(start_time, end_time) }
let_it_be(:start_time) { DateTime.new(2017) }
let_it_be(:end_time) { DateTime.new(2019) }
let_it_be(:deployment_2016) { create(:deployment, finished_at: DateTime.new(2016)) }
let_it_be(:deployment_2017) { create(:deployment, finished_at: DateTime.new(2017)) }
let_it_be(:deployment_2018) { create(:deployment, finished_at: DateTime.new(2018)) }
let_it_be(:deployment_2019) { create(:deployment, finished_at: DateTime.new(2019)) }
let_it_be(:deployment_2020) { create(:deployment, finished_at: DateTime.new(2020)) }
it 'retrieves deployments that finished between the specified times' do
is_expected.to contain_exactly(deployment_2017, deployment_2018)
end
end
describe 'visible' do
subject { described_class.visible }
......
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