Module: Brainiac::Plugins::Fizzy::Helpers

Defined in:
lib/brainiac/plugins/fizzy/helpers.rb

Overview

Fizzy-specific helper functions. These were previously in lib/brainiac/helpers.rb in core.

Class Method Summary collapse

Class Method Details



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 152

def append_fizzy_comment_footer(card_number, project_config:, agent_name: nil, since: nil)
  repo_path = project_config["repo_path"]
  env = fizzy_env_for(agent_name || AI_AGENT_NAME)

  output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, "--all", chdir: repo_path, env: env)
  comments = JSON.parse(output)["data"] || []
  agent_display = agent_display_name(agent_name || AI_AGENT_NAME)

  # Find the most recent agent comment, scoped to this session if `since` is provided.
  # Subtracts 30s buffer from `since` to account for clock skew between local server and Fizzy API.
  agent_comments = comments.select { |c| c.dig("creator", "name")&.downcase == agent_display.downcase }
  if since
    buffered_since = since - 30
    agent_comments = agent_comments.select do |c|
      comment_time = c["created_at"] && Time.parse(c["created_at"])
      comment_time && comment_time > buffered_since
    end
  end

  last_agent_comment = agent_comments.last
  return false unless last_agent_comment

  # Backup: ensure the comment surfaces branch, PR link, and live deployment link.
  # Whatever the agent already included is left alone; we only append what's missing.
  body = last_agent_comment.dig("body", "html") || ""
  updated_body = ensure_reference_footer(body, card_number, project_config)
  if updated_body != body
    run_cmd("fizzy", "comment", "update", last_agent_comment["id"], "--card", card_number.to_s,
            "--body", updated_body, chdir: repo_path, env: env)
  end

  true
rescue StandardError => e
  LOG.warn "[Fizzy] Could not append footer to card ##{card_number}: #{e.message}" if defined?(LOG)
  false
end

.card_has_tag?(tags, name) ⇒ Boolean



73
74
75
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 73

def card_has_tag?(tags, name)
  tag_names(tags).include?(name.to_s.downcase)
end

.default_fizzy_envObject



32
33
34
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 32

def default_fizzy_env
  fizzy_env_for(AI_AGENT_NAME)
end

.detect_deployment(card_number) ⇒ Object

Resolve the live deployment for a card as { env:, url: }, or nil. Reads live deployment state (tracked dev envs from deployment_state.json, and the card's ephemeral Belt env from ephemeral_envs.json) — the actual source of truth for which environment a card currently occupies.



219
220
221
222
223
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 219

def detect_deployment(card_number)
  return nil unless respond_to?(:deployment_url_for_card, true)

  deployment_url_for_card(card_number)
end

.detect_planning_mode(text:, tags:, card_internal_id:, card_number:) ⇒ Object

Determines whether a card should run in planning mode. Returns nil if planning is not active, or { card_id: } if it is. Planning mode is triggered by a "plan" or "planning" tag on the card.



251
252
253
254
255
256
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 251

def detect_planning_mode(text:, tags:, card_internal_id:, card_number:)
  tag_names = (tags || []).map { |t| t.is_a?(Hash) ? t["name"] : t.to_s }.map(&:downcase)
  return nil unless tag_names.include?("plan") || tag_names.include?("planning")

  { card_id: card_number || card_internal_id }
end

.ensure_fizzy_yaml!(chdir, project_config) ⇒ Object



225
226
227
228
229
230
231
232
233
234
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 225

def ensure_fizzy_yaml!(chdir, project_config)
  fizzy_yaml_dest = File.join(chdir, ".fizzy.yaml")
  return if File.exist?(fizzy_yaml_dest)

  fizzy_yaml_src = File.join(project_config["repo_path"], ".fizzy.yaml")
  return unless File.exist?(fizzy_yaml_src)

  FileUtils.cp(fizzy_yaml_src, fizzy_yaml_dest)
  LOG.info "[Fizzy] Copied .fizzy.yaml to #{chdir}" if defined?(LOG)
end

Build the reference footer (Branch / PR / Deployment) and append any pieces that the agent's comment body doesn't already contain. Returns the (possibly unchanged) body. Detection is deliberately lenient — if the branch name, PR URL, or deployment URL already appears anywhere in the body, it's not re-added.



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 193

def ensure_reference_footer(body, card_number, project_config)
  branch = detect_branch_from_comment(body, card_number)
  return body unless branch

  pr_url = detect_pr_url(branch, project_config)
  deployment = detect_deployment(card_number)

  footer_lines = []
  footer_lines << "<strong>Branch:</strong> <code>#{branch}</code>" unless body.include?(branch)
  if pr_url && !body.include?(pr_url) && !body.include?("github.com/#{project_config["github_repo"]}/pull")
    footer_lines << "<strong>PR:</strong> <a href=\"#{pr_url}\">#{pr_url}</a>"
  end
  if deployment && !body.include?(deployment[:url])
    footer_lines << "<strong>Deployment (#{deployment[:env]}):</strong> " \
                    "<a href=\"#{deployment[:url]}\">#{deployment[:url]}</a>"
  end

  return body if footer_lines.empty?

  "#{body}<p><em>#{footer_lines.join(" &middot; ")}</em></p>"
end

.fetch_card_comments(card_number, repo_path:, env:) ⇒ Object



108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 108

def fetch_card_comments(card_number, repo_path:, env:)
  output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, "--all", chdir: repo_path, env: env)
  comments = JSON.parse(output)["data"] || []
  return "" if comments.empty?

  comments.last(15).map do |c|
    body = c.dig("body", "plain_text") || ""
    body = "#{body[0..500]}..." if body.length > 500
    "**#{c.dig("creator", "name")}** (#{c["id"]}):\n#{body}"
  end.join("\n\n---\n\n")
rescue StandardError => e
  LOG.warn "[Fizzy] Could not fetch comments for card ##{card_number}: #{e.message}" if defined?(LOG)
  ""
end

.fetch_card_details(card_number, repo_path:, env:) ⇒ Object



94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 94

def fetch_card_details(card_number, repo_path:, env:)
  output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env)
  card = JSON.parse(output)["data"]
  return "" unless card

  parts = []
  parts << "**Title:** #{card["title"]}"
  parts << "**Body:**\n#{card.dig("body", "plain_text")}" if card.dig("body", "plain_text")
  parts.join("\n")
rescue StandardError => e
  LOG.warn "[Fizzy] Could not fetch card ##{card_number}: #{e.message}" if defined?(LOG)
  ""
end

.fetch_card_tags(card_number, repo_path:, env: nil) ⇒ Object

Live tags from fizzy card show. Returns nil on failure so callers can fall back to webhook payload tags. Fizzy has no tag-added webhook; comment payloads may omit or stale-cache tags, so this is the source of truth.



80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 80

def fetch_card_tags(card_number, repo_path:, env: nil)
  return nil unless card_number && repo_path

  env ||= default_fizzy_env
  output = run_cmd("fizzy", "card", "show", card_number.to_s, chdir: repo_path, env: env)
  card = JSON.parse(output)["data"]
  return nil unless card

  Array(card["tags"])
rescue StandardError => e
  LOG.warn "[Fizzy] Could not fetch tags for card ##{card_number}: #{e.message}" if defined?(LOG)
  nil
end

.fetch_intent_context(card_number, repo_path:, agent_name: nil) ⇒ Object

Lightweight recent comment context for intent classification. Returns a simple "author: message" format (last 5 comments).



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 125

def fetch_intent_context(card_number, repo_path:, agent_name: nil)
  env = fizzy_env_for(agent_name || AI_AGENT_NAME)
  output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, "--all", chdir: repo_path, env: env)
  comments = JSON.parse(output)["data"] || []
  return nil if comments.empty?

  comments.last(5).map do |c|
    creator = c.dig("creator", "name") || "unknown"
    body = (c.dig("body", "plain_text") || "").lines.first(3).join.strip
    body = "#{body[0..200]}..." if body.length > 200
    "#{creator}: #{body}"
  end.join("\n")
rescue StandardError => e
  LOG.warn "[Fizzy] Could not fetch intent context for card ##{card_number}: #{e.message}" if defined?(LOG)
  nil
end

.fizzy_env_for(agent_name) ⇒ Object



27
28
29
30
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 27

def fizzy_env_for(agent_name)
  token = fizzy_token_for(agent_name) || fizzy_token_for(AI_AGENT_NAME)
  token ? { "FIZZY_TOKEN" => token } : {}
end

.fizzy_token_for(agent_name) ⇒ Object



23
24
25
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 23

def fizzy_token_for(agent_name)
  agent_env_var(agent_name, "FIZZY_TOKEN")
end

.lookup_fizzy_card_info(card_internal_id) ⇒ Object

Look up a Fizzy card's work item info by its internal ID. Returns a hash with top-level "number", "agent", "branch", "worktree", "project" keys for backward compatibility with handler code, or nil if not found.



261
262
263
264
265
266
267
268
269
270
271
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 261

def lookup_fizzy_card_info(card_internal_id)
  return nil unless card_internal_id

  result = find_work_item_by_card(card_internal_id)
  return nil unless result

  _work_item_id, info = result
  # Ensure "number" is at top level for fizzy handler compat
  info["number"] ||= info.dig("sources", "fizzy", "card_number")
  info
end

.move_card_to_column(card_number, column_name, project_config:, agent_name: nil, board_key: nil) ⇒ Object



142
143
144
145
146
147
148
149
150
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 142

def move_card_to_column(card_number, column_name, project_config:, agent_name: nil, board_key: nil)
  board_key ||= Config.board_key_for_project(project_config)
  column_id = Config.board_column_id(board_key, column_name) if board_key
  return unless column_id

  repo_path = project_config["repo_path"]
  env = fizzy_env_for(agent_name || AI_AGENT_NAME)
  run_cmd("fizzy", "card", "column", card_number.to_s, "--column", column_id, chdir: repo_path, env: env)
end

.prefetch_card_context(card_number, repo_path:, agent_name: nil) ⇒ Object



52
53
54
55
56
57
58
59
60
61
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 52

def prefetch_card_context(card_number, repo_path:, agent_name: nil)
  env = fizzy_env_for(agent_name || AI_AGENT_NAME)
  card_details = fetch_card_details(card_number, repo_path: repo_path, env: env)
  card_comments = fetch_card_comments(card_number, repo_path: repo_path, env: env)

  context = ""
  context += "## Card Details\n#{card_details}\n\n" unless card_details.empty?
  context += "## Recent Comments\n#{card_comments}\n" unless card_comments.empty?
  context
end

.resolve_card_number(internal_id, repo_path:) ⇒ Object

Resolve a card number from an internal ID by querying the Fizzy API. Searches card lists to find the matching card.



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 323

def resolve_card_number(internal_id, repo_path:)
  env = default_fizzy_env
  base_cmd = %w[fizzy card list]
  ["--all", "--all --indexed-by closed"].each do |flags|
    cmd = base_cmd + flags.split
    output = run_cmd(*cmd, chdir: repo_path, env: env)
    data = JSON.parse(output)["data"] || []
    match = data.find { |c| c["id"] == internal_id }
    if match
      LOG.info "Resolved card number #{match["number"]} for internal_id #{internal_id}" if defined?(LOG)
      return match["number"]
    end
  rescue StandardError
    next
  end

  LOG.warn "Could not resolve card number for internal_id #{internal_id}" if defined?(LOG)
  nil
end

.resolve_github_agent_env(agent_name, github_repo) ⇒ Object

Resolve GitHub App token for an agent so gh CLI runs as their bot identity. Uses brainiac-github's AppClient if available.



38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 38

def resolve_github_agent_env(agent_name, github_repo)
  return {} unless github_repo

  if defined?(Brainiac::Plugins::Github::AppClient)
    repo_owner = github_repo.split("/").first
    token = Brainiac::Plugins::Github::AppClient.installation_token_for(agent_name, repo_owner: repo_owner)
    return { "GH_TOKEN" => token } if token
  end
  {}
rescue StandardError => e
  LOG.warn "[Fizzy] Could not resolve GitHub token for #{agent_name}: #{e.message}" if defined?(LOG)
  {}
end

.scrub_invalid_attachments!(dir) ⇒ Object



236
237
238
239
240
241
242
243
244
245
246
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 236

def scrub_invalid_attachments!(dir)
  attachments_dir = File.join(dir, ".fizzy-attachments")
  return unless Dir.exist?(attachments_dir)

  Dir.glob(File.join(attachments_dir, "*")).each do |file|
    next unless File.file?(file)
    next if File.size(file) > 100 # Keep files with real content

    File.delete(file)
  end
end

.tag_names(tags) ⇒ Object

Normalize Fizzy tags from webhook hashes ({ "name" => "deploy" }) or API strings ("deploy") into a lowercase name list.



65
66
67
68
69
70
71
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 65

def tag_names(tags)
  Array(tags).filter_map do |tag|
    name = tag.is_a?(Hash) ? (tag["name"] || tag[:name]) : tag
    normalized = name.to_s.downcase
    normalized unless normalized.empty?
  end
end

.update_fizzy_work_item(card_internal_id, updates) ⇒ Object

Update or create a work item entry for a Fizzy card. Accepts a hash of fields to merge into the existing entry. Handles both new-format (wi-xxx keyed) and creates new entries properly.



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 276

def update_fizzy_work_item(card_internal_id, updates)
  return unless card_internal_id

  map = load_work_item_map
  result = nil
  work_item_id = nil

  # Find existing entry by card internal ID
  map.each do |wid, info|
    next unless info.is_a?(Hash)

    fizzy_source = info.dig("sources", "fizzy")
    next unless fizzy_source && fizzy_source["card_internal_id"] == card_internal_id

    work_item_id = wid
    result = info
    break
  end

  card_number = updates.delete("number")

  if result
    result["sources"]["fizzy"]["card_number"] = card_number if card_number
    result.merge!(updates)
    map[work_item_id] = result
  else
    new_id = generate_work_item_id(branch: updates["branch"], card_number: card_number)
    map[new_id] = {
      "id" => new_id,
      "branch" => updates["branch"],
      "worktree" => updates["worktree"],
      "project" => updates["project"],
      "agent" => updates["agent"],
      "sources" => {
        "fizzy" => {
          "card_internal_id" => card_internal_id,
          "card_number" => card_number
        }.compact
      }
    }.compact.merge(updates.except("branch", "worktree", "project", "agent"))
  end

  save_work_item_map(map)
end

.verify_signature!(request, payload_body, board_key: nil) ⇒ Object

Returns true if signature is valid (or no secret configured). Returns false if signature verification fails.



12
13
14
15
16
17
18
19
20
21
# File 'lib/brainiac/plugins/fizzy/helpers.rb', line 12

def verify_signature!(request, payload_body, board_key: nil)
  signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"]
  return false unless signature

  secret = board_key ? Config.board_webhook_secret(board_key) : ENV.fetch("FIZZY_WEBHOOK_SECRET", nil)
  return false unless secret

  computed = OpenSSL::HMAC.hexdigest("sha256", secret, payload_body)
  Rack::Utils.secure_compare(signature, computed)
end