brainiac-basecamp 0.0.9 → 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: f3b4d7d9e99a700f2a533b48110071025bde9eb69b55633acffd4125f88c5540
4
- data.tar.gz: 63635c64597b65ed6e0a69e90511010d6975c418249c7a181d9a0349bded7dcc
3
+ metadata.gz: 1eb44286a14e4c833e7ee753cf3ced44e6579eea0d37ff0c1289db149cc1c97c
4
+ data.tar.gz: b24e2ed8efe6a611f3d3790dfd38a906cd753c8c766743ab50302f365430d8db
5
5
  SHA512:
6
- metadata.gz: 37d7d00ab786c8f99318d7098b8a700d8428d0c3db1da4ceaa1040aa924b627b0b886a928cb7f6f1661e35f578403ae4b8911b87a5c48c6bc67a9c0ed5f61c02
7
- data.tar.gz: a798eb76376698539c16c54ecdfea9d597893e0f0630c035e9e473401e45b43c0357e4cb6ef8717df704c98543f2bace4754b5f2db6dc97677a46ee8782b2ff9
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
@@ -214,16 +226,12 @@ module Brainiac
214
226
  # All gates have reviewed — dispatch implementation agent to address ALL feedback
215
227
  LOG.info "[Basecamp:Hooks] All gates responded for card ##{card_number} — dispatching fixes" if defined?(LOG)
216
228
  task["status"] = "in_flight"
229
+ task["dispatched_at"] = Time.now.iso8601
217
230
  task.delete("changes_debounce_started")
218
231
  save_epic_state(epic)
219
232
 
220
- # Re-assign card to implementation agent to trigger dispatch
221
- impl_agent = epic["agent"]
222
- fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
223
- if fizzy_user_id
224
- LOG.info "[Basecamp:Hooks] Re-assigning card ##{card_number} to #{impl_agent} for fixes" if defined?(LOG)
225
- safe_assign_card(card_number, fizzy_user_id)
226
- end
233
+ # Spawn agent directly safe_assign_card won't work if already assigned
234
+ dispatch_impl_directly(epic, task)
227
235
  else
228
236
  # Wait for remaining gates to respond
229
237
  responded = (task["gate_approvals"]&.size || 0) + (task["changes_requested_by"]&.size || 0)
@@ -246,11 +254,11 @@ module Brainiac
246
254
  if task_reloaded && task_reloaded["status"] == "in_review" && task_reloaded["changes_requested_by"]&.any?
247
255
  LOG.info "[Basecamp:Hooks] Debounce timeout for card ##{card_number} — forcing dispatch" if defined?(LOG)
248
256
  task_reloaded["status"] = "in_flight"
257
+ task_reloaded["dispatched_at"] = Time.now.iso8601
249
258
  task_reloaded.delete("changes_debounce_started")
250
259
  save_epic_state(epic_reloaded)
251
- # Re-assign card to trigger dispatch
252
- fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, epic_reloaded["agent"])
253
- safe_assign_card(card_number, fizzy_user_id) if fizzy_user_id
260
+ # Spawn agent directly safe_assign_card won't work if already assigned
261
+ dispatch_impl_directly(epic_reloaded, task_reloaded)
254
262
  end
255
263
  rescue StandardError => e
256
264
  LOG.error "[Basecamp:Hooks] Debounce dispatch failed: #{e.message}" if defined?(LOG)
@@ -617,13 +625,32 @@ module Brainiac
617
625
  github_repo = project_config&.dig("github_repo")
618
626
 
619
627
  # Check if PR is already merged (handles manual merges, webhook misses, etc.)
620
- if github_repo && pr_number
628
+ if github_repo && pr_number&.to_i&.positive?
621
629
  pr_state, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
622
630
  if pr_state.strip == "MERGED"
623
631
  LOG.info "[Basecamp:Hooks] PR ##{pr_number} already merged — marking card ##{card_number} complete" if defined?(LOG)
624
632
  Orchestrator.on_card_completed(card_number)
625
633
  return
626
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
627
654
  end
628
655
 
629
656
  # Mark task as awaiting final decision
@@ -663,6 +690,17 @@ module Brainiac
663
690
  gate_approvals = task["gate_approvals"] || []
664
691
  gate_agents = gate_approvals.map { |a| a["agent"] }.join(", ")
665
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
+
666
704
  prompt = <<~PROMPT
667
705
  ## Final Decision Required — Fizzy Card ##{card_number}
668
706
 
@@ -674,7 +712,7 @@ module Brainiac
674
712
  ```
675
713
  gh pr merge #{pr_number} --squash --delete-branch
676
714
  ```
677
-
715
+ #{target_note}
678
716
  Note: You cannot self-approve PRs you authored. Merge directly since gates have approved.
679
717
 
680
718
  After merging, update the Fizzy card with a brief status comment.
@@ -728,14 +766,123 @@ module Brainiac
728
766
  end
729
767
  end
730
768
 
731
- def mark_in_review(epic, card_number)
732
- task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
733
- return unless task
769
+ # Dispatch the implementation agent directly (spawns via run_agent) to address
770
+ # changes_requested feedback. Used by resume and health-check when the agent is
771
+ # no longer running but the Fizzy card is still assigned (so safe_assign_card
772
+ # would bail out with "already assigned, skipping").
773
+ #
774
+ # @param epic [Hash] Epic state
775
+ # @param task [Hash] Task within the epic
776
+ # @return [void]
777
+ def dispatch_impl_directly(epic, task)
778
+ card_number = task["fizzy_card"]
779
+ agent_name = epic["agent"]
780
+ pr_number = task["pr_number"]
781
+ project_key = task["project"]
782
+ card_key = "changes-requested-#{card_number}"
783
+
784
+ # Guard: don't spawn a duplicate if a session is already running for this task
785
+ session_alive = if defined?(session_active?)
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
792
+
793
+ if session_alive
794
+ LOG.info "[Basecamp:Hooks] Session already active for #{card_key} — skipping dispatch" if defined?(LOG)
795
+ return
796
+ end
734
797
 
735
- task["status"] = "in_review"
736
- task["review_started_at"] = Time.now.iso8601
737
- epic["updated_at"] = Time.now.iso8601
738
- save_epic_state(epic)
798
+ LOG.info "[Basecamp:Hooks] Direct-dispatching #{agent_name} for changes_requested on card ##{card_number}" if defined?(LOG)
799
+
800
+ # Get project config
801
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
802
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
803
+ project_config = projects[project_key]
804
+
805
+ unless project_config
806
+ LOG.error "[Basecamp:Hooks] No project config for '#{project_key}' — cannot direct-dispatch" if defined?(LOG)
807
+ return
808
+ end
809
+
810
+ repo_path = project_config["repo_path"]
811
+ unless repo_path
812
+ LOG.error "[Basecamp:Hooks] No repo_path for project '#{project_key}' — cannot direct-dispatch" if defined?(LOG)
813
+ return
814
+ end
815
+
816
+ # Find the worktree for this card
817
+ work_items_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "work_items.json")
818
+ worktree_path = repo_path # Default to main repo
819
+ if File.exist?(work_items_file)
820
+ work_items = JSON.parse(File.read(work_items_file))
821
+ work_item = work_items.values.find { |wi| wi.dig("sources", "fizzy", "card_number") == card_number }
822
+ worktree_path = work_item["worktree"] if work_item&.dig("worktree")
823
+ end
824
+
825
+ # Build prompt
826
+ changers = (task["changes_requested_by"] || []).join(", ")
827
+ prompt = <<~PROMPT
828
+ ## Changes Requested — Fizzy Card ##{card_number}
829
+
830
+ Review gates have requested changes on your PR#{" ##{pr_number}" if pr_number}.
831
+ Reviewers who requested changes: #{changers}
832
+
833
+ Your job:
834
+ 1. Read the review feedback: #{pr_number ? "`gh pr view #{pr_number} --comments`" : 'check the Fizzy card comments'}
835
+ 2. Address all requested changes
836
+ 3. Commit and push your fixes
837
+
838
+ After pushing, update the Fizzy card with a brief status comment noting what you fixed.
839
+ PROMPT
840
+
841
+ # Resolve GitHub App token so agent's `gh` commands run as their bot identity
842
+ github_repo = project_config&.dig("github_repo")
843
+ agent_env = github_repo ? ReviewGate.send(:resolve_agent_github_env, agent_name, github_repo) : {}
844
+
845
+ # Spawn the agent directly
846
+ pid = nil
847
+ log_file = nil
848
+
849
+ begin
850
+ pid, log_file = method(:run_agent).call(
851
+ prompt,
852
+ project_config: project_config,
853
+ chdir: worktree_path,
854
+ log_name: "changes-requested-#{card_number}",
855
+ agent_name: agent_name,
856
+ source: :basecamp,
857
+ card_number: card_number,
858
+ env: agent_env
859
+ )
860
+ rescue NameError
861
+ if Object.respond_to?(:run_agent, true)
862
+ pid, log_file = Object.send(:run_agent,
863
+ prompt,
864
+ project_config: project_config,
865
+ chdir: worktree_path,
866
+ log_name: "changes-requested-#{card_number}",
867
+ agent_name: agent_name,
868
+ source: :basecamp,
869
+ card_number: card_number,
870
+ env: agent_env)
871
+ else
872
+ LOG.warn "[Basecamp:Hooks] run_agent not available — direct impl dispatch skipped" if defined?(LOG)
873
+ return
874
+ end
875
+ end
876
+
877
+ # Register session for waybar visibility
878
+ if pid
879
+ if defined?(register_session)
880
+ register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
881
+ elsif Object.respond_to?(:register_session, true)
882
+ Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
883
+ end
884
+ LOG.info "[Basecamp:Hooks] Spawned #{agent_name} (pid #{pid}) for changes_requested on card ##{card_number}" if defined?(LOG)
885
+ end
739
886
  end
740
887
 
741
888
  def auto_merge_and_advance(epic, card_number, ctx)
@@ -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.9"
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
@@ -129,25 +141,21 @@ module Brainiac
129
141
  impl_agent = epic["agent"]
130
142
  LOG.info "[Basecamp:HealthCheck] Task ##{card_number} in_flight for #{elapsed.round}s — re-dispatching #{impl_agent}" if defined?(LOG)
131
143
 
132
- # Re-dispatch by re-assigning the card (triggers webhook)
144
+ # Spawn the agent directly safe_assign_card won't work if already assigned
133
145
  task["dispatched_at"] = Time.now.iso8601
134
- fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
135
- if fizzy_user_id
136
- Hooks.send(:safe_assign_card, card_number, fizzy_user_id)
137
- healed_any = true
138
- end
146
+ Hooks.send(:dispatch_impl_directly, epic, task)
147
+ healed_any = true
139
148
  end
140
149
  elsif task["changes_requested_by"]&.any?
141
150
  # No dispatched_at recorded but changes were requested — legacy state.
142
151
  # Always re-dispatch in this case.
143
152
  impl_agent = epic["agent"]
144
153
  LOG.info "[Basecamp:HealthCheck] Task ##{card_number} in_flight with changes_requested but no dispatched_at — re-dispatching #{impl_agent}" if defined?(LOG)
154
+
155
+ # Spawn the agent directly — safe_assign_card won't work if already assigned
145
156
  task["dispatched_at"] = Time.now.iso8601
146
- fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
147
- if fizzy_user_id
148
- Hooks.send(:safe_assign_card, card_number, fizzy_user_id)
149
- healed_any = true
150
- end
157
+ Hooks.send(:dispatch_impl_directly, epic, task)
158
+ healed_any = true
151
159
  end
152
160
 
153
161
  when "in_review"
@@ -329,13 +337,11 @@ module Brainiac
329
337
 
330
338
  # Transition to in_flight so the agent gets the right context
331
339
  task["status"] = "in_flight"
340
+ task["dispatched_at"] = Time.now.iso8601
332
341
  Hooks.send(:save_epic_state, epic)
333
342
 
334
- # Re-assign card to trigger a fresh dispatch via the Fizzy webhook
335
- fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
336
- if fizzy_user_id
337
- Hooks.send(:safe_assign_card, card_number, fizzy_user_id)
338
- end
343
+ # Spawn the agent directly safe_assign_card won't work if already assigned
344
+ Hooks.send(:dispatch_impl_directly, epic, task)
339
345
  else
340
346
  # No changes requested yet — check if gates need (re-)dispatching
341
347
  approvals = task["gate_approvals"]&.size || 0
@@ -394,6 +400,35 @@ module Brainiac
394
400
  card_number = task["fizzy_card"]
395
401
  LOG.info "[Basecamp] Resume: checking final_decision task ##{card_number}, awaiting=#{task['awaiting_final_decision']}" if defined?(LOG)
396
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
+
397
432
  # If awaiting_final_decision is set, re-dispatch
398
433
  # Also handle the case where it's nil but gates are all approved (stale state)
399
434
  if task["awaiting_final_decision"] || ReviewGate.all_gates_passed?(task)
@@ -406,6 +441,38 @@ module Brainiac
406
441
  end
407
442
  end
408
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
+
409
476
  def setup_routes(app)
410
477
  setup_webhook_route(app)
411
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.9
4
+ version: 0.0.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis