Skip to content

Commit 4d09cf1

Browse files
committed
[STC-767] Release old semantic identifiers
https://community.openproject.org/wp/STC-767
1 parent 24a5b24 commit 4d09cf1

14 files changed

Lines changed: 470 additions & 63 deletions

File tree

app/components/admin/settings/project_reserved_identifiers/release_dialog_component.html.erb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
render(
3434
Primer::OpenProject::DangerDialog.new(
3535
id: "release-identifier-dialog",
36+
# The default :medium size caps the dialog height at 320px, which the
37+
# description with the work package count can exceed, forcing an inner
38+
# scrollbar. :medium_portrait keeps the same width with a 600px cap.
39+
size: :medium_portrait,
3640
title: I18n.t("admin.reserved_identifiers.dialog.title"),
3741
confirm_button_text: I18n.t("admin.reserved_identifiers.dialog.confirm_button"),
3842
cancel_button_text: I18n.t("button_cancel"),
@@ -47,9 +51,7 @@
4751
I18n.t("admin.reserved_identifiers.dialog.heading", identifier: @slug.slug)
4852
end
4953

50-
message.with_description_content(
51-
I18n.t("admin.reserved_identifiers.dialog.description")
52-
)
54+
message.with_description_content(description_text)
5355
end
5456

5557
dialog.with_confirmation_check_box_content(

app/components/admin/settings/project_reserved_identifiers/release_dialog_component.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ def initialize(slug:)
3939
super
4040
@slug = slug
4141
end
42+
43+
private
44+
45+
def description_text
46+
if affected_work_package_count.positive?
47+
I18n.t("admin.reserved_identifiers.dialog.description_with_work_packages",
48+
count: affected_work_package_count)
49+
else
50+
I18n.t("admin.reserved_identifiers.dialog.description")
51+
end
52+
end
53+
54+
def affected_work_package_count
55+
@affected_work_package_count ||= WorkPackage.resolving_via_slug_prefix(@slug.slug).count
56+
end
4257
end
4358
end
4459
end

app/controllers/admin/settings/project_reserved_identifiers_controller.rb

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ class ProjectReservedIdentifiersController < ::ApplicationController
3434
include PaginationHelper
3535

3636
before_action :require_admin
37-
before_action :require_classic_mode
3837
before_action :find_slug, only: %i[confirm_dialog destroy]
3938

4039
menu_item :project_reserved_identifiers_settings
@@ -63,7 +62,7 @@ def confirm_dialog
6362
end
6463

6564
def destroy
66-
@slug.destroy!
65+
ProjectIdentifiers::ReleaseReservedIdentifierService.new(@slug).call
6766
redirect_to admin_settings_project_reserved_identifiers_path,
6867
flash: { notice: t("admin.reserved_identifiers.released_notice", identifier: @slug.slug) }
6968
end
@@ -85,12 +84,5 @@ def build_query
8584
query_class: Queries::ProjectReservedIdentifiers::ProjectReservedIdentifierQuery)
8685
.call(params)
8786
end
88-
89-
def require_classic_mode
90-
return unless Setting::WorkPackageIdentifier.semantic?
91-
92-
redirect_to admin_settings_work_packages_identifier_path,
93-
flash: { warning: t("admin.reserved_identifiers.not_available_in_semantic_mode") }
94-
end
9587
end
9688
end

app/models/queries/project_reserved_identifiers/project_reserved_identifier_query.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,15 @@ def self.model
3737
end
3838

3939
def default_scope
40+
# Pure-numeric slugs are legacy artifacts (identifier validation was tightened
41+
# later; the semantic-conversion autofix renames such projects, reserving the
42+
# old numeric identifier). Releasing them frees nothing — numeric identifiers
43+
# are invalid in both formats — and would break friendly_id history resolution
44+
# of old /projects/<number> links, letting them fall through to a primary-key
45+
# lookup of a different project.
4046
Project.identifier_slugs
4147
.historically_reserved
42-
.where("slug ~ ? AND slug !~ ?", "^[a-z0-9_-]+$", "^[0-9]+$")
48+
.where("slug !~ ?", "^[0-9]+$")
4349
.joins("JOIN projects ON projects.id = friendly_id_slugs.sluggable_id")
4450
.order("projects.name ASC, friendly_id_slugs.created_at DESC")
4551
end

app/models/work_package/semantic_identifier.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ module WorkPackage::SemanticIdentifier
4141
# The frontend equivalent lives in WP_ID_URL_PATTERN (work-package-id-pattern.ts).
4242
ID_ROUTE_CONSTRAINT = /\d+|#{SEMANTIC_ID_PATTERN.source}/
4343

44+
# Anchored POSIX regex matching identifiers of the exact form "<slug>-<digits>"
45+
# for a concrete project slug, so prefixes containing dashes don't over-match
46+
# (matching "my" must not touch "my-project-42"). Used by the for_slug_prefix
47+
# scopes on WorkPackage and WorkPackageSemanticAlias. Regexp.escape output is
48+
# valid in PostgreSQL's ARE syntax: it backslash-escapes punctuation (which
49+
# ARE treats as literals) and never emits class escapes.
50+
def self.slug_prefix_pattern(slug)
51+
"^#{Regexp.escape(slug)}-[0-9]+$"
52+
end
53+
4454
# Raised when a finder is invoked in a way that cannot resolve a semantic
4555
# identifier — e.g. find_by(id: "PROJ-42") which reduces to a raw SQL
4656
# WHERE clause that cannot consult the alias table. Subclasses ArgumentError
@@ -64,6 +74,21 @@ class UnsupportedLookup < ArgumentError; end
6474
joins(:project).semantically_sequenced
6575
.where("work_packages.identifier IS DISTINCT FROM projects.identifier || '-' || work_packages.sequence_number::text")
6676
}
77+
# Work packages whose identifier column carries the given project slug
78+
# prefix, i.e. is of the exact form "<slug>-<digits>". Counterpart to
79+
# WorkPackageSemanticAlias.for_slug_prefix for the denormalized column.
80+
scope :for_slug_prefix, ->(slug) {
81+
where("identifier ~ ?", WorkPackage::SemanticIdentifier.slug_prefix_pattern(slug))
82+
}
83+
# Work packages that currently resolve via identifiers of the form
84+
# "<slug>-<digits>" — through the identifier column or an alias row.
85+
# The single-identifier counterpart is FinderMethods#scope_for_semantic_identifier.
86+
# ReleaseReservedIdentifierService severs exactly this set, and the release
87+
# dialog counts it — keep the two in sync through this scope.
88+
scope :resolving_via_slug_prefix, ->(slug) {
89+
where(id: WorkPackageSemanticAlias.for_slug_prefix(slug).select(:work_package_id))
90+
.or(for_slug_prefix(slug))
91+
}
6792

6893
attr_accessor :skip_semantic_id_allocation
6994

app/models/work_package_semantic_alias.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,12 @@ class WorkPackageSemanticAlias < ApplicationRecord
4242

4343
validates :identifier, presence: true, uniqueness: true
4444
validates :work_package, presence: true
45+
46+
# Aliases created for the given project slug prefix, i.e. identifiers of the
47+
# exact form "<slug>-<digits>". Case-sensitive: aliases are always created
48+
# verbatim from slug values, and slugs differing only in case (classic "proj"
49+
# vs semantic "PROJ") are distinct reservations.
50+
scope :for_slug_prefix, ->(slug) {
51+
where("identifier ~ ?", WorkPackage::SemanticIdentifier.slug_prefix_pattern(slug))
52+
}
4553
end
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
# Releases a historically reserved project identifier slug.
33+
# Additionally removes the WorkPackageSemanticAlias rows created for that
34+
# slug prefix ("<slug>-<digits>") and clears stale work package identifier
35+
# columns carrying it, atomically with the slug destroy, so the released
36+
# prefix no longer resolves work packages. This happens regardless of the
37+
# current identifier mode: leftovers from a previous semantic phase must not
38+
# survive a release, or they would keep old links resolving and shadow the
39+
# alias rows of a new project claiming the identifier after a later
40+
# re-conversion.
41+
class ReleaseReservedIdentifierService
42+
def initialize(slug)
43+
@slug = slug
44+
end
45+
46+
def call
47+
FriendlyId::Slug.transaction do
48+
delete_aliases
49+
clear_stale_work_package_identifiers
50+
@slug.destroy!
51+
end
52+
53+
ServiceResult.success
54+
end
55+
56+
private
57+
58+
def delete_aliases
59+
WorkPackageSemanticAlias.for_slug_prefix(@slug.slug).delete_all
60+
end
61+
62+
# A historically reserved slug is no project's current identifier, so any
63+
# work package still carrying "<slug>-<digits>" in its identifier column is
64+
# stale (left over from a revert to classic mode). The finder resolves
65+
# semantic identifiers against this column as well as the alias table, so
66+
# clearing it severs resolution immediately instead of waiting for the next
67+
# semantic conversion's reset_stale_identifiers to do the same.
68+
def clear_stale_work_package_identifiers
69+
WorkPackage.for_slug_prefix(@slug.slug).update_all(identifier: nil, sequence_number: nil)
70+
end
71+
end
72+
end

config/initializers/menus.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@
464464

465465
menu.push :project_reserved_identifiers_settings,
466466
{ controller: "/admin/settings/project_reserved_identifiers", action: :index },
467-
if: ->(_) { User.current.admin? && Setting::WorkPackageIdentifier.classic? },
467+
if: ->(_) { User.current.admin? },
468468
caption: :label_reserved_identifiers,
469469
parent: :admin_projects_settings
470470

config/locales/en.yml

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,25 +61,27 @@ en:
6161

6262
admin:
6363
reserved_identifiers:
64-
title: "Reserved project identifiers"
65-
lede_html: "When a project's identifier is renamed, the previous identifier is kept reserved so that existing links and integrations keep working.<br>Here you can release reserved identifiers so that they may be used by other projects."
64+
btn_release: "Unreserve"
6665
col_identifier: "Identifier"
6766
col_project: "Project"
6867
col_reserved: "Reserved"
69-
not_available_in_semantic_mode: "Reserved project identifiers are only available in numeric identifier mode."
70-
filter_label: "Search identifiers"
71-
btn_release: "Release"
72-
released_notice: 'Identifier "%{identifier}" has been released.'
73-
identifier_not_found: "The reserved identifier could not be found. It may have already been released or the project may have been deleted. Please refresh the page."
7468
dialog:
75-
title: "Release identifier"
76-
heading: 'Release "%{identifier}"?'
77-
description: "Releasing this identifier cannot be undone. External links and integrations using it will stop resolving, and the name becomes available for any new project to claim."
7869
checkbox_label: "I understand that this cannot be undone."
79-
confirm_button: "Release identifier"
70+
confirm_button: "Unreserve identifier"
71+
description: "Unreserving this project identifier cannot be undone. External links and integrations using it will stop resolving, and the name becomes available for any new project to claim."
72+
description_with_work_packages:
73+
one: "Unreserving this project identifier (and its 1 work package identifier) cannot be undone. External links and integrations using it will stop resolving, and the name becomes available for any new project to claim."
74+
other: "Unreserving this project identifier (and its %{count} work package identifiers) cannot be undone. External links and integrations using it will stop resolving, and the name becomes available for any new project to claim."
75+
heading: 'Unreserve "%{identifier}"?'
76+
title: "Unreserve identifier"
77+
empty_body: "When a project's identifier changes, the previous one will appear here so you can unreserve it once it's safe to do so."
8078
empty_heading: "No reserved identifiers"
79+
filter_label: "Search identifiers"
80+
identifier_not_found: "The reserved identifier could not be found. It may have already been unreserved or the project may have been deleted. Please refresh the page."
81+
lede_html: "When a project's identifier is renamed, the previous identifier is kept reserved so that existing links and integrations keep working.<br>Here you can unreserve identifiers so that they may be used by other projects."
82+
released_notice: 'Identifier "%{identifier}" has been unreserved.'
8183
reserved_ago: "%{time} ago"
82-
empty_body: "When a project's identifier changes, the previous one will appear here so you can release it once it's safe to do so."
84+
title: "Reserved project identifiers"
8385
plugins:
8486
no_results_title_text: There are currently no plugins installed.
8587
no_results_content_text: See our integrations and plugins page for more information.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
require "rails_helper"
32+
33+
RSpec.describe Admin::Settings::ProjectReservedIdentifiers::ReleaseDialogComponent, type: :component do
34+
let!(:project) { create(:project) }
35+
let!(:slug) { FriendlyId::Slug.create!(sluggable: project, slug: "old-id") }
36+
37+
subject(:rendered_component) { render_inline(described_class.new(slug:)) }
38+
39+
it "renders the heading with the identifier" do
40+
expect(rendered_component)
41+
.to have_text(I18n.t("admin.reserved_identifiers.dialog.heading", identifier: "old-id"))
42+
end
43+
44+
context "without affected work packages" do
45+
it "renders the plain description" do
46+
expect(rendered_component)
47+
.to have_text(I18n.t("admin.reserved_identifiers.dialog.description"))
48+
expect(rendered_component).to have_no_text("work package")
49+
end
50+
end
51+
52+
context "with one affected work package" do
53+
before { create(:work_package_semantic_alias, identifier: "old-id-1") }
54+
55+
it "renders the singular description" do
56+
expect(rendered_component)
57+
.to have_text(I18n.t("admin.reserved_identifiers.dialog.description_with_work_packages", count: 1))
58+
end
59+
end
60+
61+
context "with several affected work packages" do
62+
before do
63+
create(:work_package_semantic_alias, identifier: "old-id-1")
64+
create(:work_package_semantic_alias, identifier: "old-id-2")
65+
end
66+
67+
it "renders the pluralized description" do
68+
expect(rendered_component)
69+
.to have_text(I18n.t("admin.reserved_identifiers.dialog.description_with_work_packages", count: 2))
70+
end
71+
end
72+
end

0 commit comments

Comments
 (0)