octobox/octobox

View on GitHub
app/models/subject.rb

Summary

Maintainability
C
1 day
Test Coverage
class Subject < ApplicationRecord
  has_many :notifications, foreign_key: :subject_url, primary_key: :url
  has_many :labels, dependent: :delete_all
  has_many :users, through: :notifications
  has_many :comments, dependent: :delete_all
  belongs_to :repository, foreign_key: :repository_full_name, primary_key: :full_name, optional: true
  has_one :app_installation, through: :repository

  BOT_AUTHOR_REGEX = /\A(.*)\[bot\]\z/.freeze
  private_constant :BOT_AUTHOR_REGEX

  scope :label, ->(label_name) { joins(:labels).where(Label.arel_table[:name].matches(label_name)) }

  validates :url, presence: true, uniqueness: true

  after_save :push_to_channels

  def update_labels(remote_labels)
    existing_labels = labels.to_a
    remote_labels.each do |l|
      label = labels.find_by_github_id(l['id'])
      if label.nil?
        labels.create({
          github_id: l['id'],
          color: l['color'],
          name: l['name'],
        })
      else
        label.github_id = l['id'] # smoothly migrate legacy labels
        label.color = l['color']
        label.name = l['name']
        label.save if label.changed?
      end
    end

    remote_label_ids = remote_labels.map{|l| l['id'] }
    deleted_labels = existing_labels.reject{|l| remote_label_ids.include?(l.github_id) }
    deleted_labels.each(&:destroy)
    push_to_channels if existing_labels != labels.to_a
  end

  def sync_involved_users
    return unless Octobox.github_app?
    involved_user_ids.each { |user_id| SyncNotificationsWorker.perform_in(1.minutes, user_id) }
  end

  def sync(remote_subject)
    update({
      repository_full_name: extract_full_name_from_remote_subject(remote_subject),
      github_id: remote_subject['id'],
      state: remote_subject['merged_at'].present? ? 'merged' : remote_subject['state'],
      author: remote_subject.fetch('user', {})['login'],
      html_url: remote_subject['html_url'],
      created_at: remote_subject['created_at'] || Time.current,
      updated_at: remote_subject['updated_at'] || Time.current,
      comment_count: extract_comment_count_from_remote_subject(remote_subject) || comment_count,
      assignees: ":#{Array(remote_subject['assignees'].try(:map) {|a| a['login'] }).join(':')}:",
      locked: remote_subject['locked'],
      sha: remote_subject.fetch('head', {})['sha'],
      body: remote_subject['body'].try(:gsub, "\u0000", ''),
      draft: remote_subject['draft'],
      title: remote_subject['title']
    })

    return unless persisted?

    update_labels(remote_subject['labels']) if remote_subject['labels'].present?
    update_comments if Octobox.include_comments? && (has_comments? || pull_request?)
    update_status
    sync_involved_users if (saved_changes.keys & notifiable_fields).any?
  end

  def self.sync(remote_subject)
    subject = Subject.find_or_create_by(url: remote_subject['url'])
    subject.sync(remote_subject)
  end

  def self.sync_status(sha, repository_full_name)
    where(repository_full_name: repository_full_name).find_by_sha(sha)&.update_status
  end

  def self.sync_comments(remote_subject)
    return if remote_subject.nil?
    subject = Subject.find_by(url: remote_subject['url'])
    Subject.sync(remote_subject) if subject.nil?
    return if subject.nil?

    subject.update_comments
  end

  def has_comments?
     comment_count && comment_count > 0
  end

  def commentable?
    !comment_count.nil?
  end

  def pull_request?
    url.match?(/pull/i)
  end

  def update_status
    if sha.present?
      remote_status = download_status
      if remote_status.present?
        self.status = assign_status(remote_status)
        self.save if changed?
      end
    end
  end

  def author_url_path
    if bot_author?
      "/apps/#{BOT_AUTHOR_REGEX.match(author)[1]}"
    else
      "/#{author}"
    end
  end

  def update_comments
    remote_comments = download_comments
    if pull_request? && remote_comments
      remote_comments.concat download_reviews
      remote_comments.concat download_review_comments
    end
    return unless remote_comments.present?
    remote_comments.each do |remote_comment|
      next if remote_comment.nil?
      comments.find_or_create_by(github_id: remote_comment.id) do |comment|
        comment.author = remote_comment.user.try(:login)
        comment.url = remote_comment.url
        comment.url = remote_comment.url
        comment.body = remote_comment.body.try(:gsub, "\u0000", '')
        comment.author_association = remote_comment.author_association
        comment.created_at = remote_comment.created_at
        comment.created_at = remote_comment.submitted_at if remote_comment.submitted_at
        comment.review_state = remote_comment.state if remote_comment.state
        comment.save
      end
    end
    comment_count = comments.count
  end

  def comment(user, comment_body)
    return if comment_body.nil? || comment_body.empty?
    comment = comments.create(author: user.github_login, body: comment_body)
    CommentWorker.perform_async_if_configured(comment.id, user.id, self.id)
  end

  def comment_on_github(comment, user)
    return if comment.body.empty?

    client = user.comment_client(comment)

    remote_comment = client.post url.gsub('/pulls/', '/issues/') + '/comments', {body: comment.body}
    comment.github_id = remote_comment.id
    comment.author_association = remote_comment.author_association
    comment.created_at = remote_comment.created_at
    comment.save
  end

  def notifiable_fields
    ['state', 'assignees', 'locked', 'sha', 'comment_count', 'draft']
  end

  def push_to_channels
    notifications.includes({:subject => :labels}, :repository, {:user => :individual_subscription_purchase}).find_each(&:push_to_channel) if (saved_changes.keys & pushable_fields).any?
  end

  private

  def pushable_fields
    ['state', 'status', 'body', 'comment_count', 'draft']
  end

  def assign_status(remote_status)
    if remote_status.state == 'pending'
      remote_status.statuses.present? ? remote_status.state : nil
    else
      remote_status.state
    end
  end

  def download_status
    return unless github_client
    github_client.combined_status(repository_full_name, sha)
  rescue Octokit::ClientError
    nil
  end

  #example https://api.github.com/repos/octobox/octobox/issues/1141/comments
  def download_comments
    return [] unless github_client
    github_client.get(url.gsub('/pulls/', '/issues/') + '/comments', since: comments.order('created_at ASC').last.try(:created_at))
  rescue Octokit::ClientError => e
    []
  end

  #example https://api.github.com/repos/octobox/octobox/pulls/1141/reviews
  def download_reviews
    return [] unless github_client && pull_request?
    reviews = github_client.get(url + '/reviews', since: comments.order('created_at ASC').last.try(:created_at))
    return [] unless reviews.present?
    reviews.map { |review|
      if review && review[:state] == "COMMENTED"
        reviews.concat download_comments_for_review(review)
        reviews.delete(review)
      end
    }
    return reviews
  rescue Octokit::ClientError => e
    []
  end

  #example https://api.github.com/repos/octobox/octobox/pulls/1141/reviews/172586974/comments
  def download_comments_for_review(review)
    return [] unless github_client
    reviews = github_client.get(review['pull_request_url'] + '/reviews/' + review['id'].to_s + '/comments', since: comments.order('created_at ASC').last.try(:created_at))
  rescue Octokit::ClientError, Octokit::InternalServerError => e
    []
  end

  #example https://api.github.com/repos/octobox/octobox/pulls/1141/comments
  def download_review_comments
    return [] unless github_client && pull_request?
    github_client.get(url + '/comments', since: comments.order('created_at ASC').last.try(:created_at))
  rescue Octokit::ClientError, Octokit::InternalServerError => e
    []
  end

  def github_client
    if app_installation.present?
      app_installation.github_client
    else
      users.with_access_token.first&.github_client
    end
  end

  def extract_full_name_from_remote_subject(remote_subject)
    # webhook payloads don't always have 'repository' info
    if remote_subject['repository']
      remote_subject['repository']['full_name']
    elsif remote_subject['full_name']
      remote_subject['full_name']
    else
      extract_full_name(remote_subject['url'])
    end
  end

  def extract_full_name(url)
    url.match(/\/repos\/([\w.-]+\/[\w.-]+)\//)[1]
  end

  def extract_comment_count_from_remote_subject(remote_subject)
    remote_subject['comments'] || remote_subject.fetch('commit', {})['comment_count']
  end

  def involved_user_ids
    involved_users = users.with_access_token.not_recently_synced.active
    involved_users += repository.users.with_access_token.not_recently_synced.active if repository.present?
    involved_users.uniq.reject(&:syncing?).map(&:id)
  end

  def bot_author?
    BOT_AUTHOR_REGEX.match?(author)
  end
end