brainiac-fizzy 0.0.1

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,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Card duplicate detection (card_published / card_triaged).
4
+ #
5
+ # When a new card is created, checks for similar existing cards using
6
+ # trigram and semantic similarity. Posts a warning comment if duplicates found.
7
+
8
+ def handle_card_published(payload)
9
+ eventable = payload["eventable"] || {}
10
+ card_number = eventable["number"]
11
+ title = eventable["title"] || ""
12
+ creator_name = payload.dig("creator", "name")
13
+ creator_id = payload.dig("creator", "id")
14
+ tags = eventable["tags"] || []
15
+
16
+ # Creator-based routing: only the machine whose local human created the card
17
+ # handles dedup. Requires `"local": true` on the human in fizzy.json authorized_users.
18
+ local_humans = FIZZY_CONFIG.fetch("authorized_users", []).select { |u| u["human"] && u["local"] }
19
+ if local_humans.empty?
20
+ LOG.info "[CardIndex] No local humans configured — skipping dedup, indexing only"
21
+ return index_card_only(card_number, title, creator_name, creator_id, tags)
22
+ end
23
+
24
+ unless local_humans.any? { |u| u["id"] == creator_id }
25
+ LOG.info "[CardIndex] Ignoring card ##{card_number} — creator '#{creator_name}' is not a local human"
26
+ return index_card_only(card_number, title, creator_name, creator_id, tags)
27
+ end
28
+
29
+ # Check for duplicates before indexing
30
+ similar = CARD_INDEX.find_similar_cards(title, exclude_number: card_number, tags: tags) if card_number
31
+ index_card_only(card_number, title, creator_name, creator_id, tags, skip_response: true)
32
+
33
+ if similar&.any?
34
+ post_duplicate_warning(card_number, title, tags, similar)
35
+ [200, { status: "duplicate_detected", card: card_number,
36
+ similar: similar.map { |s| { number: s[:number], score: s[:score].round(2) } } }.to_json]
37
+ else
38
+ LOG.info "[CardIndex] Card ##{card_number} '#{title}' indexed, no duplicates found"
39
+ [200, { status: "indexed", card: card_number }.to_json]
40
+ end
41
+ end
42
+
43
+ def index_card_only(card_number, title, creator_name, creator_id, tags, skip_response: false)
44
+ CARD_INDEX.index_card(number: card_number, title: title, creator_name: creator_name, creator_id: creator_id, tags: tags) if card_number
45
+ CARD_INDEX.save
46
+ CARD_INDEX.schedule_qmd_reindex
47
+ [200, { status: "indexed", card: card_number }.to_json] unless skip_response
48
+ end
49
+
50
+ def post_duplicate_warning(card_number, title, tags, similar)
51
+ best = similar.first
52
+ LOG.info "[CardIndex] Potential duplicate: ##{card_number} '#{title}' ≈ " \
53
+ "##{best[:number]} '#{best[:title]}' (score: #{best[:score].round(2)})"
54
+
55
+ project_result = identify_project_by_tags(tags)
56
+ return unless project_result
57
+
58
+ _project_key, project_config = project_result
59
+ repo_path = project_config["repo_path"]
60
+
61
+ Thread.new do
62
+ method_label = { trigram: "📝", semantic: "🧠", both: "📝🧠" }
63
+ dupes = similar.map do |s|
64
+ icon = method_label[s[:method]] || "📝"
65
+ "##{s[:number]} \"#{s[:title]}\" (#{(s[:score] * 100).round}% #{icon})"
66
+ end.join("\n- ")
67
+ body = "⚠️ **Possible duplicate detected:**\n- #{dupes}\n\n_📝 = text similarity, 🧠 = semantic similarity_"
68
+ run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", body,
69
+ chdir: repo_path, env: default_fizzy_env)
70
+ LOG.info "[CardIndex] Posted duplicate warning on card ##{card_number}"
71
+ rescue StandardError => e
72
+ LOG.warn "[CardIndex] Failed to post duplicate warning: #{e.message}"
73
+ end
74
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Fizzy deploy comment handler.
4
+ #
5
+ # When a comment is just "dev02" (or any dev\d+), deploy the card's
6
+ # worktree to that environment. No agent dispatch — reactions only.
7
+
8
+ def handle_deploy_comment(eventable, env_key, card_internal_id)
9
+ comment_id = eventable["id"]
10
+ card_info = load_card_map[card_internal_id]
11
+
12
+ # Validate environment exists
13
+ deploy_config = DEPLOYMENTS_CONFIG["environments"] || {}
14
+ unless deploy_config.key?(env_key)
15
+ LOG.warn "[Deploy] Unknown environment: #{env_key}"
16
+ return [200, { status: "ignored", reason: "unknown environment" }.to_json]
17
+ end
18
+
19
+ # Check environment ownership
20
+ env_owner = deploy_config[env_key]["owner"]
21
+ unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase
22
+ LOG.info "[Deploy] Skipping #{env_key} — owner is #{env_owner.inspect}, this machine is #{AI_AGENT_NAME}"
23
+ return [200, { status: "ignored", reason: env_owner ? "owned by #{env_owner}" : "no owner configured" }.to_json]
24
+ end
25
+
26
+ worktree = card_info&.dig("worktree")
27
+ card_number = card_info&.dig("number")
28
+
29
+ # If worktree doesn't exist locally, try to clone the branch from origin
30
+ if worktree.nil? || !File.directory?(worktree)
31
+ result = clone_branch_for_deploy(eventable, card_internal_id, card_info)
32
+ unless result
33
+ LOG.warn "[Deploy] Could not resolve or clone branch for card #{card_internal_id}"
34
+ return [200, { status: "ignored", reason: "no worktree and could not clone branch" }.to_json]
35
+ end
36
+ worktree = result[:worktree]
37
+ card_number = result[:card_number]
38
+ end
39
+
40
+ deploy_script = File.join(worktree, "scripts", "deploy.sh")
41
+ unless File.exist?(deploy_script)
42
+ LOG.warn "[Deploy] No deploy script at #{deploy_script}"
43
+ return [200, { status: "ignored", reason: "no deploy script" }.to_json]
44
+ end
45
+
46
+ LOG.info "[Deploy] Deploying card ##{card_number} worktree to #{env_key}"
47
+ mark_deploying(env_key, worktree_path: worktree)
48
+
49
+ Thread.new do
50
+ react_to_deploy(card_number, comment_id, worktree, "🚀")
51
+ run_deploy(env_key, card_number, comment_id, worktree)
52
+ rescue StandardError => e
53
+ LOG.error "[Deploy] Error deploying card ##{card_number} to #{env_key}: #{e.message}"
54
+ react_to_deploy(card_number, comment_id, worktree, "❌")
55
+ end
56
+
57
+ [200, { status: "deploying", card: card_number, env: env_key }.to_json]
58
+ end
59
+
60
+ def react_to_deploy(card_number, comment_id, worktree, emoji)
61
+ run_cmd("fizzy", "reaction", "create", "--card", card_number.to_s,
62
+ "--comment", comment_id.to_s, "--content", emoji,
63
+ chdir: worktree, env: default_fizzy_env)
64
+ rescue StandardError => e
65
+ LOG.warn "[Deploy] Could not add reaction #{emoji}: #{e.message}"
66
+ end
67
+
68
+ def run_deploy(env_key, card_number, comment_id, worktree)
69
+ deploy_env = {}
70
+ aws_profile = DEPLOYMENTS_CONFIG.dig("environments", env_key, "aws_profile")
71
+ deploy_env["AWS_PROFILE"] = aws_profile if aws_profile
72
+
73
+ stdout, stderr, status = Open3.capture3(deploy_env, "./scripts/deploy.sh", env_key, chdir: worktree)
74
+
75
+ if !status.success? && terraform_lock_error?(stdout, stderr)
76
+ stdout, stderr, status = retry_deploy_with_init(deploy_env, env_key, card_number, worktree)
77
+ end
78
+
79
+ if status.success?
80
+ LOG.info "[Deploy] Successfully deployed card ##{card_number} to #{env_key}"
81
+ react_to_deploy(card_number, comment_id, worktree, "✅")
82
+ deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "fizzy-comment")
83
+ else
84
+ LOG.error "[Deploy] Failed deploying card ##{card_number} to #{env_key}: #{stderr}"
85
+ react_to_deploy(card_number, comment_id, worktree, "❌")
86
+ record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr)
87
+ end
88
+ end
89
+
90
+ def retry_deploy_with_init(deploy_env, env_key, card_number, worktree)
91
+ LOG.info "[Deploy] Terraform lock file mismatch for card ##{card_number} — retrying with init -upgrade"
92
+ infra_dir = File.join(worktree, "infrastructure", env_key)
93
+ lock_file = File.join(infra_dir, ".terraform.lock.hcl")
94
+ FileUtils.rm_f(lock_file)
95
+ Open3.capture3(deploy_env, "terraform", "init", "-upgrade", chdir: infra_dir) if File.directory?(infra_dir)
96
+ Open3.capture3(deploy_env, "./scripts/deploy.sh", env_key, chdir: worktree)
97
+ end
98
+
99
+ # Clone a remote branch locally for deploy when the worktree doesn't exist on this machine.
100
+ # Returns { worktree:, card_number: } on success, nil on failure.
101
+ def clone_branch_for_deploy(eventable, card_internal_id, card_info)
102
+ card_tags = eventable.dig("card", "tags") || []
103
+ project_result = identify_project_by_tags(card_tags)
104
+ unless project_result
105
+ LOG.warn "[Deploy] Cannot identify project for card #{card_internal_id}"
106
+ return nil
107
+ end
108
+ project_key, project_config = project_result
109
+ repo_path = project_config["repo_path"]
110
+
111
+ card_number = card_info&.dig("number")
112
+ card_number ||= resolve_card_number(card_internal_id, repo_path: repo_path)
113
+ unless card_number
114
+ LOG.warn "[Deploy] Cannot resolve card number for #{card_internal_id}"
115
+ return nil
116
+ end
117
+
118
+ debounced_repo_fetch(repo_path)
119
+ branches = run_cmd("git", "branch", "-r", "--list", "origin/fizzy-#{card_number}-*", chdir: repo_path).strip
120
+ branch = branches.lines.map(&:strip).first&.sub("origin/", "")
121
+ unless branch
122
+ LOG.warn "[Deploy] No remote branch matching fizzy-#{card_number}-* found"
123
+ return nil
124
+ end
125
+
126
+ worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
127
+
128
+ unless File.directory?(worktree_path)
129
+ branch_exists_locally = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
130
+ if branch_exists_locally
131
+ run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
132
+ else
133
+ run_cmd("git", "worktree", "add", "--track", "-b", branch, worktree_path, "origin/#{branch}", chdir: repo_path)
134
+ end
135
+
136
+ trust_version_manager(worktree_path, chdir: worktree_path)
137
+ apply_worktree_includes(repo_path, worktree_path)
138
+ run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => worktree_path })
139
+ end
140
+
141
+ # Update card map
142
+ map = load_card_map
143
+ map[card_internal_id] ||= {}
144
+ map[card_internal_id].merge!("number" => card_number, "branch" => branch, "worktree" => worktree_path, "project" => project_key)
145
+ save_card_map(map)
146
+
147
+ LOG.info "[Deploy] Cloned branch #{branch} into worktree #{worktree_path} for card ##{card_number}"
148
+ { worktree: worktree_path, card_number: card_number }
149
+ rescue StandardError => e
150
+ LOG.error "[Deploy] Failed to clone branch for card #{card_internal_id}: #{e.message}"
151
+ nil
152
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ # Deployment environment tracking.
6
+ # Tracks which dev environments have active card deploys and which are available.
7
+
8
+ DEPLOYMENTS_CONFIG_FILE = File.join(BRAINIAC_DIR, "deployments.json")
9
+ DEPLOYMENT_STATE_FILE = File.join(BRAINIAC_DIR, "deployment_state.json")
10
+
11
+ def load_deployments_config
12
+ return {} unless File.exist?(DEPLOYMENTS_CONFIG_FILE)
13
+
14
+ JSON.parse(File.read(DEPLOYMENTS_CONFIG_FILE))
15
+ rescue JSON::ParserError => e
16
+ LOG.error "Failed to parse deployments config: #{e.message}"
17
+ {}
18
+ end
19
+
20
+ def load_deployment_state
21
+ return {} unless File.exist?(DEPLOYMENT_STATE_FILE)
22
+
23
+ JSON.parse(File.read(DEPLOYMENT_STATE_FILE))
24
+ rescue JSON::ParserError => e
25
+ LOG.error "Failed to parse deployment state: #{e.message}"
26
+ {}
27
+ end
28
+
29
+ def save_deployment_state(state)
30
+ File.write(DEPLOYMENT_STATE_FILE, JSON.pretty_generate(state))
31
+ end
32
+
33
+ DEPLOYMENTS_CONFIG = load_deployments_config
34
+ DEPLOYMENT_STATE = load_deployment_state
35
+
36
+ def reload_deployments_config!(force: false)
37
+ return unless file_changed?(DEPLOYMENTS_CONFIG_FILE, force: force)
38
+
39
+ DEPLOYMENTS_CONFIG.replace(load_deployments_config)
40
+ end
41
+
42
+ def reload_deployment_state!(force: false)
43
+ return unless file_changed?(DEPLOYMENT_STATE_FILE, force: force)
44
+
45
+ DEPLOYMENT_STATE.replace(load_deployment_state)
46
+ end
47
+
48
+ # Mark an environment as actively deploying (in-progress state for waybar).
49
+ def mark_deploying(env_key, worktree_path:)
50
+ state = load_deployment_state
51
+ state[env_key] ||= {}
52
+ state[env_key]["status"] = "occupied"
53
+ state[env_key]["last_deploy_status"] = "deploying"
54
+ state[env_key]["last_deploy_at"] = Time.now.iso8601
55
+ save_deployment_state(state)
56
+ DEPLOYMENT_STATE.replace(state)
57
+ end
58
+
59
+ # Mark an environment as occupied. Resolves card info from the card map using the worktree path.
60
+ def deploy_to_environment(env_key, worktree_path:, deployed_by: nil)
61
+ config = DEPLOYMENTS_CONFIG["environments"] || {}
62
+ unless config.key?(env_key)
63
+ LOG.warn "[Deploy] Unknown environment: #{env_key}"
64
+ return { error: "Unknown environment: #{env_key}" }
65
+ end
66
+
67
+ state = load_deployment_state
68
+ entry = { "status" => "occupied", "deployed_at" => Time.now.iso8601, "deployed_by" => deployed_by,
69
+ "last_deploy_status" => "success", "last_deploy_at" => Time.now.iso8601 }
70
+
71
+ # Resolve card info from card map by matching worktree path
72
+ map = load_card_map
73
+ card_entry = map.values.find { |info| info["worktree"] == worktree_path }
74
+ if card_entry
75
+ entry["card_number"] = card_entry["number"]
76
+ entry["card_title"] = card_entry["title"]
77
+ entry["branch"] = card_entry["branch"]
78
+ pr = (card_entry["prs"] || []).last
79
+ if pr
80
+ entry["pr_number"] = pr["number"]
81
+ entry["pr_url"] = pr["url"]
82
+ end
83
+ # Store card tags for URL resolution (e.g. ops-web-app → ops URL)
84
+ if defined?(CARD_INDEX)
85
+ card_idx = CARD_INDEX[card_entry["number"].to_s]
86
+ entry["card_tags"] = card_idx["tags"] if card_idx && card_idx["tags"]
87
+ end
88
+ else
89
+ # No card map match — record branch from git
90
+ branch = `git -C #{Shellwords.escape(worktree_path)} rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
91
+ entry["branch"] = branch unless branch.empty?
92
+ end
93
+
94
+ commit = `git -C #{Shellwords.escape(worktree_path)} rev-parse --short HEAD 2>/dev/null`.strip
95
+ entry["commit"] = commit unless commit.empty?
96
+
97
+ state[env_key] = entry
98
+ save_deployment_state(state)
99
+ DEPLOYMENT_STATE.replace(state)
100
+ LOG.info "[Deploy] #{env_key} marked occupied — card ##{entry["card_number"] || "none"}, branch: #{entry["branch"]}"
101
+ entry
102
+ end
103
+
104
+ DEPLOY_LOGS_DIR = File.join(BRAINIAC_DIR, "deploy_logs")
105
+
106
+ # Record a failed deploy — saves output to a log file and updates state.
107
+ def record_deploy_failure(env_key, worktree_path:, stdout: "", stderr: "")
108
+ FileUtils.mkdir_p(DEPLOY_LOGS_DIR)
109
+ log_file = File.join(DEPLOY_LOGS_DIR, "#{env_key}-#{Time.now.strftime("%Y%m%d-%H%M%S")}.log")
110
+ File.write(log_file, "=== STDOUT ===\n#{stdout}\n\n=== STDERR ===\n#{stderr}")
111
+
112
+ state = load_deployment_state
113
+ state[env_key] ||= {}
114
+ state[env_key]["last_deploy_status"] = "failed"
115
+ state[env_key]["last_deploy_at"] = Time.now.iso8601
116
+ state[env_key]["last_deploy_log"] = log_file
117
+ save_deployment_state(state)
118
+ DEPLOYMENT_STATE.replace(state)
119
+ LOG.info "[Deploy] #{env_key} deploy failed — log at #{log_file}"
120
+ end
121
+
122
+ # Auto-deploy after agent session when [deploy] tag was present.
123
+ # deploy_intent is either a specific env key (e.g. "dev04"), :auto (auto-detect), or nil (no deploy).
124
+ def auto_deploy_after_session(deploy_intent:, card_internal_id:, card_number:, worktree_path:, agent_name:)
125
+ state = load_deployment_state
126
+ config = DEPLOYMENTS_CONFIG["environments"] || {}
127
+
128
+ env_key = resolve_deploy_environment(deploy_intent, state, card_number)
129
+ return unless env_key
130
+
131
+ unless config.key?(env_key)
132
+ LOG.warn "[Deploy] Auto-deploy skipped — unknown environment: #{env_key}"
133
+ return
134
+ end
135
+
136
+ env_owner = config[env_key]["owner"]
137
+ unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase
138
+ LOG.info "[Deploy] Auto-deploy skipped #{env_key} — owner is #{env_owner.inspect}, this machine is #{AI_AGENT_NAME}"
139
+ return
140
+ end
141
+
142
+ deploy_script = File.join(worktree_path, "scripts", "deploy.sh")
143
+ unless File.exist?(deploy_script)
144
+ LOG.warn "[Deploy] Auto-deploy skipped — no deploy script at #{deploy_script}"
145
+ return
146
+ end
147
+
148
+ LOG.info "[Deploy] Auto-deploying card ##{card_number} to #{env_key} (triggered by [deploy] tag)"
149
+ mark_deploying(env_key, worktree_path: worktree_path)
150
+
151
+ deploy_env = {}
152
+ aws_profile = config.dig(env_key, "aws_profile")
153
+ deploy_env["AWS_PROFILE"] = aws_profile if aws_profile
154
+
155
+ run_deploy(deploy_env, deploy_script, env_key, worktree_path: worktree_path, card_number: card_number, agent_name: agent_name)
156
+ end
157
+
158
+ # Resolve which environment to deploy to from the intent.
159
+ def resolve_deploy_environment(deploy_intent, state, card_number)
160
+ if deploy_intent.is_a?(String) && !deploy_intent.empty?
161
+ deploy_intent
162
+ else
163
+ existing = state.find { |_k, v| v["card_number"] == card_number && v["status"] == "occupied" }&.first
164
+ LOG.info "[Deploy] Auto-deploy skipped — card ##{card_number} not currently deployed to any environment" unless existing
165
+ existing
166
+ end
167
+ end
168
+
169
+ # Execute deploy script with terraform lock retry logic.
170
+ def run_deploy(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:)
171
+ stdout, stderr, status = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree_path)
172
+
173
+ if status.success?
174
+ deploy_to_environment(env_key, worktree_path: worktree_path, deployed_by: "#{agent_name} [deploy]")
175
+ LOG.info "[Deploy] Auto-deploy to #{env_key} succeeded for card ##{card_number}"
176
+ elsif terraform_lock_error?(stdout, stderr)
177
+ retry_deploy_after_lock_fix(deploy_env, deploy_script, env_key, worktree_path: worktree_path, card_number: card_number, agent_name: agent_name)
178
+ else
179
+ record_deploy_failure(env_key, worktree_path: worktree_path, stdout: stdout, stderr: stderr)
180
+ LOG.error "[Deploy] Auto-deploy to #{env_key} failed for card ##{card_number}"
181
+ end
182
+ end
183
+
184
+ # Retry deploy after clearing terraform lock.
185
+ def retry_deploy_after_lock_fix(deploy_env, deploy_script, env_key, worktree_path:, card_number:, agent_name:)
186
+ lock_file = File.join(worktree_path, "infrastructure/#{env_key}/.terraform.lock.hcl")
187
+ FileUtils.rm_f(lock_file)
188
+ Open3.capture3("terraform", "init", "-upgrade", chdir: File.join(worktree_path, "infrastructure/#{env_key}"))
189
+ stdout2, stderr2, status2 = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree_path)
190
+ if status2.success?
191
+ deploy_to_environment(env_key, worktree_path: worktree_path, deployed_by: "#{agent_name} [deploy]")
192
+ LOG.info "[Deploy] Auto-deploy to #{env_key} succeeded (after terraform lock fix) for card ##{card_number}"
193
+ else
194
+ record_deploy_failure(env_key, worktree_path: worktree_path, stdout: stdout2, stderr: stderr2)
195
+ LOG.error "[Deploy] Auto-deploy to #{env_key} failed (after retry) for card ##{card_number}"
196
+ end
197
+ end
198
+
199
+ # Detect Terraform provider lock file checksum mismatch errors.
200
+ def terraform_lock_error?(stdout, stderr)
201
+ combined = "#{stdout}\n#{stderr}"
202
+ combined.include?("checksums previously recorded in the dependency lock file")
203
+ end
204
+
205
+ # Clear all environments occupied by a given card number (called on PR merge).
206
+ def clear_deployment_for_card(card_number)
207
+ state = load_deployment_state
208
+ cleared = []
209
+
210
+ state.each do |env_key, info|
211
+ next unless info["card_number"] == card_number && info["status"] == "occupied"
212
+
213
+ state[env_key] = { "status" => "available", "cleared_at" => Time.now.iso8601, "last_card" => card_number }
214
+ cleared << env_key
215
+ end
216
+
217
+ if cleared.any?
218
+ save_deployment_state(state)
219
+ DEPLOYMENT_STATE.replace(state)
220
+ LOG.info "[Deploy] Cleared #{cleared.join(", ")} — card ##{card_number} merged"
221
+ end
222
+
223
+ cleared
224
+ end
225
+
226
+ # Return environments with status "available", optionally filtered by project.
227
+ def available_environments(project: nil)
228
+ config = DEPLOYMENTS_CONFIG["environments"] || {}
229
+ state = load_deployment_state
230
+
231
+ config.select do |env_key, env_config|
232
+ next false if project && env_config["project"] != project
233
+
234
+ info = state[env_key]
235
+ info.nil? || info["status"] == "available"
236
+ end.keys
237
+ end
238
+
239
+ # Full deployment status for API / waybar.
240
+ def deployment_status
241
+ config = DEPLOYMENTS_CONFIG["environments"] || {}
242
+ state = load_deployment_state
243
+
244
+ config.map do |env_key, env_config|
245
+ info = state[env_key] || { "status" => "available" }
246
+ url = resolve_deployment_url(env_config, info["card_tags"])
247
+ { "env" => env_key, "label" => env_config["label"], "url" => url, "project" => env_config["project"] }.merge(info)
248
+ end
249
+ end
250
+
251
+ # Resolve the correct URL for an environment based on card tags.
252
+ # If the card has a tag matching a key in the environment's "urls" map, use that URL.
253
+ # Otherwise fall back to the default "url".
254
+ def resolve_deployment_url(env_config, card_tags)
255
+ urls = env_config["urls"] || {}
256
+ if card_tags && urls.any?
257
+ card_tags.each { |tag| return urls[tag] if urls[tag] }
258
+ end
259
+ env_config["url"]
260
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Fizzy
6
+ # Fizzy-specific helper functions.
7
+ # These were previously in lib/brainiac/helpers.rb in core.
8
+ module Helpers
9
+ class << self
10
+ # Returns true if signature is valid (or no secret configured).
11
+ # Returns false if signature verification fails.
12
+ def verify_signature!(request, payload_body, board_key: nil)
13
+ signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"]
14
+ return false unless signature
15
+
16
+ secret = board_key ? Config.board_webhook_secret(board_key) : ENV.fetch("FIZZY_WEBHOOK_SECRET", nil)
17
+ return false unless secret
18
+
19
+ computed = OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)
20
+ Rack::Utils.secure_compare(signature, computed)
21
+ end
22
+
23
+ def fizzy_token_for(agent_name)
24
+ agent_env_var(agent_name, "FIZZY_TOKEN")
25
+ end
26
+
27
+ def fizzy_env_for(agent_name)
28
+ token = fizzy_token_for(agent_name) || fizzy_token_for(AI_AGENT_NAME)
29
+ token ? { "FIZZY_TOKEN" => token } : {}
30
+ end
31
+
32
+ def default_fizzy_env
33
+ fizzy_env_for(AI_AGENT_NAME)
34
+ end
35
+
36
+ def prefetch_card_context(card_number, repo_path:, agent_name: nil)
37
+ env = fizzy_env_for(agent_name || AI_AGENT_NAME)
38
+ card_details = fetch_card_details(card_number, repo_path: repo_path, env: env)
39
+ card_comments = fetch_card_comments(card_number, repo_path: repo_path, env: env)
40
+
41
+ context = ""
42
+ context += "## Card Details\n#{card_details}\n\n" unless card_details.empty?
43
+ context += "## Recent Comments\n#{card_comments}\n" unless card_comments.empty?
44
+ context
45
+ end
46
+
47
+ def fetch_card_details(card_number, repo_path:, env:)
48
+ output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env)
49
+ card = JSON.parse(output)["data"]
50
+ return "" unless card
51
+
52
+ parts = []
53
+ parts << "**Title:** #{card["title"]}"
54
+ parts << "**Body:**\n#{card.dig("body", "plain_text")}" if card.dig("body", "plain_text")
55
+ parts.join("\n")
56
+ rescue StandardError => e
57
+ LOG.warn "[Fizzy] Could not fetch card ##{card_number}: #{e.message}" if defined?(LOG)
58
+ ""
59
+ end
60
+
61
+ def fetch_card_comments(card_number, repo_path:, env:)
62
+ output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
63
+ comments = JSON.parse(output)["data"] || []
64
+ return "" if comments.empty?
65
+
66
+ comments.last(15).map do |c|
67
+ body = c.dig("body", "plain_text") || ""
68
+ body = "#{body[0..500]}..." if body.length > 500
69
+ "**#{c["creator_name"]}** (#{c["id"]}):\n#{body}"
70
+ end.join("\n\n---\n\n")
71
+ rescue StandardError => e
72
+ LOG.warn "[Fizzy] Could not fetch comments for card ##{card_number}: #{e.message}" if defined?(LOG)
73
+ ""
74
+ end
75
+
76
+ def move_card_to_column(card_number, column_name, project_config:, agent_name: nil)
77
+ board_key = Config.board_key_for_project(project_config)
78
+ column_id = Config.board_column_id(board_key, column_name) if board_key
79
+ return unless column_id
80
+
81
+ repo_path = project_config["repo_path"]
82
+ env = fizzy_env_for(agent_name || AI_AGENT_NAME)
83
+ run_cmd("fizzy", "card", "column", card_number.to_s, "--column", column_id, chdir: repo_path, env: env)
84
+ end
85
+
86
+ def append_fizzy_comment_footer(card_number, project_config:, agent_name: nil)
87
+ repo_path = project_config["repo_path"]
88
+ env = fizzy_env_for(agent_name || AI_AGENT_NAME)
89
+
90
+ output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: repo_path, env: env)
91
+ comments = JSON.parse(output)["data"] || []
92
+ agent_display = agent_display_name(agent_name || AI_AGENT_NAME)
93
+
94
+ last_agent_comment = comments.reverse.find do |c|
95
+ c["creator_name"]&.downcase == agent_display.downcase
96
+ end
97
+ return unless last_agent_comment
98
+
99
+ # Check if footer already exists
100
+ body = last_agent_comment.dig("body", "html") || ""
101
+ return if body.include?("<em>Branch:")
102
+
103
+ # Detect branch from comment content or card map
104
+ branch = detect_branch_from_comment(body, card_number)
105
+ return unless branch
106
+
107
+ pr_url = detect_pr_url(branch, project_config)
108
+ footer = "<p><em>Branch: <code>#{branch}</code>"
109
+ footer += " | <a href=\"#{pr_url}\">PR</a>" if pr_url
110
+ footer += "</em></p>"
111
+
112
+ updated_body = body + footer
113
+ run_cmd("fizzy", "comment", "update", last_agent_comment["id"], "--card", card_number.to_s,
114
+ "--body", updated_body, chdir: repo_path, env: env)
115
+ rescue StandardError => e
116
+ LOG.warn "[Fizzy] Could not append footer to card ##{card_number}: #{e.message}" if defined?(LOG)
117
+ end
118
+
119
+ def ensure_fizzy_yaml!(chdir, project_config)
120
+ fizzy_yaml_dest = File.join(chdir, ".fizzy.yaml")
121
+ return if File.exist?(fizzy_yaml_dest)
122
+
123
+ fizzy_yaml_src = File.join(project_config["repo_path"], ".fizzy.yaml")
124
+ return unless File.exist?(fizzy_yaml_src)
125
+
126
+ FileUtils.cp(fizzy_yaml_src, fizzy_yaml_dest)
127
+ LOG.info "[Fizzy] Copied .fizzy.yaml to #{chdir}" if defined?(LOG)
128
+ end
129
+
130
+ def scrub_invalid_attachments!(dir)
131
+ attachments_dir = File.join(dir, ".fizzy-attachments")
132
+ return unless Dir.exist?(attachments_dir)
133
+
134
+ Dir.glob(File.join(attachments_dir, "*")).each do |file|
135
+ next unless File.file?(file)
136
+ next if File.size(file) > 100 # Keep files with real content
137
+
138
+ File.delete(file)
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def detect_branch_from_comment(body, card_number)
145
+ # Try to find branch in comment body
146
+ match = body.match(%r{<code>(fizzy-#{card_number}-[^<]+)</code>})
147
+ return match[1] if match
148
+
149
+ # Fall back to card map
150
+ map = load_work_item_map
151
+ entry = map.values.find { |v| v["number"].to_s == card_number.to_s }
152
+ entry&.dig("branch")
153
+ end
154
+
155
+ def detect_pr_url(branch, project_config)
156
+ repo = project_config["github_repo"]
157
+ return nil unless repo
158
+
159
+ "https://github.com/#{repo}/pull/new/#{branch}"
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end