From c83715b572edf152317fbb1ea267f1e5cd6764f8 Mon Sep 17 00:00:00 2001 From: Igor Drozdov <idrozdov@gitlab.com> Date: Tue, 9 Aug 2022 18:29:24 +0200 Subject: [PATCH] Provide GraphQL API for select in fork form It's backend part for: - https://gitlab.com/gitlab-org/gitlab/-/merge_requests/92363 Changelog: added --- app/finders/fork_targets_finder.rb | 30 ++++++-- .../projects/fork_targets_resolver.rb | 27 ++++++++ app/graphql/types/project_type.rb | 4 ++ app/models/user.rb | 9 ++- .../development/searchable_fork_targets.yml | 8 +++ doc/api/graphql/reference/index.md | 16 +++++ spec/finders/fork_targets_finder_spec.rb | 40 ++++++++--- .../projects/fork_targets_resolver_spec.rb | 49 +++++++++++++ spec/graphql/types/project_type_spec.rb | 3 +- .../api/graphql/project/fork_targets_spec.rb | 69 +++++++++++++++++++ 10 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 app/graphql/resolvers/projects/fork_targets_resolver.rb create mode 100644 config/feature_flags/development/searchable_fork_targets.yml create mode 100644 spec/graphql/resolvers/projects/fork_targets_resolver_spec.rb create mode 100644 spec/requests/api/graphql/project/fork_targets_spec.rb diff --git a/app/finders/fork_targets_finder.rb b/app/finders/fork_targets_finder.rb index 0b5dfb1657286..e129fde374816 100644 --- a/app/finders/fork_targets_finder.rb +++ b/app/finders/fork_targets_finder.rb @@ -6,17 +6,39 @@ def initialize(project, user) @user = user end - # rubocop: disable CodeReuse/ActiveRecord def execute(options = {}) - return ::Namespace.where(id: user.forkable_namespaces).sort_by_type unless options[:only_groups] + return previous_execute(options) unless Feature.enabled?(:searchable_fork_targets) - ::Group.where(id: user.manageable_groups(include_groups_with_developer_maintainer_access: true)) + items = fork_targets(options) + + by_search(items, options) end - # rubocop: enable CodeReuse/ActiveRecord private attr_reader :project, :user + + def by_search(items, options) + return items if options[:search].blank? + + items.search(options[:search]) + end + + def fork_targets(options) + if options[:only_groups] + user.manageable_groups(include_groups_with_developer_maintainer_access: true) + else + user.forkable_namespaces.sort_by_type + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def previous_execute(options = {}) + return ::Namespace.where(id: user.forkable_namespaces).sort_by_type unless options[:only_groups] + + ::Group.where(id: user.manageable_groups(include_groups_with_developer_maintainer_access: true)) + end + # rubocop: enable CodeReuse/ActiveRecord end ForkTargetsFinder.prepend_mod_with('ForkTargetsFinder') diff --git a/app/graphql/resolvers/projects/fork_targets_resolver.rb b/app/graphql/resolvers/projects/fork_targets_resolver.rb new file mode 100644 index 0000000000000..5e8be325d43e4 --- /dev/null +++ b/app/graphql/resolvers/projects/fork_targets_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class ForkTargetsResolver < BaseResolver + include ResolvesGroups + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::NamespaceType.connection_type, null: true + + authorize :fork_project + authorizes_object! + + alias_method :project, :object + + argument :search, GraphQL::Types::String, + required: false, + description: 'Search query for path or name.' + + private + + def resolve_groups(**args) + ForkTargetsFinder.new(project, current_user).execute(args) + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index c9223577b07ce..668b3ec548b4d 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -444,6 +444,10 @@ class ProjectType < BaseObject description: "Timelog categories for the project.", _deprecated_feature_flag: :timelog_categories + field :fork_targets, Types::NamespaceType.connection_type, + resolver: Resolvers::Projects::ForkTargetsResolver, + description: 'Namespaces in which the current user can fork the project into.' + def timelog_categories object.project_namespace.timelog_categories end diff --git a/app/models/user.rb b/app/models/user.rb index 6f6dde236ba0c..3c12024c5b99f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1659,7 +1659,14 @@ def unfollow(user) end def forkable_namespaces - @forkable_namespaces ||= [namespace] + manageable_groups(include_groups_with_developer_maintainer_access: true) + strong_memoize(:forkable_namespaces) do + personal_namespace = Namespace.where(id: namespace_id) + + Namespace.from_union([ + manageable_groups(include_groups_with_developer_maintainer_access: true), + personal_namespace + ]) + end end def manageable_groups(include_groups_with_developer_maintainer_access: false) diff --git a/config/feature_flags/development/searchable_fork_targets.yml b/config/feature_flags/development/searchable_fork_targets.yml new file mode 100644 index 0000000000000..aeeeb66d2f833 --- /dev/null +++ b/config/feature_flags/development/searchable_fork_targets.yml @@ -0,0 +1,8 @@ +--- +name: searchable_fork_targets +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/94991 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/370795 +milestone: '15.3' +type: development +group: group::source code +default_enabled: false diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 4e021da238de2..99b29c155842d 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -15773,6 +15773,22 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="projectenvironmentssearch"></a>`search` | [`String`](#string) | Search query for environment name. | | <a id="projectenvironmentsstates"></a>`states` | [`[String!]`](#string) | States of environments that should be included in result. | +##### `Project.forkTargets` + +Namespaces in which the current user can fork the project into. + +Returns [`NamespaceConnection`](#namespaceconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="projectforktargetssearch"></a>`search` | [`String`](#string) | Search query for path or name. | + ##### `Project.incidentManagementEscalationPolicies` Incident Management escalation policies of the project. diff --git a/spec/finders/fork_targets_finder_spec.rb b/spec/finders/fork_targets_finder_spec.rb index fe5b50ef0303a..1acc38bb49221 100644 --- a/spec/finders/fork_targets_finder_spec.rb +++ b/spec/finders/fork_targets_finder_spec.rb @@ -5,27 +5,27 @@ RSpec.describe ForkTargetsFinder do subject(:finder) { described_class.new(project, user) } - let(:project) { create(:project, namespace: create(:group)) } - let(:user) { create(:user) } - let!(:maintained_group) do + let_it_be(:project) { create(:project, namespace: create(:group)) } + let_it_be(:user) { create(:user) } + let_it_be(:maintained_group) do create(:group).tap { |g| g.add_maintainer(user) } end - let!(:owned_group) do + let_it_be(:owned_group) do create(:group).tap { |g| g.add_owner(user) } end - let!(:developer_group) do + let_it_be(:developer_group) do create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS).tap do |g| g.add_developer(user) end end - let!(:reporter_group) do + let_it_be(:reporter_group) do create(:group).tap { |g| g.add_reporter(user) } end - let!(:guest_group) do + let_it_be(:guest_group) do create(:group).tap { |g| g.add_guest(user) } end @@ -33,7 +33,7 @@ project.namespace.add_owner(user) end - describe '#execute' do + shared_examples 'returns namespaces and groups' do it 'returns all user manageable namespaces' do expect(finder.execute).to match_array([user.namespace, maintained_group, owned_group, project.namespace, developer_group]) end @@ -46,4 +46,28 @@ expect(finder.execute(only_groups: true)).to include(a_kind_of(Group)) end end + + describe '#execute' do + it_behaves_like 'returns namespaces and groups' + + context 'when search is provided' do + it 'filters the targets by the param' do + expect(finder.execute(search: maintained_group.path)).to eq([maintained_group]) + end + end + + context 'when searchable_fork_targets feature flag is disabled' do + before do + stub_feature_flags(searchable_fork_targets: false) + end + + it_behaves_like 'returns namespaces and groups' + + context 'when search is provided' do + it 'ignores the param and returns all user manageable namespaces' do + expect(finder.execute).to match_array([user.namespace, maintained_group, owned_group, project.namespace, developer_group]) + end + end + end + end end diff --git a/spec/graphql/resolvers/projects/fork_targets_resolver_spec.rb b/spec/graphql/resolvers/projects/fork_targets_resolver_spec.rb new file mode 100644 index 0000000000000..ef1b18f0a1193 --- /dev/null +++ b/spec/graphql/resolvers/projects/fork_targets_resolver_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Projects::ForkTargetsResolver do + include GraphqlHelpers + + let_it_be(:group) { create(:group, path: 'namespace-group') } + let_it_be(:another_group) { create(:group, path: 'namespace-another-group') } + let_it_be(:project) { create(:project, :private, group: group) } + let_it_be(:user) { create(:user, username: 'namespace-user', maintainer_projects: [project]) } + + let(:args) { { search: 'namespace' } } + + describe '#resolve' do + before_all do + group.add_owner(user) + another_group.add_owner(user) + end + + it 'returns forkable namespaces' do + expect_next_instance_of(ForkTargetsFinder) do |finder| + expect(finder).to receive(:execute).with(args).and_call_original + end + + expect(resolve_targets(args).items).to match_array([user.namespace, project.namespace, another_group]) + end + end + + context 'when a user cannot fork the project' do + let(:user) { create(:user) } + + it 'does not return results' do + project.add_guest(user) + + expect(resolve_targets(args)).to be_nil + end + end + + def resolve_targets(args, opts = {}) + field_options = described_class.field_options.merge( + owner: resolver_parent, + name: 'field_value' + ).merge(opts) + + field = ::Types::BaseField.new(**field_options) + resolve_field(field, project, args: args, ctx: { current_user: user }, object_type: resolver_parent) + end +end diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index fbb3aca337682..ccb6b94d3cb6f 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -36,7 +36,8 @@ pipeline_analytics squash_read_only sast_ci_configuration cluster_agent cluster_agents agent_configurations ci_template timelogs merge_commit_template squash_commit_template work_item_types - recent_issue_boards ci_config_path_or_default packages_cleanup_policy ci_variables timelog_categories + recent_issue_boards ci_config_path_or_default packages_cleanup_policy ci_variables + timelog_categories fork_targets ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/requests/api/graphql/project/fork_targets_spec.rb b/spec/requests/api/graphql/project/fork_targets_spec.rb new file mode 100644 index 0000000000000..b21a11ff4dc61 --- /dev/null +++ b/spec/requests/api/graphql/project/fork_targets_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting a list of fork targets for a project' do + include GraphqlHelpers + + let_it_be(:group) { create(:group) } + let_it_be(:another_group) { create(:group) } + let_it_be(:project) { create(:project, :private, group: group) } + let_it_be(:user) { create(:user, developer_projects: [project]) } + + let(:current_user) { user } + let(:fields) do + <<~GRAPHQL + forkTargets{ + nodes { id name fullPath visibility } + } + GRAPHQL + end + + let(:query) do + graphql_query_for( + 'project', + { 'fullPath' => project.full_path }, + fields + ) + end + + before_all do + group.add_owner(user) + another_group.add_owner(user) + end + + context 'when user has access to the project' do + before do + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'returns fork targets for the project' do + expect(graphql_data.dig('project', 'forkTargets', 'nodes')).to match_array( + [user.namespace, project.namespace, another_group].map do |target| + hash_including( + { + 'id' => target.to_global_id.to_s, + 'name' => target.name, + 'fullPath' => target.full_path, + 'visibility' => target.visibility + } + ) + end + ) + end + end + + context "when user doesn't have access to the project" do + let(:current_user) { create(:user) } + + before do + post_graphql(query, current_user: current_user) + end + + it 'does not return the project' do + expect(graphql_data).to eq('project' => nil) + end + end +end -- GitLab