milestone.rb 7.34 KB
Newer Older
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
1
class Milestone < ActiveRecord::Base
2
3
  # Represents a "No Milestone" state used for filtering Issues and Merge
  # Requests that have no milestone assigned.
4
5
6
  MilestoneStruct = Struct.new(:title, :name, :id)
  None = MilestoneStruct.new('No Milestone', 'No Milestone', 0)
  Any = MilestoneStruct.new('Any Milestone', '', -1)
tiagonbotelho's avatar
tiagonbotelho committed
7
  Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2)
8
  Started = MilestoneStruct.new('Started', '#started', -3)
9

10
  include CacheMarkdownField
11
  include NonatomicInternalId
12
  include Sortable
13
  include Referable
14
  include StripAttribute
15
  include Milestoneish
16
  include Gitlab::SQL::Pattern
17

18
19
20
  cache_markdown_field :title, pipeline: :single_line
  cache_markdown_field :description

Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
21
  belongs_to :project
Felipe Artur's avatar
Felipe Artur committed
22
23
  belongs_to :group

Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
24
  has_many :issues
25
  has_many :labels, -> { distinct.reorder('labels.title') },  through: :issues
26
  has_many :merge_requests
27
  has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
28

Felipe Artur's avatar
Felipe Artur committed
29
30
  scope :of_projects, ->(ids) { where(project_id: ids) }
  scope :of_groups, ->(ids) { where(group_id: ids) }
Andrew8xx8's avatar
Andrew8xx8 committed
31
32
  scope :active, -> { with_state(:active) }
  scope :closed, -> { with_state(:closed) }
Felipe Artur's avatar
Felipe Artur committed
33
34
35
36
37
38
39
40
41
42
43
44
  scope :for_projects, -> { where(group: nil).includes(:project) }

  scope :for_projects_and_groups, -> (project_ids, group_ids) do
    conditions = []
    conditions << arel_table[:project_id].in(project_ids) if project_ids.compact.any?
    conditions << arel_table[:group_id].in(group_ids) if group_ids.compact.any?

    where(conditions.reduce(:or))
  end

  validates :group, presence: true, unless: :project
  validates :project, presence: true, unless: :group
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
45

Felipe Artur's avatar
Felipe Artur committed
46
47
  validate :uniqueness_of_title, if: :title_changed?
  validate :milestone_type_check
mhasbini's avatar
mhasbini committed
48
  validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
Andrew8xx8's avatar
Andrew8xx8 committed
49

50
51
  strip_attributes :title

Andrew8xx8's avatar
Andrew8xx8 committed
52
  state_machine :state, initial: :active do
Andrew8xx8's avatar
Andrew8xx8 committed
53
    event :close do
Andrew8xx8's avatar
Andrew8xx8 committed
54
      transition active: :closed
Andrew8xx8's avatar
Andrew8xx8 committed
55
56
57
    end

    event :activate do
Andrew8xx8's avatar
Andrew8xx8 committed
58
      transition closed: :active
Andrew8xx8's avatar
Andrew8xx8 committed
59
60
61
62
63
64
    end

    state :closed

    state :active
  end
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
65

66
67
  alias_attribute :name, :title

Valery Sizov's avatar
Valery Sizov committed
68
  class << self
69
70
71
72
73
74
75
    # Searches for milestones matching the given query.
    #
    # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
    #
    # query - The search query as a String
    #
    # Returns an ActiveRecord::Relation.
Valery Sizov's avatar
Valery Sizov committed
76
    def search(query)
77
      fuzzy_search(query, [:title, :description])
Valery Sizov's avatar
Valery Sizov committed
78
    end
Felipe Artur's avatar
Felipe Artur committed
79
80
81
82
83
84
85
86

    def filter_by_state(milestones, state)
      case state
      when 'closed' then milestones.closed
      when 'all' then milestones
      else milestones.active
      end
    end
87
88
89
90
91
92
93

    def predefined?(milestone)
      milestone == Any ||
        milestone == None ||
        milestone == Upcoming ||
        milestone == Started
    end
Valery Sizov's avatar
Valery Sizov committed
94
95
  end

96
97
98
99
  def self.reference_prefix
    '%'
  end

100
  def self.reference_pattern
101
102
103
    # NOTE: The iid pattern only matches when all characters on the expression
    # are digits, so it will match %2 but not %2.1 because that's probably a
    # milestone name and we want it to be matched as such.
Rémy Coutable's avatar
Rémy Coutable committed
104
    @reference_pattern ||= %r{
105
106
107
      (#{Project.reference_pattern})?
      #{Regexp.escape(reference_prefix)}
      (?:
108
109
110
        (?<milestone_iid>
          \d+(?!\S\w)\b # Integer-based milestone iid, or
        ) |
111
        (?<milestone_name>
112
113
          [^"\s]+\b |  # String-based single-word milestone title, or
          "[^"]+"      # String-based multi-word milestone surrounded in quotes
114
115
116
        )
      )
    }x
117
118
119
  end

  def self.link_reference_pattern
120
    @link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/)
121
122
  end

123
124
125
126
  def self.upcoming_ids_by_projects(projects)
    rel = unscoped.of_projects(projects).active.where('due_date > ?', Time.now)

    if Gitlab::Database.postgresql?
127
      rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id')
128
    else
129
130
131
132
133
      rel
        .group(:project_id)
        .having('due_date = MIN(due_date)')
        .pluck(:id, :project_id, :due_date)
        .map(&:first)
134
    end
tiagonbotelho's avatar
tiagonbotelho committed
135
136
  end

137
  def participants
138
    User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).uniq
139
140
  end

141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
  def self.sort(method)
    case method.to_s
    when 'due_date_asc'
      reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC'))
    when 'due_date_desc'
      reorder(Gitlab::Database.nulls_last_order('due_date', 'DESC'))
    when 'start_date_asc'
      reorder(Gitlab::Database.nulls_last_order('start_date', 'ASC'))
    when 'start_date_desc'
      reorder(Gitlab::Database.nulls_last_order('start_date', 'DESC'))
    else
      order_by(method)
    end
  end

156
  ##
157
158
159
  # Returns the String necessary to reference this Milestone in Markdown. Group
  # milestones only support name references, and do not support cross-project
  # references.
160
161
162
163
164
  #
  # format - Symbol format to use (default: :iid, optional: :name)
  #
  # Examples:
  #
165
166
167
168
  #   Milestone.first.to_reference                           # => "%1"
  #   Milestone.first.to_reference(format: :name)            # => "%\"goal\""
  #   Milestone.first.to_reference(cross_namespace_project)  # => "gitlab-org/gitlab-ce%1"
  #   Milestone.first.to_reference(same_namespace_project)   # => "gitlab-ce%1"
169
  #
170
  def to_reference(from = nil, format: :name, full: false)
171
172
    format_reference = milestone_format_reference(format)
    reference = "#{self.class.reference_prefix}#{format_reference}"
173

174
    if project
175
      "#{project.to_reference(from, full: full)}#{reference}"
176
177
178
    else
      reference
    end
179
180
  end

181
  def reference_link_text(from = nil)
182
    self.title
183
184
  end

185
186
187
188
  def milestoneish_ids
    id
  end

Felipe Artur's avatar
Felipe Artur committed
189
190
191
192
  def for_display
    self
  end

Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
193
  def can_be_closed?
Andrew8xx8's avatar
Andrew8xx8 committed
194
    active? && issues.opened.count.zero?
195
196
  end

197
  def author_id
198
    nil
199
  end
200

201
  def title=(value)
202
    write_attribute(:title, sanitize_title(value)) if value.present?
203
204
  end

Felipe Artur's avatar
Felipe Artur committed
205
206
207
208
209
210
211
212
  def safe_title
    title.to_slug.normalize.to_s
  end

  def parent
    group || project
  end

213
  def group_milestone?
Felipe Artur's avatar
Felipe Artur committed
214
215
216
    group_id.present?
  end

217
  def project_milestone?
Felipe Artur's avatar
Felipe Artur committed
218
219
220
    project_id.present?
  end

221
222
  private

Felipe Artur's avatar
Felipe Artur committed
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
  # Milestone titles must be unique across project milestones and group milestones
  def uniqueness_of_title
    if project
      relation = Milestone.for_projects_and_groups([project_id], [project.group&.id])
    elsif group
      project_ids = group.projects.map(&:id)
      relation = Milestone.for_projects_and_groups(project_ids, [group.id])
    end

    title_exists = relation.find_by_title(title)
    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

244
  def milestone_format_reference(format = :iid)
Rémy Coutable's avatar
Rémy Coutable committed
245
    raise ArgumentError, 'Unknown format' unless [:iid, :name].include?(format)
246

247
248
249
250
    if group_milestone? && format == :iid
      raise ArgumentError, 'Cannot refer to a group milestone by an internal id!'
    end

251
252
253
    if format == :name && !name.include?('"')
      %("#{name}")
    else
254
      iid
255
256
    end
  end
257
258
259
260

  def sanitize_title(value)
    CGI.unescape_html(Sanitize.clean(value.to_s))
  end
261
262
263

  def start_date_should_be_less_than_due_date
    if due_date <= start_date
264
      errors.add(:due_date, "must be greater than start date")
265
266
    end
  end
267
268

  def issues_finder_params
269
    { project_id: project_id }
270
  end
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
271
end