Commit f1c79b96 authored by Alexandru Croitor's avatar Alexandru Croitor Committed by Kamil Trzciński

Inherit {start,end}_date from child epics or milestones

Epic would now inherit start_date or end_date from its
related issues milestone dates or from child epic
depending on which one gives the wider time spread.

Uses a single update statement to update start_date, due_date,
start_date_sourcing_milestone_id, start_date_sourcing_epic_id,
due_date_sourcing_milestone_id, due_date_sourcing_epic_id

Epic inherited dates update for multiple epics moved to
an async worker

Update epic dates in batches

Add foreign key constraints and indexes on
start_date_sourcing_epic_id and due_date_sourcing_epic_id

https://gitlab.com/gitlab-org/gitlab-ee/issues/7332
parent 1bdca3e2
...@@ -120,3 +120,4 @@ ...@@ -120,3 +120,4 @@
- [update_external_pull_requests, 3] - [update_external_pull_requests, 3]
- [refresh_license_compliance_checks, 2] - [refresh_license_compliance_checks, 2]
- [design_management_new_version, 1] - [design_management_new_version, 1]
- [epics, 2]
# frozen_string_literal: true
class AddSourcingEpicDates < ActiveRecord::Migration[5.1]
DOWNTIME = false
def change
add_column :epics, :start_date_sourcing_epic_id, :integer
add_column :epics, :due_date_sourcing_epic_id, :integer
end
end
# frozen_string_literal: true
class AddSourcingEpicDatesFks < ActiveRecord::Migration[5.1]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :epics, :start_date_sourcing_epic_id, where: 'start_date_sourcing_epic_id is not null'
add_concurrent_index :epics, :due_date_sourcing_epic_id, where: 'due_date_sourcing_epic_id is not null'
add_concurrent_foreign_key :epics, :epics, column: :start_date_sourcing_epic_id, on_delete: :nullify
add_concurrent_foreign_key :epics, :epics, column: :due_date_sourcing_epic_id, on_delete: :nullify
end
def down
remove_foreign_key_if_exists :epics, column: :start_date_sourcing_epic_id
remove_foreign_key_if_exists :epics, column: :due_date_sourcing_epic_id
remove_concurrent_index :epics, :start_date_sourcing_epic_id
remove_concurrent_index :epics, :due_date_sourcing_epic_id
end
end
...@@ -1423,15 +1423,19 @@ ActiveRecord::Schema.define(version: 2019_10_17_045817) do ...@@ -1423,15 +1423,19 @@ ActiveRecord::Schema.define(version: 2019_10_17_045817) do
t.integer "parent_id" t.integer "parent_id"
t.integer "relative_position" t.integer "relative_position"
t.integer "state_id", limit: 2, default: 1, null: false t.integer "state_id", limit: 2, default: 1, null: false
t.integer "start_date_sourcing_epic_id"
t.integer "due_date_sourcing_epic_id"
t.index ["assignee_id"], name: "index_epics_on_assignee_id" t.index ["assignee_id"], name: "index_epics_on_assignee_id"
t.index ["author_id"], name: "index_epics_on_author_id" t.index ["author_id"], name: "index_epics_on_author_id"
t.index ["closed_by_id"], name: "index_epics_on_closed_by_id" t.index ["closed_by_id"], name: "index_epics_on_closed_by_id"
t.index ["due_date_sourcing_epic_id"], name: "index_epics_on_due_date_sourcing_epic_id", where: "(due_date_sourcing_epic_id IS NOT NULL)"
t.index ["end_date"], name: "index_epics_on_end_date" t.index ["end_date"], name: "index_epics_on_end_date"
t.index ["group_id"], name: "index_epics_on_group_id" t.index ["group_id"], name: "index_epics_on_group_id"
t.index ["iid"], name: "index_epics_on_iid" t.index ["iid"], name: "index_epics_on_iid"
t.index ["milestone_id"], name: "index_milestone" t.index ["milestone_id"], name: "index_milestone"
t.index ["parent_id"], name: "index_epics_on_parent_id" t.index ["parent_id"], name: "index_epics_on_parent_id"
t.index ["start_date"], name: "index_epics_on_start_date" t.index ["start_date"], name: "index_epics_on_start_date"
t.index ["start_date_sourcing_epic_id"], name: "index_epics_on_start_date_sourcing_epic_id", where: "(start_date_sourcing_epic_id IS NOT NULL)"
end end
create_table "events", id: :serial, force: :cascade do |t| create_table "events", id: :serial, force: :cascade do |t|
...@@ -4158,7 +4162,9 @@ ActiveRecord::Schema.define(version: 2019_10_17_045817) do ...@@ -4158,7 +4162,9 @@ ActiveRecord::Schema.define(version: 2019_10_17_045817) do
add_foreign_key "epic_issues", "epics", on_delete: :cascade add_foreign_key "epic_issues", "epics", on_delete: :cascade
add_foreign_key "epic_issues", "issues", on_delete: :cascade add_foreign_key "epic_issues", "issues", on_delete: :cascade
add_foreign_key "epic_metrics", "epics", on_delete: :cascade add_foreign_key "epic_metrics", "epics", on_delete: :cascade
add_foreign_key "epics", "epics", column: "due_date_sourcing_epic_id", name: "fk_013c9f36ca", on_delete: :nullify
add_foreign_key "epics", "epics", column: "parent_id", name: "fk_25b99c1be3", on_delete: :cascade add_foreign_key "epics", "epics", column: "parent_id", name: "fk_25b99c1be3", on_delete: :cascade
add_foreign_key "epics", "epics", column: "start_date_sourcing_epic_id", name: "fk_9d480c64b2", on_delete: :nullify
add_foreign_key "epics", "milestones", on_delete: :nullify add_foreign_key "epics", "milestones", on_delete: :nullify
add_foreign_key "epics", "namespaces", column: "group_id", name: "fk_f081aa4489", on_delete: :cascade add_foreign_key "epics", "namespaces", column: "group_id", name: "fk_f081aa4489", on_delete: :cascade
add_foreign_key "epics", "users", column: "assignee_id", name: "fk_dccd3f98fc", on_delete: :nullify add_foreign_key "epics", "users", column: "assignee_id", name: "fk_dccd3f98fc", on_delete: :nullify
......
...@@ -50,12 +50,14 @@ Example response: ...@@ -50,12 +50,14 @@ Example response:
"start_date": null, "start_date": null,
"start_date_is_fixed": false, "start_date_is_fixed": false,
"start_date_fixed": null, "start_date_fixed": null,
"start_date_from_milestones": null, "start_date_from_milestones": null, //deprecated in favor of start_date_from_inherited_source
"end_date": "2018-07-31", "start_date_from_inherited_source": null,
"end_date": "2018-07-31", //deprecated in favor of due_date
"due_date": "2018-07-31", "due_date": "2018-07-31",
"due_date_is_fixed": false, "due_date_is_fixed": false,
"due_date_fixed": null, "due_date_fixed": null,
"due_date_from_milestones": "2018-07-31", "due_date_from_milestones": "2018-07-31", //deprecated in favor of start_date_from_inherited_source
"due_date_from_inherited_source": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z", "created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z", "updated_at": "2018-07-18T12:22:05.239Z",
"labels": [] "labels": []
...@@ -102,12 +104,14 @@ Example response: ...@@ -102,12 +104,14 @@ Example response:
"start_date": null, "start_date": null,
"start_date_is_fixed": false, "start_date_is_fixed": false,
"start_date_fixed": null, "start_date_fixed": null,
"start_date_from_milestones": null, "start_date_from_milestones": null, //deprecated in favor of start_date_from_inherited_source
"end_date": "2018-07-31", "start_date_from_inherited_source": null,
"end_date": "2018-07-31", //deprecated in favor of due_date
"due_date": "2018-07-31", "due_date": "2018-07-31",
"due_date_is_fixed": false, "due_date_is_fixed": false,
"due_date_fixed": null, "due_date_fixed": null,
"due_date_from_milestones": "2018-07-31", "due_date_from_milestones": "2018-07-31", //deprecated in favor of start_date_from_inherited_source
"due_date_from_inherited_source": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z", "created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z", "updated_at": "2018-07-18T12:22:05.239Z",
"labels": [] "labels": []
...@@ -189,12 +193,14 @@ Example response: ...@@ -189,12 +193,14 @@ Example response:
"start_date": null, "start_date": null,
"start_date_is_fixed": false, "start_date_is_fixed": false,
"start_date_fixed": null, "start_date_fixed": null,
"start_date_from_milestones": null, "start_date_from_milestones": null, //deprecated in favor of start_date_from_inherited_source
"end_date": "2018-07-31", "start_date_from_inherited_source": null,
"end_date": "2018-07-31", //deprecated in favor of due_date
"due_date": "2018-07-31", "due_date": "2018-07-31",
"due_date_is_fixed": false, "due_date_is_fixed": false,
"due_date_fixed": null, "due_date_fixed": null,
"due_date_from_milestones": "2018-07-31", "due_date_from_milestones": "2018-07-31", //deprecated in favor of start_date_from_inherited_source
"due_date_from_inherited_source": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z", "created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z", "updated_at": "2018-07-18T12:22:05.239Z",
"labels": [] "labels": []
...@@ -241,12 +247,14 @@ Example response: ...@@ -241,12 +247,14 @@ Example response:
"start_date": null, "start_date": null,
"start_date_is_fixed": false, "start_date_is_fixed": false,
"start_date_fixed": null, "start_date_fixed": null,
"start_date_from_milestones": null, "start_date_from_milestones": null, //deprecated in favor of start_date_from_inherited_source
"end_date": "2018-07-31", "start_date_from_inherited_source": null,
"end_date": "2018-07-31", //deprecated in favor of due_date
"due_date": "2018-07-31", "due_date": "2018-07-31",
"due_date_is_fixed": false, "due_date_is_fixed": false,
"due_date_fixed": null, "due_date_fixed": null,
"due_date_from_milestones": "2018-07-31", "due_date_from_milestones": "2018-07-31", //deprecated in favor of start_date_from_inherited_source
"due_date_from_inherited_source": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z", "created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z", "updated_at": "2018-07-18T12:22:05.239Z",
"labels": [] "labels": []
......
...@@ -14,9 +14,13 @@ The [epic issues API](epic_issues.md) allows you to interact with issues associa ...@@ -14,9 +14,13 @@ The [epic issues API](epic_issues.md) allows you to interact with issues associa
> [Introduced][ee-6448] in GitLab 11.3. > [Introduced][ee-6448] in GitLab 11.3.
Since start date and due date can be dynamically sourced from related issue milestones, when user has edit permission, additional fields will be shown. These include two boolean fields `start_date_is_fixed` and `due_date_is_fixed`, and four date fields `start_date_fixed`, `start_date_from_milestones`, `due_date_fixed` and `due_date_from_milestones`. Since start date and due date can be dynamically sourced from related issue milestones, when user has edit permission,
additional fields will be shown. These include two boolean fields `start_date_is_fixed` and `due_date_is_fixed`,
and four date fields `start_date_fixed`, `start_date_from_inherited_source`, `due_date_fixed` and `due_date_from_inherited_source`.
`end_date` has been deprecated in favor of `due_date`. - `end_date` has been deprecated in favor of `due_date`.
- `start_date_from_milestones` has been deprecated in favor of `start_date_from_inherited_source`
- `due_date_from_milestones` has been deprecated in favor of `due_date_from_inherited_source`
## Epics pagination ## Epics pagination
...@@ -80,12 +84,14 @@ Example response: ...@@ -80,12 +84,14 @@ Example response:
"start_date": null, "start_date": null,
"start_date_is_fixed": false, "start_date_is_fixed": false,
"start_date_fixed": null, "start_date_fixed": null,
"start_date_from_milestones": null, "start_date_from_milestones": null, //deprecated in favor of start_date_from_inherited_source
"end_date": "2018-07-31", "start_date_from_inherited_source": null,
"end_date": "2018-07-31", //deprecated in favor of due_date
"due_date": "2018-07-31", "due_date": "2018-07-31",
"due_date_is_fixed": false, "due_date_is_fixed": false,
"due_date_fixed": null, "due_date_fixed": null,
"due_date_from_milestones": "2018-07-31", "due_date_from_milestones": "2018-07-31", //deprecated in favor of start_date_from_inherited_source
"due_date_from_inherited_source": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z", "created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z", "updated_at": "2018-07-18T12:22:05.239Z",
"closed_at": "2018-08-18T12:22:05.239Z", "closed_at": "2018-08-18T12:22:05.239Z",
...@@ -136,12 +142,14 @@ Example response: ...@@ -136,12 +142,14 @@ Example response:
"start_date": null, "start_date": null,
"start_date_is_fixed": false, "start_date_is_fixed": false,
"start_date_fixed": null, "start_date_fixed": null,
"start_date_from_milestones": null, "start_date_from_milestones": null, //deprecated in favor of start_date_from_inherited_source
"end_date": "2018-07-31", "start_date_from_inherited_source": null,
"end_date": "2018-07-31", //deprecated in favor of due_date
"due_date": "2018-07-31", "due_date": "2018-07-31",
"due_date_is_fixed": false, "due_date_is_fixed": false,
"due_date_fixed": null, "due_date_fixed": null,
"due_date_from_milestones": "2018-07-31", "due_date_from_milestones": "2018-07-31", //deprecated in favor of start_date_from_inherited_source
"due_date_from_inherited_source": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z", "created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z", "updated_at": "2018-07-18T12:22:05.239Z",
"closed_at": "2018-08-18T12:22:05.239Z", "closed_at": "2018-08-18T12:22:05.239Z",
...@@ -204,12 +212,14 @@ Example response: ...@@ -204,12 +212,14 @@ Example response:
"start_date": null, "start_date": null,
"start_date_is_fixed": false, "start_date_is_fixed": false,
"start_date_fixed": null, "start_date_fixed": null,
"start_date_from_milestones": null, "start_date_from_milestones": null, //deprecated in favor of start_date_from_inherited_source
"end_date": "2018-07-31", "start_date_from_inherited_source": null,
"end_date": "2018-07-31", //deprecated in favor of due_date
"due_date": "2018-07-31", "due_date": "2018-07-31",
"due_date_is_fixed": false, "due_date_is_fixed": false,
"due_date_fixed": null, "due_date_fixed": null,
"due_date_from_milestones": "2018-07-31", "due_date_from_milestones": "2018-07-31", //deprecated in favor of start_date_from_inherited_source
"due_date_from_inherited_source": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z", "created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z", "updated_at": "2018-07-18T12:22:05.239Z",
"closed_at": "2018-08-18T12:22:05.239Z", "closed_at": "2018-08-18T12:22:05.239Z",
...@@ -272,12 +282,14 @@ Example response: ...@@ -272,12 +282,14 @@ Example response:
"start_date": null, "start_date": null,
"start_date_is_fixed": false, "start_date_is_fixed": false,
"start_date_fixed": null, "start_date_fixed": null,
"start_date_from_milestones": null, "start_date_from_milestones": null, //deprecated in favor of start_date_from_inherited_source
"end_date": "2018-07-31", "start_date_from_inherited_source": null,
"end_date": "2018-07-31", //deprecated in favor of due_date
"due_date": "2018-07-31", "due_date": "2018-07-31",
"due_date_is_fixed": false, "due_date_is_fixed": false,
"due_date_fixed": null, "due_date_fixed": null,
"due_date_from_milestones": "2018-07-31", "due_date_from_milestones": "2018-07-31", //deprecated in favor of start_date_from_inherited_source
"due_date_from_inherited_source": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z", "created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z", "updated_at": "2018-07-18T12:22:05.239Z",
"closed_at": "2018-08-18T12:22:05.239Z", "closed_at": "2018-08-18T12:22:05.239Z",
......
...@@ -92,24 +92,44 @@ To remove a child epic from a parent epic: ...@@ -92,24 +92,44 @@ To remove a child epic from a parent epic:
## Start date and due date ## Start date and due date
To set a **Start date** and **Due date** for an epic, you can choose either of the following: To set a **Start date** and **Due date** for an epic, select one of the following:
- **Fixed**: Enter a fixed value. - **Fixed**: Enter a fixed value.
- **From milestones:** Inherit a dynamic value from the issues added to the epic. - **From milestones**: Inherit a dynamic value from the issues added to the epic.
- **Inherited**: Inherit a dynamic value from the issues added to the epic. ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7332) in GitLab 12.5 to replace **From milestones**).
If you select **From milestones** for the start date, GitLab will automatically set the ### Milestones
date to be earliest start date across all milestones that are currently assigned
to the issues that are added to the epic. Similarly, if you select "From milestones"
for the due date, GitLab will set it to be the latest due date across all
milestones that are currently assigned to those issues.
These are dynamic dates which are recalculated immediately if any of the following occur: If you select **From milestones** for the start date, GitLab will automatically set the date to be earliest
start date across all milestones that are currently assigned to the issues that are added to the epic.
Similarly, if you select **From milestones** for the due date, GitLab will set it to be the latest due date across
all milestones that are currently assigned to those issues.
These are dynamic dates which are recalculated if any of the following occur:
- Milestones are re-assigned to the issues. - Milestones are re-assigned to the issues.
- Milestone dates change. - Milestone dates change.
- Issues are added or removed from the epic. - Issues are added or removed from the epic.
## Roadmap ### Inherited
If you select **Inherited** for the start date, GitLab will scan all child epics and issues assigned to the epic,
and will set the start date to match the earliest found start date or milestone. Similarly, if you select
**Inherited** for the due date, GitLab will set the due date to match the latest due date or milestone
found among its child epics and issues.
These are dynamic dates and recalculated if any of the following occur:
- A child epic's dates change.
- Milestones are reassigned to an issue.
- A milestone's dates change.
- Issues are added to, or removed from, the epic.
Because the epic's dates can inherit dates from its children, the start date and due date propagate from the bottom to the top.
If the start date of a child epic on the lowest level changes, that becomes the earliest possible start date for its parent epic,
then the parent epic's start date will reflect the change and this will propagate upwards to the top epic.
## Roadmap in epics
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7327) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.10. > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7327) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.10.
......
...@@ -26,7 +26,7 @@ Epics in the view can be sorted by: ...@@ -26,7 +26,7 @@ Epics in the view can be sorted by:
Each option contains a button that toggles the sort order between **ascending** and **descending**. The sort option and order will be persisted when browsing Epics, Each option contains a button that toggles the sort order between **ascending** and **descending**. The sort option and order will be persisted when browsing Epics,
including the [epics list view](../epics/index.md). including the [epics list view](../epics/index.md).
Roadmaps can also be [visualized inside an epic](../epics/index.md#roadmap). Roadmaps can also be [visualized inside an epic](../epics/index.md#roadmap-in-epics).
## Timeline duration ## Timeline duration
......
...@@ -282,7 +282,7 @@ export default { ...@@ -282,7 +282,7 @@ export default {
type="radio" type="radio"
@click="toggleDateType(false)" @click="toggleDateType(false)"
/> />
<span class="prepend-left-5">{{ __('From milestones:') }}</span> <span class="prepend-left-5">{{ __('Inherited:') }}</span>
<span class="value-content prepend-left-2">{{ dateFromMilestonesWords }}</span> <span class="value-content prepend-left-2">{{ dateFromMilestonesWords }}</span>
<icon <icon
v-if="isDateInvalid && !selectedDateIsFixed" v-if="isDateInvalid && !selectedDateIsFixed"
......
...@@ -14,6 +14,7 @@ module EE ...@@ -14,6 +14,7 @@ module EE
include LabelEventable include LabelEventable
include RelativePositioning include RelativePositioning
include UsageStatistics include UsageStatistics
include FromUnion
enum state_id: { enum state_id: {
opened: ::Epic.available_states[:opened], opened: ::Epic.available_states[:opened],
...@@ -40,6 +41,8 @@ module EE ...@@ -40,6 +41,8 @@ module EE
belongs_to :group belongs_to :group
belongs_to :start_date_sourcing_milestone, class_name: 'Milestone' belongs_to :start_date_sourcing_milestone, class_name: 'Milestone'
belongs_to :due_date_sourcing_milestone, class_name: 'Milestone' belongs_to :due_date_sourcing_milestone, class_name: 'Milestone'
belongs_to :start_date_sourcing_epic, class_name: 'Epic'
belongs_to :due_date_sourcing_epic, class_name: 'Epic'
belongs_to :parent, class_name: "Epic" belongs_to :parent, class_name: "Epic"
has_many :children, class_name: "Epic", foreign_key: :parent_id has_many :children, class_name: "Epic", foreign_key: :parent_id
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
...@@ -55,8 +58,12 @@ module EE ...@@ -55,8 +58,12 @@ module EE
alias_attribute :parent_ids, :parent_id alias_attribute :parent_ids, :parent_id
alias_method :issuing_parent, :group alias_method :issuing_parent, :group
scope :for_ids, -> (ids) { where(id: ids) }
scope :in_parents, -> (parent_ids) { where(parent_id: parent_ids) } scope :in_parents, -> (parent_ids) { where(parent_id: parent_ids) }
scope :inc_group, -> { includes(:group) } scope :inc_group, -> { includes(:group) }
scope :in_milestone, -> (milestone_id) { joins(:issues).where(issues: { milestone_id: milestone_id }) }
scope :in_issues, -> (issues) { joins(:epic_issues).where(epic_issues: { issue_id: issues }).distinct }
scope :has_parent, -> { where.not(parent_id: nil) }
scope :order_start_or_end_date_asc, -> do scope :order_start_or_end_date_asc, -> do
reorder("COALESCE(start_date, end_date) ASC NULLS FIRST") reorder("COALESCE(start_date, end_date) ASC NULLS FIRST")
...@@ -83,12 +90,31 @@ module EE ...@@ -83,12 +90,31 @@ module EE
end end
scope :with_api_entity_associations, -> { preload(:author, :labels, group: :route) } scope :with_api_entity_associations, -> { preload(:author, :labels, group: :route) }
scope :start_date_inherited, -> { where(start_date_is_fixed: [nil, false]) }
scope :due_date_inherited, -> { where(due_date_is_fixed: [nil, false]) }
MAX_HIERARCHY_DEPTH = 5 MAX_HIERARCHY_DEPTH = 5
def etag_caching_enabled? def etag_caching_enabled?
true true
end end
before_save :set_fixed_start_date, if: :start_date_is_fixed?
before_save :set_fixed_due_date, if: :due_date_is_fixed?
private
def set_fixed_start_date
self.start_date = start_date_fixed
self.start_date_sourcing_milestone = nil
self.due_date_sourcing_epic = nil
end
def set_fixed_due_date
self.end_date = due_date_fixed
self.due_date_sourcing_milestone = nil
self.due_date_sourcing_epic = nil
end
end end
class_methods do class_methods do
...@@ -173,45 +199,6 @@ module EE ...@@ -173,45 +199,6 @@ module EE
def deepest_relationship_level def deepest_relationship_level
::Gitlab::ObjectHierarchy.new(self.where(parent_id: nil)).max_descendants_depth ::Gitlab::ObjectHierarchy.new(self.where(parent_id: nil)).max_descendants_depth
end end
def update_start_and_due_dates(epics)
self.where(id: epics).update_all(
[
%{
start_date = CASE WHEN start_date_is_fixed = true THEN start_date_fixed ELSE (?) END,
start_date_sourcing_milestone_id = (?),
end_date = CASE WHEN due_date_is_fixed = true THEN due_date_fixed ELSE (?) END,
due_date_sourcing_milestone_id = (?)
},
start_date_milestone_query.select(:start_date),
start_date_milestone_query.select(:id),
due_date_milestone_query.select(:due_date),
due_date_milestone_query.select(:id)
]
)
end
private
def start_date_milestone_query
source_milestones_query
.where.not(start_date: nil)
.order(:start_date, :id)
.limit(1)
end
def due_date_milestone_query
source_milestones_query
.where.not(due_date: nil)
.order(due_date: :desc, id: :desc)
.limit(1)
end
def source_milestones_query
::Milestone
.joins(issues: :epic_issue)
.where('epic_issues.epic_id = epics.id')
end
end end
def resource_parent def resource_parent
...@@ -247,10 +234,6 @@ module EE ...@@ -247,10 +234,6 @@ module EE
# Needed to use EntityDateHelper#remaining_days_in_words # Needed to use EntityDateHelper#remaining_days_in_words
alias_attribute(:due_date, :end_date) alias_attribute(:due_date, :end_date)
def update_start_and_due_dates
self.class.update_start_and_due_dates([self])
end
def start_date_from_milestones def start_date_from_milestones
start_date_is_fixed? ? start_date_sourcing_milestone&.start_date : start_date start_date_is_fixed? ? start_date_sourcing_milestone&.start_date : start_date
end end
...@@ -259,6 +242,22 @@ module EE ...@@ -259,6 +242,22 @@ module EE
due_date_is_fixed? ? due_date_sourcing_milestone&.due_date : due_date due_date_is_fixed? ? due_date_sourcing_milestone&.due_date : due_date
end end
def start_date_from_inherited_source
start_date_sourcing_milestone&.start_date || start_date_sourcing_epic&.start_date
end
def due_date_from_inherited_source
due_date_sourcing_milestone&.due_date || due_date_sourcing_epic&.end_date
end
def start_date_from_inherited_source_title
start_date_sourcing_milestone&.title || start_date_sourcing_epic&.title
end
def due_date_from_inherited_source_title
due_date_sourcing_milestone&.title || due_date_sourcing_epic&.title
end
def to_reference(from = nil, full: false) def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}" reference = "#{self.class.reference_prefix}#{iid}"
......
...@@ -87,30 +87,36 @@ class EpicPresenter < Gitlab::View::Presenter::Delegated ...@@ -87,30 +87,36 @@ class EpicPresenter < Gitlab::View::Presenter::Delegated
paths paths
end end
# todo:
#
# rename the hash keys to something more like inherited_source rather than milestone
# as now source can be noth milestone and child epic, but it does require a bunch of renaming on frontend as well
def start_dates def start_dates
{ {
start_date: epic.start_date, start_date: epic.start_date,
start_date_is_fixed: epic.start_date_is_fixed?, start_date_is_fixed: epic.start_date_is_fixed?,
start_date_fixed: epic.start_date_fixed, start_date_fixed: epic.start_date_fixed,
start_date_from_milestones: epic.start_date_from_milestones, start_date_from_milestones: epic.start_date_from_inherited_source,
start_date_sourcing_milestone_title: epic.start_date_sourcing_milestone&.title, start_date_sourcing_milestone_title: epic.start_date_from_inherited_source_title,
start_date_sourcing_milestone_dates: { start_date_sourcing_milestone_dates: {
start_date: epic.start_date_sourcing_milestone&.start_date, start_date: epic.start_date_from_inherited_source,
due_date: epic.start_date_sourcing_milestone&.due_date due_date: epic.due_date_from_inherited_source
} }
} }
end end
# todo:
# same renaming applies here
def due_dates def due_dates
{ {
due_date: epic.due_date, due_date: epic.due_date,
due_date_is_fixed: epic.due_date_is_fixed?, due_date_is_fixed: epic.due_date_is_fixed?,
due_date_fixed: epic.due_date_fixed, due_date_fixed: epic.due_date_fixed,
due_date_from_milestones: epic.due_date_from_milestones, due_date_from_milestones: epic.due_date_from_inherited_source,
due_date_sourcing_milestone_title: epic.due_date_sourcing_milestone&.title, due_date_sourcing_milestone_title: epic.due_date_from_inherited_source_title,
due_date_sourcing_milestone_dates: { due_date_sourcing_milestone_dates: {
start_date: epic.due_date_sourcing_milestone&.start_date, start_date: epic.start_date_from_inherited_source,
due_date: epic.due_date_sourcing_milestone&.due_date due_date: epic.due_date_from_inherited_source
} }
} }
end end
......
...@@ -11,7 +11,7 @@ module EE ...@@ -11,7 +11,7 @@ module EE
result = super result = super
if issue.previous_changes.include?(:milestone_id) && issue.epic if issue.previous_changes.include?(:milestone_id) && issue.epic
issue.epic.update_start_and_due_dates Epics::UpdateDatesService.new([issue.epic]).execute
end end
result result
......
...@@ -6,19 +6,15 @@ module EE ...@@ -6,19 +6,15 @@ module EE
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
override :execute override :execute
# rubocop: disable CodeReuse/ActiveRecord
def execute(milestone) def execute(milestone)
super super
if saved_change_to_dates?(milestone) if saved_change_to_dates?(milestone)
::Epic.update_start_and_due_dates( Epics::UpdateDatesService.new(::Epic.in_milestone(milestone.id)).execute
::Epic.joins(:issues).where(issues: { milestone_id: milestone.id })
)
end end
milestone milestone
end end
# rubocop: enable CodeReuse/ActiveRecord
private private
......
...@@ -8,21 +8,16 @@ module EpicIssues ...@@ -8,21 +8,16 @@ module EpicIssues
def relate_issuables(referenced_issue) def relate_issuables(referenced_issue)
link = EpicIssue.find_or_initialize_by(issue: referenced_issue) link = EpicIssue.find_or_initialize_by(issue: referenced_issue)
affected_epics = [issuable] params = if link.persisted?
{ issue_moved: true, original_epic: link.epic }
if link.persisted? else
affected_epics << link.epic {}
params = { issue_moved: true, original_epic: link.epic } end
else
params = {}
end
link.epic = issuable link.epic = issuable
link.move_to_start link.move_to_start
link.save! link.save!
affected_epics.each(&:update_start_and_due_dates)
yield params yield params
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
...@@ -43,6 +38,10 @@ module EpicIssues ...@@ -43,6 +38,10 @@ module EpicIssues
{ group: issuable.group } { group: issuable.group }
end end
def affected_epics(issues)
[issuable, Epic.in_issues(issues)].flatten.uniq
end
def linkable_issuables(issues) def linkable_issuables(issues)
@linkable_issues ||= begin @linkable_issues ||= begin
return [] unless can?(current_user, :admin_epic, issuable.group) return [] unless can?(current_user, :admin_epic, issuable.group)
......
...@@ -4,7 +4,8 @@ module EpicIssues ...@@ -4,7 +4,8 @@ module EpicIssues
class DestroyService < IssuableLinks::DestroyService class DestroyService < IssuableLinks::DestroyService
def execute def execute
result = super result = super
link.epic.update_start_and_due_dates Epics::UpdateDatesService.new([link.epic]).execute
result result
end end
......
...@@ -12,14 +12,16 @@ module EpicLinks ...@@ -12,14 +12,16 @@ module EpicLinks
private private
def affected_epics(epics)
[issuable, epics].flatten.uniq
end
def relate_issuables(referenced_epic) def relate_issuables(referenced_epic)
affected_epics = [issuable] affected_epics = [issuable]
affected_epics << referenced_epic if referenced_epic.parent affected_epics << referenced_epic if referenced_epic.parent
set_child_epic!(referenced_epic) set_child_epic!(referenced_epic)
affected_epics.each(&:update_start_and_due_dates)
yield yield
end end
......
# frozen_string_literal: true
module Epics
module Strategies
class BaseDatesStrategy
def initialize(epics)
@epics = epics
end
# rubocop: disable CodeReuse/ActiveRecord
def source_milestones_query
::Milestone
.joins(issues: :epic_issue)
.where("epic_issues.epic_id = epics.id")
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
end
# frozen_string_literal: true
module Epics
module Strategies
class DueDateInheritedStrategy < BaseDatesStrategy
# rubocop: disable CodeReuse/ActiveRecord
def execute
@epics.due_date_inherited.update_all(
[
%{ (end_date, due_date_sourcing_milestone_id, due_date_sourcing_epic_id) = (?) },
::Epic.from_union([max_milestone_due_date, max_child_epics_end_date], alias_as: 'max_date')
.select('max_end_date', 'milestone_id', 'epic_id')
.order("max_end_date desc")
.limit(1)
]
)
end
private
def max_milestone_due_date
source_milestones_query
.where.not(due_date: nil)
.select(
"milestones.due_date AS max_end_date",
"NULL AS epic_id",
"milestones.id AS milestone_id")
end
def max_child_epics_end_date
epic_dates = ::Epic.arel_table.alias('epic_dates')
::Epic
.where.not(epic_dates: { end_date: nil })
.where("epic_dates.parent_id = epics.id")
.select(
"epic_dates.end_date AS max_end_date",
"epic_dates.id AS epic_id",
"NULL AS milestone_id")
.from(epic_dates)
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
end
# frozen_string_literal: true
module Epics
module Strategies
class StartDateInheritedStrategy < BaseDatesStrategy
# rubocop: disable CodeReuse/ActiveRecord
def execute
@epics.start_date_inherited.update_all(
[
%{ (start_date, start_date_sourcing_milestone_id, start_date_sourcing_epic_id) = (?) },
::Epic.from_union([min_milestone_start_date, min_child_epics_start_date], alias_as: 'min_date')
.select('min_start_date', 'milestone_id', 'epic_id')
.order("min_start_date asc")
.limit(1)
]
)
end
private
def min_milestone_start_date
source_milestones_query
.where.not(start_date: nil)
.select(
"milestones.start_date AS min_start_date",
"NULL AS epic_id",
"milestones.id AS milestone_id")
end
def min_child_epics_start_date
epic_dates = ::Epic.arel_table.alias('epic_dates')
::Epic
.where.not(epic_dates: { start_date: nil })
.where("epic_dates.parent_id = epics.id")
.select(
"epic_dates.start_date AS min_start_date",
"epic_dates.id AS epic_id",
"NULL AS milestone_id")
.from(epic_dates)
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
end
# frozen_string_literal: true
module Epics
class UpdateDatesService < ::BaseService
BATCH_SIZE = 100
STRATEGIES = [
Epics::Strategies::StartDateInheritedStrategy,
Epics::Strategies::DueDateInheritedStrategy
].freeze
def initialize(epics)
@epics = epics
@epics = Epic.for_ids(@epics) unless @epics.is_a?(ActiveRecord::Relation)
end
def execute
each_batch do |relation, parent_ids|
STRATEGIES.each do |strategy|
strategy.new(relation).execute
end
if parent_ids.any? && Feature.enabled?(:epics_update_dates_upstream, default_enabled: true)
Epics::UpdateEpicsDatesWorker.perform_async(parent_ids)
end
end
end
private
# rubocop: disable CodeReuse/ActiveRecord
def each_batch
@epics.in_batches(of: BATCH_SIZE) do |relation| # rubocop: disable Cop/InBatches
parent_ids = relation.has_parent.distinct.pluck(:parent_id)
yield(relation, parent_ids)
end
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
...@@ -17,7 +17,7 @@ module Epics ...@@ -17,7 +17,7 @@ module Epics
update_task_event(epic) || update(epic) update_task_event(epic) || update(epic)
if saved_change_to_epic_dates?(epic) if saved_change_to_epic_dates?(epic)
epic.update_start_and_due_dates Epics::UpdateDatesService.new([epic]).execute
epic.reset epic.reset
end end
......
...@@ -37,11 +37,17 @@ module IssuableLinks ...@@ -37,11 +37,17 @@ module IssuableLinks
def create_links def create_links
objects = linkable_issuables(referenced_issuables) objects = linkable_issuables(referenced_issuables)
# it is important that this is not called after relate_issuables, as it relinks epic to the issuable
# see EpicLinks::EpicIssues#relate_issuables
affected_epics = affected_epics(objects)
objects.each do |referenced_object| objects.each do |referenced_object|
relate_issuables(referenced_object) do |params| relate_issuables(referenced_object) do |params|
create_notes(referenced_object, params) create_notes(referenced_object, params)
end end
end end
Epics::UpdateDatesService.new(affected_epics).execute unless affected_epics.blank?
end end
def referenced_issuables def referenced_issuables
...@@ -68,6 +74,10 @@ module IssuableLinks ...@@ -68,6 +74,10 @@ module IssuableLinks
references(extractor) references(extractor)
end end
def affected_epics(issues)
[]
end
def references(extractor) def references(extractor)
extractor.issues extractor.issues
end end
......
...@@ -75,3 +75,4 @@ ...@@ -75,3 +75,4 @@
- refresh_license_compliance_checks - refresh_license_compliance_checks
- repository_update_mirror - repository_update_mirror
- repository_push_audit_event - repository_push_audit_event
- epics:epics_update_epics_dates
# frozen_string_literal: true
module Epics
class UpdateEpicsDatesWorker
include ApplicationWorker
queue_namespace :epics
feature_category :agile_portfolio_management
def perform(epic_ids)
return if epic_ids.blank?
Epics::UpdateDatesService.new(Epic.for_ids(epic_ids)).execute
end
end
end
---
title: Inherit children epics start and due dates
merge_request: 14366
author:
type: changed
...@@ -288,11 +288,13 @@ module EE ...@@ -288,11 +288,13 @@ module EE
expose :author, using: ::API::Entities::UserBasic expose :author, using: ::API::Entities::UserBasic
expose :start_date expose :start_date
expose :start_date_is_fixed?, as: :start_date_is_fixed, if: can_admin_epic expose :start_date_is_fixed?, as: :start_date_is_fixed, if: can_admin_epic
expose :start_date_fixed, :start_date_from_milestones, if: can_admin_epic expose :start_date_fixed, :start_date_from_inherited_source, if: can_admin_epic
expose :end_date # @deprecated expose :start_date_from_milestones, if: can_admin_epic # @deprecated in favor of start_date_from_inherited_source
expose :end_date # @deprecated in favor of due_date
expose :end_date, as: :due_date expose :end_date, as: :due_date
expose :due_date_is_fixed?, as: :due_date_is_fixed, if: can_admin_epic expose :due_date_is_fixed?, as: :due_date_is_fixed, if: can_admin_epic
expose :due_date_fixed, :due_date_from_milestones, if: can_admin_epic expose :due_date_fixed, :due_date_from_inherited_source, if: can_admin_epic
expose :due_date_from_milestones, if: can_admin_epic # @deprecated in favor of due_date_from_inherited_source
expose :state expose :state
expose :web_edit_url, if: can_admin_epic # @deprecated expose :web_edit_url, if: can_admin_epic # @deprecated
expose :web_url expose :web_url
......
...@@ -30,11 +30,13 @@ ...@@ -30,11 +30,13 @@
"start_date": { "type": ["date", "null"] }, "start_date": { "type": ["date", "null"] },
"start_date_fixed": { "type": ["date", "null"] }, "start_date_fixed": { "type": ["date", "null"] },
"start_date_from_milestones": { "type": ["date", "null"] }, "start_date_from_milestones": { "type": ["date", "null"] },
"start_date_from_inherited_source": { "type": ["date", "null"] },
"start_date_is_fixed": { "type": "boolean" }, "start_date_is_fixed": { "type": "boolean" },
"end_date": { "type": ["date", "null"] }, "end_date": { "type": ["date", "null"] },
"due_date": { "type": ["date", "null"] }, "due_date": { "type": ["date", "null"] },
"due_date_fixed": { "type": ["date", "null"] }, "due_date_fixed": { "type": ["date", "null"] },
"due_date_from_milestones": { "type": ["date", "null"] }, "due_date_from_milestones": { "type": ["date", "null"] },
"due_date_from_inherited_source": { "type": ["date", "null"] },
"due_date_is_fixed": { "type": "boolean" }, "due_date_is_fixed": { "type": "boolean" },
"state": { "type": "string" }, "state": { "type": "string" },
"created_at": { "type": ["string", "null"] }, "created_at": { "type": ["string", "null"] },
......
...@@ -86,7 +86,7 @@ exports[`SidebarDatePicker renders expected template 1`] = ` ...@@ -86,7 +86,7 @@ exports[`SidebarDatePicker renders expected template 1`] = `
<span <span
class="prepend-left-5" class="prepend-left-5"
> >
From milestones: Inherited:
</span> </span>
<span <span
......
...@@ -112,12 +112,12 @@ describe('SidebarDatePicker', () => { ...@@ -112,12 +112,12 @@ describe('SidebarDatePicker', () => {
it('returns full date string in words when `dateFromMilestones` is defined', () => { it('returns full date string in words when `dateFromMilestones` is defined', () => {
createComponent({ dateFromMilestones: new Date(2018, 0, 1) }); createComponent({ dateFromMilestones: new Date(2018, 0, 1) });
expect(wrapper.text()).toContain('From milestones: Jan 1, 2018'); expect(wrapper.text()).toContain('Inherited: Jan 1, 2018');
}); });
it('returns `None` when `dateFromMilestones` is not defined', () => { it('returns `None` when `dateFromMilestones` is not defined', () => {
createComponent(); createComponent();
expect(wrapper.text()).toContain('From milestones: None'); expect(wrapper.text()).toContain('Inherited: None');
}); });
it('passes correct popover options to directive', () => { it('passes correct popover options to directive', () => {
......
...@@ -324,260 +324,6 @@ describe Epic do ...@@ -324,260 +324,6 @@ describe Epic do
end end
end end
describe '#update_start_and_due_dates' do
def update_and_reload_subject
subject.update_start_and_due_dates
subject.reload
end
context 'fixed date is set' do
subject { create(:epic, :use_fixed_dates, start_date: nil, end_date: nil) }
it 'updates to fixed date' do
update_and_reload_subject
expect(subject.start_date).to eq(subject.start_date_fixed)
expect(subject.due_date).to eq(subject.due_date_fixed)
end
end
context 'fixed date is not set' do
subject { create(:epic, start_date: nil, end_date: nil, group: group) }
let(:milestone1) do
create(
:milestone,
start_date: Date.new(2000, 1, 1),
due_date: Date.new(2000, 1, 10),
group: group
)
end
let(:milestone2) do
create(
:milestone,
start_date: Date.new(2000, 1, 3),
due_date: Date.new(2000, 1, 20),
group: group
)
end
context 'multiple milestones' do
before do
issue1 = create(:issue, project: project, milestone: milestone1)
issue2 = create(:issue, project: project, milestone: milestone2)
create(:epic_issue, epic: subject, issue: issue1)
create(:epic_issue, epic: subject, issue: issue2)
end
context 'complete start and due dates' do
it 'updates to milestone dates' do
update_and_reload_subject
expect(subject.start_date).to eq(milestone1.start_date)
expect(subject.due_date).to eq(milestone2.due_date)
end
end
context 'without due date' do
let(:milestone1) do
create(
:milestone,
start_date: Date.new(2000, 1, 1),
due_date: nil,
group: group
)
end
let(:milestone2) do
create(
:milestone,
start_date: Date.new(2000, 1, 3),
due_date: nil,
group: group
)
end
it 'updates to milestone dates' do
update_and_reload_subject
expect(subject.start_date).to eq(milestone1.start_date)
expect(subject.due_date).to eq(nil)
end
end
context 'without any dates' do
let(:milestone1) do
create(
:milestone,
start_date: nil,
due_date: nil,
group: group
)
end
let(:milestone2) do
create(
:milestone,
start_date: nil,
due_date: nil,
group: group
)
end
it 'updates to milestone dates' do
update_and_reload_subject
expect(subject.start_date).to eq(nil)
expect(subject.due_date).to eq(nil)
end
end
end
context 'without milestone' do
before do
create(:epic_issue, epic: subject)
end
it 'updates to milestone dates' do
update_and_reload_subject
expect(subject.start_date).to eq(nil)
expect(subject.start_date_sourcing_milestone_id).to eq(nil)
expect(subject.due_date).to eq(nil)
expect(subject.due_date_sourcing_milestone_id).to eq(nil)
end
end
context 'single milestone' do
before do
epic_issue1 = create(:epic_issue, epic: subject)
epic_issue1.issue.update(milestone: milestone1, project: project)
end
context 'complete start and due dates' do
it 'updates to milestone dates' do
update_and_reload_subject
expect(subject.start_date).to eq(milestone1.start_date)
expect(subject.due_date).to eq(milestone1.due_date)
end
end
context 'without due date' do
let(:milestone1) do
create(
:milestone,
start_date: Date.new(2000, 1, 1),
due_date: nil,
group: group
)
end
it 'updates to milestone dates' do
update_and_reload_subject
expect(subject.start_date).to eq(milestone1.start_date)
expect(subject.due_date).to eq(nil)
end
end
context 'without any dates' do
let(:milestone1) do
create(
:milestone,
start_date: nil,
due_date: nil,
group: group
)
end
it 'updates to milestone dates' do
update_and_reload_subject
expect(subject.start_date).to eq(nil)
expect(subject.due_date).to eq(nil)
end
end
end
end
end
describe '.update_start_and_due_dates' do
def link_epic_to_milestone(epic, milestone)
create(:issue, epic: epic, milestone: milestone, project: project)
end
it 'updates in bulk' do
milestone1 = create(:milestone, start_date: Date.new(2000, 1, 1), due_date: Date.new(2000, 1, 10), group: group)
milestone2 = create(:milestone, due_date: Date.new(2000, 1, 30), group: group)
epics = [
create(:epic),
create(:epic),
create(:epic, :use_fixed_dates)
]
old_attributes = epics.map(&:attributes)
link_epic_to_milestone(epics[0], milestone1)
link_epic_to_milestone(epics[0], milestone2)
link_epic_to_milestone(epics[1], milestone2)
link_epic_to_milestone(epics[2], milestone1)
link_epic_to_milestone(epics[2], milestone2)
described_class.update_start_and_due_dates(described_class.where(id: epics.map(&:id)))
epics.each(&:reload)
expect(epics[0].start_date).to eq(milestone1.start_date)
expect(epics[0].start_date_sourcing_milestone).to eq(milestone1)
expect(epics[0].due_date).to eq(milestone2.due_date)
expect(epics[0].due_date_sourcing_milestone).to eq(milestone2)
expect(epics[1].start_date).to eq(nil)
expect(epics[1].start_date_sourcing_milestone).to eq(nil)
expect(epics[1].due_date).to eq(milestone2.due_date)
expect(epics[1].due_date_sourcing_milestone).to eq(milestone2)
expect(epics[2].start_date).to eq(old_attributes[2]['start_date'])
expect(epics[2].start_date_sourcing_milestone).to eq(milestone1)
expect(epics[2].due_date).to eq(old_attributes[2]['end_date'])
expect(epics[2].due_date_sourcing_milestone).to eq(milestone2)
end
context 'query count check' do
let(:milestone) { create(:milestone, start_date: Date.new(2000, 1, 1), due_date: Date.new(2000, 1, 10), group: group) }
let!(:epics) { [create(:epic, group: group)] }
def setup_control_group
link_epic_to_milestone(epics[0], milestone)
ActiveRecord::QueryRecorder.new do
described_class.update_start_and_due_dates(described_class.where(id: epics.map(&:id)))
end.count
end
it 'does not increase query count when adding epics without milestones' do
control_count = setup_control_group
epics << create(:epic)
expect do
described_class.update_start_and_due_dates(described_class.where(id: epics.map(&:id)))
end.not_to exceed_query_limit(control_count)
end
it 'does not increase query count when adding epics belongs to same milestones' do
control_count = setup_control_group
epics << create(:epic)
link_epic_to_milestone(epics[1], milestone)
expect do
described_class.update_start_and_due_dates(described_class.where(id: epics.map(&:id)))
end.not_to exceed_query_limit(control_count)
end
end
end
describe '.deepest_relationship_level' do describe '.deepest_relationship_level' do
context 'when there are no epics' do context 'when there are no epics' do
it 'returns nil' do it 'returns nil' do
......
...@@ -19,8 +19,8 @@ describe Issues::UpdateService do ...@@ -19,8 +19,8 @@ describe Issues::UpdateService do
context 'updating milestone' do context 'updating milestone' do
let(:milestone) { create(:milestone, project: project) } let(:milestone) { create(:milestone, project: project) }
it 'calls epic#update_start_and_due_dates' do it 'calls UpdateDatesService' do
expect(epic).to receive(:update_start_and_due_dates).twice expect(Epics::UpdateDatesService).to receive(:new).with([epic]).and_call_original.twice
update_issue(milestone: milestone) update_issue(milestone: milestone)
update_issue(milestone_id: nil) update_issue(milestone_id: nil)
...@@ -59,8 +59,8 @@ describe Issues::UpdateService do ...@@ -59,8 +59,8 @@ describe Issues::UpdateService do
end end
context 'updating other fields' do context 'updating other fields' do
it 'does not call epic#update_start_and_due_dates' do it 'does not call UpdateDatesService' do
expect(epic).not_to receive(:update_start_and_due_dates) expect(Epics::UpdateDatesService).not_to receive(:new)
update_issue(title: 'foo') update_issue(title: 'foo')
end end
end end
......
...@@ -25,7 +25,7 @@ describe EpicIssues::CreateService do ...@@ -25,7 +25,7 @@ describe EpicIssues::CreateService do
let(:created_link) { EpicIssue.find_by!(issue_id: issue.id) } let(:created_link) { EpicIssue.find_by!(issue_id: issue.id) }
it 'creates a new relationship and updates epic' do it 'creates a new relationship and updates epic' do
expect(epic).to receive(:update_start_and_due_dates) expect(Epics::UpdateDatesService).to receive(:new).with([epic]).and_call_original
expect { subject }.to change(EpicIssue, :count).by(1) expect { subject }.to change(EpicIssue, :count).by(1)
expect(created_link).to have_attributes(epic: epic) expect(created_link).to have_attributes(epic: epic)
...@@ -262,14 +262,12 @@ describe EpicIssues::CreateService do ...@@ -262,14 +262,12 @@ describe EpicIssues::CreateService do
end end
it 'updates both old and new epic milestone dates' do it 'updates both old and new epic milestone dates' do
expect(Epics::UpdateDatesService).to receive(:new).with([another_epic, issue.epic]).and_call_original
allow(EpicIssue).to receive(:find_or_initialize_by).with(issue: issue).and_wrap_original { |m, *args| allow(EpicIssue).to receive(:find_or_initialize_by).with(issue: issue).and_wrap_original { |m, *args|
existing_epic_issue = m.call(*args) existing_epic_issue = m.call(*args)
expect(existing_epic_issue.epic).to receive(:update_start_and_due_dates)
existing_epic_issue existing_epic_issue
} }
expect(another_epic).to receive(:update_start_and_due_dates)
subject subject
end end
......
...@@ -79,8 +79,8 @@ describe EpicIssues::DestroyService do ...@@ -79,8 +79,8 @@ describe EpicIssues::DestroyService do
end end
context 'refresh epic dates' do context 'refresh epic dates' do
it 'calls epic#update_start_and_due_dates' do it 'calls UpdateDatesService' do
expect(epic).to receive(:update_start_and_due_dates) expect(Epics::UpdateDatesService).to receive(:new).with([epic_issue.epic]).and_call_original
subject subject
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe Epics::UpdateDatesService do
let(:group) { create(:group, :internal) }
let(:user) { create(:user) }
let(:project) { create(:project, group: group) }
let(:epic) { create(:epic, group: group) }
describe '#execute' do
context 'fixed date is set' do
let(:epic) { create(:epic, :use_fixed_dates, start_date: nil, end_date: nil, group: group) }
it 'updates to fixed date' do
described_class.new([epic]).execute
epic.reload
expect(epic.start_date).to eq(epic.start_date_fixed)
expect(epic.due_date).to eq(epic.due_date_fixed)
end
end
context 'fixed date is not set' do
subject { create(:epic, start_date: nil, end_date: nil, group: group) }
let(:milestone1) do
create(
:milestone,
start_date: Date.new(2000, 1, 1),
due_date: Date.new(2000, 1, 10),
group: group
)
end
let(:milestone2) do
create(
:milestone,
start_date: Date.new(2000, 1, 3),
due_date: Date.new(2000, 1, 20),
group: group
)
end
context 'multiple milestones' do
before do
issue1 = create(:issue, project: project, milestone: milestone1)
issue2 = create(:issue, project: project, milestone: milestone2)
create(:epic_issue, epic: epic, issue: issue1)
create(:epic_issue, epic: epic, issue: issue2)
end
context 'complete start and due dates' do
it 'updates to milestone dates' do
described_class.new([epic]).execute
epic.reload
expect(epic.start_date).to eq(milestone1.start_date)
expect(epic.due_date).to eq(milestone2.due_date)
end
end
context 'without due date' do
let(:milestone1) do
create(
:milestone,
start_date: Date.new(2000, 1, 1),
due_date: nil,
group: group
)
end
let(:milestone2) do
create(
:milestone,
start_date: Date.new(2000, 1, 3),
due_date: nil,
group: group
)
end
it 'updates to milestone dates' do
described_class.new([epic]).execute
epic.reload
expect(epic.start_date).to eq(milestone1.start_date)
expect(epic.due_date).to eq(nil)
end
end
context 'without any dates' do
let(:milestone1) do
create(
:milestone,
start_date: nil,
due_date: nil,
group: group
)
end
let(:milestone2) do
create(
:milestone,
start_date: nil,
due_date: nil,
group: group
)
end
it 'updates to milestone dates' do
described_class.new([epic]).execute
epic.reload
expect(epic.start_date).to eq(nil)
expect(epic.due_date).to eq(nil)
end
end
end
context 'without milestone' do
before do
create(:epic_issue, epic: epic)
end
it 'updates to milestone dates' do
described_class.new([epic]).execute
epic.reload
expect(epic.start_date).to eq(nil)
expect(epic.start_date_sourcing_milestone_id).to eq(nil)
expect(epic.due_date).to eq(nil)
expect(epic.due_date_sourcing_milestone_id).to eq(nil)
end
end
context 'single milestone' do
before do
epic_issue1 = create(:epic_issue, epic: epic)
epic_issue1.issue.update(milestone: milestone1, project: project)
end
context 'complete start and due dates' do
it 'updates to milestone dates' do
described_class.new([epic]).execute
epic.reload
expect(epic.start_date).to eq(milestone1.start_date)
expect(epic.due_date).to eq(milestone1.due_date)
end
end
context 'without due date' do
let(:milestone1) do
create(
:milestone,
start_date: Date.new(2000, 1, 1),
due_date: nil,
group: group
)
end
it 'updates to milestone dates' do
described_class.new([epic]).execute
epic.reload
expect(epic.start_date).to eq(milestone1.start_date)
expect(epic.due_date).to eq(nil)
end
end
context 'without any dates' do
let(:milestone1) do
create(
:milestone,
start_date: nil,
due_date: nil,
group: group
)
end
it 'updates to milestone dates' do
described_class.new([epic]).execute
epic.reload
expect(epic.start_date).to eq(nil)
expect(epic.due_date).to eq(nil)
end
end
end
end
describe '#when updating multiple epics' do
let(:milestone) { create(:milestone, start_date: Date.new(2000, 1, 1), due_date: Date.new(2000, 1, 10), group: group) }
def link_epic_to_milestone(epic, milestone)
create(:issue, epic: epic, milestone: milestone, project: project)
end
it 'updates in bulk' do
milestone1 = create(:milestone, start_date: Date.new(2000, 1, 1), due_date: Date.new(2000, 1, 10), group: group)
milestone2 = create(:milestone, due_date: Date.new(2000, 1, 30), group: group)
epics = [
create(:epic),
create(:epic),
create(:epic, :use_fixed_dates)
]
old_attributes = epics.map(&:attributes)
link_epic_to_milestone(epics[0], milestone1)
link_epic_to_milestone(epics[0], milestone2)
link_epic_to_milestone(epics[1], milestone2)
link_epic_to_milestone(epics[2], milestone1)
link_epic_to_milestone(epics[2], milestone2)
described_class.new(epics).execute
epics.each(&:reload)
expect(epics[0].start_date).to eq(milestone1.start_date)
expect(epics[0].start_date_sourcing_milestone).to eq(milestone1)
expect(epics[0].due_date).to eq(milestone2.due_date)
expect(epics[0].due_date_sourcing_milestone).to eq(milestone2)
expect(epics[1].start_date).to eq(nil)
expect(epics[1].start_date_sourcing_milestone).to eq(nil)
expect(epics[1].due_date).to eq(milestone2.due_date)
expect(epics[1].due_date_sourcing_milestone).to eq(milestone2)
expect(epics[2].start_date).to eq(old_attributes[2]['start_date'])
expect(epics[2].start_date).to eq(epics[2].start_date_fixed)
expect(epics[2].start_date_sourcing_milestone).to eq(nil)
expect(epics[2].due_date).to eq(old_attributes[2]['end_date'])
expect(epics[2].due_date).to eq(epics[2].due_date_fixed)
expect(epics[2].due_date_sourcing_milestone).to eq(nil)
end
context 'query count check' do
let!(:epics) { create_list(:epic, 2, group: group) }
def setup_control_group
link_epic_to_milestone(epics[0], milestone)
link_epic_to_milestone(epics[1], milestone)
ActiveRecord::QueryRecorder.new do
described_class.new(epics).execute
end.count
end
it 'does not increase query count when adding epics without milestones' do
control_count = setup_control_group
epics << create(:epic)
epics << create(:epic)
expect do
described_class.new(epics).execute
end.not_to exceed_query_limit(control_count)
end
it 'does not increase query count when adding epics belongs to same milestones' do
control_count = setup_control_group
epics << create(:epic)
epics << create(:epic)
link_epic_to_milestone(epics[1], milestone)
link_epic_to_milestone(epics[2], milestone)
expect do
described_class.new(epics).execute
end.not_to exceed_query_limit(control_count)
end
end
end
context "when epic dates are inherited" do
let(:epic) { create(:epic, group: group) }
context 'when epic has no issues' do
it "epic dates are nil" do
described_class.new([epic]).execute
epic.reload
expect(epic.start_date).to be_nil
expect(epic.end_date).to be_nil
expect(epic.start_date_sourcing_milestone).to be_nil
expect(epic.due_date_sourcing_milestone).to be_nil
end
end
context 'when epic has issues assigned to milestones' do
let(:milestone1) { create(:milestone, group: group, start_date: Date.new(2000, 1, 1), due_date: Date.new(2001, 1, 10)) }
let(:milestone2) { create(:milestone, group: group, start_date: Date.new(2001, 1, 1), due_date: Date.new(2002, 1, 10)) }
let!(:issue1) { create(:issue, epic: epic, project: project, milestone: milestone1) }
let!(:issue2) { create(:issue, epic: epic, project: project, milestone: milestone2) }
it "returns inherited milestone dates" do
described_class.new([epic]).execute
epic.reload
expect(epic.start_date).to eq(milestone1.start_date)
expect(epic.end_date).to eq(milestone2.due_date)
expect(epic.start_date_sourcing_milestone).to eq(milestone1)
expect(epic.due_date_sourcing_milestone).to eq(milestone2)
expect(epic.start_date_sourcing_epic).to be_nil
expect(epic.due_date_sourcing_epic).to be_nil
end
context "when epic has child epics" do
let!(:child_epic) { create(:epic, group: group, parent: epic, start_date: Date.new(1998, 1, 1), end_date: Date.new(1999, 1, 1)) }
it "returns inherited dates from child epics and milestones" do
expect(Epics::UpdateEpicsDatesWorker).not_to receive(:perform_async)
described_class.new([epic]).execute
epic.reload
expect(epic.start_date).to eq(child_epic.start_date)
expect(epic.end_date).to eq(milestone2.due_date)
expect(epic.start_date_sourcing_milestone).to be_nil
expect(epic.due_date_sourcing_milestone).to eq(milestone2)
expect(epic.start_date_sourcing_epic).to eq(child_epic)
expect(epic.due_date_sourcing_epic).to be_nil
end
context "when epic dates are propagated upwards" do
let(:top_level_parent_epic) { create(:epic, group: group) }
let(:parent_epic) { create(:epic, group: group, parent: top_level_parent_epic) }
before do
epic.update(parent: parent_epic)
end
it "propagates date changes to parent epics" do
expect(Epics::UpdateEpicsDatesWorker).to receive(:perform_async)
.with([epic.parent_id])
.and_call_original
expect(Epics::UpdateEpicsDatesWorker).to receive(:perform_async)
.with([parent_epic.parent_id])
.and_call_original
described_class.new([epic]).execute
epic.reload
parent_epic.reload
top_level_parent_epic.reload
expect(parent_epic.start_date).to eq(epic.start_date)
expect(parent_epic.end_date).to eq(epic.due_date)
expect(parent_epic.start_date_sourcing_milestone).to be_nil
expect(parent_epic.due_date_sourcing_milestone).to be_nil
expect(parent_epic.start_date_sourcing_epic).to eq(epic)
expect(parent_epic.due_date_sourcing_epic).to eq(epic)
expect(top_level_parent_epic.start_date).to eq(parent_epic.start_date)
expect(top_level_parent_epic.end_date).to eq(parent_epic.due_date)
expect(top_level_parent_epic.start_date_sourcing_milestone).to be_nil
expect(top_level_parent_epic.due_date_sourcing_milestone).to be_nil
expect(top_level_parent_epic.start_date_sourcing_epic).to eq(parent_epic)
expect(top_level_parent_epic.due_date_sourcing_epic).to eq(parent_epic)
end
end
end
end
end
end
end
...@@ -198,16 +198,18 @@ describe Epics::UpdateService do ...@@ -198,16 +198,18 @@ describe Epics::UpdateService do
context 'refresh epic dates' do context 'refresh epic dates' do
context 'date fields are updated' do context 'date fields are updated' do
it 'calls epic#update_start_and_due_dates' do it 'calls UpdateDatesService' do
expect(epic).to receive(:update_start_and_due_dates) expect(Epics::UpdateDatesService).to receive(:new).with([epic]).and_call_original
update_epic(start_date_is_fixed: true, start_date_fixed: Date.today) update_epic(start_date_is_fixed: true, start_date_fixed: Date.today)
epic.reload
expect(epic.start_date).to eq(epic.start_date_fixed)
end end
end end
context 'date fields are not updated' do context 'date fields are not updated' do
it 'does not call epic#update_start_and_due_dates' do it 'does not call UpdateDatesService' do
expect(epic).not_to receive(:update_start_and_due_dates) expect(Epics::UpdateDatesService).not_to receive(:new)
update_epic(title: 'foo') update_epic(title: 'foo')
end end
......
...@@ -29,7 +29,7 @@ module Gitlab ...@@ -29,7 +29,7 @@ module Gitlab
end end
if fragments.any? if fragments.any?
fragments.join("\n#{union_keyword}\n") "(" + fragments.join(")\n#{union_keyword}\n(") + ")"
else else
'NULL' 'NULL'
end end
......
...@@ -7427,9 +7427,6 @@ msgstr "" ...@@ -7427,9 +7427,6 @@ msgstr ""
msgid "From merge request merge until deploy to production" msgid "From merge request merge until deploy to production"
msgstr "" msgstr ""
msgid "From milestones:"
msgstr ""
msgid "From the Kubernetes cluster details view, install Runner from the applications list" msgid "From the Kubernetes cluster details view, install Runner from the applications list"
msgstr "" msgstr ""
...@@ -9019,6 +9016,9 @@ msgstr "" ...@@ -9019,6 +9016,9 @@ msgstr ""
msgid "Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}." msgid "Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}."
msgstr "" msgstr ""
msgid "Inherited:"
msgstr ""
msgid "Inline" msgid "Inline"
msgstr "" msgstr ""
......
...@@ -20,7 +20,7 @@ describe Gitlab::SQL::RecursiveCTE do ...@@ -20,7 +20,7 @@ describe Gitlab::SQL::RecursiveCTE do
[rel1.except(:order).to_sql, rel2.except(:order).to_sql] [rel1.except(:order).to_sql, rel2.except(:order).to_sql]
end end
expect(sql).to eq("#{name} AS (#{sql1}\nUNION\n#{sql2})") expect(sql).to eq("#{name} AS ((#{sql1})\nUNION\n(#{sql2}))")
end end
end end
......
...@@ -14,7 +14,7 @@ describe Gitlab::SQL::Union do ...@@ -14,7 +14,7 @@ describe Gitlab::SQL::Union do
it 'returns a String joining relations together using a UNION' do it 'returns a String joining relations together using a UNION' do
union = described_class.new([relation_1, relation_2]) union = described_class.new([relation_1, relation_2])
expect(union.to_sql).to eq("#{to_sql(relation_1)}\nUNION\n#{to_sql(relation_2)}") expect(union.to_sql).to eq("(#{to_sql(relation_1)})\nUNION\n(#{to_sql(relation_2)})")
end end
it 'skips Model.none segements' do it 'skips Model.none segements' do
...@@ -22,7 +22,7 @@ describe Gitlab::SQL::Union do ...@@ -22,7 +22,7 @@ describe Gitlab::SQL::Union do
union = described_class.new([empty_relation, relation_1, relation_2]) union = described_class.new([empty_relation, relation_1, relation_2])
expect {User.where("users.id IN (#{union.to_sql})").to_a}.not_to raise_error expect {User.where("users.id IN (#{union.to_sql})").to_a}.not_to raise_error
expect(union.to_sql).to eq("#{to_sql(relation_1)}\nUNION\n#{to_sql(relation_2)}") expect(union.to_sql).to eq("(#{to_sql(relation_1)})\nUNION\n(#{to_sql(relation_2)})")
end end
it 'uses UNION ALL when removing duplicates is disabled' do it 'uses UNION ALL when removing duplicates is disabled' do
......
...@@ -15,7 +15,7 @@ describe FromUnion do ...@@ -15,7 +15,7 @@ describe FromUnion do
it 'selects from the results of the UNION' do it 'selects from the results of the UNION' do
query = model.from_union([model.where(id: 1), model.where(id: 2)]) query = model.from_union([model.where(id: 1), model.where(id: 2)])
expect(query.to_sql).to match(/FROM \(SELECT.+UNION.+SELECT.+\) users/m) expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\nUNION\n\(SELECT.+\)\) users/m)
end end
it 'supports the use of a custom alias for the sub query' do it 'supports the use of a custom alias for the sub query' do
...@@ -24,7 +24,7 @@ describe FromUnion do ...@@ -24,7 +24,7 @@ describe FromUnion do
alias_as: 'kittens' alias_as: 'kittens'
) )
expect(query.to_sql).to match(/FROM \(SELECT.+UNION.+SELECT.+\) kittens/m) expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\nUNION\n\(SELECT.+\)\) kittens/m)
end end
it 'supports keeping duplicate rows' do it 'supports keeping duplicate rows' do
...@@ -34,7 +34,7 @@ describe FromUnion do ...@@ -34,7 +34,7 @@ describe FromUnion do
) )
expect(query.to_sql) expect(query.to_sql)
.to match(/FROM \(SELECT.+UNION ALL.+SELECT.+\) users/m) .to match(/FROM \(\(SELECT.+\)\nUNION ALL\n\(SELECT.+\)\) users/m)
end end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment