Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a71ac51
Create pending work package labels when the work package is saved
akabiru Oct 23, 2026
2b3fdc2
Accept new label titles in the work package API
akabiru Oct 23, 2026
556e163
Create labels on work package save instead of while typing
akabiru Oct 23, 2026
15601ea
Pin that a new label only exists once the work package is saved
akabiru Oct 23, 2026
df8a4ad
Resolve pending label names in one query
akabiru Oct 23, 2026
a34e4bc
Fail the work package create when a pending label cannot be created
akabiru Oct 23, 2026
c51f055
Check label creation permission once per work package validation
akabiru Oct 23, 2026
eef0a3d
Treat labels with the same name as one selection in the autocompleter
akabiru Oct 23, 2026
cdb2323
Send pending labels even when the reference has no labels link
akabiru Oct 23, 2026
eb8455d
Merge branch 'dev' into implementation/comms-1071-create-labels-on-wo…
akabiru Oct 27, 2026
bee8eb1
Validate pending label names through the model and cap them
akabiru Oct 27, 2026
b87d852
Compare labels by name only in the autocompleter
akabiru Oct 27, 2026
0e5bbce
Ignore work package labels links while labels are disabled
akabiru Oct 27, 2026
9a351f9
Create labels typed into a work package in its services
akabiru Oct 27, 2026
a78d740
Document limits on labels created through work package payloads
akabiru Oct 27, 2026
53aef17
Cover label creation permission on work package updates
akabiru Oct 27, 2026
d61e9c5
Keep label error options when reporting them on the work package
akabiru Oct 27, 2026
c9913d7
Name the work package label step after what it does
akabiru Oct 27, 2026
871a7aa
Show rejected new label names on every labels editing surface
akabiru Oct 27, 2026
4357e81
State the actual rule for creating labels in the write model docs
akabiru Oct 27, 2026
6f402eb
Name the contract's label name checks after what they check
akabiru Oct 27, 2026
af507ef
Name the labels a work package will assign after their purpose
akabiru Oct 27, 2026
4697181
Validate label name replacements in their own contract concern
akabiru Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions app/contracts/labels/validate_replacements.rb
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, see <https://www.gnu.org/licenses/>.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module Labels
module ValidateReplacements
extend ActiveSupport::Concern

included do
validate :validate_label_name_replacements
end

private

def validate_label_name_replacements
return if model.label_name_replacements.blank?
return unless writable?(:labels)

if model.label_name_replacements.size > Labelable::MAX_LABEL_NAMES_PER_SAVE
errors.add(:labels, :too_many, count: Labelable::MAX_LABEL_NAMES_PER_SAVE)
return
end

label_name_error_details.each { errors.add(:labels, it[:error], **it.except(:error)) }
end

def label_name_error_details
names_to_create = label_names_to_create
return [] if names_to_create.empty?

authorization = label_creation_authorization_errors(names_to_create.first)
(authorization + names_to_create.flat_map { label_name_errors(it) }).uniq
end

def label_names_to_create
existing_names = Label.named_any(model.label_name_replacements).pluck(:name).to_set(&:downcase)
model.label_name_replacements.reject { existing_names.include?(it.downcase) }
end

def label_creation_authorization_errors(name)
contract = Labels::CreateContract.new(Label.new(name:), user)
contract.validate
contract.errors.details[:base]
end

def label_name_errors(name)
label = Label.new(name:)
label.valid?
label.errors.details[:name]
end
end
end
1 change: 1 addition & 0 deletions app/contracts/work_packages/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
module WorkPackages
class BaseContract < ::ModelContract
include ::Attachments::ValidateReplacements
include ::Labels::ValidateReplacements
include AssignableValuesContract
include WorkPackages::SetAttributesService::ProgressValuesCalculations

Expand Down
47 changes: 40 additions & 7 deletions app/models/concerns/labelable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,56 +30,89 @@
module Labelable
extend ActiveSupport::Concern

MAX_LABEL_NAMES_PER_SAVE = 50

included do
has_many :labelings, as: :labelable, dependent: :delete_all
has_many :labels, -> { order(:id) }, through: :labelings

scope :labeled_with, ->(label) { joins(:labelings).where(labelings: { label_id: label }) }

before_save :abort_save_with_label_names
after_save :persist_label_associations

validate :validate_label_id_replacements
end

attr_reader :label_id_replacements
attr_reader :label_id_replacements, :label_name_replacements

def label_id_replacements=(new_labels)
@label_id_replacements = new_labels.nil? ? nil : normalize_label_ids(new_labels)
end

def override_labels? = !label_id_replacements.nil?
def label_name_replacements=(names)
@label_name_replacements = names.nil? ? nil : normalize_label_names(names)
end

def replace_label_names_with_ids(ids)
self.label_id_replacements = label_ids_to_assign | ids
self.label_name_replacements = nil
end

def override_labels? = !label_id_replacements.nil? || !label_name_replacements.nil?

def effective_labels
return labels unless override_labels?

Label.where(id: label_id_replacements).order(:id)
Label.where(id: label_ids_to_assign).order(:id).to_a + labels_for_names_to_assign
end

def label_changes
return {} unless override_labels?
return {} if label_id_replacements.sort == label_ids.sort
return {} if label_ids_to_assign.sort == label_ids.sort && label_names_to_assign.empty?

{ "labels" => [label_ids, label_id_replacements] }
{ "labels" => [label_ids, label_ids_to_assign + label_names_to_assign] }
end

private

def label_ids_to_assign = Array(label_id_replacements)

def label_names_to_assign = Array(label_name_replacements)

def normalize_label_ids(new_labels)
Array.wrap(new_labels)
.filter_map { |label| label.respond_to?(:id) ? label.id : label.to_s.presence&.to_i }
.uniq
end

def normalize_label_names(names)
Array.wrap(names).map { it.to_s.squish }.compact_blank.uniq(&:downcase)
end

def labels_for_names_to_assign
existing = Label.named_any(label_names_to_assign).index_by { it.name.downcase }

label_names_to_assign.map { |name| existing[name.downcase] || Label.new(name:) }
end

def validate_label_id_replacements
return unless override_labels?

missing = label_id_replacements - Label.where(id: label_id_replacements).pluck(:id)
missing = label_ids_to_assign - Label.where(id: label_ids_to_assign).pluck(:id)

errors.add(:labels, :does_not_exist) if missing.any?
end

def abort_save_with_label_names
return if label_names_to_assign.empty?

errors.add(:labels, :invalid)
throw :abort
end

def persist_label_associations
replace_labels(label_id_replacements) if override_labels?
replace_labels(label_ids_to_assign) if override_labels?

self.label_id_replacements = nil
end
Expand Down
7 changes: 7 additions & 0 deletions app/models/label.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ class Label < ApplicationRecord
where("LOWER(labels.name) = LOWER(?)", normalize_value_for(:name, name))
}

scope :named_any, ->(names) {
next none if names.blank?

where("LOWER(labels.name) IN (SELECT LOWER(n) FROM unnest(ARRAY[?]::text[]) AS n)",
names.map { normalize_value_for(:name, it) })
}

normalizes :name, with: -> { it.squish }

validates :name,
Expand Down
13 changes: 12 additions & 1 deletion app/services/work_packages/create_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

class WorkPackages::CreateService < BaseServices::BaseCallable
include ::WorkPackages::Shared::UpdateAncestors
include ::WorkPackages::Shared::FindOrCreateLabels
include ::Shared::ServiceContext
include Types::ApplyPatterns

Expand Down Expand Up @@ -59,7 +60,7 @@ def create(attributes, work_package)
# Set attributes service passed, meaning the contract is fulfilled.
# Avoid running validations again as we might be in a project copy scenario.
work_package.attachments = work_package.attachments_replacements if work_package.attachments_replacements
work_package.save(validate: false)
return save_failure(result, work_package) unless save_with_labels(work_package)

apply_patterns(work_package)

Expand All @@ -76,6 +77,16 @@ def create(attributes, work_package)
result
end

def save_with_labels(work_package)
find_or_create_labels(work_package).success? && work_package.save(validate: false)
end

def save_failure(result, work_package)
result.success = false
result.errors = work_package.errors
result
end

def set_attributes(attributes, work_package)
attributes_service_class.new(user:, model: work_package, contract_class:, contract_options:).call(attributes)
end
Expand Down
3 changes: 3 additions & 0 deletions app/services/work_packages/set_attributes_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,11 @@ def set_versions_attributes(attributes)

def set_labels_attributes(attributes)
label_ids = attributes.delete(:label_ids)
label_names = attributes.delete(:label_names)

model.label_id_replacements = label_ids if label_ids

model.label_name_replacements = label_names if label_names
end

def set_static_attributes(attributes)
Expand Down
71 changes: 71 additions & 0 deletions app/services/work_packages/shared/find_or_create_labels.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 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, see <https://www.gnu.org/licenses/>.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module WorkPackages
module Shared
module FindOrCreateLabels
def find_or_create_labels(work_package)
return ServiceResult.success(result: work_package) if work_package.label_name_replacements.blank?

call = find_or_create_label_ids(work_package.label_name_replacements)

if call.success?
work_package.replace_label_names_with_ids(call.result)
ServiceResult.success(result: work_package)
else
add_label_error(work_package, call.errors)
ServiceResult.failure(result: work_package, errors: work_package.errors)
end
end

private

def add_label_error(work_package, label_errors)
detail = label_errors.details[:name].first || { error: :invalid }
work_package.errors.add(:labels, detail[:error], **detail.except(:error))
end

def find_or_create_label_ids(names)
service = Labels::FindOrCreateService.new(user:)
ids = []

# A stable order keeps concurrent saves with overlapping new names from
# deadlocking on the unique name index.
names.sort_by(&:downcase).each do |name|
call = service.call(name:)
return call if call.failure?

ids << call.result.id
end

ServiceResult.success(result: ids)
end
end
end
end
13 changes: 13 additions & 0 deletions app/services/work_packages/update_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

class WorkPackages::UpdateService < BaseServices::Update
include ::WorkPackages::Shared::UpdateAncestors
include ::WorkPackages::Shared::FindOrCreateLabels
include Attachments::ReplaceAttachments
include Types::ApplyPatterns

Expand All @@ -41,6 +42,18 @@ def initialize(user:, model:, contract_class: nil, contract_options: {}, cause_o

private

def persist(call)
labels_call = find_or_create_labels(call.result)

if labels_call.failure?
call.success = false
call.errors = labels_call.errors
return call
end

super
end

def after_perform(service_call)
# TODO: code smell here: saving the automatically generated subject depends
# on running the UpdateAncestorsService right after. The subject gets saved
Expand Down
2 changes: 2 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,8 @@ en:
identifier:
must_not_be_changed: "must not be changed manually. Work package identifiers are allocated automatically and resolved through the alias table, so changing them by hand breaks links and identifier history. If this change is deliberate, save with `save(context: :%{context})` and keep the work_package_semantic_aliases table consistent."
semantic_identifier_incomplete: "and sequence_number must both be set at the same time."
labels:
too_many: "can't include more than %{count} new labels."
observed_in_versions:
error_readonly: "cannot be set because you are not allowed to assign versions in this project."
parent:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ properties:
description: |-
List of labels attached to the work package

An entry whose `href` is `null` and whose `title` is a non-blank name creates that label when the work package
is saved, or reuses an existing label with the same name (case-insensitive). The form endpoint echoes such
entries back without creating anything. Creating a label requires the user to be allowed to edit work
packages in at least one project, as for `POST /api/v3/labels`. Names are limited to 255 characters, and a
single request may contain at most 50 entries without an `href`. A `title` is ignored on entries that have
an `href`.

**Resource**: Collection of Label
parent:
allOf:
Expand Down
Loading
Loading