brainiac 0.0.6 → 0.0.7

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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +136 -6
  4. data/bin/brainiac +382 -11
  5. data/bin/brainiac-completion.bash +1 -1
  6. data/lib/brainiac/agents.rb +15 -38
  7. data/lib/brainiac/config.rb +40 -11
  8. data/lib/brainiac/handlers/discord/api.rb +196 -0
  9. data/lib/brainiac/handlers/discord/config.rb +134 -0
  10. data/lib/brainiac/handlers/discord/delivery.rb +196 -0
  11. data/lib/brainiac/handlers/discord/gateway.rb +212 -0
  12. data/lib/brainiac/handlers/discord/message.rb +933 -0
  13. data/lib/brainiac/handlers/discord/reactions.rb +215 -0
  14. data/lib/brainiac/handlers/discord.rb +14 -1892
  15. data/lib/brainiac/handlers/fizzy/assignment.rb +125 -0
  16. data/lib/brainiac/handlers/fizzy/comments.rb +730 -0
  17. data/lib/brainiac/handlers/fizzy/dedup.rb +74 -0
  18. data/lib/brainiac/handlers/fizzy/deploy.rb +152 -0
  19. data/lib/brainiac/{deployments.rb → handlers/fizzy/deployments.rb} +4 -2
  20. data/lib/brainiac/handlers/fizzy.rb +12 -1290
  21. data/lib/brainiac/handlers/github.rb +149 -212
  22. data/lib/brainiac/handlers/shared/git.rb +203 -0
  23. data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
  24. data/lib/brainiac/handlers/zoho.rb +79 -76
  25. data/lib/brainiac/helpers.rb +2 -121
  26. data/lib/brainiac/planning.rb +2 -2
  27. data/lib/brainiac/plugins.rb +129 -0
  28. data/lib/brainiac/restart.rb +94 -0
  29. data/lib/brainiac/routes/api.rb +427 -0
  30. data/lib/brainiac/version.rb +1 -1
  31. data/lib/brainiac/zoho_mail_api.rb +2 -1
  32. data/lib/brainiac.rb +7 -0
  33. data/monitor/daemon.rb +4 -27
  34. data/monitor/shared.rb +247 -0
  35. data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +14 -86
  36. data/monitor/waybar/setup.rb +232 -0
  37. data/monitor/waybar/status.rb +51 -0
  38. data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
  39. data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
  40. data/monitor/xbar/plugin.rb +155 -0
  41. data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
  42. data/receiver.rb +91 -450
  43. data/templates/brainiac.json.example +9 -0
  44. data/templates/cli-providers/kiro.json.example +8 -2
  45. data/templates/plugins.json.example +3 -0
  46. metadata +30 -20
  47. data/lib/user_registry.rb +0 -159
  48. data/monitor/menubar.rb +0 -295
  49. data/monitor/setup-waybar-deploy-envs.rb +0 -121
  50. data/monitor/setup-waybar-deployments.rb +0 -96
  51. data/monitor/setup-waybar-module.rb +0 -113
  52. data/monitor/setup-xbar-plugin.rb +0 -35
  53. data/monitor/view-logs.rb +0 -119
  54. data/monitor/waybar-config-updater.rb +0 -56
  55. data/monitor/waybar-deployments.rb +0 -239
  56. data/monitor/waybar.rb +0 -146
  57. data/monitor/xbar.3s.rb +0 -179
  58. /data/lib/brainiac/{card_index.rb → handlers/fizzy/card_index.rb} +0 -0
  59. /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
  60. /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
@@ -75,14 +75,12 @@ def handle_github_pr_merged(payload)
75
75
  pr_title = pr["title"]
76
76
  repo_full_name = payload.dig("repository", "full_name")
77
77
 
78
- # Only act on merges into the repo's default branch
79
78
  default_branch = payload.dig("repository", "default_branch") || "main"
80
79
  unless base == default_branch
81
80
  LOG.info "PR merged into #{base}, not #{default_branch} — ignoring"
82
81
  return [200, { status: "ignored", reason: "not merged into #{default_branch}" }.to_json]
83
82
  end
84
83
 
85
- # Identify project by GitHub repo
86
84
  project_result = identify_project_by_repo(repo_full_name)
87
85
  unless project_result
88
86
  LOG.info "No project found for GitHub repo #{repo_full_name}"
@@ -100,62 +98,53 @@ def handle_github_pr_merged(payload)
100
98
 
101
99
  internal_id, card_info = result
102
100
  card_number = card_info["number"]
103
-
104
101
  unless card_number
105
102
  LOG.warn "Card #{internal_id} has no number — can't comment or move"
106
103
  return [200, { status: "ignored", reason: "card has no number" }.to_json]
107
104
  end
108
105
 
109
106
  LOG.info "PR merged into main for card ##{card_number} (project: #{project_key}): #{pr_url}"
107
+ process_merged_pr(card_info, card_number, branch, pr, pr_url, pr_title, project_key, project_config, repo_path)
108
+
109
+ [200, { status: "processed", card: card_number, pr: pr_url, action: "merged_to_uat", project: project_key }.to_json]
110
+ rescue StandardError => e
111
+ LOG.error "Error handling merged PR: #{e.message}"
112
+ [500, { error: e.message }.to_json]
113
+ end
110
114
 
111
- # Use the card's assigned agent identity for fizzy interactions
115
+ def process_merged_pr(card_info, card_number, branch, pull_request, pr_url, pr_title, project_key, project_config, repo_path)
112
116
  card_agent = card_info["agent"]
113
117
  card_fizzy_env = fizzy_env_for(card_agent)
114
118
 
115
- # Comment with the PR link if not already there
116
- if pr_link_already_commented?(card_number, pr_url, chdir: repo_path, env: card_fizzy_env)
117
- LOG.info "PR link already on card ##{card_number}, skipping comment"
118
- else
119
+ unless pr_link_already_commented?(card_number, pr_url, chdir: repo_path, env: card_fizzy_env)
119
120
  comment_body = "<p>PR merged into main: <a href=\"#{pr_url}\">#{pr_title}</a></p><p>Branch: <code>#{branch}</code></p>"
120
121
  run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", comment_body, chdir: repo_path, env: card_fizzy_env)
121
- LOG.info "Commented PR link on card ##{card_number} as #{card_agent || "default"}"
122
122
  end
123
123
 
124
- # Move card to UAT column — merged into main means it's deployed to UAT
125
124
  mark_card_merged(card_number)
126
125
  run_cmd("fizzy", "card", "column", card_number.to_s, "--column", uat_column_id(project_config), chdir: repo_path, env: card_fizzy_env)
127
126
  record_self_move(card_number)
128
- LOG.info "Moved card ##{card_number} to UAT column as #{card_agent || "default"}"
129
-
130
- # Clean up the primary worktree and any cross-agent review worktrees
131
127
  cleanup_card_worktrees(card_number, repo_path: repo_path, primary_worktree: card_info["worktree"], primary_branch: branch)
132
-
133
- # Clear any deployment environments occupied by this card
134
128
  clear_deployment_for_card(card_number)
135
129
 
136
- # Dispatch agent to comment UAT testing steps on the Fizzy card
137
130
  agent_name = card_agent || agent_name_for(project_config)
138
131
  card_title = card_info["title"] || pr_title
132
+ dispatch_uat_agent(card_number, card_title, pull_request["number"].to_s, agent_name, project_key, project_config, repo_path)
133
+ end
139
134
 
135
+ def dispatch_uat_agent(card_number, card_title, pr_number, agent_name, project_key, project_config, repo_path)
140
136
  prompt = render_prompt(PROMPT_GITHUB_UAT,
141
- { "CARD_NUMBER" => card_number,
142
- "CARD_TITLE" => card_title,
143
- "PR_NUMBER" => pr["number"].to_s },
144
- brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, card_title: card_title,
145
- project_key: project_key),
146
- agent_name: agent_name,
147
- channel: :fizzy,
137
+ { "CARD_NUMBER" => card_number, "CARD_TITLE" => card_title, "PR_NUMBER" => pr_number },
138
+ brain_context: build_brain_context(agent_name: agent_name, card_number: card_number,
139
+ card_title: card_title, project_key: project_key),
140
+ agent_name: agent_name, channel: :fizzy,
148
141
  board_key: board_key_for_project(project_config))
149
142
 
150
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: repo_path, log_name: "uat-#{card_number}", agent_name: agent_name,
143
+ pid, log_file = run_agent(prompt, project_config: project_config, chdir: repo_path,
144
+ log_name: "uat-#{card_number}", agent_name: agent_name,
151
145
  source: :fizzy, source_context: { card_number: card_number }, skip_column_move: true)
152
146
  register_session("card-#{card_number}", pid, log_file: log_file, agent_name: agent_name)
153
147
  LOG.info "Dispatched #{agent_name} for UAT testing steps on card ##{card_number}"
154
-
155
- [200, { status: "processed", card: card_number, pr: pr_url, action: "merged_to_uat", project: project_key }.to_json]
156
- rescue StandardError => e
157
- LOG.error "Error handling merged PR: #{e.message}"
158
- [500, { error: e.message }.to_json]
159
148
  end
160
149
 
161
150
  def handle_github_issue_comment(payload)
@@ -166,13 +155,11 @@ def handle_github_issue_comment(payload)
166
155
  comment_user = comment.dig("user", "login")
167
156
  repo_name = payload.dig("repository", "full_name")
168
157
 
169
- # Only process if this is a PR (issues have pull_request key when they're PRs)
170
158
  unless issue["pull_request"]
171
159
  LOG.info "Issue comment on non-PR issue ##{issue["number"]}, ignoring"
172
160
  return [200, { status: "ignored", reason: "not a PR comment" }.to_json]
173
161
  end
174
162
 
175
- # Identify project by GitHub repo
176
163
  project_result = identify_project_by_repo(repo_name)
177
164
  unless project_result
178
165
  LOG.info "No project found for GitHub repo #{repo_name}"
@@ -180,12 +167,10 @@ def handle_github_issue_comment(payload)
180
167
  end
181
168
 
182
169
  project_key, project_config = project_result
183
-
184
170
  pr_number = issue["number"]
185
- issue["html_url"]
186
171
 
187
- # Find the card by PR branch
188
- pr_data = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}", "--jq", "{branch: .head.ref}", chdir: project_config["repo_path"])
172
+ pr_data = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}", "--jq", "{branch: .head.ref}",
173
+ chdir: project_config["repo_path"])
189
174
  branch = JSON.parse(pr_data)["branch"]
190
175
 
191
176
  result = find_card_by_branch(branch)
@@ -198,54 +183,52 @@ def handle_github_issue_comment(payload)
198
183
  card_number = card_info["number"]
199
184
  worktree = card_info["worktree"]
200
185
 
201
- # Only process if worktree exists
202
186
  unless worktree && File.directory?(worktree)
203
187
  LOG.info "No active worktree for PR ##{pr_number}, ignoring comment"
204
188
  return [200, { status: "ignored", reason: "no active worktree" }.to_json]
205
189
  end
206
190
 
207
- LOG.info "PR comment from #{comment_user} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
208
-
209
191
  card_key = "card-#{card_number}"
210
192
  if session_active?(card_key)
211
193
  LOG.info "Skipping PR comment on card ##{card_number} — agent session already active"
212
194
  return [200, { status: "ignored", reason: "session already active" }.to_json]
213
195
  end
214
196
 
215
- # React in background don't block the dispatch path
197
+ LOG.info "PR comment from #{comment_user} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
198
+ dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
199
+ repo_name, worktree, project_key, project_config)
200
+
201
+ [200, { status: "processed", card: card_number, pr: pr_number, comment_id: comment_id, project: project_key }.to_json]
202
+ rescue StandardError => e
203
+ LOG.error "Error handling PR comment: #{e.message}"
204
+ [500, { error: e.message }.to_json]
205
+ end
206
+
207
+ def dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
208
+ repo_name, worktree, project_key, project_config)
216
209
  Thread.new do
217
- run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/issues/comments/#{comment_id}/reactions", "-f", "content=eyes", "-H",
218
- "Accept: application/vnd.github+json", chdir: worktree)
219
- LOG.info "Added 👀 reaction to comment ##{comment_id}"
210
+ run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/issues/comments/#{comment_id}/reactions",
211
+ "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: worktree)
220
212
  rescue StandardError => e
221
213
  LOG.warn "Could not add reaction to comment: #{e.message}"
222
214
  end
223
215
 
224
- # Detect model override
225
- model = detect_model(project_config, text: comment_body)
226
- effort = detect_effort(project_config, text: comment_body)
227
216
  agent_name = agent_name_for(project_config)
228
-
229
217
  prompt = render_prompt(PROMPT_GITHUB_PR_COMMENT,
230
- { "CARD_NUMBER" => card_number,
231
- "CARD_ID" => card_number,
232
- "COMMENT_CREATOR" => comment_user,
233
- "COMMENT_BODY" => comment_body,
234
- "PR_NUMBER" => pr_number.to_s,
235
- "WORKTREE_PATH" => worktree },
236
- brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key,
237
- comment_body: comment_body),
238
- agent_name: agent_name,
239
- channel: :github)
240
-
241
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree, log_name: "pr-comment-#{pr_number}", model: model, effort: effort, agent_name: agent_name,
242
- source: :github, source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: worktree })
218
+ { "CARD_NUMBER" => card_number, "CARD_ID" => card_number,
219
+ "COMMENT_CREATOR" => comment_user, "COMMENT_BODY" => comment_body,
220
+ "PR_NUMBER" => pr_number.to_s, "WORKTREE_PATH" => worktree },
221
+ brain_context: build_brain_context(agent_name: agent_name, card_number: card_number,
222
+ project_key: project_key, comment_body: comment_body),
223
+ agent_name: agent_name, channel: :github)
224
+
225
+ pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree,
226
+ log_name: "pr-comment-#{pr_number}",
227
+ model: detect_model(project_config, text: comment_body),
228
+ effort: detect_effort(project_config, text: comment_body),
229
+ agent_name: agent_name, source: :github,
230
+ source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: worktree })
243
231
  register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
244
-
245
- [200, { status: "processed", card: card_number, pr: pr_number, comment_id: comment_id, project: project_key }.to_json]
246
- rescue StandardError => e
247
- LOG.error "Error handling PR comment: #{e.message}"
248
- [500, { error: e.message }.to_json]
249
232
  end
250
233
 
251
234
  def handle_github_workflow_run(payload)
@@ -255,75 +238,70 @@ def handle_github_workflow_run(payload)
255
238
  repo_full_name = payload.dig("repository", "full_name")
256
239
  run_url = workflow["html_url"]
257
240
 
258
- # Handle Deploy to Production failure
259
241
  if workflow_name == "Deploy to Production" && conclusion == "failure"
260
- project_result = identify_project_by_repo(repo_full_name)
261
- project_key = project_result ? project_result[0] : repo_full_name
242
+ project_key = identify_project_by_repo(repo_full_name)&.first || repo_full_name
262
243
  send_workflow_failure_notification(project_key, workflow_name, run_url)
263
- LOG.info "Deploy to Production failed for #{project_key} — notified Discord"
264
244
  return [200, { status: "processed", action: "prod_deploy_failure_notified", project: project_key }.to_json]
265
245
  end
266
246
 
267
- # Handle Deploy to UAT success
268
247
  if workflow_name == "Deploy to UAT" && conclusion == "success"
269
- project_result = identify_project_by_repo(repo_full_name)
270
- project_key = project_result ? project_result[0] : repo_full_name
248
+ project_key = identify_project_by_repo(repo_full_name)&.first || repo_full_name
271
249
  send_uat_deploy_notification(project_key)
272
- LOG.info "Deploy to UAT succeeded for #{project_key} — notified Discord"
273
250
  return [200, { status: "processed", action: "uat_deploy_notified", project: project_key }.to_json]
274
251
  end
275
252
 
276
- unless conclusion == "success"
277
- LOG.info "Workflow '#{workflow_name}' concluded with '#{conclusion}' — ignoring"
278
- return [200, { status: "ignored", reason: "conclusion: #{conclusion}" }.to_json]
279
- end
253
+ return [200, { status: "ignored", reason: "conclusion: #{conclusion}" }.to_json] unless conclusion == "success"
280
254
 
281
- unless workflow_name == "Deploy to Production"
282
- LOG.info "Workflow '#{workflow_name}' is not a prod deploy — ignoring"
283
- return [200, { status: "ignored", reason: "workflow: #{workflow_name}" }.to_json]
284
- end
255
+ return [200, { status: "ignored", reason: "workflow: #{workflow_name}" }.to_json] unless workflow_name == "Deploy to Production"
285
256
 
286
257
  project_result = identify_project_by_repo(repo_full_name)
287
- unless project_result
288
- LOG.info "No project found for GitHub repo #{repo_full_name}"
289
- return [200, { status: "ignored", reason: "no matching project" }.to_json]
290
- end
258
+ return [200, { status: "ignored", reason: "no matching project" }.to_json] unless project_result
291
259
 
292
260
  project_key, project_config = project_result
293
- repo_path = project_config["repo_path"]
261
+ close_uat_cards_after_deploy(project_key, project_config)
262
+ rescue StandardError => e
263
+ LOG.error "Error handling workflow run: #{e.message}"
264
+ [500, { error: e.message }.to_json]
265
+ end
294
266
 
295
- # List all cards in the UAT column
296
- output = run_cmd("fizzy", "card", "list", "--column", uat_column_id(project_config), "--all", chdir: repo_path, env: default_fizzy_env)
297
- cards = JSON.parse(output)
298
- card_list = cards["data"] || []
267
+ def close_uat_cards_after_deploy(project_key, project_config)
268
+ repo_path = project_config["repo_path"]
269
+ output = run_cmd("fizzy", "card", "list", "--column", uat_column_id(project_config), "--all",
270
+ chdir: repo_path, env: default_fizzy_env)
271
+ card_list = JSON.parse(output)["data"] || []
299
272
 
300
273
  if card_list.empty?
301
274
  LOG.info "No cards in UAT column — nothing to close"
302
275
  return [200, { status: "processed", action: "no_uat_cards", project: project_key }.to_json]
303
276
  end
304
277
 
278
+ closed_cards = close_and_cleanup_uat_cards(card_list, repo_path)
279
+ send_deploy_notification(project_key, closed_cards) if closed_cards.any?
280
+
281
+ LOG.info "Prod deploy complete — closed #{closed_cards.size} UAT cards: #{closed_cards.map { |c| c[:number] }.join(", ")}"
282
+ [200, { status: "processed", action: "prod_deploy_closed_uat",
283
+ closed_cards: closed_cards.map { |c| c[:number] }, project: project_key }.to_json]
284
+ end
285
+
286
+ def close_and_cleanup_uat_cards(card_list, repo_path)
305
287
  closed_cards = []
306
288
  map = load_card_map
289
+
307
290
  card_list.each do |card|
308
291
  card_number = card["number"]
309
292
  next unless card_number
310
293
 
311
- # Try to find the card in our map to get the assigned agent
312
294
  map_entry = map.values.find { |info| info["number"] == card_number }
313
295
  agent_name = map_entry["agent"] if map_entry
314
-
315
296
  env = agent_name ? fizzy_env_for(agent_name) : default_fizzy_env
316
297
 
317
- comment_body = "<p>✅ Deployed to production. Closing card.</p>"
318
- run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", comment_body, chdir: repo_path, env: env)
298
+ run_cmd("fizzy", "comment", "create", "--card", card_number.to_s,
299
+ "--body", "<p>✅ Deployed to production. Closing card.</p>", chdir: repo_path, env: env)
319
300
  run_cmd("fizzy", "card", "close", card_number.to_s, chdir: repo_path, env: env)
320
301
 
321
- # Clean up worktrees (primary + cross-agent review)
322
- primary_worktree = map_entry&.dig("worktree")
323
- primary_branch = map_entry&.dig("branch")
324
- cleanup_card_worktrees(card_number, repo_path: repo_path, primary_worktree: primary_worktree, primary_branch: primary_branch)
302
+ cleanup_card_worktrees(card_number, repo_path: repo_path,
303
+ primary_worktree: map_entry&.dig("worktree"), primary_branch: map_entry&.dig("branch"))
325
304
 
326
- # Clean up card map entry
327
305
  if map_entry
328
306
  internal_id = map.key(map_entry)
329
307
  map.delete(internal_id)
@@ -334,14 +312,7 @@ def handle_github_workflow_run(payload)
334
312
  end
335
313
 
336
314
  save_card_map(map) if closed_cards.any?
337
-
338
- send_deploy_notification(project_key, closed_cards) if closed_cards.any?
339
-
340
- LOG.info "Prod deploy complete — closed #{closed_cards.size} UAT cards: #{closed_cards.map { |c| c[:number] }.join(", ")}"
341
- [200, { status: "processed", action: "prod_deploy_closed_uat", closed_cards: closed_cards.map { |c| c[:number] }, project: project_key }.to_json]
342
- rescue StandardError => e
343
- LOG.error "Error handling workflow run: #{e.message}"
344
- [500, { error: e.message }.to_json]
315
+ closed_cards
345
316
  end
346
317
 
347
318
  def send_deploy_notification(project_key, closed_cards)
@@ -402,171 +373,137 @@ def handle_github_pr_review_submitted(payload)
402
373
  review = payload["review"]
403
374
  branch = pr.dig("head", "ref")
404
375
  pr_number = pr["number"]
405
- pr["html_url"]
406
376
  repo_name = payload.dig("repository", "full_name")
407
377
  review_state = review["state"]
408
378
  reviewer = review.dig("user", "login")
409
379
 
410
- # Only act on submitted reviews (not just comments)
411
- unless %w[changes_requested commented].include?(review_state)
412
- LOG.info "PR review state is '#{review_state}', ignoring"
413
- return [200, { status: "ignored", reason: "review state: #{review_state}" }.to_json]
414
- end
380
+ return [200, { status: "ignored", reason: "review state: #{review_state}" }.to_json] unless %w[changes_requested commented].include?(review_state)
415
381
 
416
- # Identify project by GitHub repo
417
382
  project_result = identify_project_by_repo(repo_name)
418
- unless project_result
419
- LOG.info "No project found for GitHub repo #{repo_name}"
420
- return [200, { status: "ignored", reason: "no matching project" }.to_json]
421
- end
383
+ return [200, { status: "ignored", reason: "no matching project" }.to_json] unless project_result
422
384
 
423
385
  project_key, project_config = project_result
424
386
  repo_path = project_config["repo_path"]
425
387
 
426
388
  result = find_card_by_branch(branch)
427
- unless result
428
- LOG.info "No Fizzy card found for PR branch #{branch}"
429
- return [200, { status: "ignored", reason: "no matching card" }.to_json]
430
- end
389
+ return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
431
390
 
432
391
  internal_id, card_info = result
433
392
  card_number = card_info["number"]
434
- worktree = card_info["worktree"]
435
-
436
393
  unless card_number
437
394
  LOG.warn "Card #{internal_id} has no number — can't comment"
438
395
  return [200, { status: "ignored", reason: "card has no number" }.to_json]
439
396
  end
440
397
 
398
+ card_key = "card-#{card_number}"
399
+ return [200, { status: "ignored", reason: "session already active" }.to_json] if session_active?(card_key)
400
+
441
401
  LOG.info "PR review submitted by #{reviewer} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
402
+ dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer, repo_name, project_key, project_config, repo_path)
442
403
 
443
- card_key = "card-#{card_number}"
444
- if session_active?(card_key)
445
- LOG.info "Skipping PR review on card ##{card_number} — agent session already active"
446
- return [200, { status: "ignored", reason: "session already active" }.to_json]
447
- end
404
+ [200, { status: "processed", card: card_number, pr: pr_number, reviewer: reviewer, project: project_key }.to_json]
405
+ rescue StandardError => e
406
+ LOG.error "Error handling PR review: #{e.message}"
407
+ [500, { error: e.message }.to_json]
408
+ end
448
409
 
449
- # React to the review with 👀 to show we're starting
410
+ def dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer, repo_name, project_key, project_config, repo_path)
450
411
  review_id = review["id"]
451
- # React in background — don't block the dispatch path
452
412
  Thread.new do
453
- run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/reviews/#{review_id}/reactions", "-f", "content=eyes", "-H",
454
- "Accept: application/vnd.github+json", chdir: repo_path)
455
- LOG.info "Added 👀 reaction to review ##{review_id}"
413
+ run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/reviews/#{review_id}/reactions",
414
+ "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
456
415
  rescue StandardError => e
457
416
  LOG.warn "Could not add reaction to review: #{e.message}"
458
417
  end
459
418
 
460
- # Post status to Fizzy in background — don't block the dispatch path
461
419
  agent_name = agent_name_for(project_config)
462
420
  Thread.new do
463
421
  status_comment = "<p>🔄 Code review received from @#{reviewer}. Updates in progress...</p>"
464
- run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", status_comment, chdir: repo_path, env: fizzy_env_for(agent_name))
465
- LOG.info "Posted status update to card ##{card_number} as #{agent_name}"
422
+ run_cmd("fizzy", "comment", "create", "--card", card_number.to_s, "--body", status_comment,
423
+ chdir: repo_path, env: fizzy_env_for(agent_name))
466
424
  rescue StandardError => e
467
425
  LOG.warn "Could not post status update to card ##{card_number}: #{e.message}"
468
426
  end
469
427
 
470
- # Fetch all review comments (line-specific comments)
471
- review_comments = fetch_pr_review_comments(pr_number, repo_name)
428
+ review_context = build_review_context(reviewer, review, pr_number, repo_name)
429
+ worktree = card_info["worktree"]
430
+ work_dir = worktree && File.directory?(worktree) ? worktree : repo_path
472
431
 
473
- # Build context for the agent
474
- review_context = "GitHub PR Review from @#{reviewer}:\n\n"
475
- review_context += "Review body:\n#{review["body"]}\n\n" if review["body"] && !review["body"].empty?
432
+ prompt = render_prompt(PROMPT_GITHUB_PR_REVIEW,
433
+ { "CARD_NUMBER" => card_number, "CARD_ID" => card_number,
434
+ "COMMENT_CREATOR" => reviewer, "REVIEW_CONTEXT" => review_context,
435
+ "PR_NUMBER" => pr_number.to_s, "WORKTREE_PATH" => work_dir },
436
+ brain_context: build_brain_context(agent_name: agent_name, card_number: card_number,
437
+ project_key: project_key),
438
+ agent_name: agent_name, channel: :github)
476
439
 
440
+ pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir,
441
+ log_name: "review-#{card_number}", agent_name: agent_name,
442
+ source: :github,
443
+ source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: work_dir })
444
+ register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
445
+ end
446
+
447
+ def build_review_context(reviewer, review, pr_number, repo_name)
448
+ context = "GitHub PR Review from @#{reviewer}:\n\n"
449
+ context += "Review body:\n#{review["body"]}\n\n" if review["body"] && !review["body"].empty?
450
+
451
+ review_comments = fetch_pr_review_comments(pr_number, repo_name)
477
452
  if review_comments.any?
478
- review_context += "Line-specific comments:\n"
453
+ context += "Line-specific comments:\n"
479
454
  review_comments.each do |comment|
480
- review_context += "- #{comment["path"]}:#{comment["line"]} (@#{comment["user"]}): #{comment["body"]}\n"
455
+ context += "- #{comment["path"]}:#{comment["line"]} (@#{comment["user"]}): #{comment["body"]}\n"
481
456
  end
482
457
  end
483
-
484
- # Determine working directory
485
- work_dir = worktree && File.directory?(worktree) ? worktree : repo_path
486
-
487
- prompt = render_prompt(PROMPT_GITHUB_PR_REVIEW,
488
- { "CARD_NUMBER" => card_number,
489
- "CARD_ID" => card_number,
490
- "COMMENT_CREATOR" => reviewer,
491
- "REVIEW_CONTEXT" => review_context,
492
- "PR_NUMBER" => pr_number.to_s,
493
- "WORKTREE_PATH" => work_dir },
494
- brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key),
495
- agent_name: agent_name,
496
- channel: :github)
497
-
498
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir, log_name: "review-#{card_number}", agent_name: agent_name,
499
- source: :github, source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: work_dir })
500
- register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
501
-
502
- [200, { status: "processed", card: card_number, pr: pr_number, reviewer: reviewer, project: project_key }.to_json]
503
- rescue StandardError => e
504
- LOG.error "Error handling PR review: #{e.message}"
505
- [500, { error: e.message }.to_json]
458
+ context
506
459
  end
507
460
 
508
- # Auto-deploy when a PR gets new commits (synchronize event) if the card is already on a dev env.
509
461
  def handle_github_pr_synchronized(payload)
510
462
  pr = payload["pull_request"]
511
463
  branch = pr.dig("head", "ref")
512
- payload.dig("repository", "full_name")
513
464
 
514
465
  result = find_card_by_branch(branch)
515
- unless result
516
- LOG.info "[PR Sync] No card found for branch #{branch}"
517
- return [200, { status: "ignored", reason: "no matching card" }.to_json]
518
- end
466
+ return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
519
467
 
520
468
  _internal_id, card_info = result
521
469
  card_number = card_info["number"]
522
470
  worktree = card_info["worktree"]
523
471
 
524
- unless worktree && File.directory?(worktree)
525
- LOG.info "[PR Sync] No worktree for card ##{card_number}"
526
- return [200, { status: "ignored", reason: "no worktree" }.to_json]
527
- end
472
+ return [200, { status: "ignored", reason: "no worktree" }.to_json] unless worktree && File.directory?(worktree)
528
473
 
529
- # Check if this card is deployed to any environment
530
474
  state = load_deployment_state
531
475
  config = DEPLOYMENTS_CONFIG["environments"] || {}
532
476
  env_key = state.find { |_k, v| v["card_number"] == card_number && v["status"] == "occupied" }&.first
533
477
 
534
- unless env_key
535
- LOG.info "[PR Sync] Card ##{card_number} not deployed to any environment — skipping"
536
- return [200, { status: "ignored", reason: "card not deployed" }.to_json]
537
- end
478
+ return [200, { status: "ignored", reason: "card not deployed" }.to_json] unless env_key
538
479
 
539
- # Only deploy if this machine owns the environment
540
480
  env_owner = config.dig(env_key, "owner")
541
- unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase
542
- LOG.info "[PR Sync] Skipping #{env_key} — owner is #{env_owner.inspect}, this machine is #{AI_AGENT_NAME}"
543
- return [200, { status: "ignored", reason: "not env owner" }.to_json]
544
- end
481
+ return [200, { status: "ignored", reason: "not env owner" }.to_json] unless env_owner && env_owner.downcase == AI_AGENT_NAME.downcase
545
482
 
546
- # Cooldown debounce rapid pushes
547
- if on_deploy_cooldown?(env_key)
548
- LOG.info "[PR Sync] Skipping deploy to #{env_key} — within #{DEPLOY_COOLDOWN}s cooldown"
549
- return [200, { status: "ignored", reason: "deploy cooldown" }.to_json]
550
- end
483
+ return [200, { status: "ignored", reason: "deploy cooldown" }.to_json] if on_deploy_cooldown?(env_key)
551
484
 
552
485
  touch_deploy_cooldown(env_key)
553
486
 
554
- # Pull latest commits into the worktree
555
487
  system("git", "pull", "--ff-only", chdir: worktree)
556
-
557
488
  deploy_script = File.join(worktree, "scripts", "deploy.sh")
558
- unless File.exist?(deploy_script)
559
- LOG.warn "[PR Sync] No deploy script at #{deploy_script}"
560
- return [200, { status: "ignored", reason: "no deploy script" }.to_json]
561
- end
489
+ return [200, { status: "ignored", reason: "no deploy script" }.to_json] unless File.exist?(deploy_script)
562
490
 
563
491
  LOG.info "[PR Sync] Auto-deploying card ##{card_number} to #{env_key} (PR updated)"
564
492
  mark_deploying(env_key, worktree_path: worktree)
493
+ run_pr_sync_deploy(env_key, card_number, worktree, config)
494
+
495
+ [200, { status: "processed", action: "pr_sync_deploy", card: card_number, env: env_key }.to_json]
496
+ rescue StandardError => e
497
+ LOG.error "[PR Sync] Error: #{e.message}"
498
+ [500, { error: e.message }.to_json]
499
+ end
565
500
 
501
+ def run_pr_sync_deploy(env_key, card_number, worktree, config)
566
502
  Thread.new do
567
503
  deploy_env = {}
568
504
  aws_profile = config.dig(env_key, "aws_profile")
569
505
  deploy_env["AWS_PROFILE"] = aws_profile if aws_profile
506
+ deploy_script = File.join(worktree, "scripts", "deploy.sh")
570
507
 
571
508
  stdout, stderr, status = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree)
572
509
 
@@ -574,25 +511,25 @@ def handle_github_pr_synchronized(payload)
574
511
  deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "pr-sync")
575
512
  LOG.info "[PR Sync] Deploy to #{env_key} succeeded for card ##{card_number}"
576
513
  elsif terraform_lock_error?(stdout, stderr)
577
- lock_file = File.join(worktree, "infrastructure/#{env_key}/.terraform.lock.hcl")
578
- FileUtils.rm_f(lock_file)
579
- Open3.capture3("terraform", "init", "-upgrade", chdir: File.join(worktree, "infrastructure/#{env_key}"))
580
- stdout2, stderr2, status2 = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree)
581
- if status2.success?
582
- deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "pr-sync")
583
- LOG.info "[PR Sync] Deploy to #{env_key} succeeded (after terraform lock fix) for card ##{card_number}"
584
- else
585
- record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout2, stderr: stderr2)
586
- LOG.error "[PR Sync] Deploy to #{env_key} failed (after retry) for card ##{card_number}"
587
- end
514
+ retry_pr_sync_deploy(deploy_env, deploy_script, env_key, card_number, worktree)
588
515
  else
589
516
  record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr)
590
517
  LOG.error "[PR Sync] Deploy to #{env_key} failed for card ##{card_number}"
591
518
  end
592
519
  end
520
+ end
593
521
 
594
- [200, { status: "processed", action: "pr_sync_deploy", card: card_number, env: env_key }.to_json]
595
- rescue StandardError => e
596
- LOG.error "[PR Sync] Error: #{e.message}"
597
- [500, { error: e.message }.to_json]
522
+ def retry_pr_sync_deploy(deploy_env, deploy_script, env_key, card_number, worktree)
523
+ infra_dir = File.join(worktree, "infrastructure/#{env_key}")
524
+ FileUtils.rm_f(File.join(infra_dir, ".terraform.lock.hcl"))
525
+ Open3.capture3("terraform", "init", "-upgrade", chdir: infra_dir)
526
+ stdout, stderr, status = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree)
527
+
528
+ if status.success?
529
+ deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "pr-sync")
530
+ LOG.info "[PR Sync] Deploy to #{env_key} succeeded (after terraform lock fix) for card ##{card_number}"
531
+ else
532
+ record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr)
533
+ LOG.error "[PR Sync] Deploy to #{env_key} failed (after retry) for card ##{card_number}"
534
+ end
598
535
  end