Commit c3d87d6a authored by Vitali Tatarintev's avatar Vitali Tatarintev

Merge branch '208947-group-release-stats' into 'master'

Aggregate project level releases metrics to the group

See merge request gitlab-org/gitlab!47245
parents 87ec90d8 f2755378
......@@ -9454,6 +9454,11 @@ type Group {
"""
shareWithGroupLock: Boolean
"""
Group statistics
"""
stats: GroupStats
"""
Total storage limit of the root namespace in bytes
"""
......@@ -9878,6 +9883,33 @@ type GroupPermissions {
readGroup: Boolean!
}
"""
Contains release-related statistics about a group
"""
type GroupReleaseStats {
"""
Total number of releases in all descendant projects of the group. Will always
return `null` if `group_level_release_statistics` feature flag is disabled
"""
releasesCount: Int
"""
Percentage of the group's descendant projects that have at least one release.
Will always return `null` if `group_level_release_statistics` feature flag is disabled
"""
releasesPercentage: Int
}
"""
Contains statistics about a group
"""
type GroupStats {
"""
Statistics related to releases within the group
"""
releaseStats: GroupReleaseStats
}
"""
Health status of an issue or epic
"""
......
......@@ -25803,6 +25803,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "stats",
"description": "Group statistics",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "GroupStats",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "storageSizeLimit",
"description": "Total storage limit of the root namespace in bytes",
......@@ -26988,6 +27002,74 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "GroupReleaseStats",
"description": "Contains release-related statistics about a group",
"fields": [
{
"name": "releasesCount",
"description": "Total number of releases in all descendant projects of the group. Will always return `null` if `group_level_release_statistics` feature flag is disabled",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "releasesPercentage",
"description": "Percentage of the group's descendant projects that have at least one release. Will always return `null` if `group_level_release_statistics` feature flag is disabled",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "GroupStats",
"description": "Contains statistics about a group",
"fields": [
{
"name": "releaseStats",
"description": "Statistics related to releases within the group",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "GroupReleaseStats",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "HealthStatus",
......@@ -1447,6 +1447,7 @@ Autogenerated return type of EpicTreeReorder.
| `requireTwoFactorAuthentication` | Boolean | Indicates if all users in this group are required to set up two-factor authentication |
| `rootStorageStatistics` | RootStorageStatistics | Aggregated storage statistics of the namespace. Only available for root namespaces |
| `shareWithGroupLock` | Boolean | Indicates if sharing a project with another group within this group is prevented |
| `stats` | GroupStats | Group statistics |
| `storageSizeLimit` | Float | Total storage limit of the root namespace in bytes |
| `subgroupCreationLevel` | String | The permission level required to create subgroups within the group |
| `temporaryStorageIncreaseEndsOn` | Time | Date until the temporary storage increase is active |
......@@ -1486,6 +1487,23 @@ Represents a Group Membership.
| ----- | ---- | ----------- |
| `readGroup` | Boolean! | Indicates the user can perform `read_group` on this resource |
### GroupReleaseStats
Contains release-related statistics about a group.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `releasesCount` | Int | Total number of releases in all descendant projects of the group. Will always return `null` if `group_level_release_statistics` feature flag is disabled |
| `releasesPercentage` | Int | Percentage of the group's descendant projects that have at least one release. Will always return `null` if `group_level_release_statistics` feature flag is disabled |
### GroupStats
Contains statistics about a group.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `releaseStats` | GroupReleaseStats | Statistics related to releases within the group |
### HttpIntegrationCreatePayload
Autogenerated return type of HttpIntegrationCreate.
......
......@@ -73,6 +73,12 @@ module EE
description: 'Represents the code coverage activity for this group',
resolver: ::Resolvers::Ci::CodeCoverageActivitiesResolver,
feature_flag: :group_coverage_data_report_graph
field :stats,
::Types::GroupStatsType,
null: true,
description: 'Group statistics',
method: :itself
end
end
end
......
# frozen_string_literal: true
module Types
class GroupReleaseStatsType < BaseObject
graphql_name 'GroupReleaseStats'
description 'Contains release-related statistics about a group'
authorize :read_group_release_stats
field :releases_count, GraphQL::INT_TYPE, null: true,
description: 'Total number of releases in all descendant projects of the group. ' \
'Will always return `null` if `group_level_release_statistics` feature flag is disabled'
def releases_count
object.releases_count if Feature.enabled?(:group_level_release_statistics, object, default_enabled: true)
end
field :releases_percentage, GraphQL::INT_TYPE, null: true,
description: "Percentage of the group's descendant projects that have at least one release. " \
'Will always return `null` if `group_level_release_statistics` feature flag is disabled'
def releases_percentage
object.releases_percentage if Feature.enabled?(:group_level_release_statistics, object, default_enabled: true)
end
end
end
# frozen_string_literal: true
module Types
class GroupStatsType < BaseObject
graphql_name 'GroupStats'
description 'Contains statistics about a group'
authorize :read_group
field :release_stats, Types::GroupReleaseStatsType,
null: true, method: :itself,
description: 'Statistics related to releases within the group'
end
end
......@@ -428,6 +428,23 @@ module EE
members.count
end
def releases_count
::Release.by_namespace_id(self_and_descendants.select(:id)).count
end
def releases_percentage
calculate_sql = <<~SQL
(
COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM releases WHERE releases.project_id = projects.id)) * 100.0 / GREATEST(COUNT(*), 1)
)::integer AS releases_percentage
SQL
self.class.count_by_sql(
::Project.select(calculate_sql)
.where(namespace_id: self_and_descendants.select(:id)).to_sql
)
end
private
def custom_project_templates_group_allowed
......
......@@ -10,6 +10,8 @@ module EE
prepended do
include UsageStatistics
scope :by_namespace_id, -> (ns_id) { joins(:project).where(projects: { namespace_id: ns_id }) }
end
end
end
......@@ -114,7 +114,10 @@ module EE
enable :download_wiki_code
end
rule { guest }.enable :read_wiki
rule { guest }.policy do
enable :read_wiki
enable :read_group_release_stats
end
rule { reporter }.policy do
enable :admin_list
......
---
title: Add releasesCount and releasesPercentage to Group GraphQL type
merge_request: 47245
author:
type: added
---
name: group_level_release_statistics
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47245
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/283962
milestone: '13.6'
type: development
group: group::release
default_enabled: true
......@@ -17,6 +17,7 @@ RSpec.describe GitlabSchema.types['Group'] do
it { expect(described_class).to have_graphql_field(:vulnerabilities_count_by_day_and_severity) }
it { expect(described_class).to have_graphql_field(:vulnerability_grades) }
it { expect(described_class).to have_graphql_field(:code_coverage_activities) }
it { expect(described_class).to have_graphql_field(:stats) }
describe 'timelogs field' do
subject { described_class.fields['timelogs'] }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['GroupReleaseStats'] do
it { expect(described_class).to require_graphql_authorizations(:read_group_release_stats) }
it 'has the expected fields' do
expected_fields = %w[releasesCount releasesPercentage]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['GroupStats'] do
it { expect(described_class).to require_graphql_authorizations(:read_group) }
it 'has the expected fields' do
expected_fields = %w[releaseStats]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Release do
describe '.by_namespace_id' do
let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group) }
let_it_be(:project_in_group) { create(:project, group: group) }
let_it_be(:project_in_subgroup) { create(:project, group: subgroup) }
let_it_be(:unrelated_project) { create(:project) }
let_it_be(:release_in_group_project) { create(:release, project: project_in_group) }
let_it_be(:release_in_subgroup_project_1) { create(:release, project: project_in_subgroup) }
let_it_be(:release_in_subgroup_project_2) { create(:release, project: project_in_subgroup) }
let_it_be(:release_in_unrelated_project) { create(:release, project: unrelated_project) }
context 'when a single namespace id is passed' do
let(:ns_id) { group.id }
it 'returns releases associated to projects of the provided group' do
expect(described_class.by_namespace_id(ns_id)).to match_array([
release_in_group_project
])
end
end
context 'when an array of namespace ids is passed' do
let(:ns_id) { group.self_and_descendants.select(:id) }
it 'returns releases associated to projects of all provided groups' do
expect(described_class.by_namespace_id(ns_id)).to match_array([
release_in_group_project,
release_in_subgroup_project_1,
release_in_subgroup_project_2
])
end
end
end
end
......@@ -1174,4 +1174,49 @@ RSpec.describe Group do
it { is_expected.to match([user.email]) }
end
describe 'Releases Stats' do
context 'when there are no releases' do
describe '#releases_count' do
it 'returns 0' do
expect(group.releases_count).to eq(0)
end
end
describe '#releases_percentage' do
it 'returns 0 and does not attempt to divide by 0' do
expect(group.releases_percentage).to eq(0)
end
end
end
context 'when there are some releases' do
before do
subgroup_1 = create(:group, parent: group)
subgroup_2 = create(:group, parent: subgroup_1)
project_in_group = create(:project, group: group)
_project_in_subgroup_1 = create(:project, group: subgroup_1)
project_in_subgroup_2 = create(:project, group: subgroup_2)
project_in_unrelated_group = create(:project)
create(:release, project: project_in_group)
create(:release, project: project_in_subgroup_2)
create(:release, project: project_in_unrelated_group)
end
describe '#releases_count' do
it 'counts all releases for group and descendants' do
expect(group.releases_count).to eq(2)
end
end
describe '#releases_percentage' do
it 'calculates projects with releases percentage for group and descendants' do
# 2 out of 3 projects have releases
expect(group.releases_percentage).to eq(67)
end
end
end
end
end
......@@ -1242,4 +1242,40 @@ RSpec.describe GroupPolicy do
end
end
end
describe ':read_group_release_stats' do
shared_examples 'read_group_release_stats permissions' do
context 'when user is logged out' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:read_group_release_stats) }
end
context 'when user is not a member of the group' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:read_group_release_stats) }
end
context 'when user is guest' do
let(:current_user) { guest }
it { is_expected.to be_allowed(:read_group_release_stats) }
end
end
context 'when group is private' do
it_behaves_like 'read_group_release_stats permissions'
end
context 'when group is public' do
let(:group) { create(:group, :public) }
before do
group.add_guest(guest)
end
it_behaves_like 'read_group_release_stats permissions'
end
end
end
......@@ -233,6 +233,122 @@ RSpec.describe 'getting group information' do
current_user: user
)
end
context 'when loading release statistics' do
let_it_be(:guest_user) { create(:user) }
let_it_be(:public_user) { create(:user) }
let(:query_fields) do
<<~QUERY
stats {
releaseStats {
releasesCount
releasesPercentage
}
}
QUERY
end
let(:group_level_release_statistics) { true }
let(:query) do
graphql_query_for('group', { 'fullPath' => group.full_path }, query_fields)
end
let(:release_stats) do
graphql_data.with_indifferent_access.dig(:group, :stats, :releaseStats)
end
before do
stub_feature_flags(group_level_release_statistics: group_level_release_statistics)
group.add_guest(guest_user)
post_graphql(query, current_user: current_user)
end
shared_examples 'no access to release statistics' do
it 'returns data about release utilization within the group' do
expect(release_stats).to be_nil
end
end
shared_examples 'full access to release statistics' do
context 'when there are no releases' do
it 'returns 0 for both statistics' do
expect(release_stats).to match(
releasesCount: 0,
releasesPercentage: 0
)
end
end
context 'when there are some releases' do
let_it_be(:subgroup) { create(:group, :private, parent: group) }
let_it_be(:project_in_group) { create(:project, group: group) }
let_it_be(:project_in_subgroup) { create(:project, group: subgroup) }
let_it_be(:another_project_in_subgroup) { create(:project, group: subgroup) }
let_it_be(:project_in_unrelated_group) { create(:project) }
let_it_be(:release_1) { create(:release, project: project_in_group) }
let_it_be(:release_2) { create(:release, project: project_in_subgroup) }
let_it_be(:release_3) { create(:release, project: project_in_subgroup) }
let_it_be(:release_4) { create(:release, project: project_in_unrelated_group) }
it 'returns data about release utilization within the group' do
expect(release_stats).to match(
releasesCount: 3,
releasesPercentage: 67
)
end
end
end
shared_examples 'correct access to release statistics' do
context 'when the user is not logged in' do
let(:current_user) { nil }
it_behaves_like 'no access to release statistics'
end
context 'when the user is not a member of the group' do
let(:current_user) { public_user }
it_behaves_like 'no access to release statistics'
end
context 'when the user is at least a guest' do
let(:current_user) { guest_user }
it_behaves_like 'full access to release statistics'
end
end
context 'when the group is private' do
let_it_be(:group) { create(:group, :private) }
it_behaves_like 'correct access to release statistics'
end
context 'when the group is public' do
let_it_be(:group) { create(:group, :public) }
it_behaves_like 'correct access to release statistics'
end
context 'when the group_level_release_statistics feature flag is disabled' do
let_it_be(:group) { create(:group, :public) }
let(:current_user) { guest_user }
let(:group_level_release_statistics) { false }
it 'returns null for both statistics' do
expect(release_stats).to match(
releasesCount: nil,
releasesPercentage: nil
)
end
end
end
end
describe 'pagination' do
......
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