brainiac-basecamp 0.0.13 → 0.0.15
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8e71f8f043a52cb54d83e974031b0bb83339f02452e701bf381e4e87b32274d
|
|
4
|
+
data.tar.gz: ac57b522a30cdb3cdf42c0aca069c52bb1ce27b2b93fdbdf4b63a2c9f57088b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 955cfcffe81b1856b0ee90e8c2124b48d13b3de28db1e258054069442b8a67096deeb95cda1b5ce0e21b20cd2b78e3bd7c73fd107c5753c3de82ef0ead916d01
|
|
7
|
+
data.tar.gz: 7b6e92a54b136065013d178d3422bc7583958f83b161184c9905b2d950898907ee1eebdffa6d2cf07bc7394836b98aef52acd8bb50583231b2b6b8f2f9ca3247
|
|
@@ -16,7 +16,7 @@ module Brainiac
|
|
|
16
16
|
# :resolve_base_branch — returns epic branch as worktree base
|
|
17
17
|
# :resolve_pr_target — returns epic branch as PR target
|
|
18
18
|
module Hooks
|
|
19
|
-
class << self
|
|
19
|
+
class << self # rubocop:disable Metrics/ClassLength
|
|
20
20
|
def register_all!
|
|
21
21
|
register_agent_completed
|
|
22
22
|
register_pr_merged
|
|
@@ -380,14 +380,39 @@ module Brainiac
|
|
|
380
380
|
next unless epic["review_gate"] == "epic_branch" && ReviewGate.enabled?
|
|
381
381
|
|
|
382
382
|
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
383
|
+
next unless task && task["status"] == "in_flight"
|
|
384
|
+
|
|
385
|
+
# Ensure pr_number is set — it may be missing if initial gate dispatch
|
|
386
|
+
# failed to persist it or if state was lost during serialization.
|
|
387
|
+
unless task["pr_number"]
|
|
388
|
+
pr_number = ctx.dig(:pull_request, "number") || ctx[:pr_number]
|
|
389
|
+
if pr_number
|
|
390
|
+
task["pr_number"] = pr_number.to_i
|
|
391
|
+
LOG.info "[Basecamp:Hooks] Backfilled pr_number=#{pr_number} for card ##{card_number}" if defined?(LOG)
|
|
392
|
+
else
|
|
393
|
+
# Try to find it from the branch
|
|
394
|
+
project_key = task["project"] || Config.brainiac_project_for(epic["basecamp_project_id"])
|
|
395
|
+
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
396
|
+
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
397
|
+
repo_path = projects.dig(project_key, "repo_path")
|
|
398
|
+
if repo_path
|
|
399
|
+
branch = ctx[:branch] || "fizzy-#{card_number}-*"
|
|
400
|
+
found_pr = find_pr_number(repo_path: repo_path, branch: branch)
|
|
401
|
+
if found_pr
|
|
402
|
+
task["pr_number"] = found_pr
|
|
403
|
+
LOG.info "[Basecamp:Hooks] Resolved pr_number=#{found_pr} for card ##{card_number} via branch lookup" if defined?(LOG)
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
next unless task["pr_number"]
|
|
386
410
|
|
|
387
411
|
LOG.info "[Basecamp:Hooks] PR updated for card ##{card_number} — re-triggering review gates" if defined?(LOG)
|
|
388
412
|
|
|
389
413
|
# Reset approvals and re-dispatch gates
|
|
390
414
|
ReviewGate.reset_approvals(task)
|
|
415
|
+
task["changes_requested_by"] = []
|
|
391
416
|
task["status"] = "in_review"
|
|
392
417
|
epic["updated_at"] = Time.now.iso8601
|
|
393
418
|
save_epic_state(epic)
|
|
@@ -821,6 +846,21 @@ module Brainiac
|
|
|
821
846
|
return
|
|
822
847
|
end
|
|
823
848
|
|
|
849
|
+
# Ensure pr_number is set — look it up if missing.
|
|
850
|
+
# This is critical for the pr_synchronized hook to re-trigger gates after fixes.
|
|
851
|
+
unless pr_number
|
|
852
|
+
found_pr = find_pr_number(repo_path: repo_path, branch: "fizzy-#{card_number}-*")
|
|
853
|
+
if found_pr
|
|
854
|
+
pr_number = found_pr
|
|
855
|
+
task["pr_number"] = pr_number
|
|
856
|
+
task["pr_repo"] = project_config["github_repo"]
|
|
857
|
+
save_epic_state(epic)
|
|
858
|
+
LOG.info "[Basecamp:Hooks] Backfilled pr_number=#{pr_number} for card ##{card_number} during direct dispatch" if defined?(LOG)
|
|
859
|
+
elsif defined?(LOG)
|
|
860
|
+
LOG.warn "[Basecamp:Hooks] Could not find PR for card ##{card_number} — gates won't re-trigger after fixes"
|
|
861
|
+
end
|
|
862
|
+
end
|
|
863
|
+
|
|
824
864
|
# Find the worktree for this card
|
|
825
865
|
work_items_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "work_items.json")
|
|
826
866
|
worktree_path = repo_path # Default to main repo
|
|
@@ -189,6 +189,8 @@ module Brainiac
|
|
|
189
189
|
load_epics.find { |e| e["id"] == epic_id }
|
|
190
190
|
end
|
|
191
191
|
|
|
192
|
+
WEBHOOK_CHECK_COOLDOWN = 300 # 5 minutes
|
|
193
|
+
|
|
192
194
|
private
|
|
193
195
|
|
|
194
196
|
# Resolve current todolist state from Basecamp and dispatch unblocked cards.
|
|
@@ -264,7 +266,9 @@ module Brainiac
|
|
|
264
266
|
end
|
|
265
267
|
|
|
266
268
|
# Dispatch unblocked tasks that aren't already in-flight or complete.
|
|
269
|
+
# Ensures Fizzy webhooks are active before dispatching (prevents silent failures).
|
|
267
270
|
def dispatch_unblocked_tasks(epic)
|
|
271
|
+
ensure_fizzy_webhooks_active
|
|
268
272
|
tasks = epic["tasks"].map do |t|
|
|
269
273
|
Epic::Task.new(
|
|
270
274
|
todo_id: t["todo_id"],
|
|
@@ -316,7 +320,9 @@ module Brainiac
|
|
|
316
320
|
|
|
317
321
|
log_event(epic, "dispatched", "Card ##{card_number} dispatched to #{agent}")
|
|
318
322
|
|
|
319
|
-
# Assign the card in Fizzy via CLI — this triggers the normal Fizzy webhook flow
|
|
323
|
+
# Assign the card in Fizzy via CLI — this triggers the normal Fizzy webhook flow.
|
|
324
|
+
# If the agent is already assigned (stale state from a disabled webhook),
|
|
325
|
+
# the method will cycle the assignment to re-trigger the webhook.
|
|
320
326
|
assign_fizzy_card(card_number, agent)
|
|
321
327
|
|
|
322
328
|
# Post a comment on the Basecamp todo
|
|
@@ -416,7 +422,10 @@ module Brainiac
|
|
|
416
422
|
end
|
|
417
423
|
|
|
418
424
|
# Assign a Fizzy card to an agent via Fizzy CLI.
|
|
419
|
-
# If the agent is already assigned,
|
|
425
|
+
# If the agent is already assigned, unassigns and reassigns to re-trigger the webhook
|
|
426
|
+
# (Fizzy webhooks can silently become disabled, causing the original assignment to be lost).
|
|
427
|
+
#
|
|
428
|
+
# @return [Symbol] :assigned, :reassigned, or :failed
|
|
420
429
|
def assign_fizzy_card(card_number, agent)
|
|
421
430
|
agent_config = load_agent_registry[agent.downcase]
|
|
422
431
|
fizzy_name = agent_config&.dig("fizzy_name") || agent
|
|
@@ -425,18 +434,34 @@ module Brainiac
|
|
|
425
434
|
fizzy_user_id = resolve_fizzy_user_id(fizzy_name)
|
|
426
435
|
unless fizzy_user_id
|
|
427
436
|
LOG.error "[Basecamp:Orchestrator] Could not resolve Fizzy user ID for '#{fizzy_name}'" if defined?(LOG)
|
|
428
|
-
return
|
|
437
|
+
return :failed
|
|
429
438
|
end
|
|
430
439
|
|
|
431
|
-
# Check if agent is already assigned
|
|
440
|
+
# Check if agent is already assigned
|
|
432
441
|
stdout, _, status = Open3.capture3("fizzy", "card", "show", card_number.to_s, "--json")
|
|
433
442
|
if status.success?
|
|
434
443
|
card_data = JSON.parse(stdout).dig("data") rescue nil
|
|
435
444
|
if card_data
|
|
436
445
|
current_assignees = (card_data["assignees"] || []).map { |a| a["id"] }
|
|
437
446
|
if current_assignees.include?(fizzy_user_id)
|
|
438
|
-
|
|
439
|
-
|
|
447
|
+
# Agent is already assigned — webhook may not have fired (disabled webhook).
|
|
448
|
+
# Unassign then reassign to trigger a fresh webhook delivery.
|
|
449
|
+
LOG.warn "[Basecamp:Orchestrator] Agent already assigned to ##{card_number} — " \
|
|
450
|
+
"cycling assignment to re-trigger webhook" if defined?(LOG)
|
|
451
|
+
|
|
452
|
+
# Fizzy assign TOGGLES, so calling it removes the assignment
|
|
453
|
+
Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
|
|
454
|
+
sleep 1 # Brief pause to ensure the unassign is processed
|
|
455
|
+
|
|
456
|
+
# Now reassign — this should trigger the webhook
|
|
457
|
+
stdout2, stderr2, status2 = Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
|
|
458
|
+
if status2.success?
|
|
459
|
+
LOG.info "[Basecamp:Orchestrator] Reassigned Fizzy ##{card_number} to #{fizzy_name} (webhook re-triggered)" if defined?(LOG)
|
|
460
|
+
return :reassigned
|
|
461
|
+
else
|
|
462
|
+
LOG.error "[Basecamp:Orchestrator] Failed to reassign Fizzy ##{card_number}: #{stderr2.strip}" if defined?(LOG)
|
|
463
|
+
return :failed
|
|
464
|
+
end
|
|
440
465
|
end
|
|
441
466
|
end
|
|
442
467
|
end
|
|
@@ -445,11 +470,77 @@ module Brainiac
|
|
|
445
470
|
|
|
446
471
|
if status.success?
|
|
447
472
|
LOG.info "[Basecamp:Orchestrator] Assigned Fizzy ##{card_number} to #{fizzy_name} (#{fizzy_user_id})" if defined?(LOG)
|
|
473
|
+
:assigned
|
|
448
474
|
else
|
|
449
475
|
LOG.error "[Basecamp:Orchestrator] Failed to assign Fizzy ##{card_number}: #{stderr.strip}" if defined?(LOG)
|
|
476
|
+
:failed
|
|
450
477
|
end
|
|
451
478
|
rescue Errno::ENOENT => e
|
|
452
479
|
LOG.error "[Basecamp:Orchestrator] fizzy CLI not found: #{e.message}" if defined?(LOG)
|
|
480
|
+
:failed
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Ensure Fizzy webhooks are active before dispatching cards.
|
|
484
|
+
# Fizzy webhooks can silently become disabled (e.g., after repeated delivery failures),
|
|
485
|
+
# causing assigned cards to never trigger agent work. This pre-flight check reactivates
|
|
486
|
+
# any disabled brainiac webhooks.
|
|
487
|
+
#
|
|
488
|
+
# Uses a cooldown to avoid hammering the API on every dispatch batch.
|
|
489
|
+
def ensure_fizzy_webhooks_active
|
|
490
|
+
now = Time.now
|
|
491
|
+
@last_webhook_check ||= Time.at(0)
|
|
492
|
+
return if (now - @last_webhook_check) < WEBHOOK_CHECK_COOLDOWN
|
|
493
|
+
|
|
494
|
+
@last_webhook_check = now
|
|
495
|
+
|
|
496
|
+
# Read fizzy config to get admin_token and board list
|
|
497
|
+
fizzy_config_file = File.join(BRAINIAC_DIR, "fizzy.json")
|
|
498
|
+
return unless File.exist?(fizzy_config_file)
|
|
499
|
+
|
|
500
|
+
fizzy_config = JSON.parse(File.read(fizzy_config_file))
|
|
501
|
+
admin_token = fizzy_config["admin_token"]
|
|
502
|
+
boards = fizzy_config["boards"] || {}
|
|
503
|
+
|
|
504
|
+
unless admin_token
|
|
505
|
+
LOG.debug "[Basecamp:Orchestrator] Fizzy webhook check skipped (no admin_token in fizzy.json)" if defined?(LOG)
|
|
506
|
+
return
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
env = { "FIZZY_TOKEN" => admin_token }
|
|
510
|
+
reactivated = 0
|
|
511
|
+
|
|
512
|
+
boards.each do |board_key, board_config|
|
|
513
|
+
board_id = board_config["board_id"]
|
|
514
|
+
next unless board_id
|
|
515
|
+
|
|
516
|
+
stdout, _, status = Open3.capture3(env, "fizzy", "webhook", "list",
|
|
517
|
+
"--board", board_id, "--all", "--json",
|
|
518
|
+
chdir: Dir.home)
|
|
519
|
+
next unless status.success?
|
|
520
|
+
|
|
521
|
+
webhooks = JSON.parse(stdout)["data"] || []
|
|
522
|
+
webhooks.each do |webhook|
|
|
523
|
+
next unless webhook["name"]&.start_with?("brainiac")
|
|
524
|
+
next if webhook["active"]
|
|
525
|
+
|
|
526
|
+
wh_name = webhook["name"]
|
|
527
|
+
LOG.warn "[Basecamp:Orchestrator] Fizzy webhook '#{wh_name}' on board '#{board_key}' is INACTIVE — reactivating" if defined?(LOG)
|
|
528
|
+
_, _, reactivate_status = Open3.capture3(env, "fizzy", "webhook", "reactivate", webhook["id"],
|
|
529
|
+
"--board", board_id, chdir: Dir.home)
|
|
530
|
+
if reactivate_status.success?
|
|
531
|
+
reactivated += 1
|
|
532
|
+
LOG.info "[Basecamp:Orchestrator] Reactivated webhook '#{webhook['name']}' on board '#{board_key}'" if defined?(LOG)
|
|
533
|
+
else
|
|
534
|
+
LOG.error "[Basecamp:Orchestrator] Failed to reactivate webhook '#{webhook['name']}' on board '#{board_key}'" if defined?(LOG)
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
rescue StandardError => e
|
|
538
|
+
LOG.warn "[Basecamp:Orchestrator] Webhook check failed for board '#{board_key}': #{e.message}" if defined?(LOG)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
LOG.info "[Basecamp:Orchestrator] Fizzy webhook health check: #{reactivated} reactivated" if reactivated.positive?
|
|
542
|
+
rescue StandardError => e
|
|
543
|
+
LOG.warn "[Basecamp:Orchestrator] Fizzy webhook health check failed: #{e.message}" if defined?(LOG)
|
|
453
544
|
end
|
|
454
545
|
|
|
455
546
|
# Resolve a Fizzy user ID from their display name.
|
|
@@ -631,6 +722,15 @@ module Brainiac
|
|
|
631
722
|
profile: epic["agent"]&.downcase)
|
|
632
723
|
end
|
|
633
724
|
|
|
725
|
+
# Mark a Basecamp todo as incomplete (undo premature completion).
|
|
726
|
+
def uncheck_todo(epic, card_number)
|
|
727
|
+
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
728
|
+
return unless task && task["todo_id"]
|
|
729
|
+
|
|
730
|
+
Client.run_safe("todos", "uncomplete", task["todo_id"].to_s, "--json",
|
|
731
|
+
profile: epic["agent"]&.downcase)
|
|
732
|
+
end
|
|
733
|
+
|
|
634
734
|
# Post a completion comment on the Basecamp todo.
|
|
635
735
|
def post_completion_comment(epic, card_number)
|
|
636
736
|
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
@@ -160,7 +160,19 @@ module Brainiac
|
|
|
160
160
|
|
|
161
161
|
when "in_review"
|
|
162
162
|
# Check if all gates have actually approved (webhook might have missed)
|
|
163
|
-
|
|
163
|
+
# Resolve pr_number if missing/zero (same pattern as final_decision)
|
|
164
|
+
effective_pr = pr_number
|
|
165
|
+
if (effective_pr.nil? || effective_pr.zero?) && project_key
|
|
166
|
+
resolved = resolve_pr_for_task(task)
|
|
167
|
+
if resolved
|
|
168
|
+
task["pr_number"] = resolved[:number]
|
|
169
|
+
task["pr_repo"] = resolved[:repo] if resolved[:repo]
|
|
170
|
+
effective_pr = resolved[:number]
|
|
171
|
+
LOG.info "[Basecamp:HealthCheck] Resolved PR ##{effective_pr} for in_review card ##{card_number}" if defined?(LOG)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
if effective_pr&.positive? && project_key && ReviewGate.enabled?
|
|
164
176
|
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
165
177
|
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
166
178
|
repo_path = projects.dig(project_key, "repo_path")
|
|
@@ -178,6 +190,28 @@ module Brainiac
|
|
|
178
190
|
Hooks.send(:save_epic_state, epic)
|
|
179
191
|
Hooks.send(:dispatch_final_decision, epic, task, {})
|
|
180
192
|
healed_any = true
|
|
193
|
+
elsif task["changes_requested_by"]&.any? && pr_has_newer_commits_than_reviews?(task, repo_path: repo_path)
|
|
194
|
+
# Changes were requested but the impl agent has already pushed fixes.
|
|
195
|
+
# The pr_synchronized hook missed this (pr_number was 0, or status wasn't in_flight).
|
|
196
|
+
# Reset approvals and re-dispatch gates to review the new code.
|
|
197
|
+
LOG.info "[Basecamp:HealthCheck] PR has commits newer than last changes_requested review for ##{card_number} — re-dispatching gates" if defined?(LOG)
|
|
198
|
+
ReviewGate.reset_approvals(task)
|
|
199
|
+
task["changes_requested_by"] = []
|
|
200
|
+
task["gates_dispatched_at"] = Time.now.iso8601
|
|
201
|
+
Hooks.send(:save_epic_state, epic)
|
|
202
|
+
|
|
203
|
+
Thread.new do
|
|
204
|
+
ReviewGate.dispatch_gates(
|
|
205
|
+
epic: epic,
|
|
206
|
+
task: task,
|
|
207
|
+
pr_number: effective_pr,
|
|
208
|
+
repo_name: github_repo,
|
|
209
|
+
repo_path: repo_path
|
|
210
|
+
)
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
LOG.error "[Basecamp:HealthCheck] Gate re-dispatch failed for ##{card_number}: #{e.message}" if defined?(LOG)
|
|
213
|
+
end
|
|
214
|
+
healed_any = true
|
|
181
215
|
elsif Hooks.send(:all_gates_responded?, task) && task["changes_requested_by"]&.any?
|
|
182
216
|
# All gates responded but some requested changes — need to dispatch impl agent
|
|
183
217
|
impl_agent = epic["agent"]
|
|
@@ -208,7 +242,7 @@ module Brainiac
|
|
|
208
242
|
ReviewGate.dispatch_gates(
|
|
209
243
|
epic: epic,
|
|
210
244
|
task: task,
|
|
211
|
-
pr_number:
|
|
245
|
+
pr_number: effective_pr,
|
|
212
246
|
repo_name: github_repo,
|
|
213
247
|
repo_path: repo_path
|
|
214
248
|
)
|
|
@@ -226,7 +260,7 @@ module Brainiac
|
|
|
226
260
|
ReviewGate.dispatch_missing_gates(
|
|
227
261
|
epic: epic,
|
|
228
262
|
task: task,
|
|
229
|
-
pr_number:
|
|
263
|
+
pr_number: effective_pr,
|
|
230
264
|
repo_name: github_repo,
|
|
231
265
|
repo_path: repo_path
|
|
232
266
|
)
|
|
@@ -237,6 +271,65 @@ module Brainiac
|
|
|
237
271
|
end
|
|
238
272
|
end
|
|
239
273
|
end
|
|
274
|
+
|
|
275
|
+
when "complete"
|
|
276
|
+
# Sanity check: verify the PR is actually merged before accepting "complete" status.
|
|
277
|
+
# Catches race conditions where rapid review cycles cause premature task completion.
|
|
278
|
+
next unless project_key
|
|
279
|
+
|
|
280
|
+
effective_pr = pr_number
|
|
281
|
+
if effective_pr.nil? || effective_pr.zero?
|
|
282
|
+
resolved = resolve_pr_for_task(task)
|
|
283
|
+
if resolved
|
|
284
|
+
task["pr_number"] = resolved[:number]
|
|
285
|
+
task["pr_repo"] = resolved[:repo] if resolved[:repo]
|
|
286
|
+
effective_pr = resolved[:number]
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# If we can't find a PR, we can't verify — skip (might be a no-code task)
|
|
291
|
+
next unless effective_pr&.positive?
|
|
292
|
+
|
|
293
|
+
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
294
|
+
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
295
|
+
github_repo = projects.dig(project_key, "github_repo")
|
|
296
|
+
repo_path = projects.dig(project_key, "repo_path")
|
|
297
|
+
next unless github_repo
|
|
298
|
+
|
|
299
|
+
pr_state, = Open3.capture2("gh", "pr", "view", effective_pr.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
|
|
300
|
+
pr_state = pr_state.strip
|
|
301
|
+
|
|
302
|
+
if pr_state == "OPEN"
|
|
303
|
+
# PR is NOT merged — task was prematurely marked complete.
|
|
304
|
+
# Revert to in_review and re-dispatch gates to review current state.
|
|
305
|
+
LOG.info "[Basecamp:HealthCheck] Task ##{card_number} marked complete but PR ##{effective_pr} is still OPEN — reverting to in_review" if defined?(LOG)
|
|
306
|
+
|
|
307
|
+
task["status"] = "in_review"
|
|
308
|
+
task.delete("completed_at")
|
|
309
|
+
task["gate_approvals"] = []
|
|
310
|
+
task["changes_requested_by"] = []
|
|
311
|
+
task["gates_dispatched_at"] = Time.now.iso8601
|
|
312
|
+
Hooks.send(:save_epic_state, epic)
|
|
313
|
+
|
|
314
|
+
# Uncheck the Basecamp todo (it was incorrectly checked)
|
|
315
|
+
Orchestrator.send(:uncheck_todo, epic, card_number) if Orchestrator.respond_to?(:uncheck_todo, true)
|
|
316
|
+
|
|
317
|
+
# Re-dispatch review gates
|
|
318
|
+
if ReviewGate.enabled? && repo_path
|
|
319
|
+
Thread.new do
|
|
320
|
+
ReviewGate.dispatch_gates(
|
|
321
|
+
epic: epic,
|
|
322
|
+
task: task,
|
|
323
|
+
pr_number: effective_pr,
|
|
324
|
+
repo_name: github_repo,
|
|
325
|
+
repo_path: repo_path
|
|
326
|
+
)
|
|
327
|
+
rescue StandardError => e
|
|
328
|
+
LOG.error "[Basecamp:HealthCheck] Gate dispatch failed for ##{card_number}: #{e.message}" if defined?(LOG)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
healed_any = true
|
|
332
|
+
end
|
|
240
333
|
end
|
|
241
334
|
end
|
|
242
335
|
|
|
@@ -249,6 +342,36 @@ module Brainiac
|
|
|
249
342
|
|
|
250
343
|
private
|
|
251
344
|
|
|
345
|
+
# Check if a PR has commits newer than the most recent changes_requested review.
|
|
346
|
+
# This detects when the impl agent pushed fixes but gates weren't re-triggered.
|
|
347
|
+
#
|
|
348
|
+
# @param task [Hash] Task state with pr_number
|
|
349
|
+
# @param repo_path [String] Local repo path for gh CLI
|
|
350
|
+
# @return [Boolean] true if PR has commits after the last changes_requested review
|
|
351
|
+
def pr_has_newer_commits_than_reviews?(task, repo_path:)
|
|
352
|
+
pr_number = task["pr_number"]
|
|
353
|
+
return false unless pr_number&.positive?
|
|
354
|
+
|
|
355
|
+
# Get the last changes_requested review timestamp and the latest commit timestamp
|
|
356
|
+
stdout, _, status = Open3.capture3(
|
|
357
|
+
"gh", "pr", "view", pr_number.to_s,
|
|
358
|
+
"--json", "reviews,commits",
|
|
359
|
+
"--jq", "[(.reviews | map(select(.state == \"CHANGES_REQUESTED\")) | sort_by(.submittedAt) | last | .submittedAt), (.commits | last | .committedDate)] | @tsv",
|
|
360
|
+
chdir: repo_path
|
|
361
|
+
)
|
|
362
|
+
return false unless status.success? && !stdout.strip.empty?
|
|
363
|
+
|
|
364
|
+
last_changes_at, last_commit_at = stdout.strip.split("\t")
|
|
365
|
+
return false if last_changes_at.nil? || last_changes_at.empty? || last_commit_at.nil? || last_commit_at.empty?
|
|
366
|
+
|
|
367
|
+
# Compare timestamps — if the latest commit is newer than the last changes_requested review,
|
|
368
|
+
# the impl agent has pushed fixes that haven't been reviewed yet.
|
|
369
|
+
Time.parse(last_commit_at) > Time.parse(last_changes_at)
|
|
370
|
+
rescue StandardError => e
|
|
371
|
+
LOG.warn "[Basecamp:HealthCheck] Error checking PR commits vs reviews: #{e.message}" if defined?(LOG)
|
|
372
|
+
false
|
|
373
|
+
end
|
|
374
|
+
|
|
252
375
|
# Resume active epics on server startup.
|
|
253
376
|
# For each epic, checks task states and takes appropriate action.
|
|
254
377
|
def resume_active_epics(epics)
|