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.
@@ -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
@@ -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
@@ -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