diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 3f83bef2c79..68978f8fdd1 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -9,14 +9,12 @@ class Projects::ForksController < Projects::ApplicationController def index base_query = project.forks.includes(:creator) - @forks = base_query.merge(ProjectsFinder.new(current_user: current_user).execute) + forks = ForkProjectsFinder.new(project, params: params.merge(search: params[:filter_projects]), current_user: current_user).execute @total_forks_count = base_query.size - @private_forks_count = @total_forks_count - @forks.size + @private_forks_count = @total_forks_count - forks.size @public_forks_count = @total_forks_count - @private_forks_count - @sort = params[:sort] || 'id_desc' - @forks = @forks.search(params[:filter_projects]) if params[:filter_projects].present? - @forks = @forks.order_by(@sort).page(params[:page]) + @forks = forks.page(params[:page]) respond_to do |format| format.html diff --git a/app/finders/fork_projects_finder.rb b/app/finders/fork_projects_finder.rb new file mode 100644 index 00000000000..28d1b31868e --- /dev/null +++ b/app/finders/fork_projects_finder.rb @@ -0,0 +1,6 @@ +class ForkProjectsFinder < ProjectsFinder + def initialize(project, params: {}, current_user: nil) + project_ids = project.forks.includes(:creator).select(:id) + super(params: params, current_user: current_user, project_ids_relation: project_ids) + end +end diff --git a/changelogs/unreleased/20049-projects-api-forks.yml b/changelogs/unreleased/20049-projects-api-forks.yml new file mode 100644 index 00000000000..c6470620f57 --- /dev/null +++ b/changelogs/unreleased/20049-projects-api-forks.yml @@ -0,0 +1,5 @@ +--- +title: Add an API endpoint to determine the forks of a project +merge_request: +author: +type: added diff --git a/doc/api/projects.md b/doc/api/projects.md index 3144220e588..07331d05231 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -635,6 +635,98 @@ POST /projects/:id/fork | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | | `namespace` | integer/string | yes | The ID or path of the namespace that the project will be forked to | +## List Forks of a project + +>**Note:** This feature was introduced in GitLab 10.1 + +List the projects accessible to the calling user that have an established, forked relationship with the specified project + +``` +GET /projects/:id/forks +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `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 projects matching the search criteria | +| `simple` | boolean | no | Return only the ID, URL, name, and path of each project | +| `owned` | boolean | no | Limit by projects owned by the current user | +| `membership` | boolean | no | Limit by projects that the current user is a member of | +| `starred` | boolean | no | Limit by projects starred by the current user | +| `statistics` | boolean | no | Include project statistics | +| `with_issues_enabled` | boolean | no | Limit by enabled issues feature | +| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/5/forks" +``` + +Example responses: + +```json +[ + { + "id": 3, + "description": null, + "default_branch": "master", + "visibility": "internal", + "ssh_url_to_repo": "git@example.com:diaspora/diaspora-project-site.git", + "http_url_to_repo": "http://example.com/diaspora/diaspora-project-site.git", + "web_url": "http://example.com/diaspora/diaspora-project-site", + "tag_list": [ + "example", + "disapora project" + ], + "name": "Diaspora Project Site", + "name_with_namespace": "Diaspora / Diaspora Project Site", + "path": "diaspora-project-site", + "path_with_namespace": "diaspora/diaspora-project-site", + "issues_enabled": true, + "open_issues_count": 1, + "merge_requests_enabled": true, + "jobs_enabled": true, + "wiki_enabled": true, + "snippets_enabled": false, + "resolve_outdated_diff_discussions": false, + "container_registry_enabled": false, + "created_at": "2013-09-30T13:46:02Z", + "last_activity_at": "2013-09-30T13:46:02Z", + "creator_id": 3, + "namespace": { + "id": 3, + "name": "Diaspora", + "path": "diaspora", + "kind": "group", + "full_path": "diaspora" + }, + "import_status": "none", + "archived": true, + "avatar_url": "http://example.com/uploads/project/avatar/3/uploads/avatar.png", + "shared_runners_enabled": true, + "forks_count": 0, + "star_count": 1, + "public_jobs": true, + "shared_with_groups": [], + "only_allow_merge_if_pipeline_succeeds": false, + "only_allow_merge_if_all_discussions_are_resolved": false, + "request_access_enabled": false, + "_links": { + "self": "http://example.com/api/v4/projects", + "issues": "http://example.com/api/v4/projects/1/issues", + "merge_requests": "http://example.com/api/v4/projects/1/merge_requests", + "repo_branches": "http://example.com/api/v4/projects/1/repository_branches", + "labels": "http://example.com/api/v4/projects/1/labels", + "events": "http://example.com/api/v4/projects/1/events", + "members": "http://example.com/api/v4/projects/1/members" + } + } +] +``` + ## Star a project Stars a given project. Returns status code `304` if the project is already starred. diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 7dc19788462..aab7a6c3f93 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -70,8 +70,11 @@ module API optional :import_url, type: String, desc: 'URL from which the project is imported' end - def present_projects(options = {}) - projects = ProjectsFinder.new(current_user: current_user, params: project_finder_params).execute + def load_projects + ProjectsFinder.new(current_user: current_user, params: project_finder_params).execute + end + + def present_projects(projects, options = {}) projects = reorder_projects(projects) projects = projects.with_statistics if params[:statistics] projects = projects.with_issues_enabled if params[:with_issues_enabled] @@ -111,7 +114,7 @@ module API params[:user] = user - present_projects + present_projects load_projects end end @@ -124,7 +127,7 @@ module API use :statistics_params end get do - present_projects + present_projects load_projects end desc 'Create new project' do @@ -229,6 +232,18 @@ module API end end + desc 'List forks of this project' do + success Entities::Project + end + params do + use :collection_params + end + get ':id/forks' do + forks = ForkProjectsFinder.new(user_project, params: project_finder_params, current_user: current_user).execute + + present_projects forks + end + desc 'Update an existing project' do success Entities::Project end diff --git a/spec/finders/fork_projects_finder_spec.rb b/spec/finders/fork_projects_finder_spec.rb new file mode 100644 index 00000000000..f0cef7ea406 --- /dev/null +++ b/spec/finders/fork_projects_finder_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe ForkProjectsFinder do + let(:source_project) { create(:project, :empty_repo) } + let(:private_fork) { create(:project, :private, :empty_repo, name: 'A') } + let(:internal_fork) { create(:project, :internal, :empty_repo, name: 'B') } + let(:public_fork) { create(:project, :public, :empty_repo, name: 'C') } + + let(:non_member) { create(:user) } + let(:private_fork_member) { create(:user) } + + before do + private_fork.add_developer(private_fork_member) + + source_project.forks << private_fork + source_project.forks << internal_fork + source_project.forks << public_fork + end + + describe '#execute' do + let(:finder) { described_class.new(source_project, params: {}, current_user: current_user) } + + subject { finder.execute } + + describe 'without a user' do + let(:current_user) { nil } + + it { is_expected.to eq([public_fork]) } + end + + describe 'with a user' do + let(:current_user) { non_member } + + it { is_expected.to eq([public_fork, internal_fork]) } + end + + describe 'with a member' do + let(:current_user) { private_fork_member } + + it { is_expected.to eq([public_fork, internal_fork, private_fork]) } + end + end +end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 92e7d797cbd..508df990952 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1181,6 +1181,59 @@ describe API::Projects do end end end + + describe 'GET /projects/:id/forks' do + let(:private_fork) { create(:project, :private, :empty_repo) } + let(:member) { create(:user) } + let(:non_member) { create(:user) } + + before do + private_fork.add_developer(member) + end + + context 'for a forked project' do + before do + post api("/projects/#{private_fork.id}/fork/#{project_fork_source.id}", admin) + private_fork.reload + expect(private_fork.forked_from_project).not_to be_nil + expect(private_fork.forked?).to be_truthy + project_fork_source.reload + expect(project_fork_source.forks.length).to eq(1) + expect(project_fork_source.forks).to include(private_fork) + end + + context 'for a user that can access the forks' do + it 'returns the forks' do + get api("/projects/#{project_fork_source.id}/forks", member) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response.length).to eq(1) + expect(json_response[0]['name']).to eq(private_fork.name) + end + end + + context 'for a user that cannot access the forks' do + it 'returns an empty array' do + get api("/projects/#{project_fork_source.id}/forks", non_member) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response.length).to eq(0) + end + end + end + + context 'for a non-forked project' do + it 'returns an empty array' do + get api("/projects/#{project_fork_source.id}/forks") + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response.length).to eq(0) + end + end + end end describe "POST /projects/:id/share" do