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