milestone.rb 5.4 KB
Newer Older
D
Dmitriy Zaporozhets 已提交
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)
T
tiagonbotelho 已提交
7
  Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2)
8

9
  include InternalId
10
  include Sortable
D
Douwe Maan 已提交
11
  include Referable
12
  include StripAttribute
R
Rubén Dávila 已提交
13
  include Milestoneish
14

D
Dmitriy Zaporozhets 已提交
15 16
  belongs_to :project
  has_many :issues
17
  has_many :labels, -> { distinct.reorder('labels.title') },  through: :issues
18
  has_many :merge_requests
R
Rubén Dávila 已提交
19
  has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee
20
  has_many :events, as: :target, dependent: :destroy
D
Dmitriy Zaporozhets 已提交
21

A
Andrew8xx8 已提交
22 23
  scope :active, -> { with_state(:active) }
  scope :closed, -> { with_state(:closed) }
24
  scope :of_projects, ->(ids) { where(project_id: ids) }
D
Dmitriy Zaporozhets 已提交
25

26
  validates :title, presence: true, uniqueness: { scope: :project_id }
A
Andrey Kumanyaev 已提交
27
  validates :project, presence: true
A
Andrew8xx8 已提交
28

29 30
  strip_attributes :title

A
Andrew8xx8 已提交
31
  state_machine :state, initial: :active do
A
Andrew8xx8 已提交
32
    event :close do
A
Andrew8xx8 已提交
33
      transition active: :closed
A
Andrew8xx8 已提交
34 35 36
    end

    event :activate do
A
Andrew8xx8 已提交
37
      transition closed: :active
A
Andrew8xx8 已提交
38 39 40 41 42 43
    end

    state :closed

    state :active
  end
D
Dmitriy Zaporozhets 已提交
44

45 46
  alias_attribute :name, :title

V
Valery Sizov 已提交
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.
V
Valery Sizov 已提交
55
    def search(query)
56 57 58 59
      t = arel_table
      pattern = "%#{query}%"

      where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
V
Valery Sizov 已提交
60 61 62
    end
  end

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

D
Douwe Maan 已提交
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
Rémy Coutable 已提交
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
D
Douwe Maan 已提交
84 85 86
  end

  def self.link_reference_pattern
87
    @link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/)
D
Douwe Maan 已提交
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?
S
Sean McGivern 已提交
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
T
tiagonbotelho 已提交
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
D
Douwe Maan 已提交
124 125 126
  end

  def reference_link_text(from_project = nil)
127
    self.title
D
Douwe Maan 已提交
128 129
  end

D
Dmitriy Zaporozhets 已提交
130 131
  def expired?
    if due_date
132
      due_date.past?
D
Dmitriy Zaporozhets 已提交
133 134 135
    else
      false
    end
D
Dmitriy Zaporozhets 已提交
136
  end
137

D
Dmitriy Zaporozhets 已提交
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
A
Andrey Kumanyaev 已提交
145
    end
D
Dmitriy Zaporozhets 已提交
146
  end
D
Dmitriy Zaporozhets 已提交
147 148

  def can_be_closed?
A
Andrew8xx8 已提交
149
    active? && issues.opened.count.zero?
D
Dmitriy Zaporozhets 已提交
150 151
  end

152 153
  def is_empty?(user = nil)
    total_items_count(user).zero?
D
Dmitriy Zaporozhets 已提交
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
Rémy Coutable 已提交
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
D
Dmitriy Zaporozhets 已提交
211
end