summaryrefslogtreecommitdiff
path: root/app/models/label.rb
blob: 8dc7ded53adf894a06db72cd64435244dad2f94e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# frozen_string_literal: true

class Label < ActiveRecord::Base
  include CacheMarkdownField
  include Referable
  include Subscribable
  include Gitlab::SQL::Pattern
  include OptionallySearch
  include Sortable

  # Represents a "No Label" state used for filtering Issues and Merge
  # Requests that have no label assigned.
  LabelStruct = Struct.new(:title, :name)
  None = LabelStruct.new('No Label', 'No Label')
  Any = LabelStruct.new('Any Label', '')

  cache_markdown_field :description, pipeline: :single_line

  DEFAULT_COLOR = '#428BCA'.freeze

  default_value_for :color, DEFAULT_COLOR

  has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
  has_many :priorities, class_name: 'LabelPriority'
  has_many :label_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
  has_many :issues, through: :label_links, source: :target, source_type: 'Issue'
  has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'

  before_validation :strip_whitespace_from_title_and_color

  validates :color, color: true, allow_blank: false

  # Don't allow ',' for label titles
  validates :title, presence: true, format: { with: /\A[^,]+\z/ }
  validates :title, uniqueness: { scope: [:group_id, :project_id] }
  validates :title, length: { maximum: 255 }

  default_scope { order(title: :asc) }

  scope :templates, -> { where(template: true) }
  scope :with_title, ->(title) { where(title: title) }
  scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) }
  scope :on_group_boards, ->(group_id) { with_lists_and_board.where(boards: { group_id: group_id }) }
  scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) }
  scope :order_name_asc, -> { reorder(title: :asc) }
  scope :order_name_desc, -> { reorder(title: :desc) }

  def self.prioritized(project)
    joins(:priorities)
      .where(label_priorities: { project_id: project })
      .reorder('label_priorities.priority ASC, labels.title ASC')
  end

  def self.unprioritized(project)
    labels = Label.arel_table
    priorities = LabelPriority.arel_table

    label_priorities = labels.join(priorities, Arel::Nodes::OuterJoin)
                              .on(labels[:id].eq(priorities[:label_id]).and(priorities[:project_id].eq(project.id)))
                              .join_sources

    joins(label_priorities).where(priorities[:priority].eq(nil))
  end

  def self.left_join_priorities
    labels = Label.arel_table
    priorities = LabelPriority.arel_table

    label_priorities = labels.join(priorities, Arel::Nodes::OuterJoin)
                              .on(labels[:id].eq(priorities[:label_id]))
                              .join_sources

    joins(label_priorities)
  end

  alias_attribute :name, :title

  def self.reference_prefix
    '~'
  end

  ##
  # Pattern used to extract label references from text
  #
  # This pattern supports cross-project references.
  #
  def self.reference_pattern
    # NOTE: The id pattern only matches when all characters on the expression
    # are digits, so it will match ~2 but not ~2fa because that's probably a
    # label name and we want it to be matched as such.
    @reference_pattern ||= %r{
      (#{Project.reference_pattern})?
      #{Regexp.escape(reference_prefix)}
      (?:
          (?<label_id>\d+(?!\S\w)\b)
        | # Integer-based label ID, or
          (?<label_name>
              # String-based single-word label title, or
              [A-Za-z0-9_\-\?\.&]+
              (?<!\.|\?)
            |
              # String-based multi-word label surrounded in quotes
              ".+?"
          )
      )
    }x
  end

  def self.link_reference_pattern
    nil
  end

  # Searches for labels with a matching title or description.
  #
  # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
  #
  # query - The search query as a String.
  #
  # Returns an ActiveRecord::Relation.
  def self.search(query)
    fuzzy_search(query, [:title, :description])
  end

  def open_issues_count(user = nil)
    issues_count(user, state: 'opened')
  end

  def closed_issues_count(user = nil)
    issues_count(user, state: 'closed')
  end

  def open_merge_requests_count(user = nil)
    params = {
      subject_foreign_key => subject.id,
      label_name: title,
      scope: 'all',
      state: 'opened'
    }

    MergeRequestsFinder.new(user, params.with_indifferent_access).execute.count
  end

  def prioritize!(project, value)
    label_priority = priorities.find_or_initialize_by(project_id: project.id)
    label_priority.priority = value
    label_priority.save!
  end

  def unprioritize!(project)
    priorities.where(project: project).delete_all
  end

  def priority(project)
    priority = if priorities.loaded?
                 priorities.first { |p| p.project == project }
               else
                 priorities.find_by(project: project)
               end

    priority.try(:priority)
  end

  def priority?
    priorities.present?
  end

  def template?
    template
  end

  def color
    super || DEFAULT_COLOR
  end

  def text_color
    LabelsHelper.text_color_for_bg(self.color)
  end

  def title=(value)
    write_attribute(:title, sanitize_title(value)) if value.present?
  end

  ##
  # Returns the String necessary to reference this Label in Markdown
  #
  # format - Symbol format to use (default: :id, optional: :name)
  #
  # Examples:
  #
  #   Label.first.to_reference                                     # => "~1"
  #   Label.first.to_reference(format: :name)                      # => "~\"bug\""
  #   Label.first.to_reference(project, target_project: same_namespace_project)    # => "gitlab-ce~1"
  #   Label.first.to_reference(project, target_project: another_namespace_project) # => "gitlab-org/gitlab-ce~1"
  #
  # Returns a String
  #
  def to_reference(from = nil, target_project: nil, format: :id, full: false)
    format_reference = label_format_reference(format)
    reference = "#{self.class.reference_prefix}#{format_reference}"

    if from
      "#{from.to_reference(target_project, full: full)}#{reference}"
    else
      reference
    end
  end

  def as_json(options = {})
    super(options).tap do |json|
      json[:type] = self.try(:type)
      json[:priority] = priority(options[:project]) if options.key?(:project)
    end
  end

  def hook_attrs
    attributes
  end

  private

  def issues_count(user, params = {})
    params.merge!(subject_foreign_key => subject.id, label_name: title, scope: 'all')
    IssuesFinder.new(user, params.with_indifferent_access).execute.count
  end

  def label_format_reference(format = :id)
    raise StandardError, 'Unknown format' unless [:id, :name].include?(format)

    if format == :name && !name.include?('"')
      %("#{name}")
    else
      id
    end
  end

  def sanitize_title(value)
    CGI.unescapeHTML(Sanitize.clean(value.to_s))
  end

  def strip_whitespace_from_title_and_color
    %w(color title).each { |attr| self[attr] = self[attr]&.strip }
  end
end