gitlab_quality-test_tooling 3.0.0 → 3.6.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 +99 -0
  5. data/exe/test-coverage +58 -14
  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 +13 -27
  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,28 +11,7 @@ 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
36
15
 
37
16
  def truncate
38
17
  logger.debug("#{LOG_PREFIX} Truncating table #{full_table_name} ...")
@@ -42,14 +21,21 @@ module GitlabQuality
42
21
  logger.info("#{LOG_PREFIX} Successfully truncated table #{full_table_name}")
43
22
  end
44
23
 
45
- # Owners of particular category as group, stage and section
24
+ # Owners of particular feature category as group, stage and section
46
25
  #
47
- # @param category_name [String]
26
+ # @param feature_category_name [String] the feature_category name
48
27
  # @return [Hash]
49
- def owners(category_name)
50
- records.fetch(category_name)
28
+ def owners(feature_category_name)
29
+ if KNOWN_UNOWNED.include?(feature_category_name)
30
+ logger.info(
31
+ "#{LOG_PREFIX} #{feature_category_name} is a known feature category without owner..."
32
+ )
33
+ return {}
34
+ end
35
+
36
+ records.fetch(feature_category_name)
51
37
  rescue KeyError
52
- raise(MissingMappingError, "Category '#{category_name}' not found in table '#{table_name}'")
38
+ raise(MissingMappingError, "Feature category '#{feature_category_name}' not found in table '#{table_name}'")
53
39
  end
54
40
 
55
41
  private
@@ -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
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module CodeCoverage
6
+ class TestFileMappingData
7
+ # @param [Hash<String, Array<String>>] test_to_sources Test files
8
+ # mapped to all source files they cover
9
+ # @param [Hash<String, Array<String>>] tests_to_categories Test files
10
+ # mapped to their feature categories
11
+ # @param [Hash<String, Hash>] feature_categories_to_teams Feature categories
12
+ # mapped to their org hierarchy (group, stage, section)
13
+ def initialize(test_to_sources, tests_to_categories: {}, feature_categories_to_teams: {})
14
+ @test_to_sources = test_to_sources
15
+ @tests_to_categories = tests_to_categories
16
+ @feature_categories_to_teams = feature_categories_to_teams
17
+ end
18
+
19
+ # @return [Array<Hash<Symbol, String>>] Mapping data formatted for database insertion
20
+ # @example Return value
21
+ # [
22
+ # { test_file: "spec/models/user_spec.rb", source_file: "app/models/user.rb",
23
+ # category: "team_planning", group: "project_management", stage: "plan", section: "dev" },
24
+ # ...
25
+ # ]
26
+ def as_db_table
27
+ @test_to_sources.flat_map do |test_file, source_files|
28
+ category = @tests_to_categories[test_file]&.first || ''
29
+ team = @feature_categories_to_teams[category] || {}
30
+
31
+ source_files.map do |source_file|
32
+ {
33
+ test_file: test_file,
34
+ source_file: source_file,
35
+ category: category,
36
+ group: team[:group] || '',
37
+ stage: team[:stage] || '',
38
+ section: team[:section] || ''
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'erb'
4
-
5
3
  module GitlabQuality
6
4
  module TestTooling
7
5
  module Report
@@ -68,12 +66,12 @@ module GitlabQuality
68
66
  end
69
67
 
70
68
  def execution_graph_section(test)
71
- formatted_title = ERB::Util.url_encode(test.name)
69
+ formatted_path = CGI.escape(test.relative_file)
72
70
 
73
71
  <<~MKDOWN.strip
74
72
  ### Executions
75
73
 
76
- [Spec metrics on all environments](https://dashboards.quality.gitlab.net/d/cW0UMgv7k/single-spec-metrics?orgId=1&var-run_type=All&var-name=#{formatted_title})
74
+ [Spec metrics on all environments](https://dashboards.devex.gitlab.net/d/739c1bdd-a436-452b-bddc-fccb4d055768/single-test-overview?var-file_path=#{formatted_path})
77
75
  MKDOWN
78
76
  end
79
77
 
@@ -103,6 +103,10 @@ module GitlabQuality
103
103
  "#{ci_project_name}-#{test_subset}"
104
104
  end
105
105
 
106
+ def quarantine_disabled?
107
+ enabled?(ENV.fetch('GLCI_DISABLE_QUARANTINE', nil), default: false)
108
+ end
109
+
106
110
  private
107
111
 
108
112
  def enabled?(value, default: true)
@@ -45,7 +45,7 @@ module GitlabQuality
45
45
  commits.each_with_index.map do |(changed_line_number, spec), index|
46
46
  <<~MARKDOWN
47
47
  #{index + 1}. [`#{spec['name']}`](https://gitlab.com/#{context.project}/-/blob/#{context.ref}/#{spec['file_path']}#L#{changed_line_number.to_i + 1})
48
- | [Testcase](#{spec['testcase']}) | [Spec metrics](#{context.single_spec_metrics_link(spec['name'])})
48
+ | [Testcase](#{spec['testcase']}) | [Spec metrics](#{context.single_spec_metrics_link(spec['file_path'])})
49
49
  #{failure_issue_text(spec)}
50
50
  MARKDOWN
51
51
  end.join("\n")
@@ -348,11 +348,11 @@ module GitlabQuality
348
348
 
349
349
  # Returns the link to the Grafana dashboard for single spec metrics
350
350
  #
351
- # @param [String] example_name the full example name
351
+ # @param [String] file_path the full path of spec
352
352
  # @return [String]
353
- def single_spec_metrics_link(example_name)
354
- base_url = "https://dashboards.quality.gitlab.net/d/cW0UMgv7k/single-spec-metrics?orgId=1&var-run_type=All&var-name="
355
- base_url + CGI.escape(example_name)
353
+ def single_spec_metrics_link(file_path)
354
+ base_url = "https://dashboards.devex.gitlab.net/d/739c1bdd-a436-452b-bddc-fccb4d055768/single-test-overview?var-file_path="
355
+ base_url + CGI.escape(file_path)
356
356
  end
357
357
 
358
358
  # Returns any test description string within single or double quotes
@@ -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