diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-02-17 21:14:40 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-02-17 21:14:40 +0000 |
commit | 0c8b3354d966bf689a11736b80460fa5806b4495 (patch) | |
tree | 31259c03d4875aca416f422c912cc9b4d8a291a1 /app | |
parent | ae845b62778b7c82aae1828b5f237e0468993ef8 (diff) | |
download | gitlab-ce-0c8b3354d966bf689a11736b80460fa5806b4495.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r-- | app/assets/javascripts/contributors/stores/getters.js | 5 | ||||
-rw-r--r-- | app/graphql/mutations/work_items/create_from_task.rb | 64 | ||||
-rw-r--r-- | app/graphql/types/base_enum.rb | 1 | ||||
-rw-r--r-- | app/graphql/types/mutation_type.rb | 1 | ||||
-rw-r--r-- | app/graphql/types/work_items/convert_task_input_type.rb | 36 | ||||
-rw-r--r-- | app/services/work_items/create_and_link_service.rb | 43 | ||||
-rw-r--r-- | app/services/work_items/create_from_task_service.rb | 50 | ||||
-rw-r--r-- | app/services/work_items/task_list_reference_replacement_service.rb | 52 |
8 files changed, 250 insertions, 2 deletions
diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js index 45b569066f8..79f5c701fb8 100644 --- a/app/assets/javascripts/contributors/stores/getters.js +++ b/app/assets/javascripts/contributors/stores/getters.js @@ -7,10 +7,11 @@ export const parsedData = (state) => { state.chartData.forEach(({ date, author_name, author_email }) => { total[date] = total[date] ? total[date] + 1 : 1; - const authorData = byAuthorEmail[author_email]; + const normalizedEmail = author_email.toLowerCase(); + const authorData = byAuthorEmail[normalizedEmail]; if (!authorData) { - byAuthorEmail[author_email] = { + byAuthorEmail[normalizedEmail] = { name: author_name, commits: 1, dates: { diff --git a/app/graphql/mutations/work_items/create_from_task.rb b/app/graphql/mutations/work_items/create_from_task.rb new file mode 100644 index 00000000000..bc79cc0f2ff --- /dev/null +++ b/app/graphql/mutations/work_items/create_from_task.rb @@ -0,0 +1,64 @@ +# 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 diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb index d70236f16f9..7e187d4d816 100644 --- a/app/graphql/types/base_enum.rb +++ b/app/graphql/types/base_enum.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Graphql/GraphqlNamePosition module Types class BaseEnum < GraphQL::Schema::Enum class CustomValue < GraphQL::Schema::EnumValue diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 3c735231595..9efbde2da6b 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -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 diff --git a/app/graphql/types/work_items/convert_task_input_type.rb b/app/graphql/types/work_items/convert_task_input_type.rb new file mode 100644 index 00000000000..1f142c6815c --- /dev/null +++ b/app/graphql/types/work_items/convert_task_input_type.rb @@ -0,0 +1,36 @@ +# 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 diff --git a/app/services/work_items/create_and_link_service.rb b/app/services/work_items/create_and_link_service.rb new file mode 100644 index 00000000000..534d220a846 --- /dev/null +++ b/app/services/work_items/create_and_link_service.rb @@ -0,0 +1,43 @@ +# 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 diff --git a/app/services/work_items/create_from_task_service.rb b/app/services/work_items/create_from_task_service.rb new file mode 100644 index 00000000000..4203c96e676 --- /dev/null +++ b/app/services/work_items/create_from_task_service.rb @@ -0,0 +1,50 @@ +# 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 diff --git a/app/services/work_items/task_list_reference_replacement_service.rb b/app/services/work_items/task_list_reference_replacement_service.rb new file mode 100644 index 00000000000..1044a4feb88 --- /dev/null +++ b/app/services/work_items/task_list_reference_replacement_service.rb @@ -0,0 +1,52 @@ +# 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 |