Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/contracts/labels/create_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@

module Labels
class CreateContract < BaseContract
attribute :created_inline

validate :user_allowed_to_create

private
Expand Down
3 changes: 2 additions & 1 deletion app/models/concerns/labelable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ def normalize_label_ids(new_labels)
def validate_label_id_replacements
return unless override_labels?

missing = label_id_replacements - Label.where(id: label_id_replacements).pluck(:id)
# KEY SHARE keeps the cleanup job from deleting these labels until this save commits.
missing = label_id_replacements - Label.where(id: label_id_replacements).lock("FOR KEY SHARE").pluck(:id)

errors.add(:labels, :does_not_exist) if missing.any?
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 :created_inline, -> { where(created_inline: true) }

scope :never_used, -> {
where("NOT EXISTS (SELECT 1 FROM labelings WHERE labelings.label_id = labels.id)")
.where("NOT EXISTS (SELECT 1 FROM label_journals WHERE label_journals.label_id = labels.id)")
Comment on lines +63 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to check journals for the never_used?
Just re read the PR description.

I am thinking if this makes it more difficult to reuse labels on a non journalized model, but I guess that's not really an issue since the query will simply not find any journal entries.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's just the coupling with journals that bothers me.

I'll leave this comment in as something to give 10 seconds of thought and then forget 😅

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The coupling with journals is so that a label does not show up as "(deleted)" on the AT- the rationale is if it was used at least once; it's a legit label. AFAIK from product, the whole idea is to cleanup typos that are created within the inline creation phase.

We could use some discussion on this 👍🏾

}

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

validates :name,
Expand Down
19 changes: 13 additions & 6 deletions app/services/labels/find_or_create_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,29 @@ def initialize(user:)

def call(name:)
existing = Label.named(name).first
return ServiceResult.success(result: existing) if existing
return found(existing) if existing

create(name)
end

private

def create(name)
call = CreateService.new(user:).call(name:)
return call if call.success? || call.errors.symbols_for(:name).exclude?(:taken)
call = CreateService.new(user:).call(name:, created_inline: true)

existing(name)
if call.success?
CleanupUnusedInlineJob.perform_after_grace
call
elsif call.errors.symbols_for(:name).include?(:taken)
found(Label.named(name).first!)
else
call
end
end

def existing(name)
ServiceResult.success(result: Label.named(name).first!)
def found(label)
label.touch if label.created_inline?
ServiceResult.success(result: label)
end
end
end
9 changes: 9 additions & 0 deletions app/services/labels/set_attributes_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ module Labels
class SetAttributesService < ::BaseServices::SetAttributes
private

def set_attributes(params)
super
clear_inline_origin if model.persisted? && model.changed?
end

def clear_inline_origin
model.change_by_system { model.created_inline = false }
end

def set_default_attributes(_params)
model.change_by_system do
model.author = user
Expand Down
66 changes: 66 additions & 0 deletions app/workers/labels/cleanup_unused_inline_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 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.
#++

class Labels::CleanupUnusedInlineJob < ApplicationJob
include JobConcurrency

GRACE_PERIOD = 15.minutes
MIN_WAIT = 1.minute

queue_with_priority :low
good_job_control_concurrency_with(enqueue_limit: 1, key: name)

def self.perform_after_grace(from: Time.current)
set(wait_until: [from + GRACE_PERIOD, MIN_WAIT.from_now].max).perform_later
end

def perform
Label.transaction do
ids = candidate_ids
Label.never_used.where(id: ids).delete_all if ids.any?
end

schedule_next_run
end

private

def schedule_next_run
oldest = Label.created_inline.never_used.minimum(:updated_at)
self.class.perform_after_grace(from: oldest) if oldest
end

def candidate_ids
candidates.lock("FOR UPDATE SKIP LOCKED").pluck(:id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL

end

def candidates
Label.created_inline.never_used.where(updated_at: ..GRACE_PERIOD.ago)
end
end
4 changes: 4 additions & 0 deletions config/initializers/cronjobs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@
"AI::TextTransformRuns::CleanupJob": {
cron: "*/10 * * * *",
class: AI::TextTransformRuns::CleanupJob.name
},
"Labels::CleanupUnusedInlineJob": {
cron: "37 2 * * *", # runs at 2:37 nightly
class: Labels::CleanupUnusedInlineJob.name
}
}
)
Expand Down
34 changes: 34 additions & 0 deletions db/migrate/20261021100000_add_created_inline_to_labels.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 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 AddCreatedInlineToLabels < ActiveRecord::Migration[8.1]
def change
add_column :labels, :created_inline, :boolean, null: false, default: false
end
end
4 changes: 4 additions & 0 deletions spec/factories/label_factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,9 @@
factory :label do
sequence(:name) { "Label #{it}" }
author factory: :user

trait :created_inline do
created_inline { true }
end
end
end
30 changes: 30 additions & 0 deletions spec/models/label_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,36 @@ def label_work_packages(label, project, count:)
end
end

describe ".created_inline" do
it "returns only labels created inline" do
inline = create(:label, :created_inline)
create(:label)

expect(described_class.created_inline).to contain_exactly(inline)
end
end

describe ".never_used" do
let!(:unused) { create(:label) }

it "includes labels without labelings or journal snapshots" do
expect(described_class.never_used).to contain_exactly(unused)
end

it "excludes labels with a labeling" do
create(:labeling, label: create(:label))

expect(described_class.never_used).to contain_exactly(unused)
end

it "excludes labels only referenced by a journal snapshot" do
journaled = create(:label)
create(:journal_label_journal, journal: create(:work_package).journals.last, label: journaled)

expect(described_class.never_used).to contain_exactly(unused)
end
end

describe ".page_of" do
let!(:alpha) { create(:label, name: "Alpha") }
let!(:bravo) { create(:label, name: "bravo") }
Expand Down
4 changes: 4 additions & 0 deletions spec/services/labels/create_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@
expect(label.name).to eq("Bug")
end

it "does not flag the label as created inline" do
expect(subject.result).not_to be_created_inline
end

it "fails when the name is already taken, case-insensitively" do
create(:label, name: "Bug")

Expand Down
32 changes: 32 additions & 0 deletions spec/services/labels/find_or_create_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
expect(call.result).to be_persisted
expect(call.result.name).to eq("Bug")
expect(call.result).to be_previously_new_record
expect(call.result).to be_created_inline
end

it "books a cleanup run" do
expect { call }.to have_enqueued_job(Labels::CleanupUnusedInlineJob)
end
end

Expand All @@ -57,6 +62,7 @@
expect(call).to be_success
expect(call.result).to eq(existing)
expect(call.result).not_to be_previously_new_record
expect(call.result).not_to be_created_inline
end

context "with a name Ruby and Postgres lowercase differently" do
Expand All @@ -71,6 +77,32 @@
end
end

context "when an inline-created label with that name exists" do
shared_let(:existing) { create(:label, :created_inline, name: "bug", created_at: 2.days.ago, updated_at: 2.days.ago) }

let(:name) { "Bug" }

it "books no cleanup run" do
expect { call }.not_to have_enqueued_job(Labels::CleanupUnusedInlineJob)
end

it "touches an existing inline label so the cleanup grace period restarts" do
freeze_time

expect { call }.to change { existing.reload.updated_at }.to(Time.current)
end
end

context "when an admin-created label with that name exists" do
shared_let(:existing) { create(:label, name: "bug", updated_at: 2.days.ago) }

let(:name) { "Bug" }

it "leaves an admin-created label untouched" do
expect { call }.not_to change { existing.reload.updated_at }
end
end

context "when another request creates the label between lookup and validation" do
let(:name) { "Bug" }

Expand Down
18 changes: 18 additions & 0 deletions spec/services/labels/update_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@
expect(label.reload.name).to eq("Defect")
end

context "with a label created inline" do
let(:label) { create(:label, :created_inline, name: "Bug") }

it "turns it into a curated label" do
result = instance.call(name: "Defect")

expect(result).to be_success
expect(label.reload).not_to be_created_inline
end

it "stays a cleanup candidate when nothing changes" do
result = instance.call(name: "Bug")

expect(result).to be_success
expect(label.reload).to be_created_inline
end
end

context "when the new name is already taken in a different case" do
before { create(:label, name: "defect") }

Expand Down
Loading
Loading