summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFelipe Artur <felipefac@gmail.com>2017-06-27 15:16:44 -0300
committerFelipe Artur <felipefac@gmail.com>2017-07-05 11:24:31 -0300
commit1684a975671359ebc81eaa384f5acadffe57640d (patch)
tree3e89eadf7dda1eef9b43c4310a12b71f107e0205
parent6eb57c42a466d85d677679f3a8ff63b67336bfae (diff)
downloadgitlab-ce-1684a975671359ebc81eaa384f5acadffe57640d.tar.gz
Native group milestones
-rw-r--r--app/controllers/groups/milestones_controller.rb35
-rw-r--r--app/controllers/projects/milestones_controller.rb23
-rw-r--r--app/finders/group_milestones_finder.rb12
-rw-r--r--app/finders/issuable_finder.rb20
-rw-r--r--app/finders/milestones_finder.rb29
-rw-r--r--app/finders/project_milestones_finder.rb14
-rw-r--r--app/helpers/milestones_helper.rb4
-rw-r--r--app/models/concerns/internal_id.rb3
-rw-r--r--app/models/concerns/issuable.rb1
-rw-r--r--app/models/concerns/milestoneish.rb18
-rw-r--r--app/models/concerns/shared_milestone_properties.rb74
-rw-r--r--app/models/global_milestone.rb15
-rw-r--r--app/models/group.rb2
-rw-r--r--app/models/group_milestone.rb38
-rw-r--r--app/models/milestone.rb101
-rw-r--r--app/models/namespace.rb1
-rw-r--r--app/services/issuable_base_service.rb6
-rw-r--r--app/services/issues/move_service.rb12
-rw-r--r--app/services/milestones/close_service.rb2
-rw-r--r--app/services/milestones/reopen_service.rb2
-rw-r--r--app/views/groups/milestones/_form.html.haml29
-rw-r--r--app/views/groups/milestones/edit.html.haml7
-rw-r--r--app/views/groups/milestones/index.html.haml5
-rw-r--r--app/views/groups/milestones/new.html.haml38
-rw-r--r--app/views/groups/milestones/show.html.haml2
-rw-r--r--app/views/shared/milestones/_milestone.html.haml33
-rw-r--r--app/views/shared/milestones/_top.html.haml61
-rw-r--r--changelogs/unreleased/issue_30126_be.yml4
-rw-r--r--config/routes/group.rb2
-rw-r--r--db/migrate/20170619183807_create_group_milestones.rb23
-rw-r--r--db/migrate/20170619184243_add_group_milestone_group_id_indexes.rb19
-rw-r--r--db/migrate/20170623183807_add_group_id_to_milestones.rb18
-rw-r--r--db/migrate/20170624184243_add_group_milestone_id_indexes.rb12
-rw-r--r--db/schema.rb14
-rw-r--r--features/group/milestones.feature4
-rw-r--r--features/steps/group/milestones.rb9
-rw-r--r--lib/api/entities.rb7
-rw-r--r--spec/controllers/groups/milestones_controller_spec.rb41
-rw-r--r--spec/controllers/projects/milestones_controller_spec.rb33
-rw-r--r--spec/factories/milestones.rb22
-rw-r--r--spec/features/groups/milestone_spec.rb28
-rw-r--r--spec/features/milestone_spec.rb21
-rw-r--r--spec/finders/issues_finder_spec.rb17
-rw-r--r--spec/finders/merge_requests_finder_spec.rb19
-rw-r--r--spec/finders/milestones_finder_spec.rb48
-rw-r--r--spec/fixtures/api/schemas/public_api/v3/issues.json3
-rw-r--r--spec/fixtures/api/schemas/public_api/v3/merge_requests.json3
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/issues.json3
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/merge_requests.json3
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml1
-rw-r--r--spec/models/milestone_spec.rb44
-rw-r--r--spec/services/issues/move_service_spec.rb65
53 files changed, 694 insertions, 357 deletions
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 97eed419595..f33989cddff 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -2,13 +2,13 @@ class Groups::MilestonesController < Groups::ApplicationController
include MilestoneActions
before_action :group_projects
- before_action :milestone, only: [:show, :update, :merge_requests, :participants, :labels]
- before_action :authorize_admin_milestones!, only: [:new, :create, :update]
+ before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels]
+ before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update]
def index
respond_to do |format|
format.html do
- @milestone_states = GlobalMilestone.states_count(@projects, group)
+ @milestone_states = GlobalMilestone.states_count(group_projects, group)
@milestones = Kaminari.paginate_array(milestones).page(params[:page])
end
format.json do
@@ -18,7 +18,7 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def new
- @milestone = GroupMilestone.new
+ @milestone = Milestone.new
end
def create
@@ -35,12 +35,21 @@ class Groups::MilestonesController < Groups::ApplicationController
def show
end
+ def edit
+ render_404 if @milestone.is_legacy_group_milestone?
+ end
+
def update
- @milestone.milestones.each do |milestone|
- Milestones::UpdateService.new(milestone.project, current_user, milestone_params).execute(milestone)
+ milestones = @milestone.milestones if @milestone.is_legacy_group_milestone?
+ # Keep this compatible with legacy group milestones where we have to update
+ # all projects milestones at once.
+ milestones ||= Array(@milestone)
+
+ milestones.each do |milestone|
+ Milestones::UpdateService.new(milestone.parent, current_user, milestone_params).execute(milestone)
end
- redirect_back_or_default(default: milestone_path(@milestone.title))
+ redirect_to milestone_path(@milestone.title)
end
private
@@ -50,23 +59,23 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def milestone_params
- params.require(:group_milestone).permit(:title, :description, :start_date, :due_date, :state_event)
+ params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end
def milestone_path(title)
- group_milestone_path(@group, title.to_slug.to_s, title: title)
+ group_milestone_path(group, title.to_slug.to_s, title: title)
end
def milestones
- @group_milestones = GroupMilestonesFinder.new(group, params).execute
- @project_milestones = ProjectMilestonesFinder.new(@projects, params).execute
+ milestones = MilestonesFinder.new(groups: group, params: params).execute
+ legacy_milestones = GroupMilestone.build_collection(group, group_projects, params) || []
- @group_milestones + @project_milestones
+ milestones + legacy_milestones
end
def milestone
@milestone =
- @group.milestones.find_by_title(params[:title]) || GroupMilestone.build(@group, @projects, params[:title])
+ group.milestones.find_by_title(params[:title]) || GroupMilestone.build(group, group_projects, params[:title])
render_404 unless @milestone
end
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index ea9891697b7..884bc8e6fea 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -13,20 +13,15 @@ class Projects::MilestonesController < Projects::ApplicationController
respond_to :html
def index
- @milestones =
- case params[:state]
- when 'all' then @project.milestones
- when 'closed' then @project.milestones.closed
- else @project.milestones.active
- end
-
@sort = params[:sort] || 'due_date_asc'
- @milestones = @milestones.sort(@sort)
+ @milestones = milestones.sort(@sort)
respond_to do |format|
format.html do
@project_namespace = @project.namespace.becomes(Namespace)
- @milestones = @milestones.includes(:project)
+ # Shows only projects milestones on list
+ # although we need to show them in the dropdown.
+ @milestones = @milestones.where(group: nil).includes(:project)
@milestones = @milestones.page(params[:page])
end
format.json do
@@ -87,6 +82,16 @@ class Projects::MilestonesController < Projects::ApplicationController
protected
+ def milestones
+ @milestones ||= begin
+ if @project.group && can?(current_user, :read_group, @project.group)
+ group = @project.group
+ end
+
+ MilestonesFinder.new(projects: @project, groups: group, params: params).execute
+ end
+ end
+
def milestone
@milestone ||= @project.milestones.find_by!(iid: params[:id])
end
diff --git a/app/finders/group_milestones_finder.rb b/app/finders/group_milestones_finder.rb
deleted file mode 100644
index d5bf810c41b..00000000000
--- a/app/finders/group_milestones_finder.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-class GroupMilestonesFinder
- attr_reader :group, :params
-
- def initialize(group, params)
- @group = group
- @params = params
- end
-
- def execute
- GroupMilestone.filter_by_state(group.milestones, params[:state])
- end
-end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 7bc2117f61e..8d40f40d33e 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -330,12 +330,7 @@ class IssuableFinder
elsif filter_by_started_milestone?
items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
else
- items = items.with_milestone(params[:milestone_title])
- items_projects = projects(items)
-
- if items_projects
- items = items.where(milestones: { project_id: items_projects })
- end
+ items = filter_by_project_and_group_milestones(items)
end
end
@@ -432,4 +427,17 @@ class IssuableFinder
['issuables_count', klass.to_ability_name, opts.sort]
end
+
+ def filter_by_project_and_group_milestones(items)
+ items = items.with_milestone(params[:milestone_title])
+
+ items_projects = projects(items)
+
+ if items_projects
+ items_group = project? ? project.group : Group.find_by_id(items_projects.group_ids)
+
+ milestones = MilestonesFinder.new(projects: items_projects, groups: items_group, params: { state: 'all' }).execute
+ items.where(milestone: milestones)
+ end
+ end
end
diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb
new file mode 100644
index 00000000000..7f18e889ad3
--- /dev/null
+++ b/app/finders/milestones_finder.rb
@@ -0,0 +1,29 @@
+# Searchs for group milestones and project milestones.
+#
+# Parameters
+# projects: array of projects or single project
+# groups: array of groups or single group
+# params: Search params
+
+class MilestonesFinder
+ attr_reader :projects, :groups, :params
+
+ def initialize(projects: nil, groups: nil, params: {})
+ @projects = Array(projects)
+ @groups = Array(groups)
+ @params = params
+ end
+
+ def execute
+ conditions = []
+ table = Milestone.arel_table
+ project_ids = projects&.map(&:id)
+ group_ids = groups&.map(&:id)
+
+ conditions << table[:group_id].in(group_ids) if group_ids
+ conditions << table[:project_id].in(project_ids) if project_ids
+
+ milestones = Milestone.where(conditions.reduce(:or)).reorder("due_date ASC")
+ Milestone.filter_by_state(milestones, params[:state])
+ end
+end
diff --git a/app/finders/project_milestones_finder.rb b/app/finders/project_milestones_finder.rb
deleted file mode 100644
index a9ffbc9de55..00000000000
--- a/app/finders/project_milestones_finder.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-class ProjectMilestonesFinder
- attr_reader :projects, :params
-
- def initialize(projects, params)
- @projects = projects
- @params = params
- end
-
- def execute
- milestones = Milestone.of_projects(projects)
-
- Milestone.filter_by_state(milestones, params[:state])
- end
-end
diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb
index f346e20e807..ae70172973b 100644
--- a/app/helpers/milestones_helper.rb
+++ b/app/helpers/milestones_helper.rb
@@ -54,8 +54,10 @@ module MilestonesHelper
def milestone_class_for_state(param, check, match_blank_param = false)
if match_blank_param
'active' if param.blank? || param == check
+ elsif param == check
+ 'active'
else
- 'active' if param == check
+ check
end
end
diff --git a/app/models/concerns/internal_id.rb b/app/models/concerns/internal_id.rb
index 5382dde6765..67a0adfcd56 100644
--- a/app/models/concerns/internal_id.rb
+++ b/app/models/concerns/internal_id.rb
@@ -8,7 +8,8 @@ module InternalId
def set_iid
if iid.blank?
- records = project.send(self.class.name.tableize)
+ parent = project || group
+ records = parent.send(self.class.name.tableize)
records = records.with_deleted if self.paranoid?
max_iid = records.maximum(:iid)
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index cc39fe6eb8e..c4fcab17f9f 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -30,7 +30,6 @@ module Issuable
belongs_to :updated_by, class_name: "User"
belongs_to :last_edited_by, class_name: 'User'
belongs_to :milestone
- belongs_to :group_milestone
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do
def authors_loaded?
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index 60aeab38118..6475390edae 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -40,7 +40,7 @@ module Milestoneish
def issues_visible_to_user(user)
memoize_per_user(user, :issues_visible_to_user) do
IssuesFinder.new(user, issues_finder_params)
- .execute.preload(:assignees).where(issues_finder_conditions)
+ .execute.preload(:assignees).where(milestone_id: milestoneish_ids)
end
end
@@ -71,23 +71,19 @@ module Milestoneish
end
def is_group_milestone?
- respond_to?(:group_id)
+ false
end
def is_project_milestone?
- respond_to?(:project_id)
+ false
end
- def title=(value)
- write_attribute(:title, sanitize_title(value)) if value.present?
+ def is_legacy_group_milestone?
+ false
end
private
- def sanitize_title(value)
- CGI.unescape_html(Sanitize.clean(value.to_s))
- end
-
def count_issues_by_state(user)
memoize_per_user(user, :count_issues_by_state) do
issues_visible_to_user(user).reorder(nil).group(:state).count
@@ -105,8 +101,4 @@ module Milestoneish
def issues_finder_params
{}
end
-
- def issues_finder_conditions
- { milestone_id: milestoneish_ids }
- end
end
diff --git a/app/models/concerns/shared_milestone_properties.rb b/app/models/concerns/shared_milestone_properties.rb
deleted file mode 100644
index 77a8da9fd93..00000000000
--- a/app/models/concerns/shared_milestone_properties.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-module SharedMilestoneProperties
- extend ActiveSupport::Concern
-
- include StripAttribute
- include CacheMarkdownField
-
- included do
- has_many :issues
- has_many :merge_requests
- has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
-
- validate :uniqueness_of_title, if: :title_changed?
-
- scope :active, -> { with_state(:active) }
- scope :closed, -> { with_state(:closed) }
-
- validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
- strip_attributes :title
- alias_attribute :name, :title
-
- state_machine :state, initial: :active do
- event :close do
- transition active: :closed
- end
-
- event :activate do
- transition closed: :active
- end
-
- state :closed
-
- state :active
- end
-
- alias_attribute :name, :title
-
- cache_markdown_field :title, pipeline: :single_line
- cache_markdown_field :description
- end
-
- module ClassMethods
- def filter_by_state(milestones, state)
- case state
- when 'closed' then milestones.closed
- when 'all' then milestones
- else milestones.active
- end
- end
- end
-
- def start_date_should_be_less_than_due_date
- if due_date <= start_date
- errors.add(:start_date, "Can't be greater than due date")
- end
- end
-
- def safe_title
- title.to_slug.normalize.to_s
- end
-
- private
-
- # Milestone titles must be unique across project milestones and group milestones
- def uniqueness_of_title(group, project = nil)
- if group
- title_exists = group.milestones.find_by_title(title).present?
- title_exists ||= Milestone.where(project: group.projects).find_by_title(title).present?
- elsif project
- title_exists = project.milestones.find_by_title(title).present?
- end
-
- errors.add(:title, "Must be unique across project milestones and group milestones.") if title_exists
- end
-end
diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb
index 8ef4546ee24..d1d90ab638c 100644
--- a/app/models/global_milestone.rb
+++ b/app/models/global_milestone.rb
@@ -2,7 +2,7 @@ class GlobalMilestone
include Milestoneish
EPOCH = DateTime.parse('1970-01-01')
- STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }
+ STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze
attr_accessor :title, :milestones
alias_attribute :name, :title
@@ -12,7 +12,7 @@ class GlobalMilestone
end
def self.build_collection(projects, params)
- child_milestones = ProjectMilestonesFinder.new(projects, params).execute
+ child_milestones = MilestonesFinder.new(projects: projects, params: params).execute
milestones = child_milestones.select(:id, :title).group_by(&:title).map do |title, grouped|
milestones_relation = Milestone.where(id: grouped.map(&:id))
@@ -30,18 +30,18 @@ class GlobalMilestone
end
def self.states_count(projects, group = nil)
- projects_milestones_count = legacy_group_milestone_states_count(projects)
+ legacy_group_milestones_count = legacy_group_milestone_states_count(projects)
group_milestones_count = group_milestones_states_count(group)
- projects_milestones_count.merge(group_milestones_count) do |k, project_milestones_count, group_milestones_count|
- project_milestones_count + group_milestones_count
+ legacy_group_milestones_count.merge(group_milestones_count) do |k, legacy_group_milestones_count, group_milestones_count|
+ legacy_group_milestones_count + group_milestones_count
end
end
def self.group_milestones_states_count(group)
return STATE_COUNT_HASH unless group
- relation = GroupMilestonesFinder.new(group, state: 'all').execute
+ relation = MilestonesFinder.new(groups: group, params: { state: 'all' }).execute
grouped_by_state = relation.reorder(nil).group(:state).count
{
@@ -51,10 +51,11 @@ class GlobalMilestone
}
end
+ # Counts the legacy group milestones which must be grouped by title
def self.legacy_group_milestone_states_count(projects)
return STATE_COUNT_HASH unless projects
- relation = ProjectMilestonesFinder.new(projects, state: 'all').execute
+ relation = MilestonesFinder.new(projects: projects, params: { state: 'all' }).execute
project_milestones_by_state_and_title = relation.reorder(nil).group(:state, :title).count
opened = count_by_state(project_milestones_by_state_and_title, 'active')
diff --git a/app/models/group.rb b/app/models/group.rb
index 93e381712bf..78566285159 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -18,7 +18,7 @@ class Group < Namespace
has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember'
- has_many :milestones, class_name: 'GroupMilestone'
+ has_many :milestones
has_many :project_group_links, dependent: :destroy
has_many :shared_projects, through: :project_group_links, source: :project
has_many :notification_settings, dependent: :destroy, as: :source
diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb
index c518fe430bd..65249bd7bfc 100644
--- a/app/models/group_milestone.rb
+++ b/app/models/group_milestone.rb
@@ -1,35 +1,23 @@
-class GroupMilestone < ActiveRecord::Base
- include SharedMilestoneProperties
- include Milestoneish
- include CacheMarkdownField
+class GroupMilestone < GlobalMilestone
+ attr_accessor :group
- belongs_to :group
-
- class << self
- # Build legacy group milestone which consists on all project milestones
- # with the same title.
- def build(group, projects, title)
- GlobalMilestone.build(projects, title).tap do |milestone|
- milestone&.group = group
- end
+ def self.build_collection(group, projects, params)
+ super(projects, params).each do |milestone|
+ milestone.group = group
end
end
- def milestoneish_ids
- id
- end
-
- def participants
- User.joins(assigned_issues: :group_milestone).where("group_milestones.id = ?", id).uniq
+ def self.build(group, projects, title)
+ super(projects, title).tap do |milestone|
+ milestone&.group = group
+ end
end
- private
-
- def uniqueness_of_title
- super(group)
+ def issues_finder_params
+ { group_id: group.id }
end
- def issues_finder_conditions
- { group_milestone_id: milestoneish_ids }
+ def is_legacy_group_milestone?
+ true
end
end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 601346be318..7936fd8a9f2 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -7,17 +7,53 @@ class Milestone < ActiveRecord::Base
Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2)
Started = MilestoneStruct.new('Started', '#started', -3)
- include SharedMilestoneProperties
include InternalId
include Sortable
include Referable
include Milestoneish
+ include StripAttribute
+ include CacheMarkdownField
+
+ cache_markdown_field :title, pipeline: :single_line
+ cache_markdown_field :description
belongs_to :project
+ belongs_to :group
+ has_many :issues
+ has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
+ has_many :merge_requests
has_many :events, as: :target, dependent: :destroy
scope :of_projects, ->(ids) { where(project_id: ids) }
+ scope :of_groups, ->(ids) { where(group_id: ids) }
+ scope :active, -> { with_state(:active) }
+ scope :closed, -> { with_state(:closed) }
+
+ validates :group, presence: true, unless: :project
+ validates :project, presence: true, unless: :group
+
+ validate :uniqueness_of_title, if: :title_changed?
+ validate :milestone_type_check
+ validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
+
+ strip_attributes :title
+
+ state_machine :state, initial: :active do
+ event :close do
+ transition active: :closed
+ end
+
+ event :activate do
+ transition closed: :active
+ end
+
+ state :closed
+
+ state :active
+ end
+
+ alias_attribute :name, :title
class << self
# Searches for milestones matching the given query.
@@ -33,6 +69,14 @@ class Milestone < ActiveRecord::Base
where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
end
+
+ def filter_by_state(milestones, state)
+ case state
+ when 'closed' then milestones.closed
+ when 'all' then milestones
+ else milestones.active
+ end
+ end
end
def self.reference_prefix
@@ -111,13 +155,21 @@ class Milestone < ActiveRecord::Base
format_reference = milestone_format_reference(format)
reference = "#{self.class.reference_prefix}#{format_reference}"
- "#{project.to_reference(from_project, full: full)}#{reference}"
+ if project
+ "#{project.to_reference(from_project, full: full)}#{reference}"
+ elsif group
+ "#{group.to_reference}#{reference}"
+ end
end
def reference_link_text(from_project = nil)
self.title
end
+ def title=(value)
+ write_attribute(:title, sanitize_title(value)) if value.present?
+ end
+
def milestoneish_ids
id
end
@@ -130,10 +182,43 @@ class Milestone < ActiveRecord::Base
nil
end
+ def safe_title
+ title.to_slug.normalize.to_s
+ end
+
+ def parent
+ group || project
+ end
+
+ def is_group_milestone?
+ group_id.present?
+ end
+
+ def is_project_milestone?
+ project_id.present?
+ end
+
private
+ # Milestone titles must be unique across project milestones and group milestones
def uniqueness_of_title
- super(project.group, project)
+ if project
+ title_exists = project.milestones.find_by_title(title)
+ title_exists ||= project.group.milestones.find_by_title(title) if project.group
+ elsif group
+ title_exists = group.milestones.find_by_title(title)
+ title_exists ||= Milestone.where(project: group.projects).find_by_title(title)
+ end
+
+ errors.add(:title, "already being used for another group or project milestone.") if title_exists
+ end
+
+ # Milestone should be either a project milestone or a group milestone
+ def milestone_type_check
+ if group_id && project_id
+ field = project_id_changed? ? :project_id : :group_id
+ errors.add(field, "milestone should belong either to a project or a group.")
+ end
end
def milestone_format_reference(format = :iid)
@@ -146,6 +231,16 @@ class Milestone < ActiveRecord::Base
end
end
+ def sanitize_title(value)
+ CGI.unescape_html(Sanitize.clean(value.to_s))
+ end
+
+ def start_date_should_be_less_than_due_date
+ if due_date <= start_date
+ errors.add(:start_date, "Can't be greater than due date")
+ end
+ end
+
def issues_finder_params
{ project_id: project_id }
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index a25234b886b..672eab94c07 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -16,7 +16,6 @@ class Namespace < ActiveRecord::Base
cache_markdown_field :description, pipeline: :description
has_many :projects, dependent: :destroy
- has_many :groups, dependent: :destroy
has_many :project_statistics
belongs_to :owner, class_name: "User"
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 8dd0846f3bc..dbbc9ae273a 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -89,8 +89,10 @@ class IssuableBaseService < BaseService
milestone_id = params[:milestone_id]
return unless milestone_id
- if milestone_id == IssuableFinder::NONE ||
- project.milestones.find_by(id: milestone_id).nil?
+ params[:milestone_id] = '' if milestone_id == IssuableFinder::NONE
+
+ if project.milestones.find_by(id: milestone_id).nil? &&
+ project.group&.milestones&.find(milestone_id).nil?
params[:milestone_id] = ''
end
end
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 711f4035c55..020365fa19a 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -61,8 +61,16 @@ module Issues
end
def cloneable_milestone_id
- @new_project.milestones
- .find_by(title: @old_issue.milestone.try(:title)).try(:id)
+ title = @old_issue.milestone&.title
+ return unless title
+
+ milestone_id = @new_project.milestones.find_by(title: title).try(:id)
+
+ if milestone_id.nil? && @new_project.group && can?(current_user, :read_group, @new_project.group)
+ milestone_id = @new_project.group.milestones.find_by(title: title).try(:id)
+ end
+
+ milestone_id
end
def rewrite_notes
diff --git a/app/services/milestones/close_service.rb b/app/services/milestones/close_service.rb
index 608fc49d766..776ec4b287b 100644
--- a/app/services/milestones/close_service.rb
+++ b/app/services/milestones/close_service.rb
@@ -1,7 +1,7 @@
module Milestones
class CloseService < Milestones::BaseService
def execute(milestone)
- if milestone.close
+ if milestone.close && milestone.is_project_milestone?
event_service.close_milestone(milestone, current_user)
end
diff --git a/app/services/milestones/reopen_service.rb b/app/services/milestones/reopen_service.rb
index 573f9ee5c21..5b8b682caaf 100644
--- a/app/services/milestones/reopen_service.rb
+++ b/app/services/milestones/reopen_service.rb
@@ -1,7 +1,7 @@
module Milestones
class ReopenService < Milestones::BaseService
def execute(milestone)
- if milestone.activate
+ if milestone.activate && milestone.is_project_milestone?
event_service.reopen_milestone(milestone, current_user)
end
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
new file mode 100644
index 00000000000..2715b708a9f
--- /dev/null
+++ b/app/views/groups/milestones/_form.html.haml
@@ -0,0 +1,29 @@
+= form_for [@group, @milestone], html: { class: 'form-horizontal milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
+ .row
+ = form_errors(@milestone)
+
+ = hidden_field_tag :title, @milestone&.title
+
+ .col-md-6
+ .form-group
+ = f.label :title, "Title", class: "control-label"
+ .col-sm-10
+ = f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
+ .form-group.milestone-description
+ = f.label :description, "Description", class: "control-label"
+ .col-sm-10
+ = render layout: 'projects/md_preview', locals: { url: '' } do
+ = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
+ .clearfix
+ .error-alert
+
+ = render "shared/milestones/form_dates", f: f
+
+ .form-actions
+ - if @milestone.new_record?
+ = f.submit 'Create milestone', class: "btn-create btn"
+ = link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
+ - else
+ = f.submit 'Update milestone', class: "btn-create btn"
+ = link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn btn-cancel"
+
diff --git a/app/views/groups/milestones/edit.html.haml b/app/views/groups/milestones/edit.html.haml
new file mode 100644
index 00000000000..5f6d7d209d0
--- /dev/null
+++ b/app/views/groups/milestones/edit.html.haml
@@ -0,0 +1,7 @@
+- page_title "Milestones"
+- render "header_title"
+
+%h3.page-title
+ Edit Milestone
+
+= render "form"
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index f91bee0b610..6ceb4092307 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -9,11 +9,6 @@
= link_to new_group_milestone_path(@group), class: "btn btn-new" do
New milestone
-.row-content-block
- Only milestones from
- %strong= @group.name
- group are listed here.
-
.milestones
%ul.content-list
- if @milestones.blank?
diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml
index 7c7573862d0..e24844661ee 100644
--- a/app/views/groups/milestones/new.html.haml
+++ b/app/views/groups/milestones/new.html.haml
@@ -4,40 +4,4 @@
%h3.page-title
New Milestone
-%p.light
- This will create milestone in every selected project
-%hr
-
-= form_for @milestone, url: group_milestones_path(@group), html: { class: 'form-horizontal milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
- .row
- - if @milestone.errors.any?
- #error_explanation
- .alert.alert-danger
- %ul
- - @milestone.errors.full_messages.each do |msg|
- %li
- = msg
-
- .col-md-6
- .form-group
- = f.label :title, "Title", class: "control-label"
- .col-sm-10
- = f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
- .form-group.milestone-description
- = f.label :description, "Description", class: "control-label"
- .col-sm-10
- = render layout: 'projects/md_preview', locals: { url: '' } do
- = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
- .clearfix
- .error-alert
- .form-group
- = f.label :projects, "Projects", class: "control-label"
- .col-sm-10
- = f.collection_select :project_ids, @group.projects.non_archived, :id, :name,
- { selected: @group.projects.non_archived.pluck(:id) }, required: true, multiple: true, class: 'select2'
-
- = render "shared/milestones/form_dates", f: f
-
- .form-actions
- = f.submit 'Create milestone', class: "btn-create btn"
- = link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
+= render "form"
diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml
index 33e68bc766e..54b1b7a734a 100644
--- a/app/views/groups/milestones/show.html.haml
+++ b/app/views/groups/milestones/show.html.haml
@@ -1,4 +1,4 @@
= render "header_title"
= render 'shared/milestones/top', milestone: @milestone, group: @group
-= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true
+= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true if @milestone.is_legacy_group_milestone?
= render 'shared/milestones/sidebar', milestone: @milestone, affix_offset: 102
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index d0acf575cbe..2294d37aa4d 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -1,10 +1,15 @@
- dashboard = local_assigns[:dashboard]
-- custom_dom_id = "milestone_#{milestone.title}"
+- custom_dom_id = dom_id(milestone.try(:milestones) ? milestone.milestones.first : milestone)
%li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id }
.row
.col-sm-6
%strong= link_to truncate(milestone.title, length: 100), milestone_path
+ - if milestone.is_group_milestone?
+ %span - Group Milestone
+ - else
+ %span - Project Milestone
+
.col-sm-6
.pull-right.light #{milestone.percent_complete(current_user)}% complete
.row
@@ -13,26 +18,32 @@
&middot;
= link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path
.col-sm-6= milestone_progress_bar(milestone)
- - if milestone.is_a?(GlobalMilestone)
+ - if milestone.is_a?(GlobalMilestone) || milestone.is_group_milestone?
.row
.col-sm-6
- .expiration= render('shared/milestone_expired', milestone: milestone)
- .projects
- - milestone.milestones.each do |milestone|
- = link_to milestone_path(milestone) do
- %span.label.label-gray
- = dashboard ? milestone.project.name_with_namespace : milestone.project.name
+ - if milestone.is_legacy_group_milestone?
+ .expiration= render('shared/milestone_expired', milestone: milestone)
+ .projects
+ - milestone.milestones.each do |milestone|
+ = link_to milestone_path(milestone) do
+ %span.label.label-gray
+ = dashboard ? milestone.project.name_with_namespace : milestone.project.name
- if @group
- .col-sm-6
+ .col-sm-6.milestone-actions
- if can?(current_user, :admin_milestones, @group)
+ - if milestone.is_group_milestone?
+ = link_to edit_group_milestone_path(@group, milestone.safe_title, title: milestone.title), class: "btn btn-xs btn-grouped" do
+ Edit
+ \
- if milestone.closed?
= link_to 'Reopen Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-xs btn-grouped btn-reopen"
- else
- = link_to 'Close Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-xs btn-close"
+ = link_to 'Close Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-xs btn-grouped btn-close"
- if @project
.row
- .col-sm-6= render('shared/milestone_expired', milestone: milestone)
+ .col-sm-6
+ = render('shared/milestone_expired', milestone: milestone)
.col-sm-6.milestone-actions
- if can?(current_user, :admin_milestone, milestone.project) and milestone.active?
= link_to edit_namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone), class: "btn btn-xs btn-grouped" do
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index 58ea0a356ae..149dfdb9158 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -22,6 +22,9 @@
- if group
.pull-right
- if can?(current_user, :admin_milestones, group)
+ - if milestone.is_group_milestone?
+ = link_to edit_group_milestone_path(group, milestone.safe_title, title: milestone.title), class: "btn btn btn-grouped" do
+ Edit
- if milestone.active?
= link_to 'Close Milestone', group_milestone_path(group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-grouped btn-close"
- else
@@ -30,32 +33,44 @@
.detail-page-description.milestone-detail
%h2.title
= markdown_field(milestone, :title)
+ - if @milestone.is_group_milestone? && @milestone.description.present?
+ %div
+ .description
+ .wiki
+ = markdown_field(@milestone, :description)
- if milestone.complete?(current_user) && milestone.active?
.alert.alert-success.prepend-top-default
- close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.'
%span All issues for this milestone are closed. #{close_msg}
-%h2 Only link to group milestone issues instead of list(for now)
-/ .table-holder
-/ %table.table
-/ %thead
-/ %tr
-/ %th Project
-/ %th Open issues
-/ %th State
-/ %th Due date
-/ - milestone.milestones.each do |ms|
-/ %tr
-/ %td
-/ - project_name = group ? ms.project.name : ms.project.name_with_namespace
-/ = link_to project_name, namespace_project_milestone_path(ms.project.namespace, ms.project, ms)
-/ %td
-/ = ms.issues_visible_to_user(current_user).opened.count
-/ %td
-/ - if ms.closed?
-/ Closed
-/ - else
-/ Open
-/ %td
-/ = ms.expires_at
+- if @milestone.is_legacy_group_milestone?
+ .table-holder
+ %table.table
+ %thead
+ %tr
+ %th Project
+ %th Open issues
+ %th State
+ %th Due date
+ - milestone.milestones.each do |ms|
+ %tr
+ %td
+ - project_name = group ? ms.project.name : ms.project.name_with_namespace
+ = link_to project_name, namespace_project_milestone_path(ms.project.namespace, ms.project, ms)
+ %td
+ = ms.issues_visible_to_user(current_user).opened.count
+ %td
+ - if ms.closed?
+ Closed
+ - else
+ Open
+ %td
+ = ms.expires_at
+- elsif @milestone.is_group_milestone?
+ %br
+ View
+ = link_to 'Issues', issues_group_path(@group, milestone_title: milestone.title)
+ or
+ = link_to 'Merge Requests', merge_requests_group_path(@group, milestone_title: milestone.title)
+ in this milestone
diff --git a/changelogs/unreleased/issue_30126_be.yml b/changelogs/unreleased/issue_30126_be.yml
new file mode 100644
index 00000000000..96bb8d9574b
--- /dev/null
+++ b/changelogs/unreleased/issue_30126_be.yml
@@ -0,0 +1,4 @@
+---
+title: Add native group milestones
+merge_request:
+author:
diff --git a/config/routes/group.rb b/config/routes/group.rb
index 11cdff55ed8..47010401191 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -12,7 +12,7 @@ scope(path: 'groups/*group_id',
end
resource :avatar, only: [:destroy]
- resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create] do
+ resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :edit, :update, :new, :create] do
member do
get :merge_requests
get :participants
diff --git a/db/migrate/20170619183807_create_group_milestones.rb b/db/migrate/20170619183807_create_group_milestones.rb
deleted file mode 100644
index 9ce287bb306..00000000000
--- a/db/migrate/20170619183807_create_group_milestones.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-class CreateGroupMilestones < ActiveRecord::Migration
- DOWNTIME = false
-
- def change
- create_table :group_milestones do |t|
- t.integer :group_id
- t.string :title
- t.text :description
- t.date :start_date
- t.date :due_date
- t.string :state
- t.string :title_html
- t.string :description_html
- t.integer :cached_markdown_version, limit: 4
- end
-
- add_column :issues, :group_milestone_id, :integer
- add_column :merge_requests, :group_milestone_id, :integer
-
- add_foreign_key :group_milestones, :namespaces, column: :group_id, null: false # rubocop: disable Migration/AddConcurrentForeignKey
- add_index :group_milestones, :group_id
- end
-end
diff --git a/db/migrate/20170619184243_add_group_milestone_group_id_indexes.rb b/db/migrate/20170619184243_add_group_milestone_group_id_indexes.rb
new file mode 100644
index 00000000000..2bdea582b86
--- /dev/null
+++ b/db/migrate/20170619184243_add_group_milestone_group_id_indexes.rb
@@ -0,0 +1,19 @@
+class AddGroupMilestoneGroupIdIndexes < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ DOWNTIME = false
+
+ def up
+ add_concurrent_foreign_key :milestones, :namespaces, column: :group_id, on_delete: :cascade
+
+ add_concurrent_index :milestones, :group_id
+ end
+
+ def down
+ remove_foreign_key :milestones, column: :group_id
+
+ remove_concurrent_index :milestones, :group_id
+ end
+end
diff --git a/db/migrate/20170623183807_add_group_id_to_milestones.rb b/db/migrate/20170623183807_add_group_id_to_milestones.rb
new file mode 100644
index 00000000000..ab98905ac53
--- /dev/null
+++ b/db/migrate/20170623183807_add_group_id_to_milestones.rb
@@ -0,0 +1,18 @@
+class AddGroupIdToMilestones < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ change_column_null :milestones, :project_id, true
+
+ add_column :milestones, :group_id, :integer
+ end
+
+ def down
+ remove_column :milestones, :group_id
+ change_column_null :milestones, :project_id, true
+
+ # We cannot rollback project_id not null constraint if there are records
+ # with null values.
+ execute "DELETE from milestones WHERE project_id IS NULL"
+ end
+end
diff --git a/db/migrate/20170624184243_add_group_milestone_id_indexes.rb b/db/migrate/20170624184243_add_group_milestone_id_indexes.rb
index 836d39e6e6d..6289d6d894f 100644
--- a/db/migrate/20170624184243_add_group_milestone_id_indexes.rb
+++ b/db/migrate/20170624184243_add_group_milestone_id_indexes.rb
@@ -6,18 +6,14 @@ class AddGroupMilestoneIdIndexes < ActiveRecord::Migration
DOWNTIME = false
def up
- add_foreign_key :issues, :namespaces, column: :group_milestone_id
- add_foreign_key :merge_requests, :namespaces, column: :group_milestone_id
+ add_concurrent_foreign_key :milestones, :namespaces, column: :group_id
- add_concurrent_index :issues, :group_milestone_id
- add_concurrent_index :merge_requests, :group_milestone_id
+ add_concurrent_index :milestones, :group_id
end
def down
- remove_foreign_key :issues, column: :group_milestone_id
- remove_foreign_key :merge_requests, column: :group_milestone_id
+ remove_foreign_key :milestones, column: :group_id
- remove_concurrent_index :issues, :group_milestone_id
- remove_concurrent_index :merge_requests, :group_milestone_id
+ remove_concurrent_index :milestones, :group_id
end
end
diff --git a/db/schema.rb b/db/schema.rb
index eec2e91c3c6..6d2fe1e47ea 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -771,14 +771,7 @@ ActiveRecord::Schema.define(version: 20170624184243) do
t.datetime "last_edited_at"
t.integer "last_edited_by_id"
t.integer "head_pipeline_id"
-<<<<<<< bce6d12bb9365ec2e8ffd5f40f0d1f03f88b315a
-<<<<<<< 5af1fcd6f329858d757bab0d67cb50af6c820160
t.boolean "ref_fetched"
-=======
- t.integer "group_milestone_id"
->>>>>>> Add group milestones table
-=======
->>>>>>> Native group milestones
end
add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree
@@ -786,14 +779,7 @@ ActiveRecord::Schema.define(version: 20170624184243) do
add_index "merge_requests", ["created_at"], name: "index_merge_requests_on_created_at", using: :btree
add_index "merge_requests", ["deleted_at"], name: "index_merge_requests_on_deleted_at", using: :btree
add_index "merge_requests", ["description"], name: "index_merge_requests_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
-<<<<<<< bce6d12bb9365ec2e8ffd5f40f0d1f03f88b315a
-<<<<<<< 5af1fcd6f329858d757bab0d67cb50af6c820160
add_index "merge_requests", ["head_pipeline_id"], name: "index_merge_requests_on_head_pipeline_id", using: :btree
-=======
- add_index "merge_requests", ["group_milestone_id"], name: "index_merge_requests_on_group_milestone_id", using: :btree
->>>>>>> Add group milestones table
-=======
->>>>>>> Native group milestones
add_index "merge_requests", ["milestone_id"], name: "index_merge_requests_on_milestone_id", using: :btree
add_index "merge_requests", ["source_branch"], name: "index_merge_requests_on_source_branch", using: :btree
add_index "merge_requests", ["source_project_id"], name: "index_merge_requests_on_source_project_id", using: :btree
diff --git a/features/group/milestones.feature b/features/group/milestones.feature
index 1c1539b3e12..2211acfee20 100644
--- a/features/group/milestones.feature
+++ b/features/group/milestones.feature
@@ -22,12 +22,12 @@ Feature: Group Milestones
Then I should see group milestone with descriptions and expiry date
And I should see group milestone with all issues and MRs assigned to that milestone
- Scenario: Create multiple milestones with one form
+ Scenario: Create group milestones
Given I visit group "Owned" milestones page
And I click new milestone button
And I fill milestone name
When I press create mileston button
- Then milestone in each project should be created
+ Then group milestone should be created
Scenario: I should see Issues listed with labels
Given Group has projects with milestones
diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb
index 0542b06c0ab..03910c00729 100644
--- a/features/steps/group/milestones.rb
+++ b/features/steps/group/milestones.rb
@@ -54,14 +54,9 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
click_button "Create milestone"
end
- step 'milestone in each project should be created' do
+ step 'group milestone should be created' do
group = Group.find_by(name: 'Owned')
- expect(page).to have_content "Milestone v2.9.0"
- expect(group.projects).to be_present
-
- group.projects.each do |project|
- expect(page).to have_content project.name
- end
+ expect(page).to have_content group.milestones.find_by_title('v2.9.0').title
end
step 'I should see the "bug" label' do
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index cef5a0abe12..d1068fff03b 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -266,7 +266,12 @@ module API
expose :deleted_file?, as: :deleted_file
end
- class Milestone < ProjectEntity
+ class Milestone < Grape::Entity
+ expose :id, :iid
+ expose(:project_id) { |entity| entity&.project&.id }
+ expose(:group_id) { |entity| entity&.group&.id }
+ expose :title, :description
+ expose :state, :created_at, :updated_at
expose :due_date
expose :start_date
end
diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb
index c6e5fb61cf9..67c70af9ad1 100644
--- a/spec/controllers/groups/milestones_controller_spec.rb
+++ b/spec/controllers/groups/milestones_controller_spec.rb
@@ -17,6 +17,14 @@ describe Groups::MilestonesController do
end
let(:milestone_path) { group_milestone_path(group, milestone.safe_title, title: milestone.title) }
+ let(:milestone_params) do
+ {
+ title: title,
+ start_date: Date.today,
+ due_date: 1.month.from_now.to_date
+ }
+ end
+
before do
sign_in(user)
group.add_owner(user)
@@ -44,16 +52,31 @@ describe Groups::MilestonesController do
it "creates group milestone with Chinese title" do
post :create,
group_id: group.to_param,
- milestone: { project_ids: [project.id, project2.id], title: title }
+ milestone: milestone_params
+ milestone = Milestone.find_by_title(title)
expect(response).to redirect_to(group_milestone_path(group, title.to_slug.to_s, title: title))
- expect(Milestone.where(title: title).count).to eq(2)
+ expect(milestone.group_id).to eq(group.id)
+ expect(milestone.due_date).to eq(milestone_params[:due_date])
+ expect(milestone.start_date).to eq(milestone_params[:start_date])
end
+ end
+
+ describe "#update" do
+ let(:milestone) { create(:milestone, group: group) }
+
+ it "updates group milestone" do
+ milestone_params[:title] = "title changed"
+
+ put :update,
+ id: milestone.title.to_slug.to_s,
+ group_id: group.to_param,
+ milestone: milestone_params,
+ title: milestone.title
- it "redirects to new when there are no project ids" do
- post :create, group_id: group.to_param, milestone: { title: title, project_ids: [""] }
- expect(response).to render_template :new
- expect(assigns(:milestone).errors).not_to be_nil
+ milestone.reload
+ expect(response).to redirect_to(group_milestone_path(group, milestone.title.to_slug.to_s, title: milestone.title))
+ expect(milestone.title).to eq("title changed")
end
end
@@ -156,7 +179,7 @@ describe Groups::MilestonesController do
it 'does not 404' do
post :create,
group_id: group.to_param,
- milestone: { project_ids: [project.id, project2.id], title: title }
+ milestone: { title: title }
expect(response).not_to have_http_status(404)
end
@@ -164,7 +187,7 @@ describe Groups::MilestonesController do
it 'does not redirect to the correct casing' do
post :create,
group_id: group.to_param,
- milestone: { project_ids: [project.id, project2.id], title: title }
+ milestone: { title: title }
expect(response).not_to have_http_status(301)
end
@@ -176,7 +199,7 @@ describe Groups::MilestonesController do
it 'returns not found' do
post :create,
group_id: redirect_route.path,
- milestone: { project_ids: [project.id, project2.id], title: title }
+ milestone: { title: title }
expect(response).to have_http_status(404)
end
diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb
index 84a61b2784e..5af36df7405 100644
--- a/spec/controllers/projects/milestones_controller_spec.rb
+++ b/spec/controllers/projects/milestones_controller_spec.rb
@@ -31,6 +31,39 @@ describe Projects::MilestonesController do
end
end
+ describe "#index" do
+ context "as html" do
+ before do
+ get :index, namespace_id: project.namespace.id, project_id: project.id
+ end
+
+ it "queries only projects milestones" do
+ milestones = assigns(:milestones)
+ expect(milestones.count).to eq(1)
+ expect(milestones.where(project_id: nil)).to be_empty
+ end
+ end
+
+ context "as json" do
+ let!(:group) { create(:group, :public) }
+ let!(:group_milestone) { create(:milestone, group: group) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+
+ before do
+ project.update(namespace: group)
+ get :index, namespace_id: project.namespace.id, project_id: project.id, format: :json
+ end
+
+ it "queries projects milestones and groups milestones" do
+ milestones = assigns(:milestones)
+
+ expect(milestones.count).to eq(2)
+ expect(milestones.where(project_id: nil).first).to eq(group_milestone)
+ expect(milestones.where(group_id: nil).first).to eq(milestone)
+ end
+ end
+ end
+
describe "#destroy" do
it "removes milestone" do
expect(issue.milestone_id).to eq(milestone.id)
diff --git a/spec/factories/milestones.rb b/spec/factories/milestones.rb
index 841ab3c73b8..113665ff11b 100644
--- a/spec/factories/milestones.rb
+++ b/spec/factories/milestones.rb
@@ -1,7 +1,13 @@
FactoryGirl.define do
factory :milestone do
title
- project factory: :empty_project
+
+ transient do
+ project nil
+ group nil
+ project_id nil
+ group_id nil
+ end
trait :active do
state "active"
@@ -11,6 +17,20 @@ FactoryGirl.define do
state "closed"
end
+ after(:build) do |milestone, evaluator|
+ if evaluator.group
+ milestone.group = evaluator.group
+ elsif evaluator.group_id
+ milestone.group_id = evaluator.group_id
+ elsif evaluator.project
+ milestone.project = evaluator.project
+ elsif evaluator.project_id
+ milestone.project_id = evaluator.project_id
+ else
+ milestone.project = create(:empty_project)
+ end
+ end
+
factory :active_milestone, traits: [:active]
factory :closed_milestone, traits: [:closed]
end
diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb
index 330310eae6b..9b6eb946f4b 100644
--- a/spec/features/groups/milestone_spec.rb
+++ b/spec/features/groups/milestone_spec.rb
@@ -33,4 +33,32 @@ feature 'Group milestones', :feature, :js do
expect(find('.start_date')).to have_content(Date.today.at_beginning_of_month.strftime('%b %-d, %Y'))
end
end
+
+ context 'milestones list' do
+ let!(:other_project) { create(:project_empty_repo, group: group) }
+
+ let!(:active_group_milestone) { create(:milestone, group: group, state: 'active') }
+ let!(:active_project_milestone1) { create(:milestone, project: project, state: 'active', title: 'v1.0') }
+ let!(:active_project_milestone2) { create(:milestone, project: other_project, state: 'active', title: 'v1.0') }
+ let!(:closed_group_milestone) { create(:milestone, group: group, state: 'closed') }
+ let!(:closed_project_milestone1) { create(:milestone, project: project, state: 'closed', title: 'v2.0') }
+ let!(:closed_project_milestone2) { create(:milestone, project: other_project, state: 'closed', title: 'v2.0') }
+
+ before do
+ visit group_milestones_path(group)
+ end
+
+ it 'counts milestones correctly' do
+ expect(find('.top-area .active .badge').text).to eq("2")
+ expect(find('.top-area .closed .badge').text).to eq("2")
+ expect(find('.top-area .all .badge').text).to eq("4")
+ end
+
+ it 'lists legacy group milestones and group milestones' do
+ legacy_milestone = GroupMilestone.build_collection(group, group.projects, { state: 'active' }).first
+
+ expect(page).to have_selector("#milestone_#{active_group_milestone.id}", count: 1)
+ expect(page).to have_selector("#milestone_#{legacy_milestone.milestones.first.id}", count: 1)
+ end
+ end
end
diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb
index 58989581ffe..4a3ce55c71a 100644
--- a/spec/features/milestone_spec.rb
+++ b/spec/features/milestone_spec.rb
@@ -1,7 +1,8 @@
require 'rails_helper'
feature 'Milestone', feature: true do
- let(:project) { create(:empty_project, :public) }
+ let(:group) { create(:group, :public) }
+ let(:project) { create(:empty_project, :public, namespace: group) }
let(:user) { create(:user) }
before do
@@ -37,8 +38,8 @@ feature 'Milestone', feature: true do
end
end
- feature 'Open a milestone with an existing title' do
- scenario 'displays validation message' do
+ feature 'Open a project milestone with an existing title' do
+ scenario 'displays validation message when there is a project milestone with same title' do
milestone = create(:milestone, project: project, title: 8.7)
visit new_namespace_project_milestone_path(project.namespace, project)
@@ -47,7 +48,19 @@ feature 'Milestone', feature: true do
end
find('input[name="commit"]').click
- expect(find('.alert-danger')).to have_content('Title has already been taken')
+ expect(find('.alert-danger')).to have_content('already being used for another group or project milestone.')
+ end
+
+ scenario 'displays validation message when there is a group milestone with same title' do
+ milestone = create(:milestone, project_id: nil, group: project.group, title: 8.7)
+
+ visit new_namespace_project_milestone_path(project.namespace, project)
+ page.within '.milestone-form' do
+ fill_in "milestone_title", with: milestone.title
+ end
+ find('input[name="commit"]').click
+
+ expect(find('.alert-danger')).to have_content('already being used for another group or project milestone.')
end
end
end
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index 4a52f0d5c58..bef4fd44331 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -59,6 +59,23 @@ describe IssuesFinder do
end
end
+ context 'filtering by group milestone' do
+ let!(:group) { create(:group, :public) }
+ let(:group_milestone) { create(:milestone, group: group) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+ let(:params) { { milestone_title: group_milestone.title } }
+
+ before do
+ project2.update(namespace: group)
+ issue2.update(milestone: group_milestone)
+ issue3.update(milestone: group_milestone)
+ end
+
+ it 'returns issues assigned to that group milestone' do
+ expect(issues).to contain_exactly(issue2, issue3)
+ end
+ end
+
context 'filtering by no milestone' do
let(:params) { { milestone_title: Milestone::None.title } }
diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb
index 5eb26de6c92..b46218bf72e 100644
--- a/spec/finders/merge_requests_finder_spec.rb
+++ b/spec/finders/merge_requests_finder_spec.rb
@@ -47,6 +47,25 @@ describe MergeRequestsFinder do
expect(merge_requests).to contain_exactly(merge_request1)
end
+ context 'filtering by group milestone' do
+ let!(:group) { create(:group, :public) }
+ let(:group_milestone) { create(:milestone, group: group) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+ let(:params) { { milestone_title: group_milestone.title } }
+
+ before do
+ project2.update(namespace: group)
+ merge_request2.update(milestone: group_milestone)
+ merge_request3.update(milestone: group_milestone)
+ end
+
+ it 'returns issues assigned to that group milestone' do
+ merge_requests = described_class.new(user, params).execute
+
+ expect(merge_requests).to contain_exactly(merge_request2, merge_request3)
+ end
+ end
+
context 'with created_after and created_before params' do
let(:project4) { create(:empty_project, forked_from_project: project1) }
diff --git a/spec/finders/milestones_finder_spec.rb b/spec/finders/milestones_finder_spec.rb
new file mode 100644
index 00000000000..e81a5bae9a9
--- /dev/null
+++ b/spec/finders/milestones_finder_spec.rb
@@ -0,0 +1,48 @@
+require 'spec_helper'
+
+describe MilestonesFinder do
+ let(:group) { create(:group) }
+ let(:project_1) { create(:empty_project, namespace: group) }
+ let(:project_2) { create(:empty_project, namespace: group) }
+ let!(:milestone_1) { create(:milestone, group: group) }
+ let!(:milestone_2) { create(:milestone, group: group) }
+ let!(:milestone_3) { create(:milestone, project: project_1, state: 'active') }
+ let!(:milestone_4) { create(:milestone, project: project_2, state: 'active') }
+
+ it 'it returns milestones for projects' do
+ result = described_class.new(projects: [project_1, project_2], params: { state: 'all' }).execute
+
+ expect(result).to contain_exactly(milestone_3, milestone_4)
+ end
+
+ it 'returns milestones for groups' do
+ result = described_class.new(groups: group, params: { state: 'all' }).execute
+
+ expect(result).to contain_exactly(milestone_1, milestone_2)
+ end
+
+ it 'returns milestones for groups and projects' do
+ result = described_class.new(projects: [project_1, project_2], groups: group, params: { state: 'all' }).execute
+
+ expect(result).to contain_exactly(milestone_1, milestone_2, milestone_3, milestone_4)
+ end
+
+ context 'state filtering' do
+ before do
+ milestone_1.close
+ milestone_3.close
+ end
+
+ it 'filters by active state' do
+ result = described_class.new(projects: [project_1, project_2], groups: group, params: { state: 'active' }).execute
+
+ expect(result).to contain_exactly(milestone_2, milestone_4)
+ end
+
+ it 'filters by closed state' do
+ result = described_class.new(projects: [project_1, project_2], groups: group, params: { state: 'closed' }).execute
+
+ expect(result).to contain_exactly(milestone_1, milestone_3)
+ end
+ end
+end
diff --git a/spec/fixtures/api/schemas/public_api/v3/issues.json b/spec/fixtures/api/schemas/public_api/v3/issues.json
index f2ee9c925ae..51b0822bc66 100644
--- a/spec/fixtures/api/schemas/public_api/v3/issues.json
+++ b/spec/fixtures/api/schemas/public_api/v3/issues.json
@@ -22,7 +22,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
- "project_id": { "type": "integer" },
+ "project_id": { "type": ["integer", "null"] },
+ "group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
diff --git a/spec/fixtures/api/schemas/public_api/v3/merge_requests.json b/spec/fixtures/api/schemas/public_api/v3/merge_requests.json
index 01f9fbb2c89..b5c74bcc26e 100644
--- a/spec/fixtures/api/schemas/public_api/v3/merge_requests.json
+++ b/spec/fixtures/api/schemas/public_api/v3/merge_requests.json
@@ -53,7 +53,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
- "project_id": { "type": "integer" },
+ "project_id": { "type": ["integer", "null"] },
+ "group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json
index 2d1c84ee93d..bd6bfc03199 100644
--- a/spec/fixtures/api/schemas/public_api/v4/issues.json
+++ b/spec/fixtures/api/schemas/public_api/v4/issues.json
@@ -22,7 +22,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
- "project_id": { "type": "integer" },
+ "project_id": { "type": ["integer", "null"] },
+ "group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
diff --git a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json
index 51642e8cbb8..60aa47c1259 100644
--- a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json
+++ b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json
@@ -53,7 +53,8 @@
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
- "project_id": { "type": "integer" },
+ "project_id": { "type": ["integer", "null"] },
+ "group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index a5f09f1856e..5df4fc2de84 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -45,6 +45,7 @@ label:
- merge_requests
- priorities
milestone:
+- group
- project
- issues
- labels
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index fadd3ad1330..1c40984e863 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -82,6 +82,7 @@ Milestone:
- id
- title
- project_id
+- group_id
- description
- due_date
- start_date
diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb
index 45953023a36..6c06c10e7ce 100644
--- a/spec/models/milestone_spec.rb
+++ b/spec/models/milestone_spec.rb
@@ -6,9 +6,6 @@ describe Milestone, models: true do
allow(subject).to receive(:set_iid).and_return(false)
end
- it { is_expected.to validate_presence_of(:title) }
- it { is_expected.to validate_presence_of(:project) }
-
describe 'start_date' do
it 'adds an error when start_date is greated then due_date' do
milestone = build(:milestone, start_date: Date.tomorrow, due_date: Date.yesterday)
@@ -37,17 +34,42 @@ describe Milestone, models: true do
end
end
- describe "unique milestone title per project" do
- it "does not accept the same title in a project twice" do
- new_milestone = Milestone.new(project: milestone.project, title: milestone.title)
- expect(new_milestone).not_to be_valid
+ describe "unique milestone title" do
+ context "per project" do
+ it "does not accept the same title in a project twice" do
+ new_milestone = Milestone.new(project: milestone.project, title: milestone.title)
+ expect(new_milestone).not_to be_valid
+ end
+
+ it "accepts the same title in another project" do
+ project = build(:empty_project)
+ new_milestone = Milestone.new(project: project, title: milestone.title)
+
+ expect(new_milestone).to be_valid
+ end
end
- it "accepts the same title in another project" do
- project = build(:empty_project)
- new_milestone = Milestone.new(project: project, title: milestone.title)
+ context "per group" do
+ let(:group) { create(:group) }
+ let(:milestone) { create(:milestone, group: group) }
+
+ before do
+ project.update(group: group)
+ end
+
+ it "does not accept the same title in a group twice" do
+ new_milestone = Milestone.new(group: group, title: milestone.title)
+
+ expect(new_milestone).not_to be_valid
+ end
- expect(new_milestone).to be_valid
+ it "does not accept the same title of a child project milestone" do
+ create(:milestone, project: group.projects.first)
+
+ new_milestone = Milestone.new(group: group, title: milestone.title)
+
+ expect(new_milestone).not_to be_valid
+ end
end
end
diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb
index d1dd1466d95..36d5038fb95 100644
--- a/spec/services/issues/move_service_spec.rb
+++ b/spec/services/issues/move_service_spec.rb
@@ -37,9 +37,6 @@ describe Issues::MoveService, services: true do
describe '#execute' do
shared_context 'issue move executed' do
- let!(:milestone2) do
- create(:milestone, project_id: new_project.id, title: 'v9.0')
- end
let!(:award_emoji) { create(:award_emoji, awardable: old_issue) }
let!(:new_issue) { move_service.execute(old_issue, new_project) }
@@ -48,6 +45,63 @@ describe Issues::MoveService, services: true do
context 'issue movable' do
include_context 'user can move issue'
+ context 'move to new milestone' do
+ let(:new_issue) { move_service.execute(old_issue, new_project) }
+
+ context 'project milestone' do
+ let!(:milestone2) do
+ create(:milestone, project_id: new_project.id, title: 'v9.0')
+ end
+
+ it 'assigns milestone to new issue' do
+ expect(new_issue.reload.milestone.title).to eq 'v9.0'
+ expect(new_issue.reload.milestone).to eq(milestone2)
+ end
+ end
+
+ context 'group milestones' do
+ let!(:group) { create(:group, :private) }
+ let!(:group_milestone_1) do
+ create(:milestone, group_id: group.id, title: 'v9.0_group')
+ end
+
+ before do
+ old_issue.update(milestone: group_milestone_1)
+ old_project.update(namespace: group)
+ new_project.update(namespace: group)
+
+ group.add_users([user], GroupMember::DEVELOPER)
+ end
+
+ context 'when moving to a project of the same group' do
+ it 'keeps the same group milestone' do
+ expect(new_issue.reload.project).to eq(new_project)
+ expect(new_issue.reload.milestone).to eq(group_milestone_1)
+ end
+ end
+
+ context 'when moving to a project of a different group' do
+ let!(:group_2) { create(:group, :private) }
+
+ let!(:group_milestone_2) do
+ create(:milestone, group_id: group_2.id, title: 'v9.0_group')
+ end
+
+ before do
+ old_issue.update(milestone: group_milestone_1)
+ new_project.update(namespace: group_2)
+
+ group_2.add_users([user], GroupMember::DEVELOPER)
+ end
+
+ it 'assigns to new group milestone of same title' do
+ expect(new_issue.reload.project).to eq(new_project)
+ expect(new_issue.reload.milestone).to eq(group_milestone_2)
+ end
+ end
+ end
+ end
+
context 'generic issue' do
include_context 'issue move executed'
@@ -55,11 +109,6 @@ describe Issues::MoveService, services: true do
expect(new_issue.project).to eq new_project
end
- it 'assigns milestone to new issue' do
- expect(new_issue.reload.milestone.title).to eq 'v9.0'
- expect(new_issue.reload.milestone).to eq(milestone2)
- end
-
it 'assign labels to new issue' do
expected_label_titles = new_issue.reload.labels.map(&:title)
expect(expected_label_titles).to include 'label1'