431 lines
16 KiB
Ruby
431 lines
16 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Lifesize project import test executed from https://gitlab.com/gitlab-org/manage/import/import-metrics
|
|
|
|
# rubocop:disable Rails/Pluck, Layout/LineLength, RSpec/MultipleMemoizedHelpers
|
|
module QA
|
|
RSpec.describe "Manage", requires_admin: 'creates users', only: { job: 'large-gitlab-import' } do
|
|
describe "Gitlab migration", product_group: :import do
|
|
let(:logger) { Runtime::Logger.logger }
|
|
let(:differ) { RSpec::Support::Differ.new(color: true) }
|
|
let(:gitlab_group) { ENV['QA_LARGE_IMPORT_GROUP'] || 'gitlab-migration' }
|
|
let(:gitlab_project) { ENV['QA_LARGE_IMPORT_REPO'] || 'dri' }
|
|
let(:gitlab_source_address) { ENV['QA_LARGE_IMPORT_SOURCE_URL'] || 'https://staging.gitlab.com' }
|
|
|
|
let(:import_wait_duration) do
|
|
{
|
|
max_duration: (ENV['QA_LARGE_IMPORT_DURATION'] || 3600).to_i,
|
|
sleep_interval: 30
|
|
}
|
|
end
|
|
|
|
let(:admin_api_client) { Runtime::API::Client.as_admin }
|
|
|
|
# explicitly create PAT via api to not create it via UI in environments where admin token env var is not present
|
|
let(:target_api_client) do
|
|
Runtime::API::Client.new(
|
|
user: user,
|
|
personal_access_token: Resource::PersonalAccessToken.fabricate_via_api! do |pat|
|
|
pat.api_client = admin_api_client
|
|
end.token
|
|
)
|
|
end
|
|
|
|
let(:user) do
|
|
Resource::User.fabricate_via_api! do |usr|
|
|
usr.api_client = admin_api_client
|
|
end
|
|
end
|
|
|
|
let(:source_api_client) do
|
|
Runtime::API::Client.new(
|
|
gitlab_source_address,
|
|
personal_access_token: ENV["QA_LARGE_IMPORT_GL_TOKEN"],
|
|
is_new_session: false
|
|
)
|
|
end
|
|
|
|
let(:sandbox) do
|
|
Resource::Sandbox.fabricate_via_api! do |group|
|
|
group.api_client = admin_api_client
|
|
end
|
|
end
|
|
|
|
let(:destination_group) do
|
|
Resource::Group.fabricate_via_api! do |group|
|
|
group.api_client = admin_api_client
|
|
group.sandbox = sandbox
|
|
group.path = "imported-group-destination-#{SecureRandom.hex(4)}"
|
|
end
|
|
end
|
|
|
|
# Source group and it's objects
|
|
#
|
|
let(:source_group) do
|
|
Resource::Sandbox.fabricate_via_api! do |group|
|
|
group.api_client = source_api_client
|
|
group.path = gitlab_group
|
|
end
|
|
end
|
|
|
|
let(:source_project) { source_group.projects.find { |project| project.name.include?(gitlab_project) }.reload! }
|
|
let(:source_branches) { source_project.repository_branches(auto_paginate: true).map { |b| b[:name] } }
|
|
let(:source_commits) { source_project.commits(auto_paginate: true).map { |c| c[:id] } }
|
|
let(:source_labels) { source_project.labels(auto_paginate: true).map { |l| l.except(:id) } }
|
|
let(:source_milestones) { source_project.milestones(auto_paginate: true).map { |ms| ms.except(:id, :web_url, :project_id) } }
|
|
let(:source_pipelines) { source_project.pipelines(auto_paginate: true).map { |pp| pp.except(:id, :web_url, :project_id) } }
|
|
let(:source_mrs) { fetch_mrs(source_project, source_api_client) }
|
|
let(:source_issues) { fetch_issues(source_project, source_api_client) }
|
|
|
|
# Imported group and it's objects
|
|
#
|
|
let(:imported_group) do
|
|
Resource::BulkImportGroup.fabricate_via_api! do |group|
|
|
group.import_access_token = source_api_client.personal_access_token # token for importing on source instance
|
|
group.api_client = target_api_client # token used by qa framework to access resources in destination instance
|
|
group.gitlab_address = gitlab_source_address
|
|
group.source_group = source_group
|
|
group.sandbox = destination_group
|
|
end
|
|
end
|
|
|
|
let(:imported_project) { imported_group.projects.find { |project| project.name.include?(gitlab_project) }.reload! }
|
|
let(:branches) { imported_project.repository_branches(auto_paginate: true).map { |b| b[:name] } }
|
|
let(:commits) { imported_project.commits(auto_paginate: true).map { |c| c[:id] } }
|
|
let(:labels) { imported_project.labels(auto_paginate: true).map { |l| l.except(:id) } }
|
|
let(:milestones) { imported_project.milestones(auto_paginate: true).map { |ms| ms.except(:id, :web_url, :project_id) } }
|
|
let(:pipelines) { imported_project.pipelines.map { |pp| pp.except(:id, :web_url, :project_id) } }
|
|
let(:mrs) { fetch_mrs(imported_project, target_api_client) }
|
|
let(:issues) { fetch_issues(imported_project, target_api_client) }
|
|
|
|
let(:import_failures) { imported_group.import_details.sum([]) { |details| details[:failures] } }
|
|
|
|
before do
|
|
destination_group.add_member(user, Resource::Members::AccessLevel::MAINTAINER)
|
|
end
|
|
|
|
# rubocop:disable RSpec/InstanceVariable
|
|
after do |example|
|
|
# Log failures for easier debugging
|
|
Runtime::Logger.error("Import failures: #{import_failures}") if example.exception && !import_failures.empty?
|
|
|
|
next unless defined?(@import_time)
|
|
|
|
# save data for comparison notification creation
|
|
save_json(
|
|
"data",
|
|
{
|
|
importer: :gitlab,
|
|
import_time: @import_time,
|
|
errors: import_failures,
|
|
source: {
|
|
name: "GitLab Source",
|
|
project_name: source_project.path_with_namespace,
|
|
address: gitlab_source_address,
|
|
data: {
|
|
branches: source_branches.length,
|
|
commits: source_commits.length,
|
|
labels: source_labels.length,
|
|
milestones: source_milestones.length,
|
|
pipelines: source_pipelines.length,
|
|
mrs: source_mrs.length,
|
|
mr_comments: source_mrs.sum { |_k, v| v[:comments].length },
|
|
issues: source_issues.length,
|
|
issue_comments: source_issues.sum { |_k, v| v[:comments].length }
|
|
}
|
|
},
|
|
target: {
|
|
name: "GitLab Target",
|
|
project_name: imported_project.path_with_namespace,
|
|
address: QA::Runtime::Scenario.gitlab_address,
|
|
data: {
|
|
branches: branches.length,
|
|
commits: commits.length,
|
|
labels: labels.length,
|
|
milestones: milestones.length,
|
|
pipelines: pipelines.length,
|
|
mrs: mrs.length,
|
|
mr_comments: mrs.sum { |_k, v| v[:comments].length },
|
|
issues: issues.length,
|
|
issue_comments: issues.sum { |_k, v| v[:comments].length }
|
|
}
|
|
},
|
|
diff: {
|
|
mrs: @mr_diff,
|
|
issues: @issue_diff
|
|
}
|
|
}
|
|
)
|
|
end
|
|
# rubocop:enable RSpec/InstanceVariable
|
|
|
|
it "migrates large gitlab group via api", testcase: "https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/358842" do
|
|
start = Time.now
|
|
|
|
# trigger import and log imported group path
|
|
logger.info("== Importing group '#{gitlab_group}' in to '#{imported_group.full_path}' ==")
|
|
|
|
# fetch all objects right after import has started
|
|
fetch_source_gitlab_objects
|
|
|
|
# wait for import to finish and save import time
|
|
logger.info("== Waiting for import to be finished ==")
|
|
expect { imported_group.import_status }.not_to eventually_eq("started").within(import_wait_duration)
|
|
# finished status actually means success, don't wait for finished status explicitly
|
|
# because test would wait full duration if returned status is "failed"
|
|
expect(imported_group.import_status).to eq("finished")
|
|
|
|
@import_time = Time.now - start
|
|
|
|
aggregate_failures do
|
|
verify_repository_import
|
|
verify_labels_import
|
|
verify_milestones_import
|
|
verify_pipelines_import
|
|
verify_merge_requests_import
|
|
verify_issues_import
|
|
end
|
|
end
|
|
|
|
# Fetch source project objects for comparison
|
|
#
|
|
# @return [void]
|
|
def fetch_source_gitlab_objects
|
|
logger.info("== Fetching source group objects ==")
|
|
|
|
source_branches
|
|
source_commits
|
|
source_labels
|
|
source_milestones
|
|
source_pipelines
|
|
source_mrs
|
|
source_issues
|
|
end
|
|
|
|
# Verify repository imported correctly
|
|
#
|
|
# @return [void]
|
|
def verify_repository_import
|
|
logger.info("== Verifying repository import ==")
|
|
expect(imported_project.description).to eq(source_project.description)
|
|
expect(branches).to match_array(source_branches)
|
|
expect(commits).to match_array(source_commits)
|
|
end
|
|
|
|
# Verify imported labels
|
|
#
|
|
# @return [void]
|
|
def verify_labels_import
|
|
logger.info("== Verifying label import ==")
|
|
expect(labels).to include(*source_labels)
|
|
end
|
|
|
|
# Verify milestones import
|
|
#
|
|
# @return [void]
|
|
def verify_milestones_import
|
|
logger.info("== Verifying milestones import ==")
|
|
expect(milestones).to match_array(source_milestones)
|
|
end
|
|
|
|
# Verify pipelines import
|
|
#
|
|
# @return [void]
|
|
def verify_pipelines_import
|
|
logger.info("== Verifying pipelines import ==")
|
|
expect(pipelines).to match_array(source_pipelines)
|
|
end
|
|
|
|
# Verify imported merge requests and mr issues
|
|
#
|
|
# @return [void]
|
|
def verify_merge_requests_import
|
|
logger.info("== Verifying merge request import ==")
|
|
@mr_diff = verify_mrs_or_issues('mr')
|
|
end
|
|
|
|
# Verify imported issues and issue comments
|
|
#
|
|
# @return [void]
|
|
def verify_issues_import
|
|
logger.info("== Verifying issue import ==")
|
|
@issue_diff = verify_mrs_or_issues('issue')
|
|
end
|
|
|
|
# Verify imported mrs or issues and return missing items
|
|
#
|
|
# @param [String] type verification object, 'mr' or 'issue'
|
|
# @return [Hash]
|
|
def verify_mrs_or_issues(type)
|
|
# Compare length to have easy to read overview how many objects are missing
|
|
#
|
|
expected = type == 'mr' ? source_mrs : source_issues
|
|
actual = type == 'mr' ? mrs : issues
|
|
count_msg = "Expected to contain same amount of #{type}s. Source: #{expected.length}, Target: #{actual.length}"
|
|
expect(actual.length).to eq(expected.length), count_msg
|
|
|
|
comment_diff = verify_comments(type, actual, expected)
|
|
|
|
{
|
|
"missing_#{type}s": (expected.keys - actual.keys).map { |it| actual[it]&.slice(:title, :url) }.compact,
|
|
"extra_#{type}s": (actual.keys - expected.keys).map { |it| expected[it]&.slice(:title, :url) }.compact,
|
|
"#{type}_comments": comment_diff
|
|
}
|
|
end
|
|
|
|
# Verify imported comments
|
|
#
|
|
# @param [String] type verification object, 'mrs' or 'issues'
|
|
# @param [Hash] actual
|
|
# @param [Hash] expected
|
|
# @return [Hash]
|
|
def verify_comments(type, actual, expected)
|
|
actual.each_with_object([]) do |(key, actual_item), diff|
|
|
expected_item = expected[key]
|
|
title = actual_item[:title]
|
|
msg = "expected #{type} with title '#{title}' to have"
|
|
|
|
# Print title in the error message to see which object is missing
|
|
#
|
|
expect(actual_item).to be_truthy, "#{msg} been imported"
|
|
next unless expected_item
|
|
|
|
# Print difference in the description
|
|
#
|
|
expected_body = expected_item[:body]
|
|
actual_body = actual_item[:body]
|
|
body_msg = "#{msg} same description. diff:\n#{differ.diff(expected_body, actual_body)}"
|
|
expect(actual_body).to eq(expected_body), body_msg
|
|
|
|
# Print difference in state
|
|
#
|
|
expected_state = expected_item[:state]
|
|
actual_state = actual_item[:state]
|
|
state_msg = "#{msg} same state. Source: #{expected_state}, Target: #{actual_state}"
|
|
expect(actual_state).to eq(expected_state), state_msg
|
|
|
|
# Print amount difference first
|
|
#
|
|
expected_comments = expected_item[:comments]
|
|
actual_comments = actual_item[:comments]
|
|
comment_count_msg = <<~MSG
|
|
#{msg} same amount of comments. Source: #{expected_comments.length}, Target: #{actual_comments.length}
|
|
MSG
|
|
expect(actual_comments.length).to eq(expected_comments.length), comment_count_msg
|
|
expect(actual_comments).to match_array(expected_comments)
|
|
|
|
# Save comment diff
|
|
#
|
|
missing_comments = expected_comments - actual_comments
|
|
extra_comments = actual_comments - expected_comments
|
|
next if missing_comments.empty? && extra_comments.empty?
|
|
|
|
diff << {
|
|
title: title,
|
|
target_url: actual_item[:url],
|
|
source_url: expected_item[:url],
|
|
missing_comments: missing_comments,
|
|
extra_comments: extra_comments
|
|
}
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
# Project merge requests with comments
|
|
#
|
|
# @param [QA::Resource::Project]
|
|
# @param [Runtime::API::Client] client
|
|
# @return [Hash]
|
|
def fetch_mrs(project, client)
|
|
imported_mrs = project.merge_requests(auto_paginate: true, attempts: 2)
|
|
|
|
Parallel.map(imported_mrs, in_threads: 4) do |mr|
|
|
resource = Resource::MergeRequest.init do |resource|
|
|
resource.project = project
|
|
resource.iid = mr[:iid]
|
|
resource.api_client = client
|
|
end
|
|
|
|
[mr[:iid], {
|
|
url: mr[:web_url],
|
|
title: mr[:title],
|
|
body: sanitize_description(mr[:description]) || '',
|
|
state: mr[:state],
|
|
comments: resource
|
|
.comments(auto_paginate: true, attempts: 2)
|
|
.map { |c| sanitize_comment(c[:body]) }
|
|
}]
|
|
end.to_h
|
|
end
|
|
|
|
# Project issues with comments
|
|
#
|
|
# @param [QA::Resource::Project]
|
|
# @param [Runtime::API::Client] client
|
|
# @return [Hash]
|
|
def fetch_issues(project, client)
|
|
imported_issues = project.issues(auto_paginate: true, attempts: 2)
|
|
|
|
Parallel.map(imported_issues, in_threads: 4) do |issue|
|
|
resource = Resource::Issue.init do |issue_resource|
|
|
issue_resource.project = project
|
|
issue_resource.iid = issue[:iid]
|
|
issue_resource.api_client = client
|
|
end
|
|
|
|
[issue[:iid], {
|
|
url: issue[:web_url],
|
|
title: issue[:title],
|
|
state: issue[:state],
|
|
body: sanitize_description(issue[:description]) || '',
|
|
comments: resource
|
|
.comments(auto_paginate: true, attempts: 2)
|
|
.map { |c| sanitize_comment(c[:body]) }
|
|
}]
|
|
end.to_h
|
|
end
|
|
|
|
# Importer user mention pattern
|
|
#
|
|
# @return [Regex]
|
|
def created_by_pattern
|
|
@created_by_pattern ||= /\n\n \*By #{importer_username_pattern} on \S+ \(imported from GitLab\)\*/
|
|
end
|
|
|
|
# Username of importer user for removal from comments and descriptions
|
|
#
|
|
# @return [String]
|
|
def importer_username_pattern
|
|
@importer_username_pattern ||= ENV['QA_LARGE_IMPORT_USER_PATTERN'] || "(gitlab-migration|GitLab QA Bot)"
|
|
end
|
|
|
|
# Remove added prefixes from comments
|
|
#
|
|
# @param [String] body
|
|
# @return [String]
|
|
def sanitize_comment(body)
|
|
body&.gsub(created_by_pattern, "")
|
|
end
|
|
|
|
# Remove created by prefix from descripion
|
|
#
|
|
# @param [String] body
|
|
# @return [String]
|
|
def sanitize_description(body)
|
|
body&.gsub(created_by_pattern, "")
|
|
end
|
|
|
|
# Save json as file
|
|
#
|
|
# @param [String] name
|
|
# @param [Hash] json
|
|
# @return [void]
|
|
def save_json(name, json)
|
|
File.open("tmp/#{name}.json", "w") { |file| file.write(JSON.pretty_generate(json)) }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
# rubocop:enable Rails/Pluck, Layout/LineLength, RSpec/MultipleMemoizedHelpers
|