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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c662ad8ff8b0b48b868eb201c12bd783c4a446bf0647d01e42778d59fd9f334
4
- data.tar.gz: 3b26b7d715a0d55314e35194f490bef33aefc590356095bfb247e3f81cc2da5b
3
+ metadata.gz: 5fb5356dd9a684e76ccf4de875f66d77ee818bef86d1cd9792eb8db9d688cfe9
4
+ data.tar.gz: 75e5afd55644a124fbf09f4fe0edd8f298fb2614f54dd0884a8a18002d2fe6d8
5
5
  SHA512:
6
- metadata.gz: b09d7935e13222a40f137f51a4ddc0164e3da841b9fc30eba901f8cead0f237a5ff1cc6c8df44a121e42d207fe0b1442e62ab3bc415f477a95a95ae05553c8c7
7
- data.tar.gz: 2d9f33720064de7b9f8b78c00fafe7ff0b582e7554b7c7f7d89e690b080cf5e71aa515407e3825ce110f48221f3761816268841c594c823b2539576530a4e938
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 — metrics export, test quarantine, failure reporting, flaky test tracking, and code coverage analysis.
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
- - **TestMetricsExporter** — Serializes RSpec results to ClickHouse (exception classes, failure messages, CI metadata)
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** — Batch HTTP client for pushing metrics
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (3.16.0)
4
+ gitlab_quality-test_tooling (3.19.1)
5
5
  activesupport (>= 7.0)
6
6
  amatch (~> 0.4.1)
7
7
  fog-google (~> 1.24, >= 1.24.1)
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)
@@ -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
- 'Optional. Glob pattern for per-test coverage JSON files. ' \
72
- 'When provided, populates code_coverage.test_coverage_per_file and runs the ' \
73
- 'daily test_health_risk aggregation. (e.g., "tmp/per-test-coverage-*.json")') do |pattern|
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 nightly aggregation so Jest tests count toward is_quarantined.') do |path|
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
- if params.any? && (required_params - params.keys).none?
106
- clickhouse_password = ENV.fetch('GLCI_CLICKHOUSE_METRICS_PASSWORD', nil)
107
- if clickhouse_password.to_s.strip.empty?
108
- puts "Error: GLCI_CLICKHOUSE_METRICS_PASSWORD environment variable must be set and not empty"
109
- exit 1
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
- [:clickhouse_url, :clickhouse_database, :clickhouse_username, :clickhouse_shared_database].each do |param|
113
- if params[param].to_s.strip.empty?
114
- puts "Error: --#{param.to_s.tr('_', '-')} cannot be empty"
115
- exit 1
116
- end
117
- end
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
- begin
120
- uri = URI.parse(params[:clickhouse_url])
121
- unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
122
- puts "Error: --clickhouse-url must be a valid HTTP or HTTPS URL"
123
- exit 1
124
- end
125
- rescue URI::InvalidURIError
126
- puts "Error: --clickhouse-url is not a valid URL format"
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
- BATCH_SIZE = 500
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
- total_batches = (sanitized_data.size.to_f / BATCH_SIZE).ceil
49
- sanitized_data.each_slice(BATCH_SIZE).with_index do |batch, index|
50
- logger.debug("#{LOG_PREFIX} Pushing batch #{index + 1} of #{total_batches} (#{batch.size} rows)")
51
- client.query(build_insert_sql(batch), format: "TabSeparated")
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
- def build_insert_sql(batch)
119
- rows_sql = batch.map { |record| build_row_sql(record) }.join(",\n")
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
@@ -71,27 +71,44 @@ WITH
71
71
  GROUP BY test_file
72
72
  HAVING is_flaky
73
73
  ),
74
- -- Jest quarantine comes from a flat file (quarantined_vue3_specs.txt) rather
75
- -- than the issue-based RSpec ledger. It is populated into this helper table
76
- -- by JestQuarantinedTestsTable before each nightly aggregation run.
77
- jest_quarantine_status AS (
78
- SELECT test_file
79
- FROM code_coverage.jest_quarantined_tests_today FINAL
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
- tc.`group`,
85
- tc.stage,
86
- tc.section,
87
- tc.total_lines,
88
- tc.covered_lines,
89
- (coalesce(qs.is_quarantined, FALSE) OR (jq.test_file IS NOT NULL)) AS is_quarantined,
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 qs ON qs.test_file = tc.test_file
93
- LEFT JOIN jest_quarantine_status jq ON jq.test_file = tc.test_file
94
- LEFT JOIN flaky_status fs ON fs.test_file = tc.test_file
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};
@@ -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