brainiac-basecamp 0.0.1
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 +7 -0
- data/README.md +322 -0
- data/lib/brainiac/plugins/basecamp/cli.rb +411 -0
- data/lib/brainiac/plugins/basecamp/client.rb +174 -0
- data/lib/brainiac/plugins/basecamp/config.rb +115 -0
- data/lib/brainiac/plugins/basecamp/epic.rb +200 -0
- data/lib/brainiac/plugins/basecamp/epic_branch.rb +271 -0
- data/lib/brainiac/plugins/basecamp/hooks.rb +820 -0
- data/lib/brainiac/plugins/basecamp/metadata.rb +20 -0
- data/lib/brainiac/plugins/basecamp/orchestrator.rb +722 -0
- data/lib/brainiac/plugins/basecamp/prompts.rb +41 -0
- data/lib/brainiac/plugins/basecamp/review_gate.rb +383 -0
- data/lib/brainiac/plugins/basecamp/version.rb +9 -0
- data/lib/brainiac/plugins/basecamp/webhook.rb +189 -0
- data/lib/brainiac/plugins/basecamp.rb +314 -0
- data/lib/brainiac_basecamp.rb +4 -0
- metadata +126 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Basecamp
|
|
8
|
+
# Parses Basecamp todolist-based epics.
|
|
9
|
+
#
|
|
10
|
+
# Epic structure (Option C):
|
|
11
|
+
# Todolist: "Epic: Build Auth System"
|
|
12
|
+
# Todo: "#1234 — Set up auth models"
|
|
13
|
+
# Description: <a href="https://app.fizzy.do/org/cards/1234">Fizzy #1234</a>
|
|
14
|
+
# [depends:none] or [depends:1234,1235]
|
|
15
|
+
# Todo: "#1235 — Add API endpoints"
|
|
16
|
+
# Description: ...
|
|
17
|
+
#
|
|
18
|
+
# Each todo in the list = one work item linked to a Fizzy card.
|
|
19
|
+
# Dependencies are declared in the todo description or title.
|
|
20
|
+
module Epic
|
|
21
|
+
# Represents a single task within an epic (one todo → one Fizzy card).
|
|
22
|
+
Task = Struct.new(:todo_id, :title, :fizzy_card, :depends_on, :status, :completed,
|
|
23
|
+
:description, :assignees, :due_on, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Parse todos from a todolist into structured tasks with dependency graph.
|
|
27
|
+
#
|
|
28
|
+
# @param todos [Array<Hash>] Raw todo data from Basecamp API
|
|
29
|
+
# @return [Array<Task>] Parsed tasks with card refs and dependencies
|
|
30
|
+
def parse_todos(todos)
|
|
31
|
+
todos.map do |todo|
|
|
32
|
+
title = todo["title"] || todo["content"] || ""
|
|
33
|
+
description = todo["description"] || ""
|
|
34
|
+
todo_id = todo["id"]
|
|
35
|
+
completed = todo["completed"] || false
|
|
36
|
+
assignees = (todo["assignees"] || []).map { |a| a["name"] || a["id"].to_s }
|
|
37
|
+
due_on = todo["due_on"]
|
|
38
|
+
|
|
39
|
+
fizzy_card = extract_fizzy_card(title) || extract_fizzy_card_from_description(description)
|
|
40
|
+
depends_on = extract_dependencies(title)
|
|
41
|
+
depends_on = extract_dependencies(description) if depends_on.empty?
|
|
42
|
+
|
|
43
|
+
Task.new(
|
|
44
|
+
todo_id: todo_id,
|
|
45
|
+
title: title,
|
|
46
|
+
fizzy_card: fizzy_card,
|
|
47
|
+
depends_on: depends_on,
|
|
48
|
+
status: completed ? :complete : :pending,
|
|
49
|
+
completed: completed,
|
|
50
|
+
description: description,
|
|
51
|
+
assignees: assignees,
|
|
52
|
+
due_on: due_on
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Determine which tasks are unblocked (all dependencies satisfied).
|
|
58
|
+
#
|
|
59
|
+
# @param tasks [Array<Task>] All tasks in the epic
|
|
60
|
+
# @return [Array<Task>] Tasks ready to be worked on
|
|
61
|
+
def unblocked_tasks(tasks)
|
|
62
|
+
completed_cards = tasks.select { |t| t.status == :complete }.map(&:fizzy_card).compact
|
|
63
|
+
|
|
64
|
+
tasks.select do |task|
|
|
65
|
+
task.status == :pending &&
|
|
66
|
+
task.fizzy_card &&
|
|
67
|
+
task.depends_on.all? { |dep| completed_cards.include?(dep) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Build a full dependency graph from tasks.
|
|
72
|
+
#
|
|
73
|
+
# @param tasks [Array<Task>] All tasks
|
|
74
|
+
# @return [Hash] Graph structure for visualization/debugging
|
|
75
|
+
def dependency_graph(tasks)
|
|
76
|
+
completed_cards = tasks.select { |t| t.status == :complete }.map(&:fizzy_card).compact
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
total: tasks.size,
|
|
80
|
+
complete: tasks.count { |t| t.status == :complete },
|
|
81
|
+
pending: tasks.count { |t| t.status == :pending },
|
|
82
|
+
in_flight: tasks.count { |t| t.status == :in_flight },
|
|
83
|
+
blocked: tasks.count do |t|
|
|
84
|
+
t.status == :pending &&
|
|
85
|
+
t.depends_on.any? { |dep| !completed_cards.include?(dep) }
|
|
86
|
+
end,
|
|
87
|
+
unblocked: unblocked_tasks(tasks).size,
|
|
88
|
+
tasks: tasks.map do |t|
|
|
89
|
+
{
|
|
90
|
+
todo_id: t.todo_id,
|
|
91
|
+
fizzy_card: t.fizzy_card,
|
|
92
|
+
title: t.title,
|
|
93
|
+
status: t.status,
|
|
94
|
+
depends_on: t.depends_on,
|
|
95
|
+
assignees: t.assignees,
|
|
96
|
+
due_on: t.due_on
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Generate a rich text HTML description for a todo linked to a Fizzy card.
|
|
103
|
+
#
|
|
104
|
+
# @param fizzy_card [Integer] Fizzy card number
|
|
105
|
+
# @param fizzy_account_id [String] Fizzy account ID (for URL)
|
|
106
|
+
# @param depends_on [Array<Integer>] Card numbers this task depends on
|
|
107
|
+
# @param agent [String, nil] Agent name assigned to this task
|
|
108
|
+
# @return [String] HTML description
|
|
109
|
+
def build_todo_description(fizzy_card:, fizzy_account_id:, depends_on: [], agent: nil)
|
|
110
|
+
lines = []
|
|
111
|
+
lines << "<div>"
|
|
112
|
+
lines << "<strong>Fizzy:</strong> <a href=\"https://app.fizzy.do/#{fizzy_account_id}/cards/#{fizzy_card}\">##{fizzy_card}</a><br>"
|
|
113
|
+
|
|
114
|
+
if depends_on.any?
|
|
115
|
+
dep_links = depends_on.map { |d| "<a href=\"https://app.fizzy.do/#{fizzy_account_id}/cards/#{d}\">##{d}</a>" }
|
|
116
|
+
lines << "<strong>Depends on:</strong> #{dep_links.join(', ')}<br>"
|
|
117
|
+
else
|
|
118
|
+
lines << "<strong>Depends on:</strong> none<br>"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
lines << "<strong>Agent:</strong> #{agent}<br>" if agent
|
|
122
|
+
lines << "</div>"
|
|
123
|
+
lines.join("\n")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Extract Fizzy card number from title.
|
|
129
|
+
# Supports formats:
|
|
130
|
+
# "#1234 — Description"
|
|
131
|
+
# "#1234"
|
|
132
|
+
# "Fizzy 1234"
|
|
133
|
+
# "Fizzy #1234"
|
|
134
|
+
#
|
|
135
|
+
# @param text [String]
|
|
136
|
+
# @return [Integer, nil]
|
|
137
|
+
def extract_fizzy_card(text)
|
|
138
|
+
# Try "#NNNN" format first (more explicit)
|
|
139
|
+
if (match = text.match(/\A#(\d+)/) || text.match(/#(\d+)/))
|
|
140
|
+
return match[1].to_i
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Try "Fizzy NNNN" or "Fizzy #NNNN" format
|
|
144
|
+
if (match = text.match(/Fizzy\s+#?(\d+)/i))
|
|
145
|
+
return match[1].to_i
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Extract Fizzy card from a rich text description (look for link to fizzy.do).
|
|
152
|
+
#
|
|
153
|
+
# @param description [String] HTML description
|
|
154
|
+
# @return [Integer, nil]
|
|
155
|
+
def extract_fizzy_card_from_description(description)
|
|
156
|
+
return nil if description.nil? || description.empty?
|
|
157
|
+
|
|
158
|
+
# Look for fizzy.do card URLs
|
|
159
|
+
if (match = description.match(%r{app\.fizzy\.do/[^/]+/cards/(\d+)}))
|
|
160
|
+
return match[1].to_i
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Fallback: look for "#NNNN" in description text
|
|
164
|
+
if (match = description.match(/#(\d+)/))
|
|
165
|
+
return match[1].to_i
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Extract dependency card numbers from text (title or description).
|
|
172
|
+
# Supports:
|
|
173
|
+
# [depends:1234,1235]
|
|
174
|
+
# Depends on: #1234, #1235
|
|
175
|
+
# <strong>Depends on:</strong> #1234, #1235
|
|
176
|
+
#
|
|
177
|
+
# @param text [String]
|
|
178
|
+
# @return [Array<Integer>]
|
|
179
|
+
def extract_dependencies(text)
|
|
180
|
+
return [] if text.nil? || text.empty?
|
|
181
|
+
|
|
182
|
+
# Try [depends:N,N] format
|
|
183
|
+
if (match = text.match(/\[depends:([\d,]+)\]/))
|
|
184
|
+
return match[1].split(",").map(&:strip).map(&:to_i)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Try "Depends on:" format (handles HTML tags around it)
|
|
188
|
+
# Strip HTML tags first for matching
|
|
189
|
+
stripped = text.gsub(/<[^>]+>/, "")
|
|
190
|
+
if (match = stripped.match(/Depends on:\s*((?:#\d+[\s,]*)+)/i))
|
|
191
|
+
return match[1].scan(/#(\d+)/).flatten.map(&:to_i)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
[]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Basecamp
|
|
8
|
+
# Manages epic branches — one per repo involved in the epic.
|
|
9
|
+
# Handles creation, PR auto-merge, and final PR to main.
|
|
10
|
+
module EpicBranch
|
|
11
|
+
BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Create epic branches for all repos involved in an epic.
|
|
15
|
+
# Called when orchestration starts.
|
|
16
|
+
#
|
|
17
|
+
# @param epic [Hash] Epic state
|
|
18
|
+
# @param project_repos [Hash<String, String>] project_key => repo_path mapping
|
|
19
|
+
# @return [Hash<String, String>] project_key => epic branch name
|
|
20
|
+
def create_epic_branches(epic, project_repos)
|
|
21
|
+
slug = branch_slug(epic["title"])
|
|
22
|
+
branches = {}
|
|
23
|
+
|
|
24
|
+
project_repos.each do |project_key, repo_path|
|
|
25
|
+
branch_name = "epic/#{slug}"
|
|
26
|
+
default_branch = detect_default_branch(repo_path)
|
|
27
|
+
|
|
28
|
+
# Fetch latest
|
|
29
|
+
run_git("fetch", "origin", chdir: repo_path)
|
|
30
|
+
|
|
31
|
+
# Check if branch already exists remotely
|
|
32
|
+
remote_exists = system("git", "ls-remote", "--exit-code", "--heads", "origin", branch_name,
|
|
33
|
+
chdir: repo_path, out: File::NULL, err: File::NULL)
|
|
34
|
+
|
|
35
|
+
if remote_exists
|
|
36
|
+
# Branch exists — make sure we have it locally
|
|
37
|
+
run_git("fetch", "origin", "#{branch_name}:#{branch_name}", chdir: repo_path)
|
|
38
|
+
LOG.info "[Basecamp:EpicBranch] Reusing existing epic branch '#{branch_name}' in #{project_key}" if defined?(LOG)
|
|
39
|
+
else
|
|
40
|
+
# Create new branch from default branch
|
|
41
|
+
run_git("branch", branch_name, "origin/#{default_branch}", chdir: repo_path)
|
|
42
|
+
run_git("push", "-u", "origin", branch_name, chdir: repo_path)
|
|
43
|
+
LOG.info "[Basecamp:EpicBranch] Created epic branch '#{branch_name}' in #{project_key}" if defined?(LOG)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
branches[project_key] = branch_name
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
branches
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Auto-merge a task PR into the epic branch.
|
|
53
|
+
# Called after a task completes and its PR is ready.
|
|
54
|
+
#
|
|
55
|
+
# @param repo_path [String] Path to the repo
|
|
56
|
+
# @param branch_name [String] The task's branch name
|
|
57
|
+
# @param epic_branch [String] The epic branch to merge into
|
|
58
|
+
# @return [Boolean] Whether the merge succeeded
|
|
59
|
+
def merge_task_into_epic(repo_path:, branch_name:, epic_branch:)
|
|
60
|
+
# Find the PR for this branch
|
|
61
|
+
pr_number = find_pr_for_branch(repo_path: repo_path, branch: branch_name, base: epic_branch)
|
|
62
|
+
unless pr_number
|
|
63
|
+
LOG.warn "[Basecamp:EpicBranch] No PR found for branch '#{branch_name}' targeting '#{epic_branch}'" if defined?(LOG)
|
|
64
|
+
return false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Merge the PR (don't use --delete-branch — worktree cleanup handles branch deletion)
|
|
68
|
+
stdout, stderr, status = Open3.capture3(
|
|
69
|
+
"gh", "pr", "merge", pr_number.to_s, "--merge",
|
|
70
|
+
chdir: repo_path
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if status.success?
|
|
74
|
+
LOG.info "[Basecamp:EpicBranch] Merged PR ##{pr_number} into '#{epic_branch}'" if defined?(LOG)
|
|
75
|
+
true
|
|
76
|
+
else
|
|
77
|
+
LOG.error "[Basecamp:EpicBranch] Failed to merge PR ##{pr_number}: #{stderr.strip}" if defined?(LOG)
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
LOG.error "[Basecamp:EpicBranch] Merge failed: #{e.message}" if defined?(LOG)
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Open final PRs from epic branches to main for each repo.
|
|
86
|
+
# Called when all epic tasks are complete.
|
|
87
|
+
#
|
|
88
|
+
# @param epic [Hash] Epic state
|
|
89
|
+
# @param project_repos [Hash<String, String>] project_key => repo_path
|
|
90
|
+
# @param epic_branches [Hash<String, String>] project_key => epic branch name
|
|
91
|
+
# @return [Array<Hash>] Created PR info [{project, pr_number, url}]
|
|
92
|
+
def open_final_prs(epic, project_repos, epic_branches)
|
|
93
|
+
prs = []
|
|
94
|
+
|
|
95
|
+
epic_branches.each do |project_key, epic_branch|
|
|
96
|
+
repo_path = project_repos[project_key]
|
|
97
|
+
next unless repo_path
|
|
98
|
+
|
|
99
|
+
default_branch = detect_default_branch(repo_path)
|
|
100
|
+
|
|
101
|
+
# Check if epic branch has commits ahead of main
|
|
102
|
+
run_git("fetch", "origin", chdir: repo_path)
|
|
103
|
+
ahead = run_git("rev-list", "--count", "origin/#{default_branch}..origin/#{epic_branch}", chdir: repo_path).strip.to_i
|
|
104
|
+
|
|
105
|
+
if ahead.zero?
|
|
106
|
+
LOG.info "[Basecamp:EpicBranch] No changes in '#{epic_branch}' for #{project_key}, skipping final PR" if defined?(LOG)
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Open the PR
|
|
111
|
+
title = epic["title"].sub(/^Epic:\s*/i, "")
|
|
112
|
+
task_count = (epic["tasks"] || []).count { |t| t.dig("project") == project_key || project_repos.size == 1 }
|
|
113
|
+
|
|
114
|
+
pr_body = build_final_pr_body(epic, project_key)
|
|
115
|
+
|
|
116
|
+
stdout, stderr, status = Open3.capture3(
|
|
117
|
+
"gh", "pr", "create",
|
|
118
|
+
"--base", default_branch,
|
|
119
|
+
"--head", epic_branch,
|
|
120
|
+
"--title", "[Epic] #{title}",
|
|
121
|
+
"--body", pr_body,
|
|
122
|
+
chdir: repo_path
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if status.success?
|
|
126
|
+
pr_url = stdout.strip
|
|
127
|
+
pr_number = pr_url.split("/").last
|
|
128
|
+
LOG.info "[Basecamp:EpicBranch] Final PR opened: #{pr_url}" if defined?(LOG)
|
|
129
|
+
prs << { project: project_key, pr_number: pr_number, url: pr_url, epic_branch: epic_branch }
|
|
130
|
+
else
|
|
131
|
+
LOG.error "[Basecamp:EpicBranch] Failed to open final PR for #{project_key}: #{stderr.strip}" if defined?(LOG)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
prs
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Get the epic branch for a given card number (if it's in an active epic).
|
|
139
|
+
#
|
|
140
|
+
# @param card_number [Integer, String] Fizzy card number
|
|
141
|
+
# @return [String, nil] Epic branch name or nil
|
|
142
|
+
def epic_branch_for_card(card_number)
|
|
143
|
+
epic = Orchestrator.find_epic_for_card(card_number.to_i)
|
|
144
|
+
return nil unless epic
|
|
145
|
+
return nil unless epic["review_gate"] == "epic_branch"
|
|
146
|
+
|
|
147
|
+
epic_branches = epic["epic_branches"] || {}
|
|
148
|
+
return nil if epic_branches.empty?
|
|
149
|
+
|
|
150
|
+
# Find the project for this card's task
|
|
151
|
+
task = epic["tasks"]&.find { |t| t["fizzy_card"] == card_number.to_i }
|
|
152
|
+
return nil unless task
|
|
153
|
+
|
|
154
|
+
project_key = task["project"]
|
|
155
|
+
|
|
156
|
+
# If project is set, look up directly
|
|
157
|
+
return epic_branches[project_key] if project_key && epic_branches[project_key]
|
|
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
|
|
161
|
+
|
|
162
|
+
# Multi-project epic but no project on task — can't determine which branch
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# Generate a URL-safe slug from the epic title.
|
|
169
|
+
def branch_slug(title)
|
|
170
|
+
title
|
|
171
|
+
.sub(/^Epic:\s*/i, "")
|
|
172
|
+
.downcase
|
|
173
|
+
.gsub(/[^a-z0-9\s-]/, "")
|
|
174
|
+
.gsub(/\s+/, "-")
|
|
175
|
+
.gsub(/-+/, "-")
|
|
176
|
+
.slice(0, 50)
|
|
177
|
+
.sub(/-$/, "")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Find a PR for a given branch targeting a specific base.
|
|
181
|
+
# IMPORTANT: Only returns PRs that actually target the expected base branch.
|
|
182
|
+
# Will NOT return PRs targeting main/master to prevent accidental merges.
|
|
183
|
+
def find_pr_for_branch(repo_path:, branch:, base:)
|
|
184
|
+
# Try exact match first
|
|
185
|
+
stdout, _stderr, status = Open3.capture3(
|
|
186
|
+
"gh", "pr", "list", "--head", branch, "--base", base, "--json", "number", "--jq", ".[0].number",
|
|
187
|
+
chdir: repo_path
|
|
188
|
+
)
|
|
189
|
+
if status.success? && !stdout.strip.empty?
|
|
190
|
+
return stdout.strip.to_i
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Try prefix match (branch might be "fizzy-1168" but actual is "fizzy-1168-slug")
|
|
194
|
+
card_num = branch.match(/fizzy-(\d+)/)[1] rescue nil
|
|
195
|
+
if card_num
|
|
196
|
+
# Search with base filter
|
|
197
|
+
stdout, _stderr, status = Open3.capture3(
|
|
198
|
+
"gh", "pr", "list", "--base", base, "--json", "number,headRefName",
|
|
199
|
+
"--jq", ".[] | select(.headRefName | startswith(\"fizzy-#{card_num}\")) | .number",
|
|
200
|
+
chdir: repo_path
|
|
201
|
+
)
|
|
202
|
+
if status.success? && !stdout.strip.empty?
|
|
203
|
+
return stdout.strip.split("\n").first.to_i
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Check if there's a PR targeting the WRONG base (e.g., main instead of epic branch)
|
|
207
|
+
# This is a safeguard — we refuse to merge PRs that target the wrong branch
|
|
208
|
+
stdout, _stderr, status = Open3.capture3(
|
|
209
|
+
"gh", "pr", "list", "--json", "number,headRefName,baseRefName",
|
|
210
|
+
"--jq", ".[] | select(.headRefName | startswith(\"fizzy-#{card_num}\"))",
|
|
211
|
+
chdir: repo_path
|
|
212
|
+
)
|
|
213
|
+
if status.success? && !stdout.strip.empty?
|
|
214
|
+
# Found a PR but it targets the wrong base — log warning and refuse to auto-merge
|
|
215
|
+
pr_data = JSON.parse("[#{stdout.strip.gsub("\n", ",")}]").first rescue nil
|
|
216
|
+
if pr_data
|
|
217
|
+
actual_base = pr_data["baseRefName"]
|
|
218
|
+
pr_number = pr_data["number"]
|
|
219
|
+
if actual_base != base
|
|
220
|
+
LOG.error "[Basecamp:EpicBranch] SAFEGUARD: PR ##{pr_number} targets '#{actual_base}' " \
|
|
221
|
+
"but expected '#{base}'. Refusing to auto-merge to prevent accidental merge to main. " \
|
|
222
|
+
"Fix: close the PR and reopen targeting '#{base}', or manually retarget with: " \
|
|
223
|
+
"`gh pr edit #{pr_number} --base #{base}`" if defined?(LOG)
|
|
224
|
+
return nil
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Build the body for the final epic PR.
|
|
234
|
+
def build_final_pr_body(epic, project_key)
|
|
235
|
+
tasks = (epic["tasks"] || []).select { |t| t["project"] == project_key || true }
|
|
236
|
+
lines = []
|
|
237
|
+
lines << "## Epic: #{epic['title']}"
|
|
238
|
+
lines << ""
|
|
239
|
+
lines << "Automated epic completion — all tasks in this epic have been completed."
|
|
240
|
+
lines << ""
|
|
241
|
+
lines << "### Tasks"
|
|
242
|
+
tasks.each do |task|
|
|
243
|
+
lines << "- [x] #{task['title']} (Fizzy ##{task['fizzy_card']})"
|
|
244
|
+
end
|
|
245
|
+
lines << ""
|
|
246
|
+
lines << "---"
|
|
247
|
+
lines << "*Opened by brainiac-basecamp epic orchestrator*"
|
|
248
|
+
lines.join("\n")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Detect the default branch for a repo.
|
|
252
|
+
def detect_default_branch(repo_path)
|
|
253
|
+
ref = `git -C #{repo_path} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`.strip
|
|
254
|
+
ref.empty? ? "main" : ref.sub("origin/", "")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Run a git command, raising on failure.
|
|
258
|
+
def run_git(*args, chdir:)
|
|
259
|
+
stdout, stderr, status = Open3.capture3("git", *args, chdir: chdir)
|
|
260
|
+
unless status.success?
|
|
261
|
+
raise "git #{args.first} failed: #{stderr.strip}"
|
|
262
|
+
end
|
|
263
|
+
stdout
|
|
264
|
+
rescue Errno::ENOENT
|
|
265
|
+
raise "git not found"
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|