class Dependabot::PullRequestCreator::Github
rubocop:disable Metrics/ClassLength
def add_assignees_to_pull_request(pull_request)
def add_assignees_to_pull_request(pull_request) T.unsafe(github_client_for_source).add_assignees( source.repo, pull_request.number, assignees ) rescue Octokit::NotFound # This can happen if a passed assignee login is now an org account nil rescue Octokit::UnprocessableEntity => e # This can happen if an invalid assignee was passed raise unless e.message.include?("Could not add assignees") end
def add_milestone_to_pull_request(pull_request)
def add_milestone_to_pull_request(pull_request) T.unsafe(github_client_for_source).update_issue( source.repo, pull_request.number, milestone: milestone ) rescue Octokit::UnprocessableEntity => e raise unless e.message.include?("code: invalid") end
def add_reviewers_to_pull_request(pull_request)
def add_reviewers_to_pull_request(pull_request) reviewers_hash = T.must(reviewers).keys.to_h { |k| [k.to_sym, T.must(reviewers)[k]] } T.unsafe(github_client_for_source).request_pull_request_review( source.repo, pull_request.number, reviewers: reviewers_hash[:reviewers] || [], team_reviewers: reviewers_hash[:team_reviewers] || [] ) rescue Octokit::UnprocessableEntity => e # Special case GitHub bug for team reviewers return if e.message.include?("Could not resolve to a node") if invalid_reviewer?(e.message) comment_with_invalid_reviewer(pull_request, e.message) return end raise end
def annotate_pull_request(pull_request)
def annotate_pull_request(pull_request) labeler.label_pull_request(pull_request.number) add_reviewers_to_pull_request(pull_request) if reviewers&.any? add_assignees_to_pull_request(pull_request) if assignees&.any? add_milestone_to_pull_request(pull_request) if milestone end
def base_commit_is_up_to_date?
def base_commit_is_up_to_date? head_commit == base_commit end
def branch_exists?(name)
def branch_exists?(name) Dependabot.logger.info( "Checking if branch #{name} already exists." ) git_metadata_fetcher.ref_names.include?(name) rescue Dependabot::GitDependenciesNotReachable => e raise T.must(e.cause) if e.cause&.message&.include?("is disabled") raise T.must(e.cause) if e.cause.is_a?(Octokit::Unauthorized) raise(RepoNotFound, source.url) unless repo_exists? retrying ||= T.let(false, T::Boolean) msg = "Unexpected git error!\n\n#{e.cause&.class}: #{e.cause&.message}" raise msg if retrying retrying = true retry end
def comment_with_invalid_reviewer(pull_request, message)
def comment_with_invalid_reviewer(pull_request, message) reviewers_hash = T.must(reviewers).keys.to_h { |k| [k.to_sym, T.must(reviewers)[k]] } reviewers = [] reviewers += reviewers_hash[:reviewers] || [] reviewers += (reviewers_hash[:team_reviewers] || []) .map { |rv| "#{source.repo.split('/').first}/#{rv}" } reviewers_string = if reviewers.one? "`@#{reviewers.first}`" else names = reviewers.map { |rv| "`@#{rv}`" } "#{T.must(names[0..-2]).join(', ')} and #{names[-1]}" end msg = "Dependabot tried to add #{reviewers_string} as " msg += reviewers.count > 1 ? "reviewers" : "a reviewer" msg += " to this PR, but received the following error from GitHub:\n\n" \ "```\n" \ "#{message}\n" \ "```" T.unsafe(github_client_for_source).add_comment( source.repo, pull_request.number, msg ) end
def commit_options(tree)
def commit_options(tree) options = author_details&.any? ? { author: author_details } : {} if options[:author]&.any? && signature_key options[:author][:date] = Time.now.utc.iso8601 options[:signature] = commit_signature(tree, options[:author]) end options end
def commit_signature(tree, author_details_with_date)
def commit_signature(tree, author_details_with_date) CommitSigner.new( author_details: author_details_with_date, commit_message: commit_message, tree_sha: tree.sha, parent_sha: base_commit, signature_key: T.must(signature_key) ).signature end
def create
def create Dependabot.logger.info( "Initiating Github pull request." ) if branch_exists?(branch_name) && no_pull_request_exists? Dependabot.logger.info( "Existing branch \"#{branch_name}\" found. Pull request not created." ) raise BranchAlreadyExists, "Duplicate branch #{branch_name} already exists" end if branch_exists?(branch_name) && open_pull_request_exists? raise UnmergedPRExists, "PR ##{open_pull_requests.first.number} already exists" end if require_up_to_date_base? && !base_commit_is_up_to_date? raise BaseCommitNotUpToDate, "HEAD #{head_commit} does not match base #{base_commit}" end create_annotated_pull_request rescue AnnotationError, Octokit::Error => e handle_error(e) end
def create_annotated_pull_request
def create_annotated_pull_request commit = create_commit branch = create_or_update_branch(commit) raise UnexpectedError, "Branch not created" unless branch pull_request = create_pull_request raise UnexpectedError, "PR not created" unless pull_request begin annotate_pull_request(pull_request) rescue StandardError => e raise AnnotationError.new(e, pull_request) end pull_request end
def create_branch(commit)
def create_branch(commit) ref = "refs/heads/#{branch_name}" begin branch = T.unsafe(github_client_for_source).create_ref(source.repo, ref, commit.sha) @branch_name = ref.gsub(%r{^refs/heads/}, "") branch rescue Octokit::UnprocessableEntity => e raise if e.message.match?(/Reference already exists/i) retrying_branch_creation ||= T.let(false, T::Boolean) raise if retrying_branch_creation retrying_branch_creation = true # Branch creation will fail if a branch called `dependabot` already # exists, since git won't be able to create a dir with the same name ref = "refs/heads/#{T.must(SecureRandom.hex[0..3]) + branch_name}" retry end end
def create_commit
def create_commit tree = create_tree begin T.unsafe(github_client_for_source).create_commit( source.repo, commit_message, tree.sha, base_commit, commit_options(tree) ) rescue Octokit::UnprocessableEntity => e raise unless e.message == "Tree SHA does not exist" # Sometimes a race condition on GitHub's side means we get an error # here. No harm in retrying if we do. @commit_creation ||= T.let(0, T.nilable(Integer)) raise_or_increment_retry_counter(counter: @commit_creation, limit: 3) sleep(rand(1..1.99)) retry end rescue Octokit::UnprocessableEntity => e raise unless e.message == "Tree SHA does not exist" @tree_creation ||= T.let(0, T.nilable(Integer)) raise_or_increment_retry_counter(counter: @tree_creation, limit: 1) sleep(rand(1..1.99)) retry end
def create_or_update_branch(commit)
def create_or_update_branch(commit) if branch_exists?(branch_name) update_branch(commit) else create_branch(commit) end rescue Octokit::UnprocessableEntity => e raise unless e.message.include?("Reference update failed //") # A race condition may cause GitHub to fail here, in which case we retry retry_count ||= 0 retry_count += 1 raise if retry_count > 10 sleep(rand(1..1.99)) retry end
def create_pull_request
def create_pull_request T.unsafe(github_client_for_source).create_pull_request( source.repo, target_branch, branch_name, pr_name, pr_description, headers: custom_headers || {} ) rescue Octokit::UnprocessableEntity # Sometimes PR creation fails with no details (presumably because the # details are internal). It doesn't hurt to retry in these cases, in # case the cause is a race. retrying_pr_creation ||= T.let(false, T::Boolean) raise if retrying_pr_creation retrying_pr_creation = true retry end
def create_tree
def create_tree file_trees = files.map do |file| if file.type == "submodule" { path: file.path.sub(%r{^/}, ""), mode: Dependabot::DependencyFile::Mode::SUBMODULE, type: "commit", sha: file.content } else content = if file.operation == Dependabot::DependencyFile::Operation::DELETE { sha: nil } elsif file.binary? sha = T.unsafe(github_client_for_source).create_blob( source.repo, file.content, "base64" ) { sha: sha } else { content: file.content } end { path: file.realpath, mode: file.mode || Dependabot::DependencyFile::Mode::FILE, type: "blob" }.merge(content) end end T.unsafe(github_client_for_source).create_tree( source.repo, file_trees, base_tree: base_commit ) end
def default_branch
def default_branch @default_branch ||= T.let( T.unsafe(github_client_for_source).repo(source.repo).default_branch, T.nilable(String) ) end
def git_metadata_fetcher
def git_metadata_fetcher @git_metadata_fetcher ||= T.let( GitMetadataFetcher.new( url: source.url, credentials: credentials ), T.nilable(Dependabot::GitMetadataFetcher) ) end
def github_client_for_source
def github_client_for_source @github_client_for_source ||= T.let( Dependabot::Clients::GithubWithRetries.for_source( source: source, credentials: credentials ), T.nilable(Dependabot::Clients::GithubWithRetries) ) end
def handle_error(err)
def handle_error(err) cause = case err when AnnotationError err.cause else err end case cause when Octokit::Forbidden if err.message.include?("disabled") raise_custom_error err, RepoDisabled, err.message elsif err.message.include?("archived") raise_custom_error err, RepoArchived, err.message end raise err when Octokit::NotFound raise err if repo_exists? raise_custom_error err, RepoNotFound, err.message when Octokit::UnprocessableEntity raise_custom_error err, NoHistoryInCommon, err.message if err.message.include?("no history in common") raise err else raise err end end
def head_commit
def head_commit @head_commit ||= T.let( git_metadata_fetcher.head_commit_for_ref(target_branch), T.nilable(String) ) end
def initialize(
def initialize( source:, branch_name:, base_commit:, credentials:, files:, commit_message:, pr_description:, pr_name:, author_details:, signature_key:, custom_headers:, labeler:, reviewers:, assignees:, milestone:, require_up_to_date_base: ) @source = source @branch_name = branch_name @base_commit = base_commit @credentials = credentials @files = files @commit_message = commit_message @pr_description = pr_description @pr_name = pr_name @author_details = author_details @signature_key = signature_key @custom_headers = custom_headers @labeler = labeler @reviewers = reviewers @assignees = assignees @milestone = milestone @require_up_to_date_base = require_up_to_date_base end
def invalid_reviewer?(message)
def invalid_reviewer?(message) return true if message.include?("Could not resolve to a node") return true if message.include?("not a collaborator") return true if message.include?("Could not add requested reviewers") return true if message.include?("Review cannot be requested from pull request author") false end
def no_pull_request_exists?
def no_pull_request_exists? pull_requests_for_branch.none? end
def open_pull_request_exists?
def open_pull_request_exists? open_pull_requests.any? end
def open_pull_requests
def open_pull_requests pull_requests_for_branch.reject(&:closed).reject(&:merged) end
def pull_requests_for_branch
def pull_requests_for_branch @pull_requests_for_branch ||= T.let( begin T.unsafe(github_client_for_source).pull_requests( source.repo, head: "#{source.repo.split('/').first}:#{branch_name}", state: "all" ) rescue Octokit::InternalServerError # A GitHub bug sometimes means adding `state: all` causes problems. # In that case, fall back to making two separate requests. open_prs = T.unsafe(github_client_for_source).pull_requests( source.repo, head: "#{source.repo.split('/').first}:#{branch_name}", state: "open" ) closed_prs = T.unsafe(github_client_for_source).pull_requests( source.repo, head: "#{source.repo.split('/').first}:#{branch_name}", state: "closed" ) [*open_prs, *closed_prs] end, T.nilable(T::Array[T.untyped]) ) end
def raise_custom_error(base_err, type, message)
def raise_custom_error(base_err, type, message) case base_err when AnnotationError raise AnnotationError.new( type.new(message), base_err.pull_request ) else raise type, message end end
def raise_or_increment_retry_counter(counter:, limit:)
def raise_or_increment_retry_counter(counter:, limit:) counter ||= 0 counter += 1 raise if counter > limit end
def repo_exists?
def repo_exists? T.unsafe(github_client_for_source).repo(source.repo) true rescue Octokit::NotFound false end
def require_up_to_date_base?
def require_up_to_date_base? @require_up_to_date_base end
def target_branch
def target_branch source.branch || default_branch end
def update_branch(commit)
def update_branch(commit) T.unsafe(github_client_for_source).update_ref( source.repo, "heads/#{branch_name}", commit.sha, true ) end