BigW Consortium Gitlab

milestone.rb 6.09 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
  Started = MilestoneStruct.new('Started', '#started', -3)
9

10
  include CacheMarkdownField
11
  include InternalId
12
  include Sortable
13
  include Referable
14
  include StripAttribute
15
  include Milestoneish
16

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

20 21
  belongs_to :project
  has_many :issues
22
  has_many :labels, -> { distinct.reorder('labels.title') },  through: :issues
23
  has_many :merge_requests
24
  has_many :events, as: :target, dependent: :destroy
25

26 27
  scope :active, -> { with_state(:active) }
  scope :closed, -> { with_state(:closed) }
28
  scope :of_projects, ->(ids) { where(project_id: ids) }
29

30
  validates :title, presence: true, uniqueness: { scope: :project_id }
Andrey Kumanyaev committed
31
  validates :project, presence: true
32
  validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
33

34 35
  strip_attributes :title

Andrew8xx8 committed
36
  state_machine :state, initial: :active do
37
    event :close do
Andrew8xx8 committed
38
      transition active: :closed
39 40 41
    end

    event :activate do
Andrew8xx8 committed
42
      transition closed: :active
43 44 45 46 47 48
    end

    state :closed

    state :active
  end
49

50 51
  alias_attribute :name, :title

52
  class << self
53 54 55 56 57 58 59
    # 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.
60
    def search(query)
61 62 63 64
      t = arel_table
      pattern = "%#{query}%"

      where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
65 66 67
    end
  end

68 69 70 71
  def self.reference_prefix
    '%'
  end

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

  def self.link_reference_pattern
92
    @link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/)
93 94
  end

95 96 97 98
  def self.upcoming_ids_by_projects(projects)
    rel = unscoped.of_projects(projects).active.where('due_date > ?', Time.now)

    if Gitlab::Database.postgresql?
99
      rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id')
100 101 102 103 104 105 106
    else
      rel.
        group(:project_id).
        having('due_date = MIN(due_date)').
        pluck(:id, :project_id, :due_date).
        map(&:first)
    end
107 108
  end

109
  def participants
110
    User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).uniq
111 112
  end

113 114 115 116 117 118 119 120 121 122 123 124 125 126 127
  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

128 129 130 131 132 133 134
  ##
  # Returns the String necessary to reference this Milestone in Markdown
  #
  # format - Symbol format to use (default: :iid, optional: :name)
  #
  # Examples:
  #
135 136 137 138
  #   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"
139
  #
140
  def to_reference(from_project = nil, format: :iid, full: false)
141 142
    format_reference = milestone_format_reference(format)
    reference = "#{self.class.reference_prefix}#{format_reference}"
143

144
    "#{project.to_reference(from_project, full: full)}#{reference}"
145 146 147
  end

  def reference_link_text(from_project = nil)
148
    self.title
149 150
  end

151 152 153 154
  def milestoneish_ids
    id
  end

155
  def can_be_closed?
156
    active? && issues.opened.count.zero?
157 158
  end

159
  def author_id
160
    nil
161
  end
162

163
  def title=(value)
164
    write_attribute(:title, sanitize_title(value)) if value.present?
165 166
  end

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 195 196 197
  # 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
198 199 200

  private

201
  def milestone_format_reference(format = :iid)
202
    raise ArgumentError, 'Unknown format' unless [:iid, :name].include?(format)
203 204 205 206

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

  def sanitize_title(value)
    CGI.unescape_html(Sanitize.clean(value.to_s))
  end
214 215 216 217 218 219

  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
220 221 222 223

  def issues_finder_params
    { project_id: project_id }
  end
224
end