gitlab_quality-test_tooling 3.0.0 → 3.3.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 -2
- data/exe/test-coverage +39 -7
- 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 +26 -26
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb +5 -28
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb +3 -41
- 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 +48 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb +71 -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 +33 -0
- 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 +16 -10
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +2 -0
- data/lib/gitlab_quality/test_tooling/test_result/base_test_result.rb +14 -1
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- metadata +7 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32b52c2177aaf57f1ccf6a2bb922fd47e267b251ce74921c356f9631252051da
|
|
4
|
+
data.tar.gz: 8930c0fb427f3a946cd99902a230bcf90c48a051c9ffa1d19fb10a487107d231
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f265a7b7c74d4ef36e06c53a945b96ab066a4304acd0e6c439cf10afee6d0483de9253ca9290b279db7ec3c1d2323146da3aea8cf92aa57d4d1914c9c1479b1f
|
|
7
|
+
data.tar.gz: b983b542d66e9ac5b5775dd97b80ce6b464a618a5adf573f18613014dcfecc86555fc8b7d20e32b33b41b23ce9be255811cc276a50a1ee7945f0e4062deb15cf
|
data/Gemfile.lock
CHANGED
data/exe/test-coverage
CHANGED
|
@@ -3,21 +3,26 @@
|
|
|
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
|
|
|
9
10
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/category_owners'
|
|
10
11
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table'
|
|
11
12
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table'
|
|
13
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table'
|
|
12
14
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/coverage_data'
|
|
13
15
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/lcov_file'
|
|
14
16
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/artifacts'
|
|
15
17
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_report'
|
|
16
18
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
|
|
19
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data'
|
|
17
20
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier'
|
|
21
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier'
|
|
22
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config'
|
|
18
23
|
|
|
19
24
|
params = {}
|
|
20
|
-
required_params = [:test_reports, :coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username]
|
|
25
|
+
required_params = [:test_reports, :coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username, :responsibility_patterns]
|
|
21
26
|
|
|
22
27
|
options = OptionParser.new do |opts|
|
|
23
28
|
opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
|
|
@@ -50,6 +55,10 @@ options = OptionParser.new do |opts|
|
|
|
50
55
|
params[:clickhouse_username] = username
|
|
51
56
|
end
|
|
52
57
|
|
|
58
|
+
opts.on('--responsibility-patterns PATH', 'Path to YAML file with responsibility classification patterns') do |path|
|
|
59
|
+
params[:responsibility_patterns] = path
|
|
60
|
+
end
|
|
61
|
+
|
|
53
62
|
opts.separator ""
|
|
54
63
|
opts.separator "Environment variables:"
|
|
55
64
|
opts.separator " GLCI_CLICKHOUSE_METRICS_PASSWORD ClickHouse password (required, not passed via CLI for security)"
|
|
@@ -107,7 +116,9 @@ if params.any? && (required_params - params.keys).none?
|
|
|
107
116
|
|
|
108
117
|
code_coverage_by_source_file = GitlabQuality::TestTooling::CodeCoverage::LcovFile.new(coverage_report).parsed_content
|
|
109
118
|
|
|
110
|
-
|
|
119
|
+
test_map_parser = GitlabQuality::TestTooling::CodeCoverage::TestMap.new(test_map)
|
|
120
|
+
source_file_to_tests = test_map_parser.source_to_tests
|
|
121
|
+
test_to_sources = test_map_parser.test_to_sources
|
|
111
122
|
|
|
112
123
|
# Process test reports
|
|
113
124
|
tests_to_categories = artifacts.test_reports.reduce({}) do |combined_hash, test_report_file|
|
|
@@ -121,12 +132,31 @@ if params.any? && (required_params - params.keys).none?
|
|
|
121
132
|
source_file_classifier = GitlabQuality::TestTooling::CodeCoverage::SourceFileClassifier.new
|
|
122
133
|
source_file_types = source_file_classifier.classify(code_coverage_by_source_file.keys)
|
|
123
134
|
|
|
135
|
+
# Load responsibility patterns from config file
|
|
136
|
+
begin
|
|
137
|
+
patterns_config = GitlabQuality::TestTooling::CodeCoverage::ResponsibilityPatternsConfig.new(
|
|
138
|
+
params[:responsibility_patterns]
|
|
139
|
+
)
|
|
140
|
+
rescue GitlabQuality::TestTooling::CodeCoverage::ResponsibilityPatternsConfig::ConfigError => e
|
|
141
|
+
puts "Error: #{e.message}"
|
|
142
|
+
exit 1
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Classify test files as responsible or dependent
|
|
146
|
+
responsibility_classifier = GitlabQuality::TestTooling::CodeCoverage::ResponsibilityClassifier.new(
|
|
147
|
+
test_to_sources,
|
|
148
|
+
responsible_patterns: patterns_config.responsible_patterns,
|
|
149
|
+
dependent_patterns: patterns_config.dependent_patterns
|
|
150
|
+
)
|
|
151
|
+
test_classifications = responsibility_classifier.classify_tests
|
|
152
|
+
|
|
124
153
|
coverage_data = GitlabQuality::TestTooling::CodeCoverage::CoverageData.new(
|
|
125
154
|
code_coverage_by_source_file,
|
|
126
155
|
source_file_to_tests,
|
|
127
156
|
tests_to_categories,
|
|
128
|
-
category_owners.
|
|
129
|
-
source_file_types
|
|
157
|
+
category_owners.feature_categories_to_teams,
|
|
158
|
+
source_file_types,
|
|
159
|
+
test_classifications
|
|
130
160
|
)
|
|
131
161
|
|
|
132
162
|
clickhouse_data = {
|
|
@@ -139,15 +169,17 @@ if params.any? && (required_params - params.keys).none?
|
|
|
139
169
|
category_owners_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CategoryOwnersTable.new(**clickhouse_data)
|
|
140
170
|
coverage_metrics_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::CoverageMetricsTable.new(**clickhouse_data)
|
|
141
171
|
|
|
142
|
-
category_owners_table.create if ENV['CLICKHOUSE_CREATE_CATEGORY_OWNERS_TABLE'] == 'true'
|
|
143
|
-
coverage_metrics_table.create if ENV['CLICKHOUSE_CREATE_COVERAGE_METRICS_TABLE'] == 'true'
|
|
144
|
-
|
|
145
172
|
if ENV['CLICKHOUSE_PUSH_CATEGORY_DATA'] == 'true'
|
|
146
173
|
category_owners_table.truncate
|
|
147
174
|
category_owners_table.push(category_owners.as_db_table)
|
|
148
175
|
end
|
|
149
176
|
|
|
150
177
|
coverage_metrics_table.push(coverage_data.as_db_table)
|
|
178
|
+
|
|
179
|
+
# Export test-to-file mappings
|
|
180
|
+
test_file_mapping_data = GitlabQuality::TestTooling::CodeCoverage::TestFileMappingData.new(test_to_sources)
|
|
181
|
+
test_file_mappings_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestFileMappingsTable.new(**clickhouse_data)
|
|
182
|
+
test_file_mappings_table.push(test_file_mapping_data.as_db_table)
|
|
151
183
|
else
|
|
152
184
|
puts "Missing argument(s). Required arguments are: #{required_params}\nPassed arguments are: #{params}\n"
|
|
153
185
|
puts options
|
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
- **Test-to-file mappings** - which source files each test covers
|
|
8
|
+
|
|
9
|
+
## How It Works
|
|
10
|
+
|
|
11
|
+
### Feature Category Attribution
|
|
12
|
+
|
|
13
|
+
Coverage data is enriched with feature category ownership by joining three data sources:
|
|
14
|
+
|
|
15
|
+
1. **Coverage Report** (LCOV) - which source files have coverage and their percentages
|
|
16
|
+
2. **Test Map** - which test files cover each source file
|
|
17
|
+
3. **Test Reports** (JSON) - which feature category each test file belongs to
|
|
18
|
+
|
|
19
|
+
```mermaid
|
|
20
|
+
flowchart LR
|
|
21
|
+
subgraph Inputs
|
|
22
|
+
A["<b>Coverage Report</b><br/>user.rb: 85%"]
|
|
23
|
+
B["<b>Test Map</b><br/>user.rb → user_spec.rb"]
|
|
24
|
+
C["<b>Test Reports</b><br/>user_spec.rb → user_profile"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
subgraph Output
|
|
28
|
+
E["<b>ClickHouse Record</b><br/>file: user.rb<br/>feature_category: user_profile<br/>coverage: 85%"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
A --> D((Join))
|
|
32
|
+
B --> D
|
|
33
|
+
C --> D
|
|
34
|
+
D --> E
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This enables **multi-category attribution**: if a source file is covered by tests from
|
|
38
|
+
multiple feature categories, it creates a separate record for each category in ClickHouse.
|
|
39
|
+
|
|
40
|
+
### Why All Three Inputs Are Required
|
|
41
|
+
|
|
42
|
+
| Input | Provides | Without it |
|
|
43
|
+
|-------|----------|------------|
|
|
44
|
+
| Coverage Report | Line/branch coverage percentages | No coverage metrics |
|
|
45
|
+
| Test Map | Source file → test file relationships | No feature category attribution (all records have `category=NULL`) |
|
|
46
|
+
| Test Reports | Test file → feature category metadata | No feature category attribution (all records have `category=NULL`) |
|
|
47
|
+
|
|
48
|
+
## Responsibility Classification
|
|
49
|
+
|
|
50
|
+
Tests are classified as either **responsible** or **dependent**:
|
|
51
|
+
|
|
52
|
+
- **Responsible**: Unit tests that directly test a component in isolation
|
|
53
|
+
- **Dependent**: Integration/E2E tests that exercise a component through other layers
|
|
54
|
+
|
|
55
|
+
This classification is tracked per (source_file, feature_category) combination using two boolean columns:
|
|
56
|
+
|
|
57
|
+
| is_responsible | is_dependent | Meaning |
|
|
58
|
+
|----------------|--------------|---------|
|
|
59
|
+
| `true` | `true` | Source file has both unit AND integration test coverage from this feature category |
|
|
60
|
+
| `true` | `false` | Source file has only unit test coverage from this feature category |
|
|
61
|
+
| `false` | `true` | Source file has only integration test coverage from this feature category |
|
|
62
|
+
| `nil` | `nil` | No test mapping exists for this source file |
|
|
63
|
+
|
|
64
|
+
### Configuration
|
|
65
|
+
|
|
66
|
+
This gem is designed to be reusable across different projects. Classification patterns
|
|
67
|
+
are project-specific and must be provided via a YAML config file, since different
|
|
68
|
+
codebases have different test directory structures. The config file defines regex
|
|
69
|
+
patterns for matching test file paths:
|
|
70
|
+
|
|
71
|
+
> **Note:** The table above describes the *semantic meaning* of the flags. The patterns
|
|
72
|
+
> you configure determine *which tests* produce those flags for your project.
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
# responsibility_patterns.yml
|
|
76
|
+
responsible:
|
|
77
|
+
- "^spec/(models|controllers|services)/" # Backend unit tests
|
|
78
|
+
- "^spec/frontend/" # Frontend unit tests
|
|
79
|
+
- "_test\\.go$" # Go unit tests
|
|
80
|
+
|
|
81
|
+
dependent:
|
|
82
|
+
- "^spec/(requests|features|integration)/" # Backend integration tests
|
|
83
|
+
- "^spec/frontend_integration/" # Frontend integration tests
|
|
84
|
+
- "^qa/" # E2E tests
|
|
85
|
+
- "_integration_test\\.go$" # Go integration tests
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Pattern matching rules:**
|
|
89
|
+
1. Dependent patterns are checked first (higher priority)
|
|
90
|
+
2. If no pattern matches, the test defaults to "dependent"
|
|
91
|
+
3. Patterns are Ruby regexes (escape special characters like `.` with `\\`)
|
|
92
|
+
|
|
93
|
+
**Why dependent has priority:** We use a conservative approach. `is_responsible: true`
|
|
94
|
+
makes a stronger claim ("this file has unit test coverage") than `is_dependent: true`.
|
|
95
|
+
If a test matches both patterns or no patterns, defaulting to "dependent" avoids
|
|
96
|
+
incorrectly inflating unit test coverage metrics. It's safer to under-claim than over-claim.
|
|
97
|
+
|
|
98
|
+
### Example: GitLab Configuration
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
# .gitlab/coverage/responsibility_patterns.yml
|
|
102
|
+
responsible:
|
|
103
|
+
# Backend unit test directories
|
|
104
|
+
- "^spec/(models|controllers|services|workers|helpers|mailers|policies|presenters|uploaders|validators|lib|graphql|serializers|components)/"
|
|
105
|
+
- "^ee/spec/(models|controllers|services|workers|helpers|mailers|policies|presenters|uploaders|validators|lib|graphql|serializers|components)/"
|
|
106
|
+
# Frontend unit tests
|
|
107
|
+
- "^spec/frontend/"
|
|
108
|
+
- "^ee/spec/frontend/"
|
|
109
|
+
# Go unit tests
|
|
110
|
+
- "_test\\.go$"
|
|
111
|
+
|
|
112
|
+
dependent:
|
|
113
|
+
# Backend integration tests
|
|
114
|
+
- "^spec/(requests|features|system|integration)/"
|
|
115
|
+
- "^ee/spec/(requests|features|system|integration)/"
|
|
116
|
+
# Frontend integration tests
|
|
117
|
+
- "^spec/frontend_integration/"
|
|
118
|
+
- "^ee/spec/frontend_integration/"
|
|
119
|
+
# E2E tests
|
|
120
|
+
- "^qa/"
|
|
121
|
+
# Go integration tests
|
|
122
|
+
- "_integration_test\\.go$"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Example: Standard Rails Project
|
|
126
|
+
|
|
127
|
+
```yaml
|
|
128
|
+
# config/responsibility_patterns.yml
|
|
129
|
+
responsible:
|
|
130
|
+
- "^test/(models|controllers|services|helpers|mailers)/"
|
|
131
|
+
- "^test/unit/"
|
|
132
|
+
|
|
133
|
+
dependent:
|
|
134
|
+
- "^test/(integration|system)/"
|
|
135
|
+
- "^spec/features/"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Test-to-File Mappings
|
|
139
|
+
|
|
140
|
+
When a test map is provided, the module also exports test-to-source-file relationships
|
|
141
|
+
to a separate `test_file_mappings` table. This enables:
|
|
142
|
+
|
|
143
|
+
- **Coverage context for tests** - see which source files a specific test covers
|
|
144
|
+
- **Impact analysis** - understand which files would lose coverage if a test is quarantined
|
|
145
|
+
- **Flaky test triage** - correlate flaky tests with the source files they cover
|
|
146
|
+
|
|
147
|
+
## CLI
|
|
148
|
+
|
|
149
|
+
Example usage:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
test-coverage \
|
|
153
|
+
--test-reports 'rspec/*.json' \
|
|
154
|
+
--coverage-report 'coverage/lcov.info' \
|
|
155
|
+
--test-map 'mapping.json' \
|
|
156
|
+
--responsibility-patterns 'config/responsibility_patterns.yml' \
|
|
157
|
+
--clickhouse-url 'https://clickhouse.example.com' \
|
|
158
|
+
--clickhouse-database 'coverage' \
|
|
159
|
+
--clickhouse-username 'user'
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
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
|
|
@@ -11,29 +11,6 @@ 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
|
|
36
|
-
|
|
37
14
|
def truncate
|
|
38
15
|
logger.debug("#{LOG_PREFIX} Truncating table #{full_table_name} ...")
|
|
39
16
|
|
|
@@ -42,14 +19,14 @@ module GitlabQuality
|
|
|
42
19
|
logger.info("#{LOG_PREFIX} Successfully truncated table #{full_table_name}")
|
|
43
20
|
end
|
|
44
21
|
|
|
45
|
-
# Owners of particular category as group, stage and section
|
|
22
|
+
# Owners of particular feature category as group, stage and section
|
|
46
23
|
#
|
|
47
|
-
# @param
|
|
24
|
+
# @param feature_category_name [String] the feature_category name
|
|
48
25
|
# @return [Hash]
|
|
49
|
-
def owners(
|
|
50
|
-
records.fetch(
|
|
26
|
+
def owners(feature_category_name)
|
|
27
|
+
records.fetch(feature_category_name)
|
|
51
28
|
rescue KeyError
|
|
52
|
-
raise(MissingMappingError, "
|
|
29
|
+
raise(MissingMappingError, "Feature category '#{feature_category_name}' not found in table '#{table_name}'")
|
|
53
30
|
end
|
|
54
31
|
|
|
55
32
|
private
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'time'
|
|
4
3
|
require_relative 'table'
|
|
5
4
|
|
|
6
5
|
module GitlabQuality
|
|
@@ -10,37 +9,6 @@ module GitlabQuality
|
|
|
10
9
|
class CoverageMetricsTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
|
|
11
10
|
TABLE_NAME = "coverage_metrics"
|
|
12
11
|
|
|
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")
|
|
42
|
-
end
|
|
43
|
-
|
|
44
12
|
private
|
|
45
13
|
|
|
46
14
|
# @return [Boolean] True if the record is valid, false otherwise
|
|
@@ -97,19 +65,13 @@ module GitlabQuality
|
|
|
97
65
|
branch_coverage: record[:branch_coverage],
|
|
98
66
|
function_coverage: record[:function_coverage],
|
|
99
67
|
source_file_type: record[:source_file_type],
|
|
100
|
-
|
|
68
|
+
is_responsible: record[:is_responsible],
|
|
69
|
+
is_dependent: record[:is_dependent],
|
|
70
|
+
category: record[:feature_category],
|
|
101
71
|
**ci_metadata
|
|
102
72
|
}
|
|
103
73
|
end
|
|
104
74
|
|
|
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
|
|
111
|
-
end
|
|
112
|
-
|
|
113
75
|
# @return [Hash] CI-related metadata
|
|
114
76
|
def ci_metadata
|
|
115
77
|
{
|
|
@@ -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,48 @@
|
|
|
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
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
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
|
|
@@ -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,33 @@
|
|
|
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
|
+
def initialize(test_to_sources)
|
|
10
|
+
@test_to_sources = test_to_sources
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [Array<Hash<Symbol, String>>] Mapping data formatted for database insertion
|
|
14
|
+
# @example Return value
|
|
15
|
+
# [
|
|
16
|
+
# { test_file: "spec/models/user_spec.rb", source_file: "app/models/user.rb" },
|
|
17
|
+
# { test_file: "spec/models/user_spec.rb", source_file: "lib/utils.rb" },
|
|
18
|
+
# ...
|
|
19
|
+
# ]
|
|
20
|
+
def as_db_table
|
|
21
|
+
@test_to_sources.flat_map do |test_file, source_files|
|
|
22
|
+
source_files.map do |source_file|
|
|
23
|
+
{
|
|
24
|
+
test_file: test_file,
|
|
25
|
+
source_file: source_file
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -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
|
|
@@ -46,12 +46,15 @@ module GitlabQuality
|
|
|
46
46
|
status: example.execution_result.status,
|
|
47
47
|
run_time: (example.execution_result.run_time * 1000).round,
|
|
48
48
|
location: example_location,
|
|
49
|
-
|
|
49
|
+
# TODO: remove exception_class once migration to exception_classes is fully complete on clickhouse side
|
|
50
|
+
exception_class: example.execution_result.exception&.class&.to_s,
|
|
51
|
+
exception_classes: exception_classes.map { |e| e.class.to_s }.uniq,
|
|
50
52
|
failure_exception: failure_exception,
|
|
51
53
|
quarantined: quarantined?,
|
|
52
54
|
feature_category: example.metadata[:feature_category] || "",
|
|
53
55
|
test_retried: config.test_retried_proc.call(example),
|
|
54
|
-
run_type: run_type
|
|
56
|
+
run_type: run_type,
|
|
57
|
+
spec_file_path_prefix: config.spec_file_path_prefix
|
|
55
58
|
}
|
|
56
59
|
end
|
|
57
60
|
|
|
@@ -127,22 +130,25 @@ module GitlabQuality
|
|
|
127
130
|
@file_path ||= example_location.gsub(/:\d+$/, "")
|
|
128
131
|
end
|
|
129
132
|
|
|
130
|
-
# Failure exception
|
|
133
|
+
# Failure exception classes
|
|
131
134
|
#
|
|
132
|
-
# @return [
|
|
133
|
-
def
|
|
134
|
-
example.execution_result.exception
|
|
135
|
+
# @return [Array<Exception>]
|
|
136
|
+
def exception_classes
|
|
137
|
+
exception = example.execution_result.exception
|
|
138
|
+
return [] unless exception
|
|
139
|
+
return [exception] unless exception.respond_to?(:all_exceptions)
|
|
140
|
+
|
|
141
|
+
exception.all_exceptions.flatten
|
|
135
142
|
end
|
|
136
143
|
|
|
137
144
|
# Truncated exception stacktrace
|
|
138
145
|
#
|
|
139
146
|
# @return [String]
|
|
140
147
|
def failure_exception
|
|
141
|
-
example.execution_result.exception
|
|
142
|
-
|
|
148
|
+
exception = example.execution_result.exception
|
|
149
|
+
return unless exception
|
|
143
150
|
|
|
144
|
-
|
|
145
|
-
end
|
|
151
|
+
exception.to_s.tr("\n", " ").slice(0, 1000)
|
|
146
152
|
end
|
|
147
153
|
|
|
148
154
|
# Test run type | suite name
|
|
@@ -65,6 +65,7 @@ module GitlabQuality
|
|
|
65
65
|
test_retried Bool,
|
|
66
66
|
feature_category LowCardinality(String) DEFAULT 'unknown',
|
|
67
67
|
run_type LowCardinality(String) DEFAULT 'unknown',
|
|
68
|
+
spec_file_path_prefix LowCardinality(String) DEFAULT '',
|
|
68
69
|
ci_project_id UInt32,
|
|
69
70
|
ci_job_name LowCardinality(String),
|
|
70
71
|
ci_job_id UInt64,
|
|
@@ -75,6 +76,7 @@ module GitlabQuality
|
|
|
75
76
|
ci_target_branch LowCardinality(String),
|
|
76
77
|
ci_server_url LowCardinality(String) DEFAULT 'https://gitlab.com',
|
|
77
78
|
exception_class String DEFAULT '',
|
|
79
|
+
exception_classes Array(String) DEFAULT [],
|
|
78
80
|
failure_exception String DEFAULT ''
|
|
79
81
|
)
|
|
80
82
|
ENGINE = MergeTree()
|
|
@@ -8,6 +8,7 @@ module GitlabQuality
|
|
|
8
8
|
"Net::ReadTimeout",
|
|
9
9
|
"403 Forbidden - Your account has been blocked",
|
|
10
10
|
"API failed (502) with `GitLab is not responding",
|
|
11
|
+
"Error Code: 502",
|
|
11
12
|
"unexpected token at 'GitLab is not responding'",
|
|
12
13
|
"GitLab: Internal API error (502).",
|
|
13
14
|
"could not be found (502)",
|
|
@@ -127,14 +128,26 @@ module GitlabQuality
|
|
|
127
128
|
end
|
|
128
129
|
|
|
129
130
|
def full_stacktrace
|
|
131
|
+
page_error_failure = ""
|
|
132
|
+
first_non_ignored_failure = ""
|
|
133
|
+
|
|
130
134
|
failures.each do |failure|
|
|
131
135
|
message = failure['message'] || ""
|
|
132
136
|
message_lines = failure['message_lines'] || []
|
|
133
137
|
|
|
134
138
|
next if IGNORED_FAILURES.any? { |e| message.include?(e) }
|
|
135
139
|
|
|
136
|
-
|
|
140
|
+
formatted_failure = message_lines.empty? ? message : message_lines.join("\n")
|
|
141
|
+
|
|
142
|
+
if message.include?("PageErrorChecker")
|
|
143
|
+
page_error_failure = formatted_failure
|
|
144
|
+
elsif first_non_ignored_failure.empty?
|
|
145
|
+
first_non_ignored_failure = formatted_failure
|
|
146
|
+
end
|
|
137
147
|
end
|
|
148
|
+
|
|
149
|
+
# Return PageErrorChecker failure if found, otherwise first non-ignored failure
|
|
150
|
+
page_error_failure.empty? ? first_non_ignored_failure : page_error_failure
|
|
138
151
|
end
|
|
139
152
|
|
|
140
153
|
def calls_shared_examples?
|
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.3.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-
|
|
11
|
+
date: 2025-12-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: climate_control
|
|
@@ -227,9 +227,6 @@ dependencies:
|
|
|
227
227
|
- - ">="
|
|
228
228
|
- !ruby/object:Gem::Version
|
|
229
229
|
version: '7.0'
|
|
230
|
-
- - "<"
|
|
231
|
-
- !ruby/object:Gem::Version
|
|
232
|
-
version: '7.3'
|
|
233
230
|
type: :runtime
|
|
234
231
|
prerelease: false
|
|
235
232
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -237,9 +234,6 @@ dependencies:
|
|
|
237
234
|
- - ">="
|
|
238
235
|
- !ruby/object:Gem::Version
|
|
239
236
|
version: '7.0'
|
|
240
|
-
- - "<"
|
|
241
|
-
- !ruby/object:Gem::Version
|
|
242
|
-
version: '7.3'
|
|
243
237
|
- !ruby/object:Gem::Dependency
|
|
244
238
|
name: amatch
|
|
245
239
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -490,15 +484,20 @@ files:
|
|
|
490
484
|
- lefthook.yml
|
|
491
485
|
- lib/gitlab_quality/test_tooling.rb
|
|
492
486
|
- lib/gitlab_quality/test_tooling/click_house/client.rb
|
|
487
|
+
- lib/gitlab_quality/test_tooling/code_coverage/README.md
|
|
493
488
|
- lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb
|
|
494
489
|
- lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb
|
|
495
490
|
- lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb
|
|
496
491
|
- lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb
|
|
497
492
|
- lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb
|
|
493
|
+
- lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table.rb
|
|
498
494
|
- lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb
|
|
499
495
|
- lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb
|
|
496
|
+
- lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier.rb
|
|
497
|
+
- lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb
|
|
500
498
|
- lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb
|
|
501
499
|
- lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier.rb
|
|
500
|
+
- lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data.rb
|
|
502
501
|
- lib/gitlab_quality/test_tooling/code_coverage/test_map.rb
|
|
503
502
|
- lib/gitlab_quality/test_tooling/code_coverage/test_report.rb
|
|
504
503
|
- lib/gitlab_quality/test_tooling/code_coverage/utils.rb
|