diff --git a/doc/api/projects.md b/doc/api/projects.md index 75eea394a4013f53ae898dd03258768d0c66236b..32b26f3272649ad9dde6f37ec67c31972d12a8b2 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -2596,6 +2596,50 @@ DELETE /projects/:id/push_rule |-----------|----------------|------------------------|-------------| | `id` | integer or string | **{check-circle}** Yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding). | +## Get groups to which a user can transfer a project + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/371006) in GitLab 15.4 + +Retrieve a list of groups to which the user can transfer a project. + +```plaintext +GET /projects/:id/transfer_locations +``` + +| Attribute | Type | Required | Description | +|-------------|----------------|------------------------|-------------| +| `id` | integer or string | **{check-circle}** Yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding). | +| `search` | string | **{dotted-circle}** No | The group names to search for. | + +Example request: + +```shell +curl --request GET "https://gitlab.example.com/api/v4/projects/1/transfer_locations" +``` + +Example response: + +```json +[ + { + "id": 27, + "web_url": "https://gitlab.example.com/groups/gitlab", + "name": "GitLab", + "avatar_url": null, + "full_name": "GitLab", + "full_path": "GitLab" + }, + { + "id": 31, + "web_url": "https://gitlab.example.com/groups/foobar", + "name": "FooBar", + "avatar_url": null, + "full_name": "FooBar", + "full_path": "FooBar" + } +] +``` + ## Transfer a project to a new namespace > The `_links.cluster_agents` attribute in the response [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/347047) in GitLab 14.10. diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 6ed480518ee71ee4f38f94d3ec48bb0dada02ea7..f8dc3d45a5c8fa6425d23c3d8d7b088cd65da5b7 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -743,6 +743,22 @@ def add_import_params(params) end end + desc 'Get the namespaces to where the project can be transferred' + params do + optional :search, type: String, desc: 'Return list of namespaces matching the search criteria' + use :pagination + end + get ":id/transfer_locations", feature_category: :projects do + authorize! :change_namespace, user_project + args = declared_params(include_missing: false) + args[:permission_scope] = :transfer_projects + + groups = ::Groups::UserGroupsFinder.new(current_user, current_user, args).execute + groups = groups.with_route + + present_groups(groups) + end + desc 'Show the storage information' do success Entities::ProjectRepositoryStorage end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 97cbdcbcfa70a09b71c8a15cf6b820818a5da987..fcbd0cc784b43cd9b372ced03c96eb594f9b74c5 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -4646,6 +4646,112 @@ def failure_message(diff) end end + describe 'GET /projects/:id/transfer_locations' do + let_it_be(:user) { create(:user) } + let_it_be(:source_group) { create(:group) } + let_it_be(:project) { create(:project, group: source_group) } + + let(:params) { {} } + + subject(:request) do + get api("/projects/#{project.id}/transfer_locations", user), params: params + end + + context 'when the user has rights to transfer the project' do + let_it_be(:guest_group) { create(:group) } + let_it_be(:maintainer_group) { create(:group, name: 'maintainer group', path: 'maintainer-group') } + let_it_be(:owner_group) { create(:group, name: 'owner group', path: 'owner-group') } + + before do + source_group.add_owner(user) + guest_group.add_guest(user) + maintainer_group.add_maintainer(user) + owner_group.add_owner(user) + end + + it 'returns 200' do + request + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + end + + it 'includes groups where the user has permissions to transfer a project to' do + request + + expect(project_ids_from_response).to include(maintainer_group.id, owner_group.id) + end + + it 'does not include groups where the user doesn not have permissions to transfer a project' do + request + + expect(project_ids_from_response).not_to include(guest_group.id) + end + + context 'with search' do + let(:params) { { search: 'maintainer' } } + + it 'includes groups where the user has permissions to transfer a project to' do + request + + expect(project_ids_from_response).to contain_exactly(maintainer_group.id) + end + end + + context 'group shares' do + let_it_be(:shared_to_owner_group) { create(:group) } + let_it_be(:shared_to_guest_group) { create(:group) } + + before do + create(:group_group_link, :owner, + shared_with_group: owner_group, + shared_group: shared_to_owner_group + ) + + create(:group_group_link, :guest, + shared_with_group: guest_group, + shared_group: shared_to_guest_group + ) + end + + it 'only includes groups arising from group shares where the user has permission to transfer a project to' do + request + + expect(project_ids_from_response).to include(shared_to_owner_group.id) + expect(project_ids_from_response).not_to include(shared_to_guest_group.id) + end + + context 'when the feature flag `include_groups_from_group_shares_in_project_transfer_locations` is disabled' do + before do + stub_feature_flags(include_groups_from_group_shares_in_project_transfer_locations: false) + end + + it 'does not include any groups arising from group shares' do + request + + expect(project_ids_from_response).not_to include(shared_to_owner_group.id, shared_to_guest_group.id) + end + end + end + + def project_ids_from_response + json_response.map { |project| project['id'] } + end + end + + context 'when the user does not have permissions to transfer the project' do + before do + source_group.add_developer(user) + end + + it 'returns 403' do + request + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + describe 'GET /projects/:id/storage' do context 'when unauthenticated' do it 'does not return project storage data' do