Skip to content

Commit bcc926a

Browse files
authored
Merge pull request #22765 from opf/feature/71654-semantic-converter-2
[#71645] Convert instance to semantic identifiers
2 parents eb4e946 + 56f130d commit bcc926a

18 files changed

Lines changed: 954 additions & 20 deletions

app/components/work_packages/admin/settings/identifier_settings_form_component.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,22 @@ def work_package_identifier_controller_attrs
9999

100100
def radio_button_options
101101
if change_in_progress?
102-
{ button_options: { disabled: true } }
102+
{
103+
values: identifier_values(checked: nil),
104+
button_options: { disabled: true }
105+
}
106+
elsif completed?
107+
{ values: identifier_values(checked: Setting[:work_packages_identifier]) }
103108
else
104109
{ button_options: { data: { action: "change->admin--work-packages-identifier#handleChange" } } }
105110
end
106111
end
112+
113+
def identifier_values(checked:)
114+
Setting::WorkPackageIdentifier::ALLOWED_VALUES.map do |v|
115+
{ name: v, value: v, checked: v == checked }
116+
end
117+
end
107118
end
108119
end
109120
end

app/models/projects/semantic_identifier.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,23 @@ def allocate_wp_semantic_identifier!
5151
[seq, "#{identifier}-#{seq}"]
5252
end
5353

54+
# Returns the most-recent slug from FriendlyId history that is a valid semantic
55+
# identifier and is not currently held by another project, or nil if none exists.
56+
# Used by the backfill job to restore a prior semantic identifier instead of
57+
# generating a fresh one, so existing WP identifiers and aliases remain correct.
58+
def previous_semantic_identifier
59+
candidates = previous_semantic_identifier_candidates
60+
return nil if candidates.empty?
61+
62+
taken = self.class
63+
.where.not(id:)
64+
.where("LOWER(identifier) IN (?)", candidates.map(&:downcase))
65+
.pluck(:identifier)
66+
.to_set(&:downcase)
67+
68+
candidates.find { |slug| taken.exclude?(slug.downcase) }
69+
end
70+
5471
# Called after this project's identifier is renamed. Atomically:
5572
# 1. Appends new-prefix aliases for every WP that ever carried an old-prefix alias.
5673
# 2. Updates identifier on resident WPs to the new prefix.
@@ -67,6 +84,13 @@ def handle_semantic_rename(old_identifier, batch_size: 1000)
6784

6885
private
6986

87+
def previous_semantic_identifier_candidates
88+
slugs
89+
.order(created_at: :desc)
90+
.pluck(:slug)
91+
.select { |slug| ProjectIdentifiers::IdentifierAutofix::ProblematicIdentifiers.valid_format?(slug) }
92+
end
93+
7094
# For every alias row whose identifier starts with the old prefix, inserts a
7195
# corresponding row with the new prefix. This covers WPs still in the project
7296
# as well as any that have moved out but still carry old-prefix alias rows.

app/models/work_package/semantic_identifier.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ class UnsupportedLookup < ArgumentError; end
5050
inverse_of: :work_package,
5151
dependent: :delete_all
5252

53+
scope :semantically_sequenced, -> { where.not(sequence_number: nil) }
54+
scope :unsequenced, -> { where(sequence_number: nil) }
55+
scope :non_semantic_of, ->(project) {
56+
semantically_sequenced.where("identifier IS DISTINCT FROM (? || '-' || sequence_number::text)", project.identifier)
57+
}
58+
scope :non_semantic, -> {
59+
joins(:project).semantically_sequenced
60+
.where("work_packages.identifier IS DISTINCT FROM projects.identifier || '-' || work_packages.sequence_number::text")
61+
}
62+
5363
after_create :allocate_and_register_semantic_id, if: -> { Setting::WorkPackageIdentifier.semantic? }
5464
end
5565

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
module ProjectIdentifiers
32+
# Brings a single project fully up to date for semantic identifier mode:
33+
#
34+
# 1. Fixes the project identifier if it is not in valid semantic format.
35+
# 2. Rewrites stale WP identifiers whose prefix no longer matches the project.
36+
# 3. Assigns sequence numbers to WPs that have none yet.
37+
# 4. Seeds the alias table for all historical project identifier prefixes.
38+
class ConvertProjectToSemanticService
39+
def initialize(project)
40+
@project = project
41+
end
42+
43+
def call
44+
ApplicationRecord.transaction do
45+
fix_identifier_if_needed
46+
reset_stale_identifiers
47+
backfill_missing_ids
48+
seed_alias_table
49+
end
50+
end
51+
52+
private
53+
54+
attr_reader :project
55+
56+
def fix_identifier_if_needed
57+
# Pure format check — no DB queries.
58+
return if ProjectIdentifiers::IdentifierAutofix::ProblematicIdentifiers.valid_format?(project.identifier)
59+
60+
# Serialize all concurrent identifier assignments with a transaction-level
61+
# advisory lock. The lock is automatically released when the outer
62+
# ApplicationRecord.transaction commits, so the next job waiting on it
63+
# always reads a fully up-to-date exclusion set and can never generate a
64+
# duplicate. Without this, parallel jobs can read the same exclusion set
65+
# before any of them commits, then all pick the same candidate.
66+
OpenProject::Mutex.with_advisory_lock(
67+
Project, "semantic_identifier_generation", transaction: true
68+
) do
69+
assign_semantic_identifier
70+
end
71+
end
72+
73+
def assign_semantic_identifier
74+
# Re-instantiate inside the lock so the exclusion set reflects all
75+
# identifiers committed since this job started.
76+
detector = ProjectIdentifiers::IdentifierAutofix::ProblematicIdentifiers.new
77+
generator = ProjectIdentifiers::IdentifierAutofix::ProjectIdentifierSuggestionGenerator
78+
79+
# Prefer restoring the project's last known semantic identifier (from
80+
# FriendlyId history) so that existing WP identifiers remain valid and
81+
# aliases need no update. Fall back to generating a fresh suggestion.
82+
new_identifier = project.previous_semantic_identifier ||
83+
generator.suggest_identifier(project.name, exclude: detector.exclusion_set)
84+
85+
raise "Generated identifier is blank for project #{project.id}" if new_identifier.blank?
86+
87+
project.identifier = new_identifier
88+
# Bypass validation, because we're technically still in classic mode, so the model would be applying
89+
# validation for classic identifiers.
90+
project.save!(validate: false)
91+
end
92+
93+
def reset_stale_identifiers
94+
# Fix WPs whose identifier does not exactly match the expected semantic identifier
95+
# (caused by renames or cross-project moves in classic mode)
96+
WorkPackage.where(project:).non_semantic_of(project).update_all(identifier: nil, sequence_number: nil)
97+
end
98+
99+
def backfill_missing_ids
100+
WorkPackage.where(project:).unsequenced.find_each do |wp|
101+
seq, identifier = project.allocate_wp_semantic_identifier!
102+
wp.update_columns(sequence_number: seq, identifier:)
103+
end
104+
end
105+
106+
def seed_alias_table
107+
slug_prefixes = project.slugs.pluck(:slug)
108+
return if slug_prefixes.empty?
109+
110+
WorkPackage.where(project:).semantically_sequenced.in_batches do |batch|
111+
alias_rows = batch.pluck(:id, :sequence_number)
112+
.product(slug_prefixes)
113+
.map { |(wp_id, seq), prefix| { identifier: "#{prefix}-#{seq}", work_package_id: wp_id } }
114+
WorkPackageSemanticAlias.insert_all(alias_rows, unique_by: :identifier) if alias_rows.any?
115+
end
116+
end
117+
end
118+
end

app/services/project_identifiers/identifier_autofix.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ def self.job_in_progress?
3434
GoodJob::Job
3535
.where(job_class: [
3636
ProjectIdentifiers::ConvertInstanceToSemanticIdsJob.name,
37+
ProjectIdentifiers::ConvertProjectToSemanticIdsJob.name,
38+
ProjectIdentifiers::FinishSemanticConversionJob.name,
3739
ProjectIdentifiers::RevertInstanceToClassicIdsJob.name
3840
])
3941
.exists?(finished_at: nil)

app/services/project_identifiers/identifier_autofix/problematic_identifiers.rb

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,23 @@ def self.reserved_identifiers
6363
[:not_fully_uppercased, ->(id, _) { id != id.upcase }]
6464
].freeze
6565

66+
# Returns a symbol classifying why the identifier violates the expected format,
67+
# or nil if the identifier is format-valid. Pure in-memory check — no DB queries.
68+
def self.format_error_reason(identifier)
69+
FORMAT_RULES.each do |reason, check|
70+
return reason if check.call(identifier, max_identifier_length)
71+
end
72+
nil
73+
end
74+
75+
def self.valid_format?(identifier)
76+
format_error_reason(identifier).nil?
77+
end
78+
79+
def self.max_identifier_length
80+
ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max]
81+
end
82+
6683
def scope
6784
@scope ||= exceeds_max_length
6885
.or(contains_non_alphanumeric)
@@ -75,7 +92,7 @@ def scope
7592
# Returns a symbol classifying why the identifier is problematic.
7693
# Must handle all identifiers matched by #scope.
7794
def error_reason(identifier)
78-
format_error_reason(identifier) || collision_error_reason(identifier) || :unknown
95+
self.class.format_error_reason(identifier) || collision_error_reason(identifier) || :unknown
7996
end
8097

8198
# Returns a Set of identifiers that must not be suggested for new assignments.
@@ -95,20 +112,11 @@ def historical_identifiers
95112
.to_set
96113
end
97114

98-
def exceeds_max_length = Project.where("length(identifier) > ?", max_identifier_length)
115+
def exceeds_max_length = Project.where("length(identifier) > ?", self.class.max_identifier_length)
99116
def contains_non_alphanumeric = Project.where("identifier ~ ?", "[^a-zA-Z0-9_]")
100117
def starts_with_digit = Project.where("identifier ~ ?", "^[0-9]")
101118
def not_fully_uppercased = Project.where("identifier != UPPER(identifier)")
102119

103-
def max_identifier_length = ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max]
104-
105-
def format_error_reason(identifier)
106-
FORMAT_RULES.each do |reason, check|
107-
return reason if check.call(identifier, max_identifier_length)
108-
end
109-
nil
110-
end
111-
112120
def collision_error_reason(identifier)
113121
if in_use_identifiers.include?(identifier)
114122
:in_use
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
module ProjectIdentifiers
32+
# Returns the set of project IDs that still need backfilling before the
33+
# instance can be switched to semantic identifier mode. Three buckets:
34+
#
35+
# * projects whose identifier is not in valid semantic format
36+
# * projects that have work packages with no sequence_number yet
37+
# * projects that have work packages whose identifier doesn't match
38+
# the current project prefix (stale due to renames or cross-project moves)
39+
module PendingProjectsFinder
40+
def self.project_ids
41+
projects_with_bad_identifier | projects_with_unsequenced_wps | projects_with_stale_wps
42+
end
43+
44+
class << self
45+
private
46+
47+
def projects_with_bad_identifier
48+
ProjectIdentifiers::IdentifierAutofix::ProblematicIdentifiers.new.scope.ids.to_set
49+
end
50+
51+
def projects_with_unsequenced_wps
52+
WorkPackage.unsequenced.distinct.pluck(:project_id).to_set
53+
end
54+
55+
def projects_with_stale_wps
56+
WorkPackage.non_semantic.distinct.pluck(:project_id).to_set
57+
end
58+
end
59+
end
60+
end

app/workers/project_identifiers/convert_instance_to_semantic_ids_job.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ class ProjectIdentifiers::ConvertInstanceToSemanticIdsJob < ApplicationJob
3232
include GoodJob::ActiveJobExtensions::Concurrency
3333

3434
good_job_control_concurrency_with(total_limit: 1)
35+
queue_with_priority :above_normal
3536

36-
def perform(*); end
37+
def perform
38+
GoodJob::Batch.enqueue(on_success: ProjectIdentifiers::FinishSemanticConversionJob) do
39+
ProjectIdentifiers::PendingProjectsFinder.project_ids.each do |project_id|
40+
ProjectIdentifiers::ConvertProjectToSemanticIdsJob.perform_later(project_id)
41+
end
42+
end
43+
end
3744
end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
class ProjectIdentifiers::ConvertProjectToSemanticIdsJob < ApplicationJob
32+
include GoodJob::ActiveJobExtensions::Concurrency
33+
34+
good_job_control_concurrency_with(perform_limit: 5)
35+
queue_with_priority :above_normal
36+
retry_on StandardError, wait: :polynomially_longer, attempts: 8
37+
discard_on ActiveRecord::RecordNotFound
38+
39+
def perform(project_id)
40+
project = Project.find(project_id)
41+
ProjectIdentifiers::ConvertProjectToSemanticService.new(project).call
42+
end
43+
end

0 commit comments

Comments
 (0)