selective-ruby-core 0.2.7

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 89c79d63a06bc547e21fb0b30b57f8c76274f03487c0a84c250e539cf844b7ed
4
+ data.tar.gz: dc9d4e81532f76e6649484df041d8903d65cf778d87722fa0419afe9301bad57
5
+ SHA512:
6
+ metadata.gz: f4c048186c71d73e960052bf22b721ae2167fc924da6066c0d962925d66b847f111b87c6be6565a5d63364f20dcd33d85dc66215734f8f1307f3c469a1fc07f3
7
+ data.tar.gz: 27dac2e5ac165e6c48cf767340cccb6f8a51ea56b252599db2427b4effe3d9079a57dcbad5bebd5b8b051e3e07620d180dc2bbff23c1d0269a13184b7efd3588
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Selective
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[spec standard]
data/exe/selective ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+
5
+ # We test selective-ruby using selective-ruby. This means that
6
+ # SimpleCov.start must be called before our code is loaded.
7
+ if ENV["SELECTIVE_SIMPLECOV"]
8
+ require "simplecov"
9
+ SimpleCov.start do
10
+ add_filter "/spec/"
11
+ end
12
+ end
13
+
14
+ require "selective-ruby-core"
15
+
16
+ Selective::Ruby::Core::Init.run(ARGV.dup)
@@ -0,0 +1,79 @@
1
+ #!/bin/bash
2
+
3
+ # Detect the platform (only GitHub Actions in this case)
4
+ if [ -n "$GITHUB_ACTIONS" ]; then
5
+ platform=github_actions
6
+ branch=${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}
7
+ target_branch=$GITHUB_BASE_REF
8
+ actor=$GITHUB_ACTOR
9
+ sha=$GITHUB_SHA
10
+ run_id=$GITHUB_RUN_ID
11
+ run_attempt=$GITHUB_RUN_ATTEMPT
12
+ runner_id=$SELECTIVE_RUNNER_ID
13
+ commit_message=$(git log --format=%s -n 1 $sha)
14
+ committer_name=$(git show -s --format='%an' -n 1 $sha)
15
+ committer_email=$(git show -s --format='%ae' -n 1 $sha)
16
+ elif [ -n "$CIRCLECI" ]; then
17
+ platform=circleci
18
+ branch=$CIRCLE_BRANCH
19
+ actor=${CIRCLE_USERNAME:-$CIRCLE_PR_USERNAME}
20
+ sha=$CIRCLE_SHA1
21
+ run_attempt=$CIRCLE_BUILD_NUM
22
+ runner_id=$CIRCLE_NODE_INDEX
23
+ commit_message=$(git log --format=%s -n 1 $sha)
24
+ committer_name=$(git show -s --format='%an' -n 1 $sha)
25
+ committer_email=$(git show -s --format='%ae' -n 1 $sha)
26
+ elif [ -n "$SEMAPHORE" ]; then
27
+ platform=semaphore
28
+ branch=${SEMAPHORE_GIT_PR_BRANCH:-$SEMAPHORE_GIT_BRANCH}
29
+ if [ -n "$SEMAPHORE_GIT_PR_BRANCH" ]; then
30
+ target_branch=$SEMAPHORE_GIT_BRANCH
31
+ fi
32
+ actor=$SEMAPHORE_GIT_COMMITTER
33
+ sha=$SEMAPHORE_GIT_SHA
34
+ run_id=$SEMAPHORE_WORKFLOW_ID
35
+ run_attempt=1
36
+ runner_id=$SEMAPHORE_JOB_ID
37
+ pr_title=$SEMAPHORE_GIT_PR_NAME
38
+ commit_message=$(git log --format=%s -n 1 $sha)
39
+ committer_name=$(git show -s --format='%an' -n 1 $sha)
40
+ committer_email=$(git show -s --format='%ae' -n 1 $sha)
41
+ elif [ -n "$MINT" ]; then
42
+ platform=mint
43
+ branch="${MINT_GIT_REF_NAME}"
44
+ actor="${MINT_ACTOR}"
45
+ sha="${MINT_GIT_COMMIT_SHA}"
46
+ run_id="${MINT_RUN_ID}"
47
+ run_attempt="${MINT_TASK_ATTEMPT_NUMBER}"
48
+ runner_id="${MINT_PARALLEL_INDEX}"
49
+ # Mint does not preserve the .git directory by default to improve the likelihood of cache hits. Instead
50
+ # of asking git for commit information, then, we rely on the mint/git-clone leaf to populate the necessary
51
+ # metadata in environment variables.
52
+ commit_message="${MINT_GIT_COMMIT_SUMMARY}"
53
+ committer_name="${MINT_GIT_COMMITTER_NAME}"
54
+ committer_email="${MINT_GIT_COMMITTER_EMAIL}"
55
+ fi
56
+
57
+ function escape() {
58
+ echo -n "$1" | sed 's/"/\\"/g'
59
+ }
60
+
61
+ # Output the JSON
62
+ cat <<EOF
63
+ {
64
+ "api_key": "$(escape "${SELECTIVE_API_KEY}")",
65
+ "host": "$(escape "${SELECTIVE_HOST:-wss://app.selective.ci}")",
66
+ "platform": "$(escape "${SELECTIVE_PLATFORM:-$platform}")",
67
+ "branch": "$(escape "${SELECTIVE_BRANCH:-$branch}")",
68
+ "pr_title": "$(escape "${SELECTIVE_PR_TITLE:-$pr_title}")",
69
+ "target_branch": "$(escape "${SELECTIVE_TARGET_BRANCH:-$target_branch}")",
70
+ "actor": "$(escape "${SELECTIVE_ACTOR:-$actor}")",
71
+ "sha": "$(escape "${SELECTIVE_SHA:-$sha}")",
72
+ "run_id": "$(escape "${SELECTIVE_RUN_ID:-$run_id}")",
73
+ "run_attempt": "$(escape "${SELECTIVE_RUN_ATTEMPT:-$run_attempt}")",
74
+ "runner_id": "$(escape "${SELECTIVE_RUNNER_ID:-$runner_id}")",
75
+ "commit_message": "$(escape "${SELECTIVE_COMMIT_MESSAGE:-$commit_message}")",
76
+ "committer_name": "$(escape "${SELECTIVE_COMMITTER_NAME:-$committer_name}")",
77
+ "committer_email": "$(escape "${SELECTIVE_COMMITTER_EMAIL:-$committer_email}")"
78
+ }
79
+ EOF
@@ -0,0 +1,141 @@
1
+ #!/bin/bash
2
+
3
+ # The first argument is the number of commits to process
4
+ branch=$1
5
+ num_commits=$2
6
+
7
+ # Initialize an associative array to hold the files to check
8
+ declare -A files_to_check
9
+
10
+ # Populate the array with the script arguments, starting from the second argument
11
+ for file in "${@:3}"
12
+ do
13
+ files_to_check["$file"]=1
14
+ done
15
+
16
+ # Get a list of all commit hashes, in reverse order
17
+ all_commits=$(git log origin/$branch --no-merges --format=%H --reverse -n $num_commits)
18
+
19
+ # Initialize an associative array to store the test files
20
+ declare -A test_files
21
+ declare -A uncorrelated_test_files
22
+
23
+ # Initialize an array to store the files changed in the previous commit
24
+ prev_changed_files=()
25
+
26
+ # For each commit...
27
+ for commit in $all_commits
28
+ do
29
+ # Get a list of all files that were changed in the current commit
30
+ files=$(git diff-tree --no-commit-id --name-only -r $commit)
31
+
32
+ declare -A correlated_test_files
33
+
34
+ # # For each file in the list of files changed in the previous commit...
35
+ for file in "${prev_changed_files[@]}"
36
+ do
37
+ # If the file is in the list of files to check...
38
+ if [[ ${files_to_check[$file]} ]]; then
39
+ # For each file...
40
+ for test_file in $files
41
+ do
42
+ # If the file is in the test/ directory and ends with _test.rb...
43
+ if [[ $test_file == spec/*_spec.rb ]]
44
+ then
45
+ # Increment the count in the associative array
46
+ test_files["$file|$test_file"]=$((test_files["$file|$test_file"]+1))
47
+ # Add the test file to the correlated_test_files array
48
+ correlated_test_files["$test_file"]=1
49
+ fi
50
+ done
51
+ fi
52
+ done
53
+
54
+ # For each file in the list of files changed in the current commit...
55
+ for file in $files
56
+ do
57
+ # If the file is in the list of files to check...
58
+ if [[ ${files_to_check[$file]} ]]; then
59
+ # For each file...
60
+ for test_file in $files
61
+ do
62
+ # If the file is in the test/ directory and ends with _test.rb...
63
+ if [[ $test_file == spec/*_spec.rb ]]
64
+ then
65
+ # Increment the count in the associative array
66
+ test_files["$file|$test_file"]=$((test_files["$file|$test_file"]+1))
67
+ # Add the test file to the correlated_test_files array
68
+ correlated_test_files["$test_file"]=1
69
+ fi
70
+ done
71
+ fi
72
+ done
73
+
74
+ # For each file...
75
+ for test_file in $files
76
+ do
77
+ # If the file is in the test/ directory and ends with _test.rb...
78
+ if [[ $test_file == spec/*_spec.rb ]]
79
+ then
80
+ # If the test file is not correlated to any of the files to check in the current commit...
81
+ if [[ -z ${correlated_test_files[$test_file]} ]]
82
+ then
83
+ # Increment the count in the associative array
84
+ uncorrelated_test_files["$test_file"]=$((uncorrelated_test_files["$test_file"]+1))
85
+ fi
86
+ fi
87
+ done
88
+
89
+ # Clear the correlated_test_files array for the next commit
90
+ unset correlated_test_files
91
+
92
+ # Store the list of files changed in this commit for the next iteration
93
+ prev_changed_files=($files)
94
+ done
95
+
96
+ # OUTPUT
97
+
98
+ # Initialize an associative array to hold the JSON strings for each file
99
+ declare -A file_jsons
100
+
101
+ # Add the test_files to the file_jsons associative array
102
+ for key in "${!test_files[@]}"
103
+ do
104
+ file=${key%|*}
105
+ test_file=${key#*|}
106
+ count=${test_files[$key]}
107
+ # Append to the JSON string for this file
108
+ file_jsons["$file"]+="\"$test_file\": $count,"
109
+ done
110
+
111
+ # Initialize an empty string for the test_files JSON
112
+ correlated_files_json=""
113
+
114
+ # Add the file_jsons to the correlated_files_json string
115
+ for file in "${!file_jsons[@]}"
116
+ do
117
+ # Remove the trailing comma from the JSON string for this file
118
+ file_json=${file_jsons[$file]%?}
119
+ # Append to the correlated_files_json string
120
+ correlated_files_json+="\"$file\": { $file_json },"
121
+ done
122
+
123
+ # Remove the trailing comma from the correlated_files_json string
124
+ correlated_files_json=${correlated_files_json%?}
125
+
126
+ # Initialize an empty string for the uncorrelated_test_files JSON
127
+ uncorrelated_files_json=""
128
+
129
+ # Add the uncorrelated_test_files to the uncorrelated_files_json string
130
+ for key in "${!uncorrelated_test_files[@]}"
131
+ do
132
+ count=${uncorrelated_test_files[$key]}
133
+ # Append to the uncorrelated_files_json string
134
+ uncorrelated_files_json+="\"$key\": $count,"
135
+ done
136
+
137
+ # Remove the trailing comma from the uncorrelated_files_json string
138
+ uncorrelated_files_json=${uncorrelated_files_json%?}
139
+
140
+ # Output the JSON
141
+ echo "{ \"correlated_files\": { $correlated_files_json }, \"uncorrelated_files\": { $uncorrelated_files_json } }"
@@ -0,0 +1,359 @@
1
+ require "logger"
2
+ require "uri"
3
+ require "fileutils"
4
+
5
+ module Selective
6
+ module Ruby
7
+ module Core
8
+ class Controller
9
+ include Helper
10
+ @@selective_suppress_reporting = false
11
+ @@report_at_finish = {}
12
+
13
+ REQUIRED_CONFIGURATION = {
14
+ "host" => "SELECTIVE_HOST",
15
+ "api_key" => "SELECTIVE_API_KEY",
16
+ "platform" => "SELECTIVE_PLATFORM",
17
+ "run_id" => "SELECTIVE_RUN_ID",
18
+ "run_attempt" => "SELECTIVE_RUN_ATTEMPT",
19
+ "branch" => "SELECTIVE_BRANCH"
20
+ }.freeze
21
+
22
+ def initialize(runner_class, runner_args, debug: false, log: false)
23
+ @debug = debug
24
+ @runner = runner_class.new(runner_args, method(:test_case_callback))
25
+ @retries = 0
26
+ @runner_id = safe_filename(get_runner_id)
27
+ @logger = init_logger(log)
28
+ end
29
+
30
+ def start(reconnect: false)
31
+ @pipe = NamedPipe.new("/tmp/#{runner_id}_2", "/tmp/#{runner_id}_1")
32
+ @transport_pid = spawn_transport_process(reconnect: reconnect)
33
+
34
+ handle_termination_signals(transport_pid)
35
+ wait_for_connectivity
36
+ run_main_loop
37
+ rescue ConnectionLostError
38
+ retry!
39
+ rescue => e
40
+ with_error_handling { raise e }
41
+ end
42
+
43
+ def exec
44
+ runner.exec
45
+ rescue => e
46
+ with_error_handling(include_header: false) { raise e }
47
+ end
48
+
49
+ def self.suppress_reporting!
50
+ @@selective_suppress_reporting = true
51
+ end
52
+
53
+ def self.restore_reporting!
54
+ @@selective_suppress_reporting = false
55
+ end
56
+
57
+ def self.suppress_reporting?
58
+ @@selective_suppress_reporting
59
+ end
60
+
61
+ def self.report_at_finish
62
+ @@report_at_finish
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :runner, :pipe, :transport_pid, :retries, :logger, :runner_id, :diff
68
+
69
+ def get_runner_id
70
+ runner_id = build_env["runner_id"]
71
+ return generate_runner_id if runner_id.nil? || runner_id.empty?
72
+
73
+ runner_id
74
+ end
75
+
76
+ def init_logger(enabled)
77
+ if enabled
78
+ FileUtils.mkdir_p("log")
79
+ Logger.new("log/#{runner_id}.log")
80
+ else
81
+ Logger.new("/dev/null")
82
+ end
83
+ end
84
+
85
+ def run_main_loop
86
+ loop do
87
+ message = pipe.read
88
+ response = JSON.parse(message, symbolize_names: true)
89
+
90
+ @logger.info("Received Command: #{response}")
91
+ break if handle_command(response) == :break
92
+ end
93
+ end
94
+
95
+ def retry!
96
+ @retries += 1
97
+
98
+ with_error_handling { raise "Too many retries" } if retries > 10
99
+
100
+ puts("Retrying in #{retries} seconds...") if debug?
101
+ sleep(retries > 4 ? 4 : retries)
102
+ kill_transport
103
+
104
+ pipe.reset!
105
+ start(reconnect: true)
106
+ end
107
+
108
+ def write(data)
109
+ pipe.write JSON.dump(data)
110
+ end
111
+
112
+ def generate_runner_id
113
+ "selgen-#{SecureRandom.hex(4)}"
114
+ end
115
+
116
+ def transport_url(reconnect: false)
117
+ base_transport_url_params[:reconnect] = true if reconnect
118
+ query_string = URI.encode_www_form(base_transport_url_params)
119
+ "#{build_env["host"]}/transport/websocket?#{query_string}"
120
+ end
121
+
122
+ def base_transport_url_params
123
+ @base_transport_url_params ||= begin
124
+ api_key = build_env["api_key"]
125
+ run_id = build_env["run_id"]
126
+ run_attempt = build_env["run_attempt"]
127
+
128
+ metadata = build_env.reject { |k,v| %w(host runner_id api_key run_id run_attempt).include?(k) }
129
+
130
+ {
131
+ "api_key" => api_key,
132
+ "run_id" => run_id,
133
+ "run_attempt" => run_attempt,
134
+ "runner_id" => runner_id,
135
+ "language" => "ruby",
136
+ "core_version" => Selective::Ruby::Core::VERSION,
137
+ "framework" => runner.framework,
138
+ "framework_version" => runner.framework_version,
139
+ "framework_wrapper_version" => runner.wrapper_version,
140
+ }.merge(metadata: metadata.to_json)
141
+ end
142
+ end
143
+
144
+ def build_env
145
+ @build_env ||= begin
146
+ result = `#{File.join(ROOT_GEM_PATH, "lib", "bin", "build_env.sh")}`
147
+ JSON.parse(result).tap do |env|
148
+ validate_build_env(env)
149
+ end
150
+ end
151
+ end
152
+
153
+ def validate_build_env(env)
154
+ missing = REQUIRED_CONFIGURATION.each_with_object([]) do |(key, env_var), arry|
155
+ arry << env_var if env[key].nil? || env[key].empty?
156
+ end
157
+
158
+ with_error_handling do
159
+ raise "Missing required environment variables: #{missing.join(", ")}" unless missing.empty?
160
+ raise "Invalid host: #{env['host']}" unless env['host'].match?(/^wss?:\/\//)
161
+ end
162
+ end
163
+
164
+ def spawn_transport_process(reconnect: false)
165
+ transport_path = File.join(ROOT_GEM_PATH, "lib", "bin", "transport")
166
+ get_transport_path = File.join(ROOT_GEM_PATH, "bin", "get_transport")
167
+
168
+ if !File.exist?(transport_path)
169
+ # The get_transport script is not released with the gem, so this
170
+ # code is intended for development/CI purposes.
171
+ if File.exist?(get_transport_path)
172
+ output, status = Open3.capture2e(get_transport_path)
173
+ if !status.success?
174
+ puts <<~TEXT
175
+ Failed to download transport binary.
176
+
177
+ #{output}
178
+ TEXT
179
+ end
180
+ else
181
+ with_error_handling do
182
+ raise "Selective transport binary not found. Please contact support or compile it manually. See: https://github.com/selectiveci/transport"
183
+ end
184
+ end
185
+ end
186
+
187
+ Process.spawn(transport_path, transport_url(reconnect: reconnect), runner_id).tap do |pid|
188
+ Process.detach(pid)
189
+ end
190
+ end
191
+
192
+ def wait_for_connectivity
193
+ @connectivity = false
194
+
195
+ Thread.new do
196
+ sleep(30)
197
+ unless @connectivity
198
+ puts "Transport process failed to start. Exiting..."
199
+ kill_transport
200
+ exit(1)
201
+ end
202
+ end
203
+
204
+ loop do
205
+ message = pipe.read
206
+
207
+ # The message is nil until the transport opens the pipe
208
+ # for writing. So, we must handle that here.
209
+ next sleep(0.1) if message.nil?
210
+
211
+ response = JSON.parse(message, symbolize_names: true)
212
+ @connectivity = true if response[:command] == "connected"
213
+ break
214
+ end
215
+ end
216
+
217
+ def handle_termination_signals(pid)
218
+ ["INT", "TERM"].each do |signal|
219
+ Signal.trap(signal) do
220
+ # :nocov:
221
+ kill_transport(signal: signal)
222
+ exit
223
+ # :nocov:
224
+ end
225
+ end
226
+ end
227
+
228
+ def kill_transport(signal: "TERM")
229
+ begin
230
+ pipe.write "exit"
231
+
232
+ # Give up to 5 seconds for graceful exit
233
+ # before killing it below
234
+ 1..5.times do
235
+ Process.getpgid(transport_pid)
236
+
237
+ sleep(1)
238
+ end
239
+ rescue ConnectionLostError, IOError
240
+ # If the pipe is close, move straight to killing
241
+ # it forcefully.
242
+ end
243
+
244
+ # :nocov:
245
+ Process.kill(signal, transport_pid)
246
+ # :nocov:
247
+ rescue Errno::ESRCH
248
+ # Process already gone noop
249
+ end
250
+
251
+ def handle_command(data)
252
+ if respond_to? "handle_#{data[:command]}", true
253
+ send("handle_#{data[:command]}", data)
254
+ else
255
+ raise "Unknown command received: #{data[:command]}" if debug?
256
+ end
257
+ end
258
+
259
+ def handle_print_notice(data)
260
+ print_notice(data[:message])
261
+ end
262
+
263
+ def handle_reconnect(_data)
264
+ kill_transport
265
+ pipe.reset!
266
+ start(reconnect: true)
267
+ end
268
+
269
+ def handle_test_manifest(_data)
270
+ self.class.restore_reporting!
271
+ @logger.info("Sending Response: test_manifest")
272
+ data = {test_cases: runner.manifest["examples"] || runner.manifest["test_cases"]}
273
+ num_commits = build_env["num_commits"] || 1000
274
+ if (diff = get_diff(num_commits))
275
+ data[:modified_test_files] = modified_test_files(diff)
276
+ data[:correlated_files] = correlated_files(diff, num_commits)
277
+ end
278
+ write({type: "test_manifest", data: data})
279
+ end
280
+
281
+ def handle_run_test_cases(data)
282
+ runner.run_test_cases(data[:test_case_ids])
283
+ end
284
+
285
+ # Todo: Rename this command to match the method name
286
+ # on the runner wrapper. We should do something similar
287
+ # to normalize handle_print_notice and handle_print_message
288
+ def handle_remove_failed_test_case_result(data)
289
+ runner.remove_test_case_result(data[:test_case_id])
290
+ end
291
+
292
+ def handle_print_message(data)
293
+ print_warning(data[:message])
294
+ end
295
+
296
+ def handle_close(data)
297
+ exit_status = data[:exit_status]
298
+ self.class.restore_reporting!
299
+
300
+ with_error_handling do
301
+ Selective::Ruby::Core::Controller.report_at_finish[:connection_retries] = @retries
302
+ write({type: "report_at_finish", data: Selective::Ruby::Core::Controller.report_at_finish})
303
+
304
+ runner.finish unless exit_status.is_a?(Integer)
305
+ end
306
+
307
+ kill_transport
308
+ pipe.delete_pipes
309
+ exit(exit_status || runner.exit_status)
310
+ # This :break is here for the sake of test where
311
+ # we cannot exit but we need to break the loop
312
+ :break
313
+ end
314
+
315
+ def correlated_files(diff, num_commits)
316
+ Selective::Ruby::Core::FileCorrelator.new(diff, num_commits, build_env["target_branch"]).correlate
317
+ end
318
+
319
+ def test_case_callback(test_case)
320
+ @logger.info("Sending Response: test_case_result: #{test_case[:id]}")
321
+ write({type: "test_case_result", data: test_case})
322
+ end
323
+
324
+ def modified_test_files(diff)
325
+ @modified_test_files ||= begin
326
+ diff.filter do |f|
327
+ f.match?(/^#{runner.base_test_path}/)
328
+ end
329
+ end
330
+ end
331
+
332
+ def get_diff(num_commits)
333
+ target_branch = build_env["target_branch"]
334
+ return if target_branch.nil? || target_branch.empty?
335
+
336
+ Open3.capture2e("git fetch origin #{target_branch} --depth=#{num_commits}").then do |output, status|
337
+ unless status.success?
338
+ print_warning "Selective was unable to fetch the target branch. This may result in a sub-optimal test order. If the issue persists, please contact support. The output was:\n\n#{output}"
339
+ return
340
+ end
341
+ end
342
+
343
+ Open3.capture2e("git diff origin/#{target_branch} --name-only").then do |output, status|
344
+ if status.success?
345
+ output.split("\n")
346
+ else
347
+ print_warning "Selective was unable to diff with the target branch. This may result in a sub-optimal test order. If the issue persists, please contact support. The output was:\n\n#{output}"
348
+ nil
349
+ end
350
+ end
351
+ end
352
+
353
+ def debug?
354
+ @debug
355
+ end
356
+ end
357
+ end
358
+ end
359
+ end
@@ -0,0 +1,41 @@
1
+ module Selective
2
+ module Ruby
3
+ module Core
4
+ class FileCorrelator
5
+ include Helper
6
+
7
+ class FileCorrelatorError < StandardError; end
8
+
9
+ FILE_CORRELATION_COLLECTOR_PATH = File.join(ROOT_GEM_PATH, "lib", "bin", "file_correlation_collector.sh")
10
+
11
+ def initialize(diff, num_commits, target_branch)
12
+ @diff = diff.reject {|f| f =~ /^spec\// }
13
+ @num_commits = num_commits
14
+ @target_branch = target_branch
15
+ end
16
+
17
+ def correlate
18
+ JSON.parse(get_correlated_files, symbolize_names: true)
19
+ rescue FileCorrelatorError, JSON::ParserError
20
+ print_warning(<<~MSG)
21
+ Selective was unable to correlate the diff to test files. This may result in a sub-optimal test order.
22
+ If the issue persists, please contact support.
23
+ MSG
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :diff, :num_commits, :target_branch
29
+
30
+ def get_correlated_files
31
+ Open3.capture2e("#{FILE_CORRELATION_COLLECTOR_PATH} #{target_branch} #{num_commits} #{diff.join(" ")}").then do |output, status|
32
+
33
+ raise FileCorrelatorError unless status.success?
34
+
35
+ output
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,76 @@
1
+ module Selective
2
+ module Ruby
3
+ module Core
4
+ module Helper
5
+ def safe_filename(filename)
6
+ filename
7
+ .gsub(/[\/\\:*?"<>|\n\r]+/, '_')
8
+ .gsub(/^\.+|\.+$/, '')
9
+ .strip[0, 255]
10
+ end
11
+
12
+ def with_error_handling(include_header: true)
13
+ yield
14
+ rescue => e
15
+ raise e if debug?
16
+ header = <<~TEXT
17
+ An error occurred. Please rerun with --debug
18
+ and contact support at https://selective.ci/support
19
+ TEXT
20
+
21
+ unless $selective_banner_displayed
22
+ header = <<~TEXT
23
+ #{banner}
24
+
25
+ #{header}
26
+ TEXT
27
+ end
28
+
29
+ puts_indented <<~TEXT
30
+ \e[31m
31
+ #{header if include_header}
32
+ #{e.message}
33
+ \e[0m
34
+ TEXT
35
+
36
+ exit 1
37
+ end
38
+
39
+ def print_warning(message)
40
+ puts_indented <<~TEXT
41
+ \e[33m
42
+ #{message}
43
+ \e[0m
44
+ TEXT
45
+ end
46
+
47
+ def print_notice(message)
48
+ puts_indented <<~TEXT
49
+ #{banner unless $selective_banner_displayed}
50
+ #{message}
51
+ TEXT
52
+ end
53
+
54
+ def puts_indented(text)
55
+ puts text.gsub(/^/, " ")
56
+ end
57
+
58
+ def banner
59
+ Helper.banner
60
+ end
61
+
62
+ def self.banner
63
+ $selective_banner_displayed = true
64
+ <<~BANNER
65
+ ____ _ _ _
66
+ / ___| ___| | ___ ___| |_(_)_ _____
67
+ \\___ \\ / _ \\ |/ _ \\/ __| __| \\ \\ / / _ \\
68
+ ___) | __/ | __/ (__| |_| |\\ V / __/
69
+ |____/ \\___|_|\\___|\\___|\\__|_| \\_/ \\___|
70
+ ________________________________________
71
+ BANNER
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,101 @@
1
+ module Selective
2
+ module Ruby
3
+ module Core
4
+ class NamedPipe
5
+ attr_reader :read_pipe_path, :write_pipe_path
6
+
7
+ def initialize(read_pipe_path, write_pipe_path, skip_reset: false)
8
+ @read_pipe_path = read_pipe_path
9
+ @write_pipe_path = write_pipe_path
10
+
11
+ delete_pipes unless skip_reset
12
+ initialize_pipes
13
+ end
14
+
15
+ def initialize_pipes
16
+ create_pipes
17
+
18
+ # Open the read and write pipes in separate threads
19
+ Thread.new do
20
+ @read_pipe = File.open(read_pipe_path, "r")
21
+ end
22
+ Thread.new do
23
+ @write_pipe = File.open(write_pipe_path, "w")
24
+ end
25
+ end
26
+
27
+ def write(message)
28
+ return unless write_pipe
29
+
30
+ chunk_size = 1024 # 1KB chunks
31
+ offset = 0
32
+ begin
33
+ while offset < message.bytesize
34
+ chunk = message.byteslice(offset, chunk_size)
35
+
36
+ write_pipe.write(chunk)
37
+ write_pipe.flush
38
+
39
+ offset += chunk_size
40
+ end
41
+
42
+ write_pipe.write("\n")
43
+ write_pipe.flush
44
+ rescue Errno::EPIPE
45
+ raise ConnectionLostError
46
+ end
47
+ end
48
+
49
+ def read
50
+ return unless read_pipe
51
+ begin
52
+ message = read_pipe.gets.chomp
53
+ rescue NoMethodError => e
54
+ if e.name == :chomp
55
+ raise ConnectionLostError
56
+ else
57
+ raise e
58
+ end
59
+ end
60
+ message
61
+ end
62
+
63
+ def reset!
64
+ delete_pipes
65
+ initialize_pipes
66
+ end
67
+
68
+ def delete_pipes
69
+ # Close the pipes before deleting them
70
+ read_pipe&.close
71
+ write_pipe&.close
72
+
73
+ # Allow threads to close before deleting pipes
74
+ sleep(0.1)
75
+
76
+ delete_pipe(read_pipe_path)
77
+ delete_pipe(write_pipe_path)
78
+ rescue Errno::EPIPE
79
+ # Noop
80
+ end
81
+
82
+ private
83
+
84
+ attr_reader :read_pipe, :write_pipe
85
+
86
+ def create_pipes
87
+ create_pipe(read_pipe_path)
88
+ create_pipe(write_pipe_path)
89
+ end
90
+
91
+ def create_pipe(path)
92
+ system("mkfifo #{path}") unless File.exist?(path)
93
+ end
94
+
95
+ def delete_pipe(path)
96
+ File.delete(path) if File.exist?(path)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Selective
4
+ module Ruby
5
+ module Core
6
+ VERSION = "0.2.7"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "json"
5
+ require "open3"
6
+ require "#{__dir__}/selective/ruby/core/version"
7
+
8
+ loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
9
+ loader.ignore("#{__dir__}/selective-ruby-core.rb")
10
+ loader.ignore("#{__dir__}/selective/ruby/core/version.rb")
11
+ loader.setup
12
+
13
+ module Selective
14
+ module Ruby
15
+ module Core
16
+ class Error < StandardError; end
17
+ class ConnectionLostError < StandardError; end
18
+
19
+ ROOT_GEM_PATH = Gem.loaded_specs["selective-ruby-core"].full_gem_path
20
+
21
+ @@available_runners = {}
22
+
23
+ def self.register_runner(name, runner_class)
24
+ @@available_runners[name] = runner_class
25
+ end
26
+
27
+ def self.runner_for(name)
28
+ @@available_runners[name] || raise("Unknown runner #{name}")
29
+ end
30
+
31
+ class Init
32
+ def initialize(args)
33
+ @debug = !args.delete("--debug").nil?
34
+ @log = !args.delete("--log").nil?
35
+ @runner_name, @args, @command = parse_args(args)
36
+ require_runner
37
+ end
38
+
39
+ def self.run(args)
40
+ new(args).send(:run)
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :debug, :log, :runner_name, :args, :command
46
+
47
+ def run
48
+ Selective::Ruby::Core::Controller.new(runner_class, args, debug: debug, log: log).send(command)
49
+ end
50
+
51
+ def parse_args(args)
52
+ # Returns runner_name, args, command
53
+ if args[0] == "exec" # e.g. selective exec rspec
54
+ [args[1], args[2..], :exec]
55
+ else # e.g. selective rspec
56
+ [args[0], args[1..], :start]
57
+ end
58
+ end
59
+
60
+ def runner_class
61
+ Selective::Ruby::Core.runner_for(runner_name)
62
+ end
63
+
64
+ def require_runner
65
+ require "selective-ruby-#{runner_name}"
66
+ rescue LoadError
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: selective-ruby-core
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.7
5
+ platform: ruby
6
+ authors:
7
+ - Benjamin Wood
8
+ - Nate Vick
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2025-06-04 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: zeitwerk
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: 2.6.12
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: 2.6.12
28
+ description: Selective is an intelligent test runner for your current CI provider.
29
+ Get real-time test results, intelligent ordering based on code changes, shorter
30
+ run times, automatic flake detection, the ability to re-enqueue failed tests, and
31
+ more.
32
+ email:
33
+ - ben@hint.io
34
+ - nate@hint.io
35
+ executables:
36
+ - selective
37
+ extensions: []
38
+ extra_rdoc_files: []
39
+ files:
40
+ - LICENSE
41
+ - Rakefile
42
+ - exe/selective
43
+ - lib/bin/build_env.sh
44
+ - lib/bin/file_correlation_collector.sh
45
+ - lib/selective-ruby-core.rb
46
+ - lib/selective/ruby/core/controller.rb
47
+ - lib/selective/ruby/core/file_correlator.rb
48
+ - lib/selective/ruby/core/helper.rb
49
+ - lib/selective/ruby/core/named_pipe.rb
50
+ - lib/selective/ruby/core/version.rb
51
+ homepage: https://www.selective.ci
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ homepage_uri: https://www.selective.ci
56
+ source_code_uri: http://github.com/selectiveci/selective-ruby-core
57
+ changelog_uri: https://github.com/selectiveci/selective-ruby-core/blob/main/CHANGELOG.md
58
+ post_install_message:
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 2.6.0
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.5.3
74
+ signing_key:
75
+ specification_version: 4
76
+ summary: Selective Ruby Client Core
77
+ test_files: []