rspec-mergify 0.0.0.dev

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.
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core/formatters/base_formatter'
4
+ require 'opentelemetry-sdk'
5
+
6
+ module Mergify
7
+ module RSpec
8
+ # RSpec formatter that creates OpenTelemetry spans for CI Insights and
9
+ # prints a terminal report. It is purely observational and does not modify
10
+ # test execution.
11
+ # rubocop:disable Metrics/ClassLength
12
+ class Formatter < ::RSpec::Core::Formatters::BaseFormatter
13
+ ::RSpec::Core::Formatters.register self,
14
+ :start,
15
+ :example_started,
16
+ :example_finished,
17
+ :example_pending,
18
+ :stop
19
+
20
+ # rubocop:disable Metrics/MethodLength
21
+ def start(notification)
22
+ super
23
+
24
+ @ci_insights = Mergify::RSpec.ci_insights
25
+ return unless @ci_insights&.tracer
26
+
27
+ extract_distributed_trace_context
28
+
29
+ @session_span = @ci_insights.tracer.start_span(
30
+ 'rspec session start',
31
+ with_parent: @parent_context,
32
+ attributes: { 'test.scope' => 'session' }
33
+ )
34
+ @has_error = false
35
+ @example_spans = {}
36
+ end
37
+ # rubocop:enable Metrics/MethodLength
38
+
39
+ def example_started(notification)
40
+ return unless @ci_insights&.tracer && @session_span
41
+
42
+ example = notification.example
43
+ parent_context = OpenTelemetry::Trace.context_with_span(@session_span)
44
+ quarantined = @ci_insights.mark_test_as_quarantined_if_needed(example.id)
45
+
46
+ span = @ci_insights.tracer.start_span(
47
+ example.id,
48
+ with_parent: parent_context,
49
+ attributes: build_example_attributes(example, quarantined)
50
+ )
51
+ @example_spans[example.id] = span
52
+ end
53
+
54
+ # rubocop:disable Metrics/MethodLength
55
+ def example_finished(notification)
56
+ return unless @example_spans
57
+
58
+ example = notification.example
59
+ span = @example_spans.delete(example.id)
60
+ return unless span
61
+
62
+ result = example.execution_result
63
+ status = result.status.to_s
64
+ span.set_attribute('test.case.result.status', status)
65
+ set_flaky_attributes(span, example)
66
+
67
+ if result.status == :failed
68
+ set_error_attributes(span, result.exception)
69
+ @has_error = true
70
+ else
71
+ span.status = OpenTelemetry::Trace::Status.ok
72
+ end
73
+
74
+ span.finish
75
+ end
76
+ # rubocop:enable Metrics/MethodLength
77
+
78
+ def example_pending(notification)
79
+ return unless @example_spans
80
+
81
+ example = notification.example
82
+ span = @example_spans.delete(example.id)
83
+ return unless span
84
+
85
+ span.set_attribute('test.case.result.status', 'skipped')
86
+ span.finish
87
+ end
88
+
89
+ def stop(_notification)
90
+ finish_session_span
91
+ print_report
92
+ flush_and_shutdown
93
+ end
94
+
95
+ private
96
+
97
+ def extract_distributed_trace_context
98
+ traceparent = ENV.fetch('MERGIFY_TRACEPARENT', nil)
99
+ @parent_context = if traceparent
100
+ propagator = OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator.new
101
+ propagator.extract({ 'traceparent' => traceparent })
102
+ end
103
+ end
104
+
105
+ def build_example_attributes(example, quarantined)
106
+ {
107
+ 'test.scope' => 'case',
108
+ 'code.filepath' => example.metadata[:file_path].delete_prefix('./'),
109
+ 'code.function' => example.description,
110
+ 'code.lineno' => example.metadata[:line_number] || 0,
111
+ 'code.namespace' => example.example_group.description,
112
+ 'code.file.path' => File.expand_path(example.metadata[:file_path]),
113
+ 'code.line.number' => example.metadata[:line_number] || 0,
114
+ 'cicd.test.quarantined' => quarantined
115
+ }
116
+ end
117
+
118
+ def set_flaky_attributes(span, example)
119
+ meta = example.metadata
120
+
121
+ rerun_count = meta[:mergify_rerun_count]
122
+ span.set_attribute('cicd.test.rerun_count', rerun_count) unless rerun_count.nil?
123
+
124
+ flaky = meta[:mergify_flaky]
125
+ span.set_attribute('cicd.test.flaky', flaky) unless flaky.nil?
126
+
127
+ flaky_detection = meta[:mergify_flaky_detection]
128
+ span.set_attribute('cicd.test.flaky_detection', flaky_detection) unless flaky_detection.nil?
129
+
130
+ new_test = meta[:mergify_new_test]
131
+ span.set_attribute('cicd.test.new', new_test) unless new_test.nil?
132
+ end
133
+
134
+ def set_error_attributes(span, exception)
135
+ span.set_attribute('exception.type', exception.class.to_s)
136
+ span.set_attribute('exception.message', exception.message)
137
+ span.status = OpenTelemetry::Trace::Status.error(exception.message)
138
+ end
139
+
140
+ def finish_session_span
141
+ return unless @session_span
142
+
143
+ @session_span.status = if @has_error
144
+ OpenTelemetry::Trace::Status.error('One or more tests failed')
145
+ else
146
+ OpenTelemetry::Trace::Status.ok
147
+ end
148
+ @session_span.finish
149
+ end
150
+
151
+ # rubocop:disable Metrics/MethodLength
152
+ def print_report
153
+ output.puts ''
154
+ output.puts '--- Mergify CI ---'
155
+
156
+ unless @ci_insights
157
+ output.puts 'Mergify CI Insights is not configured.'
158
+ return
159
+ end
160
+
161
+ print_configuration_warnings
162
+ print_flaky_report
163
+ print_quarantine_report
164
+ output.puts "MERGIFY_TEST_RUN_ID=#{@ci_insights.test_run_id}"
165
+ output.puts '------------------'
166
+ end
167
+ # rubocop:enable Metrics/MethodLength
168
+
169
+ def print_configuration_warnings
170
+ output.puts 'WARNING: MERGIFY_TOKEN is not set. Traces will not be sent to Mergify.' unless @ci_insights.token
171
+
172
+ return if @ci_insights.repo_name
173
+
174
+ output.puts 'WARNING: Could not detect repository name. ' \
175
+ 'Please set GITHUB_REPOSITORY or configure a git remote.'
176
+ end
177
+
178
+ def print_flaky_report
179
+ return unless @ci_insights.flaky_detector.respond_to?(:make_report)
180
+
181
+ report = @ci_insights.flaky_detector.make_report
182
+ output.puts report if report
183
+ end
184
+
185
+ def print_quarantine_report
186
+ return unless @ci_insights.quarantined_tests.respond_to?(:report)
187
+
188
+ report = @ci_insights.quarantined_tests.report
189
+ output.puts report if report
190
+ end
191
+
192
+ def flush_and_shutdown # rubocop:disable Metrics/MethodLength
193
+ return unless @ci_insights&.tracer_provider
194
+
195
+ begin
196
+ @ci_insights.tracer_provider.force_flush
197
+ rescue StandardError => e
198
+ print_export_error(e)
199
+ end
200
+
201
+ begin
202
+ @ci_insights.tracer_provider.shutdown
203
+ rescue StandardError => e
204
+ output.puts "Error while shutting down the tracer: #{e.message}"
205
+ end
206
+ end
207
+
208
+ def print_export_error(error)
209
+ output.puts "Error while exporting traces: #{error.message}"
210
+ output.puts ''
211
+ output.puts 'Common issues:'
212
+ output.puts ' - Your MERGIFY_TOKEN might not be set or could be invalid'
213
+ output.puts ' - CI Insights might not be enabled for this repository'
214
+ output.puts ' - There might be a network connectivity issue with the Mergify API'
215
+ output.puts ''
216
+ output.puts 'Documentation: https://docs.mergify.com/ci-insights/test-frameworks/'
217
+ end
218
+ end
219
+ # rubocop:enable Metrics/ClassLength
220
+ end
221
+ end
@@ -0,0 +1,90 @@
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
+ # Fetches quarantined test names from the Mergify API and tracks which are used.
12
+ class Quarantine
13
+ attr_reader :quarantined_tests, :init_error_msg
14
+
15
+ def initialize(api_url:, token:, repo_name:, branch_name:)
16
+ @repo_name = repo_name
17
+ @branch_name = branch_name
18
+ @quarantined_tests = []
19
+ @used_tests = Set.new
20
+ @init_error_msg = nil
21
+
22
+ owner, repo = Utils.split_full_repo_name(repo_name)
23
+ fetch_quarantined_tests(api_url, token, owner, repo, branch_name)
24
+ rescue Utils::InvalidRepositoryFullNameError => e
25
+ @init_error_msg = e.message
26
+ end
27
+
28
+ def include?(example_id)
29
+ @quarantined_tests.include?(example_id)
30
+ end
31
+
32
+ def mark_as_used(example_id)
33
+ @used_tests.add(example_id)
34
+ end
35
+
36
+ # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
37
+ def report
38
+ used, unused = @quarantined_tests.partition { |t| @used_tests.include?(t) }
39
+
40
+ lines = []
41
+ lines << 'Mergify Quarantine Report'
42
+ lines << " Repository : #{@repo_name}"
43
+ lines << " Branch : #{@branch_name}"
44
+ lines << " Quarantined tests from API: #{@quarantined_tests.size}"
45
+ lines << ''
46
+ lines << " Quarantined tests run (#{used.size}):"
47
+ used.each { |t| lines << " - #{t}" }
48
+ lines << ''
49
+ lines << " Unused quarantined tests (#{unused.size}):"
50
+ unused.each { |t| lines << " - #{t}" }
51
+ lines.join("\n")
52
+ end
53
+ # rubocop:enable Metrics/MethodLength,Metrics/AbcSize
54
+
55
+ private
56
+
57
+ # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
58
+ def fetch_quarantined_tests(api_url, token, owner, repo, branch_name)
59
+ uri = URI("#{api_url}/v1/ci/#{owner}/repositories/#{repo}/quarantines")
60
+ uri.query = URI.encode_www_form(branch: branch_name)
61
+
62
+ http = Net::HTTP.new(uri.host, uri.port)
63
+ http.use_ssl = uri.scheme == 'https'
64
+ http.open_timeout = 10
65
+ http.read_timeout = 10
66
+
67
+ request = Net::HTTP::Get.new(uri)
68
+ request['Authorization'] = "Bearer #{token}"
69
+
70
+ response = http.request(request)
71
+ handle_response(response)
72
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError => e
73
+ @init_error_msg = "Failed to connect to Mergify API: #{e.message}"
74
+ end
75
+ # rubocop:enable Metrics/MethodLength,Metrics/AbcSize
76
+
77
+ def handle_response(response)
78
+ case response.code.to_i
79
+ when 200
80
+ data = JSON.parse(response.body)
81
+ @quarantined_tests = data.fetch('quarantined_tests', []).map { |t| t['test_name'] }
82
+ when 402
83
+ # No subscription — silently skip
84
+ else
85
+ @init_error_msg = "Mergify API returned HTTP #{response.code}"
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry-sdk'
4
+ require_relative '../utils'
5
+
6
+ module Mergify
7
+ module RSpec
8
+ module Resources
9
+ # Detects OpenTelemetry Resource attributes for the CI provider.
10
+ module CI
11
+ module_function
12
+
13
+ def detect
14
+ provider = Utils.ci_provider
15
+ return OpenTelemetry::SDK::Resources::Resource.create({}) if provider.nil?
16
+
17
+ OpenTelemetry::SDK::Resources::Resource.create(
18
+ 'cicd.provider.name' => provider.to_s
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry-sdk'
4
+ require_relative '../utils'
5
+
6
+ module Mergify
7
+ module RSpec
8
+ module Resources
9
+ # Detects OpenTelemetry Resource attributes from git.
10
+ module Git
11
+ module_function
12
+
13
+ GIT_MAPPING = {
14
+ 'vcs.ref.head.name' => [:to_s, -> { Utils.git('rev-parse', '--abbrev-ref', 'HEAD') }],
15
+ 'vcs.ref.head.revision' => [:to_s, -> { Utils.git('rev-parse', 'HEAD') }],
16
+ 'vcs.repository.url.full' => [:to_s, -> { Utils.git('config', '--get', 'remote.origin.url') }],
17
+ 'vcs.repository.name' => [
18
+ :to_s,
19
+ lambda {
20
+ url = Utils.git('config', '--get', 'remote.origin.url')
21
+ Utils.repository_name_from_url(url) if url
22
+ }
23
+ ]
24
+ }.freeze
25
+
26
+ def detect
27
+ return OpenTelemetry::SDK::Resources::Resource.create({}) if Utils.ci_provider.nil?
28
+
29
+ attributes = Utils.get_attributes(GIT_MAPPING)
30
+ OpenTelemetry::SDK::Resources::Resource.create(attributes)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'opentelemetry-sdk'
5
+ require_relative '../utils'
6
+
7
+ module Mergify
8
+ module RSpec
9
+ module Resources
10
+ # Detects OpenTelemetry Resource attributes for GitHub Actions.
11
+ module GitHubActions
12
+ module_function
13
+
14
+ def detect
15
+ return OpenTelemetry::SDK::Resources::Resource.create({}) if Utils.ci_provider != :github_actions
16
+
17
+ attributes = Utils.get_attributes(GHA_MAPPING)
18
+ OpenTelemetry::SDK::Resources::Resource.create(attributes)
19
+ end
20
+
21
+ GHA_MAPPING = {
22
+ 'cicd.pipeline.name' => [:to_s, 'GITHUB_WORKFLOW'],
23
+ 'cicd.pipeline.task.name' => [:to_s, 'GITHUB_JOB'],
24
+ 'cicd.pipeline.run.id' => [:to_i, 'GITHUB_RUN_ID'],
25
+ 'cicd.pipeline.run.attempt' => [:to_i, 'GITHUB_RUN_ATTEMPT'],
26
+ 'cicd.pipeline.runner.name' => [:to_s, 'RUNNER_NAME'],
27
+ 'vcs.ref.head.name' => [:to_s, -> { head_ref_name }],
28
+ 'vcs.ref.head.type' => [:to_s, 'GITHUB_REF_TYPE'],
29
+ 'vcs.ref.base.name' => [:to_s, 'GITHUB_BASE_REF'],
30
+ 'vcs.repository.name' => [:to_s, 'GITHUB_REPOSITORY'],
31
+ 'vcs.repository.id' => [:to_i, 'GITHUB_REPOSITORY_ID'],
32
+ 'vcs.repository.url.full' => [:to_s, -> { repository_url }],
33
+ 'vcs.ref.head.revision' => [:to_s, -> { head_sha }]
34
+ }.freeze
35
+
36
+ def head_ref_name
37
+ ref = ENV.fetch('GITHUB_HEAD_REF', '')
38
+ ref.empty? ? ENV.fetch('GITHUB_REF_NAME', nil) : ref
39
+ end
40
+
41
+ def repository_url
42
+ server = ENV.fetch('GITHUB_SERVER_URL', nil)
43
+ repo = ENV.fetch('GITHUB_REPOSITORY', nil)
44
+ "#{server}/#{repo}" if server && repo
45
+ end
46
+
47
+ def head_sha
48
+ if ENV.fetch('GITHUB_EVENT_NAME', nil) == 'pull_request'
49
+ event_path = ENV.fetch('GITHUB_EVENT_PATH', nil)
50
+ if event_path && File.file?(event_path)
51
+ event = JSON.parse(File.read(event_path))
52
+ return event.dig('pull_request', 'head', 'sha').to_s
53
+ end
54
+ end
55
+ ENV.fetch('GITHUB_SHA', nil)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry-sdk'
4
+ require_relative '../utils'
5
+ require_relative 'git'
6
+
7
+ module Mergify
8
+ module RSpec
9
+ module Resources
10
+ # Detects OpenTelemetry Resource attributes for Jenkins.
11
+ module Jenkins
12
+ module_function
13
+
14
+ GIT_BRANCH_PREFIXES = %w[origin/ refs/heads/].freeze
15
+
16
+ JENKINS_MAPPING = {
17
+ 'cicd.pipeline.name' => [:to_s, 'JOB_NAME'],
18
+ 'cicd.pipeline.task.name' => [:to_s, 'JOB_NAME'],
19
+ 'cicd.pipeline.run.id' => [:to_s, 'BUILD_ID'],
20
+ 'cicd.pipeline.run.url' => [:to_s, 'BUILD_URL'],
21
+ 'cicd.pipeline.runner.name' => [:to_s, 'NODE_NAME'],
22
+ 'vcs.ref.head.name' => [:to_s, -> { branch }],
23
+ 'vcs.ref.head.revision' => [:to_s, 'GIT_COMMIT'],
24
+ 'vcs.repository.url.full' => [:to_s, 'GIT_URL'],
25
+ 'vcs.repository.name' => [
26
+ :to_s,
27
+ lambda {
28
+ url = ENV.fetch('GIT_URL', nil)
29
+ Utils.repository_name_from_url(url) if url
30
+ }
31
+ ]
32
+ }.freeze
33
+
34
+ def detect
35
+ return OpenTelemetry::SDK::Resources::Resource.create({}) if Utils.ci_provider != :jenkins
36
+
37
+ git_attrs = Utils.get_attributes(Git::GIT_MAPPING)
38
+ jenkins_attrs = Utils.get_attributes(JENKINS_MAPPING)
39
+ merged = git_attrs.merge(jenkins_attrs)
40
+ OpenTelemetry::SDK::Resources::Resource.create(merged)
41
+ end
42
+
43
+ def branch
44
+ raw = ENV.fetch('GIT_BRANCH', nil)
45
+ return nil unless raw
46
+
47
+ GIT_BRANCH_PREFIXES.each do |prefix|
48
+ return raw[prefix.length..] if raw.start_with?(prefix)
49
+ end
50
+ raw
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry-sdk'
4
+ require_relative '../utils'
5
+
6
+ module Mergify
7
+ module RSpec
8
+ module Resources
9
+ # Detects OpenTelemetry Resource attributes for Mergify-specific fields.
10
+ module Mergify
11
+ module_function
12
+
13
+ MERGIFY_MAPPING = {
14
+ 'mergify.test.job.name' => [:to_s, 'MERGIFY_TEST_JOB_NAME']
15
+ }.freeze
16
+
17
+ def detect
18
+ attributes = Utils.get_attributes(MERGIFY_MAPPING)
19
+ OpenTelemetry::SDK::Resources::Resource.create(attributes)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry-sdk'
4
+ require 'rspec/core/version'
5
+
6
+ module Mergify
7
+ module RSpec
8
+ module Resources
9
+ # Detects OpenTelemetry Resource attributes for RSpec.
10
+ module RSpec
11
+ module_function
12
+
13
+ def detect
14
+ OpenTelemetry::SDK::Resources::Resource.create(
15
+ 'test.framework' => 'rspec',
16
+ 'test.framework.version' => ::RSpec::Core::Version::STRING
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry-sdk'
4
+
5
+ module Mergify
6
+ module RSpec
7
+ class ExportError < StandardError; end
8
+
9
+ # A span processor that queues spans in memory and exports them all in one
10
+ # batch when force_flush is called. This avoids HTTP requests during test
11
+ # execution.
12
+ class SynchronousBatchSpanProcessor < OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor
13
+ def initialize(exporter)
14
+ super
15
+ @queue = []
16
+ end
17
+
18
+ def on_finish(span)
19
+ return unless span.context.trace_flags.sampled?
20
+
21
+ @queue << span.to_span_data
22
+ end
23
+
24
+ def force_flush(timeout: nil) # rubocop:disable Lint/UnusedMethodArgument
25
+ spans = @queue.dup
26
+ @queue.clear
27
+ result = @span_exporter.export(spans)
28
+ raise ExportError, 'Failed to export traces' unless result == OpenTelemetry::SDK::Trace::Export::SUCCESS
29
+
30
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
31
+ end
32
+ end
33
+ end
34
+ end