milestone.rb 5.35 KB
Newer Older
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)
7
  Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2)
8

9
  include InternalId
10
  include Sortable
11
  include Referable
12
  include StripAttribute
13
  include Milestoneish
14

15 16
  belongs_to :project
  has_many :issues
17
  has_many :labels, -> { distinct.reorder('labels.title') },  through: :issues
18
  has_many :merge_requests
19
  has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee
20
  has_many :events, as: :target, dependent: :destroy
21

Andrew8xx8's avatar
Andrew8xx8 committed
22 23
  scope :active, -> { with_state(:active) }
  scope :closed, -> { with_state(:closed) }
24
  scope :of_projects, ->(ids) { where(project_id: ids) }
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
25

26
  validates :title, presence: true, uniqueness: { scope: :project_id }
Andrey Kumanyaev's avatar
Andrey Kumanyaev committed
27
  validates :project, presence: true
Andrew8xx8's avatar
Andrew8xx8 committed
28

29 30
  strip_attributes :title

Andrew8xx8's avatar
Andrew8xx8 committed
31
  state_machine :state, initial: :active do
Andrew8xx8's avatar
Andrew8xx8 committed
32
    event :close do
Andrew8xx8's avatar
Andrew8xx8 committed
33
      transition active: :closed
Andrew8xx8's avatar
Andrew8xx8 committed
34 35 36
    end

    event :activate do
Andrew8xx8's avatar
Andrew8xx8 committed
37
      transition closed: :active
Andrew8xx8's avatar
Andrew8xx8 committed
38 39 40 41 42 43
    end

    state :closed

    state :active
  end
44

45 46
  alias_attribute :name, :title

47
  class << self
48 49 50 51 52 53 54
    # 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.
55
    def search(query)
56 57 58 59
      t = arel_table
      pattern = "%#{query}%"

      where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
60 61 62
    end
  end

63 64 65 66
  def self.reference_prefix
    '%'
  end

67
  def self.reference_pattern
68 69 70
    # 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
71
    @reference_pattern ||= %r{
72 73 74
      (#{Project.reference_pattern})?
      #{Regexp.escape(reference_prefix)}
      (?:
75 76 77
        (?<milestone_iid>
          \d+(?!\S\w)\b # Integer-based milestone iid, or
        ) |
78
        (?<milestone_name>
79 80
          [^"\s]+\b |  # String-based single-word milestone title, or
          "[^"]+"      # String-based multi-word milestone surrounded in quotes
81 82 83
        )
      )
    }x
84 85 86
  end

  def self.link_reference_pattern
87
    @link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/)
88 89
  end

90 91 92 93
  def self.upcoming_ids_by_projects(projects)
    rel = unscoped.of_projects(projects).active.where('due_date > ?', Time.now)

    if Gitlab::Database.postgresql?
94
      rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id')
95 96 97 98 99 100 101
    else
      rel.
        group(:project_id).
        having('due_date = MIN(due_date)').
        pluck(:id, :project_id, :due_date).
        map(&:first)
    end
102 103
  end

104 105 106 107 108 109 110 111 112 113 114 115
  ##
  # Returns the String necessary to reference this Milestone in Markdown
  #
  # format - Symbol format to use (default: :iid, optional: :name)
  #
  # Examples:
  #
  #   Milestone.first.to_reference                # => "%1"
  #   Milestone.first.to_reference(format: :name) # => "%\"goal\""
  #   Milestone.first.to_reference(project)       # => "gitlab-org/gitlab-ce%1"
  #
  def to_reference(from_project = nil, format: :iid)
116 117
    format_reference = milestone_format_reference(format)
    reference = "#{self.class.reference_prefix}#{format_reference}"
118

119 120 121 122 123
    if cross_project_reference?(from_project)
      project.to_reference + reference
    else
      reference
    end
124 125 126
  end

  def reference_link_text(from_project = nil)
127
    self.title
128 129
  end

Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
130 131
  def expired?
    if due_date
132
      due_date.past?
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
133 134 135
    else
      false
    end
136
  end
137

138
  def expires_at
139 140
    if due_date
      if due_date.past?
141
        "expired on #{due_date.to_s(:medium)}"
142
      else
143
        "expires on #{due_date.to_s(:medium)}"
144
      end
Andrey Kumanyaev's avatar
Andrey Kumanyaev committed
145
    end
146
  end
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
147 148

  def can_be_closed?
Andrew8xx8's avatar
Andrew8xx8 committed
149
    active? && issues.opened.count.zero?
150 151
  end

152 153
  def is_empty?(user = nil)
    total_items_count(user).zero?
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
154 155
  end

156
  def author_id
157
    nil
158
  end
159

160
  def title=(value)
161
    write_attribute(:title, sanitize_title(value)) if value.present?
162 163
  end

164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194
  # Sorts the issues for the given IDs.
  #
  # This method runs a single SQL query using a CASE statement to update the
  # position of all issues in the current milestone (scoped to the list of IDs).
  #
  # Given the ids [10, 20, 30] this method produces a SQL query something like
  # the following:
  #
  #     UPDATE issues
  #     SET position = CASE
  #       WHEN id = 10 THEN 1
  #       WHEN id = 20 THEN 2
  #       WHEN id = 30 THEN 3
  #       ELSE position
  #     END
  #     WHERE id IN (10, 20, 30);
  #
  # This method expects that the IDs given in `ids` are already Fixnums.
  def sort_issues(ids)
    pairs = []

    ids.each_with_index do |id, index|
      pairs << id
      pairs << index + 1
    end

    conditions = 'WHEN id = ? THEN ? ' * ids.length

    issues.where(id: ids).
      update_all(["position = CASE #{conditions} ELSE position END", *pairs])
  end
195 196 197

  private

198
  def milestone_format_reference(format = :iid)
Rémy Coutable's avatar
Rémy Coutable committed
199
    raise ArgumentError, 'Unknown format' unless [:iid, :name].include?(format)
200 201 202 203

    if format == :name && !name.include?('"')
      %("#{name}")
    else
204
      iid
205 206
    end
  end
207 208 209 210

  def sanitize_title(value)
    CGI.unescape_html(Sanitize.clean(value.to_s))
  end
211
end