Commit bee08b53 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch 'jl-cc-add-clone-issue-api' into 'master'

Add Clone Issue feature to Issues API

See merge request gitlab-org/gitlab!57740
parents ca3ebe3f 33e9e55a
......@@ -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.
......
......@@ -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'
......
......@@ -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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment