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,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Basecamp
|
|
8
|
+
module Cli
|
|
9
|
+
BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
10
|
+
CONFIG_FILE = File.join(BRAINIAC_DIR, "basecamp.json")
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def run(args)
|
|
14
|
+
command = args.shift
|
|
15
|
+
|
|
16
|
+
case command
|
|
17
|
+
when "setup"
|
|
18
|
+
cmd_setup
|
|
19
|
+
when "config"
|
|
20
|
+
cmd_config
|
|
21
|
+
when "status"
|
|
22
|
+
cmd_status
|
|
23
|
+
when "epics"
|
|
24
|
+
cmd_epics(args)
|
|
25
|
+
when "link"
|
|
26
|
+
cmd_link(args)
|
|
27
|
+
when "bot"
|
|
28
|
+
cmd_bot(args)
|
|
29
|
+
when "projects"
|
|
30
|
+
cmd_projects(args)
|
|
31
|
+
when "set"
|
|
32
|
+
cmd_set(args)
|
|
33
|
+
else
|
|
34
|
+
print_help
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def cmd_setup
|
|
41
|
+
puts "Basecamp Plugin Setup"
|
|
42
|
+
puts "====================="
|
|
43
|
+
puts ""
|
|
44
|
+
|
|
45
|
+
# Check if basecamp CLI is installed
|
|
46
|
+
unless system("which basecamp > /dev/null 2>&1")
|
|
47
|
+
puts "❌ basecamp CLI not found. Install it first:"
|
|
48
|
+
puts " curl -fsSL https://basecamp.com/install-cli | bash"
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
puts "✓ basecamp CLI found"
|
|
52
|
+
|
|
53
|
+
# Check auth status
|
|
54
|
+
auth_output = `basecamp auth status --json 2>/dev/null`
|
|
55
|
+
if $?.success? && !auth_output.empty?
|
|
56
|
+
puts "✓ Authenticated with Basecamp"
|
|
57
|
+
else
|
|
58
|
+
puts "❌ Not authenticated. Run: basecamp auth login"
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Create config if it doesn't exist
|
|
63
|
+
unless File.exist?(CONFIG_FILE)
|
|
64
|
+
default_config = {
|
|
65
|
+
"bot_accounts" => {},
|
|
66
|
+
"project_mappings" => {},
|
|
67
|
+
"epic_prefix" => "Epic:",
|
|
68
|
+
"fizzy_account_id" => nil,
|
|
69
|
+
"review_gate" => "on_complete",
|
|
70
|
+
"notifications" => {
|
|
71
|
+
"epic_started" => true,
|
|
72
|
+
"task_dispatched" => true,
|
|
73
|
+
"task_completed" => true,
|
|
74
|
+
"epic_completed" => true
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
File.write(CONFIG_FILE, JSON.pretty_generate(default_config))
|
|
78
|
+
puts "✓ Created #{CONFIG_FILE}"
|
|
79
|
+
else
|
|
80
|
+
puts "✓ Config exists at #{CONFIG_FILE}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
puts ""
|
|
84
|
+
puts "Next steps:"
|
|
85
|
+
puts " 1. Set Fizzy account ID: brainiac basecamp set fizzy-account-id <your-fizzy-account-id>"
|
|
86
|
+
puts " 2. Add bot accounts: brainiac basecamp bot add <name> <person-id> <agent>"
|
|
87
|
+
puts " 3. Map projects: brainiac basecamp projects map <brainiac-key> <basecamp-id>"
|
|
88
|
+
puts " 4. Set review gate: brainiac basecamp set review-gate <on_complete|on_pr_merge>"
|
|
89
|
+
puts " 5. Set up webhooks: basecamp webhooks create \"https://your-ngrok/basecamp\" --types \"Todo,Todolist\" --in <project>"
|
|
90
|
+
puts " 6. Restart brainiac: brainiac restart"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def cmd_config
|
|
94
|
+
if File.exist?(CONFIG_FILE)
|
|
95
|
+
config = JSON.parse(File.read(CONFIG_FILE))
|
|
96
|
+
puts JSON.pretty_generate(config)
|
|
97
|
+
else
|
|
98
|
+
puts "No config found at #{CONFIG_FILE}"
|
|
99
|
+
puts "Run: brainiac basecamp setup"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def cmd_status
|
|
104
|
+
puts "Basecamp Plugin Status"
|
|
105
|
+
puts "======================"
|
|
106
|
+
puts ""
|
|
107
|
+
|
|
108
|
+
# Check CLI
|
|
109
|
+
if system("which basecamp > /dev/null 2>&1")
|
|
110
|
+
puts " CLI: ✓ installed"
|
|
111
|
+
else
|
|
112
|
+
puts " CLI: ✗ not found"
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check auth
|
|
117
|
+
auth_output = `basecamp auth status --json 2>/dev/null`
|
|
118
|
+
if $?.success?
|
|
119
|
+
puts " Auth: ✓ authenticated"
|
|
120
|
+
else
|
|
121
|
+
puts " Auth: ✗ not authenticated"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check config
|
|
125
|
+
if File.exist?(CONFIG_FILE)
|
|
126
|
+
config = JSON.parse(File.read(CONFIG_FILE))
|
|
127
|
+
puts " Config: ✓ #{CONFIG_FILE}"
|
|
128
|
+
puts " Bots: #{config['bot_accounts']&.size || 0} configured"
|
|
129
|
+
puts " Maps: #{config['project_mappings']&.size || 0} project mappings"
|
|
130
|
+
else
|
|
131
|
+
puts " Config: ✗ not configured"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Check active epics
|
|
135
|
+
epics_file = File.join(BRAINIAC_DIR, "basecamp_epics.json")
|
|
136
|
+
if File.exist?(epics_file)
|
|
137
|
+
epics = JSON.parse(File.read(epics_file))
|
|
138
|
+
active = (epics["epics"] || []).count { |e| e["status"] == "active" }
|
|
139
|
+
total = (epics["epics"] || []).size
|
|
140
|
+
puts " Epics: #{active} active, #{total} total"
|
|
141
|
+
else
|
|
142
|
+
puts " Epics: none"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def cmd_epics(args)
|
|
147
|
+
epics_file = File.join(BRAINIAC_DIR, "basecamp_epics.json")
|
|
148
|
+
unless File.exist?(epics_file)
|
|
149
|
+
puts "No epics found."
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
data = JSON.parse(File.read(epics_file))
|
|
154
|
+
epics = data["epics"] || []
|
|
155
|
+
|
|
156
|
+
if args.first == "--all"
|
|
157
|
+
display_epics = epics
|
|
158
|
+
else
|
|
159
|
+
display_epics = epics.select { |e| e["status"] == "active" }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if display_epics.empty?
|
|
163
|
+
puts "No #{args.first == '--all' ? '' : 'active '}epics."
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
display_epics.each do |epic|
|
|
168
|
+
tasks = epic["tasks"] || []
|
|
169
|
+
complete = tasks.count { |t| t["status"] == "complete" }
|
|
170
|
+
in_flight = tasks.count { |t| t["status"] == "in_flight" }
|
|
171
|
+
total = tasks.size
|
|
172
|
+
|
|
173
|
+
status_icon = epic["status"] == "active" ? "🚀" : "✅"
|
|
174
|
+
puts "#{status_icon} #{epic['title']}"
|
|
175
|
+
puts " Agent: #{epic['agent']} | Tasks: #{complete}/#{total} complete, #{in_flight} in-flight"
|
|
176
|
+
puts " Started: #{epic['started_at']}"
|
|
177
|
+
puts ""
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def cmd_link(args)
|
|
182
|
+
fizzy_card = args.shift
|
|
183
|
+
basecamp_url = args.shift
|
|
184
|
+
|
|
185
|
+
unless fizzy_card && basecamp_url
|
|
186
|
+
puts "Usage: brainiac basecamp link <fizzy-card-number> <basecamp-todo-url>"
|
|
187
|
+
return
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
puts "TODO: Link Fizzy card ##{fizzy_card} to #{basecamp_url}"
|
|
191
|
+
puts "(This will be used for manual linking outside of epic orchestration)"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def cmd_bot(args)
|
|
195
|
+
action = args.shift
|
|
196
|
+
|
|
197
|
+
case action
|
|
198
|
+
when "add"
|
|
199
|
+
name = args.shift
|
|
200
|
+
person_id = args.shift
|
|
201
|
+
agent = args.shift
|
|
202
|
+
|
|
203
|
+
unless name && person_id && agent
|
|
204
|
+
puts "Usage: brainiac basecamp bot add <name> <basecamp-person-id> <default-agent>"
|
|
205
|
+
puts ""
|
|
206
|
+
puts "Example:"
|
|
207
|
+
puts " brainiac basecamp bot add andy-server 12345 Galen"
|
|
208
|
+
return
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
config = load_config
|
|
212
|
+
config["bot_accounts"] ||= {}
|
|
213
|
+
config["bot_accounts"][name] = {
|
|
214
|
+
"person_id" => person_id,
|
|
215
|
+
"default_agent" => agent
|
|
216
|
+
}
|
|
217
|
+
save_config(config)
|
|
218
|
+
puts "✓ Added bot account '#{name}' (person_id: #{person_id}, agent: #{agent})"
|
|
219
|
+
|
|
220
|
+
when "list"
|
|
221
|
+
config = load_config
|
|
222
|
+
bots = config["bot_accounts"] || {}
|
|
223
|
+
if bots.empty?
|
|
224
|
+
puts "No bot accounts configured."
|
|
225
|
+
else
|
|
226
|
+
bots.each do |name, account|
|
|
227
|
+
puts " #{name}: person_id=#{account['person_id']}, agent=#{account['default_agent']}"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
when "remove"
|
|
232
|
+
name = args.shift
|
|
233
|
+
unless name
|
|
234
|
+
puts "Usage: brainiac basecamp bot remove <name>"
|
|
235
|
+
return
|
|
236
|
+
end
|
|
237
|
+
config = load_config
|
|
238
|
+
if config["bot_accounts"]&.delete(name)
|
|
239
|
+
save_config(config)
|
|
240
|
+
puts "✓ Removed bot account '#{name}'"
|
|
241
|
+
else
|
|
242
|
+
puts "Bot account '#{name}' not found"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
else
|
|
246
|
+
puts "Usage: brainiac basecamp bot <add|list|remove>"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def cmd_projects(args)
|
|
251
|
+
action = args.shift
|
|
252
|
+
|
|
253
|
+
case action
|
|
254
|
+
when "map"
|
|
255
|
+
brainiac_key = args.shift
|
|
256
|
+
basecamp_id = args.shift
|
|
257
|
+
|
|
258
|
+
unless brainiac_key && basecamp_id
|
|
259
|
+
puts "Usage: brainiac basecamp projects map <brainiac-project-key> <basecamp-project-id>"
|
|
260
|
+
puts ""
|
|
261
|
+
puts "Example:"
|
|
262
|
+
puts " brainiac basecamp projects map marketplace 12345"
|
|
263
|
+
return
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
config = load_config
|
|
267
|
+
config["project_mappings"] ||= {}
|
|
268
|
+
config["project_mappings"][brainiac_key] = {
|
|
269
|
+
"basecamp_project_id" => basecamp_id
|
|
270
|
+
}
|
|
271
|
+
save_config(config)
|
|
272
|
+
puts "✓ Mapped '#{brainiac_key}' → Basecamp project #{basecamp_id}"
|
|
273
|
+
|
|
274
|
+
when "list"
|
|
275
|
+
config = load_config
|
|
276
|
+
mappings = config["project_mappings"] || {}
|
|
277
|
+
if mappings.empty?
|
|
278
|
+
puts "No project mappings configured."
|
|
279
|
+
else
|
|
280
|
+
mappings.each do |key, mapping|
|
|
281
|
+
puts " #{key} → Basecamp project #{mapping['basecamp_project_id']}"
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
when "unmap"
|
|
286
|
+
key = args.shift
|
|
287
|
+
unless key
|
|
288
|
+
puts "Usage: brainiac basecamp projects unmap <brainiac-project-key>"
|
|
289
|
+
return
|
|
290
|
+
end
|
|
291
|
+
config = load_config
|
|
292
|
+
if config["project_mappings"]&.delete(key)
|
|
293
|
+
save_config(config)
|
|
294
|
+
puts "✓ Removed mapping for '#{key}'"
|
|
295
|
+
else
|
|
296
|
+
puts "Mapping for '#{key}' not found"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
else
|
|
300
|
+
puts "Usage: brainiac basecamp projects <map|list|unmap>"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def print_help
|
|
305
|
+
puts <<~HELP
|
|
306
|
+
Usage: brainiac basecamp <command>
|
|
307
|
+
|
|
308
|
+
Commands:
|
|
309
|
+
setup Interactive setup guide
|
|
310
|
+
config Show current config
|
|
311
|
+
status Check plugin status
|
|
312
|
+
epics [--all] List active epics (--all for completed too)
|
|
313
|
+
link <card> <url> Link a Fizzy card to a Basecamp todo
|
|
314
|
+
bot add <name> <person-id> <agent> Add a bot account mapping
|
|
315
|
+
bot list List bot accounts
|
|
316
|
+
bot remove <name> Remove a bot account
|
|
317
|
+
projects map <key> <basecamp-id> Map a Brainiac project to Basecamp
|
|
318
|
+
projects list List project mappings
|
|
319
|
+
projects unmap <key> Remove a project mapping
|
|
320
|
+
set fizzy-account-id <id> Set Fizzy account ID (for card URLs)
|
|
321
|
+
set review-gate <mode> Set review gate (on_complete or on_pr_merge)
|
|
322
|
+
set epic-prefix <prefix> Set epic todolist prefix (default: "Epic:")
|
|
323
|
+
|
|
324
|
+
Config file: ~/.brainiac/basecamp.json
|
|
325
|
+
Epics state: ~/.brainiac/basecamp_epics.json
|
|
326
|
+
|
|
327
|
+
Review gate modes:
|
|
328
|
+
on_complete — Advance to next task as soon as agent finishes (default)
|
|
329
|
+
on_pr_merge — Wait for PR to be merged before advancing (review gate)
|
|
330
|
+
HELP
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def cmd_set(args)
|
|
334
|
+
key = args.shift
|
|
335
|
+
value = args.shift
|
|
336
|
+
|
|
337
|
+
unless key && value
|
|
338
|
+
puts "Usage: brainiac basecamp set <key> <value>"
|
|
339
|
+
puts ""
|
|
340
|
+
puts "Keys:"
|
|
341
|
+
puts " fizzy-account-id <id> Fizzy account ID (for card URLs)"
|
|
342
|
+
puts " review-gate <mode> on_complete or on_pr_merge"
|
|
343
|
+
puts " epic-prefix <prefix> Todolist prefix for epic detection"
|
|
344
|
+
return
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
config = load_config
|
|
348
|
+
|
|
349
|
+
case key
|
|
350
|
+
when "fizzy-account-id"
|
|
351
|
+
config["fizzy_account_id"] = value
|
|
352
|
+
save_config(config)
|
|
353
|
+
puts "✓ Set fizzy_account_id = #{value}"
|
|
354
|
+
puts " Card URLs will be: https://app.fizzy.do/#{value}/cards/NNNN"
|
|
355
|
+
when "review-gate"
|
|
356
|
+
unless %w[on_complete on_pr_merge epic_branch].include?(value)
|
|
357
|
+
puts "Error: review-gate must be 'on_complete', 'on_pr_merge', or 'epic_branch'"
|
|
358
|
+
return
|
|
359
|
+
end
|
|
360
|
+
config["review_gate"] = value
|
|
361
|
+
save_config(config)
|
|
362
|
+
puts "✓ Set review_gate = #{value}"
|
|
363
|
+
case value
|
|
364
|
+
when "on_pr_merge"
|
|
365
|
+
puts " Epic tasks will wait for PR merge before advancing to next task"
|
|
366
|
+
when "epic_branch"
|
|
367
|
+
puts " Epic tasks auto-merge into an epic branch. Final PR to main when epic completes."
|
|
368
|
+
puts " Best for overnight/autonomous execution."
|
|
369
|
+
else
|
|
370
|
+
puts " Epic tasks advance immediately when agent completes"
|
|
371
|
+
end
|
|
372
|
+
when "epic-prefix"
|
|
373
|
+
config["epic_prefix"] = value
|
|
374
|
+
save_config(config)
|
|
375
|
+
puts "✓ Set epic_prefix = #{value}"
|
|
376
|
+
when "profile"
|
|
377
|
+
config["basecamp_profile"] = value
|
|
378
|
+
save_config(config)
|
|
379
|
+
puts "✓ Set basecamp_profile = #{value}"
|
|
380
|
+
puts " All basecamp CLI commands will use --profile #{value}"
|
|
381
|
+
puts " Set up the profile: basecamp profile create #{value} && basecamp auth login --profile #{value}"
|
|
382
|
+
else
|
|
383
|
+
puts "Unknown key: #{key}"
|
|
384
|
+
puts "Valid keys: fizzy-account-id, review-gate, epic-prefix, profile"
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def load_config
|
|
389
|
+
if File.exist?(CONFIG_FILE)
|
|
390
|
+
JSON.parse(File.read(CONFIG_FILE))
|
|
391
|
+
else
|
|
392
|
+
{}
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def save_config(config)
|
|
397
|
+
File.write(CONFIG_FILE, JSON.pretty_generate(config))
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def self.cli(args)
|
|
403
|
+
Cli.run(args)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def self.completions
|
|
407
|
+
%w[setup config status epics link bot projects set]
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Brainiac
|
|
7
|
+
module Plugins
|
|
8
|
+
module Basecamp
|
|
9
|
+
# Wrapper around the `basecamp` CLI binary.
|
|
10
|
+
# Shells out with --json for structured responses.
|
|
11
|
+
module Client
|
|
12
|
+
class << self
|
|
13
|
+
# Set the current agent profile for basecamp CLI commands.
|
|
14
|
+
# The profile name matches the agent name (e.g., "galen" → basecamp --profile galen).
|
|
15
|
+
#
|
|
16
|
+
# @param agent_name [String, nil] Agent name to use as profile
|
|
17
|
+
def with_agent_profile(agent_name)
|
|
18
|
+
@current_agent_profile = agent_name&.downcase
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Clear the current agent profile.
|
|
22
|
+
def clear_agent_profile
|
|
23
|
+
@current_agent_profile = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Run a basecamp CLI command as a specific agent.
|
|
27
|
+
#
|
|
28
|
+
# @param agent_name [String] Agent name (used as profile)
|
|
29
|
+
# @param args [Array<String>] CLI arguments
|
|
30
|
+
# @return [Hash] Parsed JSON response
|
|
31
|
+
def run_as(agent_name, *args)
|
|
32
|
+
run(*args, profile: agent_name.downcase)
|
|
33
|
+
end
|
|
34
|
+
# Run a basecamp CLI command and return parsed JSON.
|
|
35
|
+
#
|
|
36
|
+
# @param args [Array<String>] CLI arguments
|
|
37
|
+
# @param profile [String, nil] Named profile to use (defaults to agent name)
|
|
38
|
+
# @return [Hash] Parsed JSON response
|
|
39
|
+
# @raise [ClientError] If the command fails
|
|
40
|
+
def run(*args, profile: nil)
|
|
41
|
+
cmd = ["basecamp"]
|
|
42
|
+
# Use the specified profile, or fall back to the current agent's name as profile
|
|
43
|
+
profile ||= @current_agent_profile
|
|
44
|
+
cmd += ["--profile", profile] if profile
|
|
45
|
+
cmd += args.flatten
|
|
46
|
+
|
|
47
|
+
LOG.debug "[Basecamp:Client] Running: #{cmd.join(' ')}" if defined?(LOG) && LOG.debug?
|
|
48
|
+
|
|
49
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
50
|
+
|
|
51
|
+
unless status.success?
|
|
52
|
+
error_body = parse_json_safe(stdout) || parse_json_safe(stderr)
|
|
53
|
+
error_msg = error_body&.dig("error") || stderr.strip.split("\n").first || "Unknown error"
|
|
54
|
+
raise ClientError.new(error_msg, exit_code: status.exitstatus, response: error_body)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
result = parse_json_safe(stdout)
|
|
58
|
+
unless result
|
|
59
|
+
raise ClientError.new("Failed to parse JSON response", exit_code: 0, response: nil)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Run a basecamp CLI command, returning nil on failure instead of raising.
|
|
66
|
+
#
|
|
67
|
+
# @param args [Array<String>] CLI arguments
|
|
68
|
+
# @param profile [String, nil] Named profile to use
|
|
69
|
+
# @return [Hash, nil] Parsed JSON response or nil
|
|
70
|
+
def run_safe(*args, profile: nil)
|
|
71
|
+
run(*args, profile: profile)
|
|
72
|
+
rescue ClientError => e
|
|
73
|
+
LOG.warn "[Basecamp:Client] Command failed: #{e.message}" if defined?(LOG)
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get a todo by ID with subtasks info.
|
|
78
|
+
#
|
|
79
|
+
# @param todo_id [String, Integer] Todo ID
|
|
80
|
+
# @param project [String, Integer] Basecamp project/bucket ID
|
|
81
|
+
# @return [Hash, nil]
|
|
82
|
+
def get_todo(todo_id, project:)
|
|
83
|
+
run("todos", "show", todo_id.to_s, "--in", project.to_s, "--json")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# List subtasks (steps) for a todo.
|
|
87
|
+
#
|
|
88
|
+
# @param todo_id [String, Integer] Parent todo ID
|
|
89
|
+
# @param project [String, Integer] Basecamp project/bucket ID
|
|
90
|
+
# @return [Hash, nil]
|
|
91
|
+
def get_subtasks(todo_id, project:)
|
|
92
|
+
# Basecamp models todo subtasks as Kanban::Step records
|
|
93
|
+
# Use the recordings list filtered by type and parent
|
|
94
|
+
run("recordings", "list", "--in", project.to_s, "--type", "Kanban::Step",
|
|
95
|
+
"--all", "--json")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Complete a subtask.
|
|
99
|
+
#
|
|
100
|
+
# @param step_id [String, Integer] Step/subtask ID
|
|
101
|
+
# @param project [String, Integer] Basecamp project/bucket ID
|
|
102
|
+
# @return [Hash, nil]
|
|
103
|
+
def complete_subtask(step_id, project:)
|
|
104
|
+
# Use raw API to complete a step
|
|
105
|
+
run("api", "put",
|
|
106
|
+
"/buckets/#{project}/card_tables/steps/#{step_id}/completions.json",
|
|
107
|
+
"--data", '{"completion":"on"}', "--json")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Add a comment to a recording (todo, card, message, etc.)
|
|
111
|
+
#
|
|
112
|
+
# @param recording_id [String, Integer] The recording to comment on
|
|
113
|
+
# @param content [String] Comment content (Markdown)
|
|
114
|
+
# @param project [String, Integer] Basecamp project/bucket ID
|
|
115
|
+
# @return [Hash, nil]
|
|
116
|
+
def add_comment(recording_id, content, project:)
|
|
117
|
+
run("comments", "create", recording_id.to_s, content, "--in", project.to_s, "--json")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Complete a todo.
|
|
121
|
+
#
|
|
122
|
+
# @param todo_id [String, Integer] Todo ID
|
|
123
|
+
# @return [Hash, nil]
|
|
124
|
+
def complete_todo(todo_id)
|
|
125
|
+
run("todos", "complete", todo_id.to_s, "--json")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# List projects.
|
|
129
|
+
#
|
|
130
|
+
# @return [Hash]
|
|
131
|
+
def list_projects
|
|
132
|
+
run("projects", "list", "--json")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Parse a Basecamp URL to extract IDs.
|
|
136
|
+
#
|
|
137
|
+
# @param url [String] Basecamp URL
|
|
138
|
+
# @return [Hash, nil]
|
|
139
|
+
def parse_url(url)
|
|
140
|
+
run("url", "parse", url, "--json")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Check auth status.
|
|
144
|
+
#
|
|
145
|
+
# @return [Hash, nil]
|
|
146
|
+
def auth_status
|
|
147
|
+
run_safe("auth", "status", "--json")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def parse_json_safe(str)
|
|
153
|
+
return nil if str.nil? || str.strip.empty?
|
|
154
|
+
|
|
155
|
+
JSON.parse(str)
|
|
156
|
+
rescue JSON::ParserError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Error class for CLI failures.
|
|
163
|
+
class ClientError < StandardError
|
|
164
|
+
attr_reader :exit_code, :response
|
|
165
|
+
|
|
166
|
+
def initialize(message, exit_code: nil, response: nil)
|
|
167
|
+
@exit_code = exit_code
|
|
168
|
+
@response = response
|
|
169
|
+
super(message)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Basecamp
|
|
8
|
+
# Configuration loader for brainiac-basecamp.
|
|
9
|
+
#
|
|
10
|
+
# Config file: ~/.brainiac/basecamp.json
|
|
11
|
+
module Config
|
|
12
|
+
BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
13
|
+
CONFIG_FILE = File.join(BRAINIAC_DIR, "basecamp.json")
|
|
14
|
+
|
|
15
|
+
# Default config structure.
|
|
16
|
+
DEFAULT_CONFIG = {
|
|
17
|
+
"bot_accounts" => {},
|
|
18
|
+
"project_mappings" => {},
|
|
19
|
+
"epic_prefix" => "Epic:",
|
|
20
|
+
"fizzy_account_id" => nil,
|
|
21
|
+
"review_gate" => "on_complete",
|
|
22
|
+
"review_gates" => [],
|
|
23
|
+
"notifications" => {
|
|
24
|
+
"epic_started" => true,
|
|
25
|
+
"task_dispatched" => true,
|
|
26
|
+
"task_completed" => true,
|
|
27
|
+
"epic_completed" => true
|
|
28
|
+
}
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Load and cache config. Reloads if file has changed since last read.
|
|
33
|
+
#
|
|
34
|
+
# @return [Hash]
|
|
35
|
+
def current
|
|
36
|
+
return @config if @config && @config_mtime == config_mtime
|
|
37
|
+
|
|
38
|
+
load!
|
|
39
|
+
@config
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Force reload config from disk.
|
|
43
|
+
def load!
|
|
44
|
+
if File.exist?(CONFIG_FILE)
|
|
45
|
+
raw = JSON.parse(File.read(CONFIG_FILE))
|
|
46
|
+
@config = DEFAULT_CONFIG.merge(raw)
|
|
47
|
+
else
|
|
48
|
+
@config = DEFAULT_CONFIG.dup
|
|
49
|
+
end
|
|
50
|
+
@config_mtime = config_mtime
|
|
51
|
+
rescue JSON::ParserError => e
|
|
52
|
+
LOG.error "[Basecamp] Config parse error: #{e.message}" if defined?(LOG)
|
|
53
|
+
@config = DEFAULT_CONFIG.dup
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get bot account config for a given Basecamp person ID.
|
|
57
|
+
#
|
|
58
|
+
# @param person_id [String, Integer] Basecamp person ID
|
|
59
|
+
# @return [Hash, nil] Bot account config or nil
|
|
60
|
+
def bot_account_for_person(person_id)
|
|
61
|
+
current["bot_accounts"].find do |_key, account|
|
|
62
|
+
account["person_id"].to_s == person_id.to_s
|
|
63
|
+
end&.then { |key, account| account.merge("key" => key) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get the Basecamp project ID for a Brainiac project key.
|
|
67
|
+
#
|
|
68
|
+
# @param brainiac_project [String] Brainiac project key
|
|
69
|
+
# @return [String, nil] Basecamp project ID
|
|
70
|
+
def basecamp_project_for(brainiac_project)
|
|
71
|
+
mapping = current["project_mappings"][brainiac_project]
|
|
72
|
+
mapping&.dig("basecamp_project_id")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get the Brainiac project key for a Basecamp project/bucket ID.
|
|
76
|
+
#
|
|
77
|
+
# @param basecamp_project_id [String, Integer] Basecamp bucket ID
|
|
78
|
+
# @return [String, nil] Brainiac project key
|
|
79
|
+
def brainiac_project_for(basecamp_project_id)
|
|
80
|
+
current["project_mappings"].find do |_key, mapping|
|
|
81
|
+
mapping["basecamp_project_id"].to_s == basecamp_project_id.to_s
|
|
82
|
+
end&.first
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The prefix used to identify epic todolists (e.g. "Epic: My Feature")
|
|
86
|
+
#
|
|
87
|
+
# @return [String]
|
|
88
|
+
def epic_prefix
|
|
89
|
+
current["epic_prefix"] || "Epic:"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The Fizzy account ID (for building card URLs like app.fizzy.do/<id>/cards/N).
|
|
93
|
+
#
|
|
94
|
+
# @return [String, nil]
|
|
95
|
+
def fizzy_account_id
|
|
96
|
+
current["fizzy_account_id"]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Review gate mode: "on_complete" (advance immediately) or "on_pr_merge" (wait for merge).
|
|
100
|
+
#
|
|
101
|
+
# @return [String]
|
|
102
|
+
def review_gate
|
|
103
|
+
current["review_gate"] || "on_complete"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def config_mtime
|
|
109
|
+
File.exist?(CONFIG_FILE) ? File.mtime(CONFIG_FILE) : nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|