gitlab_quality-test_tooling 2.23.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c015244f26f542bc2a00450d783e83ce4067e785e4f3dcdd242178b5144a722
4
- data.tar.gz: 46f7ed259a123fcee7fb3ce26f8e31aa15beed4f3eac08f41dfadb2715abfa76
3
+ metadata.gz: 53c1f715f9a4375070f34f4e06697da5e71fd3bcbf052fbdb7367c52db0c8532
4
+ data.tar.gz: 95512423771bf43470d38d5867407f2b6850d46dbedfdb19314f4d65ef23858e
5
5
  SHA512:
6
- metadata.gz: d5e93d7172b231765c6e58bd92eb567cb02fd1fdd522f749241359ce38af74ea058c8323a749e6eb54c5de6091236b3ab8a7494aa4da9870f0dfc5f7d1e2e41f
7
- data.tar.gz: c9f71fd4df1d8af85264396cd0625e2b7cbaab934d164037c8edfc8bc90d542c780ea2394b9950c61e7e909cfcd40d482e66332c3cfecf1f2e58e29c80a77461
6
+ metadata.gz: 13ee76309735ad6662abaefc630c949be5b138abedf140c0e78096e8705faf9c148547d8d7ccb6059588c0bb805ae05a03dd8082a621510df09c7a44b39ed8e2
7
+ data.tar.gz: 8c9123729316f4e535ff958052b39e2f8e36e34bc8cb937a728cf87dd70954b3db7cb42f5e3c0fabcdd5131b534dd3bbab5bec35fbd73836570de1e4458c53c8
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (2.23.0)
4
+ gitlab_quality-test_tooling (2.24.0)
5
5
  activesupport (>= 7.0, < 7.3)
6
6
  amatch (~> 0.4.1)
7
7
  fog-google (~> 1.24, >= 1.24.1)
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/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
@@ -36,7 +36,7 @@ module GitlabQuality
36
36
  end
37
37
 
38
38
  def new_issue_labels(_test)
39
- %w[E2E status::automated]
39
+ %w[E2E status::automated suppress-contributor-links]
40
40
  end
41
41
 
42
42
  def up_to_date_labels(test:, issue: nil, new_labels: Set.new)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "2.23.0"
5
+ VERSION = "2.24.0"
6
6
  end
7
7
  end
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.23.0
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-17 00:00:00.000000000 Z
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