Commit 0dd04723 authored by Dmytro Zaporozhets's avatar Dmytro Zaporozhets

Merge branch 'group-shared-projects-api' into 'master'

Introduce API to fetch shared projects within a group

See merge request gitlab-org/gitlab!30461
parents 616e66ce 679c1275
---
title: Introduce API for fetching shared projects in a group
merge_request: 30461
author:
type: added
...@@ -241,9 +241,142 @@ Example response: ...@@ -241,9 +241,142 @@ Example response:
``` ```
NOTE: **Note:** NOTE: **Note:**
To distinguish between a project in the group and a project shared to the group, the `namespace` attribute can be used. When a project has been shared to the group, its `namespace` will be different from the group the request is being made for. To distinguish between a project in the group and a project shared to the group, the `namespace` attribute can be used. When a project has been shared to the group, its `namespace` will be different from the group the request is being made for.
## List a group's shared projects
Get a list of projects shared to this group. When accessed without authentication, only public shared projects are returned.
By default, this request returns 20 results at a time because the API results [are paginated](README.md#pagination).
```plaintext
GET /groups/:id/projects/shared
```
Parameters:
| Attribute | Type | Required | Description |
| ----------------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `archived` | boolean | no | Limit by archived status |
| `visibility` | string | no | Limit by visibility `public`, `internal`, or `private` |
| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at` |
| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` |
| `search` | string | no | Return list of authorized projects matching the search criteria |
| `simple` | boolean | no | Return only the ID, URL, name, and path of each project |
| `starred` | boolean | no | Limit by projects starred by the current user |
| `with_issues_enabled` | boolean | no | Limit by projects with issues feature enabled. Default is `false` |
| `with_merge_requests_enabled` | boolean | no | Limit by projects with merge requests feature enabled. Default is `false` |
| `min_access_level` | integer | no | Limit to projects where current user has at least this [access level](members.md) |
| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) |
Example response:
```json
[
{
"id":8,
"description":"Shared project for Html5 Boilerplate",
"name":"Html5 Boilerplate",
"name_with_namespace":"H5bp / Html5 Boilerplate",
"path":"html5-boilerplate",
"path_with_namespace":"h5bp/html5-boilerplate",
"created_at":"2020-04-27T06:13:22.642Z",
"default_branch":"master",
"tag_list":[
],
"ssh_url_to_repo":"ssh://git@gitlab.com/h5bp/html5-boilerplate.git",
"http_url_to_repo":"http://gitlab.com/h5bp/html5-boilerplate.git",
"web_url":"http://gitlab.com/h5bp/html5-boilerplate",
"readme_url":"http://gitlab.com/h5bp/html5-boilerplate/-/blob/master/README.md",
"avatar_url":null,
"star_count":0,
"forks_count":4,
"last_activity_at":"2020-04-27T06:13:22.642Z",
"namespace":{
"id":28,
"name":"H5bp",
"path":"h5bp",
"kind":"group",
"full_path":"h5bp",
"parent_id":null,
"avatar_url":null,
"web_url":"http://gitlab.com/groups/h5bp"
},
"_links":{
"self":"http://gitlab.com/api/v4/projects/8",
"issues":"http://gitlab.com/api/v4/projects/8/issues",
"merge_requests":"http://gitlab.com/api/v4/projects/8/merge_requests",
"repo_branches":"http://gitlab.com/api/v4/projects/8/repository/branches",
"labels":"http://gitlab.com/api/v4/projects/8/labels",
"events":"http://gitlab.com/api/v4/projects/8/events",
"members":"http://gitlab.com/api/v4/projects/8/members"
},
"empty_repo":false,
"archived":false,
"visibility":"public",
"resolve_outdated_diff_discussions":false,
"container_registry_enabled":true,
"container_expiration_policy":{
"cadence":"7d",
"enabled":true,
"keep_n":null,
"older_than":null,
"name_regex":null,
"name_regex_keep":null,
"next_run_at":"2020-05-04T06:13:22.654Z"
},
"issues_enabled":true,
"merge_requests_enabled":true,
"wiki_enabled":true,
"jobs_enabled":true,
"snippets_enabled":true,
"can_create_merge_request_in":true,
"issues_access_level":"enabled",
"repository_access_level":"enabled",
"merge_requests_access_level":"enabled",
"forking_access_level":"enabled",
"wiki_access_level":"enabled",
"builds_access_level":"enabled",
"snippets_access_level":"enabled",
"pages_access_level":"enabled",
"emails_disabled":null,
"shared_runners_enabled":true,
"lfs_enabled":true,
"creator_id":1,
"import_status":"failed",
"open_issues_count":10,
"ci_default_git_depth":50,
"public_jobs":true,
"build_timeout":3600,
"auto_cancel_pending_pipelines":"enabled",
"build_coverage_regex":null,
"ci_config_path":null,
"shared_with_groups":[
{
"group_id":24,
"group_name":"Commit451",
"group_full_path":"Commit451",
"group_access_level":30,
"expires_at":null
}
],
"only_allow_merge_if_pipeline_succeeds":false,
"request_access_enabled":true,
"only_allow_merge_if_all_discussions_are_resolved":false,
"remove_source_branch_after_merge":true,
"printing_merge_request_link_enabled":true,
"merge_method":"merge",
"suggestion_commit_message":null,
"auto_devops_enabled":true,
"auto_devops_deploy_strategy":"continuous",
"autoclose_referenced_issues":true,
"repository_storage":"default"
}
]
```
## Details of a group ## Details of a group
Get all details of a group. This endpoint can be accessed without authentication Get all details of a group. This endpoint can be accessed without authentication
...@@ -583,7 +716,6 @@ This endpoint returns: ...@@ -583,7 +716,6 @@ This endpoint returns:
[list a group's projects endpoint](#list-a-groups-projects) instead. [list a group's projects endpoint](#list-a-groups-projects) instead.
NOTE: **Note:** NOTE: **Note:**
The `projects` and `shared_projects` attributes [will be deprecated in GitLab 13.0](https://gitlab.com/gitlab-org/gitlab/-/issues/213797). To get the details of all projects within a group, use the [list a group's projects endpoint](#list-a-groups-projects) instead. The `projects` and `shared_projects` attributes [will be deprecated in GitLab 13.0](https://gitlab.com/gitlab-org/gitlab/-/issues/213797). To get the details of all projects within a group, use the [list a group's projects endpoint](#list-a-groups-projects) instead.
Example response: Example response:
......
...@@ -60,18 +60,14 @@ module API ...@@ -60,18 +60,14 @@ module API
.execute .execute
end end
def find_group_projects(params) def find_group_projects(params, finder_options)
group = find_group!(params[:id]) group = find_group!(params[:id])
options = {
only_owned: !params[:with_shared],
include_subgroups: params[:include_subgroups]
}
projects = GroupProjectsFinder.new( projects = GroupProjectsFinder.new(
group: group, group: group,
current_user: current_user, current_user: current_user,
params: project_finder_params, params: project_finder_params,
options: options options: finder_options
).execute ).execute
projects = projects.with_issues_available_for_user(current_user) if params[:with_issues_enabled] projects = projects.with_issues_available_for_user(current_user) if params[:with_issues_enabled]
projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled] projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled]
...@@ -80,6 +76,17 @@ module API ...@@ -80,6 +76,17 @@ module API
paginate(projects) paginate(projects)
end end
def present_projects(params, projects)
options = {
with: params[:simple] ? Entities::BasicProjectDetails : Entities::Project,
current_user: current_user
}
projects, options = with_custom_attributes(projects, options)
present options[:with].prepare_relation(projects), options
end
def present_groups(params, groups) def present_groups(params, groups)
options = { options = {
with: Entities::Group, with: Entities::Group,
...@@ -226,16 +233,42 @@ module API ...@@ -226,16 +233,42 @@ module API
use :optional_projects_params use :optional_projects_params
end end
get ":id/projects" do get ":id/projects" do
projects = find_group_projects(params) finder_options = {
only_owned: !params[:with_shared],
options = { include_subgroups: params[:include_subgroups]
with: params[:simple] ? Entities::BasicProjectDetails : Entities::Project,
current_user: current_user
} }
projects, options = with_custom_attributes(projects, options) projects = find_group_projects(params, finder_options)
present options[:with].prepare_relation(projects), options present_projects(params, projects)
end
desc 'Get a list of shared projects in this group' do
success Entities::Project
end
params do
optional :archived, type: Boolean, default: false, desc: 'Limit by archived status'
optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values,
desc: 'Limit by visibility'
optional :search, type: String, desc: 'Return list of authorized projects matching the search criteria'
optional :order_by, type: String, values: %w[id name path created_at updated_at last_activity_at],
default: 'created_at', desc: 'Return projects ordered by field'
optional :sort, type: String, values: %w[asc desc], default: 'desc',
desc: 'Return projects sorted in ascending and descending order'
optional :simple, type: Boolean, default: false,
desc: 'Return only the ID, URL, name, and path of each project'
optional :starred, type: Boolean, default: false, desc: 'Limit by starred status'
optional :with_issues_enabled, type: Boolean, default: false, desc: 'Limit by enabled issues feature'
optional :with_merge_requests_enabled, type: Boolean, default: false, desc: 'Limit by enabled merge requests feature'
optional :min_access_level, type: Integer, values: Gitlab::Access.all_values, desc: 'Limit by minimum access level of authenticated user on projects'
use :pagination
use :with_custom_attributes
end
get ":id/projects/shared" do
projects = find_group_projects(params, { only_shared: true })
present_projects(params, projects)
end end
desc 'Get a list of subgroups in this group.' do desc 'Get a list of subgroups in this group.' do
......
...@@ -916,6 +916,181 @@ describe API::Groups do ...@@ -916,6 +916,181 @@ describe API::Groups do
end end
end end
describe "GET /groups/:id/projects/shared" do
let!(:project4) do
create(:project, namespace: group2, path: 'test_project', visibility_level: Gitlab::VisibilityLevel::PRIVATE)
end
let(:path) { "/groups/#{group1.id}/projects/shared" }
before do
create(:project_group_link, project: project2, group: group1)
create(:project_group_link, project: project4, group: group1)
end
context 'when authenticated as user' do
it 'returns the shared projects in the group' do
get api(path, user1)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response.length).to eq(2)
project_ids = json_response.map { |project| project['id'] }
expect(project_ids).to match_array([project2.id, project4.id])
expect(json_response.first['visibility']).to be_present
end
it 'returns shared projects with min access level or higher' do
user = create(:user)
project2.add_guest(user)
project4.add_reporter(user)
get api(path, user), params: { min_access_level: Gitlab::Access::REPORTER }
expect(json_response).to be_an(Array)
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(project4.id)
end
it 'returns the shared projects of the group with simple representation' do
get api(path, user1), params: { simple: true }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response.length).to eq(2)
project_ids = json_response.map { |project| project['id'] }
expect(project_ids).to match_array([project2.id, project4.id])
expect(json_response.first['visibility']).not_to be_present
end
it 'filters the shared projects in the group based on visibility' do
internal_project = create(:project, :internal, namespace: create(:group))
create(:project_group_link, project: internal_project, group: group1)
get api(path, user1), params: { visibility: 'internal' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an(Array)
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(internal_project.id)
end
it 'filters the shared projects in the group based on search params' do
get api(path, user1), params: { search: 'test_project' }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an(Array)
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(project4.id)
end
it 'does not return the projects owned by the group' do
get api(path, user1)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an(Array)
project_ids = json_response.map { |project| project['id'] }
expect(project_ids).not_to include(project1.id)
end
it 'returns 404 for a non-existing group' do
get api("/groups/0000/projects/shared", user1)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'does not return a group not attached to the user' do
group = create(:group, :private)
get api("/groups/#{group.id}/projects/shared", user1)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'only returns shared projects to which user has access' do
project4.add_developer(user3)
get api(path, user3)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(project4.id)
end
it 'only returns the projects starred by user' do
user1.starred_projects = [project2]
get api(path, user1), params: { starred: true }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(project2.id)
end
end
context "when authenticated as admin" do
subject { get api(path, admin) }
it "returns shared projects of an existing group" do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response.length).to eq(2)
project_ids = json_response.map { |project| project['id'] }
expect(project_ids).to match_array([project2.id, project4.id])
end
context 'for a non-existent group' do
let(:path) { "/groups/000/projects/shared" }
it 'returns 404 for a non-existent group' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
it 'avoids N+1 queries' do
control_count = ActiveRecord::QueryRecorder.new do
subject
end.count
create(:project_group_link, project: create(:project), group: group1)
expect do
subject
end.not_to exceed_query_limit(control_count)
end
end
context 'when using group path in URL' do
let(:path) { "/groups/#{group1.path}/projects/shared" }
it 'returns the right details' do
get api(path, admin)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response.length).to eq(2)
project_ids = json_response.map { |project| project['id'] }
expect(project_ids).to match_array([project2.id, project4.id])
end
it 'returns 404 for a non-existent group' do
get api('/groups/unknown/projects/shared', admin)
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
describe 'GET /groups/:id/subgroups' do describe 'GET /groups/:id/subgroups' do
let!(:subgroup1) { create(:group, parent: group1) } let!(:subgroup1) { create(:group, parent: group1) }
let!(:subgroup2) { create(:group, :private, parent: group1) } let!(:subgroup2) { create(:group, :private, parent: group1) }
......
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