diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-21 06:11:06 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-21 06:11:06 +0300 |
commit | 175e7c55be739ac9a708448ea669ca835194c8c0 (patch) | |
tree | a7f77e56a8e90fb5054a9d5735bcf96e3814b400 | |
parent | b1031060f00cdb201cf8d790c6ec6421860c30f3 (diff) |
Add latest changes from gitlab-org/gitlab@master
-rw-r--r-- | GITLAB_PAGES_VERSION | 2 | ||||
-rw-r--r-- | app/assets/javascripts/main.js | 2 | ||||
-rw-r--r-- | doc/api/issues.md | 107 | ||||
-rw-r--r-- | lib/api/issues.rb | 28 | ||||
-rw-r--r-- | qa/Gemfile.lock | 30 | ||||
-rw-r--r-- | spec/requests/api/issues/post_projects_issues_spec.rb | 144 |
6 files changed, 294 insertions, 19 deletions
diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index 372cf402c73..50aceaa7b71 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -1.44.0 +1.45.0 diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 4962268c8e9..e422d9b1a32 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -14,6 +14,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { initRails } from '~/lib/utils/rails_ujs'; import * as popovers from '~/popovers'; import * as tooltips from '~/tooltips'; +import { initHeaderSearchApp } from '~/header_search'; import initAlertHandler from './alert_handler'; import { removeFlashClickListener } from './flash'; import initTodoToggle from './header'; @@ -35,7 +36,6 @@ import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; import { initTopNav } from './nav'; -import { initHeaderSearchApp } from '~/header_search'; import 'ee_else_ce/main_ee'; import 'jh_else_ce/main_jh'; diff --git a/doc/api/issues.md b/doc/api/issues.md index 97d0fd3ce8f..204d75e9ee4 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -1487,6 +1487,113 @@ NOTE: The `closed_by` attribute was [introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/17042) in GitLab 10.6. This value is only present for issues closed after GitLab 10.6 and if the user account that closed the issue still exists. +## Clone an issue + +Clone the issue to given project. If the user has insufficient permissions, +an error message with status code `400` is returned. + +Copies as much data as possible as long as the target project contains equivalent labels, milestones, +and so on. + +```plaintext +POST /projects/:id/issues/:issue_iid/clone +``` + +| Attribute | Type | Required | Description | +| --------------- | -------------- | ---------------------- | --------------------------------- | +| `id` | integer/string | **{check-circle}** Yes | ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user. | +| `issue_iid` | integer | **{check-circle}** Yes | Internal ID of a project's issue. | +| `to_project_id` | integer | **{check-circle}** Yes | ID of the new project. | +| `with_notes` | boolean | **{dotted-circle}** No | Clone the issue with [notes](notes.md). Default is `false`. | + +```shell +curl --request POST \ +--header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/1/clone?with_notes=true&to_project_id=6" +``` + +Example response: + +```json +{ + "id":290, + "iid":1, + "project_id":143, + "title":"foo", + "description":"closed", + "state":"opened", + "created_at":"2021-09-14T22:24:11.696Z", + "updated_at":"2021-09-14T22:24:11.696Z", + "closed_at":null, + "closed_by":null, + "labels":[ + + ], + "milestone":null, + "assignees":[ + { + "id":179, + "name":"John Doe2", + "username":"john", + "state":"active", + "avatar_url":"https://www.gravatar.com/avatar/10fc7f102be8de7657fb4d80898bbfe3?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/john" + } + ], + "author":{ + "id":179, + "name":"John Doe2", + "username":"john", + "state":"active", + "avatar_url":"https://www.gravatar.com/avatar/10fc7f102be8de7657fb4d80898bbfe3?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/john" + }, + "type":"ISSUE", + "assignee":{ + "id":179, + "name":"John Doe2", + "username":"john", + "state":"active", + "avatar_url":"https://www.gravatar.com/avatar/10fc7f102be8de7657fb4d80898bbfe3?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/john" + }, + "user_notes_count":1, + "merge_requests_count":0, + "upvotes":0, + "downvotes":0, + "due_date":null, + "confidential":false, + "discussion_locked":null, + "issue_type":"issue", + "web_url":"https://gitlab.example.com/namespace1/project2/-/issues/1", + "time_stats":{ + "time_estimate":0, + "total_time_spent":0, + "human_time_estimate":null, + "human_total_time_spent":null + }, + "task_completion_status":{ + "count":0, + "completed_count":0 + }, + "blocking_issues_count":0, + "has_tasks":false, + "_links":{ + "self":"https://gitlab.example.com/api/v4/projects/143/issues/1", + "notes":"https://gitlab.example.com/api/v4/projects/143/issues/1/notes", + "award_emoji":"https://gitlab.example.com/api/v4/projects/143/issues/1/award_emoji", + "project":"https://gitlab.example.com/api/v4/projects/143" + }, + "references":{ + "short":"#1", + "relative":"#1", + "full":"namespace1/project2#1" + }, + "subscribed":true, + "moved_to_id":null, + "service_desk_reply_to":null +} +``` + ## Subscribe to an issue Subscribes the authenticated user to an issue to receive notifications. diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 39ce6e0b062..5e66949d264 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -375,6 +375,34 @@ module API end # rubocop: enable CodeReuse/ActiveRecord + desc 'Clone an existing issue' do + success Entities::Issue + end + params do + requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue' + requires :to_project_id, type: Integer, desc: 'The ID of the new project' + optional :with_notes, type: Boolean, desc: 'Clone issue with notes', default: false + end + # rubocop: disable CodeReuse/ActiveRecord + post ':id/issues/:issue_iid/clone' do + Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/340252') + + issue = user_project.issues.find_by(iid: params[:issue_iid]) + not_found!('Issue') unless issue + + target_project = Project.find_by(id: params[:to_project_id]) + not_found!('Project') unless target_project + + begin + issue = ::Issues::CloneService.new(project: user_project, current_user: current_user) + .execute(issue, target_project, with_notes: params[:with_notes]) + present issue, with: Entities::Issue, current_user: current_user, project: target_project + rescue ::Issues::CloneService::CloneError => error + render_api_error!(error.message, 400) + end + end + # rubocop: enable CodeReuse/ActiveRecord + desc 'Delete a project issue' params do requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue' diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index 2f9f14e1ac7..8c24414555f 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -81,10 +81,33 @@ GEM faraday-net_http (1.0.1) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) - gitlab-qa (4.0.0) + ffi (1.15.4) + ffi-compiler (1.0.1) + ffi (>= 1.0.0) + rake + gitlab (4.16.1) + httparty (~> 0.14, >= 0.14.0) + terminal-table (~> 1.5, >= 1.5.1) + gitlab-qa (7.9.1) + activesupport (~> 6.1) + gitlab (~> 4.16.1) + http (= 4.3.0) + nokogiri (~> 1.10) + table_print (= 1.5.7) + http (4.3.0) + addressable (~> 2.3) + http-cookie (~> 1.0) + http-form_data (~> 2.2) + http-parser (~> 1.2.0) http-accept (1.7.0) http-cookie (1.0.3) domain_name (~> 0.5) + http-form_data (2.3.0) + http-parser (1.2.3) + ffi-compiler (>= 1.0, < 2.0) + httparty (0.19.0) + mime-types (~> 3.0) + multi_xml (>= 0.5.2) i18n (1.8.10) concurrent-ruby (~> 1.0) ice_nine (0.11.2) @@ -104,6 +127,7 @@ GEM mini_mime (1.1.0) mini_portile2 (2.5.3) minitest (5.14.4) + multi_xml (0.6.0) multipart-post (2.1.1) netrc (0.11.0) nokogiri (1.11.7) @@ -179,6 +203,9 @@ GEM rexml (~> 3.2) rubyzip (>= 1.2.2) systemu (2.6.5) + table_print (1.5.7) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) thread_safe (0.3.6) timecop (0.9.1) tzinfo (2.0.4) @@ -186,6 +213,7 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.7.7) + unicode-display_width (1.8.0) unparser (0.4.7) abstract_type (~> 0.0.7) adamantium (~> 0.2.0) diff --git a/spec/requests/api/issues/post_projects_issues_spec.rb b/spec/requests/api/issues/post_projects_issues_spec.rb index 9d3bd26a200..01898c6f256 100644 --- a/spec/requests/api/issues/post_projects_issues_spec.rb +++ b/spec/requests/api/issues/post_projects_issues_spec.rb @@ -8,15 +8,15 @@ RSpec.describe API::Issues do create(:project, :public, creator_id: user.id, namespace: user.namespace) end - let(:user2) { create(:user) } - let(:non_member) { create(:user) } - let_it_be(:guest) { create(:user) } - let_it_be(:author) { create(:author) } - let_it_be(:assignee) { create(:assignee) } - let(:admin) { create(:user, :admin) } - let(:issue_title) { 'foo' } - let(:issue_description) { 'closed' } - let!(:closed_issue) do + let_it_be(:user2) { create(:user) } + let_it_be(:non_member) { create(:user) } + let_it_be(:guest) { create(:user) } + let_it_be(:author) { create(:author) } + let_it_be(:milestone) { create(:milestone, title: '1.0.0', project: project) } + let_it_be(:assignee) { create(:assignee) } + let_it_be(:admin) { create(:user, :admin) } + + let_it_be(:closed_issue) do create :closed_issue, author: user, assignees: [user], @@ -28,7 +28,7 @@ RSpec.describe API::Issues do closed_at: 1.hour.ago end - let!(:confidential_issue) do + let_it_be(:confidential_issue) do create :issue, :confidential, project: project, @@ -38,7 +38,7 @@ RSpec.describe API::Issues do updated_at: 2.hours.ago end - let!(:issue) do + let_it_be(:issue) do create :issue, author: user, assignees: [user], @@ -46,22 +46,21 @@ RSpec.describe API::Issues do milestone: milestone, created_at: generate(:past_time), updated_at: 1.hour.ago, - title: issue_title, - description: issue_description + title: 'foo', + description: 'closed' end + let_it_be(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } + let_it_be(: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_it_be(: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) { 'None' } let(:any_milestone_title) { 'Any' } @@ -556,6 +555,114 @@ RSpec.describe API::Issues do end end + describe '/projects/:id/issues/:issue_iid/clone' do + let_it_be(:valid_target_project) { create(:project) } + let_it_be(:invalid_target_project) { create(:project) } + + before_all do + valid_target_project.add_maintainer(user) + end + + context 'when user can admin the issue' do + context 'when the user can admin the target project' do + it 'clones the issue' do + expect do + post_clone_issue(user, issue, valid_target_project) + end.to change { valid_target_project.issues.count }.by(1) + + cloned_issue = Issue.last + + expect(cloned_issue.notes.count).to eq(2) + expect(cloned_issue.notes.pluck(:note)).not_to include(issue.notes.first.note) + expect(response).to have_gitlab_http_status(:created) + expect(json_response['id']).to eq(cloned_issue.id) + expect(json_response['project_id']).to eq(valid_target_project.id) + end + + context 'when target project is the same source project' do + it 'clones the issue' do + expect do + post_clone_issue(user, issue, issue.project) + end.to change { issue.reset.project.issues.count }.by(1) + + cloned_issue = Issue.last + + expect(cloned_issue.notes.count).to eq(2) + expect(cloned_issue.notes.pluck(:note)).not_to include(issue.notes.first.note) + expect(response).to have_gitlab_http_status(:created) + expect(json_response['id']).to eq(cloned_issue.id) + expect(json_response['project_id']).to eq(issue.project.id) + end + end + end + end + + context 'when the user does not have the permission to clone issues' do + it 'returns 400' do + post api("/projects/#{project.id}/issues/#{issue.iid}/clone", user), + params: { to_project_id: invalid_target_project.id } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq(s_('CloneIssue|Cannot clone issue due to insufficient permissions!')) + end + end + + context 'when using the issue ID instead of iid' do + it 'returns 404' do + post api("/projects/#{project.id}/issues/#{issue.id}/clone", user), + params: { to_project_id: valid_target_project.id } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Issue Not Found') + end + end + + context 'when issue does not exist' do + it 'returns 404' do + post api("/projects/#{project.id}/issues/12300/clone", user), + params: { to_project_id: valid_target_project.id } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Issue Not Found') + end + end + + context 'when source project does not exist' do + it 'returns 404' do + post api("/projects/0/issues/#{issue.iid}/clone", user), + params: { to_project_id: valid_target_project.id } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Project Not Found') + end + end + + context 'when target project does not exist' do + it 'returns 404' do + post api("/projects/#{project.id}/issues/#{issue.iid}/clone", user), + params: { to_project_id: 0 } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Project Not Found') + end + end + + it 'clones the issue with notes when with_notes is true' do + expect do + post api("/projects/#{project.id}/issues/#{issue.iid}/clone", user), + params: { to_project_id: valid_target_project.id, with_notes: true } + end.to change { valid_target_project.issues.count }.by(1) + + cloned_issue = Issue.last + + expect(cloned_issue.notes.count).to eq(3) + expect(cloned_issue.notes.pluck(:note)).to include(issue.notes.first.note) + expect(response).to have_gitlab_http_status(:created) + expect(json_response['id']).to eq(cloned_issue.id) + expect(json_response['project_id']).to eq(valid_target_project.id) + end + end + describe 'POST :id/issues/:issue_iid/subscribe' do it 'subscribes to an issue' do post api("/projects/#{project.id}/issues/#{issue.iid}/subscribe", user2) @@ -621,4 +728,9 @@ RSpec.describe API::Issues do expect(response).to have_gitlab_http_status(:not_found) end end + + def post_clone_issue(current_user, issue, target_project) + post api("/projects/#{issue.project.id}/issues/#{issue.iid}/clone", current_user), + params: { to_project_id: target_project.id } + end end |