BigW Consortium Gitlab

milestone.rb 5.47 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 CacheMarkdownField
10
  include InternalId
11
  include Sortable
12
  include Referable
13
  include StripAttribute
14
  include Milestoneish
15

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

19 20
  belongs_to :project
  has_many :issues
21
  has_many :labels, -> { distinct.reorder('labels.title') },  through: :issues
22
  has_many :merge_requests
23
  has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee
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

33 34
  strip_attributes :title

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

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

    state :closed

    state :active
  end
48

49 50
  alias_attribute :name, :title

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

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

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

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

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

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

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

108 109 110 111 112 113 114 115 116 117 118 119
  ##
  # 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)
120 121
    format_reference = milestone_format_reference(format)
    reference = "#{self.class.reference_prefix}#{format_reference}"
122

123 124 125 126 127
    if cross_project_reference?(from_project)
      project.to_reference + reference
    else
      reference
    end
128 129 130
  end

  def reference_link_text(from_project = nil)
131
    self.title
132 133
  end

134 135
  def expired?
    if due_date
136
      due_date.past?
137 138 139
    else
      false
    end
140
  end
141

142
  def expires_at
143 144
    if due_date
      if due_date.past?
145
        "expired on #{due_date.to_s(:medium)}"
146
      else
147
        "expires on #{due_date.to_s(:medium)}"
148
      end
149
    end
150
  end
151 152

  def can_be_closed?
153
    active? && issues.opened.count.zero?
154 155
  end

156 157
  def is_empty?(user = nil)
    total_items_count(user).zero?
158 159
  end

160
  def author_id
161
    nil
162
  end
163

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

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 198
  # 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
199 200 201

  private

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

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

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