brainiac-basecamp 0.0.19 → 0.0.21
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 +4 -4
- data/README.md +71 -0
- data/lib/brainiac/plugins/basecamp/cli.rb +206 -5
- data/lib/brainiac/plugins/basecamp/config.rb +14 -0
- data/lib/brainiac/plugins/basecamp/epic.rb +31 -0
- data/lib/brainiac/plugins/basecamp/orchestrator.rb +127 -0
- data/lib/brainiac/plugins/basecamp/session_registry.rb +5 -2
- data/lib/brainiac/plugins/basecamp/version.rb +1 -1
- data/lib/brainiac/plugins/basecamp.rb +10 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25a59b3d406964ab51bf4c1d34e63f01cf87368afcf79a1ecd8ce95b304b9ec6
|
|
4
|
+
data.tar.gz: d7e7f94f7b9b6e6a7c1d4480fcf46c6330a9d3928c4afd723c994703e149c70a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a9529e9564869624ac8712d157fbcafae3693018a6106c7f1ea99797fab5fed3e22d967b4c9f5fb4e8319c722d4d1afebb27732c152e9fca4c7b68626ef36ea6
|
|
7
|
+
data.tar.gz: 6cf76c4f8a5b3eaef3f571983f1130a6a79de6a68a22d0a8b08d8fb3d6cb245133e76bd396dcfc80f20ee2be0271757a1e3337ef5797331cc6c984ede6629131
|
data/README.md
CHANGED
|
@@ -201,6 +201,16 @@ brainiac restart
|
|
|
201
201
|
{ "agent": "GLaDOS", "role": "test-engineer" },
|
|
202
202
|
{ "agent": "Threepio", "role": "code-reviewer" }
|
|
203
203
|
],
|
|
204
|
+
"deploy": {
|
|
205
|
+
"enabled": true,
|
|
206
|
+
"trigger": "on_final_pr",
|
|
207
|
+
"default_env": "dev",
|
|
208
|
+
"project_envs": {
|
|
209
|
+
"stowzilla": "dev",
|
|
210
|
+
"posh-nosh": "dev02"
|
|
211
|
+
},
|
|
212
|
+
"command": "belt deploy {env} --auto"
|
|
213
|
+
},
|
|
204
214
|
"notifications": {
|
|
205
215
|
"channel": "discord",
|
|
206
216
|
"target": "1423854179880927274",
|
|
@@ -212,6 +222,66 @@ brainiac restart
|
|
|
212
222
|
}
|
|
213
223
|
```
|
|
214
224
|
|
|
225
|
+
## Epic Deployment
|
|
226
|
+
|
|
227
|
+
Deploy epics to test environments before merging the final PR to main.
|
|
228
|
+
|
|
229
|
+
### Per-Epic Deploy Environment
|
|
230
|
+
|
|
231
|
+
Specify the deploy environment in the epic title or description:
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
Todolist: "Epic: Build Authentication System [deploy:dev02]"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Or in the todolist description:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
deploy:dev02
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The title syntax takes precedence over description.
|
|
244
|
+
|
|
245
|
+
### Manual Deploy
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
brainiac basecamp deploy <epic-id> [env]
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Environment resolution (in order):
|
|
252
|
+
1. Explicit env argument
|
|
253
|
+
2. `[deploy:env]` in epic title
|
|
254
|
+
3. `deploy:env` in epic description
|
|
255
|
+
4. `deploy.project_envs.<project>` in config (per-project default)
|
|
256
|
+
5. `deploy.default_env` in config (global default)
|
|
257
|
+
|
|
258
|
+
### Automated Deploy
|
|
259
|
+
|
|
260
|
+
Configure automated deployment in `~/.brainiac/basecamp.json`:
|
|
261
|
+
|
|
262
|
+
```json
|
|
263
|
+
{
|
|
264
|
+
"deploy": {
|
|
265
|
+
"enabled": true,
|
|
266
|
+
"trigger": "on_final_pr",
|
|
267
|
+
"default_env": "dev",
|
|
268
|
+
"project_envs": {
|
|
269
|
+
"stowzilla": "dev",
|
|
270
|
+
"posh-nosh": "dev02"
|
|
271
|
+
},
|
|
272
|
+
"command": "belt deploy {env} --auto"
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
| Option | Description |
|
|
278
|
+
|--------|-------------|
|
|
279
|
+
| `enabled` | Enable/disable automated deploys |
|
|
280
|
+
| `trigger` | When to deploy: `manual`, `on_final_pr` |
|
|
281
|
+
| `default_env` | Global fallback environment |
|
|
282
|
+
| `project_envs` | Per-project default environments (e.g., `{"stowzilla": "dev"}`) |
|
|
283
|
+
| `command` | Deploy command template (`{env}` is replaced) |
|
|
284
|
+
|
|
215
285
|
## CLI Commands
|
|
216
286
|
|
|
217
287
|
```bash
|
|
@@ -220,6 +290,7 @@ brainiac basecamp config # Show config
|
|
|
220
290
|
brainiac basecamp status # Plugin health check
|
|
221
291
|
brainiac basecamp epics # List active epics
|
|
222
292
|
brainiac basecamp epics --all # Include completed
|
|
293
|
+
brainiac basecamp deploy <epic-id> [env] # Deploy epic to environment
|
|
223
294
|
brainiac basecamp bot add <name> <id> <agent> # Add bot account
|
|
224
295
|
brainiac basecamp bot list # List bot accounts
|
|
225
296
|
brainiac basecamp projects map <key> <bc-id> # Map project
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "open3"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
require_relative "epic"
|
|
5
7
|
|
|
6
8
|
module Brainiac
|
|
7
9
|
module Plugins
|
|
@@ -23,6 +25,8 @@ module Brainiac
|
|
|
23
25
|
cmd_status
|
|
24
26
|
when "epics"
|
|
25
27
|
cmd_epics(args)
|
|
28
|
+
when "deploy"
|
|
29
|
+
cmd_deploy(args)
|
|
26
30
|
when "link"
|
|
27
31
|
cmd_link(args)
|
|
28
32
|
when "bot"
|
|
@@ -163,14 +167,14 @@ module Brainiac
|
|
|
163
167
|
data = JSON.parse(File.read(epics_file))
|
|
164
168
|
epics = data["epics"] || []
|
|
165
169
|
|
|
166
|
-
if args.
|
|
170
|
+
if args.include?("--all")
|
|
167
171
|
display_epics = epics
|
|
168
172
|
else
|
|
169
173
|
display_epics = epics.select { |e| e["status"] == "active" }
|
|
170
174
|
end
|
|
171
175
|
|
|
172
176
|
if display_epics.empty?
|
|
173
|
-
puts "No #{args.
|
|
177
|
+
puts "No #{args.include?('--all') ? '' : 'active '}epics."
|
|
174
178
|
return
|
|
175
179
|
end
|
|
176
180
|
|
|
@@ -181,11 +185,23 @@ module Brainiac
|
|
|
181
185
|
total = tasks.size
|
|
182
186
|
|
|
183
187
|
status_icon = epic["status"] == "active" ? "🚀" : "✅"
|
|
184
|
-
|
|
188
|
+
|
|
189
|
+
# Show deploy env if configured
|
|
190
|
+
deploy_env = Basecamp::Epic.extract_deploy_env(epic["title"], epic["description"])
|
|
191
|
+
deploy_display = deploy_env ? " [deploy:#{deploy_env}]" : ""
|
|
192
|
+
|
|
193
|
+
puts "#{status_icon} #{epic['title']}#{deploy_display}"
|
|
185
194
|
puts " Todolist: #{epic['basecamp_todolist_id']} | Agent: #{epic['agent']}"
|
|
186
195
|
puts " Tasks: #{complete}/#{total} complete, #{in_flight} in-flight"
|
|
187
196
|
puts " Started: #{epic['started_at']}"
|
|
188
197
|
|
|
198
|
+
# Show last deploy if present
|
|
199
|
+
if epic["last_deploy"]
|
|
200
|
+
ld = epic["last_deploy"]
|
|
201
|
+
deploy_status = ld["status"] == "success" ? "✅" : "❌"
|
|
202
|
+
puts " Last deploy: #{deploy_status} #{ld['env']} @ #{ld['at']}"
|
|
203
|
+
end
|
|
204
|
+
|
|
189
205
|
# Show per-task detail if verbose or few tasks
|
|
190
206
|
if args.include?("--verbose") || args.include?("-v")
|
|
191
207
|
tasks.each do |t|
|
|
@@ -203,6 +219,98 @@ module Brainiac
|
|
|
203
219
|
end
|
|
204
220
|
end
|
|
205
221
|
|
|
222
|
+
def cmd_deploy(args)
|
|
223
|
+
epic_id = args.shift
|
|
224
|
+
env_override = args.shift
|
|
225
|
+
|
|
226
|
+
unless epic_id
|
|
227
|
+
puts "Usage: brainiac basecamp deploy <epic-id> [env]"
|
|
228
|
+
puts ""
|
|
229
|
+
puts "Deploy an epic's worktree to the specified environment."
|
|
230
|
+
puts ""
|
|
231
|
+
puts "Arguments:"
|
|
232
|
+
puts " <epic-id> Basecamp todolist ID for the epic"
|
|
233
|
+
puts " [env] Environment to deploy to (overrides epic's [deploy:env])"
|
|
234
|
+
puts ""
|
|
235
|
+
puts "Environment resolution (in order):"
|
|
236
|
+
puts " 1. Explicit env argument"
|
|
237
|
+
puts " 2. [deploy:env] in epic title"
|
|
238
|
+
puts " 3. deploy:env in epic description"
|
|
239
|
+
puts " 4. deploy.default_env in config"
|
|
240
|
+
puts ""
|
|
241
|
+
puts "Active epics:"
|
|
242
|
+
list_epics_for_deploy
|
|
243
|
+
return
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Load epic
|
|
247
|
+
epics = load_epics
|
|
248
|
+
epic = epics.find { |e| e["basecamp_todolist_id"] == epic_id.to_s }
|
|
249
|
+
|
|
250
|
+
unless epic
|
|
251
|
+
puts "❌ No epic found with todolist ID #{epic_id}"
|
|
252
|
+
puts ""
|
|
253
|
+
puts "Active epics:"
|
|
254
|
+
list_epics_for_deploy
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Resolve deploy environment
|
|
259
|
+
deploy_env = resolve_deploy_env(epic, env_override)
|
|
260
|
+
|
|
261
|
+
unless deploy_env
|
|
262
|
+
puts "❌ No deploy environment specified."
|
|
263
|
+
puts ""
|
|
264
|
+
puts "Either:"
|
|
265
|
+
puts " 1. Pass env as argument: brainiac basecamp deploy #{epic_id} dev02"
|
|
266
|
+
puts " 2. Add [deploy:env] to epic title: \"Epic: My Feature [deploy:dev02]\""
|
|
267
|
+
puts " 3. Add deploy:env to epic description"
|
|
268
|
+
puts " 4. Set deploy.project_envs.<project> in ~/.brainiac/basecamp.json"
|
|
269
|
+
puts " 5. Set deploy.default_env in ~/.brainiac/basecamp.json"
|
|
270
|
+
return
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Find worktree
|
|
274
|
+
worktree_path = find_epic_worktree(epic)
|
|
275
|
+
|
|
276
|
+
unless worktree_path
|
|
277
|
+
puts "❌ No worktree found for epic '#{epic['title']}'"
|
|
278
|
+
puts " Epic branch mode may not be enabled, or no tasks have been dispatched."
|
|
279
|
+
return
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
puts "🚀 Deploying epic '#{epic['title']}'"
|
|
283
|
+
puts " Environment: #{deploy_env}"
|
|
284
|
+
puts " Worktree: #{worktree_path}"
|
|
285
|
+
puts ""
|
|
286
|
+
|
|
287
|
+
# Run deploy
|
|
288
|
+
success = run_deploy(worktree_path, deploy_env)
|
|
289
|
+
|
|
290
|
+
puts ""
|
|
291
|
+
|
|
292
|
+
if success
|
|
293
|
+
puts "✅ Deploy completed successfully"
|
|
294
|
+
|
|
295
|
+
# Update epic state
|
|
296
|
+
epic["last_deploy"] = {
|
|
297
|
+
"env" => deploy_env,
|
|
298
|
+
"at" => Time.now.iso8601,
|
|
299
|
+
"status" => "success"
|
|
300
|
+
}
|
|
301
|
+
else
|
|
302
|
+
puts "❌ Deploy failed"
|
|
303
|
+
|
|
304
|
+
epic["last_deploy"] = {
|
|
305
|
+
"env" => deploy_env,
|
|
306
|
+
"at" => Time.now.iso8601,
|
|
307
|
+
"status" => "failed"
|
|
308
|
+
}
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
save_epics(epics)
|
|
312
|
+
end
|
|
313
|
+
|
|
206
314
|
def cmd_link(args)
|
|
207
315
|
fizzy_card = args.shift
|
|
208
316
|
basecamp_url = args.shift
|
|
@@ -517,7 +625,8 @@ module Brainiac
|
|
|
517
625
|
setup Interactive setup guide
|
|
518
626
|
config Show current config
|
|
519
627
|
status Check plugin status
|
|
520
|
-
epics [--all]
|
|
628
|
+
epics [--all] [-v] List active epics (--all for completed too)
|
|
629
|
+
deploy <epic-id> [env] Deploy epic worktree to environment
|
|
521
630
|
link <card> <url> Link a Fizzy card to a Basecamp todo
|
|
522
631
|
bot add <agent-name> Add bot (auto-resolves person_id from Basecamp)
|
|
523
632
|
bot add <name> <person-id> <agent> Add bot with explicit person_id
|
|
@@ -542,6 +651,13 @@ module Brainiac
|
|
|
542
651
|
on_complete — Advance to next task as soon as agent finishes (default)
|
|
543
652
|
on_pr_merge — Wait for PR to be merged before advancing (review gate)
|
|
544
653
|
|
|
654
|
+
Deploy environment resolution (in order):
|
|
655
|
+
1. Explicit env argument: brainiac basecamp deploy 12345 dev02
|
|
656
|
+
2. [deploy:env] in epic title: "Epic: My Feature [deploy:dev02]"
|
|
657
|
+
3. deploy:env in epic description
|
|
658
|
+
4. deploy.project_envs.<project> in config (per-project default)
|
|
659
|
+
5. deploy.default_env in config (global default)
|
|
660
|
+
|
|
545
661
|
Task statuses (for reset --to):
|
|
546
662
|
pending — Not yet started, waiting for dependencies
|
|
547
663
|
in_flight — Agent is actively working on it
|
|
@@ -873,6 +989,91 @@ module Brainiac
|
|
|
873
989
|
}))
|
|
874
990
|
end
|
|
875
991
|
|
|
992
|
+
# --- Deploy helpers ---
|
|
993
|
+
|
|
994
|
+
def list_epics_for_deploy
|
|
995
|
+
epics = load_epics.select { |e| e["status"] == "active" }
|
|
996
|
+
if epics.empty?
|
|
997
|
+
puts " (no active epics)"
|
|
998
|
+
return
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
epics.each do |epic|
|
|
1002
|
+
deploy_env = resolve_deploy_env(epic, nil)
|
|
1003
|
+
env_display = deploy_env ? "[deploy:#{deploy_env}]" : "(no env)"
|
|
1004
|
+
puts " #{epic['basecamp_todolist_id']} — #{epic['title']} #{env_display}"
|
|
1005
|
+
end
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
def resolve_deploy_env(epic, override)
|
|
1009
|
+
# 1. Explicit override
|
|
1010
|
+
return override if override && !override.empty?
|
|
1011
|
+
|
|
1012
|
+
# 2. [deploy:env] in title or deploy:env in description
|
|
1013
|
+
env = Basecamp::Epic.extract_deploy_env(epic["title"], epic["description"])
|
|
1014
|
+
return env if env
|
|
1015
|
+
|
|
1016
|
+
# 3. Per-project default from config
|
|
1017
|
+
# Prefer the project from tasks (set from Fizzy card tags) over basecamp mapping
|
|
1018
|
+
# since multiple brainiac projects can share the same basecamp project.
|
|
1019
|
+
project_key = (epic["tasks"] || []).filter_map { |t| t["project"] }.first
|
|
1020
|
+
project_key ||= Config.brainiac_project_for(epic["basecamp_project_id"])
|
|
1021
|
+
project_env = Config.deploy.dig("project_envs", project_key) if project_key
|
|
1022
|
+
return project_env if project_env
|
|
1023
|
+
|
|
1024
|
+
# 4. Global default
|
|
1025
|
+
Config.deploy["default_env"]
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
def find_epic_worktree(epic)
|
|
1029
|
+
# Epic branches are stored per-project in epic["epic_branches"]
|
|
1030
|
+
# Find any worktree that has the epic branch checked out
|
|
1031
|
+
epic_branches = epic["epic_branches"] || {}
|
|
1032
|
+
return nil if epic_branches.empty?
|
|
1033
|
+
|
|
1034
|
+
# Load projects to find repo paths
|
|
1035
|
+
projects_file = File.join(BRAINIAC_DIR, "projects.json")
|
|
1036
|
+
return nil unless File.exist?(projects_file)
|
|
1037
|
+
|
|
1038
|
+
projects = JSON.parse(File.read(projects_file))
|
|
1039
|
+
|
|
1040
|
+
epic_branches.each do |project_key, branch_name|
|
|
1041
|
+
repo_path = projects.dig(project_key, "repo_path")
|
|
1042
|
+
next unless repo_path && File.directory?(repo_path)
|
|
1043
|
+
|
|
1044
|
+
# List worktrees and find one with this branch
|
|
1045
|
+
output, status = Open3.capture2("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
1046
|
+
next unless status.success?
|
|
1047
|
+
|
|
1048
|
+
# Parse porcelain output
|
|
1049
|
+
current_worktree = nil
|
|
1050
|
+
output.each_line do |line|
|
|
1051
|
+
if line.start_with?("worktree ")
|
|
1052
|
+
current_worktree = line.sub("worktree ", "").strip
|
|
1053
|
+
elsif line.start_with?("branch refs/heads/")
|
|
1054
|
+
wt_branch = line.sub("branch refs/heads/", "").strip
|
|
1055
|
+
return current_worktree if wt_branch == branch_name && current_worktree && current_worktree != repo_path
|
|
1056
|
+
end
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
nil
|
|
1061
|
+
rescue JSON::ParserError
|
|
1062
|
+
nil
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
def run_deploy(worktree_path, env)
|
|
1066
|
+
deploy_config = Config.deploy
|
|
1067
|
+
command_template = deploy_config["command"] || "belt deploy {env} --auto"
|
|
1068
|
+
command = command_template.gsub("{env}", env)
|
|
1069
|
+
|
|
1070
|
+
puts "Running: #{command}"
|
|
1071
|
+
puts "-" * 40
|
|
1072
|
+
|
|
1073
|
+
# Run the deploy command in the worktree directory
|
|
1074
|
+
system(command, chdir: worktree_path)
|
|
1075
|
+
end
|
|
1076
|
+
|
|
876
1077
|
def auto_resolve_person_id(agent_name)
|
|
877
1078
|
output, status = Open3.capture2(
|
|
878
1079
|
"basecamp", "people", "list", "--jq",
|
|
@@ -928,7 +1129,7 @@ module Brainiac
|
|
|
928
1129
|
end
|
|
929
1130
|
|
|
930
1131
|
def self.completions
|
|
931
|
-
%w[setup config status epics link bot projects set reset webhook]
|
|
1132
|
+
%w[setup config status epics deploy link bot projects set reset webhook]
|
|
932
1133
|
end
|
|
933
1134
|
end
|
|
934
1135
|
end
|
|
@@ -20,6 +20,13 @@ module Brainiac
|
|
|
20
20
|
"fizzy_account_id" => nil,
|
|
21
21
|
"review_gate" => "on_complete",
|
|
22
22
|
"review_gates" => [],
|
|
23
|
+
"deploy" => {
|
|
24
|
+
"enabled" => false,
|
|
25
|
+
"trigger" => "manual",
|
|
26
|
+
"default_env" => nil,
|
|
27
|
+
"project_envs" => {},
|
|
28
|
+
"command" => "belt deploy {env} --auto"
|
|
29
|
+
},
|
|
23
30
|
"notifications" => {
|
|
24
31
|
"epic_started" => true,
|
|
25
32
|
"task_dispatched" => true,
|
|
@@ -103,6 +110,13 @@ module Brainiac
|
|
|
103
110
|
current["review_gate"] || "on_complete"
|
|
104
111
|
end
|
|
105
112
|
|
|
113
|
+
# Deploy configuration.
|
|
114
|
+
#
|
|
115
|
+
# @return [Hash]
|
|
116
|
+
def deploy
|
|
117
|
+
current["deploy"] || DEFAULT_CONFIG["deploy"]
|
|
118
|
+
end
|
|
119
|
+
|
|
106
120
|
private
|
|
107
121
|
|
|
108
122
|
def config_mtime
|
|
@@ -123,6 +123,37 @@ module Brainiac
|
|
|
123
123
|
lines.join("\n")
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
# Extract deploy environment from epic title or description.
|
|
127
|
+
# Supports:
|
|
128
|
+
# [deploy:dev02] — in title (preferred)
|
|
129
|
+
# deploy:dev02 — in description (fallback)
|
|
130
|
+
#
|
|
131
|
+
# @param title [String] Epic/todolist title
|
|
132
|
+
# @param description [String, nil] Optional description to check as fallback
|
|
133
|
+
# @return [String, nil] Environment name or nil
|
|
134
|
+
def extract_deploy_env(title, description = nil)
|
|
135
|
+
# Try [deploy:env] format in title (preferred)
|
|
136
|
+
if title
|
|
137
|
+
marker = "[deploy:"
|
|
138
|
+
value_start = title.index(marker)
|
|
139
|
+
if value_start
|
|
140
|
+
value_start += marker.length
|
|
141
|
+
value_end = title.index("]", value_start)
|
|
142
|
+
if value_end
|
|
143
|
+
environment = title[value_start...value_end].strip
|
|
144
|
+
return environment unless environment.empty?
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Fallback: try deploy:env in description
|
|
150
|
+
if description && (match = description.match(/deploy:(\S+)/i))
|
|
151
|
+
return match[1].strip
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
126
157
|
private
|
|
127
158
|
|
|
128
159
|
# Extract Fizzy card number from title.
|
|
@@ -737,6 +737,9 @@ module Brainiac
|
|
|
737
737
|
# If epic_branch mode, open final PRs to main
|
|
738
738
|
if epic["review_gate"] == "epic_branch" && epic["epic_branches"]&.any?
|
|
739
739
|
open_final_prs(epic)
|
|
740
|
+
|
|
741
|
+
# Trigger automated deploy if configured
|
|
742
|
+
trigger_deploy_if_configured(epic, "on_final_pr")
|
|
740
743
|
end
|
|
741
744
|
|
|
742
745
|
# Post a summary message in Basecamp
|
|
@@ -925,6 +928,130 @@ module Brainiac
|
|
|
925
928
|
message: message,
|
|
926
929
|
agent: agent)
|
|
927
930
|
end
|
|
931
|
+
|
|
932
|
+
# Trigger automated deploy if configured for the given trigger point.
|
|
933
|
+
#
|
|
934
|
+
# @param epic [Hash] Epic state
|
|
935
|
+
# @param trigger [String] Trigger point ("on_final_pr", "on_task_merge", etc.)
|
|
936
|
+
def trigger_deploy_if_configured(epic, trigger)
|
|
937
|
+
deploy_config = Config.deploy
|
|
938
|
+
return unless deploy_config["enabled"]
|
|
939
|
+
return unless deploy_config["trigger"] == trigger
|
|
940
|
+
|
|
941
|
+
# Resolve deploy environment
|
|
942
|
+
deploy_env = resolve_epic_deploy_env(epic)
|
|
943
|
+
unless deploy_env
|
|
944
|
+
LOG.warn "[Basecamp:Orchestrator] Deploy triggered but no environment configured for epic '#{epic['title']}'" if defined?(LOG)
|
|
945
|
+
return
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# Find worktree
|
|
949
|
+
worktree_path = find_epic_worktree(epic)
|
|
950
|
+
unless worktree_path
|
|
951
|
+
LOG.warn "[Basecamp:Orchestrator] Deploy triggered but no worktree found for epic '#{epic['title']}'" if defined?(LOG)
|
|
952
|
+
return
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
LOG.info "[Basecamp:Orchestrator] Auto-deploying epic '#{epic['title']}' to #{deploy_env}" if defined?(LOG)
|
|
956
|
+
|
|
957
|
+
# Run deploy
|
|
958
|
+
command_template = deploy_config["command"] || "belt deploy {env} --auto"
|
|
959
|
+
command = command_template.gsub("{env}", deploy_env)
|
|
960
|
+
|
|
961
|
+
success = system(command, chdir: worktree_path)
|
|
962
|
+
|
|
963
|
+
# Record deploy result
|
|
964
|
+
epic["last_deploy"] = {
|
|
965
|
+
"env" => deploy_env,
|
|
966
|
+
"at" => Time.now.iso8601,
|
|
967
|
+
"status" => success ? "success" : "failed",
|
|
968
|
+
"trigger" => trigger
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
# Send notification
|
|
972
|
+
if success
|
|
973
|
+
send_notification(
|
|
974
|
+
event: :epic_deployed,
|
|
975
|
+
message: "🚀 Epic **#{epic['title']}** deployed to **#{deploy_env}**",
|
|
976
|
+
agent: epic["agent"]
|
|
977
|
+
)
|
|
978
|
+
else
|
|
979
|
+
send_notification(
|
|
980
|
+
event: :epic_deploy_failed,
|
|
981
|
+
message: "❌ Epic **#{epic['title']}** deploy to **#{deploy_env}** failed",
|
|
982
|
+
agent: epic["agent"]
|
|
983
|
+
)
|
|
984
|
+
end
|
|
985
|
+
rescue StandardError => e
|
|
986
|
+
LOG.error "[Basecamp:Orchestrator] Deploy failed: #{e.message}" if defined?(LOG)
|
|
987
|
+
epic["last_deploy"] = {
|
|
988
|
+
"env" => deploy_env,
|
|
989
|
+
"at" => Time.now.iso8601,
|
|
990
|
+
"status" => "error",
|
|
991
|
+
"error" => e.message,
|
|
992
|
+
"trigger" => trigger
|
|
993
|
+
}
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
# Resolve deploy environment for an epic.
|
|
997
|
+
#
|
|
998
|
+
# @param epic [Hash] Epic state
|
|
999
|
+
# @return [String, nil] Environment name or nil
|
|
1000
|
+
def resolve_epic_deploy_env(epic)
|
|
1001
|
+
# 1. [deploy:env] in title or deploy:env in description
|
|
1002
|
+
env = Epic.extract_deploy_env(epic["title"], epic["description"])
|
|
1003
|
+
return env if env
|
|
1004
|
+
|
|
1005
|
+
# 2. Per-project default from config
|
|
1006
|
+
# Prefer the project from tasks (set from Fizzy card tags) over basecamp mapping
|
|
1007
|
+
# since multiple brainiac projects can share the same basecamp project.
|
|
1008
|
+
project_key = (epic["tasks"] || []).map { |t| t["project"] }.compact.first
|
|
1009
|
+
project_key ||= Config.brainiac_project_for(epic["basecamp_project_id"])
|
|
1010
|
+
project_env = Config.deploy.dig("project_envs", project_key) if project_key
|
|
1011
|
+
return project_env if project_env
|
|
1012
|
+
|
|
1013
|
+
# 3. Global default
|
|
1014
|
+
Config.deploy["default_env"]
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
# Find the worktree path for an epic's branch.
|
|
1018
|
+
#
|
|
1019
|
+
# @param epic [Hash] Epic state
|
|
1020
|
+
# @return [String, nil] Worktree path or nil
|
|
1021
|
+
def find_epic_worktree(epic)
|
|
1022
|
+
epic_branches = epic["epic_branches"] || {}
|
|
1023
|
+
return nil if epic_branches.empty?
|
|
1024
|
+
|
|
1025
|
+
projects_file = File.join(BRAINIAC_DIR, "projects.json")
|
|
1026
|
+
return nil unless File.exist?(projects_file)
|
|
1027
|
+
|
|
1028
|
+
projects = JSON.parse(File.read(projects_file))
|
|
1029
|
+
|
|
1030
|
+
epic_branches.each do |project_key, branch_name|
|
|
1031
|
+
repo_path = projects.dig(project_key, "repo_path")
|
|
1032
|
+
next unless repo_path && File.directory?(repo_path)
|
|
1033
|
+
|
|
1034
|
+
# List worktrees and find one with this branch
|
|
1035
|
+
output, status = Open3.capture2("git", "worktree", "list", "--porcelain", chdir: repo_path)
|
|
1036
|
+
next unless status.success?
|
|
1037
|
+
|
|
1038
|
+
current_worktree = nil
|
|
1039
|
+
output.each_line do |line|
|
|
1040
|
+
if line.start_with?("worktree ")
|
|
1041
|
+
current_worktree = line.sub("worktree ", "").strip
|
|
1042
|
+
elsif line.start_with?("branch refs/heads/")
|
|
1043
|
+
wt_branch = line.sub("branch refs/heads/", "").strip
|
|
1044
|
+
if wt_branch == branch_name && current_worktree && current_worktree != repo_path
|
|
1045
|
+
return current_worktree
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
end
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
nil
|
|
1052
|
+
rescue JSON::ParserError
|
|
1053
|
+
nil
|
|
1054
|
+
end
|
|
928
1055
|
end
|
|
929
1056
|
end
|
|
930
1057
|
end
|
|
@@ -59,8 +59,11 @@ module Brainiac
|
|
|
59
59
|
|
|
60
60
|
LOG.info "[Basecamp:SessionRegistry] Registered session: #{task_id} (pid=#{pid}, agent=#{agent_name})" if defined?(LOG)
|
|
61
61
|
|
|
62
|
-
# Also forward to the global register_session if it exists (for waybar/UI)
|
|
63
|
-
|
|
62
|
+
# Also forward to the global register_session if it exists (for waybar/UI).
|
|
63
|
+
# Skip implementation-* sessions — fizzy already registers those as card-NNNN
|
|
64
|
+
# and we don't want duplicate entries in the tray.
|
|
65
|
+
if Object.respond_to?(:register_session, true) && !@suppress_global_forward &&
|
|
66
|
+
!task_id.to_s.start_with?("implementation-")
|
|
64
67
|
begin
|
|
65
68
|
Object.send(:register_session, task_id, pid, log_file: log_file, agent_name: agent_name)
|
|
66
69
|
rescue StandardError
|
|
@@ -115,9 +115,16 @@ module Brainiac
|
|
|
115
115
|
reconcile_task(epic, task, triggered_by: triggered_by)
|
|
116
116
|
end.any?
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
# Always attempt to dispatch unblocked tasks during recovery. Even if no
|
|
119
|
+
# individual task changed state, there may be pending tasks whose
|
|
120
|
+
# dependencies are satisfied that were never dispatched (e.g., after a
|
|
121
|
+
# cancellation was reversed or an epic was healed manually).
|
|
122
|
+
has_pending = tasks.any? { |t| TaskState.in?(t, :pending) }
|
|
123
|
+
if changed || has_pending
|
|
124
|
+
Orchestrator.send(:dispatch_unblocked_tasks, epic)
|
|
125
|
+
Orchestrator.send(:save_epic, epic)
|
|
126
|
+
end
|
|
127
|
+
changed || has_pending
|
|
121
128
|
end
|
|
122
129
|
|
|
123
130
|
private
|