gitlab_quality-test_tooling 3.16.0 → 3.18.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c662ad8ff8b0b48b868eb201c12bd783c4a446bf0647d01e42778d59fd9f334
4
- data.tar.gz: 3b26b7d715a0d55314e35194f490bef33aefc590356095bfb247e3f81cc2da5b
3
+ metadata.gz: 3770b676c5ff745323a47749eb04f305d435cff9769a9b567a1cc76289d5b2bb
4
+ data.tar.gz: 9cab37f5980a19ce80980dec4f97d867ca7bfe6e5524b383c71ea8d01509bbc8
5
5
  SHA512:
6
- metadata.gz: b09d7935e13222a40f137f51a4ddc0164e3da841b9fc30eba901f8cead0f237a5ff1cc6c8df44a121e42d207fe0b1442e62ab3bc415f477a95a95ae05553c8c7
7
- data.tar.gz: 2d9f33720064de7b9f8b78c00fafe7ff0b582e7554b7c7f7d89e690b080cf5e71aa515407e3825ce110f48221f3761816268841c594c823b2539576530a4e938
6
+ metadata.gz: 42a46bd31d9d7bdf985ad8d430c02e0d7037e116b426ff6358f089ae619f08680cf091758cbe6b5b229612be29e691a5ee760a57fb87b0da91324860b3939590
7
+ data.tar.gz: 71e1eda39f86d10c71d4d14770a925eaad6dc09d019c1cbcf7d769c6e23a73de79cce7fab02412b53ddfee8d4f6e0fa56fa55ae9880636491ffa9aa1024121ae
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.18.0)
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
@@ -21,12 +21,12 @@ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_report'
21
21
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
22
22
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data'
23
23
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data'
24
+ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_exporter'
24
25
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier'
25
26
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier'
26
27
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config'
27
28
 
28
29
  params = {}
29
- required_params = [:test_reports, :coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username, :clickhouse_shared_database, :responsibility_patterns]
30
30
 
31
31
  options = OptionParser.new do |opts|
32
32
  opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
@@ -68,19 +68,35 @@ options = OptionParser.new do |opts|
68
68
  end
69
69
 
70
70
  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|
71
+ 'Per-test coverage mode. Glob for per-test coverage JSON/NDJSON files. ' \
72
+ 'Inserts rows into code_coverage.test_coverage_per_file and runs the daily ' \
73
+ 'test_health_risk aggregation unless --skip-aggregation. Needs only the ' \
74
+ '--clickhouse-* options; --test-reports and --jest-quarantine-file are ' \
75
+ 'optional enrichment. (e.g., "tmp/per-test-coverage-*.ndjson")') do |pattern|
74
76
  params[:per_test_coverage] = pattern
75
77
  end
76
78
 
77
79
  opts.on('--jest-quarantine-file PATH',
78
80
  'Optional. Path to quarantined_vue3_specs.txt. When provided alongside ' \
79
81
  '--per-test-coverage, populates code_coverage.jest_quarantined_tests_today ' \
80
- 'before the nightly aggregation so Jest tests count toward is_quarantined.') do |path|
82
+ 'before the aggregation so Jest tests count toward is_quarantined.') do |path|
81
83
  params[:jest_quarantine_file] = path
82
84
  end
83
85
 
86
+ opts.on('--skip-aggregation',
87
+ 'Optional. With --per-test-coverage, insert rows (and ingest the jest ' \
88
+ 'quarantine file) without running the test_health_risk aggregation. Use ' \
89
+ 'when streaming many batches, then run --run-aggregation once at the end.') do
90
+ params[:skip_aggregation] = true
91
+ end
92
+
93
+ opts.on('--run-aggregation',
94
+ 'Run only the daily test_health_risk aggregation over ' \
95
+ 'code_coverage.test_coverage_per_file, then exit. Needs only the ' \
96
+ '--clickhouse-* options.') do
97
+ params[:run_aggregation] = true
98
+ end
99
+
84
100
  opts.separator ""
85
101
  opts.separator "Environment variables:"
86
102
  opts.separator " GLCI_CLICKHOUSE_METRICS_PASSWORD ClickHouse password (required, not passed via CLI for security)"
@@ -102,31 +118,74 @@ options = OptionParser.new do |opts|
102
118
  opts.parse(ARGV)
103
119
  end
104
120
 
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
121
+ mode =
122
+ if params[:run_aggregation]
123
+ puts "Warning: --per-test-coverage is ignored in --run-aggregation mode." if params[:per_test_coverage]
124
+ :aggregation
125
+ elsif params[:per_test_coverage]
126
+ :per_test
127
+ else
128
+ :full_export
110
129
  end
111
130
 
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
131
+ # Per-test and aggregation-only modes are self-contained: they touch only the
132
+ # ClickHouse connection, not the LCOV report, Crystalball test map, or
133
+ # responsibility patterns the full coverage-metrics export needs.
134
+ clickhouse_required = [:clickhouse_url, :clickhouse_database, :clickhouse_username]
135
+ full_export_required =
136
+ [:test_reports, :coverage_report, :test_map, :responsibility_patterns, :clickhouse_shared_database] + clickhouse_required
137
+ required_params = mode == :full_export ? full_export_required : clickhouse_required
118
138
 
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"
139
+ unless params.any? && (required_params - params.keys).none?
140
+ puts "Missing argument(s). Required arguments are: #{required_params}\nPassed arguments are: #{params}\n"
141
+ puts options
142
+ exit 1
143
+ end
144
+
145
+ clickhouse_password = ENV.fetch('GLCI_CLICKHOUSE_METRICS_PASSWORD', nil)
146
+ if clickhouse_password.to_s.strip.empty?
147
+ puts "Error: GLCI_CLICKHOUSE_METRICS_PASSWORD environment variable must be set and not empty"
148
+ exit 1
149
+ end
150
+
151
+ non_empty_params = mode == :full_export ? clickhouse_required + [:clickhouse_shared_database] : clickhouse_required
152
+ non_empty_params.each do |param|
153
+ next unless params[param].to_s.strip.empty?
154
+
155
+ puts "Error: --#{param.to_s.tr('_', '-')} cannot be empty"
156
+ exit 1
157
+ end
158
+
159
+ begin
160
+ uri = URI.parse(params[:clickhouse_url])
161
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
162
+ puts "Error: --clickhouse-url must be a valid HTTP or HTTPS URL"
127
163
  exit 1
128
164
  end
165
+ rescue URI::InvalidURIError
166
+ puts "Error: --clickhouse-url is not a valid URL format"
167
+ exit 1
168
+ end
129
169
 
170
+ clickhouse_data = {
171
+ url: params[:clickhouse_url],
172
+ database: params[:clickhouse_database],
173
+ username: params[:clickhouse_username],
174
+ password: clickhouse_password
175
+ }
176
+
177
+ case mode
178
+ when :aggregation
179
+ GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestHealthRiskAggregator.new(**clickhouse_data).run
180
+ when :per_test
181
+ GitlabQuality::TestTooling::CodeCoverage::PerTestCoverageExporter.new(
182
+ coverage_glob: params[:per_test_coverage],
183
+ clickhouse: clickhouse_data,
184
+ test_reports: params[:test_reports],
185
+ jest_quarantine_file: params[:jest_quarantine_file],
186
+ skip_aggregation: params.fetch(:skip_aggregation, false)
187
+ ).run
188
+ when :full_export
130
189
  artifacts = GitlabQuality::TestTooling::CodeCoverage::Artifacts.new(
131
190
  coverage_report: params[:coverage_report],
132
191
  test_map: params[:test_map],
@@ -181,13 +240,6 @@ if params.any? && (required_params - params.keys).none?
181
240
  test_classifications
182
241
  )
183
242
 
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
243
  shared_clickhouse_data = {
192
244
  url: params[:clickhouse_url],
193
245
  database: params[:clickhouse_shared_database],
@@ -210,36 +262,4 @@ if params.any? && (required_params - params.keys).none?
210
262
  )
211
263
  test_file_mappings_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestFileMappingsTable.new(**shared_clickhouse_data)
212
264
  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
265
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'logger'
5
+
6
+ module GitlabQuality
7
+ module TestTooling
8
+ module CodeCoverage
9
+ # Standalone per-test coverage export: inserts per-test, per-source-file
10
+ # rows into `code_coverage.test_coverage_per_file` and, unless skipped,
11
+ # runs the daily `test_health_risk` aggregation.
12
+ #
13
+ # Unlike the full coverage-metrics export, this path needs only ClickHouse
14
+ # credentials and the per-test coverage glob, not the LCOV report, the
15
+ # Crystalball test map, or the responsibility patterns. Category enrichment
16
+ # (feature_category/group/stage/section per row) is optional: pass
17
+ # `test_reports` to map test files to feature categories via the test
18
+ # report JSON, otherwise those columns stay blank.
19
+ #
20
+ # `skip_aggregation` lets a batched caller insert without re-running the
21
+ # table-wide aggregation on every batch. The streaming export invokes this
22
+ # once per artifact batch with `skip_aggregation: true`, then runs the
23
+ # aggregation once at the end.
24
+ class PerTestCoverageExporter
25
+ # @param clickhouse [Hash] connection params (:url, :database, :username, :password)
26
+ def initialize(
27
+ coverage_glob:, clickhouse:,
28
+ test_reports: nil, jest_quarantine_file: nil,
29
+ captured_sha: ENV.fetch('CI_COMMIT_SHA', ''), skip_aggregation: false, logger: nil)
30
+ @coverage_glob = coverage_glob
31
+ @clickhouse = clickhouse
32
+ @test_reports = test_reports
33
+ @jest_quarantine_file = jest_quarantine_file
34
+ @captured_sha = captured_sha.to_s
35
+ @skip_aggregation = skip_aggregation
36
+ @logger = logger || ::Logger.new($stdout)
37
+ end
38
+
39
+ # @return [void]
40
+ def run
41
+ coverage_files = Dir.glob(coverage_glob)
42
+ if coverage_files.empty?
43
+ logger.info(
44
+ "#{ClickHouse::LOG_PREFIX} No per-test coverage artifacts matched #{coverage_glob}; nothing to export."
45
+ )
46
+ return
47
+ end
48
+
49
+ insert(coverage_files)
50
+ ingest_jest_quarantine
51
+ aggregate unless skip_aggregation
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :coverage_glob, :clickhouse, :test_reports, :jest_quarantine_file,
57
+ :captured_sha, :skip_aggregation, :logger
58
+
59
+ def insert(coverage_files)
60
+ tests_to_categories, feature_categories_to_teams = resolve_categories
61
+
62
+ data = PerTestCoverageData.new(
63
+ coverage_files,
64
+ tests_to_categories: tests_to_categories,
65
+ feature_categories_to_teams: feature_categories_to_teams,
66
+ captured_sha: captured_sha
67
+ )
68
+
69
+ ClickHouse::PerTestCoverageTable.new(**clickhouse_credentials).push(data.as_db_table)
70
+ end
71
+
72
+ def ingest_jest_quarantine
73
+ return unless jest_quarantine_file
74
+
75
+ unless File.exist?(jest_quarantine_file)
76
+ logger.info(
77
+ "#{ClickHouse::LOG_PREFIX} Jest quarantine file not found at #{jest_quarantine_file}; " \
78
+ "skipping jest quarantine ingestion."
79
+ )
80
+ return
81
+ end
82
+
83
+ ClickHouse::JestQuarantinedTestsTable.new(**clickhouse_credentials)
84
+ .populate(quarantine_file: jest_quarantine_file)
85
+ end
86
+
87
+ def aggregate
88
+ ClickHouse::TestHealthRiskAggregator.new(**clickhouse_credentials).run
89
+ end
90
+
91
+ # `tests_to_categories` comes from the test report JSON; without it, rows
92
+ # carry blank categories. `CategoryOwners` fetches stages.yml over HTTP,
93
+ # so only build it when there are reports to enrich.
94
+ def resolve_categories
95
+ return [{}, {}] unless test_reports
96
+
97
+ # Comma-separated patterns, mirroring Artifacts#test_reports.
98
+ patterns = test_reports.split(',').map(&:strip).reject(&:empty?)
99
+ report_files = Dir.glob(patterns)
100
+ return [{}, {}] if report_files.empty?
101
+
102
+ [tests_to_categories_from(report_files), CategoryOwners.new.feature_categories_to_teams]
103
+ end
104
+
105
+ def tests_to_categories_from(report_files)
106
+ report_files.each_with_object({}) do |file, combined|
107
+ # Category enrichment is optional, so a malformed or vanished report
108
+ # shouldn't abort the per-test insert (the rows are the point). Warn
109
+ # and skip the file, leaving those tests with blank categories.
110
+ begin
111
+ report = TestReport.new(JSON.parse(File.read(file)))
112
+ rescue JSON::ParserError, Errno::ENOENT => e
113
+ logger.warn("#{ClickHouse::LOG_PREFIX} Skipping unreadable test report #{file}: #{e.message}")
114
+ next
115
+ end
116
+
117
+ combined.merge!(report.tests_to_categories) { |_, old_val, new_val| (old_val + new_val).uniq }
118
+ end
119
+ end
120
+
121
+ def clickhouse_credentials
122
+ clickhouse.merge(logger: logger)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -43,6 +43,8 @@ module GitlabQuality
43
43
  # The GitLab client is used for API access: https://github.com/NARKOZ/gitlab
44
44
  class IssuesClient < GitlabClient
45
45
  REPORTER_ACCESS_LEVEL = 20
46
+ DUO_WORKFLOW_TIMEOUT = 30
47
+ DUO_WORKFLOW_DEFINITION = 'developer/v1'
46
48
 
47
49
  def assert_user_permission!
48
50
  handle_gitlab_client_exceptions do
@@ -123,6 +125,28 @@ module GitlabQuality
123
125
  end
124
126
  end
125
127
 
128
+ # Starts a GitLab Duo Agent Platform workflow (Flows API).
129
+ # The agent investigates and posts its own analysis back, so no follow-up call is needed.
130
+ #
131
+ # This is a best-effort helper: it deliberately does NOT use handle_gitlab_client_exceptions
132
+ # (which would retry with long backoffs and post failures to Slack) and uses a short timeout
133
+ # so a slow or unavailable Duo endpoint can never stall the reporting job. The caller is
134
+ # expected to rescue any error.
135
+ #
136
+ # project_id accepts either a numeric ID or a namespace path (e.g. 'gitlab-org/quality/...'),
137
+ # matching the GitLab API's project-identifier convention; the path form is verified working.
138
+ def start_duo_workflow(goal:, workflow_definition: DUO_WORKFLOW_DEFINITION, project_id: project)
139
+ client.post('/ai/duo_workflows/workflows',
140
+ timeout: DUO_WORKFLOW_TIMEOUT,
141
+ headers: { 'Content-Type' => 'application/json' },
142
+ body: {
143
+ project_id: project_id,
144
+ goal: goal,
145
+ workflow_definition: workflow_definition,
146
+ start_workflow: true
147
+ }.to_json)
148
+ end
149
+
126
150
  def edit_issue_note(issue_iid:, note_id:, note:)
127
151
  handle_gitlab_client_exceptions do
128
152
  client.edit_issue_note(project, issue_iid, note_id, note)
@@ -83,12 +83,13 @@ module GitlabQuality
83
83
  @metrics_files = Array(metrics_files)
84
84
  @group_similar = group_similar
85
85
  @environment_issues_output_file = environment_issues_output_file
86
+ @duo_analysis_modes = Array(kwargs[:duo_analysis])
86
87
  end
87
88
 
88
89
  private
89
90
 
90
91
  attr_reader :max_diff_ratio, :system_logs, :base_issue_labels, :exclude_labels_for_search, :metrics_files, :ops_gitlab_client, :group_similar,
91
- :environment_issues_output_file
92
+ :environment_issues_output_file, :duo_analysis_modes
92
93
 
93
94
  def run!
94
95
  puts "Reporting test failures in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
@@ -294,9 +295,47 @@ module GitlabQuality
294
295
  # On a dry run, created_issue may not be populated
295
296
  test.failure_issue ||= created_issue&.web_url
296
297
 
298
+ trigger_duo_analysis(created_issue, test) if duo_analysis_modes.include?('create')
299
+
297
300
  created_issue
298
301
  end
299
302
 
303
+ # Best-effort: start a GitLab Duo workflow to investigate the issue; the agent comments its
304
+ # analysis back. A failure here must never block issue creation or the pipeline.
305
+ def trigger_duo_analysis(issue, test)
306
+ return unless issue&.web_url
307
+
308
+ gitlab.start_duo_workflow(goal: duo_analysis_goal(issue, test))
309
+ puts " => Triggered Duo failure analysis workflow for #{issue.web_url}"
310
+ rescue StandardError => e
311
+ warn(" => Could not trigger Duo failure analysis for #{issue.web_url} (ignored): #{e.class}: #{e.message}")
312
+ end
313
+
314
+ def duo_analysis_goal(issue, test)
315
+ <<~GOAL.chomp
316
+ Investigate the newly-reported end-to-end test failure described in this issue: #{issue.web_url}
317
+ (failing spec: `#{test.name}`). Read the issue for the failing spec and stack trace.
318
+
319
+ Investigate the `gitlab-org/gitlab` project (where the product code and E2E specs live —
320
+ note this is NOT the project hosting this issue): inspect its recent commits and merge
321
+ requests on the default branch using GitLab tools, and consult the Orbit knowledge graph
322
+ via MCP tools if it is available. Using those, identify the most likely cause of this
323
+ failure. Then post a comment on that issue with your analysis.
324
+
325
+ The failure may be caused by a product code change, or it may be environmental — for
326
+ example a flaky test, an infrastructure/environment issue, or a CI runner problem. Decide
327
+ which is more likely:
328
+
329
+ - If a specific change or merge request looks responsible, name the likely author in your
330
+ comment and ask them to confirm. Frame this as advisory ("this looks likely related —
331
+ please confirm"), never as an assertion of blame.
332
+ - If the failure looks environmental, flaky, or otherwise not attributable to a specific
333
+ change, explain why — but do not ping or attribute it to anyone.
334
+
335
+ This is an automated, best-effort request to speed up triage of newly-detected failures.
336
+ GOAL
337
+ end
338
+
300
339
  def pipeline_issues_with_similar_stacktrace(test)
301
340
  search_labels = (base_issue_labels + Set.new(%w[test failure::new])).to_a
302
341
  not_labels = exclude_labels_for_search.to_a
@@ -428,6 +467,10 @@ module GitlabQuality
428
467
  end
429
468
 
430
469
  def generate_diff_link
470
+ # Outside CI (e.g. local runs) there is no pipeline to diff against, so skip gracefully
471
+ # rather than crashing on a nil pipeline URL or an unsupported project.
472
+ return "No commit diff available (no CI pipeline context)." unless Runtime::Env.ci_pipeline_url
473
+
431
474
  initialize_gitlab_ops_client
432
475
 
433
476
  if Runtime::Env.ci_pipeline_url.include?('ops.gitlab.net')
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "3.16.0"
5
+ VERSION = "3.18.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab_quality-test_tooling
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.16.0
4
+ version: 3.18.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: 2026-05-26 00:00:00.000000000 Z
11
+ date: 2026-06-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: climate_control
@@ -502,6 +502,7 @@ files:
502
502
  - lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb
503
503
  - lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb
504
504
  - lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data.rb
505
+ - lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_exporter.rb
505
506
  - lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier.rb
506
507
  - lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb
507
508
  - lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb
@@ -594,11 +595,6 @@ files:
594
595
  - lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb
595
596
  - lib/gitlab_quality/test_tooling/test_metric/json_test_metric.rb
596
597
  - lib/gitlab_quality/test_tooling/test_metrics/json_test_metric_collection.rb
597
- - lib/gitlab_quality/test_tooling/test_metrics_exporter/client.rb
598
- - lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb
599
- - lib/gitlab_quality/test_tooling/test_metrics_exporter/config_helper.rb
600
- - lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb
601
- - lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb
602
598
  - lib/gitlab_quality/test_tooling/test_quarantine/quarantine_formatter.rb
603
599
  - lib/gitlab_quality/test_tooling/test_quarantine/quarantine_helper.rb
604
600
  - lib/gitlab_quality/test_tooling/test_result/base_test_result.rb
@@ -1,50 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "httparty"
4
- require "json"
5
-
6
- module GitlabQuality
7
- module TestTooling
8
- module TestMetricsExporter
9
- class Client
10
- ResponseError = Class.new(StandardError)
11
-
12
- TESTS_PATH = "/api/v1/tests"
13
- # Observer enforces a per-request cap; batch above this size to avoid silent failures.
14
- MAX_BATCH_SIZE = 10_000
15
-
16
- def initialize(url:, token:)
17
- @url = url
18
- @token = token
19
- end
20
-
21
- # POST array of test metric records to the observer service.
22
- # Wraps each batch as { "tests" => [...] } and splits oversized payloads
23
- # into chunks of at most MAX_BATCH_SIZE records.
24
- #
25
- # @param tests [Array<Hash>]
26
- # @return [Boolean] true when every batch succeeds (or input is empty)
27
- # @raise [ResponseError] on the first non-2xx batch response
28
- def post_tests(tests)
29
- tests.each_slice(MAX_BATCH_SIZE) do |batch|
30
- response = HTTParty.post(
31
- "#{url.to_s.chomp('/')}#{TESTS_PATH}",
32
- body: { tests: batch }.to_json,
33
- headers: {
34
- "X-Gitlab-Token" => token,
35
- "Content-Type" => "application/json"
36
- }
37
- )
38
- raise ResponseError, "Observer request failed with status #{response.code}: #{response.body}" unless response.success?
39
- end
40
-
41
- true
42
- end
43
-
44
- private
45
-
46
- attr_reader :url, :token
47
- end
48
- end
49
- end
50
- end
@@ -1,88 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "logger"
4
- require "singleton"
5
-
6
- module GitlabQuality
7
- module TestTooling
8
- module TestMetricsExporter
9
- class Config
10
- include Singleton
11
-
12
- class << self
13
- def configuration
14
- Config.instance
15
- end
16
-
17
- def configure
18
- yield(configuration)
19
- end
20
- end
21
-
22
- attr_accessor :run_type,
23
- :observer_url,
24
- :observer_token
25
- attr_writer :extra_rspec_metadata_keys,
26
- :skip_record_proc,
27
- :test_retried_proc,
28
- :custom_metrics_proc,
29
- :spec_file_path_prefix,
30
- :logger
31
-
32
- # Whether observer export is configured
33
- #
34
- # Export is considered enabled when all required attributes are set
35
- #
36
- # @return [Boolean]
37
- def observer_configured?
38
- [observer_url, observer_token].none? { |value| value.nil? || value.to_s.empty? }
39
- end
40
-
41
- # Extra rspec metadata keys to include in exported metrics
42
- #
43
- # @return [Array<Symbol>]
44
- def extra_rspec_metadata_keys
45
- @extra_rspec_metadata_keys ||= []
46
- end
47
-
48
- # Extra path prefix for constructing full file path within mono-repository setups
49
- #
50
- # @return [String]
51
- def spec_file_path_prefix
52
- @spec_file_path_prefix ||= ""
53
- end
54
-
55
- # A lambda that determines whether to skip recording a test result
56
- #
57
- # This is useful when you would want to skip initial failure when retrying specs is set up in a separate process
58
- # and you want to avoid duplicate records
59
- #
60
- # @return [Proc]
61
- def skip_record_proc
62
- @skip_record_proc ||= ->(_example) { false }
63
- end
64
-
65
- # A lambda that determines whether a test was retried or not
66
- #
67
- # @return [Proc]
68
- def test_retried_proc
69
- @test_retried_proc ||= ->(_example) { false }
70
- end
71
-
72
- # A lambda that return hash with additional custom metrics
73
- #
74
- # @return [Proc]
75
- def custom_metrics_proc
76
- @custom_metrics_proc ||= ->(_example) { {} }
77
- end
78
-
79
- # Logger instance
80
- #
81
- # @return [Logger]
82
- def logger
83
- @logger ||= Logger.new($stdout, level: Logger::INFO)
84
- end
85
- end
86
- end
87
- end
88
- end
@@ -1,115 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'logger'
4
- require 'active_support/core_ext/object/blank'
5
-
6
- require_relative 'config'
7
- require_relative 'formatter'
8
-
9
- module GitlabQuality
10
- module TestTooling
11
- module TestMetricsExporter
12
- class ConfigHelper
13
- REQUIRED_OBSERVER_ENV_VARS = %w[
14
- GLCI_OBSERVER_URL
15
- GLCI_OBSERVER_AUTH_TOKEN
16
- ].freeze
17
-
18
- class << self
19
- def configure!(run_type = test_run_type)
20
- return unless ENV.fetch("CI", nil) && ENV.fetch("GLCI_EXPORT_TEST_METRICS", "true") == "true" && run_type
21
-
22
- RSpec.configure do |rspec_config|
23
- next if rspec_config.dry_run?
24
-
25
- Config.configure do |exporter_config|
26
- self.logger = exporter_config.logger
27
- next warn_missing_observer_variables unless observer_env_vars_present?
28
-
29
- yield(exporter_config) if block_given?
30
- configure_exporter!(exporter_config, run_type)
31
-
32
- rspec_config.add_formatter Formatter
33
-
34
- logger.info("Test metrics export is enabled for run type: #{run_type}")
35
- end
36
- end
37
- end
38
-
39
- private
40
-
41
- attr_writer :logger
42
-
43
- def logger
44
- @logger ||= Logger.new($stdout)
45
- end
46
-
47
- def observer_env_vars_present?
48
- REQUIRED_OBSERVER_ENV_VARS.all? { |var| ENV.fetch(var, nil) && !ENV[var].empty? }
49
- end
50
-
51
- def configure_exporter!(config, run_type)
52
- config.run_type = run_type
53
- config.custom_metrics_proc = custom_metrics_proc
54
-
55
- configure_observer!(config)
56
- end
57
-
58
- def configure_observer!(config)
59
- config.observer_url = observer_url
60
- config.observer_token = observer_token
61
- end
62
-
63
- def warn_missing_observer_variables
64
- missing = REQUIRED_OBSERVER_ENV_VARS.reject { |var| ENV.fetch(var, nil) && !ENV[var].empty? }
65
- logger.warn("Test metrics export is enabled but missing environment variables: #{missing.join(', ')}")
66
- end
67
-
68
- def custom_metrics_proc
69
- proc do |_example|
70
- { pipeline_type: pipeline_type, ci_pipeline_id: ci_pipeline_id }
71
- end
72
- end
73
-
74
- def default_branch?
75
- ENV["CI_COMMIT_REF_NAME"] == ENV["CI_DEFAULT_BRANCH"]
76
- end
77
-
78
- def pipeline_type
79
- @pipeline_type ||= if default_branch? && ENV["SCHEDULE_TYPE"].present?
80
- "default_branch_scheduled_pipeline"
81
- elsif default_branch?
82
- "default_branch_pipeline"
83
- elsif ENV["CI_COMMIT_REF_NAME"]&.match?(/^[\d-]+-stable-ee$/)
84
- "stable_branch_pipeline"
85
- elsif ENV["CI_MERGE_REQUEST_TARGET_BRANCH_NAME"]&.match?(/^[\d-]+-stable-ee$/)
86
- "backport_merge_request_pipeline"
87
- elsif ENV["CI_MERGE_REQUEST_IID"].present?
88
- "merge_request_pipeline"
89
- elsif ENV["CI_PIPELINE_SOURCE"] == "pipeline"
90
- "downstream_pipeline"
91
- else
92
- "unknown"
93
- end
94
- end
95
-
96
- def test_run_type
97
- @run_type ||= ENV.fetch("GLCI_TEST_METRICS_RUN_TYPE", nil)
98
- end
99
-
100
- def observer_url
101
- ENV.fetch("GLCI_OBSERVER_URL", nil)
102
- end
103
-
104
- def observer_token
105
- ENV.fetch("GLCI_OBSERVER_AUTH_TOKEN", nil)
106
- end
107
-
108
- def ci_pipeline_id
109
- (ENV["PARENT_PIPELINE_ID"] || ENV.fetch("CI_PIPELINE_ID", nil)).to_i
110
- end
111
- end
112
- end
113
- end
114
- end
115
- end
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rspec/core/formatters/base_formatter"
4
-
5
- require_relative "test_metrics"
6
- require_relative "client"
7
-
8
- module GitlabQuality
9
- module TestTooling
10
- module TestMetricsExporter
11
- class Formatter < RSpec::Core::Formatters::BaseFormatter
12
- RSpec::Core::Formatters.register(self, :stop)
13
-
14
- LOG_PREFIX = "[MetricsExporter]"
15
-
16
- def stop(notification)
17
- logger.debug("#{LOG_PREFIX} Starting test metrics export")
18
- data = notification.examples.filter_map do |example|
19
- next if config.skip_record_proc.call(example)
20
-
21
- TestMetrics.new(example, time).data
22
- end
23
- return logger.warn("#{LOG_PREFIX} No test execution records found, metrics will not be exported!") if data.empty?
24
-
25
- push_to_observer(data)
26
- end
27
-
28
- private
29
-
30
- def config
31
- Config.configuration
32
- end
33
-
34
- def logger
35
- config.logger
36
- end
37
-
38
- # Single common timestamp for all exported example metrics to keep data points consistently grouped
39
- #
40
- # @return [String]
41
- def time
42
- return @time if @time
43
-
44
- ci_created_at = ENV.fetch("CI_PIPELINE_CREATED_AT", nil)
45
- @time = (ci_created_at ? Time.strptime(ci_created_at, '%Y-%m-%dT%H:%M:%S%z') : Time.now.utc).strftime('%Y-%m-%dT%H:%M:%S.%6N')
46
- end
47
-
48
- # Push data to observer service
49
- #
50
- # @param data [Array<Hash>]
51
- # @return [void]
52
- def push_to_observer(data)
53
- return logger.debug("#{LOG_PREFIX} Observer configuration missing, skipping export!") unless config.observer_configured?
54
-
55
- observer_client.post_tests(data)
56
- logger.info("#{LOG_PREFIX} Successfully pushed #{data.size} entries to Observer!")
57
- rescue StandardError => e
58
- logger.error("#{LOG_PREFIX} Error occurred while pushing metrics to Observer: #{e.message}")
59
- end
60
-
61
- def observer_client
62
- Client.new(url: config.observer_url, token: config.observer_token)
63
- end
64
- end
65
- end
66
- end
67
- end
@@ -1,228 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'time'
4
-
5
- module GitlabQuality
6
- module TestTooling
7
- module TestMetricsExporter
8
- class TestMetrics
9
- def initialize(example, timestamp)
10
- @example = example
11
- @timestamp = timestamp
12
- end
13
-
14
- # Test data hash
15
- #
16
- # @return [Hash]
17
- def data
18
- {
19
- timestamp: timestamp,
20
- **rspec_metrics,
21
- **ci_metrics,
22
- **custom_metrics
23
- }.compact
24
- end
25
-
26
- private
27
-
28
- attr_reader :example, :timestamp
29
-
30
- # Exporter configuration
31
- #
32
- # @return [Config]
33
- def config
34
- Config.configuration
35
- end
36
-
37
- # Rspec related metrics
38
- #
39
- # @return [Hash]
40
- def rspec_metrics # rubocop:disable Metrics/AbcSize
41
- {
42
- id: without_relative_path(example.id),
43
- name: example.full_description,
44
- hash: OpenSSL::Digest.hexdigest("SHA256", "#{file_path}#{example.full_description}")[..40],
45
- file_path: file_path,
46
- status: example.execution_result.status,
47
- run_time: (example.execution_result.run_time * 1000).round,
48
- location: example_location,
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,
52
- failure_exception: failure_exception,
53
- quarantined: quarantined?,
54
- quarantine_issue_url: quarantine_issue_url || "",
55
- feature_category: example.metadata[:feature_category] || "",
56
- test_retried: config.test_retried_proc.call(example),
57
- run_type: run_type,
58
- spec_file_path_prefix: config.spec_file_path_prefix
59
- }
60
- end
61
-
62
- # CI related metrics
63
- #
64
- # @return [Hash]
65
- def ci_metrics
66
- {
67
- ci_project_id: env("CI_PROJECT_ID")&.to_i,
68
- ci_project_path: env("CI_PROJECT_PATH"),
69
- ci_job_name: ci_job_name,
70
- ci_job_id: env('CI_JOB_ID')&.to_i,
71
- ci_pipeline_id: env('CI_PIPELINE_ID')&.to_i,
72
- ci_merge_request_iid: (env('CI_MERGE_REQUEST_IID') || env('TOP_UPSTREAM_MERGE_REQUEST_IID'))&.to_i,
73
- ci_branch: env("CI_COMMIT_REF_NAME"),
74
- ci_target_branch: env("CI_MERGE_REQUEST_TARGET_BRANCH_NAME"),
75
- ci_server_url: env("CI_SERVER_URL")
76
- }
77
- end
78
-
79
- # Additional custom metrics
80
- #
81
- # @return [Hash]
82
- def custom_metrics
83
- metrics = example.metadata
84
- .slice(*config.extra_rspec_metadata_keys)
85
- .merge(config.custom_metrics_proc.call(example))
86
-
87
- metrics.each_with_object({}) do |(k, value), custom_metrics|
88
- custom_metrics[k.to_sym] = metrics_value(value)
89
- end
90
- end
91
-
92
- # Checks if spec is quarantined
93
- #
94
- # @return [String]
95
- def quarantined?
96
- return false unless example.metadata.key?(:quarantine)
97
-
98
- # if quarantine key is present and status is pending, consider it quarantined
99
- example.execution_result.status == :pending
100
- end
101
-
102
- # Extract quarantine issue URL from metadata
103
- #
104
- # @return [String, nil]
105
- def quarantine_issue_url
106
- return nil unless example.metadata.key?(:quarantine)
107
-
108
- metadata = example.metadata[:quarantine]
109
- case metadata
110
- when String
111
- # Direct URL: quarantine: 'https://gitlab.com/.../issues/123'
112
- metadata if metadata.start_with?('http')
113
- when Hash
114
- # Hash format: quarantine: { issue: 'https://...', reason: '...' }
115
- issue = metadata[:issue] || metadata['issue']
116
- # Handle array of URLs (take the first one)
117
- issue.is_a?(Array) ? issue.first : issue
118
- end
119
- end
120
-
121
- # Base ci job name
122
- #
123
- # @return [String]
124
- def ci_job_name
125
- env("CI_JOB_NAME")&.gsub(%r{ \d{1,2}/\d{1,2}}, '')
126
- end
127
-
128
- # Example location
129
- #
130
- # @return [String]
131
- def example_location
132
- return @example_location if @example_location
133
-
134
- # ensures that location will be correct even in case of shared examples
135
- file = example
136
- .metadata
137
- .fetch(:shared_group_inclusion_backtrace)
138
- .last
139
- &.formatted_inclusion_location
140
-
141
- return without_relative_path(example.location) unless file
142
-
143
- @example_location = without_relative_path(file)
144
- end
145
-
146
- # File path based on actual test location, not shared example location
147
- #
148
- # @return [String]
149
- def file_path
150
- @file_path ||= example_location.gsub(/:\d+$/, "")
151
- end
152
-
153
- # Failure exception classes
154
- #
155
- # @return [Array<Exception>]
156
- def exception_classes
157
- exception = example.execution_result.exception
158
- return [] unless exception
159
- return [exception] unless exception.respond_to?(:all_exceptions)
160
-
161
- exception.all_exceptions.flatten
162
- end
163
-
164
- # Truncated exception message
165
- #
166
- # For MultipleExceptionError, returns the first wrapped exception's message
167
- # instead of the unhelpful wrapper class name.
168
- #
169
- # @return [String]
170
- def failure_exception
171
- exception = example.execution_result.exception
172
- return unless exception
173
-
174
- source = if exception.respond_to?(:all_exceptions)
175
- exception.all_exceptions.flatten.first || exception
176
- else
177
- exception
178
- end
179
-
180
- source.to_s.tr("\n", " ").slice(0, 1000)
181
- end
182
-
183
- # Test run type | suite name
184
- #
185
- # @return [String]
186
- def run_type
187
- config.run_type || ci_job_name || "unknown"
188
- end
189
-
190
- # Return non empty environment variable value
191
- #
192
- # @param [String] name
193
- # @return [String, nil]
194
- def env(name)
195
- return unless ENV[name] && !ENV[name].empty?
196
-
197
- ENV.fetch(name)
198
- end
199
-
200
- # Metrics value cast to a valid type
201
- #
202
- # @param value [Object]
203
- # @return [Object]
204
- def metrics_value(value)
205
- return value if value.is_a?(Numeric) || value.is_a?(String) || bool?(value) || value.nil?
206
-
207
- value.to_s
208
- end
209
-
210
- # Value is a true or false
211
- #
212
- # @param val [Object]
213
- # @return [Boolean]
214
- def bool?(val)
215
- [true, false].include?(val)
216
- end
217
-
218
- # Path without leading ./
219
- #
220
- # @param path [String]
221
- # @return [String]
222
- def without_relative_path(path)
223
- path.gsub(%r{^\./}, "")
224
- end
225
- end
226
- end
227
- end
228
- end