strata-cli 0.1.0.beta
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/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +65 -0
- data/LICENSE +21 -0
- data/README.md +465 -0
- data/Rakefile +10 -0
- data/exe/strata +6 -0
- data/lib/strata/cli/ai/client.rb +63 -0
- data/lib/strata/cli/ai/configuration.rb +48 -0
- data/lib/strata/cli/ai/services/table_generator.rb +282 -0
- data/lib/strata/cli/api/client.rb +170 -0
- data/lib/strata/cli/api/connection_error_handler.rb +54 -0
- data/lib/strata/cli/configuration.rb +135 -0
- data/lib/strata/cli/credentials.rb +83 -0
- data/lib/strata/cli/descriptions/create/migration.txt +25 -0
- data/lib/strata/cli/descriptions/create/relation.txt +14 -0
- data/lib/strata/cli/descriptions/create/table.txt +23 -0
- data/lib/strata/cli/descriptions/datasource/add.txt +15 -0
- data/lib/strata/cli/descriptions/datasource/auth.txt +14 -0
- data/lib/strata/cli/descriptions/datasource/exec.txt +7 -0
- data/lib/strata/cli/descriptions/datasource/meta.txt +11 -0
- data/lib/strata/cli/descriptions/datasource/tables.txt +12 -0
- data/lib/strata/cli/descriptions/datasource/test.txt +8 -0
- data/lib/strata/cli/descriptions/deploy/deploy.txt +24 -0
- data/lib/strata/cli/descriptions/deploy/status.txt +9 -0
- data/lib/strata/cli/descriptions/init.txt +14 -0
- data/lib/strata/cli/generators/datasource.rb +83 -0
- data/lib/strata/cli/generators/group.rb +13 -0
- data/lib/strata/cli/generators/migration.rb +71 -0
- data/lib/strata/cli/generators/project.rb +190 -0
- data/lib/strata/cli/generators/relation.rb +64 -0
- data/lib/strata/cli/generators/table.rb +143 -0
- data/lib/strata/cli/generators/templates/adapters/athena.yml +53 -0
- data/lib/strata/cli/generators/templates/adapters/druid.yml +42 -0
- data/lib/strata/cli/generators/templates/adapters/duckdb.yml +36 -0
- data/lib/strata/cli/generators/templates/adapters/mysql.yml +45 -0
- data/lib/strata/cli/generators/templates/adapters/postgres.yml +48 -0
- data/lib/strata/cli/generators/templates/adapters/snowflake.yml +69 -0
- data/lib/strata/cli/generators/templates/adapters/sqlserver.yml +45 -0
- data/lib/strata/cli/generators/templates/adapters/trino.yml +56 -0
- data/lib/strata/cli/generators/templates/datasources.yml +4 -0
- data/lib/strata/cli/generators/templates/migration.rename.yml +15 -0
- data/lib/strata/cli/generators/templates/migration.swap.yml +13 -0
- data/lib/strata/cli/generators/templates/project.yml +36 -0
- data/lib/strata/cli/generators/templates/rel.domain.yml +43 -0
- data/lib/strata/cli/generators/templates/strata.yml +24 -0
- data/lib/strata/cli/generators/templates/table.table_name.yml +118 -0
- data/lib/strata/cli/generators/templates/test.yml +34 -0
- data/lib/strata/cli/generators/test.rb +48 -0
- data/lib/strata/cli/guard.rb +21 -0
- data/lib/strata/cli/helpers/color_helper.rb +103 -0
- data/lib/strata/cli/helpers/command_context.rb +41 -0
- data/lib/strata/cli/helpers/datasource_helper.rb +62 -0
- data/lib/strata/cli/helpers/description_helper.rb +18 -0
- data/lib/strata/cli/helpers/project_helper.rb +85 -0
- data/lib/strata/cli/helpers/prompts.rb +42 -0
- data/lib/strata/cli/helpers/table_filter.rb +48 -0
- data/lib/strata/cli/main.rb +71 -0
- data/lib/strata/cli/sub_commands/audit.rb +262 -0
- data/lib/strata/cli/sub_commands/create.rb +419 -0
- data/lib/strata/cli/sub_commands/datasource.rb +353 -0
- data/lib/strata/cli/sub_commands/deploy.rb +433 -0
- data/lib/strata/cli/sub_commands/project.rb +38 -0
- data/lib/strata/cli/sub_commands/table.rb +58 -0
- data/lib/strata/cli/terminal.rb +102 -0
- data/lib/strata/cli/ui/autocomplete.rb +93 -0
- data/lib/strata/cli/ui/field_editor.rb +215 -0
- data/lib/strata/cli/utils/archive.rb +137 -0
- data/lib/strata/cli/utils/deployment_monitor.rb +445 -0
- data/lib/strata/cli/utils/git.rb +253 -0
- data/lib/strata/cli/utils/import_manager.rb +190 -0
- data/lib/strata/cli/utils/test_reporter.rb +131 -0
- data/lib/strata/cli/utils/yaml_import_resolver.rb +91 -0
- data/lib/strata/cli/utils.rb +39 -0
- data/lib/strata/cli/version.rb +7 -0
- data/lib/strata/cli.rb +36 -0
- data/sig/strata/cli.rbs +6 -0
- metadata +306 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../helpers/color_helper"
|
|
4
|
+
require_relative "../terminal"
|
|
5
|
+
require "tty-spinner"
|
|
6
|
+
require "pastel"
|
|
7
|
+
|
|
8
|
+
module Strata
|
|
9
|
+
module CLI
|
|
10
|
+
module Utils
|
|
11
|
+
# Monitors deployment progress by polling the API and displaying stage updates
|
|
12
|
+
# with animated spinners that transition to checkmarks as stages complete.
|
|
13
|
+
|
|
14
|
+
class DeploymentMonitor
|
|
15
|
+
include Terminal
|
|
16
|
+
|
|
17
|
+
# Terminal statuses that indicate deployment is complete
|
|
18
|
+
TERMINAL_STATUSES = ["succeeded", "failed"].freeze
|
|
19
|
+
NOT_STARTED_STAGE = "not_started"
|
|
20
|
+
DEFAULT_POLL_INTERVAL = 0.5 # seconds
|
|
21
|
+
DEFAULT_TIMEOUT = 600 # seconds (10 minutes)
|
|
22
|
+
TEST_WAIT_TIMEOUT = 5 # seconds
|
|
23
|
+
STAGES = [
|
|
24
|
+
:not_started,
|
|
25
|
+
:preparing,
|
|
26
|
+
:pre_migrations,
|
|
27
|
+
:project_configuration,
|
|
28
|
+
:processing_datasources,
|
|
29
|
+
:processing_models,
|
|
30
|
+
:removing_deleted_models,
|
|
31
|
+
:processing_relationships,
|
|
32
|
+
:forming_universes,
|
|
33
|
+
:creating_blend_paths,
|
|
34
|
+
:validating_references,
|
|
35
|
+
:post_migrations,
|
|
36
|
+
:cleaning_up,
|
|
37
|
+
:finished
|
|
38
|
+
].freeze
|
|
39
|
+
TESTING_STAGE = :running_tests
|
|
40
|
+
|
|
41
|
+
# Initialize a new DeploymentMonitor
|
|
42
|
+
#
|
|
43
|
+
# @param client [API::Client] The API client for fetching deployment status
|
|
44
|
+
# @param project_id [String] The project identifier
|
|
45
|
+
# @param branch_id [String] The branch identifier
|
|
46
|
+
# @param deployment_id [String, Integer] The deployment identifier
|
|
47
|
+
# @param has_tests [Boolean, nil] Whether tests exist for this deployment
|
|
48
|
+
# @raise [ArgumentError] if required parameters are nil or empty
|
|
49
|
+
def initialize(client, project_id, branch_id, deployment_id, has_tests: nil)
|
|
50
|
+
validate_initialization_params(client, project_id, branch_id, deployment_id)
|
|
51
|
+
|
|
52
|
+
@client = client
|
|
53
|
+
@project_id = project_id
|
|
54
|
+
@branch_id = branch_id
|
|
55
|
+
@deployment_id = deployment_id
|
|
56
|
+
@has_tests = has_tests
|
|
57
|
+
@spinners = {}
|
|
58
|
+
@completed_stages = Set.new
|
|
59
|
+
@seen_stages = []
|
|
60
|
+
@last_stage = nil
|
|
61
|
+
@tests_running = false
|
|
62
|
+
@deployment_completed = false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Start monitoring deployment progress by polling the API
|
|
66
|
+
#
|
|
67
|
+
# @param poll_interval [Integer] Seconds between API polls (default: 2)
|
|
68
|
+
# @param timeout [Integer] Maximum seconds to monitor before timing out (default: 600)
|
|
69
|
+
# @param skip_initial_display [Boolean] Skip initial status messages (used when called after display_status)
|
|
70
|
+
# @return [Hash, nil] The final deployment hash, or nil if interrupted/errored
|
|
71
|
+
def start(poll_interval: DEFAULT_POLL_INTERVAL, timeout: DEFAULT_TIMEOUT, skip_initial_display: false)
|
|
72
|
+
unless skip_initial_display
|
|
73
|
+
display_initial_status
|
|
74
|
+
display_exit_instruction
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
start_time = Time.now
|
|
78
|
+
|
|
79
|
+
loop do
|
|
80
|
+
deployment = fetch_deployment_status
|
|
81
|
+
|
|
82
|
+
break if deployment.nil?
|
|
83
|
+
|
|
84
|
+
status = deployment_value(deployment, "status")
|
|
85
|
+
stage = deployment_value(deployment, "stage")
|
|
86
|
+
|
|
87
|
+
unless terminal_status?(status)
|
|
88
|
+
if @last_stage.nil? && stage
|
|
89
|
+
process_deployment_state(deployment)
|
|
90
|
+
elsif stage && stage != @last_stage
|
|
91
|
+
handle_stage_change(stage, status)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if terminal_status?(status) && !@deployment_completed
|
|
96
|
+
result = handle_deployment_completion(deployment, status, stage)
|
|
97
|
+
@deployment_completed = true
|
|
98
|
+
return result if result
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if @deployment_completed && status == "succeeded" && stage == "finished"
|
|
102
|
+
latest_test_run = deployment_value(deployment, "latest_test_run")
|
|
103
|
+
|
|
104
|
+
if @has_tests == true && latest_test_run.nil?
|
|
105
|
+
start_stage_spinner(TESTING_STAGE) unless @tests_running
|
|
106
|
+
@tests_running = true
|
|
107
|
+
elsif latest_test_run
|
|
108
|
+
complete_stage(TESTING_STAGE) if @tests_running
|
|
109
|
+
@tests_running = false
|
|
110
|
+
test_results = deployment_value(latest_test_run, "test_results")
|
|
111
|
+
display_test_results(test_results) if test_results
|
|
112
|
+
return deployment
|
|
113
|
+
else
|
|
114
|
+
return deployment
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if Time.now - start_time > timeout
|
|
119
|
+
complete_stage(TESTING_STAGE) if @tests_running
|
|
120
|
+
say "\n Monitoring timeout reached (#{timeout}s)", ColorHelper.warning
|
|
121
|
+
say " Deployment may still be in progress. Check server for status.\n", ColorHelper.info
|
|
122
|
+
return deployment
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
sleep(poll_interval)
|
|
126
|
+
end
|
|
127
|
+
rescue Interrupt
|
|
128
|
+
stop_all_spinners
|
|
129
|
+
say "\n\n Monitoring interrupted. Deployment continues in background.", ColorHelper.warning
|
|
130
|
+
say " Check server for deployment status.\n", ColorHelper.info
|
|
131
|
+
nil
|
|
132
|
+
rescue => e
|
|
133
|
+
stop_all_spinners
|
|
134
|
+
say "\n Error monitoring deployment: #{e.message}", ColorHelper.error
|
|
135
|
+
say " Check server for deployment status.\n", ColorHelper.info
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def display_status
|
|
140
|
+
deployment = fetch_deployment_status
|
|
141
|
+
return nil if deployment.nil?
|
|
142
|
+
|
|
143
|
+
display_initial_status
|
|
144
|
+
display_exit_instruction
|
|
145
|
+
|
|
146
|
+
process_deployment_state(deployment)
|
|
147
|
+
|
|
148
|
+
if terminal_status?(deployment_value(deployment, "status"))
|
|
149
|
+
display_final_status(deployment)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
deployment
|
|
153
|
+
rescue Interrupt
|
|
154
|
+
stop_all_spinners
|
|
155
|
+
say "\n\n Exiting status view.\n", ColorHelper.info
|
|
156
|
+
nil
|
|
157
|
+
rescue => e
|
|
158
|
+
stop_all_spinners
|
|
159
|
+
say "\n Error fetching deployment status: #{e.message}", ColorHelper.error
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def process_deployment_state(deployment)
|
|
164
|
+
status = deployment_value(deployment, "status")
|
|
165
|
+
stage = deployment_value(deployment, "stage")
|
|
166
|
+
|
|
167
|
+
return unless stage && stage != NOT_STARTED_STAGE
|
|
168
|
+
|
|
169
|
+
process_stage(stage, status, track_seen: false)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def say(message, color_helper_result = nil)
|
|
175
|
+
return $stdout.puts(message) unless color_helper_result
|
|
176
|
+
|
|
177
|
+
colored_message = case color_helper_result
|
|
178
|
+
when Array
|
|
179
|
+
ColorHelper.pastel.decorate(message, *color_helper_result)
|
|
180
|
+
when Symbol
|
|
181
|
+
theme = ColorHelper::THEME[color_helper_result]
|
|
182
|
+
if theme
|
|
183
|
+
ColorHelper.pastel.decorate(message, *Array(theme))
|
|
184
|
+
else
|
|
185
|
+
begin
|
|
186
|
+
ColorHelper.pastel.send(color_helper_result, message)
|
|
187
|
+
rescue NoMethodError
|
|
188
|
+
message
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
else
|
|
192
|
+
message
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
$stdout.puts(colored_message)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def display_initial_status
|
|
199
|
+
say "\n Monitoring deployment progress...\n", ColorHelper.title
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def display_exit_instruction
|
|
203
|
+
say " Press Ctrl+C to exit monitoring.\n", ColorHelper.dim
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def fetch_deployment_status
|
|
207
|
+
@client.get_deployment(@project_id, @branch_id, @deployment_id)
|
|
208
|
+
rescue Strata::CommandError => e
|
|
209
|
+
say "\n Could not fetch deployment status: #{e.message}", ColorHelper.warning
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def handle_stage_change(stage, status = nil)
|
|
214
|
+
return if stage.nil? || stage == NOT_STARTED_STAGE
|
|
215
|
+
|
|
216
|
+
complete_previous_stage(stage.to_s)
|
|
217
|
+
process_stage(stage, status, track_seen: true)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def track_seen_stage(stage_string)
|
|
221
|
+
@seen_stages << stage_string unless @seen_stages.include?(stage_string)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def complete_previous_stage(current_stage_string)
|
|
225
|
+
return unless @last_stage && @last_stage != current_stage_string
|
|
226
|
+
return if @last_stage.to_s == NOT_STARTED_STAGE
|
|
227
|
+
|
|
228
|
+
complete_stage(@last_stage)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def complete_missed_stages(current_stage_string)
|
|
232
|
+
stage_symbol = current_stage_string.to_sym
|
|
233
|
+
|
|
234
|
+
if STAGES.include?(stage_symbol)
|
|
235
|
+
stages_array = STAGES.map(&:to_s)
|
|
236
|
+
complete_missed_stages_from_array(stages_array, current_stage_string)
|
|
237
|
+
else
|
|
238
|
+
# Fallback to dynamic tracking for unknown stages
|
|
239
|
+
complete_missed_stages_from_array(@seen_stages, current_stage_string)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def complete_missed_stages_from_array(stages_array, current_stage_string)
|
|
244
|
+
current_index = stages_array.index(current_stage_string)
|
|
245
|
+
return unless current_index
|
|
246
|
+
|
|
247
|
+
stages_array[0...current_index].each do |prev_stage|
|
|
248
|
+
next if prev_stage == NOT_STARTED_STAGE
|
|
249
|
+
next if @completed_stages.include?(prev_stage)
|
|
250
|
+
|
|
251
|
+
complete_stage(prev_stage)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def process_stage(stage, status = nil, track_seen: false)
|
|
256
|
+
stage_string = stage.to_s
|
|
257
|
+
return if stage_string == NOT_STARTED_STAGE || @completed_stages.include?(stage_string)
|
|
258
|
+
|
|
259
|
+
track_seen_stage(stage_string) if track_seen
|
|
260
|
+
complete_missed_stages(stage_string)
|
|
261
|
+
|
|
262
|
+
if status && terminal_status?(status)
|
|
263
|
+
# Already complete, just show it
|
|
264
|
+
complete_stage(stage_string)
|
|
265
|
+
else
|
|
266
|
+
# In progress, show spinner
|
|
267
|
+
start_stage_spinner(stage_string) unless @spinners[stage_string]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
@last_stage = stage
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def start_stage_spinner(stage_string)
|
|
274
|
+
return if @spinners[stage_string] # Already started
|
|
275
|
+
|
|
276
|
+
stage_name = format_stage_name(stage_string)
|
|
277
|
+
spinner = create_spinner(stage_name,
|
|
278
|
+
message_color: :cyan,
|
|
279
|
+
spinner_color: :cyan,
|
|
280
|
+
format: :dots,
|
|
281
|
+
clear: false)
|
|
282
|
+
spinner.auto_spin
|
|
283
|
+
@spinners[stage_string] = spinner
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def complete_stage(stage)
|
|
287
|
+
stage_string = stage.to_s
|
|
288
|
+
return if stage_string == NOT_STARTED_STAGE || @completed_stages.include?(stage_string)
|
|
289
|
+
|
|
290
|
+
mark_spinner_as_success(stage_string)
|
|
291
|
+
@completed_stages.add(stage_string)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def mark_spinner_as_success(stage_string)
|
|
295
|
+
if @spinners[stage_string]
|
|
296
|
+
stop_spinner(stage_string, :success)
|
|
297
|
+
else
|
|
298
|
+
# Stage completed before spinner started - show completion directly
|
|
299
|
+
# This ensures consistent [✔] format for all stages
|
|
300
|
+
stage_name = format_stage_name(stage_string)
|
|
301
|
+
spinner = create_spinner(stage_name, message_color: :cyan, spinner_color: :cyan)
|
|
302
|
+
spinner.success("")
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def terminal_status?(status)
|
|
307
|
+
TERMINAL_STATUSES.include?(status)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def display_final_status(deployment)
|
|
311
|
+
status = deployment_value(deployment, "status")
|
|
312
|
+
stage = deployment_value(deployment, "stage")
|
|
313
|
+
error_messages = deployment_value(deployment, "error_messages")
|
|
314
|
+
|
|
315
|
+
complete_remaining_stages(stage)
|
|
316
|
+
mark_final_stage_status(status, stage)
|
|
317
|
+
display_status_summary(status, stage, error_messages)
|
|
318
|
+
|
|
319
|
+
# Display test results for successful deployments, same format as monitor
|
|
320
|
+
display_test_results_if_available(deployment) if status == "succeeded" && stage == "finished"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def complete_remaining_stages(final_stage)
|
|
324
|
+
final_stage_string = final_stage&.to_s
|
|
325
|
+
@spinners.each_key do |stage_string|
|
|
326
|
+
next if stage_string == TESTING_STAGE # Handle test spinner separately
|
|
327
|
+
complete_stage(stage_string) if stage_string != final_stage_string
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def mark_final_stage_status(status, stage)
|
|
332
|
+
return unless stage
|
|
333
|
+
|
|
334
|
+
stage_string = stage.to_s
|
|
335
|
+
|
|
336
|
+
case status
|
|
337
|
+
when "succeeded"
|
|
338
|
+
complete_stage(stage_string)
|
|
339
|
+
when "failed"
|
|
340
|
+
mark_stage_as_failed(stage_string)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def mark_stage_as_failed(stage_string)
|
|
345
|
+
stop_spinner(stage_string, :error)
|
|
346
|
+
@completed_stages.add(stage_string)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def stop_spinner(stage_string, status = :success)
|
|
350
|
+
return unless @spinners[stage_string]
|
|
351
|
+
|
|
352
|
+
# Pass empty string to avoid duplicate stage name (format already includes it)
|
|
353
|
+
@spinners[stage_string].send(status, "")
|
|
354
|
+
@spinners.delete(stage_string)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def display_status_summary(status, stage, error_messages)
|
|
358
|
+
case status
|
|
359
|
+
when "succeeded"
|
|
360
|
+
say "\n Deployment completed successfully!", :success
|
|
361
|
+
when "failed"
|
|
362
|
+
say "\n Deployment failed!", :error
|
|
363
|
+
say " Failed at stage: #{format_stage_name(stage)}", :info
|
|
364
|
+
display_error_details(error_messages) if error_messages && !error_messages.strip.empty?
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def display_error_details(error_messages)
|
|
369
|
+
say "\n Error details:", :error
|
|
370
|
+
error_messages.split("\n").each do |line|
|
|
371
|
+
say " #{line}", :error
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def display_test_results(test_results, skip_opening_divider: false)
|
|
376
|
+
require_relative "test_reporter"
|
|
377
|
+
reporter = Utils::TestReporter.new
|
|
378
|
+
reporter.display(test_results, skip_opening_divider: skip_opening_divider)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def format_stage_name(stage)
|
|
382
|
+
return "Not Started" unless stage
|
|
383
|
+
|
|
384
|
+
stage.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def stop_all_spinners
|
|
388
|
+
@spinners.each do |_stage_string, spinner|
|
|
389
|
+
spinner.stop
|
|
390
|
+
end
|
|
391
|
+
@spinners.clear
|
|
392
|
+
@tests_running = false
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def validate_initialization_params(client, project_id, branch_id, deployment_id)
|
|
396
|
+
raise ArgumentError, "client cannot be nil" if client.nil?
|
|
397
|
+
raise ArgumentError, "project_id cannot be nil or empty" if project_id.nil? || project_id.to_s.strip.empty?
|
|
398
|
+
raise ArgumentError, "branch_id cannot be nil or empty" if branch_id.nil? || branch_id.to_s.strip.empty?
|
|
399
|
+
raise ArgumentError, "deployment_id cannot be nil or empty" if deployment_id.nil? || deployment_id.to_s.strip.empty?
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def deployment_value(deployment, key)
|
|
403
|
+
return nil unless deployment.is_a?(Hash)
|
|
404
|
+
deployment[key] || deployment[key.to_sym]
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def handle_deployment_completion(deployment, status, stage)
|
|
408
|
+
if status == "succeeded" && stage == "finished"
|
|
409
|
+
complete_remaining_stages(stage)
|
|
410
|
+
complete_stage(stage)
|
|
411
|
+
|
|
412
|
+
error_messages = deployment_value(deployment, "error_messages")
|
|
413
|
+
display_status_summary(status, stage, error_messages)
|
|
414
|
+
|
|
415
|
+
latest_test_run = deployment_value(deployment, "latest_test_run")
|
|
416
|
+
|
|
417
|
+
# If tests are expected but not yet available, return nil to continue monitoring
|
|
418
|
+
return nil if latest_test_run.nil? && @has_tests == true
|
|
419
|
+
|
|
420
|
+
display_test_results_if_available(deployment)
|
|
421
|
+
return deployment
|
|
422
|
+
else
|
|
423
|
+
display_final_status(deployment)
|
|
424
|
+
return deployment
|
|
425
|
+
end
|
|
426
|
+
nil
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def display_test_results_if_available(deployment)
|
|
430
|
+
latest_test_run = deployment_value(deployment, "latest_test_run")
|
|
431
|
+
return unless latest_test_run
|
|
432
|
+
|
|
433
|
+
test_results = deployment_value(latest_test_run, "test_results")
|
|
434
|
+
if test_results
|
|
435
|
+
say "\n" + "=" * 60, :border
|
|
436
|
+
complete_stage(TESTING_STAGE)
|
|
437
|
+
display_test_results(test_results, skip_opening_divider: true)
|
|
438
|
+
else
|
|
439
|
+
complete_stage(TESTING_STAGE)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Strata
|
|
6
|
+
module CLI
|
|
7
|
+
module Utils
|
|
8
|
+
module Git
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def validate_commit_hash(commit_hash)
|
|
12
|
+
return false unless commit_hash.is_a?(String)
|
|
13
|
+
return false unless commit_hash.match?(/\A[a-f0-9]{7,40}\z/i)
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def current_branch
|
|
18
|
+
return "main" unless git_repo?
|
|
19
|
+
|
|
20
|
+
run_git_command("rev-parse", "--abbrev-ref", "HEAD")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def commit_info
|
|
24
|
+
sha = run_git_command("rev-parse", "HEAD")
|
|
25
|
+
message = run_git_command("log", "-1", "--pretty=%B")
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
sha: sha,
|
|
29
|
+
message: message || "Manual deployment"
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def committer_info
|
|
34
|
+
name = run_git_command("config", "user.name")
|
|
35
|
+
email = run_git_command("config", "user.email")
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
name: name || "Unknown",
|
|
39
|
+
email: email || "unknown@example.com"
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def file_modifications
|
|
44
|
+
diff_output = run_git_command("diff", "--name-status", "HEAD~1")
|
|
45
|
+
return [] unless diff_output
|
|
46
|
+
|
|
47
|
+
modifications = parse_diff_output(diff_output)
|
|
48
|
+
filter_yml_files(modifications)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def changed_files_since(commit_hash)
|
|
52
|
+
return [] unless git_repo?
|
|
53
|
+
return [] unless commit_hash
|
|
54
|
+
|
|
55
|
+
unless validate_commit_hash(commit_hash)
|
|
56
|
+
raise Strata::CommandError, "Invalid commit hash format: #{commit_hash.inspect}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if commit exists in repo
|
|
60
|
+
_, _, status = Open3.capture3("git", "rev-parse", "--verify", "#{commit_hash}^{commit}")
|
|
61
|
+
return [] unless status.success?
|
|
62
|
+
|
|
63
|
+
# Get all changed files since that commit (including added, modified, deleted, renamed)
|
|
64
|
+
diff_output = run_git_command("diff", "--name-status", commit_hash, "HEAD")
|
|
65
|
+
return [] unless diff_output
|
|
66
|
+
|
|
67
|
+
modifications = parse_diff_output(diff_output)
|
|
68
|
+
filter_yml_files(modifications)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def changed_file_paths_since(commit_hash)
|
|
72
|
+
return [] unless git_repo?
|
|
73
|
+
return [] unless commit_hash
|
|
74
|
+
|
|
75
|
+
unless validate_commit_hash(commit_hash)
|
|
76
|
+
raise Strata::CommandError, "Invalid commit hash format: #{commit_hash.inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Check if commit exists in repo
|
|
80
|
+
_, _, status = Open3.capture3("git", "rev-parse", "--verify", "#{commit_hash}^{commit}")
|
|
81
|
+
return [] unless status.success?
|
|
82
|
+
|
|
83
|
+
# Get file paths that changed (for added and modified files only, exclude deleted)
|
|
84
|
+
# --diff-filter=ACMR: Added, Copied, Modified, Renamed (excludes Deleted)
|
|
85
|
+
diff_output = run_git_command("diff", "--name-status", "--diff-filter=ACMR", commit_hash, "HEAD")
|
|
86
|
+
return [] unless diff_output
|
|
87
|
+
|
|
88
|
+
paths = []
|
|
89
|
+
diff_output.lines.each do |line|
|
|
90
|
+
line = line.strip
|
|
91
|
+
next if line.empty?
|
|
92
|
+
|
|
93
|
+
parts = line.split(/\s+/)
|
|
94
|
+
next if parts.length < 2
|
|
95
|
+
|
|
96
|
+
status = parts[0]
|
|
97
|
+
# For renames (R), parts[1] is old path, parts[2] is new path
|
|
98
|
+
# For others, parts[1] is the path
|
|
99
|
+
if status.start_with?("R")
|
|
100
|
+
# Rename: use the new path
|
|
101
|
+
paths << parts[2] if parts.length >= 3 && parts[2]
|
|
102
|
+
elsif parts[1]
|
|
103
|
+
# Added, Copied, Modified: use the path
|
|
104
|
+
paths << parts[1]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Filter to only yml files and return unique paths
|
|
109
|
+
paths.select { |path| path&.end_with?(".yml", ".yaml") }.uniq
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def git_repo?
|
|
113
|
+
File.directory?(".git")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def git_remote_url(remote_name = "origin")
|
|
117
|
+
return nil unless git_repo?
|
|
118
|
+
|
|
119
|
+
url = run_git_command("remote", "get-url", remote_name)
|
|
120
|
+
return nil if url.nil? || url.strip.empty?
|
|
121
|
+
|
|
122
|
+
url.strip
|
|
123
|
+
rescue
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def run_git_command(*args)
|
|
128
|
+
stdout, _, _ = Open3.capture3("git", *args)
|
|
129
|
+
stdout&.strip
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_diff_output(output)
|
|
133
|
+
output.lines.map do |line|
|
|
134
|
+
parse_diff_status(line.strip)
|
|
135
|
+
end.compact
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_diff_status(line)
|
|
139
|
+
return nil if line.empty?
|
|
140
|
+
|
|
141
|
+
parts = line.split(/\s+/, 2)
|
|
142
|
+
return nil if parts.length < 2
|
|
143
|
+
|
|
144
|
+
status = parts[0]
|
|
145
|
+
path = parts[1]
|
|
146
|
+
|
|
147
|
+
format_file_modification(status, path)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def format_file_modification(status, path)
|
|
151
|
+
case status
|
|
152
|
+
when /^R(\d+)$/
|
|
153
|
+
similarity = Regexp.last_match(1)
|
|
154
|
+
old_path, new_path = path.split(/\s+/, 2)
|
|
155
|
+
["R#{similarity}", old_path, new_path]
|
|
156
|
+
when "A"
|
|
157
|
+
["A", path]
|
|
158
|
+
when "M"
|
|
159
|
+
["M", path]
|
|
160
|
+
when "D"
|
|
161
|
+
["D", path]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def filter_yml_files(modifications)
|
|
166
|
+
modifications.select do |mod|
|
|
167
|
+
path = mod.is_a?(Array) ? (mod[1] || "") : ""
|
|
168
|
+
path.end_with?(".yml", ".yaml")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def uncommitted_changes?
|
|
173
|
+
return false unless git_repo?
|
|
174
|
+
|
|
175
|
+
stdout, _, _ = Open3.capture3("git", "status", "--porcelain")
|
|
176
|
+
!stdout.strip.empty?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def fetch_branch(branch_name)
|
|
180
|
+
return unless git_repo?
|
|
181
|
+
|
|
182
|
+
run_git_command("fetch", "origin", branch_name)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def check_commit_status(branch_name)
|
|
186
|
+
return {status: :ok, message: "Not a git repository"} unless git_repo?
|
|
187
|
+
|
|
188
|
+
remote_branch = "origin/#{branch_name}"
|
|
189
|
+
|
|
190
|
+
# Check if remote branch exists
|
|
191
|
+
remote_sha, _, remote_status = Open3.capture3("git", "rev-parse", remote_branch)
|
|
192
|
+
unless remote_status.success?
|
|
193
|
+
return {status: :ok, message: "Remote branch not found, proceeding with local commit"}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
remote_sha = remote_sha.strip
|
|
197
|
+
local_sha = run_git_command("rev-parse", "HEAD")
|
|
198
|
+
|
|
199
|
+
# If SHAs are the same, branches are in sync
|
|
200
|
+
return {status: :same, message: "Local branch is in sync with remote"} if local_sha == remote_sha
|
|
201
|
+
|
|
202
|
+
# Check if local is ahead, behind, or diverged
|
|
203
|
+
# rev-list --left-right shows: > = commits in local not in remote (ahead), < = commits in remote not in local (behind)
|
|
204
|
+
diff_output, _, _ = Open3.capture3("git", "rev-list", "--left-right", "#{remote_sha}...#{local_sha}")
|
|
205
|
+
|
|
206
|
+
ahead_count = diff_output.lines.count { |line| line.start_with?(">") }
|
|
207
|
+
behind_count = diff_output.lines.count { |line| line.start_with?("<") }
|
|
208
|
+
|
|
209
|
+
if ahead_count > 0 && behind_count == 0
|
|
210
|
+
{status: :ahead, message: "Local branch is #{ahead_count} commit(s) ahead of remote"}
|
|
211
|
+
elsif ahead_count == 0 && behind_count > 0
|
|
212
|
+
{status: :behind, message: "Local branch is #{behind_count} commit(s) behind remote"}
|
|
213
|
+
else
|
|
214
|
+
{status: :diverged, message: "Local branch has diverged from remote (#{ahead_count} ahead, #{behind_count} behind)"}
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def commit_file(file_path, commit_message, project_path = Dir.pwd)
|
|
219
|
+
return false unless git_repo?
|
|
220
|
+
|
|
221
|
+
# Change to project directory for git operations
|
|
222
|
+
Dir.chdir(project_path) do
|
|
223
|
+
# Check if file exists
|
|
224
|
+
return false unless File.exist?(file_path)
|
|
225
|
+
|
|
226
|
+
# Check if file has changes (modified, added, or untracked)
|
|
227
|
+
stdout, _, status = Open3.capture3("git", "status", "--porcelain", file_path)
|
|
228
|
+
return false unless status.success?
|
|
229
|
+
|
|
230
|
+
# If no changes, nothing to commit
|
|
231
|
+
return false if stdout.strip.empty?
|
|
232
|
+
|
|
233
|
+
# Stage the file (handles both modified and untracked files)
|
|
234
|
+
_, _, add_status = Open3.capture3("git", "add", file_path)
|
|
235
|
+
return false unless add_status.success?
|
|
236
|
+
|
|
237
|
+
# Check if there are actually staged changes to commit
|
|
238
|
+
# git diff --cached --quiet returns 0 if no changes, 1 if changes exist
|
|
239
|
+
_, _, diff_status = Open3.capture3("git", "diff", "--cached", "--quiet", file_path)
|
|
240
|
+
# If success? is true (exitstatus == 0), there are no changes staged
|
|
241
|
+
return false if diff_status.success?
|
|
242
|
+
|
|
243
|
+
# Commit the file
|
|
244
|
+
_, _, commit_status = Open3.capture3("git", "commit", "-m", commit_message)
|
|
245
|
+
commit_status.success?
|
|
246
|
+
end
|
|
247
|
+
rescue
|
|
248
|
+
false
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|