Module: Brainiac::Plugins::Basecamp::EpicBranch

Defined in:
lib/brainiac/plugins/basecamp/epic_branch.rb

Overview

Manages epic branches — one per repo involved in the epic. Handles creation, PR auto-merge, and final PR to main.

Constant Summary collapse

BRAINIAC_DIR =
ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))

Class Method Summary collapse

Class Method Details

.create_epic_branches(epic, project_repos) ⇒ Hash<String, String>

Create epic branches for all repos involved in an epic. Called when orchestration starts.

Parameters:

  • epic (Hash)

    Epic state

  • project_repos (Hash<String, String>)

    project_key => repo_path mapping

Returns:

  • (Hash<String, String>)

    project_key => epic branch name



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/brainiac/plugins/basecamp/epic_branch.rb', line 20

def create_epic_branches(epic, project_repos)
  slug = branch_slug(epic["title"])
  branches = {}

  project_repos.each do |project_key, repo_path|
    branch_name = "epic/#{slug}"
    default_branch = detect_default_branch(repo_path)

    # Fetch latest
    run_git("fetch", "origin", chdir: repo_path)

    # Check if branch already exists remotely
    remote_exists = system("git", "ls-remote", "--exit-code", "--heads", "origin", branch_name,
                           chdir: repo_path, out: File::NULL, err: File::NULL)

    if remote_exists
      # Branch exists — make sure we have it locally
      run_git("fetch", "origin", "#{branch_name}:#{branch_name}", chdir: repo_path)
      LOG.info "[Basecamp:EpicBranch] Reusing existing epic branch '#{branch_name}' in #{project_key}" if defined?(LOG)
    else
      # Create new branch from default branch
      run_git("branch", branch_name, "origin/#{default_branch}", chdir: repo_path)
      run_git("push", "-u", "origin", branch_name, chdir: repo_path)
      LOG.info "[Basecamp:EpicBranch] Created epic branch '#{branch_name}' in #{project_key}" if defined?(LOG)
    end

    branches[project_key] = branch_name
  end

  branches
end

.ensure_epic_branch_for_card(card_number) ⇒ String?

Lazily ensure an epic branch exists for a card's project. Called when epic_branch_for_card returns nil but the card IS in an active epic. Creates the branch in the task's repo and registers it in epic state.

Parameters:

  • card_number (Integer, String)

    Fizzy card number

Returns:

  • (String, nil)

    Epic branch name or nil



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/brainiac/plugins/basecamp/epic_branch.rb', line 176

def ensure_epic_branch_for_card(card_number)
  epic = Orchestrator.find_epic_for_card(card_number.to_i)
  return nil unless epic
  return nil unless epic["review_gate"] == "epic_branch"

  task = epic["tasks"]&.find { |t| t["fizzy_card"] == card_number.to_i }
  return nil unless task

  project_key = task["project"]
  return nil unless project_key

  # Already have a branch for this project
  epic_branches = epic["epic_branches"] || {}
  return epic_branches[project_key] if epic_branches[project_key]

  # Resolve repo path for this project
  projects_file = File.join(BRAINIAC_DIR, "projects.json")
  return nil unless File.exist?(projects_file)

  all_projects = JSON.parse(File.read(projects_file))
  repo_path = all_projects.dig(project_key, "repo_path")
  return nil unless repo_path

  # Create the branch
  slug = branch_slug(epic["title"])
  branch_name = "epic/#{slug}"
  default_branch = detect_default_branch(repo_path)

  run_git("fetch", "origin", chdir: repo_path)

  # Check if branch already exists remotely
  remote_exists = system("git", "ls-remote", "--exit-code", "--heads", "origin", branch_name,
                         chdir: repo_path, out: File::NULL, err: File::NULL)

  if remote_exists
    run_git("fetch", "origin", "#{branch_name}:#{branch_name}", chdir: repo_path)
    LOG.info "[Basecamp:EpicBranch] Lazy-created: reusing existing '#{branch_name}' in #{project_key}" if defined?(LOG)
  else
    run_git("branch", branch_name, "origin/#{default_branch}", chdir: repo_path)
    run_git("push", "-u", "origin", branch_name, chdir: repo_path)
    LOG.info "[Basecamp:EpicBranch] Lazy-created: new '#{branch_name}' in #{project_key}" if defined?(LOG)
  end

  # Register in epic state
  epic["epic_branches"] ||= {}
  epic["epic_branches"][project_key] = branch_name
  epic["updated_at"] = Time.now.iso8601
  Orchestrator.send(:save_epic, epic)

  branch_name
rescue StandardError => e
  LOG.error "[Basecamp:EpicBranch] Lazy branch creation failed for #{project_key}: #{e.message}" if defined?(LOG)
  nil
end

.epic_branch_for_card(card_number) ⇒ String?

Get the epic branch for a given card number (if it's in an active epic).

Parameters:

  • card_number (Integer, String)

    Fizzy card number

Returns:

  • (String, nil)

    Epic branch name or nil



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/brainiac/plugins/basecamp/epic_branch.rb', line 142

def epic_branch_for_card(card_number)
  epic = Orchestrator.find_epic_for_card(card_number.to_i)
  return nil unless epic
  return nil unless epic["review_gate"] == "epic_branch"

  epic_branches = epic["epic_branches"] || {}
  return nil if epic_branches.empty?

  # Find the project for this card's task
  task = epic["tasks"]&.find { |t| t["fizzy_card"] == card_number.to_i }
  return nil unless task

  project_key = task["project"]

  # If project is set, look up directly
  return epic_branches[project_key] if project_key && epic_branches[project_key]

  # Fallback: if there's only one epic branch AND the task has no specific project set,
  # use it (assumed single-project epic).
  # Do NOT fallback if the task has a specific project that isn't in epic_branches —
  # that means the branch wasn't created for this repo and we should return nil
  # (letting the default branch be used) rather than returning a branch from a different repo.
  return epic_branches.values.first if epic_branches.size == 1 && !project_key

  # Multi-project epic but no matching branch for this task's project — return nil
  nil
end

.merge_task_into_epic(repo_path:, branch_name:, epic_branch:) ⇒ Boolean

Auto-merge a task PR into the epic branch. Called after a task completes and its PR is ready.

Parameters:

  • repo_path (String)

    Path to the repo

  • branch_name (String)

    The task's branch name

  • epic_branch (String)

    The epic branch to merge into

Returns:

  • (Boolean)

    Whether the merge succeeded



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/brainiac/plugins/basecamp/epic_branch.rb', line 59

def merge_task_into_epic(repo_path:, branch_name:, epic_branch:)
  # Find the PR for this branch
  pr_number = find_pr_for_branch(repo_path: repo_path, branch: branch_name, base: epic_branch)
  unless pr_number
    LOG.warn "[Basecamp:EpicBranch] No PR found for branch '#{branch_name}' targeting '#{epic_branch}'" if defined?(LOG)
    return false
  end

  # Merge the PR (don't use --delete-branch — worktree cleanup handles branch deletion)
  stdout, stderr, status = Open3.capture3(
    "gh", "pr", "merge", pr_number.to_s, "--merge",
    chdir: repo_path
  )

  if status.success?
    LOG.info "[Basecamp:EpicBranch] Merged PR ##{pr_number} into '#{epic_branch}'" if defined?(LOG)
    true
  else
    LOG.error "[Basecamp:EpicBranch] Failed to merge PR ##{pr_number}: #{stderr.strip}" if defined?(LOG)
    false
  end
rescue StandardError => e
  LOG.error "[Basecamp:EpicBranch] Merge failed: #{e.message}" if defined?(LOG)
  false
end

.open_final_prs(epic, project_repos, epic_branches) ⇒ Array<Hash>

Open final PRs from epic branches to main for each repo. Called when all epic tasks are complete.

Parameters:

  • epic (Hash)

    Epic state

  • project_repos (Hash<String, String>)

    project_key => repo_path

  • epic_branches (Hash<String, String>)

    project_key => epic branch name

Returns:

  • (Array<Hash>)

    Created PR info [pr_number, url]



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/brainiac/plugins/basecamp/epic_branch.rb', line 92

def open_final_prs(epic, project_repos, epic_branches)
  prs = []

  epic_branches.each do |project_key, epic_branch|
    repo_path = project_repos[project_key]
    next unless repo_path

    default_branch = detect_default_branch(repo_path)

    # Check if epic branch has commits ahead of main
    run_git("fetch", "origin", chdir: repo_path)
    ahead = run_git("rev-list", "--count", "origin/#{default_branch}..origin/#{epic_branch}", chdir: repo_path).strip.to_i

    if ahead.zero?
      LOG.info "[Basecamp:EpicBranch] No changes in '#{epic_branch}' for #{project_key}, skipping final PR" if defined?(LOG)
      next
    end

    # Open the PR
    title = epic["title"].sub(/^Epic:\s*/i, "")
    task_count = (epic["tasks"] || []).count { |t| t.dig("project") == project_key || project_repos.size == 1 }

    pr_body = build_final_pr_body(epic, project_key)

    stdout, stderr, status = Open3.capture3(
      "gh", "pr", "create",
      "--base", default_branch,
      "--head", epic_branch,
      "--title", "[Epic] #{title}",
      "--body", pr_body,
      chdir: repo_path
    )

    if status.success?
      pr_url = stdout.strip
      pr_number = pr_url.split("/").last
      LOG.info "[Basecamp:EpicBranch] Final PR opened: #{pr_url}" if defined?(LOG)
      prs << { project: project_key, pr_number: pr_number, url: pr_url, epic_branch: epic_branch }
    else
      LOG.error "[Basecamp:EpicBranch] Failed to open final PR for #{project_key}: #{stderr.strip}" if defined?(LOG)
    end
  end

  prs
end