[#71251] Migrate Versions to Sprints

https://community.openproject.org/work_packages/71251

- Validate dates only on active Agile::Sprints
- Create journals for migrating Versions to Sprints
- Create migration from Versions to Sprints
This commit is contained in:
Dombi Attila
2026-03-12 19:38:46 +02:00
parent aedcc8f55d
commit 6f437a55b6
14 changed files with 589 additions and 50 deletions
@@ -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
+1
View File
@@ -3449,6 +3449,7 @@ en:
totals_removed_from_childless_work_packages: >-
Work and progress totals automatically removed for non-parent work packages with <a href="%{href}" target="_blank">version update</a>.
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: >-
@@ -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
+3 -5
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) { [] }
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
"<strong>#{I18n.t('journals.caused_changes.system_update')}</strong> " \
"#{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
{