app/models/pull_request.rb
# frozen_string_literal: true
class PullRequest < ApplicationRecord
# It is easier overall to use the GitHub ID for relation management.
# It allows us to maintain, update or the Repository or PullRequest without
# the counterpart.
belongs_to(:repository,
primary_key: :github_id,
foreign_key: :gh_repository_id,
inverse_of: :pull_requests)
has_and_belongs_to_many :labels
after_save :queue_validation
##
# This method is used for updating our database with the current state of the PullRequest
# in GitHub. Therefore it's used with the payload of the webhook content processed by
# GithubEvent handler and by the content from the api fetched by the periodic check.
def self.update_with_github(gh_pull_request)
PullRequest.where(github_id: gh_pull_request['id']).first_or_initialize.tap do |pull_request|
# get current status. GitHub API does not expose it as an atttribute of a PR
# However, https://github.com/search does
repo_id = gh_pull_request['base']['repo']['id']
check_suite = begin
raw_check_suite = Github.client.check_suites_for_ref(repo_id, gh_pull_request['head']['sha']).check_suites.last
[raw_check_suite.status, raw_check_suite.conclusion]
rescue StandardError => e
Raven.capture_message('validate status', extra: { trace: e.backtrace, error: e.inspect, github_data: gh_pull_request.to_h })
[nil, nil]
end
pull_request.number = gh_pull_request['number']
pull_request.state = gh_pull_request['state']
pull_request.title = gh_pull_request['title']
pull_request.body = gh_pull_request['body']
pull_request.gh_created_at = gh_pull_request['created_at']
pull_request.gh_updated_at = gh_pull_request['updated_at']
pull_request.gh_repository_id = repo_id
pull_request.closed_at = gh_pull_request['closed_at']
pull_request.merged_at = gh_pull_request['merged_at']
pull_request.mergeable = gh_pull_request['mergeable']
pull_request.author = gh_pull_request['user']['login']
pull_request.status = check_suite[0]
pull_request.conclusion = check_suite[1]
pull_request.draft = gh_pull_request['draft']
pull_request.save
gh_pull_request['labels'].each do |label|
db_label = Label.find_or_create_by(name: label['name'], color: label['color'])
next if pull_request.labels.include? db_label
pull_request.labels << db_label
end
end
end
##
# helper method to create a github object for the pullrequest
def github
@github ||= Github.client.pull_request(gh_repository_id, number)
end
##
# link to github
def github_url
repository.github_url + "/pull/#{number}"
end
##
# Shortcut to check if the PullRequest is closed
def closed?
state == 'closed'
end
##
# Shortcut to check if the PullRequest is open
def open?
!closed?
end
##
# Ensure that the Label is attached to the PullRequest
#
# Therefore ensure that the Label exists for the corresponding repository
#
# Then ensure the Label is attached to this PullRequest by checking the list
# of attached Labels. Unfortunately this seems to be the only option
#
# If the list does not include the given Label we attach it
def ensure_label_is_attached(label)
if ENV['DRY_RUN']
DRY_LOGGER.info("Would attach label #{label.name} from #{title} in #{repository.full_name}")
return
end
repository.ensure_label_exists(label)
attached_labels = Github.client.labels_for_issue(gh_repository_id, number)
return if attached_labels.any? { |attached_label| attached_label['name'] == label.name }
response = Github.client.add_labels_to_an_issue(gh_repository_id, number, [label.name])
Raven.capture_message('Attached a label to an issue', extra: { label: label, repo: repository.github_url, title: title })
response
end
##
# We simply remove the given Label if it exists
def ensure_label_is_detached(label)
if ENV['DRY_RUN']
DRY_LOGGER.info("Would detach label #{label.name} from #{title} in #{repository.full_name}")
return
end
response = Github.client.remove_label(gh_repository_id, number, label.name)
Raven.capture_message('Detached a label from an issue', extra: { label: label, repo: repository.github_url, title: title })
response
rescue Octokit::NotFound
true
end
##
# Only attach a comment if eligible_for_merge_comment is true (The first iteration
# after the mergeable state changed to false)
def add_merge_comment
return if repository.vpt_config.dig('comment_on', 'needs_rebase') == false
return unless eligible_for_merge_comment
add_comment(I18n.t('comment.needs_rebase', author: author))
update(eligible_for_merge_comment: false)
end
##
# Only attach a comment if eligible_for_ci_comment is true (if CI was okay and changed to fail)
# This logic is required to prevent comments for failure -> pending -> failure
# Also it prevents duplicate comments
def add_ci_comment
return if repository.vpt_config.dig('comment_on', 'tests_failed') == false
return unless eligible_for_ci_comment
add_comment(I18n.t('comment.tests_fail', author: author))
update(eligible_for_ci_comment: false)
end
##
# Add a comment with the given text
def add_comment(text)
if ENV['DRY_RUN']
DRY_LOGGER.info("Would add comment to #{title} in #{repository.full_name}")
return
end
# TODO: why can request be nil and what is request
req = begin
request
rescue StandardError
nil
end
Raven.capture_message('Added a comment', extra: { text: text, repo: repository.github_url, title: title, request: req })
Github.client.add_comment(gh_repository_id, number, text)
end
##
# if the PullRequest is mergeable we need to check if the 'merge-conflicts' Label
# is attached. If so, we need to remove it.
#
# If the PullRequest is not yet mergeable we need to attach the 'merge-conflicts'
# Label. Therefore we also need to check if the Label exists on repository level.
#
# The saved_changes variable includes all the stuff that has changed.
# We currently don't care about them
def validate
# Don't run through validation if it's a draft
return if draft
# if the pull request is now closed, dont attach/remove labels/comments
return if closed?
# If we're running in development mode, we try to run read-only and won't modify PRs
return if Rails.env.development?
# check merge status and CI status and do requeue if required
return if validate_mergeable && validate_conclusion
RefreshPullRequestWorker.perform_in(5.minutes.from_now, repository.name, number)
end
private
##
# Queue a job into sidekiq that runs the validate() method above
# validate() might use update() to change attributes which would trigger a new job
# To prevent loops, we filter `saved_changed` of those attributes and won't create new job if those are the only changed attributes
def queue_validation
force = mergeable.nil? || status.nil? || status == 'pending'
case saved_changes.stringify_keys.keys.sort
when %w[eligible_for_merge_comment eligible_for_ci_comment].sort
return unless force
when %w[eligible_for_ci_comment]
return unless force
when %w[eligible_for_merge_comment]
return unless force
end
return if saved_changes.empty? && !force
ValidatePullRequestWorker.perform_async(id)
end
def validate_mergeable
label = Label.needs_rebase
if mergeable == true
ensure_label_is_detached(label)
update(eligible_for_merge_comment: true)
elsif mergeable == false
repository.ensure_label_exists(label)
##
# We only add a comment if we added a label. If the label already is present
# we also already added the comment. So no need for a new one.
add_merge_comment if ensure_label_is_attached(label)
elsif mergeable.nil?
return false
end
true
end
def validate_conclusion
label = Label.tests_fail
if status != 'completed'
Raven.capture_message('pending PR status', extra: { state: state, status: status, repo: repository.github_url, title: title })
return false
end
case conclusion
when 'failure'
# if CI failed, add a label to PR
add_ci_comment if ensure_label_is_attached(label)
when 'success'
ensure_label_is_detached(label)
update(eligible_for_ci_comment: true)
when nil
# it's not really clear if the status is ever nil. if so, we should log it to decide if we need to act here
Raven.capture_message('nil PR status', extra: { state: state, status: status, repo: repository.github_url, title: title })
else
Raven.capture_message('Unknown PR state /o\\',
extra: { state: state, status: status, conclusion: conclusion, repo: repository.github_url, title: title })
end
true
end
end