From c0cfd125f571d471a053fd6e4400486f8b607c1f Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 14 Sep 2026 18:27:18 +0300 Subject: [PATCH 1/8] Add Label model and polymorphic labelings join table Labels are a global, case-insensitively unique resource attached to work packages through a polymorphic join so other entities can be labeled later. Data model only; API, journaling and admin follow. https://community.openproject.org/wp/79593 --- app/models/concerns/labelable.rb | 38 +++++++++ app/models/label.rb | 39 +++++++++ app/models/labeling.rb | 36 ++++++++ app/models/work_package.rb | 1 + db/migrate/20260914120000_create_labels.rb | 49 +++++++++++ spec/factories/label_factory.rb | 35 ++++++++ spec/factories/labeling_factory.rb | 36 ++++++++ spec/models/label_spec.rb | 82 +++++++++++++++++++ spec/models/labeling_spec.rb | 52 ++++++++++++ .../work_package/work_package_labels_spec.rb | 62 ++++++++++++++ 10 files changed, 430 insertions(+) create mode 100644 app/models/concerns/labelable.rb create mode 100644 app/models/label.rb create mode 100644 app/models/labeling.rb create mode 100644 db/migrate/20260914120000_create_labels.rb create mode 100644 spec/factories/label_factory.rb create mode 100644 spec/factories/labeling_factory.rb create mode 100644 spec/models/label_spec.rb create mode 100644 spec/models/labeling_spec.rb create mode 100644 spec/models/work_package/work_package_labels_spec.rb diff --git a/app/models/concerns/labelable.rb b/app/models/concerns/labelable.rb new file mode 100644 index 000000000000..299596623c92 --- /dev/null +++ b/app/models/concerns/labelable.rb @@ -0,0 +1,38 @@ +# 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 Labelable + extend ActiveSupport::Concern + + included do + has_many :labelings, as: :labelable, dependent: :delete_all + has_many :labels, -> { order(:id) }, through: :labelings + end +end diff --git a/app/models/label.rb b/app/models/label.rb new file mode 100644 index 000000000000..984972d245d4 --- /dev/null +++ b/app/models/label.rb @@ -0,0 +1,39 @@ +# 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. +#++ + +class Label < ApplicationRecord + has_many :labelings, dependent: :delete_all + has_many :work_packages, through: :labelings, source: :labelable, source_type: "WorkPackage" + + validates :name, + presence: true, + uniqueness: { case_sensitive: false }, + length: { maximum: 255 } +end diff --git a/app/models/labeling.rb b/app/models/labeling.rb new file mode 100644 index 000000000000..aded799ea5ef --- /dev/null +++ b/app/models/labeling.rb @@ -0,0 +1,36 @@ +# 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. +#++ + +class Labeling < ApplicationRecord + belongs_to :label + belongs_to :labelable, polymorphic: true + + validates :label_id, uniqueness: { scope: %i[labelable_type labelable_id] } +end diff --git a/app/models/work_package.rb b/app/models/work_package.rb index fe89e473c45e..4d74bf8aef27 100644 --- a/app/models/work_package.rb +++ b/app/models/work_package.rb @@ -41,6 +41,7 @@ class WorkPackage < ApplicationRecord # Must stay above WorkPackage::Journalized: its after_save persists the # version rows that the journal snapshot then reads. include WorkPackage::Versions + include Labelable include WorkPackages::DerivedDates include WorkPackages::SpentTime include WorkPackages::Costs diff --git a/db/migrate/20260914120000_create_labels.rb b/db/migrate/20260914120000_create_labels.rb new file mode 100644 index 000000000000..9da9c799b46c --- /dev/null +++ b/db/migrate/20260914120000_create_labels.rb @@ -0,0 +1,49 @@ +# 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. +#++ + +class CreateLabels < ActiveRecord::Migration[8.1] + def change + create_table :labels do |t| + t.string :name, null: false + t.timestamps + end + add_index :labels, "LOWER(name)", unique: true, name: "index_labels_on_LOWER_name" + + create_table :labelings do |t| + t.references :label, null: false, foreign_key: { on_delete: :cascade }, index: false + t.references :labelable, polymorphic: true, null: false, index: false + t.timestamps + end + add_index :labelings, %i[labelable_type labelable_id label_id], + unique: true, + name: "index_labelings_on_labelable_and_label" + add_index :labelings, :label_id + end +end diff --git a/spec/factories/label_factory.rb b/spec/factories/label_factory.rb new file mode 100644 index 000000000000..e84ef7888ad3 --- /dev/null +++ b/spec/factories/label_factory.rb @@ -0,0 +1,35 @@ +# 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. +#++ + +FactoryBot.define do + factory :label do + sequence(:name) { "Label #{it}" } + end +end diff --git a/spec/factories/labeling_factory.rb b/spec/factories/labeling_factory.rb new file mode 100644 index 000000000000..7cb8fe4a2944 --- /dev/null +++ b/spec/factories/labeling_factory.rb @@ -0,0 +1,36 @@ +# 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. +#++ + +FactoryBot.define do + factory :labeling do + label + labelable factory: :work_package + end +end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb new file mode 100644 index 000000000000..408090256e37 --- /dev/null +++ b/spec/models/label_spec.rb @@ -0,0 +1,82 @@ +# 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 "spec_helper" + +RSpec.describe Label do + describe "validations" do + it "is valid with a name" do + expect(build(:label, name: "urgent")).to be_valid + end + + it "requires a name" do + expect(build(:label, name: "")).not_to be_valid + end + + it "caps the name at 255 characters" do + expect(build(:label, name: "a" * 256)).not_to be_valid + end + + it "rejects a name that only differs in case from an existing label" do + create(:label, name: "hello") + + expect(build(:label, name: "Hello")).not_to be_valid + end + + it "is backed by a case-insensitive unique index" do + create(:label, name: "hello") + + expect { described_class.insert_all!([{ name: "HELLO", created_at: Time.current, updated_at: Time.current }]) } + .to raise_error(ActiveRecord::RecordNotUnique) + end + end + + describe "#destroy" do + it "removes its labelings" do + label = create(:label) + labeling = create(:labeling, label:) + + label.destroy! + + expect(Labeling.where(id: labeling.id)).not_to exist + end + end + + describe "#work_packages" do + it "returns the labeled work packages" do + label = create(:label) + labeled = create_list(:work_package, 2) + labeled.each { create(:labeling, label:, labelable: it) } + create(:work_package) + + expect(label.work_packages).to match_array(labeled) + end + end +end diff --git a/spec/models/labeling_spec.rb b/spec/models/labeling_spec.rb new file mode 100644 index 000000000000..6bc845cad17e --- /dev/null +++ b/spec/models/labeling_spec.rb @@ -0,0 +1,52 @@ +# 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 "spec_helper" + +RSpec.describe Labeling do + let(:label) { create(:label) } + let(:work_package) { create(:work_package) } + + it "is valid with a label and a labelable" do + expect(build(:labeling, label:, labelable: work_package)).to be_valid + end + + it "rejects the same label twice on one labelable" do + create(:labeling, label:, labelable: work_package) + + expect(build(:labeling, label:, labelable: work_package)).not_to be_valid + end + + it "allows the same label on different labelables" do + create(:labeling, label:, labelable: work_package) + + expect(build(:labeling, label:, labelable: create(:work_package))).to be_valid + end +end diff --git a/spec/models/work_package/work_package_labels_spec.rb b/spec/models/work_package/work_package_labels_spec.rb new file mode 100644 index 000000000000..f401e9a560b9 --- /dev/null +++ b/spec/models/work_package/work_package_labels_spec.rb @@ -0,0 +1,62 @@ +# 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 "spec_helper" + +RSpec.describe WorkPackage, "labels" do + let(:work_package) { create(:work_package) } + let!(:lower_label) { create(:label, name: "apple") } + let!(:higher_label) { create(:label, name: "zebra") } + + before do + work_package.labels << higher_label + work_package.labels << lower_label + end + + it "returns labels in id order, also when preloaded" do + preloaded = described_class.where(id: work_package.id).includes(:labels).first + + expect(work_package.reload.labels).to eq([lower_label, higher_label]) + expect(preloaded.labels).to eq([lower_label, higher_label]) + end + + it "deletes its labelings but keeps the labels when destroyed" do + work_package.destroy! + + expect(Labeling.where(labelable: work_package)).not_to exist + expect(Label.where(id: [lower_label.id, higher_label.id]).count).to eq(2) + end + + it "drops a deleted label from its labels" do + lower_label.destroy! + + expect(work_package.reload.labels).to eq([higher_label]) + end +end From a8a276e6ad606d38bf1e59153115c2d94f9d1e95 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 14 Sep 2026 21:49:45 +0300 Subject: [PATCH 2/8] Keep BCF issue attributes out of the work package service The BCF topic services forwarded every param to the work package service and relied on it ignoring keys without a setter. Work packages now have a labels association, so the BCF labels array of strings reached the association writer instead of failing the issue contract. --- modules/bim/app/services/bim/bcf/issues/create_service.rb | 4 ++-- modules/bim/app/services/bim/bcf/issues/update_service.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/bim/app/services/bim/bcf/issues/create_service.rb b/modules/bim/app/services/bim/bcf/issues/create_service.rb index 01f99ed8aafb..a13184a290b6 100644 --- a/modules/bim/app/services/bim/bcf/issues/create_service.rb +++ b/modules/bim/app/services/bim/bcf/issues/create_service.rb @@ -70,13 +70,13 @@ def use_work_package(links:, params:) ::WorkPackages::UpdateService .new(user:, model: work_package) - .call(**params) + .call(**params.except(*Bim::Bcf::Issue::SETTABLE_ATTRIBUTES)) end def create_work_package(params) ::WorkPackages::CreateService .new(user:) - .call(**params) + .call(**params.except(*Bim::Bcf::Issue::SETTABLE_ATTRIBUTES)) end def work_package_identifier_from_links(links) diff --git a/modules/bim/app/services/bim/bcf/issues/update_service.rb b/modules/bim/app/services/bim/bcf/issues/update_service.rb index 2a095becd4fa..f34f161b98cd 100644 --- a/modules/bim/app/services/bim/bcf/issues/update_service.rb +++ b/modules/bim/app/services/bim/bcf/issues/update_service.rb @@ -36,7 +36,7 @@ def before_perform(service_result) .new(model: model.work_package, user:, contract_class: ::WorkPackages::UpdateContract) - .call(**params) + .call(**params.except(*Bim::Bcf::Issue::SETTABLE_ATTRIBUTES)) if wp_call.success? self.params = params.slice(*Bim::Bcf::Issue::SETTABLE_ATTRIBUTES) From a91fb5bbceb1ec5760639bcf25779be443c68fa5 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 15 Sep 2026 17:05:06 +0300 Subject: [PATCH 3/8] Order include appropriately --- app/models/work_package.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/work_package.rb b/app/models/work_package.rb index 4d74bf8aef27..6dccbcfc3281 100644 --- a/app/models/work_package.rb +++ b/app/models/work_package.rb @@ -41,7 +41,6 @@ class WorkPackage < ApplicationRecord # Must stay above WorkPackage::Journalized: its after_save persists the # version rows that the journal snapshot then reads. include WorkPackage::Versions - include Labelable include WorkPackages::DerivedDates include WorkPackages::SpentTime include WorkPackages::Costs @@ -49,6 +48,7 @@ class WorkPackage < ApplicationRecord include ::Scopes::Scoped include HasMembers include Remindable + include Labelable include OpenProject::Journal::AttachmentHelper From 776868d6d7cf449a15c288a0c71572a19cd86f30 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 15 Sep 2026 17:21:43 +0300 Subject: [PATCH 4/8] Express Label validations with shoulda-matchers Collapses the hand-rolled presence, length and case-insensitive uniqueness examples into one-liners, matching sibling model specs. Co-Authored-By: Claude Fable 5.1 --- spec/models/label_spec.rb | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 408090256e37..569d1faf584a 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -32,23 +32,11 @@ RSpec.describe Label do describe "validations" do - it "is valid with a name" do - expect(build(:label, name: "urgent")).to be_valid - end - - it "requires a name" do - expect(build(:label, name: "")).not_to be_valid - end - - it "caps the name at 255 characters" do - expect(build(:label, name: "a" * 256)).not_to be_valid - end - - it "rejects a name that only differs in case from an existing label" do - create(:label, name: "hello") + subject { build(:label) } - expect(build(:label, name: "Hello")).not_to be_valid - end + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_length_of(:name).is_at_most(255) } + it { is_expected.to validate_uniqueness_of(:name).case_insensitive } it "is backed by a case-insensitive unique index" do create(:label, name: "hello") From c964687f217b6db610a62f41e307b0bfedc5332e Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 16 Sep 2026 10:33:46 +0300 Subject: [PATCH 5/8] Replace the WorkPackage-specific Label accessor with generic lookups Label stays ignorant of which models can be labeled. Labelable types get a labeled_with scope and Label.with_usage_count aggregates usage in SQL, so the admin list never loads labeled records to count them. Co-Authored-By: Claude Fable 5.1 --- app/models/concerns/labelable.rb | 2 ++ app/models/label.rb | 7 +++++- spec/models/label_spec.rb | 23 ++++++++++--------- .../work_package/work_package_labels_spec.rb | 11 +++++++++ 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/app/models/concerns/labelable.rb b/app/models/concerns/labelable.rb index 299596623c92..b0ca71fd92b7 100644 --- a/app/models/concerns/labelable.rb +++ b/app/models/concerns/labelable.rb @@ -34,5 +34,7 @@ module Labelable 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 }) } end end diff --git a/app/models/label.rb b/app/models/label.rb index 984972d245d4..8436b85842a8 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -30,7 +30,12 @@ class Label < ApplicationRecord has_many :labelings, dependent: :delete_all - has_many :work_packages, through: :labelings, source: :labelable, source_type: "WorkPackage" + + scope :with_usage_count, -> { + left_joins(:labelings) + .select("labels.*, COUNT(labelings.id) AS usage_count") + .group(:id) + } validates :name, presence: true, diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 569d1faf584a..6379fbd0f5cd 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -46,6 +46,18 @@ end end + describe ".with_usage_count" do + it "counts the labelings of each label, including unused ones" do + used = create(:label) + unused = create(:label) + create_list(:labeling, 2, label: used) + + counts = described_class.with_usage_count.index_by(&:id).transform_values(&:usage_count) + + expect(counts).to eq(used.id => 2, unused.id => 0) + end + end + describe "#destroy" do it "removes its labelings" do label = create(:label) @@ -56,15 +68,4 @@ expect(Labeling.where(id: labeling.id)).not_to exist end end - - describe "#work_packages" do - it "returns the labeled work packages" do - label = create(:label) - labeled = create_list(:work_package, 2) - labeled.each { create(:labeling, label:, labelable: it) } - create(:work_package) - - expect(label.work_packages).to match_array(labeled) - end - end end diff --git a/spec/models/work_package/work_package_labels_spec.rb b/spec/models/work_package/work_package_labels_spec.rb index f401e9a560b9..fc9daa1fbaf3 100644 --- a/spec/models/work_package/work_package_labels_spec.rb +++ b/spec/models/work_package/work_package_labels_spec.rb @@ -59,4 +59,15 @@ expect(work_package.reload.labels).to eq([higher_label]) end + + describe ".labeled_with" do + it "returns only the work packages carrying the label" do + other = create(:work_package) + other.labels << lower_label + create(:work_package) + + expect(described_class.labeled_with(lower_label)).to contain_exactly(work_package, other) + expect(described_class.labeled_with(higher_label)).to contain_exactly(work_package) + end + end end From 06e1ca04fe1c151f91a58bdbfbfa4af3dd2785f6 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 16 Sep 2026 10:36:21 +0300 Subject: [PATCH 6/8] Record the author of a label Admin-created labels stay listed while unused, user-created ones do not, so a label needs to know who created it. Deleting a user hands their labels to the deleted-user placeholder like other authored records. Co-Authored-By: Claude Fable 5.1 --- app/models/label.rb | 1 + config/initializers/replace_references_service.rb | 1 + db/migrate/20260914120000_create_labels.rb | 1 + spec/factories/label_factory.rb | 1 + spec/models/label_spec.rb | 6 ++++-- .../replace_references_service_call_integration_spec.rb | 6 ++++++ 6 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/models/label.rb b/app/models/label.rb index 8436b85842a8..c6c2b8fb2809 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -29,6 +29,7 @@ #++ class Label < ApplicationRecord + belongs_to :author, class_name: "User" has_many :labelings, dependent: :delete_all scope :with_usage_count, -> { diff --git a/config/initializers/replace_references_service.rb b/config/initializers/replace_references_service.rb index e24b5aedc475..2331572249f5 100644 --- a/config/initializers/replace_references_service.rb +++ b/config/initializers/replace_references_service.rb @@ -40,6 +40,7 @@ "PersistedQuery" => :principal_id, "PersistedView" => :principal_id, "::Doorkeeper::Application" => :owner_id, + "Label" => :author_id, "Message" => :author_id, "News" => :author_id, "::Notification" => :actor_id, diff --git a/db/migrate/20260914120000_create_labels.rb b/db/migrate/20260914120000_create_labels.rb index 9da9c799b46c..1e995581eb4f 100644 --- a/db/migrate/20260914120000_create_labels.rb +++ b/db/migrate/20260914120000_create_labels.rb @@ -32,6 +32,7 @@ class CreateLabels < ActiveRecord::Migration[8.1] def change create_table :labels do |t| t.string :name, null: false + t.references :author, null: false, foreign_key: { to_table: :users } t.timestamps end add_index :labels, "LOWER(name)", unique: true, name: "index_labels_on_LOWER_name" diff --git a/spec/factories/label_factory.rb b/spec/factories/label_factory.rb index e84ef7888ad3..1877cb842e4e 100644 --- a/spec/factories/label_factory.rb +++ b/spec/factories/label_factory.rb @@ -31,5 +31,6 @@ FactoryBot.define do factory :label do sequence(:name) { "Label #{it}" } + author factory: :user end end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 6379fbd0f5cd..a9fbb5351fbf 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -37,11 +37,13 @@ it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_length_of(:name).is_at_most(255) } it { is_expected.to validate_uniqueness_of(:name).case_insensitive } + it { is_expected.to belong_to(:author).class_name("User") } it "is backed by a case-insensitive unique index" do - create(:label, name: "hello") + existing = create(:label, name: "hello") + duplicate = { name: "HELLO", author_id: existing.author_id, created_at: Time.current, updated_at: Time.current } - expect { described_class.insert_all!([{ name: "HELLO", created_at: Time.current, updated_at: Time.current }]) } + expect { described_class.insert_all!([duplicate]) } .to raise_error(ActiveRecord::RecordNotUnique) end end diff --git a/spec/services/principals/replace_references_service_call_integration_spec.rb b/spec/services/principals/replace_references_service_call_integration_spec.rb index 32365ef8da5f..05d212f1b8b4 100644 --- a/spec/services/principals/replace_references_service_call_integration_spec.rb +++ b/spec/services/principals/replace_references_service_call_integration_spec.rb @@ -120,6 +120,12 @@ end end + context "with Label" do + it_behaves_like "rewritten record", + :label, + :author_id + end + context "with CustomValue" do shared_let(:version) { create(:version) } From afbdbc76c01a182c50daa29b748de60ef41b27de Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 16 Sep 2026 11:20:34 +0300 Subject: [PATCH 7/8] Let labels be archived An archived label stays on its work packages and in the admin list but is no longer offered when labeling. The timestamp doubles as the archive date. Co-Authored-By: Claude Fable 5.1 --- app/models/label.rb | 6 ++++++ db/migrate/20260914120000_create_labels.rb | 1 + spec/factories/label_factory.rb | 4 ++++ spec/models/label_spec.rb | 12 ++++++++++++ 4 files changed, 23 insertions(+) diff --git a/app/models/label.rb b/app/models/label.rb index c6c2b8fb2809..1b2bcf309510 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -32,6 +32,8 @@ class Label < ApplicationRecord belongs_to :author, class_name: "User" has_many :labelings, dependent: :delete_all + scope :active, -> { where(archived_at: nil) } + scope :archived, -> { where.not(archived_at: nil) } scope :with_usage_count, -> { left_joins(:labelings) .select("labels.*, COUNT(labelings.id) AS usage_count") @@ -42,4 +44,8 @@ class Label < ApplicationRecord presence: true, uniqueness: { case_sensitive: false }, length: { maximum: 255 } + + def archived? + archived_at.present? + end end diff --git a/db/migrate/20260914120000_create_labels.rb b/db/migrate/20260914120000_create_labels.rb index 1e995581eb4f..5a580cac141d 100644 --- a/db/migrate/20260914120000_create_labels.rb +++ b/db/migrate/20260914120000_create_labels.rb @@ -33,6 +33,7 @@ def change create_table :labels do |t| t.string :name, null: false t.references :author, null: false, foreign_key: { to_table: :users } + t.datetime :archived_at t.timestamps end add_index :labels, "LOWER(name)", unique: true, name: "index_labels_on_LOWER_name" diff --git a/spec/factories/label_factory.rb b/spec/factories/label_factory.rb index 1877cb842e4e..af9c4389985e 100644 --- a/spec/factories/label_factory.rb +++ b/spec/factories/label_factory.rb @@ -32,5 +32,9 @@ factory :label do sequence(:name) { "Label #{it}" } author factory: :user + + trait :archived do + archived_at { Time.current } + end end end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index a9fbb5351fbf..cd3c4cd82f98 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -48,6 +48,18 @@ end end + describe "archiving" do + let!(:active_label) { create(:label) } + let!(:archived_label) { create(:label, :archived) } + + it "splits labels into active and archived by their archived_at" do + expect(described_class.active).to contain_exactly(active_label) + expect(described_class.archived).to contain_exactly(archived_label) + expect(active_label).not_to be_archived + expect(archived_label).to be_archived + end + end + describe ".with_usage_count" do it "counts the labelings of each label, including unused ones" do used = create(:label) From 0f953c29953726e5e4c26acb84edc25daccd794d Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 16 Sep 2026 12:27:01 +0300 Subject: [PATCH 8/8] Revert "Let labels be archived" This reverts commit afbdbc76c01a182c50daa29b748de60ef41b27de. Archiving is not part of the first labels iteration. --- app/models/label.rb | 6 ------ db/migrate/20260914120000_create_labels.rb | 1 - spec/factories/label_factory.rb | 4 ---- spec/models/label_spec.rb | 12 ------------ 4 files changed, 23 deletions(-) diff --git a/app/models/label.rb b/app/models/label.rb index 1b2bcf309510..c6c2b8fb2809 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -32,8 +32,6 @@ class Label < ApplicationRecord belongs_to :author, class_name: "User" has_many :labelings, dependent: :delete_all - scope :active, -> { where(archived_at: nil) } - scope :archived, -> { where.not(archived_at: nil) } scope :with_usage_count, -> { left_joins(:labelings) .select("labels.*, COUNT(labelings.id) AS usage_count") @@ -44,8 +42,4 @@ class Label < ApplicationRecord presence: true, uniqueness: { case_sensitive: false }, length: { maximum: 255 } - - def archived? - archived_at.present? - end end diff --git a/db/migrate/20260914120000_create_labels.rb b/db/migrate/20260914120000_create_labels.rb index 5a580cac141d..1e995581eb4f 100644 --- a/db/migrate/20260914120000_create_labels.rb +++ b/db/migrate/20260914120000_create_labels.rb @@ -33,7 +33,6 @@ def change create_table :labels do |t| t.string :name, null: false t.references :author, null: false, foreign_key: { to_table: :users } - t.datetime :archived_at t.timestamps end add_index :labels, "LOWER(name)", unique: true, name: "index_labels_on_LOWER_name" diff --git a/spec/factories/label_factory.rb b/spec/factories/label_factory.rb index af9c4389985e..1877cb842e4e 100644 --- a/spec/factories/label_factory.rb +++ b/spec/factories/label_factory.rb @@ -32,9 +32,5 @@ factory :label do sequence(:name) { "Label #{it}" } author factory: :user - - trait :archived do - archived_at { Time.current } - end end end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index cd3c4cd82f98..a9fbb5351fbf 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -48,18 +48,6 @@ end end - describe "archiving" do - let!(:active_label) { create(:label) } - let!(:archived_label) { create(:label, :archived) } - - it "splits labels into active and archived by their archived_at" do - expect(described_class.active).to contain_exactly(active_label) - expect(described_class.archived).to contain_exactly(archived_label) - expect(active_label).not_to be_archived - expect(archived_label).to be_archived - end - end - describe ".with_usage_count" do it "counts the labelings of each label, including unused ones" do used = create(:label)