From e20f90b82322364990658e40a357700e34995df1 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 18:40:21 +0300 Subject: [PATCH 1/8] Expose labels in the work package schema Labels are global and unbounded, so allowed values are linked to a project-scoped endpoint rather than embedded. The flag joins the schema cache dependencies so toggling it does not serve stale schemas. --- app/models/type/attribute_groups.rb | 1 + lib/api/v3/utilities/path_helper.rb | 4 ++ .../schema/work_package_schema_representer.rb | 15 +++++- .../work_package_schema_representer_spec.rb | 53 +++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/app/models/type/attribute_groups.rb b/app/models/type/attribute_groups.rb index fd40a30aaf4b..d588b6a86ad1 100644 --- a/app/models/type/attribute_groups.rb +++ b/app/models/type/attribute_groups.rb @@ -50,6 +50,7 @@ module Type::AttributeGroups remaining_time: :estimates_and_progress, percentage_done: :estimates_and_progress, priority: :details, + labels: :details, # `:excluded` is not a "real" group. It's meant to exclude built in fields from the form observed_in_versions: :excluded } diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 3614feeccb30..46552a9561f3 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -656,6 +656,10 @@ def self.views_type(type) index :label show :label + def self.labels_by_project(project_id) + "#{project(project_id)}/labels" + end + def self.versions_available_projects "#{versions}/available_projects" end diff --git a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb index 4861db10d6a7..adef6a79cfb6 100644 --- a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb +++ b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb @@ -42,7 +42,8 @@ class WorkPackageSchemaRepresenter < ::API::Decorators::SchemaRepresenter dependencies: -> { all_permissions_granted_to_user_under_project + [Setting.work_package_done_ratio, - Setting::WorkPackageMultipleVersions.active?] + Setting::WorkPackageMultipleVersions.active?, + OpenProject::FeatureDecisions.work_package_labels_active?] } custom_field_injector type: :schema_representer @@ -279,6 +280,12 @@ def initialize(schema, self_link:, **context) required: false, href_callback: ->(*) { assignee_user_autocompleter } + schema_with_allowed_link :labels, + type: "[]Label", + required: false, + show_if: ->(*) { OpenProject::FeatureDecisions.work_package_labels_active? }, + href_callback: ->(*) { labels_autocompleter } + schema_with_allowed_collection :type, value_representer: Types::TypeRepresenter, link_factory: ->(type) { @@ -460,6 +467,12 @@ def all_permissions_granted_to_user_under_project .sort end + def labels_autocompleter + project_id = represented.work_package&.project_id + + api_v3_paths.labels_by_project(project_id) if project_id + end + def assignee_user_autocompleter work_package = represented.work_package diff --git a/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb b/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb index 090a6e59e34b..355989b99e65 100644 --- a/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb @@ -1210,6 +1210,59 @@ end end + describe "labels" do + context "with the feature flag active", with_flag: :work_package_labels do + it_behaves_like "has basic schema properties" do + let(:path) { "labels" } + let(:type) { "[]Label" } + let(:name) { I18n.t("activerecord.attributes.work_package.labels") } + let(:required) { false } + let(:writable) { true } + let(:location) { "_links" } + end + + it_behaves_like "links to allowed values via collection link" do + let(:path) { "labels" } + let(:href) { api_v3_paths.labels_by_project(work_package.project_id) } + end + + context "when not embedded" do + let(:embedded) { false } + + it_behaves_like "does not link to allowed values" do + let(:path) { "labels" } + end + end + + context "when not having a project (yet)" do + before { work_package.project = nil } + + it_behaves_like "does not link to allowed values" do + let(:path) { "labels" } + end + end + + context "when lacking the edit_work_packages permission" do + let(:permissions) { [] } + + it_behaves_like "has basic schema properties" do + let(:path) { "labels" } + let(:type) { "[]Label" } + let(:name) { I18n.t("activerecord.attributes.work_package.labels") } + let(:required) { false } + let(:writable) { false } + let(:location) { "_links" } + end + end + end + + context "with the feature flag inactive" do + it "is not part of the schema" do + expect(generated).not_to have_json_path("labels") + end + end + end + describe "budget" do context "when user allowed to view_budgets" do let(:permissions) { %i[edit_work_packages view_budgets] } From 8812c495c68d27966ad6c0054c8f1d43ade1172d Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 20:03:34 +0300 Subject: [PATCH 2/8] Add labels listing by workspace ordered by relevance The work package labels dropdown needs labels already used in the current workspace first, then the most used ones, so the schema points at this endpoint instead of the global listing. --- app/models/label.rb | 19 ++- docs/api/apiv3/openapi-spec.yml | 2 + docs/api/apiv3/paths/workspace_labels.yml | 63 ++++++++++ lib/api/v3/labels/labels_by_workspace_api.rb | 54 +++++++++ lib/api/v3/utilities/path_helper.rb | 4 + .../schema/work_package_schema_representer.rb | 2 +- lib/api/v3/workspaces/nested_apis.rb | 1 + spec/lib/api/v3/utilities/path_helper_spec.rb | 12 ++ .../work_package_schema_representer_spec.rb | 2 +- spec/models/label_spec.rb | 31 +++++ .../api/v3/workspaces/label_resource_spec.rb | 113 ++++++++++++++++++ 11 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 docs/api/apiv3/paths/workspace_labels.yml create mode 100644 lib/api/v3/labels/labels_by_workspace_api.rb create mode 100644 spec/requests/api/v3/workspaces/label_resource_spec.rb diff --git a/app/models/label.rb b/app/models/label.rb index c2e59bff3ef9..b41d86ae23cc 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -32,8 +32,25 @@ class Label < ApplicationRecord belongs_to :author, class_name: "User" has_many :labelings, dependent: :delete_all + USAGE_COUNT_SQL = "(SELECT COUNT(*) FROM labelings WHERE labelings.label_id = labels.id)" + scope :with_usage_count, -> { - select("labels.*, (SELECT COUNT(*) FROM labelings WHERE labelings.label_id = labels.id) AS usage_count") + select("labels.*, #{USAGE_COUNT_SQL} AS usage_count") + } + + scope :ordered_by_relevance_for, ->(project) { + used_in_project = Labeling + .where(labelable_type: WorkPackage.name) + .where(Labeling.arel_table[:label_id].eq(arel_table[:id])) + .joins("INNER JOIN work_packages ON work_packages.id = labelings.labelable_id") + .where(work_packages: { project_id: project }) + .arel + .exists + + with_usage_count + .order(used_in_project.desc) + .order(Arel.sql("#{USAGE_COUNT_SQL} DESC")) + .order(arel_table[:name].lower.asc) } normalizes :name, with: -> { it.squish } diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index a6a0360174bd..0eb33dc8c64c 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -665,6 +665,8 @@ paths: "$ref": "./paths/workspace_work_packages_form.yml" "/api/v3/workspaces/{id}/versions": "$ref": "./paths/workspace_versions.yml" + "/api/v3/workspaces/{id}/labels": + "$ref": "./paths/workspace_labels.yml" "/api/v3/workspaces/schema": "$ref": "./paths/workspaces_schema.yml" diff --git a/docs/api/apiv3/paths/workspace_labels.yml b/docs/api/apiv3/paths/workspace_labels.yml new file mode 100644 index 000000000000..4b68b9c81019 --- /dev/null +++ b/docs/api/apiv3/paths/workspace_labels.yml @@ -0,0 +1,63 @@ +# /api/v3/workspaces/{id}/labels +--- +get: + parameters: + - description: ID of the workspace whose labels will be listed + example: 1 + in: path + name: id + required: true + schema: + type: integer + - name: filters + description: |- + JSON specifying filter conditions. + Currently supported filters are: + + + name: filters labels by name, with the operators `~` (contains), `!~` (does not contain) and `**` (all) + example: '[{ "name": { "operator": "~", "values": ["bug"] } }]' + in: query + required: false + schema: + type: string + responses: + '200': + content: + application/hal+json: + examples: + 'simple label collection': + $ref: "../components/examples/label_collection.yml" + schema: + "$ref": "../components/schemas/label_collection_model.yml" + description: OK + headers: {} + '400': + $ref: "../components/responses/invalid_query.yml" + '404': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The specified workspace does not exist. + description: |- + Returned if the workspace does not exist or the client does not have sufficient permissions + to see it. + + **Required permission:** view work packages (on given workspace) + + *Note: A client without sufficient permissions shall not be able to test for the existence of a workspace. + That's why a 404 is returned here, even if a 403 might be more appropriate.* + headers: {} + tags: + - Labels + description: |- + Returns a paginated collection of all labels, ordered by relevance for the given workspace: + labels already used on work packages of the workspace come first, followed by the remaining labels. + Within each group, labels are ordered by how often they are used overall and then by name. + operationId: List_labels_by_workspace + summary: List labels by workspace diff --git a/lib/api/v3/labels/labels_by_workspace_api.rb b/lib/api/v3/labels/labels_by_workspace_api.rb new file mode 100644 index 000000000000..e37d1b4d92f0 --- /dev/null +++ b/lib/api/v3/labels/labels_by_workspace_api.rb @@ -0,0 +1,54 @@ +# 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 API + module V3 + module Labels + class LabelsByWorkspaceAPI < ::API::OpenProjectAPI + resources :labels do + after_validation do + raise API::Errors::NotFound unless OpenProject::FeatureDecisions.work_package_labels_active? + + authorize_in_project(:view_work_packages, project: @project) + + @labels = Label.ordered_by_relevance_for(@project) + end + + get do + ::API::V3::Utilities::ParamsToQuery.collection_response(@labels, + current_user, + params.except("id"), + self_link: api_v3_paths.labels_by_workspace(@project.id)) + end + end + end + end + end +end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 46552a9561f3..8fc059d74711 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -660,6 +660,10 @@ def self.labels_by_project(project_id) "#{project(project_id)}/labels" end + def self.labels_by_workspace(workspace_id) + "#{workspace(workspace_id)}/labels" + end + def self.versions_available_projects "#{versions}/available_projects" end diff --git a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb index adef6a79cfb6..68015ec8a612 100644 --- a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb +++ b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb @@ -470,7 +470,7 @@ def all_permissions_granted_to_user_under_project def labels_autocompleter project_id = represented.work_package&.project_id - api_v3_paths.labels_by_project(project_id) if project_id + api_v3_paths.labels_by_workspace(project_id) if project_id end def assignee_user_autocompleter diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb index 4f9c27d99c9f..60f7af0c765e 100644 --- a/lib/api/v3/workspaces/nested_apis.rb +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -37,6 +37,7 @@ class NestedApis < ::API::OpenProjectAPI mount API::V3::WorkPackages::WorkPackagesByWorkspaceAPI mount API::V3::Categories::CategoriesByWorkspaceAPI mount API::V3::Versions::VersionsByProjectAPI + mount API::V3::Labels::LabelsByWorkspaceAPI mount API::V3::Queries::QueriesByWorkspaceAPI mount API::V3::Favorites::FavoriteActionsAPI, with: { favorite_object_getter: ->(*) { @project } } end diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 24e704e3b10a..1522161104e7 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -491,6 +491,18 @@ it_behaves_like "api v3 path", "/versions/available_projects" end + describe "#labels_by_project" do + subject { helper.labels_by_project 42 } + + it_behaves_like "api v3 path", "/projects/42/labels" + end + + describe "#labels_by_workspace" do + subject { helper.labels_by_workspace 42 } + + it_behaves_like "api v3 path", "/workspaces/42/labels" + end + describe "#versions_by_project" do subject { helper.versions_by_project 42 } diff --git a/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb b/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb index 355989b99e65..c017e6aae53b 100644 --- a/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb @@ -1223,7 +1223,7 @@ it_behaves_like "links to allowed values via collection link" do let(:path) { "labels" } - let(:href) { api_v3_paths.labels_by_project(work_package.project_id) } + let(:href) { api_v3_paths.labels_by_workspace(work_package.project_id) } end context "when not embedded" do diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 87db8b204fcd..55ab76708cfa 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -74,6 +74,37 @@ end end + describe ".ordered_by_relevance_for" do + let(:project) { create(:project) } + let(:other_project) { create(:project) } + + let!(:popular_elsewhere) { create(:label, name: "popular elsewhere") } + let!(:local_rare) { create(:label, name: "local rare") } + let!(:local_common) { create(:label, name: "local common") } + let!(:unused_a) { create(:label, name: "Unused A") } + let!(:unused_b) { create(:label, name: "unused b") } + + before do + label_work_packages(popular_elsewhere, other_project, count: 3) + label_work_packages(local_rare, project, count: 1) + label_work_packages(local_common, project, count: 2) + end + + def label_work_packages(label, project, count:) + create_list(:work_package, count, project:).each { create(:labeling, label:, labelable: it) } + end + + it "lists labels used in the project first, then by usage, then case-insensitively by name" do + expect(described_class.ordered_by_relevance_for(project).to_a) + .to eq([local_common, local_rare, popular_elsewhere, unused_a, unused_b]) + end + + it "keeps a scalar total when paginated" do + expect(described_class.ordered_by_relevance_for(project).paginate(page: 1, per_page: 2).total_entries) + .to eq(5) + end + end + describe ".page_of" do let!(:alpha) { create(:label, name: "Alpha") } let!(:bravo) { create(:label, name: "bravo") } diff --git a/spec/requests/api/v3/workspaces/label_resource_spec.rb b/spec/requests/api/v3/workspaces/label_resource_spec.rb new file mode 100644 index 000000000000..492048742c71 --- /dev/null +++ b/spec/requests/api/v3/workspaces/label_resource_spec.rb @@ -0,0 +1,113 @@ +# 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" +require "rack/test" + +RSpec.describe "GET workspaces/:id/labels", with_flag: :work_package_labels do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:project) { create(:project, public: false) } + shared_let(:other_project) { create(:project) } + shared_let(:permitted_user) { create(:user, member_with_permissions: { project => [:view_work_packages] }) } + shared_let(:unpermitted_user) { create(:user, member_with_permissions: { project => [] }) } + + shared_let(:popular_elsewhere) { create(:label, name: "popular elsewhere") } + shared_let(:local_label) { create(:label, name: "local") } + shared_let(:unused_label) { create(:label, name: "unused") } + + before_all do + create_list(:work_package, 2, project: other_project).each { create(:labeling, label: popular_elsewhere, labelable: it) } + create(:labeling, label: local_label, labelable: create(:work_package, project:)) + end + + subject(:response) { last_response } + + shared_context "with labels by workspace" do + context "for a user with permission to view work packages" do + current_user { permitted_user } + + before do + get get_path + end + + it_behaves_like "API V3 collection response", 3, 3, "Label" do + let(:elements) { [local_label, popular_elsewhere, unused_label] } + end + end + + context "with a name filter" do + current_user { permitted_user } + + before do + filter = [{ name: { operator: "~", values: ["popular"] } }] + + get "#{get_path}?filters=#{CGI.escape(filter.to_json)}" + end + + it_behaves_like "API V3 collection response", 1, 1, "Label" do + let(:elements) { [popular_elsewhere] } + end + end + + context "for a user without permission to view work packages" do + current_user { unpermitted_user } + + before do + get get_path + end + + it_behaves_like "unauthorized access" + end + + context "with the feature flag inactive", with_flag: { work_package_labels: false } do + current_user { permitted_user } + + before do + get get_path + end + + it_behaves_like "not found" + end + end + + context "for workspaces/:id/labels" do + let(:get_path) { api_v3_paths.labels_by_workspace project.id } + + include_context "with labels by workspace" + end + + context "for projects/:id/labels" do + let(:get_path) { api_v3_paths.labels_by_project project.id } + + include_context "with labels by workspace" + end +end From ebcdc56f83a9ad58e16ccb6a853c2e5cca673632 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 22:22:38 +0300 Subject: [PATCH 3/8] Include labels in the work package cache checksum Assigning labels does not touch the work package row, so without this the cached JSON representation kept serving the previous labels. --- .../work_packages/eager_loading/checksum.rb | 16 ++++-- .../cache_checksum_integration_spec.rb | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/lib/api/v3/work_packages/eager_loading/checksum.rb b/lib/api/v3/work_packages/eager_loading/checksum.rb index e9bbf62a7b2d..f68da2beced2 100644 --- a/lib/api/v3/work_packages/eager_loading/checksum.rb +++ b/lib/api/v3/work_packages/eager_loading/checksum.rb @@ -56,10 +56,10 @@ def fetch_checksums_for(work_packages) protected - # Versions are a has_many, which would multiply rows in the - # left_joins/pluck above, so they enter as an aggregated subquery. - # A version can attach under more than one kind, so the kind is part - # of the value and of the order. + # Versions and labels are both has_many, which would multiply rows + # in the left_joins/pluck above, so they enter as aggregated + # subqueries instead. A version can attach under more than one + # kind, so the kind is part of the value and of the order. VERSIONS_CHECKSUM_SQL = <<~SQL.squish (SELECT COALESCE(STRING_AGG(CONCAT(wpv.kind, v.id, v.updated_at), ',' ORDER BY wpv.kind, v.id), '') FROM work_package_versions wpv @@ -67,6 +67,13 @@ def fetch_checksums_for(work_packages) WHERE wpv.work_package_id = work_packages.id) SQL + LABELS_CHECKSUM_SQL = <<~SQL.squish + (SELECT COALESCE(STRING_AGG(CONCAT(l.id, l.updated_at), ',' ORDER BY l.id), '') + FROM labelings lg + INNER JOIN labels l ON l.id = lg.label_id + WHERE lg.labelable_type = 'WorkPackage' AND lg.labelable_id = work_packages.id) + SQL + def md5_concat md5_parts = checksum_associations.flat_map do |association_name| table_name = md5_checksum_table_name(association_name) @@ -74,6 +81,7 @@ def md5_concat %W[#{table_name}.id #{table_name}.updated_at] end md5_parts << VERSIONS_CHECKSUM_SQL + md5_parts << LABELS_CHECKSUM_SQL <<-SQL MD5(CONCAT(#{md5_parts.join(', ')})) diff --git a/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb b/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb index ed577e05ed09..efc24d508c38 100644 --- a/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb +++ b/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb @@ -231,5 +231,61 @@ expect(new_checksum) .not_to eql orig_checksum end + + it "produces a different checksum when a label is added to the work package" do + label = create(:label) + + create(:labeling, labelable: work_package, label:) + + expect(new_checksum) + .not_to eql orig_checksum + end + + it "produces a different checksum when a label is removed from the work package" do + label = create(:label) + labeling = create(:labeling, labelable: work_package, label:) + + previous_checksum = EagerLoadingMockWrapper + .wrap(described_class, [work_package]) + .first + .cache_checksum + + labeling.destroy! + + expect(new_checksum) + .not_to eql previous_checksum + end + + it "produces a different checksum when an assigned label is renamed" do + label = create(:label) + create(:labeling, labelable: work_package, label:) + + previous_checksum = EagerLoadingMockWrapper + .wrap(described_class, [work_package]) + .first + .cache_checksum + + label.update_attribute(:name, "Renamed label #{label.id}") + + expect(new_checksum) + .not_to eql previous_checksum + end + + it "produces the same checksum when a label not assigned to the work package changes" do + assigned_label = create(:label) + create(:labeling, labelable: work_package, label: assigned_label) + unassigned_label = create(:label) + create(:labeling, labelable: create(:work_package, project:), label: unassigned_label) + + previous_checksum = EagerLoadingMockWrapper + .wrap(described_class, [work_package]) + .first + .cache_checksum + + unassigned_label.update_attribute(:name, "Renamed label #{unassigned_label.id}") + + expect(new_checksum) + .to eql previous_checksum + end end end From ab4d4e023df8cd4c139abd29e83fdccfd6446f17 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 22:32:20 +0300 Subject: [PATCH 4/8] Derive the labelable type in the checksum from the model --- lib/api/v3/work_packages/eager_loading/checksum.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/api/v3/work_packages/eager_loading/checksum.rb b/lib/api/v3/work_packages/eager_loading/checksum.rb index f68da2beced2..e3d49f51697b 100644 --- a/lib/api/v3/work_packages/eager_loading/checksum.rb +++ b/lib/api/v3/work_packages/eager_loading/checksum.rb @@ -56,10 +56,10 @@ def fetch_checksums_for(work_packages) protected - # Versions and labels are both has_many, which would multiply rows - # in the left_joins/pluck above, so they enter as aggregated - # subqueries instead. A version can attach under more than one - # kind, so the kind is part of the value and of the order. + # Versions and labels are has_many and enter as aggregated subqueries; + # joining them in the pluck above would multiply the rows. A version + # can attach under more than one kind, so the kind is part of the + # value and of the order. VERSIONS_CHECKSUM_SQL = <<~SQL.squish (SELECT COALESCE(STRING_AGG(CONCAT(wpv.kind, v.id, v.updated_at), ',' ORDER BY wpv.kind, v.id), '') FROM work_package_versions wpv @@ -71,7 +71,7 @@ def fetch_checksums_for(work_packages) (SELECT COALESCE(STRING_AGG(CONCAT(l.id, l.updated_at), ',' ORDER BY l.id), '') FROM labelings lg INNER JOIN labels l ON l.id = lg.label_id - WHERE lg.labelable_type = 'WorkPackage' AND lg.labelable_id = work_packages.id) + WHERE lg.labelable_type = '#{WorkPackage.polymorphic_name}' AND lg.labelable_id = work_packages.id) SQL def md5_concat From 1993a75ce85f93af48807ae1f68baf77cf00e678 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 23:48:03 +0300 Subject: [PATCH 5/8] Export labels by name and hide them while the flag is off The form attribute list is derived from the schema representer's static definitions, where show_if never runs, so exports and the type form configuration listed labels with the flag off and printed the raw association with it on. --- app/models/type/attributes.rb | 7 +- .../work_package/exports/formatters/labels.rb | 43 +++++++++++ config/initializers/export_formats.rb | 1 + spec/models/type/attributes_spec.rb | 20 +++++ .../exports/formatters/labels_spec.rb | 74 +++++++++++++++++++ .../pdf_export/work_package_to_pdf_spec.rb | 12 +++ 6 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 app/models/work_package/exports/formatters/labels.rb create mode 100644 spec/models/work_package/exports/formatters/labels_spec.rb diff --git a/app/models/type/attributes.rb b/app/models/type/attributes.rb index 4de6ef25e12c..de85d76d8bbe 100644 --- a/app/models/type/attributes.rb +++ b/app/models/type/attributes.rb @@ -86,7 +86,8 @@ def all_work_package_form_attributes(merge_date: false) OpenProject::Cache.fetch_request_cached("all_work_package_form_attributes", *wp_cf_cache_parts, EXCLUDED.length, - merge_date) do + merge_date, + OpenProject::FeatureDecisions.work_package_labels_active?) do calculate_all_work_package_form_attributes(merge_date) end end @@ -139,6 +140,10 @@ def skipped_attribute?(key, definition) # We always want to include the priority even if its required return false if key == "priority" + # Remove once the work_package_labels feature flag is removed; show_if + # on the schema representer property is never evaluated here. + return true if key == "labels" && !OpenProject::FeatureDecisions.work_package_labels_active? + EXCLUDED.include?(key) || definition[:required] end diff --git a/app/models/work_package/exports/formatters/labels.rb b/app/models/work_package/exports/formatters/labels.rb new file mode 100644 index 000000000000..f1bde1f52ca4 --- /dev/null +++ b/app/models/work_package/exports/formatters/labels.rb @@ -0,0 +1,43 @@ +# 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 WorkPackage::Exports + module Formatters + class Labels < ::Exports::Formatters::Default + def self.apply?(attribute, _export_format) + attribute.to_sym == :labels + end + + def retrieve_value(object) + object.labels.map(&:name) + end + end + end +end diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb index 1dbfb575ddab..1d205bb76c93 100644 --- a/config/initializers/export_formats.rb +++ b/config/initializers/export_formats.rb @@ -49,6 +49,7 @@ formatter WorkPackage, WorkPackage::Exports::Formatters::XLS::DoneRatio formatter WorkPackage, WorkPackage::Exports::Formatters::PDF::Hours formatter WorkPackage, WorkPackage::Exports::Formatters::Id + formatter WorkPackage, WorkPackage::Exports::Formatters::Labels formatter WorkPackage, WorkPackage::Exports::Formatters::ProjectPhase formatter WorkPackage, WorkPackage::Exports::Formatters::SpentUnits formatter WorkPackage, WorkPackage::Exports::Formatters::TargetVersions diff --git a/spec/models/type/attributes_spec.rb b/spec/models/type/attributes_spec.rb index 48ea986e2052..8a01d79af27e 100644 --- a/spec/models/type/attributes_spec.rb +++ b/spec/models/type/attributes_spec.rb @@ -48,6 +48,26 @@ expect(attributes).not_to have_key("version") end end + + context "when the work package labels feature is active", with_flag: :work_package_labels do + it "offers labels" do + expect(attributes).to have_key("labels") + end + end + + context "when the work package labels feature is inactive", with_flag: { work_package_labels: false } do + it "hides labels" do + expect(attributes).not_to have_key("labels") + end + end + + it "keys the cache on the feature flag state" do + allow(OpenProject::FeatureDecisions).to receive(:work_package_labels_active?).and_return(false) + expect(TypeVariant.all_work_package_form_attributes).not_to have_key("labels") + + allow(OpenProject::FeatureDecisions).to receive(:work_package_labels_active?).and_return(true) + expect(TypeVariant.all_work_package_form_attributes).to have_key("labels") + end end describe ".translated_work_package_form_attributes" do diff --git a/spec/models/work_package/exports/formatters/labels_spec.rb b/spec/models/work_package/exports/formatters/labels_spec.rb new file mode 100644 index 000000000000..1eaf896ee904 --- /dev/null +++ b/spec/models/work_package/exports/formatters/labels_spec.rb @@ -0,0 +1,74 @@ +# 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::Exports::Formatters::Labels do + let(:formatter_instance) { described_class.new(:labels) } + + describe ".apply?" do + it "returns true for :labels with any export format" do + expect(described_class.apply?(:labels, :pdf)).to be true + expect(described_class.apply?(:labels, :csv)).to be true + expect(described_class.apply?(:labels, :xls)).to be true + end + + it "returns false for other attributes" do + expect(described_class.apply?(:subject, :pdf)).to be false + end + end + + describe "#format" do + let(:labels) { [build_stubbed(:label, name: "Urgent"), build_stubbed(:label, name: "Frontend")] } + let(:work_package) do + build_stubbed(:work_package) do |wp| + allow(wp) + .to receive(:labels) + .and_return(labels) + end + end + + it "returns the joined label names" do + expect(formatter_instance.format(work_package)).to eq("Urgent, Frontend") + end + + it "honors the array_separator option" do + expect(formatter_instance.format(work_package, array_separator: "; ")).to eq("Urgent; Frontend") + end + + context "without labels" do + let(:labels) { [] } + + it "returns an empty string" do + expect(formatter_instance.format(work_package)).to eq("") + end + end + end +end diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index 1a92c0aef444..a286ef0f8db6 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -333,6 +333,18 @@ def remove_pdf_page_footers(strings, nr_of_pages) end end + describe "with the work package labels feature active", with_flag: { work_package_labels: true } do + before do + create(:labeling, label: create(:label, name: "Urgent"), labelable: work_package) + end + + it "renders the labels attribute" do + result = remove_pdf_page_footers(pdf[:strings].join(" "), 2) + + expect(result).to include("#{WorkPackage.human_attribute_name(:labels)} Urgent") + end + end + describe "with faulty images" do before do # simulate a null pointer exception From b6b558242a53b3a5fa16f30c29cc9c8db8383fb9 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 24 Sep 2026 08:52:49 +0300 Subject: [PATCH 6/8] Simplify label relevance ordering Nothing reads usage_count from ordered_by_relevance_for, and plain SQL strings read easier than the Arel expressions here. --- app/models/label.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/models/label.rb b/app/models/label.rb index b41d86ae23cc..ea9ccd518113 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -41,16 +41,15 @@ class Label < ApplicationRecord scope :ordered_by_relevance_for, ->(project) { used_in_project = Labeling .where(labelable_type: WorkPackage.name) - .where(Labeling.arel_table[:label_id].eq(arel_table[:id])) + .where("labelings.label_id = labels.id") .joins("INNER JOIN work_packages ON work_packages.id = labelings.labelable_id") .where(work_packages: { project_id: project }) .arel .exists - with_usage_count - .order(used_in_project.desc) + order(used_in_project.desc) .order(Arel.sql("#{USAGE_COUNT_SQL} DESC")) - .order(arel_table[:name].lower.asc) + .order("LOWER(labels.name) ASC") } normalizes :name, with: -> { it.squish } From 8ab6517f1b5fbea6e66e44494062bd967d5591a9 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 24 Sep 2026 10:02:27 +0300 Subject: [PATCH 7/8] Serve workspace labels through the standard index endpoint The shared index endpoint already handles offset and pageSize; reorder lets the relevance ordering replace the query's default name order on merge. --- app/models/label.rb | 2 +- lib/api/v3/labels/labels_by_workspace_api.rb | 12 +++---- .../api/v3/workspaces/label_resource_spec.rb | 32 +++++++++++++++++++ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/app/models/label.rb b/app/models/label.rb index ea9ccd518113..af867cc9bc4f 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -47,7 +47,7 @@ class Label < ApplicationRecord .arel .exists - order(used_in_project.desc) + reorder(used_in_project.desc) .order(Arel.sql("#{USAGE_COUNT_SQL} DESC")) .order("LOWER(labels.name) ASC") } diff --git a/lib/api/v3/labels/labels_by_workspace_api.rb b/lib/api/v3/labels/labels_by_workspace_api.rb index e37d1b4d92f0..8b9ca64b74da 100644 --- a/lib/api/v3/labels/labels_by_workspace_api.rb +++ b/lib/api/v3/labels/labels_by_workspace_api.rb @@ -37,16 +37,12 @@ class LabelsByWorkspaceAPI < ::API::OpenProjectAPI raise API::Errors::NotFound unless OpenProject::FeatureDecisions.work_package_labels_active? authorize_in_project(:view_work_packages, project: @project) - - @labels = Label.ordered_by_relevance_for(@project) end - get do - ::API::V3::Utilities::ParamsToQuery.collection_response(@labels, - current_user, - params.except("id"), - self_link: api_v3_paths.labels_by_workspace(@project.id)) - end + get &::API::V3::Utilities::Endpoints::Index.new(model: Label, + scope: -> { Label.ordered_by_relevance_for(@project) }, + self_path: -> { api_v3_paths.labels_by_workspace(@project.id) }) + .mount end end end diff --git a/spec/requests/api/v3/workspaces/label_resource_spec.rb b/spec/requests/api/v3/workspaces/label_resource_spec.rb index 492048742c71..b212bab67fc5 100644 --- a/spec/requests/api/v3/workspaces/label_resource_spec.rb +++ b/spec/requests/api/v3/workspaces/label_resource_spec.rb @@ -78,6 +78,38 @@ end end + context "with a page size smaller than the number of labels" do + current_user { permitted_user } + + before do + get "#{get_path}?pageSize=2" + end + + it_behaves_like "API V3 collection response", 3, 2, "Label" do + let(:elements) { [local_label, popular_elsewhere] } + end + + it "links to the next page" do + expect(response.body).to have_json_path("_links/nextByOffset/href") + end + end + + context "with pageSize -1 requesting the maximum page size" do + current_user { permitted_user } + + before do + get "#{get_path}?pageSize=-1" + end + + it_behaves_like "API V3 collection response", 3, 3, "Label" do + let(:elements) { [local_label, popular_elsewhere, unused_label] } + end + + it "resolves to the configured maximum page size" do + expect(response.body).to be_json_eql(Setting.apiv3_max_page_size.to_i.to_json).at_path("pageSize") + end + end + context "for a user without permission to view work packages" do current_user { unpermitted_user } From 389bd60fc07c7712fb936274da6626f733770c7a Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 24 Sep 2026 17:14:43 +0300 Subject: [PATCH 8/8] Drop the project-scoped labels path helper New nested resources only get workspace-scoped path helpers; the projects route remains an undocumented alias. --- lib/api/v3/utilities/path_helper.rb | 4 ---- spec/lib/api/v3/utilities/path_helper_spec.rb | 6 ------ spec/requests/api/v3/workspaces/label_resource_spec.rb | 6 ------ 3 files changed, 16 deletions(-) diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 8fc059d74711..a3149ccb8d30 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -656,10 +656,6 @@ def self.views_type(type) index :label show :label - def self.labels_by_project(project_id) - "#{project(project_id)}/labels" - end - def self.labels_by_workspace(workspace_id) "#{workspace(workspace_id)}/labels" end diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 1522161104e7..e527d8f85e0f 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -491,12 +491,6 @@ it_behaves_like "api v3 path", "/versions/available_projects" end - describe "#labels_by_project" do - subject { helper.labels_by_project 42 } - - it_behaves_like "api v3 path", "/projects/42/labels" - end - describe "#labels_by_workspace" do subject { helper.labels_by_workspace 42 } diff --git a/spec/requests/api/v3/workspaces/label_resource_spec.rb b/spec/requests/api/v3/workspaces/label_resource_spec.rb index b212bab67fc5..065c2b8afc44 100644 --- a/spec/requests/api/v3/workspaces/label_resource_spec.rb +++ b/spec/requests/api/v3/workspaces/label_resource_spec.rb @@ -136,10 +136,4 @@ include_context "with labels by workspace" end - - context "for projects/:id/labels" do - let(:get_path) { api_v3_paths.labels_by_project project.id } - - include_context "with labels by workspace" - end end