[AGILE-318] Assign copied work packages to copied sprints#24129
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes an issue in the Backlogs project-copy flow where copied work packages could retain a sprint_id pointing to a sprint from the source project. It introduces sprint copying and remaps sprint references on copied work packages so that owned sprints are mapped to their newly created copies, while sprints owned by other projects keep their original IDs.
Changes:
- Added a
Projects::Copy::SprintsDependentServiceto copy a project’s owned sprints and build a source→target sprint ID lookup. - Patched
Projects::CopyService.copy_dependenciesto run sprint copying before work package copying. - Patched
Projects::Copy::WorkPackagesDependentServiceto remapsprint_idduring work package copy using the lookup, with integration specs covering owned vs shared sprint behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| modules/backlogs/spec/services/projects/copy_service_integration_spec.rb | Adds integration coverage for sprint copying and sprint reassignment on copied work packages. |
| modules/backlogs/lib/open_project/backlogs/patches/work_packages_dependent_service_patch.rb | Remaps copied work packages’ sprint_id via state.sprint_id_lookup. |
| modules/backlogs/lib/open_project/backlogs/patches/copy_service_patch.rb | Inserts SprintsDependentService into the project copy dependency chain before work packages. |
| modules/backlogs/lib/open_project/backlogs/engine.rb | Ensures the new patch for Projects::Copy::WorkPackagesDependentService is applied. |
| modules/backlogs/config/locales/en.yml | Adds the copy UI label for the new sprints dependency. |
| modules/backlogs/app/services/projects/copy/sprints_dependent_service.rb | New dependent service to copy sprints and populate state.sprint_id_lookup. |
Adds a `SprintsDependentService` mirroring the version copy path and injects it before the work-packages dependency so a source->copy sprint id lookup is available when work packages are copied. https://community.openproject.org/wp/AGILE-318
Patches the work-packages copy override to translate `sprint_id` through `state.sprint_id_lookup`: sprints owned by the source project remap to their copy, shared sprints keep their original id. Fixes copied work packages appearing in the source project's sprint completion modal. https://community.openproject.org/wp/AGILE-318
Assigns `state.sprint_id_lookup` after each created sprint (not once after the loop) so a mid-loop `create!` failure leaves the partial mapping intact instead of nil, which would otherwise make copied work packages retain the source project's sprint. Per-iteration assignment is required because `state` is a `Hashie::Mash`: assigning the empty hash before the loop converts it into a separate `Mash` instance, so later mutations of the plain `Hash` are invisible to `state`. Also strengthens the sprint-copy spec with a source/target decoupling assertion.
3ade633 to
f3d85ba
Compare
Drives the copy-project UI end to end and checks the copied work package lands in a copied sprint, not the source project's sprint, mirroring the version coverage in spec/features/projects/copy_spec.rb.
There was a problem hiding this comment.
I tried the feature out and it kind of works, but unfortunately there are a few shortcomings that need to be addressed.
- The positions of the work packages are not copied over. Here is a suggested solution . Hope you don't mind, I took the liberty and provided a commit (c79858c) with this fix.
- Out of scope: When a member user has the permission to Edit and Copy the project but no permission to View Sprints, the sprints checkbox is still shown on the copy form. The sprints are still copied over alongside the work packages. The expectation is to not show the "Sprints" checkbox, and do not copy the sprints over.
- While this might be out of scope of the PR, the Backlog Buckets are copied incorrectly. The work package copies in the new project are assigned with the bucket from the source project, which is incorrect. I think it would make sense to copy the buckets too, alternatively do not copy the wrong bucket ids.
Work packages inside a bucket do not show up in the project copy
The source project buckets are assigned in the copy project:
Edit: I was also wondering what should be the right behaviour when the Backlogs module is disabled in the source project? Currently the "Sprints" checkbox is still showing up in the copy form, however I'd say it shouldn't show up if the backlogs are disabled and sprints shouldn't be copied.
baa91a0 to
3512b24
Compare
Copied work packages kept a `backlog_bucket_id` pointing at the source project's bucket because project copy remapped sprints but not buckets. Mirror the sprint fix: copy owned buckets, then remap each work package.
Sprints and buckets only need to run before the work packages, so prepending them is simpler than locating the index and inserting.
3512b24 to
02dac87
Compare
Two feature specs each defined an identical wait_for_copy_to_finish. The copy is always triggered from the general settings page, so the helper moves onto that page object and both specs call it there.
…ise preserve the sprint assignment from shared sprints.
|
Warning Flaky specs
🤖 Ask Copilot to investigateCopy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer. |
|
Since there are open questions regarding this PR, I would hold off merging until they are cleared. |
The sprint and backlog-bucket services duplicated the copy-into-id-map loop and used `create!`, so a single invalid record aborted the whole project copy. A shared `copy_collection_with_id_map` on the copy `Dependency` base uses the non-raising `create` and skips unpersisted records, matching `VersionsDependentService`, and assigns `state` once instead of on every iteration.
The WorkPackagesDependentService patch runs for every project copy, so suppressing `acts_as_list` and forcing the source position also changed non-backlogs copies. Gate both overrides on `backlogs_enabled?` and read the id-map lookups nil-safely.
| let(:user) { create(:user, member_with_permissions: { project => permissions }) } | ||
| module InstanceMethods | ||
| def copy_work_package(source_work_package, parent_id, user_cf_ids) | ||
| return super unless source.backlogs_enabled? |
There was a problem hiding this comment.
@dombesz I think these guards are needed.
There was a problem hiding this comment.
Not necessarily, perhaps the permission check verification would be a more appropriate guard. I logged a separate ticket for this issue https://community.openproject.org/wp/AGILE-328 .
VersionsDependentService duplicated the copy-into-id-map loop that the new `copy_collection_with_id_map` now provides. Reusing it also skips unpersisted versions instead of mapping their id to nil.
Copying a sprint reproduced only its scalar columns, silently dropping its goals. The sprint copy now carries over the source project's own goals via nested attributes, remapped to the copy; goals owned by other projects on a shared sprint are left untouched.
| current_user { user } | ||
| def copy_dependency(*) | ||
| if source.receive_shared_sprints? | ||
| preserve_sprint_assignments |
|
Warning Flaky specs
🤖 Ask Copilot to investigateCopy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer. |
The RECEIVE_SHARED branch identity-mapped every received sprint, so a copy placed outside the sharing subtree kept work packages pointing at sprints it cannot see, and a receiving project's own (stale) sprints were never recreated. Copying now always reproduces owned sprints and preserves a shared assignment only when the copied project will still receive that sprint, clearing it otherwise.
41fe5e6 to
74ad337
Compare
|
Warning Flaky specs
🤖 Ask Copilot to investigateCopy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer. |
The shared `copy_collection_with_id_map` helper silently dropped any record whose copy failed validation, so a work package referencing it lost its assignment with the copy still reporting success. Failed copies are now merged into the dependency's error set, keeping the copy tolerant while making the skip visible instead of a silent data loss.
Copied saved queries kept their `sprint_id` and `backlog_bucket_id` filter values pointing at the source project's records, so a copied query matched nothing once the sprints and buckets were recreated with new ids. A `Queries::Copy::FiltersMapper` patch now remaps both through the copy id-maps, as already done for `version_id`.
`preserved_shared_sprints` filtered every shared sprint through `Sprint.receiving_projects(...).exists?` in Ruby, firing that CTE once per sprint and hydrating full records only to read their ids. It now resolves in a single query against the sprints the target natively receives. Goals are eager-loaded so `copied_goal_attributes` no longer queries per sprint.
| 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 |
| def copy_dependency(*) | ||
| state.sprint_id_lookup = copy_owned_sprints.merge(preserved_shared_sprints) | ||
| end |
| def copy_dependency(*) | ||
| state.backlog_bucket_id_lookup = copy_collection_with_id_map(:backlog_buckets) | ||
| end |
`CategoriesDependentService` hand-rolled the same copy loop the shared `copy_collection_with_id_map` helper now provides. Reusing it drops a stray mapping to a `nil` id when a category fails to persist and gives the copy fresh timestamps, matching every other copy dependent.
|
Warning Flaky specs
🤖 Ask Copilot to investigateCopy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer. |


Ticket
https://community.openproject.org/wp/AGILE-318
What are you trying to accomplish?
When a project is copied, work packages in the copy kept a
sprint_idpointing at the original project's sprint instead of a copy of it. The stray assignment is invisible on the source sprint's board but surfaces in the sprint-completion modal, where the copied work package is listed among the sprint's items. Saved queries and boards filtered on a sprint or backlog bucket had the same problem: their filter values were carried over verbatim and kept matching the source project's records.Since 17.6 made sprints their own model (a
sprintstable with asprint_idFK on work packages, parallel to versions), the project-copy path remappedversion_idbut had no equivalent forsprint_id/backlog_bucket_id.Screenshots
What approach did you choose and why?
Mirror the existing version-copy path, entirely within
modules/backlogs:SprintsDependentServiceandBacklogBucketsDependentServicecopy the source project's owned sprints and backlog buckets, each recording a source→copy id lookup, injected into the copy dependency list immediately before work packages are copied.sprint_idandbacklog_bucket_idthrough those lookups and carries the sourcepositionover unchanged (withacts_as_listcallbacks suppressed). Owned sprints/buckets are always recreated; a shared assignment (a sprint owned by another project) is kept only when the copied project will still receive that sprint given its placement, and cleared otherwise.Queries::Copy::FiltersMapperpatch remapssprint_id/backlog_bucket_idquery filters through the same lookups, as already done forversion_id, so copied queries and boards follow the copied records.copy_collection_with_id_maphelper onProjects::Copy::Dependency; the existing version copy reuses it too.Additional hardening (review follow-ups)
receiving_projectsCTE once per sprint; sprint goals are eager-loaded.CategoriesDependentServicenow reuses the sharedcopy_collection_with_id_maphelper, which also drops a stray mapping to anilid when a category fails to persist.Out of scope / follow-ups
receivable_by?invariant plus a data migration to heal existing rows. Tracked separately.position/acts_as_listoverride is left as a separate cleanup.Merge checklist