brainiac-basecamp 0.0.11 → 0.0.12

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: d4d21875fecdd7aa33718a8db5fa446800e8a85924490b6716a455b211d79483
4
- data.tar.gz: 9601289488c260e891a6df6da2b63be3e9965c9c8235d11c4366622d27a24d0b
3
+ metadata.gz: 1eb44286a14e4c833e7ee753cf3ced44e6579eea0d37ff0c1289db149cc1c97c
4
+ data.tar.gz: b24e2ed8efe6a611f3d3790dfd38a906cd753c8c766743ab50302f365430d8db
5
5
  SHA512:
6
- metadata.gz: f24339950cce469195888d59fbc7ddecb0b75f9a7f8279df58cc2bb681637916525341ce8b9fc86c2f38bb9e8b1718a27c9f164d14afa4acff9376f4394da3eb
7
- data.tar.gz: 39d9128c65180e56fe30e59d6b384dbc9ce2b7648f8586498b88e7e44b4c516fcea6d06b279bc1956373f84f1a8602aa1fb697699a40b98e699b2c6ef2f97c59
6
+ metadata.gz: 106c01e99ca88a0ee5d2801cb632b17fdcace3bd3ea9fe17278cae2cbfa3560f1669c164bf6a12472fc4451bdc81833889aaad2c273b936df77ead50f1736450
7
+ data.tar.gz: f766419152f38af5404a7640ac87c19540814480e67a1e12ee14621595075d0fc7fc204ed33475022a090c74fbfa97ab8b5a3929c9eedfe3458b2f46345a7fa0
@@ -123,6 +123,18 @@ module Brainiac
123
123
  if epic_branches.include?(base_branch)
124
124
  LOG.info "[Basecamp:Hooks] PR merged to epic branch #{base_branch} for card ##{card_number} — advancing" if defined?(LOG)
125
125
  Orchestrator.on_card_completed(card_number)
126
+ elsif epic_branches.empty?
127
+ # Fallback: epic_branches was never populated (branch creation failed).
128
+ # If the task is in final_decision or in_review, the merge still represents
129
+ # completion — advance the epic regardless of which branch was targeted.
130
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
131
+ if task && %w[final_decision in_review in_flight].include?(task["status"])
132
+ if defined?(LOG)
133
+ LOG.warn "[Basecamp:Hooks] epic_branches is empty but PR merged to '#{base_branch}' " \
134
+ "for card ##{card_number} (status: #{task['status']}) — advancing anyway (branch creation likely failed)"
135
+ end
136
+ Orchestrator.on_card_completed(card_number)
137
+ end
126
138
  end
127
139
  end
128
140
  end
@@ -613,13 +625,32 @@ module Brainiac
613
625
  github_repo = project_config&.dig("github_repo")
614
626
 
615
627
  # Check if PR is already merged (handles manual merges, webhook misses, etc.)
616
- if github_repo && pr_number
628
+ if github_repo && pr_number&.to_i&.positive?
617
629
  pr_state, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
618
630
  if pr_state.strip == "MERGED"
619
631
  LOG.info "[Basecamp:Hooks] PR ##{pr_number} already merged — marking card ##{card_number} complete" if defined?(LOG)
620
632
  Orchestrator.on_card_completed(card_number)
621
633
  return
622
634
  end
635
+
636
+ # SAFEGUARD: Verify PR targets the epic branch (not main/master).
637
+ # If epic_branches is populated and the PR targets the wrong base, retarget it.
638
+ epic_branches = epic["epic_branches"] || {}
639
+ expected_base = epic_branches[project_key] || epic_branches.values.first
640
+ if expected_base
641
+ actual_base, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "baseRefName", "-q",
642
+ ".baseRefName")
643
+ actual_base = actual_base.strip
644
+ if !actual_base.empty? && actual_base != expected_base
645
+ LOG.warn "[Basecamp:Hooks] PR ##{pr_number} targets '#{actual_base}' but expected '#{expected_base}' — retargeting" if defined?(LOG)
646
+ _, stderr, status = Open3.capture3("gh", "pr", "edit", pr_number.to_s, "--repo", github_repo, "--base", expected_base)
647
+ if status.success?
648
+ LOG.info "[Basecamp:Hooks] Retargeted PR ##{pr_number} to '#{expected_base}'" if defined?(LOG)
649
+ elsif defined?(LOG)
650
+ LOG.error "[Basecamp:Hooks] Failed to retarget PR ##{pr_number}: #{stderr.strip}"
651
+ end
652
+ end
653
+ end
623
654
  end
624
655
 
625
656
  # Mark task as awaiting final decision
@@ -659,6 +690,17 @@ module Brainiac
659
690
  gate_approvals = task["gate_approvals"] || []
660
691
  gate_agents = gate_approvals.map { |a| a["agent"] }.join(", ")
661
692
 
693
+ # Determine expected target branch for the prompt
694
+ epic_branches = epic["epic_branches"] || {}
695
+ expected_base = epic_branches[project_key] || epic_branches.values.first
696
+ target_note = if expected_base
697
+ "\n**IMPORTANT:** This PR should target `#{expected_base}` (the epic branch). " \
698
+ "If `gh pr view #{pr_number} --json baseRefName` shows a different base, " \
699
+ "do NOT merge — report the mismatch instead.\n"
700
+ else
701
+ ""
702
+ end
703
+
662
704
  prompt = <<~PROMPT
663
705
  ## Final Decision Required — Fizzy Card ##{card_number}
664
706
 
@@ -670,7 +712,7 @@ module Brainiac
670
712
  ```
671
713
  gh pr merge #{pr_number} --squash --delete-branch
672
714
  ```
673
-
715
+ #{target_note}
674
716
  Note: You cannot self-approve PRs you authored. Merge directly since gates have approved.
675
717
 
676
718
  After merging, update the Fizzy card with a brief status comment.
@@ -741,12 +783,12 @@ module Brainiac
741
783
 
742
784
  # Guard: don't spawn a duplicate if a session is already running for this task
743
785
  session_alive = if defined?(session_active?)
744
- session_active?(card_key)
745
- elsif Object.respond_to?(:session_active?, true)
746
- Object.send(:session_active?, card_key)
747
- else
748
- false
749
- end
786
+ session_active?(card_key)
787
+ elsif Object.respond_to?(:session_active?, true)
788
+ Object.send(:session_active?, card_key)
789
+ else
790
+ false
791
+ end
750
792
 
751
793
  if session_alive
752
794
  LOG.info "[Basecamp:Hooks] Session already active for #{card_key} — skipping dispatch" if defined?(LOG)
@@ -785,11 +827,11 @@ module Brainiac
785
827
  prompt = <<~PROMPT
786
828
  ## Changes Requested — Fizzy Card ##{card_number}
787
829
 
788
- Review gates have requested changes on your PR#{pr_number ? " ##{pr_number}" : ""}.
830
+ Review gates have requested changes on your PR#{" ##{pr_number}" if pr_number}.
789
831
  Reviewers who requested changes: #{changers}
790
832
 
791
833
  Your job:
792
- 1. Read the review feedback: #{pr_number ? "`gh pr view #{pr_number} --comments`" : "check the Fizzy card comments"}
834
+ 1. Read the review feedback: #{pr_number ? "`gh pr view #{pr_number} --comments`" : 'check the Fizzy card comments'}
793
835
  2. Address all requested changes
794
836
  3. Commit and push your fixes
795
837
 
@@ -764,14 +764,43 @@ module Brainiac
764
764
  end
765
765
 
766
766
  # Create epic branches for all projects involved in this epic.
767
+ # CRITICAL: If this fails, epic_branches will be empty and agents will
768
+ # open PRs against the default branch instead of the epic branch.
769
+ # We log loudly and retry resolution of project repos if initial attempt fails.
767
770
  def create_epic_branches_for(epic)
768
771
  project_repos = resolve_project_repos(epic)
769
- return if project_repos.empty?
772
+
773
+ if project_repos.empty?
774
+ # Fallback: try to resolve from the basecamp project mapping directly
775
+ mapped_project = Config.brainiac_project_for(epic["basecamp_project_id"])
776
+ if mapped_project
777
+ projects_file = File.join(BRAINIAC_DIR, "projects.json")
778
+ all_projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
779
+ repo = all_projects.dig(mapped_project, "repo_path")
780
+ project_repos = { mapped_project => repo } if repo
781
+ end
782
+ end
783
+
784
+ if project_repos.empty?
785
+ LOG.error "[Basecamp:Orchestrator] Cannot create epic branches — no project repos resolved. " \
786
+ "Tasks have projects: #{(epic['tasks'] || []).map { |t| t['project'] }.compact.uniq.inspect}. " \
787
+ "Epic will proceed WITHOUT epic branches — PRs will target the default branch!" if defined?(LOG)
788
+ log_event(epic, "branches_failed", "No project repos resolved — epic branches not created")
789
+ return
790
+ end
770
791
 
771
792
  epic["epic_branches"] = EpicBranch.create_epic_branches(epic, project_repos)
772
- log_event(epic, "branches_created", "Epic branches: #{epic['epic_branches'].values.uniq.join(', ')}")
793
+
794
+ if epic["epic_branches"].empty?
795
+ LOG.error "[Basecamp:Orchestrator] create_epic_branches returned empty — branch creation failed" if defined?(LOG)
796
+ log_event(epic, "branches_failed", "Branch creation returned empty for repos: #{project_repos.keys.join(', ')}")
797
+ else
798
+ log_event(epic, "branches_created", "Epic branches: #{epic['epic_branches'].values.uniq.join(', ')}")
799
+ end
773
800
  rescue StandardError => e
774
- LOG.error "[Basecamp:Orchestrator] Failed to create epic branches: #{e.message}" if defined?(LOG)
801
+ LOG.error "[Basecamp:Orchestrator] Failed to create epic branches: #{e.class}: #{e.message}" \
802
+ "\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
803
+ log_event(epic, "branches_failed", "Exception: #{e.message}")
775
804
  end
776
805
 
777
806
  # Open final PRs from epic branches to main.
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Basecamp
6
- VERSION = "0.0.11"
6
+ VERSION = "0.0.12"
7
7
  end
8
8
  end
9
9
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
3
4
  require_relative "basecamp/version"
4
5
  require_relative "basecamp/metadata"
5
6
  require_relative "basecamp/config"
@@ -99,16 +100,27 @@ module Brainiac
99
100
 
100
101
  case status
101
102
  when "final_decision"
102
- # Check if PR is already merged
103
- if pr_number && project_key
103
+ # Check if PR is already merged — resolve pr_number first if missing/zero
104
+ effective_pr = pr_number
105
+ if (effective_pr.nil? || effective_pr.zero?) && project_key
106
+ resolved = resolve_pr_for_task(task)
107
+ if resolved
108
+ task["pr_number"] = resolved[:number]
109
+ task["pr_repo"] = resolved[:repo] if resolved[:repo]
110
+ effective_pr = resolved[:number]
111
+ LOG.info "[Basecamp:HealthCheck] Resolved PR ##{effective_pr} for card ##{card_number}" if defined?(LOG)
112
+ end
113
+ end
114
+
115
+ if effective_pr&.positive? && project_key
104
116
  projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
105
117
  projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
106
118
  github_repo = projects.dig(project_key, "github_repo")
107
119
 
108
120
  if github_repo
109
- pr_state, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
121
+ pr_state, = Open3.capture2("gh", "pr", "view", effective_pr.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
110
122
  if pr_state.strip == "MERGED"
111
- LOG.info "[Basecamp:HealthCheck] PR ##{pr_number} merged but task ##{card_number} stuck in final_decision — healing" if defined?(LOG)
123
+ LOG.info "[Basecamp:HealthCheck] PR ##{effective_pr} merged but task ##{card_number} stuck in final_decision — healing" if defined?(LOG)
112
124
  Orchestrator.on_card_completed(card_number)
113
125
  healed_any = true
114
126
  end
@@ -388,6 +400,35 @@ module Brainiac
388
400
  card_number = task["fizzy_card"]
389
401
  LOG.info "[Basecamp] Resume: checking final_decision task ##{card_number}, awaiting=#{task['awaiting_final_decision']}" if defined?(LOG)
390
402
 
403
+ # First, resolve PR number if missing or zero (branch creation may have failed)
404
+ pr_number = task["pr_number"]
405
+ if (pr_number.nil? || pr_number.zero?) && task["project"]
406
+ resolved_pr = resolve_pr_for_task(task)
407
+ if resolved_pr
408
+ task["pr_number"] = resolved_pr[:number]
409
+ task["pr_repo"] = resolved_pr[:repo] if resolved_pr[:repo]
410
+ pr_number = resolved_pr[:number]
411
+ LOG.info "[Basecamp] Resume: resolved PR ##{pr_number} for card ##{card_number}" if defined?(LOG)
412
+ end
413
+ end
414
+
415
+ # Check if PR is already merged (handles manual merges, webhook misses, agent merges to wrong branch)
416
+ if pr_number&.positive?
417
+ project_key = task["project"]
418
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
419
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
420
+ github_repo = projects.dig(project_key, "github_repo")
421
+
422
+ if github_repo
423
+ pr_state, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
424
+ if pr_state.strip == "MERGED"
425
+ LOG.info "[Basecamp] Resume: PR ##{pr_number} already merged — marking card ##{card_number} complete" if defined?(LOG)
426
+ Orchestrator.on_card_completed(card_number)
427
+ return
428
+ end
429
+ end
430
+ end
431
+
391
432
  # If awaiting_final_decision is set, re-dispatch
392
433
  # Also handle the case where it's nil but gates are all approved (stale state)
393
434
  if task["awaiting_final_decision"] || ReviewGate.all_gates_passed?(task)
@@ -400,6 +441,38 @@ module Brainiac
400
441
  end
401
442
  end
402
443
 
444
+ # Resolve a PR number for a task by searching GitHub for matching branch patterns.
445
+ # Used when pr_number is 0 or nil (e.g., branch creation failed so PR was never tracked).
446
+ def resolve_pr_for_task(task)
447
+ card_number = task["fizzy_card"]
448
+ project_key = task["project"]
449
+ return nil unless project_key
450
+
451
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
452
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
453
+ repo_path = projects.dig(project_key, "repo_path")
454
+ github_repo = projects.dig(project_key, "github_repo")
455
+ return nil unless repo_path
456
+
457
+ # Search for PR by fizzy-NNNN branch pattern (any state)
458
+ stdout, _, status = Open3.capture3(
459
+ "gh", "pr", "list", "--state", "all",
460
+ "--json", "number,headRefName,state",
461
+ "--jq", ".[] | select(.headRefName | startswith(\"fizzy-#{card_number}\")) | [.number, .state] | @tsv",
462
+ chdir: repo_path
463
+ )
464
+ return nil unless status.success? && !stdout.strip.empty?
465
+
466
+ # Take the first match
467
+ number, _state = stdout.strip.split("\n").first.split("\t")
468
+ return nil unless number
469
+
470
+ { number: number.to_i, repo: github_repo }
471
+ rescue StandardError => e
472
+ LOG.warn "[Basecamp] Error resolving PR for card ##{card_number}: #{e.message}" if defined?(LOG)
473
+ nil
474
+ end
475
+
403
476
  def setup_routes(app)
404
477
  setup_webhook_route(app)
405
478
  setup_api_routes(app)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainiac-basecamp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.11
4
+ version: 0.0.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis