gitlab_quality-test_tooling 3.0.0 → 3.7.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -4
  3. data/README.md +47 -14
  4. data/exe/sync-category-owners +95 -0
  5. data/exe/test-coverage +59 -15
  6. data/lib/gitlab_quality/test_tooling/code_coverage/README.md +162 -0
  7. data/lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb +5 -2
  8. data/lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb +32 -28
  9. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb +102 -35
  10. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb +44 -37
  11. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb +17 -0
  12. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table.rb +52 -0
  13. data/lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb +77 -34
  14. data/lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb +11 -1
  15. data/lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier.rb +47 -0
  16. data/lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb +46 -0
  17. data/lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data.rb +46 -0
  18. data/lib/gitlab_quality/test_tooling/report/results_in_test_cases.rb +2 -4
  19. data/lib/gitlab_quality/test_tooling/runtime/env.rb +4 -0
  20. data/lib/gitlab_quality/test_tooling/test_meta/processor/meta_processor.rb +1 -1
  21. data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +4 -4
  22. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +8 -0
  23. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +36 -10
  24. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +2 -0
  25. data/lib/gitlab_quality/test_tooling/test_quarantine/quarantine_formatter.rb +38 -0
  26. data/lib/gitlab_quality/test_tooling/test_quarantine/quarantine_helper.rb +76 -0
  27. data/lib/gitlab_quality/test_tooling/test_result/base_test_result.rb +15 -1
  28. data/lib/gitlab_quality/test_tooling/version.rb +1 -1
  29. metadata +11 -28
  30. data/exe/existing-test-health-issue +0 -59
  31. data/exe/generate-test-session +0 -70
  32. data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +0 -288
  33. data/lib/gitlab_quality/test_tooling/report/test_health_issue_finder.rb +0 -79
@@ -11,58 +11,125 @@ module GitlabQuality
11
11
 
12
12
  MissingMappingError = Class.new(StandardError)
13
13
 
14
- # Creates the ClickHouse table, if it doesn't exist already
15
- # @return [nil]
16
- def create
17
- logger.debug("#{LOG_PREFIX} Creating category_owners table if it doesn't exist ...")
18
-
19
- client.query(<<~SQL)
20
- CREATE TABLE IF NOT EXISTS #{table_name} (
21
- timestamp DateTime64(6, 'UTC') DEFAULT now64(),
22
- category String,
23
- group String,
24
- stage String,
25
- section String,
26
- INDEX idx_group group TYPE set(360) GRANULARITY 1,
27
- INDEX idx_stage stage TYPE set(360) GRANULARITY 1,
28
- INDEX idx_section section TYPE set(360) GRANULARITY 1
29
- ) ENGINE = MergeTree()
30
- ORDER BY (category, timestamp)
31
- SETTINGS index_granularity = 8192;
32
- SQL
33
-
34
- logger.info("#{LOG_PREFIX} Category owners table created/verified successfully")
35
- end
14
+ KNOWN_UNOWNED = %w[shared not_owned tooling].freeze
15
+
16
+ # SQL query to get the latest ownership record for each category
17
+ # Uses window function to avoid loading entire table history
18
+ LATEST_RECORDS_QUERY = <<~SQL
19
+ SELECT category, group, stage, section
20
+ FROM (
21
+ SELECT category, group, stage, section,
22
+ ROW_NUMBER() OVER (PARTITION BY category ORDER BY timestamp DESC) as rn
23
+ FROM %{table_name}
24
+ )
25
+ WHERE rn = 1
26
+ SQL
27
+
28
+ # Insert only new category ownership records that don't already exist
29
+ # This avoids needing TRUNCATE permission
30
+ def push(data)
31
+ return logger.warn("#{LOG_PREFIX} No data found, skipping insert!") if data.empty?
36
32
 
37
- def truncate
38
- logger.debug("#{LOG_PREFIX} Truncating table #{full_table_name} ...")
33
+ sanitized_data = sanitize_and_filter_data(data)
34
+ return if sanitized_data.empty?
39
35
 
40
- client.query("TRUNCATE TABLE #{full_table_name}")
36
+ new_records = filter_new_records(sanitized_data)
37
+ return if new_records.empty?
41
38
 
42
- logger.info("#{LOG_PREFIX} Successfully truncated table #{full_table_name}")
39
+ insert_new_records(new_records, sanitized_data.size)
40
+ rescue StandardError => e
41
+ logger.error("#{LOG_PREFIX} Error occurred while pushing data to #{full_table_name}: #{e.message}")
42
+ raise
43
43
  end
44
44
 
45
- # Owners of particular category as group, stage and section
45
+ # Owners of particular feature category as group, stage and section
46
46
  #
47
- # @param category_name [String]
47
+ # @param feature_category_name [String] the feature_category name
48
48
  # @return [Hash]
49
- def owners(category_name)
50
- records.fetch(category_name)
49
+ def owners(feature_category_name)
50
+ if KNOWN_UNOWNED.include?(feature_category_name)
51
+ logger.info(
52
+ "#{LOG_PREFIX} #{feature_category_name} is a known feature category without owner..."
53
+ )
54
+ return {}
55
+ end
56
+
57
+ records.fetch(feature_category_name)
51
58
  rescue KeyError
52
- raise(MissingMappingError, "Category '#{category_name}' not found in table '#{table_name}'")
59
+ raise(MissingMappingError, "Feature category '#{feature_category_name}' not found in table '#{table_name}'")
53
60
  end
54
61
 
55
62
  private
56
63
 
57
64
  def records
58
- @records ||= client
59
- .query("SELECT category, group, stage, section FROM #{table_name}")
60
- .each_with_object({}) { |record, hsh| hsh[record["category"]] = record.slice("group", "stage", "section") }
65
+ @records ||= fetch_latest_records.each_with_object({}) do |record, hsh|
66
+ hsh[record["category"]] = record.slice("group", "stage", "section")
67
+ end
68
+ end
69
+
70
+ def sanitize_and_filter_data(data)
71
+ logger.debug("#{LOG_PREFIX} Starting data export to ClickHouse")
72
+ sanitized_data = sanitize(data)
73
+
74
+ logger.warn("#{LOG_PREFIX} No valid data found after sanitization, skipping ClickHouse export!") if sanitized_data.empty?
75
+
76
+ sanitized_data
77
+ end
78
+
79
+ def filter_new_records(sanitized_data)
80
+ existing_records = fetch_existing_records
81
+ # Deduplicate against latest records per category to prevent inserting duplicate historical records.
82
+ # This ensures we only insert records with new category+ownership combinations, even if an older
83
+ # version of the same category+ownership existed previously.
84
+ new_records = sanitized_data.reject { |record| existing_records.include?(record_key(record)) }
85
+
86
+ logger.info("#{LOG_PREFIX} No new records to insert, all data already exists") if new_records.empty?
87
+
88
+ new_records
89
+ end
90
+
91
+ def insert_new_records(new_records, total_sanitized_count)
92
+ client.insert_json_data(table_name, new_records)
93
+ new_count = new_records.size
94
+ existing_count = total_sanitized_count - new_count
95
+ record_word = new_count == 1 ? 'record' : 'records'
96
+ logger.info("#{LOG_PREFIX} Inserted #{new_count} new #{record_word} (#{existing_count} already existed)")
97
+ end
98
+
99
+ def fetch_existing_records
100
+ fetch_latest_records.to_set { |record| record_key(record) }
101
+ end
102
+
103
+ def fetch_latest_records
104
+ query = format(LATEST_RECORDS_QUERY, table_name: table_name)
105
+ client.query(query)
106
+ end
107
+
108
+ def sanitized_data_record(record)
109
+ {
110
+ timestamp: time,
111
+ category: record[:feature_category],
112
+ group: record[:group],
113
+ stage: record[:stage],
114
+ section: record[:section]
115
+ }
116
+ end
117
+
118
+ def record_key(record)
119
+ # Create a unique key for the combination of category + ownership
120
+ # Normalize to string keys for consistent access
121
+ normalized = record.transform_keys(&:to_s)
122
+ [
123
+ normalized["category"],
124
+ normalized["group"],
125
+ normalized["stage"],
126
+ normalized["section"]
127
+ ]
61
128
  end
62
129
 
63
130
  # @return [Boolean] True if the record is valid, false otherwise
64
131
  def valid_record?(record)
65
- required_fields = %i[category group stage section]
132
+ required_fields = %i[feature_category group stage section]
66
133
 
67
134
  required_fields.each do |field|
68
135
  if record[field].nil?
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'time'
4
3
  require_relative 'table'
4
+ require_relative 'category_owners_table'
5
5
 
6
6
  module GitlabQuality
7
7
  module TestTooling
@@ -10,39 +10,15 @@ module GitlabQuality
10
10
  class CoverageMetricsTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
11
11
  TABLE_NAME = "coverage_metrics"
12
12
 
13
- # Creates the ClickHouse table, if it doesn't exist already
14
- # @return [nil]
15
- def create
16
- logger.debug("#{LOG_PREFIX} Creating coverage_metrics table if it doesn't exist ...")
17
-
18
- client.query(<<~SQL)
19
- CREATE TABLE IF NOT EXISTS #{table_name} (
20
- timestamp DateTime64(6, 'UTC'),
21
- file String,
22
- line_coverage Float64,
23
- branch_coverage Nullable(Float64),
24
- function_coverage Nullable(Float64),
25
- source_file_type String,
26
- category Nullable(String),
27
- ci_project_id Nullable(UInt32),
28
- ci_project_path Nullable(String),
29
- ci_job_name Nullable(String),
30
- ci_job_id Nullable(UInt64),
31
- ci_pipeline_id Nullable(UInt64),
32
- ci_merge_request_iid Nullable(UInt32),
33
- ci_branch Nullable(String),
34
- ci_target_branch Nullable(String)
35
- ) ENGINE = MergeTree()
36
- PARTITION BY toYYYYMM(timestamp)
37
- ORDER BY (ci_project_path, timestamp, file, ci_pipeline_id)
38
- SETTINGS index_granularity = 8192, allow_nullable_key = 1;
39
- SQL
40
-
41
- logger.info("#{LOG_PREFIX} Coverage metrics table created/verified successfully")
13
+ def initialize(category_owners_table: nil, **args)
14
+ super(**args)
15
+ @category_owners_table = category_owners_table
42
16
  end
43
17
 
44
18
  private
45
19
 
20
+ attr_reader :category_owners_table
21
+
46
22
  # @return [Boolean] True if the record is valid, false otherwise
47
23
  def valid_record?(record)
48
24
  valid_file?(record) &&
@@ -97,17 +73,48 @@ module GitlabQuality
97
73
  branch_coverage: record[:branch_coverage],
98
74
  function_coverage: record[:function_coverage],
99
75
  source_file_type: record[:source_file_type],
100
- category: record[:category],
76
+ is_responsible: record[:is_responsible],
77
+ is_dependent: record[:is_dependent],
78
+ category: record[:feature_category],
79
+ **coverage_counts(record),
80
+ **org_data(record[:feature_category]),
101
81
  **ci_metadata
102
82
  }
103
83
  end
104
84
 
105
- # @return [Time] Common timestamp for all coverage records
106
- def time
107
- @time ||= begin
108
- ci_created_at = ENV.fetch('CI_PIPELINE_CREATED_AT', nil)
109
- ci_created_at ? Time.strptime(ci_created_at, '%Y-%m-%dT%H:%M:%S%z') : Time.now.utc
110
- end
85
+ # @return [Hash] Raw coverage counts from the record
86
+ def coverage_counts(record)
87
+ {
88
+ total_lines: record[:total_lines] || 0,
89
+ covered_lines: record[:covered_lines] || 0,
90
+ total_branches: record[:total_branches] || 0,
91
+ covered_branches: record[:covered_branches] || 0,
92
+ total_functions: record[:total_functions] || 0,
93
+ covered_functions: record[:covered_functions] || 0
94
+ }
95
+ end
96
+
97
+ # @param category [String, nil] Feature category name
98
+ # @return [Hash] Organization data (group, stage, section) for the category
99
+ def org_data(category)
100
+ return { group: '', stage: '', section: '' } if category.nil? || category_owners_table.nil?
101
+
102
+ @org_data_cache ||= {}
103
+ @org_data_cache[category] ||= fetch_org_data(category)
104
+ end
105
+
106
+ # @param category [String] Feature category name
107
+ # @return [Hash] Organization data fetched from category_owners_table
108
+ def fetch_org_data(category)
109
+ owners = category_owners_table.owners(category)
110
+ {
111
+ group: owners['group'] || '',
112
+ stage: owners['stage'] || '',
113
+ section: owners['section'] || ''
114
+ }
115
+ rescue CategoryOwnersTable::MissingMappingError
116
+ logger.warn("#{LOG_PREFIX} No org data found for category '#{category}', using empty values")
117
+ { group: '', stage: '', section: '' }
111
118
  end
112
119
 
113
120
  # @return [Hash] CI-related metadata
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module GitlabQuality
4
6
  module TestTooling
5
7
  module CodeCoverage
@@ -58,6 +60,21 @@ module GitlabQuality
58
60
  raise NotImplementedError, "#{self.class}##{__method__} method must be implemented in a subclass"
59
61
  end
60
62
 
63
+ # @return [Time] Common timestamp for all records, memoized
64
+ def time
65
+ @time ||= parse_ci_timestamp
66
+ end
67
+
68
+ def parse_ci_timestamp
69
+ ci_created_at = ENV.fetch('CI_PIPELINE_CREATED_AT', nil)
70
+ return Time.now.utc unless ci_created_at
71
+
72
+ Time.strptime(ci_created_at, '%Y-%m-%dT%H:%M:%S%z')
73
+ rescue ArgumentError
74
+ logger.warn("#{LOG_PREFIX} Invalid CI_PIPELINE_CREATED_AT format: #{ci_created_at}, using current time")
75
+ Time.now.utc
76
+ end
77
+
61
78
  # @return [GitlabQuality::TestTooling::ClickHouse::Client]
62
79
  def client
63
80
  @client ||= GitlabQuality::TestTooling::ClickHouse::Client.new(
@@ -0,0 +1,52 @@
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 TestFileMappingsTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
10
+ TABLE_NAME = "test_file_mappings"
11
+
12
+ private
13
+
14
+ # @return [Boolean] True if the record is valid, false otherwise
15
+ def valid_record?(record)
16
+ valid_test_file?(record) && valid_source_file?(record)
17
+ end
18
+
19
+ # @return [Boolean] True if the test_file field is present
20
+ def valid_test_file?(record)
21
+ return true unless record[:test_file].blank?
22
+
23
+ logger.warn("#{LOG_PREFIX} Skipping record with nil/empty test_file: #{record}")
24
+ false
25
+ end
26
+
27
+ # @return [Boolean] True if the source_file field is present
28
+ def valid_source_file?(record)
29
+ return true unless record[:source_file].blank?
30
+
31
+ logger.warn("#{LOG_PREFIX} Skipping record with nil/empty source_file: #{record}")
32
+ false
33
+ end
34
+
35
+ # @return [Hash] Transformed mapping data including timestamp and CI metadata
36
+ def sanitized_data_record(record)
37
+ {
38
+ timestamp: time,
39
+ test_file: record[:test_file],
40
+ source_file: record[:source_file],
41
+ ci_project_path: ENV.fetch('CI_PROJECT_PATH', nil),
42
+ category: record[:category] || '',
43
+ group: record[:group] || '',
44
+ stage: record[:stage] || '',
45
+ section: record[:section] || ''
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -4,22 +4,31 @@ module GitlabQuality
4
4
  module TestTooling
5
5
  module CodeCoverage
6
6
  class CoverageData
7
+ RESPONSIBLE = 'responsible'
8
+ DEPENDENT = 'dependent'
9
+
7
10
  # @param [Hash<String, Hash>] code_coverage_by_source_file Source file
8
11
  # mapped to test coverage data
9
12
  # @param [Hash<String, Array<String>>] source_file_to_tests Source files
10
13
  # mapped to all test files testing them
11
- # @param [Hash<String, Array<String>>] tests_to_categories Test files
14
+ # @param [Hash<String, Array<String>>] tests_to_feature_categories Test files
12
15
  # mapped to all feature categories they belong to
13
- # @param [Hash<String, Hash>] categories_to_teams Mapping of categories
16
+ # @param [Hash<String, Hash>] feature_categories_to_teams Mapping of feature categories
14
17
  # to teams (i.e., groups, stages, sections)
15
18
  # @param [Hash<String, String>] source_file_types Mapping of source files
16
19
  # 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 = {})
20
+ # @param [Hash<String, String>] test_classifications Mapping of test files
21
+ # to their responsibility classification (responsible or dependent)
22
+ def initialize(
23
+ code_coverage_by_source_file, source_file_to_tests, tests_to_feature_categories,
24
+ feature_categories_to_teams, source_file_types = {}, test_classifications = {}
25
+ )
18
26
  @code_coverage_by_source_file = code_coverage_by_source_file
19
27
  @source_file_to_tests = source_file_to_tests
20
- @tests_to_categories = tests_to_categories
21
- @categories_to_teams = categories_to_teams
28
+ @tests_to_feature_categories = tests_to_feature_categories
29
+ @feature_categories_to_teams = feature_categories_to_teams
22
30
  @source_file_types = source_file_types
31
+ @test_classifications = test_classifications
23
32
  end
24
33
 
25
34
  # @return [Array<Hash<Symbol, String>>] Mapping of column name to row
@@ -32,7 +41,9 @@ module GitlabQuality
32
41
  # branch_coverage: 95.0
33
42
  # function_coverage: 100.0
34
43
  # source_file_type: "backend"
35
- # category: "team_planning"
44
+ # is_responsible: true
45
+ # is_dependent: false
46
+ # feature_category: "team_planning"
36
47
  # group: "project_management"
37
48
  # stage: "plan"
38
49
  # section: "dev"
@@ -40,55 +51,87 @@ module GitlabQuality
40
51
  # ...
41
52
  # ]
42
53
  def as_db_table
43
- all_files.flat_map do |file|
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
-
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
-
58
- if categories.empty?
59
- base_data.merge(no_owner_info)
60
- else
61
- categories.map do |category|
62
- base_data.merge(owner_info(category))
63
- end
54
+ all_files.flat_map { |file| records_for_file(file) }
55
+ end
56
+
57
+ private
58
+
59
+ def records_for_file(file)
60
+ base_data = base_data_for(file)
61
+ feature_categories_with_flags = feature_categories_with_responsibility_flags_for(file)
62
+
63
+ if feature_categories_with_flags.empty?
64
+ base_data.merge(no_owner_info).merge(is_responsible: nil, is_dependent: nil)
65
+ else
66
+ feature_categories_with_flags.map do |feature_category, flags|
67
+ base_data.merge(owner_info(feature_category)).merge(
68
+ is_responsible: flags[:is_responsible],
69
+ is_dependent: flags[:is_dependent]
70
+ )
64
71
  end
65
72
  end
66
73
  end
67
74
 
68
- private
75
+ def base_data_for(file)
76
+ coverage_data = @code_coverage_by_source_file[file]
77
+
78
+ {
79
+ file: file,
80
+ line_coverage: coverage_data&.dig(:percentage),
81
+ branch_coverage: coverage_data&.dig(:branch_percentage),
82
+ function_coverage: coverage_data&.dig(:function_percentage),
83
+ source_file_type: @source_file_types[file] || 'other',
84
+ total_lines: coverage_data&.dig(:total_lines) || 0,
85
+ covered_lines: coverage_data&.dig(:covered_lines) || 0,
86
+ total_branches: coverage_data&.dig(:total_branches) || 0,
87
+ covered_branches: coverage_data&.dig(:covered_branches) || 0,
88
+ total_functions: coverage_data&.dig(:total_functions) || 0,
89
+ covered_functions: coverage_data&.dig(:covered_functions) || 0
90
+ }
91
+ end
69
92
 
70
93
  def no_owner_info
71
94
  {
72
- category: nil,
95
+ feature_category: nil,
73
96
  group: nil,
74
97
  stage: nil,
75
98
  section: nil
76
99
  }
77
100
  end
78
101
 
79
- def owner_info(category)
80
- owner_info = @categories_to_teams[category]
102
+ def owner_info(feature_category)
103
+ owner_info = @feature_categories_to_teams[feature_category]
81
104
 
82
105
  {
83
- category: category,
106
+ feature_category: feature_category,
84
107
  group: owner_info&.dig(:group),
85
108
  stage: owner_info&.dig(:stage),
86
109
  section: owner_info&.dig(:section)
87
110
  }
88
111
  end
89
112
 
90
- def categories_for(file)
91
- @source_file_to_tests[file]&.flat_map { |test_file| @tests_to_categories[test_file] || [] }&.uniq || []
113
+ # Returns a hash of feature_category => { is_responsible: bool, is_dependent: bool }
114
+ # for a given source file. A feature category can have both flags true if it has
115
+ # both unit tests (responsible) and integration/E2E tests (dependent).
116
+ def feature_categories_with_responsibility_flags_for(file)
117
+ test_files = @source_file_to_tests[file] || []
118
+ return {} if test_files.empty?
119
+
120
+ test_files.each_with_object({}) do |test_file, feature_category_to_flags|
121
+ feature_categories = @tests_to_feature_categories[test_file] || []
122
+ classification = @test_classifications[test_file]
123
+
124
+ feature_categories.each do |feature_category|
125
+ feature_category_to_flags[feature_category] ||= { is_responsible: false, is_dependent: false }
126
+
127
+ case classification
128
+ when RESPONSIBLE
129
+ feature_category_to_flags[feature_category][:is_responsible] = true
130
+ when DEPENDENT
131
+ feature_category_to_flags[feature_category][:is_dependent] = true
132
+ end
133
+ end
134
+ end
92
135
  end
93
136
 
94
137
  def all_files
@@ -85,7 +85,7 @@ module GitlabQuality
85
85
  end
86
86
 
87
87
  def register_source_file(filename)
88
- @current_file = filename.gsub(%r{^\./}, '')
88
+ @current_file = normalize_path(filename)
89
89
  @parsed_content[@current_file] = {
90
90
  line_coverage: {},
91
91
  branch_coverage: {},
@@ -94,6 +94,16 @@ module GitlabQuality
94
94
  }
95
95
  end
96
96
 
97
+ def normalize_path(filename)
98
+ # Remove leading ./ if present
99
+ path = filename.gsub(%r{^\./}, '')
100
+
101
+ # Handle GDK/CI paths like "../../../home/gdk/gitlab-development-kit/gitlab/app/..."
102
+ # Extract path starting from known root directories
103
+ match = path.match(%r{((?:ee/)?(?:app|lib|config|db|spec|scripts|tooling|workhorse|vendor)/.+)$})
104
+ match ? match[1] : path
105
+ end
106
+
97
107
  def register_line_data(line_no, count)
98
108
  return unless @current_file
99
109
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module CodeCoverage
6
+ class ResponsibilityClassifier
7
+ RESPONSIBLE = 'responsible'
8
+ DEPENDENT = 'dependent'
9
+
10
+ # @param test_to_sources [Hash<String, Array<String>>] Test files mapped to source files they cover
11
+ # @param responsible_patterns [Array<Regexp>] Patterns for unit tests
12
+ # @param dependent_patterns [Array<Regexp>] Patterns for integration/E2E tests
13
+ def initialize(test_to_sources, responsible_patterns:, dependent_patterns:)
14
+ @test_to_sources = test_to_sources
15
+ @responsible_patterns = responsible_patterns
16
+ @dependent_patterns = dependent_patterns
17
+ end
18
+
19
+ # Classifies each test file as responsible or dependent
20
+ # @return [Hash<String, String>] Test file path => classification
21
+ def classify_tests
22
+ @test_to_sources.keys.each_with_object({}) do |test_file, result|
23
+ result[test_file] = classify_test(test_file)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ # Classifies a test file as responsible (unit) or dependent (integration/E2E).
30
+ #
31
+ # Dependent patterns are checked first because it's the safer default:
32
+ # - is_responsible: true claims "this file has unit test coverage"
33
+ # - is_dependent: true claims "this file has integration test coverage"
34
+ #
35
+ # If uncertain (overlapping patterns or no match), we default to dependent
36
+ # to avoid incorrectly inflating unit test coverage metrics.
37
+ def classify_test(test_file)
38
+ return DEPENDENT if @dependent_patterns.any? { |p| test_file.match?(p) }
39
+ return RESPONSIBLE if @responsible_patterns.any? { |p| test_file.match?(p) }
40
+
41
+ # Default to dependent for unknown test types
42
+ DEPENDENT
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module GitlabQuality
6
+ module TestTooling
7
+ module CodeCoverage
8
+ class ResponsibilityPatternsConfig
9
+ ConfigError = Class.new(StandardError)
10
+
11
+ attr_reader :responsible_patterns, :dependent_patterns
12
+
13
+ # @param file_path [String] Path to YAML config file
14
+ # @raise [ConfigError] if file cannot be loaded or parsed
15
+ def initialize(file_path)
16
+ @file_path = file_path
17
+ @config = load_config
18
+ @responsible_patterns = parse_patterns('responsible')
19
+ @dependent_patterns = parse_patterns('dependent')
20
+ end
21
+
22
+ private
23
+
24
+ def load_config
25
+ YAML.load_file(@file_path)
26
+ rescue Errno::ENOENT
27
+ raise ConfigError, "Config file not found: #{@file_path}"
28
+ rescue Psych::SyntaxError => e
29
+ raise ConfigError, "Invalid YAML syntax in #{@file_path}: #{e.message}"
30
+ end
31
+
32
+ def parse_patterns(key)
33
+ patterns = @config[key]
34
+
35
+ raise ConfigError, "Missing or invalid '#{key}' key in #{@file_path}. Expected an array of patterns." unless patterns.is_a?(Array)
36
+
37
+ patterns.map do |pattern|
38
+ Regexp.new(pattern)
39
+ rescue RegexpError => e
40
+ raise ConfigError, "Invalid regex pattern '#{pattern}' in #{@file_path}: #{e.message}"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end