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.
@@ -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