diff --git a/app/models/journal/caused_by_system_update.rb b/app/models/journal/caused_by_system_update.rb
index 5cceaec9c6d..5ec71544f82 100644
--- a/app/models/journal/caused_by_system_update.rb
+++ b/app/models/journal/caused_by_system_update.rb
@@ -29,10 +29,9 @@
#++
#
class Journal::CausedBySystemUpdate < CauseOfChange::Base
- def initialize(feature:)
- additional = {
- "feature" => feature
- }
- super("system_update", additional)
+ def initialize(feature:, **additional_attributes)
+ system_update_attributes =
+ { "feature" => feature }.merge(additional_attributes.deep_stringify_keys)
+ super("system_update", system_update_attributes)
end
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 156f16a10c5..d667d4ec16b 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -3449,6 +3449,7 @@ en:
totals_removed_from_childless_work_packages: >-
Work and progress totals automatically removed for non-parent work packages with version update.
This is a maintenance task and can be safely ignored.
+ sprint_migration: "Version '%{version_name}' has been converted to a Sprint."
total_percent_complete_mode_changed_to_work_weighted_average: >-
Child work packages without Work are ignored.
total_percent_complete_mode_changed_to_simple_average: >-
diff --git a/lib/open_project/journal_formatter/cause.rb b/lib/open_project/journal_formatter/cause.rb
index 7170681b76b..3c708bafb5d 100644
--- a/lib/open_project/journal_formatter/cause.rb
+++ b/lib/open_project/journal_formatter/cause.rb
@@ -101,6 +101,8 @@ class OpenProject::JournalFormatter::Cause < JournalFormatter::Base
{ href: OpenProject::Static::Links.url_for(:blog_article_progress_changes) }
when "totals_removed_from_childless_work_packages"
{ href: OpenProject::Static::Links.url_for(:release_notes_14_0_1) }
+ when "sprint_migration"
+ { version_name: cause["version_name"] }
else
{}
end
diff --git a/modules/backlogs/app/models/agile/sprint.rb b/modules/backlogs/app/models/agile/sprint.rb
index cef4d7f6f34..d08bfde9f5e 100644
--- a/modules/backlogs/app/models/agile/sprint.rb
+++ b/modules/backlogs/app/models/agile/sprint.rb
@@ -54,13 +54,11 @@ module Agile
default: "in_planning",
validate: true
- validates :name, presence: true
- validates :project, presence: true
- validates :start_date, presence: true
- validates :finish_date, presence: true
+ validates :name, :project, presence: true
+ validates :start_date, :finish_date, presence: true, if: :active?
validates :finish_date,
comparison: { greater_than_or_equal_to: :start_date },
- if: :start_date?
+ if: -> { start_date? && finish_date? }
validate :validate_only_one_active_sprint_per_project
diff --git a/modules/backlogs/app/workers/backlogs/migrate_version_sprint_journals_job.rb b/modules/backlogs/app/workers/backlogs/migrate_version_sprint_journals_job.rb
new file mode 100644
index 00000000000..0f05964c337
--- /dev/null
+++ b/modules/backlogs/app/workers/backlogs/migrate_version_sprint_journals_job.rb
@@ -0,0 +1,55 @@
+# 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.
+#++
+
+# Writes a journal entry on each work package that was associated with a sprint
+# during the version-to-sprint migration. Runs asynchronously after the migration
+# so that the migration itself does not block on journal creation.
+module Backlogs
+ class MigrateVersionSprintJournalsJob < ApplicationJob
+ # wp_version_map: { work_package_id (Integer) => version_name (String) }
+ def perform(wp_version_map)
+ system_user = User.system
+
+ work_packages = WorkPackage.where(id: wp_version_map.keys).index_by(&:id)
+
+ Journal::NotificationConfiguration.with(false) do
+ wp_version_map.each do |wp_id_str, version_name|
+ wp_id = wp_id_str.to_i
+ work_package = work_packages[wp_id]
+ next unless work_package
+
+ Journals::CreateService
+ .new(work_package, system_user)
+ .call(cause: Journal::CausedBySystemUpdate.new(feature: "sprint_migration", version_name:))
+ end
+ end
+ end
+ end
+end
diff --git a/modules/backlogs/db/migrate/20260313163122_make_sprint_dates_nullable.rb b/modules/backlogs/db/migrate/20260313163122_make_sprint_dates_nullable.rb
new file mode 100644
index 00000000000..1b270cbcbcb
--- /dev/null
+++ b/modules/backlogs/db/migrate/20260313163122_make_sprint_dates_nullable.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 MakeSprintDatesNullable < ActiveRecord::Migration[8.0]
+ def change
+ change_column_null :sprints, :start_date, true
+ change_column_null :sprints, :finish_date, true
+ end
+end
diff --git a/modules/backlogs/db/migrate/20260313164539_migrate_versions_to_sprints.rb b/modules/backlogs/db/migrate/20260313164539_migrate_versions_to_sprints.rb
new file mode 100644
index 00000000000..b20959f764f
--- /dev/null
+++ b/modules/backlogs/db/migrate/20260313164539_migrate_versions_to_sprints.rb
@@ -0,0 +1,76 @@
+# 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 MigrateVersionsToSprints < ActiveRecord::Migration[8.0]
+ def up
+ wp_version_map = {}
+
+ sprint_versions_with_work_package_ids.each do |version, wp_ids|
+ sprint = create_sprint(version)
+ migrate_work_packages_to_sprint(sprint, wp_ids)
+ wp_ids.each { |wp_id| wp_version_map[wp_id.to_s] = sprint.name }
+ end
+
+ Backlogs::MigrateVersionSprintJournalsJob.perform_later(wp_version_map) if wp_version_map.any?
+ end
+
+ def down
+ raise ActiveRecord::IrreversibleMigration
+ end
+
+ private
+
+ def sprint_versions_with_work_package_ids
+ # Load versions used as sprints including work package ids associated with the version.
+ # Since the same version can be used as a sprint in one project but not in another,
+ # the work package ids are filtered by projects where the version is used as a sprint.
+ Version.joins(:version_settings, :work_packages)
+ .where(version_settings: { display: VersionSetting::DISPLAY_LEFT })
+ .where("work_packages.project_id = version_settings.project_id")
+ .includes(:project)
+ .group("versions.id")
+ .select("versions.*, array_agg(work_packages.id) AS wp_ids")
+ .map { |version| [version, version.wp_ids] }
+ end
+
+ def create_sprint(version)
+ Agile::Sprint.create!(
+ name: version.name,
+ project: version.project,
+ status: version.status == "open" ? "in_planning" : "completed",
+ start_date: version.start_date,
+ finish_date: version.effective_date
+ )
+ end
+
+ def migrate_work_packages_to_sprint(sprint, wp_ids)
+ WorkPackage.where(id: wp_ids).update_all(sprint_id: sprint.id)
+ end
+end
diff --git a/modules/backlogs/spec/contracts/sprints/create_contract_spec.rb b/modules/backlogs/spec/contracts/sprints/create_contract_spec.rb
index f542fac55b7..48b84dd1267 100644
--- a/modules/backlogs/spec/contracts/sprints/create_contract_spec.rb
+++ b/modules/backlogs/spec/contracts/sprints/create_contract_spec.rb
@@ -89,13 +89,13 @@ RSpec.describe Sprints::CreateContract do
context "when start_date is blank" do
let(:sprint_start_date) { nil }
- it_behaves_like "contract is invalid", start_date: :blank
+ it_behaves_like "contract is valid"
end
context "when finish_date is blank" do
let(:sprint_finish_date) { nil }
- it_behaves_like "contract is invalid", finish_date: %i[blank blank]
+ it_behaves_like "contract is valid"
end
context "when finish_date is before start_date" do
@@ -105,6 +105,29 @@ RSpec.describe Sprints::CreateContract do
it_behaves_like "contract is invalid", finish_date: %i[greater_than_or_equal_to]
end
+ context "when the sprint is active" do
+ let(:sprint_status) { "active" }
+
+ context "when start_date is blank" do
+ let(:sprint_start_date) { nil }
+
+ it_behaves_like "contract is invalid", start_date: :blank
+ end
+
+ context "when finish_date is blank" do
+ let(:sprint_finish_date) { nil }
+
+ it_behaves_like "contract is invalid", finish_date: :blank
+ end
+
+ context "when finish_date is before start_date" do
+ let(:sprint_start_date) { Time.zone.today }
+ let(:sprint_finish_date) { Time.zone.today - 1.day }
+
+ it_behaves_like "contract is invalid", finish_date: %i[greater_than_or_equal_to]
+ end
+ end
+
context "when user is admin without project permission" do
let(:user) { build_stubbed(:admin) }
let(:permissions) { [] }
diff --git a/modules/backlogs/spec/features/sprints/create_spec.rb b/modules/backlogs/spec/features/sprints/create_spec.rb
index 3bc70e498be..f3e484ee720 100644
--- a/modules/backlogs/spec/features/sprints/create_spec.rb
+++ b/modules/backlogs/spec/features/sprints/create_spec.rb
@@ -121,20 +121,6 @@ RSpec.describe "Create", :js do
describe "validations" do
let(:too_early_finish_date) { start_date - 1.day }
- it "validates required fields are present" do
- backlogs_page.open_create_sprint_dialog
-
- within_dialog "New sprint" do
- page.fill_in "Sprint name", with: ""
-
- click_on "Create"
-
- expect(page).to have_field "Sprint name", validation_error: "can't be blank"
- expect(page).to have_field "Start date", validation_error: "can't be blank"
- expect(page).to have_field "Finish date", validation_error: "can't be blank"
- end
- end
-
it "validates finish date is not before start date" do
backlogs_page.open_create_sprint_dialog
diff --git a/modules/backlogs/spec/features/sprints/edit_spec.rb b/modules/backlogs/spec/features/sprints/edit_spec.rb
index 8e49b34ddbd..442f6262c68 100644
--- a/modules/backlogs/spec/features/sprints/edit_spec.rb
+++ b/modules/backlogs/spec/features/sprints/edit_spec.rb
@@ -162,6 +162,29 @@ RSpec.describe "Edit", :js do
end
end
end
+
+ describe "validations" do
+ context "when sprint status is active" do
+ before { first_sprint.update!(status: "active") }
+
+ it "validates required fields are present" do
+ backlogs_page.click_in_sprint_menu(first_sprint, "Edit sprint")
+ backlogs_page.expect_sprint_dialog
+
+ within_dialog "Edit sprint" do
+ page.fill_in "Sprint name", with: ""
+ page.fill_in "Start date", with: ""
+ page.fill_in "Finish date", with: ""
+
+ page.click_button "Save"
+
+ expect(page).to have_field "Sprint name", validation_error: "can't be blank"
+ expect(page).to have_field "Start date", validation_error: "can't be blank"
+ expect(page).to have_field "Finish date", validation_error: "can't be blank"
+ end
+ end
+ end
+ end
end
end
diff --git a/modules/backlogs/spec/migrations/migrate_versions_to_sprints_spec.rb b/modules/backlogs/spec/migrations/migrate_versions_to_sprints_spec.rb
new file mode 100644
index 00000000000..ca7085207ff
--- /dev/null
+++ b/modules/backlogs/spec/migrations/migrate_versions_to_sprints_spec.rb
@@ -0,0 +1,209 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+require Rails.root.join("modules/backlogs/db/migrate/20260313164539_migrate_versions_to_sprints")
+
+RSpec.describe MigrateVersionsToSprints, type: :model do
+ subject(:migrate) { ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } }
+
+ let(:project) { create(:project) }
+ let(:start_date) { nil }
+ let(:effective_date) { nil }
+ let(:status) { "open" }
+ let(:version_type) { :sprint }
+ let!(:version) do
+ create(:version, project:, name: "Test Sprint", start_date:, effective_date:, status:)
+ end
+ let!(:wp1) { create(:work_package, version:, project:) }
+
+ def use_version(as:, version: self.version, project: self.project)
+ display = as == :sprint ? VersionSetting::DISPLAY_LEFT : VersionSetting::DISPLAY_RIGHT
+ create(:version_setting, version:, project:, display:)
+ end
+
+ before { use_version(as: version_type) }
+
+ describe "qualification criteria" do
+ context "when all criteria are met (used as sprint and work package present)" do
+ let(:start_date) { Date.new(2026, 1, 1) }
+ let(:effective_date) { Date.new(2026, 1, 14) }
+
+ it "creates one sprint" do
+ expect { migrate }.to change(Agile::Sprint, :count).by(1)
+ end
+
+ it "copies name, start_date and finish_date" do
+ migrate
+ sprint = Agile::Sprint.last
+ expect(sprint.name).to eq("Test Sprint")
+ expect(sprint.start_date).to eq(Date.new(2026, 1, 1))
+ expect(sprint.finish_date).to eq(Date.new(2026, 1, 14))
+ end
+ end
+
+ context "when version is used as a backlog" do
+ let(:version_type) { :backlog }
+
+ it "does not create a sprint" do
+ expect { migrate }.not_to change(Agile::Sprint, :count)
+ end
+ end
+
+ context "when no work packages are associated with the version" do
+ let!(:wp1) { nil }
+
+ it "does not create a sprint" do
+ expect { migrate }.not_to change(Agile::Sprint, :count)
+ end
+ end
+ end
+
+ describe "date handling" do
+ context "when both start_date and effective_date are null" do
+ it "creates a sprint with nil dates" do
+ expect { migrate }.to change(Agile::Sprint, :count).by(1)
+ sprint = Agile::Sprint.last
+ expect(sprint.start_date).to be_nil
+ expect(sprint.finish_date).to be_nil
+ end
+ end
+
+ context "when only effective_date is set" do
+ let(:effective_date) { Date.new(2026, 2, 28) }
+
+ it "sets effective_date for finish_date" do
+ migrate
+ sprint = Agile::Sprint.last
+ expect(sprint.start_date).to be_nil
+ expect(sprint.finish_date).to eq(Date.new(2026, 2, 28))
+ end
+ end
+
+ context "when only start_date is set" do
+ let(:start_date) { Date.new(2026, 2, 1) }
+
+ it "sets start_date for start_date" do
+ migrate
+ sprint = Agile::Sprint.last
+ expect(sprint.start_date).to eq(Date.new(2026, 2, 1))
+ expect(sprint.finish_date).to be_nil
+ end
+ end
+ end
+
+ describe "status mapping" do
+ context "when version status is open" do
+ let(:status) { "open" }
+
+ it "creates sprint with in_planning status" do
+ migrate
+ expect(Agile::Sprint.last.status).to eq("in_planning")
+ end
+ end
+
+ context "when version status is locked" do
+ let(:status) { "locked" }
+
+ it "creates sprint with completed status" do
+ migrate
+ expect(Agile::Sprint.last.status).to eq("completed")
+ end
+ end
+
+ context "when version status is closed" do
+ let(:status) { "closed" }
+
+ it "creates sprint with completed status" do
+ migrate
+ expect(Agile::Sprint.last.status).to eq("completed")
+ end
+ end
+ end
+
+ describe "work package association" do
+ let!(:wp2) { create(:work_package, version:, project:) }
+
+ it "sets sprint_id on all associated work packages" do
+ migrate
+ sprint = Agile::Sprint.last
+ expect(wp1.reload.sprint_id).to eq(sprint.id)
+ expect(wp2.reload.sprint_id).to eq(sprint.id)
+ end
+
+ it "keeps the version_id on associated work packages" do
+ migrate
+ expect(wp1.reload.version_id).to eq(version.id)
+ expect(wp2.reload.version_id).to eq(version.id)
+ end
+
+ context "with multiple versions" do
+ let!(:version2) { create(:version, project:, status: "closed") }
+ let!(:wp3) { create(:work_package, version: version2, project:) }
+
+ before { use_version(as: :sprint, version: version2) }
+
+ it "assigns work packages to their respective sprints" do
+ migrate
+ sprints = Agile::Sprint.all.index_by(&:name)
+ expect(wp1.reload.sprint_id).to eq(sprints[version.name].id)
+ expect(wp2.reload.sprint_id).to eq(sprints[version.name].id)
+ expect(wp3.reload.sprint_id).to eq(sprints[version2.name].id)
+ end
+ end
+
+ context "when the version is shared with another project that displays it as non-sprint" do
+ let(:other_project) { create(:project) }
+ let!(:wp_in_other_project) { create(:work_package, version:, project: other_project) }
+
+ before { use_version(as: :backlog, project: other_project) }
+
+ it "only assigns work packages from the sprint project" do
+ migrate
+ sprint = Agile::Sprint.last
+ expect(wp1.reload.sprint_id).to eq(sprint.id)
+ expect(wp2.reload.sprint_id).to eq(sprint.id)
+ expect(wp_in_other_project.reload.sprint_id).to be_nil
+ end
+ end
+ end
+
+ describe "journal job" do
+ it "enqueues MigrateVersionSprintJournalsJob with the work package mapping" do
+ allow(Backlogs::MigrateVersionSprintJournalsJob).to receive(:perform_later)
+
+ migrate
+
+ expect(Backlogs::MigrateVersionSprintJournalsJob)
+ .to have_received(:perform_later)
+ .with({ wp1.id.to_s => "Test Sprint" })
+ end
+ end
+end
diff --git a/modules/backlogs/spec/models/agile/sprint_spec.rb b/modules/backlogs/spec/models/agile/sprint_spec.rb
index 5ce79972dea..2063cf2c23e 100644
--- a/modules/backlogs/spec/models/agile/sprint_spec.rb
+++ b/modules/backlogs/spec/models/agile/sprint_spec.rb
@@ -32,51 +32,66 @@ require "spec_helper"
RSpec.describe Agile::Sprint do
let(:project) { create(:project) }
+ let(:sprint_status) { "in_planning" }
subject(:sprint) do
described_class.new(name: "Sprint 1",
project:,
start_date: Time.zone.today,
- finish_date: Time.zone.today + 14.days)
+ finish_date: Time.zone.today + 14.days,
+ status: sprint_status)
end
describe "validations" do
it { is_expected.to validate_presence_of(:name) }
- it { is_expected.to validate_presence_of(:start_date) }
- it { is_expected.to validate_presence_of(:finish_date) }
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_inclusion_of(:status).in_array(described_class.statuses.keys) }
- it "validates finish_date is after or equal to start_date" do
- sprint.finish_date = sprint.start_date - 1.day
- expect(sprint).not_to be_valid
- expect(sprint.errors[:finish_date]).to include(/must be greater than or equal to/)
- end
-
- it "does not validate finish_date comparison when start_date is nil" do
- sprint.start_date = nil
- sprint.finish_date = Time.zone.today
- expect(sprint).not_to be_valid
- expect(sprint.errors[:start_date]).to be_present
- expect(sprint.errors[:finish_date]).not_to include(/must be greater than or equal to/)
- end
-
- it "still validates finish_date presence even when start_date is nil" do
+ it "allows nil start and finish dates" do
sprint.start_date = nil
sprint.finish_date = nil
- expect(sprint).not_to be_valid
- expect(sprint.errors[:finish_date]).to be_present
+ expect(sprint).to be_valid
+ end
+
+ it "allows a nil finish date when start date is present" do
+ sprint.start_date = Time.zone.today
+ sprint.finish_date = nil
+ expect(sprint).to be_valid
end
context "with active sprint validation" do
+ let(:sprint_status) { "active" }
+
+ it { is_expected.to validate_presence_of(:start_date) }
+ it { is_expected.to validate_presence_of(:finish_date) }
+
+ it "validates finish_date is after or equal to start_date" do
+ sprint.finish_date = sprint.start_date - 1.day
+ expect(sprint).not_to be_valid
+ expect(sprint.errors[:finish_date]).to include(/must be greater than or equal to/)
+ end
+
+ it "does not validate finish_date comparison when start_date is nil" do
+ sprint.start_date = nil
+ sprint.finish_date = Time.zone.today
+ expect(sprint).not_to be_valid
+ expect(sprint.errors[:start_date]).to be_present
+ expect(sprint.errors[:finish_date]).not_to include(/must be greater than or equal to/)
+ end
+
+ it "still validates finish_date presence even when start_date is nil" do
+ sprint.start_date = nil
+ sprint.finish_date = nil
+ expect(sprint).not_to be_valid
+ expect(sprint.errors[:finish_date]).to be_present
+ end
+
it "allows one active sprint per project" do
- sprint.status = "active"
expect(sprint).to be_valid
end
it "prevents multiple active sprints in the same project" do
create(:agile_sprint, project:, status: "active")
- sprint.status = "active"
expect(sprint).not_to be_valid
expect(sprint.errors[:status]).to include("only one active sprint is allowed per project.")
end
@@ -84,12 +99,10 @@ RSpec.describe Agile::Sprint do
it "allows multiple active sprints in different projects" do
other_project = create(:project)
create(:agile_sprint, project: other_project, status: "active")
- sprint.status = "active"
expect(sprint).to be_valid
end
it "allows updating an existing active sprint" do
- sprint.status = "active"
sprint.save!
sprint.name = "Updated Sprint"
expect(sprint).to be_valid
diff --git a/modules/backlogs/spec/workers/backlogs/migrate_version_sprint_journals_job_spec.rb b/modules/backlogs/spec/workers/backlogs/migrate_version_sprint_journals_job_spec.rb
new file mode 100644
index 00000000000..e4a3543b0e6
--- /dev/null
+++ b/modules/backlogs/spec/workers/backlogs/migrate_version_sprint_journals_job_spec.rb
@@ -0,0 +1,94 @@
+# 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 Backlogs::MigrateVersionSprintJournalsJob, type: :model do
+ let(:project) { create(:project) }
+ let(:wp1) { create(:work_package, project:) }
+ let(:wp2) { create(:work_package, project:) }
+
+ subject(:perform) { described_class.new.perform(wp_version_map) }
+
+ describe "#perform" do
+ context "with multiple work packages" do
+ let(:wp_version_map) do
+ {
+ wp1.id.to_s => "Sprint A",
+ wp2.id.to_s => "Sprint B"
+ }
+ end
+
+ it "creates a journal entry for each work package authored by the system user" do
+ perform
+ expect(wp1.reload.last_journal.user).to eq(User.system)
+ expect(wp2.reload.last_journal.user).to eq(User.system)
+ end
+
+ it "sets the cause type to system_update" do
+ perform
+ expect(wp1.reload.last_journal.cause_type).to eq("system_update")
+ expect(wp2.reload.last_journal.cause_type).to eq("system_update")
+ end
+
+ it "sets the cause feature to sprint_migration" do
+ perform
+ expect(wp1.reload.last_journal.cause_feature).to eq("sprint_migration")
+ expect(wp2.reload.last_journal.cause_feature).to eq("sprint_migration")
+ end
+
+ it "stores the originating version name in the cause" do
+ perform
+ expect(wp1.reload.last_journal.cause["version_name"]).to eq("Sprint A")
+ expect(wp2.reload.last_journal.cause["version_name"]).to eq("Sprint B")
+ end
+
+ it "suppresses journal notifications" do
+ allow(Journal::NotificationConfiguration).to receive(:with).and_call_original
+ perform
+ expect(Journal::NotificationConfiguration).to have_received(:with).with(false)
+ end
+ end
+
+ context "when the map contains an id that no longer exists" do
+ let(:wp_version_map) do
+ {
+ "0" => "Ghost Sprint",
+ wp1.id.to_s => "Sprint A"
+ }
+ end
+
+ it "skips the missing id and still journals the existing work package" do
+ expect { perform }.not_to raise_error
+ expect(wp1.reload.last_journal.cause["version_name"]).to eq("Sprint A")
+ end
+ end
+ end
+end
diff --git a/spec/lib/open_project/journal_formatter/cause_spec.rb b/spec/lib/open_project/journal_formatter/cause_spec.rb
index 73d42e972d5..b77d9d4b26a 100644
--- a/spec/lib/open_project/journal_formatter/cause_spec.rb
+++ b/spec/lib/open_project/journal_formatter/cause_spec.rb
@@ -550,6 +550,30 @@ RSpec.describe OpenProject::JournalFormatter::Cause do
end
end
+ context "when the change was caused by a sprint migration" do
+ subject(:cause) do
+ {
+ "type" => "system_update",
+ "feature" => "sprint_migration",
+ "version_name" => "Sprint 1"
+ }
+ end
+
+ it do
+ expect(cause).to render_html_variant(
+ "#{I18n.t('journals.caused_changes.system_update')} " \
+ "#{I18n.t('journals.cause_descriptions.system_update.sprint_migration', version_name: 'Sprint 1')}"
+ )
+ end
+
+ it do
+ expect(cause).to render_raw_variant(
+ "#{I18n.t('journals.caused_changes.system_update')} " \
+ "#{I18n.t('journals.cause_descriptions.system_update.sprint_migration', version_name: 'Sprint 1')}"
+ )
+ end
+ end
+
context "when the change was caused by an import" do
subject(:cause) do
{