diff --git a/app/workers/import/jira_create_labels_job.rb b/app/workers/import/jira_create_labels_job.rb new file mode 100644 index 000000000000..8057fe2b9daf --- /dev/null +++ b/app/workers/import/jira_create_labels_job.rb @@ -0,0 +1,79 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Import + class JiraCreateLabelsJob < ApplicationJob + include Import::JiraOpenProjectReferenceCreation + + DISTINCT_LABEL_NAMES = Arel.sql("DISTINCT jsonb_array_elements_text(payload -> 'fields' -> 'labels')") + + def text + I18n.t(:"admin.jira.run.jobs.#{self.class.to_s.demodulize}.title") + end + + def perform(jira_import_id) + @jira_import = Import::JiraImport.find(jira_import_id) + @system_user = User.system + + jira_label_names.each { create_label(it) } + end + + private + + def jira_label_names + Import::JiraIssue + .where(jira_import_id: @jira_import.id) + .where("jsonb_typeof(payload -> 'fields' -> 'labels') = 'array'") + .pluck(DISTINCT_LABEL_NAMES) + .filter_map { it.to_s.squish.presence } + .uniq(&:downcase) + end + + def create_label(name) + service_call = Labels::CreateService.new(user: @system_user).call(name:) + return reference_existing_label(name) if service_call.errors.symbols_for(:name).include?(:taken) + raise service_call.message if service_call.failure? + + create_reference!(op_leg: service_call.result, jira_leg: nil, jira_import: @jira_import, uses_existing: false) + end + + def reference_existing_label(name) + label = Label.named(name).first! + return if referenced?(label) + + create_reference!(op_leg: label, jira_leg: nil, jira_import: @jira_import, uses_existing: true) + end + + def referenced?(label) + Import::JiraOpenProjectReference.exists?(jira_import_id: @jira_import.id, + op_entity_class: label.class.to_s, + op_entity_id: label.id) + end + end +end diff --git a/app/workers/import/jira_create_project_work_packages_job.rb b/app/workers/import/jira_create_project_work_packages_job.rb index 744f24c0cf9e..8a7f5ed2a5e5 100644 --- a/app/workers/import/jira_create_project_work_packages_job.rb +++ b/app/workers/import/jira_create_project_work_packages_job.rb @@ -276,6 +276,28 @@ def resolution_date_cf_attrs(jira_issue) { @resolution_date_cf.attribute_getter => Time.zone.parse(raw).iso8601 } end + def label_attrs(jira_issue) + names = Array(jira_issue.payload.dig("fields", "labels")).filter_map { it.to_s.squish.presence } + issue_labels = names.index_with { find_label(it) } + log_missing_labels(issue_labels) + + label_ids = issue_labels.values.compact.map(&:id) + label_ids.any? ? { label_ids: } : {} + end + + def log_missing_labels(issue_labels) + missing = issue_labels.filter_map { |name, label| name if label.nil? } + return if missing.empty? + + Rails.logger.warn("Skipping jira labels without an OpenProject label: #{missing.join(', ')}") + end + + def find_label(name) = labels_by_name[name.downcase] + + def labels_by_name + @labels_by_name ||= Hash.new { |cache, name| cache[name] = Label.named(name).first } + end + def update_workflows(type) Rails.logger.tagged("jira_object_type:issueType", "jira_object_id_or_name:#{type.name}") do statuses = Status.all @@ -347,6 +369,7 @@ def create_work_package(jira_issue, project, type, status, priority, custom_fiel estimated_hours: (original_estimate_seconds / 3600.0 if original_estimate_seconds), remaining_hours: (remaining_estimate_seconds / 3600.0 if remaining_estimate_seconds), skip_semantic_id_allocation: true, + **label_attrs(jira_issue), **custom_field_attrs ) unless service_call.success? diff --git a/app/workers/import/jira_revert_import_job.rb b/app/workers/import/jira_revert_import_job.rb index a4aaebba1184..225ce04c0600 100644 --- a/app/workers/import/jira_revert_import_job.rb +++ b/app/workers/import/jira_revert_import_job.rb @@ -38,6 +38,7 @@ class JiraRevertImportJob < ApplicationJob delete_groups delete_project_roles delete_custom_fields + delete_labels delete_references delete_jira_objects].freeze @@ -242,6 +243,22 @@ def delete_custom_fields end end + def delete_labels + Import::JiraOpenProjectReference + .where(jira_import_id: @jira_import.id, uses_existing: false) + .where(op_entity_class: "Label") + .find_each do |ref| + op_leg = ref.op_leg + # label is used by something outside this import run, keep it + next if op_leg.labelings.exists? + + service_call = ::Labels::DeleteService.new(user: @user, model: op_leg).call + raise service_call.message if service_call.failure? + rescue Import::JiraOpenProjectReference::LegNotFoundError + next + end + end + def delete_references Import::JiraOpenProjectReference.where(jira_import_id: @jira_import.id).delete_all end diff --git a/app/workers/import/jira_staged_import_job.rb b/app/workers/import/jira_staged_import_job.rb index bd381c94ee17..82b95ca16a37 100644 --- a/app/workers/import/jira_staged_import_job.rb +++ b/app/workers/import/jira_staged_import_job.rb @@ -81,6 +81,7 @@ def perform_stage(batch, jira_import) batch.enqueue(stage: 5) do Import::JiraCreateProjectRoleJob.set(good_job_labels: ["stage_5"]).perform_later(jira_import.id) Import::JiraCreateCustomFieldsJob.set(good_job_labels: ["stage_5"]).perform_later(jira_import.id) + Import::JiraCreateLabelsJob.set(good_job_labels: ["stage_5"]).perform_later(jira_import.id) end elsif batch.properties[:stage] == 5 Rails.logger.info "Starting stage 6" diff --git a/config/locales/en.yml b/config/locales/en.yml index 0fbb57997d02..0a942d20e828 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1683,6 +1683,8 @@ en: jobs: JiraCreateCustomFieldsJob: title: "Create custom fields" + JiraCreateLabelsJob: + title: "Create labels" JiraCreateProjectJob: title: "Create project '%{jira_project_name}'" JiraCreateProjectRoleJob: diff --git a/docs/installation-and-operations/jira-migration/field-mapping/README.md b/docs/installation-and-operations/jira-migration/field-mapping/README.md index f694e765bea0..b69d2f448aee 100644 --- a/docs/installation-and-operations/jira-migration/field-mapping/README.md +++ b/docs/installation-and-operations/jira-migration/field-mapping/README.md @@ -38,6 +38,7 @@ Every migrated project is created **private** and **active**, with no parent — | Issue key (e.g. `PROJECT-123`) | Work package identifier | Old/renamed issue keys also keep working afterward, the same way as project keys | | Issue links (blocks, relates to, etc.) | **Not migrated** | — (planned: [JIM-76](https://community.openproject.org/projects/JIM/work_packages/JIM-76)) | | Issue type | Type | Matched by name (not case-sensitive); a new Type is created automatically if nothing matches, and it's attached to the right projects (mirroring Jira's per-project Issue Type Scheme). Only the name comes across. No migrated type is marked as the project's default, even if it was the default in Jira. | +| Labels | Labels | Matched by name (not case-sensitive); created automatically if missing. | | Logged work / time entries | **Not migrated** | - (planned: [JIM-93](https://community.openproject.org/projects/JIM/work_packages/JIM-93)) | | Original time estimate | Work | - | | Priority | Priority | Matched by name (not case-sensitive); created automatically if missing. If an issue has no priority in Jira, OpenProject's default priority is used. Only the name comes across — Jira's severity order, colors, and icons aren't preserved, and this matters beyond appearance: OpenProject falls back to whichever priority ends up first in the list when an issue has none set | diff --git a/docs/installation-and-operations/jira-migration/pre-migration-checklist/README.md b/docs/installation-and-operations/jira-migration/pre-migration-checklist/README.md index 21c60451a844..71d8923a9abb 100644 --- a/docs/installation-and-operations/jira-migration/pre-migration-checklist/README.md +++ b/docs/installation-and-operations/jira-migration/pre-migration-checklist/README.md @@ -31,7 +31,6 @@ Review this against how your teams actually use Jira. If anything here would be - [ ] Sub-tasks and parent/child hierarchy (planned: [JIM-75](https://community.openproject.org/projects/JIM/work_packages/JIM-75)) - [ ] Issue links (blocks, relates to, duplicates, etc.) (planned: [JIM-76](https://community.openproject.org/projects/JIM/work_packages/JIM-76)) - [ ] Web links (workaround planned: [JIM-206](https://community.openproject.org/projects/JIM/work_packages/JIM-206)). -- [ ] Labels (planned: [JIM-109](https://community.openproject.org/projects/JIM/work_packages/JIM-109)) - [ ] Logged work / time entries (only the *estimated* and *remaining* hours fields come across — actual time logged does not) (planned: [JIM-93](https://community.openproject.org/projects/JIM/work_packages/JIM-93)) - [ ] Votes (workaround planned: [JIM-207](https://community.openproject.org/projects/JIM/work_packages/JIM-207), open feature request: [OP-3251](https://community.openproject.org/projects/OP/work_packages/OP-3251)) - [ ] Environment diff --git a/spec/support/shared/with_jira_project_import_data.rb b/spec/support/shared/with_jira_project_import_data.rb index 09423c605726..b7fef336f525 100644 --- a/spec/support/shared/with_jira_project_import_data.rb +++ b/spec/support/shared/with_jira_project_import_data.rb @@ -60,6 +60,10 @@ def create_project_role Import::JiraCreateProjectRoleJob.perform_now(jira_import.id) end + def create_labels + Import::JiraCreateLabelsJob.perform_now(jira_import.id) + end + def create_project Import::JiraCreateProjectJob.perform_now(jira_import.id, jira_project.id) end @@ -75,6 +79,7 @@ def create_work_package_attachments # Runs every per-project stage of the import in the order Import::JiraStagedImportJob enqueues them. def import_project create_project_role + create_labels create_project create_work_packages create_work_package_attachments diff --git a/spec/workers/import/jira_create_labels_job_spec.rb b/spec/workers/import/jira_create_labels_job_spec.rb new file mode 100644 index 000000000000..0a916f2ed8a0 --- /dev/null +++ b/spec/workers/import/jira_create_labels_job_spec.rb @@ -0,0 +1,173 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Import::JiraCreateLabelsJob do + include_context "with a jira demo issue" + + def references + Import::JiraOpenProjectReference.where(jira_import:, op_entity_class: "Label") + end + + describe "#perform" do + let(:jira_issue_payload) do + super().tap { |payload| payload["fields"]["labels"] = %w[backend urgent] } + end + + it "creates a label per distinct jira label of the import" do + expect { create_labels }.to change(Label, :count).by(2) + + expect(Label.pluck(:name)).to contain_exactly("backend", "urgent") + end + + it "authors the labels as the system user" do + create_labels + + expect(Label.distinct.pluck(:author_id)).to eq([User.system.id]) + end + + it "references the created labels so the revert can remove them" do + create_labels + + expect(references.pluck(:uses_existing)).to eq([false, false]) + expect(references.map { |ref| ref.op_leg.name }).to contain_exactly("backend", "urgent") + end + + it "creates nothing new when the import is run again" do + create_labels + + expect { create_labels }.to not_change(Label, :count).and not_change(references, :count) + end + + it "keeps its own labels revertable when a half-finished run is retried" do + create_labels + create_labels + + expect(references.pluck(:uses_existing)).to eq([false, false]) + end + + context "when several issues share a label" do + let!(:jira_issue2) do + payload = jira_issue_payload.deep_dup.tap do |p| + p["id"] = "10406" + p["key"] = "DPPP-7" + p["fields"]["labels"] = %w[backend Backend] + end + create(:jira_issue, jira_import:, origin_id: "10406", jira_project:, payload:) + end + + it "creates it once, ignoring case" do + expect { create_labels }.to change(Label, :count).by(2) + end + end + + context "when a label with the same name already exists in a different case" do + let!(:existing_label) { create(:label, name: "Backend") } + + it "reuses it instead of creating a second one" do + expect { create_labels }.to change(Label, :count).by(1) + + expect(Label.named("backend").first).to eq(existing_label) + end + + it "references it as pre-existing, so the revert leaves it alone" do + create_labels + + expect(references.where(uses_existing: true).map(&:op_leg)).to eq([existing_label]) + expect(references.where(uses_existing: false).map { |ref| ref.op_leg.name }).to eq(["urgent"]) + end + + it "still references it as pre-existing when a half-finished run is retried" do + create_labels + create_labels + + expect(references.where(uses_existing: true).map(&:op_leg)).to eq([existing_label]) + end + end + + context "when the issues of two projects share a label" do + let!(:other_jira_project) { create(:jira_project, jira_import:, payload: jira_project_payload) } + + let!(:other_project_issue) do + payload = jira_issue_payload.deep_dup.tap { |p| p["fields"]["labels"] = %w[backend] } + create(:jira_issue, jira_import:, jira_project: other_jira_project, payload:) + end + + it "creates the shared label once" do + expect { create_labels }.to change(Label, :count).by(2) + + expect(Label.named("backend").count).to eq(1) + end + end + + context "when the issues of another import carry labels" do + let(:other_jira_import) { create(:jira_import, jira:, author:) } + + let!(:other_jira_issue) do + payload = jira_issue_payload.deep_dup.tap { |p| p["fields"]["labels"] = %w[elsewhere] } + create(:jira_issue, jira_import: other_jira_import, origin_id: "10407", jira_project:, payload:) + end + + it "ignores them" do + create_labels + + expect(Label.named("elsewhere")).to be_empty + end + end + + context "when no issue carries a label" do + let(:jira_issue_payload) { super().tap { |payload| payload["fields"]["labels"] = [] } } + + it "creates nothing" do + expect { create_labels }.not_to change(Label, :count) + end + end + + context "when the labels field is hidden in the jira field configuration" do + let(:jira_issue_payload) { super().tap { |payload| payload["fields"].delete("labels") } } + + it "creates nothing" do + expect { create_labels }.not_to change(Label, :count) + end + end + + context "when label names are padded, blank or null" do + let(:jira_issue_payload) do + super().tap { |payload| payload["fields"]["labels"] = [" backend ", " ", nil] } + end + + it "squishes them and skips the ones left empty" do + expect { create_labels }.to change(Label, :count).by(1) + + expect(Label.pluck(:name)).to eq(["backend"]) + end + end + end +end diff --git a/spec/workers/import/jira_create_project_work_packages_job_spec.rb b/spec/workers/import/jira_create_project_work_packages_job_spec.rb index d207c89f26ce..a653864f5e8f 100644 --- a/spec/workers/import/jira_create_project_work_packages_job_spec.rb +++ b/spec/workers/import/jira_create_project_work_packages_job_spec.rb @@ -406,6 +406,90 @@ def stage_5_resolution_date_cf end end + context "with labels" do + let(:jira_issue_payload) do + super().tap { |payload| payload["fields"]["labels"] = %w[backend urgent] } + end + + it "attaches the jira labels to the work package" do + create_labels + create_work_packages + + expect(WorkPackage.find("DPPP-6").labels.pluck(:name)).to contain_exactly("backend", "urgent") + end + + it "records the labels in the work package journal" do + create_labels + create_work_packages + + work_package = WorkPackage.find("DPPP-6") + expect(work_package.journals.last.label_journals.pluck(:label_id)).to match_array(work_package.label_ids) + end + + context "when a label with the same name already exists in a different case" do + let!(:existing_label) { create(:label, name: "Backend") } + + it "attaches the existing label instead of a second one" do + create_labels + create_work_packages + + expect(WorkPackage.find("DPPP-6").labels).to include(existing_label) + expect(Label.named("backend").count).to eq(1) + end + end + + context "when a second issue carries the same label" do + let!(:jira_issue2) do + payload = jira_issue_payload.deep_dup + payload["id"] = "10406" + payload["key"] = "DPPP-7" + payload["fields"]["labels"] = %w[backend] + create(:jira_issue, jira_import:, origin_id: "10406", jira_project:, payload:) + end + + it "attaches the one label to both work packages" do + create_labels + create_work_packages + + expect(WorkPackage.find("DPPP-7").labels.pluck(:name)).to eq(["backend"]) + expect(WorkPackage.find("DPPP-6").labels).to include(Label.named("backend").first) + end + end + + context "when the issue has no labels" do + let(:jira_issue_payload) { super().tap { |payload| payload["fields"]["labels"] = [] } } + + it "leaves the work package without labels" do + create_labels + create_work_packages + + expect(WorkPackage.find("DPPP-6").labels).to be_empty + end + end + + context "when the labels field is hidden in the jira field configuration" do + let(:jira_issue_payload) { super().tap { |payload| payload["fields"].delete("labels") } } + + it "imports the work package without labels" do + create_labels + create_work_packages + + expect(WorkPackage.find("DPPP-6").labels).to be_empty + end + end + + context "when the labels stage did not run" do + it "imports the work package without labels and logs them" do + allow(Rails.logger).to receive(:warn) + + create_work_packages + + expect(WorkPackage.find("DPPP-6").labels).to be_empty + expect(Rails.logger).to have_received(:warn).with(/backend, urgent/) + end + end + end + context "with resolution fields" do context "when an issue's resolution is set" do let(:jira_issue_payload) do diff --git a/spec/workers/import/jira_revert_import_job_spec.rb b/spec/workers/import/jira_revert_import_job_spec.rb index 34cbfdd50902..6cecc9b8f711 100644 --- a/spec/workers/import/jira_revert_import_job_spec.rb +++ b/spec/workers/import/jira_revert_import_job_spec.rb @@ -258,6 +258,47 @@ def transition_to_reverting end end + describe "#delete_labels" do + let(:label) { create(:label) } + let!(:reference) { reference_for(label, "Label") } + + it "deletes the referenced label" do + expect { job.send(:delete_labels) }.to change(Label, :count).by(-1) + end + + context "when the label is already gone" do + before { label.destroy } + + it "skips the dangling reference" do + expect { job.send(:delete_labels) }.not_to raise_error + end + end + + context "when the label existed before the import" do + let(:pre_existing_label) { create(:label) } + + before { reference_for(pre_existing_label, "Label", uses_existing: true) } + + it "keeps it" do + job.send(:delete_labels) + + expect(Label).to exist(pre_existing_label.id) + end + end + + context "when the label is still attached to a work package outside the import" do + let(:work_package) { create(:work_package) } + + before { create(:labeling, label:, labelable: work_package) } + + it "keeps it" do + job.send(:delete_labels) + + expect(Label).to exist(label.id) + end + end + end + describe "#delete_references" do let(:other_import) { create(:jira_import, jira:, author:) } @@ -341,15 +382,15 @@ def transition_to_reverting describe "#progress" do it "is 0 while no step has been completed" do - expect(job.progress).to eq({ current: 0, percentage: 0, total: 8 }) + expect(job.progress).to eq({ current: 0, percentage: 0, total: 9 }) end it "grows with every completed step" do jira_import.set_job_cursor(job, :delete_projects) - expect(job.progress).to eq({ current: 1, percentage: 12.5, total: 8 }) + expect(job.progress).to eq({ current: 1, percentage: 11.11, total: 9 }) jira_import.set_job_cursor(job, :delete_jira_objects) - expect(job.progress).to eq({ current: 8, percentage: 100, total: 8 }) + expect(job.progress).to eq({ current: 9, percentage: 100, total: 9 }) end end diff --git a/spec/workers/import/jira_staged_import_job_spec.rb b/spec/workers/import/jira_staged_import_job_spec.rb index 06eb94de31e6..d57a0af0ef17 100644 --- a/spec/workers/import/jira_staged_import_job_spec.rb +++ b/spec/workers/import/jira_staged_import_job_spec.rb @@ -82,6 +82,7 @@ def transition_to_importing expect(GoodJob::Job.where(job_class: "Import::JiraCreateProjectRoleJob").count).to eq(0) expect(GoodJob::Job.where(job_class: "Import::JiraCreateCustomFieldsJob").count).to eq(0) + expect(GoodJob::Job.where(job_class: "Import::JiraCreateLabelsJob").count).to eq(0) end it "transitions the import to import_error" do @@ -216,11 +217,12 @@ def transition_to_importing describe "stage 4" do let(:stage) { 4 } - it "enqueues the project role and custom fields creation jobs" do + it "enqueues the project role, custom fields and labels creation jobs" do run_callback expect(GoodJob::Job.where(job_class: "Import::JiraCreateProjectRoleJob").count).to eq(1) expect(GoodJob::Job.where(job_class: "Import::JiraCreateCustomFieldsJob").count).to eq(1) + expect(GoodJob::Job.where(job_class: "Import::JiraCreateLabelsJob").count).to eq(1) end it "labels enqueued jobs with stage_5" do @@ -228,6 +230,7 @@ def transition_to_importing expect(GoodJob::Job.where(job_class: "Import::JiraCreateProjectRoleJob").last.labels).to include("stage_5") expect(GoodJob::Job.where(job_class: "Import::JiraCreateCustomFieldsJob").last.labels).to include("stage_5") + expect(GoodJob::Job.where(job_class: "Import::JiraCreateLabelsJob").last.labels).to include("stage_5") end end