openSUSE/open-build-service

View on GitHub
src/api/app/models/bs_request.rb

Summary

Maintainability
F
6 days
Test Coverage
A
92%
require 'xmlhash'

include MaintenanceHelper

# rubocop:disable Metrics/ClassLength
class BsRequest < ApplicationRecord
  include BsRequest::Errors

  MAX_DESCRIPTION_LENGTH_ALLOWED = 64_000

  SEARCHABLE_FIELDS = [
    'bs_requests.creator',
    'bs_requests.priority',
    'bs_request_actions.target_project',
    'bs_request_actions.target_package',
    'bs_request_actions.source_project',
    'bs_request_actions.source_package',
    'bs_request_actions.type'
  ].freeze

  FINAL_REQUEST_STATES = %i[accepted declined superseded revoked].freeze

  VALID_REQUEST_STATES = %i[new deleted declined accepted review revoked superseded].freeze

  OBSOLETE_STATES = %i[declined superseded revoked].freeze

  ACTION_NOTIFY_LIMIT = 50

  scope :to_accept_by_time, -> { where(state: %w[new review]).where(accept_at: ...Time.now) }
  # Scopes for collections
  scope :with_actions, -> { joins(:bs_request_actions).distinct.order(priority: :asc, id: :desc) }
  scope :with_involved_projects, ->(project_ids) { where(bs_request_actions: { target_project_id: project_ids }) }
  scope :with_involved_packages, ->(package_ids) { where(bs_request_actions: { target_package_id: package_ids }) }

  scope :with_source_subprojects, ->(project_name) { where('bs_request_actions.source_project like ?', project_name) }
  scope :with_target_subprojects, ->(project_name) { where('bs_request_actions.target_project like ?', project_name) }

  scope :with_types, lambda { |types|
    includes(:bs_request_actions).where(bs_request_actions: { type: types }).distinct.order(priority: :asc, id: :desc)
  }
  scope :from_source_project, ->(source_project) { where(bs_request_actions: { source_project: source_project }) }
  scope :in_ids, ->(ids) { where(id: ids) }
  scope :not_creator, ->(login) { where.not(creator: login) }
  # Searching capabilities using dataTable (1.9)
  scope :do_search, lambda { |search|
    includes(:bs_request_actions)
      .references(:bs_request_actions)
      .where(
        [
          SEARCHABLE_FIELDS.map { |field| "#{field} like ?" }.join(' or '), ["%#{search}%"] * SEARCHABLE_FIELDS.length
        ].flatten
      )
  }

  scope :with_actions_and_reviews, -> { joins(:bs_request_actions).left_outer_joins(:reviews).distinct.order(priority: :asc, id: :desc) }
  scope :with_submit_requests, -> { joins(:bs_request_actions).where(bs_request_actions: { type: 'submit' }) }

  scope :by_user_reviews, ->(user_ids) { where(reviews: { user: user_ids }) }
  scope :by_project_reviews, ->(project_ids) { where(reviews: { project: project_ids }) }
  scope :by_package_reviews, ->(package_ids) { where(reviews: { package: package_ids }) }
  scope :by_group_reviews, ->(group_ids) { where(reviews: { group: group_ids }) }

  scope :obsolete, -> { where(state: OBSOLETE_STATES) }
  scope :with_target_project, lambda { |target_project|
    includes(:bs_request_actions).where('bs_request_actions.target_project': target_project)
  }
  scope :with_open_reviews_for, lambda { |review_attributes|
    where(state: 'review', id: Review.where(review_attributes).where(state: 'new').select(:bs_request_id))
      .includes(:reviews)
  }

  scope :with_action_type, ->(action_type) { joins(:bs_request_actions).where(bs_request_actions: { type: action_type }).distinct }

  has_many :bs_request_actions, dependent: :destroy
  has_many :reviews, dependent: :delete_all
  has_many :comments, as: :commentable, dependent: :destroy
  has_one :comment_lock, as: :commentable, dependent: :destroy
  has_many :request_history_elements, -> { order(:created_at) }, class_name: 'HistoryElement::Request', foreign_key: :op_object_id
  has_many :review_history_elements, through: :reviews, source: :history_elements
  has_many :status_reports, as: :checkable, class_name: 'Status::Report', dependent: :destroy
  has_many :target_project_objects, through: :bs_request_actions
  belongs_to :staging_project, class_name: 'Project', optional: true
  has_one :request_exclusion, class_name: 'Staging::RequestExclusion', dependent: :destroy
  has_many :not_accepted_reviews, -> { where.not(state: :accepted) }, class_name: 'Review'
  has_many :notifications, as: :notifiable, dependent: :delete_all
  has_many :watched_items, as: :watchable, dependent: :destroy
  has_many :reports, as: :reportable, dependent: :nullify
  has_many :labels, as: :labelable
  accepts_nested_attributes_for :labels, allow_destroy: true

  validates :state, inclusion: { in: VALID_REQUEST_STATES }
  validates :creator, presence: true
  validate :check_supersede_state
  validate :check_creator, on: %i[create save!]
  validates :comment, length: { maximum: 65_535 }
  validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH_ALLOWED }
  validates :number, uniqueness: true
  validates_associated :bs_request_actions, message: ->(_, record) { record[:value].map { |r| r.errors.full_messages }.flatten.to_sentence }

  before_validation :sanitize!, if: :sanitize?, on: :create
  before_save :accept_staged_request
  before_save :assign_number
  after_create :notify
  before_update :send_state_change
  after_save :update_cache
  after_save { PopulateToSphinxJob.perform_later(id: id, model_name: :bs_request) }

  accepts_nested_attributes_for :bs_request_actions

  def self.delayed_auto_accept
    to_accept_by_time.each do |request|
      BsRequestAutoAcceptJob.perform_later(request.id)
    end
  end

  def self.list(opts)
    # All types means don't pass 'type'
    opts.delete(:types) if [opts[:types]].flatten.include?('all')
    # Do not allow a full collection to avoid server load
    raise 'This call requires at least one filter, either by user, project or package' if %i[project user package].all? { |filter| opts[filter].blank? }

    roles = opts[:roles] || []
    states = opts[:states] || []

    # it's wiser to split the queries
    if opts[:project] && roles.empty? && (states.empty? || states.include?('review'))
      (BsRequest::FindFor::Query.new(opts.merge(roles: ['reviewer'])).all +
        BsRequest::FindFor::Query.new(opts.merge(roles: %w[target source])).all).uniq
    else
      BsRequest::FindFor::Query.new(opts).all.uniq
    end
  end

  def self.list_numbers(opts)
    list(opts).pluck(:number)
  end

  def self.new_from_xml(xml)
    hashed = Xmlhash.parse(xml)

    raise SaveError, 'Failed parsing the request xml' unless hashed

    new_from_hash(hashed)
  end

  def self.new_from_hash(hashed)
    if hashed['id']
      theid = hashed.delete('id') { raise 'not found' }
      theid = Integer(theid)
    else
      theid = nil
    end
    # we will set it our own according to the user
    hashed.delete('creator')

    if hashed['submit'] && hashed['type'] == 'submit'
      # old style, convert to new style on the fly
      hashed.delete('type')
      hashed['action'] = hashed.delete('submit')
      hashed['action']['type'] = 'submit'
    end

    request = nil

    BsRequest.transaction do
      request = BsRequest.new
      request.number = theid if theid

      actions = hashed.delete('action')
      actions = [actions] if actions.is_a?(Hash)

      request.priority = hashed.delete('priority') || 'moderate'

      state = hashed.delete('state') || Xmlhash::XMLHash.new('name' => 'new')
      request.state = state.delete('name') || 'new'
      request.state = :declined if request.state.to_s == 'rejected'
      request.state = :accepted if request.state.to_s == 'accept'
      request.state = request.state.to_sym

      request.comment = state.value('comment')
      state.delete('comment')

      request.commenter = state.delete('who')
      unless request.commenter
        raise 'no one logged in and no user in request' unless User.session

        request.commenter = User.session!.login
      end
      # to be overwritten if we find history
      request.creator = request.commenter

      if actions
        actions.each do |ac|
          a = BsRequestAction.new_from_xml_hash(ac)
          request.bs_request_actions << a
          a.bs_request = request
        end
      end

      state.delete('created')
      str = state.delete('when')
      request.updated_when = Time.zone.parse(str) if str
      str = state.delete('superseded_by') || ''
      request.superseded_by = Integer(str) if str.present?
      str = state.delete('approver')
      request.approver = str if str.present?
      raise ArgumentError, "too much information #{state.inspect}" if state.present?

      request.description = hashed.value('description')
      hashed.delete('description')

      str = hashed.value('accept_at')
      request.accept_at = Time.parse(str) if str
      hashed.delete('accept_at')
      raise SaveError, 'Auto accept time is in the past' if request.accept_at && request.accept_at < Time.now

      # we do not support to import history anymore on purpose
      # would be all fake, but means also history gets lost when
      # updating from OBS 2.3 or older.
      hashed.delete('history')

      reviews = hashed.delete('review')
      reviews = [reviews] if reviews.is_a?(Hash)
      if reviews
        reviews.each do |r|
          request.reviews << Review.new_from_xml_hash(r)
        end
      end

      raise ArgumentError, "too much information #{hashed.inspect}" if hashed.present?

      request.updated_at ||= Time.now
    end
    request
  end

  # [DEPRECATED] TODO: there is only one instance of the @not_full_diff variable in the request scope which is using this method.
  # Once request_workflow_redesign beta is rolled out, let's drop this method
  # TODO: refactor this method as soon as the request_show_redesign feature is rolled out.
  # Now it expects an array of action hashes we'll never display more than one action at a time.
  def self.truncated_diffs?(actions)
    submit_requests = actions.select { |action| action[:type] == :submit && action[:sourcediff] }

    submit_requests.any? do |action|
      action[:sourcediff].any? { |sourcediff| sourcediff_has_shown_attribute?(sourcediff) }
    end
  end

  def self.sourcediff_has_shown_attribute?(sourcediff)
    if sourcediff && sourcediff['files']
      # the 'shown' attribute is only set if the backend truncated the diff
      sourcediff['files'].any? { |file| file[1]['diff'].try(:[], 'shown') }
    else
      false
    end
  end
  private_class_method :sourcediff_has_shown_attribute?

  # Currently only used by staging projects for the obs factories and
  # customized for that.
  def as_json(*)
    super(except: %i[state comment commenter]).tap do |request_hash|
      request_hash['superseded_by_id'] = superseded_by if has_attribute?(:superseded_by)
      request_hash['state'] = state.to_s if has_attribute?(:state)
      request_hash['request_type'] = bs_request_actions.first.type
      request_hash['package'] = bs_request_actions.first.target_package
      request_hash['project'] = bs_request_actions.first.target_project
    end
  end

  def history_elements
    HistoryElement::Base.where(id: request_history_elements.pluck(:id) + review_history_elements.pluck(:id)).order(:created_at)
  end

  def set_add_revision
    @addrevision = true
  end

  def set_ignore_build_state
    @ignore_build_state = true
  end

  def set_ignore_delegate
    @ignore_delegate = true
  end

  def sanitize?
    !@skip_sanitize
  end

  def skip_sanitize
    @skip_sanitize = true
  end

  def check_creator
    errors.add(:creator, 'No creator defined') unless creator
    # Allow admins to create requests for deleted or inactive users
    return if User.admin_session?

    user = User.not_deleted.find_by(login: creator)
    # FIXME: We should run the authorization on controller level
    raise APIError unless User.possibly_nobody.is_admin? || User.possibly_nobody == user

    errors.add(:creator, "Invalid creator specified #{creator}") unless user
    return if user.is_active?

    errors.add(:creator, "Login #{user.login} is not an active user")
  end

  def assign_number
    return if number

    # to assign a unique and steady incremental number.
    # Using MySQL auto-increment mechanism is not working on clusters.
    BsRequest.transaction do
      request_counter = BsRequestCounter.lock(true).first_or_create
      self.number = request_counter.counter
      request_counter.increment(:counter)
      request_counter.save!
    end
  end

  def check_supersede_state
    errors.add(:superseded_by, 'Superseded_by should be set') if state == :superseded && (!superseded_by.is_a?(Numeric) || superseded_by <= 0)

    return unless superseded_by && state != :superseded

    errors.add(:superseded_by, 'Superseded_by should not be set')
  end

  def updated_when
    self[:updated_when] || self[:updated_at]
  end

  def superseding
    BsRequest.where(superseded_by: number)
  end

  def first_target_package
    bs_request_actions.first.target_package
  end

  def state
    self[:state].to_sym
  end

  def to_axml(opts = {})
    if opts[:withfullhistory]
      Rails.cache.fetch("xml_bs_request_fullhistory_#{cache_key_with_version}") do
        render_xml(withfullhistory: 1)
      end
    elsif opts[:withhistory]
      Rails.cache.fetch("xml_bs_request_history_#{cache_key_with_version}") do
        render_xml(withhistory: 1)
      end
    else
      Rails.cache.fetch("xml_bs_request_#{cache_key_with_version}") do
        render_xml
      end
    end
  end

  def to_axml_id
    # FIXME: naming it axml is nonsense if it's just a string
    "<request id='#{number}'/>\n"
  end

  def to_param
    number
  end

  def render_xml(opts = {})
    builder = Nokogiri::XML::Builder.new
    builder.request(id: number, creator: creator) do |r|
      bs_request_actions.includes([:bs_request_action_accept_info]).find_each do |action|
        action.render_xml(r)
      end

      r.priority(priority) unless priority == 'moderate'

      # state element
      attributes = { name: state, who: commenter, when: updated_when.strftime('%Y-%m-%dT%H:%M:%S'), created: created_at.strftime('%Y-%m-%dT%H:%M:%S') }
      attributes[:superseded_by] = superseded_by if superseded_by
      attributes[:approver] = approver if approver
      r.state(attributes) do |s|
        comment = self.comment
        comment ||= ''
        s.comment!(comment)
      end

      reviews.each do |review|
        review.render_xml(r)
      end

      if opts[:withfullhistory] || opts[:withhistory]
        attributes = { who: creator, when: created_at.strftime('%Y-%m-%dT%H:%M:%S') }
        builder.history(attributes) do
          # request description is on purpose the comment in history:
          builder.description!('Request created')
          builder.comment!(description) if description.present?
        end
      end

      if opts[:withfullhistory]
        history_elements.each do |history|
          # we do ignore the review history here on purpose to stay compatible
          history.render_xml(r)
        end
      elsif opts[:withhistory]
        request_history_elements.each do |history|
          # we do ignore the review history here on purpose to stay compatible
          history.render_xml(r)
        end
      end

      r.accept_at(accept_at) unless accept_at.nil?
      r.description(description) unless description.nil?
    end
    builder.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::NO_DECLARATION |
                              Nokogiri::XML::Node::SaveOptions::FORMAT)
  end

  def is_reviewer?(user)
    return false if reviews.blank?

    reviews.each do |r|
      if r.by_user
        return true if user.login == r.by_user
      elsif r.by_group
        return true if user.is_in_group?(r.by_group)
      elsif r.by_project
        if r.by_package
          pkg = Package.find_by_project_and_name(r.by_project, r.by_package)
          return true if pkg && user.can_modify?(pkg)
        else
          prj = Project.find_by_name(r.by_project)
          return true if prj && user.can_modify?(prj)
        end
      end
    end

    false
  end

  def obsolete_reviews(opts)
    return false unless opts[:by_user] || opts[:by_group] || opts[:by_project] || opts[:by_package]

    reviews.each do |review|
      next unless review.reviewable_by?(opts)

      logger.debug "Obsoleting review #{review.id}"
      review.state = :obsoleted
      review.save
      history = HistoryElement::ReviewObsoleted
      history.create(review: review, comment: 'reviewer got removed', user_id: User.session!.id)

      # Maybe this will turn the request into an approved state?
      next unless state == :review && reviews.where(state: 'new').none?

      self.state = :new
      save
      history = HistoryElement::RequestAllReviewsApproved
      history.create(request: self, comment: opts[:comment], user_id: User.session!.id)
    end
  end

  def permission_check_change_review!(params)
    checker = BsRequestPermissionCheck.new(self, params)
    checker.cmd_changereviewstate_permissions
  end

  def permission_check_setincident!(incident)
    checker = BsRequestPermissionCheck.new(self, incident: incident)
    checker.cmd_setincident_permissions
  end

  def permission_check_setpriority!
    checker = BsRequestPermissionCheck.new(self, {})
    checker.cmd_setpriority_permissions
  end

  def permission_check_addreview!
    # allow request creator to add further reviewers
    checker = BsRequestPermissionCheck.new(self, {})
    checker.cmd_addreview_permissions(creator == User.session!.login || is_reviewer?(User.session!))
  end

  def permission_check_change_state!(opts)
    checker = BsRequestPermissionCheck.new(self, opts)
    checker.cmd_changestate_permissions

    # check target write permissions
    return unless opts[:newstate] == 'accepted'

    check_bs_request_actions!(skip_source: true)
  end

  def permission_check_change_state(opts)
    begin
      permission_check_change_state!(opts)
    rescue PostRequestNoPermission
      return false
    end
    true
  end

  def changestate_accepted(opts)
    # all maintenance_incident actions go into the same incident project
    incident_project = nil
    bs_request_actions.each do |action|
      next unless action.is_maintenance_incident?

      target_project = Project.get_by_name(action.target_project)
      next unless target_project.is_maintenance?

      source_project = Project.find_by_name(action.source_project)

      # create incident if it is a maintenance project
      incident_project ||= MaintenanceIncident.build_maintenance_incident(target_project, self, no_access: source_project.nil?).project
      opts[:check_for_patchinfo] = true

      raise MultipleMaintenanceIncidents, 'This request handles different maintenance incidents, this is not allowed !' unless incident_project.name.start_with?(target_project.name)

      action.target_project = incident_project.name
      action.save!
    end

    # We have permission to change all requests inside, now execute
    bs_request_actions.each do |action|
      action.execute_accept(opts)
    end

    # now do per request cleanup
    bs_request_actions.each do |action|
      action.per_request_cleanup(opts)
    end
  end

  def changestate_revoked(opts)
    bs_request_actions.where(type: 'maintenance_release').find_each do |action|
      # unlock incident project in the soft way
      prj = Project.get_by_name(action.source_project)
      if prj.is_locked?
        prj.unlock_by_request(self)
      elsif !opts.key?(:keep_packages_locked)
        pkg = Package.get_by_project_and_name(action.source_project, action.source_package)
        pkg.unlock_by_request(self) if pkg.is_locked?
      end
    end
  end

  def change_state(opts)
    with_lock do
      permission_check_change_state!(opts)
      changestate_revoked(opts) if opts[:newstate] == 'revoked'
      changestate_accepted(opts) if opts[:newstate] == 'accepted'

      state = opts[:newstate].to_sym
      bs_request_actions.each do |a|
        # "inform" the actions
        a.request_changes_state(state)
      end
      self.state = state
      self.commenter = User.session!.login
      self.comment = opts[:comment]
      self.superseded_by = opts[:superseded_by]

      # check for not accepted reviews on re-open
      if %i[new review].include?(state)
        reviews.each do |review|
          next unless review.state != :accepted

          # FIXME3.0 review history?
          review.state = :new
          review.save!
          self.state = :review
        end
      end
      save!

      params = { request: self, comment: opts[:comment], user_id: User.session!.id }
      case opts[:newstate]
      when 'accepted'
        history = HistoryElement::RequestAccepted
      when 'declined'
        history = HistoryElement::RequestDeclined
      when 'revoked'
        history = HistoryElement::RequestRevoked
      when 'superseded'
        history = HistoryElement::RequestSuperseded
        params[:description_extension] = superseded_by.to_s
      when 'review', 'new'
        history = HistoryElement::RequestReopened
      when 'deleted'
        history = HistoryElement::RequestDeleted
      else
        raise "Unhandled state #{opts[:newstate]} for history"
      end
      history.create(params)
    end
  end

  def assignreview(opts = {})
    raise InvalidStateError, 'request is not in review state' unless state == :review || state == :new

    reviewer = User.find_by_login!(opts[:reviewer])

    Review.transaction do
      # check if user is a reviewer already
      user_review = reviews.where(by_user: reviewer.login).last
      if opts[:revert]
        _assignreview_update_reviews(reviewer, opts)
        raise Review::NotFoundError unless user_review
        raise InvalidStateError, 'review is not in new state' unless user_review.state == :new
        raise Review::NotFoundError, 'Not an assigned review' unless HistoryElement::ReviewAssigned.where(op_object_id: user_review.id).last

        user_review.destroy
      elsif user_review
        review_comment = _assignreview_update_reviews(reviewer, opts)
        user_review.state = :new
        user_review.save!
        HistoryElement::ReviewReopened.create(review: user_review, comment: review_comment, user: User.session!)
      else
        review = reviews.create(by_user: reviewer.login, creator: User.session!.login, state: :new)
        review_comment = _assignreview_update_reviews(reviewer, opts, review)
        HistoryElement::ReviewAssigned.create(review: review, comment: review_comment, user: User.session!)
      end
      save!
    end
  end

  def approve(opts)
    raise InvalidStateError, "already approved by #{approver}" if approver

    approval_handling(User.session!, opts)
  end

  def cancelapproval(opts)
    raise InvalidStateError, 'request is not approved' unless approver

    approval_handling(nil, opts)
  end

  def calculate_state_from_reviews
    return :declined if reviews.declined.exists?

    if reviews.all?(&:accepted?)
      :new
    else
      :review
    end
  end

  def find_review_for_opts(opts)
    matching_reviews = reviews.order(id: :desc).select { |review| review.reviewable_by?(opts) }
    # prefer not yet accepted review
    matching_reviews.find { |review| review.state != :accepted } || matching_reviews.first
  end

  def supersede_request(history_arguments, superseded_opt)
    self.state = :superseded
    self.superseded_by = superseded_opt
    history_arguments[:description_extension] = superseded_by.to_s
    save!
    HistoryElement::RequestSuperseded.create(history_arguments)
  end

  def change_review_state(new_review_state, opts = {})
    with_lock do
      new_review_state = new_review_state.to_sym

      raise InvalidStateError, 'request is not in a changeable state (new, review or declined)' unless state == :review || (state.in?(%i[new declined]) && new_review_state == :new)

      check_if_valid_review!(opts)
      raise InvalidStateError, "review state must be new, accepted, declined or superseded, was #{new_review_state}" unless new_review_state.in?(%i[new accepted declined superseded])

      old_request_state = state
      review = find_review_for_opts(opts)
      raise Review::NotFoundError unless review

      next unless review.change_state(new_review_state, opts[:comment] || '')

      history_parameters = { request: self, comment: opts[:comment], user_id: User.session!.id }
      next supersede_request(history_parameters, opts[:superseded_by]) if new_review_state == :superseded

      new_request_state = calculate_state_from_reviews
      next if new_request_state == old_request_state

      self.comment = review.reason
      self.state = new_request_state
      self.commenter = User.session!.login
      case new_request_state
      when :new
        self.comment = 'All reviewers accepted request'
        save!
        Event::RequestReviewsDone.create(event_parameters)
        HistoryElement::RequestAllReviewsApproved.create(history_parameters)
        # pre-approved requests can be processed
        BsRequestAutoAcceptJob.perform_later(id) if approver
      when :review
        save!
      when :declined
        HistoryElement::RequestDeclined.create(history_parameters)
        save!
      end
    end
  end

  def check_if_valid_review!(opts)
    return if opts[:by_user] || opts[:by_group] || opts[:by_project]

    raise InvalidReview
  end

  def addreview(opts)
    with_lock do
      permission_check_addreview!
      check_if_valid_review!(opts)

      self.state = 'review'
      self.commenter = User.session!.login
      self.comment = opts[:comment] if opts[:comment]

      newreview = create_new_review(opts)
      save!

      history_params = {
        request: self,
        user_id: User.session!.id,
        description_extension: newreview.id.to_s
      }
      history_params[:comment] = opts[:comment] if opts[:comment]
      HistoryElement::RequestReviewAdded.create(history_params)
      newreview.create_event(event_parameters)
    end
  end

  def setpriority(opts)
    permission_check_setpriority!

    raise SaveError, "Illegal priority '#{opts[:priority]}'" unless opts[:priority].in?(%w[low moderate important critical])

    p = { request: self, user_id: User.session!.id, description_extension: "#{priority} => #{opts[:priority]}" }
    p[:comment] = opts[:comment] if opts[:comment]

    self.priority = opts[:priority]
    save!

    HistoryElement::RequestPriorityChange.create(p)
  end

  def setincident(incident)
    permission_check_setincident!(incident)

    touched = false
    # all maintenance_incident actions go into the same incident project
    p = { request: self, user_id: User.session!.id }
    bs_request_actions.where(type: 'maintenance_incident').find_each do |action|
      tprj = Project.get_by_name(action.target_project)

      # use an existing incident
      if tprj.is_maintenance?
        tprj = Project.get_by_name("#{action.target_project}:#{incident}")
        action.target_project = tprj.name
        action.save!
        touched = true
        p[:description_extension] = tprj.name
      end
    end

    return unless touched

    save!
    HistoryElement::RequestSetIncident.create(p)
  end

  def send_state_change
    return unless state_changed?
    # new->review && review->new are not worth an event - it's just spam
    return unless conclusive?

    options = event_parameters

    # measure duration unless superseding a final state, like revoked -> superseded
    options[:duration] = (updated_at - created_at).to_i if FINAL_REQUEST_STATES.exclude?(state_was.to_sym) && FINAL_REQUEST_STATES.include?(state)

    Event::RequestStatechange.create(options)
  end

  def accept_staged_request
    return if staging_project_id.nil? || state.to_sym != :accepted

    accepted_package = bs_request_actions.map(&:target_package)
    staging_project.packages.where(name: accepted_package).destroy_all
    self.staging_project_id = nil
  end

  def event_parameters
    params = { id: id,
               number: number,
               description: description,
               state: state,
               when: updated_when.strftime('%Y-%m-%dT%H:%M:%S'),
               comment: comment,
               author: creator,
               namespace: namespace }

    params[:oldstate] = state_was if state_changed?
    params[:who] = commenter if commenter.present?

    # Use a nested data structure to support multiple actions in one request
    params[:actions] = []
    bs_request_actions[0..ACTION_NOTIFY_LIMIT].each do |a|
      params[:actions] << a.notify_params
    end
    params
  end

  def namespace
    maintained_request? ? target_project_name : target_project_name.split(':').first
  end

  def maintained_request?
    maintenance_project = Project.get_maintenance_project
    return false unless maintenance_project

    maintenance_project.maintained_project_names.include?(target_project_name)
  end

  def target_project_name
    bs_request_actions&.first&.target_project.to_s
  end

  # It is considered an "incident request" if it has at least one maintenance_incident action
  def maintenance_incident_request?
    bs_request_actions.where(type: 'maintenance_incident').any?
  end

  # It is considered a "release request" if it has at least one maintenance_release action
  def maintenance_release_request?
    bs_request_actions.where(type: 'maintenance_release').any?
  end

  def auto_accept
    # do not run for processed requests. Ignoring review on purpose since this
    # must also work when people do not react anymore
    return unless state == :new || state == :review

    # use approve mechanic in case you want to wait for reviews
    return if approver && state == :review

    return unless accept_at || approver

    with_lock do
      if accept_at
        auto_accept_user = User.find_by!(login: creator)
      elsif approver
        auto_accept_user = User.find_by!(login: approver)
      end
      auto_accept_user.run_as do
        raise 'Request lacks definition of owner for auto accept' unless User.session!

        begin
          change_state(newstate: 'accepted', comment: 'Auto accept')
        rescue BsRequest::Errors::UnderEmbargo
          # not yet free to release, postponing it to the embargo date
          BsRequestAutoAcceptJob.set(wait_until: embargo_date).perform_later(id)
        rescue BsRequestPermissionCheck::NotExistingTarget
          change_state(newstate: 'revoked', comment: 'Target disappeared')
        rescue PostRequestNoPermission
          change_state(newstate: 'revoked', comment: 'Permission problem')
        rescue APIError => e
          logger.info("Failed to accept BsRequest #{number} with #{auto_accept_user.login}. #{e.class.name}: #{e}")
          change_state(newstate: 'declined', comment: 'Unhandled error during accept, contact your admin.')
        end
      end
    end
  end

  # Check if 'user' is maintainer in _all_ request sources:
  def is_source_maintainer?(user)
    bs_request_actions.all? { |action| action.is_source_maintainer?(user) }
  end

  # Check if 'user' is maintainer in _all_ request targets:
  def is_target_maintainer?(user)
    bs_request_actions.all? { |action| action.is_target_maintainer?(user) }
  end

  def sanitize!
    # apply default values, expand and do permission checks
    self.creator ||= User.session!.login
    self.commenter ||= User.session!.login
    # FIXME: Move permission checks to controller level
    raise SaveError, 'Admin permissions required to set request creator to foreign user' unless self.creator == User.session!.login || User.admin_session?
    raise SaveError, 'Admin permissions required to set request commenter to foreign user' unless self.commenter == User.session!.login || User.admin_session?

    # ensure correct initial values, no matter what has been sent to us
    self.state = :new

    # expand release and submit request targets if not specified
    expand_targets

    check_bs_request_actions!
    check_uniq_actions!

    # Autoapproval? Is the creator allowed to accept it?
    permission_check_change_state!(newstate: 'accepted') if accept_at

    apply_default_reviewers
  end

  def set_accept_at!(time = nil)
    # Approve a request to be accepted when the reviews finished
    permission_check_change_state!(newstate: 'accepted')

    self.accept_at = time || Time.now
    save!
  end

  def notify
    notify = event_parameters
    Event::RequestCreate.create(notify)

    reviews.each do |review|
      review.create_event(notify)
    end
  end

  # [DEPRECATED] TODO: drop this after request_workflow_redesign beta is rolled_out
  def webui_actions(opts = {})
    actions = []
    action_id = opts.delete(:action_id)
    xml = bs_request_actions.find_by(id: action_id) if action_id
    if xml
      actions << action_details(opts, xml: xml)
    else
      bs_request_actions.each do |action|
        actions << action_details(opts, xml: action)
      end
    end
    actions
  end

  def expand_targets
    newactions = []
    oldactions = []

    bs_request_actions.each do |action|
      new_action = action.expand_targets(@ignore_build_state.present?, @ignore_delegate.present?)
      next if new_action.nil?

      oldactions << action
      newactions.concat(new_action)
    end
    # will become an empty request
    raise MissingAction if newactions.empty? && oldactions.size == bs_request_actions.size

    oldactions.each { |a| bs_request_actions.destroy(a) }
    newactions.each { |a| bs_request_actions << a }
  end

  def forward_to(project:, package: nil, options: {})
    new_request = BsRequest.new(description: options[:description])
    BsRequest.transaction do
      bs_request_actions.where(type: 'submit').find_each do |action|
        rev = Directory.hashed(project: action.target_project, package: action.target_package)['rev']

        opts = { source_project: action.target_project,
                 source_package: action.target_package,
                 source_rev: rev,
                 target_project: project,
                 target_package: package,
                 type: action.type }
        new_request.bs_request_actions.build(opts)

        new_request.save!
      end
    end

    new_request
  end

  def required_checks
    target_project_objects.pluck(:required_checks).flatten.uniq
  end

  def staged_request?
    !staging_project_id.nil?
  end

  def can_be_reopened?
    (reviews.accepted.size + reviews.opened.size + reviews.declined.size).positive? &&
      # Declined is not really a final state, since the request can always be reopened...
      (BsRequest::FINAL_REQUEST_STATES.exclude?(state) || state == :declined)
  end

  # Collects the embargo_date from all actions and returns...
  # - the newest one
  # - nil if there are no actions with embargo date
  # - nil if all embargo_dates are in the past
  def embargo_date
    now = Time.zone.now
    embargo_dates = []
    bs_request_actions.where.not(source_project: nil).find_each do |action|
      next unless action.embargo_date

      embargo_dates.push(action.embargo_date)
    end

    return if embargo_dates.empty?

    embargo_dates.max if embargo_dates.max > now
  end

  # Methods used by ThinkingSphinx indices to collect multiple values
  def comments_bodies
    comments.collect(&:body).join(' ')
  end

  def reviews_reasons
    reviews.collect(&:reason).join(' ')
  end

  private

  # returns true if we have reached a state that we can't get out anymore
  def conclusive?
    FINAL_REQUEST_STATES.include?(state)
  end

  # [DEPRECATED] TODO: drop this after request_workflow_redesign beta is rolled_out
  def action_details(opts = {}, xml:)
    with_diff = opts.delete(:diffs)
    action = { type: xml.action_type }
    action[:id] = xml.id
    action[:number] = xml.bs_request.number
    if xml.source_project
      action[:sprj] = xml.source_project
      action[:spkg] = xml.source_package if xml.source_package
      action[:srev] = xml.source_rev if xml.source_rev
    end
    if xml.target_project
      action[:tprj] = xml.target_project
      action[:tpkg] = xml.target_package if xml.target_package
      action[:trepo] = xml.target_repository if xml.target_repository
    end
    action[:releaseproject] = xml.target_releaseproject if xml.target_releaseproject
    case xml.action_type # All further stuff depends on action type...
    when :submit
      action[:name] = "Submit #{action[:spkg]}"
      action[:sourcediff] = xml.webui_sourcediff(opts) if with_diff
      creator = User.find_by_login(self.creator)
      target_package = Package.find_by_project_and_name(action[:tprj], action[:tpkg])
      action[:creator_is_target_maintainer] = true if creator.has_local_role?(Role.hashed['maintainer'], target_package)

      if target_package
        linkinfo = target_package.linkinfo
        target_package.developed_packages.each do |dev_pkg|
          action[:forward] ||= []
          action[:forward] << { project: dev_pkg.project.name, package: dev_pkg.name, type: 'devel' }
        end
        if linkinfo
          lprj = linkinfo['project']
          lpkg = linkinfo['package']
          link_is_already_devel = false
          if action[:forward]
            action[:forward].each do |forward|
              if forward[:project] == lprj && forward[:package] == lpkg
                link_is_already_devel = true
                break
              end
            end
          end
          unless link_is_already_devel
            action[:forward] ||= []
            action[:forward] << { project: linkinfo['project'], package: linkinfo['package'], type: 'link' }
          end
        end
      end

    when :delete
      action[:name] = if action[:tpkg]
                        "Delete #{action[:tpkg]}"
                      elsif action[:trepo]
                        "Delete #{action[:trepo]}"
                      else
                        "Delete #{action[:tprj]}"
                      end

      action[:sourcediff] = xml.webui_sourcediff if action[:tpkg] && with_diff # API / Backend don't support whole project diff currently
    when :add_role
      action[:name] = 'Add Role'
      action[:role] = xml.role
      action[:user] = xml.person_name
      action[:group] = xml.group_name
    when :change_devel
      action[:name] = 'Change Devel'
    when :set_bugowner
      action[:name] = 'Set Bugowner'
      action[:user] = xml.person_name
      action[:group] = xml.group_name
    when :maintenance_incident
      action[:name] = "Incident #{action[:spkg]}"
      action[:sourcediff] = xml.webui_sourcediff(opts) if with_diff
    when :maintenance_release, :release
      action[:name] = "Release #{action[:spkg]}"
      action[:sourcediff] = xml.webui_sourcediff(opts) if with_diff
    end

    if action[:sourcediff]
      errors = action[:sourcediff].pluck(:error).compact
      action[:diff_not_cached] = errors.any? { |e| e.include?('diff not yet in cache') }
    else
      action[:diff_not_cached] = false
    end

    action
  end

  def apply_default_reviewers
    reviewers = collect_default_reviewers!
    # apply reviewers
    reviewers.each do |r|
      if r.instance_of?(User)
        next if reviews.any? { |a| a.by_user == r.login }

        reviews.new(by_user: r.login, state: :new)
      elsif r.instance_of?(Group)
        next if reviews.any? { |a| a.by_group == r.title }

        reviews.new(by_group: r.title, state: :new)
      elsif r.instance_of?(Project)
        next if reviews.any? { |a| a.by_project == r.name && a.by_package.nil? }

        reviews.new(by_project: r.name, state: :new)
      elsif r.instance_of?(Package)
        next if reviews.any? { |a| a.by_project == r.project.name && a.by_package == r.name }

        reviews.new(by_project: r.project.name, by_package: r.name, state: :new)
      else
        raise 'Unknown review type'
      end
    end
    self.state = :review if reviews.any? { |a| a.state.to_sym == :new }
  end

  #
  # Find out about defined reviewers in target
  #
  # check targets for defined default reviewers and
  # trigger the create_post_permissions_hook
  def collect_default_reviewers!
    bs_request_actions.map do |action|
      action.create_post_permissions_hook
      action.default_reviewers
    end.uniq.flatten
  end

  def raisepriority(new_priority)
    # rails enums do not support compare and break db constraints :/
    self.priority = new_priority if change_priorities?(new_priority)
  end

  # We can only raise the priority, in the context where this method is needed.
  # This method checks makes sure this is the case.
  def change_priorities?(new_priority)
    new_priority == 'critical' ||
      (new_priority == 'important' && priority.in?(%w[moderate low])) ||
      (new_priority == 'moderate' && priority == 'low')
  end

  def check_uniq_actions!
    uniq_keys = bs_request_actions.map(&:uniq_key)
    raise ConflictingActions, 'Conflicting Actions' if uniq_keys.length > uniq_keys.uniq.length
  end

  def check_bs_request_actions!(opts = {})
    bs_request_actions.each do |action|
      action.check_action_permission!(opts[:skip_source])
      action.check_for_expand_errors!(!@addrevision.nil?)
      raisepriority(action.minimum_priority)
    end

    return unless persisted? && priority_changed?

    HistoryElement::RequestPriorityChange.create(
      request: self,
      # We need to have a user here
      user: User.find_nobody!,
      description_extension: "#{priority_was} => #{priority}",
      comment: 'Automatic priority bump: Priority of related action increased.'
    )
  end

  def _assignreview_update_reviews(reviewer, opts, new_review = nil)
    review_comment = nil
    reviews.reverse_each do |review|
      next if review.by_user
      next if review.by_group && review.by_group != opts[:by_group]
      next if review.by_project && review.by_project != opts[:by_project]
      next if review.by_package && review.by_package != opts[:by_package]

      # approve for this review
      if opts[:revert]
        review.state = :new
        review_comment = 'revert the '
        history_class = HistoryElement::ReviewReopened
      else
        review.state = :accepted
        review.review_assigned_to = new_review if new_review
        review_comment = ''
        history_class = HistoryElement::ReviewAccepted
      end
      review.reviewer = User.session!.login
      review.save!

      review_comment += "review for group #{opts[:by_group]}" if opts[:by_group]
      review_comment += "review for project #{opts[:by_project]}" if opts[:by_project]
      review_comment += "review for package #{opts[:by_project]} / #{opts[:by_package]}" if opts[:by_package]
      history_class.create(review: review, comment: "review assigned to user #{reviewer.login}", user_id: User.session!.id)
    end
    raise Review::NotFoundError unless review_comment

    review_comment
  end

  def update_cache
    BsRequestCleanTasksCacheJob.perform_later(id)
  end

  def approval_handling(new_approver, opts)
    raise InvalidStateError, 'request is not in review state' unless state == :review

    # check if User.session! is allowed to potentially accept the request
    # (note: setting the :force key to true will skip some checks but
    # none of them is supposed to be crucial wrt. permission checking)
    my_opts = opts.merge(newstate: 'accepted', force: true)
    checker = BsRequestPermissionCheck.new(self, my_opts)
    checker.cmd_changestate_permissions
    check_bs_request_actions!(skip_source: true)

    self.approver = new_approver
    save!
  end

  def create_new_review(opts)
    newreview = reviews.create(
      reason: opts[:comment],
      by_user: opts[:by_user],
      by_group: opts[:by_group],
      by_project: opts[:by_project],
      by_package: opts[:by_package],
      creator: User.session!.login,
      reviewer: User.session!.login
    )
    return newreview if newreview.valid?

    newreview.check_reviewer!

    raise InvalidReview, "Review invalid: #{newreview.errors.full_messages.join("\n")}"
  end
end

# rubocop: enable Metrics/ClassLength

# == Schema Information
#
# Table name: bs_requests
#
#  id                 :integer          not null, primary key
#  accept_at          :datetime
#  approver           :string(255)
#  comment            :text(65535)
#  commenter          :string(255)
#  creator            :string(255)      indexed
#  description        :text(65535)
#  number             :integer          indexed
#  priority           :string           default("moderate")
#  state              :string(255)      indexed
#  superseded_by      :integer          indexed
#  updated_when       :datetime
#  created_at         :datetime         not null
#  updated_at         :datetime         not null
#  staging_project_id :integer          indexed
#
# Indexes
#
#  index_bs_requests_on_creator             (creator)
#  index_bs_requests_on_number              (number) UNIQUE
#  index_bs_requests_on_staging_project_id  (staging_project_id)
#  index_bs_requests_on_state               (state)
#  index_bs_requests_on_superseded_by       (superseded_by)
#