gitlab_quality-test_tooling 2.22.0 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +1 -1
- data/exe/post-to-slack +4 -0
- data/exe/test-coverage +123 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb +98 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb +158 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb +62 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb +108 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb +73 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb +82 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb +91 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb +43 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/test_map.rb +93 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/utils.rb +18 -0
- data/lib/gitlab_quality/test_tooling/report/concerns/results_reporter.rb +1 -1
- data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +43 -1
- data/lib/gitlab_quality/test_tooling/slack/post_to_slack.rb +103 -3
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +1 -1
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- metadata +14 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53c1f715f9a4375070f34f4e06697da5e71fd3bcbf052fbdb7367c52db0c8532
|
|
4
|
+
data.tar.gz: 95512423771bf43470d38d5867407f2b6850d46dbedfdb19314f4d65ef23858e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 13ee76309735ad6662abaefc630c949be5b138abedf140c0e78096e8705faf9c148547d8d7ccb6059588c0bb805ae05a03dd8082a621510df09c7a44b39ed8e2
|
|
7
|
+
data.tar.gz: 8c9123729316f4e535ff958052b39e2f8e36e34bc8cb937a728cf87dd70954b3db7cb42f5e3c0fabcdd5131b534dd3bbab5bec35fbd73836570de1e4458c53c8
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -324,7 +324,7 @@ See an [example](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/92580) fo
|
|
|
324
324
|
|
|
325
325
|
[Automated gem release process](https://gitlab.com/gitlab-org/quality/pipeline-common#release-process) is used to release new version of `gitlab_quality-test_tooling` through pipelines, and this will:
|
|
326
326
|
|
|
327
|
-
- Publish the gem: https://rubygems.org/gems/gitlab_quality-test_tooling
|
|
327
|
+
- Publish the gem: https://rubygems.org/gems/gitlab_quality-test_tooling (once the version bump is done below in [Steps to release](https://gitlab.com/gitlab-org/ruby/gems/gitlab_quality-test_tooling#steps-to-release))
|
|
328
328
|
- Add a release in the `gitlab_quality-test_tooling` project: https://gitlab.com/gitlab-org/ruby/gems/gitlab_quality-test_tooling/-/releases
|
|
329
329
|
- Populate the release log with the API contents. For example: https://gitlab.com/api/v4/projects/19861191/repository/changelog?version=3.4.4
|
|
330
330
|
|
data/exe/post-to-slack
CHANGED
|
@@ -77,6 +77,10 @@ options = OptionParser.new do |opts|
|
|
|
77
77
|
params[:icon_emoji] = icon_emoji
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
opts.on('-e', '--environment-issues-file FILE', String, 'Add environment issues alert based on grouped failure data in a file') do |file|
|
|
81
|
+
params[:environment_issues_file] = file
|
|
82
|
+
end
|
|
83
|
+
|
|
80
84
|
opts.on_tail('-v', '--version', 'Show the version') do
|
|
81
85
|
require_relative "../lib/gitlab_quality/test_tooling/version"
|
|
82
86
|
puts "#{$PROGRAM_NAME} : #{GitlabQuality::TestTooling::VERSION}"
|
data/exe/test-coverage
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
require_relative "../lib/gitlab_quality/test_tooling"
|
|
7
|
+
|
|
8
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/category_owners'
|
|
9
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table'
|
|
10
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table'
|
|
11
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/coverage_data'
|
|
12
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/lcov_file'
|
|
13
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/artifacts'
|
|
14
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/rspec_report'
|
|
15
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
|
|
16
|
+
|
|
17
|
+
params = {}
|
|
18
|
+
required_params = [:working_dir, :rspec_reports_jobs, :rspec_reports_glob, :coverage_report_path, :test_map_url]
|
|
19
|
+
|
|
20
|
+
options = OptionParser.new do |opts|
|
|
21
|
+
opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
|
|
22
|
+
|
|
23
|
+
opts.on('--working_dir PATH', 'Working directory path') do |path|
|
|
24
|
+
params[:working_dir] = path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
opts.on('--rspec-reports-jobs JOB1,...', Array, 'Comma-separated names of RSpec JSON jobs') do |jobs|
|
|
28
|
+
params[:rspec_reports_jobs] = jobs
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
opts.on('--rspec-reports-glob GLOB', 'Glob pattern for RSpec JSON reports') do |pattern|
|
|
32
|
+
params[:rspec_reports_glob] = pattern
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
opts.on('--coverage-report-path PATH', 'Path to LCOV coverage report') do |path|
|
|
36
|
+
params[:coverage_report_path] = path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
opts.on('--test-map-url URL', 'URL for the test map') do |url|
|
|
40
|
+
params[:test_map_url] = url
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
opts.on('-h', '--help', 'Show the usage') do
|
|
44
|
+
puts opts
|
|
45
|
+
puts "\nExamples:"
|
|
46
|
+
puts " #{$PROGRAM_NAME}"
|
|
47
|
+
exit
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
opts.on_tail('-v', '--version', 'Show the version') do
|
|
51
|
+
require_relative "../lib/gitlab_quality/test_tooling/version"
|
|
52
|
+
puts "#{$PROGRAM_NAME} : #{GitlabQuality::TestTooling::VERSION}"
|
|
53
|
+
exit
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
opts.parse(ARGV)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if params.any? && (required_params - params.keys).none?
|
|
60
|
+
artifacts = GitlabQuality::TestTooling::CodeCoverage::Artifacts.new(
|
|
61
|
+
working_dir: params[:working_dir],
|
|
62
|
+
rspec_reports_jobs: params[:rspec_reports_jobs],
|
|
63
|
+
rspec_reports_glob: params[:rspec_reports_glob],
|
|
64
|
+
coverage_report_path: params[:coverage_report_path],
|
|
65
|
+
test_map_url: params[:test_map_url]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
coverage_report = artifacts.coverage_report
|
|
69
|
+
rspec_reports = artifacts.rspec_reports
|
|
70
|
+
test_map = artifacts.test_map
|
|
71
|
+
|
|
72
|
+
code_coverage_by_source_file = GitlabQuality::TestTooling::CodeCoverage::LcovFile.new(coverage_report).parsed_content
|
|
73
|
+
|
|
74
|
+
source_file_to_tests = GitlabQuality::TestTooling::CodeCoverage::TestMap.new(test_map).source_to_tests
|
|
75
|
+
|
|
76
|
+
tests_to_categories = rspec_reports.reduce({}) do |combined_hash, rspec_report_file|
|
|
77
|
+
file_categories = GitlabQuality::TestTooling::CodeCoverage::RspecReport.new(rspec_report_file).tests_to_categories
|
|
78
|
+
combined_hash.merge(file_categories) { |_, old_val, new_val| (old_val + new_val).uniq }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
category_owners = GitlabQuality::TestTooling::CodeCoverage::CategoryOwners.new
|
|
82
|
+
|
|
83
|
+
coverage_data = GitlabQuality::TestTooling::CodeCoverage::CoverageData.new(
|
|
84
|
+
code_coverage_by_source_file,
|
|
85
|
+
source_file_to_tests,
|
|
86
|
+
tests_to_categories,
|
|
87
|
+
category_owners.categories_to_teams
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if ENV.fetch('CLICKHOUSE_URL', nil) &&
|
|
91
|
+
ENV.fetch('CLICKHOUSE_DATABASE', nil) &&
|
|
92
|
+
ENV.fetch('CLICKHOUSE_USERNAME', nil) &&
|
|
93
|
+
ENV.fetch('CLICKHOUSE_PASSWORD', nil)
|
|
94
|
+
|
|
95
|
+
clickhouse_data = {
|
|
96
|
+
url: ENV.fetch('CLICKHOUSE_URL', nil),
|
|
97
|
+
database: ENV.fetch('CLICKHOUSE_DATABASE', nil),
|
|
98
|
+
username: ENV.fetch('CLICKHOUSE_USERNAME', nil),
|
|
99
|
+
password: ENV.fetch('CLICKHOUSE_PASSWORD', nil)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
category_owners_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CategoryOwnersTable.new(**clickhouse_data)
|
|
103
|
+
coverage_metrics_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CoverageMetricsTable.new(**clickhouse_data)
|
|
104
|
+
|
|
105
|
+
category_owners_table.create if ENV['CLICKHOUSE_CREATE_CATEGORY_OWNERS_TABLE'] == 'true'
|
|
106
|
+
coverage_metrics_table.create if ENV['CLICKHOUSE_CREATE_COVERAGE_METRICS_TABLE'] == 'true'
|
|
107
|
+
|
|
108
|
+
if ENV['CLICKHOUSE_PUSH_CATEGORY_DATA'] == 'true'
|
|
109
|
+
category_owners_table.truncate
|
|
110
|
+
category_owners_table.push(category_owners.as_db_table)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
coverage_metrics_table.push(coverage_data.as_db_table)
|
|
114
|
+
else
|
|
115
|
+
puts "ClickHouse configuration not found.\n" \
|
|
116
|
+
'Set CLICKHOUSE_URL, CLICKHOUSE_DATABASE, CLICKHOUSE_USERNAME, ' \
|
|
117
|
+
'CLICKHOUSE_PASSWORD environment variables to enable ClickHouse export.'
|
|
118
|
+
end
|
|
119
|
+
else
|
|
120
|
+
puts "Missing argument(s). Required arguments are: #{required_params}\nPassed arguments are: #{params}\n"
|
|
121
|
+
puts options
|
|
122
|
+
exit 1
|
|
123
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'zlib'
|
|
8
|
+
|
|
9
|
+
require_relative 'utils'
|
|
10
|
+
|
|
11
|
+
module GitlabQuality
|
|
12
|
+
module TestTooling
|
|
13
|
+
module CodeCoverage
|
|
14
|
+
class Artifacts
|
|
15
|
+
include Utils
|
|
16
|
+
|
|
17
|
+
MAX_RETRIES = 7 # retries with exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 64s (+ 0-1s jitter)
|
|
18
|
+
|
|
19
|
+
def initialize(working_dir:, coverage_report_path:, rspec_reports_jobs:, rspec_reports_glob:, test_map_url:)
|
|
20
|
+
@working_dir = working_dir
|
|
21
|
+
@rspec_reports_jobs = rspec_reports_jobs
|
|
22
|
+
@rspec_reports_glob = rspec_reports_glob
|
|
23
|
+
@coverage_report_path = coverage_report_path
|
|
24
|
+
@test_map_uri = URI.parse(test_map_url)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def rspec_reports
|
|
28
|
+
@rspec_report_files ||= rspec_reports_paths.map do |report_path|
|
|
29
|
+
JSON.parse(File.read(report_path))
|
|
30
|
+
rescue JSON::ParserError => e
|
|
31
|
+
raise "Invalid JSON in RSpec report file #{report_path}: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def coverage_report
|
|
36
|
+
@coverage_report ||= read_coverage_reports
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_map
|
|
40
|
+
@test_map ||= fetch_test_map_from_url
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def rspec_reports_paths
|
|
46
|
+
@rspec_reports_paths ||= begin
|
|
47
|
+
paths = @rspec_reports_jobs.flat_map do |job_name|
|
|
48
|
+
Dir.glob(File.join(@working_dir, job_name, @rspec_reports_glob))
|
|
49
|
+
end.compact
|
|
50
|
+
|
|
51
|
+
if paths.empty? || paths.none? { |path| File.exist?(path) } # rubocop:disable Style/IfUnlessModifier
|
|
52
|
+
raise "No RSpec reports found in #{@working_dir}/<job-name>/ for these jobs: #{@rspec_reports_jobs}."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
paths
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def read_coverage_reports
|
|
60
|
+
raise "Coverage report not found in: #{@coverage_report_path}" unless File.exist?(@coverage_report_path)
|
|
61
|
+
|
|
62
|
+
File.read(@coverage_report_path)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def fetch_test_map_from_url
|
|
66
|
+
attempt = 0
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
attempt += 1
|
|
70
|
+
response = http_get(@test_map_uri)
|
|
71
|
+
raise "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
72
|
+
|
|
73
|
+
JSON.parse(decompressed_gzip(response.body))
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
if attempt <= MAX_RETRIES
|
|
76
|
+
sleep_duration = exponential_delay_with_jitter(attempt)
|
|
77
|
+
warn "Attempt #{attempt}/#{MAX_RETRIES} failed: #{e.message}. Retrying in #{sleep_duration.round(2)}s..."
|
|
78
|
+
sleep(sleep_duration)
|
|
79
|
+
retry
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
raise "Failed to fetch test map from #{@test_map_uri} after #{MAX_RETRIES} attempts: #{e.message}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def http_get(uri)
|
|
87
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
|
88
|
+
http.get(uri.request_uri)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def decompressed_gzip(gzipped_data)
|
|
93
|
+
Zlib::GzipReader.new(StringIO.new(gzipped_data)).read
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
require_relative 'utils'
|
|
7
|
+
|
|
8
|
+
module GitlabQuality
|
|
9
|
+
module TestTooling
|
|
10
|
+
module CodeCoverage
|
|
11
|
+
class CategoryOwners
|
|
12
|
+
include Utils
|
|
13
|
+
|
|
14
|
+
SOURCE_URL = URI('https://gitlab.com/gitlab-com/www-gitlab-com/raw/master/data/stages.yml')
|
|
15
|
+
BASE_DELAY = 1 # seconds
|
|
16
|
+
MAX_RETRIES = 3
|
|
17
|
+
|
|
18
|
+
# @return [Hash] Category ownership hierarchy, section -> stage -> group -> [categories]
|
|
19
|
+
# @example Return value
|
|
20
|
+
# {
|
|
21
|
+
# "team_planning" => { # section
|
|
22
|
+
# "project_management" => { # stage
|
|
23
|
+
# "plan" => [ # group
|
|
24
|
+
# "dev", # category
|
|
25
|
+
# "service_desk" # category
|
|
26
|
+
# ],
|
|
27
|
+
# "product_planning" => [ # group
|
|
28
|
+
# "portfolio_management", # category
|
|
29
|
+
# ...
|
|
30
|
+
# ]
|
|
31
|
+
# }
|
|
32
|
+
# },
|
|
33
|
+
# ...
|
|
34
|
+
# }
|
|
35
|
+
attr_reader :hierarchy
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@categories_map = {}
|
|
39
|
+
@hierarchy = {}
|
|
40
|
+
|
|
41
|
+
yaml_file = fetch_yaml_file
|
|
42
|
+
yaml_content = YAML.load(yaml_file)
|
|
43
|
+
populate_ownership_hierarchy(yaml_content)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Array<Hash>] Flattened category ownership
|
|
47
|
+
# @example Return value
|
|
48
|
+
# [
|
|
49
|
+
# { category: "team_planning", group: "project_management", stage: "plan", section: "dev" },
|
|
50
|
+
# { category: "service_desk", group: "project_management", stage: "plan", section: "dev" },
|
|
51
|
+
# { category: "portfolio_management", group: "product_planning", stage: "plan", section: "dev" }
|
|
52
|
+
# ...
|
|
53
|
+
# ]
|
|
54
|
+
def as_db_table
|
|
55
|
+
hierarchy.each_with_object([]) do |(section, stages), flattened_hierarchy|
|
|
56
|
+
next unless stages
|
|
57
|
+
|
|
58
|
+
stages.each do |stage, groups|
|
|
59
|
+
next unless groups
|
|
60
|
+
|
|
61
|
+
groups.each do |group, categories|
|
|
62
|
+
Array(categories).each do |category|
|
|
63
|
+
flattened_hierarchy << {
|
|
64
|
+
category: category,
|
|
65
|
+
group: group,
|
|
66
|
+
stage: stage,
|
|
67
|
+
section: section
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Hash] Mapping of categories to teams (i.e., groups, stages, sections)
|
|
76
|
+
# @example Return value
|
|
77
|
+
# {
|
|
78
|
+
# "team_planning" => { group: "project_management", stage: "plan", section: "dev" },
|
|
79
|
+
# "service_desk" => { group: "project_management", stage: "plan", section: "dev" },
|
|
80
|
+
# "portfolio_management" => { group: "product_planning", stage: "plan", section: "dev" },
|
|
81
|
+
# ...
|
|
82
|
+
# }
|
|
83
|
+
def categories_to_teams
|
|
84
|
+
populate_categories_map(hierarchy)
|
|
85
|
+
@categories_map
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def fetch_yaml_file
|
|
91
|
+
retries = 0
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
response = Net::HTTP.get_response(SOURCE_URL)
|
|
95
|
+
raise "Failed to fetch file: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
96
|
+
|
|
97
|
+
response.body
|
|
98
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT, IOError, RuntimeError => e
|
|
99
|
+
retries += 1
|
|
100
|
+
raise "Failed to fetch YAML after #{MAX_RETRIES} retries: #{e.message}" if retries >= MAX_RETRIES
|
|
101
|
+
|
|
102
|
+
delay = exponential_delay_with_jitter(retries)
|
|
103
|
+
warn "Fetch attempt #{retries} failed: #{e.message}. Retrying in #{delay.round(2)}s..."
|
|
104
|
+
sleep(delay)
|
|
105
|
+
retry
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def populate_ownership_hierarchy(data)
|
|
110
|
+
stages = data['stages'] || {}
|
|
111
|
+
|
|
112
|
+
stages.each do |stage, stage_data|
|
|
113
|
+
next unless stage_data.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
section = stage_data['section']
|
|
116
|
+
groups = stage_data['groups'] || {}
|
|
117
|
+
next unless section
|
|
118
|
+
|
|
119
|
+
groups.each { |group, group_data| add_hierarchy_entry(section, stage, group, group_data['categories']) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_hierarchy_entry(section, stage, group, categories)
|
|
124
|
+
@hierarchy[section] ||= {}
|
|
125
|
+
@hierarchy[section][stage] ||= {}
|
|
126
|
+
@hierarchy[section][stage][group] = categories || []
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def populate_categories_map(data, current_section = nil, current_stage = nil, current_group = nil)
|
|
130
|
+
case data
|
|
131
|
+
when Hash # Sections / Stages / Groups
|
|
132
|
+
data.each do |key, value|
|
|
133
|
+
if current_section.nil? # Sections
|
|
134
|
+
populate_categories_map(value, key, nil, nil)
|
|
135
|
+
elsif current_stage.nil? # Stages
|
|
136
|
+
populate_categories_map(value, current_section, key, nil)
|
|
137
|
+
elsif current_group.nil? # Groups
|
|
138
|
+
populate_categories_map(value, current_section, current_stage, key)
|
|
139
|
+
else # Categories
|
|
140
|
+
populate_categories_map(value, current_section, current_stage, current_group)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
when Array # Categories array
|
|
144
|
+
data.each do |category|
|
|
145
|
+
next unless category.is_a?(String)
|
|
146
|
+
|
|
147
|
+
@categories_map[category] = {
|
|
148
|
+
section: current_section,
|
|
149
|
+
stage: current_stage,
|
|
150
|
+
group: current_group
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'table'
|
|
4
|
+
|
|
5
|
+
module GitlabQuality
|
|
6
|
+
module TestTooling
|
|
7
|
+
module CodeCoverage
|
|
8
|
+
module ClickHouse
|
|
9
|
+
class CategoryOwnersTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
|
|
10
|
+
TABLE_NAME = "category_owners"
|
|
11
|
+
|
|
12
|
+
# Creates the ClickHouse table, if it doesn't exist already
|
|
13
|
+
# @return [nil]
|
|
14
|
+
def create
|
|
15
|
+
logger.debug("#{LOG_PREFIX} Creating category_owners table if it doesn't exist ...")
|
|
16
|
+
|
|
17
|
+
client.query(<<~SQL)
|
|
18
|
+
CREATE TABLE IF NOT EXISTS #{table_name} (
|
|
19
|
+
timestamp DateTime64(6, 'UTC') DEFAULT now64(),
|
|
20
|
+
category String,
|
|
21
|
+
group String,
|
|
22
|
+
stage String,
|
|
23
|
+
section String,
|
|
24
|
+
INDEX idx_group group TYPE set(360) GRANULARITY 1,
|
|
25
|
+
INDEX idx_stage stage TYPE set(360) GRANULARITY 1,
|
|
26
|
+
INDEX idx_section section TYPE set(360) GRANULARITY 1
|
|
27
|
+
) ENGINE = MergeTree()
|
|
28
|
+
ORDER BY (category, timestamp)
|
|
29
|
+
SETTINGS index_granularity = 8192;
|
|
30
|
+
SQL
|
|
31
|
+
|
|
32
|
+
logger.info("#{LOG_PREFIX} Category owners table created/verified successfully")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def truncate
|
|
36
|
+
logger.debug("#{LOG_PREFIX} Truncating table #{full_table_name} ...")
|
|
37
|
+
|
|
38
|
+
client.query("TRUNCATE TABLE #{full_table_name}")
|
|
39
|
+
|
|
40
|
+
logger.info("#{LOG_PREFIX} Successfully truncated table #{full_table_name}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] True if the record is valid, false otherwise
|
|
46
|
+
def valid_record?(record)
|
|
47
|
+
required_fields = %i[category group stage section]
|
|
48
|
+
|
|
49
|
+
required_fields.each do |field|
|
|
50
|
+
if record[field].nil?
|
|
51
|
+
logger.warn("#{LOG_PREFIX} Skipping record with nil #{field}: #{record}")
|
|
52
|
+
return false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'table'
|
|
4
|
+
|
|
5
|
+
module GitlabQuality
|
|
6
|
+
module TestTooling
|
|
7
|
+
module CodeCoverage
|
|
8
|
+
module ClickHouse
|
|
9
|
+
class CoverageMetricsTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
|
|
10
|
+
TABLE_NAME = "coverage_metrics"
|
|
11
|
+
|
|
12
|
+
# Creates the ClickHouse table, if it doesn't exist already
|
|
13
|
+
# @return [nil]
|
|
14
|
+
def create
|
|
15
|
+
logger.debug("#{LOG_PREFIX} Creating coverage_metrics table if it doesn't exist ...")
|
|
16
|
+
|
|
17
|
+
client.query(<<~SQL)
|
|
18
|
+
CREATE TABLE IF NOT EXISTS #{table_name} (
|
|
19
|
+
timestamp DateTime64(6, 'UTC'),
|
|
20
|
+
file String,
|
|
21
|
+
coverage Float64,
|
|
22
|
+
category Nullable(String),
|
|
23
|
+
ci_project_id Nullable(UInt32),
|
|
24
|
+
ci_project_path Nullable(String),
|
|
25
|
+
ci_job_name Nullable(String),
|
|
26
|
+
ci_job_id Nullable(UInt64),
|
|
27
|
+
ci_pipeline_id Nullable(UInt64),
|
|
28
|
+
ci_merge_request_iid Nullable(UInt32),
|
|
29
|
+
ci_branch Nullable(String),
|
|
30
|
+
ci_target_branch Nullable(String)
|
|
31
|
+
) ENGINE = MergeTree()
|
|
32
|
+
PARTITION BY toYYYYMM(timestamp)
|
|
33
|
+
ORDER BY (ci_project_path, timestamp, file, ci_pipeline_id)
|
|
34
|
+
SETTINGS index_granularity = 8192, allow_nullable_key = 1;
|
|
35
|
+
SQL
|
|
36
|
+
|
|
37
|
+
logger.info("#{LOG_PREFIX} Coverage metrics table created/verified successfully")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @return [Boolean] True if the record is valid, false otherwise
|
|
43
|
+
def valid_record?(record)
|
|
44
|
+
if record[:file].nil?
|
|
45
|
+
logger.warn("#{LOG_PREFIX} Skipping record with nil file: #{record}")
|
|
46
|
+
return false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if record[:coverage].nil?
|
|
50
|
+
logger.warn("#{LOG_PREFIX} Skipping record with nil coverage: #{record}")
|
|
51
|
+
return false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if record[:coverage].nan?
|
|
55
|
+
logger.warn("#{LOG_PREFIX} Skipping record with NaN coverage: #{record}")
|
|
56
|
+
return false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Hash] Transformed coverage data including timestamp and CI metadata
|
|
63
|
+
def sanitized_data_record(record)
|
|
64
|
+
{
|
|
65
|
+
timestamp: time,
|
|
66
|
+
file: record[:file],
|
|
67
|
+
coverage: record[:coverage],
|
|
68
|
+
category: record[:category],
|
|
69
|
+
**ci_metadata
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Time] Common timestamp for all coverage records
|
|
74
|
+
def time
|
|
75
|
+
@time ||= begin
|
|
76
|
+
ci_created_at = ENV.fetch('CI_PIPELINE_CREATED_AT', nil)
|
|
77
|
+
ci_created_at ? Time.strptime(ci_created_at, '%Y-%m-%dT%H:%M:%S%z') : Time.now.utc
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Hash] CI-related metadata
|
|
82
|
+
def ci_metadata
|
|
83
|
+
{
|
|
84
|
+
ci_project_id: env_to_int('CI_PROJECT_ID'),
|
|
85
|
+
ci_project_path: ENV.fetch('CI_PROJECT_PATH', nil),
|
|
86
|
+
ci_job_name: ENV.fetch('CI_JOB_NAME', nil)&.gsub(%r{ \d{1,2}/\d{1,2}}, ''),
|
|
87
|
+
ci_job_id: env_to_int('CI_JOB_ID'),
|
|
88
|
+
ci_pipeline_id: env_to_int('CI_PIPELINE_ID'),
|
|
89
|
+
ci_merge_request_iid: env_to_int('CI_MERGE_REQUEST_IID') || env_to_int('TOP_UPSTREAM_MERGE_REQUEST_IID'),
|
|
90
|
+
ci_branch: ENV.fetch('CI_COMMIT_REF_NAME', nil),
|
|
91
|
+
ci_target_branch: ENV.fetch('CI_MERGE_REQUEST_TARGET_BRANCH_NAME', nil)
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param name [String] Environment variable name
|
|
96
|
+
# @return [Integer, nil] Environment variable converted to integer or
|
|
97
|
+
# nil if not present or empty
|
|
98
|
+
def env_to_int(name)
|
|
99
|
+
value = ENV.fetch(name, nil)
|
|
100
|
+
return nil if value.nil? || value.empty?
|
|
101
|
+
|
|
102
|
+
value.to_i
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabQuality
|
|
4
|
+
module TestTooling
|
|
5
|
+
module CodeCoverage
|
|
6
|
+
module ClickHouse
|
|
7
|
+
class Table
|
|
8
|
+
LOG_PREFIX = "[CodeCoverage]"
|
|
9
|
+
|
|
10
|
+
def initialize(url:, database:, username: nil, password: nil, logger: nil)
|
|
11
|
+
@url = url
|
|
12
|
+
@database = database
|
|
13
|
+
@username = username
|
|
14
|
+
@password = password
|
|
15
|
+
@logger = logger || Runtime::Logger.logger
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param data [Array<Hash>] Code coverage related data to be pushed to ClickHouse
|
|
19
|
+
# @return [nil]
|
|
20
|
+
def push(data) # rubocop:disable Metrics/AbcSize
|
|
21
|
+
return logger.warn("#{LOG_PREFIX} No data found, skipping ClickHouse export!") if data.empty?
|
|
22
|
+
|
|
23
|
+
logger.debug("#{LOG_PREFIX} Starting data export to ClickHouse")
|
|
24
|
+
sanitized_data = sanitize(data)
|
|
25
|
+
|
|
26
|
+
client.insert_json_data(table_name, sanitized_data)
|
|
27
|
+
logger.info("#{LOG_PREFIX} Successfully pushed #{sanitized_data.size} records to #{full_table_name}!")
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
logger.error("#{LOG_PREFIX} Error occurred while pushing data to #{full_table_name}: #{e.message}")
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :url, :database, :username, :password, :logger
|
|
36
|
+
|
|
37
|
+
def sanitize(data)
|
|
38
|
+
data.filter_map { |record| sanitized_data_record(record) if valid_record?(record) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def sanitized_data_record(record)
|
|
42
|
+
record
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def full_table_name
|
|
46
|
+
"#{database}.#{table_name}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def table_name
|
|
50
|
+
self.class::TABLE_NAME
|
|
51
|
+
rescue NameError
|
|
52
|
+
raise NotImplementedError, "#{self.class} must define the TABLE_NAME constant"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def valid_record?(_record)
|
|
56
|
+
raise NotImplementedError, "#{self.class}##{__method__} method must be implemented in a subclass"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [GitlabQuality::TestTooling::ClickHouse::Client]
|
|
60
|
+
def client
|
|
61
|
+
@client ||= GitlabQuality::TestTooling::ClickHouse::Client.new(
|
|
62
|
+
url: url,
|
|
63
|
+
database: database,
|
|
64
|
+
username: username,
|
|
65
|
+
password: password,
|
|
66
|
+
logger: logger
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabQuality
|
|
4
|
+
module TestTooling
|
|
5
|
+
module CodeCoverage
|
|
6
|
+
class CoverageData
|
|
7
|
+
# @param [Hash<String, Hash>] code_coverage_by_source_file Source file
|
|
8
|
+
# mapped to test coverage data
|
|
9
|
+
# @param [Hash<String, Array<String>>] source_file_to_tests Source files
|
|
10
|
+
# mapped to all test files testing them
|
|
11
|
+
# @param [Hash<String, Array<String>>] tests_to_categories Test files
|
|
12
|
+
# mapped to all feature categories they belong to
|
|
13
|
+
# @param [Hash<String, Hash>] categories_to_teams Mapping of categories
|
|
14
|
+
# to teams (i.e., groups, stages, sections)
|
|
15
|
+
def initialize(code_coverage_by_source_file, source_file_to_tests, tests_to_categories, categories_to_teams)
|
|
16
|
+
@code_coverage_by_source_file = code_coverage_by_source_file
|
|
17
|
+
@source_file_to_tests = source_file_to_tests
|
|
18
|
+
@tests_to_categories = tests_to_categories
|
|
19
|
+
@categories_to_teams = categories_to_teams
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Array<Hash<Symbol, String>>] Mapping of column name to row
|
|
23
|
+
# value
|
|
24
|
+
# @example Return value
|
|
25
|
+
# [
|
|
26
|
+
# {
|
|
27
|
+
# file: "app/channels/application_cable/channel.rb"
|
|
28
|
+
# coverage: 100.0
|
|
29
|
+
# category: "team_planning"
|
|
30
|
+
# group: "project_management"
|
|
31
|
+
# stage: "plan"
|
|
32
|
+
# section: "dev"
|
|
33
|
+
# },
|
|
34
|
+
# ...
|
|
35
|
+
# ]
|
|
36
|
+
def as_db_table
|
|
37
|
+
all_files.flat_map do |file|
|
|
38
|
+
coverage = @code_coverage_by_source_file[file]&.dig(:percentage)
|
|
39
|
+
categories = categories_for(file)
|
|
40
|
+
if categories.empty?
|
|
41
|
+
{ file: file, coverage: coverage }.merge(no_owner_info)
|
|
42
|
+
else
|
|
43
|
+
categories.map do |category|
|
|
44
|
+
{ file: file, coverage: coverage }.merge(owner_info(category))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def no_owner_info
|
|
53
|
+
{
|
|
54
|
+
category: nil,
|
|
55
|
+
group: nil,
|
|
56
|
+
stage: nil,
|
|
57
|
+
section: nil
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def owner_info(category)
|
|
62
|
+
owner_info = @categories_to_teams[category]
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
category: category,
|
|
66
|
+
group: owner_info&.dig(:group),
|
|
67
|
+
stage: owner_info&.dig(:stage),
|
|
68
|
+
section: owner_info&.dig(:section)
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def categories_for(file)
|
|
73
|
+
@source_file_to_tests[file]&.flat_map { |test_file| @tests_to_categories[test_file] || [] }&.uniq || []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def all_files
|
|
77
|
+
@all_files ||= @code_coverage_by_source_file.keys
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitlabQuality
|
|
4
|
+
module TestTooling
|
|
5
|
+
module CodeCoverage
|
|
6
|
+
class LcovFile
|
|
7
|
+
# @param [String] lcov_file_content The content of the lcov file
|
|
8
|
+
def initialize(lcov_file_content)
|
|
9
|
+
@lcov_file_content = lcov_file_content
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Hash<String, Hash>] The parsed content of the lcov file
|
|
13
|
+
# @example Return value
|
|
14
|
+
# {
|
|
15
|
+
# "path/to/file1.rb" => {
|
|
16
|
+
# line_coverage: { 1 => 1, 2 => 0 },
|
|
17
|
+
# branch_coverage: {},
|
|
18
|
+
# total_lines: 2,
|
|
19
|
+
# covered_lines: 1,
|
|
20
|
+
# percentage: 50.0
|
|
21
|
+
# },
|
|
22
|
+
# ...
|
|
23
|
+
# }
|
|
24
|
+
def parsed_content
|
|
25
|
+
return @parsed_content if @parsed_content
|
|
26
|
+
|
|
27
|
+
@parsed_content = {}
|
|
28
|
+
@current_file = nil
|
|
29
|
+
|
|
30
|
+
@lcov_file_content.each_line do |line|
|
|
31
|
+
case line
|
|
32
|
+
when /^SF:(.+)$/
|
|
33
|
+
register_source_file(::Regexp.last_match(1))
|
|
34
|
+
when /^DA:(\d+),(\d+)$/
|
|
35
|
+
register_line_data(::Regexp.last_match(1), ::Regexp.last_match(2))
|
|
36
|
+
when /^BRDA:(\d+),(\d+),(\d+),(-|\d+)$/
|
|
37
|
+
register_branch_data(::Regexp.last_match(1), ::Regexp.last_match(4))
|
|
38
|
+
when /^end_of_record$/
|
|
39
|
+
@current_file = nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
include_coverage
|
|
44
|
+
@parsed_content
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def include_coverage
|
|
50
|
+
@parsed_content.each_key do |file|
|
|
51
|
+
# TODO: Support branch coverage too
|
|
52
|
+
@parsed_content[file].merge!(line_coverage_for(file))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def line_coverage_for(file)
|
|
57
|
+
data = @parsed_content[file]
|
|
58
|
+
return unless data
|
|
59
|
+
|
|
60
|
+
total_lines = data[:line_coverage].size
|
|
61
|
+
covered_lines = data[:line_coverage].values.count(&:positive?)
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
total_lines: total_lines,
|
|
65
|
+
covered_lines: covered_lines,
|
|
66
|
+
percentage: total_lines.zero? ? 0.0 : (covered_lines.to_f / total_lines * 100).round(2)
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def register_source_file(filename)
|
|
71
|
+
@current_file = filename.gsub(%r{^\./}, '')
|
|
72
|
+
@parsed_content[@current_file] = { line_coverage: {}, branch_coverage: {} }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def register_line_data(line_no, count)
|
|
76
|
+
return unless @current_file
|
|
77
|
+
|
|
78
|
+
@parsed_content[@current_file][:line_coverage][line_no.to_i] = count.to_i
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def register_branch_data(line_no, taken)
|
|
82
|
+
return unless @current_file
|
|
83
|
+
|
|
84
|
+
taken_count = taken == '-' ? 0 : taken.to_i
|
|
85
|
+
@parsed_content[@current_file][:branch_coverage][line_no.to_i] ||= []
|
|
86
|
+
@parsed_content[@current_file][:branch_coverage][line_no.to_i] << taken_count
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module GitlabQuality
|
|
6
|
+
module TestTooling
|
|
7
|
+
module CodeCoverage
|
|
8
|
+
class RspecReport
|
|
9
|
+
# @param [Hash<String, Object>] rspec_report The content of an RSpec
|
|
10
|
+
# report
|
|
11
|
+
def initialize(rspec_report)
|
|
12
|
+
@rspec_report = rspec_report
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [Array<Hash<String, String>>] Content of the "examples"
|
|
16
|
+
# section of the RSpec report
|
|
17
|
+
def examples
|
|
18
|
+
@examples ||= @rspec_report['examples']
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Hash<String, Array<String>>] Test files mapped to all feature
|
|
22
|
+
# categories they belong to
|
|
23
|
+
# @example Return value
|
|
24
|
+
# {
|
|
25
|
+
# "spec/path/to/file_spec.rb" => [
|
|
26
|
+
# "feature_category1", "feature_category2"
|
|
27
|
+
# ],
|
|
28
|
+
# ...
|
|
29
|
+
# }
|
|
30
|
+
def tests_to_categories
|
|
31
|
+
@tests_to_categories ||= examples.to_a.filter_map do |example|
|
|
32
|
+
next unless example.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
file_path = example['file_path']
|
|
35
|
+
next unless file_path.is_a?(String)
|
|
36
|
+
|
|
37
|
+
[file_path.gsub('./', ''), Array(example['feature_category']).compact]
|
|
38
|
+
end.to_h
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module GitlabQuality
|
|
6
|
+
module TestTooling
|
|
7
|
+
module CodeCoverage
|
|
8
|
+
class TestMap
|
|
9
|
+
SEPARATOR = '/'
|
|
10
|
+
MARKER = 1
|
|
11
|
+
|
|
12
|
+
# @param [Hash] compact_map A nested hash structure where keys are
|
|
13
|
+
# source files and values are tree structures with test file paths
|
|
14
|
+
# @example Example of compact_map for a file tested by 3 spec files
|
|
15
|
+
# {
|
|
16
|
+
# "app/models/user.rb" => {
|
|
17
|
+
# "spec" => {
|
|
18
|
+
# "models" => {
|
|
19
|
+
# "user_spec.rb" => 1 # MARKER (1) indicates a leaf node
|
|
20
|
+
# }
|
|
21
|
+
# },
|
|
22
|
+
# "ee" => {
|
|
23
|
+
# "spec" => {
|
|
24
|
+
# "lib" => {
|
|
25
|
+
# "ee" => {
|
|
26
|
+
# "gitlab" => {
|
|
27
|
+
# "background_migration" => {
|
|
28
|
+
# "delete_invalid_epic_issues_spec.rb"=>1,
|
|
29
|
+
# "backfill_security_policies_spec.rb"=>1
|
|
30
|
+
# }
|
|
31
|
+
# }
|
|
32
|
+
# }
|
|
33
|
+
# }
|
|
34
|
+
# }
|
|
35
|
+
# }
|
|
36
|
+
# }
|
|
37
|
+
# }
|
|
38
|
+
def initialize(compact_map)
|
|
39
|
+
@compact_map = compact_map
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Hash<String, Array<String>>] Source files mapped to all test
|
|
43
|
+
# files testing them
|
|
44
|
+
# @example Return value
|
|
45
|
+
# {
|
|
46
|
+
# "path/to/file1.rb" => [
|
|
47
|
+
# "spec/path/to/file1_spec.rb",
|
|
48
|
+
# "spec/path/to/another/file1_spec.rb"
|
|
49
|
+
# ],
|
|
50
|
+
# ...
|
|
51
|
+
# }
|
|
52
|
+
def source_to_tests
|
|
53
|
+
@source_to_tests ||= @compact_map.transform_values { |tree| traverse(tree).to_a.uniq }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Hash<String, Array<String>>] Test files mapped to all source
|
|
57
|
+
# files tested by them
|
|
58
|
+
# @example Return value
|
|
59
|
+
# {
|
|
60
|
+
# "spec/path/to/file1_spec.rb" => [
|
|
61
|
+
# "path/to/file1.rb",
|
|
62
|
+
# "path/to/file2.rb"
|
|
63
|
+
# ],
|
|
64
|
+
# ...
|
|
65
|
+
# }
|
|
66
|
+
def test_to_sources
|
|
67
|
+
@test_to_sources ||= begin
|
|
68
|
+
test_to_sources = Hash.new { |hash, key| hash[key] = [] }
|
|
69
|
+
|
|
70
|
+
@compact_map.each do |source_file, tree|
|
|
71
|
+
traverse(tree).to_a.each do |test_file|
|
|
72
|
+
test_to_sources[test_file] << source_file
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
test_to_sources
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def traverse(tree, segments = [], &block)
|
|
83
|
+
return to_enum(__method__, tree, segments) unless block
|
|
84
|
+
return yield segments.join(SEPARATOR) if tree == MARKER && !segments.empty?
|
|
85
|
+
|
|
86
|
+
tree.each do |key, value|
|
|
87
|
+
traverse(value, segments + [key], &block)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module GitlabQuality
|
|
7
|
+
module TestTooling
|
|
8
|
+
module CodeCoverage
|
|
9
|
+
module Utils
|
|
10
|
+
def exponential_delay_with_jitter(attempt)
|
|
11
|
+
exponential_delay = (2**(attempt - 1))
|
|
12
|
+
jitter = rand # 0-1 seconds
|
|
13
|
+
exponential_delay + jitter
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -71,6 +71,7 @@ module GitlabQuality
|
|
|
71
71
|
exclude_labels_for_search: nil,
|
|
72
72
|
metrics_files: [],
|
|
73
73
|
group_similar: false,
|
|
74
|
+
environment_issues_output_file: nil,
|
|
74
75
|
**kwargs)
|
|
75
76
|
super
|
|
76
77
|
@max_diff_ratio = max_diff_ratio.to_f
|
|
@@ -81,11 +82,13 @@ module GitlabQuality
|
|
|
81
82
|
@commented_issue_list = Set.new
|
|
82
83
|
@metrics_files = Array(metrics_files)
|
|
83
84
|
@group_similar = group_similar
|
|
85
|
+
@environment_issues_output_file = environment_issues_output_file
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
private
|
|
87
89
|
|
|
88
|
-
attr_reader :max_diff_ratio, :system_logs, :base_issue_labels, :exclude_labels_for_search, :metrics_files, :ops_gitlab_client, :group_similar
|
|
90
|
+
attr_reader :max_diff_ratio, :system_logs, :base_issue_labels, :exclude_labels_for_search, :metrics_files, :ops_gitlab_client, :group_similar,
|
|
91
|
+
:environment_issues_output_file
|
|
89
92
|
|
|
90
93
|
def run!
|
|
91
94
|
puts "Reporting test failures in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
|
|
@@ -123,6 +126,8 @@ module GitlabQuality
|
|
|
123
126
|
|
|
124
127
|
grouper.process_failures(failure_data)
|
|
125
128
|
grouper.process_issues
|
|
129
|
+
|
|
130
|
+
export_environment_issues_for_slack(environment_issues_output_file) if environment_issues_output_file
|
|
126
131
|
end
|
|
127
132
|
|
|
128
133
|
def collect_all_test_results
|
|
@@ -690,6 +695,43 @@ module GitlabQuality
|
|
|
690
695
|
|
|
691
696
|
"|| [Screenshot](#{ci_job_url}/artifacts/file/#{screenshot_path})"
|
|
692
697
|
end
|
|
698
|
+
|
|
699
|
+
def export_environment_issues_for_slack(output_file)
|
|
700
|
+
return unless similar_issues_grouped?
|
|
701
|
+
|
|
702
|
+
File.write(output_file, build_environment_issues_data.to_json)
|
|
703
|
+
rescue StandardError => e
|
|
704
|
+
puts "Warning: Failed to export environment issues for Slack: #{e.message}"
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def build_environment_issues_data
|
|
708
|
+
{
|
|
709
|
+
grouped_failures: format_grouped_failures,
|
|
710
|
+
summary: grouper.summary
|
|
711
|
+
}
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def format_grouped_failures
|
|
715
|
+
grouper.grouped_failures.map do |_fingerprint, grouped_failure|
|
|
716
|
+
{
|
|
717
|
+
fingerprint: grouped_failure[:fingerprint],
|
|
718
|
+
pattern_name: grouped_failure[:pattern_name],
|
|
719
|
+
normalized_message: grouped_failure[:normalized_message],
|
|
720
|
+
failure_count: grouped_failure[:failures].size,
|
|
721
|
+
failures: format_individual_failures(grouped_failure[:failures])
|
|
722
|
+
}
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def format_individual_failures(failures)
|
|
727
|
+
failures.map do |failure|
|
|
728
|
+
{
|
|
729
|
+
description: failure[:description],
|
|
730
|
+
file_path: failure[:file_path],
|
|
731
|
+
ci_job_url: failure[:ci_job_url]
|
|
732
|
+
}
|
|
733
|
+
end
|
|
734
|
+
end
|
|
693
735
|
end
|
|
694
736
|
end
|
|
695
737
|
end
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module GitlabQuality
|
|
4
6
|
module TestTooling
|
|
5
7
|
module Slack
|
|
6
8
|
class PostToSlack
|
|
7
|
-
|
|
9
|
+
MAX_PATTERN_MESSAGE_LENGTH = 150
|
|
10
|
+
MAX_GROUPED_FAILURES_TO_DISPLAY = 10
|
|
11
|
+
|
|
12
|
+
def initialize(slack_webhook_url:, channel:, message:, username:, icon_emoji:, environment_issues_file: nil)
|
|
8
13
|
@slack_webhook_url = slack_webhook_url
|
|
9
14
|
@channel = channel
|
|
10
15
|
@message = message
|
|
11
16
|
@username = username
|
|
12
17
|
@icon_emoji = icon_emoji
|
|
18
|
+
@environment_issues_file = environment_issues_file
|
|
13
19
|
end
|
|
14
20
|
|
|
15
21
|
def invoke!
|
|
@@ -17,7 +23,7 @@ module GitlabQuality
|
|
|
17
23
|
params['channel'] = channel
|
|
18
24
|
params['username'] = username
|
|
19
25
|
params['icon_emoji'] = icon_emoji
|
|
20
|
-
params['text'] =
|
|
26
|
+
params['text'] = build_message
|
|
21
27
|
|
|
22
28
|
Support::HttpRequest.make_http_request(
|
|
23
29
|
method: 'post',
|
|
@@ -29,7 +35,101 @@ module GitlabQuality
|
|
|
29
35
|
|
|
30
36
|
private
|
|
31
37
|
|
|
32
|
-
attr_reader :slack_webhook_url, :channel, :message, :username, :icon_emoji
|
|
38
|
+
attr_reader :slack_webhook_url, :channel, :message, :username, :icon_emoji, :environment_issues_file
|
|
39
|
+
|
|
40
|
+
def build_message
|
|
41
|
+
messages = []
|
|
42
|
+
messages << message if message && !message.empty?
|
|
43
|
+
messages << format_environment_issues if environment_issues_file && File.exist?(environment_issues_file)
|
|
44
|
+
|
|
45
|
+
messages.join("\n\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def format_environment_issues
|
|
49
|
+
issues_data = JSON.parse(File.read(environment_issues_file))
|
|
50
|
+
return nil if issues_data.nil? || issues_data['grouped_failures'].empty?
|
|
51
|
+
|
|
52
|
+
build_slack_message(issues_data)
|
|
53
|
+
rescue JSON::ParserError => e
|
|
54
|
+
":x: Error parsing environment issues file: #{e.message}"
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
":x: Error formatting environment issues: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def format_single_environment_issue(failure)
|
|
60
|
+
pattern_title = pattern_display_name(failure['pattern_name'])
|
|
61
|
+
|
|
62
|
+
issue_text = build_issue_header(pattern_title, failure)
|
|
63
|
+
issue_text + build_job_info(failure)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def pattern_display_name(pattern_name)
|
|
67
|
+
case pattern_name&.downcase
|
|
68
|
+
when /http_500/
|
|
69
|
+
"HTTP 500 Internal Server Errors"
|
|
70
|
+
when /http_400/
|
|
71
|
+
"HTTP 400 Bad Request Errors"
|
|
72
|
+
when /http_503/
|
|
73
|
+
"HTTP 503 Service Unavailable"
|
|
74
|
+
when /timeout/
|
|
75
|
+
"Timeout Errors"
|
|
76
|
+
when /git_rpc|repository/
|
|
77
|
+
"Git/Repository Errors"
|
|
78
|
+
else
|
|
79
|
+
"#{pattern_name&.humanize || 'Unknown'} Errors"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_slack_message(issues_data)
|
|
84
|
+
header = ":warning: *Environment Issues Detected*\n"
|
|
85
|
+
|
|
86
|
+
issue_messages = format_issue_messages(issues_data['grouped_failures'])
|
|
87
|
+
truncation_note = build_truncation_note(issues_data['grouped_failures'].size)
|
|
88
|
+
summary = build_summary_text(issues_data['summary'])
|
|
89
|
+
|
|
90
|
+
header + issue_messages + truncation_note + summary
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def format_issue_messages(grouped_failures)
|
|
94
|
+
failures_to_show = grouped_failures.first(MAX_GROUPED_FAILURES_TO_DISPLAY)
|
|
95
|
+
failures_to_show.map { |failure| format_single_environment_issue(failure) }.join("\n\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_truncation_note(total_failures)
|
|
99
|
+
return "" unless total_failures > MAX_GROUPED_FAILURES_TO_DISPLAY
|
|
100
|
+
|
|
101
|
+
"\n_... and #{total_failures - MAX_GROUPED_FAILURES_TO_DISPLAY} more environment issue(s)_"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_issue_header(pattern_title, failure)
|
|
105
|
+
<<~TEXT
|
|
106
|
+
*#{pattern_title}*
|
|
107
|
+
• Affected tests: #{failure['failure_count']}
|
|
108
|
+
• Pattern: `#{truncate_message(failure['normalized_message'])}`
|
|
109
|
+
TEXT
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_job_info(failure)
|
|
113
|
+
return "" unless failure['failures']&.any?
|
|
114
|
+
|
|
115
|
+
job_urls = failure['failures'].filter_map { |f| f['ci_job_url'] }.uniq
|
|
116
|
+
job_urls.any? ? "• Jobs affected: #{job_urls.size}\n" : ""
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_summary_text(summary)
|
|
120
|
+
<<~TEXT
|
|
121
|
+
|
|
122
|
+
*Summary:* #{summary['grouped_issues']} environment issue(s) affecting #{summary['total_grouped_failures']} test(s)
|
|
123
|
+
|
|
124
|
+
_Note: Future improvements will include direct GitLab issue links and enhanced filtering._
|
|
125
|
+
_Track progress: https://gitlab.com/groups/gitlab-org/quality/quality-engineering/-/epics/168_
|
|
126
|
+
TEXT
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def truncate_message(message)
|
|
130
|
+
text = message.to_s
|
|
131
|
+
text.length > MAX_PATTERN_MESSAGE_LENGTH ? "#{text[0..MAX_PATTERN_MESSAGE_LENGTH]}..." : text
|
|
132
|
+
end
|
|
33
133
|
end
|
|
34
134
|
end
|
|
35
135
|
end
|
|
@@ -78,7 +78,7 @@ module GitlabQuality
|
|
|
78
78
|
)
|
|
79
79
|
ENGINE = MergeTree()
|
|
80
80
|
PARTITION BY toYYYYMM(timestamp)
|
|
81
|
-
ORDER BY (ci_project_path,
|
|
81
|
+
ORDER BY (ci_project_path, status, run_type, feature_category, file_path, timestamp, ci_pipeline_id)
|
|
82
82
|
SETTINGS index_granularity = 8192;
|
|
83
83
|
SQL
|
|
84
84
|
return if config.extra_metadata_columns.empty?
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitlab_quality-test_tooling
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.24.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GitLab Quality
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-10-
|
|
11
|
+
date: 2025-10-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: climate_control
|
|
@@ -449,6 +449,7 @@ executables:
|
|
|
449
449
|
- report-results
|
|
450
450
|
- slow-test-issues
|
|
451
451
|
- slow-test-merge-request-report-note
|
|
452
|
+
- test-coverage
|
|
452
453
|
- update-screenshot-paths
|
|
453
454
|
- update-test-meta
|
|
454
455
|
extensions: []
|
|
@@ -483,11 +484,22 @@ files:
|
|
|
483
484
|
- exe/report-results
|
|
484
485
|
- exe/slow-test-issues
|
|
485
486
|
- exe/slow-test-merge-request-report-note
|
|
487
|
+
- exe/test-coverage
|
|
486
488
|
- exe/update-screenshot-paths
|
|
487
489
|
- exe/update-test-meta
|
|
488
490
|
- lefthook.yml
|
|
489
491
|
- lib/gitlab_quality/test_tooling.rb
|
|
490
492
|
- lib/gitlab_quality/test_tooling/click_house/client.rb
|
|
493
|
+
- lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb
|
|
494
|
+
- lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb
|
|
495
|
+
- lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb
|
|
496
|
+
- lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb
|
|
497
|
+
- lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb
|
|
498
|
+
- lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb
|
|
499
|
+
- lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb
|
|
500
|
+
- lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb
|
|
501
|
+
- lib/gitlab_quality/test_tooling/code_coverage/test_map.rb
|
|
502
|
+
- lib/gitlab_quality/test_tooling/code_coverage/utils.rb
|
|
491
503
|
- lib/gitlab_quality/test_tooling/concerns/find_set_dri.rb
|
|
492
504
|
- lib/gitlab_quality/test_tooling/failed_jobs_table.rb
|
|
493
505
|
- lib/gitlab_quality/test_tooling/feature_readiness/analyzed_items/analyzed_epic.rb
|