brainiac-basecamp 0.0.11 → 0.0.13

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: 8adcfd4dedeee9ee8fee1e9439e80f7f3917d81ece1c5420a3777619df8d7ab5
4
+ data.tar.gz: d88eb5a5e18d8bea39522ccfa6c8fbac13b2b50f87a28a29c6c1b8958e4f3eea
5
5
  SHA512:
6
- metadata.gz: f24339950cce469195888d59fbc7ddecb0b75f9a7f8279df58cc2bb681637916525341ce8b9fc86c2f38bb9e8b1718a27c9f164d14afa4acff9376f4394da3eb
7
- data.tar.gz: 39d9128c65180e56fe30e59d6b384dbc9ce2b7648f8586498b88e7e44b4c516fcea6d06b279bc1956373f84f1a8602aa1fb697699a40b98e699b2c6ef2f97c59
6
+ metadata.gz: 311711ad321fe576e24aeb51d82d9dffc4b9d2b6199b8f6b6242b7405583f224d1686d4b91cc395d3e16985dd73834324cbe539fe216f2eb22962a4f34d05d2d
7
+ data.tar.gz: 7ccb5d585757a9b87b5dfa88512e44528048c153d4d5e159c4e62baa9c78f904dec5333899abdbaeec46b0a8da0ac6bfbd1cd39ca2530030f1d6e05e3a1d096c
@@ -156,10 +156,75 @@ module Brainiac
156
156
  # If project is set, look up directly
157
157
  return epic_branches[project_key] if project_key && epic_branches[project_key]
158
158
 
159
- # Fallback: if there's only one epic branch, use it (single-project epic)
160
- return epic_branches.values.first if epic_branches.size == 1
159
+ # Fallback: if there's only one epic branch AND the task has no specific project set,
160
+ # use it (assumed single-project epic).
161
+ # Do NOT fallback if the task has a specific project that isn't in epic_branches —
162
+ # that means the branch wasn't created for this repo and we should return nil
163
+ # (letting the default branch be used) rather than returning a branch from a different repo.
164
+ return epic_branches.values.first if epic_branches.size == 1 && !project_key
165
+
166
+ # Multi-project epic but no matching branch for this task's project — return nil
167
+ nil
168
+ end
169
+
170
+ # Lazily ensure an epic branch exists for a card's project.
171
+ # Called when epic_branch_for_card returns nil but the card IS in an active epic.
172
+ # Creates the branch in the task's repo and registers it in epic state.
173
+ #
174
+ # @param card_number [Integer, String] Fizzy card number
175
+ # @return [String, nil] Epic branch name or nil
176
+ def ensure_epic_branch_for_card(card_number)
177
+ epic = Orchestrator.find_epic_for_card(card_number.to_i)
178
+ return nil unless epic
179
+ return nil unless epic["review_gate"] == "epic_branch"
180
+
181
+ task = epic["tasks"]&.find { |t| t["fizzy_card"] == card_number.to_i }
182
+ return nil unless task
183
+
184
+ project_key = task["project"]
185
+ return nil unless project_key
161
186
 
162
- # Multi-project epic but no project on task — can't determine which branch
187
+ # Already have a branch for this project
188
+ epic_branches = epic["epic_branches"] || {}
189
+ return epic_branches[project_key] if epic_branches[project_key]
190
+
191
+ # Resolve repo path for this project
192
+ projects_file = File.join(BRAINIAC_DIR, "projects.json")
193
+ return nil unless File.exist?(projects_file)
194
+
195
+ all_projects = JSON.parse(File.read(projects_file))
196
+ repo_path = all_projects.dig(project_key, "repo_path")
197
+ return nil unless repo_path
198
+
199
+ # Create the branch
200
+ slug = branch_slug(epic["title"])
201
+ branch_name = "epic/#{slug}"
202
+ default_branch = detect_default_branch(repo_path)
203
+
204
+ run_git("fetch", "origin", chdir: repo_path)
205
+
206
+ # Check if branch already exists remotely
207
+ remote_exists = system("git", "ls-remote", "--exit-code", "--heads", "origin", branch_name,
208
+ chdir: repo_path, out: File::NULL, err: File::NULL)
209
+
210
+ if remote_exists
211
+ run_git("fetch", "origin", "#{branch_name}:#{branch_name}", chdir: repo_path)
212
+ LOG.info "[Basecamp:EpicBranch] Lazy-created: reusing existing '#{branch_name}' in #{project_key}" if defined?(LOG)
213
+ else
214
+ run_git("branch", branch_name, "origin/#{default_branch}", chdir: repo_path)
215
+ run_git("push", "-u", "origin", branch_name, chdir: repo_path)
216
+ LOG.info "[Basecamp:EpicBranch] Lazy-created: new '#{branch_name}' in #{project_key}" if defined?(LOG)
217
+ end
218
+
219
+ # Register in epic state
220
+ epic["epic_branches"] ||= {}
221
+ epic["epic_branches"][project_key] = branch_name
222
+ epic["updated_at"] = Time.now.iso8601
223
+ Orchestrator.send(:save_epic, epic)
224
+
225
+ branch_name
226
+ rescue StandardError => e
227
+ LOG.error "[Basecamp:EpicBranch] Lazy branch creation failed for #{project_key}: #{e.message}" if defined?(LOG)
163
228
  nil
164
229
  end
165
230
 
@@ -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
@@ -470,12 +482,17 @@ module Brainiac
470
482
  end
471
483
 
472
484
  # Return the epic branch as the worktree base for cards in an active epic.
485
+ # If the epic branch doesn't exist for this task's project yet, create it lazily.
473
486
  def register_resolve_base_branch
474
487
  Brainiac.on(:resolve_base_branch) do |ctx|
475
488
  card_number = ctx[:card_number]
476
489
  next unless card_number
477
490
 
478
491
  branch = EpicBranch.epic_branch_for_card(card_number)
492
+
493
+ # Lazy creation: if no branch exists for this task's project, create one now
494
+ branch ||= EpicBranch.ensure_epic_branch_for_card(card_number)
495
+
479
496
  next unless branch
480
497
 
481
498
  "origin/#{branch}"
@@ -483,12 +500,15 @@ module Brainiac
483
500
  end
484
501
 
485
502
  # Return the epic branch as the PR target for cards in an active epic.
503
+ # Same lazy-creation logic as resolve_base_branch.
486
504
  def register_resolve_pr_target
487
505
  Brainiac.on(:resolve_pr_target) do |ctx|
488
506
  card_number = ctx[:card_number]
489
507
  next unless card_number
490
508
 
491
- EpicBranch.epic_branch_for_card(card_number)
509
+ branch = EpicBranch.epic_branch_for_card(card_number)
510
+ branch ||= EpicBranch.ensure_epic_branch_for_card(card_number)
511
+ branch
492
512
  end
493
513
  end
494
514
 
@@ -613,13 +633,32 @@ module Brainiac
613
633
  github_repo = project_config&.dig("github_repo")
614
634
 
615
635
  # Check if PR is already merged (handles manual merges, webhook misses, etc.)
616
- if github_repo && pr_number
636
+ if github_repo && pr_number&.to_i&.positive?
617
637
  pr_state, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
618
638
  if pr_state.strip == "MERGED"
619
639
  LOG.info "[Basecamp:Hooks] PR ##{pr_number} already merged — marking card ##{card_number} complete" if defined?(LOG)
620
640
  Orchestrator.on_card_completed(card_number)
621
641
  return
622
642
  end
643
+
644
+ # SAFEGUARD: Verify PR targets the epic branch (not main/master).
645
+ # If epic_branches is populated and the PR targets the wrong base, retarget it.
646
+ epic_branches = epic["epic_branches"] || {}
647
+ expected_base = epic_branches[project_key] || epic_branches.values.first
648
+ if expected_base
649
+ actual_base, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "baseRefName", "-q",
650
+ ".baseRefName")
651
+ actual_base = actual_base.strip
652
+ if !actual_base.empty? && actual_base != expected_base
653
+ LOG.warn "[Basecamp:Hooks] PR ##{pr_number} targets '#{actual_base}' but expected '#{expected_base}' — retargeting" if defined?(LOG)
654
+ _, stderr, status = Open3.capture3("gh", "pr", "edit", pr_number.to_s, "--repo", github_repo, "--base", expected_base)
655
+ if status.success?
656
+ LOG.info "[Basecamp:Hooks] Retargeted PR ##{pr_number} to '#{expected_base}'" if defined?(LOG)
657
+ elsif defined?(LOG)
658
+ LOG.error "[Basecamp:Hooks] Failed to retarget PR ##{pr_number}: #{stderr.strip}"
659
+ end
660
+ end
661
+ end
623
662
  end
624
663
 
625
664
  # Mark task as awaiting final decision
@@ -659,6 +698,17 @@ module Brainiac
659
698
  gate_approvals = task["gate_approvals"] || []
660
699
  gate_agents = gate_approvals.map { |a| a["agent"] }.join(", ")
661
700
 
701
+ # Determine expected target branch for the prompt
702
+ epic_branches = epic["epic_branches"] || {}
703
+ expected_base = epic_branches[project_key] || epic_branches.values.first
704
+ target_note = if expected_base
705
+ "\n**IMPORTANT:** This PR should target `#{expected_base}` (the epic branch). " \
706
+ "If `gh pr view #{pr_number} --json baseRefName` shows a different base, " \
707
+ "do NOT merge — report the mismatch instead.\n"
708
+ else
709
+ ""
710
+ end
711
+
662
712
  prompt = <<~PROMPT
663
713
  ## Final Decision Required — Fizzy Card ##{card_number}
664
714
 
@@ -670,7 +720,7 @@ module Brainiac
670
720
  ```
671
721
  gh pr merge #{pr_number} --squash --delete-branch
672
722
  ```
673
-
723
+ #{target_note}
674
724
  Note: You cannot self-approve PRs you authored. Merge directly since gates have approved.
675
725
 
676
726
  After merging, update the Fizzy card with a brief status comment.
@@ -741,12 +791,12 @@ module Brainiac
741
791
 
742
792
  # Guard: don't spawn a duplicate if a session is already running for this task
743
793
  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
794
+ session_active?(card_key)
795
+ elsif Object.respond_to?(:session_active?, true)
796
+ Object.send(:session_active?, card_key)
797
+ else
798
+ false
799
+ end
750
800
 
751
801
  if session_alive
752
802
  LOG.info "[Basecamp:Hooks] Session already active for #{card_key} — skipping dispatch" if defined?(LOG)
@@ -785,11 +835,11 @@ module Brainiac
785
835
  prompt = <<~PROMPT
786
836
  ## Changes Requested — Fizzy Card ##{card_number}
787
837
 
788
- Review gates have requested changes on your PR#{pr_number ? " ##{pr_number}" : ""}.
838
+ Review gates have requested changes on your PR#{" ##{pr_number}" if pr_number}.
789
839
  Reviewers who requested changes: #{changers}
790
840
 
791
841
  Your job:
792
- 1. Read the review feedback: #{pr_number ? "`gh pr view #{pr_number} --comments`" : "check the Fizzy card comments"}
842
+ 1. Read the review feedback: #{pr_number ? "`gh pr view #{pr_number} --comments`" : 'check the Fizzy card comments'}
793
843
  2. Address all requested changes
794
844
  3. Commit and push your fixes
795
845
 
@@ -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.13"
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.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis