Skip to content

Commit 280144a

Browse files
authored
Merge pull request #24637 from opf/bug/jim-150-custom-fields-of-type-select-list-single-choice-are-not-migrated-correctly
[JIM-150] Custom fields of type Select List (single choice) are not migrated correctly
2 parents 9d596c8 + cffd2ab commit 280144a

5 files changed

Lines changed: 343 additions & 83 deletions

File tree

app/workers/import/jira_import_projects_job.rb

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,7 @@ def import_issue(jira_issue, project, custom_field_registry)
151151

152152
def new_custom_fields_in_type(jira_issue, type, custom_field_registry)
153153
existing_cf_ids = type.custom_field_ids
154-
cfs = custom_field_registry.filter_map do |entry|
155-
field_key = entry[:jira_field].jira_field_id
156-
raw_value = jira_issue.payload["fields"][field_key]
157-
next if raw_value.blank?
158-
159-
context = find_context_for_issue(entry, jira_issue)
160-
context&.dig(:custom_field)
161-
end
162-
cfs.uniq.reject { |cf| existing_cf_ids.include?(cf.id) }
154+
custom_fields_for_issue(custom_field_registry, jira_issue).reject { |cf| existing_cf_ids.include?(cf.id) }
163155
end
164156

165157
def update_custom_fields_in_type(type, new_custom_fields)
@@ -193,12 +185,9 @@ def add_or_update_jira_import_group(groups, cf_keys)
193185
end
194186

195187
def update_custom_fields_in_project(project, jira_project, custom_field_registry)
196-
project_key = jira_project.payload["key"]
197-
applicable_cfs = custom_field_registry.flat_map do |entry|
198-
entry[:contexts]
199-
.select { |ctx| context_applies_to_project?(ctx, project_key) }
200-
.map { |ctx| ctx[:custom_field] }
201-
end
188+
applicable_cfs = Import::JiraIssue
189+
.where(jira_import_id: @jira_import.id, jira_project_id: jira_project.id)
190+
.flat_map { |jira_issue| custom_fields_for_issue(custom_field_registry, jira_issue) }
202191
existing_cf_ids = project.work_package_custom_fields.pluck(:id).to_set
203192
new_cfs = applicable_cfs.uniq.reject { |cf| existing_cf_ids.include?(cf.id) }
204193
project.work_package_custom_fields << new_cfs if new_cfs.any?

app/workers/import/jira_import_projects_job/jira_import_custom_field_builder.rb

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,14 @@ def list_field_option_values
250250
if cascading_select_as_list?
251251
flatten_cascading_allowed_values(allowed)
252252
else
253-
allowed.pluck("value").compact.uniq
253+
allowed.pluck("value").compact.map { |value| option_label(value) }.compact_blank.uniq
254254
end
255255
end
256256

257+
def option_label(value)
258+
value.to_s.strip
259+
end
260+
257261
def cascading_select_as_list?
258262
schema = jira_field.payload["schema"] || {}
259263
schema["custom"].to_s.end_with?(":cascadingselect") &&
@@ -264,7 +268,7 @@ def cascading_select_as_list?
264268
# E.g. Animals -> ["Animals"], Animals / Cat -> ["Animals", "Animals / Cat"]
265269
def flatten_cascading_allowed_values(allowed_values, parent_path: nil)
266270
allowed_values.flat_map do |av|
267-
label = av["value"]
271+
label = option_label(av["value"])
268272
next [] if label.blank?
269273

270274
full_path = parent_path ? "#{parent_path} / #{label}" : label
@@ -303,7 +307,7 @@ def jira_to_op_field_format(jira_field)
303307
def convert_multicheckbox_bool_value(raw_value)
304308
return false unless raw_value.is_a?(Array)
305309

306-
raw_value.any? { |v| v["value"] == @option_value }
310+
raw_value.any? { |v| option_label(v["value"]) == @option_value }
307311
end
308312

309313
def convert_user_value(raw_value)
@@ -341,7 +345,7 @@ def convert_single_list_value(raw_value, custom_field)
341345
end
342346

343347
def extract_list_label(value)
344-
value.is_a?(Hash) ? value["value"] : value.to_s
348+
option_label(value.is_a?(Hash) ? value["value"] : value)
345349
end
346350

347351
# Walks the parent -> child chain of a cascading select value, returning
@@ -350,7 +354,7 @@ def extract_list_label(value)
350354
def extract_cascading_chain(value, parent_path: nil)
351355
return [] unless value.is_a?(Hash) && value["value"].present?
352356

353-
label = value["value"]
357+
label = option_label(value["value"])
354358
full_path = parent_path ? "#{parent_path} / #{label}" : label
355359
[full_path] + extract_cascading_chain(value["child"], parent_path: full_path)
356360
end
@@ -369,7 +373,7 @@ def populate_hierarchy_items(custom_field)
369373
end
370374

371375
def insert_hierarchy_option(service, contract, parent, option)
372-
label = option["value"]
376+
label = option_label(option["value"])
373377
return if label.blank?
374378

375379
result = service.insert_item(contract_class: contract, parent:, label:)
@@ -398,7 +402,7 @@ def convert_hierarchy_value(raw_value, custom_field)
398402
root = custom_field.hierarchy_root
399403
return unless root
400404

401-
parent_item = root.children.find_by(label: raw_value["value"])
405+
parent_item = root.children.find_by(label: option_label(raw_value["value"]))
402406
return unless parent_item
403407

404408
find_hierarchy_child(parent_item, raw_value["child"])&.id || parent_item.id
@@ -407,7 +411,7 @@ def convert_hierarchy_value(raw_value, custom_field)
407411
def find_hierarchy_child(parent_item, child_data)
408412
return unless child_data.is_a?(Hash) && child_data["value"].present?
409413

410-
parent_item.children.find_by(label: child_data["value"])
414+
parent_item.children.find_by(label: option_label(child_data["value"]))
411415
end
412416

413417
def find_field_user(jira_user_key)

app/workers/import/jira_import_projects_job/jira_import_custom_fields.rb

Lines changed: 148 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ def collect_custom_field_attributes(custom_field_registry, jira_issue)
4747
end
4848
end
4949

50+
def custom_fields_for_issue(custom_field_registry, jira_issue)
51+
custom_field_registry.filter_map do |entry|
52+
raw_value = jira_issue.payload["fields"][entry[:jira_field].jira_field_id]
53+
next if raw_value.blank?
54+
55+
find_context_for_issue(entry, jira_issue)&.dig(:custom_field)
56+
end.uniq
57+
end
58+
5059
# Builds one OP custom field per (Jira field, context group) combination, before any
5160
# per-project import begins. Context groups describe which (project_key, issuetype_id)
5261
# tuples share an allowedValues set.
@@ -98,59 +107,30 @@ def string_array_field?(jira_field)
98107
schema["type"] == "array" && schema["items"] == "string"
99108
end
100109

101-
def build_string_array_registry_entries(jira_field)
102-
string_values = collect_string_values_from_issues(jira_field)
103-
allowed_values = string_values.map { |v| { "value" => v } }
104-
groups = jira_field.payload["contextGroups"]
105-
106-
contexts = if groups.present?
107-
groups.map { |g| build_context_entry(jira_field, g.merge("allowedValues" => allowed_values)) }
108-
else
109-
[
110-
build_context_entry(
111-
jira_field,
112-
{
113-
"projects" => [],
114-
"issuetypes" => [],
115-
"allowedValues" => allowed_values
116-
}
117-
)
118-
]
119-
end
120-
[{ jira_field:, contexts: }]
110+
def option_field?(jira_field)
111+
schema = jira_field.payload["schema"] || {}
112+
%w[option option-with-child].include?(schema["type"]) ||
113+
(schema["type"] == "array" && schema["items"] == "option")
121114
end
122115

123-
def collect_string_values_from_issues(jira_field)
124-
field_key = jira_field.jira_field_id
125-
values = Set.new
126-
Import::JiraIssue.where(jira_id: @jira_id, jira_project_id: all_jira_import_project_ids).find_each do |issue|
127-
raw = issue.payload["fields"][field_key]
128-
next unless raw.is_a?(Array)
129-
130-
raw.each { |v| values << v.to_s if v.present? }
116+
def build_string_array_registry_entries(jira_field)
117+
allowed_values = Array(jira_field.payload["contextGroups"]).flat_map { |group| Array(group["allowedValues"]) }
118+
allowed_values = issue_string_values(jira_field).reduce(allowed_values) do |values, string_value|
119+
merge_allowed_value(values, { "value" => string_value })
131120
end
132-
values.to_a.sort
121+
contexts = [
122+
build_context_entry(jira_field, { "projects" => [], "issuetypes" => [], "allowedValues" => allowed_values })
123+
]
124+
[{ jira_field:, contexts: }]
133125
end
134126

135127
def build_multicheckbox_registry_entries(jira_field)
136-
groups = jira_field.payload["contextGroups"]
137-
138-
return build_multicheckbox_entries_without_context_groups(jira_field) if groups.blank?
128+
groups = augmented_context_groups(jira_field)
129+
return [] if groups.blank?
139130

140131
build_multicheckbox_entries_with_context_groups(jira_field, groups)
141132
end
142133

143-
def build_multicheckbox_entries_without_context_groups(jira_field)
144-
option_values = collect_option_values_from_issues(jira_field)
145-
return [] if option_values.empty?
146-
147-
if option_values.size == 1
148-
[{ jira_field:, contexts: [build_context_entry(jira_field, nil, option_value: option_values.first)] }]
149-
else
150-
[{ jira_field:, contexts: [build_context_entry(jira_field, nil)] }]
151-
end
152-
end
153-
154134
def build_multicheckbox_entries_with_context_groups(jira_field, groups)
155135
boolean_groups, list_groups = partition_multicheckbox_groups_by_value_count(groups)
156136

@@ -162,7 +142,7 @@ def partition_multicheckbox_groups_by_value_count(groups)
162142
list_groups = []
163143

164144
groups.each do |group|
165-
option_values = Array(group["allowedValues"]).pluck("value").compact.uniq
145+
option_values = option_labels(group["allowedValues"])
166146
if option_values.size == 1
167147
boolean_groups << { group:, option_value: option_values.first }
168148
elsif option_values.size > 1
@@ -197,19 +177,8 @@ def all_jira_import_project_ids
197177
.pluck(:id)
198178
end
199179

200-
def collect_option_values_from_issues(jira_field)
201-
values = Set.new
202-
Import::JiraIssue.where(jira_id: @jira_id, jira_project_id: all_jira_import_project_ids).find_each do |issue|
203-
raw = issue.payload["fields"][jira_field.jira_field_id]
204-
next unless raw.is_a?(Array)
205-
206-
raw.each { |v| values << v["value"] if v["value"].present? }
207-
end
208-
values.to_a.sort
209-
end
210-
211180
def build_contexts_for_field(jira_field)
212-
groups = jira_field.payload["contextGroups"]
181+
groups = augmented_context_groups(jira_field)
213182
if groups.present?
214183
needs_disambiguation = groups.size > 1
215184
groups.map { |group| build_context_entry(jira_field, group, needs_disambiguation:) }
@@ -218,6 +187,121 @@ def build_contexts_for_field(jira_field)
218187
end
219188
end
220189

190+
def augmented_context_groups(jira_field)
191+
groups = dup_context_groups(jira_field)
192+
return groups unless option_field?(jira_field)
193+
194+
issue_options = issue_option_values(jira_field)
195+
return groups if issue_options.empty?
196+
197+
groups << global_context_group if groups.empty?
198+
issue_options.each { |option, *scope| add_option_to_context_group(groups, option, scope) }
199+
groups
200+
end
201+
202+
def dup_context_groups(jira_field)
203+
Array(jira_field.payload["contextGroups"]).map do |group|
204+
group.merge("allowedValues" => Array(group["allowedValues"]))
205+
end
206+
end
207+
208+
def global_context_group
209+
{ "projects" => [], "issuetypes" => [], "allowedValues" => [] }
210+
end
211+
212+
def add_option_to_context_group(groups, option, scope)
213+
group = groups[matching_context_group_index(groups, *scope)]
214+
group["allowedValues"] = merge_allowed_value(group["allowedValues"], option)
215+
end
216+
217+
def merge_allowed_value(allowed_values, option)
218+
label = option["value"].to_s.strip
219+
return allowed_values if label.blank?
220+
221+
existing = allowed_values.find { |av| av["value"].to_s.strip == label }
222+
return allowed_values + [build_allowed_value(label, option["child"])] if existing.nil?
223+
224+
merge_allowed_child(allowed_values, existing, option["child"])
225+
end
226+
227+
def merge_allowed_child(allowed_values, existing, child)
228+
return allowed_values if child.blank?
229+
230+
merged = existing.merge("children" => merge_allowed_value(Array(existing["children"]), child))
231+
allowed_values.map { |av| av.equal?(existing) ? merged : av }
232+
end
233+
234+
def build_allowed_value(label, child)
235+
entry = { "value" => label }
236+
entry["children"] = merge_allowed_value([], child) if child.present?
237+
entry
238+
end
239+
240+
def matching_context_group_index(groups, project_key, issuetype_id)
241+
groups.index do |group|
242+
scope_applies?(group["projects"], project_key) && scope_applies?(group["issuetypes"], issuetype_id)
243+
end || 0
244+
end
245+
246+
def option_labels(allowed_values)
247+
Array(allowed_values).pluck("value").compact.map { |value| value.to_s.strip }.compact_blank.uniq
248+
end
249+
250+
def issue_option_values(jira_field)
251+
issue_field_values_index[:options][jira_field.jira_field_id].values
252+
end
253+
254+
def issue_string_values(jira_field)
255+
issue_field_values_index[:strings][jira_field.jira_field_id].to_a.sort
256+
end
257+
258+
def issue_field_values_index
259+
@issue_field_values_index ||= build_issue_field_values_index
260+
end
261+
262+
def build_issue_field_values_index
263+
index = { options: Hash.new { |h, k| h[k] = {} }, strings: Hash.new { |h, k| h[k] = Set.new } }
264+
Import::JiraIssue.where(jira_id: @jira_id, jira_project_id: all_jira_import_project_ids).find_each do |issue|
265+
scope = issue_context_scope(issue)
266+
used_custom_field_values(issue).each { |field_key, raw| record_issue_field_values(index, field_key, raw, scope) }
267+
end
268+
index
269+
end
270+
271+
def issue_context_scope(issue)
272+
[issue.payload.dig("fields", "project", "key"), issue.payload.dig("fields", "issuetype", "id")]
273+
end
274+
275+
def used_custom_field_values(issue)
276+
issue.payload["fields"].select { |field_key, raw| field_key.start_with?("customfield_") && raw.present? }
277+
end
278+
279+
def record_issue_field_values(index, field_key, raw, scope)
280+
Array.wrap(raw).each do |value|
281+
if value.is_a?(Hash)
282+
record_issue_option_value(index[:options][field_key], value, scope)
283+
elsif raw.is_a?(Array) && value.is_a?(String)
284+
index[:strings][field_key] << value.strip if value.strip.present?
285+
end
286+
end
287+
end
288+
289+
def record_issue_option_value(field_options, option, scope)
290+
return if option["value"].blank?
291+
292+
field_options[scope + [option_chain_signature(option)]] ||= [option, *scope]
293+
end
294+
295+
def option_chain_signature(option)
296+
labels = []
297+
node = option
298+
while node.is_a?(Hash) && node["value"].present?
299+
labels << node["value"].to_s.strip
300+
node = node["child"]
301+
end
302+
labels.join(" / ")
303+
end
304+
221305
def build_context_entry(jira_field, context_group, option_value: nil, needs_disambiguation: false)
222306
builder = JiraImportCustomFieldBuilder.new(
223307
jira_field,
@@ -287,11 +371,17 @@ def find_context_for_issue(entry, jira_issue)
287371
end
288372

289373
def context_applies_to_project?(context, project_key)
290-
context[:projects].empty? || context[:projects].include?(project_key)
374+
scope_applies?(context[:projects], project_key)
291375
end
292376

293377
def context_applies_to_issuetype?(context, issuetype_id)
294-
context[:issuetypes].empty? || context[:issuetypes].include?(issuetype_id)
378+
scope_applies?(context[:issuetypes], issuetype_id)
379+
end
380+
381+
# An empty scope means "applies to all projects" / "applies to all issue types".
382+
def scope_applies?(scope, value)
383+
scope = Array(scope)
384+
scope.empty? || scope.include?(value)
295385
end
296386
end
297387
end

0 commit comments

Comments
 (0)