brainiac-fizzy 0.0.24 → 0.0.26

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5458df6821434350e3814c4002b36bf7c24449fec0375304004d96f34381c413
4
- data.tar.gz: 6fe4ea5f51574cca99f6e90cada9480d6ff7a2c69008837f1e98259e1249abeb
3
+ metadata.gz: 7b9d6e5b3e6e6ea9306dc6fd9ce800dffc01e46f99b05f2861e0acf096793fe5
4
+ data.tar.gz: ec15a0b6f7a498de7662b8bd662fb519ae45f8a14d66e5912cf6dd540cc3726b
5
5
  SHA512:
6
- metadata.gz: 6c9657c7470c20a0d288917a92d15d10823dd4ec3d113c7acf48f1b1263291106947ff0e8ecdbc675c49db1f928affc1050e9405820d11cd00e21aae329e4747
7
- data.tar.gz: 0c03004274a798059eaa5f46025fba1e69e4b084ee99acf4a3185802a0eb6af58fed9ee23a87b7de74635c3080426a9a2aa226ae48646d9247b4afcdb8d3bec2
6
+ metadata.gz: 51284c7c4cca09f1bde28d3589fc7c17b29314e898f4de8bd7345fa1fd930baf6501ef47e42a241b40f17baede635780676154e7c6f456a70ea6f1d9902da59b
7
+ data.tar.gz: 868fc4f7e922d67138125385c486f87bc221766ddadf45e640ad880ee57914f04a772bdd3b688087c505a66cfa59f7643e1134f5731eb0ef67f3e42b6f80a700
@@ -658,8 +658,73 @@ module Brainiac
658
658
  end
659
659
 
660
660
  # Subcommand names for bash completion.
661
- def self.completions
662
- %w[config status setup]
661
+ # When called with no args, returns top-level subcommands.
662
+ # When called with args (the words typed so far after the plugin name),
663
+ # returns context-sensitive completions for nested subcommands.
664
+ def self.completions(args = [])
665
+ return %w[board config setup status] if args.empty?
666
+
667
+ case args[0]
668
+ when "board"
669
+ board_completions(args[1..])
670
+ else
671
+ []
672
+ end
673
+ end
674
+
675
+ # Nested completions for `brainiac fizzy board ...`
676
+ def self.board_completions(args)
677
+ board_subcommands = %w[assign columns list ls setup webhook]
678
+ return board_subcommands if args.empty?
679
+
680
+ case args[0]
681
+ when "assign"
682
+ board_assign_completions(args[1..])
683
+ when "columns", "webhook"
684
+ board_key_completions(args[1..])
685
+ else
686
+ []
687
+ end
688
+ end
689
+
690
+ # Completions for `brainiac fizzy board assign <project> <board>`
691
+ def self.board_assign_completions(args)
692
+ case args.length
693
+ when 0
694
+ load_project_keys
695
+ when 1
696
+ load_board_keys
697
+ else
698
+ []
699
+ end
700
+ end
701
+
702
+ # Completions for commands that take a board key as next arg
703
+ def self.board_key_completions(args)
704
+ return load_board_keys if args.empty?
705
+
706
+ []
707
+ end
708
+
709
+ # Load board keys from fizzy.json for completion
710
+ def self.load_board_keys
711
+ config_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "fizzy.json")
712
+ return [] unless File.exist?(config_file)
713
+
714
+ config = JSON.parse(File.read(config_file))
715
+ (config["boards"] || {}).keys
716
+ rescue StandardError
717
+ []
718
+ end
719
+
720
+ # Load project keys from projects.json for completion
721
+ def self.load_project_keys
722
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
723
+ return [] unless File.exist?(projects_file)
724
+
725
+ JSON.parse(File.read(projects_file)).keys
726
+ rescue StandardError
727
+ []
663
728
  end
664
729
 
665
730
  # Called by brainiac CLI after `agent create` — prompts for Fizzy user ID.
@@ -25,6 +25,22 @@ def prefetch_card_context(card_number, repo_path:, agent_name: nil)
25
25
  Brainiac::Plugins::Fizzy::Helpers.prefetch_card_context(card_number, repo_path: repo_path, agent_name: agent_name)
26
26
  end
27
27
 
28
+ def tag_names(tags)
29
+ Brainiac::Plugins::Fizzy::Helpers.tag_names(tags)
30
+ end
31
+
32
+ def card_has_tag?(tags, name)
33
+ Brainiac::Plugins::Fizzy::Helpers.card_has_tag?(tags, name)
34
+ end
35
+
36
+ def fetch_card_tags(card_number, repo_path:, env: nil)
37
+ Brainiac::Plugins::Fizzy::Helpers.fetch_card_tags(card_number, repo_path: repo_path, env: env)
38
+ end
39
+
40
+ def resolve_github_agent_env(agent_name, github_repo)
41
+ Brainiac::Plugins::Fizzy::Helpers.resolve_github_agent_env(agent_name, github_repo)
42
+ end
43
+
28
44
  def fetch_intent_context(card_number, repo_path:, agent_name: nil)
29
45
  Brainiac::Plugins::Fizzy::Helpers.fetch_intent_context(card_number, repo_path: repo_path, agent_name: agent_name)
30
46
  end
@@ -58,6 +58,10 @@ def handle_card_assigned(payload, board_key: nil)
58
58
  inline_effort: initial_effort
59
59
  )
60
60
 
61
+ maybe_create_ephemeral_belt_env(
62
+ worktree_path: worktree_path, card_number: card_number, project_key: project_key, tags: tags
63
+ )
64
+
61
65
  dispatch_assigned_card(
62
66
  card_number: card_number, card_internal_id: card_internal_id, title: title, tags: tags,
63
67
  branch: branch, worktree_path: worktree_path, project_config: project_config, project_key: project_key,
@@ -142,9 +146,7 @@ def dispatch_assigned_card(card_number:, card_internal_id:, title:, tags:, branc
142
146
  }
143
147
 
144
148
  # Resolve custom PR target (for epic branch workflows)
145
- pr_target = if defined?(resolve_pr_target)
146
- resolve_pr_target(repo_path: project_config["repo_path"], card_number: card_number, project_key: project_key)
147
- end
149
+ pr_target = resolve_pr_target(repo_path: project_config["repo_path"], card_number: card_number, project_key: project_key)
148
150
  template_vars["PR_TARGET_INSTRUCTION"] = if pr_target
149
151
  "**IMPORTANT: Open the PR targeting the `#{pr_target}` branch " \
150
152
  "(not main).** Use: `gh pr create --base #{pr_target}`"
@@ -168,15 +170,153 @@ def dispatch_assigned_card(card_number:, card_internal_id:, title:, tags:, branc
168
170
  end
169
171
 
170
172
  card_key = "card-#{card_number}"
173
+
174
+ # Inject GitHub App token so agent's gh commands run as their bot identity
175
+ github_repo = project_config["github_repo"]
176
+ agent_github_env = resolve_github_agent_env(agent_name, github_repo)
177
+
171
178
  pid, log_file = run_agent(prompt,
172
179
  project_config: project_config, chdir: worktree_path,
173
180
  log_name: "assigned-#{card_number}", model: model, effort: effort,
174
181
  agent_name: agent_name, card_number: card_number, source: :fizzy,
175
182
  source_context: { card_number: card_number, board_key: board_key, dispatched_at: Time.now },
176
- cli_provider: cli_provider_override)
183
+ cli_provider: cli_provider_override, env: agent_github_env)
177
184
  register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
178
185
 
179
186
  Thread.new { move_card_to_column(card_number, "right_now", project_config: project_config, agent_name: agent_name, board_key: board_key) }
180
187
 
181
188
  [200, { status: "processed", card: card_number, branch: branch, project: project_key, agent: agent_name }.to_json]
182
189
  end
190
+
191
+ # Ensure an ephemeral Belt environment is configured in the worktree when the
192
+ # card has a `deploy` tag. Fizzy has no tag-added webhook, so comment handlers
193
+ # pass fetch_live_tags: true to read current tags from `fizzy card show`.
194
+ #
195
+ # `belt g environment` is synchronous so the worktree is configured before the
196
+ # agent starts. The actual `belt deploy` runs in the background.
197
+ def maybe_create_ephemeral_belt_env(worktree_path:, card_number:, project_key:, tags: nil,
198
+ repo_path: nil, agent_name: nil, fetch_live_tags: false)
199
+ unless defined?(BeltConfig) && defined?(BeltEnvironment)
200
+ LOG.debug "[EphemeralEnv] Belt utilities not available — skipping"
201
+ return
202
+ end
203
+
204
+ unless worktree_path && File.directory?(worktree_path)
205
+ LOG.debug "[EphemeralEnv] Worktree missing at #{worktree_path.inspect} — skipping"
206
+ return
207
+ end
208
+
209
+ unless belt_app_for_ephemeral?(worktree_path)
210
+ LOG.debug "[EphemeralEnv] #{project_key} is not a Belt app — skipping"
211
+ return
212
+ end
213
+
214
+ unless BeltConfig.ephemeral_deploys_enabled?
215
+ LOG.info "[EphemeralEnv] Ephemeral deploys disabled in basecamp.json"
216
+ return
217
+ end
218
+
219
+ resolved_tags = resolve_ephemeral_env_tags(
220
+ tags, card_number: card_number, repo_path: repo_path || worktree_path,
221
+ agent_name: agent_name, fetch_live_tags: fetch_live_tags
222
+ )
223
+ tag_list = tag_names(resolved_tags)
224
+ unless tag_list.include?("deploy")
225
+ LOG.debug "[EphemeralEnv] Card ##{card_number} tags=#{tag_list.inspect} — no deploy tag, skipping"
226
+ return
227
+ end
228
+
229
+ parent_env = BeltConfig.parent_env_for(project_key)
230
+ unless parent_env
231
+ LOG.info "[EphemeralEnv] Card ##{card_number} has deploy tag but no parent env configured for #{project_key}"
232
+ return
233
+ end
234
+
235
+ env_name = BeltConfig.ephemeral_env_for_card(card_number)
236
+ env_dir = File.join(worktree_path, "infrastructure", env_name.to_s)
237
+
238
+ if File.directory?(env_dir)
239
+ LOG.info "[EphemeralEnv] Card ##{card_number} has deploy tag; #{env_name} already configured at #{env_dir}"
240
+ track_ephemeral_env_if_needed(env_name, card_number, project_key, parent_env, worktree_path)
241
+ return
242
+ end
243
+
244
+ parent_dir = File.join(worktree_path, "infrastructure", parent_env.to_s)
245
+ unless File.directory?(parent_dir)
246
+ LOG.error "[EphemeralEnv] Parent env '#{parent_env}' not found at #{parent_dir}"
247
+ return
248
+ end
249
+
250
+ LOG.info "[EphemeralEnv] Card ##{card_number} has deploy tag; #{env_name} not in worktree — creating from '#{parent_env}'"
251
+ create_and_deploy_ephemeral_env(
252
+ worktree_path: worktree_path, env_name: env_name, parent_env: parent_env,
253
+ card_number: card_number, project_key: project_key
254
+ )
255
+ rescue StandardError => e
256
+ LOG.error "[EphemeralEnv] Error creating ephemeral env: #{e.message}"
257
+ end
258
+
259
+ def ensure_ephemeral_env_for_comment(ctx, card_number, worktree)
260
+ return unless card_number && worktree && File.directory?(worktree)
261
+
262
+ maybe_create_ephemeral_belt_env(
263
+ worktree_path: worktree, card_number: card_number, project_key: ctx.project_key,
264
+ tags: ctx.card_tags, repo_path: ctx.project_config["repo_path"],
265
+ agent_name: ctx.agent_name, fetch_live_tags: true
266
+ )
267
+ end
268
+
269
+ def belt_app_for_ephemeral?(worktree_path)
270
+ return BeltEnvironment.belt_app?(worktree_path) if defined?(BeltEnvironment) && BeltEnvironment.respond_to?(:belt_app?)
271
+
272
+ %w[config/routes.rb config/routes.tf.rb infrastructure/routes.tf.rb].any? do |rel|
273
+ File.exist?(File.join(worktree_path, rel))
274
+ end
275
+ end
276
+
277
+ def resolve_ephemeral_env_tags(tags, card_number:, repo_path:, agent_name:, fetch_live_tags:)
278
+ return tags unless fetch_live_tags && card_number
279
+
280
+ live = fetch_card_tags(card_number, repo_path: repo_path, env: fizzy_env_for(agent_name || AI_AGENT_NAME))
281
+ if live
282
+ LOG.info "[EphemeralEnv] Card ##{card_number} live tags: #{tag_names(live).inspect}"
283
+ live
284
+ else
285
+ LOG.warn "[EphemeralEnv] Could not fetch live tags for card ##{card_number} — falling back to webhook tags #{tag_names(tags).inspect}"
286
+ tags
287
+ end
288
+ end
289
+
290
+ def track_ephemeral_env_if_needed(env_name, card_number, project_key, parent_env, worktree_path)
291
+ return if BeltConfig.ephemeral_env?(env_name)
292
+
293
+ BeltConfig.track_ephemeral_env(env_name,
294
+ "card_number" => card_number,
295
+ "project" => project_key,
296
+ "parent_env" => parent_env,
297
+ "worktree" => worktree_path)
298
+ end
299
+
300
+ def create_and_deploy_ephemeral_env(worktree_path:, env_name:, parent_env:, card_number:, project_key:)
301
+ success = BeltEnvironment.create_environment(
302
+ worktree: worktree_path, env_name: env_name, parent_env: parent_env
303
+ )
304
+ unless success
305
+ LOG.error "[EphemeralEnv] Failed to create environment '#{env_name}'"
306
+ return
307
+ end
308
+
309
+ BeltConfig.track_ephemeral_env(env_name,
310
+ "card_number" => card_number,
311
+ "project" => project_key,
312
+ "parent_env" => parent_env,
313
+ "worktree" => worktree_path)
314
+ LOG.info "[EphemeralEnv] Configured #{env_name} in worktree, deploying in background"
315
+
316
+ Thread.new do
317
+ frontend_only = BeltEnvironment.frontend_only_changes?(worktree: worktree_path)
318
+ BeltEnvironment.deploy(worktree: worktree_path, env_name: env_name, frontend_only: frontend_only)
319
+ rescue StandardError => e
320
+ LOG.error "[EphemeralEnv] Error deploying '#{env_name}': #{e.message}"
321
+ end
322
+ end
@@ -453,6 +453,10 @@ def handle_existing_card_comment(ctx)
453
453
  work_dir = worktree && File.directory?(worktree) ? worktree : ctx.project_config["repo_path"]
454
454
  card_key = "card-#{card_number || ctx.card_internal_id}"
455
455
 
456
+ # Fizzy has no tag-added webhook. On comment, fetch live tags and configure
457
+ # the Belt env in this worktree before dispatching (or queueing) the agent.
458
+ ensure_ephemeral_env_for_comment(ctx, card_number, worktree)
459
+
456
460
  # Session management (wait, supersede, or queue)
457
461
  queued = handle_session_conflict(ctx, card_key, card_number, work_dir)
458
462
  return queued if queued
@@ -491,6 +495,8 @@ def handle_new_mention(ctx)
491
495
  react_to_comment(card_number, ctx.comment_id, ctx.project_config, ctx.agent_name, "👀")
492
496
 
493
497
  worktree_path, branch = setup_new_mention_worktree(ctx, card_number, card_title)
498
+ ensure_ephemeral_env_for_comment(ctx, card_number, worktree_path)
499
+
494
500
  dispatch_new_mention(ctx, card_key: card_key, card_number: card_number,
495
501
  card_title: card_title, branch: branch, worktree_path: worktree_path)
496
502
  end
@@ -33,6 +33,22 @@ module Brainiac
33
33
  fizzy_env_for(AI_AGENT_NAME)
34
34
  end
35
35
 
36
+ # Resolve GitHub App token for an agent so gh CLI runs as their bot identity.
37
+ # Uses brainiac-github's AppClient if available.
38
+ def resolve_github_agent_env(agent_name, github_repo)
39
+ return {} unless github_repo
40
+
41
+ if defined?(Brainiac::Plugins::Github::AppClient)
42
+ repo_owner = github_repo.split("/").first
43
+ token = Brainiac::Plugins::Github::AppClient.installation_token_for(agent_name, repo_owner: repo_owner)
44
+ return { "GH_TOKEN" => token } if token
45
+ end
46
+ {}
47
+ rescue StandardError => e
48
+ LOG.warn "[Fizzy] Could not resolve GitHub token for #{agent_name}: #{e.message}" if defined?(LOG)
49
+ {}
50
+ end
51
+
36
52
  def prefetch_card_context(card_number, repo_path:, agent_name: nil)
37
53
  env = fizzy_env_for(agent_name || AI_AGENT_NAME)
38
54
  card_details = fetch_card_details(card_number, repo_path: repo_path, env: env)
@@ -44,6 +60,37 @@ module Brainiac
44
60
  context
45
61
  end
46
62
 
63
+ # Normalize Fizzy tags from webhook hashes ({ "name" => "deploy" })
64
+ # or API strings ("deploy") into a lowercase name list.
65
+ def tag_names(tags)
66
+ Array(tags).filter_map do |tag|
67
+ name = tag.is_a?(Hash) ? (tag["name"] || tag[:name]) : tag
68
+ normalized = name.to_s.downcase
69
+ normalized unless normalized.empty?
70
+ end
71
+ end
72
+
73
+ def card_has_tag?(tags, name)
74
+ tag_names(tags).include?(name.to_s.downcase)
75
+ end
76
+
77
+ # Live tags from `fizzy card show`. Returns nil on failure so callers
78
+ # can fall back to webhook payload tags. Fizzy has no tag-added webhook;
79
+ # comment payloads may omit or stale-cache tags, so this is the source of truth.
80
+ def fetch_card_tags(card_number, repo_path:, env: nil)
81
+ return nil unless card_number && repo_path
82
+
83
+ env ||= default_fizzy_env
84
+ output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env)
85
+ card = JSON.parse(output)["data"]
86
+ return nil unless card
87
+
88
+ Array(card["tags"])
89
+ rescue StandardError => e
90
+ LOG.warn "[Fizzy] Could not fetch tags for card ##{card_number}: #{e.message}" if defined?(LOG)
91
+ nil
92
+ end
93
+
47
94
  def fetch_card_details(card_number, repo_path:, env:)
48
95
  output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env)
49
96
  card = JSON.parse(output)["data"]
@@ -167,17 +167,63 @@ module Brainiac
167
167
  rescue StandardError => e
168
168
  LOG.error "[Fizzy] Error in pr_merged hook: #{e.message}" if defined?(LOG)
169
169
  end
170
+
171
+ # Also listen for epic branch merges (from brainiac-basecamp)
172
+ # Move card to UAT when merged to any tracked branch
173
+ Brainiac.on(:pr_merged_to_branch) do |ctx|
174
+ card_number = ctx[:card_number]
175
+ next unless card_number
176
+
177
+ # Find the work item to get card info
178
+ branch = ctx[:branch]
179
+ work_item = find_work_item_by_branch(branch) if respond_to?(:find_work_item_by_branch, true)
180
+ work_item ||= Object.respond_to?(:find_work_item_by_branch, true) ? Object.send(:find_work_item_by_branch, branch) : nil
181
+
182
+ next unless work_item
183
+
184
+ _internal_id, card_info = work_item
185
+ card_agent = card_info["agent"]
186
+ board_key = card_info.dig("sources", "fizzy", "board_key")
187
+
188
+ # Load project config
189
+ project_key = card_info.dig("sources", "fizzy", "project") || card_info["project"]
190
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
191
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
192
+ project_config = projects[project_key] || {}
193
+ repo_path = project_config["repo_path"]
194
+
195
+ next unless repo_path
196
+
197
+ # Move card to UAT (same as main merge)
198
+ LOG.info "[Fizzy] PR merged to #{ctx[:base_branch]} for card ##{card_number} — moving to UAT" if defined?(LOG)
199
+ Helpers.move_card_to_column(card_number, "uat",
200
+ project_config: project_config,
201
+ agent_name: card_agent,
202
+ board_key: board_key)
203
+ record_self_move(card_number)
204
+ rescue StandardError => e
205
+ LOG.error "[Fizzy] Error in pr_merged_to_branch hook: #{e.message}" if defined?(LOG)
206
+ end
170
207
  end
171
208
 
172
209
  # PR review received — post status comment on card
210
+ # Only for changes_requested reviews that need action, not for approvals or gate reviews
173
211
  def register_pr_review_received
174
212
  Brainiac.on(:pr_review_received) do |ctx|
175
213
  card_number = ctx[:card_number]
176
214
  next unless card_number
177
215
 
216
+ # Only post status for changes_requested — approvals don't need a Fizzy comment
217
+ review_state = ctx[:review_state]
218
+ next unless review_state == "changes_requested"
219
+
220
+ # Skip if this is a gate agent review (handled by brainiac-basecamp)
221
+ reviewer = ctx[:reviewer]
222
+ next if reviewer&.match?(/-(brainiac|bot)\b/i)
223
+
178
224
  Thread.new do
179
225
  env = Helpers.fizzy_env_for(ctx[:agent_name])
180
- status_comment = "<p>🔄 Code review received from @#{ctx[:reviewer]}. Updates in progress...</p>"
226
+ status_comment = "<p>🔄 Code review received from @#{reviewer}. Updates in progress...</p>"
181
227
  run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", status_comment,
182
228
  chdir: ctx[:repo_path], env: env)
183
229
  rescue StandardError => e
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Fizzy
6
- VERSION = "0.0.24"
6
+ VERSION = "0.0.26"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainiac-fizzy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.24
4
+ version: 0.0.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis