Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
render(
Primer::OpenProject::DangerDialog.new(
id: "release-identifier-dialog",
# The default :medium size caps the dialog height at 320px, which the
# description with the work package count can exceed, forcing an inner
# scrollbar. :medium_portrait keeps the same width with a 600px cap.
size: :medium_portrait,
title: I18n.t("admin.reserved_identifiers.dialog.title"),
confirm_button_text: I18n.t("admin.reserved_identifiers.dialog.confirm_button"),
cancel_button_text: I18n.t("button_cancel"),
Expand All @@ -47,9 +51,7 @@
I18n.t("admin.reserved_identifiers.dialog.heading", identifier: @slug.slug)
end

message.with_description_content(
I18n.t("admin.reserved_identifiers.dialog.description")
)
message.with_description_content(description_text)
end

dialog.with_confirmation_check_box_content(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ def initialize(slug:)
super
@slug = slug
end

private

def description_text
if affected_work_package_count.positive?
I18n.t("admin.reserved_identifiers.dialog.description_with_work_packages",
count: affected_work_package_count)
else
I18n.t("admin.reserved_identifiers.dialog.description")
end
end

def affected_work_package_count
@affected_work_package_count ||= WorkPackage.resolving_via_slug_prefix(@slug.slug).count
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ class ProjectReservedIdentifiersController < ::ApplicationController
include PaginationHelper

before_action :require_admin
before_action :require_classic_mode
before_action :find_slug, only: %i[confirm_dialog destroy]

menu_item :project_reserved_identifiers_settings
Expand Down Expand Up @@ -63,7 +62,7 @@ def confirm_dialog
end

def destroy
@slug.destroy!
ProjectIdentifiers::ReleaseReservedIdentifierService.new(@slug).call
redirect_to admin_settings_project_reserved_identifiers_path,
flash: { notice: t("admin.reserved_identifiers.released_notice", identifier: @slug.slug) }
end
Expand All @@ -85,12 +84,5 @@ def build_query
query_class: Queries::ProjectReservedIdentifiers::ProjectReservedIdentifierQuery)
.call(params)
end

def require_classic_mode
return unless Setting::WorkPackageIdentifier.semantic?

redirect_to admin_settings_work_packages_identifier_path,
flash: { warning: t("admin.reserved_identifiers.not_available_in_semantic_mode") }
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,15 @@ def self.model
end

def default_scope
# Pure-numeric slugs are legacy artifacts (identifier validation was tightened
# later; the semantic-conversion autofix renames such projects, reserving the
# old numeric identifier). Releasing them frees nothing — numeric identifiers
# are invalid in both formats — and would break friendly_id history resolution
# of old /projects/<number> links, letting them fall through to a primary-key
# lookup of a different project.
Project.identifier_slugs
.historically_reserved
.where("slug ~ ? AND slug !~ ?", "^[a-z0-9_-]+$", "^[0-9]+$")
.where("slug !~ ?", "^[0-9]+$")
.joins("JOIN projects ON projects.id = friendly_id_slugs.sluggable_id")
.order("projects.name ASC, friendly_id_slugs.created_at DESC")
end
Expand Down
25 changes: 25 additions & 0 deletions app/models/work_package/semantic_identifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ module WorkPackage::SemanticIdentifier
# The frontend equivalent lives in WP_ID_URL_PATTERN (work-package-id-pattern.ts).
ID_ROUTE_CONSTRAINT = /\d+|#{SEMANTIC_ID_PATTERN.source}/

# Anchored POSIX regex matching identifiers of the exact form "<slug>-<digits>"
# for a concrete project slug, so prefixes containing dashes don't over-match
# (matching "my" must not touch "my-project-42"). Used by the for_slug_prefix
# scopes on WorkPackage and WorkPackageSemanticAlias. Regexp.escape output is
# valid in PostgreSQL's ARE syntax: it backslash-escapes punctuation (which
# ARE treats as literals) and never emits class escapes.
def self.slug_prefix_pattern(slug)
"^#{Regexp.escape(slug)}-[0-9]+$"
end

# Raised when a finder is invoked in a way that cannot resolve a semantic
# identifier — e.g. find_by(id: "PROJ-42") which reduces to a raw SQL
# WHERE clause that cannot consult the alias table. Subclasses ArgumentError
Expand All @@ -64,6 +74,21 @@ class UnsupportedLookup < ArgumentError; end
joins(:project).semantically_sequenced
.where("work_packages.identifier IS DISTINCT FROM projects.identifier || '-' || work_packages.sequence_number::text")
}
# Work packages whose identifier column carries the given project slug
# prefix, i.e. is of the exact form "<slug>-<digits>". Counterpart to
# WorkPackageSemanticAlias.for_slug_prefix for the denormalized column.
scope :for_slug_prefix, ->(slug) {
where("identifier ~ ?", WorkPackage::SemanticIdentifier.slug_prefix_pattern(slug))
}
# Work packages that currently resolve via identifiers of the form
# "<slug>-<digits>" — through the identifier column or an alias row.
# The single-identifier counterpart is FinderMethods#scope_for_semantic_identifier.
# ReleaseReservedIdentifierService severs exactly this set, and the release
# dialog counts it — keep the two in sync through this scope.
scope :resolving_via_slug_prefix, ->(slug) {
where(id: WorkPackageSemanticAlias.for_slug_prefix(slug).select(:work_package_id))
.or(for_slug_prefix(slug))
}

attr_accessor :skip_semantic_id_allocation

Expand Down
8 changes: 8 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,12 @@ class WorkPackageSemanticAlias < ApplicationRecord

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

# Aliases created for the given project slug prefix, i.e. identifiers of the
# exact form "<slug>-<digits>". Case-sensitive: aliases are always created
# verbatim from slug values, and slugs differing only in case (classic "proj"
# vs semantic "PROJ") are distinct reservations.
scope :for_slug_prefix, ->(slug) {
where("identifier ~ ?", WorkPackage::SemanticIdentifier.slug_prefix_pattern(slug))
}
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 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
# Releases a historically reserved project identifier slug.
# Additionally removes the WorkPackageSemanticAlias rows created for that
# slug prefix ("<slug>-<digits>") atomically with the slug destroy, so the
# released prefix no longer resolves work packages. In classic mode it also
# clears stale work package identifier columns carrying the prefix
# (leftovers from a previous semantic phase), which would otherwise keep old
# links resolving and shadow the alias rows of a new project claiming the
# identifier after a later re-conversion.
class ReleaseReservedIdentifierService
def initialize(slug)
@slug = slug
end

def call
FriendlyId::Slug.transaction do
delete_aliases
clear_stale_work_package_identifiers
@slug.destroy!
end

ServiceResult.success
end

private

def delete_aliases
WorkPackageSemanticAlias.for_slug_prefix(@slug.slug).delete_all
end

# A historically reserved slug is no project's current identifier, so any
# work package still carrying "<slug>-<digits>" in its identifier column is
# stale (left over from a revert to classic mode). The finder resolves
# semantic identifiers against this column as well as the alias table, so
# clearing it severs resolution immediately instead of waiting for the next
# semantic conversion's reset_stale_identifiers to do the same.
#
# Only relevant in classic mode: in semantic mode the identifier column
# carries the live semantic identifiers of the projects currently using
# the mode, which must not be cleared.
def clear_stale_work_package_identifiers
return unless Setting::WorkPackageIdentifier.classic?

WorkPackage.for_slug_prefix(@slug.slug).update_all(identifier: nil, sequence_number: nil)
end
Comment thread
thykel marked this conversation as resolved.
end
end
2 changes: 1 addition & 1 deletion config/initializers/menus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@

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

Expand Down
26 changes: 14 additions & 12 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,25 +61,27 @@ en:

admin:
reserved_identifiers:
title: "Reserved project identifiers"
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."
btn_release: "Unreserve"
col_identifier: "Identifier"
col_project: "Project"
col_reserved: "Reserved"
not_available_in_semantic_mode: "Reserved project identifiers are only available in numeric identifier mode."
filter_label: "Search identifiers"
btn_release: "Release"
released_notice: 'Identifier "%{identifier}" has been released.'
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."
dialog:
title: "Release identifier"
heading: 'Release "%{identifier}"?'
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."
checkbox_label: "I understand that this cannot be undone."
confirm_button: "Release identifier"
confirm_button: "Unreserve identifier"
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."
description_with_work_packages:
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."
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."
heading: 'Unreserve "%{identifier}"?'
title: "Unreserve identifier"
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."
empty_heading: "No reserved identifiers"
filter_label: "Search identifiers"
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."
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."
released_notice: 'Identifier "%{identifier}" has been unreserved.'
reserved_ago: "%{time} ago"
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."
title: "Reserved project identifiers"
plugins:
no_results_title_text: There are currently no plugins installed.
no_results_content_text: See our integrations and plugins page for more information.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 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.
#++

require "rails_helper"

RSpec.describe Admin::Settings::ProjectReservedIdentifiers::ReleaseDialogComponent, type: :component do
let!(:project) { create(:project) }
let!(:slug) { FriendlyId::Slug.create!(sluggable: project, slug: "old-id") }

subject(:rendered_component) { render_inline(described_class.new(slug:)) }

it "renders the heading with the identifier" do
expect(rendered_component)
.to have_text(I18n.t("admin.reserved_identifiers.dialog.heading", identifier: "old-id"))
end

context "without affected work packages" do
it "renders the plain description" do
expect(rendered_component)
.to have_text(I18n.t("admin.reserved_identifiers.dialog.description"))
expect(rendered_component).to have_no_text("work package")
end
end

context "with one affected work package" do
before { create(:work_package_semantic_alias, identifier: "old-id-1") }

it "renders the singular description" do
expect(rendered_component)
.to have_text(I18n.t("admin.reserved_identifiers.dialog.description_with_work_packages", count: 1))
end
end

context "with several affected work packages" do
before do
create(:work_package_semantic_alias, identifier: "old-id-1")
create(:work_package_semantic_alias, identifier: "old-id-2")
end

it "renders the pluralized description" do
expect(rendered_component)
.to have_text(I18n.t("admin.reserved_identifiers.dialog.description_with_work_packages", count: 2))
end
end
end
Loading
Loading