lib/dependabot/pull_request_creator/gitlab.rb



# typed: strict
# frozen_string_literal: true

require "gitlab"
require "sorbet-runtime"

require "dependabot/clients/gitlab_with_retries"
require "dependabot/pull_request_creator"

module Dependabot
  class PullRequestCreator
    class Gitlab
      extend T::Sig

      sig { returns(Dependabot::Source) }
      attr_reader :source

      sig { returns(String) }
      attr_reader :branch_name

      sig { returns(String) }
      attr_reader :base_commit

      sig { returns(T::Array[Dependabot::Credential]) }
      attr_reader :credentials

      sig { returns(T::Array[Dependabot::DependencyFile]) }
      attr_reader :files

      sig { returns(String) }
      attr_reader :pr_description

      sig { returns(String) }
      attr_reader :pr_name

      sig { returns(String) }
      attr_reader :commit_message

      sig { returns(T.nilable(T::Hash[Symbol, String])) }
      attr_reader :author_details

      sig { returns(Dependabot::PullRequestCreator::Labeler) }
      attr_reader :labeler

      sig { returns(T.nilable(T::Hash[Symbol, T::Array[Integer]])) }
      attr_reader :approvers

      sig { returns(T.nilable(T::Array[Integer])) }
      attr_reader :assignees

      sig { returns(T.nilable(T.any(T::Array[String], Integer))) }
      attr_reader :milestone

      sig { returns(T.nilable(Integer)) }
      attr_reader :target_project_id

      sig do
        params(
          source: Dependabot::Source,
          branch_name: String,
          base_commit: String,
          credentials: T::Array[Dependabot::Credential],
          files: T::Array[Dependabot::DependencyFile],
          commit_message: String,
          pr_description: String,
          pr_name: String,
          author_details: T.nilable(T::Hash[Symbol, String]),
          labeler: Dependabot::PullRequestCreator::Labeler,
          approvers: T.nilable(T::Hash[Symbol, T::Array[Integer]]),
          assignees: T.nilable(T::Array[Integer]),
          milestone: T.nilable(T.any(T::Array[String], Integer)),
          target_project_id: T.nilable(Integer)
        )
          .void
      end
      def initialize(source:, branch_name:, base_commit:, credentials:,
                     files:, commit_message:, pr_description:, pr_name:,
                     author_details:, labeler:, approvers:, assignees:,
                     milestone:, target_project_id:)
        @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
        @labeler           = labeler
        @approvers         = approvers
        @assignees         = assignees
        @milestone         = milestone
        @target_project_id = target_project_id
      end

      sig { returns(T.nilable(::Gitlab::ObjectifiedHash)) }
      def create
        return if branch_exists? && merge_request_exists?

        if branch_exists?
          create_commit unless commit_exists?
        else
          create_branch
          create_commit
        end

        labeler.create_default_labels_if_required
        merge_request = create_merge_request
        return unless merge_request

        annotate_merge_request(merge_request)

        merge_request
      end

      private

      sig { returns(Dependabot::Clients::GitlabWithRetries) }
      def gitlab_client_for_source
        @gitlab_client_for_source ||=
          T.let(
            Dependabot::Clients::GitlabWithRetries.for_source(
              source: source,
              credentials: credentials
            ),
            T.nilable(Dependabot::Clients::GitlabWithRetries)
          )
      end

      sig { returns(T::Boolean) }
      def branch_exists?
        @branch_ref ||=
          T.let(
            T.unsafe(gitlab_client_for_source).branch(source.repo, branch_name),
            T.nilable(::Gitlab::ObjectifiedHash)
          )
        true
      rescue ::Gitlab::Error::NotFound
        false
      end

      sig { returns(T::Boolean) }
      def commit_exists?
        @commits ||=
          T.let(
            T.unsafe(gitlab_client_for_source).commits(source.repo, ref_name: branch_name),
            T.nilable(::Gitlab::PaginatedResponse)
          )
        @commits.first.message == commit_message
      end

      sig { returns(T::Boolean) }
      def merge_request_exists?
        T.unsafe(gitlab_client_for_source).merge_requests(
          target_project_id || source.repo,
          source_branch: branch_name,
          target_branch: source.branch || default_branch,
          state: "all"
        ).any?
      end

      sig { returns(::Gitlab::ObjectifiedHash) }
      def create_branch
        T.unsafe(gitlab_client_for_source).create_branch(
          source.repo,
          branch_name,
          base_commit
        )
      end

      sig { returns(::Gitlab::ObjectifiedHash) }
      def create_commit
        return create_submodule_update_commit if files.count == 1 && T.must(files.first).type == "submodule"

        options = {}
        options[:author_email] = author_details&.fetch(:email) if author_details&.key?(:email)
        options[:author_name] = author_details&.fetch(:name) if author_details&.key?(:name)

        gitlab_client_for_source.create_commit(
          source.repo,
          branch_name,
          commit_message,
          files,
          **options
        )
      end

      sig { returns(::Gitlab::ObjectifiedHash) }
      def create_submodule_update_commit
        file = T.must(files.first)

        T.unsafe(gitlab_client_for_source).edit_submodule(
          source.repo,
          file.path.gsub(%r{^/}, ""),
          branch: branch_name,
          commit_sha: file.content,
          commit_message: commit_message
        )
      end

      sig { returns(T.nilable(::Gitlab::ObjectifiedHash)) }
      def create_merge_request
        T.unsafe(gitlab_client_for_source).create_merge_request(
          source.repo,
          pr_name,
          source_branch: branch_name,
          target_branch: source.branch || default_branch,
          description: pr_description,
          remove_source_branch: true,
          assignee_ids: assignees,
          labels: labeler.labels_for_pr.join(","),
          milestone_id: milestone,
          target_project_id: target_project_id,
          reviewer_ids: approvers_hash[:reviewers]
        )
      end

      sig { params(merge_request: ::Gitlab::ObjectifiedHash).returns(T.nilable(::Gitlab::ObjectifiedHash)) }
      def annotate_merge_request(merge_request)
        add_approvers_to_merge_request(merge_request)
      end

      sig { params(merge_request: ::Gitlab::ObjectifiedHash).returns(T.nilable(::Gitlab::ObjectifiedHash)) }
      def add_approvers_to_merge_request(merge_request)
        return unless approvers_hash[:approvers] || approvers_hash[:group_approvers]

        T.unsafe(gitlab_client_for_source).create_merge_request_level_rule(
          target_project_id || source.repo,
          T.unsafe(merge_request).iid,
          name: "dependency-updates",
          approvals_required: 1,
          user_ids: approvers_hash[:approvers],
          group_ids: approvers_hash[:group_approvers]
        )
      end

      sig { returns(T::Hash[Symbol, T::Array[Integer]]) }
      def approvers_hash
        @approvers_hash ||= T.let(
          approvers || {},
          T.nilable(T::Hash[Symbol, T::Array[Integer]])
        )
      end

      sig { returns(String) }
      def default_branch
        @default_branch ||=
          T.let(
            T.unsafe(gitlab_client_for_source).project(source.repo).default_branch,
            T.nilable(String)
          )
      end
    end
  end
end