gitlab_quality-test_tooling 2.27.0 → 3.1.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: 9627a5294f93832f5513406c664eb60589037f0317bca00ad7dd7d9a6668ee85
4
- data.tar.gz: 540672e53766c7c64743575b77600ffeda9916e7aa8337d269405eadb80bec12
3
+ metadata.gz: be0541b0d2e39102c7665bb733cdd068467959cf727bee7cafc385d2117b2689
4
+ data.tar.gz: 0dcc0e738cc51add35954ad281e7c0a86aa8662956e6133644f72bafd28b03a5
5
5
  SHA512:
6
- metadata.gz: a5c0623b34d029ac924e8cb52ba3b8e22999c246c17b947c29995ccdb61d1b7738fa57428afea6a8bf77138b235e780dc6d2e53e270b5691762e083025249782
7
- data.tar.gz: 1e9f61e86067efcd19c3f6e945d75e8bbc797383c3c7e0f0b5f9aa656edf020fa42a4236f49a02b63a6651af2a3f81a9b1433cde5853d4d2f0b16d9875304f5a
6
+ metadata.gz: a9f3927bc5646c115610015be3b780408518de92b8ebd741fc9270605fbf93e4a976b7eeb460a0d988b11cbadbfe154ccdb937da24f05637ae5574cb2cdd0f18
7
+ data.tar.gz: 9c641bc6e4f22c57fd16b5481a47d09ac45eac346b0b52da7f70ba3f4f4954d8e25e56fa54e9a8df5f939da95fb54c09d8dafc0d9f9e6e3733e7e1bc9e85da37
data/Gemfile.lock CHANGED
@@ -1,8 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (2.27.0)
5
- activesupport (>= 7.0, < 7.3)
4
+ gitlab_quality-test_tooling (3.1.0)
5
+ activesupport (>= 7.0)
6
6
  amatch (~> 0.4.1)
7
7
  fog-google (~> 1.24, >= 1.24.1)
8
8
  gitlab (>= 4.19, < 7.0)
data/exe/test-coverage CHANGED
@@ -12,12 +12,12 @@ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/c
12
12
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/coverage_data'
13
13
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/lcov_file'
14
14
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/artifacts'
15
- require_relative '../lib/gitlab_quality/test_tooling/code_coverage/rspec_report'
16
15
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_report'
17
16
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
17
+ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier'
18
18
 
19
19
  params = {}
20
- required_params = [:coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username]
20
+ required_params = [:test_reports, :coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username]
21
21
 
22
22
  options = OptionParser.new do |opts|
23
23
  opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
@@ -26,14 +26,10 @@ options = OptionParser.new do |opts|
26
26
  opts.separator "Options:"
27
27
 
28
28
  opts.on('--test-reports GLOB',
29
- 'Glob pattern for test JSON reports (RSpec or Jest) (e.g., "reports/**/*.json"). Takes precedence over --rspec-reports if both are provided.') do |pattern|
29
+ 'Glob pattern for test JSON reports (RSpec or Jest) (e.g., "reports/**/*.json")') do |pattern|
30
30
  params[:test_reports] = pattern
31
31
  end
32
32
 
33
- opts.on('--rspec-reports GLOB', '[DEPRECATED] Use --test-reports instead. Glob pattern for RSpec JSON reports. Ignored if --test-reports is also provided.') do |pattern|
34
- params[:rspec_reports] = pattern
35
- end
36
-
37
33
  opts.on('--coverage-report PATH', 'Path to the LCOV coverage report (e.g., "coverage/lcov/gitlab.lcov")') do |path|
38
34
  params[:coverage_report] = path
39
35
  end
@@ -100,17 +96,10 @@ if params.any? && (required_params - params.keys).none?
100
96
  exit 1
101
97
  end
102
98
 
103
- # Validate that at least one of test_reports or rspec_reports is provided
104
- if params[:test_reports].nil? && params[:rspec_reports].nil?
105
- puts "Error: At least one of --test-reports or --rspec-reports must be provided"
106
- exit 1
107
- end
108
-
109
99
  artifacts = GitlabQuality::TestTooling::CodeCoverage::Artifacts.new(
110
100
  coverage_report: params[:coverage_report],
111
101
  test_map: params[:test_map],
112
- test_reports: params[:test_reports],
113
- rspec_reports: params[:rspec_reports]
102
+ test_reports: params[:test_reports]
114
103
  )
115
104
 
116
105
  coverage_report = artifacts.coverage_report
@@ -120,28 +109,24 @@ if params.any? && (required_params - params.keys).none?
120
109
 
121
110
  source_file_to_tests = GitlabQuality::TestTooling::CodeCoverage::TestMap.new(test_map).source_to_tests
122
111
 
123
- # Process test reports using the new unified approach or fall back to legacy rspec_reports
124
- tests_to_categories = if params[:test_reports]
125
- # Use new TestReport class for unified test reports
126
- artifacts.test_reports.reduce({}) do |combined_hash, test_report_file|
127
- file_categories = GitlabQuality::TestTooling::CodeCoverage::TestReport.new(test_report_file).tests_to_categories
128
- combined_hash.merge(file_categories) { |_, old_val, new_val| (old_val + new_val).uniq }
129
- end
130
- else
131
- # Fall back to legacy RspecReport for backward compatibility
132
- artifacts.rspec_reports.reduce({}) do |combined_hash, rspec_report_file|
133
- file_categories = GitlabQuality::TestTooling::CodeCoverage::RspecReport.new(rspec_report_file).tests_to_categories
134
- combined_hash.merge(file_categories) { |_, old_val, new_val| (old_val + new_val).uniq }
135
- end
136
- end
112
+ # Process test reports
113
+ tests_to_categories = artifacts.test_reports.reduce({}) do |combined_hash, test_report_file|
114
+ file_categories = GitlabQuality::TestTooling::CodeCoverage::TestReport.new(test_report_file).tests_to_categories
115
+ combined_hash.merge(file_categories) { |_, old_val, new_val| (old_val + new_val).uniq }
116
+ end
137
117
 
138
118
  category_owners = GitlabQuality::TestTooling::CodeCoverage::CategoryOwners.new
139
119
 
120
+ # Classify source files by type (frontend, backend, etc.)
121
+ source_file_classifier = GitlabQuality::TestTooling::CodeCoverage::SourceFileClassifier.new
122
+ source_file_types = source_file_classifier.classify(code_coverage_by_source_file.keys)
123
+
140
124
  coverage_data = GitlabQuality::TestTooling::CodeCoverage::CoverageData.new(
141
125
  code_coverage_by_source_file,
142
126
  source_file_to_tests,
143
127
  tests_to_categories,
144
- category_owners.categories_to_teams
128
+ category_owners.categories_to_teams,
129
+ source_file_types
145
130
  )
146
131
 
147
132
  clickhouse_data = {
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'stringio'
5
5
  require 'zlib'
6
+ require 'active_support/core_ext/object/blank'
6
7
 
7
8
  module GitlabQuality
8
9
  module TestTooling
@@ -10,13 +11,13 @@ module GitlabQuality
10
11
  class Artifacts
11
12
  # Loads coverage artifacts from the filesystem
12
13
  #
13
- # @param test_reports [String, nil] Glob pattern for test JSON report files (RSpec or Jest) (e.g., "reports/**/*.json")
14
- # @param rspec_reports [String, nil] [DEPRECATED] Use test_reports instead. Glob pattern for RSpec JSON report files
14
+ # @param test_reports [String] Glob pattern for test JSON report files (RSpec or Jest) (e.g., "reports/**/*.json")
15
15
  # @param coverage_report [String] Path to the LCOV coverage report file (e.g., "coverage/lcov/gitlab.lcov")
16
16
  # @param test_map [String] Path to the test map file, gzipped or plain JSON (e.g., "crystalball/packed-mapping.json.gz")
17
- def initialize(coverage_report:, test_map:, test_reports: nil, rspec_reports: nil)
17
+ def initialize(coverage_report:, test_map:, test_reports:)
18
+ raise ArgumentError, "test_reports cannot be blank" if test_reports.blank?
19
+
18
20
  @test_reports_glob = test_reports
19
- @rspec_reports_glob = rspec_reports
20
21
  @coverage_report_path = coverage_report
21
22
  @test_map_path = test_map
22
23
  end
@@ -33,19 +34,6 @@ module GitlabQuality
33
34
  end
34
35
  end
35
36
 
36
- # Loads and parses RSpec JSON report files
37
- #
38
- # @deprecated Use {#test_reports} instead
39
- # @return [Array<Hash>] Array of parsed JSON RSpec reports
40
- # @raise [RuntimeError] If no RSpec reports are found or if JSON parsing fails
41
- def rspec_reports
42
- @rspec_report_files ||= rspec_reports_paths.map do |report_path|
43
- JSON.parse(File.read(report_path))
44
- rescue JSON::ParserError => e
45
- raise "Invalid JSON in RSpec report file #{report_path}: #{e.message}"
46
- end
47
- end
48
-
49
37
  # Loads the LCOV coverage report file
50
38
  #
51
39
  # @return [String] Raw content of the LCOV coverage report
@@ -65,8 +53,6 @@ module GitlabQuality
65
53
  private
66
54
 
67
55
  def test_reports_paths
68
- return [] if @test_reports_glob.nil? || @test_reports_glob.empty?
69
-
70
56
  @test_reports_paths ||= begin
71
57
  paths = Dir.glob(@test_reports_glob)
72
58
 
@@ -76,18 +62,6 @@ module GitlabQuality
76
62
  end
77
63
  end
78
64
 
79
- def rspec_reports_paths
80
- return [] if @rspec_reports_glob.nil? || @rspec_reports_glob.empty?
81
-
82
- @rspec_reports_paths ||= begin
83
- paths = Dir.glob(@rspec_reports_glob)
84
-
85
- raise "No RSpec reports found matching pattern: #{@rspec_reports_glob}" if paths.empty?
86
-
87
- paths
88
- end
89
- end
90
-
91
65
  def read_coverage_reports
92
66
  raise "Coverage report not found in: #{@coverage_report_path}" unless File.exist?(@coverage_report_path)
93
67
 
@@ -19,7 +19,10 @@ module GitlabQuality
19
19
  CREATE TABLE IF NOT EXISTS #{table_name} (
20
20
  timestamp DateTime64(6, 'UTC'),
21
21
  file String,
22
- coverage Float64,
22
+ line_coverage Float64,
23
+ branch_coverage Nullable(Float64),
24
+ function_coverage Nullable(Float64),
25
+ source_file_type String,
23
26
  category Nullable(String),
24
27
  ci_project_id Nullable(UInt32),
25
28
  ci_project_path Nullable(String),
@@ -42,22 +45,47 @@ module GitlabQuality
42
45
 
43
46
  # @return [Boolean] True if the record is valid, false otherwise
44
47
  def valid_record?(record)
45
- if record[:file].nil?
46
- logger.warn("#{LOG_PREFIX} Skipping record with nil file: #{record}")
47
- return false
48
- end
48
+ valid_file?(record) &&
49
+ valid_line_coverage?(record) &&
50
+ valid_branch_coverage?(record) &&
51
+ valid_function_coverage?(record)
52
+ end
49
53
 
50
- if record[:coverage].nil?
51
- logger.warn("#{LOG_PREFIX} Skipping record with nil coverage: #{record}")
52
- return false
53
- end
54
+ # @return [Boolean] True if the file field is present
55
+ def valid_file?(record)
56
+ return true unless record[:file].nil?
57
+
58
+ logger.warn("#{LOG_PREFIX} Skipping record with nil file: #{record}")
59
+ false
60
+ end
54
61
 
55
- if record[:coverage].nan?
56
- logger.warn("#{LOG_PREFIX} Skipping record with NaN coverage: #{record}")
62
+ # @return [Boolean] True if line_coverage is present and not NaN
63
+ def valid_line_coverage?(record)
64
+ if record[:line_coverage].nil?
65
+ logger.warn("#{LOG_PREFIX} Skipping record with nil line_coverage: #{record}")
57
66
  return false
58
67
  end
59
68
 
60
- true
69
+ return true unless record[:line_coverage].nan?
70
+
71
+ logger.warn("#{LOG_PREFIX} Skipping record with NaN line_coverage: #{record}")
72
+ false
73
+ end
74
+
75
+ # @return [Boolean] True if branch_coverage is not NaN (or is nil)
76
+ def valid_branch_coverage?(record)
77
+ return true unless record[:branch_coverage]&.nan?
78
+
79
+ logger.warn("#{LOG_PREFIX} Skipping record with NaN branch_coverage: #{record}")
80
+ false
81
+ end
82
+
83
+ # @return [Boolean] True if function_coverage is not NaN (or is nil)
84
+ def valid_function_coverage?(record)
85
+ return true unless record[:function_coverage]&.nan?
86
+
87
+ logger.warn("#{LOG_PREFIX} Skipping record with NaN function_coverage: #{record}")
88
+ false
61
89
  end
62
90
 
63
91
  # @return [Hash] Transformed coverage data including timestamp and CI metadata
@@ -65,7 +93,10 @@ module GitlabQuality
65
93
  {
66
94
  timestamp: time,
67
95
  file: record[:file],
68
- coverage: record[:coverage],
96
+ line_coverage: record[:line_coverage],
97
+ branch_coverage: record[:branch_coverage],
98
+ function_coverage: record[:function_coverage],
99
+ source_file_type: record[:source_file_type],
69
100
  category: record[:category],
70
101
  **ci_metadata
71
102
  }
@@ -23,6 +23,8 @@ module GitlabQuality
23
23
  logger.debug("#{LOG_PREFIX} Starting data export to ClickHouse")
24
24
  sanitized_data = sanitize(data)
25
25
 
26
+ return logger.warn("#{LOG_PREFIX} No valid data found after sanitization, skipping ClickHouse export!") if sanitized_data.empty?
27
+
26
28
  client.insert_json_data(table_name, sanitized_data)
27
29
  logger.info("#{LOG_PREFIX} Successfully pushed #{sanitized_data.size} records to #{full_table_name}!")
28
30
  rescue StandardError => e
@@ -12,11 +12,14 @@ module GitlabQuality
12
12
  # mapped to all feature categories they belong to
13
13
  # @param [Hash<String, Hash>] categories_to_teams Mapping of categories
14
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)
15
+ # @param [Hash<String, String>] source_file_types Mapping of source files
16
+ # to their types (frontend, backend, etc.)
17
+ def initialize(code_coverage_by_source_file, source_file_to_tests, tests_to_categories, categories_to_teams, source_file_types = {})
16
18
  @code_coverage_by_source_file = code_coverage_by_source_file
17
19
  @source_file_to_tests = source_file_to_tests
18
20
  @tests_to_categories = tests_to_categories
19
21
  @categories_to_teams = categories_to_teams
22
+ @source_file_types = source_file_types
20
23
  end
21
24
 
22
25
  # @return [Array<Hash<Symbol, String>>] Mapping of column name to row
@@ -25,7 +28,10 @@ module GitlabQuality
25
28
  # [
26
29
  # {
27
30
  # file: "app/channels/application_cable/channel.rb"
28
- # coverage: 100.0
31
+ # line_coverage: 100.0
32
+ # branch_coverage: 95.0
33
+ # function_coverage: 100.0
34
+ # source_file_type: "backend"
29
35
  # category: "team_planning"
30
36
  # group: "project_management"
31
37
  # stage: "plan"
@@ -35,13 +41,25 @@ module GitlabQuality
35
41
  # ]
36
42
  def as_db_table
37
43
  all_files.flat_map do |file|
38
- coverage = @code_coverage_by_source_file[file]&.dig(:percentage)
44
+ coverage_data = @code_coverage_by_source_file[file]
45
+ line_coverage = coverage_data&.dig(:percentage)
46
+ branch_coverage = coverage_data&.dig(:branch_percentage)
47
+ function_coverage = coverage_data&.dig(:function_percentage)
48
+
39
49
  categories = categories_for(file)
50
+ base_data = {
51
+ file: file,
52
+ line_coverage: line_coverage,
53
+ branch_coverage: branch_coverage,
54
+ function_coverage: function_coverage,
55
+ source_file_type: @source_file_types[file] || 'other'
56
+ }
57
+
40
58
  if categories.empty?
41
- { file: file, coverage: coverage }.merge(no_owner_info)
59
+ base_data.merge(no_owner_info)
42
60
  else
43
61
  categories.map do |category|
44
- { file: file, coverage: coverage }.merge(owner_info(category))
62
+ base_data.merge(owner_info(category))
45
63
  end
46
64
  end
47
65
  end
@@ -17,7 +17,13 @@ module GitlabQuality
17
17
  # branch_coverage: {},
18
18
  # total_lines: 2,
19
19
  # covered_lines: 1,
20
- # percentage: 50.0
20
+ # percentage: 50.0,
21
+ # total_branches: 4,
22
+ # covered_branches: 3,
23
+ # branch_percentage: 75.0,
24
+ # total_functions: 2,
25
+ # covered_functions: 1,
26
+ # function_percentage: 50.0
21
27
  # },
22
28
  # ...
23
29
  # }
@@ -27,18 +33,7 @@ module GitlabQuality
27
33
  @parsed_content = {}
28
34
  @current_file = nil
29
35
 
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
36
+ @lcov_file_content.each_line { |line| parse_line(line) }
42
37
 
43
38
  include_coverage
44
39
  @parsed_content
@@ -46,10 +41,32 @@ module GitlabQuality
46
41
 
47
42
  private
48
43
 
44
+ def parse_line(line)
45
+ case line
46
+ when /^SF:(.+)$/
47
+ register_source_file(::Regexp.last_match(1))
48
+ when /^DA:(\d+),(\d+)$/
49
+ register_line_data(::Regexp.last_match(1), ::Regexp.last_match(2))
50
+ when /^BRDA:(\d+),(\d+),(\d+),(-|\d+)$/
51
+ register_branch_data(::Regexp.last_match(1), ::Regexp.last_match(4))
52
+ when /^FNF:(\d+)$/
53
+ register_functions_found(::Regexp.last_match(1))
54
+ when /^FNH:(\d+)$/
55
+ register_functions_hit(::Regexp.last_match(1))
56
+ when /^BRF:(\d+)$/
57
+ register_branches_found(::Regexp.last_match(1))
58
+ when /^BRH:(\d+)$/
59
+ register_branches_hit(::Regexp.last_match(1))
60
+ when /^end_of_record$/
61
+ @current_file = nil
62
+ end
63
+ end
64
+
49
65
  def include_coverage
50
66
  @parsed_content.each_key do |file|
51
- # TODO: Support branch coverage too
52
67
  @parsed_content[file].merge!(line_coverage_for(file))
68
+ @parsed_content[file].merge!(branch_coverage_for(file))
69
+ @parsed_content[file].merge!(function_coverage_for(file))
53
70
  end
54
71
  end
55
72
 
@@ -69,7 +86,12 @@ module GitlabQuality
69
86
 
70
87
  def register_source_file(filename)
71
88
  @current_file = filename.gsub(%r{^\./}, '')
72
- @parsed_content[@current_file] = { line_coverage: {}, branch_coverage: {} }
89
+ @parsed_content[@current_file] = {
90
+ line_coverage: {},
91
+ branch_coverage: {},
92
+ branches: { found: 0, hit: 0 },
93
+ functions: { found: 0, hit: 0 }
94
+ }
73
95
  end
74
96
 
75
97
  def register_line_data(line_no, count)
@@ -85,6 +107,62 @@ module GitlabQuality
85
107
  @parsed_content[@current_file][:branch_coverage][line_no.to_i] ||= []
86
108
  @parsed_content[@current_file][:branch_coverage][line_no.to_i] << taken_count
87
109
  end
110
+
111
+ def register_functions_found(count)
112
+ return unless @current_file
113
+
114
+ @parsed_content[@current_file][:functions][:found] = count.to_i
115
+ end
116
+
117
+ def register_functions_hit(count)
118
+ return unless @current_file
119
+
120
+ @parsed_content[@current_file][:functions][:hit] = count.to_i
121
+ end
122
+
123
+ def register_branches_found(count)
124
+ return unless @current_file
125
+
126
+ @parsed_content[@current_file][:branches][:found] = count.to_i
127
+ end
128
+
129
+ def register_branches_hit(count)
130
+ return unless @current_file
131
+
132
+ @parsed_content[@current_file][:branches][:hit] = count.to_i
133
+ end
134
+
135
+ def branch_coverage_for(file)
136
+ data = @parsed_content[file]
137
+ return {} unless data && data[:branches]
138
+
139
+ total = data[:branches][:found]
140
+ covered = data[:branches][:hit]
141
+
142
+ return {} if total.nil? || total.zero?
143
+
144
+ {
145
+ total_branches: total,
146
+ covered_branches: covered,
147
+ branch_percentage: (covered.to_f / total * 100).round(2)
148
+ }
149
+ end
150
+
151
+ def function_coverage_for(file)
152
+ data = @parsed_content[file]
153
+ return {} unless data && data[:functions]
154
+
155
+ total = data[:functions][:found]
156
+ covered = data[:functions][:hit]
157
+
158
+ return {} if total.nil? || total.zero?
159
+
160
+ {
161
+ total_functions: total,
162
+ covered_functions: covered,
163
+ function_percentage: (covered.to_f / total * 100).round(2)
164
+ }
165
+ end
88
166
  end
89
167
  end
90
168
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module CodeCoverage
6
+ class SourceFileClassifier
7
+ PATTERNS = {
8
+ 'frontend' => [
9
+ %r{^app/assets/javascripts/.*\.(js|jsx|ts|tsx|vue)$},
10
+ %r{^app/assets/stylesheets/.*\.(css|scss)$},
11
+ %r{^ee/app/assets/javascripts/.*\.(js|jsx|ts|tsx|vue)$},
12
+ %r{^ee/app/assets/stylesheets/.*\.(css|scss)$},
13
+ %r{^jh/app/assets/javascripts/.*\.(js|jsx|ts|tsx|vue)$},
14
+ %r{^spec/frontend/},
15
+ %r{^ee/spec/frontend/},
16
+ %r{^spec/frontend_integration/},
17
+ %r{^app/assets/javascripts/.*\.graphql$},
18
+ /\.stories\.js$/
19
+ ],
20
+ 'backend' => [
21
+ %r{^app/(models|controllers|services|workers|helpers|mailers|policies|presenters|uploaders|validators|enums|events|experiments|facades|channels)/.*\.rb$},
22
+ %r{^app/serializers/.*\.rb$},
23
+ %r{^app/graphql/.*\.rb$},
24
+ %r{^app/components/.*\.rb$},
25
+ %r{^app/views/.*\.(haml|erb)$},
26
+ %r{^lib/.*\.rb$},
27
+ %r{^lib/api/.*\.rb$},
28
+ %r{^ee/app/.*\.rb$},
29
+ %r{^ee/lib/.*\.rb$},
30
+ %r{^jh/app/.*\.rb$},
31
+ %r{^jh/lib/.*\.rb$},
32
+ %r{^spec/.*_spec\.rb$},
33
+ %r{^ee/spec/.*_spec\.rb$},
34
+ %r{^lib/tasks/.*\.rake$}
35
+ ],
36
+ 'database' => [
37
+ %r{^db/migrate/.*\.rb$},
38
+ %r{^db/post_migrate/.*\.rb$},
39
+ %r{^ee/db/geo/migrate/.*\.rb$},
40
+ %r{^db/structure\.sql$},
41
+ %r{^db/seeds\.rb$},
42
+ %r{^db/fixtures/}
43
+ ],
44
+ 'infrastructure' => [
45
+ /^\.gitlab-ci\.yml$/,
46
+ %r{^\.gitlab/ci/.*\.(yml|yaml)$},
47
+ /Dockerfile/,
48
+ /\.dockerfile$/,
49
+ %r{^scripts/pipeline/}
50
+ ],
51
+ 'qa' => [
52
+ %r{^qa/.*\.rb$}
53
+ ],
54
+ 'workhorse' => [
55
+ %r{^workhorse/.*\.go$}
56
+ ],
57
+ 'tooling' => [
58
+ %r{^tooling/.*\.(rb|js)$},
59
+ %r{^rubocop/.*\.rb$},
60
+ %r{^danger/.*\.rb$},
61
+ /^\.rubocop\.yml$/
62
+ ],
63
+ 'configuration' => [
64
+ %r{^config/.*\.(yml|yaml|rb)$}
65
+ ]
66
+ }.freeze
67
+
68
+ # Classifies a collection of file paths into their respective types
69
+ #
70
+ # @param file_paths [Array<String>] List of file paths to classify
71
+ # @return [Hash<String, String>] Hash mapping file path to file type
72
+ def classify(file_paths)
73
+ file_paths.each_with_object({}) do |file_path, result|
74
+ result[file_path] = determine_type(file_path)
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ # Determines the type of a single file based on pattern matching
81
+ #
82
+ # @param file_path [String] The file path to classify
83
+ # @return [String] The file type category
84
+ def determine_type(file_path)
85
+ PATTERNS.each do |type, patterns|
86
+ return type if patterns.any? { |pattern| file_path.match?(pattern) }
87
+ end
88
+
89
+ 'other'
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -52,6 +52,7 @@ module GitlabQuality
52
52
  :skip_record_proc,
53
53
  :test_retried_proc,
54
54
  :custom_metrics_proc,
55
+ :spec_file_path_prefix,
55
56
  :logger
56
57
 
57
58
  # rubocop:disable Style/TrivialAccessors -- allows documenting that setting config enables the export as well as document input class type
@@ -108,6 +109,13 @@ module GitlabQuality
108
109
  @extra_rspec_metadata_keys ||= []
109
110
  end
110
111
 
112
+ # Extra path prefix for constructing full file path within mono-repository setups
113
+ #
114
+ # @return [String]
115
+ def spec_file_path_prefix
116
+ @spec_file_path_prefix ||= ""
117
+ end
118
+
111
119
  # A lambda that determines whether to skip recording a test result
112
120
  #
113
121
  # This is useful when you would want to skip initial failure when retrying specs is set up in a separate process
@@ -46,12 +46,15 @@ module GitlabQuality
46
46
  status: example.execution_result.status,
47
47
  run_time: (example.execution_result.run_time * 1000).round,
48
48
  location: example_location,
49
- exception_class: exception_class,
49
+ # TODO: remove exception_class once migration to exception_classes is fully complete on clickhouse side
50
+ exception_class: example.execution_result.exception&.class&.to_s,
51
+ exception_classes: exception_classes.map { |e| e.class.to_s }.uniq,
50
52
  failure_exception: failure_exception,
51
53
  quarantined: quarantined?,
52
54
  feature_category: example.metadata[:feature_category] || "",
53
55
  test_retried: config.test_retried_proc.call(example),
54
- run_type: run_type
56
+ run_type: run_type,
57
+ spec_file_path_prefix: config.spec_file_path_prefix
55
58
  }
56
59
  end
57
60
 
@@ -127,22 +130,25 @@ module GitlabQuality
127
130
  @file_path ||= example_location.gsub(/:\d+$/, "")
128
131
  end
129
132
 
130
- # Failure exception class
133
+ # Failure exception classes
131
134
  #
132
- # @return [String]
133
- def exception_class
134
- example.execution_result.exception&.class&.to_s
135
+ # @return [Array<Exception>]
136
+ def exception_classes
137
+ exception = example.execution_result.exception
138
+ return [] unless exception
139
+ return [exception] unless exception.respond_to?(:all_exceptions)
140
+
141
+ exception.all_exceptions.flatten
135
142
  end
136
143
 
137
144
  # Truncated exception stacktrace
138
145
  #
139
146
  # @return [String]
140
147
  def failure_exception
141
- example.execution_result.exception.then do |exception|
142
- next unless exception
148
+ exception = example.execution_result.exception
149
+ return unless exception
143
150
 
144
- exception.to_s.tr("\n", " ").slice(0, 1000)
145
- end
151
+ exception.to_s.tr("\n", " ").slice(0, 1000)
146
152
  end
147
153
 
148
154
  # Test run type | suite name
@@ -65,6 +65,7 @@ module GitlabQuality
65
65
  test_retried Bool,
66
66
  feature_category LowCardinality(String) DEFAULT 'unknown',
67
67
  run_type LowCardinality(String) DEFAULT 'unknown',
68
+ spec_file_path_prefix LowCardinality(String) DEFAULT '',
68
69
  ci_project_id UInt32,
69
70
  ci_job_name LowCardinality(String),
70
71
  ci_job_id UInt64,
@@ -75,6 +76,7 @@ module GitlabQuality
75
76
  ci_target_branch LowCardinality(String),
76
77
  ci_server_url LowCardinality(String) DEFAULT 'https://gitlab.com',
77
78
  exception_class String DEFAULT '',
79
+ exception_classes Array(String) DEFAULT [],
78
80
  failure_exception String DEFAULT ''
79
81
  )
80
82
  ENGINE = MergeTree()
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "2.27.0"
5
+ VERSION = "3.1.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.27.0
4
+ version: 3.1.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-11-13 00:00:00.000000000 Z
11
+ date: 2025-12-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: climate_control
@@ -227,9 +227,6 @@ dependencies:
227
227
  - - ">="
228
228
  - !ruby/object:Gem::Version
229
229
  version: '7.0'
230
- - - "<"
231
- - !ruby/object:Gem::Version
232
- version: '7.3'
233
230
  type: :runtime
234
231
  prerelease: false
235
232
  version_requirements: !ruby/object:Gem::Requirement
@@ -237,9 +234,6 @@ dependencies:
237
234
  - - ">="
238
235
  - !ruby/object:Gem::Version
239
236
  version: '7.0'
240
- - - "<"
241
- - !ruby/object:Gem::Version
242
- version: '7.3'
243
237
  - !ruby/object:Gem::Dependency
244
238
  name: amatch
245
239
  requirement: !ruby/object:Gem::Requirement
@@ -498,6 +492,7 @@ files:
498
492
  - lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb
499
493
  - lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb
500
494
  - lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb
495
+ - lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier.rb
501
496
  - lib/gitlab_quality/test_tooling/code_coverage/test_map.rb
502
497
  - lib/gitlab_quality/test_tooling/code_coverage/test_report.rb
503
498
  - lib/gitlab_quality/test_tooling/code_coverage/utils.rb