summaryrefslogtreecommitdiff
path: root/lib/gitlab/import_export/relation_factory.rb
blob: efd3f550a2251e757f153d1aa149ec3e2ada6b89 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# frozen_string_literal: true

module Gitlab
  module ImportExport
    class RelationFactory
      OVERRIDES = { snippets: :project_snippets,
                    ci_pipelines: 'Ci::Pipeline',
                    pipelines: 'Ci::Pipeline',
                    stages: 'Ci::Stage',
                    statuses: 'commit_status',
                    triggers: 'Ci::Trigger',
                    pipeline_schedules: 'Ci::PipelineSchedule',
                    builds: 'Ci::Build',
                    runners: 'Ci::Runner',
                    hooks: 'ProjectHook',
                    merge_access_levels: 'ProtectedBranch::MergeAccessLevel',
                    push_access_levels: 'ProtectedBranch::PushAccessLevel',
                    create_access_levels: 'ProtectedTag::CreateAccessLevel',
                    labels: :project_labels,
                    priorities: :label_priorities,
                    auto_devops: :project_auto_devops,
                    label: :project_label,
                    custom_attributes: 'ProjectCustomAttribute',
                    project_badges: 'Badge',
                    metrics: 'MergeRequest::Metrics',
                    ci_cd_settings: 'ProjectCiCdSetting',
                    error_tracking_setting: 'ErrorTracking::ProjectErrorTrackingSetting',
                    links: 'Releases::Link',
                    metrics_setting: 'ProjectMetricsSetting' }.freeze

      USER_REFERENCES = %w[author_id assignee_id updated_by_id merged_by_id latest_closed_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id closed_by_id].freeze

      PROJECT_REFERENCES = %w[project_id source_project_id target_project_id].freeze

      BUILD_MODELS = %w[Ci::Build commit_status].freeze

      IMPORTED_OBJECT_MAX_RETRIES = 5.freeze

      EXISTING_OBJECT_CHECK = %i[milestone milestones label labels project_label project_labels group_label group_labels project_feature].freeze

      TOKEN_RESET_MODELS = %w[Project Namespace Ci::Trigger Ci::Build Ci::Runner ProjectHook].freeze

      def self.create(*args)
        new(*args).create
      end

      def self.relation_class(relation_name)
        # There are scenarios where the model is pluralized (e.g.
        # MergeRequest::Metrics), and we don't want to force it to singular
        # with #classify.
        relation_name.to_s.classify.constantize
      rescue NameError
        relation_name.to_s.constantize
      end

      def initialize(relation_sym:, relation_hash:, members_mapper:, user:, project:, excluded_keys: [])
        @relation_name = self.class.overrides[relation_sym] || relation_sym
        @relation_hash = relation_hash.except('noteable_id')
        @members_mapper = members_mapper
        @user = user
        @project = project
        @imported_object_retries = 0

        @relation_hash['project_id'] = @project.id

        # Remove excluded keys from relation_hash
        # We don't do this in the parsed_relation_hash because of the 'transformed attributes'
        # For example, MergeRequestDiffFiles exports its diff attribute as utf8_diff. Then,
        # in the create method that attribute is renamed to diff. And because diff is an excluded key,
        # if we clean the excluded keys in the parsed_relation_hash, it will be removed
        # from the object attributes and the export will fail.
        @relation_hash.except!(*excluded_keys)
      end

      # Creates an object from an actual model with name "relation_sym" with params from
      # the relation_hash, updating references with new object IDs, mapping users using
      # the "members_mapper" object, also updating notes if required.
      def create
        return if unknown_service?

        setup_models

        generate_imported_object
      end

      def self.overrides
        OVERRIDES
      end

      private

      def setup_models
        case @relation_name
        when :merge_request_diff_files       then setup_diff
        when :notes                          then setup_note
        end

        update_user_references
        update_project_references
        update_group_references
        remove_duplicate_assignees

        setup_pipeline if @relation_name == 'Ci::Pipeline'

        reset_tokens!
        remove_encrypted_attributes!
      end

      def update_user_references
        USER_REFERENCES.each do |reference|
          if @relation_hash[reference]
            @relation_hash[reference] = @members_mapper.map[@relation_hash[reference]]
          end
        end
      end

      def remove_duplicate_assignees
        return unless @relation_hash['issue_assignees']

        # When an assignee did not exist in the members mapper, the importer is
        # assigned. We only need to assign each user once.
        @relation_hash['issue_assignees'].uniq!(&:user_id)
      end

      def setup_note
        set_note_author
        # attachment is deprecated and note uploads are handled by Markdown uploader
        @relation_hash['attachment'] = nil
      end

      # Sets the author for a note. If the user importing the project
      # has admin access, an actual mapping with new project members
      # will be used. Otherwise, a note stating the original author name
      # is left.
      def set_note_author
        old_author_id = @relation_hash['author_id']
        author = @relation_hash.delete('author')

        update_note_for_missing_author(author['name']) unless has_author?(old_author_id)
      end

      def has_author?(old_author_id)
        admin_user? && @members_mapper.include?(old_author_id)
      end

      def missing_author_note(updated_at, author_name)
        timestamp = updated_at.split('.').first
        "\n\n *By #{author_name} on #{timestamp} (imported from GitLab project)*"
      end

      def generate_imported_object
        if BUILD_MODELS.include?(@relation_name)
          @relation_hash.delete('trace') # old export files have trace
          @relation_hash.delete('token')
          @relation_hash.delete('commands')
          @relation_hash.delete('artifacts_file_store')
          @relation_hash.delete('artifacts_metadata_store')
          @relation_hash.delete('artifacts_size')

          imported_object
        elsif @relation_name == :merge_requests
          MergeRequestParser.new(@project, @relation_hash.delete('diff_head_sha'), imported_object, @relation_hash).parse!
        else
          imported_object
        end
      end

      def update_project_references
        # If source and target are the same, populate them with the new project ID.
        if @relation_hash['source_project_id']
          @relation_hash['source_project_id'] = same_source_and_target? ? @relation_hash['project_id'] : MergeRequestParser::FORKED_PROJECT_ID
        end

        @relation_hash['target_project_id'] = @relation_hash['project_id'] if @relation_hash['target_project_id']
      end

      def same_source_and_target?
        @relation_hash['target_project_id'] && @relation_hash['target_project_id'] == @relation_hash['source_project_id']
      end

      def update_group_references
        return unless EXISTING_OBJECT_CHECK.include?(@relation_name)
        return unless @relation_hash['group_id']

        @relation_hash['group_id'] = @project.group&.id
      end

      def reset_tokens!
        return unless Gitlab::ImportExport.reset_tokens? && TOKEN_RESET_MODELS.include?(@relation_name.to_s)

        # If we import/export a project to the same instance, tokens will have to be reset.
        # We also have to reset them to avoid issues when the gitlab secrets file cannot be copied across.
        relation_class.attribute_names.select { |name| name.include?('token') }.each do |token|
          @relation_hash[token] = nil
        end
      end

      def remove_encrypted_attributes!
        return unless relation_class.respond_to?(:encrypted_attributes) && relation_class.encrypted_attributes.any?

        relation_class.encrypted_attributes.each_key do |key|
          @relation_hash[key.to_s] = nil
        end
      end

      def relation_class
        @relation_class ||= self.class.relation_class(@relation_name)
      end

      def imported_object
        if existing_or_new_object.respond_to?(:importing)
          existing_or_new_object.importing = true
        end

        existing_or_new_object
      rescue ActiveRecord::RecordNotUnique
        # as the operation is not atomic, retry in the unlikely scenario an INSERT is
        # performed on the same object between the SELECT and the INSERT
        @imported_object_retries += 1
        retry if @imported_object_retries < IMPORTED_OBJECT_MAX_RETRIES
      end

      def update_note_for_missing_author(author_name)
        @relation_hash['note'] = '*Blank note*' if @relation_hash['note'].blank?
        @relation_hash['note'] = "#{@relation_hash['note']}#{missing_author_note(@relation_hash['updated_at'], author_name)}"
      end

      def admin_user?
        @user.admin?
      end

      def parsed_relation_hash
        @parsed_relation_hash ||= Gitlab::ImportExport::AttributeCleaner.clean(relation_hash: @relation_hash,
                                                                               relation_class: relation_class)
      end

      def setup_diff
        @relation_hash['diff'] = @relation_hash.delete('utf8_diff')
      end

      def setup_pipeline
        @relation_hash.fetch('stages').each do |stage|
          stage.statuses.each do |status|
            status.pipeline = imported_object
          end
        end
      end

      def existing_or_new_object
        # Only find existing records to avoid mapping tables such as milestones
        # Otherwise always create the record, skipping the extra SELECT clause.
        @existing_or_new_object ||= begin
          if EXISTING_OBJECT_CHECK.include?(@relation_name)
            attribute_hash = attribute_hash_for(['events'])

            existing_object.assign_attributes(attribute_hash) if attribute_hash.any?

            existing_object
          else
            relation_class.new(parsed_relation_hash)
          end
        end
      end

      def attribute_hash_for(attributes)
        attributes.inject({}) do |hash, value|
          hash[value] = parsed_relation_hash.delete(value) if parsed_relation_hash[value]
          hash
        end
      end

      def existing_object
        @existing_object ||= find_or_create_object!
      end

      def unknown_service?
        @relation_name == :services && parsed_relation_hash['type'] &&
          !Object.const_defined?(parsed_relation_hash['type'])
      end

      def find_or_create_object!
        return relation_class.find_or_create_by(project_id: @project.id) if @relation_name == :project_feature

        # Can't use IDs as validation exists calling `group` or `project` attributes
        finder_hash = parsed_relation_hash.tap do |hash|
          hash['group'] = @project.group if relation_class.attribute_method?('group_id')
          hash['project'] = @project
          hash.delete('project_id')
        end

        GroupProjectObjectBuilder.build(relation_class, finder_hash)
      end
    end
  end
end