diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 36300dbe099e777c6b1f24e239992f5d950a5a4f..0aedd952b97323853026113bd332d3f6291321fb 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -16833,6 +16833,7 @@ Extra metadata for AI message. | <a id="aimetricscodesuggestionscontributorscount"></a>`codeSuggestionsContributorsCount` | [`Int`](#int) | Number of code contributors who used GitLab Duo Code Suggestions features. | | <a id="aimetricscodesuggestionsshowncount"></a>`codeSuggestionsShownCount` | [`Int`](#int) | Total count of code suggestions shown to code contributors. | | <a id="aimetricsduochatcontributorscount"></a>`duoChatContributorsCount` | [`Int`](#int) | Number of contributors who used GitLab Duo Chat features. | +| <a id="aimetricsduoproassigneduserscount"></a>`duoProAssignedUsersCount` | [`Int`](#int) | Number of assigned Duo Pro seats. Ignores time period filter and always returns current data. | ### `AiSelfHostedModel` diff --git a/ee/app/finders/gitlab_subscriptions/add_on_assigned_users_finder.rb b/ee/app/finders/gitlab_subscriptions/add_on_assigned_users_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..a3e6e532779bc0040792ef5e25bdccda1d81ee7f --- /dev/null +++ b/ee/app/finders/gitlab_subscriptions/add_on_assigned_users_finder.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module GitlabSubscriptions + class AddOnAssignedUsersFinder + include Gitlab::Utils::StrongMemoize + + def initialize(current_user, namespace, add_on_name:) + @current_user = current_user + @namespace = namespace + @add_on_name = add_on_name + end + + def execute + add_on_purchase = GitlabSubscriptions::AddOnPurchase + .by_add_on_name(add_on_name).by_namespace(namespace.root_ancestor).active.first + + return User.none unless add_on_purchase + + add_on_purchase.users.by_ids(namespace_members.reselect(:user_id)) + end + + private + + attr_reader :namespace, :current_user, :add_on_name + + def namespace_members + # rubocop:disable CodeReuse/Finder -- member finders logic is way too complex to reconstruct it with scopes. + if namespace.is_a?(Namespaces::ProjectNamespace) + MembersFinder.new(namespace.project, current_user).execute(include_relations: %i[direct inherited descendants]) + else + GroupMembersFinder.new(namespace, current_user).execute(include_relations: %i[direct inherited descendants]) + end + # rubocop:enable CodeReuse/Finder + end + end +end diff --git a/ee/app/graphql/resolvers/analytics/ai_metrics_resolver.rb b/ee/app/graphql/resolvers/analytics/ai_metrics_resolver.rb index ac48d7f9e8a51717ae3b6c8cfdc6438bbac9b042..3be155ddf0d447b716bfbdaa2420a8fe64ee9e92 100644 --- a/ee/app/graphql/resolvers/analytics/ai_metrics_resolver.rb +++ b/ee/app/graphql/resolvers/analytics/ai_metrics_resolver.rb @@ -28,27 +28,17 @@ def ready?(**args) def resolve_with_lookahead(**args) params = params_with_defaults(args) - code_suggestion_usage = ::Analytics::AiAnalytics::CodeSuggestionUsageService.new( + usage = ::Analytics::AiAnalytics::AiMetricsService.new( current_user, namespace: namespace, from: params[:start_date], to: params[:end_date], - fields: selected_code_suggestion_fields + fields: selected_fields ).execute - return unless code_suggestion_usage.success? + return unless usage.success? - duo_chat_usage = ::Analytics::AiAnalytics::DuoChatUsageService.new( - current_user, - namespace: namespace, - from: params[:start_date], - to: params[:end_date], - fields: selected_duo_chat_fields - ).execute - - return unless duo_chat_usage.success? - - code_suggestion_usage.payload.merge(duo_chat_usage.payload) + usage.payload end private @@ -69,16 +59,8 @@ def namespace object.respond_to?(:project_namespace) ? object.project_namespace : object end - def selected_code_suggestion_fields - ::Analytics::AiAnalytics::CodeSuggestionUsageService::FIELDS.select do |field| - lookahead.selects?(field) - end - end - - def selected_duo_chat_fields - ::Analytics::AiAnalytics::DuoChatUsageService::FIELDS.select do |field| - lookahead.selects?(field) - end + def selected_fields + lookahead.selections.map(&:name) end end end diff --git a/ee/app/graphql/types/analytics/ai_metrics.rb b/ee/app/graphql/types/analytics/ai_metrics.rb index 6bd99a57274fbfe9390f230b29c94e3d023c50fd..3dd07fe59b6ac1d43b5d435cd76331f33a277449 100644 --- a/ee/app/graphql/types/analytics/ai_metrics.rb +++ b/ee/app/graphql/types/analytics/ai_metrics.rb @@ -19,6 +19,9 @@ class AiMetrics < BaseObject field :duo_chat_contributors_count, GraphQL::Types::Int, description: 'Number of contributors who used GitLab Duo Chat features.', null: true + field :duo_pro_assigned_users_count, GraphQL::Types::Int, + description: 'Number of assigned Duo Pro seats. Ignores time period filter and always returns current data.', + null: true end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/ee/app/models/gitlab_subscriptions/add_on_purchase.rb b/ee/app/models/gitlab_subscriptions/add_on_purchase.rb index 7075970fece186d8f95026a2838b514f517c5377..ec06640a2682ccbd6b20efd2ae3c5c6d845e6bd1 100644 --- a/ee/app/models/gitlab_subscriptions/add_on_purchase.rb +++ b/ee/app/models/gitlab_subscriptions/add_on_purchase.rb @@ -13,6 +13,7 @@ class AddOnPurchase < ApplicationRecord belongs_to :namespace, optional: true belongs_to :organization, class_name: 'Organizations::Organization' has_many :assigned_users, class_name: 'GitlabSubscriptions::UserAddOnAssignment', inverse_of: :add_on_purchase + has_many :users, through: :assigned_users validates :add_on, :expires_on, presence: true validate :valid_namespace, if: :gitlab_com? diff --git a/ee/app/services/analytics/ai_analytics/ai_metrics_service.rb b/ee/app/services/analytics/ai_analytics/ai_metrics_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..e08932914ce29e689bde3596d1e453055c849b3a --- /dev/null +++ b/ee/app/services/analytics/ai_analytics/ai_metrics_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Analytics + module AiAnalytics + class AiMetricsService + attr_reader :current_user, :namespace, :from, :to, :fields + + def initialize(current_user, namespace:, from:, to:, fields:) + @current_user = current_user + @namespace = namespace + @from = from + @to = to + @fields = fields + end + + def execute + data = {} + + data = add_code_suggestions_usage(data) + data = add_duo_chat_usage(data) + data = add_duo_pro_assigned(data) + + ServiceResponse.success(payload: data) + end + + private + + def add_duo_pro_assigned(data) + return data unless fields.include?(:duo_pro_assigned_users_count) + + users = GitlabSubscriptions::AddOnAssignedUsersFinder.new( + current_user, namespace, add_on_name: :code_suggestions).execute + + data.merge(duo_pro_assigned_users_count: users.count) + end + + def add_code_suggestions_usage(data) + usage = CodeSuggestionUsageService.new( + current_user, + namespace: namespace, + from: from, + to: to, + fields: fields & CodeSuggestionUsageService::FIELDS + ).execute + + usage.success? ? data.merge(usage.payload) : data + end + + def add_duo_chat_usage(data) + usage = DuoChatUsageService.new( + current_user, + namespace: namespace, + from: from, + to: to, + fields: fields & DuoChatUsageService::FIELDS + ).execute + + usage.success? ? data.merge(usage.payload) : data + end + end + end +end diff --git a/ee/spec/finders/gitlab_subscriptions/add_on_assigned_users_finder_spec.rb b/ee/spec/finders/gitlab_subscriptions/add_on_assigned_users_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..35f7803b6bd3f40e0536ddb5e5e1a2f629f8cce1 --- /dev/null +++ b/ee/spec/finders/gitlab_subscriptions/add_on_assigned_users_finder_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::AddOnAssignedUsersFinder, feature_category: :seat_cost_management do + describe '#execute' do + let_it_be(:user) { create(:user) } + let_it_be(:namespace) { create(:group, owners: user) } + let_it_be(:subgroup) { create(:group, parent: namespace) } + let_it_be(:another_subgroup) { create(:group, parent: namespace) } + let_it_be(:project) { create(:project, group: another_subgroup) } + let_it_be(:add_on) { create(:gitlab_subscription_add_on, :gitlab_duo_pro) } + + subject(:assigned_users) { described_class.new(user, namespace, add_on_name: :code_suggestions).execute } + + describe '#execute' do + context 'without add_on_purchase' do + it { is_expected.to be_empty } + end + + context 'with expired add_on_purchase' do + let_it_be(:add_on_purchase) do + create(:gitlab_subscription_add_on_purchase, :expired, add_on: add_on, namespace: namespace) + end + + let_it_be(:member_with_duo_pro) do + create(:user, developer_of: namespace).tap do |u| + create(:gitlab_subscription_user_add_on_assignment, user: u, add_on_purchase: add_on_purchase) + end + end + + it { is_expected.to be_empty } + end + + context 'with add on purchase available' do + let_it_be(:add_on_purchase) do + create(:gitlab_subscription_add_on_purchase, :active, add_on: add_on, namespace: namespace) + end + + let_it_be(:member_with_duo_pro) do + create(:user, developer_of: namespace).tap do |u| + create(:gitlab_subscription_user_add_on_assignment, user: u, add_on_purchase: add_on_purchase) + end + end + + let_it_be(:subgroup_member_with_duo_pro) do + create(:user, developer_of: subgroup).tap do |u| + create(:gitlab_subscription_user_add_on_assignment, user: u, add_on_purchase: add_on_purchase) + end + end + + let_it_be(:another_subgroup_member_with_duo_pro) do + create(:user, developer_of: another_subgroup).tap do |u| + create(:gitlab_subscription_user_add_on_assignment, user: u, add_on_purchase: add_on_purchase) + end + end + + let_it_be(:project_member_with_duo_pro) do + create(:user, developer_of: project).tap do |u| + create(:gitlab_subscription_user_add_on_assignment, user: u, add_on_purchase: add_on_purchase) + end + end + + let_it_be(:member_without_duo_pro) { create(:user, developer_of: namespace) } + + it 'returns all assigned users of a group' do + expect(assigned_users).to match_array([member_with_duo_pro, another_subgroup_member_with_duo_pro, + subgroup_member_with_duo_pro]) + end + + context 'with subgroup' do + let(:assigned_users) { described_class.new(user, subgroup, add_on_name: :code_suggestions).execute } + + it 'returns all subgroup members with assigned seat' do + expect(assigned_users).to match_array([member_with_duo_pro, subgroup_member_with_duo_pro]) + end + end + + context 'with project namespace' do + let(:assigned_users) do + described_class.new(user, project.project_namespace, add_on_name: :code_suggestions).execute + end + + it 'returns all project members with assigned seat' do + expect(assigned_users) + .to match_array([member_with_duo_pro, another_subgroup_member_with_duo_pro, project_member_with_duo_pro]) + end + end + end + end + end +end diff --git a/ee/spec/requests/api/graphql/analytics/ai_analytics/ai_metrics_spec.rb b/ee/spec/requests/api/graphql/analytics/ai_analytics/ai_metrics_spec.rb index 5833db62bd6f4f3efe0f18db35942b2303dcb6bb..afddcaf8af021ccdbe82ab8f172063e0d8188f4d 100644 --- a/ee/spec/requests/api/graphql/analytics/ai_analytics/ai_metrics_spec.rb +++ b/ee/spec/requests/api/graphql/analytics/ai_analytics/ai_metrics_spec.rb @@ -18,7 +18,7 @@ shared_examples 'common ai metrics' do let(:fields) do %w[codeSuggestionsContributorsCount codeContributorsCount codeSuggestionsShownCount codeSuggestionsAcceptedCount - duoChatContributorsCount] + duoChatContributorsCount duoProAssignedUsersCount] end let(:from) { '2024-05-01'.to_date } @@ -26,22 +26,21 @@ let(:filter_params) { { startDate: from, endDate: to } } let(:expected_filters) { { from: from, to: to } } - before do - allow_next_instance_of(::Analytics::AiAnalytics::CodeSuggestionUsageService, - current_user, hash_including(expected_filters)) do |instance| - allow(instance).to receive(:execute).and_return(ServiceResponse.success(payload: { - code_contributors_count: 10, - code_suggestions_contributors_count: 3, - code_suggestions_shown_count: 5, - code_suggestions_accepted_count: 2 - })) - end + let(:service_payload) do + { + code_contributors_count: 10, + code_suggestions_contributors_count: 3, + code_suggestions_shown_count: 5, + code_suggestions_accepted_count: 2, + duo_chat_contributors_count: 8, + duo_pro_assigned_users_count: 18 + } + end - allow_next_instance_of(::Analytics::AiAnalytics::DuoChatUsageService, + before do + allow_next_instance_of(::Analytics::AiAnalytics::AiMetricsService, current_user, hash_including(expected_filters)) do |instance| - allow(instance).to receive(:execute).and_return(ServiceResponse.success(payload: { - duo_chat_contributors_count: 8 - })) + allow(instance).to receive(:execute).and_return(ServiceResponse.success(payload: service_payload)) end post_graphql(query, current_user: current_user) @@ -53,10 +52,33 @@ 'codeContributorsCount' => 10, 'codeSuggestionsShownCount' => 5, 'codeSuggestionsAcceptedCount' => 2, - 'duoChatContributorsCount' => 8 + 'duoChatContributorsCount' => 8, + 'duoProAssignedUsersCount' => 18 }) end + context 'when AiMetrics service returns only part of queried fields' do + let(:service_payload) do + { + code_contributors_count: 10, + code_suggestions_contributors_count: 3, + code_suggestions_shown_count: 5, + code_suggestions_accepted_count: 2 + } + end + + it 'returns all metrics filled by default' do + expect(ai_metrics).to eq({ + 'codeSuggestionsContributorsCount' => 3, + 'codeContributorsCount' => 10, + 'codeSuggestionsShownCount' => 5, + 'codeSuggestionsAcceptedCount' => 2, + 'duoChatContributorsCount' => nil, + 'duoProAssignedUsersCount' => nil + }) + end + end + context 'when filter range is too wide' do let(:filter_params) { { startDate: 5.years.ago } } diff --git a/ee/spec/services/analytics/ai_analytics/ai_metrics_service_spec.rb b/ee/spec/services/analytics/ai_analytics/ai_metrics_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fdd2ac477b0e1d85755a545119504514fe780877 --- /dev/null +++ b/ee/spec/services/analytics/ai_analytics/ai_metrics_service_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Analytics::AiAnalytics::AiMetricsService, feature_category: :value_stream_management do + subject(:service_response) do + described_class.new(current_user, namespace: container, from: from, to: to, fields: fields).execute + end + + let_it_be(:group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: group) } + let_it_be(:project) { create(:project, group: subgroup) } + let_it_be(:user1) { create(:user, developer_of: group) } + + let(:current_user) { user1 } + let(:from) { Time.current } + let(:to) { Time.current } + let(:fields) do + Analytics::AiAnalytics::DuoChatUsageService::FIELDS + + Analytics::AiAnalytics::CodeSuggestionUsageService::FIELDS + + [:duo_pro_assigned_users_count] + end + + let(:expected_filters) { { from: from, to: to } } + + before do + allow(Gitlab::ClickHouse).to receive(:enabled_for_analytics?).and_return(true) + end + + shared_examples 'common ai metrics service' do + before do + allow_next_instance_of(::Analytics::AiAnalytics::DuoChatUsageService, + current_user, + hash_including(expected_filters.merge(fields: ::Analytics::AiAnalytics::DuoChatUsageService::FIELDS)) + ) do |instance| + allow(instance).to receive(:execute).and_return(ServiceResponse.success(payload: { + duo_chat_contributors_count: 8 + })) + end + + allow_next_instance_of(::Analytics::AiAnalytics::CodeSuggestionUsageService, + current_user, + hash_including(expected_filters.merge(fields: ::Analytics::AiAnalytics::CodeSuggestionUsageService::FIELDS)) + ) do |instance| + allow(instance).to receive(:execute).and_return(ServiceResponse.success(payload: { + code_contributors_count: 10, + code_suggestions_contributors_count: 3, + code_suggestions_shown_count: 5, + code_suggestions_accepted_count: 2 + })) + end + + allow_next_instance_of(GitlabSubscriptions::AddOnAssignedUsersFinder, + current_user, container, add_on_name: :code_suggestions) do |instance| + allow(instance).to receive(:execute).and_return([:foo, :bar, :baz]) + end + end + + it 'returns merged payload of all services' do + expect(service_response).to be_success + expect(service_response.payload).to eq({ + duo_chat_contributors_count: 8, + code_contributors_count: 10, + code_suggestions_contributors_count: 3, + code_suggestions_shown_count: 5, + code_suggestions_accepted_count: 2, + duo_pro_assigned_users_count: 3 + }) + end + end + + context 'for group' do + let_it_be(:container) { subgroup } + + it_behaves_like 'common ai metrics service' + end + + context 'for project' do + let_it_be(:container) { project.project_namespace.reload } + + it_behaves_like 'common ai metrics service' + end +end