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