mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Merge branch 'feature/71896-change-identifier-with-semantic-identifiers' into feature/72855-new-project-with-semantic-identifiers
This commit is contained in:
@@ -39,7 +39,7 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
<%= settings_primer_form_with(model: project,
|
||||
url: project_identifier_path(project),
|
||||
id: "change-identifier-form",
|
||||
class: "mt-4") do |f| %>
|
||||
mt: 4) do |f| %>
|
||||
<%= render(Primer::Forms::FormList.new(Projects::Settings::EditableIdentifierForm.new(f))) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -46,27 +46,29 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
%>
|
||||
<% end %>
|
||||
|
||||
<% if OpenProject::FeatureDecisions.semantic_work_package_ids_active? %>
|
||||
<%= render(Primer::BaseComponent.new(tag: :section, mb: 4)) do %>
|
||||
<%=
|
||||
render(Primer::Beta::Subhead.new) do |component|
|
||||
component.with_heading(tag: :h3, size: :medium) { t(:label_identifier) }
|
||||
end
|
||||
%>
|
||||
<%=
|
||||
settings_primer_form_with(model: project, url: project_settings_general_path(project)) do |f|
|
||||
render(Primer::Forms::FormList.new(Projects::Settings::IdentifierForm.new(f)))
|
||||
end
|
||||
%>
|
||||
<%= render(Primer::Beta::Button.new(
|
||||
tag: :a,
|
||||
href: projects_identifier_dialog_path(project_id: project),
|
||||
data: { turbo_stream: true }
|
||||
)) { t("projects.settings.change_identifier") } %>
|
||||
<% end %>
|
||||
<%= render(Primer::BaseComponent.new(tag: :section, mb: 4)) do %>
|
||||
<%=
|
||||
render(Primer::Beta::Subhead.new) do |component|
|
||||
component.with_heading(tag: :h3, size: :medium) { t(:label_identifier) }
|
||||
end
|
||||
%>
|
||||
<%=
|
||||
settings_primer_form_with(model: project, url: project_settings_general_path(project)) do |f|
|
||||
render(Primer::Forms::FormList.new(Projects::Settings::IdentifierForm.new(f)))
|
||||
end
|
||||
%>
|
||||
<%=
|
||||
render(
|
||||
Primer::Beta::Button.new(
|
||||
tag: :a,
|
||||
href: identifier_update_dialog_project_identifier_path(project_id: project),
|
||||
data: { turbo_stream: true },
|
||||
mt: 2
|
||||
)
|
||||
) { t("projects.settings.change_identifier") }
|
||||
%>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= render(Primer::BaseComponent.new(tag: :section, mb: 4)) do %>
|
||||
<%=
|
||||
render(Primer::Beta::Subhead.new) do |component|
|
||||
|
||||
@@ -24,21 +24,6 @@
|
||||
end
|
||||
end
|
||||
|
||||
unless OpenProject::FeatureDecisions.semantic_work_package_ids_active?
|
||||
header.with_action_button(
|
||||
tag: :a,
|
||||
mobile_icon: :pencil,
|
||||
mobile_label: t("projects.settings.change_identifier"),
|
||||
size: :medium,
|
||||
href: project_identifier_path(@project),
|
||||
aria: { label: t("projects.settings.change_identifier") },
|
||||
title: t("projects.settings.change_identifier")
|
||||
) do |button|
|
||||
button.with_leading_visual_icon(icon: :pencil)
|
||||
t("projects.settings.change_identifier")
|
||||
end
|
||||
end
|
||||
|
||||
header.with_action_menu(
|
||||
menu_arguments: {
|
||||
anchor_align: :end
|
||||
|
||||
+2
-2
@@ -86,10 +86,10 @@
|
||||
end
|
||||
end
|
||||
row.with_column(flex: 1) do
|
||||
render(Primer::Beta::Text.new) { entry[:suggested_handle] }
|
||||
render(Primer::Beta::Text.new) { entry[:suggested_identifier] }
|
||||
end
|
||||
row.with_column(flex: 1) do
|
||||
render(Primer::Beta::Text.new) { sample_wp_id(entry[:suggested_handle]) }
|
||||
render(Primer::Beta::Text.new) { sample_wp_id(entry[:suggested_identifier]) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,12 +53,11 @@ module WorkPackages
|
||||
end
|
||||
|
||||
# Produces a realistic-looking example work package ID for the preview table.
|
||||
# The sequence number is derived deterministically from the handle so it looks
|
||||
# The sequence number is derived deterministically from the identifier so it looks
|
||||
# varied across projects but is stable across renders. Range: 1–500.
|
||||
# Single-digit numbers are zero-padded ("FP-07"), two/three digits are not ("FP-42").
|
||||
def sample_wp_id(handle)
|
||||
n = (handle.bytes.sum % 500) + 1
|
||||
"#{handle}-#{format('%02d', n)}"
|
||||
def sample_wp_id(identifier)
|
||||
n = (identifier.bytes.sum % 500) + 1
|
||||
"#{identifier}-#{n}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,7 +34,9 @@ class Projects::IdentifierController < ApplicationController
|
||||
before_action :find_project_by_project_id
|
||||
before_action :authorize
|
||||
|
||||
def show; end
|
||||
def identifier_update_dialog
|
||||
respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project)
|
||||
end
|
||||
|
||||
def update
|
||||
service_call = Projects::UpdateService
|
||||
@@ -45,11 +47,9 @@ class Projects::IdentifierController < ApplicationController
|
||||
if service_call.success?
|
||||
flash[:notice] = I18n.t(:notice_successful_update)
|
||||
redirect_to project_settings_general_path(@project)
|
||||
elsif OpenProject::FeatureDecisions.semantic_work_package_ids_active? # Handle error for the new modal
|
||||
respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project),
|
||||
else
|
||||
respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: service_call.result),
|
||||
status: :unprocessable_entity
|
||||
else # Handle error for the legacy standalone identifier setting page
|
||||
render action: "show", status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,8 +34,7 @@ class ProjectsController < ApplicationController
|
||||
menu_item :overview
|
||||
menu_item :roadmap, only: :roadmap
|
||||
|
||||
before_action :find_project, except: %i[index new create destroy destroy_info identifier_dialog]
|
||||
before_action :find_project_by_project_id, only: %i[identifier_dialog]
|
||||
before_action :find_project, except: %i[index new create destroy destroy_info]
|
||||
before_action :find_project_including_archived, only: %i[destroy destroy_info]
|
||||
before_action :load_query_or_deny_access, only: %i[index]
|
||||
before_action :authorize,
|
||||
@@ -46,7 +45,6 @@ class ProjectsController < ApplicationController
|
||||
before_action :find_optional_template, only: %i[new create]
|
||||
|
||||
no_authorization_required! :index
|
||||
no_authorization_required! :identifier_dialog
|
||||
|
||||
include SortHelper
|
||||
include PaginationHelper
|
||||
@@ -163,12 +161,6 @@ class ProjectsController < ApplicationController
|
||||
respond_with_dialog Projects::DeleteDialogComponent.new(project: @project)
|
||||
end
|
||||
|
||||
def identifier_dialog
|
||||
return render_404 unless OpenProject::FeatureDecisions.semantic_work_package_ids_active?
|
||||
|
||||
respond_with_dialog Projects::Settings::ChangeIdentifierDialogComponent.new(project: @project)
|
||||
end
|
||||
|
||||
def deactivate_work_package_attachments
|
||||
call = Projects::UpdateService
|
||||
.new(user: current_user, model: @project, contract_class: Projects::SettingsContract)
|
||||
|
||||
@@ -31,13 +31,12 @@ module Projects
|
||||
module Settings
|
||||
class EditableIdentifierForm < ApplicationForm
|
||||
form do |f|
|
||||
if Project.semantic_alphanumeric_identifier?
|
||||
if Setting::WorkPackageIdentifier.alphanumeric?
|
||||
f.text_field(
|
||||
name: :identifier,
|
||||
label: attribute_name(:identifier),
|
||||
caption: I18n.t("projects.settings.change_identifier_format_hint_semantic"),
|
||||
required: true,
|
||||
maxlength: Project::SEMANTIC_IDENTIFIER_MAX_LENGTH,
|
||||
validation_message: validation_message_for(:identifier)
|
||||
)
|
||||
else
|
||||
@@ -46,7 +45,6 @@ module Projects
|
||||
label: attribute_name(:identifier),
|
||||
caption: I18n.t("projects.settings.change_identifier_format_hint_legacy"),
|
||||
required: true,
|
||||
maxlength: Project::IDENTIFIER_MAX_LENGTH,
|
||||
validation_message: validation_message_for(:identifier)
|
||||
)
|
||||
end
|
||||
@@ -55,7 +53,7 @@ module Projects
|
||||
private
|
||||
|
||||
def validation_message_for(attribute)
|
||||
model.errors.messages_for(attribute).to_sentence.presence
|
||||
model.errors.full_messages_for(attribute).to_sentence.presence
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,7 +31,7 @@ module Projects
|
||||
module Settings
|
||||
class IdentifierForm < ApplicationForm
|
||||
form do |f|
|
||||
caption_key = if Project.semantic_alphanumeric_identifier?
|
||||
caption_key = if Setting::WorkPackageIdentifier.alphanumeric?
|
||||
:text_project_identifier_description
|
||||
else
|
||||
:text_project_identifier_url_description
|
||||
|
||||
@@ -48,7 +48,7 @@ class Project < ApplicationRecord
|
||||
SEMANTIC_IDENTIFIER_MAX_LENGTH = 10
|
||||
|
||||
# reserved identifiers
|
||||
RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_dialog identifier_suggestion].freeze
|
||||
RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_update_dialog identifier_suggestion].freeze
|
||||
|
||||
enum :workspace_type, {
|
||||
project: "project",
|
||||
@@ -211,18 +211,20 @@ class Project < ApplicationRecord
|
||||
# Contains only a-z, 0-9, dashes and underscores but cannot consist of numbers only as it would clash with the id.
|
||||
validates :identifier,
|
||||
format: { with: /\A(?!^\d+\z)[a-z0-9\-_]+\z/ },
|
||||
if: ->(p) { p.identifier_changed? && p.identifier.present? && !Project.semantic_alphanumeric_identifier? }
|
||||
if: ->(p) {
|
||||
p.identifier_changed? && p.identifier.present? && !Setting::WorkPackageIdentifier.alphanumeric?
|
||||
}
|
||||
|
||||
# When semantic work package IDs with alphanumeric mode are active, identifiers must follow JIRA-style key rules.
|
||||
validates :identifier,
|
||||
format: { with: /\A[A-Z]/, message: :must_start_with_letter },
|
||||
if: ->(p) { p.identifier_changed? && p.identifier.present? && Project.semantic_alphanumeric_identifier? }
|
||||
if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? }
|
||||
|
||||
validates :identifier,
|
||||
format: { with: /\A[A-Z][A-Z0-9_]*\z/, message: :no_special_characters },
|
||||
length: { maximum: SEMANTIC_IDENTIFIER_MAX_LENGTH },
|
||||
if: ->(p) {
|
||||
p.identifier_changed? && p.identifier.present? && Project.semantic_alphanumeric_identifier? &&
|
||||
p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? &&
|
||||
p.identifier.match?(/\A[A-Z]/)
|
||||
}
|
||||
|
||||
@@ -280,12 +282,8 @@ class Project < ApplicationRecord
|
||||
User.current.allowed_in_project?(:copy_projects, self)
|
||||
end
|
||||
|
||||
def self.semantic_alphanumeric_identifier?
|
||||
OpenProject::FeatureDecisions.semantic_work_package_ids_active? && Setting::WorkPackageIdentifier.alphanumeric?
|
||||
end
|
||||
|
||||
def self.suggest_identifier(name)
|
||||
if semantic_alphanumeric_identifier?
|
||||
if Setting::WorkPackageIdentifier.alphanumeric?
|
||||
WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator.suggest_for_name(name)
|
||||
else
|
||||
name.to_url.first(IDENTIFIER_MAX_LENGTH).presence || "project"
|
||||
|
||||
@@ -41,33 +41,46 @@ module WorkPackages
|
||||
.limit(DISPLAY_COUNT)
|
||||
.to_a
|
||||
|
||||
suggestions = WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator.call(
|
||||
suggestions = WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator.call(
|
||||
preview,
|
||||
in_use_handles:,
|
||||
reserved_handles:
|
||||
in_use_identifiers:,
|
||||
reserved_identifiers:
|
||||
)
|
||||
|
||||
Result.new(projects_data: suggestions, total_count: total)
|
||||
projects_data = suggestions.map do |entry|
|
||||
entry.merge(error_reason: error_reason(entry[:current_identifier]))
|
||||
end
|
||||
|
||||
Result.new(projects_data:, total_count: total)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# FIXME: Replace WHERE clause with:
|
||||
# Project.where.not(id: OldProjectIdentifier.where(current: true).select(:project_id))
|
||||
# once all valid identifiers have been migrated to handle rows.
|
||||
def problematic_scope
|
||||
@problematic_scope ||= Project.where(
|
||||
"length(identifier) > ? OR identifier ~ ?",
|
||||
WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator::HANDLE_MAX_LENGTH,
|
||||
"[^a-zA-Z0-9]"
|
||||
ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max],
|
||||
"[^a-zA-Z0-9_]"
|
||||
)
|
||||
end
|
||||
|
||||
def in_use_handles
|
||||
Project.where.not(id: problematic_scope.select(:id)).pluck(:identifier).to_set
|
||||
def error_reason(identifier)
|
||||
if identifier.length > ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max]
|
||||
:too_long
|
||||
elsif identifier.match?(/[^a-zA-Z0-9_]/)
|
||||
:special_characters
|
||||
elsif in_use_identifiers.include?(identifier)
|
||||
:in_use
|
||||
elsif reserved_identifiers.include?(identifier)
|
||||
:reserved
|
||||
end
|
||||
end
|
||||
|
||||
def reserved_handles
|
||||
def in_use_identifiers
|
||||
@in_use_identifiers ||= Project.where.not(id: problematic_scope.select(:id)).pluck(:identifier).to_set
|
||||
end
|
||||
|
||||
def reserved_identifiers
|
||||
# TODO: OldProjectIdentifier.pluck(:identifier).to_set
|
||||
# once the OldProjectIdentifier model and migration are added.
|
||||
Set.new
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
# 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 WorkPackages
|
||||
module IdentifierAutofix
|
||||
# Generates a short uppercase acronym suggestion for each given project.
|
||||
#
|
||||
# The suggestion is derived from the project name: taking the first letter of
|
||||
# each word and uppercasing ("Flight Planning Algorithm" → "FPA"). When two
|
||||
# projects produce the same acronym, a numeric suffix resolves the collision
|
||||
# ("SC", "SC2", "SC3", …).
|
||||
#
|
||||
# Each result entry includes an error_reason classifying why the project's
|
||||
# current identifier is problematic:
|
||||
# - :too_long — identifier length exceeds HANDLE_MAX_LENGTH
|
||||
# - :special_characters — identifier contains characters outside [a-zA-Z0-9]
|
||||
# - :in_use — identifier is another project's active handle
|
||||
# - :reserved — identifier appears in another project's handle history
|
||||
#
|
||||
class ProjectHandleSuggestionGenerator
|
||||
HANDLE_MAX_LENGTH = 5
|
||||
SINGLE_WORD_LENGTH = 3
|
||||
FALLBACK_HANDLE = "PROJ"
|
||||
SUFFIX_LIMIT = 10_000
|
||||
|
||||
def self.call(projects, reserved_handles: Set.new, in_use_handles: Set.new)
|
||||
new.call(projects, reserved_handles:, in_use_handles:)
|
||||
end
|
||||
|
||||
def self.suggest_for_name(name)
|
||||
new.suggest_for_name(name)
|
||||
end
|
||||
|
||||
def suggest_for_name(name)
|
||||
handle_from_name(name)
|
||||
end
|
||||
|
||||
def call(projects, reserved_handles:, in_use_handles:)
|
||||
generate_suggestions(projects, reserved_handles:, in_use_handles:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_suggestions(projects, reserved_handles:, in_use_handles:)
|
||||
used_handles = Set.new
|
||||
used_handles.merge(in_use_handles)
|
||||
used_handles.merge(reserved_handles)
|
||||
|
||||
projects.map do |project|
|
||||
base = handle_from_name(project.name)
|
||||
handle = unique_handle(base, used_handles)
|
||||
used_handles << handle
|
||||
|
||||
{
|
||||
project:,
|
||||
current_identifier: project.identifier,
|
||||
suggested_handle: handle,
|
||||
error_reason: error_reason(project.identifier, reserved_handles:, in_use_handles:)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_from_name(name)
|
||||
# Use POSIX [[:alpha:]] so accented letters (é, ñ, ü…) are kept inside
|
||||
# their word rather than treated as separators by the ASCII-only [a-zA-Z].
|
||||
words = name.to_s.scan(/[[:alpha:][:digit:]]+/)
|
||||
return FALLBACK_HANDLE if words.empty?
|
||||
|
||||
words.size == 1 ? handle_from_single_word(words.first) : handle_from_words(words)
|
||||
end
|
||||
|
||||
def handle_from_single_word(word)
|
||||
# e.g. "Banana" → "BAN", "Kiwi" → "KIW", "日本語" → FALLBACK_HANDLE
|
||||
t = I18n.with_locale(:en) { I18n.transliterate(word) }
|
||||
chars = t.scan(/[A-Za-z0-9]/).first(SINGLE_WORD_LENGTH).map(&:upcase).join
|
||||
chars.empty? ? FALLBACK_HANDLE : chars
|
||||
end
|
||||
|
||||
def handle_from_words(words)
|
||||
# Multi-word names: take initials (first letter of each word), truncated.
|
||||
acronym = words.filter_map do |word|
|
||||
ch = I18n.with_locale(:en) { I18n.transliterate(word[0]) }.upcase[0]
|
||||
ch if ch&.match?(/\A[A-Z0-9]\z/)
|
||||
end.join
|
||||
return FALLBACK_HANDLE if acronym.empty?
|
||||
|
||||
acronym.slice(0, HANDLE_MAX_LENGTH)
|
||||
end
|
||||
|
||||
def unique_handle(base, used_handles)
|
||||
return base unless used_handles.include?(base)
|
||||
|
||||
counter = 2
|
||||
loop do
|
||||
raise "Could not find a unique handle for base '#{base}' within #{SUFFIX_LIMIT} attempts" \
|
||||
if counter > SUFFIX_LIMIT
|
||||
|
||||
suffix = counter.to_s
|
||||
candidate = "#{base.slice(0, HANDLE_MAX_LENGTH - suffix.length)}#{suffix}"
|
||||
break candidate unless used_handles.include?(candidate)
|
||||
|
||||
counter += 1
|
||||
end
|
||||
end
|
||||
|
||||
def error_reason(identifier, reserved_handles:, in_use_handles:)
|
||||
if identifier.length > HANDLE_MAX_LENGTH
|
||||
:too_long
|
||||
elsif identifier.match?(/[^a-zA-Z0-9]/)
|
||||
:special_characters
|
||||
elsif in_use_handles.include?(identifier)
|
||||
:in_use
|
||||
elsif reserved_handles.include?(identifier)
|
||||
:reserved
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
# 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 WorkPackages
|
||||
module IdentifierAutofix
|
||||
# Generates a short uppercase semantic identifier for each project.
|
||||
#
|
||||
# Identifiers are 2–10 uppercase alphanumeric characters that always start
|
||||
# with a letter.
|
||||
#
|
||||
# == Algorithm
|
||||
#
|
||||
# *Multi-word names* use word initials, truncated to +IDENTIFIER_LENGTH[:base]+ (5):
|
||||
# "Flight Planning Algorithm" → "FPA"
|
||||
# "A B C D E F G H I J K" → "ABCDE"
|
||||
#
|
||||
# *Single-word names* use the first +IDENTIFIER_LENGTH[:single_word]+ (3) characters:
|
||||
# "Banana" → "BAN"
|
||||
#
|
||||
# *Accented characters* are transliterated ("Cécile" → "CEC").
|
||||
# *Non-Latin scripts* that have no transliteration fall back to "PROJ".
|
||||
#
|
||||
# == Collision resolution
|
||||
#
|
||||
# When a candidate is already taken, the identifier is progressively widened
|
||||
# with more characters from the name, up to +IDENTIFIER_LENGTH[:max]+ (10):
|
||||
#
|
||||
# Multi-word: "SC" → "STC" → "STCO" → "STRCO" → … → "STREACOMMU"
|
||||
# Single-word: "BAN" → "BANA" → "BANAN" → "BANANA"
|
||||
# Initials: "ABCDE" → "ABCDEF" → … → "ABCDEFGHIJ"
|
||||
#
|
||||
# If all expansion candidates are exhausted, a numeric suffix is appended
|
||||
# as a last resort ("GO" → "GO2").
|
||||
#
|
||||
class ProjectIdentifierSuggestionGenerator
|
||||
IDENTIFIER_LENGTH = { min: 2, max: 10, base: 5, single_word: 3 }.freeze
|
||||
FALLBACK_IDENTIFIER = "PROJ"
|
||||
SUFFIX_LIMIT = 10_000
|
||||
|
||||
def self.call(projects, reserved_identifiers: Set.new, in_use_identifiers: Set.new)
|
||||
new.call(projects, reserved_identifiers:, in_use_identifiers:)
|
||||
end
|
||||
|
||||
# Returns a single suggested identifier string for the given project name.
|
||||
#
|
||||
def self.suggest_identifier(name, reserved_identifiers: Set.new, in_use_identifiers: Set.new)
|
||||
new.suggest_identifier(name, reserved_identifiers:, in_use_identifiers:)
|
||||
end
|
||||
|
||||
def call(projects, reserved_identifiers:, in_use_identifiers:)
|
||||
generate_suggestions(projects, reserved_identifiers:, in_use_identifiers:)
|
||||
end
|
||||
|
||||
def suggest_identifier(name, reserved_identifiers: Set.new, in_use_identifiers: Set.new)
|
||||
used = reserved_identifiers | in_use_identifiers
|
||||
candidates = identifier_candidates(name)
|
||||
find_unique(candidates, used)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_suggestions(projects, reserved_identifiers:, in_use_identifiers:)
|
||||
used_identifiers = reserved_identifiers | in_use_identifiers
|
||||
|
||||
projects.map do |project|
|
||||
candidates = identifier_candidates(project.name)
|
||||
identifier = find_unique(candidates, used_identifiers)
|
||||
used_identifiers << identifier
|
||||
|
||||
{
|
||||
project:,
|
||||
current_identifier: project.identifier,
|
||||
suggested_identifier: identifier
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Returns an ordered list of progressively longer identifier candidates
|
||||
# derived from the project name. The first unique candidate wins.
|
||||
def identifier_candidates(name)
|
||||
words = transliterated_words(name)
|
||||
return [FALLBACK_IDENTIFIER] if words.empty?
|
||||
|
||||
candidates = words.size == 1 ? single_word_candidates(words.first) : multi_word_candidates(words)
|
||||
candidates = candidates.filter_map do |c|
|
||||
stripped = ensure_starts_with_letter(c)
|
||||
stripped if stripped&.length.to_i >= IDENTIFIER_LENGTH[:min]
|
||||
end
|
||||
candidates.presence || [FALLBACK_IDENTIFIER]
|
||||
end
|
||||
|
||||
# Splits a name into words and transliterates each, returning only words
|
||||
# that contain at least one ASCII-alphanumeric character.
|
||||
def transliterated_words(name)
|
||||
# Use POSIX [[:alpha:]] so accented letters (é, ñ, ü…) are kept inside
|
||||
# their word rather than treated as separators by the ASCII-only [a-zA-Z].
|
||||
raw_words = name.to_s.scan(/[[:alpha:][:digit:]]+/)
|
||||
raw_words.filter_map do |word|
|
||||
t = I18n.with_locale(:en) { I18n.transliterate(word) }
|
||||
clean = t.gsub(/[^A-Za-z0-9]/, "")
|
||||
clean.presence
|
||||
end
|
||||
end
|
||||
|
||||
# "Banana" → ["BAN", "BANA", "BANAN", "BANANA"]
|
||||
def single_word_candidates(word)
|
||||
chars = word.upcase
|
||||
max_len = [chars.length, IDENTIFIER_LENGTH[:max]].min
|
||||
return [] if max_len < IDENTIFIER_LENGTH[:min]
|
||||
|
||||
start_len = IDENTIFIER_LENGTH[:single_word].clamp(IDENTIFIER_LENGTH[:min], max_len)
|
||||
(start_len..max_len).map { chars[0, it] }
|
||||
end
|
||||
|
||||
# "Stream Communicator" → ["SC", "STC", "STCO", "STRCO", …]
|
||||
# "A B C D E F G H I J K" → ["ABCDE", "ABCDEF", …, "ABCDEFGHIJ"]
|
||||
#
|
||||
# Starts with initials truncated to IDENTIFIER_LENGTH[:base], progressively
|
||||
# includes more initials, then expands words beyond single chars.
|
||||
def multi_word_candidates(words)
|
||||
upcased_words = words.map(&:upcase)
|
||||
candidates = initial_candidates(upcased_words)
|
||||
|
||||
append_expansion_candidates!(candidates, upcased_words) if candidates.last.length < IDENTIFIER_LENGTH[:max]
|
||||
candidates
|
||||
end
|
||||
|
||||
def initial_candidates(upcased_words)
|
||||
initials = upcased_words.pluck(0).join[0, IDENTIFIER_LENGTH[:max]]
|
||||
start = [IDENTIFIER_LENGTH[:base], initials.length].min
|
||||
(start..initials.length).map { initials[0, it] }
|
||||
end
|
||||
|
||||
# Progressively pulls more characters from each word left-to-right.
|
||||
def append_expansion_candidates!(candidates, upcased_words)
|
||||
chars_per_word = upcased_words.map { 1 }
|
||||
|
||||
loop do
|
||||
expandable = upcased_words.each_index.find { |i| chars_per_word[i] < upcased_words[i].length }
|
||||
break unless expandable
|
||||
|
||||
chars_per_word[expandable] += 1
|
||||
candidate = build_candidate(upcased_words, chars_per_word)
|
||||
candidates << candidate unless candidates.include?(candidate)
|
||||
break if candidate.length >= IDENTIFIER_LENGTH[:max]
|
||||
end
|
||||
end
|
||||
|
||||
def build_candidate(upcased_words, chars_per_word)
|
||||
parts = upcased_words.each_with_index.map { |w, i| w[0, chars_per_word[i]] }
|
||||
parts.join[0, IDENTIFIER_LENGTH[:max]]
|
||||
end
|
||||
|
||||
# Strips leading digits so identifiers always start with a letter.
|
||||
# For names like "3D Printing Lab", initials "3PL" become "PL".
|
||||
# This is lossy but acceptable for auto-generated suggestions.
|
||||
def ensure_starts_with_letter(candidate)
|
||||
candidate.sub(/\A\d+/, "").presence
|
||||
end
|
||||
|
||||
# Iterates through expansion candidates, then falls back to numeric suffix.
|
||||
# Candidates are already filtered to start with a letter and meet min length.
|
||||
def find_unique(candidates, used_identifiers)
|
||||
candidates.each do |candidate|
|
||||
return candidate unless used_identifiers.include?(candidate)
|
||||
end
|
||||
|
||||
base = candidates.last || FALLBACK_IDENTIFIER
|
||||
numeric_suffix_fallback(base, used_identifiers)
|
||||
end
|
||||
|
||||
def numeric_suffix_fallback(base, used_identifiers)
|
||||
# Ensure the base itself starts with a letter before appending digits.
|
||||
base = ensure_starts_with_letter(base) || FALLBACK_IDENTIFIER
|
||||
|
||||
counter = 2
|
||||
loop do
|
||||
raise "Could not find a unique identifier for base '#{base}' within #{SUFFIX_LIMIT} attempts" \
|
||||
if counter > SUFFIX_LIMIT
|
||||
|
||||
suffix = counter.to_s
|
||||
candidate = "#{base[0, IDENTIFIER_LENGTH[:max] - suffix.length]}#{suffix}"
|
||||
return candidate unless used_identifiers.include?(candidate)
|
||||
|
||||
counter += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,94 +0,0 @@
|
||||
<%#-- 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.
|
||||
|
||||
++#%>
|
||||
|
||||
<%=
|
||||
render Primer::OpenProject::PageHeader.new do |header|
|
||||
header.with_title { t("project.identifier.title") }
|
||||
header.with_breadcrumbs(
|
||||
[{ href: project_overview_path(@project.id), text: @project.name },
|
||||
{ href: project_settings_general_path(@project.id), text: I18n.t("label_project_settings") },
|
||||
t("project.identifier.title")]
|
||||
)
|
||||
end
|
||||
%>
|
||||
|
||||
<%= error_messages_for @project %>
|
||||
|
||||
<%= form_for @project,
|
||||
url: project_identifier_path(@project),
|
||||
html: { class: "danger-zone form -vertical" } do |f| %>
|
||||
<section class="form--section">
|
||||
<h3 class="form--section-title">
|
||||
<%= t("project.identifier.title") %>
|
||||
</h3>
|
||||
|
||||
<p class="danger-zone--warning">
|
||||
<span class="icon icon-error"></span>
|
||||
<span><%= t("project.identifier.warning_one").html_safe %></span>
|
||||
<br>
|
||||
<span class="icon icon-error"></span>
|
||||
<span><%= t("project.identifier.warning_two").html_safe %></span>
|
||||
</p>
|
||||
|
||||
<%= styled_label_tag "identifier", Project.human_attribute_name(:identifier), class: "-required" %>
|
||||
<div class="danger-zone--verification">
|
||||
<% if Project.semantic_alphanumeric_identifier? %>
|
||||
<%= f.text_field :identifier,
|
||||
pattern: "[A-Z][A-Z0-9_]*",
|
||||
maxlength: 10,
|
||||
title: t(:text_project_identifier_handle_format) %>
|
||||
<% else %>
|
||||
<%= f.text_field :identifier,
|
||||
pattern: "[a-z][a-z0-9\\-_]*",
|
||||
maxlength: 100,
|
||||
title: t(:text_project_identifier_format) %>
|
||||
<% end %>
|
||||
<span>
|
||||
<%= f.submit t(:button_update), class: "button -primary -with-icon icon-checkmark" %>
|
||||
</span>
|
||||
<span>
|
||||
<%= link_to project_settings_general_path(@project), class: "button" do %>
|
||||
<%= op_icon("button--icon icon-cancel") %>
|
||||
<span class="button--text"><%= t(:button_cancel) %></span>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="form--field-instructions">
|
||||
<%= t(
|
||||
:text_length_between, min: 1,
|
||||
max: Project::IDENTIFIER_MAX_LENGTH
|
||||
) %>
|
||||
<%= t(:text_project_identifier_info).html_safe %>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<% end %>
|
||||
@@ -137,7 +137,7 @@ Rails.application.reloader.to_prepare do
|
||||
"projects/settings/subitems": %i[show update],
|
||||
"projects/settings/template": %i[show update toggle_template],
|
||||
"projects/templated": %i[create destroy],
|
||||
"projects/identifier": %i[show update update_identifier_dialog],
|
||||
"projects/identifier": %i[show update identifier_update_dialog],
|
||||
"projects/status": %i[update destroy]
|
||||
},
|
||||
permissible_on: :project,
|
||||
|
||||
@@ -394,7 +394,7 @@ en:
|
||||
box_header:
|
||||
label_project: Project
|
||||
label_previous_identifier: Previous identifier
|
||||
label_autofixed_suggestion: Autofixed suggestion
|
||||
label_autofixed_suggestion: Future identifier
|
||||
label_example_work_package_id: Example work package ID
|
||||
autofix_preview:
|
||||
error_too_long: Has to be fewer than 5 characters
|
||||
|
||||
+3
-4
@@ -283,9 +283,6 @@ Rails.application.routes.draw do
|
||||
namespace :projects do
|
||||
resource :menu, only: %i[show]
|
||||
resource :filters, only: %i[show]
|
||||
get "identifier_dialog", to: "/projects#identifier_dialog",
|
||||
as: :identifier_dialog,
|
||||
defaults: { format: :turbo_stream }
|
||||
end
|
||||
|
||||
get "projects/identifier_suggestion", to: "projects/identifier_suggestions#show", as: :projects_identifier_suggestion
|
||||
@@ -359,7 +356,9 @@ Rails.application.routes.draw do
|
||||
get :dialog
|
||||
end
|
||||
end
|
||||
resource :identifier, only: %i[show update], controller: "identifier"
|
||||
resource :identifier, only: %i[show update], controller: "identifier" do
|
||||
get :identifier_update_dialog, on: :member, defaults: { format: :turbo_stream }
|
||||
end
|
||||
resource :status, only: %i[update destroy], controller: "status"
|
||||
resource :creation_wizard, only: %i[show update], controller: "creation_wizard" do
|
||||
get :help_text, on: :member
|
||||
|
||||
@@ -76,9 +76,7 @@ RSpec.describe Projects::Settings::General::ShowComponent, type: :component do
|
||||
end
|
||||
end
|
||||
|
||||
describe "Identifier" do
|
||||
before { with_flags(semantic_work_package_ids: true) }
|
||||
|
||||
describe "Identifier", with_flag: { semantic_work_package_ids: true } do
|
||||
it_behaves_like "section with heading", "Identifier"
|
||||
|
||||
it "renders a Change identifier button" do
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent,
|
||||
{
|
||||
project:,
|
||||
current_identifier: identifier,
|
||||
suggested_handle: handle,
|
||||
suggested_identifier: handle,
|
||||
error_reason:
|
||||
}
|
||||
end
|
||||
@@ -76,7 +76,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierAutofixSectionComponent,
|
||||
|
||||
it "shows a realistic example work package ID" do
|
||||
render_inline(component)
|
||||
# Numbers are deterministic from the handle's byte sum; format is handle + zero-padded number.
|
||||
# Numbers are deterministic from the identifier's byte sum.
|
||||
expect(page).to have_text("FP-151") # "FP".bytes.sum % 500 + 1 = 151
|
||||
expect(page).to have_text("VLNP-321") # "VLNP".bytes.sum % 500 + 1 = 321
|
||||
end
|
||||
|
||||
+1
-1
@@ -131,7 +131,7 @@ RSpec.describe WorkPackages::Admin::Settings::IdentifierSettingsFormComponent, t
|
||||
let(:problematic_result) do
|
||||
WorkPackages::IdentifierAutofix::PreviewQuery::Result.new(
|
||||
projects_data: [
|
||||
{ project:, current_identifier: "bad-proj", suggested_handle: "BP", error_reason: :special_characters }
|
||||
{ project:, current_identifier: "bad-proj", suggested_identifier: "BP", error_reason: :special_characters }
|
||||
],
|
||||
total_count: 1
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ RSpec.describe Projects::IdentifierController do
|
||||
context "with an invalid identifier" do
|
||||
it "does not change the project identifier and correctly renders the view" do
|
||||
previous_identifier = project.identifier
|
||||
put :update, params: { project_id: project.id, project: { identifier: "bad identifier" } }
|
||||
put :update, params: { project_id: project.id, project: { identifier: "bad identifier" }, format: :turbo_stream }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).to include("Identifier is invalid")
|
||||
|
||||
@@ -48,106 +48,75 @@ RSpec.describe "Projects", "editing settings", :js do
|
||||
visit project_settings_general_path(project.id)
|
||||
|
||||
expect(page).to have_no_text :all, "Active"
|
||||
expect(page).to have_no_text :all, "Identifier"
|
||||
end
|
||||
|
||||
describe "identifier edit" do
|
||||
it "updates the project identifier" do
|
||||
visit projects_path
|
||||
click_on project.name
|
||||
click_on "Project settings"
|
||||
click_on "Change identifier"
|
||||
context "with numerical IDs", with_settings: { work_packages_identifier: "numeric" } do
|
||||
it "updates the project identifier via dialog" do
|
||||
visit project_settings_general_path(project)
|
||||
|
||||
expect(page).to have_content "Change the project's identifier".upcase
|
||||
expect(page).to have_current_path "/projects/foo-project/identifier"
|
||||
click_on "Change identifier"
|
||||
|
||||
fill_in "project[identifier]", with: "foo-bar"
|
||||
click_on "Update"
|
||||
expect(page).to have_dialog "Change project identifier"
|
||||
|
||||
expect(page).to have_content "Successful update."
|
||||
expect(page)
|
||||
.to have_current_path %r{/projects/foo-bar/settings/general}
|
||||
expect(Project.first.identifier).to eq "foo-bar"
|
||||
within "dialog" do
|
||||
expect(page).to have_text "This will permanently change identifiers and URLs"
|
||||
fill_in "project[identifier]", with: "foo-bar"
|
||||
click_on "Change identifier"
|
||||
end
|
||||
|
||||
expect(page).to have_content "Successful update."
|
||||
expect(page).to have_current_path %r{/projects/foo-bar/settings/general}
|
||||
expect(project.reload.identifier).to eq "foo-bar"
|
||||
end
|
||||
end
|
||||
|
||||
it "displays error messages on invalid input" do
|
||||
visit project_identifier_path(project)
|
||||
context "with alphanumeric IDs", with_settings: { work_packages_identifier: "alphanumeric" } do
|
||||
it "updates the project identifier via dialog" do
|
||||
visit project_settings_general_path(project)
|
||||
|
||||
fill_in "project[identifier]", with: "FOOO"
|
||||
click_on "Update"
|
||||
click_on "Change identifier"
|
||||
|
||||
expect(page).to have_content "Identifier is invalid."
|
||||
expect(page).to have_current_path "/projects/foo-project/identifier"
|
||||
end
|
||||
expect(page).to have_dialog "Change project identifier"
|
||||
|
||||
context "with the semantic work package IDs flag enabled", with_flag: { semantic_work_package_ids: true } do
|
||||
context "with numerical IDs", with_settings: { work_packages_identifier: "numeric" } do
|
||||
it "updates the project identifier via dialog" do
|
||||
visit project_settings_general_path(project)
|
||||
within "dialog" do
|
||||
expect(page).to have_text "This will permanently change identifiers and URLs"
|
||||
fill_in "project[identifier]", with: "FOOBAR"
|
||||
click_on "Change identifier"
|
||||
end
|
||||
|
||||
expect(page).to have_content "Successful update."
|
||||
expect(page).to have_current_path %r{/projects/FOOBAR/settings/general}
|
||||
expect(project.reload.identifier).to eq "FOOBAR"
|
||||
end
|
||||
|
||||
it "displays an error when the identifier does not start with a letter" do
|
||||
visit project_settings_general_path(project)
|
||||
|
||||
click_on "Change identifier"
|
||||
|
||||
expect(page).to have_dialog "Change project identifier"
|
||||
|
||||
within "dialog" do
|
||||
fill_in "project[identifier]", with: "123ABC"
|
||||
click_on "Change identifier"
|
||||
|
||||
expect(page).to have_dialog "Change project identifier"
|
||||
|
||||
within "dialog" do
|
||||
expect(page).to have_text "This will permanently change identifiers and URLs"
|
||||
fill_in "project[identifier]", with: "foo-bar"
|
||||
click_on "Change identifier"
|
||||
end
|
||||
|
||||
expect(page).to have_content "Successful update."
|
||||
expect(page).to have_current_path %r{/projects/foo-bar/settings/general}
|
||||
expect(project.reload.identifier).to eq "foo-bar"
|
||||
expect(page).to have_text "The first character has to be a letter."
|
||||
end
|
||||
end
|
||||
|
||||
context "with alphanumeric IDs", with_settings: { work_packages_identifier: "alphanumeric" } do
|
||||
it "updates the project identifier via dialog" do
|
||||
visit project_settings_general_path(project)
|
||||
it "displays an error when the identifier contains special characters" do
|
||||
visit project_settings_general_path(project)
|
||||
|
||||
click_on "Change identifier"
|
||||
|
||||
expect(page).to have_dialog "Change project identifier"
|
||||
|
||||
within "dialog" do
|
||||
fill_in "project[identifier]", with: "FOO@BAR"
|
||||
click_on "Change identifier"
|
||||
|
||||
expect(page).to have_dialog "Change project identifier"
|
||||
|
||||
within "dialog" do
|
||||
expect(page).to have_text "This will permanently change identifiers and URLs"
|
||||
fill_in "project[identifier]", with: "FOOBAR"
|
||||
click_on "Change identifier"
|
||||
end
|
||||
|
||||
expect(page).to have_content "Successful update."
|
||||
expect(page).to have_current_path %r{/projects/FOOBAR/settings/general}
|
||||
expect(project.reload.identifier).to eq "FOOBAR"
|
||||
end
|
||||
|
||||
it "displays an error when the identifier does not start with a letter" do
|
||||
visit project_settings_general_path(project)
|
||||
|
||||
click_on "Change identifier"
|
||||
|
||||
expect(page).to have_dialog "Change project identifier"
|
||||
|
||||
within "dialog" do
|
||||
fill_in "project[identifier]", with: "123ABC"
|
||||
click_on "Change identifier"
|
||||
|
||||
expect(page).to have_text "The first character has to be a letter."
|
||||
end
|
||||
end
|
||||
|
||||
it "displays an error when the identifier contains special characters" do
|
||||
visit project_settings_general_path(project)
|
||||
|
||||
click_on "Change identifier"
|
||||
|
||||
expect(page).to have_dialog "Change project identifier"
|
||||
|
||||
within "dialog" do
|
||||
fill_in "project[identifier]", with: "FOO@BAR"
|
||||
click_on "Change identifier"
|
||||
|
||||
expect(page).to have_text "Special characters not allowed."
|
||||
end
|
||||
expect(page).to have_text "Special characters not allowed."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -52,6 +52,15 @@ RSpec.describe WorkPackages::IdentifierAutofix::PreviewQuery do
|
||||
end
|
||||
end
|
||||
|
||||
context "when a project has underscores in its identifier" do
|
||||
before { create_valid_project(name: "My Project", identifier: "my_proj") }
|
||||
|
||||
it "does not flag it as problematic" do
|
||||
expect(result.total_count).to eq(0)
|
||||
expect(result.projects_data).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context "when there are fewer than DISPLAY_COUNT problematic projects" do
|
||||
let!(:problematic) do
|
||||
[
|
||||
@@ -90,15 +99,32 @@ RSpec.describe WorkPackages::IdentifierAutofix::PreviewQuery do
|
||||
let!(:second_project) { create_problematic_project(name: "Foxtrot Papa", identifier: "foxtrot-papa") }
|
||||
|
||||
it "does not assign the same handle to both" do
|
||||
handles = result.projects_data.pluck(:suggested_handle)
|
||||
expect(handles.uniq.size).to eq(handles.size)
|
||||
identifiers = result.projects_data.pluck(:suggested_identifier)
|
||||
expect(identifiers.uniq.size).to eq(identifiers.size)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns Result entries shaped like generator output" do
|
||||
it "returns Result entries with project, current_identifier, suggested_identifier, and error_reason" do
|
||||
create_problematic_project(name: "Alpha Beta", identifier: "alpha-beta")
|
||||
|
||||
entry = result.projects_data.first
|
||||
expect(entry).to include(:project, :current_identifier, :suggested_handle, :error_reason)
|
||||
expect(entry).to include(:project, :current_identifier, :suggested_identifier, :error_reason)
|
||||
end
|
||||
|
||||
describe "error_reason classification" do
|
||||
it "assigns :too_long when identifier length exceeds MAX_IDENTIFIER_LENGTH" do
|
||||
create_problematic_project(name: "Test", identifier: "averylongidentifier")
|
||||
expect(result.projects_data.first[:error_reason]).to eq(:too_long)
|
||||
end
|
||||
|
||||
it "assigns :special_characters when identifier has non-alphanumeric chars but is short" do
|
||||
create_problematic_project(name: "Test", identifier: "ab-c")
|
||||
expect(result.projects_data.first[:error_reason]).to eq(:special_characters)
|
||||
end
|
||||
|
||||
it "assigns :too_long (priority) when identifier is both too long and has special chars" do
|
||||
create_problematic_project(name: "Test", identifier: "my-very-long-identifier")
|
||||
expect(result.projects_data.first[:error_reason]).to eq(:too_long)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-194
@@ -1,194 +0,0 @@
|
||||
# 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 WorkPackages::IdentifierAutofix::ProjectHandleSuggestionGenerator do
|
||||
describe ".call" do
|
||||
context "when given an empty array" do
|
||||
it "returns an empty array" do
|
||||
expect(described_class.call([])).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context "when a project has a too-long identifier" do
|
||||
shared_let(:project) { create(:project, identifier: "verylongidentifier", name: "Very Long Identifier") }
|
||||
|
||||
it "returns one suggestion entry for the project" do
|
||||
result = described_class.call([project])
|
||||
expect(result.size).to eq(1)
|
||||
expect(result.first[:project]).to eq(project)
|
||||
expect(result.first[:current_identifier]).to eq("verylongidentifier")
|
||||
expect(result.first[:error_reason]).to eq(:too_long)
|
||||
expect(result.first[:suggested_handle]).to be_present
|
||||
expect(result.first[:suggested_handle].length).to be <= described_class::HANDLE_MAX_LENGTH
|
||||
end
|
||||
end
|
||||
|
||||
context "when a project has a special-character identifier" do
|
||||
# "fs" is 2 chars (≤ HANDLE_MAX_LENGTH) but contains no special chars;
|
||||
# use "f-s" (3 chars ≤ HANDLE_MAX_LENGTH) to trigger :special_characters.
|
||||
shared_let(:project) { create(:project, identifier: "f-s", name: "Fly Sky") }
|
||||
|
||||
it "returns a suggestion entry with error_reason :special_characters" do
|
||||
result = described_class.call([project])
|
||||
expect(result.size).to eq(1)
|
||||
expect(result.first[:error_reason]).to eq(:special_characters)
|
||||
end
|
||||
end
|
||||
|
||||
context "when multiple projects generate conflicting handles" do
|
||||
shared_let(:project_sc1) { create(:project, identifier: "sc-app", name: "Stream Communicator") }
|
||||
shared_let(:project_sc2) { create(:project, identifier: "stream-channel", name: "Stream Channel") }
|
||||
|
||||
it "generates unique handles for each project" do
|
||||
handles = described_class.call([project_sc1, project_sc2]).pluck(:suggested_handle)
|
||||
expect(handles.uniq.size).to eq(handles.size)
|
||||
end
|
||||
|
||||
it "appends a numeric suffix to resolve conflicts" do
|
||||
handles = described_class.call([project_sc1, project_sc2]).pluck(:suggested_handle)
|
||||
expect(handles).to include("SC")
|
||||
expect(handles.any? { it.match?(/\ASC\d+\z/) }).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".suggest_for_name" do
|
||||
it "returns an acronym derived from a multi-word name" do
|
||||
expect(described_class.suggest_for_name("Flight Planning Algorithm")).to eq("FPA")
|
||||
end
|
||||
|
||||
it "returns an uppercase prefix for a single-word name" do
|
||||
expect(described_class.suggest_for_name("Banana")).to eq("BAN")
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle generation from project name" do
|
||||
{
|
||||
# Single-word names: first SINGLE_WORD_LENGTH (3) transliterated chars
|
||||
"Banana" => "BAN",
|
||||
"Kiwi" => "KIW",
|
||||
"Strawberry" => "STR",
|
||||
"Cécile" => "CEC", # single word with accented letter
|
||||
# Multi-word names: initials, truncated to HANDLE_MAX_LENGTH (5)
|
||||
"Flight Planning Algorithm" => "FPA",
|
||||
"Fly & Sky" => "FS",
|
||||
"Social media marketing" => "SMM",
|
||||
"Arcanos (mobile-web-app)" => "AMWA",
|
||||
"Flight Planning Training" => "FPT",
|
||||
"A B C D E F G H I J K" => "ABCDE", # truncated to HANDLE_MAX_LENGTH (5)
|
||||
"Cécile Martin" => "CM", # Unicode: "Cécile" is one word, not ["C","cile"]
|
||||
"étude de cas" => "EDC", # Unicode: é→E via transliteration
|
||||
# Non-Latin scripts have no transliteration entries (I18n.transliterate → "?").
|
||||
# All initials are dropped and the name falls back to FALLBACK_HANDLE.
|
||||
"日本語プロジェクト" => "PROJ", # Japanese: every initial → "?" → fallback
|
||||
"Plan 日本" => "P" # Mixed: Latin "P" survives; "日" is dropped
|
||||
}.each do |project_name, expected_handle|
|
||||
it "generates '#{expected_handle}' from '#{project_name}'" do
|
||||
project = create(:project, identifier: "bad-id", name: project_name)
|
||||
expect(described_class.call([project]).first[:suggested_handle]).to eq(expected_handle)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "unique_handle conflict resolution" do
|
||||
it "uses the base handle when not yet taken" do
|
||||
project = create(:project, identifier: "sc-app", name: "Stream Communicator")
|
||||
expect(described_class.call([project]).first[:suggested_handle]).to eq("SC")
|
||||
end
|
||||
|
||||
it "increments the suffix until unique" do
|
||||
p1 = create(:project, identifier: "sc-a", name: "Stream Communicator")
|
||||
p2 = create(:project, identifier: "sc-b", name: "Stream Channel")
|
||||
p3 = create(:project, identifier: "sc-c", name: "Something Cool")
|
||||
expect(described_class.call([p1, p2, p3]).pluck(:suggested_handle)).to contain_exactly("SC", "SC2", "SC3")
|
||||
end
|
||||
|
||||
it "trims the base to fit within HANDLE_MAX_LENGTH when adding a suffix" do
|
||||
p1 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J")
|
||||
p2 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J")
|
||||
handles = described_class.call([p1, p2]).pluck(:suggested_handle)
|
||||
expect(handles.all? { it.length <= described_class::HANDLE_MAX_LENGTH }).to be true
|
||||
expect(handles.uniq.size).to eq(2)
|
||||
end
|
||||
|
||||
it "does not suggest a handle that is already in use (pre-seeded collision)" do
|
||||
# "SC" is pre-seeded as an in-use handle; the generator must skip it and use "SC2".
|
||||
project = create(:project, identifier: "sc-app", name: "Stream Communicator")
|
||||
result = described_class.call([project], in_use_handles: Set["SC"])
|
||||
expect(result.first[:suggested_handle]).not_to eq("SC")
|
||||
expect(result.first[:suggested_handle]).to match(/\ASC\d+\z/) # e.g. "SC2"
|
||||
end
|
||||
end
|
||||
|
||||
describe "error reason assignment" do
|
||||
it "assigns :too_long when identifier length exceeds HANDLE_MAX_LENGTH" do
|
||||
project = create(:project, identifier: "verylongidentifier", name: "Test")
|
||||
expect(described_class.call([project]).first[:error_reason]).to eq(:too_long)
|
||||
end
|
||||
|
||||
it "assigns :special_characters when identifier has non-alphanumeric chars but is short" do
|
||||
project = create(:project, identifier: "ab-c", name: "Test")
|
||||
expect(described_class.call([project]).first[:error_reason]).to eq(:special_characters)
|
||||
end
|
||||
|
||||
it "assigns :too_long (priority) when identifier is both too long and has special chars" do
|
||||
project = create(:project, identifier: "my-very-long-identifier", name: "Test")
|
||||
expect(described_class.call([project]).first[:error_reason]).to eq(:too_long)
|
||||
end
|
||||
|
||||
it "assigns :in_use when identifier is another project's active handle" do
|
||||
# "abc" is valid (lowercase alphanumeric, ≤ 5 chars, no special chars)
|
||||
project = create(:project, identifier: "abc", name: "Alpha Beta Corp")
|
||||
result = described_class.call([project], in_use_handles: Set["abc"])
|
||||
expect(result.first[:error_reason]).to eq(:in_use)
|
||||
end
|
||||
|
||||
it "assigns :reserved when identifier appears in historical handles" do
|
||||
project = create(:project, identifier: "abc", name: "Alpha Beta Corp")
|
||||
result = described_class.call([project], reserved_handles: Set["abc"])
|
||||
expect(result.first[:error_reason]).to eq(:reserved)
|
||||
end
|
||||
|
||||
it "prefers :in_use over :reserved when identifier is in both sets" do
|
||||
project = create(:project, identifier: "abc", name: "Alpha Beta Corp")
|
||||
result = described_class.call([project], in_use_handles: Set["abc"], reserved_handles: Set["abc"])
|
||||
expect(result.first[:error_reason]).to eq(:in_use)
|
||||
end
|
||||
|
||||
it "prefers :too_long over :in_use when identifier is also too long" do
|
||||
# "toolong" is 7 chars (> HANDLE_MAX_LENGTH=5) and alphanumeric — too_long wins
|
||||
project = create(:project, identifier: "toolong", name: "Too Long Handle")
|
||||
result = described_class.call([project], in_use_handles: Set["toolong"])
|
||||
expect(result.first[:error_reason]).to eq(:too_long)
|
||||
end
|
||||
end
|
||||
end
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# 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 WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator do
|
||||
describe ".call" do
|
||||
context "when given an empty array" do
|
||||
it "returns an empty array" do
|
||||
expect(described_class.call([])).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context "when a project has a too-long identifier" do
|
||||
shared_let(:project) { create(:project, identifier: "verylongidentifier", name: "Very Long Identifier") }
|
||||
|
||||
it "returns one suggestion entry for the project" do
|
||||
result = described_class.call([project])
|
||||
expect(result.size).to eq(1)
|
||||
expect(result.first[:project]).to eq(project)
|
||||
expect(result.first[:current_identifier]).to eq("verylongidentifier")
|
||||
expect(result.first[:suggested_identifier]).to be_present
|
||||
expect(result.first[:suggested_identifier].length).to be <= described_class::IDENTIFIER_LENGTH[:max]
|
||||
end
|
||||
end
|
||||
|
||||
context "when a project has a special-character identifier" do
|
||||
shared_let(:project) { create(:project, identifier: "f-s", name: "Fly Sky") }
|
||||
|
||||
it "returns a suggestion entry with a suggested_identifier" do
|
||||
result = described_class.call([project])
|
||||
expect(result.size).to eq(1)
|
||||
expect(result.first[:suggested_identifier]).to eq("FS")
|
||||
end
|
||||
end
|
||||
|
||||
context "when multiple projects generate conflicting identifiers" do
|
||||
shared_let(:project_sc1) { create(:project, identifier: "sc-app", name: "Stream Communicator") }
|
||||
shared_let(:project_sc2) { create(:project, identifier: "stream-channel", name: "Stream Channel") }
|
||||
|
||||
it "generates unique identifiers for each project" do
|
||||
identifiers = described_class.call([project_sc1, project_sc2]).pluck(:suggested_identifier)
|
||||
expect(identifiers.uniq.size).to eq(identifiers.size)
|
||||
end
|
||||
|
||||
it "resolves conflicts by widening the acronym, not numeric suffixes" do
|
||||
identifiers = described_class.call([project_sc1, project_sc2]).pluck(:suggested_identifier)
|
||||
expect(identifiers).to include("SC")
|
||||
# Second project expands to "STC" (Stream → ST, Channel → C) instead of "SC2"
|
||||
expect(identifiers).to include("STC")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "identifier generation from project name" do
|
||||
{
|
||||
# Single-word names: first IDENTIFIER_LENGTH[:single_word] (3) transliterated chars
|
||||
"Banana" => "BAN",
|
||||
"Kiwi" => "KIW",
|
||||
"Strawberry" => "STR",
|
||||
"Cécile" => "CEC",
|
||||
# Multi-word names: initials (truncated to IDENTIFIER_LENGTH[:base] = 5)
|
||||
"Flight Planning Algorithm" => "FPA",
|
||||
"Fly & Sky" => "FS",
|
||||
"Social media marketing" => "SMM",
|
||||
"Arcanos (mobile-web-app)" => "AMWA",
|
||||
"Flight Planning Training" => "FPT",
|
||||
"A B C D E F G H I J K" => "ABCDE",
|
||||
"Cécile Martin" => "CM",
|
||||
"étude de cas" => "EDC",
|
||||
# Non-Latin scripts: every initial → "?" → fallback
|
||||
"日本語プロジェクト" => "PROJ",
|
||||
# Mixed: only "Plan" survives transliteration → single-word → starts at 3 chars
|
||||
"Plan 日本" => "PLA"
|
||||
}.each do |project_name, expected_identifier|
|
||||
it "generates '#{expected_identifier}' from '#{project_name}'" do
|
||||
project = create(:project, identifier: "bad-id", name: project_name)
|
||||
expect(described_class.call([project]).first[:suggested_identifier]).to eq(expected_identifier)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "must start with a letter" do
|
||||
it "strips leading digits from generated identifiers" do
|
||||
project = create(:project, identifier: "bad-id", name: "3D Printing Lab")
|
||||
result = described_class.call([project]).first[:suggested_identifier]
|
||||
expect(result).to match(/\A[A-Z]/)
|
||||
end
|
||||
|
||||
it "falls back to PROJ for all-digit names" do
|
||||
project = create(:project, identifier: "bad-id", name: "123 456")
|
||||
result = described_class.call([project]).first[:suggested_identifier]
|
||||
expect(result).to eq("PROJ")
|
||||
end
|
||||
end
|
||||
|
||||
describe "minimum identifier length" do
|
||||
it "never generates identifiers shorter than MIN_IDENTIFIER_LENGTH" do
|
||||
# Single letter word — too short on its own
|
||||
project = create(:project, identifier: "bad-id", name: "A")
|
||||
result = described_class.call([project]).first[:suggested_identifier]
|
||||
expect(result.length).to be >= described_class::IDENTIFIER_LENGTH[:min]
|
||||
end
|
||||
end
|
||||
|
||||
describe "collision resolution by widening" do
|
||||
it "uses the base identifier when not yet taken" do
|
||||
project = create(:project, identifier: "sc-app", name: "Stream Communicator")
|
||||
expect(described_class.call([project]).first[:suggested_identifier]).to eq("SC")
|
||||
end
|
||||
|
||||
it "widens the acronym instead of appending numeric suffixes" do
|
||||
p1 = create(:project, identifier: "sc-a", name: "Stream Communicator")
|
||||
p2 = create(:project, identifier: "sc-b", name: "Stream Channel")
|
||||
p3 = create(:project, identifier: "sc-c", name: "Something Cool")
|
||||
identifiers = described_class.call([p1, p2, p3]).pluck(:suggested_identifier)
|
||||
expect(identifiers).to contain_exactly("SC", "STC", "SOC")
|
||||
end
|
||||
|
||||
it "expands single-word identifiers on collision" do
|
||||
p1 = create(:project, identifier: "bad-a", name: "Banana")
|
||||
p2 = create(:project, identifier: "bad-b", name: "Banking")
|
||||
identifiers = described_class.call([p1, p2]).pluck(:suggested_identifier)
|
||||
# Both start as "BAN"; second expands to "BANK"
|
||||
expect(identifiers).to contain_exactly("BAN", "BANK")
|
||||
end
|
||||
|
||||
it "keeps all identifiers within MAX_IDENTIFIER_LENGTH" do
|
||||
p1 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j", name: "A B C D E F G H I J")
|
||||
p2 = create(:project, identifier: "a-b-c-d-e-f-g-h-i-j-x", name: "A B C D E F G H I J")
|
||||
identifiers = described_class.call([p1, p2]).pluck(:suggested_identifier)
|
||||
expect(identifiers.all? { it.length <= described_class::IDENTIFIER_LENGTH[:max] }).to be true
|
||||
expect(identifiers.uniq.size).to eq(2)
|
||||
end
|
||||
|
||||
it "does not suggest an identifier that is already in use (pre-seeded collision)" do
|
||||
project = create(:project, identifier: "sc-app", name: "Stream Communicator")
|
||||
result = described_class.call([project], in_use_identifiers: Set["SC"])
|
||||
# "SC" is taken, so it widens to "STC" (Stream → ST, Communicator → C)
|
||||
expect(result.first[:suggested_identifier]).to eq("STC")
|
||||
end
|
||||
|
||||
it "falls back to numeric suffix only when all expansion candidates are exhausted" do
|
||||
# Reserve all expansion candidates for "Go" (a 2-char word)
|
||||
project = create(:project, identifier: "bad-id", name: "Go")
|
||||
result = described_class.call([project], in_use_identifiers: Set["GO"])
|
||||
# "GO" is taken, no further expansion possible, so numeric suffix
|
||||
expect(result.first[:suggested_identifier]).to eq("GO2")
|
||||
end
|
||||
|
||||
it "assigns identifiers in array order — first project claims the base" do
|
||||
p1 = create(:project, identifier: "bad-a", name: "Stream Communicator")
|
||||
p2 = create(:project, identifier: "bad-b", name: "Stream Channel")
|
||||
result = described_class.call([p1, p2])
|
||||
|
||||
# p1 is first in the array, so it claims "SC"; p2 gets the widened "STC"
|
||||
expect(result[0][:suggested_identifier]).to eq("SC")
|
||||
expect(result[1][:suggested_identifier]).to eq("STC")
|
||||
|
||||
# Reversed order: p2 now claims "SC"
|
||||
reversed = described_class.call([p2, p1])
|
||||
expect(reversed[0][:suggested_identifier]).to eq("SC")
|
||||
expect(reversed[1][:suggested_identifier]).to eq("STC")
|
||||
end
|
||||
end
|
||||
|
||||
describe ".suggest_identifier" do
|
||||
it "produces the same identifier as .call for the same name" do
|
||||
project = build_stubbed(:project, name: "Alpha Beta", identifier: "alpha-beta")
|
||||
batch_result = described_class.call([project]).first[:suggested_identifier]
|
||||
single_result = described_class.suggest_identifier("Alpha Beta")
|
||||
expect(single_result).to eq(batch_result)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".call result shape" do
|
||||
it "does not include error_reason (that is PreviewQuery's concern)" do
|
||||
project = create(:project, identifier: "ab-c", name: "Test")
|
||||
expect(described_class.call([project]).first).not_to have_key(:error_reason)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user