Skip to content
Open
Show file tree
Hide file tree
Changes from 123 commits
Commits
Show all changes
135 commits
Select commit Hold shift + click to select a range
334b85e
[#73373] PoC: Create semantic WP identifier registry
thykel Mar 26, 2026
c2566fc
add bg job todo
thykel Mar 26, 2026
cf7a41d
remove stale files
thykel Mar 26, 2026
fa179c1
fix broken tests
thykel Mar 26, 2026
490c90e
rubocop
thykel Mar 26, 2026
4a7cedf
minor refactoring & config guards
thykel Mar 26, 2026
471f2f5
address more edge cases
thykel Mar 26, 2026
b9dccaa
rubocop
thykel Mar 26, 2026
b32d601
add missing column
thykel Mar 26, 2026
711dc65
rubocop ignore
thykel Mar 26, 2026
4c2631c
adapt migrations
thykel Mar 29, 2026
42887b4
migration: add semantic_id to wp and remove :current from aliases
thykel Mar 29, 2026
39587d5
work_package_semantic_id => work_package_semantic_aliase
thykel Mar 29, 2026
3b52f25
rename the association on WP
thykel Mar 29, 2026
a0cd7ea
Merge remote-tracking branch 'origin/dev' into poc/73373-semantic-ide…
thykel Mar 29, 2026
e941542
make sure WP table is looked up before the alias table
thykel Mar 29, 2026
595496c
rename the concern
thykel Mar 29, 2026
15a477a
move wp sequence allocation to Project
thykel Mar 29, 2026
de16856
refactor the identifier allocation
thykel Mar 29, 2026
47d6972
remove superfluous migrations
thykel Mar 29, 2026
4460fc4
add down migration
thykel Mar 29, 2026
d5f02b8
address the security warning
thykel Mar 29, 2026
9120f9d
remove the bg job
thykel Mar 29, 2026
f3fc8a7
wip
thykel Mar 29, 2026
306443a
remove the spec as well
thykel Mar 29, 2026
ed304e4
Merge branch 'poc/73373-semantic-identifier-registry' into poc/71645-…
thykel Mar 29, 2026
8830fd8
revert some unrelated linter changes
thykel Mar 29, 2026
1c4aefc
add a unique index on sequence_number
thykel Mar 29, 2026
1145211
anchor the route regex
thykel Mar 29, 2026
8edd09d
transaction isolation fix
thykel Mar 29, 2026
62ea269
fix alignment
thykel Mar 29, 2026
30a752f
rails already anchors routes automatically
thykel Mar 29, 2026
26a890a
address latest PR feedback
thykel Mar 29, 2026
25962e5
squash some rubocop warns
thykel Mar 29, 2026
ba9cfd2
fix a test
thykel Mar 29, 2026
5d143c3
unblock migration
thykel Mar 29, 2026
7f21633
allowlist find_by_semantic_identifier
thykel Mar 29, 2026
6be9cb8
lint
thykel Mar 29, 2026
4752193
revert find back to find_by
thykel Mar 29, 2026
24f3012
align to the new 5-step script and move alias methods to identifier c…
thykel Mar 29, 2026
b4baa10
move the tests to concern spec
thykel Mar 29, 2026
9f65fb7
rename the register methods
thykel Mar 29, 2026
342e744
address rubocop complaints
thykel Mar 29, 2026
c68f43f
rewrite
thykel Mar 30, 2026
6fa6b36
Merge branch 'poc/73373-semantic-identifier-registry' into poc/71645-…
thykel Mar 30, 2026
a3930e3
rewrite to rely more heavily on alias storage
thykel Mar 30, 2026
1772030
fix a class-scoped reference
thykel Mar 30, 2026
a14f008
fix scoping
thykel Mar 30, 2026
3117589
Apply suggestions from code review
thykel Mar 30, 2026
aa8d6b5
Apply suggestion from @Copilot
thykel Mar 30, 2026
aad767e
remove empty line
thykel Mar 30, 2026
87f4c90
Merge remote-tracking branch 'origin/impl/73373-semantic-registry-que…
thykel Mar 30, 2026
7955c21
rename
thykel Mar 30, 2026
d91920b
rename touchups
thykel Mar 30, 2026
ec086c7
Apply suggestions from code review
thykel Mar 30, 2026
6532170
Merge branch 'impl/73373-semantic-registry-query-optimized' into poc/…
thykel Mar 30, 2026
8ab410d
seed alias table
thykel Mar 30, 2026
7f47303
squash the 2 create/move hook methods into a single allocate_and_regi…
thykel Mar 30, 2026
50b56a0
add more comments
thykel Mar 30, 2026
b419054
find_by_identifier => find_by_id_or_identifier
thykel Mar 30, 2026
48b8d16
split semantic ID detection into its own method
thykel Mar 30, 2026
aa4c910
semantic_id => identifier
thykel Mar 30, 2026
17559b3
superfluous to_s line
thykel Mar 30, 2026
9548b0f
cosmetics
thykel Mar 30, 2026
209b0c4
Merge remote-tracking branch 'origin/dev' into impl/73373-semantic-re…
thykel Mar 30, 2026
65790dd
rename migration
thykel Mar 30, 2026
6dc7474
clean up migration
thykel Mar 30, 2026
2ac5a4c
alphanumeric? => semantic?
thykel Mar 30, 2026
fcfbceb
fix the leftover tests still using :numeric?
thykel Mar 30, 2026
a6fc410
cosmetics
thykel Mar 30, 2026
3693926
move the route constraint
thykel Mar 30, 2026
79b9997
Merge branch 'impl/73373-semantic-registry-query-optimized' into poc/…
thykel Mar 30, 2026
7343662
rewrite to use batches and multiple steps
thykel Mar 30, 2026
59f64a8
lint
thykel Mar 30, 2026
fb64770
Merge remote-tracking branch 'origin/dev' into impl/73373-semantic-re…
thykel Mar 31, 2026
e9cf0c1
make sure to destroy the semantic aliases in bulk when wp is destroyed
thykel Mar 31, 2026
1cdef00
simplify find_by_semantic_identifier
thykel Mar 31, 2026
af1e613
reorganize spec files as per suggestion
thykel Mar 31, 2026
20c6d37
optimize the semantic finder
thykel Mar 31, 2026
8216a62
remove the nasty FriendlyId invocations from the operations spec
thykel Mar 31, 2026
bab0b30
rewrite the identifier allocation method as per suggestion
thykel Mar 31, 2026
314bc36
remove the route updates
thykel Mar 31, 2026
ded6728
revert the controller too
thykel Mar 31, 2026
e11d5fe
convert #semantic_identifier_aliases into a project association
thykel Mar 31, 2026
c7f954b
rename SemanticIdentifierOperations to SemanticIdentifier for uniformity
thykel Mar 31, 2026
4acbb83
add method comments
thykel Mar 31, 2026
6307392
patch some Rubocop warns
thykel Mar 31, 2026
1ba50ee
convert alias updates to raw SQL and align batching
thykel Mar 31, 2026
98f1e20
use keywords instead of positional args in the raw query
thykel Mar 31, 2026
df05930
add tests for batching
thykel Mar 31, 2026
8e877b8
revert the migration bulkization
thykel Mar 31, 2026
0e99e46
Merge branch 'impl/73373-semantic-registry-query-optimized' into poc/…
thykel Mar 31, 2026
53c00f9
Merge remote-tracking branch 'origin/dev' into impl/73373-semantic-re…
thykel Apr 1, 2026
4939b5a
Merge branch 'impl/73373-semantic-registry-query-optimized' into poc/…
thykel Apr 1, 2026
5671e11
replace identifier_aliases with simple call to slugs
thykel Apr 1, 2026
de99339
Merge branch 'impl/73373-semantic-registry-query-optimized' into poc/…
thykel Apr 1, 2026
94e79c8
Merge remote-tracking branch 'origin/dev' into poc/71645-convert-to-w…
thykel Apr 1, 2026
3529fa1
Merge remote-tracking branch 'origin/dev' into poc/71645-convert-to-w…
thykel Apr 7, 2026
d92529b
remove private scope for alias builder method
thykel Apr 7, 2026
aa5af20
add retry when data not yet processed at the end
thykel Apr 7, 2026
74dcdd7
move into a better-named top-level module
thykel Apr 7, 2026
99d12fd
add a spec for autofix checker
thykel Apr 7, 2026
90f3c5f
make sure to flip the parameter if data is already in order
thykel Apr 7, 2026
a50e9c0
minor refactoring
thykel Apr 7, 2026
50646af
cleanly assert that a job has been enqueued
thykel Apr 8, 2026
851b551
remove a superfluous spec
thykel Apr 8, 2026
52fce11
add a controller spec
thykel Apr 8, 2026
6931dbc
rework the admin update interface
thykel Apr 8, 2026
4359313
fix the parameter retrieval
thykel Apr 8, 2026
4179141
get rid of the final job
thykel Apr 8, 2026
5422b14
lint
thykel Apr 8, 2026
45ddcfc
Update app/workers/project_identifiers/convert_instance_to_semantic_i…
thykel Apr 8, 2026
b278845
make the controller action more readable & do not enqueue if in progress
thykel Apr 8, 2026
cb1d926
add iteration testing
thykel Apr 8, 2026
b6884b0
fix a test
thykel Apr 8, 2026
7c5508c
restore semantic ID from friendly_id history
thykel Apr 12, 2026
aa3eb23
handle semantic ID mismatch (moved packages)
thykel Apr 12, 2026
5cb7162
move the last slug method to Project concern
thykel Apr 12, 2026
ff68d81
use with_settings in tests
thykel Apr 12, 2026
2116fc7
remove a useless alias upsert
thykel Apr 12, 2026
483bc89
batch the alias insert
thykel Apr 12, 2026
f998398
split the meaty stuff into services
thykel Apr 12, 2026
c52f096
fix the moved package sequence handling
thykel Apr 12, 2026
7710554
Merge remote-tracking branch 'origin/dev' into poc/71645-convert-to-w…
thykel Apr 13, 2026
8eb3662
use total_limit for main converter job to prevent multiple enqueues
thykel Apr 13, 2026
3e3d17f
wrap backfilling in transaction
thykel Apr 13, 2026
7ad067f
mass reset stale identifiers/seqs
thykel Apr 13, 2026
1c0d5d7
discard project backfill on 404
thykel Apr 13, 2026
7520e92
do not skip validation when fixing project identifier
thykel Apr 13, 2026
ba53230
create a better assertion for conversion to classic
thykel Apr 13, 2026
60e63e8
appease rubocop
thykel Apr 13, 2026
a7d6f86
rename the backfill service/job
thykel Apr 13, 2026
85dac65
Revert "do not skip validation when fixing project identifier"
thykel Apr 13, 2026
e684e8f
raise on blank IDs in project converter
thykel Apr 13, 2026
d01c444
Apply suggestions from code review
thykel Apr 13, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,10 @@ def show
end

def update
return render_400 unless params[:settings]

if autofix_requested?
call = update_service.new(user: current_user).call(settings_params)
call.on_success do
WorkPackages::IdentifierAutofix::ApplyHandlesJob.perform_later
redirect_to action: "show"
end
call.on_failure { failure_callback(call) }
else
super
case params.dig(:settings, :work_packages_identifier)
when Setting::WorkPackageIdentifier::SEMANTIC then switch_to_semantic
when Setting::WorkPackageIdentifier::CLASSIC then switch_to_classic
else render_400
end
end

Expand All @@ -74,12 +67,21 @@ def status

private

def switch_to_semantic
ProjectIdentifiers::ConvertInstanceToSemanticIdsJob.perform_later unless WorkPackages::IdentifierAutofix.job_in_progress?
redirect_to action: "show"
end

def switch_to_classic
call = Settings::UpdateService.new(user: current_user)
.call(work_packages_identifier: Setting::WorkPackageIdentifier::CLASSIC)
call.on_success { redirect_to action: "show" }
call.on_failure { render_400 }
end

def check_feature_flag
render_404 unless OpenProject::FeatureDecisions.semantic_work_package_ids_active?
end

def autofix_requested?
ActiveRecord::Type::Boolean.new.cast(params[:confirm_dangerous_action])
end
end
end
15 changes: 15 additions & 0 deletions app/models/projects/semantic_identifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ def allocate_wp_semantic_identifier!
[seq, "#{identifier}-#{seq}"]
end

# Returns the most-recent slug from FriendlyId history that is a valid semantic
# identifier and is not currently held by another project, or nil if none exists.
# Used by the backfill job to restore a prior semantic identifier instead of
# generating a fresh one, so existing WP identifiers and aliases remain correct.
def previous_semantic_identifier
detector = WorkPackages::IdentifierAutofix::ProblematicIdentifiers.new
slugs
.order(created_at: :desc)
.pluck(:slug)
.find do |slug|
detector.format_error_reason(slug).nil? &&
!self.class.where.not(id:).exists?(identifier: slug)
end
end

# Called after this project's identifier is renamed. Atomically:
# 1. Appends new-prefix aliases for every WP that ever carried an old-prefix alias.
# 2. Updates identifier on resident WPs to the new prefix.
Expand Down
1 change: 1 addition & 0 deletions app/models/setting/work_package_identifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ module WorkPackageIdentifier

def self.semantic? = Setting[:work_packages_identifier] == SEMANTIC
def self.classic? = Setting[:work_packages_identifier] == CLASSIC
def self.enable_semantic! = Setting.work_packages_identifier = SEMANTIC
end
end
2 changes: 0 additions & 2 deletions app/models/work_package/semantic_identifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,6 @@ def allocate_and_register_semantic_id
end
end

private

# 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)
Expand Down
4 changes: 4 additions & 0 deletions app/models/work_package_semantic_alias.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,8 @@ class WorkPackageSemanticAlias < ApplicationRecord

validates :identifier, presence: true, uniqueness: true
validates :work_package, presence: true

def self.upsert_rows(rows)
insert_all(rows, unique_by: :identifier) if rows.any?
end
end
105 changes: 105 additions & 0 deletions app/services/project_identifiers/backfill_project_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module ProjectIdentifiers
# Brings a single project fully up to date for semantic identifier mode:
#
# 1. Fixes the project identifier if it is not in valid semantic format.
# 2. Syncs the sequence counter so it is >= any existing sequence_number
# (guards against counter underflow caused by cross-project WP moves).
# 3. Rewrites stale WP identifiers whose prefix no longer matches the project.
# 4. Assigns sequence numbers to WPs that have none yet.
# 5. Seeds the alias table for all historical project identifier prefixes.
class BackfillProjectService
def initialize(project)
@project = project
end

def call
fix_identifier_if_needed
reset_stale_identifiers
backfill_missing_ids
seed_alias_table
end

private

attr_reader :project

def fix_identifier_if_needed
detector = WorkPackages::IdentifierAutofix::ProblematicIdentifiers.new
generator = WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator
# Pure format check — no DB queries. nil means the identifier is fine.
return unless detector.format_error_reason(project.identifier)

# Prefer restoring the project's last known semantic identifier (from FriendlyId history)
# so that existing WP identifiers remain valid and aliases need no update.
# Fall back to generating a fresh suggestion if no usable prior slug exists.
# Two concurrent jobs may occasionally suggest the same identifier, but the
# unique constraint on projects.identifier will reject the second writer, and
# the job can be retried.
new_identifier = project.previous_semantic_identifier ||
generator.suggest_identifier(project.name, exclude: detector.exclusion_set)

project.identifier = new_identifier
project.save!(validate: false)
end


def reset_stale_identifiers

Check notice on line 76 in app/services/project_identifiers/backfill_project_service.rb

View workflow job for this annotation

GitHub Actions / rubocop

[rubocop] app/services/project_identifiers/backfill_project_service.rb#L75-L76 <Layout/EmptyLines>

Extra blank line detected.
Raw output
app/services/project_identifiers/backfill_project_service.rb:75:1: C: Layout/EmptyLines: Extra blank line detected.

Check notice on line 76 in app/services/project_identifiers/backfill_project_service.rb

View workflow job for this annotation

GitHub Actions / rubocop

[rubocop] app/services/project_identifiers/backfill_project_service.rb#L76 <Layout/EmptyLineBetweenDefs>

Expected 1 empty line between method definitions; found 2.
Raw output
app/services/project_identifiers/backfill_project_service.rb:76:5: C: Layout/EmptyLineBetweenDefs: Expected 1 empty line between method definitions; found 2.
# Fix WPs that contain identifier that doesn't match the current project prefix
# (caused by renames or cross-project moves in classic mode)
WorkPackage
.where(project:)
.where.not(sequence_number: nil)
.where("identifier NOT LIKE ?", "#{project.identifier}-%")
.find_each { |wp| wp.update_columns(identifier: nil, sequence_number: nil) }
end

def backfill_missing_ids
WorkPackage.where(project:, sequence_number: nil).order(:id).find_each do |wp|
seq, identifier = project.allocate_wp_semantic_identifier!
wp.update_columns(sequence_number: seq, identifier:)
end
end

def seed_alias_table
slug_prefixes = project.slugs.pluck(:slug)
return if slug_prefixes.empty?

WorkPackage.where(project:).where.not(sequence_number: nil).in_batches do |batch|
alias_rows = batch.pluck(:id, :sequence_number)
.product(slug_prefixes)
.map { |(wp_id, seq), prefix| { identifier: "#{prefix}-#{seq}", work_package_id: wp_id } }
WorkPackageSemanticAlias.upsert_rows(alias_rows)
end
end
end
end
63 changes: 63 additions & 0 deletions app/services/project_identifiers/pending_projects_finder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module ProjectIdentifiers
# Returns the set of project IDs that still need backfilling before the
# instance can be switched to semantic identifier mode. Three buckets:
#
# * projects whose identifier is not in valid semantic format
# * projects that have work packages with no sequence_number yet
# * projects that have work packages whose identifier doesn't match
# the current project prefix (stale due to renames or cross-project moves)
class PendingProjectsFinder
def project_ids
projects_with_bad_identifier | projects_with_unsequenced_wps | projects_with_stale_wps
end

private

def projects_with_bad_identifier
WorkPackages::IdentifierAutofix::ProblematicIdentifiers.new.scope.ids.to_set
end

def projects_with_unsequenced_wps
WorkPackage.where(sequence_number: nil).distinct.pluck(:project_id).to_set
end

def projects_with_stale_wps
WorkPackage
.joins(:project)
.where.not(sequence_number: nil)
.where("work_packages.identifier IS DISTINCT FROM " \
"projects.identifier || '-' || work_packages.sequence_number::text")
.distinct.pluck(:project_id).to_set
end
end
end
5 changes: 4 additions & 1 deletion app/services/work_packages/identifier_autofix.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ module WorkPackages
module IdentifierAutofix
def self.job_in_progress?
GoodJob::Job
.where(job_class: WorkPackages::IdentifierAutofix::ApplyHandlesJob.name)
.where(job_class: [
ProjectIdentifiers::ConvertInstanceToSemanticIdsJob.name,
ProjectIdentifiers::BackfillProjectJob.name
])
.exists?(finished_at: nil)
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ def scope

delegate :count, to: :scope

# Returns a symbol classifying why the identifier violates the expected format,
# or nil if the identifier is format-valid. Pure in-memory check — no DB queries.
def format_error_reason(identifier)
FORMAT_RULES.each do |reason, check|
return reason if check.call(identifier, max_identifier_length)
end
nil
end

# Returns a symbol classifying why the identifier is problematic.
# Must handle all identifiers matched by #scope.
def error_reason(identifier)
Expand All @@ -87,13 +96,6 @@ def not_fully_uppercased = Project.where("identifier != UPPER(identifier)")

def max_identifier_length = ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max]

def format_error_reason(identifier)
FORMAT_RULES.each do |reason, check|
return reason if check.call(identifier, max_identifier_length)
end
nil
end

def collision_error_reason(identifier)
if in_use_identifiers.include?(identifier)
:in_use
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,9 @@
# See COPYRIGHT and LICENSE files for more details.
#++

class WorkPackages::IdentifierAutofix::ApplyHandlesJob < ApplicationJob
# FIXME: The admin UI's job_in_progress? query and :change_in_progress state
# assume at most one active instance of this job at any given time.
# Enforce this with good_job_control_concurrency_with(perform_limit: 1)
# when the real migration body is implemented.
def perform
# FIXME: replace with actual project handle migration
sleep 5
class ProjectIdentifiers::BackfillProjectJob < ApplicationJob
def perform(project_id)
project = Project.find(project_id)
ProjectIdentifiers::BackfillProjectService.new(project).call
end
end
Loading
Loading