issue.rb 8.37 KB
Newer Older
1 2
# frozen_string_literal: true

3 4
require 'carrierwave/orm/activerecord'

5
class Issue < ApplicationRecord
6
  include AtomicInternalId
Shinya Maeda's avatar
Shinya Maeda committed
7
  include IidRoutes
8
  include Issuable
9
  include Noteable
10
  include Referable
11
  include Spammable
12
  include FasterCacheKeys
13
  include RelativePositioning
14
  include TimeTrackable
15
  include ThrottledTouch
16
  include IgnorableColumn
Jan Provaznik's avatar
Jan Provaznik committed
17
  include LabelEventable
18

19
  ignore_column :assignee_id, :branch_name, :deleted_at
20

21 22 23 24 25 26 27
  DueDateStruct                   = Struct.new(:title, :name).freeze
  NoDueDate                       = DueDateStruct.new('No Due Date', '0').freeze
  AnyDueDate                      = DueDateStruct.new('Any Due Date', '').freeze
  Overdue                         = DueDateStruct.new('Overdue', 'overdue').freeze
  DueThisWeek                     = DueDateStruct.new('Due This Week', 'week').freeze
  DueThisMonth                    = DueDateStruct.new('Due This Month', 'month').freeze
  DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze
28

29 30
  SORTING_PREFERENCE_FIELD = :issues_sort

31
  belongs_to :project
32
  belongs_to :moved_to, class_name: 'Issue'
haseeb's avatar
haseeb committed
33
  belongs_to :closed_by, class_name: 'User'
34

35
  has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.issues&.maximum(:iid) }
36

37
  has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
38

39 40 41 42 43
  has_many :merge_requests_closing_issues,
    class_name: 'MergeRequestsClosingIssues',
    dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent

  has_many :issue_assignees
44
  has_many :assignees, class_name: "User", through: :issue_assignees
45

46 47
  validates :project, presence: true

48
  alias_attribute :parent_ids, :project_id
49

50
  scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
51

52
  scope :with_due_date, -> { where.not(due_date: nil) }
53 54 55
  scope :without_due_date, -> { where(due_date: nil) }
  scope :due_before, ->(date) { where('issues.due_date < ?', date) }
  scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
56
  scope :due_tomorrow, -> { where(due_date: Date.tomorrow) }
57

58 59
  scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') }
  scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') }
60
  scope :order_closest_future_date, -> { reorder('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC') }
61

Felipe Artur's avatar
Felipe Artur committed
62
  scope :preload_associations, -> { preload(:labels, project: :namespace) }
63
  scope :with_api_entity_associations, -> { preload(:timelogs, :assignees, :author, :notes, :labels, project: [:route, { namespace: :route }] ) }
64

65
  scope :public_only, -> { where(confidential: false) }
66
  scope :confidential_only, -> { where(confidential: true) }
67

Regis Boudinot's avatar
Regis Boudinot committed
68
  after_save :expire_etag_cache
69
  after_save :ensure_metrics, unless: :imported?
Regis Boudinot's avatar
Regis Boudinot committed
70

71 72
  attr_spammable :title, spam_title: true
  attr_spammable :description, spam_description: true
73

Andrew8xx8's avatar
Andrew8xx8 committed
74
  state_machine :state, initial: :opened do
Andrew8xx8's avatar
Andrew8xx8 committed
75
    event :close do
76
      transition [:opened] => :closed
Andrew8xx8's avatar
Andrew8xx8 committed
77 78 79
    end

    event :reopen do
80
      transition closed: :opened
Andrew8xx8's avatar
Andrew8xx8 committed
81 82 83 84
    end

    state :opened
    state :closed
85 86

    before_transition any => :closed do |issue|
87
      issue.closed_at = issue.system_note_timestamp
88
    end
89 90 91 92 93

    before_transition closed: :opened do |issue|
      issue.closed_at = nil
      issue.closed_by = nil
    end
Andrew8xx8's avatar
Andrew8xx8 committed
94
  end
95

96 97 98 99
  class << self
    alias_method :in_parents, :in_projects
  end

100 101 102 103
  def self.parent_column
    :project_id
  end

104 105 106 107
  def self.reference_prefix
    '#'
  end

108 109 110 111
  # Pattern used to extract `#123` issue references from text
  #
  # This pattern supports cross-project references.
  def self.reference_pattern
112
    @reference_pattern ||= %r{
113 114
      (#{Project.reference_pattern})?
      #{Regexp.escape(reference_prefix)}(?<issue>\d+)
115
    }x
Kirill Zaitsev's avatar
Kirill Zaitsev committed
116 117
  end

118
  def self.link_reference_pattern
119
    @link_reference_pattern ||= super("issues", /(?<issue>\d+)/)
120 121
  end

122 123 124 125
  def self.reference_valid?(reference)
    reference.to_i > 0 && reference.to_i <= Gitlab::Database::MAX_INT_VALUE
  end

126 127 128 129
  def self.project_foreign_key
    'project_id'
  end

130
  def self.sort_by_attribute(method, excluded_labels: [])
131
    case method.to_s
132
    when 'closest_future_date' then order_closest_future_date
133 134
    when 'due_date'      then order_due_date_asc
    when 'due_date_asc'  then order_due_date_asc
135
    when 'due_date_desc' then order_due_date_desc
136 137 138 139 140
    else
      super
    end
  end

141
  def self.order_by_position_and_priority
142 143
    order_labels_priority
      .reorder(Gitlab::Database.nulls_last_order('relative_position', 'ASC'),
144 145 146 147
              Gitlab::Database.nulls_last_order('highest_priority', 'ASC'),
              "id DESC")
  end

148
  def hook_attrs
149
    Gitlab::HookData::IssueBuilder.new(self).build
150 151
  end

152
  # `from` argument can be a Namespace or Project.
153
  def to_reference(from = nil, full: false)
154 155
    reference = "#{self.class.reference_prefix}#{iid}"

156
    "#{project.to_reference(from, full: full)}#{reference}"
157 158
  end

159 160 161
  def suggested_branch_name
    return to_branch_name unless project.repository.branch_exists?(to_branch_name)

162
    start_counting_from = 2
163
    Uniquify.new(start_counting_from).string(-> (counter) { "#{to_branch_name}-#{counter}" }) do |suggested_branch_name|
164 165
      project.repository.branch_exists?(suggested_branch_name)
    end
166 167
  end

168 169
  # Returns boolean if a related branch exists for the current issue
  # ignores merge requests branchs
170
  def has_related_branch?
171 172 173 174 175
    project.repository.branch_names.any? do |branch|
      /\A#{iid}-(?!\d+-stable)/i =~ branch
    end
  end

176 177 178 179
  # To allow polymorphism with MergeRequest.
  def source_project
    project
  end
180

181 182 183 184 185 186 187 188 189
  def moved?
    !moved_to.nil?
  end

  def can_move?(user, to_project = nil)
    if to_project
      return false unless user.can?(:admin_issue, to_project)
    end

190 191
    !moved? && persisted? &&
      user.can?(:admin_issue, self.project)
192
  end
193

Zeger-Jan van de Weg's avatar
Zeger-Jan van de Weg committed
194
  def to_branch_name
195
    if self.confidential?
196
      "#{iid}-confidential-issue"
197
    else
198
      "#{iid}-#{title.parameterize}"
199
    end
Zeger-Jan van de Weg's avatar
Zeger-Jan van de Weg committed
200 201
  end

202 203
  def can_be_worked_on?
    !self.closed? && !self.project.forked?
Zeger-Jan van de Weg's avatar
Zeger-Jan van de Weg committed
204
  end
205

206 207 208
  # Returns `true` if the current issue can be viewed by either a logged in User
  # or an anonymous user.
  def visible_to_user?(user = nil)
209
    return false unless project && project.feature_available?(:issues, user)
210

211 212 213 214 215 216 217
    return publicly_visible? unless user

    return false unless readable_by?(user)

    user.full_private_access? ||
      ::Gitlab::ExternalAuthorization.access_allowed?(
        user, project.external_authorization_classification_label)
218 219
  end

220
  def check_for_spam?
221 222
    publicly_visible? &&
      (title_changed? || description_changed? || confidential_changed?)
223
  end
224 225 226

  def as_json(options = {})
    super(options).tap do |json|
227
      if options.key?(:labels)
228 229
        json[:labels] = labels.as_json(
          project: project,
230
          only: [:id, :title, :description, :color, :priority],
231 232 233
          methods: [:text_color]
        )
      end
234 235
    end
  end
236

Felipe Artur's avatar
Felipe Artur committed
237 238 239 240
  def etag_caching_enabled?
    true
  end

241 242 243 244
  def discussions_rendered_on_frontend?
    true
  end

245
  # rubocop: disable CodeReuse/ServiceClass
246 247 248
  def update_project_counter_caches
    Projects::OpenIssuesCountService.new(project).refresh_cache
  end
249
  # rubocop: enable CodeReuse/ServiceClass
250

251 252
  def merge_requests_count(user = nil)
    ::MergeRequestsClosingIssues.count_for_issue(self.id, user)
Alexander Koval's avatar
Alexander Koval committed
253 254
  end

255 256
  private

257 258 259 260 261
  def ensure_metrics
    super
    metrics.record!
  end

262 263 264 265 266 267 268 269 270 271 272 273
  # Returns `true` if the given User can read the current Issue.
  #
  # This method duplicates the same check of issue_policy.rb
  # for performance reasons, check commit: 002ad215818450d2cbbc5fa065850a953dc7ada8
  # Make sure to sync this method with issue_policy.rb
  def readable_by?(user)
    if user.admin?
      true
    elsif project.owner == user
      true
    elsif confidential?
      author == user ||
274
        assignees.include?(user) ||
275 276 277 278 279 280 281 282 283 284
        project.team.member?(user, Gitlab::Access::REPORTER)
    else
      project.public? ||
        project.internal? && !user.external? ||
        project.team.member?(user)
    end
  end

  # Returns `true` if this Issue is visible to everybody.
  def publicly_visible?
285
    project.public? && !confidential? && !::Gitlab::ExternalAuthorization.enabled?
286
  end
Regis Boudinot's avatar
Regis Boudinot committed
287 288

  def expire_etag_cache
289
    key = Gitlab::Routing.url_helpers.realtime_changes_project_issue_path(project, self)
Regis Boudinot's avatar
Regis Boudinot committed
290 291
    Gitlab::EtagCaching::Store.new.touch(key)
  end
gitlabhq's avatar
gitlabhq committed
292
end