rspec-mergify 0.0.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 +7 -0
- data/LICENSE +674 -0
- data/README.md +66 -0
- data/lib/mergify/rspec/ci_insights.rb +174 -0
- data/lib/mergify/rspec/configuration.rb +82 -0
- data/lib/mergify/rspec/flaky_detection.rb +252 -0
- data/lib/mergify/rspec/formatter.rb +210 -0
- data/lib/mergify/rspec/quarantine.rb +90 -0
- data/lib/mergify/rspec/resources/ci.rb +24 -0
- data/lib/mergify/rspec/resources/git.rb +35 -0
- data/lib/mergify/rspec/resources/github_actions.rb +60 -0
- data/lib/mergify/rspec/resources/jenkins.rb +55 -0
- data/lib/mergify/rspec/resources/mergify.rb +24 -0
- data/lib/mergify/rspec/resources/rspec.rb +22 -0
- data/lib/mergify/rspec/synchronous_batch_span_processor.rb +34 -0
- data/lib/mergify/rspec/utils.rb +140 -0
- data/lib/mergify/rspec/version.rb +9 -0
- data/lib/mergify/rspec.rb +11 -0
- data/lib/rspec_mergify.rb +12 -0
- metadata +104 -0
data/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# rspec-mergify
|
|
2
|
+
|
|
3
|
+
RSpec plugin for [Mergify CI Insights](https://docs.mergify.com/ci-insights/).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Test tracing** — Sends OpenTelemetry traces for every test to Mergify's API
|
|
8
|
+
- **Flaky test detection** — Intelligently reruns tests to detect flakiness with budget constraints
|
|
9
|
+
- **Test quarantine** — Quarantines failing tests so they don't block CI
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add to your Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem 'rspec-mergify'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then run `bundle install`.
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
Set the `MERGIFY_TOKEN` environment variable with your Mergify API token.
|
|
24
|
+
|
|
25
|
+
The plugin activates automatically when running in CI (detected via the `CI` environment variable). To enable outside CI, set `RSPEC_MERGIFY_ENABLE=true`.
|
|
26
|
+
|
|
27
|
+
### Environment Variables
|
|
28
|
+
|
|
29
|
+
| Variable | Description | Default |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `MERGIFY_TOKEN` | Mergify API authentication token | (required) |
|
|
32
|
+
| `MERGIFY_API_URL` | Mergify API endpoint | `https://api.mergify.com` |
|
|
33
|
+
| `RSPEC_MERGIFY_ENABLE` | Force-enable outside CI | `false` |
|
|
34
|
+
| `RSPEC_MERGIFY_DEBUG` | Print spans to console | `false` |
|
|
35
|
+
| `MERGIFY_TRACEPARENT` | W3C distributed trace context | — |
|
|
36
|
+
| `MERGIFY_TEST_JOB_NAME` | Mergify test job name | — |
|
|
37
|
+
|
|
38
|
+
## Development
|
|
39
|
+
|
|
40
|
+
### Prerequisites
|
|
41
|
+
|
|
42
|
+
- Ruby >= 3.1 (`.ruby-version` pins to 3.4.4 — use [rbenv](https://github.com/rbenv/rbenv) or [mise](https://mise.jdx.dev/) to install it)
|
|
43
|
+
- Bundler
|
|
44
|
+
|
|
45
|
+
### Setup
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
rbenv install # install the Ruby version from .ruby-version (if needed)
|
|
49
|
+
bundle install
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Running Tests
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
bundle exec rspec
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Linting
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bundle exec rubocop
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
GPL-3.0-only
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'opentelemetry-sdk'
|
|
5
|
+
require_relative 'utils'
|
|
6
|
+
require_relative 'synchronous_batch_span_processor'
|
|
7
|
+
require_relative 'resources/ci'
|
|
8
|
+
require_relative 'resources/git'
|
|
9
|
+
require_relative 'resources/github_actions'
|
|
10
|
+
require_relative 'resources/jenkins'
|
|
11
|
+
require_relative 'resources/mergify'
|
|
12
|
+
require_relative 'resources/rspec'
|
|
13
|
+
|
|
14
|
+
module Mergify
|
|
15
|
+
module RSpec
|
|
16
|
+
# Central orchestrator for CI Insights: sets up OpenTelemetry tracing,
|
|
17
|
+
# manages the tracer provider, and coordinates flaky detection and quarantine.
|
|
18
|
+
# rubocop:disable Metrics/ClassLength
|
|
19
|
+
class CIInsights
|
|
20
|
+
attr_reader :token, :repo_name, :api_url, :test_run_id,
|
|
21
|
+
:tracer_provider, :tracer, :exporter,
|
|
22
|
+
:branch_name,
|
|
23
|
+
:flaky_detector, :flaky_detector_error_message, :quarantined_tests
|
|
24
|
+
|
|
25
|
+
# rubocop:disable Metrics/MethodLength
|
|
26
|
+
def initialize
|
|
27
|
+
@token = ENV.fetch('MERGIFY_TOKEN', nil)
|
|
28
|
+
@repo_name = Utils.repository_name
|
|
29
|
+
@api_url = ENV.fetch('MERGIFY_API_URL', 'https://api.mergify.com')
|
|
30
|
+
@test_run_id = SecureRandom.hex(8)
|
|
31
|
+
@tracer_provider = nil
|
|
32
|
+
@tracer = nil
|
|
33
|
+
@exporter = nil
|
|
34
|
+
@branch_name = nil
|
|
35
|
+
@flaky_detector = nil
|
|
36
|
+
@flaky_detector_error_message = nil
|
|
37
|
+
@quarantined_tests = nil
|
|
38
|
+
|
|
39
|
+
setup_tracing if Utils.in_ci?
|
|
40
|
+
end
|
|
41
|
+
# rubocop:enable Metrics/MethodLength
|
|
42
|
+
|
|
43
|
+
def mark_test_as_quarantined_if_needed(example_id) # rubocop:disable Naming/PredicateMethod
|
|
44
|
+
return false unless @quarantined_tests&.include?(example_id)
|
|
45
|
+
|
|
46
|
+
@quarantined_tests.mark_as_used(example_id)
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def setup_tracing
|
|
53
|
+
processor, exp = build_processor
|
|
54
|
+
return unless processor
|
|
55
|
+
|
|
56
|
+
@exporter = exp
|
|
57
|
+
resource = build_resource
|
|
58
|
+
@tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource)
|
|
59
|
+
@tracer_provider.add_span_processor(processor)
|
|
60
|
+
@tracer = @tracer_provider.tracer('rspec-mergify', Mergify::RSpec::VERSION)
|
|
61
|
+
@branch_name = extract_branch_name(resource)
|
|
62
|
+
load_flaky_detector
|
|
63
|
+
load_quarantine
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_processor
|
|
67
|
+
if debug_mode? || test_mode?
|
|
68
|
+
build_in_memory_processor
|
|
69
|
+
elsif @token && @repo_name
|
|
70
|
+
build_otlp_processor
|
|
71
|
+
else
|
|
72
|
+
[nil, nil]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def debug_mode?
|
|
77
|
+
ENV.key?('RSPEC_MERGIFY_DEBUG')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_mode?
|
|
81
|
+
ENV['_RSPEC_MERGIFY_TEST'] == 'true'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_in_memory_processor
|
|
85
|
+
exp = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
|
|
86
|
+
processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exp)
|
|
87
|
+
[processor, exp]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_otlp_processor
|
|
91
|
+
owner, repo = Utils.split_full_repo_name(@repo_name)
|
|
92
|
+
endpoint = "#{@api_url}/v1/ci/#{owner}/repositories/#{repo}/traces"
|
|
93
|
+
exp = create_otlp_exporter(endpoint)
|
|
94
|
+
processor = SynchronousBatchSpanProcessor.new(exp)
|
|
95
|
+
[processor, exp]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# rubocop:disable Metrics/MethodLength
|
|
99
|
+
def build_resource
|
|
100
|
+
resources = [
|
|
101
|
+
Resources::CI.detect,
|
|
102
|
+
Resources::Git.detect,
|
|
103
|
+
Resources::GitHubActions.detect,
|
|
104
|
+
Resources::Jenkins.detect,
|
|
105
|
+
Resources::Mergify.detect,
|
|
106
|
+
Resources::RSpec.detect
|
|
107
|
+
]
|
|
108
|
+
base = resources.reduce(OpenTelemetry::SDK::Resources::Resource.create({})) do |merged, r|
|
|
109
|
+
merged.merge(r)
|
|
110
|
+
end
|
|
111
|
+
run_id_resource = OpenTelemetry::SDK::Resources::Resource.create('test.run.id' => @test_run_id)
|
|
112
|
+
base.merge(run_id_resource)
|
|
113
|
+
end
|
|
114
|
+
# rubocop:enable Metrics/MethodLength
|
|
115
|
+
|
|
116
|
+
def extract_branch_name(resource)
|
|
117
|
+
attrs = resource.attribute_enumerator.to_h
|
|
118
|
+
attrs['vcs.ref.base.name'] || attrs['vcs.ref.head.name']
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# rubocop:disable Metrics/MethodLength
|
|
122
|
+
def create_otlp_exporter(endpoint)
|
|
123
|
+
require 'opentelemetry-exporter-otlp'
|
|
124
|
+
original_env = ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil)
|
|
125
|
+
ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = endpoint
|
|
126
|
+
begin
|
|
127
|
+
OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
128
|
+
endpoint: endpoint,
|
|
129
|
+
headers: { 'Authorization' => "Bearer #{@token}" },
|
|
130
|
+
compression: 'gzip'
|
|
131
|
+
)
|
|
132
|
+
ensure
|
|
133
|
+
if original_env
|
|
134
|
+
ENV['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'] = original_env
|
|
135
|
+
else
|
|
136
|
+
ENV.delete('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
# rubocop:enable Metrics/MethodLength
|
|
141
|
+
|
|
142
|
+
# rubocop:disable Metrics/MethodLength
|
|
143
|
+
def load_flaky_detector
|
|
144
|
+
return unless @token && @repo_name
|
|
145
|
+
return unless Utils.env_truthy?('_MERGIFY_TEST_NEW_FLAKY_DETECTION')
|
|
146
|
+
|
|
147
|
+
require_relative 'flaky_detection'
|
|
148
|
+
mode = @branch_name ? 'new' : 'unhealthy'
|
|
149
|
+
@flaky_detector = FlakyDetector.new(
|
|
150
|
+
token: @token,
|
|
151
|
+
url: @api_url,
|
|
152
|
+
full_repository_name: @repo_name,
|
|
153
|
+
mode: mode
|
|
154
|
+
)
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
@flaky_detector_error_message = "Could not load flaky detector: #{e.message}"
|
|
157
|
+
end
|
|
158
|
+
# rubocop:enable Metrics/MethodLength
|
|
159
|
+
|
|
160
|
+
def load_quarantine
|
|
161
|
+
return unless @token && @repo_name && @branch_name
|
|
162
|
+
|
|
163
|
+
require_relative 'quarantine'
|
|
164
|
+
@quarantined_tests = Quarantine.new(
|
|
165
|
+
api_url: @api_url,
|
|
166
|
+
token: @token,
|
|
167
|
+
repo_name: @repo_name,
|
|
168
|
+
branch_name: @branch_name
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
# rubocop:enable Metrics/ClassLength
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Mergify
|
|
6
|
+
module RSpec
|
|
7
|
+
# Registers RSpec hooks for quarantine and flaky detection, and adds the
|
|
8
|
+
# CI Insights formatter when running inside CI.
|
|
9
|
+
module Configuration
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# rubocop:disable Metrics/MethodLength,Metrics/BlockLength,Metrics/AbcSize
|
|
13
|
+
# rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
14
|
+
def setup!
|
|
15
|
+
::RSpec.configure do |config|
|
|
16
|
+
# Add formatter when in CI
|
|
17
|
+
config.add_formatter(Mergify::RSpec::Formatter) if Utils.in_ci?
|
|
18
|
+
|
|
19
|
+
# Quarantine: mark tests before execution
|
|
20
|
+
config.before(:each) do |example|
|
|
21
|
+
ci = Mergify::RSpec.ci_insights
|
|
22
|
+
next unless ci&.quarantined_tests&.include?(example.id)
|
|
23
|
+
|
|
24
|
+
ci.quarantined_tests.mark_as_used(example.id)
|
|
25
|
+
example.metadata[:mergify_quarantined] = true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Flaky detection: rerun tests within budget
|
|
29
|
+
config.around(:each) do |example|
|
|
30
|
+
ci = Mergify::RSpec.ci_insights
|
|
31
|
+
fd = ci&.flaky_detector
|
|
32
|
+
|
|
33
|
+
example.run
|
|
34
|
+
|
|
35
|
+
next unless fd&.rerunning_test?(example.id)
|
|
36
|
+
|
|
37
|
+
fd.set_test_deadline(example.id)
|
|
38
|
+
next if fd.test_too_slow?(example.id)
|
|
39
|
+
|
|
40
|
+
example.metadata[:mergify_flaky_detection] = true
|
|
41
|
+
example.metadata[:mergify_new_test] = true if fd.mode == 'new'
|
|
42
|
+
|
|
43
|
+
distinct_outcomes = Set.new
|
|
44
|
+
distinct_outcomes.add(example.execution_result.status) if example.execution_result.status
|
|
45
|
+
|
|
46
|
+
rerun_count = 0
|
|
47
|
+
until example.metadata[:is_last_rerun]
|
|
48
|
+
example.metadata[:is_last_rerun] = fd.last_rerun_for_test?(example.id)
|
|
49
|
+
|
|
50
|
+
# Reset example state for rerun
|
|
51
|
+
example.instance_variable_set(:@exception, nil)
|
|
52
|
+
if example.example_group_instance
|
|
53
|
+
memoized = example.example_group_instance.instance_variable_get(:@__memoized)
|
|
54
|
+
memoized&.clear
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
example.run
|
|
58
|
+
distinct_outcomes.add(example.execution_result.status)
|
|
59
|
+
rerun_count += 1
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
is_flaky = distinct_outcomes.include?(:passed) &&
|
|
63
|
+
distinct_outcomes.include?(:failed)
|
|
64
|
+
example.metadata[:mergify_flaky] = true if is_flaky
|
|
65
|
+
example.metadata[:mergify_rerun_count] = rerun_count
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Quarantine: override failed quarantined test results
|
|
69
|
+
config.after(:each) do |example|
|
|
70
|
+
next unless example.metadata[:mergify_quarantined] && example.exception
|
|
71
|
+
|
|
72
|
+
example.instance_variable_set(:@exception, nil)
|
|
73
|
+
example.execution_result.status = :pending
|
|
74
|
+
example.execution_result.pending_message = 'Test is quarantined from Mergify CI Insights'
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
# rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
79
|
+
# rubocop:enable Metrics/MethodLength,Metrics/BlockLength,Metrics/AbcSize
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'set'
|
|
7
|
+
require_relative 'utils'
|
|
8
|
+
|
|
9
|
+
module Mergify
|
|
10
|
+
module RSpec
|
|
11
|
+
# Manages intelligent test rerunning with budget constraints for flaky detection.
|
|
12
|
+
# rubocop:disable Metrics/ClassLength
|
|
13
|
+
class FlakyDetector
|
|
14
|
+
# Per-test tracking metrics.
|
|
15
|
+
class TestMetrics
|
|
16
|
+
attr_accessor :initial_setup_duration, :initial_call_duration, :initial_teardown_duration,
|
|
17
|
+
:rerun_count, :deadline, :prevented_timeout, :total_duration
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@initial_setup_duration = 0.0
|
|
21
|
+
@initial_call_duration = 0.0
|
|
22
|
+
@initial_teardown_duration = 0.0
|
|
23
|
+
@rerun_count = 0
|
|
24
|
+
@deadline = nil
|
|
25
|
+
@prevented_timeout = false
|
|
26
|
+
@total_duration = 0.0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initial_duration
|
|
30
|
+
@initial_setup_duration + @initial_call_duration + @initial_teardown_duration
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def remaining_time
|
|
34
|
+
return 0.0 if @deadline.nil?
|
|
35
|
+
|
|
36
|
+
[(@deadline - Time.now.to_f), 0.0].max
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def will_exceed_deadline?
|
|
40
|
+
return false if @deadline.nil?
|
|
41
|
+
|
|
42
|
+
(Time.now.to_f + initial_duration) >= @deadline
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fill_from_report(phase, duration, _status)
|
|
46
|
+
case phase
|
|
47
|
+
when 'setup'
|
|
48
|
+
@initial_setup_duration = duration if @initial_setup_duration.zero?
|
|
49
|
+
when 'call'
|
|
50
|
+
@initial_call_duration = duration if @initial_call_duration.zero?
|
|
51
|
+
@rerun_count += 1
|
|
52
|
+
when 'teardown'
|
|
53
|
+
@initial_teardown_duration = duration if @initial_teardown_duration.zero?
|
|
54
|
+
end
|
|
55
|
+
@total_duration += duration
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
attr_reader :tests_to_process, :budget, :mode
|
|
60
|
+
|
|
61
|
+
def initialize(token:, url:, full_repository_name:, mode:)
|
|
62
|
+
@token = token
|
|
63
|
+
@url = url
|
|
64
|
+
@full_repository_name = full_repository_name
|
|
65
|
+
@mode = mode
|
|
66
|
+
@metrics = {}
|
|
67
|
+
@over_length_tests = Set.new
|
|
68
|
+
@tests_to_process = []
|
|
69
|
+
@budget = 0.0
|
|
70
|
+
|
|
71
|
+
fetch_context
|
|
72
|
+
validate!
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
76
|
+
def prepare_for_session(test_ids)
|
|
77
|
+
existing = Set.new(@context[:existing_test_names])
|
|
78
|
+
unhealthy = Set.new(@context[:unhealthy_test_names])
|
|
79
|
+
|
|
80
|
+
@tests_to_process =
|
|
81
|
+
if @mode == 'new'
|
|
82
|
+
test_ids.reject { |id| existing.include?(id) }
|
|
83
|
+
else
|
|
84
|
+
test_ids.select { |id| unhealthy.include?(id) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
budget_ratio = if @mode == 'new'
|
|
88
|
+
@context[:budget_ratio_for_new_tests]
|
|
89
|
+
else
|
|
90
|
+
@context[:budget_ratio_for_unhealthy_tests]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
mean_duration_s = @context[:existing_tests_mean_duration_ms] / 1000.0
|
|
94
|
+
existing_count = @context[:existing_test_names].size
|
|
95
|
+
min_budget_s = @context[:min_budget_duration_ms] / 1000.0
|
|
96
|
+
|
|
97
|
+
ratio_budget = budget_ratio * mean_duration_s * existing_count
|
|
98
|
+
@budget = [ratio_budget, min_budget_s].max
|
|
99
|
+
end
|
|
100
|
+
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize
|
|
101
|
+
|
|
102
|
+
# rubocop:disable Metrics/MethodLength
|
|
103
|
+
def fill_metrics_from_report(test_id, phase, duration, status)
|
|
104
|
+
if status == :skipped
|
|
105
|
+
@metrics.delete(test_id)
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
return unless @tests_to_process.include?(test_id)
|
|
110
|
+
|
|
111
|
+
if test_id.length > @context[:max_test_name_length]
|
|
112
|
+
@over_length_tests.add(test_id)
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Only initialize metrics when the first phase is "setup"
|
|
117
|
+
return if !@metrics.key?(test_id) && phase != 'setup'
|
|
118
|
+
|
|
119
|
+
@metrics[test_id] ||= TestMetrics.new
|
|
120
|
+
@metrics[test_id].fill_from_report(phase, duration, status)
|
|
121
|
+
end
|
|
122
|
+
# rubocop:enable Metrics/MethodLength
|
|
123
|
+
|
|
124
|
+
def rerunning_test?(test_id)
|
|
125
|
+
@metrics.key?(test_id) && @metrics[test_id].rerun_count >= 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def test_rerun?(test_id)
|
|
129
|
+
@metrics.key?(test_id) && @metrics[test_id].rerun_count > 1
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def set_test_deadline(test_id, timeout: nil)
|
|
133
|
+
return unless @metrics.key?(test_id)
|
|
134
|
+
|
|
135
|
+
remaining_tests = [remaining_tests_count, 1].max
|
|
136
|
+
per_test_budget = remaining_budget / remaining_tests
|
|
137
|
+
|
|
138
|
+
allocated =
|
|
139
|
+
if timeout
|
|
140
|
+
[per_test_budget, timeout * 0.9].min
|
|
141
|
+
else
|
|
142
|
+
per_test_budget
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@metrics[test_id].deadline = Time.now.to_f + allocated
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def test_too_slow?(test_id)
|
|
149
|
+
return false unless @metrics.key?(test_id)
|
|
150
|
+
|
|
151
|
+
metrics = @metrics[test_id]
|
|
152
|
+
min_exec = @context[:min_test_execution_count]
|
|
153
|
+
(metrics.initial_duration * min_exec) > metrics.remaining_time
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def last_rerun_for_test?(test_id)
|
|
157
|
+
return false unless @metrics.key?(test_id)
|
|
158
|
+
|
|
159
|
+
metrics = @metrics[test_id]
|
|
160
|
+
metrics.will_exceed_deadline? || metrics.rerun_count >= @context[:max_test_execution_count]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def test_metrics(test_id)
|
|
164
|
+
@metrics[test_id]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
168
|
+
def make_report
|
|
169
|
+
lines = []
|
|
170
|
+
lines << 'Mergify Flaky Detection Report'
|
|
171
|
+
lines << " Mode : #{@mode}"
|
|
172
|
+
lines << " Budget : #{format('%.2f', @budget)}s"
|
|
173
|
+
lines << " Budget used : #{format('%.2f', budget_used)}s"
|
|
174
|
+
lines << " Tests tracked: #{@metrics.size}"
|
|
175
|
+
lines << ''
|
|
176
|
+
|
|
177
|
+
@metrics.each do |test_id, m|
|
|
178
|
+
lines << " #{test_id}"
|
|
179
|
+
lines << " Reruns : #{m.rerun_count}"
|
|
180
|
+
lines << " Initial dur : #{format('%.3f', m.initial_duration)}s"
|
|
181
|
+
lines << " Total dur : #{format('%.3f', m.total_duration)}s"
|
|
182
|
+
lines << " Timeout warn : #{m.prevented_timeout}" if m.prevented_timeout
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
lines << '' unless @over_length_tests.empty?
|
|
186
|
+
@over_length_tests.each do |id|
|
|
187
|
+
lines << " WARNING: test name too long (skipped): #{id[0, 80]}..."
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
lines.join("\n")
|
|
191
|
+
end
|
|
192
|
+
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
# rubocop:disable Metrics/AbcSize
|
|
197
|
+
def fetch_context
|
|
198
|
+
owner, repo = Utils.split_full_repo_name(@full_repository_name)
|
|
199
|
+
uri = URI("#{@url}/v1/ci/#{owner}/repositories/#{repo}/flaky-detection-context")
|
|
200
|
+
|
|
201
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
202
|
+
http.use_ssl = uri.scheme == 'https'
|
|
203
|
+
http.open_timeout = 10
|
|
204
|
+
http.read_timeout = 10
|
|
205
|
+
|
|
206
|
+
request = Net::HTTP::Get.new(uri)
|
|
207
|
+
request['Authorization'] = "Bearer #{@token}"
|
|
208
|
+
|
|
209
|
+
response = http.request(request)
|
|
210
|
+
parse_context(response.body)
|
|
211
|
+
end
|
|
212
|
+
# rubocop:enable Metrics/AbcSize
|
|
213
|
+
|
|
214
|
+
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
215
|
+
def parse_context(body)
|
|
216
|
+
data = JSON.parse(body, symbolize_names: true)
|
|
217
|
+
@context = {
|
|
218
|
+
budget_ratio_for_new_tests: data[:budget_ratio_for_new_tests].to_f,
|
|
219
|
+
budget_ratio_for_unhealthy_tests: data[:budget_ratio_for_unhealthy_tests].to_f,
|
|
220
|
+
existing_test_names: Array(data[:existing_test_names]),
|
|
221
|
+
existing_tests_mean_duration_ms: data[:existing_tests_mean_duration_ms].to_f,
|
|
222
|
+
unhealthy_test_names: Array(data[:unhealthy_test_names]),
|
|
223
|
+
max_test_execution_count: data[:max_test_execution_count].to_i,
|
|
224
|
+
max_test_name_length: data[:max_test_name_length].to_i,
|
|
225
|
+
min_budget_duration_ms: data[:min_budget_duration_ms].to_f,
|
|
226
|
+
min_test_execution_count: data[:min_test_execution_count].to_i
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize
|
|
230
|
+
|
|
231
|
+
def validate!
|
|
232
|
+
return unless @mode == 'new' && @context[:existing_test_names].empty?
|
|
233
|
+
|
|
234
|
+
raise 'Cannot use "new" mode without existing test names in the context'
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def remaining_budget
|
|
238
|
+
used = budget_used
|
|
239
|
+
[@budget - used, 0.0].max
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def budget_used
|
|
243
|
+
@metrics.sum { |_, m| m.total_duration }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def remaining_tests_count
|
|
247
|
+
@tests_to_process.count { |id| !@metrics.key?(id) || @metrics[id].deadline.nil? }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
# rubocop:enable Metrics/ClassLength
|
|
251
|
+
end
|
|
252
|
+
end
|