Commit 8b3c1f38 authored by Sean McGivern's avatar Sean McGivern

Merge branch '6470-milestone-dates-integrated-into-epics' into 'master'

Add date fields to Epic to enable dates to source from milestones

Closes #6470

See merge request gitlab-org/gitlab-ee!6448
parents e7ccc1f6 1e950bac
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
module Issues module Issues
class UpdateService < Issues::BaseService class UpdateService < Issues::BaseService
prepend EE::Issues::UpdateService
include SpamCheckService include SpamCheckService
def execute(issue) def execute(issue)
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
module Milestones module Milestones
class UpdateService < Milestones::BaseService class UpdateService < Milestones::BaseService
prepend EE::Milestones::UpdateService
def execute(milestone) def execute(milestone)
state = params[:state_event] state = params[:state_event]
......
...@@ -960,6 +960,12 @@ ActiveRecord::Schema.define(version: 20180803001726) do ...@@ -960,6 +960,12 @@ ActiveRecord::Schema.define(version: 20180803001726) do
t.string "title_html", null: false t.string "title_html", null: false
t.text "description" t.text "description"
t.text "description_html" t.text "description_html"
t.integer "start_date_sourcing_milestone_id"
t.integer "due_date_sourcing_milestone_id"
t.date "start_date_fixed"
t.date "due_date_fixed"
t.boolean "start_date_is_fixed"
t.boolean "due_date_is_fixed"
end end
add_index "epics", ["assignee_id"], name: "index_epics_on_assignee_id", using: :btree add_index "epics", ["assignee_id"], name: "index_epics_on_assignee_id", using: :btree
......
...@@ -10,6 +10,14 @@ If epics feature is not available a `403` status code will be returned. ...@@ -10,6 +10,14 @@ If epics feature is not available a `403` status code will be returned.
The [epic issues API](epic_issues.md) allows you to interact with issues associated with an epic. The [epic issues API](epic_issues.md) allows you to interact with issues associated with an epic.
# Milestone dates integration
> [Introduced][ee-6448] in GitLab 11.2.
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`.
`end_date` has been deprecated in favor of `due_date`.
## List epics for a group ## List epics for a group
Gets all epics of the requested group and its subgroups. Gets all epics of the requested group and its subgroups.
...@@ -51,11 +59,18 @@ Example response: ...@@ -51,11 +59,18 @@ Example response:
"avatar_url": "http://www.gravatar.com/avatar/018729e129a6f31c80a6327a30196823?s=80&d=identicon", "avatar_url": "http://www.gravatar.com/avatar/018729e129a6f31c80a6327a30196823?s=80&d=identicon",
"web_url": "http://localhost:3001/kam" "web_url": "http://localhost:3001/kam"
}, },
"labels": [],
"start_date": null, "start_date": null,
"end_date": null, "start_date_is_fixed": false,
"created_at": "2018-01-21T06:21:13.165Z", "start_date_fixed": null,
"updated_at": "2018-01-22T12:41:41.166Z" "start_date_from_milestones": null,
"end_date": "2018-07-31",
"due_date": "2018-07-31",
"due_date_is_fixed": false,
"due_date_fixed": null,
"due_date_from_milestones": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z",
"labels": []
} }
] ]
``` ```
...@@ -95,9 +110,17 @@ Example response: ...@@ -95,9 +110,17 @@ Example response:
"web_url": "http://localhost:3001/arnita" "web_url": "http://localhost:3001/arnita"
}, },
"start_date": null, "start_date": null,
"end_date": null, "start_date_is_fixed": false,
"created_at": "2018-01-21T06:21:13.165Z", "start_date_fixed": null,
"updated_at": "2018-01-22T12:41:41.166Z" "start_date_from_milestones": null,
"end_date": "2018-07-31",
"due_date": "2018-07-31",
"due_date_is_fixed": false,
"due_date_fixed": null,
"due_date_from_milestones": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z",
"labels": []
} }
``` ```
...@@ -139,11 +162,18 @@ Example response: ...@@ -139,11 +162,18 @@ Example response:
"id" : 18, "id" : 18,
"username" : "eileen.lowe" "username" : "eileen.lowe"
}, },
"labels": [],
"start_date": null, "start_date": null,
"end_date": null, "start_date_is_fixed": false,
"created_at": "2018-01-21T06:21:13.165Z", "start_date_fixed": null,
"updated_at": "2018-01-22T12:41:41.166Z" "start_date_from_milestones": null,
"end_date": "2018-07-31",
"due_date": "2018-07-31",
"due_date_is_fixed": false,
"due_date_fixed": null,
"due_date_from_milestones": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z",
"labels": []
} }
``` ```
...@@ -151,6 +181,8 @@ Example response: ...@@ -151,6 +181,8 @@ Example response:
Updates an epic Updates an epic
Note that after 11.2, `start_date` and `end_date` should no longer be updated directly, as they are now composite fields. User can configure the `_is_fixed` and `_fixed` fields instead.
``` ```
PUT /groups/:id/epics/:epic_iid PUT /groups/:id/epics/:epic_iid
``` ```
...@@ -162,8 +194,10 @@ PUT /groups/:id/epics/:epic_iid ...@@ -162,8 +194,10 @@ PUT /groups/:id/epics/:epic_iid
| `title` | string | no | The title of an epic | | `title` | string | no | The title of an epic |
| `description` | string | no | The description of an epic | | `description` | string | no | The description of an epic |
| `labels` | string | no | The comma separated list of labels | | `labels` | string | no | The comma separated list of labels |
| `start_date` | string | no | The start date of an epic | | `start_date_is_fixed` | boolean | no | Whether start date should be sourced from `start_date_fixed` |
| `end_date` | string. | no | The end date of an epic | | `start_date_fixed` | string | no | The fixed start date of an epic |
| `due_date_is_fixed` | boolean | no | Whether due date should be sourced from `due_date_fixed` |
| `due_date_fixed` | string | no | The fixed due date of an epic |
```bash ```bash
curl --header PUT "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/1/epics/5?title=New%20Title curl --header PUT "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/1/epics/5?title=New%20Title
...@@ -186,11 +220,18 @@ Example response: ...@@ -186,11 +220,18 @@ Example response:
"id" : 18, "id" : 18,
"username" : "eileen.lowe" "username" : "eileen.lowe"
}, },
"labels": [],
"start_date": null, "start_date": null,
"end_date": null, "start_date_is_fixed": false,
"created_at": "2018-01-21T06:21:13.165Z", "start_date_fixed": null,
"updated_at": "2018-01-22T12:41:41.166Z" "start_date_from_milestones": null,
"end_date": "2018-07-31",
"due_date": "2018-07-31",
"due_date_is_fixed": false,
"due_date_fixed": null,
"due_date_from_milestones": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z",
"labels": []
} }
``` ```
...@@ -278,3 +319,5 @@ Example response: ...@@ -278,3 +319,5 @@ Example response:
"created_at": "2016-07-01T11:09:13.992Z" "created_at": "2016-07-01T11:09:13.992Z"
} }
``` ```
[ee-6448]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/6448
...@@ -66,8 +66,10 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -66,8 +66,10 @@ class Groups::EpicsController < Groups::ApplicationController
[ [
:title, :title,
:description, :description,
:start_date, :start_date_fixed,
:end_date, :start_date_is_fixed,
:due_date_fixed,
:due_date_is_fixed,
label_ids: [] label_ids: []
] ]
end end
......
# frozen_string_literal: true
module Epics
class DateSourcingMilestonesFinder
include Gitlab::Utils::StrongMemoize
FIELDS = [:id, :start_date, :due_date].freeze
ID_INDEX = FIELDS.index(:id)
START_DATE_INDEX = FIELDS.index(:start_date)
DUE_DATE_INDEX = FIELDS.index(:due_date)
def initialize(epic_id)
@epic_id = epic_id
end
def execute
strong_memoize(:execute) do
Milestone.joins(issues: :epic_issue).where(epic_issues: { epic_id: epic_id }).joins(
<<~SQL
INNER JOIN (
SELECT MIN(milestones.start_date) AS start_date, MAX(milestones.due_date) AS due_date
FROM milestones
INNER JOIN issues ON issues.milestone_id = milestones.id
INNER JOIN epic_issues ON epic_issues.issue_id = issues.id
WHERE epic_issues.epic_id = #{epic_id}
) inner_results ON (inner_results.start_date = milestones.start_date OR inner_results.due_date = milestones.due_date)
SQL
).pluck(*FIELDS)
end
end
def start_date
start_date_sourcing_milestone&.slice(START_DATE_INDEX)
end
def start_date_sourcing_milestone_id
start_date_sourcing_milestone&.slice(ID_INDEX)
end
def due_date
due_date_sourcing_milestone&.slice(DUE_DATE_INDEX)
end
def due_date_sourcing_milestone_id
due_date_sourcing_milestone&.slice(ID_INDEX)
end
private
attr_reader :epic_id
def start_date_sourcing_milestone
@start_date_sourcing_milestone ||= execute
.reject { |row| row[START_DATE_INDEX].nil? }
.min_by { |row| row[START_DATE_INDEX] }
end
def due_date_sourcing_milestone
@due_date_sourcing_milestone ||= execute
.reject { |row| row[DUE_DATE_INDEX].nil? }
.max_by { |row| row[DUE_DATE_INDEX] }
end
end
end
...@@ -16,11 +16,25 @@ module EpicsHelper ...@@ -16,11 +16,25 @@ module EpicsHelper
todo_exists: todo.present?, todo_exists: todo.present?,
todo_path: group_todos_path(group), todo_path: group_todos_path(group),
start_date: epic.start_date, start_date: epic.start_date,
due_date: epic.due_date,
end_date: epic.end_date end_date: epic.end_date
} }
epic_meta[:todo_delete_path] = dashboard_todo_path(todo) if todo.present? epic_meta[:todo_delete_path] = dashboard_todo_path(todo) if todo.present?
if Ability.allowed?(current_user, :update_epic, epic.group)
epic_meta.merge!(
start_date_fixed: epic.start_date_fixed,
start_date_is_fixed: epic.start_date_is_fixed?,
start_date_from_milestones: epic.start_date_from_milestones,
start_date_sourcing_milestone_title: epic.start_date_sourcing_milestone&.title,
due_date_fixed: epic.due_date_fixed,
due_date_is_fixed: epic.due_date_is_fixed?,
due_date_from_milestones: epic.due_date_from_milestones,
due_date_sourcing_milestone_title: epic.due_date_sourcing_milestone&.title
)
end
participants = UserSerializer.new.represent(epic.participants) participants = UserSerializer.new.represent(epic.participants)
initial = opts[:initial].merge(labels: epic.labels, initial = opts[:initial].merge(labels: epic.labels,
participants: participants, participants: participants,
......
...@@ -13,10 +13,13 @@ module EE ...@@ -13,10 +13,13 @@ module EE
belongs_to :assignee, class_name: "User" belongs_to :assignee, class_name: "User"
belongs_to :group belongs_to :group
belongs_to :start_date_sourcing_milestone, class_name: 'Milestone'
belongs_to :due_date_sourcing_milestone, class_name: 'Milestone'
has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.epics&.maximum(:iid) } has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.epics&.maximum(:iid) }
has_many :epic_issues has_many :epic_issues
has_many :issues, through: :epic_issues
validates :group, presence: true validates :group, presence: true
...@@ -78,6 +81,36 @@ module EE ...@@ -78,6 +81,36 @@ module EE
def parent_class def parent_class
::Group ::Group
end end
def update_start_and_due_dates(epics)
groups = epics.includes(:issues).group_by do |epic|
milestone_ids = epic.issues.map(&:milestone_id)
milestone_ids.compact!
milestone_ids.uniq!
milestone_ids
end
groups.each do |milestone_ids, epics|
next if milestone_ids.empty?
results = Epics::DateSourcingMilestonesFinder.new(epics.first.id)
self.where(id: epics.map(&:id)).update_all(
[
%{
start_date = CASE WHEN start_date_is_fixed = true THEN start_date ELSE ? END,
start_date_sourcing_milestone_id = ?,
end_date = CASE WHEN due_date_is_fixed = true THEN end_date ELSE ? END,
due_date_sourcing_milestone_id = ?
},
results.start_date,
results.start_date_sourcing_milestone_id,
results.due_date,
results.due_date_sourcing_milestone_id
]
)
end
end
end end
def assignees def assignees
...@@ -109,6 +142,25 @@ module EE ...@@ -109,6 +142,25 @@ 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
results = Epics::DateSourcingMilestonesFinder.new(id)
self.start_date = start_date_is_fixed? ? start_date_fixed : results.start_date
self.start_date_sourcing_milestone_id = results.start_date_sourcing_milestone_id
self.due_date = due_date_is_fixed? ? due_date_fixed : results.due_date
self.due_date_sourcing_milestone_id = results.due_date_sourcing_milestone_id
save if changed?
end
def start_date_from_milestones
start_date_is_fixed? ? start_date_sourcing_milestone&.start_date : start_date
end
def due_date_from_milestones
due_date_is_fixed? ? due_date_sourcing_milestone&.due_date : due_date
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}"
...@@ -125,7 +177,7 @@ module EE ...@@ -125,7 +177,7 @@ module EE
def update_project_counter_caches def update_project_counter_caches
end end
def issues(current_user) def issues_readable_by(current_user)
related_issues = ::Issue.select('issues.*, epic_issues.id as epic_issue_id, epic_issues.relative_position') related_issues = ::Issue.select('issues.*, epic_issues.id as epic_issue_id, epic_issues.relative_position')
.joins(:epic_issue) .joins(:epic_issue)
.where("epic_issues.epic_id = #{id}") .where("epic_issues.epic_id = #{id}")
......
...@@ -6,8 +6,15 @@ class EpicEntity < IssuableEntity ...@@ -6,8 +6,15 @@ class EpicEntity < IssuableEntity
expose :group_full_name do |epic| expose :group_full_name do |epic|
epic.group.full_name epic.group.full_name
end end
expose :start_date expose :start_date
expose :end_date expose :start_date_is_fixed?, as: :start_date_is_fixed
expose :start_date_fixed, :start_date_from_milestones
expose :end_date # @deprecated
expose :end_date, as: :due_date
expose :due_date_is_fixed?, as: :due_date_is_fixed
expose :due_date_fixed, :due_date_from_milestones
expose :web_url do |epic| expose :web_url do |epic|
group_epic_path(epic.group, epic) group_epic_path(epic.group, epic)
end end
......
# frozen_string_literal: true
module EE
module Issues
module UpdateService
extend ::Gitlab::Utils::Override
override :execute
def execute(issue)
result = super
if issue.previous_changes.include?(:milestone_id) && issue.epic
issue.epic.update_start_and_due_dates
end
result
end
end
end
end
# frozen_string_literal: true
module EE
module Milestones
module UpdateService
extend ::Gitlab::Utils::Override
override :execute
def execute(milestone)
super
if dates_changed?(milestone)
::Epic.update_start_and_due_dates(
::Epic.joins(:issues).where(issues: { milestone_id: milestone.id })
)
end
milestone
end
private
def dates_changed?(milestone)
changes = milestone.previous_changes
changes.include?(:start_date) || changes.include?(:due_date)
end
end
end
end
module EpicIssues module EpicIssues
class CreateService < IssuableLinks::CreateService class CreateService < IssuableLinks::CreateService
def execute
result = super
issuable.update_start_and_due_dates
result
end
private private
def relate_issues(referenced_issue) def relate_issues(referenced_issue)
......
module EpicIssues module EpicIssues
class DestroyService < IssuableLinks::DestroyService class DestroyService < IssuableLinks::DestroyService
def execute
result = super
link.epic.update_start_and_due_dates
result
end
private private
def source def source
......
...@@ -5,7 +5,7 @@ module EpicIssues ...@@ -5,7 +5,7 @@ module EpicIssues
def issues def issues
return [] unless issuable&.group&.feature_available?(:epics) return [] unless issuable&.group&.feature_available?(:epics)
issuable.issues(current_user) issuable.issues_readable_by(current_user)
end end
def relation_path(issue) def relation_path(issue)
......
module Epics module Epics
class UpdateService < Epics::BaseService class UpdateService < Epics::BaseService
EPIC_DATE_FIELDS = %I[
start_date_fixed
start_date_is_fixed
due_date_fixed
due_date_is_fixed
].freeze
def execute(epic) def execute(epic)
# start_date and end_date columns are no longer writable by users because those
# are composite fields managed by the system.
params.except!(:start_date, :end_date)
update(epic) update(epic)
epic.update_start_and_due_dates if have_epic_dates_changed?(epic)
epic epic
end end
...@@ -17,5 +30,11 @@ module Epics ...@@ -17,5 +30,11 @@ module Epics
todo_service.update_epic(epic, current_user, old_mentioned_users) todo_service.update_epic(epic, current_user, old_mentioned_users)
end end
private
def have_epic_dates_changed?(epic)
(epic.previous_changes.keys.map(&:to_sym) & EPIC_DATE_FIELDS).present?
end
end end
end end
---
title: Allow epic start/due dates to be sourceable from issue milestones
merge_request: 6470
author:
type: added
# frozen_string_literal: true
class AddDateColumnsToEpics < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
change_table :epics do |t|
t.references :start_date_sourcing_milestone
t.references :due_date_sourcing_milestone
t.date :start_date_fixed
t.date :due_date_fixed
t.boolean :start_date_is_fixed
t.boolean :due_date_is_fixed
end
end
end
# frozen_string_literal: true
class UpdateDateColumnsOnEpics < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
class Epic < ActiveRecord::Base
self.table_name = 'epics'
include EachBatch
end
def up
Epic.where.not(start_date: nil).each_batch do |batch|
batch.update_all('start_date_is_fixed = true, start_date_fixed = start_date')
end
Epic.where.not(end_date: nil).each_batch do |batch|
batch.update_all('due_date_is_fixed = true, due_date_fixed = end_date')
end
end
def down
# NOOP
end
end
# frozen_string_literal: true
class UpdateEpicDatesFromMilestones < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
class Epic < ActiveRecord::Base
self.table_name = 'epics'
include EachBatch
has_many :epic_issues
has_many :issues, through: :epic_issues
def self.update_start_and_due_dates(epics)
groups = epics.includes(:issues).group_by do |epic|
milestone_ids = epic.issues.map(&:milestone_id)
milestone_ids.compact!
milestone_ids.uniq!
milestone_ids
end
groups.each do |milestone_ids, epics|
next if milestone_ids.empty?
data = ::UpdateEpicDatesFromMilestones::Epics::DateSourcingMilestonesFinder.new(epics.first.id)
self.where(id: epics.map(&:id)).update_all(
[
%{
start_date = CASE WHEN start_date_is_fixed = true THEN start_date ELSE ? END,
start_date_sourcing_milestone_id = ?,
end_date = CASE WHEN due_date_is_fixed = true THEN end_date ELSE ? END,
due_date_sourcing_milestone_id = ?
},
data.start_date,
data.start_date_sourcing_milestone_id,
data.due_date,
data.due_date_sourcing_milestone_id
]
)
end
end
end
module Epics
class DateSourcingMilestonesFinder
include Gitlab::Utils::StrongMemoize
FIELDS = [:id, :start_date, :due_date].freeze
ID_INDEX = FIELDS.index(:id)
START_DATE_INDEX = FIELDS.index(:start_date)
DUE_DATE_INDEX = FIELDS.index(:due_date)
def initialize(epic_id)
@epic_id = epic_id
end
def execute
strong_memoize(:execute) do
Milestone.joins(issues: :epic_issue).where(epic_issues: { epic_id: epic_id }).joins(
<<~SQL
INNER JOIN (
SELECT MIN(milestones.start_date) AS start_date, MAX(milestones.due_date) AS due_date
FROM milestones
INNER JOIN issues ON issues.milestone_id = milestones.id
INNER JOIN epic_issues ON epic_issues.issue_id = issues.id
WHERE epic_issues.epic_id = #{epic_id}
) inner_results ON (inner_results.start_date = milestones.start_date OR inner_results.due_date = milestones.due_date)
SQL
).pluck(*FIELDS)
end
end
def start_date
start_date_sourcing_milestone&.slice(START_DATE_INDEX)
end
def start_date_sourcing_milestone_id
start_date_sourcing_milestone&.slice(ID_INDEX)
end
def due_date
due_date_sourcing_milestone&.slice(DUE_DATE_INDEX)
end
def due_date_sourcing_milestone_id
due_date_sourcing_milestone&.slice(ID_INDEX)
end
private
attr_reader :epic_id
def start_date_sourcing_milestone
@start_date_sourcing_milestone ||= execute
.reject { |row| row[START_DATE_INDEX].nil? }
.min_by { |row| row[START_DATE_INDEX] }
end
def due_date_sourcing_milestone
@due_date_sourcing_milestone ||= execute
.reject { |row| row[DUE_DATE_INDEX].nil? }
.max_by { |row| row[DUE_DATE_INDEX] }
end
end
end
def up
# Fill fixed date columns for remaining eligible records touched after regular migration is run
# (20180711014026_update_date_columns_on_epics) but before new app code takes effect.
Epic.where(start_date_is_fixed: nil).where.not(start_date: nil).each_batch do |batch|
batch.update_all('start_date_is_fixed = true, start_date_fixed = start_date')
end
Epic.where(due_date_is_fixed: nil).where.not(end_date: nil).each_batch do |batch|
batch.update_all('due_date_is_fixed = true, due_date_fixed = end_date')
end
Epic.joins(:issues).where('issues.milestone_id IS NOT NULL').each_batch do |epics|
Epic.update_start_and_due_dates(epics)
end
end
def down
# NOOP
end
end
...@@ -53,7 +53,7 @@ module API ...@@ -53,7 +53,7 @@ module API
# For now we return empty body # For now we return empty body
# The issues list in the correct order in body will be returned as part of #4250 # The issues list in the correct order in body will be returned as part of #4250
if result if result
present epic.issues(current_user), present epic.issues_readable_by(current_user),
with: EE::API::Entities::EpicIssue, with: EE::API::Entities::EpicIssue,
current_user: current_user current_user: current_user
else else
...@@ -70,7 +70,7 @@ module API ...@@ -70,7 +70,7 @@ module API
get ':id/(-/)epics/:epic_iid/issues' do get ':id/(-/)epics/:epic_iid/issues' do
authorize_can_read! authorize_can_read!
present epic.issues(current_user), present epic.issues_readable_by(current_user),
with: EE::API::Entities::EpicIssue, with: EE::API::Entities::EpicIssue,
current_user: current_user current_user: current_user
end end
......
...@@ -58,7 +58,7 @@ module API ...@@ -58,7 +58,7 @@ module API
optional :labels, type: String, desc: 'Comma-separated list of label names' optional :labels, type: String, desc: 'Comma-separated list of label names'
end end
get ':id/(-/)epics' do get ':id/(-/)epics' do
present find_epics(group_id: user_group.id), with: EE::API::Entities::Epic present find_epics(group_id: user_group.id), with: EE::API::Entities::Epic, user: current_user
end end
desc 'Get details of an epic' do desc 'Get details of an epic' do
...@@ -70,7 +70,7 @@ module API ...@@ -70,7 +70,7 @@ module API
get ':id/(-/)epics/:epic_iid' do get ':id/(-/)epics/:epic_iid' do
authorize_can_read! authorize_can_read!
present epic, with: EE::API::Entities::Epic present epic, with: EE::API::Entities::Epic, user: current_user
end end
desc 'Create a new epic' do desc 'Create a new epic' do
...@@ -79,8 +79,10 @@ module API ...@@ -79,8 +79,10 @@ module API
params do params do
requires :title, type: String, desc: 'The title of an epic' requires :title, type: String, desc: 'The title of an epic'
optional :description, type: String, desc: 'The description of an epic' optional :description, type: String, desc: 'The description of an epic'
optional :start_date, type: String, desc: 'The start date of an epic' optional :start_date, as: :start_date_fixed, type: String, desc: 'The start date of an epic'
optional :end_date, type: String, desc: 'The end date of an epic' optional :start_date_is_fixed, type: Boolean, desc: 'Indicates start date should be sourced from start_date_fixed field not the issue milestones'
optional :end_date, as: :due_date_fixed, type: String, desc: 'The due date of an epic'
optional :due_date_is_fixed, type: Boolean, desc: 'Indicates due date should be sourced from due_date_fixed field not the issue milestones'
optional :labels, type: String, desc: 'Comma-separated list of label names' optional :labels, type: String, desc: 'Comma-separated list of label names'
end end
post ':id/(-/)epics' do post ':id/(-/)epics' do
...@@ -88,7 +90,7 @@ module API ...@@ -88,7 +90,7 @@ module API
epic = ::Epics::CreateService.new(user_group, current_user, declared_params(include_missing: false)).execute epic = ::Epics::CreateService.new(user_group, current_user, declared_params(include_missing: false)).execute
if epic.valid? if epic.valid?
present epic, with: EE::API::Entities::Epic present epic, with: EE::API::Entities::Epic, user: current_user
else else
render_validation_error!(epic) render_validation_error!(epic)
end end
...@@ -101,10 +103,12 @@ module API ...@@ -101,10 +103,12 @@ module API
requires :epic_iid, type: Integer, desc: 'The internal ID of an epic' requires :epic_iid, type: Integer, desc: 'The internal ID of an epic'
optional :title, type: String, desc: 'The title of an epic' optional :title, type: String, desc: 'The title of an epic'
optional :description, type: String, desc: 'The description of an epic' optional :description, type: String, desc: 'The description of an epic'
optional :start_date, type: String, desc: 'The start date of an epic' optional :start_date, as: :start_date_fixed, type: String, desc: 'The start date of an epic'
optional :end_date, type: String, desc: 'The end date of an epic' optional :start_date_is_fixed, type: Boolean, desc: 'Indicates start date should be sourced from start_date_fixed field not the issue milestones'
optional :end_date, as: :due_date_fixed, type: String, desc: 'The due date of an epic'
optional :due_date_is_fixed, type: Boolean, desc: 'Indicates due date should be sourced from due_date_fixed field not the issue milestones'
optional :labels, type: String, desc: 'Comma-separated list of label names' optional :labels, type: String, desc: 'Comma-separated list of label names'
at_least_one_of :title, :description, :start_date, :end_date, :labels at_least_one_of :title, :description, :start_date_fixed, :due_date_fixed, :labels
end end
put ':id/(-/)epics/:epic_iid' do put ':id/(-/)epics/:epic_iid' do
authorize_can_admin! authorize_can_admin!
...@@ -114,7 +118,7 @@ module API ...@@ -114,7 +118,7 @@ module API
result = ::Epics::UpdateService.new(user_group, current_user, update_params).execute(epic) result = ::Epics::UpdateService.new(user_group, current_user, update_params).execute(epic)
if result.valid? if result.valid?
present result, with: EE::API::Entities::Epic present result, with: EE::API::Entities::Epic, user: current_user
else else
render_validation_error!(result) render_validation_error!(result)
end end
......
...@@ -164,6 +164,8 @@ module EE ...@@ -164,6 +164,8 @@ module EE
end end
class Epic < Grape::Entity class Epic < Grape::Entity
can_admin_epic = ->(epic, opts) { Ability.allowed?(opts[:user], :admin_epic, epic) }
expose :id expose :id
expose :iid expose :iid
expose :group_id expose :group_id
...@@ -171,7 +173,12 @@ module EE ...@@ -171,7 +173,12 @@ module EE
expose :description expose :description
expose :author, using: ::API::Entities::UserBasic expose :author, using: ::API::Entities::UserBasic
expose :start_date expose :start_date
expose :end_date 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 :end_date # @deprecated
expose :end_date, as: :due_date
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 :created_at expose :created_at
expose :updated_at expose :updated_at
expose :labels do |epic, options| expose :labels do |epic, options|
......
...@@ -181,9 +181,11 @@ describe Groups::EpicsController do ...@@ -181,9 +181,11 @@ describe Groups::EpicsController do
end end
describe 'PUT #update' do describe 'PUT #update' do
let(:date) { Date.new(2002, 1, 1)}
before do before do
group.add_developer(user) group.add_developer(user)
put :update, group_id: group, id: epic.to_param, epic: { title: 'New title', label_ids: [label.id] }, format: :json put :update, group_id: group, id: epic.to_param, epic: { title: 'New title', label_ids: [label.id], start_date_fixed: '2002-01-01', start_date_is_fixed: true }, format: :json
end end
it 'returns status 200' do it 'returns status 200' do
...@@ -195,6 +197,9 @@ describe Groups::EpicsController do ...@@ -195,6 +197,9 @@ describe Groups::EpicsController do
expect(epic.title).to eq('New title') expect(epic.title).to eq('New title')
expect(epic.labels).to eq([label]) expect(epic.labels).to eq([label])
expect(epic.start_date_fixed).to eq(date)
expect(epic.start_date).to eq(date)
expect(epic.start_date_is_fixed).to eq(true)
end end
end end
......
...@@ -4,6 +4,15 @@ FactoryBot.define do ...@@ -4,6 +4,15 @@ FactoryBot.define do
group group
author author
trait :use_fixed_dates do
start_date { Date.new(2010, 1, 1) }
start_date_fixed { Date.new(2010, 1, 1) }
start_date_is_fixed true
end_date { Date.new(2010, 1, 3) }
due_date_fixed { Date.new(2010, 1, 3) }
due_date_is_fixed true
end
factory :labeled_epic do factory :labeled_epic do
transient do transient do
labels [] labels []
......
# frozen_string_literal: true
require 'spec_helper'
describe Epics::DateSourcingMilestonesFinder do
describe '#execute' do
it 'returns date and id from milestones' do
epic = create(:epic)
milestone1 = create(:milestone, start_date: Date.new(2000, 1, 1), due_date: Date.new(2000, 1, 10))
milestone2 = create(:milestone, due_date: Date.new(2000, 1, 30))
milestone3 = create(:milestone, start_date: Date.new(2000, 1, 1), due_date: Date.new(2000, 1, 20))
create(:issue, epic: epic, milestone: milestone1)
create(:issue, epic: epic, milestone: milestone2)
create(:issue, epic: epic, milestone: milestone3)
results = described_class.new(epic.id)
expect(results).to have_attributes(
start_date: milestone1.start_date,
start_date_sourcing_milestone_id: milestone1.id,
due_date: milestone2.due_date,
due_date_sourcing_milestone_id: milestone2.id
)
end
it 'returns date and id from single milestone' do
epic = create(:epic)
milestone1 = create(:milestone, start_date: Date.new(2000, 1, 1), due_date: Date.new(2000, 1, 10))
create(:issue, epic: epic, milestone: milestone1)
results = described_class.new(epic.id)
expect(results).to have_attributes(
start_date: milestone1.start_date,
start_date_sourcing_milestone_id: milestone1.id,
due_date: milestone1.due_date,
due_date_sourcing_milestone_id: milestone1.id
)
end
it 'returns date and id from milestone without date' do
epic = create(:epic)
milestone1 = create(:milestone, start_date: Date.new(2000, 1, 1))
create(:issue, epic: epic, milestone: milestone1)
results = described_class.new(epic.id)
expect(results).to have_attributes(
start_date: milestone1.start_date,
start_date_sourcing_milestone_id: milestone1.id,
due_date: nil,
due_date_sourcing_milestone_id: nil
)
end
it 'handles epics without milestone' do
epic = create(:epic)
results = described_class.new(epic.id)
expect(results).to have_attributes(
start_date: nil,
start_date_sourcing_milestone_id: nil,
due_date: nil,
due_date_sourcing_milestone_id: nil
)
end
end
end
...@@ -12,7 +12,14 @@ ...@@ -12,7 +12,14 @@
"milestone_id": { "type": ["string", "null"] }, "milestone_id": { "type": ["string", "null"] },
"state": { "type": "string" }, "state": { "type": "string" },
"start_date": { "type": ["date", "null"] }, "start_date": { "type": ["date", "null"] },
"start_date_fixed": { "type": ["date", "null"] },
"start_date_is_fixed": { "type": "boolean" },
"start_date_from_milestones": { "type": ["date", "null"] },
"end_date": { "type": ["date", "null"] }, "end_date": { "type": ["date", "null"] },
"due_date": { "type": ["date", "null"] },
"due_date_fixed": { "type": ["date", "null"] },
"due_date_from_milestones": { "type": ["date", "null"] },
"due_date_is_fixed": { "type": "boolean" },
"updated_by_id": { "type": ["string", "null"] }, "updated_by_id": { "type": ["string", "null"] },
"created_at": { "type": "string" }, "created_at": { "type": "string" },
"updated_at": { "type": "string" }, "updated_at": { "type": "string" },
......
...@@ -24,8 +24,15 @@ ...@@ -24,8 +24,15 @@
"type": "string" "type": "string"
} }
}, },
"start_date": { "type": ["string", "null"] }, "start_date": { "type": ["date", "null"] },
"end_date": { "type": ["string", "null"] }, "start_date_fixed": { "type": ["date", "null"] },
"start_date_from_milestones": { "type": ["date", "null"] },
"start_date_is_fixed": { "type": "boolean" },
"end_date": { "type": ["date", "null"] },
"due_date": { "type": ["date", "null"] },
"due_date_fixed": { "type": ["date", "null"] },
"due_date_from_milestones": { "type": ["date", "null"] },
"due_date_is_fixed": { "type": "boolean" },
"created_at": { "type": ["string", "null"] }, "created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] } "updated_at": { "type": ["string", "null"] }
}, },
......
...@@ -18,8 +18,15 @@ ...@@ -18,8 +18,15 @@
"group_id": { "type": "integer" }, "group_id": { "type": "integer" },
"description": { "type": ["string", "null"] }, "description": { "type": ["string", "null"] },
"author": { "type": ["object", "null"] }, "author": { "type": ["object", "null"] },
"start_date": { "type": ["string", "null"] }, "start_date": { "type": ["date", "null"] },
"end_date": { "type": ["string", "null"] }, "start_date_fixed": { "type": ["date", "null"] },
"start_date_from_milestones": { "type": ["date", "null"] },
"start_date_is_fixed": { "type": "boolean" },
"end_date": { "type": ["date", "null"] },
"due_date": { "type": ["date", "null"] },
"due_date_fixed": { "type": ["date", "null"] },
"due_date_from_milestones": { "type": ["date", "null"] },
"due_date_is_fixed": { "type": "boolean" },
"created_at": { "type": ["string", "null"] }, "created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] }, "updated_at": { "type": ["string", "null"] },
"labels": { "labels": {
......
...@@ -4,18 +4,21 @@ describe EpicsHelper do ...@@ -4,18 +4,21 @@ describe EpicsHelper do
include ApplicationHelper include ApplicationHelper
describe '#epic_show_app_data' do describe '#epic_show_app_data' do
it 'returns the correct json' do let(:user) { create(:user) }
user = create(:user) let!(:epic) { create(:epic, author: user) }
@epic = create(:epic, author: user)
before do
allow(helper).to receive(:current_user).and_return(user) allow(helper).to receive(:current_user).and_return(user)
stub_licensed_features(epics: true)
end
data = helper.epic_show_app_data(@epic, initial: {}, author_icon: 'icon_path') it 'returns the correct json' do
data = helper.epic_show_app_data(epic, initial: {}, author_icon: 'icon_path')
meta_data = JSON.parse(data[:meta]) meta_data = JSON.parse(data[:meta])
expected_keys = %i(initial meta namespace labels_path toggle_subscription_path labels_web_url epics_web_url) expected_keys = %i(initial meta namespace labels_path toggle_subscription_path labels_web_url epics_web_url)
expect(data.keys).to match_array(expected_keys) expect(data.keys).to match_array(expected_keys)
expect(meta_data.keys).to match_array(%w[created author start_date end_date epic_id todo_exists todo_path]) expect(meta_data.keys).to match_array(%w[created author start_date end_date due_date epic_id todo_exists todo_path])
expect(meta_data['author']).to eq({ expect(meta_data['author']).to eq({
'name' => user.name, 'name' => user.name,
'url' => "/#{user.username}", 'url' => "/#{user.username}",
...@@ -23,6 +26,40 @@ describe EpicsHelper do ...@@ -23,6 +26,40 @@ describe EpicsHelper do
'src' => 'icon_path' 'src' => 'icon_path'
}) })
end end
context 'when user has edit permission' do
let(:milestone) { create(:milestone, title: 'make me a sandwich') }
let!(:epic) do
create(
:epic,
author: user,
start_date_sourcing_milestone: milestone,
start_date: Date.new(2000, 1, 1),
due_date_sourcing_milestone: milestone,
due_date: Date.new(2000, 1, 2)
)
end
before do
epic.group.add_developer(user)
end
it 'returns extra date fields if user can edit' do
data = helper.epic_show_app_data(epic, initial: {}, author_icon: 'icon_path')
meta_data = JSON.parse(data[:meta])
expect(meta_data.keys).to match_array(%w[
created author epic_id todo_exists todo_path
start_date start_date_fixed start_date_is_fixed start_date_from_milestones start_date_sourcing_milestone_title
end_date due_date due_date_fixed due_date_is_fixed due_date_from_milestones due_date_sourcing_milestone_title
])
expect(meta_data['start_date']).to eq('2000-01-01')
expect(meta_data['start_date_sourcing_milestone_title']).to eq(milestone.title)
expect(meta_data['due_date']).to eq('2000-01-02')
expect(meta_data['due_date_sourcing_milestone_title']).to eq(milestone.title)
end
end
end end
describe '#epic_endpoint_query_params' do describe '#epic_endpoint_query_params' do
......
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('ee', 'db', 'post_migrate', '20180713171825_update_epic_dates_from_milestones.rb')
describe UpdateEpicDatesFromMilestones, :migration do
let(:migration) { described_class.new }
let(:users) { table(:users) }
let(:namespaces) { table(:namespaces) }
let(:epics) { table(:epics) }
let(:projects) { table(:projects) }
let(:issues) { table(:issues) }
let(:epic_issues) { table(:epic_issues) }
let(:milestones) { table(:milestones) }
describe '#up' do
before do
user = users.create!(email: 'test@example.com', projects_limit: 100, username: 'test')
namespaces.create(id: 1, name: 'gitlab-org', path: 'gitlab-org')
projects.create!(id: 1, namespace_id: 1, name: 'gitlab1', path: 'gitlab1')
epics.create!(id: 1, iid: 1, group_id: 1, title: 'epic with start and due dates', title_html: '', author_id: user.id)
epics.create!(id: 2, iid: 2, group_id: 1, title: 'epic with only due date', title_html: '', author_id: user.id)
epics.create!(id: 3, iid: 3, group_id: 1, title: 'epic without milestone', title_html: '', author_id: user.id)
milestones.create!(
id: 1,
iid: 1,
project_id: 1,
title: 'milestone-1',
start_date: Date.new(2000, 1, 1),
due_date: Date.new(2000, 1, 10)
)
milestones.create!(
id: 2,
iid: 2,
project_id: 1,
title: 'milestone-2',
due_date: Date.new(2000, 1, 30)
)
issues.create!(id: 1, iid: 1, project_id: 1, milestone_id: 1, title: 'issue-1')
issues.create!(id: 2, iid: 2, project_id: 1, milestone_id: 2, title: 'issue-2')
issues.create!(id: 3, iid: 3, project_id: 1, milestone_id: 2, title: 'issue-2')
epic_issues.create!(epic_id: 1, issue_id: 1)
epic_issues.create!(epic_id: 1, issue_id: 2)
epic_issues.create!(epic_id: 2, issue_id: 3)
end
it 'updates dates milestone ids' do
migration.up
expect(Epic.find(1)).to have_attributes(
start_date: Date.new(2000, 1, 1),
start_date_sourcing_milestone_id: 1,
due_date: Date.new(2000, 1, 30),
due_date_sourcing_milestone_id: 2
)
expect(Epic.find(2)).to have_attributes(
start_date: nil,
start_date_sourcing_milestone_id: nil,
due_date: Date.new(2000, 1, 30),
due_date_sourcing_milestone_id: 2
)
expect(Epic.find(3)).to have_attributes(
start_date: nil,
start_date_sourcing_milestone_id: nil,
due_date: nil,
due_date_sourcing_milestone_id: nil
)
end
end
end
...@@ -7,6 +7,7 @@ describe Epic do ...@@ -7,6 +7,7 @@ describe Epic do
it { is_expected.to belong_to(:author).class_name('User') } it { is_expected.to belong_to(:author).class_name('User') }
it { is_expected.to belong_to(:assignee).class_name('User') } it { is_expected.to belong_to(:assignee).class_name('User') }
it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:group) }
it { is_expected.to have_many(:epic_issues) }
end end
describe 'validations' do describe 'validations' do
...@@ -84,7 +85,299 @@ describe Epic do ...@@ -84,7 +85,299 @@ describe Epic do
end end
end end
describe '#issues' do describe '#start_date' do
let(:date) { Date.new(2000, 1, 1) }
context 'is set' do
subject { build(:epic, :use_fixed_dates, start_date: date) }
it 'returns as is' do
expect(subject.start_date).to eq(date)
end
end
end
describe '#start_date_from_milestones' do
context 'fixed date' do
it 'returns start date from start date sourcing milestone' do
subject = create(:epic, :use_fixed_dates)
milestone = create(:milestone, :with_dates)
subject.start_date_sourcing_milestone = milestone
expect(subject.start_date_from_milestones).to eq(milestone.start_date)
end
end
context 'milestone date' do
it 'returns start_date' do
subject = create(:epic, start_date: Date.new(2017, 3, 4))
expect(subject.start_date_from_milestones).to eq(subject.start_date)
end
end
end
describe '#due_date_from_milestones' do
context 'fixed date' do
it 'returns due date from due date sourcing milestone' do
subject = create(:epic, :use_fixed_dates)
milestone = create(:milestone, :with_dates)
subject.due_date_sourcing_milestone = milestone
expect(subject.due_date_from_milestones).to eq(milestone.due_date)
end
end
context 'milestone date' do
it 'returns due_date' do
subject = create(:epic, due_date: Date.new(2017, 3, 4))
expect(subject.due_date_from_milestones).to eq(subject.due_date)
end
end
end
describe '#update_start_and_due_dates' do
context 'fixed date is set' do
subject { create(:epic, :use_fixed_dates, start_date: nil, end_date: nil) }
it 'updates to fixed date' do
subject.update_start_and_due_dates
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) }
let(:milestone1) do
create(
:milestone,
start_date: Date.new(2000, 1, 1),
due_date: Date.new(2000, 1, 10)
)
end
let(:milestone2) do
create(
:milestone,
start_date: Date.new(2000, 1, 3),
due_date: Date.new(2000, 1, 20)
)
end
context 'multiple milestones' do
before do
epic_issue1 = create(:epic_issue, epic: subject)
epic_issue1.issue.update(milestone: milestone1)
epic_issue2 = create(:epic_issue, epic: subject)
epic_issue2.issue.update(milestone: milestone2)
end
context 'complete start and due dates' do
it 'updates to milestone dates' do
subject.update_start_and_due_dates
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
)
end
let(:milestone2) do
create(
:milestone,
start_date: Date.new(2000, 1, 3),
due_date: nil
)
end
it 'updates to milestone dates' do
subject.update_start_and_due_dates
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
)
end
let(:milestone2) do
create(
:milestone,
start_date: nil,
due_date: nil
)
end
it 'updates to milestone dates' do
subject.update_start_and_due_dates
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
subject.update_start_and_due_dates
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)
end
context 'complete start and due dates' do
it 'updates to milestone dates' do
subject.update_start_and_due_dates
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
)
end
it 'updates to milestone dates' do
subject.update_start_and_due_dates
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
)
end
it 'updates to milestone dates' do
subject.update_start_and_due_dates
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)
end
it 'updates in bulk' do
milestone1 = create(:milestone, start_date: Date.new(2000, 1, 1), due_date: Date.new(2000, 1, 10))
milestone2 = create(:milestone, due_date: Date.new(2000, 1, 30))
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)) }
let!(:epics) { [create(:epic)] }
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 '#issues_readable_by' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:group) { create(:group, :private) } let(:group) { create(:group, :private) }
let(:project) { create(:project, group: group) } let(:project) { create(:project, group: group) }
...@@ -101,7 +394,7 @@ describe Epic do ...@@ -101,7 +394,7 @@ describe Epic do
] ]
end end
let(:result) { epic.issues(user) } let(:result) { epic.issues_readable_by(user) }
it 'returns all issues if a user has access to them' do it 'returns all issues if a user has access to them' do
group.add_developer(user) group.add_developer(user)
......
...@@ -40,6 +40,32 @@ describe API::Epics do ...@@ -40,6 +40,32 @@ describe API::Epics do
end end
end end
shared_examples 'can admin epics' do
let(:extra_date_fields) { %w[start_date_is_fixed start_date_fixed due_date_is_fixed due_date_fixed] }
context 'when permission is absent' do
RSpec::Matchers.define_negated_matcher :exclude, :include
it 'returns epic with extra date fields' do
get api(url, user), params
expect(Array.wrap(JSON.parse(response.body))).to all(exclude(*extra_date_fields))
end
end
context 'when permission is present' do
before do
group.add_maintainer(user)
end
it 'returns epic with extra date fields' do
get api(url, user), params
expect(Array.wrap(JSON.parse(response.body))).to all(include(*extra_date_fields))
end
end
end
describe 'GET /groups/:id/epics' do describe 'GET /groups/:id/epics' do
let(:url) { "/groups/#{group.path}/epics" } let(:url) { "/groups/#{group.path}/epics" }
...@@ -138,6 +164,8 @@ describe API::Epics do ...@@ -138,6 +164,8 @@ describe API::Epics do
expect_array_response([epic2.id]) expect_array_response([epic2.id])
end end
it_behaves_like 'can admin epics'
end end
end end
...@@ -149,17 +177,21 @@ describe API::Epics do ...@@ -149,17 +177,21 @@ describe API::Epics do
context 'when the request is correct' do context 'when the request is correct' do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
get api(url, user)
end end
it 'returns 200 status' do it 'returns 200 status' do
get api(url, user)
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
end end
it 'matches the response schema' do it 'matches the response schema' do
get api(url, user)
expect(response).to match_response_schema('public_api/v4/epic', dir: 'ee') expect(response).to match_response_schema('public_api/v4/epic', dir: 'ee')
end end
it_behaves_like 'can admin epics'
end end
end end
...@@ -206,13 +238,26 @@ describe API::Epics do ...@@ -206,13 +238,26 @@ describe API::Epics do
expect(epic.description).to eq('epic description') expect(epic.description).to eq('epic description')
expect(epic.labels.first.title).to eq('label1') expect(epic.labels.first.title).to eq('label1')
end end
context 'when deprecated start_date and end_date params are present' do
let(:start_date) { Date.new(2001, 1, 1) }
let(:due_date) { Date.new(2001, 1, 2) }
let(:params) { { title: 'new epic', start_date: start_date, end_date: due_date } }
it 'updates start_date_fixed and due_date_fixed' do
result = Epic.last
expect(result.start_date_fixed).to eq(start_date)
expect(result.due_date_fixed).to eq(due_date)
end
end
end end
end end
end end
describe 'PUT /groups/:id/epics/:epic_iid' do describe 'PUT /groups/:id/epics/:epic_iid' do
let(:url) { "/groups/#{group.path}/epics/#{epic.iid}" } let(:url) { "/groups/#{group.path}/epics/#{epic.iid}" }
let(:params) { { title: 'new title', description: 'new description', labels: 'label2' } } let(:params) { { title: 'new title', description: 'new description', labels: 'label2', start_date_fixed: "2018-07-17", start_date_is_fixed: true } }
it_behaves_like 'error requests' it_behaves_like 'error requests'
...@@ -260,6 +305,23 @@ describe API::Epics do ...@@ -260,6 +305,23 @@ describe API::Epics do
expect(result.title).to eq('new title') expect(result.title).to eq('new title')
expect(result.description).to eq('new description') expect(result.description).to eq('new description')
expect(result.labels.first.title).to eq('label2') expect(result.labels.first.title).to eq('label2')
expect(result.start_date).to eq(Date.new(2018, 7, 17))
expect(result.start_date_fixed).to eq(Date.new(2018, 7, 17))
expect(result.start_date_is_fixed).to eq(true)
end
context 'when deprecated start_date and end_date params are present' do
let(:epic) { create(:epic, :use_fixed_dates, group: group) }
let(:new_start_date) { epic.start_date + 1.day }
let(:new_due_date) { epic.end_date + 1.day }
let!(:params) { { start_date: new_start_date, end_date: new_due_date } }
it 'updates start_date_fixed and due_date_fixed' do
result = epic.reload
expect(result.start_date_fixed).to eq(new_start_date)
expect(result.due_date_fixed).to eq(new_due_date)
end
end end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe Issues::UpdateService do
let(:issue) { create(:issue) }
let(:user) { issue.author }
let(:project) { issue.project }
describe 'execute' do
def update_issue(opts)
described_class.new(project, user, opts).execute(issue)
end
context 'refresh epic dates' do
let(:epic) { create(:epic) }
let(:issue) { create(:issue, epic: epic) }
context 'updating milestone' do
let(:milestone) { create(:milestone) }
it 'calls epic#update_start_and_due_dates' do
expect(epic).to receive(:update_start_and_due_dates).twice
update_issue(milestone: milestone)
update_issue(milestone_id: nil)
end
end
context 'updating other fields' do
it 'does not call epic#update_start_and_due_dates' do
expect(epic).not_to receive(:update_start_and_due_dates)
update_issue(title: 'foo')
end
end
end
end
end
...@@ -268,6 +268,14 @@ describe EpicIssues::CreateService do ...@@ -268,6 +268,14 @@ describe EpicIssues::CreateService do
include_examples 'returns an error' include_examples 'returns an error'
end end
context 'refresh epic dates' do
it 'calls epic#update_start_and_due_dates' do
expect(epic).to receive(:update_start_and_due_dates)
assign_issue([valid_reference])
end
end
end end
end end
end end
...@@ -75,6 +75,14 @@ describe EpicIssues::DestroyService do ...@@ -75,6 +75,14 @@ describe EpicIssues::DestroyService do
is_expected.to eq(message: 'No Issue Link found', status: :error, http_status: 404) is_expected.to eq(message: 'No Issue Link found', status: :error, http_status: 404)
end end
end end
context 'refresh epic dates' do
it 'calls epic#update_start_and_due_dates' do
expect(epic).to receive(:update_start_and_due_dates)
subject
end
end
end end
end end
end end
...@@ -18,8 +18,10 @@ describe Epics::UpdateService do ...@@ -18,8 +18,10 @@ describe Epics::UpdateService do
{ {
title: 'New title', title: 'New title',
description: 'New description', description: 'New description',
start_date: '2017-01-09', start_date_fixed: '2017-01-09',
end_date: '2017-10-21' start_date_is_fixed: true,
due_date_fixed: '2017-10-21',
due_date_is_fixed: true
} }
end end
...@@ -27,10 +29,11 @@ describe Epics::UpdateService do ...@@ -27,10 +29,11 @@ describe Epics::UpdateService do
update_epic(opts) update_epic(opts)
expect(epic).to be_valid expect(epic).to be_valid
expect(epic.title).to eq(opts[:title]) expect(epic).to have_attributes(opts.except(:due_date_fixed, :start_date_fixed))
expect(epic.description).to eq(opts[:description]) expect(epic).to have_attributes(
expect(epic.start_date).to eq(Date.strptime(opts[:start_date])) start_date_fixed: Date.strptime(opts[:start_date_fixed]),
expect(epic.end_date).to eq(Date.strptime(opts[:end_date])) due_date_fixed: Date.strptime(opts[:due_date_fixed])
)
end end
it 'updates the last_edited_at value' do it 'updates the last_edited_at value' do
...@@ -115,5 +118,32 @@ describe Epics::UpdateService do ...@@ -115,5 +118,32 @@ describe Epics::UpdateService do
end end
end end
end end
context 'filter out start_date and end_date' do
it 'ignores start_date and end_date' do
expect { update_epic(start_date: Date.today, end_date: Date.today) }.not_to change { Note.count }
expect(epic).to be_valid
expect(epic).to have_attributes(start_date: nil, due_date: nil)
end
end
context 'refresh epic dates' do
context 'date fields are updated' do
it 'calls epic#update_start_and_due_dates' do
expect(epic).to receive(:update_start_and_due_dates)
update_epic(start_date_is_fixed: true, start_date_fixed: Date.today)
end
end
context 'date fields are not updated' do
it 'does not call epic#update_start_and_due_dates' do
expect(epic).not_to receive(:update_start_and_due_dates)
update_epic(title: 'foo')
end
end
end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
describe Milestones::UpdateService do
describe '#execute' do
context 'refresh related epic dates' do
it 'updates milestone sourced dates' do
project = create(:project)
user = build(:user)
milestone = create(:milestone, project: project)
epic = create(:epic)
create(:issue, milestone: milestone, epic: epic)
due_date = 3.days.from_now.to_date
described_class.new(project, user, { due_date: due_date }).execute(milestone)
expect(epic.reload).to have_attributes(
start_date: nil,
start_date_sourcing_milestone: nil,
due_date: due_date,
due_date_sourcing_milestone: milestone
)
end
end
end
end
...@@ -18,6 +18,11 @@ FactoryBot.define do ...@@ -18,6 +18,11 @@ FactoryBot.define do
state "closed" state "closed"
end end
trait :with_dates do
start_date { Date.new(2000, 1, 1) }
due_date { Date.new(2000, 1, 30) }
end
after(:build, :stub) do |milestone, evaluator| after(:build, :stub) do |milestone, evaluator|
if evaluator.group if evaluator.group
milestone.group = evaluator.group milestone.group = evaluator.group
......
# frozen_string_literal: true
require 'spec_helper'
describe Milestones::UpdateService do
let(:project) { create(:project) }
let(:user) { build(:user) }
let(:milestone) { create(:milestone, project: project) }
describe '#execute' do
context "valid params" do
let(:inner_service) { double(:service) }
before do
project.add_maintainer(user)
end
subject { described_class.new(project, user, { title: 'new_title' }).execute(milestone) }
it { expect(subject).to be_valid }
it { expect(subject.title).to eq('new_title') }
context 'state_event is activate' do
it 'calls ReopenService' do
expect(Milestones::ReopenService).to receive(:new).with(project, user, {}).and_return(inner_service)
expect(inner_service).to receive(:execute).with(milestone)
described_class.new(project, user, { state_event: 'activate' }).execute(milestone)
end
end
context 'state_event is close' do
it 'calls ReopenService' do
expect(Milestones::CloseService).to receive(:new).with(project, user, {}).and_return(inner_service)
expect(inner_service).to receive(:execute).with(milestone)
described_class.new(project, user, { state_event: 'close' }).execute(milestone)
end
end
end
end
end
...@@ -145,7 +145,11 @@ describe Notes::CreateService do ...@@ -145,7 +145,11 @@ describe Notes::CreateService do
let(:note_text) { %(HELLO\n/close\n/assign @#{user.username}\nWORLD) } let(:note_text) { %(HELLO\n/close\n/assign @#{user.username}\nWORLD) }
it 'saves the note and does not alter the note text' do it 'saves the note and does not alter the note text' do
expect_any_instance_of(Issues::UpdateService).to receive(:execute).and_call_original service = instance_double('Issues::UpdateService', :service)
allow(Issues::UpdateService).to receive(:new).and_return(service)
expect(service).to receive(:execute)
note = described_class.new(project, user, opts.merge(note: note_text)).execute note = described_class.new(project, user, opts.merge(note: note_text)).execute
......
...@@ -4,7 +4,11 @@ shared_examples 'issues move service' do |group| ...@@ -4,7 +4,11 @@ shared_examples 'issues move service' do |group|
let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } } let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } }
it 'delegates the label changes to Issues::UpdateService' do it 'delegates the label changes to Issues::UpdateService' do
expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once service = instance_double('Issues::UpdateService', :service)
allow(Issues::UpdateService).to receive(:new).and_return(service)
expect(service).to receive(:execute).with(issue).once
described_class.new(parent, user, params).execute(issue) described_class.new(parent, user, params).execute(issue)
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