Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions app/workers/import/jira_create_labels_job.rb
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
#
# 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
23 changes: 23 additions & 0 deletions app/workers/import/jira_create_project_work_packages_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down
17 changes: 17 additions & 0 deletions app/workers/import/jira_revert_import_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class JiraRevertImportJob < ApplicationJob
delete_groups
delete_project_roles
delete_custom_fields
delete_labels
delete_references
delete_jira_objects].freeze

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/workers/import/jira_staged_import_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1683,6 +1683,8 @@ en:
jobs:
JiraCreateCustomFieldsJob:
title: "Create custom fields"
JiraCreateLabelsJob:
title: "Create labels"
JiraCreateProjectJob:
title: "Create project '%{jira_project_name}'"
JiraCreateProjectRoleJob:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions spec/support/shared/with_jira_project_import_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
173 changes: 173 additions & 0 deletions spec/workers/import/jira_create_labels_job_spec.rb
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
#
# 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
Loading
Loading