Commit 00ddfebf authored by Rémy Coutable's avatar Rémy Coutable

Merge branch '346042-convert-task-to-work-item' into 'master'

Convert task list item into work item

See merge request gitlab-org/gitlab!78414
parents a262d631 b2af2fd6
# frozen_string_literal: true
module Mutations
module WorkItems
class CreateFromTask < BaseMutation
include Mutations::SpamProtection
description "Creates a work item from a task in another work item's description." \
" Available only when feature flag `work_items` is enabled. This feature is experimental and is subject to change without notice."
graphql_name 'WorkItemCreateFromTask'
authorize :update_work_item
argument :id, ::Types::GlobalIDType[::WorkItem],
required: true,
description: 'Global ID of the work item.'
argument :work_item_data, ::Types::WorkItems::ConvertTaskInputType,
required: true,
description: 'Arguments necessary to convert a task into a work item.',
prepare: ->(attributes, _ctx) { attributes.to_h }
field :work_item, Types::WorkItemType,
null: true,
description: 'Updated work item.'
field :new_work_item, Types::WorkItemType,
null: true,
description: 'New work item created from task.'
def resolve(id:, work_item_data:)
work_item = authorized_find!(id: id)
unless Feature.enabled?(:work_items, work_item.project)
return { errors: ['`work_items` feature flag disabled for this project'] }
end
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
result = ::WorkItems::CreateFromTaskService.new(
work_item: work_item,
current_user: current_user,
work_item_params: work_item_data,
spam_params: spam_params
).execute
check_spam_action_response!(result[:work_item]) if result[:work_item]
response = { errors: result.errors }
response.merge!(work_item: work_item, new_work_item: result[:work_item]) if result.success?
response
end
private
def find_object(id:)
# TODO: Remove coercion when working on https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end
end
......@@ -126,6 +126,7 @@ module Types
mount_mutation Mutations::Packages::DestroyFile
mount_mutation Mutations::Echo
mount_mutation Mutations::WorkItems::Create
mount_mutation Mutations::WorkItems::CreateFromTask
mount_mutation Mutations::WorkItems::Delete
mount_mutation Mutations::WorkItems::Update
end
......
# frozen_string_literal: true
module Types
module WorkItems
class ConvertTaskInputType < BaseInputObject
graphql_name 'WorkItemConvertTaskInput'
argument :line_number_end, GraphQL::Types::Int,
required: true,
description: 'Last line in the Markdown source that defines the list item task.'
argument :line_number_start, GraphQL::Types::Int,
required: true,
description: 'First line in the Markdown source that defines the list item task.'
argument :lock_version, GraphQL::Types::Int,
required: true,
description: 'Current lock version of the work item containing the task in the description.'
argument :title, GraphQL::Types::String,
required: true,
description: 'Full string of the task to be replaced. New title for the created work item.'
argument :work_item_type_id, ::Types::GlobalIDType[::WorkItems::Type],
required: true,
description: 'Global ID of the work item type used to create the new work item.',
prepare: ->(attribute, _ctx) { work_item_type_global_id(attribute) }
class << self
def work_item_type_global_id(global_id)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
global_id = ::Types::GlobalIDType[::WorkItems::Type].coerce_isolated_input(global_id)
global_id&.model_id
end
end
end
end
end
# frozen_string_literal: true
module WorkItems
# Create and link operations are not run inside a transaction in this class
# because CreateFromTaskService also creates a transaction.
# This class should always be run inside a transaction as we could end up with
# new work items that were never associated with other work items as expected.
class CreateAndLinkService
def initialize(project:, current_user: nil, params: {}, spam_params:, link_params: {})
@create_service = CreateService.new(
project: project,
current_user: current_user,
params: params,
spam_params: spam_params
)
@project = project
@current_user = current_user
@link_params = link_params
end
def execute
create_result = @create_service.execute
return create_result if create_result.error?
work_item = create_result[:work_item]
return ::ServiceResponse.success(payload: payload(work_item)) if @link_params.blank?
result = IssueLinks::CreateService.new(work_item, @current_user, @link_params).execute
if result[:status] == :success
::ServiceResponse.success(payload: payload(work_item))
else
::ServiceResponse.error(message: result[:message], http_status: 404)
end
end
private
def payload(work_item)
{ work_item: work_item }
end
end
end
# frozen_string_literal: true
module WorkItems
class CreateFromTaskService
def initialize(work_item:, current_user: nil, work_item_params: {}, spam_params:)
@work_item = work_item
@current_user = current_user
@work_item_params = work_item_params
@spam_params = spam_params
@errors = []
end
def execute
transaction_result = ApplicationRecord.transaction do
create_and_link_result = CreateAndLinkService.new(
project: @work_item.project,
current_user: @current_user,
params: @work_item_params.slice(:title, :work_item_type_id),
spam_params: @spam_params,
link_params: { target_issuable: @work_item }
).execute
if create_and_link_result.error?
@errors += create_and_link_result.errors
raise ActiveRecord::Rollback
end
replacement_result = TaskListReferenceReplacementService.new(
work_item: @work_item,
work_item_reference: create_and_link_result[:work_item].to_reference,
line_number_start: @work_item_params[:line_number_start],
line_number_end: @work_item_params[:line_number_end],
title: @work_item_params[:title],
lock_version: @work_item_params[:lock_version]
).execute
if replacement_result.error?
@errors += replacement_result.errors
raise ActiveRecord::Rollback
end
create_and_link_result
end
return transaction_result if transaction_result
::ServiceResponse.error(message: @errors, http_status: 422)
end
end
end
# frozen_string_literal: true
module WorkItems
class TaskListReferenceReplacementService
STALE_OBJECT_MESSAGE = 'Stale work item. Check lock version'
def initialize(work_item:, work_item_reference:, line_number_start:, line_number_end:, title:, lock_version:)
@work_item = work_item
@work_item_reference = work_item_reference
@line_number_start = line_number_start
@line_number_end = line_number_end
@title = title
@lock_version = lock_version
end
def execute
return ::ServiceResponse.error(message: STALE_OBJECT_MESSAGE) if @work_item.lock_version > @lock_version
return ::ServiceResponse.error(message: 'line_number_start must be greater than 0') if @line_number_start < 1
return ::ServiceResponse.error(message: 'line_number_end must be greater or equal to line_number_start') if @line_number_end < @line_number_start
return ::ServiceResponse.error(message: "Work item description can't be blank") if @work_item.description.blank?
source_lines = @work_item.description.split("\n")
markdown_task_first_line = source_lines[@line_number_start - 1]
task_line = Taskable::ITEM_PATTERN.match(markdown_task_first_line)
return ::ServiceResponse.error(message: "Unable to detect a task on line #{@line_number_start}") unless task_line
captures = task_line.captures
markdown_task_first_line.sub!(Taskable::ITEM_PATTERN, "#{captures[0]} #{captures[1]} #{@work_item_reference}+")
source_lines[@line_number_start - 1] = markdown_task_first_line
remove_additional_lines!(source_lines)
@work_item.update!(description: source_lines.join("\n"))
::ServiceResponse.success
rescue ActiveRecord::StaleObjectError
::ServiceResponse.error(message: STALE_OBJECT_MESSAGE)
end
private
def remove_additional_lines!(source_lines)
return if @line_number_end <= @line_number_start
source_lines.delete_if.each_with_index do |_line, index|
index >= @line_number_start && index < @line_number_end
end
end
end
end
......@@ -5178,6 +5178,29 @@ Input type: `WorkItemCreateInput`
| <a id="mutationworkitemcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationworkitemcreateworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Created work item. |
### `Mutation.workItemCreateFromTask`
Creates a work item from a task in another work item's description. Available only when feature flag `work_items` is enabled. This feature is experimental and is subject to change without notice.
Input type: `WorkItemCreateFromTaskInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemcreatefromtaskclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemcreatefromtaskid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
| <a id="mutationworkitemcreatefromtaskworkitemdata"></a>`workItemData` | [`WorkItemConvertTaskInput!`](#workitemconverttaskinput) | Arguments necessary to convert a task into a work item. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemcreatefromtaskclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemcreatefromtaskerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationworkitemcreatefromtasknewworkitem"></a>`newWorkItem` | [`WorkItem`](#workitem) | New work item created from task. |
| <a id="mutationworkitemcreatefromtaskworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Updated work item. |
### `Mutation.workItemDelete`
Deletes a work item. Available only when feature flag `work_items` is enabled. The feature is experimental and is subject to change without notice.
......@@ -19901,3 +19924,15 @@ A time-frame defined as a closed inclusive range of two dates.
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="vulnerabilityscannervendorinputname"></a>`name` | [`String!`](#string) | Name of the vendor/maintainer. |
### `WorkItemConvertTaskInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemconverttaskinputlinenumberend"></a>`lineNumberEnd` | [`Int!`](#int) | Last line in the Markdown source that defines the list item task. |
| <a id="workitemconverttaskinputlinenumberstart"></a>`lineNumberStart` | [`Int!`](#int) | First line in the Markdown source that defines the list item task. |
| <a id="workitemconverttaskinputlockversion"></a>`lockVersion` | [`Int!`](#int) | Current lock version of the work item containing the task in the description. |
| <a id="workitemconverttaskinputtitle"></a>`title` | [`String!`](#string) | Full string of the task to be replaced. New title for the created work item. |
| <a id="workitemconverttaskinputworkitemtypeid"></a>`workItemTypeId` | [`WorkItemsTypeID!`](#workitemstypeid) | Global ID of the work item type used to create the new work item. |
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['WorkItem'] do
specify { expect(described_class.graphql_name).to eq('WorkItem') }
it 'has specific fields' do
fields = %i[description description_html id iid state title title_html work_item_type]
fields.each do |field_name|
expect(described_class).to have_graphql_fields(*fields)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe "Create a work item from a task in a work item's description" do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
let_it_be(:work_item, refind: true) { create(:work_item, project: project, description: '- [ ] A task in a list', lock_version: 3) }
let(:lock_version) { work_item.lock_version }
let(:input) do
{
'id' => work_item.to_global_id.to_s,
'workItemData' => {
'title' => 'A task in a list',
'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_global_id.to_s,
'lineNumberStart' => 1,
'lineNumberEnd' => 1,
'lockVersion' => lock_version
}
}
end
let(:mutation) { graphql_mutation(:workItemCreateFromTask, input) }
let(:mutation_response) { graphql_mutation_response(:work_item_create_from_task) }
context 'the user is not allowed to update a work item' do
let(:current_user) { create(:user) }
it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to create a work item' do
let(:current_user) { developer }
it 'creates the work item' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.to change(WorkItem, :count).by(1)
created_work_item = WorkItem.last
work_item.reload
expect(response).to have_gitlab_http_status(:success)
expect(work_item.description).to eq("- [ ] #{created_work_item.to_reference}+")
expect(created_work_item.issue_type).to eq('task')
expect(created_work_item.work_item_type.base_type).to eq('task')
expect(mutation_response['workItem']).to include('id' => work_item.to_global_id.to_s)
expect(mutation_response['newWorkItem']).to include('id' => created_work_item.to_global_id.to_s)
end
context 'when creating a work item fails' do
let(:lock_version) { 2 }
it 'makes no changes to the DB and returns an error message' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to not_change(WorkItem, :count).and(
not_change(work_item, :description)
)
expect(mutation_response['errors']).to contain_exactly('Stale work item. Check lock version')
end
end
it_behaves_like 'has spam protection' do
let(:mutation_class) { ::Mutations::WorkItems::CreateFromTask }
end
context 'when the work_items feature flag is disabled' do
before do
stub_feature_flags(work_items: false)
end
it 'does nothing and returns and error' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.to not_change(WorkItem, :count)
expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe WorkItems::CreateAndLinkService do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:user) { create(:user) }
let_it_be(:related_work_item) { create(:work_item, project: project) }
let(:spam_params) { double }
let(:link_params) { {} }
let(:params) do
{
title: 'Awesome work item',
description: 'please fix'
}
end
before_all do
project.add_developer(user)
end
describe '#execute' do
subject(:service_result) { described_class.new(project: project, current_user: user, params: params, spam_params: spam_params, link_params: link_params).execute }
before do
stub_spam_services
end
context 'when work item params are valid' do
it { is_expected.to be_success }
it 'creates a work item successfully with no links' do
expect do
service_result
end.to change(WorkItem, :count).by(1).and(
not_change(IssueLink, :count)
)
end
context 'when link params are valid' do
let(:link_params) { { issuable_references: [related_work_item.to_reference] } }
it 'creates a work item successfully with links' do
expect do
service_result
end.to change(WorkItem, :count).by(1).and(
change(IssueLink, :count).by(1)
)
end
end
context 'when link params are invalid' do
let(:link_params) { { issuable_references: ['invalid reference'] } }
it { is_expected.to be_error }
it 'does not create a link and does not rollback transaction' do
expect do
service_result
end.to not_change(IssueLink, :count).and(
change(WorkItem, :count).by(1)
)
end
it 'returns a link creation error message' do
expect(service_result.errors).to contain_exactly('No matching issue found. Make sure that you are adding a valid issue URL.')
end
end
end
context 'when work item params are invalid' do
let(:params) do
{
title: '',
description: 'invalid work item'
}
end
it { is_expected.to be_error }
it 'does not create a work item or links' do
expect do
service_result
end.to not_change(WorkItem, :count).and(
not_change(IssueLink, :count)
)
end
it 'returns work item errors' do
expect(service_result.errors).to contain_exactly("Title can't be blank")
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe WorkItems::CreateFromTaskService do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user) }
let_it_be(:list_work_item, refind: true) { create(:work_item, project: project, description: "- [ ] Item to be converted\n second line\n third line") }
let(:work_item_to_update) { list_work_item }
let(:spam_params) { double }
let(:link_params) { {} }
let(:current_user) { developer }
let(:params) do
{
title: 'Awesome work item',
work_item_type_id: WorkItems::Type.default_by_type(:task).id,
line_number_start: 1,
line_number_end: 3,
lock_version: work_item_to_update.lock_version
}
end
before_all do
project.add_developer(developer)
end
shared_examples 'CreateFromTask service with invalid params' do
it { is_expected.to be_error }
it 'does not create a work item or links' do
expect do
service_result
end.to not_change(WorkItem, :count).and(
not_change(IssueLink, :count)
)
end
end
describe '#execute' do
subject(:service_result) { described_class.new(work_item: work_item_to_update, current_user: current_user, work_item_params: params, spam_params: spam_params).execute }
before do
stub_spam_services
end
context 'when work item params are valid' do
it { is_expected.to be_success }
it 'creates a work item and links it to the original work item successfully' do
expect do
service_result
end.to change(WorkItem, :count).by(1).and(
change(IssueLink, :count)
)
end
it 'replaces the original issue markdown description with new work item reference' do
service_result
created_work_item = WorkItem.last
expect(list_work_item.description).to eq("- [ ] #{created_work_item.to_reference}+")
end
end
context 'when last operation fails' do
before do
params.merge!(line_number_start: 0)
end
it 'rollbacks all operations' do
expect do
service_result
end.to not_change(WorkItem, :count).and(
not_change(IssueLink, :count)
)
end
it { is_expected.to be_error }
it 'returns an error message' do
expect(service_result.errors).to contain_exactly('line_number_start must be greater than 0')
end
end
context 'when work item params are invalid' do
let(:params) { { title: '' } }
it_behaves_like 'CreateFromTask service with invalid params'
it 'returns work item errors' do
expect(service_result.errors).to contain_exactly("Title can't be blank")
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe WorkItems::TaskListReferenceReplacementService do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:single_line_work_item, refind: true) { create(:work_item, project: project, description: '- [ ] single line', lock_version: 3) }
let_it_be(:multiple_line_work_item, refind: true) { create(:work_item, project: project, description: "Any text\n\n* [ ] Item to be converted\n second line\n third line", lock_version: 3) }
let(:line_number_start) { 3 }
let(:line_number_end) { 5 }
let(:title) { 'work item title' }
let(:reference) { 'any reference' }
let(:work_item) { multiple_line_work_item }
let(:lock_version) { 3 }
let(:expected_additional_text) { '' }
shared_examples 'successful work item task reference replacement service' do
it { is_expected.to be_success }
it 'replaces the original issue markdown description with new work item reference' do
result
expect(work_item.description).to eq("#{expected_additional_text}#{task_prefix} #{reference}+")
end
end
shared_examples 'failing work item task reference replacement service' do |error_message|
it { is_expected.to be_error }
it 'returns an error message' do
expect(result.errors).to contain_exactly(error_message)
end
end
describe '#execute' do
subject(:result) do
described_class.new(
work_item: work_item,
work_item_reference: reference,
line_number_start: line_number_start,
line_number_end: line_number_end,
title: title,
lock_version: lock_version
).execute
end
context 'when task mardown spans a single line' do
let(:line_number_start) { 1 }
let(:line_number_end) { 1 }
let(:work_item) { single_line_work_item }
let(:task_prefix) { '- [ ]' }
it_behaves_like 'successful work item task reference replacement service'
end
context 'when task mardown spans multiple lines' do
let(:task_prefix) { '* [ ]' }
let(:expected_additional_text) { "Any text\n\n" }
it_behaves_like 'successful work item task reference replacement service'
end
context 'when description does not contain a task' do
let_it_be(:no_matching_work_item) { create(:work_item, project: project, description: 'no matching task') }
let(:work_item) { no_matching_work_item }
it_behaves_like 'failing work item task reference replacement service', 'Unable to detect a task on line 3'
end
context 'when description is empty' do
let_it_be(:empty_work_item) { create(:work_item, project: project, description: '') }
let(:work_item) { empty_work_item }
it_behaves_like 'failing work item task reference replacement service', "Work item description can't be blank"
end
context 'when line_number_start is lower than 1' do
let(:line_number_start) { 0 }
it_behaves_like 'failing work item task reference replacement service', 'line_number_start must be greater than 0'
end
context 'when line_number_end is lower than line_number_start' do
let(:line_number_end) { line_number_start - 1 }
it_behaves_like 'failing work item task reference replacement service', 'line_number_end must be greater or equal to line_number_start'
end
context 'when lock_version is older than current' do
let(:lock_version) { 2 }
it_behaves_like 'failing work item task reference replacement service', 'Stale work item. Check lock version'
end
context 'when work item is stale before updating' do
it_behaves_like 'failing work item task reference replacement service', 'Stale work item. Check lock version' do
before do
::WorkItem.where(id: work_item.id).update_all(lock_version: lock_version + 1)
end
end
end
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