zillacore 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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +126 -0
  6. data/README.md +1166 -0
  7. data/Rakefile +12 -0
  8. data/bin/zillacore +1521 -0
  9. data/certs/stowzilla.pem +26 -0
  10. data/docs/waybar-config.md +96 -0
  11. data/lib/user_registry.rb +159 -0
  12. data/lib/zillacore/agents.rb +203 -0
  13. data/lib/zillacore/brain.rb +197 -0
  14. data/lib/zillacore/card_index.rb +389 -0
  15. data/lib/zillacore/config.rb +263 -0
  16. data/lib/zillacore/cron.rb +629 -0
  17. data/lib/zillacore/deployments.rb +258 -0
  18. data/lib/zillacore/handlers/discord.rb +1643 -0
  19. data/lib/zillacore/handlers/fizzy.rb +1249 -0
  20. data/lib/zillacore/handlers/github.rb +598 -0
  21. data/lib/zillacore/handlers/zoho.rb +487 -0
  22. data/lib/zillacore/helpers.rb +760 -0
  23. data/lib/zillacore/planning.rb +237 -0
  24. data/lib/zillacore/prompts.rb +620 -0
  25. data/lib/zillacore/sessions.rb +282 -0
  26. data/lib/zillacore/skills.rb +276 -0
  27. data/lib/zillacore/users.rb +76 -0
  28. data/lib/zillacore/version.rb +6 -0
  29. data/lib/zillacore/zoho_mail_api.rb +109 -0
  30. data/lib/zillacore.rb +10 -0
  31. data/monitor/daemon.rb +99 -0
  32. data/monitor/deploy-env-macos.rb +131 -0
  33. data/monitor/menubar.rb +295 -0
  34. data/monitor/open-action.sh +15 -0
  35. data/monitor/setup-menubar.rb +78 -0
  36. data/monitor/setup-waybar-deploy-envs.rb +121 -0
  37. data/monitor/setup-waybar-deployments.rb +96 -0
  38. data/monitor/setup-waybar-module.rb +113 -0
  39. data/monitor/setup-xbar-plugin.rb +35 -0
  40. data/monitor/view-logs-macos.rb +210 -0
  41. data/monitor/view-logs-rofi.rb +194 -0
  42. data/monitor/view-logs.rb +119 -0
  43. data/monitor/waybar-config-updater.rb +56 -0
  44. data/monitor/waybar-deploy-env.rb +206 -0
  45. data/monitor/waybar-deployments.rb +239 -0
  46. data/monitor/waybar.rb +146 -0
  47. data/monitor/xbar.3s.rb +179 -0
  48. data/receiver.rb +956 -0
  49. data/templates/agents.json.example +10 -0
  50. data/templates/discord.json.example +17 -0
  51. data/templates/fizzy.json.example +24 -0
  52. data/templates/github.json.example +4 -0
  53. data/templates/testflight.json.example +8 -0
  54. data/templates/users.json.example +121 -0
  55. data/templates/zoho.json.example +27 -0
  56. data/views/dashboard.erb +437 -0
  57. data/zillacore.gemspec +30 -0
  58. data.tar.gz.sig +2 -0
  59. metadata +235 -0
  60. metadata.gz.sig +0 -0
@@ -0,0 +1,598 @@
1
+ # frozen_string_literal: true
2
+
3
+ # GitHub webhook handlers: PR merges, reviews, and issue comments.
4
+
5
+ # Fallback column ID for backwards compatibility when no board config exists
6
+ DEFAULT_UAT_COLUMN_ID = "03fsmglsr6az06ppyotawsti8"
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
16
+ map.each do |internal_id, info|
17
+ next unless info["branch"] == branch
18
+
19
+ return [internal_id, info]
20
+ end
21
+ nil
22
+ end
23
+
24
+ # Track a newly opened PR in the card map by matching its branch.
25
+ def track_pr_in_card_map(payload)
26
+ pr = payload["pull_request"]
27
+ branch = pr.dig("head", "ref")
28
+ pr_number = pr["number"]
29
+ pr_url = pr["html_url"]
30
+
31
+ result = find_card_by_branch(branch)
32
+ unless result
33
+ LOG.info "[PR Track] No card found for branch #{branch}"
34
+ return
35
+ end
36
+
37
+ internal_id, card_info = result
38
+ prs = card_info["prs"] || []
39
+ return if prs.any? { |p| p["number"] == pr_number }
40
+
41
+ prs << { "number" => pr_number, "url" => pr_url }
42
+ card_info["prs"] = prs
43
+
44
+ map = load_card_map
45
+ map[internal_id] = card_info
46
+ save_card_map(map)
47
+ LOG.info "[PR Track] Tracked PR ##{pr_number} on card ##{card_info["number"]} (branch: #{branch})"
48
+ end
49
+
50
+ # Fetch review comments from a PR using GitHub CLI
51
+ def fetch_pr_review_comments(pr_number, repo)
52
+ output = run_cmd("gh", "api", "/repos/#{repo}/pulls/#{pr_number}/comments", "--jq", ".[] | {path, line, body, user: .user.login}")
53
+ output.lines.map { |line| JSON.parse(line) }
54
+ rescue StandardError => e
55
+ LOG.warn "Could not fetch PR review comments: #{e.message}"
56
+ []
57
+ end
58
+
59
+ # 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
+
70
+ def handle_github_pr_merged(payload)
71
+ pr = payload["pull_request"]
72
+ branch = pr.dig("head", "ref")
73
+ base = pr.dig("base", "ref")
74
+ pr_url = pr["html_url"]
75
+ pr_title = pr["title"]
76
+ repo_full_name = payload.dig("repository", "full_name")
77
+
78
+ # Only act on merges into the repo's default branch
79
+ default_branch = payload.dig("repository", "default_branch") || "main"
80
+ unless base == default_branch
81
+ LOG.info "PR merged into #{base}, not #{default_branch} — ignoring"
82
+ return [200, { status: "ignored", reason: "not merged into #{default_branch}" }.to_json]
83
+ end
84
+
85
+ # Identify project by GitHub repo
86
+ project_result = identify_project_by_repo(repo_full_name)
87
+ unless project_result
88
+ LOG.info "No project found for GitHub repo #{repo_full_name}"
89
+ return [200, { status: "ignored", reason: "no matching project" }.to_json]
90
+ end
91
+
92
+ project_key, project_config = project_result
93
+ repo_path = project_config["repo_path"]
94
+
95
+ result = find_card_by_branch(branch)
96
+ unless result
97
+ LOG.info "No Fizzy card found for branch #{branch}"
98
+ return [200, { status: "ignored", reason: "no matching card" }.to_json]
99
+ end
100
+
101
+ internal_id, card_info = result
102
+ card_number = card_info["number"]
103
+
104
+ unless card_number
105
+ LOG.warn "Card #{internal_id} has no number — can't comment or move"
106
+ return [200, { status: "ignored", reason: "card has no number" }.to_json]
107
+ end
108
+
109
+ 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}"
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
+ end
160
+
161
+ def handle_github_issue_comment(payload)
162
+ comment = payload["comment"]
163
+ issue = payload["issue"]
164
+ comment_body = comment["body"] || ""
165
+ comment_id = comment["id"]
166
+ comment_user = comment.dig("user", "login")
167
+ repo_name = payload.dig("repository", "full_name")
168
+
169
+ # Only process if this is a PR (issues have pull_request key when they're PRs)
170
+ unless issue["pull_request"]
171
+ LOG.info "Issue comment on non-PR issue ##{issue["number"]}, ignoring"
172
+ return [200, { status: "ignored", reason: "not a PR comment" }.to_json]
173
+ end
174
+
175
+ # Identify project by GitHub repo
176
+ project_result = identify_project_by_repo(repo_name)
177
+ unless project_result
178
+ LOG.info "No project found for GitHub repo #{repo_name}"
179
+ return [200, { status: "ignored", reason: "no matching project" }.to_json]
180
+ end
181
+
182
+ project_key, project_config = project_result
183
+
184
+ pr_number = issue["number"]
185
+ issue["html_url"]
186
+
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"])
189
+ branch = JSON.parse(pr_data)["branch"]
190
+
191
+ result = find_card_by_branch(branch)
192
+ unless result
193
+ LOG.info "No Fizzy card found for PR ##{pr_number} (branch: #{branch})"
194
+ return [200, { status: "ignored", reason: "no matching card" }.to_json]
195
+ end
196
+
197
+ _, card_info = result
198
+ card_number = card_info["number"]
199
+ worktree = card_info["worktree"]
200
+
201
+ # Only process if worktree exists
202
+ unless worktree && File.directory?(worktree)
203
+ LOG.info "No active worktree for PR ##{pr_number}, ignoring comment"
204
+ return [200, { status: "ignored", reason: "no active worktree" }.to_json]
205
+ end
206
+
207
+ LOG.info "PR comment from #{comment_user} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
208
+
209
+ card_key = "card-#{card_number}"
210
+ if session_active?(card_key)
211
+ LOG.info "Skipping PR comment on card ##{card_number} — agent session already active"
212
+ return [200, { status: "ignored", reason: "session already active" }.to_json]
213
+ end
214
+
215
+ # React in background — don't block the dispatch path
216
+ 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}"
220
+ rescue StandardError => e
221
+ LOG.warn "Could not add reaction to comment: #{e.message}"
222
+ end
223
+
224
+ # Detect model override
225
+ model = detect_model(project_config, text: comment_body)
226
+ effort = detect_effort(project_config, text: comment_body)
227
+ agent_name = agent_name_for(project_config)
228
+
229
+ 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 })
243
+ 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
+ end
250
+
251
+ def handle_github_workflow_run(payload)
252
+ workflow = payload["workflow_run"]
253
+ workflow_name = workflow["name"]
254
+ conclusion = workflow["conclusion"]
255
+ repo_full_name = payload.dig("repository", "full_name")
256
+ run_url = workflow["html_url"]
257
+
258
+ # Handle Deploy to Production failure
259
+ 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
262
+ send_workflow_failure_notification(project_key, workflow_name, run_url)
263
+ LOG.info "Deploy to Production failed for #{project_key} — notified Discord"
264
+ return [200, { status: "processed", action: "prod_deploy_failure_notified", project: project_key }.to_json]
265
+ end
266
+
267
+ # Handle Deploy to UAT success
268
+ 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
271
+ send_uat_deploy_notification(project_key)
272
+ LOG.info "Deploy to UAT succeeded for #{project_key} — notified Discord"
273
+ return [200, { status: "processed", action: "uat_deploy_notified", project: project_key }.to_json]
274
+ end
275
+
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
280
+
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
285
+
286
+ 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
291
+
292
+ 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)
325
+
326
+ # Clean up card map entry
327
+ if map_entry
328
+ internal_id = map.key(map_entry)
329
+ map.delete(internal_id)
330
+ end
331
+
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"})"
334
+ end
335
+
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]
345
+ end
346
+
347
+ def send_deploy_notification(project_key, closed_cards)
348
+ channel_id = DISCORD_CONFIG["deploy_notification_channel_id"]
349
+ return unless channel_id
350
+
351
+ token = discord_bot_tokens.values.first
352
+ return unless token
353
+
354
+ card_lines = closed_cards.map { |c| "• [##{c[:number]} — #{c[:title]}](#{c[:url]})" }.join("\n")
355
+ message = "🚀 **#{project_key.capitalize}** deployed to production\nClosed UAT cards:\n#{card_lines}"
356
+
357
+ send_discord_message(channel_id, message, token: token)
358
+ rescue StandardError => e
359
+ LOG.warn "Failed to send deploy notification: #{e.message}"
360
+ end
361
+
362
+ def send_uat_deploy_notification(project_key)
363
+ channel_id = DISCORD_CONFIG["deploy_notification_channel_id"]
364
+ return unless channel_id
365
+
366
+ token = discord_bot_tokens.values.first
367
+ return unless token
368
+
369
+ message = "✅ **#{project_key.capitalize}** deployed to UAT successfully"
370
+ send_discord_message(channel_id, message, token: token)
371
+ rescue StandardError => e
372
+ LOG.warn "Failed to send UAT deploy notification: #{e.message}"
373
+ end
374
+
375
+ def send_workflow_failure_notification(project_key, workflow_name, run_url)
376
+ channel_id = DISCORD_CONFIG["deploy_notification_channel_id"]
377
+ return unless channel_id
378
+
379
+ token = discord_bot_tokens.values.first
380
+ return unless token
381
+
382
+ message = "❌ **#{project_key.capitalize}** — #{workflow_name} failed\n[View run](#{run_url})"
383
+ send_discord_message(channel_id, message, token: token)
384
+ rescue StandardError => e
385
+ LOG.warn "Failed to send workflow failure notification: #{e.message}"
386
+ end
387
+
388
+ def handle_github_issue_opened(payload)
389
+ issue = payload["issue"]
390
+ issue_url = issue["html_url"]
391
+ issue_title = issue["title"]
392
+ issue_number = issue["number"]
393
+ repo_name = payload.dig("repository", "full_name")
394
+
395
+ LOG.info "New GitHub issue ##{issue_number} on #{repo_name}: #{issue_title} (#{issue_url})"
396
+
397
+ [200, { status: "logged", issue: issue_number, title: issue_title, url: issue_url }.to_json]
398
+ end
399
+
400
+ def handle_github_pr_review_submitted(payload)
401
+ pr = payload["pull_request"]
402
+ review = payload["review"]
403
+ branch = pr.dig("head", "ref")
404
+ pr_number = pr["number"]
405
+ pr["html_url"]
406
+ repo_name = payload.dig("repository", "full_name")
407
+ review_state = review["state"]
408
+ reviewer = review.dig("user", "login")
409
+
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
415
+
416
+ # Identify project by GitHub repo
417
+ 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
422
+
423
+ project_key, project_config = project_result
424
+ repo_path = project_config["repo_path"]
425
+
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
431
+
432
+ internal_id, card_info = result
433
+ card_number = card_info["number"]
434
+ worktree = card_info["worktree"]
435
+
436
+ unless card_number
437
+ LOG.warn "Card #{internal_id} has no number — can't comment"
438
+ return [200, { status: "ignored", reason: "card has no number" }.to_json]
439
+ end
440
+
441
+ LOG.info "PR review submitted by #{reviewer} on PR ##{pr_number} for card ##{card_number} (project: #{project_key})"
442
+
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
448
+
449
+ # React to the review with 👀 to show we're starting
450
+ review_id = review["id"]
451
+ # React in background — don't block the dispatch path
452
+ 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}"
456
+ rescue StandardError => e
457
+ LOG.warn "Could not add reaction to review: #{e.message}"
458
+ end
459
+
460
+ # Post status to Fizzy in background — don't block the dispatch path
461
+ 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
469
+
470
+ # Fetch all review comments (line-specific comments)
471
+ review_comments = fetch_pr_review_comments(pr_number, repo_name)
472
+
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?
476
+
477
+ if review_comments.any?
478
+ review_context += "Line-specific comments:\n"
479
+ review_comments.each do |comment|
480
+ review_context += "- #{comment["path"]}:#{comment["line"]} (@#{comment["user"]}): #{comment["body"]}\n"
481
+ end
482
+ 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]
506
+ end
507
+
508
+ # Auto-deploy when a PR gets new commits (synchronize event) if the card is already on a dev env.
509
+ def handle_github_pr_synchronized(payload)
510
+ pr = payload["pull_request"]
511
+ branch = pr.dig("head", "ref")
512
+ payload.dig("repository", "full_name")
513
+
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
519
+
520
+ _internal_id, card_info = result
521
+ card_number = card_info["number"]
522
+ worktree = card_info["worktree"]
523
+
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
528
+
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
533
+
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
592
+ end
593
+
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]
598
+ end