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.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -4
- data/README.md +47 -14
- data/exe/sync-category-owners +95 -0
- data/exe/test-coverage +59 -15
- 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 +102 -35
- 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,58 +11,125 @@ module GitlabQuality
|
|
|
11
11
|
|
|
12
12
|
MissingMappingError = Class.new(StandardError)
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
38
|
-
|
|
33
|
+
sanitized_data = sanitize_and_filter_data(data)
|
|
34
|
+
return if sanitized_data.empty?
|
|
39
35
|
|
|
40
|
-
|
|
36
|
+
new_records = filter_new_records(sanitized_data)
|
|
37
|
+
return if new_records.empty?
|
|
41
38
|
|
|
42
|
-
|
|
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
|
|
47
|
+
# @param feature_category_name [String] the feature_category name
|
|
48
48
|
# @return [Hash]
|
|
49
|
-
def owners(
|
|
50
|
-
|
|
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, "
|
|
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 ||=
|
|
59
|
-
.
|
|
60
|
-
|
|
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[
|
|
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
|
-
|
|
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
|