openSUSE/open-build-service

View on GitHub
src/api/app/models/event/base.rb

Summary

Maintainability
C
1 day
Test Coverage
A
94%
# This class represents some kind of event within the build service
# that users (or services) would like to know about
module Event
  class Base < ApplicationRecord
    self.inheritance_column = 'eventtype'
    self.table_name = 'events'

    after_create :create_project_log_entry_job, if: -> { (PROJECT_CLASSES | PACKAGE_CLASSES).include?(self.class.name) }

    EXPLANATION_FOR_NOTIFICATIONS =  {
      'Event::BuildFail' => 'Receive notifications of build failures for packages for which you are...',
      'Event::ServiceFail' => 'Receive notifications of source service failures for packages for which you are...',
      'Event::ReviewWanted' => 'Receive notifications of reviews created that have you as a wanted...',
      'Event::RequestCreate' => 'Receive notifications of requests created for projects/packages for which you are...',
      'Event::RequestStatechange' => 'Receive notifications of requests state changes for projects for which you are...',
      'Event::CommentForProject' => 'Receive notifications of comments created on projects for which you are...',
      'Event::CommentForPackage' => 'Receive notifications of comments created on a package for which you are...',
      'Event::CommentForRequest' => 'Receive notifications of comments created on a request for which you are...',
      'Event::RelationshipCreate' => "Receive notifications when someone adds you or your group to a project or package with any of these roles: #{Role.local_roles.to_sentence}.",
      'Event::RelationshipDelete' => "Receive notifications when someone removes you or your group from a project or package with any of these roles: #{Role.local_roles.to_sentence}.",
      'Event::ReportForComment' => 'Receive notifications for reported comments.',
      'Event::ReportForPackage' => 'Receive notifications for reported packages.',
      'Event::ReportForProject' => 'Receive notifications for reported projects.',
      'Event::ReportForUser' => 'Receive notifications for reported users.',
      'Event::ReportForRequest' => 'Receive notifications for reported requests',
      'Event::ClearedDecision' => 'Receive notifications for cleared report decisions.',
      'Event::FavoredDecision' => 'Receive notifications for favored report decisions.',
      'Event::WorkflowRunFail' => 'Receive notifications for failed workflow runs on SCM/CI integration.',
      'Event::AppealCreated' => 'Receive notifications when a user appeals against a decision of a moderator.'
    }.freeze

    class << self
      attr_accessor :description, :message_bus_routing_key

      @payload_keys = nil
      @create_jobs = nil
      @classnames = nil
      @receiver_roles = nil
      @shortenable_key = nil

      def notification_events
        ['Event::BuildFail', 'Event::ServiceFail', 'Event::ReviewWanted', 'Event::RequestCreate',
         'Event::RequestStatechange', 'Event::CommentForProject', 'Event::CommentForPackage',
         'Event::CommentForRequest',
         'Event::RelationshipCreate', 'Event::RelationshipDelete',
         'Event::ReportForComment', 'Event::ReportForPackage', 'Event::ReportForProject', 'Event::ReportForUser', 'Event::ReportForRequest',
         'Event::WorkflowRunFail', 'Event::AppealCreated', 'Event::ClearedDecision', 'Event::FavoredDecision'].map(&:constantize)
      end

      def classnames
        @classnames || [name]
      end

      def add_classname(name)
        @classnames ||= [self.name]
        @classnames << name
      end

      def payload_keys(*keys)
        # this function serves both for reading and setting
        return @payload_keys if keys.empty?

        @payload_keys ||= []
        @payload_keys += keys
      end

      # FIXME: Find a way to get rid of these setter/getter methods for all these class variables
      def shortenable_key(key = nil)
        # this function serves both for reading and setting
        return @shortenable_key if key.nil?

        @shortenable_key = key
      end

      def create_jobs(*keys)
        # this function serves both for reading and setting
        return @create_jobs || [] if keys.empty?

        @create_jobs ||= []
        @create_jobs += keys
      end

      def receiver_roles(*keys)
        # this function serves both for reading and setting
        return @receiver_roles || [] if keys.empty?

        @receiver_roles ||= []
        @receiver_roles += keys
      end

      # make sure that subclasses can set shared attributes
      def inherited(subclass)
        super

        subclass.after_create_commit(:send_to_bus)
        subclass.after_create_commit(:clear_caches)
        subclass.add_classname(name) unless name == 'Event::Base'
        subclass.payload_keys(*payload_keys)
        subclass.create_jobs(*create_jobs)
        subclass.receiver_roles(*receiver_roles)
      end
    end

    # just for convenience
    def payload_keys
      self.class.payload_keys
    end

    def shortenable_key
      self.class.shortenable_key
    end

    def create_jobs
      self.class.create_jobs
    end

    def receiver_roles
      self.class.receiver_roles
    end

    def initialize(attribs)
      attributes = attribs.dup.with_indifferent_access
      super()
      self.created_at = attribs[:time] if attributes[:time]
      attributes.delete :eventtype
      attributes.delete :time

      set_payload(attributes, payload_keys)
    end

    def check_left_attribs(attribs)
      # remove default rails params
      attribs.delete 'format'
      attribs.delete 'action'
      attribs.delete 'controller'

      return if attribs.empty?

      na = []
      attribs.keys.each { |k| na << k.to_s }
      logger.debug "LEFT #{self.class.name} payload_keys :#{na.sort.join(', :')}"
      raise "Unexpected payload_keys :#{na.sort.join(', :')} (#{attribs.inspect}) provided during '#{self.class.name}' event creation. "
    end

    def set_payload(attribs, keys)
      values = {}
      keys.each do |k|
        # for internal events it's a symbol, for external ones a string, so try both
        v = attribs.delete k
        k = k.to_s
        v ||= attribs.delete k
        values[k] = v unless v.nil?
      end
      self.payload = ActiveSupport::JSON.encode(calculate_payload(values))
      # now check if anything but the default rails params are left
      check_left_attribs(attribs)
    end

    def payload
      @payload ||= ActiveSupport::JSON.decode(self[:payload])
    end

    def create_project_log_entry_job
      CreateProjectLogEntryJob.perform_later(payload, created_at.to_s, self.class.name)
    end

    after_create :perform_create_jobs

    def perform_create_jobs
      self.undone_jobs = 0
      save
      create_jobs.each do |job|
        job_class = job.to_s.camelize.safe_constantize

        # we want to keep SCM capitalized in the job name, so we catch the case and overwrite the name
        job_class = ReportToSCMJob.name.safe_constantize if job.to_s.camelize.casecmp(ReportToSCMJob.name).zero?

        raise "#{job.to_s.camelize} does not map to a constant" if job_class.nil?

        job_obj = job_class.new
        raise("#{job.to_s.camelize} is not a CreateJob") unless job_obj.is_a?(CreateJob)

        job_class.perform_later(id)

        self.undone_jobs += 1
      end
      save if self.undone_jobs.positive?
    end

    def mark_job_done!
      return unless undone_jobs.positive?

      self.undone_jobs -= 1
      save!
    end

    # FIXME: This should be done in the event specific EmailMailer view
    # https://guides.rubyonrails.org/action_mailer_basics.html#using-action-mailer-helpers
    def subject
      'Build Service Notification'
    end

    # needs to return a hash (merge super)
    def custom_headers
      {}
    end

    def subscriptions(channel = :instant_email)
      EventSubscription::FindForEvent.new(self).subscriptions(channel)
    end

    def subscribers
      subscriptions.map(&:subscriber)
    end

    # to calculate expensive things we don't want to store in database (i.e. diffs)
    def expanded_payload
      payload
    end

    def payload_address(field)
      return User.find_by_login(payload[field]) if payload[field]

      nil
    end

    def originator
      payload_address('sender')
    end

    def template_name
      self.class.name.gsub('Event::', '').underscore
    end

    def maintainers
      Rails.logger.debug { "Maintainers #{payload.inspect}" }
      ret = _roles('maintainer', payload['project'], payload['package'])
      Rails.logger.debug { "Maintainers ret #{ret.inspect}" }
      ret
    end

    def bugowners
      Rails.logger.debug { "Maintainers #{payload.inspect}" }
      ret = _roles('bugowner', payload['project'], payload['package'])
      Rails.logger.debug { "Maintainers ret #{ret.inspect}" }
      ret
    end

    def readers
      Rails.logger.debug { "Readers #{payload.inspect}" }
      ret = _roles('reader', payload['project'])
      Rails.logger.debug { "Readers ret #{ret.inspect}" }
      ret
    end

    def watchers
      project = ::Project.find_by_name(payload['project'])
      return [] if project.blank?

      project.watched_items.includes(:user).map(&:user)
    end

    def package_watchers
      package = Package.get_by_project_and_name(payload['project'], payload['package'], { follow_multibuild: true, follow_project_links: false, use_source: false })
      return [] if package.blank?

      package.watched_items.includes(:user).map(&:user)
    rescue Package::Errors::UnknownObjectError, Project::Errors::UnknownObjectError
      []
    end

    def request_watchers
      bs_request = BsRequest.find_by(number: payload['number'])
      return [] if bs_request.blank?

      bs_request.watched_items.includes(:user).map(&:user)
    end

    def moderators
      users = User.moderators
      return users unless users.empty?

      User.admins.or(User.staff)
    end

    def reporters
      decision = Decision.find(payload['id'])
      decision.reports.map(&:user)
    end

    def offenders
      decision = Decision.find(payload['id'])
      reportables = decision.reports.map(&:reportable)
      reportables.map do |reportable|
        case reportable
        when Package, Project
          reportable.maintainers
        when User
          reportable
        when BsRequest
          User.find_by(login: reportable.creator)
        when Comment
          reportable.user
        end
      end
    end

    def _roles(role, project, package = nil)
      return [] unless project

      p = nil
      p = ::Package.find_by_project_and_name(project, package) if package
      p ||= ::Project.find_by_name(project)
      obj_roles(p, role)
    end

    def send_to_bus
      RabbitmqBus.send_to_bus(message_bus_routing_key, self[:payload]) if message_bus_routing_key
      RabbitmqBus.send_to_bus('metrics', to_metric) if metric_fields.present?
    end

    def clear_caches
      # no default implementation
    end

    def parameters_for_notification
      { event_type: eventtype,
        event_payload: payload,
        notifiable_id: payload['id'],
        created_at: payload['when']&.to_datetime,
        title: subject_to_title }
    end

    def involves_hidden_project?
      false
    end

    private

    def message_bus_routing_key
      self.class.message_bus_routing_key
    end

    def metric_tags
      {}
    end

    def metric_fields
      {}
    end

    def metric_measurement
      message_bus_routing_key
    end

    def to_metric
      tags = metric_tags.map { |k, v| "#{k}=#{v}" }.join(',')
      tags = ",#{tags}" if tags.present?
      fields = metric_fields.map { |k, v| "#{k}=#{v}" }.join(',')
      "#{metric_measurement}#{tags} #{fields}"
    end

    def calculate_payload(values)
      return values if shortenable_key.nil? # If no shortenable_key is set then we cannot shorten the payload

      overflow_bytes = ActiveSupport::JSON.encode(values).bytesize - 65_535

      return values if overflow_bytes <= 0

      # Shorten the payload so it will fit into the database column
      shortenable_content = values[shortenable_key.to_s]
      new_size = shortenable_content.bytesize - overflow_bytes
      values[shortenable_key.to_s] = shortenable_content.mb_chars.limit(new_size)

      values
    end

    def obj_roles(obj, role)
      # old/deleted obj
      return [] unless obj || role.blank?

      rel = obj.relationships.where(role: Role.hashed[role])
      receivers = rel.map { |r| r.user_id ? r.user : r.group }
      receivers = obj_roles(obj.project, role) if receivers.empty? && obj.respond_to?(:project)

      # for now we define develpackage maintainers as being maintainers too
      receivers.concat(obj_roles(obj.develpackage, role)) if obj.respond_to?(:develpackage)
      receivers
    end

    def subject_to_title
      return subject if subject.size <= 255

      subject.slice(0, 252).concat('...')
    end
  end
end

# == Schema Information
#
# Table name: events
#
#  id          :bigint           not null, primary key
#  eventtype   :string(255)      not null, indexed
#  mails_sent  :boolean          default(FALSE), indexed
#  payload     :text(16777215)
#  undone_jobs :integer          default(0)
#  created_at  :datetime         indexed
#  updated_at  :datetime
#
# Indexes
#
#  index_events_on_created_at  (created_at)
#  index_events_on_eventtype   (eventtype)
#  index_events_on_mails_sent  (mails_sent)
#