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