gitlab_quality-test_tooling 3.16.0 → 3.19.1
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/AGENTS.md +7 -4
- data/Gemfile.lock +1 -1
- data/README.md +11 -0
- data/exe/relate-failure-issue +10 -0
- data/exe/test-coverage +91 -63
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/per_test_coverage_table.rb +41 -8
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregation.sql +33 -16
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_score_snapshot.sql +31 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_score_snapshotter.rb +95 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data.rb +16 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_exporter.rb +181 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/issues_client.rb +24 -0
- data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +44 -1
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- metadata +5 -7
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/client.rb +0 -50
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +0 -88
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config_helper.rb +0 -115
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb +0 -67
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +0 -228
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5fb5356dd9a684e76ccf4de875f66d77ee818bef86d1cd9792eb8db9d688cfe9
|
|
4
|
+
data.tar.gz: 75e5afd55644a124fbf09f4fe0edd8f298fb2614f54dd0884a8a18002d2fe6d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 35ff364a8fbbd437bef5e94a6bedccf66b8288965fd6c136ce6c21baa3d9052ac44281e54dd3a11eb92dae156f6e9e123756fa526ddb24fd39c00450b1d7187e
|
|
7
|
+
data.tar.gz: 1de79819dcb8aadf7ef3994f54387008ff9d1e15ea4a0b0590caebe9cf2af99a2821dcc7008b7bff5b2df9da69d4103622624983ce7434272ec2e5663c1e6695
|
data/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# gitlab_quality-test_tooling
|
|
2
2
|
|
|
3
|
-
Ruby gem providing test tooling for GitLab CI —
|
|
3
|
+
Ruby gem providing test tooling for GitLab CI — test quarantine, failure reporting, flaky test tracking, and code coverage analysis.
|
|
4
4
|
|
|
5
5
|
## Tech Stack
|
|
6
6
|
|
|
@@ -70,11 +70,14 @@ Reference: https://docs.gitlab.com/ee/development/changelog.html
|
|
|
70
70
|
|
|
71
71
|
## Key Modules
|
|
72
72
|
|
|
73
|
-
- **
|
|
74
|
-
- **Report** — Creates/updates GitLab issues for test failures, flaky tests, slow tests
|
|
73
|
+
- **Report** — Creates/updates GitLab issues for test failures, flaky tests, slow tests. `RelateFailureIssue` can also trigger a GitLab Duo Agent Platform workflow to investigate newly-created failure issues (see `--duo-analysis` on `exe/relate-failure-issue`); the agent posts its analysis back as an issue comment.
|
|
75
74
|
- **TestQuarantine** — RSpec formatter that skips quarantined tests
|
|
76
75
|
- **CodeCoverage** — Coverage analysis and responsibility patterns
|
|
77
|
-
- **ClickHouse::Client** —
|
|
76
|
+
- **ClickHouse::Client** — HTTP query/insert client for ClickHouse, used by the code coverage subsystem
|
|
77
|
+
|
|
78
|
+
## Review Guidance
|
|
79
|
+
|
|
80
|
+
- `RelateFailureIssue#trigger_duo_analysis` deliberately rescues all `StandardError` (best-effort): a Duo/API failure must never block failure-issue creation. Preserve the broad rescue — don't narrow or remove it.
|
|
78
81
|
|
|
79
82
|
## Release Process
|
|
80
83
|
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -93,6 +93,7 @@ Usage: exe/relate-failure-issue [options]
|
|
|
93
93
|
Labels to exclude when searching for existing issues
|
|
94
94
|
--confidential Makes created new issues confidential
|
|
95
95
|
--dry-run Perform a dry-run (don't create or update issues)
|
|
96
|
+
--duo-analysis MODES Start a GitLab Duo workflow to investigate failure issues (comma-separated modes; supported: create). Off by default
|
|
96
97
|
-v, --version Show the version
|
|
97
98
|
-h, --help Show the usage
|
|
98
99
|
```
|
|
@@ -263,6 +264,12 @@ Usage: exe/feature-readiness-evaluation [options]
|
|
|
263
264
|
|
|
264
265
|
### `exe/test-coverage`
|
|
265
266
|
|
|
267
|
+
Runs in one of three modes:
|
|
268
|
+
|
|
269
|
+
- **Full coverage-metrics export** (default, no `--per-test-coverage` / `--run-aggregation`): reads the LCOV report, Crystalball test map, and test reports, then exports the coverage-metrics and test-file-mapping tables. Requires the full argument set.
|
|
270
|
+
- **Per-test coverage** (`--per-test-coverage GLOB`): inserts per-test rows into `code_coverage.test_coverage_per_file` and runs the daily `test_health_risk` aggregation unless `--skip-aggregation`. Needs only the `--clickhouse-*` options; `--test-reports` and `--jest-quarantine-file` are optional enrichment.
|
|
271
|
+
- **Aggregation only** (`--run-aggregation`): runs the daily `test_health_risk` aggregation and exits. Needs only the `--clickhouse-*` options. Pair with `--skip-aggregation` on batched per-test inserts to aggregate once at the end instead of once per batch.
|
|
272
|
+
|
|
266
273
|
```shell
|
|
267
274
|
Purpose: Export test coverage metrics to ClickHouse
|
|
268
275
|
Usage: exe/test-coverage [options]
|
|
@@ -280,6 +287,10 @@ Options:
|
|
|
280
287
|
ClickHouse shared database name
|
|
281
288
|
--responsibility-patterns PATH
|
|
282
289
|
Path to YAML file with responsibility classification patterns
|
|
290
|
+
--per-test-coverage GLOB Per-test coverage mode (see above). Glob for per-test coverage JSON/NDJSON files.
|
|
291
|
+
--jest-quarantine-file PATH Optional. Path to quarantined_vue3_specs.txt, ingested before the aggregation.
|
|
292
|
+
--skip-aggregation Optional. With --per-test-coverage, insert rows without running the aggregation.
|
|
293
|
+
--run-aggregation Run only the daily test_health_risk aggregation, then exit.
|
|
283
294
|
|
|
284
295
|
Environment variables:
|
|
285
296
|
GLCI_CLICKHOUSE_METRICS_PASSWORD ClickHouse password (required, not passed via CLI for security)
|
data/exe/relate-failure-issue
CHANGED
|
@@ -66,6 +66,16 @@ options = OptionParser.new do |opts|
|
|
|
66
66
|
params[:environment_issues_output_file] = file
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
opts.on('--duo-analysis MODES', Array,
|
|
70
|
+
'Start a GitLab Duo Agent Platform workflow to investigate failure issues (the agent posts its ' \
|
|
71
|
+
'analysis back as a comment). Comma-separated modes; currently supported: create. Off by default.') do |modes|
|
|
72
|
+
supported = %w[create]
|
|
73
|
+
invalid = modes - supported
|
|
74
|
+
raise OptionParser::InvalidArgument, "#{invalid.join(',')} (supported: #{supported.join(',')})" if invalid.any?
|
|
75
|
+
|
|
76
|
+
params[:duo_analysis] = modes
|
|
77
|
+
end
|
|
78
|
+
|
|
69
79
|
opts.on_tail('-v', '--version', 'Show the version') do
|
|
70
80
|
require_relative "../lib/gitlab_quality/test_tooling/version"
|
|
71
81
|
puts "#{$PROGRAM_NAME} : #{GitlabQuality::TestTooling::VERSION}"
|
data/exe/test-coverage
CHANGED
|
@@ -14,6 +14,7 @@ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/p
|
|
|
14
14
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table'
|
|
15
15
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/jest_quarantined_tests_table'
|
|
16
16
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregator'
|
|
17
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_score_snapshotter'
|
|
17
18
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/coverage_data'
|
|
18
19
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/lcov_file'
|
|
19
20
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/artifacts'
|
|
@@ -21,12 +22,12 @@ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_report'
|
|
|
21
22
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
|
|
22
23
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data'
|
|
23
24
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data'
|
|
25
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_exporter'
|
|
24
26
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier'
|
|
25
27
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier'
|
|
26
28
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config'
|
|
27
29
|
|
|
28
30
|
params = {}
|
|
29
|
-
required_params = [:test_reports, :coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username, :clickhouse_shared_database, :responsibility_patterns]
|
|
30
31
|
|
|
31
32
|
options = OptionParser.new do |opts|
|
|
32
33
|
opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
|
|
@@ -68,19 +69,35 @@ options = OptionParser.new do |opts|
|
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
opts.on('--per-test-coverage GLOB',
|
|
71
|
-
'
|
|
72
|
-
'
|
|
73
|
-
'
|
|
72
|
+
'Per-test coverage mode. Glob for per-test coverage JSON/NDJSON files. ' \
|
|
73
|
+
'Inserts rows into code_coverage.test_coverage_per_file and runs the daily ' \
|
|
74
|
+
'test_health_risk aggregation unless --skip-aggregation. Needs only the ' \
|
|
75
|
+
'--clickhouse-* options; --test-reports and --jest-quarantine-file are ' \
|
|
76
|
+
'optional enrichment. (e.g., "tmp/per-test-coverage-*.ndjson")') do |pattern|
|
|
74
77
|
params[:per_test_coverage] = pattern
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
opts.on('--jest-quarantine-file PATH',
|
|
78
81
|
'Optional. Path to quarantined_vue3_specs.txt. When provided alongside ' \
|
|
79
82
|
'--per-test-coverage, populates code_coverage.jest_quarantined_tests_today ' \
|
|
80
|
-
'before the
|
|
83
|
+
'before the aggregation so Jest tests count toward is_quarantined.') do |path|
|
|
81
84
|
params[:jest_quarantine_file] = path
|
|
82
85
|
end
|
|
83
86
|
|
|
87
|
+
opts.on('--skip-aggregation',
|
|
88
|
+
'Optional. With --per-test-coverage, insert rows (and ingest the jest ' \
|
|
89
|
+
'quarantine file) without running the test_health_risk aggregation. Use ' \
|
|
90
|
+
'when streaming many batches, then run --run-aggregation once at the end.') do
|
|
91
|
+
params[:skip_aggregation] = true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
opts.on('--run-aggregation',
|
|
95
|
+
'Run only the daily test_health_risk aggregation over ' \
|
|
96
|
+
'code_coverage.test_coverage_per_file, then exit. Needs only the ' \
|
|
97
|
+
'--clickhouse-* options.') do
|
|
98
|
+
params[:run_aggregation] = true
|
|
99
|
+
end
|
|
100
|
+
|
|
84
101
|
opts.separator ""
|
|
85
102
|
opts.separator "Environment variables:"
|
|
86
103
|
opts.separator " GLCI_CLICKHOUSE_METRICS_PASSWORD ClickHouse password (required, not passed via CLI for security)"
|
|
@@ -102,31 +119,81 @@ options = OptionParser.new do |opts|
|
|
|
102
119
|
opts.parse(ARGV)
|
|
103
120
|
end
|
|
104
121
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
122
|
+
mode =
|
|
123
|
+
if params[:run_aggregation]
|
|
124
|
+
puts "Warning: --per-test-coverage is ignored in --run-aggregation mode." if params[:per_test_coverage]
|
|
125
|
+
:aggregation
|
|
126
|
+
elsif params[:per_test_coverage]
|
|
127
|
+
:per_test
|
|
128
|
+
else
|
|
129
|
+
:full_export
|
|
110
130
|
end
|
|
111
131
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
132
|
+
# Per-test and aggregation-only modes are self-contained: they touch only the
|
|
133
|
+
# ClickHouse connection, not the LCOV report, Crystalball test map, or
|
|
134
|
+
# responsibility patterns the full coverage-metrics export needs.
|
|
135
|
+
clickhouse_required = [:clickhouse_url, :clickhouse_database, :clickhouse_username]
|
|
136
|
+
full_export_required =
|
|
137
|
+
[:test_reports, :coverage_report, :test_map, :responsibility_patterns, :clickhouse_shared_database] + clickhouse_required
|
|
138
|
+
required_params = mode == :full_export ? full_export_required : clickhouse_required
|
|
118
139
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
140
|
+
unless params.any? && (required_params - params.keys).none?
|
|
141
|
+
puts "Missing argument(s). Required arguments are: #{required_params}\nPassed arguments are: #{params}\n"
|
|
142
|
+
puts options
|
|
143
|
+
exit 1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
clickhouse_password = ENV.fetch('GLCI_CLICKHOUSE_METRICS_PASSWORD', nil)
|
|
147
|
+
if clickhouse_password.to_s.strip.empty?
|
|
148
|
+
puts "Error: GLCI_CLICKHOUSE_METRICS_PASSWORD environment variable must be set and not empty"
|
|
149
|
+
exit 1
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
non_empty_params = mode == :full_export ? clickhouse_required + [:clickhouse_shared_database] : clickhouse_required
|
|
153
|
+
non_empty_params.each do |param|
|
|
154
|
+
next unless params[param].to_s.strip.empty?
|
|
155
|
+
|
|
156
|
+
puts "Error: --#{param.to_s.tr('_', '-')} cannot be empty"
|
|
157
|
+
exit 1
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
begin
|
|
161
|
+
uri = URI.parse(params[:clickhouse_url])
|
|
162
|
+
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
163
|
+
puts "Error: --clickhouse-url must be a valid HTTP or HTTPS URL"
|
|
127
164
|
exit 1
|
|
128
165
|
end
|
|
166
|
+
rescue URI::InvalidURIError
|
|
167
|
+
puts "Error: --clickhouse-url is not a valid URL format"
|
|
168
|
+
exit 1
|
|
169
|
+
end
|
|
129
170
|
|
|
171
|
+
clickhouse_data = {
|
|
172
|
+
url: params[:clickhouse_url],
|
|
173
|
+
database: params[:clickhouse_database],
|
|
174
|
+
username: params[:clickhouse_username],
|
|
175
|
+
password: clickhouse_password
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case mode
|
|
179
|
+
when :aggregation
|
|
180
|
+
GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestHealthRiskAggregator.new(**clickhouse_data).run
|
|
181
|
+
# Best-effort: the score snapshot is a non-critical add-on, so it must not
|
|
182
|
+
# fail the job after the aggregation has already succeeded.
|
|
183
|
+
begin
|
|
184
|
+
GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestHealthScoreSnapshotter.new(**clickhouse_data).run
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
warn "Warning: score snapshot failed (aggregation succeeded), continuing: #{e.message}"
|
|
187
|
+
end
|
|
188
|
+
when :per_test
|
|
189
|
+
GitlabQuality::TestTooling::CodeCoverage::PerTestCoverageExporter.new(
|
|
190
|
+
coverage_glob: params[:per_test_coverage],
|
|
191
|
+
clickhouse: clickhouse_data,
|
|
192
|
+
test_reports: params[:test_reports],
|
|
193
|
+
jest_quarantine_file: params[:jest_quarantine_file],
|
|
194
|
+
skip_aggregation: params.fetch(:skip_aggregation, false)
|
|
195
|
+
).run
|
|
196
|
+
when :full_export
|
|
130
197
|
artifacts = GitlabQuality::TestTooling::CodeCoverage::Artifacts.new(
|
|
131
198
|
coverage_report: params[:coverage_report],
|
|
132
199
|
test_map: params[:test_map],
|
|
@@ -181,13 +248,6 @@ if params.any? && (required_params - params.keys).none?
|
|
|
181
248
|
test_classifications
|
|
182
249
|
)
|
|
183
250
|
|
|
184
|
-
clickhouse_data = {
|
|
185
|
-
url: params[:clickhouse_url],
|
|
186
|
-
database: params[:clickhouse_database],
|
|
187
|
-
username: params[:clickhouse_username],
|
|
188
|
-
password: clickhouse_password
|
|
189
|
-
}
|
|
190
|
-
|
|
191
251
|
shared_clickhouse_data = {
|
|
192
252
|
url: params[:clickhouse_url],
|
|
193
253
|
database: params[:clickhouse_shared_database],
|
|
@@ -210,36 +270,4 @@ if params.any? && (required_params - params.keys).none?
|
|
|
210
270
|
)
|
|
211
271
|
test_file_mappings_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestFileMappingsTable.new(**shared_clickhouse_data)
|
|
212
272
|
test_file_mappings_table.push(test_file_mapping_data.as_db_table)
|
|
213
|
-
|
|
214
|
-
# Per-test coverage export (optional). Only runs when --per-test-coverage
|
|
215
|
-
# was provided AND at least one matching artifact exists.
|
|
216
|
-
if params[:per_test_coverage]
|
|
217
|
-
per_test_files = Dir.glob(params[:per_test_coverage])
|
|
218
|
-
if per_test_files.any?
|
|
219
|
-
per_test_data = GitlabQuality::TestTooling::CodeCoverage::PerTestCoverageData.new(
|
|
220
|
-
per_test_files,
|
|
221
|
-
tests_to_categories: tests_to_categories,
|
|
222
|
-
feature_categories_to_teams: category_owners.feature_categories_to_teams,
|
|
223
|
-
captured_sha: ENV.fetch('CI_COMMIT_SHA', '')
|
|
224
|
-
)
|
|
225
|
-
per_test_coverage_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::PerTestCoverageTable.new(**clickhouse_data)
|
|
226
|
-
per_test_coverage_table.push(per_test_data.as_db_table)
|
|
227
|
-
|
|
228
|
-
if params[:jest_quarantine_file] && File.exist?(params[:jest_quarantine_file])
|
|
229
|
-
jest_qt = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::JestQuarantinedTestsTable.new(**clickhouse_data)
|
|
230
|
-
jest_qt.populate(quarantine_file: params[:jest_quarantine_file])
|
|
231
|
-
elsif params[:jest_quarantine_file]
|
|
232
|
-
puts "Jest quarantine file not found at #{params[:jest_quarantine_file]}; skipping jest quarantine ingestion."
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
aggregator = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestHealthRiskAggregator.new(**clickhouse_data)
|
|
236
|
-
aggregator.run
|
|
237
|
-
else
|
|
238
|
-
puts "No per-test coverage artifacts matched #{params[:per_test_coverage]}; skipping per-test export and aggregation."
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
else
|
|
242
|
-
puts "Missing argument(s). Required arguments are: #{required_params}\nPassed arguments are: #{params}\n"
|
|
243
|
-
puts options
|
|
244
|
-
exit 1
|
|
245
273
|
end
|
|
@@ -20,7 +20,15 @@ module GitlabQuality
|
|
|
20
20
|
# unioned into one row by the loader, not handed in as duplicates.
|
|
21
21
|
class PerTestCoverageTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
|
|
22
22
|
TABLE_NAME = "test_coverage_per_file"
|
|
23
|
-
|
|
23
|
+
# Cap each INSERT by rendered byte size rather than a fixed row count.
|
|
24
|
+
# test_coverage_per_file is a SharedReplacingMergeTree partitioned by
|
|
25
|
+
# month, so every INSERT lands a part in one partition; hundreds of
|
|
26
|
+
# tiny inserts pile up parts faster than background merges collapse
|
|
27
|
+
# them, and ClickHouse throttles inserts once the per-partition part
|
|
28
|
+
# count climbs. Batching by bytes keeps the part count low while
|
|
29
|
+
# bounding each statement well within ClickHouse's limits, regardless
|
|
30
|
+
# of how wide a row's covered-line bitmap is.
|
|
31
|
+
MAX_BATCH_BYTES = 4 * 1024 * 1024
|
|
24
32
|
# Intentionally generous ceiling on line numbers. Real source files
|
|
25
33
|
# are thousands of lines; generated artifacts (large GraphQL schemas,
|
|
26
34
|
# bundled JS, JSON manifests) can run past 100k. The cap is set to
|
|
@@ -45,10 +53,11 @@ module GitlabQuality
|
|
|
45
53
|
|
|
46
54
|
return logger.warn("#{LOG_PREFIX} No valid data found after sanitization, skipping ClickHouse export!") if sanitized_data.empty?
|
|
47
55
|
|
|
48
|
-
|
|
49
|
-
sanitized_data
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
batch_index = 0
|
|
57
|
+
each_byte_limited_batch(sanitized_data) do |rows_sql|
|
|
58
|
+
batch_index += 1
|
|
59
|
+
logger.debug("#{LOG_PREFIX} Pushing batch #{batch_index} (#{rows_sql.size} rows)")
|
|
60
|
+
client.query(build_insert_sql(rows_sql), format: "TabSeparated")
|
|
52
61
|
end
|
|
53
62
|
logger.info("#{LOG_PREFIX} Successfully pushed #{sanitized_data.size} records to #{full_table_name}")
|
|
54
63
|
rescue StandardError => e
|
|
@@ -115,16 +124,40 @@ module GitlabQuality
|
|
|
115
124
|
}
|
|
116
125
|
end
|
|
117
126
|
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
# Render an INSERT from already-built per-row SQL fragments. Callers
|
|
128
|
+
# group the fragments by byte size via `each_byte_limited_batch`.
|
|
129
|
+
def build_insert_sql(rows_sql)
|
|
120
130
|
<<~SQL
|
|
121
131
|
INSERT INTO #{full_table_name}
|
|
122
132
|
(timestamp, ci_project_path, test_file, source_file, covered_lines, total_lines, feature_category, `group`, stage, section, captured_sha)
|
|
123
133
|
VALUES
|
|
124
|
-
#{rows_sql}
|
|
134
|
+
#{rows_sql.join(",\n")}
|
|
125
135
|
SQL
|
|
126
136
|
end
|
|
127
137
|
|
|
138
|
+
# Yield arrays of rendered row SQL grouped so each INSERT stays under
|
|
139
|
+
# MAX_BATCH_BYTES. A record whose own rendered size exceeds the cap is
|
|
140
|
+
# still emitted alone rather than dropped.
|
|
141
|
+
def each_byte_limited_batch(records)
|
|
142
|
+
batch = []
|
|
143
|
+
batch_bytes = 0
|
|
144
|
+
|
|
145
|
+
records.each do |record|
|
|
146
|
+
row_sql = build_row_sql(record)
|
|
147
|
+
|
|
148
|
+
if batch.any? && batch_bytes + row_sql.bytesize > MAX_BATCH_BYTES
|
|
149
|
+
yield batch
|
|
150
|
+
batch = []
|
|
151
|
+
batch_bytes = 0
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
batch << row_sql
|
|
155
|
+
batch_bytes += row_sql.bytesize
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
yield batch if batch.any?
|
|
159
|
+
end
|
|
160
|
+
|
|
128
161
|
# Precondition: `record[:covered_lines]` is the sanitised integer
|
|
129
162
|
# array produced upstream by `sanitize_lines` (via
|
|
130
163
|
# `sanitized_data_record`). Values are positive integers within
|
data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregation.sql
CHANGED
|
@@ -71,27 +71,44 @@ WITH
|
|
|
71
71
|
GROUP BY test_file
|
|
72
72
|
HAVING is_flaky
|
|
73
73
|
),
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
test_ownership AS (
|
|
75
|
+
-- Authoritative test-file ownership from test_metrics, which resolves
|
|
76
|
+
-- feature_category to group/stage/section from a current source. The
|
|
77
|
+
-- per-test coverage export's own CategoryOwners mapping is stale (it fetches
|
|
78
|
+
-- a dated stages.yml), so many rows arrive with a blank group; this is the
|
|
79
|
+
-- fallback. argMax pins the latest ownership per file over the risk window.
|
|
80
|
+
SELECT
|
|
81
|
+
file_path,
|
|
82
|
+
argMax(`group`, timestamp) AS owner_group,
|
|
83
|
+
argMax(stage, timestamp) AS owner_stage,
|
|
84
|
+
argMax(section, timestamp) AS owner_section
|
|
85
|
+
FROM test_metrics.test_results
|
|
86
|
+
WHERE timestamp >= now() - INTERVAL {risk_window}
|
|
87
|
+
AND `group` NOT IN ('', 'unknown')
|
|
88
|
+
GROUP BY file_path
|
|
80
89
|
),
|
|
81
90
|
per_test_status AS (
|
|
82
91
|
SELECT
|
|
83
|
-
tc.source_file,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
tc
|
|
88
|
-
|
|
89
|
-
(
|
|
92
|
+
tc.source_file AS source_file,
|
|
93
|
+
-- Fall back to test_metrics ownership when the coverage row has no group
|
|
94
|
+
-- (stale CategoryOwners mapping). Resolve all three off the same condition
|
|
95
|
+
-- so the team stays internally consistent. Alias the passthrough columns to
|
|
96
|
+
-- bare names too: the analyzer otherwise keeps the `tc.` qualifier once the
|
|
97
|
+
-- ownership join is in play, and per_file references them unqualified.
|
|
98
|
+
if(tc.`group` != '', tc.`group`, own.owner_group) AS `group`,
|
|
99
|
+
if(tc.`group` != '', tc.stage, own.owner_stage) AS stage,
|
|
100
|
+
if(tc.`group` != '', tc.section, own.owner_section) AS section,
|
|
101
|
+
tc.total_lines AS total_lines,
|
|
102
|
+
tc.covered_lines AS covered_lines,
|
|
103
|
+
-- Jest quarantine is a flat list (quarantined_vue3_specs.txt), loaded into
|
|
104
|
+
-- jest_quarantined_tests_today by JestQuarantinedTestsTable before each run.
|
|
105
|
+
(coalesce(qs.is_quarantined, FALSE)
|
|
106
|
+
OR tc.test_file IN (SELECT test_file FROM code_coverage.jest_quarantined_tests_today FINAL)) AS is_quarantined,
|
|
90
107
|
coalesce(fs.is_flaky, FALSE) AS is_flaky
|
|
91
108
|
FROM code_coverage.test_coverage_per_file tc FINAL
|
|
92
|
-
LEFT JOIN quarantine_status
|
|
93
|
-
LEFT JOIN
|
|
94
|
-
LEFT JOIN
|
|
109
|
+
LEFT JOIN quarantine_status qs ON qs.test_file = tc.test_file
|
|
110
|
+
LEFT JOIN flaky_status fs ON fs.test_file = tc.test_file
|
|
111
|
+
LEFT JOIN test_ownership own ON own.file_path = tc.test_file
|
|
95
112
|
WHERE tc.timestamp >= now() - INTERVAL {coverage_window}
|
|
96
113
|
),
|
|
97
114
|
per_file AS (
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Freezes today's per-pillar Test Health Index scores into the history table.
|
|
2
|
+
--
|
|
3
|
+
-- Reads one pillar's score view (the same view the dashboard reads, so the
|
|
4
|
+
-- snapshot and the dashboard cannot diverge) and tags each row with the
|
|
5
|
+
-- snapshot date and the pillar. Run once per pillar by
|
|
6
|
+
-- TestHealthScoreSnapshotter, right after the daily risk aggregation refreshes
|
|
7
|
+
-- test_health_risk_per_group, which the view reads.
|
|
8
|
+
--
|
|
9
|
+
-- Idempotency: the target is a SharedReplacingMergeTree keyed by
|
|
10
|
+
-- (snapshot_date, level, group, stage, section, pillar) with a version column,
|
|
11
|
+
-- so re-running for the same snapshot_date upserts and the last run of the day
|
|
12
|
+
-- wins on merge. No "final run of the day" detection is needed.
|
|
13
|
+
--
|
|
14
|
+
-- Parameter substitution uses {name} braces, replaced via gsub in
|
|
15
|
+
-- TestHealthScoreSnapshotter#build_sql after each value passes a regex check:
|
|
16
|
+
-- {snapshot_date} : the date stamp for this run, e.g. '2026-05-07'
|
|
17
|
+
-- {pillar} : quarantine | slow (names the view and tags the rows)
|
|
18
|
+
INSERT INTO code_coverage.test_health_score_history
|
|
19
|
+
(snapshot_date, level, `group`, stage, section, pillar, score, rag_status, formula_version, factors)
|
|
20
|
+
SELECT
|
|
21
|
+
toDate('{snapshot_date}') AS snapshot_date,
|
|
22
|
+
level,
|
|
23
|
+
`group`,
|
|
24
|
+
stage,
|
|
25
|
+
section,
|
|
26
|
+
'{pillar}' AS pillar,
|
|
27
|
+
score,
|
|
28
|
+
rag_status,
|
|
29
|
+
formula_version,
|
|
30
|
+
factors
|
|
31
|
+
FROM code_coverage.test_health_scores_{pillar};
|
data/lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_score_snapshotter.rb
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'client'
|
|
4
|
+
require_relative 'table'
|
|
5
|
+
|
|
6
|
+
module GitlabQuality
|
|
7
|
+
module TestTooling
|
|
8
|
+
module CodeCoverage
|
|
9
|
+
module ClickHouse
|
|
10
|
+
# Freezes the day's per-pillar Test Health Index scores into
|
|
11
|
+
# `code_coverage.test_health_score_history`. Reads the per-pillar score
|
|
12
|
+
# views (the same views the dashboard reads, so the snapshot cannot
|
|
13
|
+
# diverge from the live score) and tags each row with the snapshot date
|
|
14
|
+
# and the pillar.
|
|
15
|
+
#
|
|
16
|
+
# Runs right after TestHealthRiskAggregator in the daily job: the views
|
|
17
|
+
# read `test_health_risk_per_group`, which the aggregator has just
|
|
18
|
+
# refreshed, so the freeze captures the current day. The producer never
|
|
19
|
+
# holds the score formula; it copies whatever the views compute.
|
|
20
|
+
class TestHealthScoreSnapshotter
|
|
21
|
+
include Client
|
|
22
|
+
|
|
23
|
+
SQL_FILE = File.expand_path('test_health_score_snapshot.sql', __dir__)
|
|
24
|
+
|
|
25
|
+
# One INSERT per pillar. The token names the view
|
|
26
|
+
# (test_health_scores_<pillar>) and tags the row's `pillar` column.
|
|
27
|
+
# Flaky is excluded until it is scored live.
|
|
28
|
+
PILLARS = %w[quarantine slow].freeze
|
|
29
|
+
|
|
30
|
+
DATE_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/
|
|
31
|
+
|
|
32
|
+
def initialize(url:, database:, username: nil, password: nil, logger: nil)
|
|
33
|
+
@url = url
|
|
34
|
+
@database = database
|
|
35
|
+
@username = username
|
|
36
|
+
@password = password
|
|
37
|
+
@logger = logger || ::Logger.new($stdout, level: ::Logger::INFO)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param snapshot_date [Date, String] date stamp for this run; defaults to today.
|
|
41
|
+
# @return [void]
|
|
42
|
+
def run(snapshot_date: Date.today)
|
|
43
|
+
PILLARS.each do |pillar|
|
|
44
|
+
logger.info("#{LOG_PREFIX} Snapshotting #{pillar} scores snapshot_date=#{snapshot_date}")
|
|
45
|
+
client.query(build_sql(snapshot_date: snapshot_date, pillar: pillar), format: "TabSeparated")
|
|
46
|
+
end
|
|
47
|
+
inserted = fetch_row_count(snapshot_date)
|
|
48
|
+
logger.info("#{LOG_PREFIX} Score snapshot wrote #{inserted} rows for snapshot_date=#{snapshot_date}")
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
logger.error("#{LOG_PREFIX} Score snapshot failed for #{snapshot_date}: #{e.message}")
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :url, :database, :username, :password, :logger
|
|
57
|
+
|
|
58
|
+
def build_sql(snapshot_date:, pillar:)
|
|
59
|
+
File.read(SQL_FILE)
|
|
60
|
+
.gsub('{snapshot_date}', validate_date(snapshot_date))
|
|
61
|
+
.gsub('{pillar}', validate_pillar(pillar))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# DateTime/Time `to_s` includes the time portion and is rejected.
|
|
65
|
+
def validate_date(value)
|
|
66
|
+
str = value.to_s
|
|
67
|
+
raise ArgumentError, "Invalid snapshot_date: #{value.inspect}" unless DATE_PATTERN.match?(str)
|
|
68
|
+
|
|
69
|
+
str
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The pillar names the view and is interpolated into the SQL, so
|
|
73
|
+
# restrict it to the known set rather than a looser pattern.
|
|
74
|
+
def validate_pillar(value)
|
|
75
|
+
raise ArgumentError, "Invalid pillar: #{value.inspect}" unless PILLARS.include?(value)
|
|
76
|
+
|
|
77
|
+
value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns 'unknown' on any error so a transient count-query failure
|
|
81
|
+
# can't mask the success of the actual INSERTs.
|
|
82
|
+
def fetch_row_count(snapshot_date)
|
|
83
|
+
count_sql = "SELECT count() FROM code_coverage.test_health_score_history FINAL " \
|
|
84
|
+
"WHERE snapshot_date = toDate('#{validate_date(snapshot_date)}')"
|
|
85
|
+
result = client.query(count_sql, format: "JSONCompact")
|
|
86
|
+
result&.dig('data', 0, 0) || 'unknown'
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
logger.debug("#{LOG_PREFIX} Could not fetch post-snapshot row count: #{e.message}")
|
|
89
|
+
'unknown'
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -50,6 +50,12 @@ module GitlabQuality
|
|
|
50
50
|
# catching unrelated standard exceptions.
|
|
51
51
|
ParseError = Class.new(StandardError)
|
|
52
52
|
|
|
53
|
+
# Source files we never want in coverage risk. Vendored gems (bundled
|
|
54
|
+
# into vendor/ruby in CI) are touched by nearly every test, which both
|
|
55
|
+
# inflates the export volume and pollutes the coverage-risk metrics with
|
|
56
|
+
# third-party code we don't own. Dropped before aggregation.
|
|
57
|
+
EXCLUDED_SOURCE_PREFIXES = %w[vendor/].freeze
|
|
58
|
+
|
|
53
59
|
# @param coverage_files [Array<String>] paths to per-test coverage JSON artifacts
|
|
54
60
|
# @param tests_to_categories [Hash<String, Array<String>>] test_file => [feature_category]
|
|
55
61
|
# @param feature_categories_to_teams [Hash<String, Hash>] category => {group:, stage:, section:}
|
|
@@ -73,6 +79,8 @@ module GitlabQuality
|
|
|
73
79
|
each_example(path) do |example_id, files|
|
|
74
80
|
test_file = extract_test_file_path(example_id)
|
|
75
81
|
files.each do |source_file, line_hits|
|
|
82
|
+
next if excluded_source_file?(source_file)
|
|
83
|
+
|
|
76
84
|
covered, total = parse_line_hits(line_hits)
|
|
77
85
|
next if covered.empty?
|
|
78
86
|
|
|
@@ -168,6 +176,14 @@ module GitlabQuality
|
|
|
168
176
|
end
|
|
169
177
|
[covered, total]
|
|
170
178
|
end
|
|
179
|
+
|
|
180
|
+
# True for source files excluded from coverage entirely (e.g. vendored
|
|
181
|
+
# gems). Strips a leading ./ so both "vendor/..." and "./vendor/..."
|
|
182
|
+
# match.
|
|
183
|
+
def excluded_source_file?(source_file)
|
|
184
|
+
normalized = source_file.to_s.delete_prefix('./')
|
|
185
|
+
EXCLUDED_SOURCE_PREFIXES.any? { |prefix| normalized.start_with?(prefix) }
|
|
186
|
+
end
|
|
171
187
|
end
|
|
172
188
|
end
|
|
173
189
|
end
|