gitlab_quality-test_tooling 3.1.0 → 3.2.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 +1 -1
- data/exe/test-coverage +31 -3
- data/lib/gitlab_quality/test_tooling/code_coverage/README.md +113 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb +5 -2
- data/lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb +26 -26
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb +5 -5
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb +5 -1
- data/lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb +71 -34
- 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/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9344fabcbb0c753d63b87584efe906b972a6504e7cdb87ef14f46ec2fdeccab
|
|
4
|
+
data.tar.gz: 9f30d7e5edebcf6116cd09343bffb4cc3a0946071a5500ac1d4b3372cd034376
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4596d36fee333944d309fc9eb6428e9cca37990f2cc6ca13914af87f6a4dff208dd993c8c51a9642daf63c50f051e2902a8b6d2647ddf42270c348d7e9c701e5
|
|
7
|
+
data.tar.gz: 480a96a3a1242d87f946d3e610729c97c374d69cb1b6bd96e7dc1255f4558c505126f5b74fe8e7d730c7c84359138a7e9677eee49c7b616d3362f9d072887046
|
data/Gemfile.lock
CHANGED
data/exe/test-coverage
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "optparse"
|
|
5
5
|
require "uri"
|
|
6
|
+
require "yaml"
|
|
6
7
|
|
|
7
8
|
require_relative "../lib/gitlab_quality/test_tooling"
|
|
8
9
|
|
|
@@ -15,9 +16,11 @@ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/artifacts'
|
|
|
15
16
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_report'
|
|
16
17
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
|
|
17
18
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier'
|
|
19
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier'
|
|
20
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config'
|
|
18
21
|
|
|
19
22
|
params = {}
|
|
20
|
-
required_params = [:test_reports, :coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username]
|
|
23
|
+
required_params = [:test_reports, :coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username, :responsibility_patterns]
|
|
21
24
|
|
|
22
25
|
options = OptionParser.new do |opts|
|
|
23
26
|
opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
|
|
@@ -50,6 +53,10 @@ options = OptionParser.new do |opts|
|
|
|
50
53
|
params[:clickhouse_username] = username
|
|
51
54
|
end
|
|
52
55
|
|
|
56
|
+
opts.on('--responsibility-patterns PATH', 'Path to YAML file with responsibility classification patterns') do |path|
|
|
57
|
+
params[:responsibility_patterns] = path
|
|
58
|
+
end
|
|
59
|
+
|
|
53
60
|
opts.separator ""
|
|
54
61
|
opts.separator "Environment variables:"
|
|
55
62
|
opts.separator " GLCI_CLICKHOUSE_METRICS_PASSWORD ClickHouse password (required, not passed via CLI for security)"
|
|
@@ -107,7 +114,9 @@ if params.any? && (required_params - params.keys).none?
|
|
|
107
114
|
|
|
108
115
|
code_coverage_by_source_file = GitlabQuality::TestTooling::CodeCoverage::LcovFile.new(coverage_report).parsed_content
|
|
109
116
|
|
|
110
|
-
|
|
117
|
+
test_map_parser = GitlabQuality::TestTooling::CodeCoverage::TestMap.new(test_map)
|
|
118
|
+
source_file_to_tests = test_map_parser.source_to_tests
|
|
119
|
+
test_to_sources = test_map_parser.test_to_sources
|
|
111
120
|
|
|
112
121
|
# Process test reports
|
|
113
122
|
tests_to_categories = artifacts.test_reports.reduce({}) do |combined_hash, test_report_file|
|
|
@@ -121,12 +130,31 @@ if params.any? && (required_params - params.keys).none?
|
|
|
121
130
|
source_file_classifier = GitlabQuality::TestTooling::CodeCoverage::SourceFileClassifier.new
|
|
122
131
|
source_file_types = source_file_classifier.classify(code_coverage_by_source_file.keys)
|
|
123
132
|
|
|
133
|
+
# Load responsibility patterns from config file
|
|
134
|
+
begin
|
|
135
|
+
patterns_config = GitlabQuality::TestTooling::CodeCoverage::ResponsibilityPatternsConfig.new(
|
|
136
|
+
params[:responsibility_patterns]
|
|
137
|
+
)
|
|
138
|
+
rescue GitlabQuality::TestTooling::CodeCoverage::ResponsibilityPatternsConfig::ConfigError => e
|
|
139
|
+
puts "Error: #{e.message}"
|
|
140
|
+
exit 1
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Classify test files as responsible or dependent
|
|
144
|
+
responsibility_classifier = GitlabQuality::TestTooling::CodeCoverage::ResponsibilityClassifier.new(
|
|
145
|
+
test_to_sources,
|
|
146
|
+
responsible_patterns: patterns_config.responsible_patterns,
|
|
147
|
+
dependent_patterns: patterns_config.dependent_patterns
|
|
148
|
+
)
|
|
149
|
+
test_classifications = responsibility_classifier.classify_tests
|
|
150
|
+
|
|
124
151
|
coverage_data = GitlabQuality::TestTooling::CodeCoverage::CoverageData.new(
|
|
125
152
|
code_coverage_by_source_file,
|
|
126
153
|
source_file_to_tests,
|
|
127
154
|
tests_to_categories,
|
|
128
155
|
category_owners.categories_to_teams,
|
|
129
|
-
source_file_types
|
|
156
|
+
source_file_types,
|
|
157
|
+
test_classifications
|
|
130
158
|
)
|
|
131
159
|
|
|
132
160
|
clickhouse_data = {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Code Coverage Module
|
|
2
|
+
|
|
3
|
+
Exports test coverage data to ClickHouse, enriched with:
|
|
4
|
+
|
|
5
|
+
- **Feature category ownership** - group, stage, and section for each covered file
|
|
6
|
+
- **Responsibility classification** - whether coverage comes from unit or integration tests
|
|
7
|
+
|
|
8
|
+
## Responsibility Classification
|
|
9
|
+
|
|
10
|
+
Tests are classified as either **responsible** or **dependent**:
|
|
11
|
+
|
|
12
|
+
- **Responsible**: Unit tests that directly test a component in isolation
|
|
13
|
+
- **Dependent**: Integration/E2E tests that exercise a component through other layers
|
|
14
|
+
|
|
15
|
+
This classification is tracked per (source_file, feature_category) combination using two boolean columns:
|
|
16
|
+
|
|
17
|
+
| is_responsible | is_dependent | Meaning |
|
|
18
|
+
|----------------|--------------|---------|
|
|
19
|
+
| `true` | `true` | Source file has both unit AND integration test coverage from this feature category |
|
|
20
|
+
| `true` | `false` | Source file has only unit test coverage from this feature category |
|
|
21
|
+
| `false` | `true` | Source file has only integration test coverage from this feature category |
|
|
22
|
+
| `nil` | `nil` | No test mapping exists for this source file |
|
|
23
|
+
|
|
24
|
+
### Configuration
|
|
25
|
+
|
|
26
|
+
This gem is designed to be reusable across different projects. Classification patterns
|
|
27
|
+
are project-specific and must be provided via a YAML config file, since different
|
|
28
|
+
codebases have different test directory structures. The config file defines regex
|
|
29
|
+
patterns for matching test file paths:
|
|
30
|
+
|
|
31
|
+
> **Note:** The table above describes the *semantic meaning* of the flags. The patterns
|
|
32
|
+
> you configure determine *which tests* produce those flags for your project.
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
# responsibility_patterns.yml
|
|
36
|
+
responsible:
|
|
37
|
+
- "^spec/(models|controllers|services)/" # Backend unit tests
|
|
38
|
+
- "^spec/frontend/" # Frontend unit tests
|
|
39
|
+
- "_test\\.go$" # Go unit tests
|
|
40
|
+
|
|
41
|
+
dependent:
|
|
42
|
+
- "^spec/(requests|features|integration)/" # Backend integration tests
|
|
43
|
+
- "^spec/frontend_integration/" # Frontend integration tests
|
|
44
|
+
- "^qa/" # E2E tests
|
|
45
|
+
- "_integration_test\\.go$" # Go integration tests
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Pattern matching rules:**
|
|
49
|
+
1. Dependent patterns are checked first (higher priority)
|
|
50
|
+
2. If no pattern matches, the test defaults to "dependent"
|
|
51
|
+
3. Patterns are Ruby regexes (escape special characters like `.` with `\\`)
|
|
52
|
+
|
|
53
|
+
**Why dependent has priority:** We use a conservative approach. `is_responsible: true`
|
|
54
|
+
makes a stronger claim ("this file has unit test coverage") than `is_dependent: true`.
|
|
55
|
+
If a test matches both patterns or no patterns, defaulting to "dependent" avoids
|
|
56
|
+
incorrectly inflating unit test coverage metrics. It's safer to under-claim than over-claim.
|
|
57
|
+
|
|
58
|
+
### Example: GitLab Configuration
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
# .gitlab/coverage/responsibility_patterns.yml
|
|
62
|
+
responsible:
|
|
63
|
+
# Backend unit test directories
|
|
64
|
+
- "^spec/(models|controllers|services|workers|helpers|mailers|policies|presenters|uploaders|validators|lib|graphql|serializers|components)/"
|
|
65
|
+
- "^ee/spec/(models|controllers|services|workers|helpers|mailers|policies|presenters|uploaders|validators|lib|graphql|serializers|components)/"
|
|
66
|
+
# Frontend unit tests
|
|
67
|
+
- "^spec/frontend/"
|
|
68
|
+
- "^ee/spec/frontend/"
|
|
69
|
+
# Go unit tests
|
|
70
|
+
- "_test\\.go$"
|
|
71
|
+
|
|
72
|
+
dependent:
|
|
73
|
+
# Backend integration tests
|
|
74
|
+
- "^spec/(requests|features|system|integration)/"
|
|
75
|
+
- "^ee/spec/(requests|features|system|integration)/"
|
|
76
|
+
# Frontend integration tests
|
|
77
|
+
- "^spec/frontend_integration/"
|
|
78
|
+
- "^ee/spec/frontend_integration/"
|
|
79
|
+
# E2E tests
|
|
80
|
+
- "^qa/"
|
|
81
|
+
# Go integration tests
|
|
82
|
+
- "_integration_test\\.go$"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Example: Standard Rails Project
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
# config/responsibility_patterns.yml
|
|
89
|
+
responsible:
|
|
90
|
+
- "^test/(models|controllers|services|helpers|mailers)/"
|
|
91
|
+
- "^test/unit/"
|
|
92
|
+
|
|
93
|
+
dependent:
|
|
94
|
+
- "^test/(integration|system)/"
|
|
95
|
+
- "^spec/features/"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## CLI
|
|
99
|
+
|
|
100
|
+
Example usage:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
test-coverage \
|
|
104
|
+
--test-reports 'rspec/*.json' \
|
|
105
|
+
--coverage-report 'coverage/lcov.info' \
|
|
106
|
+
--test-map 'mapping.json' \
|
|
107
|
+
--responsibility-patterns 'config/responsibility_patterns.yml' \
|
|
108
|
+
--clickhouse-url 'https://clickhouse.example.com' \
|
|
109
|
+
--clickhouse-database 'coverage' \
|
|
110
|
+
--clickhouse-username 'user'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
See `exe/test-coverage --help` for full usage.
|
|
@@ -11,7 +11,8 @@ module GitlabQuality
|
|
|
11
11
|
class Artifacts
|
|
12
12
|
# Loads coverage artifacts from the filesystem
|
|
13
13
|
#
|
|
14
|
-
# @param test_reports [String] Glob pattern for test JSON report files (RSpec or Jest)
|
|
14
|
+
# @param test_reports [String] Glob pattern(s) for test JSON report files (RSpec or Jest).
|
|
15
|
+
# Supports comma-separated patterns (e.g., "jest/**/*.json,rspec/**/*.json")
|
|
15
16
|
# @param coverage_report [String] Path to the LCOV coverage report file (e.g., "coverage/lcov/gitlab.lcov")
|
|
16
17
|
# @param test_map [String] Path to the test map file, gzipped or plain JSON (e.g., "crystalball/packed-mapping.json.gz")
|
|
17
18
|
def initialize(coverage_report:, test_map:, test_reports:)
|
|
@@ -54,7 +55,9 @@ module GitlabQuality
|
|
|
54
55
|
|
|
55
56
|
def test_reports_paths
|
|
56
57
|
@test_reports_paths ||= begin
|
|
57
|
-
|
|
58
|
+
# Support comma-separated glob patterns (e.g., "jest/**/*.json,rspec/**/*.json")
|
|
59
|
+
patterns = @test_reports_glob.split(',').map(&:strip).reject(&:empty?)
|
|
60
|
+
paths = Dir.glob(patterns)
|
|
58
61
|
|
|
59
62
|
raise "No test reports found matching pattern: #{@test_reports_glob}" if paths.empty?
|
|
60
63
|
|
|
@@ -15,17 +15,17 @@ module GitlabQuality
|
|
|
15
15
|
BASE_DELAY = 1 # seconds
|
|
16
16
|
MAX_RETRIES = 3
|
|
17
17
|
|
|
18
|
-
# @return [Hash]
|
|
18
|
+
# @return [Hash] Feature category ownership hierarchy, section -> stage -> group -> [feature_categories]
|
|
19
19
|
# @example Return value
|
|
20
20
|
# {
|
|
21
21
|
# "team_planning" => { # section
|
|
22
22
|
# "project_management" => { # stage
|
|
23
23
|
# "plan" => [ # group
|
|
24
|
-
# "dev", #
|
|
25
|
-
# "service_desk" #
|
|
24
|
+
# "dev", # feature_category
|
|
25
|
+
# "service_desk" # feature_category
|
|
26
26
|
# ],
|
|
27
27
|
# "product_planning" => [ # group
|
|
28
|
-
# "portfolio_management", #
|
|
28
|
+
# "portfolio_management", # feature_category
|
|
29
29
|
# ...
|
|
30
30
|
# ]
|
|
31
31
|
# }
|
|
@@ -35,7 +35,7 @@ module GitlabQuality
|
|
|
35
35
|
attr_reader :hierarchy
|
|
36
36
|
|
|
37
37
|
def initialize
|
|
38
|
-
@
|
|
38
|
+
@feature_categories_map = {}
|
|
39
39
|
@hierarchy = {}
|
|
40
40
|
|
|
41
41
|
yaml_file = fetch_yaml_file
|
|
@@ -43,12 +43,12 @@ module GitlabQuality
|
|
|
43
43
|
populate_ownership_hierarchy(yaml_content)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
# @return [Array<Hash>] Flattened category ownership
|
|
46
|
+
# @return [Array<Hash>] Flattened feature category ownership
|
|
47
47
|
# @example Return value
|
|
48
48
|
# [
|
|
49
|
-
# {
|
|
50
|
-
# {
|
|
51
|
-
# {
|
|
49
|
+
# { feature_category: "team_planning", group: "project_management", stage: "plan", section: "dev" },
|
|
50
|
+
# { feature_category: "service_desk", group: "project_management", stage: "plan", section: "dev" },
|
|
51
|
+
# { feature_category: "portfolio_management", group: "product_planning", stage: "plan", section: "dev" }
|
|
52
52
|
# ...
|
|
53
53
|
# ]
|
|
54
54
|
def as_db_table
|
|
@@ -58,10 +58,10 @@ module GitlabQuality
|
|
|
58
58
|
stages.each do |stage, groups|
|
|
59
59
|
next unless groups
|
|
60
60
|
|
|
61
|
-
groups.each do |group,
|
|
62
|
-
Array(
|
|
61
|
+
groups.each do |group, feature_categories|
|
|
62
|
+
Array(feature_categories).each do |feature_category|
|
|
63
63
|
flattened_hierarchy << {
|
|
64
|
-
|
|
64
|
+
feature_category: feature_category,
|
|
65
65
|
group: group,
|
|
66
66
|
stage: stage,
|
|
67
67
|
section: section
|
|
@@ -72,7 +72,7 @@ module GitlabQuality
|
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
# @return [Hash] Mapping of categories to teams (i.e., groups, stages, sections)
|
|
75
|
+
# @return [Hash] Mapping of feature categories to teams (i.e., groups, stages, sections)
|
|
76
76
|
# @example Return value
|
|
77
77
|
# {
|
|
78
78
|
# "team_planning" => { group: "project_management", stage: "plan", section: "dev" },
|
|
@@ -80,9 +80,9 @@ module GitlabQuality
|
|
|
80
80
|
# "portfolio_management" => { group: "product_planning", stage: "plan", section: "dev" },
|
|
81
81
|
# ...
|
|
82
82
|
# }
|
|
83
|
-
def
|
|
84
|
-
|
|
85
|
-
@
|
|
83
|
+
def feature_categories_to_teams
|
|
84
|
+
populate_feature_categories_map(hierarchy)
|
|
85
|
+
@feature_categories_map
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
private
|
|
@@ -126,25 +126,25 @@ module GitlabQuality
|
|
|
126
126
|
@hierarchy[section][stage][group] = categories || []
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
-
def
|
|
129
|
+
def populate_feature_categories_map(data, current_section = nil, current_stage = nil, current_group = nil)
|
|
130
130
|
case data
|
|
131
131
|
when Hash # Sections / Stages / Groups
|
|
132
132
|
data.each do |key, value|
|
|
133
133
|
if current_section.nil? # Sections
|
|
134
|
-
|
|
134
|
+
populate_feature_categories_map(value, key, nil, nil)
|
|
135
135
|
elsif current_stage.nil? # Stages
|
|
136
|
-
|
|
136
|
+
populate_feature_categories_map(value, current_section, key, nil)
|
|
137
137
|
elsif current_group.nil? # Groups
|
|
138
|
-
|
|
139
|
-
else #
|
|
140
|
-
|
|
138
|
+
populate_feature_categories_map(value, current_section, current_stage, key)
|
|
139
|
+
else # Feature categories
|
|
140
|
+
populate_feature_categories_map(value, current_section, current_stage, current_group)
|
|
141
141
|
end
|
|
142
142
|
end
|
|
143
|
-
when Array #
|
|
144
|
-
data.each do |
|
|
145
|
-
next unless
|
|
143
|
+
when Array # Feature categories array
|
|
144
|
+
data.each do |feature_category|
|
|
145
|
+
next unless feature_category.is_a?(String)
|
|
146
146
|
|
|
147
|
-
@
|
|
147
|
+
@feature_categories_map[feature_category] = {
|
|
148
148
|
section: current_section,
|
|
149
149
|
stage: current_stage,
|
|
150
150
|
group: current_group
|
|
@@ -42,14 +42,14 @@ module GitlabQuality
|
|
|
42
42
|
logger.info("#{LOG_PREFIX} Successfully truncated table #{full_table_name}")
|
|
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
|
-
records.fetch(
|
|
49
|
+
def owners(feature_category_name)
|
|
50
|
+
records.fetch(feature_category_name)
|
|
51
51
|
rescue KeyError
|
|
52
|
-
raise(MissingMappingError, "
|
|
52
|
+
raise(MissingMappingError, "Feature category '#{feature_category_name}' not found in table '#{table_name}'")
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
private
|
|
@@ -23,6 +23,8 @@ module GitlabQuality
|
|
|
23
23
|
branch_coverage Nullable(Float64),
|
|
24
24
|
function_coverage Nullable(Float64),
|
|
25
25
|
source_file_type String,
|
|
26
|
+
is_responsible Nullable(Bool),
|
|
27
|
+
is_dependent Nullable(Bool),
|
|
26
28
|
category Nullable(String),
|
|
27
29
|
ci_project_id Nullable(UInt32),
|
|
28
30
|
ci_project_path Nullable(String),
|
|
@@ -97,7 +99,9 @@ module GitlabQuality
|
|
|
97
99
|
branch_coverage: record[:branch_coverage],
|
|
98
100
|
function_coverage: record[:function_coverage],
|
|
99
101
|
source_file_type: record[:source_file_type],
|
|
100
|
-
|
|
102
|
+
is_responsible: record[:is_responsible],
|
|
103
|
+
is_dependent: record[:is_dependent],
|
|
104
|
+
category: record[:feature_category],
|
|
101
105
|
**ci_metadata
|
|
102
106
|
}
|
|
103
107
|
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,81 @@ 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
|
+
}
|
|
85
|
+
end
|
|
69
86
|
|
|
70
87
|
def no_owner_info
|
|
71
88
|
{
|
|
72
|
-
|
|
89
|
+
feature_category: nil,
|
|
73
90
|
group: nil,
|
|
74
91
|
stage: nil,
|
|
75
92
|
section: nil
|
|
76
93
|
}
|
|
77
94
|
end
|
|
78
95
|
|
|
79
|
-
def owner_info(
|
|
80
|
-
owner_info = @
|
|
96
|
+
def owner_info(feature_category)
|
|
97
|
+
owner_info = @feature_categories_to_teams[feature_category]
|
|
81
98
|
|
|
82
99
|
{
|
|
83
|
-
|
|
100
|
+
feature_category: feature_category,
|
|
84
101
|
group: owner_info&.dig(:group),
|
|
85
102
|
stage: owner_info&.dig(:stage),
|
|
86
103
|
section: owner_info&.dig(:section)
|
|
87
104
|
}
|
|
88
105
|
end
|
|
89
106
|
|
|
90
|
-
|
|
91
|
-
|
|
107
|
+
# Returns a hash of feature_category => { is_responsible: bool, is_dependent: bool }
|
|
108
|
+
# for a given source file. A feature category can have both flags true if it has
|
|
109
|
+
# both unit tests (responsible) and integration/E2E tests (dependent).
|
|
110
|
+
def feature_categories_with_responsibility_flags_for(file)
|
|
111
|
+
test_files = @source_file_to_tests[file] || []
|
|
112
|
+
return {} if test_files.empty?
|
|
113
|
+
|
|
114
|
+
test_files.each_with_object({}) do |test_file, feature_category_to_flags|
|
|
115
|
+
feature_categories = @tests_to_feature_categories[test_file] || []
|
|
116
|
+
classification = @test_classifications[test_file]
|
|
117
|
+
|
|
118
|
+
feature_categories.each do |feature_category|
|
|
119
|
+
feature_category_to_flags[feature_category] ||= { is_responsible: false, is_dependent: false }
|
|
120
|
+
|
|
121
|
+
case classification
|
|
122
|
+
when RESPONSIBLE
|
|
123
|
+
feature_category_to_flags[feature_category][:is_responsible] = true
|
|
124
|
+
when DEPENDENT
|
|
125
|
+
feature_category_to_flags[feature_category][:is_dependent] = true
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
92
129
|
end
|
|
93
130
|
|
|
94
131
|
def all_files
|
|
@@ -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
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitlab_quality-test_tooling
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GitLab Quality
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-12-
|
|
11
|
+
date: 2025-12-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: climate_control
|
|
@@ -484,6 +484,7 @@ files:
|
|
|
484
484
|
- lefthook.yml
|
|
485
485
|
- lib/gitlab_quality/test_tooling.rb
|
|
486
486
|
- lib/gitlab_quality/test_tooling/click_house/client.rb
|
|
487
|
+
- lib/gitlab_quality/test_tooling/code_coverage/README.md
|
|
487
488
|
- lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb
|
|
488
489
|
- lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb
|
|
489
490
|
- lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb
|
|
@@ -491,6 +492,8 @@ files:
|
|
|
491
492
|
- lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb
|
|
492
493
|
- lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb
|
|
493
494
|
- lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb
|
|
495
|
+
- lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier.rb
|
|
496
|
+
- lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb
|
|
494
497
|
- lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb
|
|
495
498
|
- lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier.rb
|
|
496
499
|
- lib/gitlab_quality/test_tooling/code_coverage/test_map.rb
|