common/lib/dependabot/clients/gitlab_with_retries.rb
# typed: strict
# frozen_string_literal: true

require "gitlab"
require "sorbet-runtime"

require "dependabot/credential"

module Dependabot
  module Clients
    class GitlabWithRetries
      extend T::Sig

      RETRYABLE_ERRORS = T.let(
        [Gitlab::Error::BadGateway].freeze,
        T::Array[T.class_of(Gitlab::Error::ResponseError)]
      )

      class ContentEncoding
        BASE64 = "base64"
        TEXT = "text"
      end

      #######################
      # Constructor methods #
      #######################

      sig do
        params(
          source: Dependabot::Source,
          credentials: T::Array[Dependabot::Credential]
        )
          .returns(Dependabot::Clients::GitlabWithRetries)
      end
      def self.for_source(source:, credentials:)
        access_token =
          credentials
          .select { |cred| cred["type"] == "git_source" }
          .select { |cred| cred["password"] }
          .find { |cred| cred["host"] == source.hostname }
          &.fetch("password")

        new(
          endpoint: source.api_endpoint,
          private_token: access_token || ""
        )
      end

      sig { params(credentials: T::Array[Dependabot::Credential]).returns(Dependabot::Clients::GitlabWithRetries) }
      def self.for_gitlab_dot_com(credentials:)
        access_token =
          credentials
          .select { |cred| cred["type"] == "git_source" }
          .select { |cred| cred["password"] }
          .find { |cred| cred["host"] == "gitlab.com" }
          &.fetch("password")

        new(
          endpoint: "https://gitlab.com/api/v4",
          private_token: access_token || ""
        )
      end

      #################
      # VCS Interface #
      #################

      sig { params(repo: String, branch: String).returns(String) }
      def fetch_commit(repo, branch)
        T.unsafe(self).branch(repo, branch).commit.id
      end

      sig { params(repo: String).returns(String) }
      def fetch_default_branch(repo)
        T.unsafe(self).project(repo).default_branch
      end

      ############
      # Proxying #
      ############

      sig { params(max_retries: T.nilable(Integer), args: T.untyped).void }
      def initialize(max_retries: 3, **args)
        @max_retries = T.let(max_retries || 3, Integer)
        @client = T.let(::Gitlab::Client.new(args), ::Gitlab::Client)
      end

      # Create commit in gitlab repo with correctly mapped file actions
      #
      # @param [String] repo
      # @param [String] branch_name
      # @param [String] commit_message
      # @param [Array<Dependabot::DependencyFile>] files
      # @param [Hash] options
      # @return [Gitlab::ObjectifiedHash]
      sig do
        params(
          repo: String,
          branch_name: String,
          commit_message: String,
          files: T::Array[Dependabot::DependencyFile],
          options: T.untyped
        )
          .returns(Gitlab::ObjectifiedHash)
      end
      def create_commit(repo, branch_name, commit_message, files, **options)
        @client.create_commit(
          repo,
          branch_name,
          commit_message,
          file_actions(files),
          options
        )
      end

      # TODO: Create all the methods that are called on the client
      sig do
        params(
          method_name: T.any(Symbol, String),
          args: T.untyped,
          block: T.nilable(T.proc.returns(T.untyped))
        )
          .returns(T.untyped)
      end
      def method_missing(method_name, *args, &block)
        retry_connection_failures do
          if @client.respond_to?(method_name)
            mutatable_args = args.map(&:dup)
            T.unsafe(@client).public_send(method_name, *mutatable_args, &block)
          else
            super
          end
        end
      end

      sig do
        params(
          method_name: Symbol,
          include_private: T::Boolean
        )
          .returns(T::Boolean)
      end
      def respond_to_missing?(method_name, include_private = false)
        @client.respond_to?(method_name) || super
      end

      sig do
        type_parameters(:T)
          .params(_blk: T.proc.returns(T.type_parameter(:T)))
          .returns(T.type_parameter(:T))
      end
      def retry_connection_failures(&_blk)
        retry_attempt = 0

        begin
          yield
        rescue *RETRYABLE_ERRORS
          retry_attempt += 1
          retry_attempt <= @max_retries ? retry : raise
        end
      end

      private

      # Array of file actions for a commit
      #
      # @param [Array<Dependabot::DependencyFile>] files
      # @return [Array<Hash>]
      sig { params(files: T::Array[Dependabot::DependencyFile]).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
      def file_actions(files)
        files.map do |file|
          {
            action: file_action(file),
            encoding: file_encoding(file),
            file_path: file.type == "symlink" ? file.symlink_target : file.path,
            content: file.content
          }
        end
      end

      # Single file action
      #
      # @param [Dependabot::DependencyFile] file
      # @return [String]
      sig { params(file: Dependabot::DependencyFile).returns(String) }
      def file_action(file)
        if file.operation == Dependabot::DependencyFile::Operation::DELETE
          "delete"
        elsif file.operation == Dependabot::DependencyFile::Operation::CREATE
          "create"
        else
          "update"
        end
      end

      # Encoding option for gitlab commit operation
      #
      # @param [Dependabot::DependencyFile] file
      # @return [String]
      sig { params(file: Dependabot::DependencyFile).returns(String) }
      def file_encoding(file)
        return ContentEncoding::BASE64 if file.content_encoding == Dependabot::DependencyFile::ContentEncoding::BASE64

        ContentEncoding::TEXT
      end
    end
  end
end
