emerge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/README.md +94 -0
- data/exe/emerge +6 -0
- data/lib/commands/config/snapshots/snapshots_ios.rb +235 -0
- data/lib/commands/global_options.rb +15 -0
- data/lib/commands/integrate/fastlane.rb +124 -0
- data/lib/commands/upload/snapshots/client_libraries/default.rb +26 -0
- data/lib/commands/upload/snapshots/client_libraries/paparazzi.rb +29 -0
- data/lib/commands/upload/snapshots/client_libraries/swift_snapshot_testing.rb +28 -0
- data/lib/commands/upload/snapshots/snapshots.rb +247 -0
- data/lib/emerge_cli.rb +37 -0
- data/lib/utils/git.rb +103 -0
- data/lib/utils/git_info_provider.rb +25 -0
- data/lib/utils/git_result.rb +14 -0
- data/lib/utils/github.rb +71 -0
- data/lib/utils/logger.rb +60 -0
- data/lib/utils/network.rb +95 -0
- data/lib/utils/profiler.rb +49 -0
- data/lib/utils/project_detector.rb +11 -0
- data/lib/version.rb +3 -0
- metadata +277 -0
@@ -0,0 +1,247 @@
|
|
1
|
+
require 'dry/cli'
|
2
|
+
require 'json'
|
3
|
+
require 'uri'
|
4
|
+
require 'chunky_png'
|
5
|
+
require 'async'
|
6
|
+
require 'async/barrier'
|
7
|
+
require 'async/semaphore'
|
8
|
+
require 'async/http/internet/instance'
|
9
|
+
|
10
|
+
module EmergeCLI
|
11
|
+
module Commands
|
12
|
+
module Upload
|
13
|
+
class Snapshots < EmergeCLI::Commands::GlobalOptions
|
14
|
+
desc 'Upload snapshot images to Emerge'
|
15
|
+
|
16
|
+
option :id, type: :string, required: true, desc: 'Unique identifier to group runs together'
|
17
|
+
option :name, type: :string, required: true, desc: 'Name of the run'
|
18
|
+
|
19
|
+
# Optional options
|
20
|
+
option :api_token, type: :string, required: false,
|
21
|
+
desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]'
|
22
|
+
option :sha, type: :string, required: false, desc: 'SHA of the commit'
|
23
|
+
option :branch, type: :string, required: false, desc: 'Branch name'
|
24
|
+
option :repo_name, type: :string, required: false, desc: 'Repository name'
|
25
|
+
option :base_sha, type: :string, required: false, desc: 'Base SHA'
|
26
|
+
option :previous_sha, type: :string, required: false, desc: 'Previous SHA'
|
27
|
+
option :pr_number, type: :string, required: false, desc: 'PR number'
|
28
|
+
option :concurrency, type: :integer, default: 5, desc: 'Number of concurrency for parallel uploads'
|
29
|
+
|
30
|
+
option :client_library, type: :string, required: false, values: %w[swift-snapshot-testing paparazzi],
|
31
|
+
desc: 'Client library used for snapshots'
|
32
|
+
option :project_root, type: :string, required: false, desc: 'Path to the project root'
|
33
|
+
|
34
|
+
option :profile, type: :boolean, default: false, desc: 'Enable performance profiling metrics'
|
35
|
+
|
36
|
+
argument :image_paths, type: :array, required: false, desc: 'Paths to folders containing images'
|
37
|
+
|
38
|
+
def initialize(network: nil, git_info_provider: nil)
|
39
|
+
@network = network
|
40
|
+
@git_info_provider = git_info_provider
|
41
|
+
@profiler = EmergeCLI::Profiler.new
|
42
|
+
end
|
43
|
+
|
44
|
+
def call(image_paths:, **options)
|
45
|
+
@options = options
|
46
|
+
@profiler = EmergeCLI::Profiler.new(enabled: options[:profile])
|
47
|
+
before(options)
|
48
|
+
|
49
|
+
start_time = Time.now
|
50
|
+
run_id = nil
|
51
|
+
|
52
|
+
begin
|
53
|
+
api_token = @options[:api_token] || ENV['EMERGE_API_TOKEN']
|
54
|
+
raise 'API token is required' unless api_token
|
55
|
+
|
56
|
+
@network ||= EmergeCLI::Network.new(api_token:)
|
57
|
+
@git_info_provider ||= GitInfoProvider.new
|
58
|
+
|
59
|
+
Sync do
|
60
|
+
validate_options(image_paths)
|
61
|
+
|
62
|
+
client = create_client(image_paths)
|
63
|
+
|
64
|
+
image_files = @profiler.measure('find_image_files') { find_image_files(client) }
|
65
|
+
|
66
|
+
@profiler.measure('check_duplicate_files') { check_duplicate_files(image_files, client) }
|
67
|
+
|
68
|
+
run_id = @profiler.measure('create_run') { create_run }
|
69
|
+
|
70
|
+
upload_images(run_id, options[:concurrency], image_files, client)
|
71
|
+
|
72
|
+
@profiler.measure('finish_run') { finish_run(run_id) }
|
73
|
+
end
|
74
|
+
|
75
|
+
Logger.info 'Upload completed successfully!'
|
76
|
+
Logger.info "Time taken: #{(Time.now - start_time).round(2)} seconds"
|
77
|
+
@profiler.report
|
78
|
+
Logger.info "✅ View your snapshots at https://emergetools.com/snapshot/#{run_id}"
|
79
|
+
rescue StandardError => e
|
80
|
+
Logger.error "CLI Error: #{e.message}"
|
81
|
+
Sync { report_error(run_id, e.message, 'crash') } if run_id
|
82
|
+
ensure
|
83
|
+
@network&.close
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
def validate_options(image_paths)
|
90
|
+
if @options[:client_library] && !@options[:project_root]
|
91
|
+
raise 'Project path is required when using a client library'
|
92
|
+
end
|
93
|
+
if @options[:project_root] && !@options[:client_library]
|
94
|
+
raise 'Client library is required when using a project path'
|
95
|
+
end
|
96
|
+
return unless (@options[:project_root] || @options[:client_library]) && !image_paths.empty?
|
97
|
+
raise 'Cannot specify image paths when using a project path or client library'
|
98
|
+
end
|
99
|
+
|
100
|
+
def create_client(image_paths)
|
101
|
+
case @options[:client_library]
|
102
|
+
when 'swift-snapshot-testing'
|
103
|
+
ClientLibraries::SwiftSnapshotTesting.new(@options[:project_root])
|
104
|
+
when 'paparazzi'
|
105
|
+
ClientLibraries::Paparazzi.new(@options[:project_root])
|
106
|
+
else
|
107
|
+
ClientLibraries::Default.new(image_paths)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def find_image_files(client)
|
112
|
+
found_images = client.image_files
|
113
|
+
Logger.info "Found #{found_images.size} images"
|
114
|
+
found_images
|
115
|
+
end
|
116
|
+
|
117
|
+
def check_duplicate_files(image_files, client)
|
118
|
+
seen_files = {}
|
119
|
+
image_files.each do |image_path|
|
120
|
+
file_name = client.parse_file_info(image_path)[:file_name]
|
121
|
+
|
122
|
+
if seen_files[file_name]
|
123
|
+
Logger.warn "Duplicate file name detected: '#{file_name}'. " \
|
124
|
+
"Previous occurrence: '#{seen_files[file_name]}'. " \
|
125
|
+
'This upload will overwrite the previous one.'
|
126
|
+
end
|
127
|
+
seen_files[file_name] = image_path
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def create_run
|
132
|
+
Logger.info 'Creating run...'
|
133
|
+
|
134
|
+
git_result = @git_info_provider.fetch_git_info
|
135
|
+
|
136
|
+
sha = @options[:sha] || git_result.sha
|
137
|
+
branch = @options[:branch] || git_result.branch
|
138
|
+
base_sha = @options[:base_sha] || git_result.base_sha
|
139
|
+
previous_sha = @options[:previous_sha] || git_result.previous_sha
|
140
|
+
pr_number = @options[:pr_number] || git_result.pr_number
|
141
|
+
|
142
|
+
# TODO: Make optional
|
143
|
+
raise 'SHA is required' unless sha
|
144
|
+
raise 'Branch is required' unless branch
|
145
|
+
raise 'Repo name is required' unless @options[:repo_name]
|
146
|
+
|
147
|
+
payload = {
|
148
|
+
id: @options[:id],
|
149
|
+
name: @options[:name],
|
150
|
+
sha:,
|
151
|
+
branch:,
|
152
|
+
repo_name: @options[:repo_name],
|
153
|
+
# Optional
|
154
|
+
base_sha:,
|
155
|
+
previous_sha:,
|
156
|
+
pr_number: pr_number&.to_s
|
157
|
+
}.compact
|
158
|
+
|
159
|
+
response = @network.post(path: '/v1/snapshots/run', body: payload)
|
160
|
+
run_id = JSON.parse(response.read).fetch('run_id')
|
161
|
+
Logger.info "Created run: #{run_id}"
|
162
|
+
|
163
|
+
run_id
|
164
|
+
end
|
165
|
+
|
166
|
+
def upload_images(run_id, concurrency, image_files, client)
|
167
|
+
Logger.info 'Uploading images...'
|
168
|
+
|
169
|
+
post_image_barrier = Async::Barrier.new
|
170
|
+
post_image_semaphore = Async::Semaphore.new(concurrency, parent: post_image_barrier)
|
171
|
+
|
172
|
+
upload_image_barrier = Async::Barrier.new
|
173
|
+
upload_image_semaphore = Async::Semaphore.new(concurrency, parent: upload_image_barrier)
|
174
|
+
|
175
|
+
image_files.each_with_index do |image_path, index|
|
176
|
+
post_image_semaphore.async do
|
177
|
+
Logger.debug "Fetching upload URL for image #{index + 1}/#{image_files.size}: #{image_path}"
|
178
|
+
|
179
|
+
file_info = client.parse_file_info(image_path)
|
180
|
+
|
181
|
+
dimensions = @profiler.measure('chunky_png_processing') do
|
182
|
+
datastream = ChunkyPNG::Datastream.from_file(image_path)
|
183
|
+
{
|
184
|
+
width: datastream.header_chunk.width,
|
185
|
+
height: datastream.header_chunk.height
|
186
|
+
}
|
187
|
+
end
|
188
|
+
|
189
|
+
body = {
|
190
|
+
run_id:,
|
191
|
+
file_name: file_info[:file_name],
|
192
|
+
group_name: file_info[:group_name],
|
193
|
+
variant_name: file_info[:variant_name],
|
194
|
+
width: dimensions[:width],
|
195
|
+
height: dimensions[:height]
|
196
|
+
}
|
197
|
+
|
198
|
+
upload_url = @profiler.measure('create_image_upload_url') do
|
199
|
+
response = @network.post(path: '/v1/snapshots/run/image', body:)
|
200
|
+
JSON.parse(response.read).fetch('image_url')
|
201
|
+
end
|
202
|
+
|
203
|
+
# Start uploading the image without waiting for it to finish
|
204
|
+
upload_image_semaphore.async do
|
205
|
+
Logger.info "Uploading image #{index + 1}/#{image_files.size}: #{image_path}"
|
206
|
+
|
207
|
+
@profiler.measure('upload_image') do
|
208
|
+
@network.put(
|
209
|
+
path: upload_url,
|
210
|
+
headers: { 'Content-Type' => 'image/png' },
|
211
|
+
body: File.read(image_path)
|
212
|
+
)
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
post_image_barrier.wait
|
219
|
+
upload_image_barrier.wait
|
220
|
+
ensure
|
221
|
+
post_image_barrier&.stop
|
222
|
+
upload_image_barrier&.stop
|
223
|
+
end
|
224
|
+
|
225
|
+
def finish_run(run_id)
|
226
|
+
Logger.info 'Finishing run...'
|
227
|
+
@network.post(path: '/v1/snapshots/run/finish', body: { run_id: })
|
228
|
+
nil
|
229
|
+
end
|
230
|
+
|
231
|
+
def report_error(run_id, error_message, error_code = 'generic')
|
232
|
+
@network.post(
|
233
|
+
path: '/v1/snapshots/run/error',
|
234
|
+
body: {
|
235
|
+
run_id:,
|
236
|
+
error_code:,
|
237
|
+
error_message:
|
238
|
+
}
|
239
|
+
)
|
240
|
+
Logger.info 'Reported error to Emerge'
|
241
|
+
rescue StandardError => e
|
242
|
+
Logger.error "Failed to report error to Emerge: #{e.message}"
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
data/lib/emerge_cli.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require_relative './commands/global_options'
|
2
|
+
require_relative './commands/upload/snapshots/snapshots'
|
3
|
+
require_relative './commands/upload/snapshots/client_libraries/swift_snapshot_testing'
|
4
|
+
require_relative './commands/upload/snapshots/client_libraries/paparazzi'
|
5
|
+
require_relative './commands/upload/snapshots/client_libraries/default'
|
6
|
+
require_relative './commands/integrate/fastlane'
|
7
|
+
require_relative './commands/config/snapshots/snapshots_ios'
|
8
|
+
|
9
|
+
require_relative './utils/git_info_provider'
|
10
|
+
require_relative './utils/git_result'
|
11
|
+
require_relative './utils/github'
|
12
|
+
require_relative './utils/git'
|
13
|
+
require_relative './utils/logger'
|
14
|
+
require_relative './utils/network'
|
15
|
+
require_relative './utils/profiler'
|
16
|
+
require_relative './utils/project_detector'
|
17
|
+
|
18
|
+
require 'dry/cli'
|
19
|
+
|
20
|
+
module EmergeCLI
|
21
|
+
extend Dry::CLI::Registry
|
22
|
+
|
23
|
+
register 'upload', aliases: ['u'] do |prefix|
|
24
|
+
prefix.register 'snapshots', Commands::Upload::Snapshots
|
25
|
+
end
|
26
|
+
|
27
|
+
register 'integrate' do |prefix|
|
28
|
+
prefix.register 'fastlane-ios', Commands::Integrate::Fastlane, aliases: ['i']
|
29
|
+
end
|
30
|
+
|
31
|
+
register 'configure' do |prefix|
|
32
|
+
prefix.register 'snapshots-ios', Commands::Config::SnapshotsIOS, aliases: ['c']
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# By default the log level is INFO, but can be overridden by the --debug flag
|
37
|
+
EmergeCLI::Logger.configure(::Logger::INFO)
|
data/lib/utils/git.rb
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
require 'open3'
|
2
|
+
|
3
|
+
module EmergeCLI
|
4
|
+
module Git
|
5
|
+
def self.branch
|
6
|
+
command = 'git rev-parse --abbrev-ref HEAD'
|
7
|
+
Logger.debug command
|
8
|
+
stdout, _, status = Open3.capture3(command)
|
9
|
+
unless status.success?
|
10
|
+
Logger.error 'Failed to get the current branch name'
|
11
|
+
return nil
|
12
|
+
end
|
13
|
+
|
14
|
+
branch_name = stdout.strip
|
15
|
+
if branch_name == 'HEAD'
|
16
|
+
# We're in a detached HEAD state
|
17
|
+
# Find all branches that contains the current HEAD commit
|
18
|
+
#
|
19
|
+
# Example output:
|
20
|
+
# * (HEAD detached at dec13a5)
|
21
|
+
# telkins/detached-test
|
22
|
+
# remotes/origin/telkins/detached-test
|
23
|
+
#
|
24
|
+
# So far I've seen this output be fairly stable
|
25
|
+
# If the input is invalid for whatever reason, sed/awk will return an empty string
|
26
|
+
command = "git branch -a --contains HEAD | sed -n 2p | awk '{ printf $1 }'"
|
27
|
+
Logger.debug command
|
28
|
+
head_stdout, _, head_status = Open3.capture3(command)
|
29
|
+
|
30
|
+
unless head_status.success?
|
31
|
+
Logger.error 'Failed to get the current branch name for detached HEAD'
|
32
|
+
return nil
|
33
|
+
end
|
34
|
+
|
35
|
+
branch_name = head_stdout.strip
|
36
|
+
end
|
37
|
+
|
38
|
+
branch_name == 'HEAD' ? nil : branch_name
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.sha
|
42
|
+
command = 'git rev-parse HEAD'
|
43
|
+
Logger.debug command
|
44
|
+
stdout, _, status = Open3.capture3(command)
|
45
|
+
stdout.strip if status.success?
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.base_sha
|
49
|
+
current_branch = branch
|
50
|
+
remote_head = remote_head_branch
|
51
|
+
return nil if current_branch.nil? || remote_head.nil?
|
52
|
+
|
53
|
+
command = "git merge-base #{remote_head} #{current_branch}"
|
54
|
+
Logger.debug command
|
55
|
+
stdout, _, status = Open3.capture3(command)
|
56
|
+
return nil if stdout.strip.empty? || !status.success?
|
57
|
+
current_sha = sha
|
58
|
+
stdout.strip == current_sha ? nil : stdout.strip
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.previous_sha
|
62
|
+
command = 'git rev-parse HEAD^'
|
63
|
+
Logger.debug command
|
64
|
+
stdout, _, status = Open3.capture3(command)
|
65
|
+
stdout.strip if status.success?
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.primary_remote
|
69
|
+
remote = remote()
|
70
|
+
return nil if remote.nil?
|
71
|
+
remote.include?('origin') ? 'origin' : remote.first
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.remote_head_branch(remote = primary_remote)
|
75
|
+
return nil if remote.nil?
|
76
|
+
command = "git remote show #{remote}"
|
77
|
+
Logger.debug command
|
78
|
+
stdout, _, status = Open3.capture3(command)
|
79
|
+
return nil if stdout.nil? || !status.success?
|
80
|
+
stdout
|
81
|
+
.split("\n")
|
82
|
+
.map(&:strip)
|
83
|
+
.find { |line| line.start_with?('HEAD branch: ') }
|
84
|
+
&.split(' ')
|
85
|
+
&.last
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.remote_url(remote = primary_remote)
|
89
|
+
return nil if remote.nil?
|
90
|
+
command = "git config --get remote.#{remote}.url"
|
91
|
+
Logger.debug command
|
92
|
+
stdout, _, status = Open3.capture3(command)
|
93
|
+
stdout if status.success?
|
94
|
+
end
|
95
|
+
|
96
|
+
def self.remote
|
97
|
+
command = 'git remote'
|
98
|
+
Logger.debug command
|
99
|
+
stdout, _, status = Open3.capture3(command)
|
100
|
+
stdout.split("\n") if status.success?
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module EmergeCLI
|
2
|
+
class GitInfoProvider
|
3
|
+
def fetch_git_info
|
4
|
+
if EmergeCLI::Github.supported_github_event?
|
5
|
+
Logger.info 'Fetching Git info from Github event'
|
6
|
+
EmergeCLI::GitResult.new(
|
7
|
+
sha: EmergeCLI::Github.sha,
|
8
|
+
base_sha: EmergeCLI::Github.base_sha,
|
9
|
+
branch: EmergeCLI::Github.branch,
|
10
|
+
pr_number: EmergeCLI::Github.pr_number,
|
11
|
+
repo_name: EmergeCLI::Github.repo_name,
|
12
|
+
previous_sha: EmergeCLI::Github.previous_sha
|
13
|
+
)
|
14
|
+
else
|
15
|
+
Logger.info 'Fetching Git info from system Git'
|
16
|
+
EmergeCLI::GitResult.new(
|
17
|
+
sha: EmergeCLI::Git.sha,
|
18
|
+
base_sha: EmergeCLI::Git.base_sha,
|
19
|
+
branch: EmergeCLI::Git.branch,
|
20
|
+
previous_sha: EmergeCLI::Git.previous_sha
|
21
|
+
)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module EmergeCLI
|
2
|
+
class GitResult
|
3
|
+
attr_accessor :sha, :base_sha, :previous_sha, :branch, :pr_number, :repo_name
|
4
|
+
|
5
|
+
def initialize(sha:, base_sha:, branch:, pr_number: nil, repo_name: nil, previous_sha: nil)
|
6
|
+
@pr_number = pr_number
|
7
|
+
@sha = sha
|
8
|
+
@base_sha = base_sha
|
9
|
+
@previous_sha = previous_sha
|
10
|
+
@branch = branch
|
11
|
+
@repo_name = repo_name
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/lib/utils/github.rb
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
module EmergeCLI
|
2
|
+
module Github
|
3
|
+
GITHUB_EVENT_PR = 'pull_request'.freeze
|
4
|
+
GITHUB_EVENT_PUSH = 'push'.freeze
|
5
|
+
|
6
|
+
def self.event_name
|
7
|
+
ENV['GITHUB_EVENT_NAME']
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.supported_github_event?
|
11
|
+
Logger.info "GitHub event name: #{event_name}"
|
12
|
+
pull_request? || push?
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.pull_request?
|
16
|
+
event_name == GITHUB_EVENT_PR
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.push?
|
20
|
+
event_name == GITHUB_EVENT_PUSH
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.sha
|
24
|
+
if push?
|
25
|
+
ENV['GITHUB_SHA']
|
26
|
+
elsif pull_request?
|
27
|
+
github_event_data.dig(:pull_request, :head, :sha)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.base_sha
|
32
|
+
return unless pull_request?
|
33
|
+
github_event_data.dig(:pull_request, :base, :sha)
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.pr_number
|
37
|
+
pull_request? ? github_event_data[:number] : nil
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.branch
|
41
|
+
pull_request? ? github_event_data.dig(:pull_request, :head, :ref) : Git.branch
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.repo_owner
|
45
|
+
github_event_data.dig(:repository, :owner, :login)
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.repo_name
|
49
|
+
github_event_data.dig(:repository, :full_name)
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.previous_sha
|
53
|
+
Git.previous_sha
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.github_event_data
|
57
|
+
@github_event_data ||= begin
|
58
|
+
github_event_path = ENV['GITHUB_EVENT_PATH']
|
59
|
+
Logger.error 'GITHUB_EVENT_PATH is not set' if github_event_path.nil?
|
60
|
+
|
61
|
+
Logger.error "File #{github_event_path} doesn't exist" unless File.exist?(github_event_path)
|
62
|
+
|
63
|
+
file_content = File.read(github_event_path)
|
64
|
+
file_json = JSON.parse(file_content, symbolize_names: true)
|
65
|
+
Logger.debug "Parsed GitHub event data: #{file_json.inspect}"
|
66
|
+
|
67
|
+
file_json
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
data/lib/utils/logger.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'colorize'
|
3
|
+
|
4
|
+
module EmergeCLI
|
5
|
+
class Logger
|
6
|
+
class << self
|
7
|
+
def configure(log_level)
|
8
|
+
logger.level = log_level
|
9
|
+
end
|
10
|
+
|
11
|
+
def info(message)
|
12
|
+
log(:info, message)
|
13
|
+
end
|
14
|
+
|
15
|
+
def warn(message)
|
16
|
+
log(:warn, message)
|
17
|
+
end
|
18
|
+
|
19
|
+
def error(message)
|
20
|
+
log(:error, message)
|
21
|
+
end
|
22
|
+
|
23
|
+
def debug(message)
|
24
|
+
log(:debug, message)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def logger
|
30
|
+
@logger ||= create_logger
|
31
|
+
end
|
32
|
+
|
33
|
+
def create_logger
|
34
|
+
logger = ::Logger.new(STDOUT)
|
35
|
+
logger.formatter = proc do |severity, datetime, _progname, msg|
|
36
|
+
timestamp = datetime.strftime('%Y-%m-%d %H:%M:%S.%L')
|
37
|
+
formatted_severity = severity.ljust(5)
|
38
|
+
colored_message = case severity
|
39
|
+
when 'INFO'
|
40
|
+
msg.to_s.white
|
41
|
+
when 'WARN'
|
42
|
+
msg.to_s.yellow
|
43
|
+
when 'ERROR'
|
44
|
+
msg.to_s.red
|
45
|
+
when 'DEBUG'
|
46
|
+
msg.to_s.light_blue
|
47
|
+
else
|
48
|
+
msg.to_s
|
49
|
+
end
|
50
|
+
"[#{timestamp}] #{formatted_severity} -- : #{colored_message}\n"
|
51
|
+
end
|
52
|
+
logger
|
53
|
+
end
|
54
|
+
|
55
|
+
def log(level, message)
|
56
|
+
logger.send(level, message)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'json'
|
3
|
+
require 'uri'
|
4
|
+
require 'async/http/internet/instance'
|
5
|
+
|
6
|
+
module EmergeCLI
|
7
|
+
class Network
|
8
|
+
EMERGE_API_PROD_URL = 'api.emergetools.com'.freeze
|
9
|
+
public_constant :EMERGE_API_PROD_URL
|
10
|
+
|
11
|
+
RETRY_DELAY = 5
|
12
|
+
MAX_RETRIES = 1
|
13
|
+
|
14
|
+
def initialize(api_token:, base_url: EMERGE_API_PROD_URL)
|
15
|
+
@base_url = base_url
|
16
|
+
@api_token = api_token
|
17
|
+
@internet = Async::HTTP::Internet.new
|
18
|
+
end
|
19
|
+
|
20
|
+
def get(path:, headers: {})
|
21
|
+
request(:get, path, nil, headers)
|
22
|
+
end
|
23
|
+
|
24
|
+
def post(path:, body:, headers: {})
|
25
|
+
request(:post, path, body, headers)
|
26
|
+
end
|
27
|
+
|
28
|
+
def put(path:, body:, headers: {})
|
29
|
+
request(:put, path, body, headers)
|
30
|
+
end
|
31
|
+
|
32
|
+
def close
|
33
|
+
@internet.close
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def request(method, path, body, custom_headers)
|
39
|
+
uri = if path.start_with?('http')
|
40
|
+
URI.parse(path)
|
41
|
+
else
|
42
|
+
URI::HTTPS.build(host: @base_url, path:)
|
43
|
+
end
|
44
|
+
absolute_uri = uri.to_s
|
45
|
+
|
46
|
+
headers = { 'X-API-Token' => @api_token }
|
47
|
+
headers['Content-Type'] = 'application/json' if method == :post && body.is_a?(Hash)
|
48
|
+
headers.merge!(custom_headers)
|
49
|
+
|
50
|
+
body = JSON.dump(body) if body.is_a?(Hash) && method == :post
|
51
|
+
|
52
|
+
Logger.debug "Request: #{method} #{truncate_uri(absolute_uri)} #{method == :post ? body : 'N/A'}"
|
53
|
+
|
54
|
+
retries = 0
|
55
|
+
begin
|
56
|
+
response = perform_request(method, absolute_uri, headers, body)
|
57
|
+
|
58
|
+
unless response.success?
|
59
|
+
Logger.error "Request failed: #{absolute_uri} #{response}"
|
60
|
+
raise "Request failed: #{absolute_uri} #{response}"
|
61
|
+
end
|
62
|
+
|
63
|
+
response
|
64
|
+
rescue StandardError => e
|
65
|
+
# Workaround for an issue where the request is not fully written, haven't determined the root cause yet
|
66
|
+
if e.message.include?('Wrote 0 bytes') && retries < MAX_RETRIES
|
67
|
+
retries += 1
|
68
|
+
Logger.warn "Request failed due to incomplete write. Retrying in #{RETRY_DELAY} seconds..."
|
69
|
+
sleep RETRY_DELAY
|
70
|
+
retry
|
71
|
+
else
|
72
|
+
Logger.error "Request failed: #{absolute_uri} #{e.message}"
|
73
|
+
raise e
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def perform_request(method, absolute_uri, headers, body)
|
79
|
+
case method
|
80
|
+
when :get
|
81
|
+
@internet.get(absolute_uri, headers:)
|
82
|
+
when :post
|
83
|
+
@internet.post(absolute_uri, headers:, body:)
|
84
|
+
when :put
|
85
|
+
@internet.put(absolute_uri, headers:, body:)
|
86
|
+
else
|
87
|
+
raise "Unsupported method: #{method}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def truncate_uri(uri, max_length = 100)
|
92
|
+
uri.length > max_length ? "#{uri[0..max_length]}..." : uri
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|