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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +1166 -0
- data/Rakefile +12 -0
- data/bin/zillacore +1521 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/user_registry.rb +159 -0
- data/lib/zillacore/agents.rb +203 -0
- data/lib/zillacore/brain.rb +197 -0
- data/lib/zillacore/card_index.rb +389 -0
- data/lib/zillacore/config.rb +263 -0
- data/lib/zillacore/cron.rb +629 -0
- data/lib/zillacore/deployments.rb +258 -0
- data/lib/zillacore/handlers/discord.rb +1643 -0
- data/lib/zillacore/handlers/fizzy.rb +1249 -0
- data/lib/zillacore/handlers/github.rb +598 -0
- data/lib/zillacore/handlers/zoho.rb +487 -0
- data/lib/zillacore/helpers.rb +760 -0
- data/lib/zillacore/planning.rb +237 -0
- data/lib/zillacore/prompts.rb +620 -0
- data/lib/zillacore/sessions.rb +282 -0
- data/lib/zillacore/skills.rb +276 -0
- data/lib/zillacore/users.rb +76 -0
- data/lib/zillacore/version.rb +6 -0
- data/lib/zillacore/zoho_mail_api.rb +109 -0
- data/lib/zillacore.rb +10 -0
- data/monitor/daemon.rb +99 -0
- data/monitor/deploy-env-macos.rb +131 -0
- data/monitor/menubar.rb +295 -0
- data/monitor/open-action.sh +15 -0
- data/monitor/setup-menubar.rb +78 -0
- data/monitor/setup-waybar-deploy-envs.rb +121 -0
- data/monitor/setup-waybar-deployments.rb +96 -0
- data/monitor/setup-waybar-module.rb +113 -0
- data/monitor/setup-xbar-plugin.rb +35 -0
- data/monitor/view-logs-macos.rb +210 -0
- data/monitor/view-logs-rofi.rb +194 -0
- data/monitor/view-logs.rb +119 -0
- data/monitor/waybar-config-updater.rb +56 -0
- data/monitor/waybar-deploy-env.rb +206 -0
- data/monitor/waybar-deployments.rb +239 -0
- data/monitor/waybar.rb +146 -0
- data/monitor/xbar.3s.rb +179 -0
- data/receiver.rb +956 -0
- data/templates/agents.json.example +10 -0
- data/templates/discord.json.example +17 -0
- data/templates/fizzy.json.example +24 -0
- data/templates/github.json.example +4 -0
- data/templates/testflight.json.example +8 -0
- data/templates/users.json.example +121 -0
- data/templates/zoho.json.example +27 -0
- data/views/dashboard.erb +437 -0
- data/zillacore.gemspec +30 -0
- data.tar.gz.sig +2 -0
- metadata +235 -0
- 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
|