From 82b0d0b5dc2fcf2585b05bf58a072455eb6e103e Mon Sep 17 00:00:00 2001
From: James Nutt <jnutt@gitlab.com>
Date: Fri, 22 Dec 2023 00:14:48 +0000
Subject: [PATCH] Save external_identifiers on ImportFailure for file import
 relations

The `FileImport` model has a field for tracking the external identifier
associated with a failed record import. This field is used for other
importers such as GitHub and BitBucket, but not file-based import.

This MR tracks the IID associated with issues, merge requests,
milestones and CI pipelines that fail to import. It also lays the
groundwork for tracking identifiers for other relations, should the
need arise and we can identify a unique key.

Changelog: changed
---
 .../layout/space_in_lambda_literal.yml        |  1 -
 .../style/explicit_block_argument.yml         |  1 -
 .../style/keyword_parameters_order.yml        |  1 -
 .../group/relation_tree_restorer.rb           |  7 +++-
 .../import_export/import_failure_service.rb   |  8 ++--
 .../tree/project.json                         | 11 ++++++
 .../tree/project/issues.ndjson                |  2 +
 .../tree/project/milestones.ndjson            |  2 +
 .../group/relation_tree_restorer_spec.rb      | 37 ++++++++++++++++---
 .../import_failure_service_spec.rb            |  3 +-
 .../project/tree_restorer_spec.rb             | 18 ++++++++-
 .../import_failure_service_shared_examples.rb |  1 +
 12 files changed, 78 insertions(+), 14 deletions(-)
 create mode 100644 spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project.json
 create mode 100644 spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project/issues.ndjson
 create mode 100644 spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project/milestones.ndjson

diff --git a/.rubocop_todo/layout/space_in_lambda_literal.yml b/.rubocop_todo/layout/space_in_lambda_literal.yml
index 7e3af689b5f01..e0113d2628d93 100644
--- a/.rubocop_todo/layout/space_in_lambda_literal.yml
+++ b/.rubocop_todo/layout/space_in_lambda_literal.yml
@@ -354,7 +354,6 @@ Layout/SpaceInLambdaLiteral:
     - 'lib/gitlab/event_store.rb'
     - 'lib/gitlab/gl_repository.rb'
     - 'lib/gitlab/health_checks/server.rb'
-    - 'lib/gitlab/import_export/import_failure_service.rb'
     - 'lib/gitlab/merge_requests/message_generator.rb'
     - 'lib/gitlab/metrics/exporter/base_exporter.rb'
     - 'lib/gitlab/visibility_level.rb'
diff --git a/.rubocop_todo/style/explicit_block_argument.yml b/.rubocop_todo/style/explicit_block_argument.yml
index db1c8073df8c8..1e5725a2ef0db 100644
--- a/.rubocop_todo/style/explicit_block_argument.yml
+++ b/.rubocop_todo/style/explicit_block_argument.yml
@@ -49,7 +49,6 @@ Style/ExplicitBlockArgument:
     - 'lib/gitlab/gitaly_client/storage_settings.rb'
     - 'lib/gitlab/github_import/client.rb'
     - 'lib/gitlab/graphql/tracers/application_context_tracer.rb'
-    - 'lib/gitlab/import_export/import_failure_service.rb'
     - 'lib/gitlab/import_export/json/ndjson_writer.rb'
     - 'lib/gitlab/import_export/json/streaming_serializer.rb'
     - 'lib/gitlab/import_export/project/base_task.rb'
diff --git a/.rubocop_todo/style/keyword_parameters_order.yml b/.rubocop_todo/style/keyword_parameters_order.yml
index e4b751a92da36..8f27a2c9f4584 100644
--- a/.rubocop_todo/style/keyword_parameters_order.yml
+++ b/.rubocop_todo/style/keyword_parameters_order.yml
@@ -18,7 +18,6 @@ Style/KeywordParametersOrder:
     - 'lib/gitlab/diff/diff_refs.rb'
     - 'lib/gitlab/email/smime/signer.rb'
     - 'lib/gitlab/exclusive_lease.rb'
-    - 'lib/gitlab/import_export/import_failure_service.rb'
     - 'lib/gitlab/merge_requests/mergeability/results_store.rb'
     - 'lib/gitlab/usage_data_counters/editor_unique_counter.rb'
     - 'lib/microsoft_teams/notifier.rb'
diff --git a/lib/gitlab/import_export/group/relation_tree_restorer.rb b/lib/gitlab/import_export/group/relation_tree_restorer.rb
index 5453792e9043d..ae47c95036ea5 100644
--- a/lib/gitlab/import_export/group/relation_tree_restorer.rb
+++ b/lib/gitlab/import_export/group/relation_tree_restorer.rb
@@ -84,7 +84,8 @@ def process_relation_item!(relation_key, relation_definition, relation_index, da
             source: 'process_relation_item!',
             relation_key: relation_key,
             relation_index: relation_index,
-            exception: e)
+            exception: e,
+            external_identifiers: external_identifiers(data_hash))
         end
 
         def save_relation_object(relation_object, relation_key, relation_definition, relation_index)
@@ -314,6 +315,10 @@ def log_invalid_subrelations(invalid_subrelations, relation_key)
         def importable_column_name
           @column_name ||= @importable.class.reflect_on_association(:import_failures).foreign_key.to_sym
         end
+
+        def external_identifiers(data_hash)
+          { iid: data_hash['iid'] }.compact
+        end
       end
     end
   end
diff --git a/lib/gitlab/import_export/import_failure_service.rb b/lib/gitlab/import_export/import_failure_service.rb
index bf7200726a1fa..f93822a9d955e 100644
--- a/lib/gitlab/import_export/import_failure_service.rb
+++ b/lib/gitlab/import_export/import_failure_service.rb
@@ -13,7 +13,7 @@ def initialize(importable)
       end
 
       def with_retry(action:, relation_key: nil, relation_index: nil)
-        on_retry = -> (exception, retry_count, *_args) do
+        on_retry = ->(exception, retry_count, *_args) do
           log_import_failure(
             source: action,
             relation_key: relation_key,
@@ -27,7 +27,8 @@ def with_retry(action:, relation_key: nil, relation_index: nil)
         end
       end
 
-      def log_import_failure(source:, relation_key: nil, relation_index: nil, exception:, retry_count: 0)
+      def log_import_failure(
+        source:, exception:, relation_key: nil, relation_index: nil, retry_count: 0, external_identifiers: {})
         attributes = {
           relation_index: relation_index,
           source: source,
@@ -45,7 +46,8 @@ def log_import_failure(source:, relation_key: nil, relation_index: nil, exceptio
             exception_class: exception.class.to_s,
             exception_message: exception.message.truncate(255),
             correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id,
-            relation_key: relation_key
+            relation_key: relation_key,
+            external_identifiers: external_identifiers
           )
         )
       end
diff --git a/spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project.json b/spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project.json
new file mode 100644
index 0000000000000..a522590d037fa
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project.json
@@ -0,0 +1,11 @@
+{
+  "id": 5,
+  "description": "Nisi et repellendus ut enim quo accusamus vel magnam.",
+  "import_type": "gitlab_project",
+  "creator_id": 999,
+  "visibility_level": 10,
+  "archived": false,
+  "hooks": [
+
+  ]
+}
diff --git a/spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project/issues.ndjson b/spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project/issues.ndjson
new file mode 100644
index 0000000000000..cf7d9211460c7
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project/issues.ndjson
@@ -0,0 +1,2 @@
+{"id":39,"author_id":22,"project_id":null,"created_at":"2016-06-14T15:02:08.233Z","updated_at":"2016-06-14T15:02:48.194Z","position":0,"branch_name":null,"description":"Voluptate vel reprehenderit facilis omnis voluptas magnam tenetur.","state":"opened","iid":9,"updated_by_id":null,"confidential":false,"due_date":"2020-08-14","moved_to_id":null,"issue_assignees":[],"milestone":{"id":1,"title":"test milestone","project_id":8,"description":"test milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"events":[{"id":487,"target_type":"Milestone","target_id":1,"project_id":46,"created_at":"2016-06-14T15:02:04.418Z","updated_at":"2016-06-14T15:02:04.418Z","action":1,"author_id":18}]},"notes":[{"id":359,"note":"Quo eius velit quia et id quam.","noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:48.009Z","updated_at":"2016-06-14T15:02:48.009Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":360,"note":"Nulla commodi ratione cumque id autem.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:48.032Z","updated_at":"2016-06-14T15:02:48.032Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":361,"note":"Illum non ea sed dolores corrupti.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:48.056Z","updated_at":"2016-06-14T15:02:48.056Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":362,"note":"Facere dolores ipsum dolorum maiores omnis occaecati ab.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:48.082Z","updated_at":"2016-06-14T15:02:48.082Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":363,"note":"Quod laudantium similique sint aut est ducimus.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:48.113Z","updated_at":"2016-06-14T15:02:48.113Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":364,"note":"Aut omnis eos esse incidunt vero reiciendis.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:48.139Z","updated_at":"2016-06-14T15:02:48.139Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":365,"note":"Beatae dolore et doloremque asperiores sunt.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:48.162Z","updated_at":"2016-06-14T15:02:48.162Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":366,"note":"Doloribus ipsam ex delectus rerum libero recusandae modi repellendus.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:48.192Z","updated_at":"2016-06-14T15:02:48.192Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}]}
+{"id":39,"author_id":22,"project_id":null,"created_at":"2016-06-14T15:02:08.233Z","updated_at":"2016-06-14T15:02:48.194Z","position":0,"branch_name":null,"description":"Voluptate vel reprehenderit facilis omnis voluptas magnam tenetur.","state":"opened","updated_by_id":null,"confidential":false,"due_date":"2020-08-14","moved_to_id":null,"issue_assignees":[],"milestone":{"id":1,"title":"test milestone","project_id":8,"description":"test milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"events":[{"id":487,"target_type":"Milestone","target_id":1,"project_id":46,"created_at":"2016-06-14T15:02:04.418Z","updated_at":"2016-06-14T15:02:04.418Z","action":1,"author_id":18}]},"notes":[{"id":359,"note":"Quo eius velit quia et id quam.","noteable_type":"Issue","author_id":26,"created_at":"2016-06-14T15:02:48.009Z","updated_at":"2016-06-14T15:02:48.009Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 4"},"events":[]},{"id":360,"note":"Nulla commodi ratione cumque id autem.","noteable_type":"Issue","author_id":25,"created_at":"2016-06-14T15:02:48.032Z","updated_at":"2016-06-14T15:02:48.032Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 3"},"events":[]},{"id":361,"note":"Illum non ea sed dolores corrupti.","noteable_type":"Issue","author_id":22,"created_at":"2016-06-14T15:02:48.056Z","updated_at":"2016-06-14T15:02:48.056Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"User 0"},"events":[]},{"id":362,"note":"Facere dolores ipsum dolorum maiores omnis occaecati ab.","noteable_type":"Issue","author_id":20,"created_at":"2016-06-14T15:02:48.082Z","updated_at":"2016-06-14T15:02:48.082Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ottis Schuster II"},"events":[]},{"id":363,"note":"Quod laudantium similique sint aut est ducimus.","noteable_type":"Issue","author_id":16,"created_at":"2016-06-14T15:02:48.113Z","updated_at":"2016-06-14T15:02:48.113Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Rhett Emmerich IV"},"events":[]},{"id":364,"note":"Aut omnis eos esse incidunt vero reiciendis.","noteable_type":"Issue","author_id":15,"created_at":"2016-06-14T15:02:48.139Z","updated_at":"2016-06-14T15:02:48.139Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Burdette Bernier"},"events":[]},{"id":365,"note":"Beatae dolore et doloremque asperiores sunt.","noteable_type":"Issue","author_id":6,"created_at":"2016-06-14T15:02:48.162Z","updated_at":"2016-06-14T15:02:48.162Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Ari Wintheiser"},"events":[]},{"id":366,"note":"Doloribus ipsam ex delectus rerum libero recusandae modi repellendus.","noteable_type":"Issue","author_id":1,"created_at":"2016-06-14T15:02:48.192Z","updated_at":"2016-06-14T15:02:48.192Z","project_id":5,"attachment":{"url":null},"line_code":null,"commit_id":null,"noteable_id":39,"system":false,"st_diff":null,"updated_by_id":null,"author":{"name":"Administrator"},"events":[]}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project/milestones.ndjson b/spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project/milestones.ndjson
new file mode 100644
index 0000000000000..28e737fa43c3a
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/import_export/with_invalid_issues_and_milestones/tree/project/milestones.ndjson
@@ -0,0 +1,2 @@
+{"id":1,"title":null,"project_id":8,"description":123,"due_date":null,"created_at":"NOT A DATE","updated_at":"NOT A DATE","state":"active","iid":1,"group_id":null}
+{"id":42,"title":"A valid milestone","project_id":8,"description":"Project-level milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"group_id":null}
diff --git a/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb
index 9852f6c9652b2..8ab99875a0a8b 100644
--- a/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb
@@ -52,10 +52,10 @@
     )
   end
 
-  subject { relation_tree_restorer.restore }
+  subject(:restore_relations) { relation_tree_restorer.restore }
 
   it 'restores group tree' do
-    expect(subject).to eq(true)
+    expect(restore_relations).to eq(true)
   end
 
   it 'logs top-level relation creation' do
@@ -64,7 +64,7 @@
       .with(hash_including(message: '[Project/Group Import] Created new object relation'))
       .at_least(:once)
 
-    subject
+    restore_relations
   end
 
   describe 'relation object saving' do
@@ -100,7 +100,7 @@
               error_messages: "Label can't be blank, Position can't be blank, and Position is not a number"
             )
 
-          subject
+          restore_relations
 
           board = importable.boards.last
           failure = importable.import_failures.first
@@ -115,6 +115,33 @@
       end
     end
 
+    context 'when invalid relation object has a loggable external identifier' do
+      before do
+        allow(relation_reader)
+          .to receive(:consume_relation)
+          .with(importable_name, 'milestones')
+          .and_return([
+            [invalid_milestone, 0],
+            [invalid_milestone_with_no_iid, 1]
+          ])
+      end
+
+      let(:invalid_milestone) { build(:milestone, iid: 123, name: nil) }
+      let(:invalid_milestone_with_no_iid) { build(:milestone, iid: nil, name: nil) }
+
+      it 'logs invalid record with external identifier' do
+        restore_relations
+
+        iids_for_failures = importable.import_failures.collect { |f| [f.relation_key, f.external_identifiers] }
+        expected_iids = [
+          ["milestones", { "iid" => invalid_milestone.iid }],
+          ["milestones", {}]
+        ]
+
+        expect(iids_for_failures).to match_array(expected_iids)
+      end
+    end
+
     context 'when relation object is persisted' do
       before do
         allow(relation_reader)
@@ -129,7 +156,7 @@
         it 'saves import failure with nested errors' do
           label.priorities << [LabelPriority.new, LabelPriority.new]
 
-          subject
+          restore_relations
 
           failure = importable.import_failures.first
 
diff --git a/spec/lib/gitlab/import_export/import_failure_service_spec.rb b/spec/lib/gitlab/import_export/import_failure_service_spec.rb
index 30d1634782896..9628e9fbf4d3c 100644
--- a/spec/lib/gitlab/import_export/import_failure_service_spec.rb
+++ b/spec/lib/gitlab/import_export/import_failure_service_spec.rb
@@ -21,7 +21,8 @@
         relation_key: relation_key,
         relation_index: relation_index,
         exception: exception,
-        retry_count: retry_count)
+        retry_count: retry_count,
+        external_identifiers: { iid: 1234 })
     end
 
     before do
diff --git a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
index 14af3028a6efe..d565f3f3150f2 100644
--- a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
@@ -1114,9 +1114,10 @@ def match_mr1_note(content_regex)
       let(:user) { create(:user) }
       let!(:project) { create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') }
       let(:project_tree_restorer) { described_class.new(user: user, shared: shared, project: project) }
+      let(:project_fixture) { 'with_invalid_records' }
 
       before do
-        setup_import_export_config('with_invalid_records')
+        setup_import_export_config(project_fixture)
         setup_reader
 
         subject
@@ -1142,6 +1143,21 @@ def match_mr1_note(content_regex)
           expect(import_failure.correlation_id_value).not_to be_empty
           expect(import_failure.created_at).to be_present
         end
+
+        context 'when there are a mix of invalid milestones and issues with IIDs' do
+          let(:project_fixture) { 'with_invalid_issues_and_milestones' }
+
+          it 'tracks the relation IID if present' do
+            iids_for_failures = project.import_failures.collect { |f| [f.relation_key, f.external_identifiers] }
+            expected_iids = [
+              ["milestones", { "iid" => 1 }],
+              ["issues", { "iid" => 9 }],
+              ["issues", {}]
+            ]
+
+            expect(iids_for_failures).to match_array(expected_iids)
+          end
+        end
       end
     end
 
diff --git a/spec/support/shared_examples/lib/gitlab/import_export/import_failure_service_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/import_export/import_failure_service_shared_examples.rb
index 67afd2035c4fb..afb1bfb6dc928 100644
--- a/spec/support/shared_examples/lib/gitlab/import_export/import_failure_service_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/import_export/import_failure_service_shared_examples.rb
@@ -34,6 +34,7 @@
       expect(import_failure.exception_message).to eq(standard_error_message)
       expect(import_failure.correlation_id_value).to eq(correlation_id)
       expect(import_failure.retry_count).to eq(retry_count)
+      expect(import_failure.external_identifiers).to eq("iid" => 1234)
     end
   end
 end
-- 
GitLab