diff options
57 files changed, 2810 insertions, 365 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e02b1ae1c..71d38e5453d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 8.16.4 (2017-02-02) + +- Support non-ASCII characters in GFM autocomplete. !8729 +- Fix search bar search param encoding. !8753 +- Fix project name label's for reference in project settings. !8795 +- Fix filtering with multiple words. !8830 +- Fixed services form cancel not redirecting back the integrations settings view. !8843 +- Fix filtering usernames with multiple words. !8851 +- Improve performance of slash commands. !8876 +- Remove old project members when retrying an export. +- Fix permalink discussion note being collapsed. +- Add project ID index to `project_authorizations` table to optimize queries. +- Check public snippets for spam. +- 19164 Add settings dropdown to mobile screens. + ## 8.16.3 (2017-01-27) - Add caching of droplab ajax requests. !8725 diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index f1ff9faef7f..0a26b4c6a8c 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -137,11 +137,13 @@ a { @include transition(background-color, color, border); } -.tree-table td, -.well-list > li { - @include transition(background-color, border-color); -} - .stage-nav-item { @include transition(background-color, box-shadow); } + +.nav-sidebar a, +.dropdown-menu a, +.dropdown-menu button, +.dropdown-menu-nav a { + transition: none; +} diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml index e2a5107a883..dde2e2b644d 100644 --- a/app/views/projects/snippets/_actions.html.haml +++ b/app/views/projects/snippets/_actions.html.haml @@ -1,3 +1,5 @@ +- return unless current_user + .hidden-xs - if can?(current_user, :update_project_snippet, @snippet) = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet), class: "btn btn-grouped" do diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml index 9a9a3ff9220..855a995afa9 100644 --- a/app/views/snippets/_actions.html.haml +++ b/app/views/snippets/_actions.html.haml @@ -1,3 +1,5 @@ +- return unless current_user + .hidden-xs - if can?(current_user, :update_personal_snippet, @snippet) = link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do @@ -5,29 +7,27 @@ - if can?(current_user, :admin_personal_snippet, @snippet) = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do Delete - - if current_user - = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: "New snippet" do - New snippet + = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: "New snippet" do + New snippet - if @snippet.submittable_as_spam? && current_user.admin? = link_to 'Submit as spam', mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: 'Submit as spam' -- if current_user - .visible-xs-block.dropdown - %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } - Options - = icon('caret-down') - .dropdown-menu.dropdown-menu-full-width - %ul +.visible-xs-block.dropdown + %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } + Options + = icon('caret-down') + .dropdown-menu.dropdown-menu-full-width + %ul + %li + = link_to new_snippet_path, title: "New snippet" do + New snippet + - if can?(current_user, :admin_personal_snippet, @snippet) %li - = link_to new_snippet_path, title: "New snippet" do - New snippet - - if can?(current_user, :admin_personal_snippet, @snippet) - %li - = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do - Delete - - if can?(current_user, :update_personal_snippet, @snippet) - %li - = link_to edit_snippet_path(@snippet) do - Edit - - if @snippet.submittable_as_spam? && current_user.admin? - %li - = link_to 'Submit as spam', mark_as_spam_snippet_path(@snippet), method: :post + = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do + Delete + - if can?(current_user, :update_personal_snippet, @snippet) + %li + = link_to edit_snippet_path(@snippet) do + Edit + - if @snippet.submittable_as_spam? && current_user.admin? + %li + = link_to 'Submit as spam', mark_as_spam_snippet_path(@snippet), method: :post diff --git a/changelogs/unreleased/19164-mobile-settings.yml b/changelogs/unreleased/19164-mobile-settings.yml deleted file mode 100644 index c26a20f87e2..00000000000 --- a/changelogs/unreleased/19164-mobile-settings.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: 19164 Add settings dropdown to mobile screens -merge_request: -author: diff --git a/changelogs/unreleased/26863-Remove-hover-animation-from-row-elements.yml b/changelogs/unreleased/26863-Remove-hover-animation-from-row-elements.yml new file mode 100644 index 00000000000..8dfabf87c2a --- /dev/null +++ b/changelogs/unreleased/26863-Remove-hover-animation-from-row-elements.yml @@ -0,0 +1,4 @@ +--- +title: Remove hover animation from row elements +merge_request: +author: diff --git a/changelogs/unreleased/27067-mention-user-dropdown-does-not-suggest-by-non-ascii-characters-in-name.yml b/changelogs/unreleased/27067-mention-user-dropdown-does-not-suggest-by-non-ascii-characters-in-name.yml deleted file mode 100644 index 1758ed9e9ea..00000000000 --- a/changelogs/unreleased/27067-mention-user-dropdown-does-not-suggest-by-non-ascii-characters-in-name.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Support non-ASCII characters in GFM autocomplete -merge_request: 8729 -author: diff --git a/changelogs/unreleased/27089-26860-27151-fix-discussion-note-permalink-collapsed.yml b/changelogs/unreleased/27089-26860-27151-fix-discussion-note-permalink-collapsed.yml deleted file mode 100644 index ddd454da376..00000000000 --- a/changelogs/unreleased/27089-26860-27151-fix-discussion-note-permalink-collapsed.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix permalink discussion note being collapsed -merge_request: -author: diff --git a/changelogs/unreleased/27248-filtered-search-does-not-allow-filtering-labels-with-multiple-words.yml b/changelogs/unreleased/27248-filtered-search-does-not-allow-filtering-labels-with-multiple-words.yml deleted file mode 100644 index 6e036923158..00000000000 --- a/changelogs/unreleased/27248-filtered-search-does-not-allow-filtering-labels-with-multiple-words.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix filtering with multiple words -merge_request: 8830 -author: diff --git a/changelogs/unreleased/27259-label-for-references-the-wrong-associated-text-input.yml b/changelogs/unreleased/27259-label-for-references-the-wrong-associated-text-input.yml deleted file mode 100644 index 2591f161bc5..00000000000 --- a/changelogs/unreleased/27259-label-for-references-the-wrong-associated-text-input.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix project name label's for reference in project settings -merge_request: 8795 -author: diff --git a/changelogs/unreleased/9-0-api-changes.yml b/changelogs/unreleased/9-0-api-changes.yml new file mode 100644 index 00000000000..2f0f1887257 --- /dev/null +++ b/changelogs/unreleased/9-0-api-changes.yml @@ -0,0 +1,4 @@ +--- +title: Remove deprecated MR and Issue endpoints and preserve V3 namespace +merge_request: 8967 +author: diff --git a/changelogs/unreleased/fix-cancel-integration-settings.yml b/changelogs/unreleased/fix-cancel-integration-settings.yml deleted file mode 100644 index 294b0aa5db9..00000000000 --- a/changelogs/unreleased/fix-cancel-integration-settings.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fixed services form cancel not redirecting back the integrations settings view -merge_request: 8843 -author: diff --git a/changelogs/unreleased/fix-filtering-username-with-multiple-words.yml b/changelogs/unreleased/fix-filtering-username-with-multiple-words.yml deleted file mode 100644 index 3513f5afdfb..00000000000 --- a/changelogs/unreleased/fix-filtering-username-with-multiple-words.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix filtering usernames with multiple words -merge_request: 8851 -author: diff --git a/changelogs/unreleased/fix-import-user-validation-error.yml b/changelogs/unreleased/fix-import-user-validation-error.yml deleted file mode 100644 index 985a3b0b26f..00000000000 --- a/changelogs/unreleased/fix-import-user-validation-error.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Remove old project members when retrying an export -merge_request: -author: diff --git a/changelogs/unreleased/fix-search-bar-search-param.yml b/changelogs/unreleased/fix-search-bar-search-param.yml deleted file mode 100644 index 4df14d3bf13..00000000000 --- a/changelogs/unreleased/fix-search-bar-search-param.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix search bar search param encoding -merge_request: 8753 -author: diff --git a/changelogs/unreleased/sh-add-project-id-index-project-authorizations.yml b/changelogs/unreleased/sh-add-project-id-index-project-authorizations.yml deleted file mode 100644 index e69fcd2aa63..00000000000 --- a/changelogs/unreleased/sh-add-project-id-index-project-authorizations.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Add project ID index to `project_authorizations` table to optimize queries -merge_request: -author: diff --git a/changelogs/unreleased/snippet-spam.yml b/changelogs/unreleased/snippet-spam.yml deleted file mode 100644 index 4867f088953..00000000000 --- a/changelogs/unreleased/snippet-spam.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Check public snippets for spam -merge_request: -author: diff --git a/changelogs/unreleased/zj-slow-service-fetch.yml b/changelogs/unreleased/zj-slow-service-fetch.yml deleted file mode 100644 index 8037361d2fc..00000000000 --- a/changelogs/unreleased/zj-slow-service-fetch.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Improve performance of slash commands -merge_request: 8876 -author: diff --git a/doc/administration/high_availability/nfs.md b/doc/administration/high_availability/nfs.md index 5602d70f1ef..3893d837006 100644 --- a/doc/administration/high_availability/nfs.md +++ b/doc/administration/high_availability/nfs.md @@ -47,13 +47,13 @@ When using default Omnibus configuration you will need to share 5 data locations between all GitLab cluster nodes. No other locations should be shared. The following are the 5 locations you need to mount: -| Location | Description | -| -------- | ----------- | -| `/var/opt/gitlab/git-data` | Git repository data. This will account for a large portion of your data -| `/var/opt/gitlab/.ssh` | SSH `authorized_keys` file and keys used to import repositories from some other Git services -| `/var/opt/gitlab/gitlab-rails/uploads` | User uploaded attachments -| `/var/opt/gitlab/gitlab-rails/shared` | Build artifacts, GitLab Pages, LFS objects, temp files, etc. If you're using LFS this may also account for a large portion of your data -| `/var/opt/gitlab/gitlab-ci/builds` | GitLab CI build traces +| Location | Description | Default configuration | +| -------- | ----------- | --------------------- | +| `/var/opt/gitlab/git-data` | Git repository data. This will account for a large portion of your data | `git_data_dirs({"default" => "/var/opt/gitlab/git-data"})` +| `/var/opt/gitlab/.ssh` | SSH `authorized_keys` file and keys used to import repositories from some other Git services | `user['home'] = '/var/opt/gitlab/'` +| `/var/opt/gitlab/gitlab-rails/uploads` | User uploaded attachments | `gitlab_rails['uploads_directory'] = '/var/opt/gitlab/gitlab-rails/uploads'` +| `/var/opt/gitlab/gitlab-rails/shared` | Build artifacts, GitLab Pages, LFS objects, temp files, etc. If you're using LFS this may also account for a large portion of your data | `gitlab_rails['shared_path'] = '/var/opt/gitlab/gitlab-rails/shared'` +| `/var/opt/gitlab/gitlab-ci/builds` | GitLab CI build traces | `gitlab_ci['builds_directory'] = '/var/opt/gitlab/gitlab-ci/builds'` Other GitLab directories should not be shared between nodes. They contain node-specific files and GitLab code that does not need to be shared. To ship @@ -73,10 +73,10 @@ as subdirectories. Mount `/gitlab-data` then use the following Omnibus configuration to move each data location to a subdirectory: ```ruby +git_data_dirs({"default" => "/gitlab-data/git-data"}) user['home'] = '/gitlab-data/home' -git_data_dir '/gitlab-data/git-data' -gitlab_rails['shared_path'] = '/gitlab-data/shared' gitlab_rails['uploads_directory'] = '/gitlab-data/uploads' +gitlab_rails['shared_path'] = '/gitlab-data/shared' gitlab_ci['builds_directory'] = '/gitlab-data/builds' ``` diff --git a/doc/api/issues.md b/doc/api/issues.md index b276d1ad918..7c0a444d4fa 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -181,7 +181,6 @@ GET /projects/:id/issues?labels=foo,bar GET /projects/:id/issues?labels=foo,bar&state=opened GET /projects/:id/issues?milestone=1.0.0 GET /projects/:id/issues?milestone=1.0.0&state=opened -GET /projects/:id/issues?iid=42 ``` | Attribute | Type | Required | Description | diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 7b005591545..1cf7632d60c 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -10,8 +10,7 @@ The pagination parameters `page` and `per_page` can be used to restrict the list GET /projects/:id/merge_requests GET /projects/:id/merge_requests?state=opened GET /projects/:id/merge_requests?state=all -GET /projects/:id/merge_requests?iid=42 -GET /projects/:id/merge_requests?iid[]=42&iid[]=43 +GET /projects/:id/merge_requests?iids[]=42&iids[]=43 ``` Parameters: diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md index 8a6baed5987..73dde599b7e 100644 --- a/doc/api/repository_files.md +++ b/doc/api/repository_files.md @@ -53,7 +53,7 @@ Example response: ```json { - "file_name": "app/project.rb", + "file_path": "app/project.rb", "branch_name": "master" } ``` @@ -82,7 +82,7 @@ Example response: ```json { - "file_name": "app/project.rb", + "file_path": "app/project.rb", "branch_name": "master" } ``` @@ -120,7 +120,7 @@ Example response: ```json { - "file_name": "app/project.rb", + "file_path": "app/project.rb", "branch_name": "master" } ``` diff --git a/doc/api/v3_to_v4.md b/doc/api/v3_to_v4.md index 01de1e59fcb..9748aec17ad 100644 --- a/doc/api/v3_to_v4.md +++ b/doc/api/v3_to_v4.md @@ -7,4 +7,7 @@ changes are in V4: ### Changes - Removed `/projects/:search` (use: `/projects?search=x`) +- `iid` filter has been removed from `projects/:id/issues` +- `projects/:id/merge_requests?iid[]=x&iid[]=y` array filter has been renamed to `iids` +- Endpoints under `projects/merge_request/:id` have been removed (use: `projects/merge_requests/:id`) diff --git a/lib/api/api.rb b/lib/api/api.rb index 090109d5e6f..1950d2791ab 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -5,6 +5,8 @@ module API version %w(v3 v4), using: :path version 'v3', using: :path do + mount ::API::V3::Issues + mount ::API::V3::MergeRequests mount ::API::V3::Projects end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index fe016c1ec0a..90fca20d4fa 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -15,8 +15,6 @@ module API labels = args.delete(:labels) args[:label_name] = labels if match_all_labels - args[:search] = "#{Issue.reference_prefix}#{args.delete(:iid)}" if args.key?(:iid) - issues = IssuesFinder.new(current_user, args).execute.inc_notes_with_associations # TODO: Remove in 9.0 pass `label_name: args.delete(:labels)` to IssuesFinder @@ -97,7 +95,6 @@ module API params do optional :state, type: String, values: %w[opened closed all], default: 'all', desc: 'Return opened, closed, or all issues' - optional :iid, type: Integer, desc: 'Return the issue having the given `iid`' use :issues_params end get ":id/issues" do diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 7ffb38e62da..782147883c8 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -2,8 +2,6 @@ module API class MergeRequests < Grape::API include PaginationParams - DEPRECATION_MESSAGE = 'This endpoint is deprecated and will be removed in GitLab 9.0.'.freeze - before { authenticate! } params do @@ -46,14 +44,14 @@ module API desc: 'Return merge requests ordered by `created_at` or `updated_at` fields.' optional :sort, type: String, values: %w[asc desc], default: 'desc', desc: 'Return merge requests sorted in `asc` or `desc` order.' - optional :iid, type: Array[Integer], desc: 'The IID of the merge requests' + optional :iids, type: Array[Integer], desc: 'The IID array of merge requests' use :pagination end get ":id/merge_requests" do authorize! :read_merge_request, user_project merge_requests = user_project.merge_requests.inc_notes_with_associations - merge_requests = filter_by_iid(merge_requests, params[:iid]) if params[:iid].present? + merge_requests = filter_by_iid(merge_requests, params[:iids]) if params[:iids].present? merge_requests = case params[:state] @@ -104,177 +102,167 @@ module API merge_request.destroy end - # Routing "merge_request/:merge_request_id/..." is DEPRECATED and WILL BE REMOVED in version 9.0 - # Use "merge_requests/:merge_request_id/..." instead. - # params do requires :merge_request_id, type: Integer, desc: 'The ID of a merge request' end - { ":id/merge_request/:merge_request_id" => :deprecated, ":id/merge_requests/:merge_request_id" => :ok }.each do |path, status| - desc 'Get a single merge request' do - if status == :deprecated - detail DEPRECATION_MESSAGE - end - success Entities::MergeRequest - end - get path do - merge_request = find_merge_request_with_access(params[:merge_request_id]) + desc 'Get a single merge request' do + success Entities::MergeRequest + end + get ':id/merge_requests/:merge_request_id' do + merge_request = find_merge_request_with_access(params[:merge_request_id]) - present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project - end + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project + end - desc 'Get the commits of a merge request' do - success Entities::RepoCommit - end - get "#{path}/commits" do - merge_request = find_merge_request_with_access(params[:merge_request_id]) + desc 'Get the commits of a merge request' do + success Entities::RepoCommit + end + get ':id/merge_requests/:merge_request_id/commits' do + merge_request = find_merge_request_with_access(params[:merge_request_id]) - present merge_request.commits, with: Entities::RepoCommit - end + present merge_request.commits, with: Entities::RepoCommit + end - desc 'Show the merge request changes' do - success Entities::MergeRequestChanges - end - get "#{path}/changes" do - merge_request = find_merge_request_with_access(params[:merge_request_id]) + desc 'Show the merge request changes' do + success Entities::MergeRequestChanges + end + get ':id/merge_requests/:merge_request_id/changes' do + merge_request = find_merge_request_with_access(params[:merge_request_id]) - present merge_request, with: Entities::MergeRequestChanges, current_user: current_user - end + present merge_request, with: Entities::MergeRequestChanges, current_user: current_user + end - desc 'Update a merge request' do - success Entities::MergeRequest - end - params do - optional :title, type: String, allow_blank: false, desc: 'The title of the merge request' - optional :target_branch, type: String, allow_blank: false, desc: 'The target branch' - optional :state_event, type: String, values: %w[close reopen merge], - desc: 'Status of the merge request' - use :optional_params - at_least_one_of :title, :target_branch, :description, :assignee_id, - :milestone_id, :labels, :state_event, - :remove_source_branch - end - put path do - merge_request = find_merge_request_with_access(params.delete(:merge_request_id), :update_merge_request) + desc 'Update a merge request' do + success Entities::MergeRequest + end + params do + optional :title, type: String, allow_blank: false, desc: 'The title of the merge request' + optional :target_branch, type: String, allow_blank: false, desc: 'The target branch' + optional :state_event, type: String, values: %w[close reopen merge], + desc: 'Status of the merge request' + use :optional_params + at_least_one_of :title, :target_branch, :description, :assignee_id, + :milestone_id, :labels, :state_event, + :remove_source_branch + end + put ':id/merge_requests/:merge_request_id' do + merge_request = find_merge_request_with_access(params.delete(:merge_request_id), :update_merge_request) - mr_params = declared_params(include_missing: false) - mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) if mr_params[:remove_source_branch].present? + mr_params = declared_params(include_missing: false) + mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) if mr_params[:remove_source_branch].present? - merge_request = ::MergeRequests::UpdateService.new(user_project, current_user, mr_params).execute(merge_request) + merge_request = ::MergeRequests::UpdateService.new(user_project, current_user, mr_params).execute(merge_request) - if merge_request.valid? - present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project - else - handle_merge_request_errors! merge_request.errors - end + if merge_request.valid? + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project + else + handle_merge_request_errors! merge_request.errors end + end - desc 'Merge a merge request' do - success Entities::MergeRequest - end - params do - optional :merge_commit_message, type: String, desc: 'Custom merge commit message' - optional :should_remove_source_branch, type: Boolean, - desc: 'When true, the source branch will be deleted if possible' - optional :merge_when_build_succeeds, type: Boolean, - desc: 'When true, this merge request will be merged when the pipeline succeeds' - optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch' - end - put "#{path}/merge" do - merge_request = find_project_merge_request(params[:merge_request_id]) + desc 'Merge a merge request' do + success Entities::MergeRequest + end + params do + optional :merge_commit_message, type: String, desc: 'Custom merge commit message' + optional :should_remove_source_branch, type: Boolean, + desc: 'When true, the source branch will be deleted if possible' + optional :merge_when_build_succeeds, type: Boolean, + desc: 'When true, this merge request will be merged when the pipeline succeeds' + optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch' + end + put ':id/merge_requests/:merge_request_id/merge' do + merge_request = find_project_merge_request(params[:merge_request_id]) - # Merge request can not be merged - # because user dont have permissions to push into target branch - unauthorized! unless merge_request.can_be_merged_by?(current_user) + # Merge request can not be merged + # because user dont have permissions to push into target branch + unauthorized! unless merge_request.can_be_merged_by?(current_user) - not_allowed! unless merge_request.mergeable_state? + not_allowed! unless merge_request.mergeable_state? - render_api_error!('Branch cannot be merged', 406) unless merge_request.mergeable? + render_api_error!('Branch cannot be merged', 406) unless merge_request.mergeable? - if params[:sha] && merge_request.diff_head_sha != params[:sha] - render_api_error!("SHA does not match HEAD of source branch: #{merge_request.diff_head_sha}", 409) - end + if params[:sha] && merge_request.diff_head_sha != params[:sha] + render_api_error!("SHA does not match HEAD of source branch: #{merge_request.diff_head_sha}", 409) + end - merge_params = { - commit_message: params[:merge_commit_message], - should_remove_source_branch: params[:should_remove_source_branch] - } - - if params[:merge_when_build_succeeds] && merge_request.head_pipeline && merge_request.head_pipeline.active? - ::MergeRequests::MergeWhenPipelineSucceedsService - .new(merge_request.target_project, current_user, merge_params) - .execute(merge_request) - else - ::MergeRequests::MergeService - .new(merge_request.target_project, current_user, merge_params) - .execute(merge_request) - end + merge_params = { + commit_message: params[:merge_commit_message], + should_remove_source_branch: params[:should_remove_source_branch] + } - present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project + if params[:merge_when_build_succeeds] && merge_request.head_pipeline && merge_request.head_pipeline.active? + ::MergeRequests::MergeWhenPipelineSucceedsService + .new(merge_request.target_project, current_user, merge_params) + .execute(merge_request) + else + ::MergeRequests::MergeService + .new(merge_request.target_project, current_user, merge_params) + .execute(merge_request) end - desc 'Cancel merge if "Merge When Pipeline Succeeds" is enabled' do - success Entities::MergeRequest - end - post "#{path}/cancel_merge_when_build_succeeds" do - merge_request = find_project_merge_request(params[:merge_request_id]) + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project + end - unauthorized! unless merge_request.can_cancel_merge_when_build_succeeds?(current_user) + desc 'Cancel merge if "Merge When Pipeline Succeeds" is enabled' do + success Entities::MergeRequest + end + post ':id/merge_requests/:merge_request_id/cancel_merge_when_build_succeeds' do + merge_request = find_project_merge_request(params[:merge_request_id]) - ::MergeRequest::MergeWhenPipelineSucceedsService - .new(merge_request.target_project, current_user) - .cancel(merge_request) - end + unauthorized! unless merge_request.can_cancel_merge_when_build_succeeds?(current_user) - desc 'Get the comments of a merge request' do - detail 'Duplicate. DEPRECATED and WILL BE REMOVED in 9.0' - success Entities::MRNote - end - params do - use :pagination - end - get "#{path}/comments" do - merge_request = find_merge_request_with_access(params[:merge_request_id]) - present paginate(merge_request.notes.fresh), with: Entities::MRNote - end + ::MergeRequest::MergeWhenPipelineSucceedsService + .new(merge_request.target_project, current_user) + .cancel(merge_request) + end - desc 'Post a comment to a merge request' do - detail 'Duplicate. DEPRECATED and WILL BE REMOVED in 9.0' - success Entities::MRNote - end - params do - requires :note, type: String, desc: 'The text of the comment' - end - post "#{path}/comments" do - merge_request = find_merge_request_with_access(params[:merge_request_id], :create_note) + desc 'Get the comments of a merge request' do + success Entities::MRNote + end + params do + use :pagination + end + get ':id/merge_requests/:merge_request_id/comments' do + merge_request = find_merge_request_with_access(params[:merge_request_id]) + present paginate(merge_request.notes.fresh), with: Entities::MRNote + end - opts = { - note: params[:note], - noteable_type: 'MergeRequest', - noteable_id: merge_request.id - } + desc 'Post a comment to a merge request' do + success Entities::MRNote + end + params do + requires :note, type: String, desc: 'The text of the comment' + end + post ':id/merge_requests/:merge_request_id/comments' do + merge_request = find_merge_request_with_access(params[:merge_request_id], :create_note) - note = ::Notes::CreateService.new(user_project, current_user, opts).execute + opts = { + note: params[:note], + noteable_type: 'MergeRequest', + noteable_id: merge_request.id + } - if note.save - present note, with: Entities::MRNote - else - render_api_error!("Failed to save note #{note.errors.messages}", 400) - end - end + note = ::Notes::CreateService.new(user_project, current_user, opts).execute - desc 'List issues that will be closed on merge' do - success Entities::MRNote - end - params do - use :pagination - end - get "#{path}/closes_issues" do - merge_request = find_merge_request_with_access(params[:merge_request_id]) - issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user)) - present paginate(issues), with: issue_entity(user_project), current_user: current_user + if note.save + present note, with: Entities::MRNote + else + render_api_error!("Failed to save note #{note.errors.messages}", 400) end end + + desc 'List issues that will be closed on merge' do + success Entities::MRNote + end + params do + use :pagination + end + get ':id/merge_requests/:merge_request_id/closes_issues' do + merge_request = find_merge_request_with_access(params[:merge_request_id]) + issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user)) + present paginate(issues), with: issue_entity(user_project), current_user: current_user + end end end end diff --git a/lib/api/v3/issues.rb b/lib/api/v3/issues.rb new file mode 100644 index 00000000000..be3ecc29449 --- /dev/null +++ b/lib/api/v3/issues.rb @@ -0,0 +1,231 @@ +module API + module V3 + class Issues < Grape::API + include PaginationParams + + before { authenticate! } + + helpers do + def find_issues(args = {}) + args = params.merge(args) + + args.delete(:id) + args[:milestone_title] = args.delete(:milestone) + + match_all_labels = args.delete(:match_all_labels) + labels = args.delete(:labels) + args[:label_name] = labels if match_all_labels + + args[:search] = "#{Issue.reference_prefix}#{args.delete(:iid)}" if args.key?(:iid) + + issues = IssuesFinder.new(current_user, args).execute.inc_notes_with_associations + + if !match_all_labels && labels.present? + issues = issues.includes(:labels).where('labels.title' => labels.split(',')) + end + + issues.reorder(args[:order_by] => args[:sort]) + end + + params :issues_params do + optional :labels, type: String, desc: 'Comma-separated list of label names' + optional :milestone, type: String, desc: 'Milestone title' + optional :order_by, type: String, values: %w[created_at updated_at], default: 'created_at', + desc: 'Return issues ordered by `created_at` or `updated_at` fields.' + optional :sort, type: String, values: %w[asc desc], default: 'desc', + desc: 'Return issues sorted in `asc` or `desc` order.' + optional :milestone, type: String, desc: 'Return issues for a specific milestone' + use :pagination + end + + params :issue_params do + optional :description, type: String, desc: 'The description of an issue' + optional :assignee_id, type: Integer, desc: 'The ID of a user to assign issue' + optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign issue' + optional :labels, type: String, desc: 'Comma-separated list of label names' + optional :due_date, type: String, desc: 'Date time string in the format YEAR-MONTH-DAY' + optional :confidential, type: Boolean, desc: 'Boolean parameter if the issue should be confidential' + end + end + + resource :issues do + desc "Get currently authenticated user's issues" do + success Entities::Issue + end + params do + optional :state, type: String, values: %w[opened closed all], default: 'all', + desc: 'Return opened, closed, or all issues' + use :issues_params + end + get do + issues = find_issues(scope: 'authored') + + present paginate(issues), with: Entities::Issue, current_user: current_user + end + end + + params do + requires :id, type: String, desc: 'The ID of a group' + end + resource :groups do + desc 'Get a list of group issues' do + success Entities::Issue + end + params do + optional :state, type: String, values: %w[opened closed all], default: 'opened', + desc: 'Return opened, closed, or all issues' + use :issues_params + end + get ":id/issues" do + group = find_group!(params[:id]) + + issues = find_issues(group_id: group.id, state: params[:state] || 'opened', match_all_labels: true) + + present paginate(issues), with: Entities::Issue, current_user: current_user + end + end + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects do + include TimeTrackingEndpoints + + desc 'Get a list of project issues' do + detail 'iid filter is deprecated have been removed on V4' + success Entities::Issue + end + params do + optional :state, type: String, values: %w[opened closed all], default: 'all', + desc: 'Return opened, closed, or all issues' + optional :iid, type: Integer, desc: 'Return the issue having the given `iid`' + use :issues_params + end + get ":id/issues" do + project = find_project(params[:id]) + + issues = find_issues(project_id: project.id) + + present paginate(issues), with: Entities::Issue, current_user: current_user, project: user_project + end + + desc 'Get a single project issue' do + success Entities::Issue + end + params do + requires :issue_id, type: Integer, desc: 'The ID of a project issue' + end + get ":id/issues/:issue_id" do + issue = find_project_issue(params[:issue_id]) + present issue, with: Entities::Issue, current_user: current_user, project: user_project + end + + desc 'Create a new project issue' do + success Entities::Issue + end + params do + requires :title, type: String, desc: 'The title of an issue' + optional :created_at, type: DateTime, + desc: 'Date time when the issue was created. Available only for admins and project owners.' + optional :merge_request_for_resolving_discussions, type: Integer, + desc: 'The IID of a merge request for which to resolve discussions' + use :issue_params + end + post ':id/issues' do + # Setting created_at time only allowed for admins and project owners + unless current_user.admin? || user_project.owner == current_user + params.delete(:created_at) + end + + issue_params = declared_params(include_missing: false) + + if merge_request_iid = params[:merge_request_for_resolving_discussions] + issue_params[:merge_request_for_resolving_discussions] = MergeRequestsFinder.new(current_user, project_id: user_project.id). + execute. + find_by(iid: merge_request_iid) + end + + issue = ::Issues::CreateService.new(user_project, + current_user, + issue_params.merge(request: request, api: true)).execute + if issue.spam? + render_api_error!({ error: 'Spam detected' }, 400) + end + + if issue.valid? + present issue, with: Entities::Issue, current_user: current_user, project: user_project + else + render_validation_error!(issue) + end + end + + desc 'Update an existing issue' do + success Entities::Issue + end + params do + requires :issue_id, type: Integer, desc: 'The ID of a project issue' + optional :title, type: String, desc: 'The title of an issue' + optional :updated_at, type: DateTime, + desc: 'Date time when the issue was updated. Available only for admins and project owners.' + optional :state_event, type: String, values: %w[reopen close], desc: 'State of the issue' + use :issue_params + at_least_one_of :title, :description, :assignee_id, :milestone_id, + :labels, :created_at, :due_date, :confidential, :state_event + end + put ':id/issues/:issue_id' do + issue = user_project.issues.find(params.delete(:issue_id)) + authorize! :update_issue, issue + + # Setting created_at time only allowed for admins and project owners + unless current_user.admin? || user_project.owner == current_user + params.delete(:updated_at) + end + + issue = ::Issues::UpdateService.new(user_project, + current_user, + declared_params(include_missing: false)).execute(issue) + + if issue.valid? + present issue, with: Entities::Issue, current_user: current_user, project: user_project + else + render_validation_error!(issue) + end + end + + desc 'Move an existing issue' do + success Entities::Issue + end + params do + requires :issue_id, type: Integer, desc: 'The ID of a project issue' + requires :to_project_id, type: Integer, desc: 'The ID of the new project' + end + post ':id/issues/:issue_id/move' do + issue = user_project.issues.find_by(id: params[:issue_id]) + not_found!('Issue') unless issue + + new_project = Project.find_by(id: params[:to_project_id]) + not_found!('Project') unless new_project + + begin + issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project) + present issue, with: Entities::Issue, current_user: current_user, project: user_project + rescue ::Issues::MoveService::MoveError => error + render_api_error!(error.message, 400) + end + end + + desc 'Delete a project issue' + params do + requires :issue_id, type: Integer, desc: 'The ID of a project issue' + end + delete ":id/issues/:issue_id" do + issue = user_project.issues.find_by(id: params[:issue_id]) + not_found!('Issue') unless issue + + authorize!(:destroy_issue, issue) + issue.destroy + end + end + end + end +end diff --git a/lib/api/v3/merge_requests.rb b/lib/api/v3/merge_requests.rb new file mode 100644 index 00000000000..1af70cf58cc --- /dev/null +++ b/lib/api/v3/merge_requests.rb @@ -0,0 +1,280 @@ +module API + module V3 + class MergeRequests < Grape::API + include PaginationParams + + DEPRECATION_MESSAGE = 'This endpoint is deprecated and has been removed on V4'.freeze + + before { authenticate! } + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects do + include TimeTrackingEndpoints + + helpers do + def handle_merge_request_errors!(errors) + if errors[:project_access].any? + error!(errors[:project_access], 422) + elsif errors[:branch_conflict].any? + error!(errors[:branch_conflict], 422) + elsif errors[:validate_fork].any? + error!(errors[:validate_fork], 422) + elsif errors[:validate_branches].any? + conflict!(errors[:validate_branches]) + end + + render_api_error!(errors, 400) + end + + params :optional_params do + optional :description, type: String, desc: 'The description of the merge request' + optional :assignee_id, type: Integer, desc: 'The ID of a user to assign the merge request' + optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign the merge request' + optional :labels, type: String, desc: 'Comma-separated list of label names' + optional :remove_source_branch, type: Boolean, desc: 'Remove source branch when merging' + end + end + + desc 'List merge requests' do + detail 'iid filter is deprecated have been removed on V4' + success Entities::MergeRequest + end + params do + optional :state, type: String, values: %w[opened closed merged all], default: 'all', + desc: 'Return opened, closed, merged, or all merge requests' + optional :order_by, type: String, values: %w[created_at updated_at], default: 'created_at', + desc: 'Return merge requests ordered by `created_at` or `updated_at` fields.' + optional :sort, type: String, values: %w[asc desc], default: 'desc', + desc: 'Return merge requests sorted in `asc` or `desc` order.' + optional :iid, type: Array[Integer], desc: 'The IID of the merge requests' + use :pagination + end + get ":id/merge_requests" do + authorize! :read_merge_request, user_project + + merge_requests = user_project.merge_requests.inc_notes_with_associations + merge_requests = filter_by_iid(merge_requests, params[:iid]) if params[:iid].present? + + merge_requests = + case params[:state] + when 'opened' then merge_requests.opened + when 'closed' then merge_requests.closed + when 'merged' then merge_requests.merged + else merge_requests + end + + merge_requests = merge_requests.reorder(params[:order_by] => params[:sort]) + present paginate(merge_requests), with: Entities::MergeRequest, current_user: current_user, project: user_project + end + + desc 'Create a merge request' do + success Entities::MergeRequest + end + params do + requires :title, type: String, desc: 'The title of the merge request' + requires :source_branch, type: String, desc: 'The source branch' + requires :target_branch, type: String, desc: 'The target branch' + optional :target_project_id, type: Integer, + desc: 'The target project of the merge request defaults to the :id of the project' + use :optional_params + end + post ":id/merge_requests" do + authorize! :create_merge_request, user_project + + mr_params = declared_params(include_missing: false) + mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) if mr_params[:remove_source_branch].present? + + merge_request = ::MergeRequests::CreateService.new(user_project, current_user, mr_params).execute + + if merge_request.valid? + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project + else + handle_merge_request_errors! merge_request.errors + end + end + + desc 'Delete a merge request' + params do + requires :merge_request_id, type: Integer, desc: 'The ID of a merge request' + end + delete ":id/merge_requests/:merge_request_id" do + merge_request = find_project_merge_request(params[:merge_request_id]) + + authorize!(:destroy_merge_request, merge_request) + merge_request.destroy + end + + params do + requires :merge_request_id, type: Integer, desc: 'The ID of a merge request' + end + { ":id/merge_request/:merge_request_id" => :deprecated, ":id/merge_requests/:merge_request_id" => :ok }.each do |path, status| + desc 'Get a single merge request' do + if status == :deprecated + detail DEPRECATION_MESSAGE + end + success Entities::MergeRequest + end + get path do + merge_request = find_merge_request_with_access(params[:merge_request_id]) + + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project + end + + desc 'Get the commits of a merge request' do + success Entities::RepoCommit + end + get "#{path}/commits" do + merge_request = find_merge_request_with_access(params[:merge_request_id]) + + present merge_request.commits, with: Entities::RepoCommit + end + + desc 'Show the merge request changes' do + success Entities::MergeRequestChanges + end + get "#{path}/changes" do + merge_request = find_merge_request_with_access(params[:merge_request_id]) + + present merge_request, with: Entities::MergeRequestChanges, current_user: current_user + end + + desc 'Update a merge request' do + success Entities::MergeRequest + end + params do + optional :title, type: String, allow_blank: false, desc: 'The title of the merge request' + optional :target_branch, type: String, allow_blank: false, desc: 'The target branch' + optional :state_event, type: String, values: %w[close reopen merge], + desc: 'Status of the merge request' + use :optional_params + at_least_one_of :title, :target_branch, :description, :assignee_id, + :milestone_id, :labels, :state_event, + :remove_source_branch + end + put path do + merge_request = find_merge_request_with_access(params.delete(:merge_request_id), :update_merge_request) + + mr_params = declared_params(include_missing: false) + mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) if mr_params[:remove_source_branch].present? + + merge_request = ::MergeRequests::UpdateService.new(user_project, current_user, mr_params).execute(merge_request) + + if merge_request.valid? + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project + else + handle_merge_request_errors! merge_request.errors + end + end + + desc 'Merge a merge request' do + success Entities::MergeRequest + end + params do + optional :merge_commit_message, type: String, desc: 'Custom merge commit message' + optional :should_remove_source_branch, type: Boolean, + desc: 'When true, the source branch will be deleted if possible' + optional :merge_when_build_succeeds, type: Boolean, + desc: 'When true, this merge request will be merged when the pipeline succeeds' + optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch' + end + put "#{path}/merge" do + merge_request = find_project_merge_request(params[:merge_request_id]) + + # Merge request can not be merged + # because user dont have permissions to push into target branch + unauthorized! unless merge_request.can_be_merged_by?(current_user) + + not_allowed! unless merge_request.mergeable_state? + + render_api_error!('Branch cannot be merged', 406) unless merge_request.mergeable? + + if params[:sha] && merge_request.diff_head_sha != params[:sha] + render_api_error!("SHA does not match HEAD of source branch: #{merge_request.diff_head_sha}", 409) + end + + merge_params = { + commit_message: params[:merge_commit_message], + should_remove_source_branch: params[:should_remove_source_branch] + } + + if params[:merge_when_build_succeeds] && merge_request.head_pipeline && merge_request.head_pipeline.active? + ::MergeRequests::MergeWhenPipelineSucceedsService + .new(merge_request.target_project, current_user, merge_params) + .execute(merge_request) + else + ::MergeRequests::MergeService + .new(merge_request.target_project, current_user, merge_params) + .execute(merge_request) + end + + present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project + end + + desc 'Cancel merge if "Merge When Pipeline Succeeds" is enabled' do + success Entities::MergeRequest + end + post "#{path}/cancel_merge_when_build_succeeds" do + merge_request = find_project_merge_request(params[:merge_request_id]) + + unauthorized! unless merge_request.can_cancel_merge_when_build_succeeds?(current_user) + + ::MergeRequest::MergeWhenPipelineSucceedsService + .new(merge_request.target_project, current_user) + .cancel(merge_request) + end + + desc 'Get the comments of a merge request' do + detail 'Duplicate. DEPRECATED and HAS BEEN REMOVED in V4' + success Entities::MRNote + end + params do + use :pagination + end + get "#{path}/comments" do + merge_request = find_merge_request_with_access(params[:merge_request_id]) + present paginate(merge_request.notes.fresh), with: Entities::MRNote + end + + desc 'Post a comment to a merge request' do + detail 'Duplicate. DEPRECATED and HAS BEEN REMOVED in V4' + success Entities::MRNote + end + params do + requires :note, type: String, desc: 'The text of the comment' + end + post "#{path}/comments" do + merge_request = find_merge_request_with_access(params[:merge_request_id], :create_note) + + opts = { + note: params[:note], + noteable_type: 'MergeRequest', + noteable_id: merge_request.id + } + + note = ::Notes::CreateService.new(user_project, current_user, opts).execute + + if note.save + present note, with: Entities::MRNote + else + render_api_error!("Failed to save note #{note.errors.messages}", 400) + end + end + + desc 'List issues that will be closed on merge' do + success Entities::MRNote + end + params do + use :pagination + end + get "#{path}/closes_issues" do + merge_request = find_merge_request_with_access(params[:merge_request_id]) + issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user)) + present paginate(issues), with: issue_entity(user_project), current_user: current_user + end + end + end + end + end +end diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index b7bb9290712..3173aae664c 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -2,7 +2,6 @@ require 'spec_helper' describe SearchController do let(:user) { create(:user) } - let(:project) { create(:empty_project, :public) } before do sign_in(user) @@ -22,7 +21,7 @@ describe SearchController do before { sign_out(user) } it "doesn't expose comments on issues" do - project = create(:empty_project, :public, issues_access_level: ProjectFeature::PRIVATE) + project = create(:empty_project, :public, :issues_private) note = create(:note_on_issue, project: project) get :show, project_id: project.id, scope: 'notes', search: note.note @@ -31,17 +30,8 @@ describe SearchController do end end - it "doesn't expose comments on issues" do - project = create(:empty_project, :public, issues_access_level: ProjectFeature::PRIVATE) - note = create(:note_on_issue, project: project) - - get :show, project_id: project.id, scope: 'notes', search: note.note - - expect(assigns[:search_objects].count).to eq(0) - end - it "doesn't expose comments on merge_requests" do - project = create(:empty_project, :public, merge_requests_access_level: ProjectFeature::PRIVATE) + project = create(:empty_project, :public, :merge_requests_private) note = create(:note_on_merge_request, project: project) get :show, project_id: project.id, scope: 'notes', search: note.note @@ -50,7 +40,7 @@ describe SearchController do end it "doesn't expose comments on snippets" do - project = create(:empty_project, :public, snippets_access_level: ProjectFeature::PRIVATE) + project = create(:empty_project, :public, :snippets_private) note = create(:note_on_project_snippet, project: project) get :show, project_id: project.id, scope: 'notes', search: note.note diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 715b2a27b30..c80b09e9b9d 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -56,6 +56,25 @@ FactoryGirl.define do end end + trait(:wiki_enabled) { wiki_access_level ProjectFeature::ENABLED } + trait(:wiki_disabled) { wiki_access_level ProjectFeature::DISABLED } + trait(:wiki_private) { wiki_access_level ProjectFeature::PRIVATE } + trait(:builds_enabled) { builds_access_level ProjectFeature::ENABLED } + trait(:builds_disabled) { builds_access_level ProjectFeature::DISABLED } + trait(:builds_private) { builds_access_level ProjectFeature::PRIVATE } + trait(:snippets_enabled) { snippets_access_level ProjectFeature::ENABLED } + trait(:snippets_disabled) { snippets_access_level ProjectFeature::DISABLED } + trait(:snippets_private) { snippets_access_level ProjectFeature::PRIVATE } + trait(:issues_disabled) { issues_access_level ProjectFeature::DISABLED } + trait(:issues_enabled) { issues_access_level ProjectFeature::ENABLED } + trait(:issues_private) { issues_access_level ProjectFeature::PRIVATE } + trait(:merge_requests_enabled) { merge_requests_access_level ProjectFeature::ENABLED } + trait(:merge_requests_disabled) { merge_requests_access_level ProjectFeature::DISABLED } + trait(:merge_requests_private) { merge_requests_access_level ProjectFeature::PRIVATE } + trait(:repository_enabled) { repository_access_level ProjectFeature::ENABLED } + trait(:repository_disabled) { repository_access_level ProjectFeature::DISABLED } + trait(:repository_private) { repository_access_level ProjectFeature::PRIVATE } + # Nest Project Feature attributes transient do wiki_access_level ProjectFeature::ENABLED diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb index 78a11ffee99..b55078c3bf6 100644 --- a/spec/features/groups/merge_requests_spec.rb +++ b/spec/features/groups/merge_requests_spec.rb @@ -7,7 +7,7 @@ feature 'Group merge requests page', feature: true do include_examples 'project features apply to issuables', MergeRequest context 'archived issuable' do - let(:project_archived) { create(:project, :archived, group: group, merge_requests_access_level: ProjectFeature::ENABLED) } + let(:project_archived) { create(:project, :archived, :merge_requests_enabled, group: group) } let(:issuable_archived) { create(:merge_request, source_project: project_archived, target_project: project_archived, title: 'issuable of an archived project') } let(:access_level) { ProjectFeature::ENABLED } let(:user) { user_in_group } diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index 7d1805f5001..fb3a1ae4bd0 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -5,30 +5,53 @@ describe 'Merge request', :feature, :js do let(:project) { create(:project) } let(:user) { create(:user) } + let(:merge_request) { create(:merge_request, source_project: project) } before do project.team << [user, :master] login_as(user) + end - visit new_namespace_project_merge_request_path( - project.namespace, - project, - merge_request: { - source_project_id: project.id, - target_project_id: project.id, - source_branch: 'feature', - target_branch: 'master' - } - ) + context 'new merge request' do + before do + visit new_namespace_project_merge_request_path( + project.namespace, + project, + merge_request: { + source_project_id: project.id, + target_project_id: project.id, + source_branch: 'feature', + target_branch: 'master' + } + ) + end + + it 'shows widget status after creating new merge request' do + click_button 'Submit merge request' + + expect(find('.mr-state-widget')).to have_content('Checking ability to merge automatically') + + wait_for_ajax + + expect(page).to have_selector('.accept_merge_request') + end end - it 'shows widget status after creating new merge request' do - click_button 'Submit merge request' + context 'view merge request' do + let!(:environment) { create(:environment, project: project) } + let!(:deployment) { create(:deployment, environment: environment, ref: 'feature', sha: merge_request.diff_head_sha) } - expect(find('.mr-state-widget')).to have_content('Checking ability to merge automatically') + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end - wait_for_ajax + it 'shows environments link' do + wait_for_ajax - expect(page).to have_selector('.accept_merge_request') + page.within('.mr-widget-heading') do + expect(page).to have_content("Deployed to #{environment.name}") + expect(find('.js-environment-link')[:href]).to include(environment.formatted_external_url) + end + end end end diff --git a/spec/features/projects/settings/merge_requests_settings_spec.rb b/spec/features/projects/settings/merge_requests_settings_spec.rb index 034b75c2e51..6815039d5ed 100644 --- a/spec/features/projects/settings/merge_requests_settings_spec.rb +++ b/spec/features/projects/settings/merge_requests_settings_spec.rb @@ -12,13 +12,8 @@ feature 'Project settings > Merge Requests', feature: true, js: true do end context 'when Merge Request and Pipelines are initially enabled' do - before do - project.project_feature.update_attribute('merge_requests_access_level', ProjectFeature::ENABLED) - end - context 'when Pipelines are initially enabled' do before do - project.project_feature.update_attribute('builds_access_level', ProjectFeature::ENABLED) visit edit_project_path(project) end diff --git a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb index b4f5f6b3fc5..20219f3cc9a 100644 --- a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb +++ b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb @@ -2,7 +2,6 @@ require 'spec_helper' describe 'Projects > Wiki > User views wiki in project page', feature: true do let(:user) { create(:user) } - let(:project) { create(:empty_project) } before do project.team << [user, :master] @@ -10,12 +9,11 @@ describe 'Projects > Wiki > User views wiki in project page', feature: true do end context 'when repository is disabled for project' do - before do - project.project_feature.update!( - repository_access_level: ProjectFeature::DISABLED, - merge_requests_access_level: ProjectFeature::DISABLED, - builds_access_level: ProjectFeature::DISABLED - ) + let(:project) do + create(:empty_project, + :repository_disabled, + :merge_requests_disabled, + :builds_disabled) end context 'when wiki homepage contains a link' do diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 97737d7ddc7..12ab1d6dde8 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -224,7 +224,7 @@ describe IssuesFinder do let(:scope) { nil } it "doesn't return team-only issues to non team members" do - project = create(:empty_project, :public, issues_access_level: ProjectFeature::PRIVATE) + project = create(:empty_project, :public, :issues_private) issue = create(:issue, project: project) expect(issues).not_to include(issue) diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index bac653ea451..f8b05d4e9bc 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -9,8 +9,6 @@ describe NotesFinder do end describe '#execute' do - it 'finds notes on snippets when project is public and user isnt a member' - it 'finds notes on merge requests' do create(:note_on_merge_request, project: project) @@ -45,9 +43,11 @@ describe NotesFinder do context 'on restricted projects' do let(:project) do - create(:empty_project, :public, issues_access_level: ProjectFeature::PRIVATE, - snippets_access_level: ProjectFeature::PRIVATE, - merge_requests_access_level: ProjectFeature::PRIVATE) + create(:empty_project, + :public, + :issues_private, + :snippets_private, + :merge_requests_private) end it 'publicly excludes notes on merge requests' do diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 8d1570aa6f3..aca0bb1d794 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -203,7 +203,6 @@ describe ProjectsHelper do context "when project moves from public to private" do before do - project.project_feature.update_attributes(issues_access_level: ProjectFeature::ENABLED) project.update_attributes(visibility_level: Gitlab::VisibilityLevel::PRIVATE) end diff --git a/spec/lib/gitlab/contributions_calendar_spec.rb b/spec/lib/gitlab/contributions_calendar_spec.rb index 01b2a55b63c..e18a219ef36 100644 --- a/spec/lib/gitlab/contributions_calendar_spec.rb +++ b/spec/lib/gitlab/contributions_calendar_spec.rb @@ -17,7 +17,7 @@ describe Gitlab::ContributionsCalendar do end let(:feature_project) do - create(:empty_project, :public, issues_access_level: ProjectFeature::PRIVATE) do |project| + create(:empty_project, :public, :issues_private) do |project| create(:project_member, user: contributor, project: project).project end end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 116ab16ae74..a55bd4387e0 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -94,8 +94,6 @@ describe Gitlab::GitAccess, lib: true do context 'when repository is enabled' do it 'give access to download code' do - public_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::ENABLED) - expect(subject.allowed?).to be_truthy end end diff --git a/spec/lib/gitlab/git_access_wiki_spec.rb b/spec/lib/gitlab/git_access_wiki_spec.rb index 4a0cdc6887e..1ae293416e4 100644 --- a/spec/lib/gitlab/git_access_wiki_spec.rb +++ b/spec/lib/gitlab/git_access_wiki_spec.rb @@ -36,8 +36,6 @@ describe Gitlab::GitAccessWiki, lib: true do context 'when wiki feature is enabled' do it 'give access to download wiki code' do - project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::ENABLED) - expect(subject.allowed?).to be_truthy end end diff --git a/spec/lib/gitlab/github_import/importer_spec.rb b/spec/lib/gitlab/github_import/importer_spec.rb index 72421832ffc..afd78abdc9b 100644 --- a/spec/lib/gitlab/github_import/importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer_spec.rb @@ -202,7 +202,7 @@ describe Gitlab::GithubImport::Importer, lib: true do end end - let(:project) { create(:project, import_url: "#{repo_root}/octocat/Hello-World.git", wiki_access_level: ProjectFeature::DISABLED) } + let(:project) { create(:project, :wiki_disabled, import_url: "#{repo_root}/octocat/Hello-World.git") } let(:credentials) { { user: 'joe' } } context 'when importing a GitHub project' do diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index 40d7d59f03b..0af13ba8e47 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do let(:user) { create(:user) } let(:namespace) { create(:namespace, owner: user) } let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: "", project_path: 'path') } - let!(:project) { create(:empty_project, name: 'project', path: 'project', builds_access_level: ProjectFeature::DISABLED, issues_access_level: ProjectFeature::DISABLED) } + let!(:project) { create(:empty_project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') } let(:project_tree_restorer) { described_class.new(user: user, shared: shared, project: project) } let(:restored_project_json) { project_tree_restorer.restore } @@ -121,13 +121,13 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do end context 'with group' do - let!(:project) do + let!(:project) do create(:empty_project, - name: 'project', - path: 'project', - builds_access_level: ProjectFeature::DISABLED, - issues_access_level: ProjectFeature::DISABLED, - group: create(:group)) + :builds_disabled, + :issues_disabled, + name: 'project', + path: 'project', + group: create(:group)) end it 'has group labels' do diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index 1d65b24c2c9..550daa44010 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -152,6 +152,9 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do project = create(:project, :public, :repository, + :issues_disabled, + :wiki_enabled, + :builds_private, issues: [issue], snippets: [snippet], releases: [release], @@ -185,10 +188,6 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do create(:event, :created, target: milestone, project: project, author: user) create(:service, project: project, type: 'CustomIssueTrackerService', category: 'issue_tracker') - project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) - project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::ENABLED) - project.project_feature.update_attribute(:builds_access_level, ProjectFeature::PRIVATE) - project end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 92e3624a8d8..9a8096208db 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -163,7 +163,7 @@ describe Gitlab::ProjectSearchResults, lib: true do end it "doesn't list issue notes when access is restricted" do - project = create(:empty_project, :public, issues_access_level: ProjectFeature::PRIVATE) + project = create(:empty_project, :public, :issues_private) note = create(:note_on_issue, project: project) results = described_class.new(user, project, note.note) @@ -172,7 +172,7 @@ describe Gitlab::ProjectSearchResults, lib: true do end it "doesn't list merge_request notes when access is restricted" do - project = create(:empty_project, :public, merge_requests_access_level: ProjectFeature::PRIVATE) + project = create(:empty_project, :public, :merge_requests_private) note = create(:note_on_merge_request, project: project) results = described_class.new(user, project, note.note) diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 2f4a33a1868..30f8fdf91b2 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -247,7 +247,7 @@ describe Ability, lib: true do end describe '.project_disabled_features_rules' do - let(:project) { create(:empty_project, wiki_access_level: ProjectFeature::DISABLED) } + let(:project) { create(:empty_project, :wiki_disabled) } subject { described_class.allowed(project.owner, project) } diff --git a/spec/models/guest_spec.rb b/spec/models/guest_spec.rb index 582b54c0712..c60bd7af958 100644 --- a/spec/models/guest_spec.rb +++ b/spec/models/guest_spec.rb @@ -37,8 +37,6 @@ describe Guest, lib: true do context 'when repository is enabled' do it 'allows to pull the repo' do - public_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::ENABLED) - expect(Guest.can?(:download_code, public_project)).to eq(true) end end diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index 8589f1eb712..09a4448d387 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -57,7 +57,6 @@ describe ProjectFeature do context 'when feature is enabled for everyone' do it "returns true" do features.each do |feature| - project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::ENABLED) expect(project.feature_available?(:issues, user)).to eq(true) end end @@ -104,7 +103,6 @@ describe ProjectFeature do it "returns true when feature is enabled for everyone" do features.each do |feature| - project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::ENABLED) expect(project.public_send("#{feature}_enabled?")).to eq(true) end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 591ea314142..d7e6da02261 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -633,7 +633,7 @@ describe Project, models: true do end describe '#has_wiki?' do - let(:no_wiki_project) { create(:empty_project, wiki_access_level: ProjectFeature::DISABLED, has_external_wiki: false) } + let(:no_wiki_project) { create(:empty_project, :wiki_disabled, has_external_wiki: false) } let(:wiki_enabled_project) { create(:empty_project) } let(:external_wiki_project) { create(:empty_project, has_external_wiki: true) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 6d58b1455c4..6c4c82cef53 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1232,7 +1232,7 @@ describe User, models: true do end it 'does not include projects for which issues are disabled' do - project = create(:empty_project, issues_access_level: ProjectFeature::DISABLED) + project = create(:empty_project, :issues_disabled) expect(user.projects_where_can_admin_issues.to_a).to be_empty expect(user.can?(:admin_issue, project)).to eq(false) diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 62f1b8d7ca2..cca00df9591 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -425,7 +425,7 @@ describe API::Issues, api: true do end it 'returns no issues when user has access to project but not issues' do - restricted_project = create(:empty_project, :public, issues_access_level: ProjectFeature::PRIVATE) + restricted_project = create(:empty_project, :public, :issues_private) create(:issue, project: restricted_project) get api("/projects/#{restricted_project.id}/issues", non_member) @@ -612,23 +612,6 @@ describe API::Issues, api: true do expect(json_response['iid']).to eq(issue.iid) end - it 'returns a project issue by iid' do - get api("/projects/#{project.id}/issues?iid=#{issue.iid}", user) - - expect(response.status).to eq 200 - expect(json_response.length).to eq 1 - expect(json_response.first['title']).to eq issue.title - expect(json_response.first['id']).to eq issue.id - expect(json_response.first['iid']).to eq issue.iid - end - - it 'returns an empty array for an unknown project issue iid' do - get api("/projects/#{project.id}/issues?iid=#{issue.iid + 10}", user) - - expect(response.status).to eq 200 - expect(json_response.length).to eq 0 - end - it "returns 404 if issue id not found" do get api("/projects/#{project.id}/issues/54321", user) expect(response).to have_http_status(404) diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 21a2c583aa8..ff10e79e417 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -73,6 +73,16 @@ describe API::MergeRequests, api: true do expect(json_response.first['title']).to eq(merge_request_merged.title) end + it 'returns merge_request by "iids" array' do + get api("/projects/#{project.id}/merge_requests", user), iids: [merge_request.iid, merge_request_closed.iid] + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.first['title']).to eq merge_request_closed.title + expect(json_response.first['id']).to eq merge_request_closed.id + end + context "with ordering" do before do @mr_later = mr_with_later_created_and_updated_at_time @@ -159,24 +169,6 @@ describe API::MergeRequests, api: true do expect(json_response['force_close_merge_request']).to be_falsy end - it 'returns merge_request by iid' do - url = "/projects/#{project.id}/merge_requests?iid=#{merge_request.iid}" - get api(url, user) - expect(response.status).to eq 200 - expect(json_response.first['title']).to eq merge_request.title - expect(json_response.first['id']).to eq merge_request.id - end - - it 'returns merge_request by iid array' do - get api("/projects/#{project.id}/merge_requests", user), iid: [merge_request.iid, merge_request_closed.iid] - - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.length).to eq(2) - expect(json_response.first['title']).to eq merge_request_closed.title - expect(json_response.first['id']).to eq merge_request_closed.id - end - it "returns a 404 error if merge_request_id not found" do get api("/projects/#{project.id}/merge_requests/999", user) expect(response).to have_http_status(404) diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb new file mode 100644 index 00000000000..33a127de98a --- /dev/null +++ b/spec/requests/api/v3/issues_spec.rb @@ -0,0 +1,1259 @@ +require 'spec_helper' + +describe API::V3::Issues, api: true do + include ApiHelpers + include EmailHelpers + + let(:user) { create(:user) } + let(:user2) { create(:user) } + let(:non_member) { create(:user) } + let(:guest) { create(:user) } + let(:author) { create(:author) } + let(:assignee) { create(:assignee) } + let(:admin) { create(:user, :admin) } + let!(:project) { create(:empty_project, :public, creator_id: user.id, namespace: user.namespace ) } + let!(:closed_issue) do + create :closed_issue, + author: user, + assignee: user, + project: project, + state: :closed, + milestone: milestone, + created_at: generate(:issue_created_at), + updated_at: 3.hours.ago + end + let!(:confidential_issue) do + create :issue, + :confidential, + project: project, + author: author, + assignee: assignee, + created_at: generate(:issue_created_at), + updated_at: 2.hours.ago + end + let!(:issue) do + create :issue, + author: user, + assignee: user, + project: project, + milestone: milestone, + created_at: generate(:issue_created_at), + updated_at: 1.hour.ago + end + let!(:label) do + create(:label, title: 'label', color: '#FFAABB', project: project) + end + let!(:label_link) { create(:label_link, label: label, target: issue) } + let!(:milestone) { create(:milestone, title: '1.0.0', project: project) } + let!(:empty_milestone) do + create(:milestone, title: '2.0.0', project: project) + end + let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } + + let(:no_milestone_title) { URI.escape(Milestone::None.title) } + + before do + project.team << [user, :reporter] + project.team << [guest, :guest] + end + + describe "GET /issues" do + context "when unauthenticated" do + it "returns authentication error" do + get v3_api("/issues") + + expect(response).to have_http_status(401) + end + end + + context "when authenticated" do + it "returns an array of issues" do + get v3_api("/issues", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['title']).to eq(issue.title) + expect(json_response.last).to have_key('web_url') + end + + it 'returns an array of closed issues' do + get v3_api('/issues?state=closed', user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(closed_issue.id) + end + + it 'returns an array of opened issues' do + get v3_api('/issues?state=opened', user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'returns an array of all issues' do + get v3_api('/issues?state=all', user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.first['id']).to eq(issue.id) + expect(json_response.second['id']).to eq(closed_issue.id) + end + + it 'returns an array of labeled issues' do + get v3_api("/issues?labels=#{label.title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['labels']).to eq([label.title]) + end + + it 'returns an array of labeled issues when at least one label matches' do + get v3_api("/issues?labels=#{label.title},foo,bar", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['labels']).to eq([label.title]) + end + + it 'returns an empty array if no issue matches labels' do + get v3_api('/issues?labels=foo,bar', user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an array of labeled issues matching given state' do + get v3_api("/issues?labels=#{label.title}&state=opened", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['labels']).to eq([label.title]) + expect(json_response.first['state']).to eq('opened') + end + + it 'returns an empty array if no issue matches labels and state filters' do + get v3_api("/issues?labels=#{label.title}&state=closed", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an empty array if no issue matches milestone' do + get v3_api("/issues?milestone=#{empty_milestone.title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an empty array if milestone does not exist' do + get v3_api("/issues?milestone=foo", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an array of issues in given milestone' do + get v3_api("/issues?milestone=#{milestone.title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.first['id']).to eq(issue.id) + expect(json_response.second['id']).to eq(closed_issue.id) + end + + it 'returns an array of issues matching state in milestone' do + get v3_api("/issues?milestone=#{milestone.title}", user), + '&state=closed' + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(closed_issue.id) + end + + it 'returns an array of issues with no milestone' do + get v3_api("/issues?milestone=#{no_milestone_title}", author) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(confidential_issue.id) + end + + it 'sorts by created_at descending by default' do + get v3_api('/issues', user) + + response_dates = json_response.map { |issue| issue['created_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts ascending when requested' do + get v3_api('/issues?sort=asc', user) + + response_dates = json_response.map { |issue| issue['created_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end + + it 'sorts by updated_at descending when requested' do + get v3_api('/issues?order_by=updated_at', user) + + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts by updated_at ascending when requested' do + get v3_api('/issues?order_by=updated_at&sort=asc', user) + + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end + end + end + + describe "GET /groups/:id/issues" do + let!(:group) { create(:group) } + let!(:group_project) { create(:empty_project, :public, creator_id: user.id, namespace: group) } + let!(:group_closed_issue) do + create :closed_issue, + author: user, + assignee: user, + project: group_project, + state: :closed, + milestone: group_milestone, + updated_at: 3.hours.ago + end + let!(:group_confidential_issue) do + create :issue, + :confidential, + project: group_project, + author: author, + assignee: assignee, + updated_at: 2.hours.ago + end + let!(:group_issue) do + create :issue, + author: user, + assignee: user, + project: group_project, + milestone: group_milestone, + updated_at: 1.hour.ago + end + let!(:group_label) do + create(:label, title: 'group_lbl', color: '#FFAABB', project: group_project) + end + let!(:group_label_link) { create(:label_link, label: group_label, target: group_issue) } + let!(:group_milestone) { create(:milestone, title: '3.0.0', project: group_project) } + let!(:group_empty_milestone) do + create(:milestone, title: '4.0.0', project: group_project) + end + let!(:group_note) { create(:note_on_issue, author: user, project: group_project, noteable: group_issue) } + + before do + group_project.team << [user, :reporter] + end + let(:base_url) { "/groups/#{group.id}/issues" } + + it 'returns group issues without confidential issues for non project members' do + get v3_api(base_url, non_member) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['title']).to eq(group_issue.title) + end + + it 'returns group confidential issues for author' do + get v3_api(base_url, author) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + end + + it 'returns group confidential issues for assignee' do + get v3_api(base_url, assignee) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + end + + it 'returns group issues with confidential issues for project members' do + get v3_api(base_url, user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + end + + it 'returns group confidential issues for admin' do + get v3_api(base_url, admin) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + end + + it 'returns an array of labeled group issues' do + get v3_api("#{base_url}?labels=#{group_label.title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['labels']).to eq([group_label.title]) + end + + it 'returns an array of labeled group issues where all labels match' do + get v3_api("#{base_url}?labels=#{group_label.title},foo,bar", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an empty array if no group issue matches labels' do + get v3_api("#{base_url}?labels=foo,bar", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an empty array if no issue matches milestone' do + get v3_api("#{base_url}?milestone=#{group_empty_milestone.title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an empty array if milestone does not exist' do + get v3_api("#{base_url}?milestone=foo", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an array of issues in given milestone' do + get v3_api("#{base_url}?milestone=#{group_milestone.title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(group_issue.id) + end + + it 'returns an array of issues matching state in milestone' do + get v3_api("#{base_url}?milestone=#{group_milestone.title}", user), + '&state=closed' + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(group_closed_issue.id) + end + + it 'returns an array of issues with no milestone' do + get v3_api("#{base_url}?milestone=#{no_milestone_title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(group_confidential_issue.id) + end + + it 'sorts by created_at descending by default' do + get v3_api(base_url, user) + + response_dates = json_response.map { |issue| issue['created_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts ascending when requested' do + get v3_api("#{base_url}?sort=asc", user) + + response_dates = json_response.map { |issue| issue['created_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end + + it 'sorts by updated_at descending when requested' do + get v3_api("#{base_url}?order_by=updated_at", user) + + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts by updated_at ascending when requested' do + get v3_api("#{base_url}?order_by=updated_at&sort=asc", user) + + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end + end + + describe "GET /projects/:id/issues" do + let(:base_url) { "/projects/#{project.id}" } + + it "returns 404 on private projects for other users" do + private_project = create(:empty_project, :private) + create(:issue, project: private_project) + + get v3_api("/projects/#{private_project.id}/issues", non_member) + + expect(response).to have_http_status(404) + end + + it 'returns no issues when user has access to project but not issues' do + restricted_project = create(:empty_project, :public, issues_access_level: ProjectFeature::PRIVATE) + create(:issue, project: restricted_project) + + get v3_api("/projects/#{restricted_project.id}/issues", non_member) + + expect(json_response).to eq([]) + end + + it 'returns project issues without confidential issues for non project members' do + get v3_api("#{base_url}/issues", non_member) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'returns project issues without confidential issues for project members with guest role' do + get v3_api("#{base_url}/issues", guest) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'returns project confidential issues for author' do + get v3_api("#{base_url}/issues", author) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'returns project confidential issues for assignee' do + get v3_api("#{base_url}/issues", assignee) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'returns project issues with confidential issues for project members' do + get v3_api("#{base_url}/issues", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'returns project confidential issues for admin' do + get v3_api("#{base_url}/issues", admin) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'returns an array of labeled project issues' do + get v3_api("#{base_url}/issues?labels=#{label.title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['labels']).to eq([label.title]) + end + + it 'returns an array of labeled project issues where all labels match' do + get v3_api("#{base_url}/issues?labels=#{label.title},foo,bar", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['labels']).to eq([label.title]) + end + + it 'returns an empty array if no project issue matches labels' do + get v3_api("#{base_url}/issues?labels=foo,bar", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an empty array if no issue matches milestone' do + get v3_api("#{base_url}/issues?milestone=#{empty_milestone.title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an empty array if milestone does not exist' do + get v3_api("#{base_url}/issues?milestone=foo", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an array of issues in given milestone' do + get v3_api("#{base_url}/issues?milestone=#{milestone.title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.first['id']).to eq(issue.id) + expect(json_response.second['id']).to eq(closed_issue.id) + end + + it 'returns an array of issues matching state in milestone' do + get v3_api("#{base_url}/issues?milestone=#{milestone.title}", user), + '&state=closed' + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(closed_issue.id) + end + + it 'returns an array of issues with no milestone' do + get v3_api("#{base_url}/issues?milestone=#{no_milestone_title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(confidential_issue.id) + end + + it 'sorts by created_at descending by default' do + get v3_api("#{base_url}/issues", user) + + response_dates = json_response.map { |issue| issue['created_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts ascending when requested' do + get v3_api("#{base_url}/issues?sort=asc", user) + + response_dates = json_response.map { |issue| issue['created_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end + + it 'sorts by updated_at descending when requested' do + get v3_api("#{base_url}/issues?order_by=updated_at", user) + + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts by updated_at ascending when requested' do + get v3_api("#{base_url}/issues?order_by=updated_at&sort=asc", user) + + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end + end + + describe "GET /projects/:id/issues/:issue_id" do + it 'exposes known attributes' do + get v3_api("/projects/#{project.id}/issues/#{issue.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['id']).to eq(issue.id) + expect(json_response['iid']).to eq(issue.iid) + expect(json_response['project_id']).to eq(issue.project.id) + expect(json_response['title']).to eq(issue.title) + expect(json_response['description']).to eq(issue.description) + expect(json_response['state']).to eq(issue.state) + expect(json_response['created_at']).to be_present + expect(json_response['updated_at']).to be_present + expect(json_response['labels']).to eq(issue.label_names) + expect(json_response['milestone']).to be_a Hash + expect(json_response['assignee']).to be_a Hash + expect(json_response['author']).to be_a Hash + expect(json_response['confidential']).to be_falsy + end + + it "returns a project issue by id" do + get v3_api("/projects/#{project.id}/issues/#{issue.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq(issue.title) + expect(json_response['iid']).to eq(issue.iid) + end + + it 'returns a project issue by iid' do + get v3_api("/projects/#{project.id}/issues?iid=#{issue.iid}", user) + + expect(response.status).to eq 200 + expect(json_response.length).to eq 1 + expect(json_response.first['title']).to eq issue.title + expect(json_response.first['id']).to eq issue.id + expect(json_response.first['iid']).to eq issue.iid + end + + it 'returns an empty array for an unknown project issue iid' do + get v3_api("/projects/#{project.id}/issues?iid=#{issue.iid + 10}", user) + + expect(response.status).to eq 200 + expect(json_response.length).to eq 0 + end + + it "returns 404 if issue id not found" do + get v3_api("/projects/#{project.id}/issues/54321", user) + + expect(response).to have_http_status(404) + end + + context 'confidential issues' do + it "returns 404 for non project members" do + get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member) + + expect(response).to have_http_status(404) + end + + it "returns 404 for project members with guest role" do + get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest) + + expect(response).to have_http_status(404) + end + + it "returns confidential issue for project members" do + get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it "returns confidential issue for author" do + get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", author) + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it "returns confidential issue for assignee" do + get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", assignee) + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it "returns confidential issue for admin" do + get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin) + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + end + end + + describe "POST /projects/:id/issues" do + it 'creates a new project issue' do + post v3_api("/projects/#{project.id}/issues", user), + title: 'new issue', labels: 'label, label2' + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['description']).to be_nil + expect(json_response['labels']).to eq(['label', 'label2']) + expect(json_response['confidential']).to be_falsy + end + + it 'creates a new confidential project issue' do + post v3_api("/projects/#{project.id}/issues", user), + title: 'new issue', confidential: true + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['confidential']).to be_truthy + end + + it 'creates a new confidential project issue with a different param' do + post v3_api("/projects/#{project.id}/issues", user), + title: 'new issue', confidential: 'y' + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['confidential']).to be_truthy + end + + it 'creates a public issue when confidential param is false' do + post v3_api("/projects/#{project.id}/issues", user), + title: 'new issue', confidential: false + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['confidential']).to be_falsy + end + + it 'creates a public issue when confidential param is invalid' do + post v3_api("/projects/#{project.id}/issues", user), + title: 'new issue', confidential: 'foo' + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('confidential is invalid') + end + + it "sends notifications for subscribers of newly added labels" do + label = project.labels.first + label.toggle_subscription(user2, project) + + perform_enqueued_jobs do + post v3_api("/projects/#{project.id}/issues", user), + title: 'new issue', labels: label.title + end + + should_email(user2) + end + + it "returns a 400 bad request if title not given" do + post v3_api("/projects/#{project.id}/issues", user), labels: 'label, label2' + + expect(response).to have_http_status(400) + end + + it 'allows special label names' do + post v3_api("/projects/#{project.id}/issues", user), + title: 'new issue', + labels: 'label, label?, label&foo, ?, &' + + expect(response.status).to eq(201) + expect(json_response['labels']).to include 'label' + expect(json_response['labels']).to include 'label?' + expect(json_response['labels']).to include 'label&foo' + expect(json_response['labels']).to include '?' + expect(json_response['labels']).to include '&' + end + + it 'returns 400 if title is too long' do + post v3_api("/projects/#{project.id}/issues", user), + title: 'g' * 256 + + expect(response).to have_http_status(400) + expect(json_response['message']['title']).to eq([ + 'is too long (maximum is 255 characters)' + ]) + end + + context 'resolving issues in a merge request' do + let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:merge_request) { discussion.noteable } + let(:project) { merge_request.source_project } + before do + project.team << [user, :master] + post v3_api("/projects/#{project.id}/issues", user), + title: 'New Issue', + merge_request_for_resolving_discussions: merge_request.iid + end + + it 'creates a new project issue' do + expect(response).to have_http_status(:created) + end + + it 'resolves the discussions in a merge request' do + discussion.first_note.reload + + expect(discussion.resolved?).to be(true) + end + + it 'assigns a description to the issue mentioning the merge request' do + expect(json_response['description']).to include(merge_request.to_reference) + end + end + + context 'with due date' do + it 'creates a new project issue' do + due_date = 2.weeks.from_now.strftime('%Y-%m-%d') + + post v3_api("/projects/#{project.id}/issues", user), + title: 'new issue', due_date: due_date + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['description']).to be_nil + expect(json_response['due_date']).to eq(due_date) + end + end + + context 'when an admin or owner makes the request' do + it 'accepts the creation date to be set' do + creation_time = 2.weeks.ago + post v3_api("/projects/#{project.id}/issues", user), + title: 'new issue', labels: 'label, label2', created_at: creation_time + + expect(response).to have_http_status(201) + expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time) + end + end + + context 'the user can only read the issue' do + it 'cannot create new labels' do + expect do + post v3_api("/projects/#{project.id}/issues", non_member), title: 'new issue', labels: 'label, label2' + end.not_to change { project.labels.count } + end + end + end + + describe 'POST /projects/:id/issues with spam filtering' do + before do + allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(AkismetService).to receive_messages(is_spam?: true) + end + + let(:params) do + { + title: 'new issue', + description: 'content here', + labels: 'label, label2' + } + end + + it "does not create a new project issue" do + expect { post v3_api("/projects/#{project.id}/issues", user), params }.not_to change(Issue, :count) + + expect(response).to have_http_status(400) + expect(json_response['message']).to eq({ "error" => "Spam detected" }) + + spam_logs = SpamLog.all + + expect(spam_logs.count).to eq(1) + expect(spam_logs[0].title).to eq('new issue') + expect(spam_logs[0].description).to eq('content here') + expect(spam_logs[0].user).to eq(user) + expect(spam_logs[0].noteable_type).to eq('Issue') + end + end + + describe "PUT /projects/:id/issues/:issue_id to update only title" do + it "updates a project issue" do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), + title: 'updated title' + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq('updated title') + end + + it "returns 404 error if issue id not found" do + put v3_api("/projects/#{project.id}/issues/44444", user), + title: 'updated title' + + expect(response).to have_http_status(404) + end + + it 'allows special label names' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), + title: 'updated title', + labels: 'label, label?, label&foo, ?, &' + + expect(response.status).to eq(200) + expect(json_response['labels']).to include 'label' + expect(json_response['labels']).to include 'label?' + expect(json_response['labels']).to include 'label&foo' + expect(json_response['labels']).to include '?' + expect(json_response['labels']).to include '&' + end + + context 'confidential issues' do + it "returns 403 for non project members" do + put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member), + title: 'updated title' + + expect(response).to have_http_status(403) + end + + it "returns 403 for project members with guest role" do + put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest), + title: 'updated title' + + expect(response).to have_http_status(403) + end + + it "updates a confidential issue for project members" do + put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + title: 'updated title' + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq('updated title') + end + + it "updates a confidential issue for author" do + put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", author), + title: 'updated title' + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq('updated title') + end + + it "updates a confidential issue for admin" do + put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin), + title: 'updated title' + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq('updated title') + end + + it 'sets an issue to confidential' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), + confidential: true + + expect(response).to have_http_status(200) + expect(json_response['confidential']).to be_truthy + end + + it 'makes a confidential issue public' do + put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + confidential: false + + expect(response).to have_http_status(200) + expect(json_response['confidential']).to be_falsy + end + + it 'does not update a confidential issue with wrong confidential flag' do + put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + confidential: 'foo' + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('confidential is invalid') + end + end + end + + describe 'PUT /projects/:id/issues/:issue_id to update labels' do + let!(:label) { create(:label, title: 'dummy', project: project) } + let!(:label_link) { create(:label_link, label: label, target: issue) } + + it 'does not update labels if not present' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), + title: 'updated title' + + expect(response).to have_http_status(200) + expect(json_response['labels']).to eq([label.title]) + end + + it "sends notifications for subscribers of newly added labels when issue is updated" do + label = create(:label, title: 'foo', color: '#FFAABB', project: project) + label.toggle_subscription(user2, project) + + perform_enqueued_jobs do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), + title: 'updated title', labels: label.title + end + + should_email(user2) + end + + it 'removes all labels' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), labels: '' + + expect(response).to have_http_status(200) + expect(json_response['labels']).to eq([]) + end + + it 'updates labels' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), + labels: 'foo,bar' + + expect(response).to have_http_status(200) + expect(json_response['labels']).to include 'foo' + expect(json_response['labels']).to include 'bar' + end + + it 'allows special label names' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), + labels: 'label:foo, label-bar,label_bar,label/bar,label?bar,label&bar,?,&' + + expect(response.status).to eq(200) + expect(json_response['labels']).to include 'label:foo' + expect(json_response['labels']).to include 'label-bar' + expect(json_response['labels']).to include 'label_bar' + expect(json_response['labels']).to include 'label/bar' + expect(json_response['labels']).to include 'label?bar' + expect(json_response['labels']).to include 'label&bar' + expect(json_response['labels']).to include '?' + expect(json_response['labels']).to include '&' + end + + it 'returns 400 if title is too long' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), + title: 'g' * 256 + + expect(response).to have_http_status(400) + expect(json_response['message']['title']).to eq([ + 'is too long (maximum is 255 characters)' + ]) + end + end + + describe "PUT /projects/:id/issues/:issue_id to update state and label" do + it "updates a project issue" do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), + labels: 'label2', state_event: "close" + + expect(response).to have_http_status(200) + expect(json_response['labels']).to include 'label2' + expect(json_response['state']).to eq "closed" + end + + it 'reopens a project isssue' do + put v3_api("/projects/#{project.id}/issues/#{closed_issue.id}", user), state_event: 'reopen' + + expect(response).to have_http_status(200) + expect(json_response['state']).to eq 'reopened' + end + + context 'when an admin or owner makes the request' do + it 'accepts the update date to be set' do + update_time = 2.weeks.ago + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), + labels: 'label3', state_event: 'close', updated_at: update_time + + expect(response).to have_http_status(200) + expect(json_response['labels']).to include 'label3' + expect(Time.parse(json_response['updated_at'])).to be_like_time(update_time) + end + end + end + + describe 'PUT /projects/:id/issues/:issue_id to update due date' do + it 'creates a new project issue' do + due_date = 2.weeks.from_now.strftime('%Y-%m-%d') + + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), due_date: due_date + + expect(response).to have_http_status(200) + expect(json_response['due_date']).to eq(due_date) + end + end + + describe "DELETE /projects/:id/issues/:issue_id" do + it "rejects a non member from deleting an issue" do + delete v3_api("/projects/#{project.id}/issues/#{issue.id}", non_member) + + expect(response).to have_http_status(403) + end + + it "rejects a developer from deleting an issue" do + delete v3_api("/projects/#{project.id}/issues/#{issue.id}", author) + + expect(response).to have_http_status(403) + end + + context "when the user is project owner" do + let(:owner) { create(:user) } + let(:project) { create(:empty_project, namespace: owner.namespace) } + + it "deletes the issue if an admin requests it" do + delete v3_api("/projects/#{project.id}/issues/#{issue.id}", owner) + + expect(response).to have_http_status(200) + expect(json_response['state']).to eq 'opened' + end + end + + context 'when issue does not exist' do + it 'returns 404 when trying to move an issue' do + delete v3_api("/projects/#{project.id}/issues/123", user) + + expect(response).to have_http_status(404) + end + end + end + + describe '/projects/:id/issues/:issue_id/move' do + let!(:target_project) { create(:empty_project, path: 'project2', creator_id: user.id, namespace: user.namespace ) } + let!(:target_project2) { create(:empty_project, creator_id: non_member.id, namespace: non_member.namespace ) } + + it 'moves an issue' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", user), + to_project_id: target_project.id + + expect(response).to have_http_status(201) + expect(json_response['project_id']).to eq(target_project.id) + end + + context 'when source and target projects are the same' do + it 'returns 400 when trying to move an issue' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", user), + to_project_id: project.id + + expect(response).to have_http_status(400) + expect(json_response['message']).to eq('Cannot move issue to project it originates from!') + end + end + + context 'when the user does not have the permission to move issues' do + it 'returns 400 when trying to move an issue' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", user), + to_project_id: target_project2.id + + expect(response).to have_http_status(400) + expect(json_response['message']).to eq('Cannot move issue due to insufficient permissions!') + end + end + + it 'moves the issue to another namespace if I am admin' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", admin), + to_project_id: target_project2.id + + expect(response).to have_http_status(201) + expect(json_response['project_id']).to eq(target_project2.id) + end + + context 'when issue does not exist' do + it 'returns 404 when trying to move an issue' do + post v3_api("/projects/#{project.id}/issues/123/move", user), + to_project_id: target_project.id + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Issue Not Found') + end + end + + context 'when source project does not exist' do + it 'returns 404 when trying to move an issue' do + post v3_api("/projects/123/issues/#{issue.id}/move", user), + to_project_id: target_project.id + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Project Not Found') + end + end + + context 'when target project does not exist' do + it 'returns 404 when trying to move an issue' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", user), + to_project_id: 123 + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST :id/issues/:issue_id/subscription' do + it 'subscribes to an issue' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/subscription", user2) + + expect(response).to have_http_status(201) + expect(json_response['subscribed']).to eq(true) + end + + it 'returns 304 if already subscribed' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/subscription", user) + + expect(response).to have_http_status(304) + end + + it 'returns 404 if the issue is not found' do + post v3_api("/projects/#{project.id}/issues/123/subscription", user) + + expect(response).to have_http_status(404) + end + + it 'returns 404 if the issue is confidential' do + post v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}/subscription", non_member) + + expect(response).to have_http_status(404) + end + end + + describe 'DELETE :id/issues/:issue_id/subscription' do + it 'unsubscribes from an issue' do + delete v3_api("/projects/#{project.id}/issues/#{issue.id}/subscription", user) + + expect(response).to have_http_status(200) + expect(json_response['subscribed']).to eq(false) + end + + it 'returns 304 if not subscribed' do + delete v3_api("/projects/#{project.id}/issues/#{issue.id}/subscription", user2) + + expect(response).to have_http_status(304) + end + + it 'returns 404 if the issue is not found' do + delete v3_api("/projects/#{project.id}/issues/123/subscription", user) + + expect(response).to have_http_status(404) + end + + it 'returns 404 if the issue is confidential' do + delete v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}/subscription", non_member) + + expect(response).to have_http_status(404) + end + end + + describe 'time tracking endpoints' do + let(:issuable) { issue } + + include_examples 'time tracking endpoints', 'issue' + end +end diff --git a/spec/requests/api/v3/merge_requests_spec.rb b/spec/requests/api/v3/merge_requests_spec.rb new file mode 100644 index 00000000000..b94e1ef4ced --- /dev/null +++ b/spec/requests/api/v3/merge_requests_spec.rb @@ -0,0 +1,726 @@ +require "spec_helper" + +describe API::MergeRequests, api: true do + include ApiHelpers + let(:base_time) { Time.now } + let(:user) { create(:user) } + let(:admin) { create(:user, :admin) } + let(:non_member) { create(:user) } + let!(:project) { create(:project, :public, :repository, creator: user, namespace: user.namespace) } + let!(:merge_request) { create(:merge_request, :simple, author: user, assignee: user, source_project: project, title: "Test", created_at: base_time) } + let!(:merge_request_closed) { create(:merge_request, state: "closed", author: user, assignee: user, source_project: project, title: "Closed test", created_at: base_time + 1.second) } + let!(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignee: user, source_project: project, title: "Merged test", created_at: base_time + 2.seconds, merge_commit_sha: '9999999999999999999999999999999999999999') } + let(:milestone) { create(:milestone, title: '1.0.0', project: project) } + + before do + project.team << [user, :reporter] + end + + describe "GET /projects/:id/merge_requests" do + context "when unauthenticated" do + it "returns authentication error" do + get v3_api("/projects/#{project.id}/merge_requests") + expect(response).to have_http_status(401) + end + end + + context "when authenticated" do + it "returns an array of all merge_requests" do + get v3_api("/projects/#{project.id}/merge_requests", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.last['title']).to eq(merge_request.title) + expect(json_response.last).to have_key('web_url') + expect(json_response.last['sha']).to eq(merge_request.diff_head_sha) + expect(json_response.last['merge_commit_sha']).to be_nil + expect(json_response.last['merge_commit_sha']).to eq(merge_request.merge_commit_sha) + expect(json_response.first['title']).to eq(merge_request_merged.title) + expect(json_response.first['sha']).to eq(merge_request_merged.diff_head_sha) + expect(json_response.first['merge_commit_sha']).not_to be_nil + expect(json_response.first['merge_commit_sha']).to eq(merge_request_merged.merge_commit_sha) + end + + it "returns an array of all merge_requests" do + get v3_api("/projects/#{project.id}/merge_requests?state", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.last['title']).to eq(merge_request.title) + end + + it "returns an array of open merge_requests" do + get v3_api("/projects/#{project.id}/merge_requests?state=opened", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.last['title']).to eq(merge_request.title) + end + + it "returns an array of closed merge_requests" do + get v3_api("/projects/#{project.id}/merge_requests?state=closed", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['title']).to eq(merge_request_closed.title) + end + + it "returns an array of merged merge_requests" do + get v3_api("/projects/#{project.id}/merge_requests?state=merged", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['title']).to eq(merge_request_merged.title) + end + + context "with ordering" do + before do + @mr_later = mr_with_later_created_and_updated_at_time + @mr_earlier = mr_with_earlier_created_and_updated_at_time + end + + it "returns an array of merge_requests in ascending order" do + get v3_api("/projects/#{project.id}/merge_requests?sort=asc", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + response_dates = json_response.map{ |merge_request| merge_request['created_at'] } + expect(response_dates).to eq(response_dates.sort) + end + + it "returns an array of merge_requests in descending order" do + get v3_api("/projects/#{project.id}/merge_requests?sort=desc", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + response_dates = json_response.map{ |merge_request| merge_request['created_at'] } + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it "returns an array of merge_requests ordered by updated_at" do + get v3_api("/projects/#{project.id}/merge_requests?order_by=updated_at", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + response_dates = json_response.map{ |merge_request| merge_request['updated_at'] } + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it "returns an array of merge_requests ordered by created_at" do + get v3_api("/projects/#{project.id}/merge_requests?order_by=created_at&sort=asc", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + response_dates = json_response.map{ |merge_request| merge_request['created_at'] } + expect(response_dates).to eq(response_dates.sort) + end + end + end + end + + describe "GET /projects/:id/merge_requests/:merge_request_id" do + it 'exposes known attributes' do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['id']).to eq(merge_request.id) + expect(json_response['iid']).to eq(merge_request.iid) + expect(json_response['project_id']).to eq(merge_request.project.id) + expect(json_response['title']).to eq(merge_request.title) + expect(json_response['description']).to eq(merge_request.description) + expect(json_response['state']).to eq(merge_request.state) + expect(json_response['created_at']).to be_present + expect(json_response['updated_at']).to be_present + expect(json_response['labels']).to eq(merge_request.label_names) + expect(json_response['milestone']).to be_nil + expect(json_response['assignee']).to be_a Hash + expect(json_response['author']).to be_a Hash + expect(json_response['target_branch']).to eq(merge_request.target_branch) + expect(json_response['source_branch']).to eq(merge_request.source_branch) + expect(json_response['upvotes']).to eq(0) + expect(json_response['downvotes']).to eq(0) + expect(json_response['source_project_id']).to eq(merge_request.source_project.id) + expect(json_response['target_project_id']).to eq(merge_request.target_project.id) + expect(json_response['work_in_progress']).to be_falsy + expect(json_response['merge_when_build_succeeds']).to be_falsy + expect(json_response['merge_status']).to eq('can_be_merged') + expect(json_response['should_close_merge_request']).to be_falsy + expect(json_response['force_close_merge_request']).to be_falsy + end + + it "returns merge_request" do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) + expect(response).to have_http_status(200) + expect(json_response['title']).to eq(merge_request.title) + expect(json_response['iid']).to eq(merge_request.iid) + expect(json_response['work_in_progress']).to eq(false) + expect(json_response['merge_status']).to eq('can_be_merged') + expect(json_response['should_close_merge_request']).to be_falsy + expect(json_response['force_close_merge_request']).to be_falsy + end + + it 'returns merge_request by iid' do + url = "/projects/#{project.id}/merge_requests?iid=#{merge_request.iid}" + get v3_api(url, user) + expect(response.status).to eq 200 + expect(json_response.first['title']).to eq merge_request.title + expect(json_response.first['id']).to eq merge_request.id + end + + it 'returns merge_request by iid array' do + get v3_api("/projects/#{project.id}/merge_requests", user), iid: [merge_request.iid, merge_request_closed.iid] + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.first['title']).to eq merge_request_closed.title + expect(json_response.first['id']).to eq merge_request_closed.id + end + + it "returns a 404 error if merge_request_id not found" do + get v3_api("/projects/#{project.id}/merge_requests/999", user) + expect(response).to have_http_status(404) + end + + context 'Work in Progress' do + let!(:merge_request_wip) { create(:merge_request, author: user, assignee: user, source_project: project, target_project: project, title: "WIP: Test", created_at: base_time + 1.second) } + + it "returns merge_request" do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request_wip.id}", user) + expect(response).to have_http_status(200) + expect(json_response['work_in_progress']).to eq(true) + end + end + end + + describe 'GET /projects/:id/merge_requests/:merge_request_id/commits' do + it 'returns a 200 when merge request is valid' do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/commits", user) + commit = merge_request.commits.first + + expect(response.status).to eq 200 + expect(json_response.size).to eq(merge_request.commits.size) + expect(json_response.first['id']).to eq(commit.id) + expect(json_response.first['title']).to eq(commit.title) + end + + it 'returns a 404 when merge_request_id not found' do + get v3_api("/projects/#{project.id}/merge_requests/999/commits", user) + expect(response).to have_http_status(404) + end + end + + describe 'GET /projects/:id/merge_requests/:merge_request_id/changes' do + it 'returns the change information of the merge_request' do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/changes", user) + expect(response.status).to eq 200 + expect(json_response['changes'].size).to eq(merge_request.diffs.size) + end + + it 'returns a 404 when merge_request_id not found' do + get v3_api("/projects/#{project.id}/merge_requests/999/changes", user) + expect(response).to have_http_status(404) + end + end + + describe "POST /projects/:id/merge_requests" do + context 'between branches projects' do + it "returns merge_request" do + post v3_api("/projects/#{project.id}/merge_requests", user), + title: 'Test merge_request', + source_branch: 'feature_conflict', + target_branch: 'master', + author: user, + labels: 'label, label2', + milestone_id: milestone.id, + remove_source_branch: true + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('Test merge_request') + expect(json_response['labels']).to eq(['label', 'label2']) + expect(json_response['milestone']['id']).to eq(milestone.id) + expect(json_response['force_remove_source_branch']).to be_truthy + end + + it "returns 422 when source_branch equals target_branch" do + post v3_api("/projects/#{project.id}/merge_requests", user), + title: "Test merge_request", source_branch: "master", target_branch: "master", author: user + expect(response).to have_http_status(422) + end + + it "returns 400 when source_branch is missing" do + post v3_api("/projects/#{project.id}/merge_requests", user), + title: "Test merge_request", target_branch: "master", author: user + expect(response).to have_http_status(400) + end + + it "returns 400 when target_branch is missing" do + post v3_api("/projects/#{project.id}/merge_requests", user), + title: "Test merge_request", source_branch: "markdown", author: user + expect(response).to have_http_status(400) + end + + it "returns 400 when title is missing" do + post v3_api("/projects/#{project.id}/merge_requests", user), + target_branch: 'master', source_branch: 'markdown' + expect(response).to have_http_status(400) + end + + it 'allows special label names' do + post v3_api("/projects/#{project.id}/merge_requests", user), + title: 'Test merge_request', + source_branch: 'markdown', + target_branch: 'master', + author: user, + labels: 'label, label?, label&foo, ?, &' + expect(response.status).to eq(201) + expect(json_response['labels']).to include 'label' + expect(json_response['labels']).to include 'label?' + expect(json_response['labels']).to include 'label&foo' + expect(json_response['labels']).to include '?' + expect(json_response['labels']).to include '&' + end + + context 'with existing MR' do + before do + post v3_api("/projects/#{project.id}/merge_requests", user), + title: 'Test merge_request', + source_branch: 'feature_conflict', + target_branch: 'master', + author: user + @mr = MergeRequest.all.last + end + + it 'returns 409 when MR already exists for source/target' do + expect do + post v3_api("/projects/#{project.id}/merge_requests", user), + title: 'New test merge_request', + source_branch: 'feature_conflict', + target_branch: 'master', + author: user + end.to change { MergeRequest.count }.by(0) + expect(response).to have_http_status(409) + end + end + end + + context 'forked projects' do + let!(:user2) { create(:user) } + let!(:fork_project) { create(:empty_project, forked_from_project: project, namespace: user2.namespace, creator_id: user2.id) } + let!(:unrelated_project) { create(:empty_project, namespace: create(:user).namespace, creator_id: user2.id) } + + before :each do |each| + fork_project.team << [user2, :reporter] + end + + it "returns merge_request" do + post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + title: 'Test merge_request', source_branch: "feature_conflict", target_branch: "master", + author: user2, target_project_id: project.id, description: 'Test description for Test merge_request' + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('Test merge_request') + expect(json_response['description']).to eq('Test description for Test merge_request') + end + + it "does not return 422 when source_branch equals target_branch" do + expect(project.id).not_to eq(fork_project.id) + expect(fork_project.forked?).to be_truthy + expect(fork_project.forked_from_project).to eq(project) + post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + title: 'Test merge_request', source_branch: "master", target_branch: "master", author: user2, target_project_id: project.id + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('Test merge_request') + end + + it "returns 400 when source_branch is missing" do + post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id + expect(response).to have_http_status(400) + end + + it "returns 400 when target_branch is missing" do + post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id + expect(response).to have_http_status(400) + end + + it "returns 400 when title is missing" do + post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: project.id + expect(response).to have_http_status(400) + end + + context 'when target_branch is specified' do + it 'returns 422 if not a forked project' do + post v3_api("/projects/#{project.id}/merge_requests", user), + title: 'Test merge_request', + target_branch: 'master', + source_branch: 'markdown', + author: user, + target_project_id: fork_project.id + expect(response).to have_http_status(422) + end + + it 'returns 422 if targeting a different fork' do + post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + title: 'Test merge_request', + target_branch: 'master', + source_branch: 'markdown', + author: user2, + target_project_id: unrelated_project.id + expect(response).to have_http_status(422) + end + end + + it "returns 201 when target_branch is specified and for the same project" do + post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: fork_project.id + expect(response).to have_http_status(201) + end + end + end + + describe "DELETE /projects/:id/merge_requests/:merge_request_id" do + context "when the user is developer" do + let(:developer) { create(:user) } + + before do + project.team << [developer, :developer] + end + + it "denies the deletion of the merge request" do + delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", developer) + expect(response).to have_http_status(403) + end + end + + context "when the user is project owner" do + it "destroys the merge request owners can destroy" do + delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) + + expect(response).to have_http_status(200) + end + end + end + + describe "PUT /projects/:id/merge_requests/:merge_request_id/merge" do + let(:pipeline) { create(:ci_pipeline_without_jobs) } + + it "returns merge_request in case of success" do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + + expect(response).to have_http_status(200) + end + + it "returns 406 if branch can't be merged" do + allow_any_instance_of(MergeRequest). + to receive(:can_be_merged?).and_return(false) + + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + + expect(response).to have_http_status(406) + expect(json_response['message']).to eq('Branch cannot be merged') + end + + it "returns 405 if merge_request is not open" do + merge_request.close + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + expect(response).to have_http_status(405) + expect(json_response['message']).to eq('405 Method Not Allowed') + end + + it "returns 405 if merge_request is a work in progress" do + merge_request.update_attribute(:title, "WIP: #{merge_request.title}") + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + expect(response).to have_http_status(405) + expect(json_response['message']).to eq('405 Method Not Allowed') + end + + it 'returns 405 if the build failed for a merge request that requires success' do + allow_any_instance_of(MergeRequest).to receive(:mergeable_ci_state?).and_return(false) + + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + + expect(response).to have_http_status(405) + expect(json_response['message']).to eq('405 Method Not Allowed') + end + + it "returns 401 if user has no permissions to merge" do + user2 = create(:user) + project.team << [user2, :reporter] + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user2) + expect(response).to have_http_status(401) + expect(json_response['message']).to eq('401 Unauthorized') + end + + it "returns 409 if the SHA parameter doesn't match" do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), sha: merge_request.diff_head_sha.reverse + + expect(response).to have_http_status(409) + expect(json_response['message']).to start_with('SHA does not match HEAD of source branch') + end + + it "succeeds if the SHA parameter matches" do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), sha: merge_request.diff_head_sha + + expect(response).to have_http_status(200) + end + + it "enables merge when pipeline succeeds if the pipeline is active" do + allow_any_instance_of(MergeRequest).to receive(:head_pipeline).and_return(pipeline) + allow(pipeline).to receive(:active?).and_return(true) + + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), merge_when_build_succeeds: true + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq('Test') + expect(json_response['merge_when_build_succeeds']).to eq(true) + end + end + + describe "PUT /projects/:id/merge_requests/:merge_request_id" do + context "to close a MR" do + it "returns merge_request" do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: "close" + + expect(response).to have_http_status(200) + expect(json_response['state']).to eq('closed') + end + end + + it "updates title and returns merge_request" do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), title: "New title" + expect(response).to have_http_status(200) + expect(json_response['title']).to eq('New title') + end + + it "updates description and returns merge_request" do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), description: "New description" + expect(response).to have_http_status(200) + expect(json_response['description']).to eq('New description') + end + + it "updates milestone_id and returns merge_request" do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), milestone_id: milestone.id + expect(response).to have_http_status(200) + expect(json_response['milestone']['id']).to eq(milestone.id) + end + + it "returns merge_request with renamed target_branch" do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), target_branch: "wiki" + expect(response).to have_http_status(200) + expect(json_response['target_branch']).to eq('wiki') + end + + it "returns merge_request that removes the source branch" do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), remove_source_branch: true + + expect(response).to have_http_status(200) + expect(json_response['force_remove_source_branch']).to be_truthy + end + + it 'allows special label names' do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), + title: 'new issue', + labels: 'label, label?, label&foo, ?, &' + + expect(response.status).to eq(200) + expect(json_response['labels']).to include 'label' + expect(json_response['labels']).to include 'label?' + expect(json_response['labels']).to include 'label&foo' + expect(json_response['labels']).to include '?' + expect(json_response['labels']).to include '&' + end + + it 'does not update state when title is empty' do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: 'close', title: nil + + merge_request.reload + expect(response).to have_http_status(400) + expect(merge_request.state).to eq('opened') + end + + it 'does not update state when target_branch is empty' do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: 'close', target_branch: nil + + merge_request.reload + expect(response).to have_http_status(400) + expect(merge_request.state).to eq('opened') + end + end + + describe "POST /projects/:id/merge_requests/:merge_request_id/comments" do + it "returns comment" do + original_count = merge_request.notes.size + + post v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user), note: "My comment" + + expect(response).to have_http_status(201) + expect(json_response['note']).to eq('My comment') + expect(json_response['author']['name']).to eq(user.name) + expect(json_response['author']['username']).to eq(user.username) + expect(merge_request.reload.notes.size).to eq(original_count + 1) + end + + it "returns 400 if note is missing" do + post v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user) + expect(response).to have_http_status(400) + end + + it "returns 404 if note is attached to non existent merge request" do + post v3_api("/projects/#{project.id}/merge_requests/404/comments", user), + note: 'My comment' + expect(response).to have_http_status(404) + end + end + + describe "GET :id/merge_requests/:merge_request_id/comments" do + let!(:note) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") } + let!(:note2) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "another comment on a MR") } + + it "returns merge_request comments ordered by created_at" do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.first['note']).to eq("a comment on a MR") + expect(json_response.first['author']['id']).to eq(user.id) + expect(json_response.last['note']).to eq("another comment on a MR") + end + + it "returns a 404 error if merge_request_id not found" do + get v3_api("/projects/#{project.id}/merge_requests/999/comments", user) + expect(response).to have_http_status(404) + end + end + + describe 'GET :id/merge_requests/:merge_request_id/closes_issues' do + it 'returns the issue that will be closed on merge' do + issue = create(:issue, project: project) + mr = merge_request.tap do |mr| + mr.update_attribute(:description, "Closes #{issue.to_reference(mr.project)}") + end + + get v3_api("/projects/#{project.id}/merge_requests/#{mr.id}/closes_issues", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'returns an empty array when there are no issues to be closed' do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", user) + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'handles external issues' do + jira_project = create(:jira_project, :public, name: 'JIR_EXT1') + issue = ExternalIssue.new("#{jira_project.name}-123", jira_project) + merge_request = create(:merge_request, :simple, author: user, assignee: user, source_project: jira_project) + merge_request.update_attribute(:description, "Closes #{issue.to_reference(jira_project)}") + + get v3_api("/projects/#{jira_project.id}/merge_requests/#{merge_request.id}/closes_issues", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['title']).to eq(issue.title) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'returns 403 if the user has no access to the merge request' do + project = create(:empty_project, :private) + merge_request = create(:merge_request, :simple, source_project: project) + guest = create(:user) + project.team << [guest, :guest] + + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", guest) + + expect(response).to have_http_status(403) + end + end + + describe 'POST :id/merge_requests/:merge_request_id/subscription' do + it 'subscribes to a merge request' do + post v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", admin) + + expect(response).to have_http_status(201) + expect(json_response['subscribed']).to eq(true) + end + + it 'returns 304 if already subscribed' do + post v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", user) + + expect(response).to have_http_status(304) + end + + it 'returns 404 if the merge request is not found' do + post v3_api("/projects/#{project.id}/merge_requests/123/subscription", user) + + expect(response).to have_http_status(404) + end + + it 'returns 403 if user has no access to read code' do + guest = create(:user) + project.team << [guest, :guest] + + post v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", guest) + + expect(response).to have_http_status(403) + end + end + + describe 'DELETE :id/merge_requests/:merge_request_id/subscription' do + it 'unsubscribes from a merge request' do + delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", user) + + expect(response).to have_http_status(200) + expect(json_response['subscribed']).to eq(false) + end + + it 'returns 304 if not subscribed' do + delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", admin) + + expect(response).to have_http_status(304) + end + + it 'returns 404 if the merge request is not found' do + post v3_api("/projects/#{project.id}/merge_requests/123/subscription", user) + + expect(response).to have_http_status(404) + end + + it 'returns 403 if user has no access to read code' do + guest = create(:user) + project.team << [guest, :guest] + + delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", guest) + + expect(response).to have_http_status(403) + end + end + + describe 'Time tracking' do + let(:issuable) { merge_request } + + include_examples 'time tracking endpoints', 'merge_request' + end + + def mr_with_later_created_and_updated_at_time + merge_request + merge_request.created_at += 1.hour + merge_request.updated_at += 30.minutes + merge_request.save + merge_request + end + + def mr_with_earlier_created_and_updated_at_time + merge_request_closed + merge_request_closed.created_at -= 1.hour + merge_request_closed.updated_at -= 30.minutes + merge_request_closed.save + merge_request_closed + end +end diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 4a16824de04..87786e85621 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -57,7 +57,7 @@ describe 'Git HTTP requests', lib: true do end context 'but the repo is disabled' do - let(:project) { create(:project, repository_access_level: ProjectFeature::DISABLED, wiki_access_level: ProjectFeature::ENABLED) } + let(:project) { create(:project, :repository_disabled, :wiki_enabled) } let(:wiki) { ProjectWiki.new(project) } let(:path) { "/#{wiki.repository.path_with_namespace}.git" } @@ -141,7 +141,7 @@ describe 'Git HTTP requests', lib: true do context 'when the repo is public' do context 'but the repo is disabled' do it 'does not allow to clone the repo' do - project = create(:project, :public, repository_access_level: ProjectFeature::DISABLED) + project = create(:project, :public, :repository_disabled) download("#{project.path_with_namespace}.git", {}) do |response| expect(response).to have_http_status(:unauthorized) @@ -151,7 +151,7 @@ describe 'Git HTTP requests', lib: true do context 'but the repo is enabled' do it 'allows to clone the repo' do - project = create(:project, :public, repository_access_level: ProjectFeature::ENABLED) + project = create(:project, :public, :repository_enabled) download("#{project.path_with_namespace}.git", {}) do |response| expect(response).to have_http_status(:ok) @@ -161,7 +161,7 @@ describe 'Git HTTP requests', lib: true do context 'but only project members are allowed' do it 'does not allow to clone the repo' do - project = create(:project, :public, repository_access_level: ProjectFeature::PRIVATE) + project = create(:project, :public, :repository_private) download("#{project.path_with_namespace}.git", {}) do |response| expect(response).to have_http_status(:unauthorized) @@ -360,10 +360,6 @@ describe 'Git HTTP requests', lib: true do let(:project) { build.project } let(:other_project) { create(:empty_project) } - before do - project.project_feature.update_attributes(builds_access_level: ProjectFeature::ENABLED) - end - context 'when build created by system is authenticated' do it "downloads get status 200" do clone_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index a1539b69401..af515ad2e0e 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -90,10 +90,6 @@ describe Projects::CreateService, '#execute', services: true do end context 'global builds_enabled true does enable CI by default' do - before do - project.project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED) - end - it { is_expected.to be_truthy } end end diff --git a/spec/support/services/issuable_create_service_shared_examples.rb b/spec/support/services/issuable_create_service_shared_examples.rb index 93c0267d2db..4f0c745b7ee 100644 --- a/spec/support/services/issuable_create_service_shared_examples.rb +++ b/spec/support/services/issuable_create_service_shared_examples.rb @@ -31,8 +31,8 @@ shared_examples 'issuable create service' do context "when issuable feature is private" do before do - project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE) - project.project_feature.update(merge_requests_access_level: ProjectFeature::PRIVATE) + project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, + merge_requests_access_level: ProjectFeature::PRIVATE) end levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] diff --git a/spec/workers/repository_check/single_repository_worker_spec.rb b/spec/workers/repository_check/single_repository_worker_spec.rb index 59cfb2c8e3a..d2609d21546 100644 --- a/spec/workers/repository_check/single_repository_worker_spec.rb +++ b/spec/workers/repository_check/single_repository_worker_spec.rb @@ -5,7 +5,7 @@ describe RepositoryCheck::SingleRepositoryWorker do subject { described_class.new } it 'passes when the project has no push events' do - project = create(:project_empty_repo, wiki_access_level: ProjectFeature::DISABLED) + project = create(:project_empty_repo, :wiki_disabled) project.events.destroy_all break_repo(project) @@ -25,7 +25,7 @@ describe RepositoryCheck::SingleRepositoryWorker do end it 'fails if the wiki repository is broken' do - project = create(:project_empty_repo, wiki_access_level: ProjectFeature::ENABLED) + project = create(:project_empty_repo, :wiki_enabled) project.create_wiki # Test sanity: everything should be fine before the wiki repo is broken @@ -39,7 +39,7 @@ describe RepositoryCheck::SingleRepositoryWorker do end it 'skips wikis when disabled' do - project = create(:project_empty_repo, wiki_access_level: ProjectFeature::DISABLED) + project = create(:project_empty_repo, :wiki_disabled) # Make sure the test would fail if the wiki repo was checked break_wiki(project) @@ -49,7 +49,7 @@ describe RepositoryCheck::SingleRepositoryWorker do end it 'creates missing wikis' do - project = create(:project_empty_repo, wiki_access_level: ProjectFeature::ENABLED) + project = create(:project_empty_repo, :wiki_enabled) FileUtils.rm_rf(wiki_path(project)) subject.perform(project.id) |