brainiac 0.0.6 → 0.0.8

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +136 -6
  4. data/bin/brainiac +472 -44
  5. data/bin/brainiac-completion.bash +1 -1
  6. data/lib/brainiac/agents.rb +27 -74
  7. data/lib/brainiac/brain.rb +6 -6
  8. data/lib/brainiac/config.rb +40 -76
  9. data/lib/brainiac/handlers/discord/api.rb +196 -0
  10. data/lib/brainiac/handlers/discord/config.rb +134 -0
  11. data/lib/brainiac/handlers/discord/delivery.rb +196 -0
  12. data/lib/brainiac/handlers/discord/gateway.rb +212 -0
  13. data/lib/brainiac/handlers/discord/message.rb +933 -0
  14. data/lib/brainiac/handlers/discord/reactions.rb +215 -0
  15. data/lib/brainiac/handlers/discord.rb +14 -1892
  16. data/lib/brainiac/handlers/github.rb +134 -317
  17. data/lib/brainiac/handlers/shared/git.rb +190 -0
  18. data/lib/brainiac/handlers/shared/inline_tags.rb +89 -0
  19. data/lib/brainiac/handlers/zoho.rb +103 -153
  20. data/lib/brainiac/helpers.rb +43 -455
  21. data/lib/brainiac/hooks.rb +86 -0
  22. data/lib/brainiac/plugins.rb +154 -0
  23. data/lib/brainiac/prompts.rb +34 -172
  24. data/lib/brainiac/restart.rb +112 -0
  25. data/lib/brainiac/routes/api.rb +411 -0
  26. data/lib/brainiac/users.rb +1 -7
  27. data/lib/brainiac/version.rb +1 -1
  28. data/lib/brainiac/zoho_mail_api.rb +2 -1
  29. data/lib/brainiac.rb +8 -1
  30. data/monitor/daemon.rb +4 -27
  31. data/monitor/shared.rb +247 -0
  32. data/monitor/{waybar-deploy-env.rb → waybar/deploy_env.rb} +17 -89
  33. data/monitor/waybar/setup.rb +232 -0
  34. data/monitor/waybar/status.rb +51 -0
  35. data/monitor/{view-logs-rofi.rb → waybar/view_logs.rb} +21 -88
  36. data/monitor/{deploy-env-macos.rb → xbar/deploy_env.rb} +3 -2
  37. data/monitor/xbar/plugin.rb +149 -0
  38. data/monitor/{setup-menubar.rb → xbar/setup.rb} +14 -30
  39. data/receiver.rb +44 -551
  40. data/templates/agents.json.example +1 -2
  41. data/templates/brainiac.json.example +8 -0
  42. data/templates/cli-providers/kiro.json.example +8 -2
  43. data/templates/plugins.json.example +3 -0
  44. data/templates/users.json.example +0 -3
  45. metadata +25 -23
  46. data/lib/brainiac/card_index.rb +0 -389
  47. data/lib/brainiac/deployments.rb +0 -258
  48. data/lib/brainiac/handlers/fizzy.rb +0 -1292
  49. data/lib/brainiac/planning.rb +0 -237
  50. data/lib/user_registry.rb +0 -159
  51. data/monitor/menubar.rb +0 -295
  52. data/monitor/setup-waybar-deploy-envs.rb +0 -121
  53. data/monitor/setup-waybar-deployments.rb +0 -96
  54. data/monitor/setup-waybar-module.rb +0 -113
  55. data/monitor/setup-xbar-plugin.rb +0 -35
  56. data/monitor/view-logs.rb +0 -119
  57. data/monitor/waybar-config-updater.rb +0 -56
  58. data/monitor/waybar-deployments.rb +0 -239
  59. data/monitor/waybar.rb +0 -146
  60. data/monitor/xbar.3s.rb +0 -179
  61. data/templates/fizzy.json.example +0 -24
  62. /data/monitor/{open-action.sh → xbar/open_action.sh} +0 -0
  63. /data/monitor/{view-logs-macos.rb → xbar/view_logs.rb} +0 -0
@@ -5,14 +5,9 @@
5
5
  # Fallback column ID for backwards compatibility when no board config exists
6
6
  DEFAULT_UAT_COLUMN_ID = "03fsmglsr6az06ppyotawsti8"
7
7
 
8
- def uat_column_id(project_config)
9
- bk = board_key_for_project(project_config)
10
- (bk && board_column_id(bk, "uat")) || DEFAULT_UAT_COLUMN_ID
11
- end
12
-
13
- # Find a Fizzy card by matching the PR's head branch to a branch in the card map.
14
- def find_card_by_branch(branch)
15
- map = load_card_map
8
+ # Find a work item by matching the PR's head branch to a branch in the card map.
9
+ def find_work_item_by_branch(branch)
10
+ map = load_work_item_map
16
11
  map.each do |internal_id, info|
17
12
  next unless info["branch"] == branch
18
13
 
@@ -21,14 +16,14 @@ def find_card_by_branch(branch)
21
16
  nil
22
17
  end
23
18
 
24
- # Track a newly opened PR in the card map by matching its branch.
25
- def track_pr_in_card_map(payload)
19
+ # Track a newly opened PR in the work item map by matching its branch.
20
+ def track_pr_in_work_items(payload)
26
21
  pr = payload["pull_request"]
27
22
  branch = pr.dig("head", "ref")
28
23
  pr_number = pr["number"]
29
24
  pr_url = pr["html_url"]
30
25
 
31
- result = find_card_by_branch(branch)
26
+ result = find_work_item_by_branch(branch)
32
27
  unless result
33
28
  LOG.info "[PR Track] No card found for branch #{branch}"
34
29
  return
@@ -41,9 +36,9 @@ def track_pr_in_card_map(payload)
41
36
  prs << { "number" => pr_number, "url" => pr_url }
42
37
  card_info["prs"] = prs
43
38
 
44
- map = load_card_map
39
+ map = load_work_item_map
45
40
  map[internal_id] = card_info
46
- save_card_map(map)
41
+ save_work_item_map(map)
47
42
  LOG.info "[PR Track] Tracked PR ##{pr_number} on card ##{card_info["number"]} (branch: #{branch})"
48
43
  end
49
44
 
@@ -57,15 +52,6 @@ rescue StandardError => e
57
52
  end
58
53
 
59
54
  # Check if a PR link is already present in the card's comments.
60
- def pr_link_already_commented?(card_number, pr_url, chdir:, env: default_fizzy_env)
61
- output = run_cmd("fizzy", "comment", "list", "--card", card_number.to_s, chdir: chdir, env: env)
62
- data = JSON.parse(output)
63
- comments = data["data"] || []
64
- comments.any? { |c| (c.dig("body", "plain_text") || "").include?(pr_url) }
65
- rescue StandardError => e
66
- LOG.warn "Could not check existing comments for card ##{card_number}: #{e.message}"
67
- false
68
- end
69
55
 
70
56
  def handle_github_pr_merged(payload)
71
57
  pr = payload["pull_request"]
@@ -75,14 +61,12 @@ def handle_github_pr_merged(payload)
75
61
  pr_title = pr["title"]
76
62
  repo_full_name = payload.dig("repository", "full_name")
77
63
 
78
- # Only act on merges into the repo's default branch
79
64
  default_branch = payload.dig("repository", "default_branch") || "main"
80
65
  unless base == default_branch
81
66
  LOG.info "PR merged into #{base}, not #{default_branch} — ignoring"
82
67
  return [200, { status: "ignored", reason: "not merged into #{default_branch}" }.to_json]
83
68
  end
84
69
 
85
- # Identify project by GitHub repo
86
70
  project_result = identify_project_by_repo(repo_full_name)
87
71
  unless project_result
88
72
  LOG.info "No project found for GitHub repo #{repo_full_name}"
@@ -92,65 +76,21 @@ def handle_github_pr_merged(payload)
92
76
  project_key, project_config = project_result
93
77
  repo_path = project_config["repo_path"]
94
78
 
95
- result = find_card_by_branch(branch)
79
+ result = find_work_item_by_branch(branch)
96
80
  unless result
97
- LOG.info "No Fizzy card found for branch #{branch}"
81
+ LOG.info "No card found for branch #{branch}"
98
82
  return [200, { status: "ignored", reason: "no matching card" }.to_json]
99
83
  end
100
84
 
101
85
  internal_id, card_info = result
102
86
  card_number = card_info["number"]
103
-
104
87
  unless card_number
105
88
  LOG.warn "Card #{internal_id} has no number — can't comment or move"
106
89
  return [200, { status: "ignored", reason: "card has no number" }.to_json]
107
90
  end
108
91
 
109
92
  LOG.info "PR merged into main for card ##{card_number} (project: #{project_key}): #{pr_url}"
110
-
111
- # Use the card's assigned agent identity for fizzy interactions
112
- card_agent = card_info["agent"]
113
- card_fizzy_env = fizzy_env_for(card_agent)
114
-
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
- comment_body = "<p>PR merged into main: <a href=\"#{pr_url}\">#{pr_title}</a></p><p>Branch: <code>#{branch}</code></p>"
120
- 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
- end
123
-
124
- # Move card to UAT column — merged into main means it's deployed to UAT
125
- mark_card_merged(card_number)
126
- run_cmd("fizzy", "card", "column", card_number.to_s, "--column", uat_column_id(project_config), chdir: repo_path, env: card_fizzy_env)
127
- 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
- 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
- clear_deployment_for_card(card_number)
135
-
136
- # Dispatch agent to comment UAT testing steps on the Fizzy card
137
- agent_name = card_agent || agent_name_for(project_config)
138
- card_title = card_info["title"] || pr_title
139
-
140
- 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,
148
- board_key: board_key_for_project(project_config))
149
-
150
- pid, log_file = run_agent(prompt, project_config: project_config, chdir: repo_path, log_name: "uat-#{card_number}", agent_name: agent_name,
151
- source: :fizzy, source_context: { card_number: card_number }, skip_column_move: true)
152
- register_session("card-#{card_number}", pid, log_file: log_file, agent_name: agent_name)
153
- LOG.info "Dispatched #{agent_name} for UAT testing steps on card ##{card_number}"
93
+ process_merged_pr(card_info, card_number, branch, pr, pr_url, pr_title, project_key, project_config, repo_path)
154
94
 
155
95
  [200, { status: "processed", card: card_number, pr: pr_url, action: "merged_to_uat", project: project_key }.to_json]
156
96
  rescue StandardError => e
@@ -158,6 +98,24 @@ rescue StandardError => e
158
98
  [500, { error: e.message }.to_json]
159
99
  end
160
100
 
101
+ def process_merged_pr(card_info, card_number, branch, pull_request, pr_url, pr_title, project_key, project_config, repo_path)
102
+ mark_work_item_merged(card_number)
103
+ cleanup_work_item_worktrees(card_number, repo_path: repo_path, primary_worktree: card_info["worktree"], primary_branch: branch)
104
+
105
+ # Emit hook — plugins handle their own post-merge actions
106
+ # (e.g., card plugin posts PR link, moves card column, dispatches UAT agent)
107
+ Brainiac.emit(:pr_merged,
108
+ card_number: card_number,
109
+ card_info: card_info,
110
+ branch: branch,
111
+ pull_request: pull_request,
112
+ pr_url: pr_url,
113
+ pr_title: pr_title,
114
+ project_key: project_key,
115
+ project_config: project_config,
116
+ repo_path: repo_path)
117
+ end
118
+
161
119
  def handle_github_issue_comment(payload)
162
120
  comment = payload["comment"]
163
121
  issue = payload["issue"]
@@ -166,13 +124,11 @@ def handle_github_issue_comment(payload)
166
124
  comment_user = comment.dig("user", "login")
167
125
  repo_name = payload.dig("repository", "full_name")
168
126
 
169
- # Only process if this is a PR (issues have pull_request key when they're PRs)
170
127
  unless issue["pull_request"]
171
128
  LOG.info "Issue comment on non-PR issue ##{issue["number"]}, ignoring"
172
129
  return [200, { status: "ignored", reason: "not a PR comment" }.to_json]
173
130
  end
174
131
 
175
- # Identify project by GitHub repo
176
132
  project_result = identify_project_by_repo(repo_name)
177
133
  unless project_result
178
134
  LOG.info "No project found for GitHub repo #{repo_name}"
@@ -180,17 +136,15 @@ def handle_github_issue_comment(payload)
180
136
  end
181
137
 
182
138
  project_key, project_config = project_result
183
-
184
139
  pr_number = issue["number"]
185
- issue["html_url"]
186
140
 
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"])
141
+ pr_data = run_cmd("gh", "api", "/repos/#{repo_name}/pulls/#{pr_number}", "--jq", "{branch: .head.ref}",
142
+ chdir: project_config["repo_path"])
189
143
  branch = JSON.parse(pr_data)["branch"]
190
144
 
191
- result = find_card_by_branch(branch)
145
+ result = find_work_item_by_branch(branch)
192
146
  unless result
193
- LOG.info "No Fizzy card found for PR ##{pr_number} (branch: #{branch})"
147
+ LOG.info "No card found for PR ##{pr_number} (branch: #{branch})"
194
148
  return [200, { status: "ignored", reason: "no matching card" }.to_json]
195
149
  end
196
150
 
@@ -198,54 +152,52 @@ def handle_github_issue_comment(payload)
198
152
  card_number = card_info["number"]
199
153
  worktree = card_info["worktree"]
200
154
 
201
- # Only process if worktree exists
202
155
  unless worktree && File.directory?(worktree)
203
156
  LOG.info "No active worktree for PR ##{pr_number}, ignoring comment"
204
157
  return [200, { status: "ignored", reason: "no active worktree" }.to_json]
205
158
  end
206
159
 
207
- LOG.info "PR comment from #{comment_user} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
208
-
209
160
  card_key = "card-#{card_number}"
210
161
  if session_active?(card_key)
211
162
  LOG.info "Skipping PR comment on card ##{card_number} — agent session already active"
212
163
  return [200, { status: "ignored", reason: "session already active" }.to_json]
213
164
  end
214
165
 
215
- # React in background don't block the dispatch path
166
+ LOG.info "PR comment from #{comment_user} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
167
+ dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
168
+ repo_name, worktree, project_key, project_config)
169
+
170
+ [200, { status: "processed", card: card_number, pr: pr_number, comment_id: comment_id, project: project_key }.to_json]
171
+ rescue StandardError => e
172
+ LOG.error "Error handling PR comment: #{e.message}"
173
+ [500, { error: e.message }.to_json]
174
+ end
175
+
176
+ def dispatch_pr_comment(card_number, card_key, pr_number, comment_id, comment_user, comment_body,
177
+ repo_name, worktree, project_key, project_config)
216
178
  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}"
179
+ run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/issues/comments/#{comment_id}/reactions",
180
+ "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: worktree)
220
181
  rescue StandardError => e
221
182
  LOG.warn "Could not add reaction to comment: #{e.message}"
222
183
  end
223
184
 
224
- # Detect model override
225
- model = detect_model(project_config, text: comment_body)
226
- effort = detect_effort(project_config, text: comment_body)
227
185
  agent_name = agent_name_for(project_config)
228
-
229
186
  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 })
187
+ { "CARD_NUMBER" => card_number, "CARD_ID" => card_number,
188
+ "COMMENT_CREATOR" => comment_user, "COMMENT_BODY" => comment_body,
189
+ "PR_NUMBER" => pr_number.to_s, "WORKTREE_PATH" => worktree },
190
+ brain_context: build_brain_context(agent_name: agent_name, card_number: card_number,
191
+ project_key: project_key, comment_body: comment_body),
192
+ agent_name: agent_name, channel: :github)
193
+
194
+ pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree,
195
+ log_name: "pr-comment-#{pr_number}",
196
+ model: detect_model(project_config, text: comment_body),
197
+ effort: detect_effort(project_config, text: comment_body),
198
+ agent_name: agent_name, source: :github,
199
+ source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: worktree })
243
200
  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
201
  end
250
202
 
251
203
  def handle_github_workflow_run(payload)
@@ -255,93 +207,45 @@ def handle_github_workflow_run(payload)
255
207
  repo_full_name = payload.dig("repository", "full_name")
256
208
  run_url = workflow["html_url"]
257
209
 
258
- # Handle Deploy to Production failure
259
210
  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
211
+ project_key = identify_project_by_repo(repo_full_name)&.first || repo_full_name
262
212
  send_workflow_failure_notification(project_key, workflow_name, run_url)
263
- LOG.info "Deploy to Production failed for #{project_key} — notified Discord"
264
213
  return [200, { status: "processed", action: "prod_deploy_failure_notified", project: project_key }.to_json]
265
214
  end
266
215
 
267
- # Handle Deploy to UAT success
268
216
  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
217
+ project_key = identify_project_by_repo(repo_full_name)&.first || repo_full_name
271
218
  send_uat_deploy_notification(project_key)
272
- LOG.info "Deploy to UAT succeeded for #{project_key} — notified Discord"
273
219
  return [200, { status: "processed", action: "uat_deploy_notified", project: project_key }.to_json]
274
220
  end
275
221
 
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
222
+ return [200, { status: "ignored", reason: "conclusion: #{conclusion}" }.to_json] unless conclusion == "success"
280
223
 
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
224
+ return [200, { status: "ignored", reason: "workflow: #{workflow_name}" }.to_json] unless workflow_name == "Deploy to Production"
285
225
 
286
226
  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
227
+ return [200, { status: "ignored", reason: "no matching project" }.to_json] unless project_result
291
228
 
292
229
  project_key, project_config = project_result
293
- repo_path = project_config["repo_path"]
294
-
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"] || []
299
-
300
- if card_list.empty?
301
- LOG.info "No cards in UAT column — nothing to close"
302
- return [200, { status: "processed", action: "no_uat_cards", project: project_key }.to_json]
303
- end
304
-
305
- closed_cards = []
306
- map = load_card_map
307
- card_list.each do |card|
308
- card_number = card["number"]
309
- next unless card_number
310
-
311
- # Try to find the card in our map to get the assigned agent
312
- map_entry = map.values.find { |info| info["number"] == card_number }
313
- agent_name = map_entry["agent"] if map_entry
314
-
315
- env = agent_name ? fizzy_env_for(agent_name) : default_fizzy_env
316
-
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)
319
- run_cmd("fizzy", "card", "close", card_number.to_s, chdir: repo_path, env: env)
320
-
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)
230
+ close_uat_cards_after_deploy(project_key, project_config)
231
+ rescue StandardError => e
232
+ LOG.error "Error handling workflow run: #{e.message}"
233
+ [500, { error: e.message }.to_json]
234
+ end
325
235
 
326
- # Clean up card map entry
327
- if map_entry
328
- internal_id = map.key(map_entry)
329
- map.delete(internal_id)
330
- end
236
+ def close_uat_cards_after_deploy(project_key, project_config)
237
+ # Emit hook — work item plugins handle closing cards after production deploy
238
+ results = Brainiac.emit(:production_deployed, project_key: project_key, project_config: project_config)
239
+ closed_cards = results.flatten.compact
331
240
 
332
- closed_cards << { number: card_number, url: card["url"], title: card["title"] }
333
- LOG.info "Closed UAT card ##{card_number} after prod deploy (agent: #{agent_name || "default"})"
241
+ if closed_cards.any?
242
+ send_deploy_notification(project_key, closed_cards)
243
+ LOG.info "Prod deploy complete — closed #{closed_cards.size} cards"
244
+ else
245
+ LOG.info "Prod deploy processed — no cards closed (plugin may not be installed)"
334
246
  end
335
247
 
336
- 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]
248
+ [200, { status: "processed", action: "prod_deploy", closed_cards: closed_cards.map { |c| c[:number] }, project: project_key }.to_json]
345
249
  end
346
250
 
347
251
  def send_deploy_notification(project_key, closed_cards)
@@ -402,196 +306,109 @@ def handle_github_pr_review_submitted(payload)
402
306
  review = payload["review"]
403
307
  branch = pr.dig("head", "ref")
404
308
  pr_number = pr["number"]
405
- pr["html_url"]
406
309
  repo_name = payload.dig("repository", "full_name")
407
310
  review_state = review["state"]
408
311
  reviewer = review.dig("user", "login")
409
312
 
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
313
+ return [200, { status: "ignored", reason: "review state: #{review_state}" }.to_json] unless %w[changes_requested commented].include?(review_state)
415
314
 
416
- # Identify project by GitHub repo
417
315
  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
316
+ return [200, { status: "ignored", reason: "no matching project" }.to_json] unless project_result
422
317
 
423
318
  project_key, project_config = project_result
424
319
  repo_path = project_config["repo_path"]
425
320
 
426
- 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
321
+ result = find_work_item_by_branch(branch)
322
+ return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
431
323
 
432
324
  internal_id, card_info = result
433
325
  card_number = card_info["number"]
434
- worktree = card_info["worktree"]
435
-
436
326
  unless card_number
437
327
  LOG.warn "Card #{internal_id} has no number — can't comment"
438
328
  return [200, { status: "ignored", reason: "card has no number" }.to_json]
439
329
  end
440
330
 
331
+ card_key = "card-#{card_number}"
332
+ return [200, { status: "ignored", reason: "session already active" }.to_json] if session_active?(card_key)
333
+
441
334
  LOG.info "PR review submitted by #{reviewer} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
335
+ dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer, repo_name, project_key, project_config, repo_path)
442
336
 
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
337
+ [200, { status: "processed", card: card_number, pr: pr_number, reviewer: reviewer, project: project_key }.to_json]
338
+ rescue StandardError => e
339
+ LOG.error "Error handling PR review: #{e.message}"
340
+ [500, { error: e.message }.to_json]
341
+ end
448
342
 
449
- # React to the review with 👀 to show we're starting
343
+ def dispatch_pr_review(card_number, card_key, card_info, pr_number, review, reviewer, repo_name, project_key, project_config, repo_path)
450
344
  review_id = review["id"]
451
- # React in background — don't block the dispatch path
452
345
  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}"
346
+ run_cmd("gh", "api", "-X", "POST", "/repos/#{repo_name}/pulls/reviews/#{review_id}/reactions",
347
+ "-f", "content=eyes", "-H", "Accept: application/vnd.github+json", chdir: repo_path)
456
348
  rescue StandardError => e
457
349
  LOG.warn "Could not add reaction to review: #{e.message}"
458
350
  end
459
351
 
460
- # Post status to Fizzy in background — don't block the dispatch path
461
352
  agent_name = agent_name_for(project_config)
462
- Thread.new do
463
- 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}"
466
- rescue StandardError => e
467
- LOG.warn "Could not post status update to card ##{card_number}: #{e.message}"
468
- end
353
+ # Notify work item system that a review was received (plugin posts status comment)
354
+ Brainiac.emit(:pr_review_received, card_number: card_number, reviewer: reviewer,
355
+ agent_name: agent_name, project_config: project_config, repo_path: repo_path)
469
356
 
470
- # Fetch all review comments (line-specific comments)
471
- review_comments = fetch_pr_review_comments(pr_number, repo_name)
357
+ review_context = build_review_context(reviewer, review, pr_number, repo_name)
358
+ worktree = card_info["worktree"]
359
+ work_dir = worktree && File.directory?(worktree) ? worktree : repo_path
360
+
361
+ prompt = render_prompt(PROMPT_GITHUB_PR_REVIEW,
362
+ { "CARD_NUMBER" => card_number, "CARD_ID" => card_number,
363
+ "COMMENT_CREATOR" => reviewer, "REVIEW_CONTEXT" => review_context,
364
+ "PR_NUMBER" => pr_number.to_s, "WORKTREE_PATH" => work_dir },
365
+ brain_context: build_brain_context(agent_name: agent_name, card_number: card_number,
366
+ project_key: project_key),
367
+ agent_name: agent_name, channel: :github)
368
+
369
+ pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir,
370
+ log_name: "review-#{card_number}", agent_name: agent_name,
371
+ source: :github,
372
+ source_context: { pr_number: pr_number, repo_name: repo_name, work_dir: work_dir })
373
+ register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
374
+ end
472
375
 
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?
376
+ def build_review_context(reviewer, review, pr_number, repo_name)
377
+ context = "GitHub PR Review from @#{reviewer}:\n\n"
378
+ context += "Review body:\n#{review["body"]}\n\n" if review["body"] && !review["body"].empty?
476
379
 
380
+ review_comments = fetch_pr_review_comments(pr_number, repo_name)
477
381
  if review_comments.any?
478
- review_context += "Line-specific comments:\n"
382
+ context += "Line-specific comments:\n"
479
383
  review_comments.each do |comment|
480
- review_context += "- #{comment["path"]}:#{comment["line"]} (@#{comment["user"]}): #{comment["body"]}\n"
384
+ context += "- #{comment["path"]}:#{comment["line"]} (@#{comment["user"]}): #{comment["body"]}\n"
481
385
  end
482
386
  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]
387
+ context
506
388
  end
507
389
 
508
- # Auto-deploy when a PR gets new commits (synchronize event) if the card is already on a dev env.
509
390
  def handle_github_pr_synchronized(payload)
510
391
  pr = payload["pull_request"]
511
392
  branch = pr.dig("head", "ref")
512
- payload.dig("repository", "full_name")
513
393
 
514
- 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
394
+ result = find_work_item_by_branch(branch)
395
+ return [200, { status: "ignored", reason: "no matching card" }.to_json] unless result
519
396
 
520
397
  _internal_id, card_info = result
521
398
  card_number = card_info["number"]
522
399
  worktree = card_info["worktree"]
523
400
 
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
401
+ return [200, { status: "ignored", reason: "no worktree" }.to_json] unless worktree && File.directory?(worktree)
528
402
 
529
- # Check if this card is deployed to any environment
530
- state = load_deployment_state
531
- config = DEPLOYMENTS_CONFIG["environments"] || {}
532
- env_key = state.find { |_k, v| v["card_number"] == card_number && v["status"] == "occupied" }&.first
403
+ # Emit hook deployment plugin handles sync-deploy logic
404
+ results = Brainiac.emit(:pr_synchronized, card_number: card_number, card_info: card_info,
405
+ worktree: worktree, pull_request: pr, branch: branch)
533
406
 
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
538
-
539
- # Only deploy if this machine owns the environment
540
- 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
545
-
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
551
-
552
- touch_deploy_cooldown(env_key)
553
-
554
- # Pull latest commits into the worktree
555
- system("git", "pull", "--ff-only", chdir: worktree)
556
-
557
- 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
562
-
563
- LOG.info "[PR Sync] Auto-deploying card ##{card_number} to #{env_key} (PR updated)"
564
- mark_deploying(env_key, worktree_path: worktree)
565
-
566
- Thread.new do
567
- deploy_env = {}
568
- aws_profile = config.dig(env_key, "aws_profile")
569
- deploy_env["AWS_PROFILE"] = aws_profile if aws_profile
570
-
571
- stdout, stderr, status = Open3.capture3(deploy_env, deploy_script, env_key, chdir: worktree)
572
-
573
- if status.success?
574
- deploy_to_environment(env_key, worktree_path: worktree, deployed_by: "pr-sync")
575
- LOG.info "[PR Sync] Deploy to #{env_key} succeeded for card ##{card_number}"
576
- 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
588
- else
589
- record_deploy_failure(env_key, worktree_path: worktree, stdout: stdout, stderr: stderr)
590
- LOG.error "[PR Sync] Deploy to #{env_key} failed for card ##{card_number}"
591
- end
407
+ if results.any?
408
+ [200, { status: "processed", action: "pr_sync", card: card_number }.to_json]
409
+ else
410
+ [200, { status: "ignored", reason: "no deployment plugin" }.to_json]
592
411
  end
593
-
594
- [200, { status: "processed", action: "pr_sync_deploy", card: card_number, env: env_key }.to_json]
595
412
  rescue StandardError => e
596
413
  LOG.error "[PR Sync] Error: #{e.message}"
597
414
  [500, { error: e.message }.to_json]