Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b9c4c08
[AGILE-318] Copy sprints when copying a project
myabc Jul 6, 2026
5b4c7cf
[AGILE-318] Remap copied WP sprint assignment
myabc Jul 6, 2026
e860036
Populate sprint lookup incrementally on copy
myabc Jul 6, 2026
f3d85ba
Ignore order in .copyable_dependencies expectation
myabc Jul 6, 2026
1a75b1a
Add feature spec for copied-sprint reassignment
myabc Jul 6, 2026
c79858c
Copy work package positions too.
dombesz Jul 7, 2026
c3618d5
Sort copyable_dependencies in expectation
myabc Jul 7, 2026
d7e352f
Copy backlog buckets when copying a project
myabc Jul 7, 2026
ae1dee6
Prepend copy dependencies instead of index insert
myabc Jul 7, 2026
02dac87
Rename copy spec and assert copied buckets
myabc Jul 7, 2026
9357f44
Move copy-wait helper to general settings page
myabc Jul 7, 2026
4225f3b
Copy sprints only when the source project has its own sprints, otherw…
dombesz Jul 7, 2026
fab189a
Dedup backlog copy into a tolerant id-map helper
myabc Jul 7, 2026
a1f65c0
Scope backlogs WP copy overrides to backlogs projects
myabc Jul 7, 2026
f62f348
Reuse the copy id-map helper for version copying
myabc Jul 7, 2026
6f51e63
Copy owned sprint goals when copying a project
myabc Jul 7, 2026
74ad337
Decide sprint copy per sprint, not per project
myabc Jul 7, 2026
49faa14
Surface records skipped while copying a project
myabc Jul 8, 2026
d5a4aa7
Remap sprint and bucket query filters on copy
myabc Jul 8, 2026
031fc1a
Resolve copied work-package sprints in one query
myabc Jul 8, 2026
88de819
Copy categories through the shared id-map helper
myabc Jul 8, 2026
de96fb8
Keep borrowed sprint links on copied work packages
myabc Jul 22, 2026
9825d65
Copy sprint goal through goal_text_for lookup
myabc Jul 22, 2026
8638dd6
Scope copy specs' source project per describe
myabc Jul 22, 2026
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
24 changes: 24 additions & 0 deletions app/services/projects/copy/dependency.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,29 @@ def self.should_copy?(params, check)

params[:only].any? { |key| key.to_sym == check }
end

protected

##
# Copy every record of the named +association+ from the source project to
# the target, returning a Hash mapping each source record id to its copy's
# id. Uses the non-raising +create+ (as VersionsDependentService does) so a
# single invalid record is skipped rather than aborting the whole copy.
#
# When a block is given, its return value is merged into the copied
# attributes, letting a caller carry over child records via nested
# attributes (see SprintsDependentService copying sprint goals).
def copy_collection_with_id_map(association)
source.public_send(association).each_with_object({}) do |source_record, id_map|
attributes = copyable_attributes(source_record)
attributes.merge!(yield(source_record)) if block_given?
copy = target.public_send(association).create(attributes)
id_map[source_record.id] = copy.id if copy.persisted?
end
end

def copyable_attributes(source_record)
source_record.attributes.dup.except("id", "project_id", "created_at", "updated_at")
end
end
end
10 changes: 2 additions & 8 deletions app/services/projects/copy/versions_dependent_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,8 @@ def source_count

protected

def copy_dependency(params:)
version_id_map = {}
source.versions.each do |source_version|
version = target.versions.create source_version.attributes.dup.except("id", "project_id", "created_at", "updated_at")
version_id_map[source_version.id] = version.id
end

state.version_id_lookup = version_id_map
def copy_dependency(*)
state.version_id_lookup = copy_collection_with_id_map(:versions)
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 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 Projects::Copy
class BacklogBucketsDependentService < Dependency
def self.human_name
I18n.t("projects.copy.backlog_buckets")
end

def source_count
source.backlog_buckets.count
end

protected

def copy_dependency(*)
state.backlog_bucket_id_lookup = copy_collection_with_id_map(:backlog_buckets)
end
Comment thread
myabc marked this conversation as resolved.
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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 Projects::Copy
class SprintsDependentService < Dependency
def self.human_name
I18n.t("projects.copy.sprints")
end

def source_count
source.sprints.count
end

protected

def copy_dependency(*)
if source.receive_shared_sprints?
preserve_sprint_assignments
Comment thread
myabc marked this conversation as resolved.
Outdated
else
copy_sprints
end
end
Comment thread
myabc marked this conversation as resolved.

def preserve_sprint_assignments
state.sprint_id_lookup = Sprint.for_project(source).pluck(:id).index_with { |id| id }
end

def copy_sprints
state.sprint_id_lookup = copy_collection_with_id_map(:sprints) do |source_sprint|
{ goals_attributes: copied_goal_attributes(source_sprint) }
end
end

# Only the source project's own goals are carried over; a shared sprint may
# also hold goals owned by other projects, which are not ours to copy.
def copied_goal_attributes(source_sprint)
source_sprint.goals.where(project_id: source.id).map do |goal|
{ text: goal.text, project_id: target.id }
end
end
end
end
3 changes: 3 additions & 0 deletions modules/backlogs/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,9 @@ en:
project_module_backlogs: "Backlogs"

projects:
copy:
backlog_buckets: "Backlog buckets"
sprints: "Sprints"
settings:
backlog_sharing:
options:
Expand Down
2 changes: 1 addition & 1 deletion modules/backlogs/lib/open_project/backlogs/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def self.settings
patch_with_namespace :WorkPackages, :SetAttributesService
patch_with_namespace :WorkPackages, :BaseContract
patch_with_namespace :WorkPackages, :UpdateContract
patch_with_namespace :Projects, :CopyService
patch_with_namespace :Projects, :Copy, :WorkPackagesDependentService
patch_with_namespace :API, :V3, :WorkPackages, :EagerLoading, :Checksum
patch_with_namespace :API, :V3, :WorkPackages, :Schema, :SpecificWorkPackageSchema

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ module OpenProject::Backlogs::Patches::CopyServicePatch

included do
prepend InstanceMethods
singleton_class.prepend ClassMethods
end

module ClassMethods
def copy_dependencies
# Sprints and backlog buckets must precede the `WorkPackagesDependentService`
# so their id maps are ready when the work packages are copied and their
# sprint/bucket ids remapped.
[::Projects::Copy::SprintsDependentService,
::Projects::Copy::BacklogBucketsDependentService] + super
end
Comment thread
myabc marked this conversation as resolved.
end

module InstanceMethods
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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 OpenProject::Backlogs::Patches::WorkPackagesDependentServicePatch
extend ActiveSupport::Concern

included do
prepend InstanceMethods
end

module InstanceMethods
def copy_work_package(source_work_package, parent_id, user_cf_ids)
return super unless source.backlogs_enabled?
Comment thread
myabc marked this conversation as resolved.

# Disable the acts_as_list callbacks so the position is carried over from
# the source work package unchanged instead of being reappended.
WorkPackage.acts_as_list_no_update { super }
end

def copy_work_package_attribute_overrides(source_work_package, parent_id, user_cf_ids)
return super unless source.backlogs_enabled?

super.merge(
sprint_id: work_package_sprint_id(source_work_package),
backlog_bucket_id: work_package_backlog_bucket_id(source_work_package),
position: source_work_package.position
)
end
Comment thread
dombesz marked this conversation as resolved.
Comment thread
myabc marked this conversation as resolved.

def work_package_sprint_id(source_work_package)
return unless source_work_package.sprint_id

state.sprint_id_lookup&.[](source_work_package.sprint_id)
end

def work_package_backlog_bucket_id(source_work_package)
return unless source_work_package.backlog_bucket_id

state.backlog_bucket_id_lookup&.[](source_work_package.backlog_bucket_id)
end
end
end
91 changes: 91 additions & 0 deletions modules/backlogs/spec/features/projects/copy_backlogs_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 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 "Project copy with sprints and buckets", :js,
with_good_job_batches: [CopyProjectJob,
Storages::CopyProjectFoldersJob,
SendCopyProjectStatusEmailJob] do
shared_let(:admin) { create(:admin) }
shared_let(:type) { create(:type) }
shared_let(:project) do
create(:project,
enabled_module_names: %w[work_package_tracking backlogs],
types: [type])
end
shared_let(:sprint) { create(:sprint, project:, name: "Sprint A") }
shared_let(:bucket) { create(:backlog_bucket, project:, name: "Bucket A") }
shared_let(:work_package) do
create(:work_package, project:, type:, subject: "Sprint story", sprint:)
end
shared_let(:bucket_work_package) do
create(:work_package, project:, type:, subject: "Bucket story", backlog_bucket: bucket)
end

let(:general_settings_page) { Pages::Projects::Settings::General.new(project) }

before do
# Clear jobs enqueued during object creation so they don't interfere with the copy.
clear_enqueued_jobs
clear_performed_jobs

login_as admin
end

it "assigns copied work packages to the copied sprint and bucket, not the source ones" do
general_settings_page.visit!
general_settings_page.click_copy_action

expect(page).to have_heading "Copy project \"#{project.name}\""

fill_in "Name", with: "Copied project"
click_on "Copy"

general_settings_page.wait_for_copy_to_finish

copied_project = Project.find_by(name: "Copied project")
expect(copied_project).to be_present

copied_sprint = copied_project.sprints.find_by(name: "Sprint A")
expect(copied_sprint).to be_present
expect(copied_sprint.id).not_to eq(sprint.id)

copied_work_package = copied_project.work_packages.find_by(subject: "Sprint story")
expect(copied_work_package.sprint).to eq(copied_sprint)

copied_bucket = copied_project.backlog_buckets.find_by(name: "Bucket A")
expect(copied_bucket).to be_present
expect(copied_bucket.id).not_to eq(bucket.id)

copied_bucket_work_package = copied_project.work_packages.find_by(subject: "Bucket story")
expect(copied_bucket_work_package.backlog_bucket).to eq(copied_bucket)
end
end
Loading
Loading