summaryrefslogtreecommitdiff
path: root/qa/qa/tools
diff options
context:
space:
mode:
Diffstat (limited to 'qa/qa/tools')
-rw-r--r--qa/qa/tools/delete_projects.rb2
-rw-r--r--qa/qa/tools/delete_subgroups.rb2
-rw-r--r--qa/qa/tools/delete_test_resources.rb85
-rw-r--r--qa/qa/tools/delete_test_ssh_keys.rb2
-rw-r--r--qa/qa/tools/generate_perf_testdata.rb2
-rw-r--r--qa/qa/tools/initialize_gitlab_auth.rb2
-rw-r--r--qa/qa/tools/knapsack_report.rb118
-rw-r--r--qa/qa/tools/long_running_spec_reporter.rb97
-rw-r--r--qa/qa/tools/reliable_report.rb114
-rw-r--r--qa/qa/tools/revoke_all_personal_access_tokens.rb2
-rw-r--r--qa/qa/tools/test_resource_data_processor.rb66
11 files changed, 410 insertions, 82 deletions
diff --git a/qa/qa/tools/delete_projects.rb b/qa/qa/tools/delete_projects.rb
index 240901eea6f..1f550f035d1 100644
--- a/qa/qa/tools/delete_projects.rb
+++ b/qa/qa/tools/delete_projects.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-require_relative '../../qa'
-
# This script deletes all projects directly under a group specified by ENV['TOP_LEVEL_GROUP_NAME']
# Required environment variables: GITLAB_QA_ACCESS_TOKEN and GITLAB_ADDRESS
# Optional environment variable: TOP_LEVEL_GROUP_NAME (defaults to 'gitlab-qa-sandbox-group')
diff --git a/qa/qa/tools/delete_subgroups.rb b/qa/qa/tools/delete_subgroups.rb
index bc905fdeadd..11b45365d4c 100644
--- a/qa/qa/tools/delete_subgroups.rb
+++ b/qa/qa/tools/delete_subgroups.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-require_relative '../../qa'
-
# This script deletes all subgroups of a group specified by ENV['TOP_LEVEL_GROUP_NAME']
# Required environment variables: GITLAB_QA_ACCESS_TOKEN and GITLAB_ADDRESS
# Optional environment variable: TOP_LEVEL_GROUP_NAME (defaults to 'gitlab-qa-sandbox-group')
diff --git a/qa/qa/tools/delete_test_resources.rb b/qa/qa/tools/delete_test_resources.rb
new file mode 100644
index 00000000000..917cb2fa992
--- /dev/null
+++ b/qa/qa/tools/delete_test_resources.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+# This script reads from test_resources.txt file to collect data about resources to delete
+# Deletes all deletable resources that E2E tests created
+# Resource type: Sandbox, User, Fork and RSpec::Mocks::Double are not included
+#
+# Required environment variables: GITLAB_QA_ACCESS_TOKEN and GITLAB_ADDRESS
+# When in CI also requires: QA_TEST_RESOURCES_FILE_PATTERN
+# Run `rake delete_test_resources[<file_pattern>]`
+
+module QA
+ module Tools
+ class DeleteTestResources
+ include Support::API
+
+ def initialize(file_pattern = nil)
+ raise ArgumentError, "Please provide GITLAB_ADDRESS" unless ENV['GITLAB_ADDRESS']
+ raise ArgumentError, "Please provide GITLAB_QA_ACCESS_TOKEN" unless ENV['GITLAB_QA_ACCESS_TOKEN']
+
+ @api_client = Runtime::API::Client.new(ENV['GITLAB_ADDRESS'], personal_access_token: ENV['GITLAB_QA_ACCESS_TOKEN'])
+ @file_pattern = file_pattern
+ end
+
+ def run
+ puts 'Deleting test created resources...'
+
+ if Runtime::Env.running_in_ci?
+ raise ArgumentError, 'Please provide QA_TEST_RESOURCES_FILE_PATTERN' unless ENV['QA_TEST_RESOURCES_FILE_PATTERN']
+
+ Dir.glob(@file_pattern).each do |file|
+ delete_resources(load_file(file))
+ end
+ else
+ file = Runtime::Env.test_resources_created_filepath
+ raise ArgumentError, "'#{file}' either does not exist or empty." if !File.exist?(file) || File.zero?(file)
+
+ delete_resources(load_file(file))
+ end
+
+ puts "\nDone"
+ end
+
+ private
+
+ def load_file(json)
+ JSON.parse(File.read(json))
+ end
+
+ def delete_resources(resources)
+ failures = []
+
+ resources.each_key do |type|
+ next if resources[type].empty?
+
+ resources[type].each do |resource|
+ next if resource_not_found?(resource['api_path'])
+
+ msg = resource['info'] ? "#{type} - #{resource['info']}" : "#{type} at #{resource['api_path']}"
+
+ puts "\nDeleting #{msg}..."
+ delete_response = delete(Runtime::API::Request.new(@api_client, resource['api_path']).url)
+
+ if delete_response.code == 202
+ print "\e[32m.\e[0m"
+ else
+ print "\e[31mF\e[0m"
+ failures << msg
+ end
+ end
+ end
+
+ unless failures.empty?
+ puts "\nFailed to delete #{failures.length} resources:\n"
+ puts failures
+ end
+ end
+
+ def resource_not_found?(api_path)
+ get_response = get Runtime::API::Request.new(@api_client, api_path).url
+
+ get_response.code.eql? 404
+ end
+ end
+ end
+end
diff --git a/qa/qa/tools/delete_test_ssh_keys.rb b/qa/qa/tools/delete_test_ssh_keys.rb
index 58ab4865336..9e5728a5509 100644
--- a/qa/qa/tools/delete_test_ssh_keys.rb
+++ b/qa/qa/tools/delete_test_ssh_keys.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-require_relative '../../qa'
-
# This script deletes all selected test ssh keys for a specific user
# Keys can be selected by a string matching part of the key's title and by created date
# - Specify `title_portion` to delete only keys that include the string provided
diff --git a/qa/qa/tools/generate_perf_testdata.rb b/qa/qa/tools/generate_perf_testdata.rb
index 8e5da94e7e6..0f06fd2fbc4 100644
--- a/qa/qa/tools/generate_perf_testdata.rb
+++ b/qa/qa/tools/generate_perf_testdata.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'yaml'
-require_relative '../../qa'
+
# This script generates testdata for Performance Testing.
# Required environment variables: GITLAB_QA_ACCESS_TOKEN and GITLAB_ADDRESS
# This job creates a urls.txt which contains a hash of all the URLs needed for Performance Testing
diff --git a/qa/qa/tools/initialize_gitlab_auth.rb b/qa/qa/tools/initialize_gitlab_auth.rb
index 3ead8fc9bd4..86791f1f624 100644
--- a/qa/qa/tools/initialize_gitlab_auth.rb
+++ b/qa/qa/tools/initialize_gitlab_auth.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-require_relative '../../qa'
-
module QA
module Tools
# Task to set default password from Runtime::Env.default_password if not set already
diff --git a/qa/qa/tools/knapsack_report.rb b/qa/qa/tools/knapsack_report.rb
index fb405e82e83..e50c4fe63d2 100644
--- a/qa/qa/tools/knapsack_report.rb
+++ b/qa/qa/tools/knapsack_report.rb
@@ -5,50 +5,86 @@ require "fog/google"
module QA
module Tools
class KnapsackReport
+ extend SingleForwardable
+
PROJECT = "gitlab-qa-resources"
BUCKET = "knapsack-reports"
+ FALLBACK_REPORT = "knapsack/master_report.json"
- class << self
- def download
- new.download_report
- end
+ def_delegators :new, :configure!, :move_regenerated_report, :download_report, :upload_report
- def upload(glob)
- new.upload_report(glob)
- end
- end
+ # Configure knapsack report
+ #
+ # * Setup variables
+ # * Fetch latest report
+ #
+ # @return [void]
+ def configure!
+ ENV["KNAPSACK_TEST_FILE_PATTERN"] ||= "qa/specs/features/**/*_spec.rb"
+ ENV["KNAPSACK_REPORT_PATH"] = report_path
- def initialize
- ENV["KNAPSACK_REPORT_PATH"] || raise("KNAPSACK_REPORT_PATH env var is required!")
- ENV["QA_KNAPSACK_REPORT_GCS_CREDENTIALS"] || raise("QA_KNAPSACK_REPORT_GCS_CREDENTIALS env var is required!")
+ Knapsack.logger = QA::Runtime::Logger.logger
+
+ download_report
end
# Download knapsack report from gcs bucket
#
# @return [void]
def download_report
- logger.info("Downloading latest knapsack report '#{report_file}'")
+ logger.debug("Downloading latest knapsack report for '#{report_name}' to '#{report_path}'")
file = client.get_object(BUCKET, report_file)
-
- logger.info("Saving latest knapsack report to '#{report_path}'")
File.write(report_path, file[:body])
+ rescue StandardError => e
+ ENV["KNAPSACK_REPORT_PATH"] = FALLBACK_REPORT
+ logger.warn("Failed to fetch latest knapsack report: #{e}")
+ logger.warn("Falling back to '#{FALLBACK_REPORT}'")
+ end
+
+ # Rename and move new regenerated report to a separate folder used to indicate report name
+ #
+ # @return [void]
+ def move_regenerated_report
+ return unless ENV["KNAPSACK_GENERATE_REPORT"] == "true"
+
+ tmp_path = "tmp/knapsack/#{report_name}"
+ FileUtils.mkdir_p(tmp_path)
+
+ # Use path from knapsack config in case of fallback to master_report.json
+ knapsack_report_path = Knapsack.report.report_path
+ logger.debug("Moving regenerated #{knapsack_report_path} to save as artifact")
+ FileUtils.cp(knapsack_report_path, "#{tmp_path}/#{ENV['CI_NODE_INDEX']}.json")
end
# Merge and upload knapsack report to gcs bucket
#
+ # Fetches all files defined in glob and uses parent folder as report name
+ #
# @param [String] glob
# @return [void]
def upload_report(glob)
- reports = Dir[glob]
- return logger.error("Pattern '#{glob}' did not match any files!") if reports.empty?
+ reports = Pathname.glob(glob).each_with_object(Hash.new { |hsh, key| hsh[key] = [] }) do |report, hash|
+ next unless report.extname == ".json"
+
+ hash[report.parent.basename.to_s].push(report)
+ end
+ return logger.error("Glob '#{glob}' did not contain any valid report files!") if reports.empty?
- report = reports
- .map { |path| JSON.parse(File.read(path)) }
- .reduce({}, :merge)
- return logger.error("Knapsack generated empty report, skipping upload!") if report.empty?
+ reports.each do |name, jsons|
+ file = "#{name}.json"
- logger.info("Uploading latest knapsack report '#{report_file}'")
- client.put_object(BUCKET, report_file, JSON.pretty_generate(report))
+ report = jsons
+ .map { |json| JSON.parse(File.read(json)) }
+ .reduce({}, :merge)
+ .sort_by { |k, v| v } # sort report by execution time
+ .to_h
+ next logger.warn("Knapsack generated empty report for '#{name}', skipping upload!") if report.empty?
+
+ logger.info("Uploading latest knapsack report '#{file}'")
+ client.put_object(BUCKET, file, JSON.pretty_generate(report))
+ rescue StandardError => e
+ logger.error("Failed to upload knapsack report for '#{name}'. Error: #{e}")
+ end
end
private
@@ -64,24 +100,50 @@ module QA
#
# @return [Fog::Storage::GoogleJSON]
def client
- @client ||= Fog::Storage::Google.new(
- google_project: PROJECT,
- google_json_key_location: ENV["QA_KNAPSACK_REPORT_GCS_CREDENTIALS"]
- )
+ @client ||= Fog::Storage::Google.new(google_project: PROJECT, **gcs_credentials)
+ end
+
+ # Base path of knapsack report
+ #
+ # @return [String]
+ def report_base_path
+ @report_base_path ||= "knapsack"
end
# Knapsack report path
#
# @return [String]
def report_path
- @report_path ||= ENV["KNAPSACK_REPORT_PATH"]
+ @report_path ||= "#{report_base_path}/#{report_file}"
end
# Knapsack report name
#
# @return [String]
def report_file
- @report_name ||= report_path.split("/").last
+ @report_file ||= "#{report_name}.json"
+ end
+
+ # Report name
+ #
+ # Infer report name from ci job name
+ # Remove characters incompatible with gcs bucket naming from job names like ee:instance-parallel
+ #
+ # @return [String]
+ def report_name
+ @report_name ||= ENV["CI_JOB_NAME"].split(" ").first.tr(":", "-")
+ end
+
+ # GCS credentials json
+ #
+ # @return [Hash]
+ def gcs_credentials
+ json_key = ENV["QA_KNAPSACK_REPORT_GCS_CREDENTIALS"] || raise(
+ "QA_KNAPSACK_REPORT_GCS_CREDENTIALS env variable is required!"
+ )
+ return { google_json_key_location: json_key } if File.exist?(json_key)
+
+ { google_json_key_string: json_key }
end
end
end
diff --git a/qa/qa/tools/long_running_spec_reporter.rb b/qa/qa/tools/long_running_spec_reporter.rb
new file mode 100644
index 00000000000..ce035248baa
--- /dev/null
+++ b/qa/qa/tools/long_running_spec_reporter.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require "fog/google"
+require "slack-notifier"
+
+module QA
+ module Tools
+ class LongRunningSpecReporter
+ extend SingleForwardable
+
+ SLACK_CHANNEL = "#quality-reports"
+ PROJECT = "gitlab-qa-resources"
+ BUCKET = "knapsack-reports"
+ REPORT_NAME = "ee-instance-parallel.json"
+ RUNTIME_THRESHOLD = 300
+
+ def_delegator :new, :execute
+
+ # Find and report specs exceeding runtime threshold
+ #
+ # @return [void]
+ def execute
+ return puts("No long running specs detected, all good!") if long_running_specs.empty?
+
+ specs = long_running_specs.map { |k, v| "#{k}: #{(v / 60).round(2)} minutes" }.join("\n")
+ average = mean_runtime < 60 ? "#{mean_runtime.round(0)} seconds" : "#{(mean_runtime / 60).round(2)} minutes"
+ msg = <<~MSG
+ Following spec files are exceeding #{RUNTIME_THRESHOLD / 60} minute runtime threshold!
+ Current average spec runtime: #{average}.
+ MSG
+
+ puts("#{msg}\n#{specs}")
+ notifier.post(icon_emoji: ":time-out:", text: "#{msg}\n```#{specs}```")
+ end
+
+ private
+
+ # Average runtime of spec files
+ #
+ # @return [Number]
+ def mean_runtime
+ @mean_runtime ||= latest_report.values
+ .select { |v| v < RUNTIME_THRESHOLD }
+ .yield_self { |runtimes| runtimes.sum(0.0) / runtimes.length }
+ end
+
+ # Spec files exceeding runtime threshold
+ #
+ # @return [Hash]
+ def long_running_specs
+ @long_running_specs ||= latest_report.select { |k, v| v > RUNTIME_THRESHOLD }
+ end
+
+ # Latest knapsack report
+ #
+ # @return [Hash]
+ def latest_report
+ @latest_report ||= JSON.parse(client.get_object(BUCKET, REPORT_NAME)[:body])
+ end
+
+ # Slack notifier
+ #
+ # @return [Slack::Notifier]
+ def notifier
+ @notifier ||= Slack::Notifier.new(
+ slack_webhook_url,
+ channel: SLACK_CHANNEL,
+ username: "Spec Runtime Report"
+ )
+ end
+
+ # GCS client
+ #
+ # @return [Fog::Storage::GoogleJSON]
+ def client
+ @client ||= Fog::Storage::Google.new(
+ google_project: PROJECT,
+ **(File.exist?(gcs_json) ? { google_json_key_location: gcs_json } : { google_json_key_string: gcs_json })
+ )
+ end
+
+ # Slack webhook url
+ #
+ # @return [String]
+ def slack_webhook_url
+ @slack_webhook_url ||= ENV["SLACK_WEBHOOK"] || raise("Missing SLACK_WEBHOOK env variable")
+ end
+
+ # GCS credentials json
+ #
+ # @return [Hash]
+ def gcs_json
+ ENV["QA_KNAPSACK_REPORT_GCS_CREDENTIALS"] || raise("Missing QA_KNAPSACK_REPORT_GCS_CREDENTIALS env variable!")
+ end
+ end
+ end
+end
diff --git a/qa/qa/tools/reliable_report.rb b/qa/qa/tools/reliable_report.rb
index 40a452be36e..b99b97c1ea6 100644
--- a/qa/qa/tools/reliable_report.rb
+++ b/qa/qa/tools/reliable_report.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-require_relative "../../qa"
-
require "influxdb-client"
require "terminal-table"
require "slack-notifier"
@@ -16,7 +14,7 @@ module QA
PROJECT_ID = 278964
def initialize(range)
- @range = range
+ @range = range.to_i
@influxdb_bucket = "e2e-test-stats"
@slack_channel = "#quality-reports"
@influxdb_url = ENV["QA_INFLUXDB_URL"] || raise("Missing QA_INFLUXDB_URL env variable")
@@ -34,9 +32,8 @@ module QA
reporter.print_report
reporter.report_in_issue_and_slack if report_in_issue_and_slack == "true"
rescue StandardError => e
- puts "Report creation failed! Error: '#{e}'".colorize(:red)
- reporter.notify_failure(e)
- exit(1)
+ reporter&.notify_failure(e)
+ raise(e)
end
# Print top stable specs
@@ -58,7 +55,11 @@ module QA
puts "Creating report".colorize(:green)
response = post(
"#{gitlab_api_url}/projects/#{PROJECT_ID}/issues",
- { title: "Reliable spec report", description: report_issue_body, labels: "Quality,test" },
+ {
+ title: "Reliable e2e test report",
+ description: report_issue_body,
+ labels: "Quality,test,type::maintenance,reliable test report"
+ },
headers: { "PRIVATE-TOKEN" => gitlab_access_token }
)
web_url = parse_body(response)[:web_url]
@@ -96,68 +97,79 @@ module QA
#
# @return [String]
def report_issue_body
+ execution_interval = "(#{Date.today - range} - #{Date.today})"
+
issue = []
issue << "[[_TOC_]]"
- issue << "# Candidates for promotion to reliable\n\n```\n#{stable_summary_table}\n```"
- issue << results_markdown(stable_results_tables)
+ issue << "# Candidates for promotion to reliable #{execution_interval}"
+ issue << "Total amount: **#{stable_test_runs.sum { |_k, v| v.count }}**"
+ issue << stable_summary_table(markdown: true).to_s
+ issue << results_markdown(:stable)
return issue.join("\n\n") if unstable_reliable_test_runs.empty?
- issue << "# Reliable specs with failures\n\n```\n#{unstable_summary_table}\n```"
- issue << results_markdown(unstable_reliable_results_tables)
+ issue << "# Reliable specs with failures #{execution_interval}"
+ issue << "Total amount: **#{unstable_reliable_test_runs.sum { |_k, v| v.count }}**"
+ issue << unstable_summary_table(markdown: true).to_s
+ issue << results_markdown(:unstable)
issue.join("\n\n")
end
# Stable spec summary table
#
+ # @param [Boolean] markdown
# @return [Terminal::Table]
- def stable_summary_table
- @stable_summary_table ||= terminal_table(
+ def stable_summary_table(markdown: false)
+ terminal_table(
rows: stable_test_runs.map { |stage, specs| [stage, specs.length] },
title: "Stable spec summary for past #{range} days".ljust(50),
- headings: %w[STAGE COUNT]
+ headings: %w[STAGE COUNT],
+ markdown: markdown
)
end
# Unstable reliable summary table
#
+ # @param [Boolean] markdown
# @return [Terminal::Table]
- def unstable_summary_table
- @unstable_summary_table ||= terminal_table(
+ def unstable_summary_table(markdown: false)
+ terminal_table(
rows: unstable_reliable_test_runs.map { |stage, specs| [stage, specs.length] },
title: "Unstable spec summary for past #{range} days".ljust(50),
- headings: %w[STAGE COUNT]
+ headings: %w[STAGE COUNT],
+ markdown: markdown
)
end
# Result tables for stable specs
#
+ # @param [Boolean] markdown
# @return [Hash]
- def stable_results_tables
- @stable_results ||= results_tables(:stable)
+ def stable_results_tables(markdown: false)
+ results_tables(:stable, markdown: markdown)
end
# Result table for unstable specs
#
+ # @param [Boolean] markdown
# @return [Hash]
- def unstable_reliable_results_tables
- @unstable_results ||= results_tables(:unstable)
+ def unstable_reliable_results_tables(markdown: false)
+ results_tables(:unstable, markdown: markdown)
end
# Markdown formatted tables
#
- # @param [Hash] results
+ # @param [Symbol] type result type - :stable, :unstable
# @return [String]
- def results_markdown(results)
- results.map do |stage, table|
+ def results_markdown(type)
+ runs = type == :stable ? stable_test_runs : unstable_reliable_test_runs
+ results_tables(type, markdown: true).map do |stage, table|
<<~STAGE.strip
- ## #{stage}
+ ## #{stage} (#{runs[stage].count})
<details>
<summary>Executions table</summary>
- ```
#{table}
- ```
</details>
STAGE
@@ -167,15 +179,19 @@ module QA
# Results table
#
# @param [Symbol] type result type - :stable, :unstable
+ # @param [Boolean] markdown
# @return [Hash<Symbol, Terminal::Table>]
- def results_tables(type)
+ def results_tables(type, markdown: false)
(type == :stable ? stable_test_runs : unstable_reliable_test_runs).to_h do |stage, specs|
headings = ["name", "runs", "failures", "failure rate"]
[stage, terminal_table(
- rows: specs.map { |k, v| [name_column(k, v[:file]), *table_params(v.values)] },
title: "Top #{type} specs in '#{stage}' stage for past #{range} days",
- headings: headings.map(&:upcase)
+ headings: headings.map(&:upcase),
+ markdown: markdown,
+ rows: specs.map do |k, v|
+ [name_column(name: k, file: v[:file], markdown: markdown), *table_params(v.values)]
+ end
)]
end
end
@@ -214,13 +230,17 @@ module QA
# Terminal table for result formatting
#
+ # @param [Array] rows
+ # @param [Array] headings
+ # @param [String] title
+ # @param [Boolean] markdown
# @return [Terminal::Table]
- def terminal_table(rows:, headings:, title: nil)
+ def terminal_table(rows:, headings:, title:, markdown:)
Terminal::Table.new(
headings: headings,
- style: { all_separators: true },
- title: title,
- rows: rows
+ title: markdown ? nil : title,
+ rows: rows,
+ style: markdown ? { border: :markdown } : { all_separators: true }
)
end
@@ -232,17 +252,17 @@ module QA
[*parameters[1..2], "#{parameters.last}%"]
end
- # Name column value
+ # Name column content
#
# @param [String] name
# @param [String] file
+ # @param [Boolean] markdown
# @return [String]
- def name_column(name, file)
- spec_name = name.length > 150 ? "#{name} ".scan(/.{1,150} /).map(&:strip).join("\n") : name
- name_line = "name: '#{spec_name}'"
- file_line = "file: '#{file}'"
+ def name_column(name:, file:, markdown: false)
+ return "**name**: #{name}<br>**file**: #{file}" if markdown
- "#{name_line}\n#{file_line.ljust(160)}"
+ wrapped_name = name.length > 150 ? "#{name} ".scan(/.{1,150} /).map(&:strip).join("\n") : name
+ "name: '#{wrapped_name}'\nfile: #{file.ljust(160)}"
end
# Test executions grouped by name
@@ -254,10 +274,16 @@ module QA
all_runs = query_api.query(query: query(reliable)).values
all_runs.each_with_object(Hash.new { |hsh, key| hsh[key] = {} }) do |table, result|
- records = table.records
- name = records.last.values["name"]
- file = records.last.values["file_path"].split("/").last
- stage = records.last.values["stage"] || "unknown"
+ records = table.records.sort_by { |record| record.values["_time"] }
+ # skip specs that executed less time than defined by range or stopped executing before report date
+ # offset 1 day due to how schedulers are configured and first run can be 1 day later
+ next if (Date.today - Date.parse(records.first.values["_time"])).to_i < (range - 1)
+ next if (Date.today - Date.parse(records.last.values["_time"])).to_i > 1
+
+ last_record = records.last.values
+ name = last_record["name"]
+ file = last_record["file_path"].split("/").last
+ stage = last_record["stage"] || "unknown"
runs = records.count
failed = records.count { |r| r.values["status"] == "failed" }
diff --git a/qa/qa/tools/revoke_all_personal_access_tokens.rb b/qa/qa/tools/revoke_all_personal_access_tokens.rb
index c0a1697fa16..b4fa02a36d4 100644
--- a/qa/qa/tools/revoke_all_personal_access_tokens.rb
+++ b/qa/qa/tools/revoke_all_personal_access_tokens.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
-require_relative '../../qa'
require 'net/protocol'
+
# This script revokes all personal access tokens with the name of 'api-test-token' on the host specified by GITLAB_ADDRESS
# Required environment variables: GITLAB_USERNAME, GITLAB_PASSWORD and GITLAB_ADDRESS
# Run `rake revoke_personal_access_tokens`
diff --git a/qa/qa/tools/test_resource_data_processor.rb b/qa/qa/tools/test_resource_data_processor.rb
new file mode 100644
index 00000000000..78fb6ef6cd0
--- /dev/null
+++ b/qa/qa/tools/test_resource_data_processor.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+# This script collects all resources created during each test execution
+# Save the data and write it to a JSON file at the end of suite
+
+module QA
+ module Tools
+ class TestResourceDataProcessor
+ @resources ||= Hash.new { |hsh, key| hsh[key] = [] }
+
+ class << self
+ # Ignoring rspec-mocks, sandbox, user and fork resources
+ # TODO: Will need to figure out which user resources can be collected, ignore for now
+ #
+ # Collecting resources created in E2E tests
+ # Data is a Hash of resources with keys as resource type (group, project, issue, etc.)
+ # Each type contains an array of resource object (hash) of the same type
+ # E.g: { "QA::Resource::Project": [ { info: 'foo', api_path: '/foo'}, {...} ] }
+ def collect(resource, info)
+ return if resource.api_response.nil? ||
+ resource.is_a?(RSpec::Mocks::Double) ||
+ resource.is_a?(Resource::Sandbox) ||
+ resource.is_a?(Resource::User) ||
+ resource.is_a?(Resource::Fork)
+
+ api_path = if resource.respond_to?(:api_delete_path)
+ resource.api_delete_path.gsub('%2F', '/')
+ elsif resource.respond_to?(:api_get_path)
+ resource.api_get_path.gsub('%2F', '/')
+ else
+ 'Cannot find resource API path'
+ end
+
+ type = resource.class.name
+
+ @resources[type] << { info: info, api_path: api_path }
+ end
+
+ # If JSON file exists and not empty, read and load file content
+ # Merge what is saved in @resources into the content from file
+ # Overwrite file content with the new data hash
+ # Otherwise create file and write data hash to file for the first time
+ def write_to_file
+ return if @resources.empty?
+
+ file = Runtime::Env.test_resources_created_filepath
+ FileUtils.mkdir_p('tmp/')
+ FileUtils.touch(file)
+ data = nil
+
+ if File.zero?(file)
+ data = @resources
+ else
+ data = JSON.parse(File.read(file))
+
+ @resources.each_pair do |key, val|
+ data[key].nil? ? data[key] = val : val.each { |item| data[key] << item }
+ end
+ end
+
+ File.open(file, 'w') { |f| f.write(JSON.pretty_generate(data.each_value(&:uniq!))) }
+ end
+ end
+ end
+ end
+end