# typed: strict
# frozen_string_literal: true
require "octokit"
require "sorbet-runtime"
require "dependabot/clients/github_with_retries"
require "dependabot/pull_request_creator/commit_signer"
require "dependabot/pull_request_updater"
module Dependabot
class PullRequestUpdater
class Github
extend T::Sig
sig { returns(Dependabot::Source) }
attr_reader :source
sig { returns(T::Array[Dependabot::DependencyFile]) }
attr_reader :files
sig { returns(String) }
attr_reader :base_commit
sig { returns(String) }
attr_reader :old_commit
sig { returns(T::Array[Dependabot::Credential]) }
attr_reader :credentials
sig { returns(Integer) }
attr_reader :pull_request_number
sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
attr_reader :author_details
sig { returns(T.nilable(String)) }
attr_reader :signature_key
sig do
params(
source: Dependabot::Source,
base_commit: String,
old_commit: String,
files: T::Array[Dependabot::DependencyFile],
credentials: T::Array[Dependabot::Credential],
pull_request_number: Integer,
author_details: T.nilable(T::Hash[Symbol, T.untyped]),
signature_key: T.nilable(String)
)
.void
end
def initialize(source:, base_commit:, old_commit:, files:,
credentials:, pull_request_number:,
author_details: nil, signature_key: nil)
@source = source
@base_commit = base_commit
@old_commit = old_commit
@files = files
@credentials = credentials
@pull_request_number = pull_request_number
@author_details = author_details
@signature_key = signature_key
end
sig { returns(T.nilable(Sawyer::Resource)) }
def update
return unless pull_request_exists?
return unless branch_exists?(pull_request.head.ref)
commit = create_commit
branch = update_branch(commit)
update_pull_request_target_branch
branch
end
private
sig { void }
def update_pull_request_target_branch
target_branch = source.branch || pull_request.base.repo.default_branch
return if target_branch == pull_request.base.ref
T.unsafe(github_client_for_source).update_pull_request(
source.repo,
pull_request_number,
base: target_branch
)
rescue Octokit::UnprocessableEntity => e
handle_pr_update_error(e)
end
sig { params(error: Octokit::Error).void }
def handle_pr_update_error(error)
# Return quietly if the PR has been closed
return if error.message.match?(/closed pull request/i)
# Ignore cases where the target branch has been deleted
return if error.message.include?("field: base") &&
source.branch &&
!branch_exists?(T.must(source.branch))
raise error
end
sig { returns(Dependabot::Clients::GithubWithRetries) }
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
sig { returns(T::Boolean) }
def pull_request_exists?
pull_request
true
rescue Octokit::NotFound
false
end
sig { returns(T.untyped) }
def pull_request
@pull_request ||=
T.let(
T.unsafe(github_client_for_source).pull_request(
source.repo,
pull_request_number
),
T.untyped
)
end
sig { params(name: String).returns(T::Boolean) }
def branch_exists?(name)
T.unsafe(github_client_for_source).branch(source.repo, name)
true
rescue Octokit::NotFound
false
end
sig { returns(T.untyped) }
def create_commit
tree = create_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
begin
T.unsafe(github_client_for_source).create_commit(
source.repo,
commit_message,
tree.sha,
base_commit,
options
)
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.
retry_count ||= 0
retry_count += 1
raise if retry_count > 10
sleep(rand(1..1.99))
retry
end
end
sig { returns(T.untyped) }
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: 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
BRANCH_PROTECTION_ERROR_MESSAGES = T.let(
[
/protected branch/i,
/not authorized to push/i,
/must not contain merge commits/i,
/required status check/i,
/cannot force-push to this branch/i,
/pull request for this branch has been added to a merge queue/i,
# Unverified commits can be present when PR contains commits from other authors
/commits must have verified signatures/i,
/changes must be made through a pull request/i
].freeze,
T::Array[Regexp]
)
sig { params(commit: T.untyped).returns(T.untyped) }
def update_branch(commit)
T.unsafe(github_client_for_source).update_ref(
source.repo,
"heads/" + pull_request.head.ref,
commit.sha,
true
)
rescue Octokit::UnprocessableEntity => e
# Return quietly if the branch has been deleted or merged
return nil if e.message.match?(/Reference does not exist/i)
return nil if e.message.match?(/Reference cannot be updated/i)
raise BranchProtected, e.message if BRANCH_PROTECTION_ERROR_MESSAGES.any? { |msg| e.message.match?(msg) }
raise
end
sig { returns(String) }
def commit_message
fallback_message =
"#{pull_request.title}" \
"\n\n" \
"Dependabot couldn't find the original pull request head commit, " \
"#{old_commit}."
# Take the commit message from the old commit. If the old commit can't
# be found, use the PR title as the commit message.
commit_being_updated&.message || fallback_message
end
sig { returns(T.untyped) }
def commit_being_updated
return @commit_being_updated if defined?(@commit_being_updated)
@commit_being_updated =
T.let(
if pull_request.commits == 1
T.unsafe(github_client_for_source)
.git_commit(source.repo, pull_request.head.sha)
else
commits =
T.unsafe(github_client_for_source)
.pull_request_commits(source.repo, pull_request_number)
commit = commits.find { |c| c.sha == old_commit }
commit&.commit
end,
T.untyped
)
end
sig { params(tree: T.untyped, author_details_with_date: T::Hash[Symbol, T.untyped]).returns(String) }
def commit_signature(tree, author_details_with_date)
PullRequestCreator::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
end
end
end