brainiac-basecamp 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
- data/README.md +322 -0
- data/lib/brainiac/plugins/basecamp/cli.rb +411 -0
- data/lib/brainiac/plugins/basecamp/client.rb +174 -0
- data/lib/brainiac/plugins/basecamp/config.rb +115 -0
- data/lib/brainiac/plugins/basecamp/epic.rb +200 -0
- data/lib/brainiac/plugins/basecamp/epic_branch.rb +271 -0
- data/lib/brainiac/plugins/basecamp/hooks.rb +820 -0
- data/lib/brainiac/plugins/basecamp/metadata.rb +20 -0
- data/lib/brainiac/plugins/basecamp/orchestrator.rb +722 -0
- data/lib/brainiac/plugins/basecamp/prompts.rb +41 -0
- data/lib/brainiac/plugins/basecamp/review_gate.rb +383 -0
- data/lib/brainiac/plugins/basecamp/version.rb +9 -0
- data/lib/brainiac/plugins/basecamp/webhook.rb +189 -0
- data/lib/brainiac/plugins/basecamp.rb +314 -0
- data/lib/brainiac_basecamp.rb +4 -0
- metadata +126 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Basecamp
|
|
8
|
+
# Registers lifecycle hooks with the core event system.
|
|
9
|
+
#
|
|
10
|
+
# Hooks:
|
|
11
|
+
# :agent_completed — advances epic or dispatches review gates
|
|
12
|
+
# :pr_merged — marks task as truly complete (on_pr_merge mode)
|
|
13
|
+
# :pr_review_received — tracks gate approvals / change requests
|
|
14
|
+
# :pr_synchronized — re-triggers gates after fixes are pushed
|
|
15
|
+
# :build_brain_context — injects epic context into agent prompts
|
|
16
|
+
# :resolve_base_branch — returns epic branch as worktree base
|
|
17
|
+
# :resolve_pr_target — returns epic branch as PR target
|
|
18
|
+
module Hooks
|
|
19
|
+
class << self
|
|
20
|
+
def register_all!
|
|
21
|
+
register_agent_completed
|
|
22
|
+
register_pr_merged
|
|
23
|
+
register_pr_review_received
|
|
24
|
+
register_pr_synchronized
|
|
25
|
+
register_build_brain_context
|
|
26
|
+
register_resolve_base_branch
|
|
27
|
+
register_resolve_pr_target
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# When an implementation agent completes a Fizzy card task:
|
|
33
|
+
# - on_complete: advance immediately
|
|
34
|
+
# - on_pr_merge: wait for manual PR merge
|
|
35
|
+
# - epic_branch: dispatch review gates (parallel), then auto-merge when all approve
|
|
36
|
+
def register_agent_completed
|
|
37
|
+
Brainiac.on(:agent_completed) do |ctx|
|
|
38
|
+
next unless ctx[:source] == :fizzy
|
|
39
|
+
next unless ctx[:exit_status]&.zero? && !ctx[:signaled]
|
|
40
|
+
|
|
41
|
+
card_number = ctx[:card_number]
|
|
42
|
+
next unless card_number
|
|
43
|
+
|
|
44
|
+
epic = Orchestrator.find_epic_for_card(card_number)
|
|
45
|
+
next unless epic
|
|
46
|
+
|
|
47
|
+
review_gate = epic["review_gate"] || Config.review_gate
|
|
48
|
+
|
|
49
|
+
case review_gate
|
|
50
|
+
when "on_complete"
|
|
51
|
+
Orchestrator.on_card_completed(card_number)
|
|
52
|
+
when "on_pr_merge"
|
|
53
|
+
mark_in_review(epic, card_number)
|
|
54
|
+
when "epic_branch"
|
|
55
|
+
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
56
|
+
next unless task
|
|
57
|
+
|
|
58
|
+
# Only dispatch gates for fresh tasks, not ones already in review/final_decision/complete
|
|
59
|
+
current_status = task["status"]
|
|
60
|
+
if %w[in_review final_decision complete].include?(current_status)
|
|
61
|
+
LOG.info "[Basecamp:Hooks] Skipping gate dispatch for card ##{card_number} — already #{current_status}" if defined?(LOG)
|
|
62
|
+
next
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if ReviewGate.enabled?
|
|
66
|
+
# Dispatch review gates in parallel after a delay (wait for PR to be created)
|
|
67
|
+
task["status"] = "in_review"
|
|
68
|
+
epic["updated_at"] = Time.now.iso8601
|
|
69
|
+
save_epic_state(epic)
|
|
70
|
+
|
|
71
|
+
Thread.new do
|
|
72
|
+
sleep 20 # Wait for agent to push + open PR
|
|
73
|
+
dispatch_review_gates(epic, task, ctx)
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
LOG.error "[Basecamp:Hooks] Review gate dispatch failed: #{e.message}\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
|
|
76
|
+
end
|
|
77
|
+
else
|
|
78
|
+
# No gates configured — auto-merge directly
|
|
79
|
+
Thread.new do
|
|
80
|
+
sleep 10
|
|
81
|
+
auto_merge_and_advance(epic, card_number, ctx)
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
LOG.error "[Basecamp:Hooks] Auto-merge failed: #{e.message}" if defined?(LOG)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# When a PR is merged.
|
|
91
|
+
# - For on_pr_merge mode: listens to :pr_merged (main branch only)
|
|
92
|
+
# - For epic_branch mode: listens to :pr_merged_to_branch (any branch)
|
|
93
|
+
def register_pr_merged
|
|
94
|
+
# Legacy hook for on_pr_merge mode (merged to main)
|
|
95
|
+
Brainiac.on(:pr_merged) do |ctx|
|
|
96
|
+
card_number = ctx[:card_number]
|
|
97
|
+
next unless card_number
|
|
98
|
+
|
|
99
|
+
epic = Orchestrator.find_epic_for_card(card_number)
|
|
100
|
+
next unless epic
|
|
101
|
+
|
|
102
|
+
review_gate = epic["review_gate"] || Config.review_gate
|
|
103
|
+
if review_gate == "on_pr_merge"
|
|
104
|
+
LOG.info "[Basecamp:Hooks] PR merged for card ##{card_number} — advancing epic" if defined?(LOG)
|
|
105
|
+
Orchestrator.on_card_completed(card_number)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# New hook for epic_branch mode (merged to any branch, including epic branches)
|
|
110
|
+
Brainiac.on(:pr_merged_to_branch) do |ctx|
|
|
111
|
+
card_number = ctx[:card_number]
|
|
112
|
+
next unless card_number
|
|
113
|
+
|
|
114
|
+
epic = Orchestrator.find_epic_for_card(card_number)
|
|
115
|
+
next unless epic
|
|
116
|
+
|
|
117
|
+
review_gate = epic["review_gate"] || Config.review_gate
|
|
118
|
+
next unless review_gate == "epic_branch"
|
|
119
|
+
|
|
120
|
+
base_branch = ctx[:base_branch]
|
|
121
|
+
epic_branches = epic["epic_branches"]&.values || []
|
|
122
|
+
|
|
123
|
+
if epic_branches.include?(base_branch)
|
|
124
|
+
LOG.info "[Basecamp:Hooks] PR merged to epic branch #{base_branch} for card ##{card_number} — advancing" if defined?(LOG)
|
|
125
|
+
Orchestrator.on_card_completed(card_number)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# When a PR review is submitted (from brainiac-github).
|
|
131
|
+
# Tracks gate approvals — when all gates approve, auto-merge and advance.
|
|
132
|
+
# For changes_requested, we batch/debounce to wait for all gates before dispatching fixes.
|
|
133
|
+
def register_pr_review_received
|
|
134
|
+
Brainiac.on(:pr_review_received) do |ctx|
|
|
135
|
+
card_number = ctx[:card_number]
|
|
136
|
+
next unless card_number
|
|
137
|
+
|
|
138
|
+
epic = Orchestrator.find_epic_for_card(card_number)
|
|
139
|
+
next unless epic
|
|
140
|
+
next unless epic["review_gate"] == "epic_branch" && ReviewGate.enabled?
|
|
141
|
+
|
|
142
|
+
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
143
|
+
# Accept reviews during in_review OR in_flight (when addressing changes)
|
|
144
|
+
next unless task && %w[in_review in_flight].include?(task["status"])
|
|
145
|
+
|
|
146
|
+
review_state = ctx[:review_state]
|
|
147
|
+
reviewer = ctx[:reviewer] || ctx[:agent_name]
|
|
148
|
+
|
|
149
|
+
LOG.info "[Basecamp:Hooks] PR review received: #{reviewer} (#{review_state}) on card ##{card_number}" if defined?(LOG)
|
|
150
|
+
|
|
151
|
+
# Match reviewer to a configured gate agent
|
|
152
|
+
# GitHub bot logins are like "threepio-brainiac" or "glados-brainiac[bot]"
|
|
153
|
+
gate = match_reviewer_to_gate(reviewer)
|
|
154
|
+
|
|
155
|
+
unless gate
|
|
156
|
+
# Not a gate agent — check if it's the implementation agent's final approval
|
|
157
|
+
impl_agent = epic["agent"]
|
|
158
|
+
is_impl_agent = match_reviewer_to_agent?(reviewer, impl_agent)
|
|
159
|
+
|
|
160
|
+
if is_impl_agent && task["awaiting_final_decision"] && review_state == "approved"
|
|
161
|
+
# Implementation agent approved after reviewing gate feedback — proceed to merge
|
|
162
|
+
LOG.info "[Basecamp:Hooks] Final decision: #{impl_agent} approved — merging card ##{card_number}" if defined?(LOG)
|
|
163
|
+
task.delete("awaiting_final_decision")
|
|
164
|
+
save_epic_state(epic)
|
|
165
|
+
|
|
166
|
+
Thread.new do
|
|
167
|
+
post_gate_summary(epic, task)
|
|
168
|
+
auto_merge_and_advance(epic, card_number, ctx)
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
LOG.error "[Basecamp:Hooks] Post-final-decision merge failed: #{e.message}" if defined?(LOG)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
next # Not a gate agent and not a final decision — skip
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
agent_name = gate["agent"]
|
|
178
|
+
role = gate["role"] || "review"
|
|
179
|
+
|
|
180
|
+
# Only record approval for APPROVED reviews
|
|
181
|
+
if review_state == "approved"
|
|
182
|
+
LOG.info "[Basecamp:Hooks] Gate APPROVED: #{agent_name} (#{role}) on card ##{card_number}" if defined?(LOG)
|
|
183
|
+
ReviewGate.record_approval(task, agent: agent_name, role: role)
|
|
184
|
+
# Clear this gate from changes_requested if it was there
|
|
185
|
+
task["changes_requested_by"]&.delete(agent_name)
|
|
186
|
+
epic["updated_at"] = Time.now.iso8601
|
|
187
|
+
|
|
188
|
+
if ReviewGate.all_gates_passed?(task)
|
|
189
|
+
LOG.info "[Basecamp:Hooks] All review gates passed for card ##{card_number} — dispatching final decision" if defined?(LOG)
|
|
190
|
+
save_epic_state(epic)
|
|
191
|
+
|
|
192
|
+
Thread.new do
|
|
193
|
+
dispatch_final_decision(epic, task, ctx)
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
LOG.error "[Basecamp:Hooks] Final decision dispatch failed: #{e.message}" if defined?(LOG)
|
|
196
|
+
end
|
|
197
|
+
else
|
|
198
|
+
approvals = task["gate_approvals"] || []
|
|
199
|
+
remaining = ReviewGate.gates.size - approvals.size
|
|
200
|
+
LOG.info "[Basecamp:Hooks] #{approvals.size}/#{ReviewGate.gates.size} gates passed, #{remaining} remaining" if defined?(LOG)
|
|
201
|
+
save_epic_state(epic)
|
|
202
|
+
end
|
|
203
|
+
elsif review_state == "changes_requested"
|
|
204
|
+
LOG.info "[Basecamp:Hooks] Gate CHANGES_REQUESTED: #{agent_name} (#{role}) on card ##{card_number}" if defined?(LOG)
|
|
205
|
+
|
|
206
|
+
# Track which gates requested changes
|
|
207
|
+
task["changes_requested_by"] ||= []
|
|
208
|
+
task["changes_requested_by"] << agent_name unless task["changes_requested_by"].include?(agent_name)
|
|
209
|
+
|
|
210
|
+
# Check if all gates have now responded (either approved or requested changes)
|
|
211
|
+
all_responded = all_gates_responded?(task)
|
|
212
|
+
|
|
213
|
+
if all_responded
|
|
214
|
+
# All gates have reviewed — dispatch implementation agent to address ALL feedback
|
|
215
|
+
LOG.info "[Basecamp:Hooks] All gates responded for card ##{card_number} — dispatching fixes" if defined?(LOG)
|
|
216
|
+
task["status"] = "in_flight"
|
|
217
|
+
save_epic_state(epic)
|
|
218
|
+
# brainiac-github will dispatch the impl agent since this is changes_requested
|
|
219
|
+
else
|
|
220
|
+
# Wait for remaining gates to respond
|
|
221
|
+
responded = (task["gate_approvals"]&.size || 0) + (task["changes_requested_by"]&.size || 0)
|
|
222
|
+
remaining = ReviewGate.gates.size - responded
|
|
223
|
+
LOG.info "[Basecamp:Hooks] #{responded}/#{ReviewGate.gates.size} gates responded, waiting for #{remaining} more" if defined?(LOG)
|
|
224
|
+
save_epic_state(epic)
|
|
225
|
+
|
|
226
|
+
# Start a debounce timer if this is the first changes_requested
|
|
227
|
+
# In case some gates never respond, we'll dispatch after 60 seconds
|
|
228
|
+
unless task["changes_debounce_started"]
|
|
229
|
+
task["changes_debounce_started"] = true
|
|
230
|
+
save_epic_state(epic)
|
|
231
|
+
Thread.new do
|
|
232
|
+
sleep 60
|
|
233
|
+
# Reload epic state using public API
|
|
234
|
+
epic_reloaded = Orchestrator.find_epic_for_card(card_number)
|
|
235
|
+
next unless epic_reloaded
|
|
236
|
+
|
|
237
|
+
task_reloaded = epic_reloaded["tasks"]&.find { |t| t["fizzy_card"] == card_number.to_i }
|
|
238
|
+
if task_reloaded && task_reloaded["status"] == "in_review" && task_reloaded["changes_requested_by"]&.any?
|
|
239
|
+
LOG.info "[Basecamp:Hooks] Debounce timeout for card ##{card_number} — forcing dispatch" if defined?(LOG)
|
|
240
|
+
task_reloaded["status"] = "in_flight"
|
|
241
|
+
task_reloaded.delete("changes_debounce_started")
|
|
242
|
+
save_epic_state(epic_reloaded)
|
|
243
|
+
# Re-assign card to trigger dispatch
|
|
244
|
+
fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, epic_reloaded["agent"])
|
|
245
|
+
Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id) if fizzy_user_id
|
|
246
|
+
end
|
|
247
|
+
rescue StandardError => e
|
|
248
|
+
LOG.error "[Basecamp:Hooks] Debounce dispatch failed: #{e.message}" if defined?(LOG)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Check if all configured gates have responded (either approved or requested changes)
|
|
257
|
+
def all_gates_responded?(task)
|
|
258
|
+
approvals = (task["gate_approvals"] || []).map { |a| a["agent"].downcase }
|
|
259
|
+
changes = (task["changes_requested_by"] || []).map(&:downcase)
|
|
260
|
+
responded = approvals + changes
|
|
261
|
+
|
|
262
|
+
required = ReviewGate.gates.map { |g| g["agent"].downcase }
|
|
263
|
+
required.all? { |agent| responded.include?(agent) }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Match a GitHub reviewer login to a configured gate agent.
|
|
267
|
+
# Handles patterns like "threepio-brainiac", "glados-brainiac[bot]"
|
|
268
|
+
def match_reviewer_to_gate(reviewer)
|
|
269
|
+
return nil unless reviewer
|
|
270
|
+
|
|
271
|
+
normalized = reviewer.to_s.downcase.delete_suffix("[bot]")
|
|
272
|
+
|
|
273
|
+
ReviewGate.gates.find do |gate|
|
|
274
|
+
agent = gate["agent"].to_s.downcase
|
|
275
|
+
# Direct match
|
|
276
|
+
next true if normalized == agent
|
|
277
|
+
# GitHub bot pattern: "agent-brainiac"
|
|
278
|
+
next true if normalized == "#{agent}-brainiac"
|
|
279
|
+
# Check against display_name from registry
|
|
280
|
+
agents_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "agents.json")
|
|
281
|
+
if File.exist?(agents_file)
|
|
282
|
+
agents = JSON.parse(File.read(agents_file))
|
|
283
|
+
agent_entry = agents[agent]
|
|
284
|
+
display = agent_entry&.dig("display_name")&.to_s&.downcase
|
|
285
|
+
next true if display && (normalized == display || normalized == "#{display}-brainiac")
|
|
286
|
+
end
|
|
287
|
+
false
|
|
288
|
+
end
|
|
289
|
+
rescue StandardError
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Check if a reviewer matches a specific agent name
|
|
294
|
+
def match_reviewer_to_agent?(reviewer, agent_name)
|
|
295
|
+
return false unless reviewer && agent_name
|
|
296
|
+
|
|
297
|
+
normalized = reviewer.to_s.downcase.delete_suffix("[bot]")
|
|
298
|
+
agent = agent_name.to_s.downcase
|
|
299
|
+
|
|
300
|
+
return true if normalized == agent
|
|
301
|
+
return true if normalized == "#{agent}-brainiac"
|
|
302
|
+
|
|
303
|
+
# Check display_name
|
|
304
|
+
agents_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "agents.json")
|
|
305
|
+
if File.exist?(agents_file)
|
|
306
|
+
agents = JSON.parse(File.read(agents_file))
|
|
307
|
+
agent_entry = agents[agent]
|
|
308
|
+
display = agent_entry&.dig("display_name")&.to_s&.downcase
|
|
309
|
+
return true if display && (normalized == display || normalized == "#{display}-brainiac")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
false
|
|
313
|
+
rescue StandardError
|
|
314
|
+
false
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# When a PR is updated (new commits pushed) — re-trigger gates if in review.
|
|
318
|
+
# This handles the "Galen fixes → pushes → gates re-review" flow.
|
|
319
|
+
def register_pr_synchronized
|
|
320
|
+
Brainiac.on(:pr_synchronized) do |ctx|
|
|
321
|
+
card_number = ctx[:card_number]
|
|
322
|
+
next unless card_number
|
|
323
|
+
|
|
324
|
+
epic = Orchestrator.find_epic_for_card(card_number)
|
|
325
|
+
next unless epic
|
|
326
|
+
next unless epic["review_gate"] == "epic_branch" && ReviewGate.enabled?
|
|
327
|
+
|
|
328
|
+
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
329
|
+
# Only re-trigger if the task is in_flight (fixes pushed) and had prior reviews
|
|
330
|
+
next unless task && task["status"] == "in_flight" && task["gate_approvals"]&.any?
|
|
331
|
+
|
|
332
|
+
LOG.info "[Basecamp:Hooks] PR updated for card ##{card_number} — re-triggering review gates" if defined?(LOG)
|
|
333
|
+
|
|
334
|
+
# Reset approvals and re-dispatch gates
|
|
335
|
+
ReviewGate.reset_approvals(task)
|
|
336
|
+
task["status"] = "in_review"
|
|
337
|
+
epic["updated_at"] = Time.now.iso8601
|
|
338
|
+
save_epic_state(epic)
|
|
339
|
+
|
|
340
|
+
Thread.new do
|
|
341
|
+
dispatch_review_gates(epic, task, ctx)
|
|
342
|
+
rescue StandardError => e
|
|
343
|
+
LOG.error "[Basecamp:Hooks] Gate re-dispatch failed: #{e.message}" if defined?(LOG)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Inject epic context into agent prompts.
|
|
349
|
+
def register_build_brain_context
|
|
350
|
+
Brainiac.on(:build_brain_context) do |ctx|
|
|
351
|
+
card_number = ctx[:card_number]
|
|
352
|
+
next unless card_number
|
|
353
|
+
|
|
354
|
+
epic = Orchestrator.find_epic_for_card(card_number)
|
|
355
|
+
next unless epic
|
|
356
|
+
|
|
357
|
+
tasks = epic["tasks"] || []
|
|
358
|
+
current_task = tasks.find { |t| t["fizzy_card"] == card_number.to_i }
|
|
359
|
+
complete_count = tasks.count { |t| t["status"] == "complete" }
|
|
360
|
+
agent_name = ctx[:agent_name] || epic["agent"]
|
|
361
|
+
|
|
362
|
+
context_lines = [
|
|
363
|
+
"## Epic Context",
|
|
364
|
+
"This card is part of epic: **#{epic['title']}**",
|
|
365
|
+
"Progress: #{complete_count}/#{tasks.size} tasks complete",
|
|
366
|
+
""
|
|
367
|
+
]
|
|
368
|
+
|
|
369
|
+
# List completed tasks with their memory files for reference
|
|
370
|
+
completed_tasks = tasks.select { |t| t["status"] == "complete" }
|
|
371
|
+
if completed_tasks.any?
|
|
372
|
+
context_lines << "### Completed tasks (you can reference their memory files):"
|
|
373
|
+
completed_tasks.each do |t|
|
|
374
|
+
memory_file = "~/.brainiac/brain/memory/#{agent_name&.downcase}/card-#{t['fizzy_card']}.md"
|
|
375
|
+
context_lines << " - ##{t['fizzy_card']}: #{t['title']} → `#{memory_file}`"
|
|
376
|
+
end
|
|
377
|
+
context_lines << ""
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
if current_task
|
|
381
|
+
deps = current_task["depends_on"] || []
|
|
382
|
+
context_lines << "Dependencies (all satisfied): #{deps.map { |d| "##{d}" }.join(', ')}" if deps.any?
|
|
383
|
+
|
|
384
|
+
review_gate = epic["review_gate"] || Config.review_gate
|
|
385
|
+
if review_gate == "epic_branch"
|
|
386
|
+
epic_branches = epic["epic_branches"] || {}
|
|
387
|
+
branch = epic_branches[current_task["project"]] || epic_branches.values.first
|
|
388
|
+
context_lines << "**Epic branch mode:** Your PR should target `#{branch}` (not main)." if branch
|
|
389
|
+
|
|
390
|
+
if ReviewGate.enabled?
|
|
391
|
+
gate_names = ReviewGate.gates.map { |g| "#{g['agent']} (#{g['role']})" }.join(", ")
|
|
392
|
+
context_lines << "**Review gates:** #{gate_names} will review your PR after you open it."
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Final decision mode — agent reads gate feedback and decides
|
|
396
|
+
if current_task["awaiting_final_decision"]
|
|
397
|
+
pr_number = current_task["pr_number"]
|
|
398
|
+
approvals = current_task["gate_approvals"] || []
|
|
399
|
+
gate_agents = approvals.map { |a| a["agent"] }.join(", ")
|
|
400
|
+
|
|
401
|
+
context_lines << ""
|
|
402
|
+
context_lines << "## ⚡ FINAL DECISION REQUIRED"
|
|
403
|
+
context_lines << ""
|
|
404
|
+
context_lines << "All review gates have approved (#{gate_agents}). Your job now:"
|
|
405
|
+
context_lines << ""
|
|
406
|
+
context_lines << "1. Read their feedback: `gh pr view #{pr_number} --comments`"
|
|
407
|
+
context_lines << "2. If fixes needed → make them, commit, push"
|
|
408
|
+
context_lines << "3. When ready → **merge the PR**:"
|
|
409
|
+
context_lines << " ```"
|
|
410
|
+
context_lines << " gh pr merge #{pr_number} --squash --delete-branch"
|
|
411
|
+
context_lines << " ```"
|
|
412
|
+
context_lines << ""
|
|
413
|
+
context_lines << "**Out-of-scope work:** If reviewers flagged issues better solved outside this epic,"
|
|
414
|
+
context_lines << "create a new Fizzy card for that work instead of expanding this PR's scope."
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
remaining = tasks.select { |t| t["status"] == "pending" }
|
|
419
|
+
if remaining.any?
|
|
420
|
+
context_lines << "" << "Upcoming tasks:"
|
|
421
|
+
remaining.first(3).each { |t| context_lines << " - #{t['title']} (Fizzy ##{t['fizzy_card']})" }
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
context_lines.join("\n")
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Return the epic branch as the worktree base for cards in an active epic.
|
|
430
|
+
def register_resolve_base_branch
|
|
431
|
+
Brainiac.on(:resolve_base_branch) do |ctx|
|
|
432
|
+
card_number = ctx[:card_number]
|
|
433
|
+
next unless card_number
|
|
434
|
+
|
|
435
|
+
branch = EpicBranch.epic_branch_for_card(card_number)
|
|
436
|
+
next unless branch
|
|
437
|
+
|
|
438
|
+
"origin/#{branch}"
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Return the epic branch as the PR target for cards in an active epic.
|
|
443
|
+
def register_resolve_pr_target
|
|
444
|
+
Brainiac.on(:resolve_pr_target) do |ctx|
|
|
445
|
+
card_number = ctx[:card_number]
|
|
446
|
+
next unless card_number
|
|
447
|
+
|
|
448
|
+
EpicBranch.epic_branch_for_card(card_number)
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# --- Private helpers ---
|
|
453
|
+
|
|
454
|
+
# Dispatch all review gate agents in parallel for a task.
|
|
455
|
+
def dispatch_review_gates(epic, task, ctx)
|
|
456
|
+
card_number = task["fizzy_card"]
|
|
457
|
+
project_key = task["project"] || Config.brainiac_project_for(epic["basecamp_project_id"])
|
|
458
|
+
|
|
459
|
+
# Resolve repo info
|
|
460
|
+
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
461
|
+
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
462
|
+
project_config = projects[project_key] || {}
|
|
463
|
+
repo_path = project_config["repo_path"]
|
|
464
|
+
github_repo = project_config["github_repo"]
|
|
465
|
+
|
|
466
|
+
unless repo_path && github_repo
|
|
467
|
+
LOG.warn "[Basecamp:Hooks] Cannot dispatch gates — missing repo_path or github_repo for #{project_key}" if defined?(LOG)
|
|
468
|
+
return
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Find the PR number for this card's branch
|
|
472
|
+
branch = ctx[:branch] || "fizzy-#{card_number}-*"
|
|
473
|
+
pr_number = find_pr_number(repo_path: repo_path, branch: branch)
|
|
474
|
+
|
|
475
|
+
unless pr_number
|
|
476
|
+
LOG.warn "[Basecamp:Hooks] No PR found for card ##{card_number} — gates cannot be dispatched" if defined?(LOG)
|
|
477
|
+
# Retry once after delay
|
|
478
|
+
sleep 30
|
|
479
|
+
pr_number = find_pr_number(repo_path: repo_path, branch: branch)
|
|
480
|
+
unless pr_number
|
|
481
|
+
LOG.error "[Basecamp:Hooks] Still no PR for card ##{card_number} after retry" if defined?(LOG)
|
|
482
|
+
return
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
task["pr_number"] = pr_number
|
|
487
|
+
task["pr_repo"] = github_repo
|
|
488
|
+
|
|
489
|
+
# Self-healing: sync gate approvals from GitHub before dispatching
|
|
490
|
+
# This handles the case where gates already reviewed but our state is stale
|
|
491
|
+
sync_result = ReviewGate.sync_from_github(task, repo_path: repo_path)
|
|
492
|
+
if sync_result[:synced] && sync_result[:changes]
|
|
493
|
+
changes = sync_result[:changes]
|
|
494
|
+
if changes[:approvals_added]&.any?
|
|
495
|
+
LOG.info "[Basecamp:Hooks] Self-healed: recorded approvals from #{changes[:approvals_added].join(', ')}" if defined?(LOG)
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Check if all gates have already approved (self-healed state)
|
|
500
|
+
if ReviewGate.all_gates_passed?(task)
|
|
501
|
+
LOG.info "[Basecamp:Hooks] All gates already approved for card ##{card_number} — dispatching final decision" if defined?(LOG)
|
|
502
|
+
save_epic_state(epic)
|
|
503
|
+
dispatch_final_decision(epic, task, ctx)
|
|
504
|
+
return
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
ReviewGate.dispatch_gates(
|
|
508
|
+
epic: epic,
|
|
509
|
+
task: task,
|
|
510
|
+
pr_number: pr_number,
|
|
511
|
+
repo_name: github_repo,
|
|
512
|
+
repo_path: repo_path
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
save_epic_state(epic)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Post a summary comment on the Fizzy card after all gates pass.
|
|
519
|
+
def post_gate_summary(epic, task)
|
|
520
|
+
card_number = task["fizzy_card"]
|
|
521
|
+
pr_number = task["pr_number"]
|
|
522
|
+
pr_repo = task["pr_repo"]
|
|
523
|
+
pr_url = "https://github.com/#{pr_repo}/pull/#{pr_number}" if pr_repo && pr_number
|
|
524
|
+
|
|
525
|
+
comment_html = ReviewGate.build_gate_summary_comment(task, pr_url: pr_url || "")
|
|
526
|
+
|
|
527
|
+
# Post via Fizzy CLI as the implementation agent
|
|
528
|
+
agent_name = epic["agent"]
|
|
529
|
+
fizzy_env = resolve_fizzy_env(agent_name)
|
|
530
|
+
|
|
531
|
+
Open3.capture3(
|
|
532
|
+
"fizzy", "comment", "create",
|
|
533
|
+
"--card", card_number.to_s,
|
|
534
|
+
"--body", comment_html,
|
|
535
|
+
**({ env: fizzy_env } if fizzy_env)
|
|
536
|
+
)
|
|
537
|
+
rescue StandardError => e
|
|
538
|
+
LOG.warn "[Basecamp:Hooks] Failed to post gate summary on card ##{card_number}: #{e.message}" if defined?(LOG)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def mark_in_review(epic, card_number)
|
|
542
|
+
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
543
|
+
return unless task
|
|
544
|
+
|
|
545
|
+
task["status"] = "in_review"
|
|
546
|
+
task["review_started_at"] = Time.now.iso8601
|
|
547
|
+
epic["updated_at"] = Time.now.iso8601
|
|
548
|
+
save_epic_state(epic)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Dispatch the implementation agent to read gate feedback and make a final decision.
|
|
552
|
+
# The agent reads all review comments, then either:
|
|
553
|
+
# - Makes fixes and pushes (triggers re-review cycle)
|
|
554
|
+
# - Approves the PR (triggers the merge)
|
|
555
|
+
#
|
|
556
|
+
# This spawns the agent directly via run_agent (not Fizzy assignment) to avoid
|
|
557
|
+
# confusing unassign/reassign activity in the Fizzy feed.
|
|
558
|
+
def dispatch_final_decision(epic, task, _ctx)
|
|
559
|
+
card_number = task["fizzy_card"]
|
|
560
|
+
agent_name = epic["agent"]
|
|
561
|
+
pr_number = task["pr_number"]
|
|
562
|
+
project_key = task["project"]
|
|
563
|
+
|
|
564
|
+
LOG.info "[Basecamp:Hooks] Dispatching #{agent_name} for final decision on card ##{card_number}" if defined?(LOG)
|
|
565
|
+
|
|
566
|
+
# Mark task as awaiting final decision
|
|
567
|
+
task["awaiting_final_decision"] = true
|
|
568
|
+
task["status"] = "final_decision"
|
|
569
|
+
epic["updated_at"] = Time.now.iso8601
|
|
570
|
+
save_epic_state(epic)
|
|
571
|
+
|
|
572
|
+
# Reset the agent-to-agent dispatch depth for this card
|
|
573
|
+
card_internal_id = lookup_card_internal_id(card_number)
|
|
574
|
+
if card_internal_id
|
|
575
|
+
LOG.info "[Basecamp:Hooks] Resetting dispatch depth for card #{card_internal_id}" if defined?(LOG)
|
|
576
|
+
if defined?(record_human_comment)
|
|
577
|
+
record_human_comment(card_internal_id)
|
|
578
|
+
elsif Object.respond_to?(:record_human_comment, true)
|
|
579
|
+
Object.send(:record_human_comment, card_internal_id)
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Get project config and repo path
|
|
584
|
+
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
585
|
+
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
586
|
+
project_config = projects[project_key]
|
|
587
|
+
repo_path = project_config&.dig("repo_path")
|
|
588
|
+
|
|
589
|
+
unless repo_path
|
|
590
|
+
LOG.error "[Basecamp:Hooks] No repo_path for project #{project_key} — cannot dispatch final decision" if defined?(LOG)
|
|
591
|
+
return
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Find the worktree for this card
|
|
595
|
+
work_items_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "work_items.json")
|
|
596
|
+
worktree_path = repo_path # Default to main repo
|
|
597
|
+
if File.exist?(work_items_file)
|
|
598
|
+
work_items = JSON.parse(File.read(work_items_file))
|
|
599
|
+
work_item = work_items.values.find { |wi| wi.dig("sources", "fizzy", "card_number") == card_number }
|
|
600
|
+
worktree_path = work_item["worktree"] if work_item&.dig("worktree")
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# Build prompt for final decision
|
|
604
|
+
gate_approvals = task["gate_approvals"] || []
|
|
605
|
+
gate_agents = gate_approvals.map { |a| a["agent"] }.join(", ")
|
|
606
|
+
|
|
607
|
+
prompt = <<~PROMPT
|
|
608
|
+
## Final Decision Required — Fizzy Card ##{card_number}
|
|
609
|
+
|
|
610
|
+
All review gates have approved (#{gate_agents}). Your job:
|
|
611
|
+
|
|
612
|
+
1. Read their feedback: `gh pr view #{pr_number} --comments`
|
|
613
|
+
2. If fixes needed → make them, commit, push
|
|
614
|
+
3. When ready → **merge the PR directly**:
|
|
615
|
+
```
|
|
616
|
+
gh pr merge #{pr_number} --squash --delete-branch
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Note: You cannot self-approve PRs you authored. Merge directly since gates have approved.
|
|
620
|
+
|
|
621
|
+
After merging, update the Fizzy card with a brief status comment.
|
|
622
|
+
PROMPT
|
|
623
|
+
|
|
624
|
+
# Resolve GitHub App token so agent's `gh` commands run as their bot identity
|
|
625
|
+
github_repo = project_config&.dig("github_repo")
|
|
626
|
+
agent_env = github_repo ? ReviewGate.send(:resolve_agent_github_env, agent_name, github_repo) : {}
|
|
627
|
+
|
|
628
|
+
# Spawn the agent directly (like gate agents do)
|
|
629
|
+
pid = nil
|
|
630
|
+
log_file = nil
|
|
631
|
+
card_key = "final-decision-#{card_number}"
|
|
632
|
+
|
|
633
|
+
begin
|
|
634
|
+
pid, log_file = method(:run_agent).call(
|
|
635
|
+
prompt,
|
|
636
|
+
project_config: project_config,
|
|
637
|
+
chdir: worktree_path,
|
|
638
|
+
log_name: "final-decision-#{card_number}",
|
|
639
|
+
agent_name: agent_name,
|
|
640
|
+
source: :basecamp,
|
|
641
|
+
card_number: card_number,
|
|
642
|
+
env: agent_env
|
|
643
|
+
)
|
|
644
|
+
rescue NameError
|
|
645
|
+
if Object.respond_to?(:run_agent, true)
|
|
646
|
+
pid, log_file = Object.send(:run_agent,
|
|
647
|
+
prompt,
|
|
648
|
+
project_config: project_config,
|
|
649
|
+
chdir: worktree_path,
|
|
650
|
+
log_name: "final-decision-#{card_number}",
|
|
651
|
+
agent_name: agent_name,
|
|
652
|
+
source: :basecamp,
|
|
653
|
+
card_number: card_number,
|
|
654
|
+
env: agent_env)
|
|
655
|
+
else
|
|
656
|
+
LOG.warn "[Basecamp:Hooks] run_agent not available — final decision dispatch skipped" if defined?(LOG)
|
|
657
|
+
return
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Register session for waybar visibility
|
|
662
|
+
if pid
|
|
663
|
+
if defined?(register_session)
|
|
664
|
+
register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
|
|
665
|
+
elsif Object.respond_to?(:register_session, true)
|
|
666
|
+
Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
|
|
667
|
+
end
|
|
668
|
+
LOG.info "[Basecamp:Hooks] Spawned #{agent_name} (pid #{pid}) for final decision on card ##{card_number}" if defined?(LOG)
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def mark_in_review(epic, card_number)
|
|
673
|
+
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
674
|
+
return unless task
|
|
675
|
+
|
|
676
|
+
task["status"] = "in_review"
|
|
677
|
+
task["review_started_at"] = Time.now.iso8601
|
|
678
|
+
epic["updated_at"] = Time.now.iso8601
|
|
679
|
+
save_epic_state(epic)
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def auto_merge_and_advance(epic, card_number, ctx)
|
|
683
|
+
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
684
|
+
return unless task
|
|
685
|
+
|
|
686
|
+
project_key = task["project"] || Config.brainiac_project_for(epic["basecamp_project_id"])
|
|
687
|
+
epic_branches = epic["epic_branches"] || {}
|
|
688
|
+
epic_branch = epic_branches[project_key] || epic_branches.values.first
|
|
689
|
+
return unless epic_branch
|
|
690
|
+
|
|
691
|
+
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
692
|
+
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
693
|
+
repo_path = projects.dig(project_key, "repo_path")
|
|
694
|
+
return unless repo_path
|
|
695
|
+
|
|
696
|
+
branch_name = ctx[:branch]
|
|
697
|
+
|
|
698
|
+
# If we don't have the exact branch name, try to find it
|
|
699
|
+
unless branch_name
|
|
700
|
+
stdout, _, status = Open3.capture3("gh", "pr", "list", "--head", "fizzy-#{card_number}",
|
|
701
|
+
"--json", "headRefName", "--jq", ".[0].headRefName",
|
|
702
|
+
chdir: repo_path)
|
|
703
|
+
branch_name = stdout.strip if status.success? && !stdout.strip.empty?
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
merged = EpicBranch.merge_task_into_epic(
|
|
707
|
+
repo_path: repo_path,
|
|
708
|
+
branch_name: branch_name || "fizzy-#{card_number}",
|
|
709
|
+
epic_branch: epic_branch
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
if merged
|
|
713
|
+
LOG.info "[Basecamp:Hooks] Merged card ##{card_number} into #{epic_branch} — advancing epic" if defined?(LOG)
|
|
714
|
+
Orchestrator.on_card_completed(card_number)
|
|
715
|
+
else
|
|
716
|
+
sleep 30
|
|
717
|
+
merged = EpicBranch.merge_task_into_epic(
|
|
718
|
+
repo_path: repo_path,
|
|
719
|
+
branch_name: branch_name || "fizzy-#{card_number}",
|
|
720
|
+
epic_branch: epic_branch
|
|
721
|
+
)
|
|
722
|
+
if merged
|
|
723
|
+
Orchestrator.on_card_completed(card_number)
|
|
724
|
+
else
|
|
725
|
+
LOG.warn "[Basecamp:Hooks] Could not merge card ##{card_number} — manual intervention needed" if defined?(LOG)
|
|
726
|
+
task["status"] = "merge_failed"
|
|
727
|
+
save_epic_state(epic)
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
# Find a PR number by branch name pattern.
|
|
733
|
+
def find_pr_number(repo_path:, branch:)
|
|
734
|
+
# Try exact match first
|
|
735
|
+
stdout, _, status = Open3.capture3(
|
|
736
|
+
"gh", "pr", "list", "--head", branch, "--json", "number", "--jq", ".[0].number",
|
|
737
|
+
chdir: repo_path
|
|
738
|
+
)
|
|
739
|
+
return stdout.strip.to_i if status.success? && !stdout.strip.empty?
|
|
740
|
+
|
|
741
|
+
# Try pattern match (fizzy-NNNN-*)
|
|
742
|
+
if branch.include?("*")
|
|
743
|
+
card_num = branch.match(/fizzy-(\d+)/)[1] rescue nil
|
|
744
|
+
if card_num
|
|
745
|
+
stdout, _, status = Open3.capture3(
|
|
746
|
+
"gh", "pr", "list", "--json", "number,headRefName",
|
|
747
|
+
"--jq", ".[] | select(.headRefName | startswith(\"fizzy-#{card_num}\")) | .number",
|
|
748
|
+
chdir: repo_path
|
|
749
|
+
)
|
|
750
|
+
return stdout.strip.to_i if status.success? && !stdout.strip.empty?
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
nil
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# Look up the Fizzy internal ID for a card number from work_items.
|
|
758
|
+
def lookup_card_internal_id(card_number)
|
|
759
|
+
work_items_file = File.join(
|
|
760
|
+
ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")),
|
|
761
|
+
"work_items.json"
|
|
762
|
+
)
|
|
763
|
+
return nil unless File.exist?(work_items_file)
|
|
764
|
+
|
|
765
|
+
work_items = JSON.parse(File.read(work_items_file))
|
|
766
|
+
work_items.each do |_id, item|
|
|
767
|
+
fizzy_card = item.dig("sources", "fizzy", "card_number") || item["card_number"]
|
|
768
|
+
if fizzy_card.to_i == card_number.to_i
|
|
769
|
+
return item.dig("sources", "fizzy", "card_internal_id") || item["card_internal_id"]
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
nil
|
|
773
|
+
rescue StandardError
|
|
774
|
+
nil
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Resolve Fizzy env for an agent (FIZZY_TOKEN).
|
|
778
|
+
def resolve_fizzy_env(agent_name)
|
|
779
|
+
agents_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "agents.json")
|
|
780
|
+
return nil unless File.exist?(agents_file)
|
|
781
|
+
|
|
782
|
+
agents = JSON.parse(File.read(agents_file))
|
|
783
|
+
agent = agents[agent_name.downcase]
|
|
784
|
+
return nil unless agent
|
|
785
|
+
|
|
786
|
+
env = agent.dig("env") || {}
|
|
787
|
+
env.empty? ? nil : env
|
|
788
|
+
rescue StandardError
|
|
789
|
+
nil
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def save_epic_state(epic)
|
|
793
|
+
epics_file = File.join(
|
|
794
|
+
ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")),
|
|
795
|
+
"basecamp_epics.json"
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
all = if File.exist?(epics_file)
|
|
799
|
+
data = JSON.parse(File.read(epics_file))
|
|
800
|
+
data["epics"] || []
|
|
801
|
+
else
|
|
802
|
+
[]
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
idx = all.index { |e| e["id"] == epic["id"] }
|
|
806
|
+
if idx
|
|
807
|
+
all[idx] = epic
|
|
808
|
+
else
|
|
809
|
+
all << epic
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
File.write(epics_file, JSON.pretty_generate({ "epics" => all, "updated_at" => Time.now.iso8601 }))
|
|
813
|
+
rescue StandardError => e
|
|
814
|
+
LOG.error "[Basecamp:Hooks] Failed to save epic state: #{e.message}" if defined?(LOG)
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
end
|