diff --git a/app/services/import/jira_custom_field_builder.rb b/app/services/import/jira_custom_field_builder.rb new file mode 100644 index 00000000000..d8781df029c --- /dev/null +++ b/app/services/import/jira_custom_field_builder.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Import + class JiraCustomFieldBuilder + JIRA_TO_OP_FIELD_FORMAT = { + "string" => "string", + "text" => "text", + "number" => "float", + "date" => "date", + "datetime" => "date", # TODO loss of precision + "option" => "list", + "any" => "string" + }.freeze + + attr_reader :jira_project, :jira_field, :values + + def initialize(jira_field, jira_project, values) + @jira_field = jira_field + @jira_project = jira_project + @values = values + @import_name = jira_field.payload["name"] + end + + def find_existing_custom_field + existing_cf = custom_field_by_name(@import_name) if %w[hierarchy list].exclude?(format) + return existing_cf if existing_cf&.field_format == format + + @import_name = unique_custom_field_name + nil + end + + def custom_field_settings + [@import_name, format] + end + + def custom_field_parameters + params = {} + if format == "list" + params[:multi_value] = multi + options = collect_list_options(values) + params[:possible_values] = options unless options.empty? + end + params + end + + def convert_values(custom_field) + @values + end + + def custom_field_post_processing(custom_field) + populate_hierarchy_items(custom_field, values) if format == "hierarchy" + end + + private + + def custom_field_by_name(name) + WorkPackageCustomField.where("LOWER(name) = LOWER(?)", name).first + end + + def unique_custom_field_name + unique_name = @import_name + suffix = 2 + while custom_field_by_name(unique_name) + unique_name = "#{@import_name} (#{suffix})" + suffix += 1 + end + unique_name + end + + def format + @format ||= jira_to_op_field_format(jira_field) + end + + def jira_to_op_field_format(jira_field) + schema = jira_field.payload["schema"] || {} + type = schema["type"] + items = schema["items"] + custom = schema["custom"].to_s + + if type == "array" + items == "option" ? "list" : "string" + elsif type == "option" && custom.include?("cascadingselect") + EnterpriseToken.allows_to?(:custom_field_hierarchies) ? "hierarchy" : "string" + else + JIRA_TO_OP_FIELD_FORMAT.fetch(type, "string") + end + end + + def collect_list_options(values) + [] # TODO: collect_list_options + end + + def populate_hierarchy_items(custom_field, values) + # TODO: populate_hierarchy_items + end + end +end diff --git a/app/workers/import/jira_import_projects_job.rb b/app/workers/import/jira_import_projects_job.rb index d1dd71b5d74..1191f5ff145 100644 --- a/app/workers/import/jira_import_projects_job.rb +++ b/app/workers/import/jira_import_projects_job.rb @@ -44,9 +44,10 @@ module Import @project_role = setup_project_role Import::JiraProject.where(jira_id: @jira_id, jira_project_id: @jira_import.project_ids).find_each do |jira_project| - project = import_project(jira_project) + custom_field_list = import_custom_fields(jira_project) + project = import_project(jira_project, custom_field_list) Import::JiraIssue.where(jira_id: @jira_id, jira_project_id: jira_project.id).find_each do |jira_issue| - import_issue(jira_issue, project) + import_issue(jira_issue, project, custom_field_list) end end end @@ -71,7 +72,7 @@ module Import Role.find_by!(name: "JiraMember") end - def import_project(jira_project) + def import_project(jira_project, _custom_field_list) identifier = jira_project.payload.fetch("key").downcase service_call = Projects::CreateService .new(user: @user, contract_class: EmptyContract) @@ -96,18 +97,18 @@ module Import taken_identifier = error.options[:value] project = Project.find_by!(identifier: taken_identifier) raise "You are trying to import a project with already used " \ - "identifier: #{taken_identifier}. Existing project: #{project}." + "identifier: #{taken_identifier}. Existing project: #{project}." end raise service_call.message end - def import_issue(jira_issue, project) + def import_issue(jira_issue, project, custom_field_list) type = import_type(jira_issue, project) status = import_status(jira_issue) update_workflows(type) priority = import_priority(jira_issue) - import_work_package(jira_issue, project, type, status, priority) + import_work_package(jira_issue, project, type, status, priority, custom_field_list) end def import_type(jira_issue, project) @@ -173,7 +174,7 @@ module Import raise call.message if call.failure? end - def import_work_package(jira_issue, project, type, status, priority) + def import_work_package(jira_issue, project, type, status, priority, custom_field_list) # required because otherwise project.types does not include type and then wp creation fails. project.reload author_key = jira_issue.payload.dig("fields", "creator", "key") @@ -182,17 +183,27 @@ module Import assigned_to = find_user(assignee_key) [author, assigned_to].uniq.compact.each { |member| import_member(project, member) } + description = Import::JiraWikiMarkupConverter.new(jira_issue.payload["fields"]["description"] || "").convert + + custom_field_attrs = custom_field_list.each_with_object({}) do |field, attrs| + value = field[:values].select { |value| value[:issue_id] == jira_issue.id } + next if value&.nil? + + custom_field = field[:custom_field] + attrs[custom_field.attribute_getter] = value.first[:value] + end service_call = WorkPackages::CreateService .new(user: author || User.system, contract_class: EmptyContract) .call( project:, subject: jira_issue.payload["fields"]["summary"], - description: convert_rich_text(jira_issue.payload["fields"]["description"]), + description:, type:, priority:, status:, - assigned_to: + assigned_to:, + **custom_field_attrs ) raise service_call.message unless service_call.success? @@ -262,6 +273,57 @@ module Import raise service_call.message if service_call.errors.find { |error| error.type == :taken }.blank? end + def import_custom_fields(jira_project) + usage = {} + Import::JiraIssue.where(jira_id: @jira_id, jira_project_id: jira_project.id).find_each do |issue| + issue.payload["fields"].each do |key, value| + next unless key.start_with?("customfield_") && value.present? + + usage[key] ||= { values: [] } + usage[key][:values] << { value:, issue_id: issue.id } + end + end + + custom_field_list = [] + usage.each do |jira_field_id, value| + jira_field = Import::JiraField.find_by(jira_id: @jira_id, jira_field_id:) + custom_field, values = import_custom_field(jira_field, jira_project, value[:values]) + custom_field_list << { custom_field:, values: } + end + custom_field_list + end + + def import_custom_field(jira_field, jira_project, values) + jira_custom_field_builder = Import::JiraCustomFieldBuilder.new(jira_field, jira_project, values) + existing_cf = jira_custom_field_builder.find_existing_custom_field + if existing_cf + unless Import::JiraOpenProjectReference.exists?(op_entity_id: existing_cf.id, + op_entity_class: existing_cf.class.to_s, + jira_id: @jira_id) + create_reference!(op_leg: existing_cf, jira_leg: jira_field, jira_import:, uses_existing: true) + end + return [existing_cf, jira_custom_field_builder.convert_values(existing_cf)] + end + name, field_format = jira_custom_field_builder.custom_field_settings + params = { + type: "WorkPackageCustomField", + name:, + field_format:, + is_required: false, + is_for_all: false, + **jira_custom_field_builder.custom_field_parameters + } + service_call = CustomFields::CreateService.new(user: @user).call(**params) + if service_call.success? + custom_field = service_call.result + create_reference!(op_leg: custom_field, jira_leg: jira_field, jira_import: @jira_import, uses_existing: false) + jira_custom_field_builder.custom_field_post_processing(custom_field) + [custom_field, jira_custom_field_builder.convert_values(custom_field)] + else + raise "Failed to create custom field '#{jira_field['name']}': #{service_call.message}" + end + end + def find_user(jira_user_key) return if jira_user_key.blank? @@ -276,11 +338,6 @@ module Import end end - def convert_rich_text(description) - return "" if description.blank? - - Import::JiraWikiMarkupConverter.new(description).convert - end # rubocop:enable Metrics/AbcSize end end diff --git a/app/workers/import/jira_revert_import_job.rb b/app/workers/import/jira_revert_import_job.rb index 2dc19977bef..2f4b7958da3 100644 --- a/app/workers/import/jira_revert_import_job.rb +++ b/app/workers/import/jira_revert_import_job.rb @@ -37,6 +37,7 @@ module Import delete_users delete_groups delete_project_roles + delete_custom_fields delete_references delete_jira_objects].freeze @@ -132,6 +133,16 @@ module Import end end + def delete_custom_fields + Import::JiraOpenProjectReference + .where(jira_import_id: @jira_import.id, uses_existing: false) + .where(op_entity_class: "WorkPackageCustomField") + .find_each do |ref| + op_leg = ref.op_leg + op_leg.destroy! + end + end + def delete_references Import::JiraOpenProjectReference.where(jira_import_id: @jira_import.id).delete_all end