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.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -4
- data/README.md +47 -14
- data/exe/sync-category-owners +99 -0
- data/exe/test-coverage +58 -14
- data/lib/gitlab_quality/test_tooling/code_coverage/README.md +162 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb +5 -2
- data/lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb +32 -28
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb +13 -27
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb +44 -37
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb +17 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table.rb +52 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb +77 -34
- data/lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb +11 -1
- data/lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier.rb +47 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb +46 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data.rb +46 -0
- data/lib/gitlab_quality/test_tooling/report/results_in_test_cases.rb +2 -4
- data/lib/gitlab_quality/test_tooling/runtime/env.rb +4 -0
- data/lib/gitlab_quality/test_tooling/test_meta/processor/meta_processor.rb +1 -1
- data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +4 -4
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +8 -0
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +36 -10
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +2 -0
- data/lib/gitlab_quality/test_tooling/test_quarantine/quarantine_formatter.rb +38 -0
- data/lib/gitlab_quality/test_tooling/test_quarantine/quarantine_helper.rb +76 -0
- data/lib/gitlab_quality/test_tooling/test_result/base_test_result.rb +15 -1
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- metadata +11 -28
- data/exe/existing-test-health-issue +0 -59
- data/exe/generate-test-session +0 -70
- data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +0 -288
- 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
|
-
|
|
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
|
|
26
|
+
# @param feature_category_name [String] the feature_category name
|
|
48
27
|
# @return [Hash]
|
|
49
|
-
def owners(
|
|
50
|
-
|
|
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, "
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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 [
|
|
106
|
-
def
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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>>]
|
|
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>]
|
|
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
|
-
|
|
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
|
-
@
|
|
21
|
-
@
|
|
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
|
-
#
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
80
|
-
owner_info = @
|
|
102
|
+
def owner_info(feature_category)
|
|
103
|
+
owner_info = @feature_categories_to_teams[feature_category]
|
|
81
104
|
|
|
82
105
|
{
|
|
83
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
|
@@ -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['
|
|
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]
|
|
351
|
+
# @param [String] file_path the full path of spec
|
|
352
352
|
# @return [String]
|
|
353
|
-
def single_spec_metrics_link(
|
|
354
|
-
base_url = "https://dashboards.
|
|
355
|
-
base_url + CGI.escape(
|
|
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
|