1# frozen_string_literal: true
2
3require 'carrierwave/orm/activerecord'
4
5class Issue < ApplicationRecord
6  include AtomicInternalId
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 LabelEventable
17  include IgnorableColumns
18  include MilestoneEventable
19  include WhereComposite
20  include StateEventable
21  include IdInOrdered
22  include Presentable
23  include IssueAvailableFeatures
24  include Todoable
25  include FromUnion
26  include EachBatch
27
28  extend ::Gitlab::Utils::Override
29
30  DueDateStruct                   = Struct.new(:title, :name).freeze
31  NoDueDate                       = DueDateStruct.new('No Due Date', '0').freeze
32  AnyDueDate                      = DueDateStruct.new('Any Due Date', '').freeze
33  Overdue                         = DueDateStruct.new('Overdue', 'overdue').freeze
34  DueThisWeek                     = DueDateStruct.new('Due This Week', 'week').freeze
35  DueThisMonth                    = DueDateStruct.new('Due This Month', 'month').freeze
36  DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze
37
38  SORTING_PREFERENCE_FIELD = :issues_sort
39
40  # Types of issues that should be displayed on lists across the app
41  # for example, project issues list, group issues list and issue boards.
42  # Some issue types, like test cases, should be hidden by default.
43  TYPES_FOR_LIST = %w(issue incident).freeze
44
45  belongs_to :project
46  has_one :namespace, through: :project
47
48  belongs_to :duplicated_to, class_name: 'Issue'
49  belongs_to :closed_by, class_name: 'User'
50  belongs_to :iteration, foreign_key: 'sprint_id'
51  belongs_to :work_item_type, class_name: 'WorkItem::Type', inverse_of: :work_items
52
53  belongs_to :moved_to, class_name: 'Issue'
54  has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id
55
56  has_internal_id :iid, scope: :project, track_if: -> { !importing? }
57
58  has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
59
60  has_many :merge_requests_closing_issues,
61    class_name: 'MergeRequestsClosingIssues',
62    dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
63
64  has_many :issue_assignees
65  has_many :issue_email_participants
66  has_one :email
67  has_many :assignees, class_name: "User", through: :issue_assignees
68  has_many :zoom_meetings
69  has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
70  has_many :sent_notifications, as: :noteable
71  has_many :designs, class_name: 'DesignManagement::Design', inverse_of: :issue
72  has_many :design_versions, class_name: 'DesignManagement::Version', inverse_of: :issue do
73    def most_recent
74      ordered.first
75    end
76  end
77
78  has_one :issuable_severity
79  has_one :sentry_issue
80  has_one :alert_management_alert, class_name: 'AlertManagement::Alert'
81  has_one :incident_management_issuable_escalation_status, class_name: 'IncidentManagement::IssuableEscalationStatus'
82  has_and_belongs_to_many :self_managed_prometheus_alert_events, join_table: :issues_self_managed_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany
83  has_and_belongs_to_many :prometheus_alert_events, join_table: :issues_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany
84  has_many :prometheus_alerts, through: :prometheus_alert_events
85  has_many :issue_customer_relations_contacts, class_name: 'CustomerRelations::IssueContact', inverse_of: :issue
86  has_many :customer_relations_contacts, through: :issue_customer_relations_contacts, source: :contact, class_name: 'CustomerRelations::Contact', inverse_of: :issues
87
88  accepts_nested_attributes_for :issuable_severity, update_only: true
89  accepts_nested_attributes_for :sentry_issue
90
91  validates :project, presence: true
92  validates :issue_type, presence: true
93
94  enum issue_type: WorkItem::Type.base_types
95
96  alias_method :issuing_parent, :project
97
98  alias_attribute :external_author, :service_desk_reply_to
99
100  scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
101  scope :not_in_projects, ->(project_ids) { where.not(project_id: project_ids) }
102
103  scope :with_due_date, -> { where.not(due_date: nil) }
104  scope :without_due_date, -> { where(due_date: nil) }
105  scope :due_before, ->(date) { where('issues.due_date < ?', date) }
106  scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
107  scope :due_tomorrow, -> { where(due_date: Date.tomorrow) }
108  scope :not_authored_by, ->(user) { where.not(author_id: user) }
109
110  scope :order_due_date_asc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'ASC')) }
111  scope :order_due_date_desc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) }
112  scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) }
113  scope :order_closed_date_desc, -> { reorder(closed_at: :desc) }
114  scope :order_created_at_desc, -> { reorder(created_at: :desc) }
115  scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') }
116  scope :order_severity_desc, -> { includes(:issuable_severity).order('issuable_severities.severity DESC NULLS LAST') }
117
118  scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) }
119  scope :with_web_entity_associations, -> { preload(:author, project: [:project_feature, :route, namespace: :route]) }
120  scope :preload_awardable, -> { preload(:award_emoji) }
121  scope :with_label_attributes, ->(label_attributes) { joins(:labels).where(labels: label_attributes) }
122  scope :with_alert_management_alerts, -> { joins(:alert_management_alert) }
123  scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) }
124  scope :with_self_managed_prometheus_alert_events, -> { joins(:issues_self_managed_prometheus_alert_events) }
125  scope :with_api_entity_associations, -> {
126    preload(:timelogs, :closed_by, :assignees, :author, :labels,
127      milestone: { project: [:route, { namespace: :route }] },
128      project: [:route, { namespace: :route }])
129  }
130  scope :with_issue_type, ->(types) { where(issue_type: types) }
131  scope :without_issue_type, ->(types) { where.not(issue_type: types) }
132
133  scope :public_only, -> {
134    without_hidden.where(confidential: false)
135  }
136
137  scope :confidential_only, -> { where(confidential: true) }
138
139  scope :without_hidden, -> {
140    if Feature.enabled?(:ban_user_feature_flag)
141      where('NOT EXISTS (?)', Users::BannedUser.select(1).where('issues.author_id = banned_users.user_id'))
142    else
143      all
144    end
145  }
146
147  scope :counts_by_state, -> { reorder(nil).group(:state_id).count }
148
149  scope :service_desk, -> { where(author: ::User.support_bot) }
150  scope :inc_relations_for_view, -> { includes(author: :status, assignees: :status) }
151
152  # An issue can be uniquely identified by project_id and iid
153  # Takes one or more sets of composite IDs, expressed as hash-like records of
154  # `{project_id: x, iid: y}`.
155  #
156  # @see WhereComposite::where_composite
157  #
158  # e.g:
159  #
160  #   .by_project_id_and_iid({project_id: 1, iid: 2})
161  #   .by_project_id_and_iid([]) # returns ActiveRecord::NullRelation
162  #   .by_project_id_and_iid([
163  #     {project_id: 1, iid: 1},
164  #     {project_id: 2, iid: 1},
165  #     {project_id: 1, iid: 2}
166  #   ])
167  #
168  scope :by_project_id_and_iid, ->(composites) do
169    where_composite(%i[project_id iid], composites)
170  end
171  scope :with_null_relative_position, -> { where(relative_position: nil) }
172  scope :with_non_null_relative_position, -> { where.not(relative_position: nil) }
173
174  after_commit :expire_etag_cache, unless: :importing?
175  after_save :ensure_metrics, unless: :importing?
176  after_create_commit :record_create_action, unless: :importing?
177
178  attr_spammable :title, spam_title: true
179  attr_spammable :description, spam_description: true
180
181  state_machine :state_id, initial: :opened, initialize: false do
182    event :close do
183      transition [:opened] => :closed
184    end
185
186    event :reopen do
187      transition closed: :opened
188    end
189
190    state :opened, value: Issue.available_states[:opened]
191    state :closed, value: Issue.available_states[:closed]
192
193    before_transition any => :closed do |issue, transition|
194      args = transition.args
195
196      issue.closed_at = issue.system_note_timestamp
197
198      next if args.empty?
199
200      next unless args.first.is_a?(User)
201
202      issue.closed_by = args.first
203    end
204
205    before_transition closed: :opened do |issue|
206      issue.closed_at = nil
207      issue.closed_by = nil
208
209      issue.clear_closure_reason_references
210    end
211  end
212
213  class << self
214    extend ::Gitlab::Utils::Override
215
216    # Alias to state machine .with_state_id method
217    # This needs to be defined after the state machine block to avoid errors
218    alias_method :with_state, :with_state_id
219    alias_method :with_states, :with_state_ids
220
221    override :order_upvotes_desc
222    def order_upvotes_desc
223      reorder(upvotes_count: :desc)
224    end
225
226    override :order_upvotes_asc
227    def order_upvotes_asc
228      reorder(upvotes_count: :asc)
229    end
230  end
231
232  def next_object_by_relative_position(ignoring: nil, order: :asc)
233    return super unless Feature.enabled?(:optimized_issue_neighbor_queries, project, default_enabled: :yaml)
234
235    array_mapping_scope = -> (id_expression) do
236      relation = Issue.where(Issue.arel_table[:project_id].eq(id_expression))
237
238      if order == :asc
239        relation.where(Issue.arel_table[:relative_position].gt(relative_position))
240      else
241        relation.where(Issue.arel_table[:relative_position].lt(relative_position))
242      end
243    end
244
245    relation = Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new(
246      scope: Issue.order(relative_position: order, id: order),
247      array_scope: relative_positioning_parent_projects,
248      array_mapping_scope: array_mapping_scope,
249      finder_query: -> (_, id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) }
250    ).execute
251
252    relation = exclude_self(relation, excluded: ignoring) if ignoring.present?
253
254    relation.take
255  end
256
257  def relative_positioning_parent_projects
258    project.group&.root_ancestor&.all_projects&.select(:id) || Project.id_in(project).select(:id)
259  end
260
261  def self.relative_positioning_query_base(issue)
262    in_projects(issue.relative_positioning_parent_projects)
263  end
264
265  def self.relative_positioning_parent_column
266    :project_id
267  end
268
269  def self.reference_prefix
270    '#'
271  end
272
273  # Pattern used to extract `#123` issue references from text
274  #
275  # This pattern supports cross-project references.
276  def self.reference_pattern
277    @reference_pattern ||= %r{
278      (#{Project.reference_pattern})?
279      #{Regexp.escape(reference_prefix)}#{Gitlab::Regex.issue}
280    }x
281  end
282
283  def self.link_reference_pattern
284    @link_reference_pattern ||= super("issues", Gitlab::Regex.issue)
285  end
286
287  def self.reference_valid?(reference)
288    reference.to_i > 0 && reference.to_i <= Gitlab::Database::MAX_INT_VALUE
289  end
290
291  def self.project_foreign_key
292    'project_id'
293  end
294
295  def self.simple_sorts
296    super.merge(
297      {
298        'closest_future_date' => -> { order_closest_future_date },
299        'closest_future_date_asc' => -> { order_closest_future_date },
300        'due_date' => -> { order_due_date_asc.with_order_id_desc },
301        'due_date_asc' => -> { order_due_date_asc.with_order_id_desc },
302        'due_date_desc' => -> { order_due_date_desc.with_order_id_desc },
303        'relative_position' => -> { order_by_relative_position },
304        'relative_position_asc' => -> { order_by_relative_position }
305      }
306    )
307  end
308
309  def self.sort_by_attribute(method, excluded_labels: [])
310    case method.to_s
311    when 'closest_future_date', 'closest_future_date_asc' then order_closest_future_date
312    when 'due_date', 'due_date_asc'                       then order_due_date_asc.with_order_id_desc
313    when 'due_date_desc'                                  then order_due_date_desc.with_order_id_desc
314    when 'relative_position', 'relative_position_asc'     then order_by_relative_position
315    when 'severity_asc'                                   then order_severity_asc.with_order_id_desc
316    when 'severity_desc'                                  then order_severity_desc.with_order_id_desc
317    else
318      super
319    end
320  end
321
322  def self.order_by_relative_position
323    reorder(Gitlab::Pagination::Keyset::Order.build([column_order_relative_position, column_order_id_asc]))
324  end
325
326  def self.column_order_relative_position
327    Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
328      attribute_name: 'relative_position',
329      column_expression: arel_table[:relative_position],
330      order_expression: Gitlab::Database.nulls_last_order('issues.relative_position', 'ASC'),
331      reversed_order_expression: Gitlab::Database.nulls_last_order('issues.relative_position', 'DESC'),
332      order_direction: :asc,
333      nullable: :nulls_last,
334      distinct: false
335    )
336  end
337
338  def self.column_order_id_asc
339    Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
340      attribute_name: 'id',
341      order_expression: arel_table[:id].asc
342    )
343  end
344
345  def self.to_branch_name(*args)
346    branch_name = args.map(&:to_s).each_with_index.map do |arg, i|
347      arg.parameterize(preserve_case: i == 0).presence
348    end.compact.join('-')
349
350    if branch_name.length > 100
351      truncated_string = branch_name[0, 100]
352      # Delete everything dangling after the last hyphen so as not to risk
353      # existence of unintended words in the branch name due to mid-word split.
354      branch_name = truncated_string.sub(/-[^-]*\Z/, '')
355    end
356
357    branch_name
358  end
359
360  # Temporary disable moving null elements because of performance problems
361  # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
362  def check_repositioning_allowed!
363    if blocked_for_repositioning?
364      raise ::Gitlab::RelativePositioning::IssuePositioningDisabled, "Issue relative position changes temporarily disabled."
365    end
366  end
367
368  def blocked_for_repositioning?
369    resource_parent.root_namespace&.issue_repositioning_disabled?
370  end
371
372  def hook_attrs
373    Gitlab::HookData::IssueBuilder.new(self).build
374  end
375
376  # `from` argument can be a Namespace or Project.
377  def to_reference(from = nil, full: false)
378    reference = "#{self.class.reference_prefix}#{iid}"
379
380    "#{project.to_reference_base(from, full: full)}#{reference}"
381  end
382
383  def suggested_branch_name
384    return to_branch_name unless project.repository.branch_exists?(to_branch_name)
385
386    start_counting_from = 2
387    Uniquify.new(start_counting_from).string(-> (counter) { "#{to_branch_name}-#{counter}" }) do |suggested_branch_name|
388      project.repository.branch_exists?(suggested_branch_name)
389    end
390  end
391
392  # Returns boolean if a related branch exists for the current issue
393  # ignores merge requests branchs
394  def has_related_branch?
395    project.repository.branch_names.any? do |branch|
396      /\A#{iid}-(?!\d+-stable)/i =~ branch
397    end
398  end
399
400  # To allow polymorphism with MergeRequest.
401  def source_project
402    project
403  end
404
405  def moved?
406    !moved_to_id.nil?
407  end
408
409  def duplicated?
410    !duplicated_to_id.nil?
411  end
412
413  def clear_closure_reason_references
414    self.moved_to_id = nil
415    self.duplicated_to_id = nil
416  end
417
418  def can_move?(user, to_project = nil)
419    if to_project
420      return false unless user.can?(:admin_issue, to_project)
421    end
422
423    !moved? && persisted? &&
424      user.can?(:admin_issue, self.project)
425  end
426  alias_method :can_clone?, :can_move?
427
428  def to_branch_name
429    if self.confidential?
430      "#{iid}-confidential-issue"
431    else
432      self.class.to_branch_name(iid, title)
433    end
434  end
435
436  def related_issues(current_user, preload: nil)
437    related_issues = ::Issue
438                       .select(['issues.*', 'issue_links.id AS issue_link_id',
439                                'issue_links.link_type as issue_link_type_value',
440                                'issue_links.target_id as issue_link_source_id',
441                                'issue_links.created_at as issue_link_created_at',
442                                'issue_links.updated_at as issue_link_updated_at'])
443                       .joins("INNER JOIN issue_links ON
444	                             (issue_links.source_id = issues.id AND issue_links.target_id = #{id})
445	                             OR
446	                             (issue_links.target_id = issues.id AND issue_links.source_id = #{id})")
447                       .preload(preload)
448                       .reorder('issue_link_id')
449
450    related_issues = yield related_issues if block_given?
451
452    cross_project_filter = -> (issues) { issues.where(project: project) }
453    Ability.issues_readable_by_user(related_issues,
454      current_user,
455      filters: { read_cross_project: cross_project_filter })
456  end
457
458  def can_be_worked_on?
459    !self.closed? && !self.project.forked?
460  end
461
462  # Returns `true` if the current issue can be viewed by either a logged in User
463  # or an anonymous user.
464  def visible_to_user?(user = nil)
465    return publicly_visible? unless user
466
467    return false unless readable_by?(user)
468
469    user.can_read_all_resources? ||
470      ::Gitlab::ExternalAuthorization.access_allowed?(
471        user, project.external_authorization_classification_label)
472  end
473
474  def check_for_spam?(user:)
475    # content created via support bots is always checked for spam, EVEN if
476    # the issue is not publicly visible and/or confidential
477    return true if user.support_bot? && spammable_attribute_changed?
478
479    # Only check for spam on issues which are publicly visible (and thus indexed in search engines)
480    return false unless publicly_visible?
481
482    # Only check for spam if certain attributes have changed
483    spammable_attribute_changed?
484  end
485
486  def as_json(options = {})
487    super(options).tap do |json|
488      if options.key?(:labels)
489        json[:labels] = labels.as_json(
490          project: project,
491          only: [:id, :title, :description, :color, :priority],
492          methods: [:text_color]
493        )
494      end
495    end
496  end
497
498  def etag_caching_enabled?
499    true
500  end
501
502  def discussions_rendered_on_frontend?
503    true
504  end
505
506  # rubocop: disable CodeReuse/ServiceClass
507  def update_project_counter_caches
508    Projects::OpenIssuesCountService.new(project).refresh_cache
509  end
510  # rubocop: enable CodeReuse/ServiceClass
511
512  def merge_requests_count(user = nil)
513    ::MergeRequestsClosingIssues.count_for_issue(self.id, user)
514  end
515
516  def labels_hook_attrs
517    labels.map(&:hook_attrs)
518  end
519
520  def previous_updated_at
521    previous_changes['updated_at']&.first || updated_at
522  end
523
524  def banzai_render_context(field)
525    super.merge(label_url_method: :project_issues_url)
526  end
527
528  def design_collection
529    @design_collection ||= ::DesignManagement::DesignCollection.new(self)
530  end
531
532  def from_service_desk?
533    author.id == User.support_bot.id
534  end
535
536  def issue_link_type
537    return unless respond_to?(:issue_link_type_value) && respond_to?(:issue_link_source_id)
538
539    type = IssueLink.link_types.key(issue_link_type_value) || IssueLink::TYPE_RELATES_TO
540    return type if issue_link_source_id == id
541
542    IssueLink.inverse_link_type(type)
543  end
544
545  def relocation_target
546    moved_to || duplicated_to
547  end
548
549  def supports_assignee?
550    issue_type_supports?(:assignee)
551  end
552
553  def supports_time_tracking?
554    issue_type_supports?(:time_tracking)
555  end
556
557  def supports_move_and_clone?
558    issue_type_supports?(:move_and_clone)
559  end
560
561  def email_participants_emails
562    issue_email_participants.pluck(:email)
563  end
564
565  def email_participants_emails_downcase
566    issue_email_participants.pluck(IssueEmailParticipant.arel_table[:email].lower)
567  end
568
569  def issue_assignee_user_ids
570    issue_assignees.pluck(:user_id)
571  end
572
573  def update_upvotes_count
574    self.lock!
575    self.update_column(:upvotes_count, self.upvotes)
576  end
577
578  # Returns `true` if the given User can read the current Issue.
579  #
580  # This method duplicates the same check of issue_policy.rb
581  # for performance reasons, check commit: 002ad215818450d2cbbc5fa065850a953dc7ada8
582  # Make sure to sync this method with issue_policy.rb
583  def readable_by?(user)
584    if user.can_read_all_resources?
585      true
586    elsif project.owner == user
587      true
588    elsif confidential? && !assignee_or_author?(user)
589      project.team.member?(user, Gitlab::Access::REPORTER)
590    elsif hidden?
591      false
592    elsif project.public? || (project.internal? && !user.external?)
593      project.feature_available?(:issues, user)
594    else
595      project.team.member?(user)
596    end
597  end
598
599  def hidden?
600    author&.banned?
601  end
602
603  private
604
605  def spammable_attribute_changed?
606    title_changed? ||
607      description_changed? ||
608      # NOTE: We need to check them for spam when issues are made non-confidential, because spam
609      # may have been added while they were confidential and thus not being checked for spam.
610      confidential_changed?(from: true, to: false)
611  end
612
613  override :ensure_metrics
614  def ensure_metrics
615    Issue::Metrics.record!(self)
616  end
617
618  def record_create_action
619    Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(author: author)
620  end
621
622  # Returns `true` if this Issue is visible to everybody.
623  def publicly_visible?
624    project.public? && !confidential? && !hidden? && !::Gitlab::ExternalAuthorization.enabled?
625  end
626
627  def expire_etag_cache
628    key = Gitlab::Routing.url_helpers.realtime_changes_project_issue_path(project, self)
629    Gitlab::EtagCaching::Store.new.touch(key)
630  end
631
632  def could_not_move(exception)
633    # Symptom of running out of space - schedule rebalancing
634    Issues::RebalancingWorker.perform_async(nil, *project.self_or_root_group_ids)
635  end
636end
637
638Issue.prepend_mod_with('Issue')
639