diff --git a/app/components/admin/import/jira/import_runs/error_banner_component.html.erb b/app/components/admin/import/jira/import_runs/error_banner_component.html.erb index 061038fa2bef..0df71f020842 100644 --- a/app/components/admin/import/jira/import_runs/error_banner_component.html.erb +++ b/app/components/admin/import/jira/import_runs/error_banner_component.html.erb @@ -37,5 +37,5 @@ See COPYRIGHT and LICENSE files for more details. href: continue_admin_import_jira_run_path(jira_id: import_run.jira.id, id: import_run.id, step: step), data: { turbo_stream: true } ) { I18n.t(:"admin.jira.run.wizard.button_retry") } %> - <%= import_run.error || I18n.t('js.error.internal') %> + <%= import_run.error&.html_safe || I18n.t('js.error.internal') %> <% end %> diff --git a/app/components/admin/import/jira/import_runs/error_banner_component.rb b/app/components/admin/import/jira/import_runs/error_banner_component.rb index 11f2c05f1e5c..8a2011f18fc9 100644 --- a/app/components/admin/import/jira/import_runs/error_banner_component.rb +++ b/app/components/admin/import/jira/import_runs/error_banner_component.rb @@ -31,6 +31,7 @@ module Admin::Import::Jira::ImportRuns class ErrorBannerComponent < ApplicationComponent include OpPrimer::ComponentHelpers + include Redmine::I18n attr_reader :import_run, :step diff --git a/app/models/import/jira_project.rb b/app/models/import/jira_project.rb index 31577d8e93ab..4be525888656 100644 --- a/app/models/import/jira_project.rb +++ b/app/models/import/jira_project.rb @@ -34,5 +34,6 @@ class JiraProject < ApplicationRecord belongs_to :jira, class_name: "Import::Jira" belongs_to :jira_import, class_name: "Import::JiraImport" + has_many :jira_issues, class_name: "Import::JiraIssue" end end diff --git a/app/models/work_package/semantic_identifier.rb b/app/models/work_package/semantic_identifier.rb index a1608d74860f..25b5780c4582 100644 --- a/app/models/work_package/semantic_identifier.rb +++ b/app/models/work_package/semantic_identifier.rb @@ -60,7 +60,9 @@ class UnsupportedLookup < ArgumentError; end .where("work_packages.identifier IS DISTINCT FROM projects.identifier || '-' || work_packages.sequence_number::text") } - after_create :allocate_and_register_semantic_id, if: -> { Setting::WorkPackageIdentifier.semantic? } + attr_accessor :skip_semantic_id_allocation + + after_create :allocate_and_register_semantic_id, if: -> { Setting::WorkPackageIdentifier.semantic? && !skip_semantic_id_allocation } validate :semantic_identifier_fields_consistent end @@ -126,6 +128,15 @@ def allocate_and_register_semantic_id end end + # Builds alias rows for every identifier this project has ever used at the given sequence (including the current one). + # This also includes "ghost identifiers" -- i.e. those that weren't ever actually generated, but should work + # as a historical alias (e.g. OLDPROJ-42 should work even if WP #42 was created after rename to NEWPROJ) + def alias_rows_for_sequence_number(seq) + project.slugs + .pluck(:slug) + .map { |prefix| { identifier: "#{prefix}-#{seq}", work_package_id: id } } + end + private # Ensures identifier and sequence_number are always written together. @@ -135,13 +146,4 @@ def semantic_identifier_fields_consistent errors.add(:identifier, :semantic_identifier_incomplete) end - - # Builds alias rows for every identifier this project has ever used at the given sequence (including the current one). - # This also includes "ghost identifiers" -- i.e. those that weren't ever actually generated, but should work - # as a historical alias (e.g. OLDPROJ-42 should work even if WP #42 was created after rename to NEWPROJ) - def alias_rows_for_sequence_number(seq) - project.slugs - .pluck(:slug) - .map { |prefix| { identifier: "#{prefix}-#{seq}", work_package_id: id } } - end end diff --git a/app/views/admin/import/jira/instances/index.html.erb b/app/views/admin/import/jira/instances/index.html.erb index d656cf73b1d9..7a3240ec255b 100644 --- a/app/views/admin/import/jira/instances/index.html.erb +++ b/app/views/admin/import/jira/instances/index.html.erb @@ -49,9 +49,9 @@ See COPYRIGHT and LICENSE files for more details. label: t("admin.jira.configuration.title"), scheme: :primary, tag: :a, - href: new_admin_import_jira_path) { - t("admin.jira.configuration.title") - } + href: new_admin_import_jira_path, + disabled: !Setting::WorkPackageIdentifier.semantic? + ) { t("admin.jira.configuration.title") } end %> @@ -72,6 +72,20 @@ See COPYRIGHT and LICENSE files for more details. }) } end + unless Setting::WorkPackageIdentifier.semantic? + container.with_row do + render(Primer::Alpha::Banner.new(scheme: :danger, icon: :stop, mb: 3)) { + concat(render(Primer::Beta::Text.new(tag: :p, font_weight: :bold)) { + I18n.t("admin.jira.errors.semantic_identifiers_must_be_enabled.title") + }) + concat(render(Primer::Beta::Text.new(tag: :p)) { + link_translate("admin.jira.errors.semantic_identifiers_must_be_enabled.description", + links: { link: admin_settings_work_packages_identifier_path }, + external: true) + }) + } + end + end container.with_row(pb: 3) do flex_layout do |flex| flex.with_column(flex: 1, mr: 1) do diff --git a/app/workers/import/jira_import_projects_job.rb b/app/workers/import/jira_import_projects_job.rb index 3c0c9974ee9d..30a231ad4bc0 100644 --- a/app/workers/import/jira_import_projects_job.rb +++ b/app/workers/import/jira_import_projects_job.rb @@ -32,6 +32,7 @@ module Import class JiraImportProjectsJob < ApplicationJob include Import::JiraOpenProjectReferenceCreation include JiraImportCustomFields + include Redmine::I18n # rubocop:disable Metrics/AbcSize def perform(jira_import_id) @@ -41,6 +42,19 @@ def perform(jira_import_id) @user = User.system @jira_client = Import::JiraClient.new(url: jira.url, personal_access_token: jira.personal_access_token) + unless Setting::WorkPackageIdentifier.semantic? + view_context = ApplicationController.new.view_context + title = view_context.render(Primer::Beta::Text.new(tag: :p, font_weight: :bold).with_content( + I18n.t("admin.jira.errors.semantic_identifiers_must_be_enabled.title") + )) + description = view_context.render(Primer::Beta::Text.new(tag: :p).with_content( + link_translate("admin.jira.errors.semantic_identifiers_must_be_enabled.description", + links: { link: OpenProject::StaticRouting::StaticUrlHelpers.new.admin_settings_work_packages_identifier_path }, + external: true) + )) + raise title + description + end + ActiveRecord::Base.transaction do @project_role = setup_project_role custom_field_registry = build_custom_field_registry @@ -76,12 +90,12 @@ def setup_project_role def import_project(jira_project) project_key = jira_project.payload.fetch("key") - identifier = Setting::WorkPackageIdentifier.semantic? ? project_key.upcase : project_key.downcase + project_keys = jira_project.payload.fetch("projectKeys") service_call = Projects::CreateService .new(user: @user, contract_class: EmptyContract) .call( name: jira_project.payload.fetch("name"), - identifier:, + identifier: project_key, description: jira_project.payload.fetch("description"), active: true, public: false, @@ -92,8 +106,12 @@ def import_project(jira_project) workspace_type: "project" ) if service_call.success? - create_reference!(op_leg: service_call.result, jira_leg: jira_project, jira_import: @jira_import, uses_existing: false) - return service_call.result + project = service_call.result + project_keys.each do |key| + project.slugs.create(slug: key) + end + create_reference!(op_leg: project, jira_leg: jira_project, jira_import: @jira_import, uses_existing: false) + return project end if (error = service_call.errors.find { |e| e.attribute == :identifier && e.type == :taken }) && error.present? @@ -246,11 +264,34 @@ def import_work_package(jira_issue, project, type, status, priority, custom_fiel priority:, status:, assigned_to:, + skip_semantic_id_allocation: true, **custom_field_attrs ) raise service_call.message unless service_call.success? work_package = service_call.result + + key = jira_issue.payload["key"] + _, sequence_number = key.split("-") + work_package.update_columns(sequence_number:, identifier: key) + work_package_id = work_package.id + aliases_from_history = jira_issue + .payload["changelog"]["histories"] + .flat_map { |i| i["items"] } + .select { |i| i["field"] == "Key" } + .flat_map do |i| + [ + { identifier: i["toString"], work_package_id: }, + { identifier: i["fromString"], work_package_id: } + ] + end + aliases = work_package.alias_rows_for_sequence_number(sequence_number) + aliases.concat(aliases_from_history) + aliases.uniq! + work_package.semantic_aliases.upsert_all(aliases, + on_duplicate: :skip, + unique_by: :identifier) + create_reference!(op_leg: work_package, jira_leg: jira_issue, jira_import: @jira_import, uses_existing: false) import_work_package_history(work_package, jira_issue, project) end diff --git a/config/locales/en.yml b/config/locales/en.yml index 0319501ef858..3147cea06eec 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -126,6 +126,9 @@ en: errors: cannot_delete_with_imports: "Cannot delete Jira host with existing imports" custom_field_creation_failed: "Failed to create custom field '%{name}': %{message}" + semantic_identifiers_must_be_enabled: + title: "Project-based semantic identifiers must be enabled." + description: "Jira uses work items identifiers consisting of project key and a sequence number (PRJ-123). OpenProject also supports it, but it needs to be enabled [here](link)." blank: title: "No Jira hosts configured yet" description: "Configure a Jira host to start importing items from Jira to this OpenProject instance."