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 +7 -0
- data/LICENSE +21 -0
- data/Rakefile +10 -0
- data/exe/selective +16 -0
- data/lib/bin/build_env.sh +79 -0
- data/lib/bin/file_correlation_collector.sh +141 -0
- data/lib/selective/ruby/core/controller.rb +359 -0
- data/lib/selective/ruby/core/file_correlator.rb +41 -0
- data/lib/selective/ruby/core/helper.rb +76 -0
- data/lib/selective/ruby/core/named_pipe.rb +101 -0
- data/lib/selective/ruby/core/version.rb +9 -0
- data/lib/selective-ruby-core.rb +72 -0
- metadata +77 -0
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
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,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: []
|