brainiac 0.0.27 → 0.0.29

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,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Belt application detection and environment management utilities.
4
+ #
5
+ # Belt is a Ruby framework for building serverless applications on AWS Lambda.
6
+ # Projects using Belt have infrastructure defined in `config/routes.rb` or the
7
+ # legacy `infrastructure/routes.tf.rb` path.
8
+ #
9
+ # This module provides detection and ephemeral environment helpers that plugins
10
+ # (brainiac-fizzy, brainiac-github, brainiac-basecamp) can use.
11
+
12
+ # Module containing Belt-related helpers accessible from Sinatra routes.
13
+ module BeltHelpers
14
+ # Detect if a directory is a Belt application by checking for routes file.
15
+ #
16
+ # Belt apps are detected by the presence of any of these files:
17
+ # - config/routes.rb (current)
18
+ # - config/routes.tf.rb (legacy)
19
+ # - infrastructure/routes.tf.rb (legacy)
20
+ #
21
+ # @param path [String] Path to check (repo root or worktree)
22
+ # @return [Boolean] True if the path contains a Belt application
23
+ def belt_app?(path)
24
+ return false unless path && File.directory?(path)
25
+
26
+ candidates = [
27
+ File.join(path, "config/routes.rb"),
28
+ File.join(path, "config/routes.tf.rb"),
29
+ File.join(path, "infrastructure/routes.tf.rb")
30
+ ]
31
+ candidates.any? { |f| File.exist?(f) }
32
+ end
33
+
34
+ # Check if a Belt app routes file exists and contains actual route definitions.
35
+ # This is a stricter check than belt_app? — it verifies the file has Belt routes,
36
+ # not just a Rails routes.rb file (which wouldn't have Belt-style route definitions).
37
+ #
38
+ # @param path [String] Path to check
39
+ # @return [Boolean] True if the path contains a Belt routes file with Belt syntax
40
+ def belt_routes_file?(path)
41
+ routes_file = belt_routes_path(path)
42
+ return false unless routes_file && File.exist?(routes_file)
43
+
44
+ # Check for Belt-specific syntax (e.g., `app.get`, `api`, `resources`, etc.)
45
+ content = File.read(routes_file, 4096) # Read first 4KB
46
+ content.match?(/\bapp\.(get|post|put|patch|delete)\b/) ||
47
+ content.match?(/\bapi\s+do\b/) ||
48
+ content.match?(/\bresources?\s+:/) ||
49
+ content.match?(/\bBelt\.routes\b/)
50
+ rescue StandardError
51
+ false
52
+ end
53
+
54
+ # Get the path to the Belt routes file (checking all possible locations).
55
+ #
56
+ # @param path [String] Path to check
57
+ # @return [String, nil] Path to routes file or nil
58
+ def belt_routes_path(path)
59
+ return nil unless path && File.directory?(path)
60
+
61
+ candidates = [
62
+ File.join(path, "config/routes.rb"),
63
+ File.join(path, "config/routes.tf.rb"),
64
+ File.join(path, "infrastructure/routes.tf.rb")
65
+ ]
66
+ candidates.find { |f| File.exist?(f) }
67
+ end
68
+ end
69
+
70
+ # Configuration for Belt ephemeral environments.
71
+ # Stored in ~/.brainiac/basecamp.json under the "deploy" key.
72
+ module BeltConfig
73
+ BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
74
+ BASECAMP_CONFIG_FILE = File.join(BRAINIAC_DIR, "basecamp.json")
75
+
76
+ class << self
77
+ # Load the basecamp config, caching for performance.
78
+ # @return [Hash]
79
+ def load_config
80
+ return {} unless File.exist?(BASECAMP_CONFIG_FILE)
81
+
82
+ JSON.parse(File.read(BASECAMP_CONFIG_FILE))
83
+ rescue JSON::ParserError => e
84
+ LOG.error "[Belt] Failed to parse basecamp.json: #{e.message}" if defined?(LOG)
85
+ {}
86
+ end
87
+
88
+ # Get the parent environment for a project (used as base for ephemeral envs).
89
+ # This is where `belt g environment <name> <parent>` copies settings from.
90
+ #
91
+ # @param project_key [String] Brainiac project key
92
+ # @return [String, nil] Parent environment name (e.g., "dev02", "dev") or nil
93
+ def parent_env_for(project_key)
94
+ config = load_config
95
+ config.dig("deploy", "project_envs", project_key)
96
+ end
97
+
98
+ # Check if ephemeral deploys are enabled globally.
99
+ # @return [Boolean]
100
+ def ephemeral_deploys_enabled?
101
+ config = load_config
102
+ # Default to true if deploy section exists but enabled isn't specified
103
+ deploy_config = config["deploy"] || {}
104
+ deploy_config.fetch("ephemeral_enabled", true)
105
+ end
106
+
107
+ # Track an ephemeral environment in deployment state.
108
+ # @param env_name [String] Environment name (e.g., "fizzy-123")
109
+ # @param metadata [Hash] Metadata about the ephemeral env
110
+ def track_ephemeral_env(env_name, metadata = {})
111
+ state_file = File.join(BRAINIAC_DIR, "ephemeral_envs.json")
112
+ state = File.exist?(state_file) ? JSON.parse(File.read(state_file)) : {}
113
+
114
+ state[env_name] = {
115
+ "created_at" => Time.now.iso8601,
116
+ "status" => "active"
117
+ }.merge(metadata)
118
+
119
+ File.write(state_file, JSON.pretty_generate(state))
120
+ rescue StandardError => e
121
+ LOG.error "[Belt] Failed to track ephemeral env: #{e.message}" if defined?(LOG)
122
+ end
123
+
124
+ # Check if an environment is ephemeral.
125
+ # @param env_name [String] Environment name
126
+ # @return [Boolean]
127
+ def ephemeral_env?(env_name)
128
+ state_file = File.join(BRAINIAC_DIR, "ephemeral_envs.json")
129
+ return false unless File.exist?(state_file)
130
+
131
+ state = JSON.parse(File.read(state_file))
132
+ state.key?(env_name) && state[env_name]["status"] == "active"
133
+ rescue StandardError
134
+ false
135
+ end
136
+
137
+ # Get ephemeral environment metadata.
138
+ # @param env_name [String] Environment name
139
+ # @return [Hash, nil]
140
+ def ephemeral_env_info(env_name)
141
+ state_file = File.join(BRAINIAC_DIR, "ephemeral_envs.json")
142
+ return nil unless File.exist?(state_file)
143
+
144
+ state = JSON.parse(File.read(state_file))
145
+ state[env_name]
146
+ rescue StandardError
147
+ nil
148
+ end
149
+
150
+ # Mark an ephemeral environment as destroyed.
151
+ # @param env_name [String] Environment name
152
+ def mark_ephemeral_destroyed(env_name)
153
+ state_file = File.join(BRAINIAC_DIR, "ephemeral_envs.json")
154
+ return unless File.exist?(state_file)
155
+
156
+ state = JSON.parse(File.read(state_file))
157
+ return unless state[env_name]
158
+
159
+ state[env_name]["status"] = "destroyed"
160
+ state[env_name]["destroyed_at"] = Time.now.iso8601
161
+
162
+ File.write(state_file, JSON.pretty_generate(state))
163
+ rescue StandardError => e
164
+ LOG.error "[Belt] Failed to mark ephemeral env destroyed: #{e.message}" if defined?(LOG)
165
+ end
166
+
167
+ # Find ephemeral environment by card number.
168
+ # @param card_number [Integer, String] Fizzy card number
169
+ # @return [String, nil] Environment name or nil
170
+ def ephemeral_env_for_card(card_number)
171
+ "fizzy-#{card_number}"
172
+ end
173
+
174
+ # Find ephemeral environment by epic number.
175
+ # @param epic_number [Integer, String] Epic number
176
+ # @return [String, nil] Environment name or nil
177
+ def ephemeral_env_for_epic(epic_number)
178
+ "epic-#{epic_number}"
179
+ end
180
+ end
181
+ end
182
+
183
+ # Belt environment operations (create, deploy, destroy).
184
+ # These wrap the `belt` CLI commands for ephemeral environment management.
185
+ module BeltEnvironment
186
+ class << self
187
+ include BeltHelpers
188
+
189
+ # Check whether an environment is configured in a worktree.
190
+ # `belt g environment` writes infrastructure/<env_name>/; that directory
191
+ # is the source of truth, not the ephemeral_envs.json tracking file.
192
+ #
193
+ # @param worktree [String] Path to the worktree
194
+ # @param env_name [String] Environment name (e.g. "fizzy-1299")
195
+ # @return [Boolean]
196
+ def environment_configured?(worktree:, env_name:)
197
+ return false unless worktree && env_name && File.directory?(worktree)
198
+
199
+ File.directory?(File.join(worktree, "infrastructure", env_name.to_s))
200
+ end
201
+
202
+ # Create an ephemeral environment from a parent environment.
203
+ #
204
+ # @param worktree [String] Path to the worktree
205
+ # @param env_name [String] Name for the ephemeral environment
206
+ # @param parent_env [String] Parent environment to copy from
207
+ # @return [Boolean] True on success
208
+ def create_environment(worktree:, env_name:, parent_env:)
209
+ return false unless belt_app?(worktree)
210
+
211
+ LOG.info "[Belt] Creating ephemeral environment '#{env_name}' from parent '#{parent_env}'"
212
+
213
+ _, stderr, status = Open3.capture3("belt", "g", "environment", env_name, parent_env, chdir: worktree)
214
+
215
+ if status.success?
216
+ LOG.info "[Belt] Successfully created environment '#{env_name}'"
217
+ true
218
+ else
219
+ LOG.error "[Belt] Failed to create environment '#{env_name}': #{stderr.strip}"
220
+ false
221
+ end
222
+ rescue StandardError => e
223
+ LOG.error "[Belt] Error creating environment: #{e.message}"
224
+ false
225
+ end
226
+
227
+ # Deploy to an environment.
228
+ #
229
+ # Always non-interactive: `belt deploy` prompts "Apply these changes? [y/N]"
230
+ # unless `--auto` is passed. Open3.capture3 provides empty stdin, so without
231
+ # `--auto` belt prints "Cancelled." and still exits 0 — a silent no-op.
232
+ # Frontend-only uses `belt deploy frontend <env>` (subcommand first).
233
+ #
234
+ # @param worktree [String] Path to the worktree
235
+ # @param env_name [String] Environment name
236
+ # @param frontend_only [Boolean] If true, only deploy frontend
237
+ # @return [Boolean] True on success
238
+ def deploy(worktree:, env_name:, frontend_only: false, capture3: nil)
239
+ return false unless belt_app?(worktree)
240
+
241
+ cmd = deploy_command(env_name, frontend_only: frontend_only)
242
+
243
+ LOG.info "[Belt] Deploying to '#{env_name}'#{" (frontend only)" if frontend_only}"
244
+ LOG.info "[Belt] Running: #{cmd.join(" ")} (in #{worktree})"
245
+
246
+ runner = capture3 || Open3.method(:capture3)
247
+ stdout, stderr, status = runner.call(*cmd, chdir: worktree)
248
+ log_cli_tail(stdout, stderr)
249
+
250
+ if deploy_cancelled?(stdout, stderr)
251
+ LOG.error "[Belt] Deploy to '#{env_name}' cancelled — non-interactive belt deploy needs --auto"
252
+ return false
253
+ end
254
+
255
+ if status.success?
256
+ LOG.info "[Belt] Successfully deployed to '#{env_name}'"
257
+ true
258
+ else
259
+ LOG.error "[Belt] Failed to deploy to '#{env_name}': #{stderr.strip}"
260
+ false
261
+ end
262
+ rescue StandardError => e
263
+ LOG.error "[Belt] Error deploying: #{e.message}"
264
+ false
265
+ end
266
+
267
+ # argv for `belt deploy`. Extracted so tests can assert the command without
268
+ # stubbing Open3 for every call site.
269
+ def deploy_command(env_name, frontend_only: false)
270
+ if frontend_only
271
+ ["belt", "deploy", "frontend", env_name]
272
+ else
273
+ ["belt", "deploy", env_name, "--auto"]
274
+ end
275
+ end
276
+
277
+ # Destroy an ephemeral environment.
278
+ #
279
+ # @param worktree [String] Path to the worktree (infrastructure lives here)
280
+ # @param env_name [String] Environment name
281
+ # @return [Boolean] True on success
282
+ def destroy_environment(worktree:, env_name:)
283
+ return false unless belt_app?(worktree)
284
+
285
+ LOG.info "[Belt] Destroying ephemeral environment '#{env_name}'"
286
+
287
+ _, stderr, status = Open3.capture3("belt", "destroy", "environment", env_name, "--full", chdir: worktree)
288
+
289
+ if status.success?
290
+ LOG.info "[Belt] Successfully destroyed environment '#{env_name}'"
291
+ BeltConfig.mark_ephemeral_destroyed(env_name)
292
+ true
293
+ else
294
+ LOG.error "[Belt] Failed to destroy environment '#{env_name}': #{stderr.strip}"
295
+ false
296
+ end
297
+ rescue StandardError => e
298
+ LOG.error "[Belt] Error destroying environment: #{e.message}"
299
+ false
300
+ end
301
+
302
+ # Check if changes are frontend-only by examining the diff.
303
+ # Frontend-only changes can be deployed faster with `belt deploy frontend <env>`.
304
+ #
305
+ # @param worktree [String] Path to the worktree
306
+ # @param base_branch [String, nil] Optional base (e.g. "master", "origin/main").
307
+ # When omitted, uses origin/HEAD, then origin/main, then origin/master.
308
+ # @return [Boolean] True if changes are frontend-only
309
+ def frontend_only_changes?(worktree:, base_branch: nil)
310
+ base_ref = resolve_frontend_diff_base(worktree, base_branch)
311
+ unless base_ref
312
+ LOG.warn "[Belt] No base ref for frontend-only check in #{worktree}"
313
+ return false
314
+ end
315
+
316
+ stdout, stderr, status = Open3.capture3("git", "diff", "--name-only", base_ref, "--", chdir: worktree)
317
+ unless status.success?
318
+ LOG.warn "[Belt] Could not diff against #{base_ref}: #{stderr.strip}"
319
+ return false
320
+ end
321
+
322
+ changed_files = stdout.strip.split("\n")
323
+ return false if changed_files.empty?
324
+
325
+ # Frontend directories that don't affect backend
326
+ frontend_patterns = %w[
327
+ frontend/
328
+ app/javascript/
329
+ app/assets/
330
+ public/
331
+ static/
332
+ src/
333
+ ]
334
+
335
+ # Backend patterns that require full deploy
336
+ backend_patterns = %w[
337
+ lambda/
338
+ infrastructure/
339
+ config/routes
340
+ config/contracts
341
+ Gemfile
342
+ *.gemspec
343
+ Rakefile
344
+ ]
345
+
346
+ # Check if all changes are frontend-only
347
+ changed_files.all? do |file|
348
+ frontend_patterns.any? { |pattern| file.start_with?(pattern) } &&
349
+ backend_patterns.none? { |pattern| file.start_with?(pattern.delete("*")) || File.fnmatch?(pattern, file) }
350
+ end
351
+ rescue StandardError => e
352
+ LOG.warn "[Belt] Error checking frontend-only changes: #{e.message}"
353
+ false
354
+ end
355
+
356
+ # Resolve a git ref to diff against. Never assumes origin/main.
357
+ # Prefer an explicit PR/base branch, then origin/HEAD, then main/master.
358
+ def resolve_frontend_diff_base(worktree, explicit)
359
+ candidates = []
360
+ if explicit && !explicit.to_s.strip.empty?
361
+ ref = explicit.to_s.strip
362
+ ref = "origin/#{ref}" unless ref.include?("/")
363
+ candidates << ref
364
+ end
365
+
366
+ head = origin_head_branch(worktree)
367
+ candidates << "origin/#{head}" if head
368
+ candidates.push("origin/main", "origin/master")
369
+ candidates.uniq.find { |ref| git_commit?(worktree, ref) }
370
+ end
371
+
372
+ def origin_head_branch(worktree)
373
+ stdout, _stderr, status = Open3.capture3(
374
+ "git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD",
375
+ chdir: worktree
376
+ )
377
+ return nil unless status.success?
378
+
379
+ name = stdout.strip.delete_prefix("origin/")
380
+ name.empty? ? nil : name
381
+ rescue StandardError
382
+ nil
383
+ end
384
+
385
+ def git_commit?(worktree, ref)
386
+ _stdout, _stderr, status = Open3.capture3(
387
+ "git", "rev-parse", "--verify", "#{ref}^{commit}",
388
+ chdir: worktree
389
+ )
390
+ status.success?
391
+ rescue StandardError
392
+ false
393
+ end
394
+
395
+ def deploy_cancelled?(stdout, stderr)
396
+ [stdout, stderr].any? { |s| s.to_s.match?(/\bCancelled\.?\s*$/) }
397
+ end
398
+
399
+ def log_cli_tail(stdout, stderr, limit: 25)
400
+ lines = []
401
+ lines.concat(stdout.to_s.lines) unless stdout.to_s.strip.empty?
402
+ lines.concat(stderr.to_s.lines.map { |l| "stderr: #{l}" }) unless stderr.to_s.strip.empty?
403
+ return if lines.empty?
404
+
405
+ tail = lines.last(limit).join
406
+ LOG.info "[Belt] Output (last #{[lines.size, limit].min} lines):\n#{tail}"
407
+ end
408
+ end
409
+ end
@@ -172,37 +172,12 @@ def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path:
172
172
  base_ref ||= "origin/#{get_default_branch(repo_path)}"
173
173
 
174
174
  worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
175
-
176
- if File.directory?(worktree_path)
177
- is_tracked = worktree_list.include?(worktree_path)
178
-
179
- if is_tracked
180
- LOG.info "Worktree directory #{worktree_path} is tracked by git"
181
- else
182
- LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
183
- begin
184
- FileUtils.rm_rf(worktree_path)
185
- LOG.info "Successfully removed orphaned directory"
186
- rescue StandardError => e
187
- LOG.error "Failed to remove orphaned directory: #{e.message}"
188
- raise
189
- end
190
- end
191
- end
175
+ cleanup_orphaned_worktree(worktree_path, worktree_list) if File.directory?(worktree_path)
192
176
 
193
177
  branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
194
178
 
195
179
  if branch_exists
196
- LOG.info "Branch #{branch} already exists, checking for existing worktree"
197
- worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
198
- has_worktree = worktree_list.lines.any? { |line| line.strip == "worktree #{worktree_path}" }
199
-
200
- if has_worktree && File.directory?(worktree_path)
201
- LOG.info "Reusing existing worktree at #{worktree_path}"
202
- else
203
- LOG.info "Creating worktree from existing branch #{branch}"
204
- run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
205
- end
180
+ attach_or_create_worktree_for_branch(repo_path, branch, worktree_path)
206
181
  else
207
182
  LOG.info "Creating new branch #{branch} and worktree"
208
183
  run_cmd("git", "worktree", "add", "-b", branch, worktree_path, base_ref, chdir: repo_path)
@@ -215,6 +190,56 @@ def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path:
215
190
  worktree_path
216
191
  end
217
192
 
193
+ # Remove an orphaned worktree directory (exists on disk but not tracked by git).
194
+ # If the directory is tracked, logs and leaves it alone.
195
+ def cleanup_orphaned_worktree(worktree_path, worktree_list)
196
+ resolved_path = begin
197
+ File.realpath(worktree_path)
198
+ rescue StandardError
199
+ worktree_path
200
+ end
201
+ is_tracked = worktree_list.include?(worktree_path) || worktree_list.include?(resolved_path)
202
+
203
+ if is_tracked
204
+ LOG.info "Worktree directory #{worktree_path} is tracked by git"
205
+ else
206
+ LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
207
+ begin
208
+ FileUtils.rm_rf(worktree_path)
209
+ LOG.info "Successfully removed orphaned directory"
210
+ rescue StandardError => e
211
+ LOG.error "Failed to remove orphaned directory: #{e.message}"
212
+ raise
213
+ end
214
+ end
215
+ end
216
+
217
+ # Attach an existing branch to a worktree, reusing it if already present.
218
+ def attach_or_create_worktree_for_branch(repo_path, branch, worktree_path)
219
+ LOG.info "Branch #{branch} already exists, checking for existing worktree"
220
+ worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
221
+ # Resolve symlinks for comparison (macOS /var/folders vs /private/var/folders)
222
+ resolved_wt = begin
223
+ File.realpath(worktree_path)
224
+ rescue StandardError
225
+ worktree_path
226
+ end
227
+ has_worktree = worktree_list.lines.any? do |line|
228
+ stripped = line.strip
229
+ next false unless stripped.start_with?("worktree ")
230
+
231
+ listed_path = stripped.sub("worktree ", "")
232
+ listed_path == worktree_path || listed_path == resolved_wt
233
+ end
234
+
235
+ if has_worktree && File.directory?(worktree_path)
236
+ LOG.info "Reusing existing worktree at #{worktree_path}"
237
+ else
238
+ LOG.info "Creating worktree from existing branch #{branch}"
239
+ run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
240
+ end
241
+ end
242
+
218
243
  # Find an existing worktree for a card by scanning the filesystem.
219
244
  def find_worktree_for_card(card_number, repo_path:)
220
245
  return nil unless card_number